Compare commits

...
Author SHA1 Message Date
Vojtech NovakandClaude Opus 5.5 8b12a03494 [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 <[email protected]>
2026-10-27 10:11:46 +01:00
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
12 changed files with 278 additions and 10 deletions

No files matched your search

+5
View File
@@ -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.
+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,10 +286,19 @@ 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) {
if (!err.messageAlreadyPrinted) {
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 || 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) => {
@@ -323,9 +348,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, code, 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,146 @@ 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.',
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');
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.`,
code: undefined,
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].code).toBeUndefined();
});
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');
@@ -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<SessionManager>();
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
+4 -1
View File
@@ -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;
}
}
+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", "code"?, "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", "code"?, "requestId"?}}.',
}),
};
@@ -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 ?? '<account-name>'} --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' }
);
}
+2 -1
View File
@@ -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) {
@@ -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)
@@ -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.' },
});
});
});
+25
View File
@@ -1,4 +1,5 @@
import assert from 'assert';
import { stripVTControlCharacters } from 'util';
import Log from '../log';
@@ -22,6 +23,30 @@ export function printJsonOnlyOutput(value: object): void {
}
}
export interface JsonErrorOutput {
message: string;
code?: string;
requestId?: string;
}
let jsonErrorPrinted = false;
/**
* 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.
*/
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));