Files
paseo/packages/server/src/utils/spawn.ts
T
Mohamed BoudraandGitHub 08a0d0c28d Fix worktree setup scripts losing PATH (#1908)
* fix(worktree): preserve PATH for lifecycle scripts

Lifecycle command strings were running through login shells, which could rewrite PATH before setup commands saw the daemon environment. Use the shared stable script-shell helper without login startup files, and route related project-authored command strings through it.

* fix(worktree): ignore Bash startup env hook

Project command strings should not source shell startup hooks before they see Paseo's supplied environment. Strip BASH_ENV from lifecycle, loop verify, and ACP string-command shells and pin the behavior in setup tests.

* fix(worktree): resolve Bash through PATH

* fix(worktree): preserve Windows cmd command strings

* test(acp): expect Windows cmd terminal strings
2026-07-06 20:34:42 +08:00

116 lines
3.5 KiB
TypeScript

import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import { extname } from "node:path";
import { promisify } from "node:util";
import { createExternalCommandProcessEnv, type ProcessEnvRecord } from "../server/paseo-env.js";
import {
isWindowsCommandScript,
quoteWindowsArgument,
quoteWindowsCommand,
} from "./windows-command.js";
const execFileAsync = promisify(execFile);
interface ExternalEnvOptions {
baseEnv?: ProcessEnvRecord;
envMode?: "external" | "internal";
env?: ProcessEnvRecord;
envOverlay?: ProcessEnvRecord;
}
export type SpawnProcessOptions = Omit<SpawnOptions, "env"> & ExternalEnvOptions;
interface ExecCommandOptions extends ExternalEnvOptions {
cwd?: string;
encoding?: BufferEncoding;
killSignal?: NodeJS.Signals;
timeout?: number;
maxBuffer?: number;
shell?: boolean | string;
}
interface ExecCommandResult {
stdout: string;
stderr: string;
}
function hasPathSeparator(value: string): boolean {
return value.includes("/") || value.includes("\\");
}
function shouldUseWindowsShell(
command: string,
requestedShell?: boolean | string,
): boolean | string {
if (isWindowsCommandScript(command)) {
return true;
}
if (requestedShell !== undefined) {
return requestedShell;
}
return process.platform === "win32" && !hasPathSeparator(command) && !extname(command);
}
export function spawnProcess(
command: string,
args: string[],
options?: SpawnProcessOptions,
): ChildProcess {
const { baseEnv, env, envOverlay, ...spawnOptions } = options ?? {};
const resolvedBaseEnv = env ?? baseEnv ?? process.env;
const isWindows = process.platform === "win32";
const shell = shouldUseWindowsShell(command, spawnOptions.shell);
const shouldQuoteForShell = isWindows && shell !== false;
const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
const childEnv =
options?.envMode === "internal"
? ({ ...resolvedBaseEnv, ...envOverlay } as NodeJS.ProcessEnv)
: createExternalCommandProcessEnv(
command,
resolvedBaseEnv,
...(envOverlay ? [envOverlay] : []),
);
return spawn(resolvedCommand, resolvedArgs, {
...spawnOptions,
env: childEnv,
shell,
windowsHide: true,
});
}
export async function execCommand(
command: string,
args: string[],
options?: ExecCommandOptions,
): Promise<ExecCommandResult> {
const { baseEnv, env, envOverlay } = options ?? {};
const resolvedBaseEnv = env ?? baseEnv ?? process.env;
const isWindows = process.platform === "win32";
const shell = shouldUseWindowsShell(command, options?.shell);
const shouldQuoteForShell = isWindows && shell !== false;
const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
const childEnv =
options?.envMode === "internal"
? ({ ...resolvedBaseEnv, ...envOverlay } as NodeJS.ProcessEnv)
: createExternalCommandProcessEnv(
command,
resolvedBaseEnv,
...(envOverlay ? [envOverlay] : []),
);
return execFileAsync(resolvedCommand, resolvedArgs, {
cwd: options?.cwd,
env: childEnv,
encoding: options?.encoding ?? "utf8",
killSignal: options?.killSignal,
timeout: options?.timeout,
maxBuffer: options?.maxBuffer,
shell,
windowsHide: true,
}) as Promise<ExecCommandResult>;
}