@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +67 -116
- package/README.md +93 -7
- package/docs/architecture.md +408 -9
- package/package.json +6 -4
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
- package/packages/extension/src/__tests__/git-info.test.ts +67 -55
- package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
- package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
- package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
- package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
- package/packages/extension/src/bridge.ts +69 -2
- package/packages/extension/src/dev-build.ts +1 -1
- package/packages/extension/src/git-info.ts +9 -19
- package/packages/extension/src/pi-env.d.ts +1 -0
- package/packages/extension/src/process-scanner.ts +72 -38
- package/packages/extension/src/provider-register.ts +304 -16
- package/packages/extension/src/server-auto-start.ts +27 -1
- package/packages/extension/src/server-launcher.ts +71 -27
- package/packages/server/package.json +16 -2
- package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
- package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
- package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
- package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
- package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
- package/packages/server/src/__tests__/config-api.test.ts +68 -0
- package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
- package/packages/server/src/__tests__/directory-service.test.ts +234 -8
- package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
- package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
- package/packages/server/src/__tests__/extension-register.test.ts +3 -1
- package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
- package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
- package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
- package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
- package/packages/server/src/__tests__/home-lock.test.ts +308 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
- package/packages/server/src/__tests__/node-guard.test.ts +85 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
- package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
- package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
- package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
- package/packages/server/src/__tests__/process-manager.test.ts +45 -18
- package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
- package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
- package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
- package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
- package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
- package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
- package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
- package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
- package/packages/server/src/__tests__/tunnel.test.ts +13 -7
- package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
- package/packages/server/src/bootstrap-queue.ts +130 -0
- package/packages/server/src/bootstrap-state.ts +131 -0
- package/packages/server/src/browse.ts +8 -3
- package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
- package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
- package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
- package/packages/server/src/cli.ts +256 -32
- package/packages/server/src/config-api.ts +16 -0
- package/packages/server/src/directory-service.ts +270 -39
- package/packages/server/src/editor-detection.ts +12 -9
- package/packages/server/src/editor-manager.ts +19 -4
- package/packages/server/src/editor-pid-registry.ts +9 -8
- package/packages/server/src/editor-registry.ts +22 -25
- package/packages/server/src/git-operations.ts +1 -1
- package/packages/server/src/headless-pid-registry.ts +7 -20
- package/packages/server/src/home-lock-release.ts +72 -0
- package/packages/server/src/home-lock.ts +389 -0
- package/packages/server/src/node-guard.ts +52 -0
- package/packages/server/src/package-manager-wrapper.ts +207 -47
- package/packages/server/src/pi-core-checker.ts +1 -1
- package/packages/server/src/pi-core-updater.ts +7 -1
- package/packages/server/src/pi-resource-scanner.ts +5 -8
- package/packages/server/src/pi-version-skew.ts +196 -0
- package/packages/server/src/preferences-store.ts +17 -3
- package/packages/server/src/process-manager.ts +403 -222
- package/packages/server/src/provider-probe.ts +234 -0
- package/packages/server/src/restart-helper.ts +130 -0
- package/packages/server/src/routes/bootstrap-routes.ts +88 -0
- package/packages/server/src/routes/openspec-routes.ts +25 -1
- package/packages/server/src/routes/pi-core-routes.ts +24 -1
- package/packages/server/src/routes/provider-auth-routes.ts +8 -8
- package/packages/server/src/routes/provider-routes.ts +43 -0
- package/packages/server/src/routes/recommended-routes.ts +10 -12
- package/packages/server/src/routes/system-routes.ts +20 -33
- package/packages/server/src/routes/tool-routes.ts +153 -0
- package/packages/server/src/server-pid.ts +5 -9
- package/packages/server/src/server.ts +211 -10
- package/packages/server/src/session-api.ts +77 -8
- package/packages/server/src/session-bootstrap.ts +17 -3
- package/packages/server/src/session-diff.ts +21 -21
- package/packages/server/src/terminal-manager.ts +61 -20
- package/packages/server/src/tunnel.ts +42 -28
- package/packages/shared/package.json +10 -3
- package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
- package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
- package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
- package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
- package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
- package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
- package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
- package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
- package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
- package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
- package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
- package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
- package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
- package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
- package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
- package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
- package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
- package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
- package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
- package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
- package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
- package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
- package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
- package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
- package/packages/shared/src/__tests__/config.test.ts +56 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
- package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
- package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
- package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
- package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
- package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
- package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
- package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
- package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
- package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
- package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
- package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
- package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
- package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
- package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
- package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
- package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
- package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
- package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
- package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
- package/packages/shared/src/bootstrap-install.ts +212 -0
- package/packages/shared/src/bridge-register.ts +87 -20
- package/packages/shared/src/browser-protocol.ts +71 -1
- package/packages/shared/src/config.ts +87 -15
- package/packages/shared/src/managed-paths.ts +31 -4
- package/packages/shared/src/openspec-poller.ts +63 -46
- package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
- package/packages/shared/src/platform/commands.ts +100 -0
- package/packages/shared/src/platform/detached-spawn.ts +305 -0
- package/packages/shared/src/platform/exec.ts +220 -0
- package/packages/shared/src/platform/git.ts +155 -0
- package/packages/shared/src/platform/index.ts +15 -0
- package/packages/shared/src/platform/npm.ts +162 -0
- package/packages/shared/src/platform/openspec.ts +91 -0
- package/packages/shared/src/platform/paths.ts +276 -0
- package/packages/shared/src/platform/process-identify.ts +126 -0
- package/packages/shared/src/platform/process-scan.ts +94 -0
- package/packages/shared/src/platform/process.ts +168 -0
- package/packages/shared/src/platform/runner.ts +369 -0
- package/packages/shared/src/platform/shell.ts +44 -0
- package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
- package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
- package/packages/shared/src/recommended-extensions.ts +18 -2
- package/packages/shared/src/resolve-jiti.ts +62 -3
- package/packages/shared/src/rest-api.ts +26 -0
- package/packages/shared/src/semaphore.ts +83 -0
- package/packages/shared/src/tool-registry/definitions.ts +342 -0
- package/packages/shared/src/tool-registry/index.ts +56 -0
- package/packages/shared/src/tool-registry/overrides.ts +118 -0
- package/packages/shared/src/tool-registry/registry.ts +262 -0
- package/packages/shared/src/tool-registry/strategies.ts +198 -0
- package/packages/shared/src/tool-registry/types.ts +180 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent per-tool path overrides at `~/.pi/dashboard/tool-overrides.json`.
|
|
3
|
+
*
|
|
4
|
+
* Schema:
|
|
5
|
+
* { "version": 1, "overrides": { "<toolName>": { "path": "<abs>" } } }
|
|
6
|
+
*
|
|
7
|
+
* Design notes (see change: consolidate-tool-resolution, design §5):
|
|
8
|
+
* - Separate from `config.json` — path overrides are machine-local and
|
|
9
|
+
* should NOT follow a user's dotfiles across machines.
|
|
10
|
+
* - Atomic write via the same tmp+rename pattern used by
|
|
11
|
+
* `server/src/json-store.ts` (duplicated here to keep `shared`
|
|
12
|
+
* self-contained; the two live in different packages).
|
|
13
|
+
* - Malformed files are treated as empty. No throw, no crash.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
|
|
19
|
+
/** Path to the overrides file. Exposed for tests and the settings UI. */
|
|
20
|
+
export function defaultOverridesPath(): string {
|
|
21
|
+
return path.join(os.homedir(), ".pi", "dashboard", "tool-overrides.json");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Internal shape persisted to disk. `version` lets us evolve later. */
|
|
25
|
+
interface OverridesFile {
|
|
26
|
+
version: 1;
|
|
27
|
+
overrides: Record<string, { path: string }>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface OverridesStoreDeps {
|
|
31
|
+
filePath?: string;
|
|
32
|
+
/** Logger hook (defaults to console.warn). Tests inject a sink. */
|
|
33
|
+
warn?(message: string): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read-through + write-through in-memory store. One instance per registry.
|
|
38
|
+
* Keeps the disk read lazy — the file is only touched on first access.
|
|
39
|
+
*/
|
|
40
|
+
export class OverridesStore {
|
|
41
|
+
private readonly filePath: string;
|
|
42
|
+
private readonly warn: (message: string) => void;
|
|
43
|
+
private cache: Record<string, string> | null = null;
|
|
44
|
+
|
|
45
|
+
constructor(deps: OverridesStoreDeps = {}) {
|
|
46
|
+
this.filePath = deps.filePath ?? defaultOverridesPath();
|
|
47
|
+
this.warn = deps.warn ?? ((m) => console.warn(`[tool-registry] ${m}`));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Snapshot of current overrides. Lazy-loads from disk on first call. */
|
|
51
|
+
list(): Readonly<Record<string, string>> {
|
|
52
|
+
if (this.cache === null) this.cache = this.load();
|
|
53
|
+
return this.cache;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Set one override + persist. */
|
|
57
|
+
set(name: string, overridePath: string): void {
|
|
58
|
+
const current = this.cache ?? this.load();
|
|
59
|
+
current[name] = overridePath;
|
|
60
|
+
this.cache = current;
|
|
61
|
+
this.persist(current);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Remove one override + persist. No-op if absent. */
|
|
65
|
+
clear(name: string): void {
|
|
66
|
+
const current = this.cache ?? this.load();
|
|
67
|
+
if (!(name in current)) return;
|
|
68
|
+
delete current[name];
|
|
69
|
+
this.cache = current;
|
|
70
|
+
this.persist(current);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Drop the in-memory cache; next `list()` re-reads the file. */
|
|
74
|
+
invalidate(): void {
|
|
75
|
+
this.cache = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Internal ─────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
private load(): Record<string, string> {
|
|
81
|
+
try {
|
|
82
|
+
if (!fs.existsSync(this.filePath)) return {};
|
|
83
|
+
const raw = fs.readFileSync(this.filePath, "utf-8");
|
|
84
|
+
if (!raw.trim()) return {};
|
|
85
|
+
const parsed = JSON.parse(raw) as Partial<OverridesFile>;
|
|
86
|
+
if (!parsed || typeof parsed !== "object" || !parsed.overrides) {
|
|
87
|
+
this.warn(`malformed overrides file at ${this.filePath}; ignoring`);
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
const out: Record<string, string> = {};
|
|
91
|
+
for (const [name, entry] of Object.entries(parsed.overrides)) {
|
|
92
|
+
if (entry && typeof entry === "object" && typeof (entry as { path?: unknown }).path === "string") {
|
|
93
|
+
out[name] = (entry as { path: string }).path;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
this.warn(
|
|
99
|
+
`failed to read overrides file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
100
|
+
);
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private persist(overrides: Record<string, string>): void {
|
|
106
|
+
const dir = path.dirname(this.filePath);
|
|
107
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
108
|
+
const data: OverridesFile = {
|
|
109
|
+
version: 1,
|
|
110
|
+
overrides: Object.fromEntries(
|
|
111
|
+
Object.entries(overrides).map(([k, v]) => [k, { path: v }]),
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
const tmpPath = this.filePath + ".tmp";
|
|
115
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
|
|
116
|
+
fs.renameSync(tmpPath, this.filePath);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToolRegistry — single-source resolver for every external binary, module,
|
|
3
|
+
* and directory the dashboard depends on.
|
|
4
|
+
*
|
|
5
|
+
* Design (see change: consolidate-tool-resolution, design §1-§4):
|
|
6
|
+
* - Ordered strategy chain per tool (override → managed → bare-import →
|
|
7
|
+
* npm-global → where).
|
|
8
|
+
* - One Resolution record per tool, cached in the registry.
|
|
9
|
+
* - Rescan invalidates one or all cached Resolutions.
|
|
10
|
+
* - Module resolution dynamically imports the resolved entry and caches
|
|
11
|
+
* the loaded ES module alongside the Resolution.
|
|
12
|
+
*/
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import {
|
|
15
|
+
type ExecutorResolution,
|
|
16
|
+
ModuleResolutionError,
|
|
17
|
+
type Resolution,
|
|
18
|
+
type Source,
|
|
19
|
+
type StrategyCtx,
|
|
20
|
+
type ToolDefinition,
|
|
21
|
+
UnknownToolError,
|
|
22
|
+
type TriedEntry,
|
|
23
|
+
} from "./types.js";
|
|
24
|
+
import { OverridesStore } from "./overrides.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Minimal platform-environment snapshot injected into strategies.
|
|
28
|
+
* Production leaves this undefined and strategies fall back to
|
|
29
|
+
* `os.homedir()` / `process.cwd()`. Tests inject fakes so the bootstrap
|
|
30
|
+
* harness can reason about alternate HOME directories.
|
|
31
|
+
*/
|
|
32
|
+
export interface PlatformEnv {
|
|
33
|
+
homedir?: string;
|
|
34
|
+
cwd?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ToolRegistryDeps {
|
|
38
|
+
overrides?: OverridesStore;
|
|
39
|
+
platform?: NodeJS.Platform;
|
|
40
|
+
/** Injected for tests; default uses native dynamic `import(url)`. */
|
|
41
|
+
importModule?: (url: string) => Promise<unknown>;
|
|
42
|
+
/** Clock injector (used by tests for deterministic `resolvedAt`). */
|
|
43
|
+
now?: () => number;
|
|
44
|
+
/** Environment overrides threaded into `StrategyCtx.env` (see types.ts). */
|
|
45
|
+
env?: PlatformEnv;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Default strategy → source mapping when a definition doesn't override it. */
|
|
49
|
+
const DEFAULT_CLASSIFY = (strategyName: string): Source => {
|
|
50
|
+
switch (strategyName) {
|
|
51
|
+
case "override":
|
|
52
|
+
return "override";
|
|
53
|
+
case "managed":
|
|
54
|
+
return "managed";
|
|
55
|
+
case "bare-import":
|
|
56
|
+
return "bare-import";
|
|
57
|
+
case "npm-global":
|
|
58
|
+
return "npm-global";
|
|
59
|
+
default:
|
|
60
|
+
return "system";
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export class ToolRegistry {
|
|
65
|
+
private readonly definitions = new Map<string, ToolDefinition>();
|
|
66
|
+
private readonly cache = new Map<string, Resolution>();
|
|
67
|
+
private readonly moduleCache = new Map<string, unknown>();
|
|
68
|
+
private readonly overrides: OverridesStore;
|
|
69
|
+
private readonly platform: NodeJS.Platform;
|
|
70
|
+
private readonly importModule: (url: string) => Promise<unknown>;
|
|
71
|
+
private readonly now: () => number;
|
|
72
|
+
private readonly env: PlatformEnv | undefined;
|
|
73
|
+
|
|
74
|
+
constructor(deps: ToolRegistryDeps = {}) {
|
|
75
|
+
this.overrides = deps.overrides ?? new OverridesStore();
|
|
76
|
+
this.platform = deps.platform ?? process.platform;
|
|
77
|
+
this.importModule = deps.importModule ?? ((url) => import(/* @vite-ignore */ url));
|
|
78
|
+
this.now = deps.now ?? (() => Date.now());
|
|
79
|
+
this.env = deps.env;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Platform the registry was created for (or the current runtime's
|
|
84
|
+
* `process.platform`). Exposed so platform-conditional tool registration
|
|
85
|
+
* (e.g. skip `ps`/`pgrep` on Windows) in `registerDefaultTools` honours
|
|
86
|
+
* the test's injected platform instead of always reading the host.
|
|
87
|
+
*/
|
|
88
|
+
getPlatform(): NodeJS.Platform {
|
|
89
|
+
return this.platform;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Register a tool definition. Last registration wins (tests re-register). */
|
|
93
|
+
register(def: ToolDefinition): void {
|
|
94
|
+
this.definitions.set(def.name, def);
|
|
95
|
+
this.cache.delete(def.name);
|
|
96
|
+
this.moduleCache.delete(def.name);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** True when the name has a registered definition. */
|
|
100
|
+
has(name: string): boolean {
|
|
101
|
+
return this.definitions.has(name);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Snapshot of every registered tool's resolution. Triggers resolution as needed. */
|
|
105
|
+
list(): Resolution[] {
|
|
106
|
+
return Array.from(this.definitions.keys()).map((n) => this.resolve(n));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Resolve a binary/directory/module-path. Uses cached result when present. */
|
|
110
|
+
resolve(name: string): Resolution {
|
|
111
|
+
const def = this.definitions.get(name);
|
|
112
|
+
if (!def) throw new UnknownToolError(name);
|
|
113
|
+
|
|
114
|
+
const cached = this.cache.get(name);
|
|
115
|
+
if (cached) return cached;
|
|
116
|
+
|
|
117
|
+
const ctx: StrategyCtx = {
|
|
118
|
+
overrides: this.overrides.list(),
|
|
119
|
+
platform: this.platform,
|
|
120
|
+
env: this.env,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const tried: TriedEntry[] = [];
|
|
124
|
+
let winner: { strategy: string; path: string } | null = null;
|
|
125
|
+
|
|
126
|
+
// Platform-specific strategy chain overrides the default when
|
|
127
|
+
// present. Use case: tool resolution chain itself differs per OS
|
|
128
|
+
// (e.g. `pi` on Windows finds pi-coding-agent's cli.js; on Unix
|
|
129
|
+
// finds the `pi` binary on PATH).
|
|
130
|
+
const strategies = def.platformStrategies?.[this.platform] ?? def.strategies;
|
|
131
|
+
|
|
132
|
+
for (const strategy of strategies) {
|
|
133
|
+
const result = strategy.run(ctx);
|
|
134
|
+
if (!result.ok) {
|
|
135
|
+
tried.push({ strategy: strategy.name, result: result.reason });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// Optional validation (existence check, "must end in dist/index.js", ...).
|
|
139
|
+
if (def.validate) {
|
|
140
|
+
const v = def.validate(result.path);
|
|
141
|
+
if (!v.ok) {
|
|
142
|
+
tried.push({ strategy: strategy.name, result: `invalid: ${v.reason}` });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
tried.push({ strategy: strategy.name, result: "ok" });
|
|
147
|
+
winner = { strategy: strategy.name, path: result.path };
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const classify = def.classify ?? DEFAULT_CLASSIFY;
|
|
152
|
+
const resolution: Resolution = winner
|
|
153
|
+
? {
|
|
154
|
+
name,
|
|
155
|
+
ok: true,
|
|
156
|
+
path: winner.path,
|
|
157
|
+
source: classify(winner.strategy),
|
|
158
|
+
tried,
|
|
159
|
+
resolvedAt: this.now(),
|
|
160
|
+
}
|
|
161
|
+
: {
|
|
162
|
+
name,
|
|
163
|
+
ok: false,
|
|
164
|
+
path: null,
|
|
165
|
+
source: null,
|
|
166
|
+
tried,
|
|
167
|
+
resolvedAt: this.now(),
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
this.cache.set(name, resolution);
|
|
171
|
+
return resolution;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve a tool and return its spawn-ready argv.
|
|
176
|
+
*
|
|
177
|
+
* Uses `resolve()` to find the artifact path, then applies the
|
|
178
|
+
* definition's `toArgv` transform (if any) to produce argv. Default:
|
|
179
|
+
* `argv = [path]` — appropriate for binary-kind tools resolved to an
|
|
180
|
+
* absolute path on PATH.
|
|
181
|
+
*
|
|
182
|
+
* For executor-kind tools with platform-specific interpreter needs
|
|
183
|
+
* (e.g. pi on Windows → `[node.exe, cli.js]`), `toArgv` does the
|
|
184
|
+
* assembly. `toArgv` may call `this.resolve(peer)` to find peer
|
|
185
|
+
* tools (e.g. `node`) and MUST fall back to `[path]` if peers are
|
|
186
|
+
* missing.
|
|
187
|
+
*
|
|
188
|
+
* Callers spawn via `spawn(argv[0], [...argv.slice(1), ...userArgs])`.
|
|
189
|
+
*
|
|
190
|
+
* See change: consolidate-windows-spawn-and-platform-handlers.
|
|
191
|
+
*/
|
|
192
|
+
resolveExecutor(name: string): ExecutorResolution {
|
|
193
|
+
const def = this.definitions.get(name);
|
|
194
|
+
if (!def) throw new UnknownToolError(name);
|
|
195
|
+
|
|
196
|
+
const resolution = this.resolve(name);
|
|
197
|
+
if (!resolution.ok || !resolution.path) {
|
|
198
|
+
return { ...resolution, argv: [] };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const argv = def.toArgv
|
|
202
|
+
? def.toArgv(resolution.path, { platform: this.platform, registry: this })
|
|
203
|
+
: [resolution.path];
|
|
204
|
+
|
|
205
|
+
return { ...resolution, argv };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolve AND dynamically import a registered module-kind tool.
|
|
210
|
+
* Throws `ModuleResolutionError` with the full `tried[]` trail when
|
|
211
|
+
* every strategy fails. The loaded ES module is cached alongside the
|
|
212
|
+
* Resolution; `rescan(name)` invalidates both.
|
|
213
|
+
*/
|
|
214
|
+
async resolveModule<T = unknown>(name: string): Promise<{ resolution: Resolution; module: T }> {
|
|
215
|
+
const def = this.definitions.get(name);
|
|
216
|
+
if (!def) throw new UnknownToolError(name);
|
|
217
|
+
if (def.kind !== "module") {
|
|
218
|
+
throw new Error(`Tool "${name}" is not kind: "module"; use resolve() instead.`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const resolution = this.resolve(name);
|
|
222
|
+
if (!resolution.ok || !resolution.path) {
|
|
223
|
+
throw new ModuleResolutionError(resolution);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const cached = this.moduleCache.get(name) as T | undefined;
|
|
227
|
+
if (cached) return { resolution, module: cached };
|
|
228
|
+
|
|
229
|
+
const url = pathToFileURL(resolution.path).href;
|
|
230
|
+
const loaded = (await this.importModule(url)) as T;
|
|
231
|
+
this.moduleCache.set(name, loaded);
|
|
232
|
+
return { resolution, module: loaded };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Drop cached Resolution(s). Next resolve() re-runs strategies. */
|
|
236
|
+
rescan(name?: string): void {
|
|
237
|
+
if (name === undefined) {
|
|
238
|
+
this.cache.clear();
|
|
239
|
+
this.moduleCache.clear();
|
|
240
|
+
this.overrides.invalidate();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
this.cache.delete(name);
|
|
244
|
+
this.moduleCache.delete(name);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Set a path override. Invalidates the target's cache. */
|
|
248
|
+
setOverride(name: string, overridePath: string): void {
|
|
249
|
+
if (!this.definitions.has(name)) throw new UnknownToolError(name);
|
|
250
|
+
this.overrides.set(name, overridePath);
|
|
251
|
+
this.cache.delete(name);
|
|
252
|
+
this.moduleCache.delete(name);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Clear a path override. Invalidates the target's cache. */
|
|
256
|
+
clearOverride(name: string): void {
|
|
257
|
+
if (!this.definitions.has(name)) throw new UnknownToolError(name);
|
|
258
|
+
this.overrides.clear(name);
|
|
259
|
+
this.cache.delete(name);
|
|
260
|
+
this.moduleCache.delete(name);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable resolution strategies shared across tool definitions.
|
|
3
|
+
*
|
|
4
|
+
* Strategies are pure functions over their `StrategyCtx` — filesystem
|
|
5
|
+
* access (`existsSync`) is the only side effect. They never spawn; PATH
|
|
6
|
+
* search delegates to `ToolResolver.which()` which is injectable for
|
|
7
|
+
* tests via the `lookup` parameter.
|
|
8
|
+
*
|
|
9
|
+
* See change: consolidate-tool-resolution (design §2).
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { ToolResolver } from "../platform/binary-lookup.js";
|
|
15
|
+
import { getManagedBin, getManagedDir } from "../managed-paths.js";
|
|
16
|
+
import * as npm from "../platform/npm.js";
|
|
17
|
+
import type { Strategy, StrategyCtx, StrategyResult } from "./types.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Injectable surfaces used by strategies.
|
|
21
|
+
*
|
|
22
|
+
* - `exists` — fs existence probe (memfs in tests).
|
|
23
|
+
* - `which` — PATH search.
|
|
24
|
+
* - `npmRootGlobal` — result of `npm root -g` (tests inject to avoid spawn).
|
|
25
|
+
* - `resolveModule` — node-module resolution (id, from) → absolute path.
|
|
26
|
+
* Production uses `createRequire(from).resolve(id)`; tests walk fake
|
|
27
|
+
* node_modules trees.
|
|
28
|
+
*/
|
|
29
|
+
export interface StrategyDeps {
|
|
30
|
+
exists?(p: string): boolean;
|
|
31
|
+
which?(name: string): string | null;
|
|
32
|
+
npmRootGlobal?(): string;
|
|
33
|
+
resolveModule?(id: string, from: string): string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function defaultResolveModule(id: string, from: string): string | null {
|
|
37
|
+
try {
|
|
38
|
+
return createRequire(from).resolve(id);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function defaults(): Required<StrategyDeps> {
|
|
45
|
+
const resolver = new ToolResolver({
|
|
46
|
+
processExecPath: process.execPath,
|
|
47
|
+
useLoginShell: true,
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
exists: existsSync,
|
|
51
|
+
which: (name) => resolver.which(name),
|
|
52
|
+
npmRootGlobal: () => npm.rootGlobalOr(""),
|
|
53
|
+
resolveModule: defaultResolveModule,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Merge caller-supplied deps over the live defaults. */
|
|
58
|
+
function d(deps?: StrategyDeps): Required<StrategyDeps> {
|
|
59
|
+
const base = defaults();
|
|
60
|
+
if (!deps) return base;
|
|
61
|
+
return {
|
|
62
|
+
exists: deps.exists ?? base.exists,
|
|
63
|
+
which: deps.which ?? base.which,
|
|
64
|
+
npmRootGlobal: deps.npmRootGlobal ?? base.npmRootGlobal,
|
|
65
|
+
resolveModule: deps.resolveModule ?? base.resolveModule,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Strategies ──────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Look up a registered path override by tool name. Existence is checked
|
|
73
|
+
* here so invalid overrides fall through with reason `invalid: <...>`
|
|
74
|
+
* without requiring callers to wire a separate validator.
|
|
75
|
+
*/
|
|
76
|
+
export function overrideStrategy(toolName: string, deps?: StrategyDeps): Strategy {
|
|
77
|
+
const { exists } = d(deps);
|
|
78
|
+
return {
|
|
79
|
+
name: "override",
|
|
80
|
+
run(ctx): StrategyResult {
|
|
81
|
+
const p = ctx.overrides[toolName];
|
|
82
|
+
if (!p) return { ok: false, reason: "no override set" };
|
|
83
|
+
if (!exists(p)) return { ok: false, reason: `invalid: path does not exist: ${p}` };
|
|
84
|
+
return { ok: true, path: p };
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Managed install: `~/.pi-dashboard/node_modules/.bin/<name>(.cmd)` for
|
|
91
|
+
* binaries, or any explicit relative path under `MANAGED_DIR` for
|
|
92
|
+
* modules/directories.
|
|
93
|
+
*/
|
|
94
|
+
export function managedBinStrategy(
|
|
95
|
+
binaryName: string,
|
|
96
|
+
deps?: StrategyDeps,
|
|
97
|
+
): Strategy {
|
|
98
|
+
const { exists } = d(deps);
|
|
99
|
+
return {
|
|
100
|
+
name: "managed",
|
|
101
|
+
run(ctx): StrategyResult {
|
|
102
|
+
const ext = ctx.platform === "win32" ? ".cmd" : "";
|
|
103
|
+
const candidate = path.join(getManagedBin(ctx.env), binaryName + ext);
|
|
104
|
+
if (exists(candidate)) return { ok: true, path: candidate };
|
|
105
|
+
return { ok: false, reason: `missing: ${candidate}` };
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Managed module entry: `~/.pi-dashboard/node_modules/<pkg>/dist/index.js`
|
|
112
|
+
* (or a caller-specified relative entry).
|
|
113
|
+
*/
|
|
114
|
+
export function managedModuleStrategy(
|
|
115
|
+
pkgName: string,
|
|
116
|
+
entryRelative: string = path.join("dist", "index.js"),
|
|
117
|
+
deps?: StrategyDeps,
|
|
118
|
+
): Strategy {
|
|
119
|
+
const { exists } = d(deps);
|
|
120
|
+
return {
|
|
121
|
+
name: "managed",
|
|
122
|
+
run(ctx: StrategyCtx): StrategyResult {
|
|
123
|
+
const candidate = path.join(getManagedDir(ctx.env), "node_modules", pkgName, entryRelative);
|
|
124
|
+
if (exists(candidate)) return { ok: true, path: candidate };
|
|
125
|
+
return { ok: false, reason: `missing: ${candidate}` };
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Global npm install: `<npm root -g>/<pkg>/<entry>`. Falls back to
|
|
132
|
+
* `{ ok: false }` when `npm root -g` fails or the file is absent.
|
|
133
|
+
*/
|
|
134
|
+
export function npmGlobalStrategy(
|
|
135
|
+
pkgName: string,
|
|
136
|
+
entryRelative: string = path.join("dist", "index.js"),
|
|
137
|
+
deps?: StrategyDeps,
|
|
138
|
+
): Strategy {
|
|
139
|
+
const { exists, npmRootGlobal } = d(deps);
|
|
140
|
+
return {
|
|
141
|
+
name: "npm-global",
|
|
142
|
+
run(): StrategyResult {
|
|
143
|
+
const root = npmRootGlobal();
|
|
144
|
+
if (!root) return { ok: false, reason: "npm root -g failed" };
|
|
145
|
+
const candidate = path.join(root, pkgName, entryRelative);
|
|
146
|
+
if (exists(candidate)) return { ok: true, path: candidate };
|
|
147
|
+
return { ok: false, reason: `missing: ${candidate}` };
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* PATH search via `ToolResolver.which()`. This is the plain-old "is it
|
|
154
|
+
* on PATH" strategy and should appear last in most chains.
|
|
155
|
+
*/
|
|
156
|
+
export function whereStrategy(binaryName: string, deps?: StrategyDeps): Strategy {
|
|
157
|
+
const { which } = d(deps);
|
|
158
|
+
return {
|
|
159
|
+
name: "where",
|
|
160
|
+
run(): StrategyResult {
|
|
161
|
+
const p = which(binaryName);
|
|
162
|
+
if (p) return { ok: true, path: p };
|
|
163
|
+
return { ok: false, reason: `not found on PATH` };
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Bare `import("<pkg>")` — succeeds when the package is reachable from
|
|
170
|
+
* the caller's node_modules tree. We probe synchronously via
|
|
171
|
+
* `createRequire(import.meta.url).resolve(pkgName)`, which follows the
|
|
172
|
+
* same module-resolution algorithm as `import()` but returns a path.
|
|
173
|
+
*
|
|
174
|
+
* The returned path is the resolved entry file; `resolveModule()` then
|
|
175
|
+
* dynamically imports it via `pathToFileURL`. This keeps strategies
|
|
176
|
+
* uniformly sync and keeps the diagnostic trail honest (if the package
|
|
177
|
+
* isn't resolvable, we record the reason here instead of letting it
|
|
178
|
+
* surface as an opaque `import()` throw later).
|
|
179
|
+
*
|
|
180
|
+
* `anchor` determines which node_modules tree we search. Default is
|
|
181
|
+
* this file's URL (i.e. the shared package) — which is typically what
|
|
182
|
+
* callers want: "is pi a dependency of the dashboard?"
|
|
183
|
+
*/
|
|
184
|
+
export function bareImportStrategy(
|
|
185
|
+
pkgName: string,
|
|
186
|
+
anchor: string = import.meta.url,
|
|
187
|
+
deps?: StrategyDeps,
|
|
188
|
+
): Strategy {
|
|
189
|
+
const { resolveModule } = d(deps);
|
|
190
|
+
return {
|
|
191
|
+
name: "bare-import",
|
|
192
|
+
run(): StrategyResult {
|
|
193
|
+
const resolved = resolveModule(pkgName, anchor);
|
|
194
|
+
if (!resolved) return { ok: false, reason: `cannot resolve ${pkgName} from ${anchor}` };
|
|
195
|
+
return { ok: true, path: resolved };
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|