From 9209789afbaac7449632c458eefbb3142148d8a0 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:57:10 +0700 Subject: [PATCH 1/7] refactor(cli): wait for child processes with events.once tryCopy and launchAgent each wrapped a child process in `new Promise` under an `eslint-disable promise/avoid-new`. `once(child, "exit")` from node:events gives the same result: it resolves on exit and rejects on a spawn error. launchAgent's comment also said it resolves true when the agent exits normally. It resolves true on any exit, so the comment now says that and a test pins it. New clipboard tests cover the fallback order and the case where every tool fails. --- .../docker-doctor/src/agents/clipboard.ts | 34 ++++++++------ .../docker-doctor/src/agents/launch-agent.ts | 37 +++++++-------- packages/docker-doctor/test/clipboard.test.ts | 47 +++++++++++++++++++ .../docker-doctor/test/launch-agent.test.ts | 15 +++++- 4 files changed, 97 insertions(+), 36 deletions(-) create mode 100644 packages/docker-doctor/test/clipboard.test.ts 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/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/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/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) => { From 576f7b8103d381d8346c6291fadd871638e4498b Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:58:31 +0700 Subject: [PATCH 2/7] refactor(cli): extract the executable check from isCommandAvailable isCommandAvailable kept an empty catch block alive with a comment that existed only to satisfy no-empty. isExecutableFile returns true or false instead, and the loop reads as a lookup. New tests cover an executable, a file that is not executable and a missing command. --- .../src/agents/is-command-available.ts | 16 +++++--- .../test/is-command-available.test.ts | 38 +++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 packages/docker-doctor/test/is-command-available.test.ts 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/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); + }); +}); From 9d74c5bb3522e29933acc8ab1b1c379b43b4acfd Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:59:52 +0700 Subject: [PATCH 3/7] refactor(cli): name the terminal control sequences The prompts, the spinner and the score animation wrote raw escape sequences such as `\u001B[?25l` and `\u001B[3A`, with a comment above each to say what it does. terminal-control.ts names them (HIDE_CURSOR, SHOW_CURSOR, CLEAR_TO_LINE_END and cursorUp), so those comments go. The score animation now moves up `whaleLines.length` lines instead of a literal 4. The output is byte-identical. A pseudo-terminal capture of a scan with its spinner, the score animation and two prompts matched the old build. --- packages/docker-doctor/src/cli.ts | 82 +++++++++---------- .../docker-doctor/src/formatters/terminal.ts | 12 +-- .../docker-doctor/src/terminal-control.ts | 5 ++ 3 files changed, 47 insertions(+), 52 deletions(-) create mode 100644 packages/docker-doctor/src/terminal-control.ts diff --git a/packages/docker-doctor/src/cli.ts b/packages/docker-doctor/src/cli.ts index bcfbd46..426dfc5 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. @@ -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); @@ -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); @@ -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); @@ -968,14 +966,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 +1007,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 +1072,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 +1096,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..5a9f845 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 => @@ -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(); } @@ -311,11 +309,9 @@ const printScoreBox = async ( } } } 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; 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`; From 58b563e7e7e51d8f4c0b48d76757f4ac3ef037f5 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:00:16 +0700 Subject: [PATCH 4/7] refactor(cli): name the failed-file count in the terminal formatter formatTerminal's last parameter was called unanalyzedCount, with a comment saying it counts the files that could not be read or parsed. A file with nothing to check is also unanalyzed, yet it is not in that count. failedFileCount says what the caller passes. --- .../docker-doctor/src/formatters/terminal.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/docker-doctor/src/formatters/terminal.ts b/packages/docker-doctor/src/formatters/terminal.ts index 5a9f845..d84ff28 100644 --- a/packages/docker-doctor/src/formatters/terminal.ts +++ b/packages/docker-doctor/src/formatters/terminal.ts @@ -180,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.")}` ); @@ -233,7 +233,7 @@ const printScoreBox = async ( label: string, warningsCount: number, errorsCount: number, - unanalyzedCount: number + failedFileCount: number ): Promise => { const { isTTY } = process.stdout; @@ -334,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`); @@ -362,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")}`); @@ -400,7 +399,7 @@ export const formatTerminal = async ( verbose, fileContents, categoryIssueCounts, - unanalyzedCount + failedFileCount ); await printScoreBox( @@ -408,6 +407,6 @@ export const formatTerminal = async ( label, warningsCount, errorsCount, - unanalyzedCount + failedFileCount ); }; From df68bddebc78a05aa166bd755c6277d5605f7eef Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:01:06 +0700 Subject: [PATCH 5/7] refactor(cli): read every agent's display name from agent-install agentDisplayName special-cased `universal` because, its comment said, getSkillAgentConfig rejects that id. agent-install 0.0.8 accepts it and returns "Universal", so the special case goes. A new install test pins the name. --- packages/docker-doctor/src/cli.ts | 3 +-- packages/docker-doctor/test/cli.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/docker-doctor/src/cli.ts b/packages/docker-doctor/src/cli.ts index 426dfc5..20cb1a6 100644 --- a/packages/docker-doctor/src/cli.ts +++ b/packages/docker-doctor/src/cli.ts @@ -380,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 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(); From 34efbeb4a5882fbfc764d1f58e40b6ac15acea33 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:01:23 +0700 Subject: [PATCH 6/7] refactor(cli): share lineSuffix between the prompt and the report files handoff-payload.ts and diagnostics-dir.ts each defined the same lineSuffix with the same comment. diagnostics-dir.ts now exports it and the handoff prompt imports it. --- packages/docker-doctor/src/agents/diagnostics-dir.ts | 3 +-- packages/docker-doctor/src/agents/handoff-payload.ts | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) 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 = { From 2d25d56dae8e9a7f481cf8862d140b5e748cbfe0 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:02:08 +0700 Subject: [PATCH 7/7] refactor(cli): give the remaining lint suppressions a reason Four suppressions in the CLI gave no reason, which this repository asks for. The three prompts resolve from inside their keypress listener, and the score animation plays its frames one after another, so the rules stay off at those lines with the reason written inline. --- packages/docker-doctor/src/cli.ts | 6 +++--- packages/docker-doctor/src/formatters/terminal.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/docker-doctor/src/cli.ts b/packages/docker-doctor/src/cli.ts index 20cb1a6..3ace7da 100644 --- a/packages/docker-doctor/src/cli.ts +++ b/packages/docker-doctor/src/cli.ts @@ -115,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; @@ -193,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; @@ -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); diff --git a/packages/docker-doctor/src/formatters/terminal.ts b/packages/docker-doctor/src/formatters/terminal.ts index d84ff28..0670e7a 100644 --- a/packages/docker-doctor/src/formatters/terminal.ts +++ b/packages/docker-doctor/src/formatters/terminal.ts @@ -304,7 +304,7 @@ 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); } }