Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages.

### 🎉 New features

- [eas-cli] 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. ([#4594](https://github.com/expo/eas-cli/pull/4594) by [@vonovak](https://github.com/vonovak))
- [eas-cli] 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. ([#4593](https://github.com/expo/eas-cli/pull/4593) by [@vonovak](https://github.com/vonovak))
- [build-tools] Let the MCP server read text and image files from sandboxes. ([#4555](https://github.com/expo/eas-cli/pull/4555) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))

Expand Down
7 changes: 5 additions & 2 deletions packages/eas-cli/src/commandUtils/EasCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,13 +278,16 @@ 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) {
} else if (err instanceof EasCommandError || err instanceof Errors.CLIError) {
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) => {
Expand Down Expand Up @@ -329,7 +332,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 => {
Expand Down
36 changes: 35 additions & 1 deletion packages/eas-cli/src/commandUtils/__tests__/EasCommand-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,10 +344,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');
Expand All @@ -360,6 +393,7 @@ describe('EasCommand', () => {

expect(printJsonErrorOutput).toHaveBeenCalledWith({
message: `${TestEasCommand.id} command failed.`,
code: undefined,
requestId: undefined,
});
});
Expand All @@ -385,7 +419,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([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,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.ensureLoggedInAsync(anything())).thenResolve({
Expand Down
8 changes: 5 additions & 3 deletions packages/eas-cli/src/commandUtils/errors.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export class EasCommandError extends Error {
// constructor is not useless, since the constructor for Error allows for optional `message`
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
constructor(message: string) {
/** A stable identifier, printed in the `--json` error output so programs need not match the message. */
public readonly code?: string;

constructor(message: string, { code }: { code?: string } = {}) {
super(message);
this.code = code;
}
}
4 changes: 2 additions & 2 deletions packages/eas-cli/src/commandUtils/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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"?}}.',
}),
};

Expand Down
8 changes: 5 additions & 3 deletions packages/eas-cli/src/project/projectNotConfiguredError.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { EasCommandError } from '../commandUtils/errors';
import { Actor, getCreatableAccountNames } from '../user/User';

/**
Expand All @@ -20,15 +21,16 @@ 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` +
` eas init --id <project-id> --non-interactive\n\n` +
`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' }
);
}
3 changes: 2 additions & 1 deletion packages/eas-cli/src/user/SessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,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) {
Expand Down
9 changes: 9 additions & 0 deletions packages/eas-cli/src/user/__tests__/SessionManager-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,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)
Expand Down
3 changes: 2 additions & 1 deletion packages/eas-cli/src/utils/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading