From 0873b041662301ec415d7b2aad6cf4729fcb07ab Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Mon, 26 Oct 2026 20:01:54 +0100 Subject: [PATCH] [eas-cli] Print a JSON error on stdout when a --json command fails A command run with --json printed nothing on stdout when it failed, so a program had to read the human message. - EasCommand.run enables JSON output before the context is resolved, so context messages and errors stay off stdout. build:list enabled it only after getContextAsync. - EasCommand.catch prints {"error": {"message", "requestId"?}}. The request ID is the one the human message already shows. Once per process, because update:rollback runs other commands in-process. - Errors.exit(1) carries no reason, so its message is " command failed." instead of "EEXIT: 1". Co-Authored-By: Claude Opus 5.5 --- .changeset/json-error-output.md | 5 + .../eas-cli/src/commandUtils/EasCommand.ts | 31 ++++- .../commandUtils/__tests__/EasCommand-test.ts | 107 ++++++++++++++++++ packages/eas-cli/src/commandUtils/flags.ts | 5 +- .../eas-cli/src/utils/__tests__/json-test.ts | 36 ++++++ packages/eas-cli/src/utils/json.ts | 24 ++++ 6 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 .changeset/json-error-output.md create mode 100644 packages/eas-cli/src/utils/__tests__/json-test.ts diff --git a/.changeset/json-error-output.md b/.changeset/json-error-output.md new file mode 100644 index 0000000000..ddaf51cb34 --- /dev/null +++ b/.changeset/json-error-output.md @@ -0,0 +1,5 @@ +--- +'eas-cli': minor +--- + +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. diff --git a/packages/eas-cli/src/commandUtils/EasCommand.ts b/packages/eas-cli/src/commandUtils/EasCommand.ts index 19a6b03811..5b1b5da57a 100644 --- a/packages/eas-cli/src/commandUtils/EasCommand.ts +++ b/packages/eas-cli/src/commandUtils/EasCommand.ts @@ -1,4 +1,4 @@ -import { Command } from '@oclif/core'; +import { Command, Errors } from '@oclif/core'; import { CombinedError } from '@urql/core'; import chalk from 'chalk'; import { GraphQLError } from 'graphql/error'; @@ -31,6 +31,7 @@ import Log, { link } from '../log'; import Sentry from '../sentry'; import SessionManager from '../user/SessionManager'; import { getActorDisplayName } from '../user/User'; +import { enableJsonOutput, printJsonErrorOutput } from '../utils/json'; import { Client } from '../vcs/vcs'; export type ContextInput< @@ -257,9 +258,24 @@ export default abstract class EasCommand extends Command { action: `eas ${this.id}`, }); + // Before the context is resolved, so its messages and errors stay off stdout too. + if (this.jsonOutputRequested()) { + enableJsonOutput(); + } + return await this.runAsync(); } + /** Mirrors oclif's `jsonEnabled`, for commands that declare their own `json` flag. */ + private jsonOutputRequested(): boolean { + if (!this.ctor.flags?.json) { + return false; + } + const jsonIndex = this.argv.indexOf('--json'); + const passThroughIndex = this.argv.indexOf('--'); + return jsonIndex !== -1 && (passThroughIndex === -1 || jsonIndex < passThroughIndex); + } + // eslint-disable-next-line async-protect/async-suffix override async finally(err: Error): Promise { await this.analytics.flushAsync(); @@ -270,7 +286,13 @@ export default abstract class EasCommand extends Command { protected override catch(err: Error): Promise { const commandId = this.id ?? 'unknown'; let baseMessage = `${commandId} command failed.`; - if (err instanceof EasCommandError) { + let message = err.message; + 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) { Log.errorToStderr(err.message); } @@ -323,9 +345,14 @@ export default abstract class EasCommand extends Command { : cleanGQLErrorsMessage; Log.errorToStderr(cleanMessage); baseMessage = BASE_GRAPHQL_ERROR_MESSAGE; + message = cleanMessage; + requestId = err.graphQLErrors[0]?.extensions?.requestId as string | undefined; } else { Log.errorToStderr(err.message); } + if (this.jsonOutputRequested()) { + printJsonErrorOutput({ message, requestId }); + } Log.debug(err); Sentry.withScope(scope => { scope.setTag('command', commandId); diff --git a/packages/eas-cli/src/commandUtils/__tests__/EasCommand-test.ts b/packages/eas-cli/src/commandUtils/__tests__/EasCommand-test.ts index 48cd749869..0f43f7a637 100644 --- a/packages/eas-cli/src/commandUtils/__tests__/EasCommand-test.ts +++ b/packages/eas-cli/src/commandUtils/__tests__/EasCommand-test.ts @@ -24,6 +24,7 @@ jest.mock('../../analytics/AnalyticsManager', () => { }; }); jest.mock('../../log'); +jest.mock('../../utils/json'); const mockRequestId = uuidv4(); @@ -335,6 +336,112 @@ describe('EasCommand', () => { expect(logDebugSpy).toBeCalledWith(error); }); + describe('with --json', () => { + const createJsonTestEasCommand = (): any => { + const TestEasCommand = createTestEasCommand(); + const { Flags } = require('@oclif/core'); + TestEasCommand.flags = { json: Flags.boolean() }; + return TestEasCommand; + }; + + it('enables JSON output before the command runs', async () => { + const TestEasCommand = createJsonTestEasCommand(); + const { enableJsonOutput } = jest.requireMock('../../utils/json'); + let enabledBeforeRun = false; + jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(async () => { + enabledBeforeRun = enableJsonOutput.mock.calls.length > 0; + }); + + await TestEasCommand.run(['--json'], mockConfig); + + expect(enabledBeforeRun).toBe(true); + }); + + it('prints the error message', 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.'); + }); + + await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow(); + + expect(printJsonErrorOutput).toHaveBeenCalledWith({ + message: 'EAS project not configured.', + requestId: undefined, + }); + }); + + it('prints the command failure, not EEXIT, for Errors.exit', async () => { + const TestEasCommand = createJsonTestEasCommand(); + const { printJsonErrorOutput } = jest.requireMock('../../utils/json'); + const { Errors } = require('@oclif/core'); + jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => { + Errors.exit(1); + }); + + await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow(); + + expect(printJsonErrorOutput).toHaveBeenCalledWith({ + message: `${TestEasCommand.id} command failed.`, + requestId: undefined, + }); + }); + + it('prints the GraphQL request ID, not the server error code', async () => { + const TestEasCommand = createJsonTestEasCommand(); + const { printJsonErrorOutput } = jest.requireMock('../../utils/json'); + const { CombinedError } = require('@urql/core'); + const { GraphQLError } = require('graphql/error'); + jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => { + throw new CombinedError({ + graphQLErrors: [ + new GraphQLError('Entity not authorized', null, null, null, null, null, { + errorCode: 'UNAUTHORIZED_ERROR', + requestId: mockRequestId, + }), + ], + }); + }); + + await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow(); + + expect(printJsonErrorOutput).toHaveBeenCalledWith( + expect.objectContaining({ requestId: mockRequestId }) + ); + expect(printJsonErrorOutput.mock.calls[0][0]).not.toHaveProperty('code'); + }); + + it.each([ + ['without --json', []], + ['with --json after --', ['--', '--json']], + ])('prints no JSON error %s', async (_, argv) => { + const TestEasCommand = createJsonTestEasCommand(); + const { enableJsonOutput, printJsonErrorOutput } = jest.requireMock('../../utils/json'); + jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => { + throw new Error('foo'); + }); + + await expect(TestEasCommand.run(argv, mockConfig)).rejects.toThrow(); + + expect(enableJsonOutput).not.toHaveBeenCalled(); + expect(printJsonErrorOutput).not.toHaveBeenCalled(); + }); + + it('prints no JSON error when the command has no json flag', async () => { + const TestEasCommand = createTestEasCommand(); + const { printJsonErrorOutput } = jest.requireMock('../../utils/json'); + jest.spyOn(TestEasCommand.prototype, 'runAsync').mockImplementation(() => { + throw new Error('foo'); + }); + + await expect(TestEasCommand.run(['--json'], mockConfig)).rejects.toThrow(); + + expect(printJsonErrorOutput).not.toHaveBeenCalled(); + }); + }); + it('re-throws the error with default base message', async () => { const TestEasCommand = createTestEasCommand(); const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync'); diff --git a/packages/eas-cli/src/commandUtils/flags.ts b/packages/eas-cli/src/commandUtils/flags.ts index b7e3787da0..3158ef7326 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. Implies --non-interactive.', + 'Enable JSON output, non-JSON messages will be printed to stderr. On failure, prints {"error": {"message", "requestId"?}}. Implies --non-interactive.', }), 'non-interactive': Flags.boolean({ description: 'Run the command in non-interactive mode.', @@ -87,7 +87,8 @@ export const EASNonInteractiveFlag = { export const EasJsonOnlyFlag = { json: Flags.boolean({ - description: 'Enable JSON output, non-JSON messages will be printed to stderr.', + description: + 'Enable JSON output, non-JSON messages will be printed to stderr. On failure, prints {"error": {"message", "requestId"?}}.', }), }; diff --git a/packages/eas-cli/src/utils/__tests__/json-test.ts b/packages/eas-cli/src/utils/__tests__/json-test.ts new file mode 100644 index 0000000000..8b773ea9fb --- /dev/null +++ b/packages/eas-cli/src/utils/__tests__/json-test.ts @@ -0,0 +1,36 @@ +describe('printJsonErrorOutput', () => { + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + + afterEach(() => { + jest.restoreAllMocks(); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + }); + + it('prints one error object on stdout, without ANSI codes, once per process', () => { + const stdout = jest.fn(() => true); + const stderr = jest.fn(() => true); + process.stdout.write = stdout as any; + process.stderr.write = stderr as any; + // Jest's console does not write through process.stdout, so record which stream was live. + const printed: { text: string; toStdout: boolean }[] = []; + jest.spyOn(console, 'log').mockImplementation((text: string) => { + printed.push({ text, toStdout: process.stdout.write === stdout }); + }); + + jest.isolateModules(() => { + const { printJsonErrorOutput } = require('../json'); + printJsonErrorOutput({ + message: '\u001b[31mEAS project not configured.\u001b[39m', + }); + printJsonErrorOutput({ message: 'update:republish command failed.' }); + }); + + expect(printed).toHaveLength(1); + expect(printed[0].toStdout).toBe(true); + expect(JSON.parse(printed[0].text)).toEqual({ + error: { message: 'EAS project not configured.' }, + }); + }); +}); diff --git a/packages/eas-cli/src/utils/json.ts b/packages/eas-cli/src/utils/json.ts index 4f66f14ebc..5a438326d1 100644 --- a/packages/eas-cli/src/utils/json.ts +++ b/packages/eas-cli/src/utils/json.ts @@ -1,4 +1,5 @@ import assert from 'assert'; +import { stripVTControlCharacters } from 'util'; import Log from '../log'; @@ -22,6 +23,29 @@ export function printJsonOnlyOutput(value: object): void { } } +export interface JsonErrorOutput { + message: string; + requestId?: string; +} + +let jsonErrorPrinted = false; + +/** + * Print `{ "error": { message, 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. + */ +export function printJsonErrorOutput(error: JsonErrorOutput): void { + if (jsonErrorPrinted) { + return; + } + jsonErrorPrinted = true; + // The command may have failed before it could enable JSON output. + enableJsonOutput(); + printJsonOnlyOutput({ error: { ...error, message: stripVTControlCharacters(error.message) } }); +} + function sanitizeValue(value: any): unknown { if (Array.isArray(value)) { return value.map(val => sanitizeValue(val));