Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/empty-cameras-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@aws-amplify/platform-core': patch
'@aws-amplify/sandbox': patch
'@aws-amplify/backend-cli': patch
---

refactor top level cli error handling
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 10 additions & 11 deletions packages/cli/src/ampx.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
#!/usr/bin/env node
import { createMainParser } from './main_parser_factory.js';
import { attachUnhandledExceptionListeners } from './error_handler.js';
import {
attachUnhandledExceptionListeners,
generateCommandFailureHandler,
} from './error_handler.js';
import { extractSubCommands } from './extract_sub_commands.js';
import {
AmplifyFault,
PackageJsonReader,
UsageDataEmitterFactory,
} from '@aws-amplify/platform-core';
import { fileURLToPath } from 'node:url';
import { LogLevel, format, printer } from '@aws-amplify/cli-core';
import { verifyCommandName } from './verify_command_name.js';
import { parseAsyncSafely } from './parse_async_safely.js';
import { hideBin } from 'yargs/helpers';
import { format } from '@aws-amplify/cli-core';

const packageJson = new PackageJsonReader().read(
fileURLToPath(new URL('../package.json', import.meta.url))
Expand All @@ -32,11 +35,11 @@ attachUnhandledExceptionListeners(usageDataEmitter);

verifyCommandName();

const parser = createMainParser(libraryVersion, usageDataEmitter);

await parseAsyncSafely(parser);
const parser = createMainParser(libraryVersion);
const errorHandler = generateCommandFailureHandler(parser, usageDataEmitter);

try {
await parser.parseAsync(hideBin(process.argv));
const metricDimension: Record<string, string> = {};
const subCommands = extractSubCommands(parser);

Expand All @@ -47,10 +50,6 @@ try {
await usageDataEmitter.emitSuccess({}, metricDimension);
} catch (e) {
if (e instanceof Error) {
printer.log(format.error('Failed to emit usage metrics'), LogLevel.DEBUG);
printer.log(format.error(e), LogLevel.DEBUG);
if (e.stack) {
printer.log(e.stack, LogLevel.DEBUG);
}
await errorHandler(format.error(e), e);
}
}
32 changes: 24 additions & 8 deletions packages/cli/src/main_parser_factory.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { TestCommandRunner } from './test-utils/command_runner.js';
import {
TestCommandError,
TestCommandRunner,
} from './test-utils/command_runner.js';
import { createMainParser } from './main_parser_factory.js';
import { version } from '#package.json';

Expand All @@ -20,16 +23,29 @@ void describe('main parser', { concurrency: false }, () => {
});

void it('prints help if command is not provided', async () => {
const output = await commandRunner.runCommand('');
assert.match(output, /Commands:/);
assert.match(output, /Not enough non-option arguments:/);
await assert.rejects(
() => commandRunner.runCommand(''),
(err) => {
assert(err instanceof TestCommandError);
assert.match(err.output, /Commands:/);
assert.match(err.error.message, /Not enough non-option arguments:/);
return true;
}
);
});

void it('errors and prints help if invalid option is given', async () => {
const output = await commandRunner.runCommand(
'sandbox --non-existing-option 1'
await assert.rejects(
() => commandRunner.runCommand('sandbox --non-existing-option 1'),
(err) => {
assert(err instanceof TestCommandError);
assert.match(err.output, /Commands:/);
assert.match(
err.error.message,
/Unknown arguments: non-existing-option/
);
return true;
}
);
assert.match(output, /Commands:/);
assert.match(output, /Unknown arguments: non-existing-option/);
});
});
12 changes: 3 additions & 9 deletions packages/cli/src/main_parser_factory.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import yargs, { Argv } from 'yargs';
import { UsageDataEmitter } from '@aws-amplify/platform-core';
import { createGenerateCommand } from './commands/generate/generate_command_factory.js';
import { createSandboxCommand } from './commands/sandbox/sandbox_command_factory.js';
import { createPipelineDeployCommand } from './commands/pipeline-deploy/pipeline_deploy_command_factory.js';
import { createConfigureCommand } from './commands/configure/configure_command_factory.js';
import { generateCommandFailureHandler } from './error_handler.js';
import { createInfoCommand } from './commands/info/info_command_factory.js';
import * as path from 'path';

/**
* Creates main parser.
*/
export const createMainParser = (
libraryVersion: string,
usageDataEmitter?: UsageDataEmitter
): Argv => {
export const createMainParser = (libraryVersion: string): Argv => {
const parser = yargs()
.version(libraryVersion)
// This option is being used indirectly to configure the log level of the Printer instance.
Expand All @@ -36,9 +31,8 @@ export const createMainParser = (
.help()
.demandCommand()
.strictCommands()
.recommendCommands();

parser.fail(generateCommandFailureHandler(parser, usageDataEmitter));
.recommendCommands()
.fail(false);

return parser;
};
16 changes: 0 additions & 16 deletions packages/cli/src/parse_async_safely.test.ts

This file was deleted.

23 changes: 0 additions & 23 deletions packages/cli/src/parse_async_safely.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/platform-core/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export abstract class AmplifyError<T extends string = string> extends Error {
// (undocumented)
readonly details?: string;
// (undocumented)
static fromError: (error: unknown) => AmplifyError<'UnknownFault'>;
static fromError: (error: unknown) => AmplifyError<'UnknownFault' | 'CredentialsError'>;
// (undocumented)
static fromStderr: (_stderr: string) => AmplifyError | undefined;
// (undocumented)
Expand Down
14 changes: 13 additions & 1 deletion packages/platform-core/src/errors/amplify_error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,20 @@ export abstract class AmplifyError<T extends string = string> extends Error {
return undefined;
};

static fromError = (error: unknown): AmplifyError<'UnknownFault'> => {
static fromError = (
error: unknown
): AmplifyError<'UnknownFault' | 'CredentialsError'> => {
const errorMessage =
error instanceof Error
? `${error.name}: ${error.message}`
: 'An unknown error happened. Check downstream error';

if (error instanceof Error && isCredentialsError(error)) {
return new AmplifyUserError('CredentialsError', {
message: errorMessage,
resolution: '',
});
}
return new AmplifyFault(
'UnknownFault',
{
Expand All @@ -114,6 +122,10 @@ export abstract class AmplifyError<T extends string = string> extends Error {
};
}

const isCredentialsError = (err?: Error): boolean => {
return !!err && err?.name === 'CredentialsProviderError';
};

/**
* Amplify exception classifications
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { AccountIdFetcher } from './account_id_fetcher';
import { GetCallerIdentityCommandOutput, STSClient } from '@aws-sdk/client-sts';
import { describe, mock, test } from 'node:test';
import assert from 'node:assert';

void describe('AccountIdFetcher', async () => {
void test('fetches account ID successfully', async () => {
const mockSend = mock.method(STSClient.prototype, 'send', () =>
Promise.resolve({
Account: '123456789012',
} as GetCallerIdentityCommandOutput)
);

const accountIdFetcher = new AccountIdFetcher(new STSClient({}));
const accountId = await accountIdFetcher.fetch();

assert.strictEqual(accountId, '123456789012');
mockSend.mock.resetCalls();
});

void test('returns default account ID when STS fails', async () => {
const mockSend = mock.method(STSClient.prototype, 'send', () =>
Promise.reject(new Error('STS error'))
);

const accountIdFetcher = new AccountIdFetcher(new STSClient({}));
const accountId = await accountIdFetcher.fetch();

assert.strictEqual(accountId, 'NO_ACCOUNT_ID');
mockSend.mock.resetCalls();
});

void test('returns cached account ID on subsequent calls', async () => {
const mockSend = mock.method(STSClient.prototype, 'send', () =>
Promise.resolve({
Account: '123456789012',
} as GetCallerIdentityCommandOutput)
);

const accountIdFetcher = new AccountIdFetcher(new STSClient({}));
const accountId1 = await accountIdFetcher.fetch();
const accountId2 = await accountIdFetcher.fetch();

assert.strictEqual(accountId1, '123456789012');
assert.strictEqual(accountId2, '123456789012');

// we only call the service once.
assert.strictEqual(mockSend.mock.callCount(), 1);
mockSend.mock.resetCalls();
});
});
26 changes: 18 additions & 8 deletions packages/platform-core/src/usage-data/account_id_fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts';

const NO_ACCOUNT_ID = 'NO_ACCOUNT_ID';
/**
* Retrieves the account ID of the user
*/
export class AccountIdFetcher {
private accountId: string | undefined;
/**
* constructor for AccountIdFetcher
*/
constructor(private readonly stsClient = new STSClient()) {}
fetch = async () => {
const stsResponse = await this.stsClient.send(
new GetCallerIdentityCommand({})
);
if (stsResponse && stsResponse.Account) {
return stsResponse.Account;
if (this.accountId) {
return this.accountId;
}
try {
const stsResponse = await this.stsClient.send(
new GetCallerIdentityCommand({})
);
if (stsResponse && stsResponse.Account) {
this.accountId = stsResponse.Account;
return this.accountId;
}
// We failed to get the account Id. Most likely the user doesn't have credentials
return NO_ACCOUNT_ID;
} catch (error) {
// We failed to get the account Id. Most likely the user doesn't have credentials
return NO_ACCOUNT_ID;
}
throw new Error(
'Cannot retrieve the account Id from GetCallerIdentityCommand'
);
};
}
Loading