@blackbelt-technology/pi-agent-dashboard 0.4.0 → 0.4.1

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.
Files changed (35) hide show
  1. package/AGENTS.md +30 -8
  2. package/README.md +386 -494
  3. package/docs/architecture.md +63 -9
  4. package/package.json +8 -5
  5. package/packages/extension/package.json +6 -4
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
  7. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  8. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  9. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  10. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  11. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  12. package/packages/extension/src/ask-user-tool.ts +5 -4
  13. package/packages/extension/src/bridge.ts +102 -15
  14. package/packages/extension/src/multiselect-list.ts +146 -0
  15. package/packages/extension/src/multiselect-polyfill.ts +43 -0
  16. package/packages/extension/src/server-launcher.ts +15 -3
  17. package/packages/server/package.json +5 -5
  18. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  19. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  20. package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
  21. package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
  22. package/packages/server/src/cli.ts +56 -9
  23. package/packages/server/src/pi-version-skew.ts +12 -1
  24. package/packages/server/src/restart-helper.ts +13 -2
  25. package/packages/shared/package.json +1 -1
  26. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  27. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  28. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  29. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  30. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  31. package/packages/shared/src/platform/index.ts +1 -0
  32. package/packages/shared/src/platform/node-spawn.ts +154 -0
  33. package/packages/shared/src/protocol.ts +23 -0
  34. package/packages/shared/src/state-replay.ts +9 -0
  35. package/packages/shared/src/tool-registry/definitions.ts +92 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Repo-level invariant: build-time scripts (CI workflows, Dockerfiles,
3
+ * shell scripts, root-level CJS helpers) MUST NOT hardcode
4
+ * `node_modules/electron` or `node_modules/node-pty` paths. Instead, they
5
+ * MUST resolve through the tool registry — either via the shared shell
6
+ * wrapper at `packages/shared/bin/pi-dashboard-resolve-tool.cjs`, or
7
+ * (for postinstall paths that run before the shared package is built)
8
+ * via `require.resolve("<pkg>/package.json")` matching the registry's
9
+ * `bare-import` strategy semantics.
10
+ *
11
+ * This invariant exists because npm workspace hoisting moves these
12
+ * packages between `packages/<workspace>/node_modules/<pkg>/` (nested)
13
+ * and `<repoRoot>/node_modules/<pkg>/` (hoisted) depending on the
14
+ * workspaces config and npm version. The v0.4.0 release crisis was
15
+ * caused exactly by this: `cd packages/electron/node_modules/electron`
16
+ * stopped working after `f51e352` switched workspace cross-refs to
17
+ * plain semver.
18
+ *
19
+ * If this test fails, replace the offending substring with one of:
20
+ *
21
+ * # Shell / YAML / Dockerfile (build-time, has access to repo source):
22
+ * ELECTRON_DIR=$(node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron)
23
+ * cd "$ELECTRON_DIR" && ...
24
+ *
25
+ * # CJS root postinstall (runs DURING npm install — must inline):
26
+ * const ptyPkg = require.resolve("node-pty/package.json");
27
+ * const prebuildsDir = path.join(path.dirname(ptyPkg), "prebuilds");
28
+ *
29
+ * See change: register-build-time-tools.
30
+ */
31
+ import { describe, expect, it } from "vitest";
32
+ import fs from "node:fs";
33
+ import path from "node:path";
34
+ import url from "node:url";
35
+
36
+ /** Banned substrings (after comment-stripping). */
37
+ const PATTERNS: readonly { re: RegExp; suggestion: string }[] = [
38
+ {
39
+ re: /node_modules\/electron(?:\/|\b)/,
40
+ suggestion:
41
+ "Use `node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron`",
42
+ },
43
+ {
44
+ re: /node_modules\/node-pty(?:\/|\b)/,
45
+ suggestion:
46
+ 'Use `require.resolve("node-pty/package.json")` (mirrors the registry\'s bare-import strategy)',
47
+ },
48
+ ];
49
+
50
+ /**
51
+ * Files explicitly allowed to contain the banned substrings. Each entry
52
+ * is a repo-relative path matched exactly. Add an entry only when the
53
+ * substring appears as a non-path token (e.g. an argument to
54
+ * `require.resolve`, a comment quoting an example, or this lint file
55
+ * itself). Document the reason inline.
56
+ */
57
+ const ALLOWLIST: readonly string[] = [
58
+ // The lint file itself contains every banned substring as test data.
59
+ "packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts",
60
+ // Root postinstall — uses `require.resolve("node-pty/package.json")`,
61
+ // which contains "node-pty" as an argument string but not as a
62
+ // hardcoded path. Allowlisted because it must run before the shared
63
+ // package is unpacked. See file header for full reasoning.
64
+ "scripts/fix-pty-permissions.cjs",
65
+ // Sister postinstall script (workspace-scoped) — same rationale.
66
+ "packages/server/scripts/fix-pty-permissions.cjs",
67
+ ];
68
+
69
+ /**
70
+ * Repo-relative file list to scan.
71
+ *
72
+ * The scope is intentionally narrow: only the build-time sites that the
73
+ * `register-build-time-tools` change migrated, plus the postinstall
74
+ * scripts that mirror the registry's `bare-import` semantics. Bundle /
75
+ * Docker entrypoint scripts (`bundle-server.sh`, `docker-make.sh`,
76
+ * `test-electron-install-inner.sh`, etc.) are NOT in scope: those
77
+ * operate on a known WORKDIR with deterministic node_modules layout
78
+ * inside the build image and are not affected by host-side hoisting.
79
+ */
80
+ const SCAN_FILES: readonly string[] = [
81
+ ".github/workflows/publish.yml",
82
+ ".github/workflows/ci.yml",
83
+ "packages/electron/scripts/Dockerfile.build",
84
+ "scripts/fix-pty-permissions.cjs",
85
+ "packages/server/scripts/fix-pty-permissions.cjs",
86
+ ];
87
+
88
+ interface Violation {
89
+ file: string;
90
+ line: number;
91
+ col: number;
92
+ text: string;
93
+ suggestion: string;
94
+ }
95
+
96
+ /**
97
+ * Strip a single line's trailing comment for YAML / shell / JS-style
98
+ * line comments. Preserves substring matches inside strings as actual
99
+ * content (we don't try to parse string literals — keeping it simple).
100
+ *
101
+ * Specifically:
102
+ * - `# ...` (YAML, shell): everything from a `#` not preceded by a
103
+ * non-space alphanumeric is dropped. Matches GitHub Actions /
104
+ * bash conventions.
105
+ * - `// ...` (JS): everything from `//` to end of line is dropped.
106
+ *
107
+ * This is intentionally simple. False positives only matter if a banned
108
+ * pattern appears INSIDE a string literal (which would still be the
109
+ * bug we want to catch); false negatives only matter for inline
110
+ * comments (`echo foo # comment node_modules/electron`), which we
111
+ * exclude correctly.
112
+ */
113
+ function stripLineComment(line: string): string {
114
+ // JS-style first.
115
+ const jsIdx = line.indexOf("//");
116
+ if (jsIdx >= 0) line = line.slice(0, jsIdx);
117
+ // Shell/YAML `#` — only when preceded by whitespace or start-of-line.
118
+ const hashMatch = line.match(/(^|\s)#/);
119
+ if (hashMatch && typeof hashMatch.index === "number") {
120
+ line = line.slice(0, hashMatch.index);
121
+ }
122
+ return line;
123
+ }
124
+
125
+ describe("no hardcoded node_modules/<dep> paths in build-time files", () => {
126
+ it("only allowlisted files reference node_modules/electron or node_modules/node-pty", () => {
127
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
128
+ const repoRoot = path.resolve(here, "..", "..", "..", "..");
129
+
130
+ const violations: Violation[] = [];
131
+ const allowSet = new Set(
132
+ ALLOWLIST.map((p) => path.resolve(repoRoot, p).replace(/\\/g, "/")),
133
+ );
134
+
135
+ for (const rel of SCAN_FILES) {
136
+ const file = path.resolve(repoRoot, rel);
137
+ if (!fs.existsSync(file)) continue;
138
+ const normalized = file.replace(/\\/g, "/");
139
+ if (allowSet.has(normalized)) continue;
140
+
141
+ const content = fs.readFileSync(file, "utf-8");
142
+ const lines = content.split(/\r?\n/);
143
+
144
+ lines.forEach((rawLine, idx) => {
145
+ const stripped = stripLineComment(rawLine);
146
+ for (const { re, suggestion } of PATTERNS) {
147
+ const m = stripped.match(re);
148
+ if (!m) continue;
149
+ const col = rawLine.indexOf(m[0]);
150
+ violations.push({
151
+ file: path.relative(repoRoot, file),
152
+ line: idx + 1,
153
+ col: col >= 0 ? col + 1 : 1,
154
+ text: rawLine.trim(),
155
+ suggestion,
156
+ });
157
+ }
158
+ });
159
+ }
160
+
161
+ if (violations.length > 0) {
162
+ const msg =
163
+ `Hardcoded \`node_modules/<dep>\` path(s) found in build-time files.\n` +
164
+ `These break under npm workspace hoisting changes (see v0.4.0 release crisis).\n` +
165
+ `Use the tool registry instead. See change: register-build-time-tools.\n\n` +
166
+ `Offenders (${violations.length}):\n` +
167
+ violations
168
+ .map(
169
+ (v) =>
170
+ ` ${v.file}:${v.line}:${v.col} ${v.text}\n → ${v.suggestion}`,
171
+ )
172
+ .join("\n");
173
+ expect(violations, msg).toEqual([]);
174
+ }
175
+ });
176
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Repo-level invariant: any source file that passes an argv to Node
3
+ * with `--import` or `--loader` MUST wrap the following positions
4
+ * (loader and entry script) in `file://` URLs via `toFileUrl(...)` or
5
+ * `pathToFileURL(...).href`. Raw OS paths on Windows drives whose
6
+ * letter collides with URL-scheme parsing (e.g. `B:\`) crash Node with
7
+ * `ERR_UNSUPPORTED_ESM_URL_SCHEME`.
8
+ *
9
+ * If this test fails, migrate the offending file to use
10
+ * `spawnNodeScript` or wrap the entry/loader with `toFileUrl` from
11
+ * `@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js`.
12
+ *
13
+ * See change: fix-windows-entry-script-url.
14
+ */
15
+ import { describe, it, expect } from "vitest";
16
+ import fs from "node:fs/promises";
17
+ import path from "node:path";
18
+ import url from "node:url";
19
+
20
+ /** Files allowed to reference --import / --loader with raw identifiers. */
21
+ const ALLOWLIST: readonly string[] = [
22
+ "packages/shared/src/platform/node-spawn.ts",
23
+ // resolve-jiti.ts returns a file:// URL to callers; it does not itself
24
+ // build a `["--import", X, Y]` argv. Allowlisted as the documented
25
+ // source of loader URLs referenced in server spawn call sites.
26
+ "packages/shared/src/resolve-jiti.ts",
27
+ ];
28
+
29
+ /** Per-line opt-out for intentional usages (e.g. comment examples). */
30
+ const OPT_OUT_MARKER = "ban:raw-node-import-ok";
31
+
32
+ /**
33
+ * Detect argv arrays containing `"--import"` or `"--loader"` followed by
34
+ * a bare identifier (not a string literal and not a wrapped call).
35
+ *
36
+ * We match the argv-literal shape:
37
+ * ["--import", X, Y]
38
+ * args: ["--import", X, Y, ...]
39
+ *
40
+ * Then check that both X and Y are either:
41
+ * - a string literal starting with "file:" (already a URL)
42
+ * - a call expression to toFileUrl(...) or pathToFileURL(...).href
43
+ * - the identifier resolveJitiImport() / resolveJitiFromAnchor() (which
44
+ * are documented to return file:// URLs — allowlisted by name)
45
+ *
46
+ * Anything else is flagged.
47
+ */
48
+ const IMPORT_ARGV_RE =
49
+ /["']--(?:import|loader)["']\s*,\s*([^,\]]+?)\s*,\s*([^,\]]+?)(?:\s*,|\s*\])/g;
50
+
51
+ const URL_LOOKING_RE =
52
+ /^(?:["']file:|toFileUrl\s*\(|pathToFileURL\s*\([^)]*\)\s*\.href|resolveJitiImport\s*\(|resolveJitiFromAnchor\s*\()/;
53
+
54
+ /** Recursively walk a directory, yielding .ts / .tsx files. */
55
+ async function* walk(dir: string): AsyncGenerator<string> {
56
+ const entries = await fs.readdir(dir, { withFileTypes: true });
57
+ for (const entry of entries) {
58
+ const full = path.join(dir, entry.name);
59
+ if (entry.isDirectory()) {
60
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "__tests__") continue;
61
+ yield* walk(full);
62
+ } else if (entry.isFile() && /\.(ts|tsx|mts|cts)$/.test(entry.name)) {
63
+ yield full;
64
+ }
65
+ }
66
+ }
67
+
68
+ describe("no raw paths passed to node --import / --loader", () => {
69
+ it("only URL-wrapped or allowlisted argv positions follow --import / --loader", async () => {
70
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
71
+ const repoRoot = path.resolve(here, "..", "..", "..", "..");
72
+ const packagesDir = path.resolve(repoRoot, "packages");
73
+
74
+ const allowSet = new Set(
75
+ ALLOWLIST.map((p) => path.resolve(repoRoot, p).replace(/\\/g, "/")),
76
+ );
77
+
78
+ const violations: Array<{ file: string; line: number; text: string }> = [];
79
+
80
+ for (const pkg of await fs.readdir(packagesDir, { withFileTypes: true })) {
81
+ if (!pkg.isDirectory()) continue;
82
+ const srcDir = path.join(packagesDir, pkg.name, "src");
83
+ try {
84
+ await fs.access(srcDir);
85
+ } catch {
86
+ continue;
87
+ }
88
+ for await (const file of walk(srcDir)) {
89
+ const normalized = file.replace(/\\/g, "/");
90
+ if (allowSet.has(normalized)) continue;
91
+
92
+ const content = await fs.readFile(file, "utf-8");
93
+ const lines = content.split(/\r?\n/);
94
+
95
+ // Walk each line and check for the argv pattern. Track byte
96
+ // offsets so we can compute line numbers for multi-line matches.
97
+ let offset = 0;
98
+ for (let i = 0; i < lines.length; i++) {
99
+ const line = lines[i]!;
100
+ // Fast path: only inspect lines that mention --import or --loader.
101
+ if (!line.includes("--import") && !line.includes("--loader")) {
102
+ offset += line.length + 1;
103
+ continue;
104
+ }
105
+ if (line.includes(OPT_OUT_MARKER)) {
106
+ offset += line.length + 1;
107
+ continue;
108
+ }
109
+ // Check the current line alone (we allow argv to be on one line;
110
+ // multi-line argv arrays are a rare style and would still trip
111
+ // the quick search above).
112
+ IMPORT_ARGV_RE.lastIndex = 0;
113
+ let m: RegExpExecArray | null;
114
+ while ((m = IMPORT_ARGV_RE.exec(line)) !== null) {
115
+ const loaderArg = m[1]!.trim();
116
+ const entryArg = m[2]!.trim();
117
+ const loaderOk = URL_LOOKING_RE.test(loaderArg);
118
+ const entryOk = URL_LOOKING_RE.test(entryArg);
119
+ if (!loaderOk || !entryOk) {
120
+ violations.push({
121
+ file: path.relative(repoRoot, file),
122
+ line: i + 1,
123
+ text: line.trim(),
124
+ });
125
+ }
126
+ }
127
+ offset += line.length + 1;
128
+ }
129
+ }
130
+ }
131
+
132
+ if (violations.length > 0) {
133
+ const msg =
134
+ `Raw filesystem paths passed to node --import / --loader found.\n` +
135
+ `Migrate each call site to use spawnNodeScript() or wrap the\n` +
136
+ `loader/entry with toFileUrl(...) from:\n` +
137
+ ` import { toFileUrl, spawnNodeScript } from\n` +
138
+ ` "@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js";\n\n` +
139
+ `Offenders (${violations.length}):\n` +
140
+ violations
141
+ .map((v) => ` ${v.file}:${v.line} ${v.text}`)
142
+ .join("\n");
143
+ expect(violations, msg).toEqual([]);
144
+ }
145
+ });
146
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Tests for `platform/node-spawn.ts` — the canonical helper for
3
+ * constructing `node --import <loader> <entry>` argv.
4
+ *
5
+ * See change: fix-windows-entry-script-url.
6
+ */
7
+ import { describe, it, expect, vi } from "vitest";
8
+ import { toFileUrl, spawnNodeScript, isTsxLoader, shouldUrlWrapEntry } from "../platform/node-spawn.js";
9
+ import * as execModule from "../platform/exec.js";
10
+
11
+ describe("toFileUrl", () => {
12
+ it("returns a file:// URL input unchanged (idempotent)", () => {
13
+ expect(toFileUrl("file:///C:/foo.ts")).toBe("file:///C:/foo.ts");
14
+ expect(toFileUrl("file:///usr/local/bin/cli.js")).toBe("file:///usr/local/bin/cli.js");
15
+ });
16
+
17
+ it("wraps Windows B:\\ drive-letter paths on any host OS", () => {
18
+ expect(toFileUrl("B:\\Dev\\cli.ts")).toBe("file:///B:/Dev/cli.ts");
19
+ });
20
+
21
+ it("wraps Windows C:\\ drive-letter paths on any host OS", () => {
22
+ expect(toFileUrl("C:\\Users\\x\\cli.ts")).toBe("file:///C:/Users/x/cli.ts");
23
+ });
24
+
25
+ it("wraps forward-slash Windows paths", () => {
26
+ expect(toFileUrl("B:/Dev/cli.ts")).toBe("file:///B:/Dev/cli.ts");
27
+ });
28
+
29
+ it("wraps POSIX absolute paths", () => {
30
+ expect(toFileUrl("/usr/local/bin/cli.js")).toBe("file:///usr/local/bin/cli.js");
31
+ });
32
+
33
+ it("handles uppercase and lowercase drive letters identically", () => {
34
+ expect(toFileUrl("b:\\Dev\\cli.ts")).toBe("file:///b:/Dev/cli.ts");
35
+ expect(toFileUrl("Z:\\foo.ts")).toBe("file:///Z:/foo.ts");
36
+ });
37
+ });
38
+
39
+ describe("isTsxLoader", () => {
40
+ it("returns true for loader URLs containing /tsx/", () => {
41
+ expect(isTsxLoader("file:///home/x/node_modules/tsx/dist/esm/index.mjs")).toBe(true);
42
+ expect(isTsxLoader("file:///C:/project/node_modules/tsx/dist/esm/index.mjs")).toBe(true);
43
+ });
44
+
45
+ it("returns true for raw paths with /tsx/ segment", () => {
46
+ expect(isTsxLoader("/home/x/node_modules/tsx/dist/esm/index.mjs")).toBe(true);
47
+ });
48
+
49
+ it("returns true for Windows raw paths with \\tsx\\ segment", () => {
50
+ expect(isTsxLoader("C:\\project\\node_modules\\tsx\\dist\\esm\\index.mjs")).toBe(true);
51
+ });
52
+
53
+ it("returns false for jiti loader", () => {
54
+ expect(isTsxLoader("file:///home/x/node_modules/@mariozechner/jiti/lib/jiti-register.mjs")).toBe(false);
55
+ expect(isTsxLoader("/home/x/node_modules/@mariozechner/jiti/lib/jiti-register.mjs")).toBe(false);
56
+ });
57
+
58
+ it("returns false for undefined / empty", () => {
59
+ expect(isTsxLoader(undefined)).toBe(false);
60
+ expect(isTsxLoader("")).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe("spawnNodeScript", () => {
65
+ it("URL-wraps both loader and entry when loader is NOT tsx (jiti/default)", () => {
66
+ const spawnSpy = vi
67
+ .spyOn(execModule, "spawn")
68
+ .mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
69
+
70
+ spawnNodeScript({
71
+ nodeBin: "C:\\Program Files\\nodejs\\node.exe",
72
+ loader: "B:\\jiti\\register.mjs",
73
+ entry: "B:\\Dev\\cli.ts",
74
+ args: ["start", "--dev"],
75
+ });
76
+
77
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
78
+ const [bin, argv] = spawnSpy.mock.calls[0]!;
79
+ expect(bin).toBe("C:\\Program Files\\nodejs\\node.exe");
80
+ // On Linux host: entry stays raw even with a Windows-styled path
81
+ // (shouldUrlWrapEntry consults process.platform, which is Linux).
82
+ // The Windows-wrapped branch is exercised separately via shouldUrlWrapEntry.
83
+ expect(argv).toEqual([
84
+ "--import",
85
+ "file:///B:/jiti/register.mjs",
86
+ "B:\\Dev\\cli.ts",
87
+ "start",
88
+ "--dev",
89
+ ]);
90
+
91
+ spawnSpy.mockRestore();
92
+ });
93
+
94
+ it("URL-wraps loader but passes RAW entry when loader is tsx", () => {
95
+ const spawnSpy = vi
96
+ .spyOn(execModule, "spawn")
97
+ .mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
98
+
99
+ spawnNodeScript({
100
+ nodeBin: "/usr/bin/node",
101
+ loader: "/home/u/node_modules/tsx/dist/esm/index.mjs",
102
+ entry: "/home/u/repo/packages/server/src/cli.ts",
103
+ args: ["start"],
104
+ });
105
+
106
+ const [, argv] = spawnSpy.mock.calls[0]!;
107
+ expect(argv).toEqual([
108
+ "--import",
109
+ "file:///home/u/node_modules/tsx/dist/esm/index.mjs",
110
+ "/home/u/repo/packages/server/src/cli.ts", // RAW, not URL
111
+ "start",
112
+ ]);
113
+ spawnSpy.mockRestore();
114
+ });
115
+
116
+ it("defaults nodeBin to process.execPath when omitted", () => {
117
+ const spawnSpy = vi
118
+ .spyOn(execModule, "spawn")
119
+ .mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
120
+
121
+ spawnNodeScript({
122
+ entry: "/usr/local/cli.ts",
123
+ });
124
+
125
+ const [bin] = spawnSpy.mock.calls[0]!;
126
+ expect(bin).toBe(process.execPath);
127
+ spawnSpy.mockRestore();
128
+ });
129
+
130
+ it("omits --import when no loader is provided", () => {
131
+ const spawnSpy = vi
132
+ .spyOn(execModule, "spawn")
133
+ .mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
134
+
135
+ spawnNodeScript({
136
+ entry: "B:\\Dev\\cli.ts",
137
+ args: ["help"],
138
+ });
139
+
140
+ const [, argv] = spawnSpy.mock.calls[0]!;
141
+ // No loader → shouldUrlWrapEntry returns false on Linux host → raw entry.
142
+ expect(argv).toEqual(["B:\\Dev\\cli.ts", "help"]);
143
+ spawnSpy.mockRestore();
144
+ });
145
+
146
+ it("passes spawnOptions through to exec.spawn unchanged", () => {
147
+ const spawnSpy = vi
148
+ .spyOn(execModule, "spawn")
149
+ .mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
150
+
151
+ const opts = { detached: true, stdio: ["ignore", 1, 2] as ("ignore" | number)[], env: { FOO: "bar" } };
152
+ spawnNodeScript({
153
+ entry: "/usr/local/cli.ts",
154
+ spawnOptions: opts,
155
+ });
156
+
157
+ const [, , passedOpts] = spawnSpy.mock.calls[0]!;
158
+ expect(passedOpts).toBe(opts);
159
+ spawnSpy.mockRestore();
160
+ });
161
+
162
+ it("accepts a loader that is already a file:// URL without double-wrapping", () => {
163
+ const spawnSpy = vi
164
+ .spyOn(execModule, "spawn")
165
+ .mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
166
+
167
+ spawnNodeScript({
168
+ loader: "file:///C:/jiti/register.mjs",
169
+ entry: "B:\\Dev\\cli.ts",
170
+ });
171
+
172
+ const [, argv] = spawnSpy.mock.calls[0]!;
173
+ // On Linux host with non-tsx loader: entry stays raw.
174
+ expect(argv).toEqual([
175
+ "--import",
176
+ "file:///C:/jiti/register.mjs",
177
+ "B:\\Dev\\cli.ts",
178
+ ]);
179
+ spawnSpy.mockRestore();
180
+ });
181
+ });
182
+
183
+ describe("shouldUrlWrapEntry", () => {
184
+ it("returns false for tsx loader on any platform", () => {
185
+ const tsxLoader = "file:///home/u/node_modules/tsx/dist/esm/index.mjs";
186
+ expect(shouldUrlWrapEntry(tsxLoader, "linux")).toBe(false);
187
+ expect(shouldUrlWrapEntry(tsxLoader, "darwin")).toBe(false);
188
+ expect(shouldUrlWrapEntry(tsxLoader, "win32")).toBe(false);
189
+ });
190
+
191
+ it("returns false for non-tsx loader on POSIX (jiti MUST get raw entry)", () => {
192
+ const jiti = "file:///home/u/node_modules/@mariozechner/jiti/lib/jiti-register.mjs";
193
+ expect(shouldUrlWrapEntry(jiti, "linux")).toBe(false);
194
+ expect(shouldUrlWrapEntry(jiti, "darwin")).toBe(false);
195
+ });
196
+
197
+ it("returns true for non-tsx loader on Windows (drive letters need file://)", () => {
198
+ const jiti = "file:///C:/node_modules/@mariozechner/jiti/lib/jiti-register.mjs";
199
+ expect(shouldUrlWrapEntry(jiti, "win32")).toBe(true);
200
+ });
201
+
202
+ it("returns false when no loader is provided, regardless of platform", () => {
203
+ // Without a loader, Node's default resolver handles the entry; the URL
204
+ // wrap was historically used for Windows drive-letter collision, but
205
+ // if we were to spawn without a loader we'd still default to raw on POSIX.
206
+ // On Windows without a loader, callers should wrap themselves.
207
+ expect(shouldUrlWrapEntry(undefined, "linux")).toBe(false);
208
+ expect(shouldUrlWrapEntry(undefined, "darwin")).toBe(false);
209
+ });
210
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Unit tests for the shell-callable resolver wrapper at
3
+ * `packages/shared/bin/pi-dashboard-resolve-tool.cjs`.
4
+ *
5
+ * We spawn the wrapper as a child process (matching its real-world use)
6
+ * and assert stdout/stderr/exit-code per the spec scenarios in
7
+ * openspec/changes/register-build-time-tools/specs/tool-registry/spec.md
8
+ *
9
+ * See change: register-build-time-tools.
10
+ */
11
+ import { describe, expect, it } from "vitest";
12
+ import { spawnSync } from "node:child_process";
13
+ import path from "node:path";
14
+ import url from "node:url";
15
+
16
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
17
+ const repoRoot = path.resolve(here, "..", "..", "..", "..");
18
+ const SCRIPT = path.join(
19
+ repoRoot,
20
+ "packages",
21
+ "shared",
22
+ "bin",
23
+ "pi-dashboard-resolve-tool.cjs",
24
+ );
25
+
26
+ function run(args: string[]) {
27
+ return spawnSync(process.execPath, [SCRIPT, ...args], {
28
+ cwd: repoRoot,
29
+ encoding: "utf8",
30
+ // Isolate from any user override file so tests are deterministic.
31
+ env: {
32
+ ...process.env,
33
+ // Point HOME at /tmp so ~/.pi/dashboard/tool-overrides.json is
34
+ // (almost certainly) absent, keeping the resolver in the
35
+ // bare-import branch.
36
+ HOME: "/tmp/pi-dashboard-resolve-tool-test",
37
+ },
38
+ });
39
+ }
40
+
41
+ describe("pi-dashboard-resolve-tool.cjs", () => {
42
+ it("prints absolute path to stdout for `electron`", () => {
43
+ const r = run(["electron"]);
44
+ expect(r.status).toBe(0);
45
+ expect(r.stdout.trim()).toMatch(/[\\/]node_modules[\\/]electron$/);
46
+ expect(r.stderr).toBe("");
47
+ });
48
+
49
+ it("prints absolute path to stdout for `node-pty`", () => {
50
+ const r = run(["node-pty"]);
51
+ expect(r.status).toBe(0);
52
+ expect(r.stdout.trim()).toMatch(/[\\/]node_modules[\\/]node-pty$/);
53
+ expect(r.stderr).toBe("");
54
+ });
55
+
56
+ it("emits JSON Resolution shape with --json", () => {
57
+ const r = run(["electron", "--json"]);
58
+ expect(r.status).toBe(0);
59
+ const parsed = JSON.parse(r.stdout);
60
+ expect(parsed.name).toBe("electron");
61
+ expect(parsed.ok).toBe(true);
62
+ expect(parsed.path).toMatch(/electron$/);
63
+ expect(parsed.source).toBe("bare-import");
64
+ expect(Array.isArray(parsed.tried)).toBe(true);
65
+ // First strategy attempted is `override`, then `bare-import`.
66
+ expect(parsed.tried.map((t: { strategy: string }) => t.strategy)).toEqual([
67
+ "override",
68
+ "bare-import",
69
+ ]);
70
+ expect(typeof parsed.resolvedAt).toBe("number");
71
+ });
72
+
73
+ it("exits 1 with stderr when tool name is unknown", () => {
74
+ const r = run(["nonexistent-tool"]);
75
+ expect(r.status).toBe(1);
76
+ expect(r.stdout).toBe("");
77
+ expect(r.stderr).toContain("nonexistent-tool");
78
+ expect(r.stderr).toContain("not registered");
79
+ });
80
+
81
+ it("exits 1 with stderr when --json is passed for unknown tool", () => {
82
+ const r = run(["nonexistent-tool", "--json"]);
83
+ expect(r.status).toBe(1);
84
+ expect(r.stderr).toContain("not registered");
85
+ });
86
+
87
+ it("exits 1 with usage message when no tool name given", () => {
88
+ const r = run([]);
89
+ expect(r.status).toBe(1);
90
+ expect(r.stderr).toContain("usage:");
91
+ expect(r.stderr).toContain("registered:");
92
+ });
93
+
94
+ it("strategy chain order in --json mirrors definitions.ts", () => {
95
+ // Both build-time tools share the same chain shape: override → bare-import.
96
+ for (const tool of ["electron", "node-pty"]) {
97
+ const r = run([tool, "--json"]);
98
+ expect(r.status).toBe(0);
99
+ const parsed = JSON.parse(r.stdout);
100
+ expect(
101
+ parsed.tried.map((t: { strategy: string }) => t.strategy),
102
+ ).toEqual(["override", "bare-import"]);
103
+ }
104
+ });
105
+ });