Compare commits

...
Author SHA1 Message Date
Vojtech NovakandClaude Opus 5.5 0873b04166 [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> command failed." instead of "EEXIT: 1".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 10:11:46 +01:00
6 changed files with 204 additions and 4 deletions

No files matched your search

+5
View File
@@ -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.
@@ -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<any> {
await this.analytics.flushAsync();
@@ -270,7 +286,13 @@ export default abstract class EasCommand extends Command {
protected override catch(err: Error): Promise<any> {
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);
@@ -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');
+3 -2
View File
@@ -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"?}}.',
}),
};
@@ -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.' },
});
});
});
+24
View File
@@ -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));