diff --git a/CHANGELOG.md b/CHANGELOG.md index a8080e5f48..7389b860f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features +- [eas-cli] Add `code` to the `--json` error: `EAS_PROJECT_NOT_CONFIGURED` when the project is not linked to EAS, and `EAS_LOGIN_REQUIRED` when a non-interactive run is not logged in. ([#4594](https://github.com/expo/eas-cli/pull/4594) by [@vonovak](https://github.com/vonovak)) - [eas-cli] Print `{"error": {"message", "requestId"?}}` on stdout when a command run with `--json` fails, and keep all other output off stdout from the start of the command. ([#4593](https://github.com/expo/eas-cli/pull/4593) by [@vonovak](https://github.com/vonovak)) - [build-tools] Let the MCP server read text and image files from sandboxes. ([#4555](https://github.com/expo/eas-cli/pull/4555) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR)) diff --git a/packages/eas-cli/src/commandUtils/EasCommand.ts b/packages/eas-cli/src/commandUtils/EasCommand.ts index becadd3e48..ad2e424bb6 100644 --- a/packages/eas-cli/src/commandUtils/EasCommand.ts +++ b/packages/eas-cli/src/commandUtils/EasCommand.ts @@ -278,13 +278,16 @@ export default abstract class EasCommand extends Command { const commandId = this.id ?? 'unknown'; let baseMessage = `${commandId} command failed.`; let message = err.message; + let code: string | undefined; let requestId: string | undefined; if (err instanceof Errors.ExitError) { // Errors.exit(1) carries no reason; the code that called it printed the reason already. Log.errorToStderr(err.message); message = baseMessage; - } else if (err instanceof EasCommandError) { + } else if (err instanceof EasCommandError || err instanceof Errors.CLIError) { Log.errorToStderr(err.message); + // Only codes that eas-cli sets. GraphQL error codes belong to the server and stay out. + code = err.code; } else if (err instanceof CombinedError && err?.graphQLErrors) { const cleanGQLErrorsMessage = err?.graphQLErrors .map((graphQLError: GraphQLError) => { @@ -329,7 +332,7 @@ export default abstract class EasCommand extends Command { Log.errorToStderr(err.message); } if (this.jsonOutputRequested()) { - printJsonErrorOutput({ message, requestId }); + printJsonErrorOutput({ message, code, requestId }); } Log.debug(err); Sentry.withScope(scope => { diff --git a/packages/eas-cli/src/commandUtils/__tests__/EasCommand-test.ts b/packages/eas-cli/src/commandUtils/__tests__/EasCommand-test.ts index 20b0447fc0..47db92f6fb 100644 --- a/packages/eas-cli/src/commandUtils/__tests__/EasCommand-test.ts +++ b/packages/eas-cli/src/commandUtils/__tests__/EasCommand-test.ts @@ -344,10 +344,43 @@ describe('EasCommand', () => { expect(printJsonErrorOutput).toHaveBeenCalledWith({ message: 'EAS project not configured.', + code: undefined, requestId: undefined, }); }); + it('prints the code of an EasCommandError', async () => { + const TestEasCommand = createJsonTestEasCommand(); + const { printJsonErrorOutput } = jest.requireMock('../../utils/json'); + const { EasCommandError } = require('../errors'); + jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => { + throw new EasCommandError('EAS project not configured.', { + code: 'EAS_PROJECT_NOT_CONFIGURED', + }); + }); + + await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow(); + + expect(printJsonErrorOutput).toHaveBeenCalledWith( + expect.objectContaining({ code: 'EAS_PROJECT_NOT_CONFIGURED' }) + ); + }); + + it('prints the code of an oclif error', async () => { + const TestEasCommand = createJsonTestEasCommand(); + const { printJsonErrorOutput } = jest.requireMock('../../utils/json'); + const { Errors } = require('@oclif/core'); + jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => { + Errors.error('Log in first.', { code: 'EAS_LOGIN_REQUIRED' }); + }); + + await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow(); + + expect(printJsonErrorOutput).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Log in first.', code: 'EAS_LOGIN_REQUIRED' }) + ); + }); + it('prints the command failure, not EEXIT, for Errors.exit', async () => { const TestEasCommand = createJsonTestEasCommand(); const { printJsonErrorOutput } = jest.requireMock('../../utils/json'); @@ -360,6 +393,7 @@ describe('EasCommand', () => { expect(printJsonErrorOutput).toHaveBeenCalledWith({ message: `${TestEasCommand.id} command failed.`, + code: undefined, requestId: undefined, }); }); @@ -385,7 +419,7 @@ describe('EasCommand', () => { expect(printJsonErrorOutput).toHaveBeenCalledWith( expect.objectContaining({ requestId: mockRequestId }) ); - expect(printJsonErrorOutput.mock.calls[0][0]).not.toHaveProperty('code'); + expect(printJsonErrorOutput.mock.calls[0][0].code).toBeUndefined(); }); it.each([ diff --git a/packages/eas-cli/src/commandUtils/context/contextUtils/__tests__/getProjectIdAsync-test.ts b/packages/eas-cli/src/commandUtils/context/contextUtils/__tests__/getProjectIdAsync-test.ts index b7f724a3b0..38f8f0204d 100644 --- a/packages/eas-cli/src/commandUtils/context/contextUtils/__tests__/getProjectIdAsync-test.ts +++ b/packages/eas-cli/src/commandUtils/context/contextUtils/__tests__/getProjectIdAsync-test.ts @@ -506,6 +506,16 @@ describe(getProjectIdAsync, () => { expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).not.toHaveBeenCalled(); }); + it('gives the non-interactive error the EAS_PROJECT_NOT_CONFIGURED code', async () => { + await expect( + getProjectIdAsync( + sessionManager, + { sdkVersion: '52.0.0', name: 'test', slug: 'test' }, + { nonInteractive: true } + ) + ).rejects.toMatchObject({ code: 'EAS_PROJECT_NOT_CONFIGURED' }); + }); + it('defers to the project fetch in non-interactive mode when the account is already known', async () => { const sessionManagerMock = mock(); when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({ diff --git a/packages/eas-cli/src/commandUtils/errors.ts b/packages/eas-cli/src/commandUtils/errors.ts index 43186a3f33..338ef7fdc6 100644 --- a/packages/eas-cli/src/commandUtils/errors.ts +++ b/packages/eas-cli/src/commandUtils/errors.ts @@ -1,7 +1,9 @@ export class EasCommandError extends Error { - // constructor is not useless, since the constructor for Error allows for optional `message` - // eslint-disable-next-line @typescript-eslint/no-useless-constructor - constructor(message: string) { + /** A stable identifier, printed in the `--json` error output so programs need not match the message. */ + public readonly code?: string; + + constructor(message: string, { code }: { code?: string } = {}) { super(message); + this.code = code; } } diff --git a/packages/eas-cli/src/commandUtils/flags.ts b/packages/eas-cli/src/commandUtils/flags.ts index 3158ef7326..9f18cdd906 100644 --- a/packages/eas-cli/src/commandUtils/flags.ts +++ b/packages/eas-cli/src/commandUtils/flags.ts @@ -8,7 +8,7 @@ export function isNonInteractiveByDefault(): boolean { export const EasNonInteractiveAndJsonFlags = { json: Flags.boolean({ description: - 'Enable JSON output, non-JSON messages will be printed to stderr. On failure, prints {"error": {"message", "requestId"?}}. Implies --non-interactive.', + 'Enable JSON output, non-JSON messages will be printed to stderr. On failure, prints {"error": {"message", "code"?, "requestId"?}}. Implies --non-interactive.', }), 'non-interactive': Flags.boolean({ description: 'Run the command in non-interactive mode.', @@ -88,7 +88,7 @@ export const EASNonInteractiveFlag = { export const EasJsonOnlyFlag = { json: Flags.boolean({ description: - 'Enable JSON output, non-JSON messages will be printed to stderr. On failure, prints {"error": {"message", "requestId"?}}.', + 'Enable JSON output, non-JSON messages will be printed to stderr. On failure, prints {"error": {"message", "code"?, "requestId"?}}.', }), }; diff --git a/packages/eas-cli/src/project/projectNotConfiguredError.ts b/packages/eas-cli/src/project/projectNotConfiguredError.ts index 4f475cef99..66d63409c4 100644 --- a/packages/eas-cli/src/project/projectNotConfiguredError.ts +++ b/packages/eas-cli/src/project/projectNotConfiguredError.ts @@ -1,3 +1,4 @@ +import { EasCommandError } from '../commandUtils/errors'; import { Actor, getCreatableAccountNames } from '../user/User'; /** @@ -20,8 +21,8 @@ export function getUnconfiguredProjectError({ accountName?: string; reason?: string; additionalFix?: string; -}): Error { - return new Error( +}): EasCommandError { + return new EasCommandError( `EAS project not configured. ${reason} ` + `Run one of the following, then re-run this command:\n\n` + `To link an existing project:\n\n` + @@ -29,6 +30,7 @@ export function getUnconfiguredProjectError({ `To create a new project:\n\n` + ` eas init --account ${accountName ?? ''} --non-interactive\n\n` + `Accounts you can create projects in: ${getCreatableAccountNames(actor).join(', ')}` + - (additionalFix ? `\n\n${additionalFix}` : '') + (additionalFix ? `\n\n${additionalFix}` : ''), + { code: 'EAS_PROJECT_NOT_CONFIGURED' } ); } diff --git a/packages/eas-cli/src/user/SessionManager.ts b/packages/eas-cli/src/user/SessionManager.ts index 43ff17e53d..834810bf1f 100644 --- a/packages/eas-cli/src/user/SessionManager.ts +++ b/packages/eas-cli/src/user/SessionManager.ts @@ -156,7 +156,8 @@ export default class SessionManager { )} environment variable if you're using EAS CLI on CI (${learnMore( 'https://docs.expo.dev/accounts/programmatic-access/', { dim: false } - )})` + )})`, + { code: 'EAS_LOGIN_REQUIRED' } ); } if (printNewLine) { diff --git a/packages/eas-cli/src/user/__tests__/SessionManager-test.ts b/packages/eas-cli/src/user/__tests__/SessionManager-test.ts index 5840df0f79..4a8b337661 100644 --- a/packages/eas-cli/src/user/__tests__/SessionManager-test.ts +++ b/packages/eas-cli/src/user/__tests__/SessionManager-test.ts @@ -254,6 +254,15 @@ describe(SessionManager, () => { }); describe('showLoginPromptAsync', () => { + it('fails with the EAS_LOGIN_REQUIRED code in non-interactive mode', async () => { + const sessionManager = new SessionManager(analytics); + + await expect( + sessionManager.showLoginPromptAsync({ nonInteractive: true }) + ).rejects.toMatchObject({ code: 'EAS_LOGIN_REQUIRED' }); + expect(promptAsync).not.toHaveBeenCalled(); + }); + it('prompts for OTP when 2FA is enabled', async () => { jest .mocked(promptAsync) diff --git a/packages/eas-cli/src/utils/json.ts b/packages/eas-cli/src/utils/json.ts index 5a438326d1..33425ce456 100644 --- a/packages/eas-cli/src/utils/json.ts +++ b/packages/eas-cli/src/utils/json.ts @@ -25,13 +25,14 @@ export function printJsonOnlyOutput(value: object): void { export interface JsonErrorOutput { message: string; + code?: string; requestId?: string; } let jsonErrorPrinted = false; /** - * Print `{ "error": { message, requestId? } }` on stdout for a command that failed with --json. + * Print `{ "error": { message, code?, requestId? } }` on stdout for a command that failed with --json. * * Once per process: `update:rollback` runs other commands in-process, and the innermost error is the one * that says what went wrong.