From 59d602fb4075ec05206043623e37eb48b9f9cb85 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 16 Jun 2026 12:18:34 -0700 Subject: [PATCH] fix: stream custom build/publish command output and surface real failures --- .bumpy/stream-custom-command-output.md | 5 ++ packages/bumpy/src/core/publish-pipeline.ts | 6 +-- packages/bumpy/src/utils/shell.ts | 55 +++++++++++++++++++-- packages/bumpy/test/utils/shell.test.ts | 39 +++++++++++++++ 4 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 .bumpy/stream-custom-command-output.md create mode 100644 packages/bumpy/test/utils/shell.test.ts diff --git a/.bumpy/stream-custom-command-output.md b/.bumpy/stream-custom-command-output.md new file mode 100644 index 0000000..b29ed68 --- /dev/null +++ b/.bumpy/stream-custom-command-output.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': patch +--- + +Stream `buildCommand`/`publishCommand` output live to the parent process and surface the child's real failure reason. Custom publish commands (vsce, ovsx, anything bespoke) previously ran through a buffering runner that discarded stdout and never streamed output, so a failure like an expired marketplace token produced only a generic `Command failed` wrapper with no usable cause in CI logs. These commands now run through a streaming runner (`spawn` with piped+teed stdio) that prints output as it happens and includes both stdout and stderr in the thrown error. The capturing `runAsync`/`runArgsAsync` helpers (still used for internal git/npm calls whose output is parsed) also now include stdout in their error messages. diff --git a/packages/bumpy/src/core/publish-pipeline.ts b/packages/bumpy/src/core/publish-pipeline.ts index aa4f7e8..8c64394 100644 --- a/packages/bumpy/src/core/publish-pipeline.ts +++ b/packages/bumpy/src/core/publish-pipeline.ts @@ -2,7 +2,7 @@ import { resolve } from 'node:path'; import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'; import { unlink } from 'node:fs/promises'; import { readJson, updateJsonNestedField } from '../utils/fs.ts'; -import { runAsync, runArgsAsync, tryRunArgs, sq } from '../utils/shell.ts'; +import { runStreaming, runArgsAsync, tryRunArgs, sq } from '../utils/shell.ts'; import { log, colorize } from '../utils/logger.ts'; import { createTag, tagExists } from './git.ts'; import { DependencyGraph } from './dep-graph.ts'; @@ -217,7 +217,7 @@ export async function publishPackages( if (pkgConfig.buildCommand) { log.dim(` Building...`); if (!opts.dryRun) { - await runAsync(pkgConfig.buildCommand, { cwd: pkg.dir }); + await runStreaming(pkgConfig.buildCommand, { cwd: pkg.dir }); } } @@ -243,7 +243,7 @@ export async function publishPackages( .replace(/\{\{name\}\}/g, sq(release.name)); log.dim(` Running: ${expanded}`); if (!opts.dryRun) { - await runAsync(expanded, { cwd: pkg.dir }); + await runStreaming(expanded, { cwd: pkg.dir }); } } } else if (!pkgConfig.skipNpmPublish) { diff --git a/packages/bumpy/src/utils/shell.ts b/packages/bumpy/src/utils/shell.ts index 82759d9..01bcf10 100644 --- a/packages/bumpy/src/utils/shell.ts +++ b/packages/bumpy/src/utils/shell.ts @@ -1,4 +1,4 @@ -import { execSync, execFileSync, exec, execFile } from 'node:child_process'; +import { execSync, execFileSync, exec, execFile, spawn } from 'node:child_process'; /** * Escape a value for safe interpolation inside a single-quoted shell string. @@ -53,7 +53,7 @@ export function runAsync(cmd: string, opts?: { cwd?: string; input?: string }): return new Promise((resolve, reject) => { const child = exec(cmd, { cwd: opts?.cwd, encoding: 'utf-8' }, (err, stdout, stderr) => { if (err) { - reject(new Error(`Command failed: ${cmd}\n${stderr}`)); + reject(new Error(`Command failed: ${cmd}\n${stdout}\n${stderr}`.trim())); } else { resolve(stdout.trim()); } @@ -65,6 +65,55 @@ export function runAsync(cmd: string, opts?: { cwd?: string; input?: string }): }); } +/** + * Run a shell command, streaming its stdout/stderr to the parent process live + * (so output appears in CI logs as it happens) while also capturing it so the + * thrown error on failure includes the child's real output. + * + * Use this for user-defined commands (build/publish) where we don't need to + * parse the output but DO want it visible — unlike the capturing `runAsync`, + * which buffers everything and is meant for internal helpers whose stdout we + * parse. A failing custom command (e.g. `vsce publish`) writes its real error + * to stdout; this surfaces both streams instead of swallowing them. + */ +export function runStreaming(cmd: string, opts?: { cwd?: string; input?: string }): Promise { + const result = checkIntercept(cmd.split(/\s+/), opts); + if (result?.intercepted) { + if ('error' in result) return Promise.reject(new Error(result.error)); + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const child = spawn(cmd, { + cwd: opts?.cwd, + shell: true, + stdio: [opts?.input ? 'pipe' : 'inherit', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk) => { + stdout += chunk; + process.stdout.write(chunk); + }); + child.stderr?.on('data', (chunk) => { + stderr += chunk; + process.stderr.write(chunk); + }); + if (opts?.input) { + child.stdin?.write(opts.input); + child.stdin?.end(); + } + child.on('error', (err) => reject(err)); + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + const detail = `${stdout}\n${stderr}`.trim(); + reject(new Error(`Command failed (exit code ${code}): ${cmd}${detail ? `\n${detail}` : ''}`)); + } + }); + }); +} + export function tryRun(cmd: string, opts?: { cwd?: string }): string | null { try { return run(cmd, opts); @@ -102,7 +151,7 @@ export function runArgsAsync(args: string[], opts?: { cwd?: string; input?: stri return new Promise((resolve, reject) => { const child = execFile(cmd!, rest, { cwd: opts?.cwd, encoding: 'utf-8' }, (err, stdout, stderr) => { if (err) { - reject(new Error(`Command failed: ${args.join(' ')}\n${stderr}`)); + reject(new Error(`Command failed: ${args.join(' ')}\n${stdout}\n${stderr}`.trim())); } else { resolve(stdout.trim()); } diff --git a/packages/bumpy/test/utils/shell.test.ts b/packages/bumpy/test/utils/shell.test.ts new file mode 100644 index 0000000..b8b55a0 --- /dev/null +++ b/packages/bumpy/test/utils/shell.test.ts @@ -0,0 +1,39 @@ +import { test, expect, describe } from 'bun:test'; +import { runStreaming, runAsync, runArgsAsync } from '../../src/utils/shell.ts'; + +describe('runStreaming', () => { + test('resolves for a successful command', async () => { + await expect(runStreaming('true')).resolves.toBeUndefined(); + }); + + test('surfaces output written to stdout (not stderr) when a command fails', async () => { + // The command writes its real error to stdout, then exits non-zero — this is + // exactly the vsce failure mode that used to be swallowed. + const cmd = `node -e "console.log('REAL_ERROR_ON_STDOUT'); process.exit(1)"`; + await expect(runStreaming(cmd)).rejects.toThrow(/REAL_ERROR_ON_STDOUT/); + }); + + test('surfaces output written to stderr when a command fails', async () => { + const cmd = `node -e "console.error('REAL_ERROR_ON_STDERR'); process.exit(1)"`; + await expect(runStreaming(cmd)).rejects.toThrow(/REAL_ERROR_ON_STDERR/); + }); + + test('includes the exit code and command in the error', async () => { + await expect(runStreaming('exit 3')).rejects.toThrow(/exit code 3/); + }); +}); + +describe('runAsync error reporting', () => { + test('includes stdout in the thrown error when a command writes its error there', async () => { + const cmd = `node -e "console.log('STDOUT_FAILURE_REASON'); process.exit(1)"`; + await expect(runAsync(cmd)).rejects.toThrow(/STDOUT_FAILURE_REASON/); + }); +}); + +describe('runArgsAsync error reporting', () => { + test('includes stdout in the thrown error when a command writes its error there', async () => { + await expect(runArgsAsync(['node', '-e', "console.log('ARGS_STDOUT_FAILURE'); process.exit(1)"])).rejects.toThrow( + /ARGS_STDOUT_FAILURE/, + ); + }); +});