mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b12a03494 | ||
|
|
0873b04166 |
No files matched your search
@@ -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.
|
||||
@@ -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');
|
||||
|
||||
+10
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
);
|
||||
}
|
||||
@@ -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.' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user