diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ae6cd74 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Tests +on: + push: + pull_request: +permissions: + contents: read +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node: [22, 24] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - run: npm install --ignore-scripts --no-audit --no-fund + - run: npm run typecheck + - run: npm test diff --git a/package.json b/package.json index 781c55b..6bf9aaa 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "LICENSE" ], "scripts": { - "test": "tsx --test test/*.test.mjs", + "test": "node test-runner.mjs", "typecheck": "tsc --noEmit" }, "peerDependencies": { diff --git a/src/cli.ts b/src/cli.ts index f8627ba..df448ff 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { statSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { execFile, spawn } from "node:child_process"; @@ -6,21 +6,50 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); -const KNOWN_BINS = [ - process.env.DEVIN_CLI, - join(homedir(), ".local/bin/devin"), - join(homedir(), ".devin/bin/devin"), - "/opt/homebrew/bin/devin", - "/usr/local/bin/devin", - "/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin", -].filter((p): p is string => Boolean(p)); +function candidateBins(): string[] { + const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), "AppData/Local"); + return [ + // Explicit override always wins. Read lazily so a value set after module + // load (e.g. by the host process) is still honoured. + process.env.DEVIN_CLI, + ...(process.platform === "win32" ? [ + // Official installer location (%LOCALAPPDATA%\devin\cli\bin\devin.exe) + join(localAppData, "devin/cli/bin/devin.exe"), + // Devin Desktop (Windsurf-based) bundled CLI, per-user install + join(localAppData, "Programs/Devin/resources/app/extensions/windsurf/devin/bin/devin.exe"), + // Devin Desktop bundled CLI, machine-wide install + process.env.ProgramFiles && + join(process.env.ProgramFiles, "Devin/resources/app/extensions/windsurf/devin/bin/devin.exe"), + // Set by the official Windows installer when it completes normally. + process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "devin/bin/devin.exe"), + // Manual fallback for installs that could not finish while Devin was running. + join(homedir(), ".devin-cli/devin.exe"), + ] : [ + join(homedir(), ".local/bin/devin"), + join(homedir(), ".devin/bin/devin"), + "/opt/homebrew/bin/devin", + "/usr/local/bin/devin", + "/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin", + ]), + ].filter((p): p is string => Boolean(p)); +} let cachedBin: string | null | undefined; +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + export function findDevinBin(): string | null { + // An explicit override must take effect even after a successful discovery. + if (process.env.DEVIN_CLI && isFile(process.env.DEVIN_CLI)) return process.env.DEVIN_CLI; if (cachedBin !== undefined) return cachedBin; - for (const bin of KNOWN_BINS) { - if (existsSync(bin)) { + for (const bin of candidateBins()) { + if (isFile(bin)) { cachedBin = bin; return bin; } @@ -36,15 +65,24 @@ export function clearDevinBinCache(): void { export async function whichDevin(): Promise { const known = findDevinBin(); if (known) return known; - try { - const { stdout } = await execFileAsync("/usr/bin/which", ["devin"], { timeout: 5_000 }); - const path = stdout.trim(); - if (path && existsSync(path)) { - cachedBin = path; - return path; + // Windows has no `which`; use `where` and probe the returned paths directly. + const isWindows = process.platform === "win32"; + const locator = isWindows ? "where.exe" : "/usr/bin/which"; + const candidates = isWindows ? ["devin.exe", "devin.cmd", "devin"] : ["devin"]; + for (const candidate of candidates) { + try { + const { stdout } = await execFileAsync(locator, [candidate], { timeout: 5_000 }); + // `where` may return several lines; take the first existing one. + for (const line of stdout.split(/\r?\n/)) { + const path = line.trim(); + if (path && isFile(path)) { + cachedBin = path; + return path; + } + } + } catch { + // not on PATH } - } catch { - // not on PATH } return null; } @@ -60,9 +98,18 @@ export async function runDevin( ); } + // Batch wrappers need cmd.exe. Quote paths with spaces, and reject shell + // expansion characters rather than interpreting them as part of a command. + const wrapper = process.platform === "win32" && /\.(cmd|bat)$/i.test(bin); + if (wrapper && [bin, ...args].some((part) => /["%!\r\n]/.test(part))) { + throw new Error("Unsupported shell characters in Devin wrapper path or arguments. Set DEVIN_CLI to devin.exe."); + } + const executable = wrapper ? process.env.ComSpec || "cmd.exe" : bin; + const commandArgs = wrapper ? ["/d", "/s", "/c", `"${[bin, ...args].map((part) => `"${part}"`).join(" ")}"`] : args; + if (opts.inheritStdio) { return await new Promise((resolve, reject) => { - const child = spawn(bin, args, { stdio: "inherit" }); + const child = spawn(executable, commandArgs, { stdio: "inherit", windowsVerbatimArguments: wrapper }); child.on("error", reject); child.on("close", (code) => { resolve({ stdout: "", stderr: "", code: code ?? 1 }); @@ -71,9 +118,10 @@ export async function runDevin( } try { - const { stdout, stderr } = await execFileAsync(bin, args, { + const { stdout, stderr } = await execFileAsync(executable, commandArgs, { timeout: opts.timeoutMs ?? 30_000, maxBuffer: 8 * 1024 * 1024, + windowsVerbatimArguments: wrapper, }); return { stdout, stderr, code: 0 }; } catch (error) { diff --git a/test-runner.mjs b/test-runner.mjs new file mode 100644 index 0000000..6b25dfe --- /dev/null +++ b/test-runner.mjs @@ -0,0 +1,8 @@ +import { readdirSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; + +const files = readdirSync("test").filter((file) => file.endsWith(".test.mjs")).sort().map((file) => join("test", file)); +const result = spawnSync(process.execPath, ["--import", "tsx", "--test", ...files], { stdio: "inherit" }); +if (result.error) throw result.error; +process.exit(result.status ?? 1); diff --git a/test/catalog-cache.test.mjs b/test/catalog-cache.test.mjs index 4669244..f2db4ed 100644 --- a/test/catalog-cache.test.mjs +++ b/test/catalog-cache.test.mjs @@ -52,8 +52,8 @@ test("checks cache age and future timestamps", () => { }); test("uses XDG cache directory when configured", () => { - assert.equal(catalogCachePath({ XDG_CACHE_HOME: "/cache" }, "/home/test"), "/cache/pi-devin/models.json"); - assert.equal(catalogCachePath({}, "/home/test"), "/home/test/.cache/pi-devin/models.json"); + assert.equal(catalogCachePath({ XDG_CACHE_HOME: "/cache" }, "/home/test"), join("/cache", "pi-devin", "models.json")); + assert.equal(catalogCachePath({}, "/home/test"), join("/home/test", ".cache", "pi-devin", "models.json")); }); for (const [field, value] of Object.entries({ cost_summary: {}, max_context_tokens: "1000", max_output_tokens: -1, model_uid: "", is_beta: "yes" })) { diff --git a/test/catalog-startup.test.mjs b/test/catalog-startup.test.mjs index 2a4f8f4..6243f24 100644 --- a/test/catalog-startup.test.mjs +++ b/test/catalog-startup.test.mjs @@ -8,10 +8,12 @@ import { catalogCachePath, writeCatalogCache } from "../src/catalog-cache.ts"; const directory = fs.mkdtempSync(join(tmpdir(), "pi-devin-catalog-test-")); after(() => fs.rmSync(directory, { recursive: true, force: true })); -const cli = join(directory, "devin"); +const cli = join(directory, process.platform === "win32" ? "devin.cmd" : "devin"); const counter = join(directory, "calls"); const catalog = { families: [{ family_label: "Test", family_uid: "test", slug: "test", variants: [{ model_uid: "test-high", label: "Test high", cost_summary: "$1/MTok In, $2/MTok Out" }] }] }; -fs.writeFileSync(cli, `#!${process.execPath}\nconst fs = require('node:fs');\nfs.appendFileSync(${JSON.stringify(counter)}, 'call\\n');\nsetTimeout(() => { console.log(process.env.TEST_CATALOG); process.exit(Number(process.env.TEST_CLI_EXIT || 0)); }, 30);\n`, { mode: 0o700 }); +const script = join(directory, "catalog.cjs"); +fs.writeFileSync(script, `const fs = require('node:fs');\nfs.appendFileSync(${JSON.stringify(counter)}, 'call\\n');\nsetTimeout(() => { console.log(process.env.TEST_CATALOG); process.exit(Number(process.env.TEST_CLI_EXIT || 0)); }, 30);\n`); +fs.writeFileSync(cli, process.platform === "win32" ? `@echo off\r\n"${process.execPath}" "${script}"\r\n` : `#!/bin/sh\nexec "${process.execPath}" "${script}"\n`, { mode: 0o700 }); const originalEnv = { ...process.env }; process.env.DEVIN_CLI = cli; const { default: extension } = await import("../extensions/index.ts"); @@ -28,7 +30,7 @@ function setup(t) { delete process.env.TEST_CLI_EXIT; fs.writeFileSync(counter, ""); const read = fs.readFileSync, exists = fs.existsSync; - const isCredentials = (path) => String(path).endsWith("devin/credentials.toml"); + const isCredentials = (path) => String(path).replaceAll("\\", "/").endsWith("devin/credentials.toml"); t.mock.method(fs, "readFileSync", (path, ...args) => isCredentials(path) ? 'api_key = "synthetic-test-key"\n' : read(path, ...args)); t.mock.method(fs, "existsSync", (path) => isCredentials(path) || exists(path)); t.mock.method(console, "warn", () => {}); diff --git a/test/cli.test.mjs b/test/cli.test.mjs new file mode 100644 index 0000000..3d8c633 --- /dev/null +++ b/test/cli.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { clearDevinBinCache, findDevinBin, whichDevin, runDevin } from "../src/cli.ts"; + +function fixture(t) { + const directory = mkdtempSync(join(tmpdir(), "pi devin cli ")); + const original = { ...process.env }; + clearDevinBinCache(); + t.after(() => { + for (const key of ["DEVIN_CLI", "LOCALAPPDATA", "ProgramFiles", "PATH"]) { + if (original[key] === undefined) delete process.env[key]; else process.env[key] = original[key]; + } + clearDevinBinCache(); rmSync(directory, { recursive: true, force: true }); + }); + return directory; +} + +test("honors an override set after the discovery cache has already been populated", async (t) => { + const directory = fixture(t); + const first = join(directory, "first"), second = join(directory, "second"); + writeFileSync(first, ""); writeFileSync(second, ""); + process.env.DEVIN_CLI = first; assert.equal(findDevinBin(), first); + process.env.DEVIN_CLI = second; assert.equal(await whichDevin(), second); +}); + +test("runs a native executable directly with argument boundaries intact", async (t) => { + fixture(t); process.env.DEVIN_CLI = process.execPath; + const value = 'spaces & punctuation "stay literal"'; + const result = await runDevin(["-e", "process.stdout.write(process.argv[1])", value]); + assert.equal(result.code, 0); assert.equal(result.stdout, value); +}); + +test("finds the Windows installer binary and ignores a directory override", { skip: process.platform !== "win32" }, (t) => { + const directory = fixture(t); process.env.LOCALAPPDATA = directory; + process.env.DEVIN_CLI = directory; + const binary = join(directory, "devin", "cli", "bin", "devin.exe"); + mkdirSync(dirname(binary), { recursive: true }); writeFileSync(binary, ""); + assert.equal(findDevinBin(), binary); +}); + +test("Windows PATH lookup uses where.exe and reads multiple results", { skip: process.platform !== "win32" }, async (t) => { + const directory = fixture(t); delete process.env.DEVIN_CLI; + process.env.LOCALAPPDATA = directory; process.env.ProgramFiles = directory; + const bin = join(directory, "path bin"); mkdirSync(bin); + const binary = join(bin, "devin.exe"); writeFileSync(binary, ""); + process.env.PATH = `${bin};${process.env.PATH}`; + assert.equal(await whichDevin(), binary); +}); + +test("Windows batch wrappers work in paths with spaces, including inherited stdio", { skip: process.platform !== "win32" }, async (t) => { + const directory = fixture(t), script = join(directory, "echo.cjs"), wrapper = join(directory, "devin.cmd"); + writeFileSync(script, 'process.stdout.write(JSON.stringify(process.argv.slice(2)));'); + writeFileSync(wrapper, `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`); + process.env.DEVIN_CLI = wrapper; + const result = await runDevin(["models", "list", "--format", "json", "a b"]); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), ["models", "list", "--format", "json", "a b"]); + assert.equal((await runDevin(["version"], { inheritStdio: true })).code, 0); + await assert.rejects(runDevin(["%PATH%"]), /Unsupported shell characters/); +});