diff --git a/cli/package.json b/cli/package.json index 11407d9..577f47c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@cleanslice/ranch", - "version": "0.1.10", + "version": "0.1.11", "type": "module", "description": "Ranch project CLI", "license": "MIT", diff --git a/cli/src/utils/bin.test.ts b/cli/src/utils/bin.test.ts new file mode 100644 index 0000000..dd80c4c --- /dev/null +++ b/cli/src/utils/bin.test.ts @@ -0,0 +1,44 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { hasBinary } from "./bin"; + +const created: string[] = []; + +afterEach(() => { + for (const dir of created.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function withFakeBin(name: string): string { + const dir = mkdtempSync(join(tmpdir(), "ranch-bin-")); + created.push(dir); + const filename = process.platform === "win32" ? `${name}.cmd` : name; + const file = join(dir, filename); + writeFileSync(file, process.platform === "win32" ? "@echo off\n" : "#!/bin/sh\n"); + if (process.platform !== "win32") chmodSync(file, 0o755); + const prev = process.env.PATH ?? ""; + process.env.PATH = `${dir}${process.platform === "win32" ? ";" : ":"}${prev}`; + return prev; +} + +describe("hasBinary", () => { + test("finds a platform helper that is already on PATH", () => { + expect(hasBinary(process.platform === "win32" ? "where" : "sh")).toBe(true); + }); + + test("finds a binary we drop onto PATH", () => { + const prev = withFakeBin("ranch-fake-bin"); + try { + expect(hasBinary("ranch-fake-bin")).toBe(true); + } finally { + process.env.PATH = prev; + } + }); + + test("returns false when the binary is not on PATH", () => { + expect(hasBinary("ranch-missing-binary-CLEAN-41")).toBe(false); + }); +}); diff --git a/cli/src/utils/bin.ts b/cli/src/utils/bin.ts new file mode 100644 index 0000000..664478d --- /dev/null +++ b/cli/src/utils/bin.ts @@ -0,0 +1,33 @@ +import { existsSync } from "node:fs"; +import { delimiter, join } from "node:path"; + +function candidateNames(name: string): string[] { + if (process.platform !== "win32") return [name]; + const names = new Set([name]); + const exts = (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean); + const lower = name.toLowerCase(); + const hasExt = exts.some((ext) => lower.endsWith(ext.toLowerCase())); + if (!hasExt) { + for (const ext of exts) names.add(name + ext); + } + return [...names]; +} + +/** + * True if `name` is on PATH. + * + * Node's `execSync` uses cmd.exe on Windows, so a Unix `command -v` check + * always fails there even when Docker Desktop is installed and running. + */ +export function hasBinary(name: string): boolean { + const pathEnv = process.env.PATH ?? ""; + if (!pathEnv) return false; + const names = candidateNames(name); + for (const dir of pathEnv.split(delimiter)) { + if (!dir) continue; + for (const candidate of names) { + if (existsSync(join(dir, candidate))) return true; + } + } + return false; +} diff --git a/cli/src/utils/docker.ts b/cli/src/utils/docker.ts index bfd73d9..1203540 100644 --- a/cli/src/utils/docker.ts +++ b/cli/src/utils/docker.ts @@ -1,14 +1,6 @@ import { execSync } from "node:child_process"; import { consola } from "consola"; - -function hasBinary(name: string): boolean { - try { - execSync(`command -v ${name}`, { stdio: "ignore" }); - return true; - } catch { - return false; - } -} +import { hasBinary } from "./bin"; export function ensureDockerRunning(): void { if (!hasBinary("docker")) { diff --git a/cli/src/utils/k3d.ts b/cli/src/utils/k3d.ts index 93c6179..aa0fc9c 100644 --- a/cli/src/utils/k3d.ts +++ b/cli/src/utils/k3d.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { consola } from "consola"; +import { hasBinary } from "./bin"; import { tryRun } from "./exec"; const READY_TIMEOUT_MS = 90_000; @@ -11,15 +12,6 @@ const READY_POLL_MS = 2_000; const CLUSTER = "ranch"; const KUBECONFIG_LOCAL = join(homedir(), ".kube", "ranch-local.yaml"); -function hasBinary(name: string): boolean { - try { - execSync(`command -v ${name}`, { stdio: "ignore" }); - return true; - } catch { - return false; - } -} - type ClusterState = "running" | "stopped" | "missing"; function clusterState(): ClusterState { diff --git a/cli/src/utils/port-forward.ts b/cli/src/utils/port-forward.ts index 91d0934..f62fa8b 100644 --- a/cli/src/utils/port-forward.ts +++ b/cli/src/utils/port-forward.ts @@ -3,6 +3,7 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { consola } from "consola"; +import { hasBinary } from "./bin"; const KUBECONFIG_LOCAL = join(homedir(), ".kube", "ranch-local.yaml"); const RESTART_DELAY_MS = 3_000; @@ -17,15 +18,6 @@ function portInUse(port: number): boolean { } } -function hasBinary(name: string): boolean { - try { - execSync(`command -v ${name}`, { stdio: "ignore" }); - return true; - } catch { - return false; - } -} - export interface PortForwardSpec { label: string; namespace: string;