diff --git a/src/exit-code.ts b/src/exit-code.ts new file mode 100644 index 0000000..02c9c53 --- /dev/null +++ b/src/exit-code.ts @@ -0,0 +1,3 @@ +export function settledExitCode(): number { + return typeof process.exitCode === 'number' ? process.exitCode : 0; +} diff --git a/src/main.ts b/src/main.ts index db1512d..b9144f9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import { loadConfig } from './config/loader'; import { handleError } from './errors/handler'; import { CLIError, isCLIError } from './errors/base'; import { ExitCode } from './errors/codes'; +import { settledExitCode } from './exit-code'; import { registerAllCommands } from './commands'; import { registry, @@ -160,6 +161,10 @@ async function run(): Promise { } } await command.execute(config, globalFlags, args); + // Commands that finish without throwing but still failed (a browser wait + // that timed out) flag it on process.exitCode; a hard exit(0) here used to + // discard it. + const exitCode = settledExitCode(); if (!isTelemetryCommand) { await dispatch( config, @@ -168,14 +173,14 @@ async function run(): Promise { command: command.name, flags: flagNames, positionalArgCount: positional.length, - exitCode: ExitCode.SUCCESS, + exitCode, durationMs: Date.now() - started, credential, error: null, }) ); } - process.exit(0); + process.exit(exitCode); } catch (err) { const { exitCode, category, rawMessage } = classifyError(err); if (!isTelemetryCommand) { diff --git a/test/exit-code.test.ts b/test/exit-code.test.ts new file mode 100644 index 0000000..47265bf --- /dev/null +++ b/test/exit-code.test.ts @@ -0,0 +1,53 @@ +import { describe, it, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { settledExitCode } from '../src/exit-code'; + +describe('settledExitCode', () => { + afterEach(() => { + process.exitCode = undefined; + }); + + it('is 0 when no command flagged a soft failure', () => { + assert.equal(settledExitCode(), 0); + }); + + it('carries the code a command set on process.exitCode', () => { + process.exitCode = 1; + assert.equal(settledExitCode(), 1); + }); +}); + +describe('main honors process.exitCode after a command completes', () => { + // Preload sets process.exitCode the way the connect commands do on a + // browser-wait timeout; `config show` completes without throwing. + function runConfigShow(preset: string): number | null { + const home = mkdtempSync(join(tmpdir(), 'polylane-exit-code-test-')); + try { + const result = spawnSync( + process.execPath, + ['--import', 'tsx', '--import', './test/helpers/preset-exit-code.ts', 'src/main.ts', 'config', 'show'], + { + cwd: join(import.meta.dirname, '..'), + env: { ...process.env, HOME: home, PRESET_EXIT_CODE: preset, POLYLANE_TELEMETRY: '0' }, + encoding: 'utf8', + } + ); + return result.status; + } finally { + rmSync(home, { recursive: true, force: true }); + } + } + + it('exits 0 when nothing was flagged', () => { + assert.equal(runConfigShow('0'), 0); + }); + + it('exits with the flagged code instead of 0', () => { + assert.equal(runConfigShow('1'), 1); + }); +}); diff --git a/test/helpers/preset-exit-code.ts b/test/helpers/preset-exit-code.ts new file mode 100644 index 0000000..f153139 --- /dev/null +++ b/test/helpers/preset-exit-code.ts @@ -0,0 +1 @@ +process.exitCode = Number(process.env.PRESET_EXIT_CODE);