Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .bumpy/stream-custom-command-output.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions packages/bumpy/src/core/publish-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
}
}

Expand All @@ -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) {
Expand Down
55 changes: 52 additions & 3 deletions packages/bumpy/src/utils/shell.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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());
}
Expand All @@ -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<void> {
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);
Expand Down Expand Up @@ -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());
}
Expand Down
39 changes: 39 additions & 0 deletions packages/bumpy/test/utils/shell.test.ts
Original file line number Diff line number Diff line change
@@ -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/,
);
});
});