@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
|
@@ -3,13 +3,18 @@
|
|
|
3
3
|
* The spawned server runs in foreground mode (no subcommand) and writes
|
|
4
4
|
* its own PID file at ~/.pi/dashboard/server.pid.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { spawnDetached, waitForReady } from "@blackbelt-technology/pi-dashboard-shared/platform/detached-spawn.js";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
7
9
|
import path from "node:path";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
8
11
|
import { fileURLToPath } from "node:url";
|
|
9
12
|
import type { DashboardConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
10
13
|
import { resolveJitiImport } from "@blackbelt-technology/pi-dashboard-shared/resolve-jiti.js";
|
|
14
|
+
import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
|
|
11
15
|
|
|
12
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
13
18
|
|
|
14
19
|
export interface LaunchResult {
|
|
15
20
|
success: boolean;
|
|
@@ -17,11 +22,26 @@ export interface LaunchResult {
|
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
/**
|
|
20
|
-
* Resolve the dashboard server CLI script path
|
|
21
|
-
*
|
|
25
|
+
* Resolve the dashboard server CLI script path.
|
|
26
|
+
*
|
|
27
|
+
* Handles two layouts:
|
|
28
|
+
* 1. Monorepo dev: `<repo>/packages/extension/src/` → `<repo>/packages/server/src/cli.ts`
|
|
29
|
+
* 2. Installed : `<x>/node_modules/@blackbelt-technology/pi-dashboard-extension/src/`
|
|
30
|
+
* → `<x>/node_modules/@blackbelt-technology/pi-dashboard-server/src/cli.ts`
|
|
31
|
+
*
|
|
32
|
+
* Uses Node's module resolver (`require.resolve`) to find the server package
|
|
33
|
+
* and joins `src/cli.ts`. Falls back to the monorepo-relative path so existing
|
|
34
|
+
* dev workflows keep working even if the server package isn't resolvable (e.g.
|
|
35
|
+
* a pristine checkout with no node_modules yet).
|
|
22
36
|
*/
|
|
23
37
|
export function resolveServerCliPath(): string {
|
|
24
|
-
|
|
38
|
+
try {
|
|
39
|
+
const serverPkgJson = require.resolve("@blackbelt-technology/pi-dashboard-server/package.json");
|
|
40
|
+
return path.resolve(path.dirname(serverPkgJson), "src", "cli.ts");
|
|
41
|
+
} catch {
|
|
42
|
+
// Dev-repo fallback: <extension>/src/../../server/src/cli.ts
|
|
43
|
+
return path.resolve(__dirname, "..", "..", "server", "src", "cli.ts");
|
|
44
|
+
}
|
|
25
45
|
}
|
|
26
46
|
|
|
27
47
|
/**
|
|
@@ -43,36 +63,60 @@ export async function launchServer(config: DashboardConfig): Promise<LaunchResul
|
|
|
43
63
|
const args = buildSpawnArgs(config);
|
|
44
64
|
|
|
45
65
|
try {
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
66
|
+
// Open the server.log in append mode so any startup error is visible.
|
|
67
|
+
// Matches the log location used by `pi-dashboard start`.
|
|
68
|
+
let logFd: number | undefined;
|
|
69
|
+
try {
|
|
70
|
+
const logDir = path.join(os.homedir(), ".pi", "dashboard");
|
|
71
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
72
|
+
const logPath = path.join(logDir, "server.log");
|
|
73
|
+
logFd = fs.openSync(logPath, "a");
|
|
74
|
+
fs.writeSync(
|
|
75
|
+
logFd,
|
|
76
|
+
`\n[${new Date().toISOString()}] bridge auto-start (parent pid ${process.pid}, port ${config.port})\n`,
|
|
77
|
+
);
|
|
78
|
+
} catch { /* if we can't open the log, spawn still works */ }
|
|
79
|
+
|
|
80
|
+
// Spawn server via the detached-spawn primitive. resolveJitiImport()
|
|
81
|
+
// returns a file:// URL (required on Windows for node --import).
|
|
82
|
+
const r = await spawnDetached({
|
|
83
|
+
cmd: process.execPath,
|
|
84
|
+
args: ["--import", resolveJitiImport(), cliPath, ...args],
|
|
52
85
|
env: { ...process.env },
|
|
86
|
+
logFd,
|
|
53
87
|
});
|
|
54
88
|
|
|
55
|
-
child.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const timer = setTimeout(() => {
|
|
60
|
-
resolve(false); // No early exit — server is running
|
|
61
|
-
}, 2000);
|
|
89
|
+
// Close the parent's copy of the log fd — the child has its own.
|
|
90
|
+
if (logFd !== undefined) {
|
|
91
|
+
try { fs.closeSync(logFd); } catch { /* ignore */ }
|
|
92
|
+
}
|
|
62
93
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
});
|
|
94
|
+
if (!r.ok || !r.process) {
|
|
95
|
+
return { success: false, message: `Server process failed to spawn: ${r.error ?? "unknown"}` };
|
|
96
|
+
}
|
|
67
97
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
98
|
+
// Wait for the server to actually become available via positive
|
|
99
|
+
// HTTP probe. NO deadline — we rely on child-exit for failure
|
|
100
|
+
// detection. A timeout here only catches the pathological case
|
|
101
|
+
// "process alive but never ready", which is rarer than the
|
|
102
|
+
// false-positive case "slow cold-start mistakenly flagged as
|
|
103
|
+
// failure" (Fastify + jiti compile + session scan can take 15–30s
|
|
104
|
+
// on Windows). If the child crashes, `waitForReady` returns
|
|
105
|
+
// { ok: false, error: "child exited with code N" } via its
|
|
106
|
+
// `child` listener. If the child hangs alive-but-broken, the user
|
|
107
|
+
// can kill it manually — timers don't help that case anyway.
|
|
108
|
+
const ready = await waitForReady({
|
|
109
|
+
probe: async () => (await isDashboardRunning(config.port)).running,
|
|
110
|
+
pollIntervalMs: 300,
|
|
111
|
+
child: r.process,
|
|
112
|
+
// deadlineMs intentionally omitted — wait indefinitely.
|
|
72
113
|
});
|
|
73
114
|
|
|
74
|
-
if (
|
|
75
|
-
return {
|
|
115
|
+
if (!ready.ok) {
|
|
116
|
+
return {
|
|
117
|
+
success: false,
|
|
118
|
+
message: `Server process failed: ${ready.error ?? "unknown"}. See ~/.pi/dashboard/server.log`,
|
|
119
|
+
};
|
|
76
120
|
}
|
|
77
121
|
|
|
78
122
|
return { success: true, message: "Server started" };
|
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blackbelt-technology/pi-dashboard-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Dashboard server for monitoring and interacting with pi agent sessions",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=22.18.0"
|
|
11
|
+
},
|
|
12
|
+
"piCompatibility": {
|
|
13
|
+
"minimum": "0.6.7",
|
|
14
|
+
"recommended": "0.6.7",
|
|
15
|
+
"maximum": null
|
|
16
|
+
},
|
|
6
17
|
"main": "src/cli.ts",
|
|
7
18
|
"bin": {
|
|
8
19
|
"pi-dashboard": "src/cli.ts"
|
|
@@ -15,7 +26,8 @@
|
|
|
15
26
|
"postinstall": "node scripts/fix-pty-permissions.cjs"
|
|
16
27
|
},
|
|
17
28
|
"dependencies": {
|
|
18
|
-
"@blackbelt-technology/pi-dashboard-
|
|
29
|
+
"@blackbelt-technology/pi-dashboard-extension": "^0.4.0",
|
|
30
|
+
"@blackbelt-technology/pi-dashboard-shared": "^0.4.0",
|
|
19
31
|
"@fastify/compress": "^8.3.1",
|
|
20
32
|
"@fastify/cookie": "^11.0.2",
|
|
21
33
|
"@fastify/cors": "^11.0.0",
|
|
@@ -28,11 +40,13 @@
|
|
|
28
40
|
"fastify": "^5.0.0",
|
|
29
41
|
"jsonwebtoken": "^9.0.3",
|
|
30
42
|
"node-pty": "^1.1.0",
|
|
43
|
+
"proper-lockfile": "^4.1.2",
|
|
31
44
|
"ws": "^8.18.0"
|
|
32
45
|
},
|
|
33
46
|
"devDependencies": {
|
|
34
47
|
"@types/diff": "^7.0.0",
|
|
35
48
|
"@types/jsonwebtoken": "^9.0.9",
|
|
49
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
36
50
|
"@types/ws": "^8.18.1"
|
|
37
51
|
}
|
|
38
52
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the in-memory bootstrap ticket queue.
|
|
3
|
+
*
|
|
4
|
+
* See change: unified-bootstrap-install.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import { createBootstrapQueue } from "../bootstrap-queue.js";
|
|
8
|
+
|
|
9
|
+
describe("bootstrap-queue", () => {
|
|
10
|
+
it("enqueue returns a unique ticketId + pending result", () => {
|
|
11
|
+
const q = createBootstrapQueue();
|
|
12
|
+
const a = q.enqueue(async () => "A");
|
|
13
|
+
const b = q.enqueue(async () => "B");
|
|
14
|
+
expect(a.ticketId).not.toBe(b.ticketId);
|
|
15
|
+
expect(q.size()).toBe(2);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("flushAll runs handlers in enqueue order and resolves results", async () => {
|
|
19
|
+
const q = createBootstrapQueue();
|
|
20
|
+
const order: string[] = [];
|
|
21
|
+
const a = q.enqueue(async () => {
|
|
22
|
+
order.push("a");
|
|
23
|
+
return "A";
|
|
24
|
+
});
|
|
25
|
+
const b = q.enqueue(async () => {
|
|
26
|
+
order.push("b");
|
|
27
|
+
return "B";
|
|
28
|
+
});
|
|
29
|
+
await q.flushAll();
|
|
30
|
+
expect(order).toEqual(["a", "b"]);
|
|
31
|
+
await expect(a.result).resolves.toBe("A");
|
|
32
|
+
await expect(b.result).resolves.toBe("B");
|
|
33
|
+
expect(q.size()).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("handler exceptions reject the ticket promise", async () => {
|
|
37
|
+
const q = createBootstrapQueue();
|
|
38
|
+
const t = q.enqueue(async () => {
|
|
39
|
+
throw new Error("boom");
|
|
40
|
+
});
|
|
41
|
+
await q.flushAll();
|
|
42
|
+
await expect(t.result).rejects.toThrow("boom");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("onTicketComplete fires success=true for resolved handlers", async () => {
|
|
46
|
+
const q = createBootstrapQueue();
|
|
47
|
+
const events: Array<{ ticketId: string; success: boolean; error?: string }> = [];
|
|
48
|
+
q.onTicketComplete((e) => events.push(e));
|
|
49
|
+
const t = q.enqueue(async () => 42);
|
|
50
|
+
await q.flushAll();
|
|
51
|
+
await t.result;
|
|
52
|
+
expect(events).toEqual([{ ticketId: t.ticketId, success: true }]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("onTicketComplete fires success=false with error message on rejection", async () => {
|
|
56
|
+
const q = createBootstrapQueue();
|
|
57
|
+
const events: Array<{ ticketId: string; success: boolean; error?: string }> = [];
|
|
58
|
+
q.onTicketComplete((e) => events.push(e));
|
|
59
|
+
const t = q.enqueue(async () => {
|
|
60
|
+
throw new Error("oh no");
|
|
61
|
+
});
|
|
62
|
+
await q.flushAll();
|
|
63
|
+
await t.result.catch(() => undefined);
|
|
64
|
+
expect(events).toEqual([
|
|
65
|
+
{ ticketId: t.ticketId, success: false, error: "oh no" },
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("onTicketComplete returns an unsubscribe function", async () => {
|
|
70
|
+
const q = createBootstrapQueue();
|
|
71
|
+
const events: unknown[] = [];
|
|
72
|
+
const off = q.onTicketComplete((e) => events.push(e));
|
|
73
|
+
off();
|
|
74
|
+
q.enqueue(async () => "x");
|
|
75
|
+
await q.flushAll();
|
|
76
|
+
expect(events).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("clear drops pending tickets with an error result and broadcasts completion", async () => {
|
|
80
|
+
const q = createBootstrapQueue();
|
|
81
|
+
const events: Array<{ ticketId: string; success: boolean; error?: string }> = [];
|
|
82
|
+
q.onTicketComplete((e) => events.push(e));
|
|
83
|
+
const t = q.enqueue(async () => "never runs");
|
|
84
|
+
q.clear("server shutting down");
|
|
85
|
+
await t.result.catch(() => undefined);
|
|
86
|
+
expect(events).toHaveLength(1);
|
|
87
|
+
expect(events[0]).toMatchObject({
|
|
88
|
+
ticketId: t.ticketId,
|
|
89
|
+
success: false,
|
|
90
|
+
error: "server shutting down",
|
|
91
|
+
});
|
|
92
|
+
expect(q.size()).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("multiple listeners all receive the completion event", async () => {
|
|
96
|
+
const q = createBootstrapQueue();
|
|
97
|
+
const a: unknown[] = [];
|
|
98
|
+
const b: unknown[] = [];
|
|
99
|
+
q.onTicketComplete((e) => a.push(e));
|
|
100
|
+
q.onTicketComplete((e) => b.push(e));
|
|
101
|
+
const t = q.enqueue(async () => "ok");
|
|
102
|
+
await q.flushAll();
|
|
103
|
+
await t.result;
|
|
104
|
+
expect(a).toHaveLength(1);
|
|
105
|
+
expect(b).toHaveLength(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("a listener that throws does not block other listeners", async () => {
|
|
109
|
+
const q = createBootstrapQueue();
|
|
110
|
+
const seen: unknown[] = [];
|
|
111
|
+
q.onTicketComplete(() => {
|
|
112
|
+
throw new Error("listener crash");
|
|
113
|
+
});
|
|
114
|
+
q.onTicketComplete((e) => seen.push(e));
|
|
115
|
+
const t = q.enqueue(async () => "ok");
|
|
116
|
+
await q.flushAll();
|
|
117
|
+
await t.result;
|
|
118
|
+
expect(seen).toHaveLength(1);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route tests for `/api/bootstrap/*`.
|
|
3
|
+
*
|
|
4
|
+
* Spins up a minimal Fastify instance with the bootstrap routes wired
|
|
5
|
+
* to a fresh state store and a pair of spy triggers. No real network
|
|
6
|
+
* access, no real subprocesses.
|
|
7
|
+
*
|
|
8
|
+
* See change: unified-bootstrap-install.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import Fastify, { type FastifyInstance } from "fastify";
|
|
12
|
+
import { createBootstrapState, type BootstrapStateStore } from "../bootstrap-state.js";
|
|
13
|
+
import { registerBootstrapRoutes } from "../routes/bootstrap-routes.js";
|
|
14
|
+
|
|
15
|
+
const noopGuard = async () => {
|
|
16
|
+
/* allow all requests in tests */
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
interface Harness {
|
|
20
|
+
app: FastifyInstance;
|
|
21
|
+
state: BootstrapStateStore;
|
|
22
|
+
upgradeCalls: string[];
|
|
23
|
+
retryCalls: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function makeHarness(): Promise<Harness> {
|
|
27
|
+
const app = Fastify({ logger: false });
|
|
28
|
+
const state = createBootstrapState();
|
|
29
|
+
const upgradeCalls: string[] = [];
|
|
30
|
+
const retryCalls: string[] = [];
|
|
31
|
+
|
|
32
|
+
registerBootstrapRoutes(app, {
|
|
33
|
+
bootstrapState: state,
|
|
34
|
+
networkGuard: noopGuard,
|
|
35
|
+
triggerUpgradePi: async (ticketId) => {
|
|
36
|
+
upgradeCalls.push(ticketId);
|
|
37
|
+
},
|
|
38
|
+
triggerRetry: async (ticketId) => {
|
|
39
|
+
retryCalls.push(ticketId);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await app.ready();
|
|
44
|
+
return { app, state, upgradeCalls, retryCalls };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("bootstrap-routes", () => {
|
|
48
|
+
let h: Harness;
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
h = await makeHarness();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
await h.app.close();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("GET /api/bootstrap/status", () => {
|
|
59
|
+
it("returns the current state (default ready)", async () => {
|
|
60
|
+
const res = await h.app.inject({ method: "GET", url: "/api/bootstrap/status" });
|
|
61
|
+
expect(res.statusCode).toBe(200);
|
|
62
|
+
expect(res.json()).toEqual({ status: "ready" });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("reflects subsequent state changes", async () => {
|
|
66
|
+
h.state.set({ status: "installing", progress: { step: "pi" } });
|
|
67
|
+
const res = await h.app.inject({ method: "GET", url: "/api/bootstrap/status" });
|
|
68
|
+
expect(res.json()).toMatchObject({
|
|
69
|
+
status: "installing",
|
|
70
|
+
progress: { step: "pi" },
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("POST /api/bootstrap/upgrade-pi", () => {
|
|
76
|
+
it("returns 202 with a ticketId and invokes the trigger", async () => {
|
|
77
|
+
const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/upgrade-pi" });
|
|
78
|
+
expect(res.statusCode).toBe(202);
|
|
79
|
+
const body = res.json() as { ticketId: string; status: string };
|
|
80
|
+
expect(body.status).toBe("accepted");
|
|
81
|
+
expect(typeof body.ticketId).toBe("string");
|
|
82
|
+
expect(body.ticketId.length).toBeGreaterThan(0);
|
|
83
|
+
// Trigger runs async — await a microtask.
|
|
84
|
+
await new Promise((r) => setImmediate(r));
|
|
85
|
+
expect(h.upgradeCalls).toEqual([body.ticketId]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns 409 when an install is already in progress", async () => {
|
|
89
|
+
h.state.set({ status: "installing" });
|
|
90
|
+
const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/upgrade-pi" });
|
|
91
|
+
expect(res.statusCode).toBe(409);
|
|
92
|
+
expect(h.upgradeCalls).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("is allowed when status is failed (to upgrade after a previous failure)", async () => {
|
|
96
|
+
h.state.set({ status: "failed", error: { message: "network" } });
|
|
97
|
+
const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/upgrade-pi" });
|
|
98
|
+
expect(res.statusCode).toBe(202);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("POST /api/bootstrap/retry", () => {
|
|
103
|
+
it("returns 409 when status is ready", async () => {
|
|
104
|
+
const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/retry" });
|
|
105
|
+
expect(res.statusCode).toBe(409);
|
|
106
|
+
expect(h.retryCalls).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns 409 when status is installing", async () => {
|
|
110
|
+
h.state.set({ status: "installing" });
|
|
111
|
+
const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/retry" });
|
|
112
|
+
expect(res.statusCode).toBe(409);
|
|
113
|
+
expect(h.retryCalls).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns 202 when status is failed and invokes the trigger", async () => {
|
|
117
|
+
h.state.set({ status: "failed", error: { message: "network" } });
|
|
118
|
+
const res = await h.app.inject({ method: "POST", url: "/api/bootstrap/retry" });
|
|
119
|
+
expect(res.statusCode).toBe(202);
|
|
120
|
+
const body = res.json() as { ticketId: string };
|
|
121
|
+
await new Promise((r) => setImmediate(r));
|
|
122
|
+
expect(h.retryCalls).toEqual([body.ticketId]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the in-memory bootstrap state store.
|
|
3
|
+
*
|
|
4
|
+
* See change: unified-bootstrap-install.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import { createBootstrapState } from "../bootstrap-state.js";
|
|
8
|
+
|
|
9
|
+
describe("bootstrap-state", () => {
|
|
10
|
+
it("defaults to status=ready", () => {
|
|
11
|
+
const s = createBootstrapState();
|
|
12
|
+
expect(s.get()).toEqual({ status: "ready" });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("applies initial overrides", () => {
|
|
16
|
+
const s = createBootstrapState({
|
|
17
|
+
status: "installing",
|
|
18
|
+
progress: { step: "pi", output: "starting" },
|
|
19
|
+
});
|
|
20
|
+
const state = s.get();
|
|
21
|
+
expect(state.status).toBe("installing");
|
|
22
|
+
expect(state.progress).toEqual({ step: "pi", output: "starting" });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("set merges partial into state", () => {
|
|
26
|
+
const s = createBootstrapState();
|
|
27
|
+
s.set({ status: "installing", progress: { step: "pi" } });
|
|
28
|
+
expect(s.get().status).toBe("installing");
|
|
29
|
+
s.set({ progress: { step: "openspec" } });
|
|
30
|
+
expect(s.get().progress).toEqual({ step: "openspec" });
|
|
31
|
+
expect(s.get().status).toBe("installing");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("set with undefined explicitly clears a key", () => {
|
|
35
|
+
const s = createBootstrapState({ progress: { step: "pi" } });
|
|
36
|
+
expect(s.get().progress).toBeDefined();
|
|
37
|
+
s.set({ progress: undefined });
|
|
38
|
+
expect(s.get().progress).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("notifies subscribers on set", () => {
|
|
42
|
+
const s = createBootstrapState();
|
|
43
|
+
const calls: string[] = [];
|
|
44
|
+
s.subscribe((st) => calls.push(st.status));
|
|
45
|
+
s.set({ status: "installing" });
|
|
46
|
+
s.set({ status: "ready" });
|
|
47
|
+
expect(calls).toEqual(["installing", "ready"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("subscribe returns an unsubscribe function", () => {
|
|
51
|
+
const s = createBootstrapState();
|
|
52
|
+
const calls: string[] = [];
|
|
53
|
+
const off = s.subscribe((st) => calls.push(st.status));
|
|
54
|
+
s.set({ status: "installing" });
|
|
55
|
+
off();
|
|
56
|
+
s.set({ status: "ready" });
|
|
57
|
+
expect(calls).toEqual(["installing"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("listener errors do not stop other listeners", () => {
|
|
61
|
+
const s = createBootstrapState();
|
|
62
|
+
const calls: string[] = [];
|
|
63
|
+
s.subscribe(() => {
|
|
64
|
+
throw new Error("boom");
|
|
65
|
+
});
|
|
66
|
+
s.subscribe((st) => calls.push(st.status));
|
|
67
|
+
s.set({ status: "installing" });
|
|
68
|
+
expect(calls).toEqual(["installing"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("dispose clears all listeners", () => {
|
|
72
|
+
const s = createBootstrapState();
|
|
73
|
+
const calls: string[] = [];
|
|
74
|
+
s.subscribe((st) => calls.push(st.status));
|
|
75
|
+
s.dispose();
|
|
76
|
+
s.set({ status: "installing" });
|
|
77
|
+
expect(calls).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("get returns a fresh snapshot (external mutation does not affect store)", () => {
|
|
81
|
+
const s = createBootstrapState({ progress: { step: "pi" } });
|
|
82
|
+
const snap = s.get();
|
|
83
|
+
snap.status = "failed";
|
|
84
|
+
expect(s.get().status).toBe("ready");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("lastInstallPackages", () => {
|
|
88
|
+
it("defaults to an empty array", () => {
|
|
89
|
+
const s = createBootstrapState();
|
|
90
|
+
expect(s.getLastInstallPackages()).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("records and returns a fresh copy", () => {
|
|
94
|
+
const s = createBootstrapState();
|
|
95
|
+
s.setLastInstallPackages(["pi", "openspec"]);
|
|
96
|
+
const got = s.getLastInstallPackages();
|
|
97
|
+
expect(got).toEqual(["pi", "openspec"]);
|
|
98
|
+
// External mutation does not affect the stored value.
|
|
99
|
+
got.push("tsx");
|
|
100
|
+
expect(s.getLastInstallPackages()).toEqual(["pi", "openspec"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("accepts a readonly input without type error", () => {
|
|
104
|
+
const s = createBootstrapState();
|
|
105
|
+
const readonlyInput: readonly string[] = ["a", "b"];
|
|
106
|
+
s.setLastInstallPackages(readonlyInput);
|
|
107
|
+
expect(s.getLastInstallPackages()).toEqual(["a", "b"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("is independent of status broadcast (not part of snapshot)", () => {
|
|
111
|
+
const s = createBootstrapState();
|
|
112
|
+
const seen: string[] = [];
|
|
113
|
+
s.subscribe((st) => seen.push(st.status));
|
|
114
|
+
s.setLastInstallPackages(["pi"]);
|
|
115
|
+
// setLastInstallPackages MUST NOT trigger a listener.
|
|
116
|
+
expect(seen).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -102,6 +102,23 @@ describe("listDirectories", () => {
|
|
|
102
102
|
expect(entry.path).toBe(path.join(projectRoot, entry.name));
|
|
103
103
|
}
|
|
104
104
|
});
|
|
105
|
+
|
|
106
|
+
it("should return the server's platform", async () => {
|
|
107
|
+
const projectRoot = path.resolve(import.meta.dirname, "../../../..");
|
|
108
|
+
const result = await listDirectories(projectRoot);
|
|
109
|
+
expect(result.platform).toBe(process.platform);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns parent=null at the filesystem root", async () => {
|
|
113
|
+
// Use whichever root is appropriate for the host: "/" on Unix, the
|
|
114
|
+
// process's drive root on Windows. Previously this test only
|
|
115
|
+
// exercised Unix; `isFilesystemRoot` covers both branches now.
|
|
116
|
+
const root = process.platform === "win32"
|
|
117
|
+
? path.parse(process.cwd()).root // e.g., "C:\\" or "B:\\"
|
|
118
|
+
: "/";
|
|
119
|
+
const result = await listDirectories(root);
|
|
120
|
+
expect(result.parent).toBeNull();
|
|
121
|
+
});
|
|
105
122
|
});
|
|
106
123
|
|
|
107
124
|
describe("listDirectories with q filter", () => {
|
|
@@ -31,6 +31,17 @@ describe("parseArgs", () => {
|
|
|
31
31
|
expect(result.subcommand).toBe("status");
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
it("parses upgrade-pi subcommand (unified-bootstrap-install §8)", () => {
|
|
35
|
+
const result = parseArgs(["upgrade-pi"]);
|
|
36
|
+
expect(result.subcommand).toBe("upgrade-pi");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("parses upgrade-pi with --port flag", () => {
|
|
40
|
+
const result = parseArgs(["upgrade-pi", "--port", "9090"]);
|
|
41
|
+
expect(result.subcommand).toBe("upgrade-pi");
|
|
42
|
+
expect(result.flags.port).toBe(9090);
|
|
43
|
+
});
|
|
44
|
+
|
|
34
45
|
it("parses subcommand with flags", () => {
|
|
35
46
|
const result = parseArgs(["start", "--port", "3000", "--pi-port", "4000"]);
|
|
36
47
|
expect(result.subcommand).toBe("start");
|