Merge pull request #11 with Windows wrapper execution and platform tests
This commit is contained in:
@@ -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
|
||||||
+1
-1
@@ -29,7 +29,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "tsx --test test/*.test.mjs",
|
"test": "node test-runner.mjs",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
+58
-10
@@ -1,4 +1,4 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { statSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { execFile, spawn } from "node:child_process";
|
import { execFile, spawn } from "node:child_process";
|
||||||
@@ -6,21 +6,50 @@ import { promisify } from "node:util";
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
const KNOWN_BINS = [
|
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.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(), ".local/bin/devin"),
|
||||||
join(homedir(), ".devin/bin/devin"),
|
join(homedir(), ".devin/bin/devin"),
|
||||||
"/opt/homebrew/bin/devin",
|
"/opt/homebrew/bin/devin",
|
||||||
"/usr/local/bin/devin",
|
"/usr/local/bin/devin",
|
||||||
"/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin",
|
"/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin",
|
||||||
].filter((p): p is string => Boolean(p));
|
]),
|
||||||
|
].filter((p): p is string => Boolean(p));
|
||||||
|
}
|
||||||
|
|
||||||
let cachedBin: string | null | undefined;
|
let cachedBin: string | null | undefined;
|
||||||
|
|
||||||
|
function isFile(path: string): boolean {
|
||||||
|
try {
|
||||||
|
return statSync(path).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function findDevinBin(): string | null {
|
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;
|
if (cachedBin !== undefined) return cachedBin;
|
||||||
for (const bin of KNOWN_BINS) {
|
for (const bin of candidateBins()) {
|
||||||
if (existsSync(bin)) {
|
if (isFile(bin)) {
|
||||||
cachedBin = bin;
|
cachedBin = bin;
|
||||||
return bin;
|
return bin;
|
||||||
}
|
}
|
||||||
@@ -36,16 +65,25 @@ export function clearDevinBinCache(): void {
|
|||||||
export async function whichDevin(): Promise<string | null> {
|
export async function whichDevin(): Promise<string | null> {
|
||||||
const known = findDevinBin();
|
const known = findDevinBin();
|
||||||
if (known) return known;
|
if (known) return known;
|
||||||
|
// 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 {
|
try {
|
||||||
const { stdout } = await execFileAsync("/usr/bin/which", ["devin"], { timeout: 5_000 });
|
const { stdout } = await execFileAsync(locator, [candidate], { timeout: 5_000 });
|
||||||
const path = stdout.trim();
|
// `where` may return several lines; take the first existing one.
|
||||||
if (path && existsSync(path)) {
|
for (const line of stdout.split(/\r?\n/)) {
|
||||||
|
const path = line.trim();
|
||||||
|
if (path && isFile(path)) {
|
||||||
cachedBin = path;
|
cachedBin = path;
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// not on PATH
|
// not on PATH
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return null;
|
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) {
|
if (opts.inheritStdio) {
|
||||||
return await new Promise((resolve, reject) => {
|
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("error", reject);
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
resolve({ stdout: "", stderr: "", code: code ?? 1 });
|
resolve({ stdout: "", stderr: "", code: code ?? 1 });
|
||||||
@@ -71,9 +118,10 @@ export async function runDevin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr } = await execFileAsync(bin, args, {
|
const { stdout, stderr } = await execFileAsync(executable, commandArgs, {
|
||||||
timeout: opts.timeoutMs ?? 30_000,
|
timeout: opts.timeoutMs ?? 30_000,
|
||||||
maxBuffer: 8 * 1024 * 1024,
|
maxBuffer: 8 * 1024 * 1024,
|
||||||
|
windowsVerbatimArguments: wrapper,
|
||||||
});
|
});
|
||||||
return { stdout, stderr, code: 0 };
|
return { stdout, stderr, code: 0 };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -52,8 +52,8 @@ test("checks cache age and future timestamps", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("uses XDG cache directory when configured", () => {
|
test("uses XDG cache directory when configured", () => {
|
||||||
assert.equal(catalogCachePath({ XDG_CACHE_HOME: "/cache" }, "/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"), "/home/test/.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" })) {
|
for (const [field, value] of Object.entries({ cost_summary: {}, max_context_tokens: "1000", max_output_tokens: -1, model_uid: "", is_beta: "yes" })) {
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { catalogCachePath, writeCatalogCache } from "../src/catalog-cache.ts";
|
|||||||
|
|
||||||
const directory = fs.mkdtempSync(join(tmpdir(), "pi-devin-catalog-test-"));
|
const directory = fs.mkdtempSync(join(tmpdir(), "pi-devin-catalog-test-"));
|
||||||
after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
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 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" }] }] };
|
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 };
|
const originalEnv = { ...process.env };
|
||||||
process.env.DEVIN_CLI = cli;
|
process.env.DEVIN_CLI = cli;
|
||||||
const { default: extension } = await import("../extensions/index.ts");
|
const { default: extension } = await import("../extensions/index.ts");
|
||||||
@@ -28,7 +30,7 @@ function setup(t) {
|
|||||||
delete process.env.TEST_CLI_EXIT;
|
delete process.env.TEST_CLI_EXIT;
|
||||||
fs.writeFileSync(counter, "");
|
fs.writeFileSync(counter, "");
|
||||||
const read = fs.readFileSync, exists = fs.existsSync;
|
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, "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(fs, "existsSync", (path) => isCredentials(path) || exists(path));
|
||||||
t.mock.method(console, "warn", () => {});
|
t.mock.method(console, "warn", () => {});
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user