From 8b12a034940dbde7a1847e007e7403428a13db4e Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Mon, 26 Oct 2026 20:02:19 +0100 Subject: [PATCH] [eas-cli] Add error codes to the --json error output A program that reads the --json error must still match the message to tell a project that is not linked from a run that is not logged in. - EasCommandError takes an optional code. - getUnconfiguredProjectError: EAS_PROJECT_NOT_CONFIGURED. - Non-interactive login prompt: EAS_LOGIN_REQUIRED (oclif's own code option on Errors.error). - EasCommand.catch prints the code of an EasCommandError or CLIError. GraphQL error codes stay out: they belong to the server. Co-Authored-By: Claude Opus 5.5 --- .changeset/json-error-codes.md | 5 +++ .../eas-cli/src/commandUtils/EasCommand.ts | 9 +++-- .../commandUtils/__tests__/EasCommand-test.ts | 36 ++++++++++++++++++- .../__tests__/getProjectIdAsync-test.ts | 10 ++++++ packages/eas-cli/src/commandUtils/errors.ts | 5 ++- packages/eas-cli/src/commandUtils/flags.ts | 4 +-- .../src/project/projectNotConfiguredError.ts | 8 +++-- packages/eas-cli/src/user/SessionManager.ts | 3 +- .../src/user/__tests__/SessionManager-test.ts | 9 +++++ packages/eas-cli/src/utils/json.ts | 3 +- 10 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 .changeset/json-error-codes.md diff --git a/.changeset/json-error-codes.md b/.changeset/json-error-codes.md new file mode 100644 index 0000000000..c4a7272091 --- /dev/null +++ b/.changeset/json-error-codes.md @@ -0,0 +1,5 @@ +--- +'eas-cli': minor +--- + +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. diff --git a/packages/eas-cli/src/commandUtils/EasCommand.ts b/packages/eas-cli/src/commandUtils/EasCommand.ts index 5b1b5da57a..3e52f20c7d 100644 --- a/packages/eas-cli/src/commandUtils/EasCommand.ts +++ b/packages/eas-cli/src/commandUtils/EasCommand.ts @@ -287,15 +287,18 @@ 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) { - if (!err.messageAlreadyPrinted) { + } else if (err instanceof EasCommandError || err instanceof Errors.CLIError) { + if (!(err instanceof EasCommandError && err.messageAlreadyPrinted)) { 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) => { @@ -351,7 +354,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 0f43f7a637..3038e306c1 100644 --- a/packages/eas-cli/src/commandUtils/__tests__/EasCommand-test.ts +++ b/packages/eas-cli/src/commandUtils/__tests__/EasCommand-test.ts @@ -369,10 +369,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'); @@ -385,6 +418,7 @@ describe('EasCommand', () => { expect(printJsonErrorOutput).toHaveBeenCalledWith({ message: `${TestEasCommand.id} command failed.`, + code: undefined, requestId: undefined, }); }); @@ -410,7 +444,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 b3dc6d22ee..8d632ead7c 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 @@ -513,6 +513,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.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient); diff --git a/packages/eas-cli/src/commandUtils/errors.ts b/packages/eas-cli/src/commandUtils/errors.ts index ac68780798..bc5a46df21 100644 --- a/packages/eas-cli/src/commandUtils/errors.ts +++ b/packages/eas-cli/src/commandUtils/errors.ts @@ -1,12 +1,15 @@ export class EasCommandError extends Error { /** True when the command already printed the message, so `EasCommand.catch` must not print it again. */ public readonly messageAlreadyPrinted: boolean; + /** A stable identifier, printed in the `--json` error output so programs need not match the message. */ + public readonly code?: string; constructor( message: string, - { messageAlreadyPrinted = false }: { messageAlreadyPrinted?: boolean } = {} + { messageAlreadyPrinted = false, code }: { messageAlreadyPrinted?: boolean; code?: string } = {} ) { super(message); this.messageAlreadyPrinted = messageAlreadyPrinted; + 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 3d99fa7d6c..ec8c1e01f6 100644 --- a/packages/eas-cli/src/user/SessionManager.ts +++ b/packages/eas-cli/src/user/SessionManager.ts @@ -185,7 +185,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 01322521d3..51e39b4e53 100644 --- a/packages/eas-cli/src/user/__tests__/SessionManager-test.ts +++ b/packages/eas-cli/src/user/__tests__/SessionManager-test.ts @@ -313,6 +313,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.