Initial import: open-design source for helix-mind.ai distribution
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
This repository contains the open-design daemon CLI source code, built and packaged at https://helix-mind.ai/cli/open-design/latest.tgz for use by the HelixMind /design slash command. Licenses: Apache-2.0 (root) + MIT (skills/*)
This commit is contained in:
11
packages/platform/esbuild.config.mjs
Normal file
11
packages/platform/esbuild.config.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
import { build } from "esbuild";
|
||||
|
||||
await build({
|
||||
bundle: true,
|
||||
entryPoints: ["./src/index.ts"],
|
||||
format: "esm",
|
||||
outfile: "./dist/index.mjs",
|
||||
packages: "external",
|
||||
platform: "node",
|
||||
target: "node24",
|
||||
});
|
||||
31
packages/platform/package.json
Normal file
31
packages/platform/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@open-design/platform",
|
||||
"version": "0.4.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node ./esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.12.2",
|
||||
"esbuild": "0.27.7",
|
||||
"vitest": "^2.1.8",
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "~24"
|
||||
}
|
||||
}
|
||||
529
packages/platform/src/index.ts
Normal file
529
packages/platform/src/index.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
import { execFile, spawn, type ChildProcess, type StdioOptions } from "node:child_process";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
export type CommandInvocation = {
|
||||
args: string[];
|
||||
command: string;
|
||||
// When true, callers must forward this to `child_process.spawn` /
|
||||
// `child_process.execFile` options. Required for Windows `.bat` / `.cmd`
|
||||
// shims so cmd.exe's `/s /c` quoting survives Node's default per-arg
|
||||
// CommandLineToArgvW escaping. See `createCommandInvocation`.
|
||||
windowsVerbatimArguments?: boolean;
|
||||
};
|
||||
|
||||
export type ProcessStampShape = object;
|
||||
|
||||
export type ProcessStampField<TStamp extends ProcessStampShape> = Extract<keyof TStamp, string>;
|
||||
|
||||
export type ProcessStampContract<
|
||||
TStamp extends ProcessStampShape,
|
||||
TCriteria extends Partial<TStamp> = Partial<TStamp>,
|
||||
> = {
|
||||
normalizeStamp(input: unknown): TStamp;
|
||||
normalizeStampCriteria(input?: unknown): TCriteria;
|
||||
stampFields: readonly ProcessStampField<TStamp>[];
|
||||
stampFlags: { readonly [K in ProcessStampField<TStamp>]: string };
|
||||
};
|
||||
|
||||
export type CommandInvocationRequest = {
|
||||
args?: string[];
|
||||
command: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
export type SpawnProcessRequest = CommandInvocationRequest & {
|
||||
cwd?: string;
|
||||
detached?: boolean;
|
||||
logFd?: number | null;
|
||||
};
|
||||
|
||||
export type ProcessSnapshot = {
|
||||
command: string;
|
||||
pid: number;
|
||||
ppid: number;
|
||||
};
|
||||
|
||||
export type StampedProcessMatchCriteria<TStamp extends ProcessStampShape> = Partial<TStamp>;
|
||||
|
||||
export type StopProcessesResult = {
|
||||
alreadyStopped: boolean;
|
||||
forcedPids: number[];
|
||||
matchedPids: number[];
|
||||
remainingPids: number[];
|
||||
stoppedPids: number[];
|
||||
};
|
||||
|
||||
export type HttpWaitOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type WindowsProcessRecord = {
|
||||
CommandLine?: string | null;
|
||||
ParentProcessId?: number | string | null;
|
||||
ProcessId?: number | string | null;
|
||||
};
|
||||
|
||||
export function createProcessStampArgs<TStamp extends ProcessStampShape>(
|
||||
stamp: TStamp,
|
||||
contract: ProcessStampContract<TStamp>,
|
||||
): string[] {
|
||||
const normalized = contract.normalizeStamp(stamp);
|
||||
return contract.stampFields.map((field) => {
|
||||
const value = normalized[field];
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`process stamp field ${field} must normalize to a string`);
|
||||
}
|
||||
return `${contract.stampFlags[field]}=${value}`;
|
||||
});
|
||||
}
|
||||
|
||||
function commandArgs(command: string): string[] {
|
||||
return command.trim().split(/\s+/).filter((part) => part.length > 0);
|
||||
}
|
||||
|
||||
export function readFlagValue(args: readonly string[], flagName: string): string | null {
|
||||
const inlinePrefix = `${flagName}=`;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const argument = args[index];
|
||||
if (argument === flagName) return args[index + 1] ?? null;
|
||||
if (typeof argument === "string" && argument.startsWith(inlinePrefix)) {
|
||||
return argument.slice(inlinePrefix.length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readProcessStamp<TStamp extends ProcessStampShape>(
|
||||
args: readonly string[],
|
||||
contract: ProcessStampContract<TStamp>,
|
||||
): TStamp | null {
|
||||
try {
|
||||
const input = Object.fromEntries(
|
||||
contract.stampFields.map((field) => [field, readFlagValue(args, contract.stampFlags[field])]),
|
||||
);
|
||||
return contract.normalizeStamp(input);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readProcessStampFromCommand<TStamp extends ProcessStampShape>(
|
||||
command: string,
|
||||
contract: ProcessStampContract<TStamp>,
|
||||
): TStamp | null {
|
||||
return readProcessStamp(commandArgs(command), contract);
|
||||
}
|
||||
|
||||
export function matchesProcessStamp<TStamp extends ProcessStampShape, TCriteria extends Partial<TStamp> = Partial<TStamp>>(
|
||||
stamp: TStamp,
|
||||
criteria: TCriteria | undefined,
|
||||
contract: ProcessStampContract<TStamp, TCriteria>,
|
||||
): boolean {
|
||||
const normalizedStamp = contract.normalizeStamp(stamp);
|
||||
const normalizedCriteria = contract.normalizeStampCriteria(criteria ?? {});
|
||||
return contract.stampFields.every((field) => {
|
||||
const expected = normalizedCriteria[field as keyof TCriteria];
|
||||
return expected == null || normalizedStamp[field] === expected;
|
||||
});
|
||||
}
|
||||
|
||||
export function matchesStampedProcess<TStamp extends ProcessStampShape, TCriteria extends Partial<TStamp> = Partial<TStamp>>(
|
||||
processInfo: Pick<ProcessSnapshot, "command">,
|
||||
criteria: TCriteria | undefined,
|
||||
contract: ProcessStampContract<TStamp, TCriteria>,
|
||||
): boolean {
|
||||
const stamp = readProcessStampFromCommand(processInfo.command, contract);
|
||||
return stamp != null && matchesProcessStamp(stamp, criteria, contract);
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | null {
|
||||
if (typeof error !== "object" || error == null || !("code" in error)) return null;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return code == null ? null : String(code);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
// `cmd.exe /s /c "..."` runs percent-expansion on the inner line *regardless*
|
||||
// of whether the `%name%` pair sits inside a `"..."` quoted segment, so a
|
||||
// `.cmd` / `.bat` shim spawn with an attacker-influenced argv (e.g. an LLM
|
||||
// adapter that ships the user prompt as a positional argument) lets a stray
|
||||
// `%DEEPSEEK_API_KEY%` substring substitute live env values into the line
|
||||
// before the child sees it. Plain quote-doubling is not enough on its own.
|
||||
//
|
||||
// The fix is to break each potential `%var%` pair by toggling out of the
|
||||
// outer quote with `"^%"`: cmd treats the `^` as the standard escape for the
|
||||
// next char (here, `%`), making it literal and skipping percent-expansion;
|
||||
// `CommandLineToArgvW` then concatenates the surrounding quote segments back
|
||||
// into one literal arg with the `%` preserved. The two layers cancel, so the
|
||||
// child receives the original arg byte-for-byte while cmd never has a chance
|
||||
// to expand anything inside it.
|
||||
function quoteWindowsCommandArg(value: string): string {
|
||||
if (!/[\s"&<>|^%]/.test(value)) return value;
|
||||
const escaped = value.replace(/"/g, '""').replace(/%/g, '"^%"');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
// Build the `cmd.exe /d /s /c "<line>"` invocation Node uses internally for
|
||||
// `shell: true`. The outer `"..."` plus `windowsVerbatimArguments: true` is
|
||||
// the only shape that survives both layers of quoting:
|
||||
//
|
||||
// 1. Node would otherwise escape each argv element with CommandLineToArgvW
|
||||
// rules (turning `"path with space"` into `\"path with space\"`), which
|
||||
// cmd.exe does not understand.
|
||||
// 2. cmd.exe with `/s /c` strips exactly one leading and one trailing `"`
|
||||
// from the rest of the command line. The outer wrap absorbs that strip
|
||||
// so any inner per-arg quoting stays intact.
|
||||
//
|
||||
// Without this, paths containing spaces (`C:\Users\First Last\...\foo.cmd`)
|
||||
// get split on the first space and cmd.exe reports "not recognized as an
|
||||
// internal or external command" — see issue #315.
|
||||
function buildCmdShimInvocation(command: string, args: string[], env: NodeJS.ProcessEnv): CommandInvocation {
|
||||
const inner = [command, ...args].map(quoteWindowsCommandArg).join(" ");
|
||||
return {
|
||||
args: ["/d", "/s", "/c", `"${inner}"`],
|
||||
command: env.ComSpec ?? process.env.ComSpec ?? "cmd.exe",
|
||||
windowsVerbatimArguments: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCommandInvocation({ args = [], command, env = process.env }: CommandInvocationRequest): CommandInvocation {
|
||||
if (process.platform === "win32" && /\.(bat|cmd)$/i.test(command)) {
|
||||
return buildCmdShimInvocation(command, args, env);
|
||||
}
|
||||
return { args, command };
|
||||
}
|
||||
|
||||
export function createPackageManagerInvocation(args: string[], env: NodeJS.ProcessEnv = process.env): CommandInvocation {
|
||||
const execPath = env.npm_execpath;
|
||||
if (execPath) return { args: [execPath, ...args], command: process.execPath };
|
||||
if (process.platform === "win32") {
|
||||
return buildCmdShimInvocation("pnpm", args, env);
|
||||
}
|
||||
return { args, command: "pnpm" };
|
||||
}
|
||||
|
||||
function createLoggedStdio(logFd?: number | null): StdioOptions {
|
||||
return logFd == null ? ["ignore", "ignore", "ignore"] : ["ignore", logFd, logFd];
|
||||
}
|
||||
|
||||
async function waitForChildSpawn(child: ChildProcess): Promise<void> {
|
||||
await new Promise<void>((resolveSpawn, rejectSpawn) => {
|
||||
child.once("error", rejectSpawn);
|
||||
child.once("spawn", resolveSpawn);
|
||||
});
|
||||
}
|
||||
|
||||
export async function spawnBackgroundProcess(request: SpawnProcessRequest): Promise<{ pid: number }> {
|
||||
const invocation = createCommandInvocation(request);
|
||||
const child = spawn(invocation.command, invocation.args, {
|
||||
cwd: request.cwd,
|
||||
detached: request.detached ?? true,
|
||||
env: request.env,
|
||||
stdio: createLoggedStdio(request.logFd),
|
||||
windowsHide: process.platform === "win32",
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
});
|
||||
await waitForChildSpawn(child);
|
||||
if (child.pid == null) throw new Error(`failed to spawn background process: ${invocation.command}`);
|
||||
child.unref();
|
||||
return { pid: child.pid };
|
||||
}
|
||||
|
||||
export async function spawnLoggedProcess(request: SpawnProcessRequest): Promise<ChildProcess> {
|
||||
const invocation = createCommandInvocation(request);
|
||||
const child = spawn(invocation.command, invocation.args, {
|
||||
cwd: request.cwd,
|
||||
detached: request.detached ?? false,
|
||||
env: request.env,
|
||||
stdio: createLoggedStdio(request.logFd),
|
||||
windowsHide: process.platform === "win32",
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
});
|
||||
await waitForChildSpawn(child);
|
||||
if (child.pid == null) throw new Error(`failed to spawn process: ${invocation.command}`);
|
||||
return child;
|
||||
}
|
||||
|
||||
export function isProcessAlive(pid: number | null | undefined): boolean {
|
||||
if (typeof pid !== "number") return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ESRCH") return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForProcessExit(pid: number | null | undefined, timeoutMs = 5000): Promise<boolean> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (!isProcessAlive(pid)) return true;
|
||||
await sleep(100);
|
||||
}
|
||||
return !isProcessAlive(pid);
|
||||
}
|
||||
|
||||
function parsePsOutput(stdout: string): ProcessSnapshot[] {
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => {
|
||||
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/);
|
||||
if (!match) return null;
|
||||
return { pid: Number(match[1]), ppid: Number(match[2]), command: match[3] };
|
||||
})
|
||||
.filter((snapshot): snapshot is ProcessSnapshot => snapshot != null);
|
||||
}
|
||||
|
||||
async function listPosixProcessSnapshots(): Promise<ProcessSnapshot[]> {
|
||||
const stdout = await new Promise<string>((resolveList, rejectList) => {
|
||||
execFile("ps", ["-axo", "pid=,ppid=,command="], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }, (error, out) => {
|
||||
if (error) rejectList(error);
|
||||
else resolveList(out);
|
||||
});
|
||||
});
|
||||
return parsePsOutput(stdout);
|
||||
}
|
||||
|
||||
async function listWindowsProcessSnapshots(): Promise<ProcessSnapshot[]> {
|
||||
const command = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
"Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, CommandLine | ConvertTo-Json -Compress",
|
||||
].join("; ");
|
||||
const stdout = await new Promise<string>((resolveList, rejectList) => {
|
||||
execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }, (error, out) => {
|
||||
if (error) rejectList(error);
|
||||
else resolveList(out);
|
||||
});
|
||||
});
|
||||
const payload = stdout.trim();
|
||||
if (!payload) return [];
|
||||
const records = JSON.parse(payload) as WindowsProcessRecord | WindowsProcessRecord[];
|
||||
return (Array.isArray(records) ? records : [records])
|
||||
.map((record) => {
|
||||
const pid = Number(record.ProcessId);
|
||||
const ppid = Number(record.ParentProcessId);
|
||||
const commandLine = record.CommandLine?.trim();
|
||||
if (!commandLine || Number.isNaN(pid) || Number.isNaN(ppid)) return null;
|
||||
return { command: commandLine, pid, ppid };
|
||||
})
|
||||
.filter((snapshot): snapshot is ProcessSnapshot => snapshot != null);
|
||||
}
|
||||
|
||||
export async function listProcessSnapshots(): Promise<ProcessSnapshot[]> {
|
||||
try {
|
||||
return process.platform === "win32"
|
||||
? await listWindowsProcessSnapshots()
|
||||
: await listPosixProcessSnapshots();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function collectProcessTreePids(
|
||||
processes: ProcessSnapshot[],
|
||||
rootPids: Array<number | null | undefined>,
|
||||
): number[] {
|
||||
const queue = [...new Set(rootPids.filter((pid): pid is number => typeof pid === "number"))];
|
||||
const visited = new Set<number>();
|
||||
const childrenByParent = new Map<number, number[]>();
|
||||
for (const processInfo of processes) {
|
||||
const children = childrenByParent.get(processInfo.ppid) ?? [];
|
||||
children.push(processInfo.pid);
|
||||
childrenByParent.set(processInfo.ppid, children);
|
||||
}
|
||||
while (queue.length > 0) {
|
||||
const pid = queue.shift();
|
||||
if (pid == null || visited.has(pid)) continue;
|
||||
visited.add(pid);
|
||||
for (const childPid of childrenByParent.get(pid) ?? []) {
|
||||
if (!visited.has(childPid)) queue.push(childPid);
|
||||
}
|
||||
}
|
||||
return [...visited].sort((left, right) => right - left);
|
||||
}
|
||||
|
||||
function signalProcesses(pids: number[], signal: NodeJS.Signals): void {
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch (error) {
|
||||
if (errorCode(error) !== "ESRCH") throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessesToExit(pids: number[], timeoutMs = 5000): Promise<number[]> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const remaining = pids.filter(isProcessAlive);
|
||||
if (remaining.length === 0) return [];
|
||||
await sleep(100);
|
||||
}
|
||||
return pids.filter(isProcessAlive);
|
||||
}
|
||||
|
||||
export async function stopProcesses(pids: Array<number | null | undefined>): Promise<StopProcessesResult> {
|
||||
const uniquePids = [...new Set(pids)]
|
||||
.filter((pid): pid is number => typeof pid === "number" && pid !== process.pid)
|
||||
.sort((left, right) => right - left);
|
||||
if (uniquePids.length === 0) {
|
||||
return { alreadyStopped: true, forcedPids: [], matchedPids: [], remainingPids: [], stoppedPids: [] };
|
||||
}
|
||||
signalProcesses(uniquePids, "SIGTERM");
|
||||
const remainingAfterTerm = await waitForProcessesToExit(uniquePids);
|
||||
if (remainingAfterTerm.length === 0) {
|
||||
return { alreadyStopped: false, forcedPids: [], matchedPids: uniquePids, remainingPids: [], stoppedPids: uniquePids };
|
||||
}
|
||||
signalProcesses(remainingAfterTerm, "SIGKILL");
|
||||
const remainingAfterKill = await waitForProcessesToExit(remainingAfterTerm);
|
||||
const stoppedPids = uniquePids.filter((pid) => !remainingAfterKill.includes(pid));
|
||||
return { alreadyStopped: false, forcedPids: remainingAfterTerm, matchedPids: uniquePids, remainingPids: remainingAfterKill, stoppedPids };
|
||||
}
|
||||
|
||||
export async function waitForHttpOk(url: string, { timeoutMs = 20000 }: HttpWaitOptions = {}): Promise<true> {
|
||||
const startedAt = Date.now();
|
||||
let lastError: Error | null = null;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (response.ok) return true;
|
||||
lastError = new Error(`HTTP ${response.status} from ${url}`);
|
||||
} catch (error) {
|
||||
lastError = new Error(errorMessage(error));
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
throw new Error(`timed out waiting for ${url}${lastError ? ` (${lastError.message})` : ""}`);
|
||||
}
|
||||
|
||||
export async function readLogTail(filePath: string, maxLines = 80): Promise<string[]> {
|
||||
try {
|
||||
const payload = await readFile(filePath, "utf8");
|
||||
return payload.split(/\r?\n/).filter((line) => line.length > 0).slice(-maxLines);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export type WellKnownUserToolchainOptions = {
|
||||
// Override homedir() so callers in sandboxed tests or namespaced launches
|
||||
// can substitute a fixture directory. Falls back to os.homedir().
|
||||
home?: string;
|
||||
// Include /opt/homebrew/bin and /usr/local/bin in the result. Defaults to
|
||||
// true on POSIX so GUI-launched processes (which inherit a minimal PATH
|
||||
// from launchd / desktop launchers) still see Homebrew-installed CLIs;
|
||||
// defaults to false on Windows because those paths are POSIX-only.
|
||||
includeSystemBins?: boolean;
|
||||
// Read $NPM_CONFIG_PREFIX / $npm_config_prefix from this map and append
|
||||
// `<prefix>/bin` if defined. Defaults to process.env so user-customised
|
||||
// npm prefixes are picked up automatically. Pass an empty object to
|
||||
// suppress lookup (useful in tests).
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
// Single source of truth for "user-level CLI install locations the daemon
|
||||
// must search even when launched with a minimal PATH". GUI launchers
|
||||
// (macOS .app bundles, Linux .desktop files) typically inherit a stripped
|
||||
// PATH from launchd / the desktop session and do not read interactive
|
||||
// shell rc files, so without this list any CLI installed under the user's
|
||||
// own toolchain (`npm i -g`, `pnpm self-install`, `cargo install`, asdf,
|
||||
// nvm, fnm, mise, ...) is silently undetected. Both the daemon resolver
|
||||
// and the packaged sidecar PATH builder consume this so the two layers
|
||||
// can never drift again.
|
||||
export function wellKnownUserToolchainBins(
|
||||
options: WellKnownUserToolchainOptions = {},
|
||||
): string[] {
|
||||
const home = options.home ?? homedir();
|
||||
const includeSystemBins = options.includeSystemBins ?? process.platform !== "win32";
|
||||
const env = options.env ?? process.env;
|
||||
const dirs: string[] = [];
|
||||
// The user's *explicit* npm prefix outranks every conventional
|
||||
// location below — including `~/.local/bin`. The env var is the
|
||||
// user's current npm configuration, so a binary installed via
|
||||
// `npm i -g` today lives at `<prefix>/bin`. Conventional locations
|
||||
// (`~/.local/bin`, `~/.npm-global`, `~/.npm-packages`) routinely
|
||||
// hold *stale* installs from an older prefix the user has since
|
||||
// rewritten, and `~/.local/bin` in particular is also a shared
|
||||
// dumping ground for pip --user / cargo install / hand-built
|
||||
// binaries that may collide with old npm artefacts. Putting the
|
||||
// env-driven prefix first matches npm's own resolution order
|
||||
// (env > .npmrc > default) and gives "explicit beats convention"
|
||||
// semantics across the whole list, not just the npm-prefix block.
|
||||
// Trim before length-checking so accidental whitespace-only values
|
||||
// (`NPM_CONFIG_PREFIX=" "`) do not produce a `/bin`-suffixed garbage
|
||||
// entry.
|
||||
const npmPrefixRaw = env.NPM_CONFIG_PREFIX ?? env.npm_config_prefix;
|
||||
if (typeof npmPrefixRaw === "string") {
|
||||
const npmPrefix = npmPrefixRaw.trim();
|
||||
if (npmPrefix.length > 0) {
|
||||
dirs.push(join(npmPrefix, "bin"));
|
||||
}
|
||||
}
|
||||
dirs.push(
|
||||
join(home, ".local", "bin"),
|
||||
join(home, ".opencode", "bin"),
|
||||
join(home, ".bun", "bin"),
|
||||
join(home, ".volta", "bin"),
|
||||
join(home, ".asdf", "shims"),
|
||||
join(home, "Library", "pnpm"),
|
||||
join(home, ".cargo", "bin"),
|
||||
// Common user-level npm prefixes for sudo-free global installs.
|
||||
// ~/.npm-global is the dominant non-canonical convention shipped
|
||||
// in most third-party "fix npm EACCES" tutorials, and
|
||||
// ~/.npm-packages is the second-most common variant. Without
|
||||
// these, GUI-launched daemons miss `npm i -g`'d CLIs even though
|
||||
// they resolve cleanly from the user's shell. See open-design
|
||||
// issue #442.
|
||||
join(home, ".npm-global", "bin"),
|
||||
join(home, ".npm-packages", "bin"),
|
||||
);
|
||||
if (includeSystemBins) {
|
||||
dirs.push("/opt/homebrew/bin", "/usr/local/bin");
|
||||
}
|
||||
// Per-version Node toolchains: scan the install root and surface every
|
||||
// version directory's bin folder. Best-effort — missing roots simply
|
||||
// contribute nothing.
|
||||
for (const installRoot of [
|
||||
{
|
||||
root: join(home, ".local", "share", "mise", "installs", "node"),
|
||||
segments: ["bin"],
|
||||
},
|
||||
{
|
||||
root: join(home, ".nvm", "versions", "node"),
|
||||
segments: ["bin"],
|
||||
},
|
||||
{
|
||||
root: join(home, ".local", "share", "fnm", "node-versions"),
|
||||
segments: ["installation", "bin"],
|
||||
},
|
||||
]) {
|
||||
for (const dir of existingChildBinDirs(installRoot.root, installRoot.segments)) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function existingChildBinDirs(root: string, segments: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
let entries: import("node:fs").Dirent<string>[];
|
||||
try {
|
||||
entries = readdirSync(root, { encoding: "utf8", withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const candidate = join(root, entry.name, ...segments);
|
||||
if (existsSync(candidate)) out.push(candidate);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
498
packages/platform/tests/index.test.ts
Normal file
498
packages/platform/tests/index.test.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createCommandInvocation,
|
||||
createPackageManagerInvocation,
|
||||
createProcessStampArgs,
|
||||
matchesStampedProcess,
|
||||
readProcessStampFromCommand,
|
||||
wellKnownUserToolchainBins,
|
||||
type ProcessStampContract,
|
||||
} from "../src/index.js";
|
||||
|
||||
type FakeStamp = {
|
||||
app: "api" | "ui";
|
||||
ipc: string;
|
||||
mode: "dev" | "runtime";
|
||||
namespace: string;
|
||||
source: "tool" | "pack";
|
||||
};
|
||||
|
||||
const fakeContract: ProcessStampContract<FakeStamp> = {
|
||||
stampFields: ["app", "mode", "namespace", "ipc", "source"],
|
||||
stampFlags: {
|
||||
app: "--fake-app",
|
||||
ipc: "--fake-ipc",
|
||||
mode: "--fake-mode",
|
||||
namespace: "--fake-namespace",
|
||||
source: "--fake-source",
|
||||
},
|
||||
normalizeStamp(input) {
|
||||
const value = input as Partial<FakeStamp>;
|
||||
if (value.app !== "api" && value.app !== "ui") throw new Error("invalid app");
|
||||
if (value.mode !== "dev" && value.mode !== "runtime") throw new Error("invalid mode");
|
||||
if (typeof value.namespace !== "string" || value.namespace.length === 0) throw new Error("invalid namespace");
|
||||
if (typeof value.ipc !== "string" || value.ipc.length === 0) throw new Error("invalid ipc");
|
||||
if (value.source !== "tool" && value.source !== "pack") throw new Error("invalid source");
|
||||
return {
|
||||
app: value.app,
|
||||
ipc: value.ipc,
|
||||
mode: value.mode,
|
||||
namespace: value.namespace,
|
||||
source: value.source,
|
||||
};
|
||||
},
|
||||
normalizeStampCriteria(input = {}) {
|
||||
const value = input as Partial<FakeStamp>;
|
||||
return {
|
||||
...(value.app == null ? {} : { app: value.app }),
|
||||
...(value.ipc == null ? {} : { ipc: value.ipc }),
|
||||
...(value.mode == null ? {} : { mode: value.mode }),
|
||||
...(value.namespace == null ? {} : { namespace: value.namespace }),
|
||||
...(value.source == null ? {} : { source: value.source }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const stamp: FakeStamp = {
|
||||
app: "ui",
|
||||
ipc: "/tmp/fake-product/ipc/stamp-boundary-a/ui.sock",
|
||||
mode: "dev",
|
||||
namespace: "stamp-boundary-a",
|
||||
source: "tool",
|
||||
};
|
||||
|
||||
describe("generic process stamp primitives", () => {
|
||||
it("serializes descriptor-defined stamp flags", () => {
|
||||
const args = createProcessStampArgs(stamp, fakeContract);
|
||||
|
||||
expect(args).toHaveLength(5);
|
||||
expect(args.join(" ")).toContain("--fake-app=ui");
|
||||
expect(args.join(" ")).toContain("--fake-mode=dev");
|
||||
expect(args.join(" ")).toContain("--fake-namespace=stamp-boundary-a");
|
||||
expect(args.join(" ")).toContain("--fake-ipc=/tmp/fake-product/ipc/stamp-boundary-a/ui.sock");
|
||||
expect(args.join(" ")).toContain("--fake-source=tool");
|
||||
});
|
||||
|
||||
it("reads and matches stamped process commands using the descriptor", () => {
|
||||
const command = ["node", "ui.js", ...createProcessStampArgs(stamp, fakeContract)].join(" ");
|
||||
|
||||
expect(readProcessStampFromCommand(command, fakeContract)).toEqual(stamp);
|
||||
expect(matchesStampedProcess({ command }, { app: "ui", namespace: stamp.namespace, source: "tool" }, fakeContract)).toBe(true);
|
||||
expect(matchesStampedProcess({ command }, { namespace: "stamp-boundary-b" }, fakeContract)).toBe(false);
|
||||
expect(matchesStampedProcess({ command }, { source: "pack" }, fakeContract)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// `createCommandInvocation` makes a platform-conditional choice based on
|
||||
// `process.platform`. These tests stub it both ways so we exercise the
|
||||
// Windows .cmd / .bat shim path on every CI runner, not just Windows.
|
||||
describe("createCommandInvocation", () => {
|
||||
const originalPlatform = process.platform;
|
||||
function setPlatform(value: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value });
|
||||
}
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
|
||||
});
|
||||
|
||||
it("returns the raw command and args unchanged on POSIX", () => {
|
||||
setPlatform("linux");
|
||||
const invocation = createCommandInvocation({
|
||||
command: "/usr/local/bin/codex",
|
||||
args: ["--help"],
|
||||
});
|
||||
expect(invocation).toEqual({
|
||||
args: ["--help"],
|
||||
command: "/usr/local/bin/codex",
|
||||
});
|
||||
expect(invocation.windowsVerbatimArguments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the raw command and args unchanged on Windows for non-shim binaries", () => {
|
||||
setPlatform("win32");
|
||||
const invocation = createCommandInvocation({
|
||||
command: "C:\\Program Files\\node\\node.exe",
|
||||
args: ["script.js"],
|
||||
});
|
||||
expect(invocation).toEqual({
|
||||
args: ["script.js"],
|
||||
command: "C:\\Program Files\\node\\node.exe",
|
||||
});
|
||||
expect(invocation.windowsVerbatimArguments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("wraps a Windows .CMD shim through cmd.exe with verbatim arguments", () => {
|
||||
setPlatform("win32");
|
||||
const invocation = createCommandInvocation({
|
||||
command: "C:\\Users\\Ethical Byte\\AppData\\Local\\Programs\\nodejs\\codex.CMD",
|
||||
args: ["--version"],
|
||||
env: { ComSpec: "C:\\Windows\\System32\\cmd.exe" } as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(invocation.command).toBe("C:\\Windows\\System32\\cmd.exe");
|
||||
expect(invocation.windowsVerbatimArguments).toBe(true);
|
||||
// Critical: the inner command line is wrapped in extra `"…"` so that
|
||||
// cmd.exe's `/s /c` quote-stripping (strip first + last `"`) leaves the
|
||||
// path quoting intact. Without the outer wrap, `Ethical Byte` gets
|
||||
// split on the space and cmd reports "not recognized" (issue #315).
|
||||
expect(invocation.args).toEqual([
|
||||
"/d",
|
||||
"/s",
|
||||
"/c",
|
||||
'""C:\\Users\\Ethical Byte\\AppData\\Local\\Programs\\nodejs\\codex.CMD" --version"',
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats .bat shims the same as .cmd shims", () => {
|
||||
setPlatform("win32");
|
||||
const invocation = createCommandInvocation({
|
||||
command: "C:\\tools\\bin\\my tool.bat",
|
||||
args: [],
|
||||
env: { ComSpec: "cmd.exe" } as NodeJS.ProcessEnv,
|
||||
});
|
||||
expect(invocation.windowsVerbatimArguments).toBe(true);
|
||||
expect(invocation.args).toEqual(["/d", "/s", "/c", '""C:\\tools\\bin\\my tool.bat""']);
|
||||
});
|
||||
|
||||
it("quotes argv elements containing spaces alongside the shim path", () => {
|
||||
setPlatform("win32");
|
||||
const invocation = createCommandInvocation({
|
||||
command: "C:\\Users\\First Last\\codex.cmd",
|
||||
args: ["--cwd", "C:\\Some Path\\proj", "exec", "echo hi"],
|
||||
env: { ComSpec: "cmd.exe" } as NodeJS.ProcessEnv,
|
||||
});
|
||||
// After the outer wrap and `/s /c` stripping, cmd will see:
|
||||
// "C:\Users\First Last\codex.cmd" --cwd "C:\Some Path\proj" exec "echo hi"
|
||||
expect(invocation.args).toEqual([
|
||||
"/d",
|
||||
"/s",
|
||||
"/c",
|
||||
'""C:\\Users\\First Last\\codex.cmd" --cwd "C:\\Some Path\\proj" exec "echo hi""',
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not quote argv elements without whitespace or shell metacharacters", () => {
|
||||
setPlatform("win32");
|
||||
const invocation = createCommandInvocation({
|
||||
command: "codex.cmd",
|
||||
args: ["--model", "claude-opus-4", "--max-tokens=4096"],
|
||||
env: { ComSpec: "cmd.exe" } as NodeJS.ProcessEnv,
|
||||
});
|
||||
expect(invocation.args).toEqual([
|
||||
"/d",
|
||||
"/s",
|
||||
"/c",
|
||||
'"codex.cmd --model claude-opus-4 --max-tokens=4096"',
|
||||
]);
|
||||
});
|
||||
|
||||
// cmd.exe runs percent-expansion on the inner command line of `cmd /s /c
|
||||
// "..."` regardless of inner quote state, so a `.cmd` shim spawn whose
|
||||
// argv carries an attacker-influenced `%DEEPSEEK_API_KEY%` substring would
|
||||
// otherwise have the daemon environment substituted into the child's
|
||||
// command line before the child saw the prompt. Pin that the constructed
|
||||
// invocation breaks every potential `%var%` pair with `"^%"` so cmd has no
|
||||
// chance to expand it, while `CommandLineToArgvW` still concatenates the
|
||||
// surrounding quote segments back into the original arg.
|
||||
it("escapes %var% sequences in argv so cmd.exe cannot expand them on a .cmd shim", () => {
|
||||
setPlatform("win32");
|
||||
const invocation = createCommandInvocation({
|
||||
command: "C:\\Users\\Tester\\AppData\\Roaming\\npm\\deepseek.cmd",
|
||||
args: ["exec", "--auto", "write a function that reads %DEEPSEEK_API_KEY% from env"],
|
||||
env: { ComSpec: "cmd.exe" } as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(invocation.command).toBe("cmd.exe");
|
||||
expect(invocation.windowsVerbatimArguments).toBe(true);
|
||||
// The full inner line cmd.exe receives after `/s` strips its outer wrap.
|
||||
const innerLine = invocation.args[3];
|
||||
if (typeof innerLine !== "string") throw new Error("expected an inner cmd line");
|
||||
|
||||
// The literal `%DEEPSEEK_API_KEY%` pair must NOT survive intact in the
|
||||
// inner line — if it did, cmd would expand it before the child runs.
|
||||
expect(innerLine).not.toContain("%DEEPSEEK_API_KEY%");
|
||||
|
||||
// Each `%` must be wrapped in `"^%"` so cmd's `^` escape neutralizes the
|
||||
// percent and `CommandLineToArgvW` rejoins the quote segments. Two `%`
|
||||
// chars in the prompt → two escaped occurrences.
|
||||
const escapedOccurrences = innerLine.split('"^%"').length - 1;
|
||||
expect(escapedOccurrences).toBe(2);
|
||||
|
||||
// Sanity: the literal env-var name still appears (the prompt itself is
|
||||
// not corrupted, only the surrounding `%` are escaped).
|
||||
expect(innerLine).toContain("DEEPSEEK_API_KEY");
|
||||
});
|
||||
|
||||
it("does not perturb argv quoting when no %var% sequence is present", () => {
|
||||
setPlatform("win32");
|
||||
const invocation = createCommandInvocation({
|
||||
command: "deepseek.cmd",
|
||||
args: ["exec", "--auto", "write hello world"],
|
||||
env: { ComSpec: "cmd.exe" } as NodeJS.ProcessEnv,
|
||||
});
|
||||
// Pre-fix shape — adding the `%` escape must not change the line for
|
||||
// ordinary prompts that happen not to mention env-var names.
|
||||
expect(invocation.args).toEqual([
|
||||
"/d",
|
||||
"/s",
|
||||
"/c",
|
||||
'"deepseek.cmd exec --auto "write hello world""',
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to process.env.ComSpec when env override is absent", () => {
|
||||
setPlatform("win32");
|
||||
const original = process.env.ComSpec;
|
||||
process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe";
|
||||
try {
|
||||
const invocation = createCommandInvocation({
|
||||
command: "tool.cmd",
|
||||
args: [],
|
||||
});
|
||||
expect(invocation.command).toBe("C:\\Windows\\System32\\cmd.exe");
|
||||
} finally {
|
||||
if (original == null) delete process.env.ComSpec;
|
||||
else process.env.ComSpec = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPackageManagerInvocation", () => {
|
||||
const originalPlatform = process.platform;
|
||||
function setPlatform(value: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value });
|
||||
}
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
|
||||
});
|
||||
|
||||
it("uses npm_execpath via process.execPath when set, regardless of platform", () => {
|
||||
setPlatform("win32");
|
||||
const invocation = createPackageManagerInvocation(["install"], {
|
||||
npm_execpath: "C:\\Users\\u\\.nvm\\pnpm.cjs",
|
||||
} as NodeJS.ProcessEnv);
|
||||
expect(invocation.command).toBe(process.execPath);
|
||||
expect(invocation.args[0]).toBe("C:\\Users\\u\\.nvm\\pnpm.cjs");
|
||||
expect(invocation.args.slice(1)).toEqual(["install"]);
|
||||
expect(invocation.windowsVerbatimArguments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns plain pnpm invocation on POSIX without npm_execpath", () => {
|
||||
setPlatform("linux");
|
||||
const invocation = createPackageManagerInvocation(["install"], {} as NodeJS.ProcessEnv);
|
||||
expect(invocation).toEqual({ args: ["install"], command: "pnpm" });
|
||||
});
|
||||
|
||||
it("wraps pnpm through cmd.exe with verbatim arguments on Windows", () => {
|
||||
setPlatform("win32");
|
||||
const invocation = createPackageManagerInvocation(["--filter", "@open-design/desktop", "build"], {
|
||||
ComSpec: "cmd.exe",
|
||||
} as NodeJS.ProcessEnv);
|
||||
expect(invocation.command).toBe("cmd.exe");
|
||||
expect(invocation.windowsVerbatimArguments).toBe(true);
|
||||
expect(invocation.args).toEqual([
|
||||
"/d",
|
||||
"/s",
|
||||
"/c",
|
||||
'"pnpm --filter @open-design/desktop build"',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wellKnownUserToolchainBins", () => {
|
||||
// Filesystem-backed cases use a sandboxed home so we don't depend on the
|
||||
// real machine's toolchain layout. PATHEXT-style Windows quirks aren't
|
||||
// relevant here — the helper returns directories, not resolved binaries.
|
||||
it("returns the documented user-level CLI install locations under home", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "wkutb-home-"));
|
||||
try {
|
||||
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: false });
|
||||
expect(dirs).toContain(join(home, ".local", "bin"));
|
||||
expect(dirs).toContain(join(home, ".opencode", "bin"));
|
||||
expect(dirs).toContain(join(home, ".bun", "bin"));
|
||||
expect(dirs).toContain(join(home, ".volta", "bin"));
|
||||
expect(dirs).toContain(join(home, ".asdf", "shims"));
|
||||
expect(dirs).toContain(join(home, "Library", "pnpm"));
|
||||
expect(dirs).toContain(join(home, ".cargo", "bin"));
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Regression for #442. The two dominant non-canonical npm prefixes used
|
||||
// by sudo-free tutorials (~/.npm-global, ~/.npm-packages) must always
|
||||
// appear, otherwise GUI-launched daemons miss `npm i -g`'d CLIs.
|
||||
it("includes both ~/.npm-global/bin and ~/.npm-packages/bin (issue #442)", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "wkutb-npm-"));
|
||||
try {
|
||||
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: false });
|
||||
expect(dirs).toContain(join(home, ".npm-global", "bin"));
|
||||
expect(dirs).toContain(join(home, ".npm-packages", "bin"));
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("appends $NPM_CONFIG_PREFIX/bin when set so corporate prefixes resolve", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "wkutb-prefix-"));
|
||||
const customPrefix = mkdtempSync(join(tmpdir(), "wkutb-custom-"));
|
||||
try {
|
||||
const dirs = wellKnownUserToolchainBins({
|
||||
home,
|
||||
env: { NPM_CONFIG_PREFIX: customPrefix },
|
||||
includeSystemBins: false,
|
||||
});
|
||||
expect(dirs).toContain(join(customPrefix, "bin"));
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(customPrefix, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to lower-case npm_config_prefix when NPM_CONFIG_PREFIX is absent", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "wkutb-prefix-lc-"));
|
||||
const customPrefix = mkdtempSync(join(tmpdir(), "wkutb-custom-lc-"));
|
||||
try {
|
||||
const dirs = wellKnownUserToolchainBins({
|
||||
home,
|
||||
env: { npm_config_prefix: customPrefix },
|
||||
includeSystemBins: false,
|
||||
});
|
||||
expect(dirs).toContain(join(customPrefix, "bin"));
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(customPrefix, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not append a prefix entry when neither env var is set", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "wkutb-noprefix-"));
|
||||
try {
|
||||
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: false });
|
||||
// The bare `/bin` suffix would be ambiguous, but we can at least
|
||||
// confirm nothing equal to "/bin" leaked in from a `join(undefined,
|
||||
// "bin")`-style bug.
|
||||
expect(dirs).not.toContain("/bin");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// PR #614 review (mrcfps): npm's own resolution order is env > .npmrc
|
||||
// > default, so when the user has explicitly configured a prefix via
|
||||
// $NPM_CONFIG_PREFIX, that location holds the *current* `npm i -g`
|
||||
// installs and should outrank every conventional location below —
|
||||
// including ~/.local/bin (which is also a shared pip --user / cargo
|
||||
// install dumping ground). Conventional locations frequently retain
|
||||
// *stale* binaries from an older prefix.
|
||||
it("places $NPM_CONFIG_PREFIX/bin before every conventional location, including ~/.local/bin", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "wkutb-prefix-order-"));
|
||||
const customPrefix = mkdtempSync(join(tmpdir(), "wkutb-custom-order-"));
|
||||
try {
|
||||
const dirs = wellKnownUserToolchainBins({
|
||||
home,
|
||||
env: { NPM_CONFIG_PREFIX: customPrefix },
|
||||
includeSystemBins: false,
|
||||
});
|
||||
const explicitIdx = dirs.indexOf(join(customPrefix, "bin"));
|
||||
const localBinIdx = dirs.indexOf(join(home, ".local", "bin"));
|
||||
const npmGlobalIdx = dirs.indexOf(join(home, ".npm-global", "bin"));
|
||||
const npmPackagesIdx = dirs.indexOf(join(home, ".npm-packages", "bin"));
|
||||
// Explicit prefix must be present and ahead of every conventional
|
||||
// sibling. The first hit wins inside resolveOnPath() and the
|
||||
// packaged PATH builder, so this ordering propagates verbatim.
|
||||
expect(explicitIdx).toBe(0);
|
||||
expect(localBinIdx).toBeGreaterThan(explicitIdx);
|
||||
expect(npmGlobalIdx).toBeGreaterThan(explicitIdx);
|
||||
expect(npmPackagesIdx).toBeGreaterThan(explicitIdx);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(customPrefix, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores whitespace-only npm prefix values rather than emitting a `/bin` entry", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "wkutb-whitespace-prefix-"));
|
||||
try {
|
||||
const dirs = wellKnownUserToolchainBins({
|
||||
home,
|
||||
env: { NPM_CONFIG_PREFIX: " " },
|
||||
includeSystemBins: false,
|
||||
});
|
||||
// Whitespace-only must not produce a bogus `<whitespace>/bin` entry
|
||||
// nor a bare `/bin` (the join(" ", "bin") shape).
|
||||
for (const dir of dirs) {
|
||||
expect(dir.trim()).not.toBe("/bin");
|
||||
expect(dir).not.toMatch(/^\s+\/bin$/);
|
||||
}
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("includes /opt/homebrew/bin and /usr/local/bin when includeSystemBins is true", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "wkutb-sys-"));
|
||||
try {
|
||||
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: true });
|
||||
expect(dirs).toContain("/opt/homebrew/bin");
|
||||
expect(dirs).toContain("/usr/local/bin");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("omits /opt/homebrew/bin and /usr/local/bin when includeSystemBins is false", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "wkutb-nosys-"));
|
||||
try {
|
||||
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: false });
|
||||
expect(dirs).not.toContain("/opt/homebrew/bin");
|
||||
expect(dirs).not.toContain("/usr/local/bin");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("expands per-version Node toolchains for mise / nvm / fnm", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "wkutb-versioned-"));
|
||||
try {
|
||||
const miseBin = join(home, ".local", "share", "mise", "installs", "node", "24.14.1", "bin");
|
||||
const nvmBin = join(home, ".nvm", "versions", "node", "v22.10.0", "bin");
|
||||
const fnmBin = join(home, ".local", "share", "fnm", "node-versions", "v20.11.1", "installation", "bin");
|
||||
mkdirSync(miseBin, { recursive: true });
|
||||
mkdirSync(nvmBin, { recursive: true });
|
||||
mkdirSync(fnmBin, { recursive: true });
|
||||
writeFileSync(join(miseBin, "marker"), "");
|
||||
writeFileSync(join(nvmBin, "marker"), "");
|
||||
writeFileSync(join(fnmBin, "marker"), "");
|
||||
chmodSync(join(miseBin, "marker"), 0o644);
|
||||
chmodSync(join(nvmBin, "marker"), 0o644);
|
||||
chmodSync(join(fnmBin, "marker"), 0o644);
|
||||
|
||||
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: false });
|
||||
expect(dirs).toContain(miseBin);
|
||||
expect(dirs).toContain(nvmBin);
|
||||
expect(dirs).toContain(fnmBin);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an empty version slice when toolchain root is absent", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "wkutb-empty-"));
|
||||
try {
|
||||
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: false });
|
||||
// No mise/nvm/fnm directories were created — none of the per-version
|
||||
// bins should appear.
|
||||
expect(dirs.some((dir) => dir.includes(join(".nvm", "versions", "node")))).toBe(false);
|
||||
expect(dirs.some((dir) => dir.includes(join("fnm", "node-versions")))).toBe(false);
|
||||
expect(dirs.some((dir) => dir.includes(join("mise", "installs", "node")))).toBe(false);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
21
packages/platform/tsconfig.json
Normal file
21
packages/platform/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["ES2024"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"resolveJsonModule": true,
|
||||
"rootDir": "./src",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2024",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
9
packages/platform/tsconfig.tests.json
Normal file
9
packages/platform/tsconfig.tests.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"emitDeclarationOnly": false,
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user