Skip to content

Commit 3165561

Browse files
committed
feat(daemon): preflight dep check on ak start/restart
Refuse to start the daemon when required binaries (git, gh, npx, gpg) or an agent runtime CLI is missing, with platform-aware install hints. Previously the daemon would start and fail later in the dispatch loop with confusing errors.
1 parent 3e73320 commit 3165561

3 files changed

Lines changed: 366 additions & 0 deletions

File tree

packages/cli/src/commands/start.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { join } from "node:path";
1717
import type { Command } from "commander";
1818
import { getCredentials, saveCredentials, setCurrent } from "../config.js";
19+
import { assertDaemonDependencies } from "../daemon/preflight.js";
1920
import { DAEMON_STATE_FILE, LOGS_DIR, PID_FILE, SESSIONS_DIR, STATE_DIR } from "../paths.js";
2021
import { getAvailableProviders } from "../providers/registry.js";
2122
import { listSessions } from "../session/store.js";
@@ -143,6 +144,8 @@ export function registerStartCommand(program: Command) {
143144
process.exit(1);
144145
}
145146

147+
assertDaemonDependencies();
148+
146149
// Clear session cache if API URL changed. Sessions are backend-specific
147150
// and must not survive environment switches. Identities are now scoped
148151
// by api-url + machine + runtime, so they remain valid side by side.
@@ -305,6 +308,8 @@ export function registerRestartCommand(program: Command) {
305308
.option("--poll-interval <ms>", "Poll interval in ms")
306309
.option("--task-timeout <ms>", "Task timeout in ms (0 to disable)")
307310
.action(async (opts) => {
311+
assertDaemonDependencies();
312+
308313
// Stop existing daemon if running
309314
const pid = readDaemonPid();
310315
if (pid) {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { spawnSync } from "node:child_process";
2+
import { platform } from "node:os";
3+
import { getAvailableProviders } from "../providers/registry.js";
4+
5+
interface BinaryDep {
6+
command: string;
7+
purpose: string;
8+
hints: { darwin?: string; linux?: string; generic: string };
9+
}
10+
11+
const REQUIRED_BINARIES: BinaryDep[] = [
12+
{
13+
command: "git",
14+
purpose: "repository clone, worktree, and branch operations",
15+
hints: {
16+
darwin: "brew install git",
17+
linux: "apt install git | dnf install git",
18+
generic: "https://git-scm.com/downloads",
19+
},
20+
},
21+
{
22+
command: "gh",
23+
purpose: "repository cloning and PR status checks",
24+
hints: {
25+
darwin: "brew install gh",
26+
linux: "apt install gh | dnf install gh",
27+
generic: "https://cli.github.com/",
28+
},
29+
},
30+
{
31+
command: "npx",
32+
purpose: "installing agent skills into worktrees",
33+
hints: {
34+
darwin: "brew install node (or: volta install node)",
35+
linux: "install Node.js — https://nodejs.org/",
36+
generic: "https://nodejs.org/",
37+
},
38+
},
39+
{
40+
command: "gpg",
41+
purpose: "signing agent commits",
42+
hints: {
43+
darwin: "brew install gnupg",
44+
linux: "apt install gnupg | dnf install gnupg2",
45+
generic: "https://gnupg.org/download/",
46+
},
47+
},
48+
];
49+
50+
function isOnPath(command: string): boolean {
51+
return spawnSync("which", [command], { stdio: "ignore" }).status === 0;
52+
}
53+
54+
function hintFor(dep: BinaryDep): string {
55+
const plat = platform();
56+
if (plat === "darwin" && dep.hints.darwin) return dep.hints.darwin;
57+
if (plat === "linux" && dep.hints.linux) return dep.hints.linux;
58+
return dep.hints.generic;
59+
}
60+
61+
export function checkDaemonDependencies(): string[] {
62+
const errors: string[] = [];
63+
64+
for (const dep of REQUIRED_BINARIES) {
65+
if (!isOnPath(dep.command)) {
66+
errors.push(` • \`${dep.command}\` — ${dep.purpose}\n Install: ${hintFor(dep)}`);
67+
}
68+
}
69+
70+
if (getAvailableProviders().length === 0) {
71+
errors.push(
72+
" • no agent runtime on PATH — need at least one of: claude, codex, gemini, copilot, hermes\n" +
73+
" Install e.g. `npm install -g @anthropic-ai/claude-code` or `volta install @anthropic-ai/claude-code`",
74+
);
75+
}
76+
77+
return errors;
78+
}
79+
80+
export function assertDaemonDependencies(): void {
81+
const errors = checkDaemonDependencies();
82+
if (errors.length === 0) return;
83+
84+
console.error("Cannot start daemon — missing required dependencies:\n");
85+
console.error(errors.join("\n\n"));
86+
console.error("");
87+
process.exit(1);
88+
}
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
// @vitest-environment node
2+
/**
3+
* Unit tests for checkDaemonDependencies() and assertDaemonDependencies().
4+
*
5+
* spawnSync and getAvailableProviders are mocked so tests never touch the real
6+
* filesystem or PATH — they control exactly which binaries appear present.
7+
*/
8+
9+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10+
11+
// ── Mock node:child_process BEFORE importing the module under test ─────────────
12+
vi.mock("node:child_process", () => ({
13+
spawnSync: vi.fn(),
14+
}));
15+
16+
// ── Mock node:os so we can control the platform branch in hintFor() ───────────
17+
vi.mock("node:os", () => ({
18+
platform: vi.fn().mockReturnValue("darwin"),
19+
}));
20+
21+
// ── Mock the providers registry ───────────────────────────────────────────────
22+
vi.mock("../src/providers/registry.js", () => ({
23+
getAvailableProviders: vi.fn(),
24+
}));
25+
26+
// ── Import mocks and module under test AFTER vi.mock declarations ─────────────
27+
import { spawnSync } from "node:child_process";
28+
import { platform } from "node:os";
29+
import { assertDaemonDependencies, checkDaemonDependencies } from "../src/daemon/preflight.js";
30+
import { getAvailableProviders } from "../src/providers/registry.js";
31+
32+
const mockPlatform = vi.mocked(platform);
33+
34+
const mockSpawnSync = vi.mocked(spawnSync);
35+
const mockGetAvailableProviders = vi.mocked(getAvailableProviders);
36+
37+
/** Make spawnSync return status 0 (binary present) for all commands by default. */
38+
function allBinariesPresent() {
39+
mockSpawnSync.mockReturnValue({ status: 0 } as any);
40+
}
41+
42+
/** Make spawnSync return status 1 for a specific command name, status 0 for others. */
43+
function missingBinary(missing: string) {
44+
mockSpawnSync.mockImplementation((_cmd: string, args?: readonly string[]) => {
45+
if (Array.isArray(args) && args[0] === missing) {
46+
return { status: 1 } as any;
47+
}
48+
return { status: 0 } as any;
49+
});
50+
}
51+
52+
/** Make spawnSync return status 1 for multiple command names. */
53+
function missingBinaries(...missing: string[]) {
54+
mockSpawnSync.mockImplementation((_cmd: string, args?: readonly string[]) => {
55+
if (Array.isArray(args) && missing.includes(args[0])) {
56+
return { status: 1 } as any;
57+
}
58+
return { status: 0 } as any;
59+
});
60+
}
61+
62+
beforeEach(() => {
63+
vi.clearAllMocks();
64+
// Default: all binaries present, one runtime available
65+
allBinariesPresent();
66+
mockGetAvailableProviders.mockReturnValue([{ name: "claude" } as any]);
67+
});
68+
69+
afterEach(() => {
70+
vi.restoreAllMocks();
71+
});
72+
73+
// ── checkDaemonDependencies ───────────────────────────────────────────────────
74+
75+
describe("checkDaemonDependencies()", () => {
76+
it("returns empty array when all binaries are present and a runtime is available", () => {
77+
const errors = checkDaemonDependencies();
78+
expect(errors).toEqual([]);
79+
});
80+
81+
it("returns one error block when git is missing", () => {
82+
missingBinary("git");
83+
84+
const errors = checkDaemonDependencies();
85+
86+
expect(errors).toHaveLength(1);
87+
expect(errors[0]).toContain("`git`");
88+
expect(errors[0]).toContain("Install:");
89+
});
90+
91+
it("includes a platform-appropriate install hint for missing git", () => {
92+
missingBinary("git");
93+
94+
const errors = checkDaemonDependencies();
95+
96+
// At least one of the known install hint substrings must appear
97+
const hint = errors[0];
98+
const hasKnownHint = hint.includes("brew install git") || hint.includes("apt install git") || hint.includes("git-scm.com");
99+
expect(hasKnownHint).toBe(true);
100+
});
101+
102+
it("returns one error block when gh is missing", () => {
103+
missingBinary("gh");
104+
105+
const errors = checkDaemonDependencies();
106+
107+
expect(errors).toHaveLength(1);
108+
expect(errors[0]).toContain("`gh`");
109+
expect(errors[0]).toContain("Install:");
110+
});
111+
112+
it("returns one error block when npx is missing", () => {
113+
missingBinary("npx");
114+
115+
const errors = checkDaemonDependencies();
116+
117+
expect(errors).toHaveLength(1);
118+
expect(errors[0]).toContain("`npx`");
119+
expect(errors[0]).toContain("Install:");
120+
});
121+
122+
it("returns one error block when gpg is missing", () => {
123+
missingBinary("gpg");
124+
125+
const errors = checkDaemonDependencies();
126+
127+
expect(errors).toHaveLength(1);
128+
expect(errors[0]).toContain("`gpg`");
129+
expect(errors[0]).toContain("Install:");
130+
});
131+
132+
it("uses the linux hint for gpg when running on linux", () => {
133+
mockPlatform.mockReturnValue("linux");
134+
missingBinary("gpg");
135+
136+
const errors = checkDaemonDependencies();
137+
138+
expect(errors[0]).toContain("apt install gnupg");
139+
});
140+
141+
it("returns two distinct error blocks when gh and npx are both missing", () => {
142+
missingBinaries("gh", "npx");
143+
144+
const errors = checkDaemonDependencies();
145+
146+
expect(errors).toHaveLength(2);
147+
const joined = errors.join("\n");
148+
expect(joined).toContain("`gh`");
149+
expect(joined).toContain("`npx`");
150+
});
151+
152+
it("returns an error block when no agent runtime is available", () => {
153+
mockGetAvailableProviders.mockReturnValue([]);
154+
155+
const errors = checkDaemonDependencies();
156+
157+
expect(errors).toHaveLength(1);
158+
expect(errors[0]).toContain("no agent runtime on PATH");
159+
});
160+
161+
it("lists all five runtime names in the no-runtime error block", () => {
162+
mockGetAvailableProviders.mockReturnValue([]);
163+
164+
const errors = checkDaemonDependencies();
165+
166+
const block = errors[0];
167+
expect(block).toContain("claude");
168+
expect(block).toContain("codex");
169+
expect(block).toContain("gemini");
170+
expect(block).toContain("copilot");
171+
expect(block).toContain("hermes");
172+
});
173+
174+
it("returns errors for both missing binaries and missing runtime together", () => {
175+
missingBinary("git");
176+
mockGetAvailableProviders.mockReturnValue([]);
177+
178+
const errors = checkDaemonDependencies();
179+
180+
expect(errors).toHaveLength(2);
181+
const joined = errors.join("\n");
182+
expect(joined).toContain("`git`");
183+
expect(joined).toContain("no agent runtime on PATH");
184+
});
185+
186+
it("returns four error blocks when all four binaries are missing", () => {
187+
missingBinaries("git", "gh", "npx", "gpg");
188+
189+
const errors = checkDaemonDependencies();
190+
191+
expect(errors).toHaveLength(4);
192+
const joined = errors.join("\n");
193+
expect(joined).toContain("`git`");
194+
expect(joined).toContain("`gh`");
195+
expect(joined).toContain("`npx`");
196+
expect(joined).toContain("`gpg`");
197+
});
198+
199+
it("uses the linux hint when running on linux", () => {
200+
mockPlatform.mockReturnValue("linux");
201+
missingBinary("git");
202+
203+
const errors = checkDaemonDependencies();
204+
205+
expect(errors[0]).toContain("apt install git");
206+
});
207+
208+
it("uses the generic hint when running on an unknown platform", () => {
209+
mockPlatform.mockReturnValue("win32");
210+
missingBinary("git");
211+
212+
const errors = checkDaemonDependencies();
213+
214+
expect(errors[0]).toContain("git-scm.com");
215+
});
216+
});
217+
218+
// ── assertDaemonDependencies ──────────────────────────────────────────────────
219+
220+
describe("assertDaemonDependencies()", () => {
221+
it("does not call process.exit when all dependencies are satisfied", () => {
222+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
223+
224+
assertDaemonDependencies();
225+
226+
expect(exitSpy).not.toHaveBeenCalled();
227+
});
228+
229+
it("calls process.exit(1) when a dependency is missing", () => {
230+
missingBinary("git");
231+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
232+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
233+
234+
assertDaemonDependencies();
235+
236+
expect(exitSpy).toHaveBeenCalledWith(1);
237+
errorSpy.mockRestore();
238+
});
239+
240+
it("calls console.error with output containing the error block when a dependency is missing", () => {
241+
missingBinary("gh");
242+
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
243+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
244+
245+
assertDaemonDependencies();
246+
247+
const allOutput = errorSpy.mock.calls.map((args) => String(args[0])).join("\n");
248+
expect(allOutput).toContain("`gh`");
249+
errorSpy.mockRestore();
250+
});
251+
252+
it("calls console.error with the preamble message when errors exist", () => {
253+
missingBinary("npx");
254+
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
255+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
256+
257+
assertDaemonDependencies();
258+
259+
const allOutput = errorSpy.mock.calls.map((args) => String(args[0])).join("\n");
260+
expect(allOutput).toContain("Cannot start daemon");
261+
errorSpy.mockRestore();
262+
});
263+
264+
it("does not call console.error when all dependencies are satisfied", () => {
265+
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
266+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
267+
268+
assertDaemonDependencies();
269+
270+
expect(errorSpy).not.toHaveBeenCalled();
271+
errorSpy.mockRestore();
272+
});
273+
});

0 commit comments

Comments
 (0)