agent-orchestrator/packages/cli/src/lib/install-helpers.ts

103 lines
2.9 KiB
TypeScript

/**
* Shared primitives for interactively installing missing CLI tools.
*
* Used by `startup-preflight.ts` (ensureGit, ensureTmux), the agent runtime
* installer, and the optional gh install path in autoCreateConfig. Lives in
* its own module so the preflight code can be moved out of `start.ts`
* without re-extracting these helpers later.
*/
import { spawn } from "node:child_process";
import chalk from "chalk";
import { formatCommandError } from "./cli-errors.js";
import { isHumanCaller } from "./caller-context.js";
import { promptConfirm } from "./prompts.js";
export interface InstallAttempt {
cmd: string;
args: string[];
label: string;
}
export function canPromptForInstall(): boolean {
return isHumanCaller() && Boolean(process.stdin.isTTY && process.stdout.isTTY);
}
export function genericInstallHints(command: string): string[] {
switch (command) {
case "node":
case "npm":
return ["Install Node.js/npm from https://nodejs.org/"];
case "pnpm":
return ["corepack enable && corepack prepare pnpm@latest --activate", "npm install -g pnpm"];
case "pipx":
return ["python3 -m pip install --user pipx", "python3 -m pipx ensurepath"];
default:
return [];
}
}
export async function askYesNo(
question: string,
defaultYes = true,
nonInteractiveDefault = defaultYes,
): Promise<boolean> {
if (!canPromptForInstall()) return nonInteractiveDefault;
return await promptConfirm(question, defaultYes);
}
export async function runInteractiveCommand(
cmd: string,
args: string[],
options?: {
cwd?: string;
env?: Record<string, string>;
action?: string;
installHints?: string[];
},
): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(cmd, args, {
cwd: options?.cwd,
env: options?.env ? { ...process.env, ...options.env } : process.env,
stdio: "inherit",
});
child.once("error", (err) => {
reject(
formatCommandError(err, {
cmd,
args,
action: options?.action ?? "run an interactive command",
installHints: options?.installHints ?? genericInstallHints(cmd),
}),
);
});
child.once("close", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`Command failed (${code ?? "unknown"}): ${cmd} ${args.join(" ")}`));
});
});
}
export async function tryInstallWithAttempts(
attempts: InstallAttempt[],
verify: () => Promise<boolean>,
): Promise<boolean> {
for (const attempt of attempts) {
try {
console.log(chalk.dim(` Running: ${attempt.label}`));
await runInteractiveCommand(attempt.cmd, attempt.args, {
action: "run an interactive installer",
installHints: genericInstallHints(attempt.cmd),
});
if (await verify()) return true;
} catch {
// Try next installer
}
}
return verify();
}