cli-common: add unified run utils

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-11-29 13:46:39 +01:00
parent c353de0b9b
commit 5cfb2a4ea8
7 changed files with 306 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli-common': patch
---
Added new `run`, `runOutput`, and `runCheck` utilities to help run child processes in a safe and portable way.
+3
View File
@@ -35,11 +35,14 @@
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/errors": "workspace:^",
"cross-spawn": "^7.0.3",
"global-agent": "^3.0.0",
"undici": "^7.2.3"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@types/cross-spawn": "^6.0.2",
"@types/node": "^20.16.0"
}
}
+39
View File
@@ -3,12 +3,23 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ChildProcess } from 'child_process';
import { CustomErrorBase } from '@backstage/errors';
import { SpawnOptions } from 'child_process';
// @public
export const BACKSTAGE_JSON = 'backstage.json';
// @public
export function bootstrapEnvProxyAgents(): void;
// @public
export class ExitCodeError extends CustomErrorBase {
constructor(code: number, command?: string);
// (undocumented)
readonly code: number;
}
// @public
export function findPaths(searchDir: string): Paths;
@@ -29,4 +40,32 @@ export type Paths = {
// @public
export type ResolveFunc = (...paths: string[]) => string;
// @public
export function run(args: string[], options?: RunOptions): RunChildProcess;
// @public
export function runCheck(args: string[]): Promise<boolean>;
// @public
export interface RunChildProcess extends ChildProcess {
waitForExit(): Promise<void>;
}
// @public
export type RunLogFunc = (data: Buffer) => void;
// @public
export type RunOptions = Omit<SpawnOptions, 'env'> & {
env?: Partial<NodeJS.ProcessEnv>;
stdoutLogFunc?: RunLogFunc;
stderrLogFunc?: RunLogFunc;
stdio?: SpawnOptions['stdio'];
};
// @public
export function runOutput(
args: string[],
options?: RunOptions,
): Promise<string>;
```
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CustomErrorBase } from '@backstage/errors';
/**
* Error thrown when a child process exits with a non-zero code.
* @public
*/
export class ExitCodeError extends CustomErrorBase {
readonly code: number;
constructor(code: number, command?: string) {
super(
command
? `Command '${command}' exited with code ${code}`
: `Child exited with code ${code}`,
);
this.code = code;
}
}
+9
View File
@@ -24,3 +24,12 @@ export { findPaths, BACKSTAGE_JSON } from './paths';
export { isChildPath } from './isChildPath';
export type { Paths, ResolveFunc } from './paths';
export { bootstrapEnvProxyAgents } from './proxyBootstrap';
export {
run,
runOutput,
runCheck,
type RunChildProcess,
type RunOptions,
type RunLogFunc,
} from './run';
export { ExitCodeError } from './errors';
+213
View File
@@ -0,0 +1,213 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ChildProcess, SpawnOptions } from 'child_process';
import spawn from 'cross-spawn';
import { ExitCodeError } from './errors';
import { assertError } from '@backstage/errors';
/**
* Callback function that can be used to receive stdout or stderr data from a child process.
*
* @public
*/
export type RunLogFunc = (data: Buffer) => void;
/**
* Options for running a child process with {@link run} or related functions.
*
* @public
*/
export type RunOptions = Omit<SpawnOptions, 'env'> & {
env?: Partial<NodeJS.ProcessEnv>;
stdoutLogFunc?: RunLogFunc;
stderrLogFunc?: RunLogFunc;
stdio?: SpawnOptions['stdio'];
};
/**
* Child process handle returned by {@link run}.
*
* @public
*/
export interface RunChildProcess extends ChildProcess {
/**
* Waits for the child process to exit.
*
* @remarks
*
* Resolves when the process exits successfully (exit code 0) or is terminated by a signal.
* If the process exits with a non-zero exit code, the promise is rejected with an {@link ExitCodeError}.
*
* @returns A promise that resolves when the process exits successfully or is terminated by a signal, or rejects on error.
*/
waitForExit(): Promise<void>;
}
/**
* Runs a command and returns a child process handle.
*
* @public
*/
export function run(args: string[], options: RunOptions = {}): RunChildProcess {
if (args.length === 0) {
throw new Error('run requires at least one argument');
}
const [name, ...cmdArgs] = args;
const {
stdoutLogFunc,
stderrLogFunc,
stdio: customStdio,
...spawnOptions
} = options;
const env: NodeJS.ProcessEnv = {
...process.env,
FORCE_COLOR: 'true',
...(options.env ?? {}),
};
const stdio =
customStdio ??
([
'inherit',
stdoutLogFunc ? 'pipe' : 'inherit',
stderrLogFunc ? 'pipe' : 'inherit',
] as ('inherit' | 'pipe')[]);
const child = spawn(name, cmdArgs, {
...spawnOptions,
stdio,
env,
}) as RunChildProcess;
if (stdoutLogFunc && child.stdout) {
child.stdout.on('data', stdoutLogFunc);
}
if (stderrLogFunc && child.stderr) {
child.stderr.on('data', stderrLogFunc);
}
const commandName = args.join(' ');
let signalHandlersRegistered = false;
const handleSignal = () => {
if (!child.killed && child.exitCode === null) {
child.kill();
}
};
child.waitForExit = async (): Promise<void> => {
// Register signal handlers to kill child process on SIGINT/SIGTERM
if (!signalHandlersRegistered) {
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, handleSignal);
}
signalHandlersRegistered = true;
}
try {
if (typeof child.exitCode === 'number') {
if (child.exitCode) {
throw new ExitCodeError(child.exitCode, commandName);
}
return;
}
await new Promise<void>((resolve, reject) => {
child.once('error', reject);
child.once('exit', code => {
if (code) {
reject(new ExitCodeError(code, commandName));
} else {
resolve();
}
});
});
} finally {
// Clean up signal handlers when done waiting
if (signalHandlersRegistered) {
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.removeListener(signal, handleSignal);
}
signalHandlersRegistered = false;
}
}
};
return child;
}
/**
* Runs a command and returns the stdout.
*
* @remarks
*
* On error, both stdout and stderr are attached to the error object as properties.
*
* @public
*/
export async function runOutput(
args: string[],
options?: RunOptions,
): Promise<string> {
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
if (args.length === 0) {
throw new Error('runOutput requires at least one argument');
}
try {
await run(args, {
...options,
stdoutLogFunc: data => {
stdoutChunks.push(data);
options?.stdoutLogFunc?.(data);
},
stderrLogFunc: data => {
stderrChunks.push(data);
options?.stderrLogFunc?.(data);
},
}).waitForExit();
return Buffer.concat(stdoutChunks).toString().trim();
} catch (error) {
assertError(error);
(error as Error & { stdout?: string }).stdout =
Buffer.concat(stdoutChunks).toString();
(error as Error & { stderr?: string }).stderr =
Buffer.concat(stderrChunks).toString();
throw error;
}
}
/**
* Runs a command and returns true if it exits with code 0, false otherwise.
*
* @public
*/
export async function runCheck(args: string[]): Promise<boolean> {
try {
await run(args).waitForExit();
return true;
} catch {
return false;
}
}
+3
View File
@@ -3140,7 +3140,10 @@ __metadata:
resolution: "@backstage/cli-common@workspace:packages/cli-common"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/errors": "workspace:^"
"@types/cross-spawn": "npm:^6.0.2"
"@types/node": "npm:^20.16.0"
cross-spawn: "npm:^7.0.3"
global-agent: "npm:^3.0.0"
undici: "npm:^7.2.3"
languageName: unknown