/** * Registered tool definitions. * * Each definition declares an ordered strategy chain. Individual * strategies are responsible for validating their own resolved paths * (they use the injected `exists` from StrategyDeps), so tests can * inject fakes without triggering real `fs.existsSync` lookups. * * See change: consolidate-tool-resolution. */ import { existsSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { ToolDefinition, Source } from "./types.js"; import type { ToolRegistry } from "./registry.js"; import { type StrategyDeps, bareImportStrategy, managedBinStrategy, managedModuleStrategy, managedRuntimeStrategy, npmGlobalStrategy, overrideStrategy, whereStrategy, } from "./strategies.js"; import type { Strategy } from "./types.js"; // ── Classifier ────────────────────────────────────────────────────────────── /** Classifier: strategies → Source. Shared across binary and module tools. */ function classify(strategyName: string): Source { if (strategyName === "override") return "override"; if (strategyName === "managed") return "managed"; if (strategyName === "npm-global") return "npm-global"; if (strategyName === "bare-import") return "bare-import"; // `where` and anything else — resolved via PATH — classifies as system. return "system"; } // ── Binary definitions ────────────────────────────────────────────────────── function binaryDef(binaryName: string, deps?: StrategyDeps): ToolDefinition { // The `node` binary gets the managed-Node runtime strategy prepended // (after override) so the persistent /node/ install wins // over PATH lookup. See change: embed-managed-node-runtime. const isNode = binaryName === "node"; const strategies = [ overrideStrategy(binaryName, deps), ...(isNode ? [managedRuntimeStrategy("node", deps)] : []), managedBinStrategy(binaryName, deps), whereStrategy(binaryName, deps), ]; return { name: binaryName, kind: "binary", strategies, classify, }; } // ── Module definitions ────────────────────────────────────────────────────── /** Sibling probe for an aliased package name (pi: `@earendil-works/*` + `@mariozechner/*`). */ function moduleDefWithAliases( canonicalName: string, pkgNames: readonly string[], entry: string, deps?: StrategyDeps, ): ToolDefinition { const strategies = [overrideStrategy(canonicalName, deps)]; for (const pkg of pkgNames) strategies.push(bareImportStrategy(pkg)); for (const pkg of pkgNames) strategies.push(managedModuleStrategy(pkg, entry, deps)); for (const pkg of pkgNames) strategies.push(npmGlobalStrategy(pkg, entry, deps)); return { name: canonicalName, kind: "module", strategies, classify }; } // ── Build-time module definitions (electron, node-pty) ──────────────────── /** * Bare-import strategy that resolves `/package.json` and returns the * containing directory. Used for build-time tools whose useful artifact is * a sibling file of `package.json` (e.g. `electron/install.js`, * `node-pty/prebuilds/`). Mirrors the semantics that build-time consumers * (`publish.yml`, `Dockerfile.build`, `scripts/fix-pty-permissions.cjs`) * need — see change: register-build-time-tools. * * `searchPaths` are passed to Node's resolver as the `paths` option, * making the lookup work whether the package is hoisted to the repo root * or nested under a workspace. */ function bareImportPackageDirStrategy( pkgName: string, searchPaths?: readonly string[], deps?: StrategyDeps, ): Strategy { const fallbackResolve = (id: string, from: string): string | null => { try { if (searchPaths && searchPaths.length > 0) { const req = createRequire(from) as unknown as { resolve(id: string, opts?: { paths?: readonly string[] }): string; }; return req.resolve(id, { paths: searchPaths }); } return createRequire(from).resolve(id); } catch { return null; } }; const resolveModule = deps?.resolveModule ?? fallbackResolve; return { name: "bare-import", run() { const pkgJson = resolveModule(`${pkgName}/package.json`, import.meta.url) ?? findPackageJsonByDirWalk(pkgName, import.meta.url, searchPaths, deps?.exists); if (!pkgJson) { return { ok: false, reason: `cannot resolve ${pkgName} package directory` }; } return { ok: true, path: path.dirname(pkgJson) }; }, }; } /** * Helper: walks up from `fromUrl`'s directory looking for * `node_modules//package.json` directly on the filesystem. * * Exports-map-immune: required because both `@earendil-works/pi-coding-agent` * and `@fission-ai/openspec` declare `exports` blocks that omit * `./package.json`, so `createRequire(from).resolve("/package.json")` * returns `ERR_PACKAGE_PATH_NOT_EXPORTED` in modern Node. This walk is * a deliberate end-run around the resolver — we already know the file * we want and just need its absolute path. * * Honors the injected `exists` predicate so tests with mocked * filesystems stay deterministic; falls back to `existsSync` when * none is injected. * * See change: eliminate-electron-runtime-install (F9 follow-on). */ function findPackageJsonByDirWalk( pkgName: string, fromUrl: string, searchPaths?: readonly string[], exists?: StrategyDeps["exists"], ): string | null { const check = exists ?? existsSync; const candidates: string[] = []; try { candidates.push(path.dirname(fileURLToPath(fromUrl))); } catch { // fromUrl might not be a file: URL in synthetic test contexts. } for (const sp of searchPaths ?? []) candidates.push(sp); for (const start of candidates) { let dir = start; // Bound the walk: stop at filesystem root or once dirname is // unchanged. Defensive cap at 64 levels covers any plausible // workspace nesting without an infinite-loop risk on broken paths. for (let i = 0; i < 64; i += 1) { const candidate = path.join(dir, "node_modules", pkgName, "package.json"); if (check(candidate)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } } return null; } /** Module def that returns the package directory (containing package.json). */ function packageDirModuleDef( toolName: string, pkgName: string, options: { searchPaths?: readonly string[]; includeManaged?: boolean }, deps?: StrategyDeps, ): ToolDefinition { const strategies: Strategy[] = [ overrideStrategy(toolName, deps), bareImportPackageDirStrategy(pkgName, options.searchPaths, deps), ]; if (options.includeManaged) { strategies.push(managedModuleStrategy(pkgName, "package.json", deps)); } return { name: toolName, kind: "module", strategies, classify }; } // ── Registration ───────────────────────────────────────────────── // Tools intentionally NOT registered: // - `tsx` — a TypeScript *loader* used via `node --import tsx`, // not a tool the dashboard spawns. When pi is installed, pi ships // jiti which the server prefers; otherwise tsx is co-installed // as a dev dep of the server package. // - `pi-dashboard` — that's the package this code is part of. // "Is it installed" is a bootstrap concern handled directly in // `packages/electron/src/lib/dependency-detector.ts`. // // Build-time tools (see change: register-build-time-tools): // - `electron` — module, returns the package directory containing // `install.js`. Resolved with paths anchored at // `packages/electron` to handle hoisted vs. nested // layouts uniformly. // - `node-pty` — module, returns the package directory containing // `prebuilds/`. Standard module resolution suffices. // See change: consolidate-tool-resolution (follow-up). /** * Shared `toArgv` for Node-script executors (pi, openspec, npm). * * On Windows + `.js` resolved path → prepend node.exe to bypass the * `.cmd` shim entirely (no cmd.exe in the spawn chain → no console * flash). Elsewhere → direct invocation. * * This is the heart of the "no cmd flash" story: every CLI that ships * as `.cmd` on Windows and is actually a Node script should be * registered with this `toArgv` so the spawn becomes * `node.exe ` (pure console-subsystem inherit, no new * window ever). * * Node resolution order on Windows (see change: * fix-windows-standalone-spawn): * 1. `registry.resolve("node")` when it returns ok with a non-null * path (the strategy chain has already validated existence via * its injected `exists` dep). * 2. `process.execPath` — the dashboard server's own Node — as a * guaranteed-working fallback. Live repro: Windows 11 standalone * install where the registry chain failed to find node and the * spawn argv became `[cli.js]` → `spawn EFTYPE`. Falling back to * execPath keeps the spawn argv well-formed because the dashboard * server is itself running on a compatible Node. */ const nodeScriptToArgv: ToolDefinition["toArgv"] = (resolvedPath, { platform, registry }) => { if (platform === "win32" && /\.js$/i.test(resolvedPath)) { const node = registry.resolve("node"); if (node.ok && node.path) return [node.path, resolvedPath]; return [process.execPath, resolvedPath]; } return [resolvedPath]; }; /** * Executor definition for `pi` — ONE tool, OS dispatch inside. * * On Windows, the strategy chain finds pi-coding-agent's `dist/cli.js` * (managed → bare-import → npm-global), and `toArgv` wraps it with * `node.exe` to produce `[node.exe, cli.js]`. Falls back to `pi.cmd` * on PATH when the cli.js is nowhere to be found. * * On Unix, the chain first tries `bare-import` so a bundled * `/node_modules/@earendil-works/pi-coding-agent/dist/cli.js` * wins over a system install. This is load-bearing for the Electron * immutable-bundle architecture (see openspec change * `eliminate-electron-runtime-install` finding F9). On a clean machine * with no system `pi` and no managed `~/.pi-dashboard/node/bin/`, * bare-import resolves the bundled cli.js (`#!/usr/bin/env node` * shebang, executable) and `nodeScriptToArgv` returns `[cli.js]` * directly. Without this strategy, the server falls into * `bootstrapInstall(...)` and writes to `~/.pi-dashboard/` — the * exact failure mode the immutable-bundle architecture eliminates. */ function piExecutorDef(deps?: StrategyDeps): ToolDefinition { const piPkgAliases = ["@earendil-works/pi-coding-agent", "@mariozechner/pi-coding-agent"]; const cliEntry = path.join("dist", "cli.js"); const winStrategies = [ overrideStrategy("pi", deps), ...piPkgAliases.map((pkg) => bareImportCliStrategy(pkg, cliEntry, deps)), ...piPkgAliases.map((pkg) => managedModuleStrategy(pkg, cliEntry, deps)), ...piPkgAliases.map((pkg) => npmGlobalStrategy(pkg, cliEntry, deps)), managedBinStrategy("pi", deps), whereStrategy("pi", deps), ]; const unixStrategies = [ overrideStrategy("pi", deps), ...piPkgAliases.map((pkg) => bareImportCliStrategy(pkg, cliEntry, deps)), managedBinStrategy("pi", deps), whereStrategy("pi", deps), ]; return { name: "pi", kind: "executor", strategies: unixStrategies, platformStrategies: { win32: winStrategies }, toArgv: nodeScriptToArgv, classify, }; } /** * Executor definition for `openspec`. * * On Windows: finds `@fission-ai/openspec/bin/openspec.js` via managed * → bare-import → npm-global. `toArgv` wraps with node.exe. * On Unix: tries bare-import first (bundled * `/node_modules/@fission-ai/openspec/bin/openspec.js`), then * managed-bin, then PATH. Symmetric with pi; same Electron * immutable-bundle rationale (F9). */ function openspecExecutorDef(deps?: StrategyDeps): ToolDefinition { const pkgName = "@fission-ai/openspec"; const cliEntry = path.join("bin", "openspec.js"); const winStrategies = [ overrideStrategy("openspec", deps), bareImportCliStrategy(pkgName, cliEntry, deps), managedModuleStrategy(pkgName, cliEntry, deps), npmGlobalStrategy(pkgName, cliEntry, deps), managedBinStrategy("openspec", deps), whereStrategy("openspec", deps), ]; const unixStrategies = [ overrideStrategy("openspec", deps), bareImportCliStrategy(pkgName, cliEntry, deps), managedBinStrategy("openspec", deps), whereStrategy("openspec", deps), ]; return { name: "openspec", kind: "executor", strategies: unixStrategies, platformStrategies: { win32: winStrategies }, toArgv: nodeScriptToArgv, classify, }; } /** * Executor definition for `npm`. * * npm is bundled with Node itself, not a standalone npm install. On * Windows: find `/node_modules/npm/bin/npm-cli.js` by * looking beside the resolved `node.exe`. Fallback: PATH lookup * (which returns npm.cmd). * On Unix: find `npm` on PATH. * * Motivation: npm.cmd internally runs `node.exe npm-cli.js`, and the * inner node.exe can allocate a new console (Node issue #21825). By * resolving to npm-cli.js directly + spawning via node.exe ourselves, * we bypass cmd.exe + npm.cmd entirely. */ function npmExecutorDef(deps?: StrategyDeps): ToolDefinition { const npmRelativeToNode = path.join("node_modules", "npm", "bin", "npm-cli.js"); // Custom strategy: find npm-cli.js beside the resolved node.exe. // We can't pre-compute the node path at definition time (the registry // isn't fully constructed yet), so the strategy resolves node // lazily at run time via the global registry hook. const npmCliBesideNodeStrategy = { name: "managed", // classified as managed because it ships with node run(): { ok: true; path: string } | { ok: false; reason: string } { // Find node.exe from process.execPath or environment. const nodeExe = process.execPath; if (!nodeExe) return { ok: false, reason: "process.execPath unset" }; const nodeDir = path.dirname(nodeExe); const candidate = path.join(nodeDir, npmRelativeToNode); try { if (existsSync(candidate)) return { ok: true, path: candidate }; return { ok: false, reason: `missing: ${candidate}` }; } catch (err) { return { ok: false, reason: err instanceof Error ? err.message : String(err) }; } }, }; // Managed-Node runtime: prefer /node/{npm.cmd,bin/npm} // when the runtime is installed. See change: embed-managed-node-runtime. const managedNpm = managedRuntimeStrategy("npm", deps); const winStrategies = [ overrideStrategy("npm", deps), managedNpm, npmCliBesideNodeStrategy, whereStrategy("npm", deps), ]; const unixStrategies = [ overrideStrategy("npm", deps), managedNpm, whereStrategy("npm", deps), ]; return { name: "npm", kind: "executor", strategies: unixStrategies, platformStrategies: { win32: winStrategies }, toArgv: nodeScriptToArgv, classify, }; } /** * Helper: bare-import strategy that, on success, transforms the * resolved `package.json` into the sibling `` path. Used by * the pi executor to find pi-coding-agent's cli.js via the same * module-resolution algorithm as `import()`. */ function bareImportCliStrategy( pkgName: string, entryRelative: string, deps?: StrategyDeps, ) { // Default uses the real module resolver anchored to this file; // tests inject a fake via deps.resolveModule. // Fallback to a filesystem walk because both pi-coding-agent and // openspec declare exports maps that omit ./package.json (modern Node // resolver returns ERR_PACKAGE_PATH_NOT_EXPORTED). See change: // eliminate-electron-runtime-install (F9 follow-on). const resolveModule: NonNullable = deps?.resolveModule ?? ((id, from) => { try { return createRequire(from).resolve(id); } catch { return null; } }); return { name: "bare-import", run(): { ok: true; path: string } | { ok: false; reason: string } { const pkgJson = resolveModule(`${pkgName}/package.json`, import.meta.url) ?? findPackageJsonByDirWalk(pkgName, import.meta.url, undefined, deps?.exists); if (!pkgJson) { return { ok: false, reason: `cannot locate ${pkgName} package directory` }; } const entry = path.join(path.dirname(pkgJson), entryRelative); return { ok: true, path: entry }; }, }; } /** * Register the standard set of dashboard tools. Idempotent — callers * may re-register to supply custom strategy deps (e.g. tests). */ export function registerDefaultTools(registry: ToolRegistry, deps?: StrategyDeps): void { // Executor-kind tools — Node scripts shipped as .cmd shims on // Windows. Each registers as [node.exe,