From c19aaf6967ae0680aa609ce51439117f1aa47920 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Thu, 26 Mar 2026 14:35:44 +0530 Subject: [PATCH] fix(cli): disable non-interactive auto-install for required tools --- packages/cli/__tests__/commands/start.test.ts | 66 +++++++++++++++++++ packages/cli/src/commands/start.ts | 12 ++-- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 90deed7ea..c2e87a592 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -606,6 +606,72 @@ describe("start command — URL argument", () => { }); }); +describe("start command — non-interactive install safety", () => { + function hasPrivilegedInstallAttempt(): boolean { + return mockExec.mock.calls.some((call) => { + const cmd = String(call[0]); + const args = Array.isArray(call[1]) ? (call[1] as string[]) : []; + const joined = `${cmd} ${args.join(" ")}`; + return joined.includes(" install ") && (cmd === "sudo" || cmd === "brew" || cmd === "winget"); + }); + } + + it("does not auto-install tmux when missing in non-interactive mode", async () => { + const { isHumanCaller } = await import("../../src/lib/caller-context.js"); + vi.mocked(isHumanCaller).mockReturnValue(false); + + mockConfigRef.current = makeConfig({ "my-app": makeProject() }); + mockExecSilent.mockImplementation(async (cmd: string, args: string[] = []) => { + if (cmd === "git" && args[0] === "--version") return "git version 2.43.0"; + if (cmd === "tmux" && args[0] === "-V") return null; + if (cmd === "gh" && args[0] === "--version") return null; + if (cmd === "gh" && args[0] === "auth" && args[1] === "status") return null; + return null; + }); + + await expect( + program.parseAsync(["node", "test", "start", "--no-dashboard", "--no-orchestrator"]), + ).rejects.toThrow("process.exit(1)"); + + expect(hasPrivilegedInstallAttempt()).toBe(false); + expect(mockExec.mock.calls.some((call) => String(call[0]) === "tmux")).toBe(false); + }); + + it("does not auto-install git when missing in non-interactive URL start", async () => { + const { isHumanCaller } = await import("../../src/lib/caller-context.js"); + vi.mocked(isHumanCaller).mockReturnValue(false); + + mockCwd(tmpDir); + mockExecSilent.mockImplementation(async (cmd: string, args: string[] = []) => { + if (cmd === "git" && args[0] === "--version") return null; + if (cmd === "tmux" && args[0] === "-V") return "tmux 3.4"; + if (cmd === "gh" && args[0] === "--version") return null; + if (cmd === "gh" && args[0] === "auth" && args[1] === "status") return null; + return null; + }); + + await expect( + program.parseAsync([ + "node", + "test", + "start", + "https://github.com/owner/nonexistent", + "--no-dashboard", + "--no-orchestrator", + ]), + ).rejects.toThrow("process.exit(1)"); + + expect(hasPrivilegedInstallAttempt()).toBe(false); + expect( + mockExec.mock.calls.some((call) => { + const cmd = String(call[0]); + const args = Array.isArray(call[1]) ? (call[1] as string[]) : []; + return cmd === "git" && args[0] === "clone"; + }), + ).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // waitForPortAndOpen — port polling logic // --------------------------------------------------------------------------- diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 8943f2986..39baa2679 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -148,8 +148,12 @@ function canPromptForInstall(): boolean { return isHumanCaller() && IS_TTY; } -async function askYesNo(question: string, defaultYes = true): Promise { - if (!canPromptForInstall()) return defaultYes; +async function askYesNo( + question: string, + defaultYes = true, + nonInteractiveDefault = defaultYes, +): Promise { + if (!canPromptForInstall()) return nonInteractiveDefault; const { createInterface } = await import("node:readline/promises"); const rl = createInterface({ input: process.stdin, output: process.stdout }); @@ -238,7 +242,7 @@ async function ensureGit(context: string): Promise { if (hasGit) return; console.log(chalk.yellow(`⚠ Git is required for ${context}.`)); - const shouldInstall = await askYesNo("Install Git now?", true); + const shouldInstall = await askYesNo("Install Git now?", true, false); if (shouldInstall) { const installed = await tryInstallWithAttempts( gitInstallAttempts(), @@ -748,7 +752,7 @@ async function ensureTmux(): Promise { if (hasTmux) return; console.log(chalk.yellow("⚠ tmux is required for runtime \"tmux\".")); - const shouldInstall = await askYesNo("Install tmux now?", true); + const shouldInstall = await askYesNo("Install tmux now?", true, false); if (shouldInstall) { const installed = await tryInstallWithAttempts( tmuxInstallAttempts(),