@blackbelt-technology/pi-agent-dashboard 0.4.5 → 0.5.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 +342 -267
- package/README.md +51 -2
- package/docs/architecture.md +266 -25
- package/package.json +14 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-bus.test.ts +44 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- package/packages/extension/src/__tests__/vcs-info-jj.test.ts +145 -0
- package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
- package/packages/extension/src/bridge-context.ts +7 -0
- package/packages/extension/src/bridge.ts +142 -4
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/model-tracker.ts +35 -1
- package/packages/extension/src/prompt-bus.ts +4 -3
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +117 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +6 -1
- package/packages/extension/src/vcs-info.ts +184 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- package/packages/server/src/__tests__/is-unread-trigger.test.ts +4 -2
- package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
- package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +58 -21
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/cli.ts +22 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +57 -2
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- package/packages/server/src/openspec-tasks.ts +50 -19
- package/packages/server/src/pi-core-updater.ts +65 -9
- package/packages/server/src/pi-gateway.ts +6 -0
- package/packages/server/src/process-manager.ts +62 -11
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +41 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/jj-routes.ts +386 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/session-routes.ts +12 -3
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +16 -9
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-diff.ts +118 -1
- package/packages/server/src/session-discovery.ts +10 -3
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +236 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +29 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/diff-types.ts +17 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/jj.ts +405 -0
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +60 -2
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +19 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +91 -0
- package/packages/extension/src/git-info.ts +0 -55
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor markdown formatter — section ordering, escaping, remediation list.
|
|
3
|
+
* See change: doctor-rich-output (design.md Decision 8).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
formatDoctorReportMarkdown,
|
|
8
|
+
formatDoctorReportPlain,
|
|
9
|
+
type DoctorReport,
|
|
10
|
+
type DoctorCheck,
|
|
11
|
+
} from "../doctor-core.js";
|
|
12
|
+
|
|
13
|
+
function mkReport(checks: DoctorCheck[]): DoctorReport {
|
|
14
|
+
const summary = {
|
|
15
|
+
ok: checks.filter((c) => c.status === "ok").length,
|
|
16
|
+
warnings: checks.filter((c) => c.status === "warning").length,
|
|
17
|
+
errors: checks.filter((c) => c.status === "error").length,
|
|
18
|
+
};
|
|
19
|
+
return { checks, summary };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("formatDoctorReportMarkdown", () => {
|
|
23
|
+
it("emits one table per non-empty section in fixed order", () => {
|
|
24
|
+
const report = mkReport([
|
|
25
|
+
{ name: "API key", section: "setup", status: "ok", message: "Configured" },
|
|
26
|
+
{ name: "pi CLI", section: "pi-tooling", status: "ok", message: "v1" },
|
|
27
|
+
{ name: "Electron", section: "runtime", status: "ok", message: "v40" },
|
|
28
|
+
{
|
|
29
|
+
name: "Managed install (~/.pi-dashboard)",
|
|
30
|
+
section: "diagnostics",
|
|
31
|
+
status: "ok",
|
|
32
|
+
message: "fine",
|
|
33
|
+
},
|
|
34
|
+
]);
|
|
35
|
+
const md = formatDoctorReportMarkdown(report);
|
|
36
|
+
const runtimeIdx = md.indexOf("## Runtime");
|
|
37
|
+
const piIdx = md.indexOf("## PI Tooling");
|
|
38
|
+
const setupIdx = md.indexOf("## Setup");
|
|
39
|
+
const diagIdx = md.indexOf("## Diagnostics");
|
|
40
|
+
// Server section absent — skipped silently.
|
|
41
|
+
expect(md.includes("## Server")).toBe(false);
|
|
42
|
+
expect(runtimeIdx).toBeGreaterThan(0);
|
|
43
|
+
expect(runtimeIdx).toBeLessThan(piIdx);
|
|
44
|
+
expect(piIdx).toBeLessThan(setupIdx);
|
|
45
|
+
expect(setupIdx).toBeLessThan(diagIdx);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("includes summary line", () => {
|
|
49
|
+
const report = mkReport([
|
|
50
|
+
{ name: "Electron", section: "runtime", status: "ok", message: "v40" },
|
|
51
|
+
{ name: "pi CLI", section: "pi-tooling", status: "error", message: "missing", detail: "x" },
|
|
52
|
+
]);
|
|
53
|
+
const md = formatDoctorReportMarkdown(report);
|
|
54
|
+
expect(md).toMatch(/Summary:.*1 ok.*0 warning.*1 error/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("omits the Remediation section when all rows are ok", () => {
|
|
58
|
+
const report = mkReport([{ name: "Electron", section: "runtime", status: "ok", message: "v40" }]);
|
|
59
|
+
const md = formatDoctorReportMarkdown(report);
|
|
60
|
+
expect(md.includes("## Remediation")).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("renders Remediation bullets for non-ok rows with suggestions", () => {
|
|
64
|
+
const report = mkReport([
|
|
65
|
+
{
|
|
66
|
+
name: "pi CLI",
|
|
67
|
+
section: "pi-tooling",
|
|
68
|
+
status: "error",
|
|
69
|
+
message: "Not found",
|
|
70
|
+
detail: "PATH searched",
|
|
71
|
+
suggestion: "Run setup wizard.",
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
const md = formatDoctorReportMarkdown(report);
|
|
75
|
+
expect(md).toContain("## Remediation");
|
|
76
|
+
expect(md).toContain("- **pi CLI** — Run setup wizard.");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("escapes pipe / newline / backtick in detail so the table column count is preserved", () => {
|
|
80
|
+
const detail = "line1 | with pipe\nline2 with `backtick`";
|
|
81
|
+
const report = mkReport([
|
|
82
|
+
{
|
|
83
|
+
name: "Server launch test",
|
|
84
|
+
section: "server",
|
|
85
|
+
status: "error",
|
|
86
|
+
message: "boom",
|
|
87
|
+
detail,
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
const md = formatDoctorReportMarkdown(report);
|
|
91
|
+
// Find the row line in the output. Each row must have exactly 4 separators
|
|
92
|
+
// outside the leading/trailing ones — i.e. the row should start with `| `
|
|
93
|
+
// and contain exactly 5 `|` characters.
|
|
94
|
+
const tableRow = md
|
|
95
|
+
.split("\n")
|
|
96
|
+
.find((l) => l.startsWith("| ") && l.includes("Server launch test"));
|
|
97
|
+
expect(tableRow).toBeDefined();
|
|
98
|
+
// A 4-column row has 5 unescaped pipes. Count unescaped pipes.
|
|
99
|
+
const unescaped = tableRow!
|
|
100
|
+
.split("")
|
|
101
|
+
.reduce(
|
|
102
|
+
(acc, ch, i, arr) =>
|
|
103
|
+
ch === "|" && arr[i - 1] !== "\\" ? acc + 1 : acc,
|
|
104
|
+
0,
|
|
105
|
+
);
|
|
106
|
+
expect(unescaped).toBe(5);
|
|
107
|
+
// No literal newline inside the cell.
|
|
108
|
+
expect(tableRow!.includes("\n")).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("formatDoctorReportPlain (legacy)", () => {
|
|
113
|
+
it("contains the canonical header and summary footer", () => {
|
|
114
|
+
const report = mkReport([
|
|
115
|
+
{ name: "Electron", section: "runtime", status: "ok", message: "v40" },
|
|
116
|
+
]);
|
|
117
|
+
const out = formatDoctorReportPlain(report);
|
|
118
|
+
expect(out).toContain("PI Dashboard Doctor");
|
|
119
|
+
expect(out).toContain("1 passed, 0 warnings, 0 errors");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pin the call order in `installStandalone` (the Electron entry that
|
|
3
|
+
* the spec calls `installAllTools`):
|
|
4
|
+
*
|
|
5
|
+
* installManagedNode(...) < any sharedBootstrapInstall(...)
|
|
6
|
+
*
|
|
7
|
+
* We can't easily run installStandalone end-to-end here (it's Electron
|
|
8
|
+
* code, requires a packaged resources path, spawns real npm). So this
|
|
9
|
+
* is a script-text test that greps the dependency-installer source for
|
|
10
|
+
* the call sites and asserts their byte offsets are in the right order.
|
|
11
|
+
* If a refactor moves installManagedNode after the first npm install,
|
|
12
|
+
* the regression \u2014 a fresh Windows install having no managed Node when
|
|
13
|
+
* the very first install runs \u2014 cannot land silently.
|
|
14
|
+
*
|
|
15
|
+
* See change: embed-managed-node-runtime (task 4.3).
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import url from "node:url";
|
|
20
|
+
import { describe, expect, it } from "vitest";
|
|
21
|
+
|
|
22
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
23
|
+
// packages/shared/src/__tests__/ \u2192 ../../../electron/src/lib/dependency-installer.ts
|
|
24
|
+
const SOURCE_PATH = path.resolve(
|
|
25
|
+
__dirname,
|
|
26
|
+
"..",
|
|
27
|
+
"..",
|
|
28
|
+
"..",
|
|
29
|
+
"electron",
|
|
30
|
+
"src",
|
|
31
|
+
"lib",
|
|
32
|
+
"dependency-installer.ts",
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
describe("installStandalone call order", () => {
|
|
36
|
+
it("installManagedNode runs before sharedBootstrapInstall", () => {
|
|
37
|
+
const text = fs.readFileSync(SOURCE_PATH, "utf-8");
|
|
38
|
+
|
|
39
|
+
// Locate the installStandalone function body.
|
|
40
|
+
const fnIdx = text.indexOf("export async function installStandalone");
|
|
41
|
+
expect(fnIdx).toBeGreaterThan(-1);
|
|
42
|
+
|
|
43
|
+
// Scope the search to the function body — find its closing brace.
|
|
44
|
+
// Cheap bracket counter: starts after the first `{` after fnIdx.
|
|
45
|
+
const bodyStart = text.indexOf("{", fnIdx);
|
|
46
|
+
let depth = 0;
|
|
47
|
+
let bodyEnd = bodyStart;
|
|
48
|
+
for (let i = bodyStart; i < text.length; i++) {
|
|
49
|
+
const ch = text[i];
|
|
50
|
+
if (ch === "{") depth++;
|
|
51
|
+
else if (ch === "}") {
|
|
52
|
+
depth--;
|
|
53
|
+
if (depth === 0) {
|
|
54
|
+
bodyEnd = i;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const body = text.slice(bodyStart, bodyEnd);
|
|
60
|
+
|
|
61
|
+
const managedIdx = body.indexOf("installManagedNode(");
|
|
62
|
+
const sharedIdx = body.indexOf("sharedBootstrapInstall(");
|
|
63
|
+
|
|
64
|
+
expect(managedIdx).toBeGreaterThan(-1);
|
|
65
|
+
expect(sharedIdx).toBeGreaterThan(-1);
|
|
66
|
+
expect(managedIdx).toBeLessThan(sharedIdx);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `installManagedNode` from `bootstrap-install.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Uses a real tmp HOME (per the global setup-home tripwire) and a
|
|
5
|
+
* real-on-disk fake bundled-node directory. The bundled-Node version
|
|
6
|
+
* is read via the `_readVersion` test seam so we never spawn `node`
|
|
7
|
+
* here.
|
|
8
|
+
*
|
|
9
|
+
* See change: embed-managed-node-runtime (task 2.4).
|
|
10
|
+
*/
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
15
|
+
import { installManagedNode } from "../bootstrap-install.js";
|
|
16
|
+
|
|
17
|
+
const isWin = process.platform === "win32";
|
|
18
|
+
|
|
19
|
+
function makeFakeBundle(dir: string, opts?: { partial?: boolean }): void {
|
|
20
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
21
|
+
if (isWin) {
|
|
22
|
+
fs.writeFileSync(path.join(dir, "node.exe"), "fake-node-binary");
|
|
23
|
+
if (!opts?.partial) {
|
|
24
|
+
fs.writeFileSync(path.join(dir, "npm.cmd"), "@echo off\nnpm");
|
|
25
|
+
fs.writeFileSync(path.join(dir, "npx.cmd"), "@echo off\nnpx");
|
|
26
|
+
}
|
|
27
|
+
fs.mkdirSync(path.join(dir, "node_modules", "npm", "bin"), {
|
|
28
|
+
recursive: true,
|
|
29
|
+
});
|
|
30
|
+
fs.writeFileSync(
|
|
31
|
+
path.join(dir, "node_modules", "npm", "bin", "npm-cli.js"),
|
|
32
|
+
"// npm-cli",
|
|
33
|
+
);
|
|
34
|
+
} else {
|
|
35
|
+
fs.mkdirSync(path.join(dir, "bin"), { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(dir, "bin", "node"), "fake-node-binary");
|
|
37
|
+
fs.writeFileSync(path.join(dir, "bin", "npm"), "#!/bin/sh\nnpm");
|
|
38
|
+
fs.writeFileSync(path.join(dir, "bin", "npx"), "#!/bin/sh\nnpx");
|
|
39
|
+
fs.mkdirSync(path.join(dir, "lib", "node_modules", "npm", "bin"), {
|
|
40
|
+
recursive: true,
|
|
41
|
+
});
|
|
42
|
+
fs.writeFileSync(
|
|
43
|
+
path.join(dir, "lib", "node_modules", "npm", "bin", "npm-cli.js"),
|
|
44
|
+
"// npm-cli",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("installManagedNode", () => {
|
|
50
|
+
let tmpRoot: string;
|
|
51
|
+
let bundledDir: string;
|
|
52
|
+
let managedDir: string;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "managed-node-test-"));
|
|
56
|
+
bundledDir = path.join(tmpRoot, "bundled");
|
|
57
|
+
managedDir = path.join(tmpRoot, "managed");
|
|
58
|
+
makeFakeBundle(bundledDir);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("first run: copies tree and writes .version marker", async () => {
|
|
66
|
+
const r = await installManagedNode({
|
|
67
|
+
bundledNodeDir: bundledDir,
|
|
68
|
+
managedDir,
|
|
69
|
+
_readVersion: () => "v22.12.0",
|
|
70
|
+
});
|
|
71
|
+
expect(r.ok).toBe(true);
|
|
72
|
+
expect(r.copied).toBe(true);
|
|
73
|
+
expect(r.version).toBe("v22.12.0");
|
|
74
|
+
|
|
75
|
+
const targetBinary = isWin
|
|
76
|
+
? path.join(managedDir, "node", "node.exe")
|
|
77
|
+
: path.join(managedDir, "node", "bin", "node");
|
|
78
|
+
expect(fs.existsSync(targetBinary)).toBe(true);
|
|
79
|
+
|
|
80
|
+
const marker = fs.readFileSync(
|
|
81
|
+
path.join(managedDir, "node", ".version"),
|
|
82
|
+
"utf-8",
|
|
83
|
+
);
|
|
84
|
+
expect(marker.trim()).toBe("v22.12.0");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("idempotent re-run with matching version is a no-op", async () => {
|
|
88
|
+
await installManagedNode({
|
|
89
|
+
bundledNodeDir: bundledDir,
|
|
90
|
+
managedDir,
|
|
91
|
+
_readVersion: () => "v22.12.0",
|
|
92
|
+
});
|
|
93
|
+
const targetBinary = isWin
|
|
94
|
+
? path.join(managedDir, "node", "node.exe")
|
|
95
|
+
: path.join(managedDir, "node", "bin", "node");
|
|
96
|
+
const mtimeBefore = fs.statSync(targetBinary).mtimeMs;
|
|
97
|
+
|
|
98
|
+
// Wait one tick to ensure mtime would change if we recopied.
|
|
99
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
100
|
+
|
|
101
|
+
const r = await installManagedNode({
|
|
102
|
+
bundledNodeDir: bundledDir,
|
|
103
|
+
managedDir,
|
|
104
|
+
_readVersion: () => "v22.12.0",
|
|
105
|
+
});
|
|
106
|
+
expect(r.ok).toBe(true);
|
|
107
|
+
expect(r.copied).toBe(false);
|
|
108
|
+
expect(r.reason).toMatch(/version matches/);
|
|
109
|
+
const mtimeAfter = fs.statSync(targetBinary).mtimeMs;
|
|
110
|
+
expect(mtimeAfter).toBe(mtimeBefore);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("mismatched version triggers a re-copy", async () => {
|
|
114
|
+
await installManagedNode({
|
|
115
|
+
bundledNodeDir: bundledDir,
|
|
116
|
+
managedDir,
|
|
117
|
+
_readVersion: () => "v22.12.0",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Bundle now reports a newer version.
|
|
121
|
+
const r = await installManagedNode({
|
|
122
|
+
bundledNodeDir: bundledDir,
|
|
123
|
+
managedDir,
|
|
124
|
+
_readVersion: () => "v22.13.0",
|
|
125
|
+
});
|
|
126
|
+
expect(r.ok).toBe(true);
|
|
127
|
+
expect(r.copied).toBe(true);
|
|
128
|
+
expect(r.version).toBe("v22.13.0");
|
|
129
|
+
|
|
130
|
+
const marker = fs.readFileSync(
|
|
131
|
+
path.join(managedDir, "node", ".version"),
|
|
132
|
+
"utf-8",
|
|
133
|
+
);
|
|
134
|
+
expect(marker.trim()).toBe("v22.13.0");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("missing bundled source: no-op without error", async () => {
|
|
138
|
+
const r = await installManagedNode({
|
|
139
|
+
bundledNodeDir: null,
|
|
140
|
+
managedDir,
|
|
141
|
+
});
|
|
142
|
+
expect(r.ok).toBe(true);
|
|
143
|
+
expect(r.copied).toBe(false);
|
|
144
|
+
expect(r.reason).toMatch(/no bundled source/);
|
|
145
|
+
expect(fs.existsSync(path.join(managedDir, "node"))).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("bundled binary missing: no-op (treat as no source)", async () => {
|
|
149
|
+
const emptyBundled = path.join(tmpRoot, "empty");
|
|
150
|
+
fs.mkdirSync(emptyBundled, { recursive: true });
|
|
151
|
+
const r = await installManagedNode({
|
|
152
|
+
bundledNodeDir: emptyBundled,
|
|
153
|
+
managedDir,
|
|
154
|
+
_readVersion: () => null,
|
|
155
|
+
});
|
|
156
|
+
expect(r.ok).toBe(true);
|
|
157
|
+
expect(r.copied).toBe(false);
|
|
158
|
+
expect(r.reason).toMatch(/bundled node binary/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("dir present but marker missing: re-copies (treats as mismatch)", async () => {
|
|
162
|
+
// Pretend a partial copy left behind a directory with no marker.
|
|
163
|
+
fs.mkdirSync(path.join(managedDir, "node", "leftover"), { recursive: true });
|
|
164
|
+
|
|
165
|
+
const r = await installManagedNode({
|
|
166
|
+
bundledNodeDir: bundledDir,
|
|
167
|
+
managedDir,
|
|
168
|
+
_readVersion: () => "v22.12.0",
|
|
169
|
+
});
|
|
170
|
+
expect(r.ok).toBe(true);
|
|
171
|
+
expect(r.copied).toBe(true);
|
|
172
|
+
// Leftover should be gone after the rm-then-copy.
|
|
173
|
+
expect(
|
|
174
|
+
fs.existsSync(path.join(managedDir, "node", "leftover")),
|
|
175
|
+
).toBe(false);
|
|
176
|
+
expect(
|
|
177
|
+
fs.existsSync(path.join(managedDir, "node", ".version")),
|
|
178
|
+
).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("reports progress through the callback", async () => {
|
|
182
|
+
const events: Array<{ step: string; status: string }> = [];
|
|
183
|
+
await installManagedNode({
|
|
184
|
+
bundledNodeDir: bundledDir,
|
|
185
|
+
managedDir,
|
|
186
|
+
_readVersion: () => "v22.12.0",
|
|
187
|
+
progress: (p) => events.push({ step: p.step, status: p.status }),
|
|
188
|
+
});
|
|
189
|
+
expect(events.some((e) => e.step === "node-runtime" && e.status === "running")).toBe(true);
|
|
190
|
+
expect(events.some((e) => e.step === "node-runtime" && e.status === "done")).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mergeInstallableList, readInstallableList, writeInstallableList } from "../installable-list.js";
|
|
3
|
+
import type { InstallableList } from "../installable-list.js";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
// ── mergeInstallableList tests ──────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
describe("mergeInstallableList", () => {
|
|
11
|
+
it("keep-user-pin: user version wins when it differs from bundled", () => {
|
|
12
|
+
const existing: InstallableList = {
|
|
13
|
+
version: "1",
|
|
14
|
+
packages: [{ name: "tsx", version: "^4.0.0", required: true, kind: "npm" }],
|
|
15
|
+
};
|
|
16
|
+
const bundled: InstallableList = {
|
|
17
|
+
version: "2",
|
|
18
|
+
packages: [{ name: "tsx", version: "^5.0.0", required: true, kind: "npm" }],
|
|
19
|
+
};
|
|
20
|
+
const { list, warnings } = mergeInstallableList(existing, bundled);
|
|
21
|
+
const found = list.packages.find((p) => p.name === "tsx");
|
|
22
|
+
expect(found?.version).toBe("^4.0.0");
|
|
23
|
+
expect(warnings.length).toBeGreaterThan(0);
|
|
24
|
+
expect(warnings[0]).toContain("tsx");
|
|
25
|
+
expect(warnings[0]).toContain("^4.0.0");
|
|
26
|
+
expect(warnings[0]).toContain("^5.0.0");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("drop-pin-warn: package in existing but not in bundled gets deprecated=true + warning", () => {
|
|
30
|
+
const existing: InstallableList = {
|
|
31
|
+
version: "1",
|
|
32
|
+
packages: [{ name: "old-tool", version: "^1.0.0", required: false, kind: "npm" }],
|
|
33
|
+
};
|
|
34
|
+
const bundled: InstallableList = { version: "2", packages: [] };
|
|
35
|
+
const { list, warnings } = mergeInstallableList(existing, bundled);
|
|
36
|
+
const found = list.packages.find((p) => p.name === "old-tool");
|
|
37
|
+
expect(found?.deprecated).toBe(true);
|
|
38
|
+
expect(warnings.length).toBeGreaterThan(0);
|
|
39
|
+
expect(warnings[0]).toContain("old-tool");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("add-new-required: new required package from bundled is added as-is", () => {
|
|
43
|
+
const existing: InstallableList = { version: "1", packages: [] };
|
|
44
|
+
const bundled: InstallableList = {
|
|
45
|
+
version: "2",
|
|
46
|
+
packages: [{ name: "pi", version: "*", required: true, kind: "npm" }],
|
|
47
|
+
};
|
|
48
|
+
const { list, warnings } = mergeInstallableList(existing, bundled);
|
|
49
|
+
const found = list.packages.find((p) => p.name === "pi");
|
|
50
|
+
expect(found).toBeDefined();
|
|
51
|
+
expect(found?.required).toBe(true);
|
|
52
|
+
expect(found?.defaultOff).toBeFalsy();
|
|
53
|
+
expect(warnings).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("add-new-optional: new optional package from bundled is added with defaultOff=true", () => {
|
|
57
|
+
const existing: InstallableList = { version: "1", packages: [] };
|
|
58
|
+
const bundled: InstallableList = {
|
|
59
|
+
version: "2",
|
|
60
|
+
packages: [{ name: "openspec", version: "*", required: false, kind: "npm" }],
|
|
61
|
+
};
|
|
62
|
+
const { list, warnings } = mergeInstallableList(existing, bundled);
|
|
63
|
+
const found = list.packages.find((p) => p.name === "openspec");
|
|
64
|
+
expect(found).toBeDefined();
|
|
65
|
+
expect(found?.defaultOff).toBe(true);
|
|
66
|
+
expect(warnings).toHaveLength(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("version marker in result comes from bundled", () => {
|
|
70
|
+
const existing: InstallableList = { version: "1", packages: [] };
|
|
71
|
+
const bundled: InstallableList = { version: "42", packages: [] };
|
|
72
|
+
const { list } = mergeInstallableList(existing, bundled);
|
|
73
|
+
expect(list.version).toBe("42");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── readInstallableList tests ───────────────────────────────────────────────
|
|
78
|
+
// Use a real temp directory (HOME is already ephemeral in the test runner).
|
|
79
|
+
|
|
80
|
+
describe("readInstallableList", () => {
|
|
81
|
+
let tmpDir: string;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "installable-test-"));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
89
|
+
vi.restoreAllMocks();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns null when file is absent", async () => {
|
|
93
|
+
const result = await readInstallableList(tmpDir);
|
|
94
|
+
expect(result).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("drops entries with invalid kind and warns", async () => {
|
|
98
|
+
const list: InstallableList = {
|
|
99
|
+
version: "1",
|
|
100
|
+
packages: [
|
|
101
|
+
{ name: "good-pkg", version: "*", required: true, kind: "npm" },
|
|
102
|
+
{ name: "bad-pkg", version: "*", required: true, kind: "unknown-kind" as any },
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
// Write via writeInstallableList (bypasses the drop-invalid-kind guard).
|
|
106
|
+
const filePath = path.join(tmpDir, "installable.json");
|
|
107
|
+
fs.writeFileSync(filePath, JSON.stringify(list), "utf-8");
|
|
108
|
+
|
|
109
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
110
|
+
const result = await readInstallableList(tmpDir);
|
|
111
|
+
|
|
112
|
+
expect(result).not.toBeNull();
|
|
113
|
+
expect(result!.packages.map((p) => p.name)).toEqual(["good-pkg"]);
|
|
114
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
115
|
+
expect(warnSpy.mock.calls[0]![0]).toContain("bad-pkg");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("reads a valid file and returns the list", async () => {
|
|
119
|
+
const list: InstallableList = {
|
|
120
|
+
version: "3",
|
|
121
|
+
packages: [{ name: "tsx", version: "^5.0.0", required: true, kind: "npm" }],
|
|
122
|
+
};
|
|
123
|
+
await writeInstallableList(list, tmpDir);
|
|
124
|
+
const result = await readInstallableList(tmpDir);
|
|
125
|
+
expect(result).not.toBeNull();
|
|
126
|
+
expect(result!.version).toBe("3");
|
|
127
|
+
expect(result!.packages).toHaveLength(1);
|
|
128
|
+
expect(result!.packages[0]!.name).toBe("tsx");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `prependManagedNodeToPath` and friends.
|
|
3
|
+
*
|
|
4
|
+
* Real tmp HOME (per setup-home tripwire) so `getManagedDir()` resolves
|
|
5
|
+
* under the tmp tree. We create / remove the managed Node binary on
|
|
6
|
+
* disk to flip the present/absent branches.
|
|
7
|
+
*
|
|
8
|
+
* See change: embed-managed-node-runtime (task 2.5).
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
14
|
+
import {
|
|
15
|
+
getManagedNodeBinDir,
|
|
16
|
+
getManagedNodeBinary,
|
|
17
|
+
isManagedNodePresent,
|
|
18
|
+
prependManagedNodeToPath,
|
|
19
|
+
} from "../platform/managed-node-path.js";
|
|
20
|
+
import { getManagedDir } from "../managed-paths.js";
|
|
21
|
+
|
|
22
|
+
const isWin = process.platform === "win32";
|
|
23
|
+
|
|
24
|
+
describe("getManagedNodeBinDir", () => {
|
|
25
|
+
it("returns <managedDir>/node on Windows", () => {
|
|
26
|
+
const env = { homedir: "/fake/home" };
|
|
27
|
+
expect(getManagedNodeBinDir(env, "win32")).toBe(
|
|
28
|
+
path.join("/fake/home", ".pi-dashboard", "node"),
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns <managedDir>/node/bin on Unix", () => {
|
|
33
|
+
const env = { homedir: "/fake/home" };
|
|
34
|
+
expect(getManagedNodeBinDir(env, "linux")).toBe(
|
|
35
|
+
path.join("/fake/home", ".pi-dashboard", "node", "bin"),
|
|
36
|
+
);
|
|
37
|
+
expect(getManagedNodeBinDir(env, "darwin")).toBe(
|
|
38
|
+
path.join("/fake/home", ".pi-dashboard", "node", "bin"),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("getManagedNodeBinary", () => {
|
|
44
|
+
it("uses node.exe on Windows", () => {
|
|
45
|
+
expect(getManagedNodeBinary({ homedir: "/h" }, "win32")).toBe(
|
|
46
|
+
path.join("/h", ".pi-dashboard", "node", "node.exe"),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("uses bin/node on Unix", () => {
|
|
51
|
+
expect(getManagedNodeBinary({ homedir: "/h" }, "linux")).toBe(
|
|
52
|
+
path.join("/h", ".pi-dashboard", "node", "bin", "node"),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("prependManagedNodeToPath", () => {
|
|
58
|
+
let tmpHome: string;
|
|
59
|
+
let origHome: string | undefined;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "managed-node-path-"));
|
|
63
|
+
origHome = process.env.HOME;
|
|
64
|
+
process.env.HOME = tmpHome;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
if (origHome === undefined) delete process.env.HOME;
|
|
69
|
+
else process.env.HOME = origHome;
|
|
70
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function installFakeManagedNode(): string {
|
|
74
|
+
// os.homedir() reads HOME on Unix and USERPROFILE on Windows. The
|
|
75
|
+
// setup-home tripwire only sets HOME, so on Windows tests we set
|
|
76
|
+
// USERPROFILE here too.
|
|
77
|
+
if (isWin) process.env.USERPROFILE = tmpHome;
|
|
78
|
+
const dir = getManagedNodeBinDir();
|
|
79
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
80
|
+
const binary = getManagedNodeBinary();
|
|
81
|
+
fs.writeFileSync(binary, "fake");
|
|
82
|
+
return dir;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
it("no-op (returns clone) when managed runtime is absent", () => {
|
|
86
|
+
const base = { PATH: "/usr/bin:/bin", FOO: "bar" };
|
|
87
|
+
const out = prependManagedNodeToPath(base);
|
|
88
|
+
expect(out).not.toBe(base);
|
|
89
|
+
expect(out.PATH).toBe("/usr/bin:/bin");
|
|
90
|
+
expect(out.FOO).toBe("bar");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("does not mutate process.env", () => {
|
|
94
|
+
const beforePath = process.env.PATH;
|
|
95
|
+
prependManagedNodeToPath();
|
|
96
|
+
expect(process.env.PATH).toBe(beforePath);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("prepends managed Node bin dir when present", () => {
|
|
100
|
+
const dir = installFakeManagedNode();
|
|
101
|
+
const base = { PATH: "/usr/bin:/bin", X: "y" };
|
|
102
|
+
const out = prependManagedNodeToPath(base);
|
|
103
|
+
expect(out).not.toBe(base);
|
|
104
|
+
expect(out.PATH?.startsWith(dir)).toBe(true);
|
|
105
|
+
expect(out.PATH).toBe(`${dir}${path.delimiter}/usr/bin:/bin`);
|
|
106
|
+
expect(out.X).toBe("y");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("does not double-prepend when dir already at head", () => {
|
|
110
|
+
const dir = installFakeManagedNode();
|
|
111
|
+
const initial = `${dir}${path.delimiter}/usr/bin`;
|
|
112
|
+
const base = { PATH: initial };
|
|
113
|
+
const out = prependManagedNodeToPath(base);
|
|
114
|
+
expect(out.PATH).toBe(initial);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("isManagedNodePresent flips with the binary on disk", () => {
|
|
118
|
+
expect(isManagedNodePresent()).toBe(false);
|
|
119
|
+
installFakeManagedNode();
|
|
120
|
+
expect(isManagedNodePresent()).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|