From 5cfb2a4ea8c73e10b33b6abf08352df6f1580c0c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 29 Nov 2025 13:46:39 +0100 Subject: [PATCH] cli-common: add unified run utils Signed-off-by: Patrik Oldsberg --- .changeset/modern-taxes-start.md | 5 + packages/cli-common/package.json | 3 + packages/cli-common/report.api.md | 39 ++++++ packages/cli-common/src/errors.ts | 34 +++++ packages/cli-common/src/index.ts | 9 ++ packages/cli-common/src/run.ts | 213 ++++++++++++++++++++++++++++++ yarn.lock | 3 + 7 files changed, 306 insertions(+) create mode 100644 .changeset/modern-taxes-start.md create mode 100644 packages/cli-common/src/errors.ts create mode 100644 packages/cli-common/src/run.ts diff --git a/.changeset/modern-taxes-start.md b/.changeset/modern-taxes-start.md new file mode 100644 index 0000000000..9fd7645570 --- /dev/null +++ b/.changeset/modern-taxes-start.md @@ -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. diff --git a/packages/cli-common/package.json b/packages/cli-common/package.json index b532da83c8..8f8904a968 100644 --- a/packages/cli-common/package.json +++ b/packages/cli-common/package.json @@ -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" } } diff --git a/packages/cli-common/report.api.md b/packages/cli-common/report.api.md index 0e3c80afa4..a2aba06228 100644 --- a/packages/cli-common/report.api.md +++ b/packages/cli-common/report.api.md @@ -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; + +// @public +export interface RunChildProcess extends ChildProcess { + waitForExit(): Promise; +} + +// @public +export type RunLogFunc = (data: Buffer) => void; + +// @public +export type RunOptions = Omit & { + env?: Partial; + stdoutLogFunc?: RunLogFunc; + stderrLogFunc?: RunLogFunc; + stdio?: SpawnOptions['stdio']; +}; + +// @public +export function runOutput( + args: string[], + options?: RunOptions, +): Promise; ``` diff --git a/packages/cli-common/src/errors.ts b/packages/cli-common/src/errors.ts new file mode 100644 index 0000000000..07664d2ffb --- /dev/null +++ b/packages/cli-common/src/errors.ts @@ -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; + } +} diff --git a/packages/cli-common/src/index.ts b/packages/cli-common/src/index.ts index 11e5d05f4f..632aff9eb6 100644 --- a/packages/cli-common/src/index.ts +++ b/packages/cli-common/src/index.ts @@ -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'; diff --git a/packages/cli-common/src/run.ts b/packages/cli-common/src/run.ts new file mode 100644 index 0000000000..9a0a0975c1 --- /dev/null +++ b/packages/cli-common/src/run.ts @@ -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 & { + env?: Partial; + 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; +} + +/** + * 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 => { + // 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((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 { + 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 { + try { + await run(args).waitForExit(); + return true; + } catch { + return false; + } +} diff --git a/yarn.lock b/yarn.lock index 3502df3388..9011dc7b35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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