diff --git a/packages/docker-doctor/src/agents/clipboard.ts b/packages/docker-doctor/src/agents/clipboard.ts index e7cd715..099af6c 100644 --- a/packages/docker-doctor/src/agents/clipboard.ts +++ b/packages/docker-doctor/src/agents/clipboard.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { once } from "node:events"; interface ClipboardCommand { command: string; @@ -21,22 +22,25 @@ const getClipboardCommands = (): ClipboardCommand[] => { ]; }; -const tryCopy = async ({ command, args }: ClipboardCommand, text: string) => - /* eslint-disable promise/avoid-new */ - await new Promise((resolve) => { - const child = spawn(command, args, { - stdio: ["pipe", "ignore", "ignore"], - }); - - child.once("error", () => { - resolve(false); - }); - child.once("exit", (code) => { - resolve(code === 0); - }); - child.stdin.end(text); +const tryCopy = async ( + { command, args }: ClipboardCommand, + text: string +): Promise => { + const child = spawn(command, args, { + stdio: ["pipe", "ignore", "ignore"], }); -/* eslint-enable promise/avoid-new */ + + const exited = once(child, "exit"); + child.stdin.end(text); + + try { + await exited; + } catch { + return false; + } + + return child.exitCode === 0; +}; const tryCommands = async ( commands: ClipboardCommand[], diff --git a/packages/docker-doctor/src/agents/diagnostics-dir.ts b/packages/docker-doctor/src/agents/diagnostics-dir.ts index 97aac8a..e4902ab 100644 --- a/packages/docker-doctor/src/agents/diagnostics-dir.ts +++ b/packages/docker-doctor/src/agents/diagnostics-dir.ts @@ -16,8 +16,7 @@ export const TRUST_BOUNDARY_NOTE = const UNSAFE_FILE_CHARS = /[^a-z0-9-]+/giu; -// ":12" for a diagnostic on line 12, nothing for one without a line. -const lineSuffix = (line: number | undefined): string => +export const lineSuffix = (line: number | undefined): string => line === undefined ? "" : `:${line}`; const ruleFileName = (rule: string): string => { diff --git a/packages/docker-doctor/src/agents/handoff-payload.ts b/packages/docker-doctor/src/agents/handoff-payload.ts index beb8b1f..cdb37fd 100644 --- a/packages/docker-doctor/src/agents/handoff-payload.ts +++ b/packages/docker-doctor/src/agents/handoff-payload.ts @@ -4,14 +4,11 @@ import { findRule } from "@docker-doctor/core"; import { DIAGNOSTICS_DIR_NAME, groupDiagnosticsByRule, + lineSuffix, TRUST_BOUNDARY_NOTE, } from "./diagnostics-dir"; import { sanitizeMessage, sanitizePath } from "./sanitize"; -// ":12" for a diagnostic on line 12, nothing for one without a line. -const lineSuffix = (line: number | undefined): string => - line === undefined ? "" : `:${line}`; - const MAX_FILES_PER_RULE = 5; const SEVERITY_RANK: Record = { diff --git a/packages/docker-doctor/src/agents/is-command-available.ts b/packages/docker-doctor/src/agents/is-command-available.ts index de410ac..dc0a98f 100644 --- a/packages/docker-doctor/src/agents/is-command-available.ts +++ b/packages/docker-doctor/src/agents/is-command-available.ts @@ -3,6 +3,16 @@ import path from "node:path"; const WINDOWS_EXTENSIONS = [".exe", ".cmd", ".bat"]; +const isExecutableFile = (filePath: string): boolean => { + try { + fs.accessSync(filePath, fs.constants.X_OK); + + return true; + } catch { + return false; + } +}; + export const isCommandAvailable = (command: string): boolean => { const pathValue = process.env.PATH ?? ""; const extensions = process.platform === "win32" ? WINDOWS_EXTENSIONS : [""]; @@ -13,12 +23,8 @@ export const isCommandAvailable = (command: string): boolean => { } for (const extension of extensions) { - try { - fs.accessSync(path.join(dir, command + extension), fs.constants.X_OK); - + if (isExecutableFile(path.join(dir, command + extension))) { return true; - } catch { - // Not in this directory — keep looking. } } } diff --git a/packages/docker-doctor/src/agents/launch-agent.ts b/packages/docker-doctor/src/agents/launch-agent.ts index 2faab5d..37f2c86 100644 --- a/packages/docker-doctor/src/agents/launch-agent.ts +++ b/packages/docker-doctor/src/agents/launch-agent.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { once } from "node:events"; import type { LaunchableAgentId } from "./launchable-agents"; import { AGENT_AUTO_FLAGS, AGENT_BINARIES } from "./launchable-agents"; @@ -6,27 +7,25 @@ import { AGENT_AUTO_FLAGS, AGENT_BINARIES } from "./launchable-agents"; // Launches the agent's CLI with the prompt as its initial argument, handing it // this terminal. The agent starts in `cwd`, the scanned directory. Every path // in the prompt is relative to it, and the shell's own directory can be a -// different project. Resolves true when the agent process exits normally, -// false when it could not be started (caller falls back to the clipboard -// path). +// different project. Resolves true once the agent exits, whatever its exit +// code, and false when it could not be started (the caller falls back to the +// clipboard path). export const launchAgent = async ( agentId: LaunchableAgentId, prompt: string, cwd: string -): Promise => - /* eslint-disable promise/avoid-new */ - await new Promise((resolve) => { - const child = spawn( - AGENT_BINARIES[agentId], - [...AGENT_AUTO_FLAGS[agentId], prompt], - { cwd, stdio: "inherit" } - ); +): Promise => { + const child = spawn( + AGENT_BINARIES[agentId], + [...AGENT_AUTO_FLAGS[agentId], prompt], + { cwd, stdio: "inherit" } + ); - child.once("error", () => { - resolve(false); - }); - child.once("exit", () => { - resolve(true); - }); - }); -/* eslint-enable promise/avoid-new */ + try { + await once(child, "exit"); + } catch { + return false; + } + + return true; +}; diff --git a/packages/docker-doctor/src/cli.ts b/packages/docker-doctor/src/cli.ts index bcfbd46..3ace7da 100644 --- a/packages/docker-doctor/src/cli.ts +++ b/packages/docker-doctor/src/cli.ts @@ -55,6 +55,12 @@ import { installSkillForAgents, } from "./agents/skill-install"; import { formatTerminal } from "./formatters/terminal"; +import { + CLEAR_TO_LINE_END, + cursorUp, + HIDE_CURSOR, + SHOW_CURSOR, +} from "./terminal-control"; import { hasText } from "./text"; import { scaffoldActionWorkflow, @@ -74,7 +80,6 @@ interface KeypressKey { // releases a breaking major. const ACTION_REF = "PunGrumpy/docker-doctor@v1"; -// Stops listening for keys, leaves raw mode and shows the cursor again. const stopPrompt = ( onKeypress: (str: string, key: KeypressKey) => void ): void => { @@ -85,7 +90,7 @@ const stopPrompt = ( } process.stdin.pause(); - process.stdout.write("\u001B[?25h"); + process.stdout.write(SHOW_CURSOR); }; // Arrow keys and their vim equivalents flip a yes/no prompt. @@ -110,7 +115,7 @@ const askConfirm = async ( return defaultYes; } - /* eslint-disable promise/avoid-new */ + /* eslint-disable promise/avoid-new -- the prompt settles from inside its keypress listener */ return await new Promise((resolve) => { let value = defaultYes; @@ -118,26 +123,24 @@ const askConfirm = async ( process.stdin.setRawMode(true); process.stdin.resume(); - // Hide cursor during prompt - process.stdout.write("\u001B[?25l"); + process.stdout.write(HIDE_CURSOR); const render = (firstTime = false) => { if (!firstTime) { - process.stdout.write("\u001B[3A\r"); + process.stdout.write(`${cursorUp(3)}\r`); } process.stdout.write( - `\r\u001B[K ${chalk.green("✔")} ${chalk.bold(question)}\n` + `\r${CLEAR_TO_LINE_END} ${chalk.green("✔")} ${chalk.bold(question)}\n` ); - // Print options vertically const yesPrefix = value ? chalk.cyan("❯ ") : " "; const yesText = value ? chalk.cyan.bold("Yes") : chalk.dim("Yes"); - process.stdout.write(`\r\u001B[K${yesPrefix}${yesText}\n`); + process.stdout.write(`\r${CLEAR_TO_LINE_END}${yesPrefix}${yesText}\n`); const noPrefix = value ? " " : chalk.cyan("❯ "); const noText = value ? chalk.dim("No") : chalk.cyan.bold("No"); - process.stdout.write(`\r\u001B[K${noPrefix}${noText}\n`); + process.stdout.write(`\r${CLEAR_TO_LINE_END}${noPrefix}${noText}\n`); }; render(true); @@ -159,14 +162,13 @@ const askConfirm = async ( str === "\n" ) { stopPrompt(handleKeypress); - // Overwrite and resolve vertical layout cleanly - process.stdout.write("\u001B[3A\r\u001B[K"); + process.stdout.write(`${cursorUp(3)}\r${CLEAR_TO_LINE_END}`); process.stdout.write( ` ${chalk.green("✔")} ${chalk.bold(question)} › ${value ? chalk.cyan("Yes") : chalk.dim("No")}\n` ); - process.stdout.write("\r\u001B[K\n"); - process.stdout.write("\r\u001B[K\n"); - process.stdout.write("\u001B[2A"); + process.stdout.write(`\r${CLEAR_TO_LINE_END}\n`); + process.stdout.write(`\r${CLEAR_TO_LINE_END}\n`); + process.stdout.write(cursorUp(2)); resolve(value); } else if (key.ctrl === true && key.name === "c") { stopPrompt(handleKeypress); @@ -191,7 +193,7 @@ const askSelect = async ( return defaultIndex; } - /* eslint-disable promise/avoid-new */ + /* eslint-disable promise/avoid-new -- the prompt settles from inside its keypress listener */ return await new Promise((resolve) => { let index = defaultIndex; @@ -199,27 +201,24 @@ const askSelect = async ( process.stdin.setRawMode(true); process.stdin.resume(); - // Hide cursor during prompt - process.stdout.write("\u001B[?25l"); + process.stdout.write(HIDE_CURSOR); const render = (firstTime = false) => { if (!firstTime) { - // Move back up to overwrite previous render - process.stdout.write(`\u001B[${options.length + 1}A\r`); + process.stdout.write(`${cursorUp(options.length + 1)}\r`); } process.stdout.write( - `\r\u001B[K ${chalk.green("✔")} ${chalk.bold(question)}\n` + `\r${CLEAR_TO_LINE_END} ${chalk.green("✔")} ${chalk.bold(question)}\n` ); - // Print options let i = 0; for (const option of options) { const isSelected = i === index; const prefix = isSelected ? chalk.cyan("❯ ") : " "; const text = isSelected ? chalk.cyan.bold(option) : chalk.dim(option); - process.stdout.write(`\r\u001B[K${prefix}${text}\n`); + process.stdout.write(`\r${CLEAR_TO_LINE_END}${prefix}${text}\n`); i += 1; } }; @@ -240,15 +239,16 @@ const askSelect = async ( str === "\n" ) { stopPrompt(handleKeypress); - // Overwrite and resolve - process.stdout.write(`\u001B[${options.length + 1}A\r\u001B[K`); + process.stdout.write( + `${cursorUp(options.length + 1)}\r${CLEAR_TO_LINE_END}` + ); process.stdout.write( ` ${chalk.green("✔")} ${chalk.bold(question)} › ${chalk.cyan(options[index])}\n` ); - process.stdout.write("\r\u001B[K\n".repeat(options.length)); + process.stdout.write(`\r${CLEAR_TO_LINE_END}\n`.repeat(options.length)); - process.stdout.write(`\u001B[${options.length}A`); + process.stdout.write(cursorUp(options.length)); resolve(index); } else if (key.ctrl === true && key.name === "c") { stopPrompt(handleKeypress); @@ -277,7 +277,7 @@ const askMultiSelect = async ( return options.flatMap((option, i) => (option.selected ? [i] : [])); } - /* eslint-disable promise/avoid-new */ + /* eslint-disable promise/avoid-new -- the prompt settles from inside its keypress listener */ return await new Promise((resolve) => { let index = 0; const selected = options.map((option) => option.selected); @@ -287,16 +287,15 @@ const askMultiSelect = async ( process.stdin.setRawMode(true); process.stdin.resume(); - // Hide cursor during prompt - process.stdout.write("\u001B[?25l"); + process.stdout.write(HIDE_CURSOR); const render = (firstTime = false) => { if (!firstTime) { - process.stdout.write(`\u001B[${lineCount}A\r`); + process.stdout.write(`${cursorUp(lineCount)}\r`); } process.stdout.write( - `\r\u001B[K ${chalk.green("✔")} ${chalk.bold(question)}\n` + `\r${CLEAR_TO_LINE_END} ${chalk.green("✔")} ${chalk.bold(question)}\n` ); let i = 0; @@ -313,12 +312,12 @@ const askMultiSelect = async ( text = option.label; } - process.stdout.write(`\r\u001B[K${cursor}${box} ${text}\n`); + process.stdout.write(`\r${CLEAR_TO_LINE_END}${cursor}${box} ${text}\n`); i += 1; } process.stdout.write( - `\r\u001B[K ${chalk.dim("space to toggle · enter to confirm")}\n` + `\r${CLEAR_TO_LINE_END} ${chalk.dim("space to toggle · enter to confirm")}\n` ); }; @@ -346,17 +345,16 @@ const askMultiSelect = async ( selected[i] ? [option.label] : [] ); - // Overwrite the prompt with a one-line summary - process.stdout.write(`\u001B[${lineCount}A\r\u001B[K`); + process.stdout.write(`${cursorUp(lineCount)}\r${CLEAR_TO_LINE_END}`); process.stdout.write( ` ${chalk.green("✔")} ${chalk.bold(question)} › ${chosen.length > 0 ? chalk.cyan(chosen.join(", ")) : chalk.dim("none")}\n` ); for (let i = 0; i < lineCount - 1; i += 1) { - process.stdout.write("\r\u001B[K\n"); + process.stdout.write(`\r${CLEAR_TO_LINE_END}\n`); } - process.stdout.write(`\u001B[${lineCount - 1}A`); + process.stdout.write(cursorUp(lineCount - 1)); resolve(options.flatMap((_, i) => (selected[i] ? [i] : []))); } else if (key.ctrl === true && key.name === "c") { stopPrompt(handleKeypress); @@ -382,9 +380,8 @@ interface WizardContext { rootDir: string; } -// getSkillAgentConfig rejects the synthetic "universal" id at the type level. const agentDisplayName = (agent: SkillAgentType): string => - agent === "universal" ? "Universal" : getSkillAgentConfig(agent).displayName; + getSkillAgentConfig(agent).displayName; // Post-scan handoff: offer to send the findings to a coding agent detected on // this machine (launching it with the issues as its prompt), or copy the @@ -968,14 +965,12 @@ program // terminal in the default output mode. const showProgress = process.stdout.isTTY && !isSilent; - // Helper to safely show terminal cursor const restoreCursor = (): void => { if (showProgress) { - process.stdout.write("\u001B[?25h"); + process.stdout.write(SHOW_CURSOR); } }; - // Set signal handlers to restore cursor on aborts process.on("exit", restoreCursor); process.once("SIGINT", () => { restoreCursor(); @@ -1011,12 +1006,11 @@ program }; if (showProgress) { - // Hide cursor during progress/spinner - process.stdout.write("\u001B[?25l"); + process.stdout.write(HIDE_CURSOR); process.stdout.write(`${chalk.cyan(spinnerFrames[0])} ${statusText}`); spinnerInterval = setInterval(() => { process.stdout.write( - `\r\u001B[K${chalk.cyan(spinnerFrames[frameIndex])} ${statusText}` + `\r${CLEAR_TO_LINE_END}${chalk.cyan(spinnerFrames[frameIndex])} ${statusText}` ); frameIndex = (frameIndex + 1) % spinnerFrames.length; }, 80); @@ -1077,8 +1071,7 @@ program if (spinnerInterval !== null) { clearInterval(spinnerInterval); spinnerInterval = null; - // Clear spinner line and restore cursor - process.stdout.write("\r\u001B[K\u001B[?25h"); + process.stdout.write(`\r${CLEAR_TO_LINE_END}${SHOW_CURSOR}`); } if (showProgress) { @@ -1102,7 +1095,7 @@ program } finally { if (spinnerInterval !== null) { clearInterval(spinnerInterval); - process.stdout.write("\r\u001B[K\u001B[?25h"); + process.stdout.write(`\r${CLEAR_TO_LINE_END}${SHOW_CURSOR}`); } } } catch (error: unknown) { diff --git a/packages/docker-doctor/src/formatters/terminal.ts b/packages/docker-doctor/src/formatters/terminal.ts index 56c1f69..0670e7a 100644 --- a/packages/docker-doctor/src/formatters/terminal.ts +++ b/packages/docker-doctor/src/formatters/terminal.ts @@ -8,6 +8,7 @@ import type { import { findRule } from "@docker-doctor/core"; import chalk from "chalk"; +import { cursorUp, HIDE_CURSOR, SHOW_CURSOR } from "../terminal-control"; import { hasText } from "../text"; const isLineNumber = (line: number | undefined): line is number => @@ -179,13 +180,13 @@ const printDiagnostics = ( verbose: boolean, fileContents: Record, categoryIssueCounts: Record, - unanalyzedCount: number + failedFileCount: number ): void => { if (diagnostics.length === 0) { // "Healthy" is a claim about every discovered file, so an incomplete // scan does not make it. console.log( - unanalyzedCount === 0 + failedFileCount === 0 ? `\n${chalk.green.bold("✔ No issues found! Your Docker setup looks healthy.")}` : `\n${chalk.yellow.bold("⚠ No issues found in the files that could be analyzed.")}` ); @@ -232,7 +233,7 @@ const printScoreBox = async ( label: string, warningsCount: number, errorsCount: number, - unanalyzedCount: number + failedFileCount: number ): Promise => { const { isTTY } = process.stdout; @@ -274,8 +275,7 @@ const printScoreBox = async ( const frameCount = 20; const frameDelay = 15; - // Hide cursor - process.stdout.write("\u001B[?25l"); + process.stdout.write(HIDE_CURSOR); try { for (let frame = 0; frame <= frameCount; frame += 1) { @@ -289,10 +289,8 @@ const printScoreBox = async ( chalk.dim("░".repeat(emptyBlocks)); if (frame > 0) { - // Move cursor up 4 lines and carriage return - process.stdout.write("\u001B[4A\r"); + process.stdout.write(`${cursorUp(whaleLines.length)}\r`); } else { - // print an extra newline first to start console.log(); } @@ -306,16 +304,14 @@ const printScoreBox = async ( ); if (frame < frameCount) { - // eslint-disable-next-line no-await-in-loop + // eslint-disable-next-line no-await-in-loop -- each frame waits for the one before it await setTimeout(frameDelay); } } } finally { - // Show cursor - process.stdout.write("\u001B[?25h"); + process.stdout.write(SHOW_CURSOR); } } else { - // Non-TTY fall back to static print const filledBlocks = Math.round(score / 2); const emptyBlocks = 50 - filledBlocks; @@ -338,13 +334,13 @@ const printScoreBox = async ( `\n ${chalk.dim("────────────────────────────────────────────────────────────")}\n` ); - if (unanalyzedCount === 0) { + if (failedFileCount === 0) { console.log(` Share: ${chalk.cyan(shareUrl)}`); console.log(` Tell others how you did on socials\n`); } else { // The score covers only part of the project, so it is not one to share. - const fileWord = unanalyzedCount === 1 ? "file" : "files"; - const warning = `⚠ Incomplete scan: ${unanalyzedCount} ${fileWord} could not be analyzed.`; + const fileWord = failedFileCount === 1 ? "file" : "files"; + const warning = `⚠ Incomplete scan: ${failedFileCount} ${fileWord} could not be analyzed.`; console.log(` ${chalk.yellow(warning)}`); console.log(` The score covers only the analyzed files.\n`); @@ -366,8 +362,7 @@ export const formatTerminal = async ( project: ProjectInfo, verbose = false, fileContents: Record = {}, - // Files that were discovered but could not be read or parsed. - unanalyzedCount = 0 + failedFileCount = 0 ): Promise => { if (verbose) { console.log(`\n${chalk.bold("Docker Doctor Diagnostics")}`); @@ -404,7 +399,7 @@ export const formatTerminal = async ( verbose, fileContents, categoryIssueCounts, - unanalyzedCount + failedFileCount ); await printScoreBox( @@ -412,6 +407,6 @@ export const formatTerminal = async ( label, warningsCount, errorsCount, - unanalyzedCount + failedFileCount ); }; diff --git a/packages/docker-doctor/src/terminal-control.ts b/packages/docker-doctor/src/terminal-control.ts new file mode 100644 index 0000000..cf4432a --- /dev/null +++ b/packages/docker-doctor/src/terminal-control.ts @@ -0,0 +1,5 @@ +export const HIDE_CURSOR = "\u001B[?25l"; +export const SHOW_CURSOR = "\u001B[?25h"; +export const CLEAR_TO_LINE_END = "\u001B[K"; + +export const cursorUp = (lines: number): string => `\u001B[${lines}A`; diff --git a/packages/docker-doctor/test/cli.test.ts b/packages/docker-doctor/test/cli.test.ts index 1aca577..5b3e48d 100644 --- a/packages/docker-doctor/test/cli.test.ts +++ b/packages/docker-doctor/test/cli.test.ts @@ -856,6 +856,22 @@ describe("install", () => { } }); + test("names every installed agent, the universal one included", async () => { + const { project, env, cleanup } = makeTempDirs(); + + try { + const { exitCode, stdout } = await runCli( + ["install", "--agent", "universal"], + { cwd: project, env } + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Universal →"); + } finally { + cleanup(); + } + }); + test("non-interactive run without --agent exits 1 with guidance", async () => { const { project, env, cleanup } = makeTempDirs(); diff --git a/packages/docker-doctor/test/clipboard.test.ts b/packages/docker-doctor/test/clipboard.test.ts new file mode 100644 index 0000000..4dbc55f --- /dev/null +++ b/packages/docker-doctor/test/clipboard.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { copyToClipboard } from "../src/agents/clipboard"; + +// On Linux the clipboard tools are tried in order: wl-copy, xclip, xsel. +describe.skipIf(process.platform !== "linux")("copyToClipboard", () => { + let bin: string; + let originalPath: string | undefined; + + beforeEach(() => { + bin = fs.mkdtempSync(path.join(os.tmpdir(), "dd-clipboard-bin-")); + originalPath = process.env.PATH; + process.env.PATH = bin; + }); + + afterEach(() => { + process.env.PATH = originalPath; + fs.rmSync(bin, { force: true, recursive: true }); + }); + + const installTool = (name: string, script: string): void => { + fs.writeFileSync(path.join(bin, name), `#!/bin/sh\n${script}\n`, { + mode: 0o755, + }); + }; + + test("pipes the text to the first tool that succeeds", async () => { + const copied = path.join(bin, "copied.txt"); + installTool("wl-copy", "exit 1"); + installTool( + "xclip", + `IFS= read -r line; printf "%s" "$line" > "${copied}"` + ); + + expect(await copyToClipboard("fix it")).toBe(true); + expect(fs.readFileSync(copied, "utf-8")).toBe("fix it"); + }); + + test("resolves false when every tool fails or is missing", async () => { + installTool("xsel", "exit 1"); + + expect(await copyToClipboard("fix it")).toBe(false); + }); +}); diff --git a/packages/docker-doctor/test/is-command-available.test.ts b/packages/docker-doctor/test/is-command-available.test.ts new file mode 100644 index 0000000..82e102f --- /dev/null +++ b/packages/docker-doctor/test/is-command-available.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { isCommandAvailable } from "../src/agents/is-command-available"; + +describe.skipIf(process.platform === "win32")("isCommandAvailable", () => { + let bin: string; + let originalPath: string | undefined; + + beforeEach(() => { + bin = fs.mkdtempSync(path.join(os.tmpdir(), "dd-command-bin-")); + originalPath = process.env.PATH; + process.env.PATH = `${path.delimiter}${bin}`; + }); + + afterEach(() => { + process.env.PATH = originalPath; + fs.rmSync(bin, { force: true, recursive: true }); + }); + + test("finds an executable file on PATH", () => { + fs.writeFileSync(path.join(bin, "agent"), "#!/bin/sh\n", { mode: 0o755 }); + + expect(isCommandAvailable("agent")).toBe(true); + }); + + test("skips a file that is not executable", () => { + fs.writeFileSync(path.join(bin, "agent"), "#!/bin/sh\n", { mode: 0o644 }); + + expect(isCommandAvailable("agent")).toBe(false); + }); + + test("returns false when no PATH directory has the command", () => { + expect(isCommandAvailable("agent")).toBe(false); + }); +}); diff --git a/packages/docker-doctor/test/launch-agent.test.ts b/packages/docker-doctor/test/launch-agent.test.ts index e8b1a56..00553ff 100644 --- a/packages/docker-doctor/test/launch-agent.test.ts +++ b/packages/docker-doctor/test/launch-agent.test.ts @@ -12,7 +12,7 @@ const FAKE_AGENT = const withFakeAgentOnPath = async ( run: (project: string) => Promise, - options: { installAgent: boolean } + options: { installAgent: boolean; script?: string } ): Promise => { const bin = fs.mkdtempSync(path.join(os.tmpdir(), "dd-agent-bin-")); @@ -26,7 +26,9 @@ const withFakeAgentOnPath = async ( try { if (options.installAgent) { - fs.writeFileSync(path.join(bin, "claude"), FAKE_AGENT, { mode: 0o755 }); + fs.writeFileSync(path.join(bin, "claude"), options.script ?? FAKE_AGENT, { + mode: 0o755, + }); } process.env.PATH = options.installAgent @@ -62,6 +64,15 @@ describe.skipIf(process.platform === "win32")("launchAgent", () => { ); }); + test("resolves true when the agent exits with a failure code", async () => { + await withFakeAgentOnPath( + async (project) => { + expect(await launchAgent("claude-code", "fix it", project)).toBe(true); + }, + { installAgent: true, script: "#!/bin/sh\nexit 3\n" } + ); + }); + test("resolves false when the agent binary cannot be started", async () => { await withFakeAgentOnPath( async (project) => {