fix(infra): use 'where' instead of 'which' on Windows for binary detection

Fixes #2172

On Windows, the `which` command doesn't exist; Windows uses `where.exe`
instead. This caused skill detection to fail for skills that require CLI
binaries (e.g., blogwatcher, yt-dlp, ffmpeg).

This change follows the existing pattern used elsewhere in the codebase:
- src/daemon/program-args.ts:140
- src/commands/onboard-helpers.ts:304

Test coverage added for both Unix and Windows platforms.
This commit is contained in:
Yurii Chukhlib 2026-01-26 11:50:04 +01:00
parent 6859e1e6a6
commit 7cb17860c6
2 changed files with 77 additions and 23 deletions

View File

@ -1,34 +1,86 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import type { runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { ensureBinary } from "./binaries.js";
const originalPlatform = process.platform;
describe("ensureBinary", () => {
it("passes through when binary exists", async () => {
const exec: typeof runExec = vi.fn().mockResolvedValue({
stdout: "",
stderr: "",
afterEach(() => {
// Restore original platform after each test
Object.defineProperty(process, "platform", {
value: originalPlatform,
});
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await ensureBinary("node", exec, runtime);
expect(exec).toHaveBeenCalledWith("which", ["node"]);
});
it("logs and exits when missing", async () => {
const exec: typeof runExec = vi.fn().mockRejectedValue(new Error("missing"));
const error = vi.fn();
const exit = vi.fn(() => {
throw new Error("exit");
describe("on Unix", () => {
beforeEach(() => {
Object.defineProperty(process, "platform", {
value: "linux",
});
});
it("passes through when binary exists", async () => {
const exec: typeof runExec = vi.fn().mockResolvedValue({
stdout: "",
stderr: "",
});
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await ensureBinary("node", exec, runtime);
expect(exec).toHaveBeenCalledWith("which", ["node"]);
});
it("logs and exits when missing", async () => {
const exec: typeof runExec = vi.fn().mockRejectedValue(new Error("missing"));
const error = vi.fn();
const exit = vi.fn(() => {
throw new Error("exit");
});
await expect(ensureBinary("ghost", exec, { log: vi.fn(), error, exit })).rejects.toThrow(
"exit",
);
expect(error).toHaveBeenCalledWith("Missing required binary: ghost. Please install it.");
expect(exit).toHaveBeenCalledWith(1);
});
});
describe("on Windows", () => {
beforeEach(() => {
Object.defineProperty(process, "platform", {
value: "win32",
});
});
it("passes through when binary exists", async () => {
const exec: typeof runExec = vi.fn().mockResolvedValue({
stdout: "",
stderr: "",
});
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await ensureBinary("node", exec, runtime);
expect(exec).toHaveBeenCalledWith("where", ["node"]);
});
it("logs and exits when missing", async () => {
const exec: typeof runExec = vi.fn().mockRejectedValue(new Error("missing"));
const error = vi.fn();
const exit = vi.fn(() => {
throw new Error("exit");
});
await expect(ensureBinary("ghost", exec, { log: vi.fn(), error, exit })).rejects.toThrow(
"exit",
);
expect(error).toHaveBeenCalledWith("Missing required binary: ghost. Please install it.");
expect(exit).toHaveBeenCalledWith(1);
});
await expect(ensureBinary("ghost", exec, { log: vi.fn(), error, exit })).rejects.toThrow(
"exit",
);
expect(error).toHaveBeenCalledWith("Missing required binary: ghost. Please install it.");
expect(exit).toHaveBeenCalledWith(1);
});
});

View File

@ -7,7 +7,9 @@ export async function ensureBinary(
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
// Abort early if a required CLI tool is missing.
await exec("which", [name]).catch(() => {
// Windows uses 'where', Unix uses 'which'
const command = process.platform === "win32" ? "where" : "which";
await exec(command, [name]).catch(() => {
runtime.error(`Missing required binary: ${name}. Please install it.`);
runtime.exit(1);
});