* 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
116 lines
3.5 KiB
TypeScript
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>;
|
|
}
|