From e9ab32e76a62323d7d513f98b4ab0b270190d40c Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 15 Oct 2026 16:35:00 +0200 Subject: [PATCH 1/2] [eas-cli] Write the reason a command failed to stderr EasCommand.catch printed the reason with Log.error, which writes to stdout. oclif then printed " command failed." to stderr. A program that reads stderr for the error found only that generic line. Only the catch changes. Other Log.error and Log.warn calls stay on stdout, because some of them are not failures and the spinner keeps non-TTY output off stderr on purpose. Co-Authored-By: Claude Opus 5.5 --- .changeset/failure-reason-stderr.md | 5 +++++ packages/eas-cli/src/__tests__/log-test.ts | 17 +++++++++++++++++ packages/eas-cli/src/commandUtils/EasCommand.ts | 6 +++--- .../commandUtils/__tests__/EasCommand-test.ts | 8 ++++---- packages/eas-cli/src/log.ts | 10 ++++++++++ 5 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 .changeset/failure-reason-stderr.md create mode 100644 packages/eas-cli/src/__tests__/log-test.ts diff --git a/.changeset/failure-reason-stderr.md b/.changeset/failure-reason-stderr.md new file mode 100644 index 0000000000..8f75e64733 --- /dev/null +++ b/.changeset/failure-reason-stderr.md @@ -0,0 +1,5 @@ +--- +'eas-cli': major +--- + +Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. diff --git a/packages/eas-cli/src/__tests__/log-test.ts b/packages/eas-cli/src/__tests__/log-test.ts new file mode 100644 index 0000000000..6d9a71acd9 --- /dev/null +++ b/packages/eas-cli/src/__tests__/log-test.ts @@ -0,0 +1,17 @@ +import Log from '../log'; + +describe(Log.errorToStderr, () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('writes to stderr, not stdout', () => { + const stdout = jest.spyOn(console, 'log').mockImplementation(() => {}); + const stderr = jest.spyOn(console, 'error').mockImplementation(() => {}); + + Log.errorToStderr('EAS project not configured.'); + + expect(stderr).toHaveBeenCalledWith(expect.stringContaining('EAS project not configured.')); + expect(stdout).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/commandUtils/EasCommand.ts b/packages/eas-cli/src/commandUtils/EasCommand.ts index d167880247..9359d377b7 100644 --- a/packages/eas-cli/src/commandUtils/EasCommand.ts +++ b/packages/eas-cli/src/commandUtils/EasCommand.ts @@ -262,7 +262,7 @@ export default abstract class EasCommand extends Command { const commandId = this.id ?? 'unknown'; let baseMessage = `${commandId} command failed.`; if (err instanceof EasCommandError) { - Log.error(err.message); + Log.errorToStderr(err.message); } else if (err instanceof CombinedError && err?.graphQLErrors) { const cleanGQLErrorsMessage = err?.graphQLErrors .map((graphQLError: GraphQLError) => { @@ -299,10 +299,10 @@ export default abstract class EasCommand extends Command { const cleanMessage = err.networkError ? `${cleanGQLErrorsMessage}\n${err.networkError.message}` : cleanGQLErrorsMessage; - Log.error(cleanMessage); + Log.errorToStderr(cleanMessage); baseMessage = BASE_GRAPHQL_ERROR_MESSAGE; } else { - Log.error(err.message); + Log.errorToStderr(err.message); } Log.debug(err); Sentry.withScope(scope => { diff --git a/packages/eas-cli/src/commandUtils/__tests__/EasCommand-test.ts b/packages/eas-cli/src/commandUtils/__tests__/EasCommand-test.ts index a3c74cd36d..bae6b4fdb4 100644 --- a/packages/eas-cli/src/commandUtils/__tests__/EasCommand-test.ts +++ b/packages/eas-cli/src/commandUtils/__tests__/EasCommand-test.ts @@ -199,7 +199,7 @@ describe('EasCommand', () => { it('logs the message', async () => { const TestEasCommand = createTestEasCommand(); const Log = jest.requireMock('../../log').default; - const logErrorSpy = jest.spyOn(Log, 'error'); + const logErrorSpy = jest.spyOn(Log, 'errorToStderr'); const logDebugSpy = jest.spyOn(Log, 'debug'); const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync'); const error = new Error('Unexpected, internal error message'); @@ -217,7 +217,7 @@ describe('EasCommand', () => { it('logs the cleaned message if needed', async () => { const TestEasCommand = createTestEasCommand(); const Log = jest.requireMock('../../log').default; - const logErrorSpy = jest.spyOn(Log, 'error'); + const logErrorSpy = jest.spyOn(Log, 'errorToStderr'); const logDebugSpy = jest.spyOn(Log, 'debug'); const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync'); const { CombinedError } = require('@urql/core'); @@ -237,7 +237,7 @@ describe('EasCommand', () => { it('logs the cleaned message with request ID if present', async () => { const TestEasCommand = createTestEasCommand(); const Log = jest.requireMock('../../log').default; - const logErrorSpy = jest.spyOn(Log, 'error'); + const logErrorSpy = jest.spyOn(Log, 'errorToStderr'); const logDebugSpy = jest.spyOn(Log, 'debug'); const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync'); const { CombinedError } = require('@urql/core'); @@ -272,7 +272,7 @@ describe('EasCommand', () => { it('logs the cleaned messages with request IDs if multiple GraphQL errors present', async () => { const TestEasCommand = createTestEasCommand(); const Log = jest.requireMock('../../log').default; - const logErrorSpy = jest.spyOn(Log, 'error'); + const logErrorSpy = jest.spyOn(Log, 'errorToStderr'); const logDebugSpy = jest.spyOn(Log, 'debug'); const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync'); const { CombinedError } = require('@urql/core'); diff --git a/packages/eas-cli/src/log.ts b/packages/eas-cli/src/log.ts index 64c57ac54a..52a3513cbd 100644 --- a/packages/eas-cli/src/log.ts +++ b/packages/eas-cli/src/log.ts @@ -39,6 +39,16 @@ export default class Log { Log.consoleLog(...Log.withTextColor(args, chalk.red)); } + /** + * Print the reason a command failed. It goes to stderr, next to oclif's "command failed." line, + * so a program that runs eas-cli finds it there. + */ + public static errorToStderr(...args: any[]): void { + Log.updateIsLastLineNewLine(args); + // eslint-disable-next-line no-console + console.error(...Log.withTextColor(args, chalk.red)); + } + public static warn(...args: any[]): void { Log.consoleLog(...Log.withTextColor(args, chalk.yellow)); } From 0f3bb8df3908e13999fab4167c286bbf70aea7f9 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 15 Oct 2026 16:50:00 +0200 Subject: [PATCH 2/2] [eas-cli] Drop the errorToStderr unit test and doc comment Co-Authored-By: Claude Opus 5.5 --- packages/eas-cli/src/__tests__/log-test.ts | 17 ----------------- packages/eas-cli/src/log.ts | 4 ---- 2 files changed, 21 deletions(-) delete mode 100644 packages/eas-cli/src/__tests__/log-test.ts diff --git a/packages/eas-cli/src/__tests__/log-test.ts b/packages/eas-cli/src/__tests__/log-test.ts deleted file mode 100644 index 6d9a71acd9..0000000000 --- a/packages/eas-cli/src/__tests__/log-test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import Log from '../log'; - -describe(Log.errorToStderr, () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('writes to stderr, not stdout', () => { - const stdout = jest.spyOn(console, 'log').mockImplementation(() => {}); - const stderr = jest.spyOn(console, 'error').mockImplementation(() => {}); - - Log.errorToStderr('EAS project not configured.'); - - expect(stderr).toHaveBeenCalledWith(expect.stringContaining('EAS project not configured.')); - expect(stdout).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/eas-cli/src/log.ts b/packages/eas-cli/src/log.ts index 52a3513cbd..deb5e67581 100644 --- a/packages/eas-cli/src/log.ts +++ b/packages/eas-cli/src/log.ts @@ -39,10 +39,6 @@ export default class Log { Log.consoleLog(...Log.withTextColor(args, chalk.red)); } - /** - * Print the reason a command failed. It goes to stderr, next to oclif's "command failed." line, - * so a program that runs eas-cli finds it there. - */ public static errorToStderr(...args: any[]): void { Log.updateIsLastLineNewLine(args); // eslint-disable-next-line no-console