From d8951e1565d1c5f8c599d3eee5fab8bd02fd4027 Mon Sep 17 00:00:00 2001 From: Justin Helmer Date: Tue, 25 Aug 2026 12:05:47 -0700 Subject: [PATCH] fix: exit with the code a command set on process.exitCode instead of a hard 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `integration connect` and `cloud connect` flag a browser-wait timeout with `process.exitCode = ExitCode.GENERAL` and return, but main.ts followed every completed command with `process.exit(0)`, discarding it — the CLI printed "Timed out waiting" and exited 0, and the telemetry event reported SUCCESS. The installer recorded a connect.github.accepted for a connect that never happened (nominal#1511 N9). main.ts now exits with the settled code and reports it in the telemetry event. Tests: unit on settledExitCode, plus a subprocess run of main.ts with process.exitCode preset (the shape the connect commands leave behind). Closes #75 Co-Authored-By: Claude Fable 5 --- src/exit-code.ts | 3 ++ src/main.ts | 9 ++++-- test/exit-code.test.ts | 53 ++++++++++++++++++++++++++++++++ test/helpers/preset-exit-code.ts | 1 + 4 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/exit-code.ts create mode 100644 test/exit-code.test.ts create mode 100644 test/helpers/preset-exit-code.ts 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);