@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
|
@@ -11,6 +11,8 @@ import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/type
|
|
|
11
11
|
import { spawnPiSession } from "./process-manager.js";
|
|
12
12
|
import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
13
13
|
import type { PendingForkRegistry } from "./pending-fork-registry.js";
|
|
14
|
+
import type { BootstrapStateStore } from "./bootstrap-state.js";
|
|
15
|
+
import type { BootstrapQueue } from "./bootstrap-queue.js";
|
|
14
16
|
|
|
15
17
|
export interface SessionApiDeps {
|
|
16
18
|
sessionManager: SessionManager;
|
|
@@ -18,6 +20,13 @@ export interface SessionApiDeps {
|
|
|
18
20
|
browserGateway: BrowserGateway;
|
|
19
21
|
pendingForkRegistry?: PendingForkRegistry;
|
|
20
22
|
pendingDashboardSpawns?: Map<string, number>;
|
|
23
|
+
/**
|
|
24
|
+
* Bootstrap state + queue for degraded-mode gating. When omitted,
|
|
25
|
+
* session operations run normally (legacy behavior for tests that
|
|
26
|
+
* don't exercise the bootstrap flow). See change: unified-bootstrap-install.
|
|
27
|
+
*/
|
|
28
|
+
bootstrapState?: BootstrapStateStore;
|
|
29
|
+
bootstrapQueue?: BootstrapQueue;
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
type IdParams = { Params: { id: string } };
|
|
@@ -30,7 +39,54 @@ function getSessionOrFail(sessionManager: SessionManager, id: string): { session
|
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDeps) {
|
|
33
|
-
const { sessionManager, piGateway, browserGateway, pendingForkRegistry, pendingDashboardSpawns } = deps;
|
|
42
|
+
const { sessionManager, piGateway, browserGateway, pendingForkRegistry, pendingDashboardSpawns, bootstrapState, bootstrapQueue } = deps;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Gate pi-dependent operations on bootstrap status. Returns:
|
|
46
|
+
* - null when ready (proceed).
|
|
47
|
+
* - `{ code: 202, body: { status: "queued", ticketId } }` when installing;
|
|
48
|
+
* the operation is enqueued and will run once status flips to "ready".
|
|
49
|
+
* - `{ code: 503, body: { error } }` when failed.
|
|
50
|
+
* See change: unified-bootstrap-install §5.
|
|
51
|
+
*/
|
|
52
|
+
function gateOrEnqueue<T>(handler: () => Promise<T>):
|
|
53
|
+
| null
|
|
54
|
+
| { code: 202; body: { status: "queued"; ticketId: string } }
|
|
55
|
+
| { code: 503; body: { error: string; bootstrap: "failed" | "version-too-old" } } {
|
|
56
|
+
if (!bootstrapState) return null;
|
|
57
|
+
const snap = bootstrapState.get();
|
|
58
|
+
// Block when pi version is below the configured minimum —
|
|
59
|
+
// even when status is "ready", a too-old pi must not run sessions.
|
|
60
|
+
// See change: unified-bootstrap-install §9.3.
|
|
61
|
+
if (
|
|
62
|
+
snap.status === "ready"
|
|
63
|
+
&& snap.error?.message?.startsWith("pi version ")
|
|
64
|
+
) {
|
|
65
|
+
return {
|
|
66
|
+
code: 503,
|
|
67
|
+
body: { error: snap.error.message, bootstrap: "version-too-old" },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (snap.status === "ready") return null;
|
|
71
|
+
if (snap.status === "installing") {
|
|
72
|
+
if (!bootstrapQueue) {
|
|
73
|
+
return {
|
|
74
|
+
code: 202,
|
|
75
|
+
body: { status: "queued", ticketId: "" },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const ticket = bootstrapQueue.enqueue(handler);
|
|
79
|
+
return {
|
|
80
|
+
code: 202,
|
|
81
|
+
body: { status: "queued", ticketId: ticket.ticketId },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// status === "failed"
|
|
85
|
+
return {
|
|
86
|
+
code: 503,
|
|
87
|
+
body: { error: "pi not installed (bootstrap failed)", bootstrap: "failed" },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
34
90
|
|
|
35
91
|
// POST /api/session/:id/prompt
|
|
36
92
|
fastify.post<IdParams & { Body: { text?: string; images?: any[] } }>(
|
|
@@ -160,14 +216,27 @@ export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDep
|
|
|
160
216
|
reply.code(400);
|
|
161
217
|
return { success: false, error: "cwd is required" } satisfies ApiResponse;
|
|
162
218
|
}
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
219
|
+
|
|
220
|
+
const doSpawn = async () => {
|
|
221
|
+
const config = loadConfig();
|
|
222
|
+
const spawnResult = await spawnPiSession(cwd, { strategy: config.spawnStrategy });
|
|
223
|
+
if (spawnResult.process && spawnResult.pid) {
|
|
224
|
+
browserGateway.headlessPidRegistry.register(spawnResult.pid, cwd, spawnResult.process);
|
|
225
|
+
}
|
|
226
|
+
if (spawnResult.dashboardSpawned && spawnResult.success) {
|
|
227
|
+
pendingDashboardSpawns?.set(cwd, (pendingDashboardSpawns?.get(cwd) ?? 0) + 1);
|
|
228
|
+
}
|
|
229
|
+
return spawnResult;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Bootstrap gate: if pi isn't ready, queue the spawn and return 202.
|
|
233
|
+
const gate = gateOrEnqueue(doSpawn);
|
|
234
|
+
if (gate) {
|
|
235
|
+
reply.code(gate.code);
|
|
236
|
+
return gate.body;
|
|
170
237
|
}
|
|
238
|
+
|
|
239
|
+
const spawnResult = await doSpawn();
|
|
171
240
|
if (!spawnResult.success) {
|
|
172
241
|
reply.code(500);
|
|
173
242
|
return { success: false, error: spawnResult.message } satisfies ApiResponse;
|
|
@@ -73,8 +73,22 @@ export async function discoverAndBroadcastSessions(deps: SessionBootstrapDeps):
|
|
|
73
73
|
} as any);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
// Initial OpenSpec poll for all known directories
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
// Initial OpenSpec poll for all known directories.
|
|
77
|
+
//
|
|
78
|
+
// NOTE: `refreshOpenSpec` / `pollOpenSpec` is currently synchronous internally
|
|
79
|
+
// (spawnSync per change) — on Windows with many active changes (~19) and
|
|
80
|
+
// multiple pinned directories this can block the event loop for minutes,
|
|
81
|
+
// making the HTTP server unresponsive during startup. We intentionally do
|
|
82
|
+
// NOT await it here so HTTP + WebSocket startup completes immediately;
|
|
83
|
+
// openspec data populates in the background and pushes `openspec_update`
|
|
84
|
+
// broadcasts to browsers as each directory finishes.
|
|
85
|
+
//
|
|
86
|
+
// A proper fix is to migrate the openspec Recipe to async spawn; tracked
|
|
87
|
+
// separately. See change: consolidate-tool-resolution.
|
|
88
|
+
void Promise.all(
|
|
89
|
+
directoryService.knownDirectories().map(async (cwd) => {
|
|
90
|
+
try { await directoryService.refreshOpenSpec(cwd); }
|
|
91
|
+
catch (err) { console.error(`[dashboard] initial openspec poll failed for ${cwd}:`, err); }
|
|
92
|
+
}),
|
|
79
93
|
);
|
|
80
94
|
}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Session diff extraction — scans session events for file changes
|
|
3
3
|
* and optionally enriches with git diffs.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
6
|
-
import { resolve, relative, isAbsolute } from "node:path";
|
|
5
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { resolve, relative, isAbsolute, sep as pathSep } from "node:path";
|
|
7
|
+
import * as git from "@blackbelt-technology/pi-dashboard-shared/platform/git.js";
|
|
7
8
|
import type { DashboardEvent } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
8
9
|
import type { FileChangeEvent, FileDiffEntry, EditOperation } from "@blackbelt-technology/pi-dashboard-shared/diff-types.js";
|
|
9
10
|
import { isGitRepo } from "./git-operations.js";
|
|
@@ -105,7 +106,11 @@ function normalizePath(rawPath: string, cwd: string): string | null {
|
|
|
105
106
|
return null;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
// Normalize to posix separators. These paths are embedded into git diff
|
|
110
|
+
// headers (`diff --git a/<path> b/<path>`) which expect forward slashes,
|
|
111
|
+
// and are also used by the client for display and URL construction.
|
|
112
|
+
// See change: fix-windows-server-parity.
|
|
113
|
+
return pathSep === "/" ? rel : rel.split(pathSep).join("/");
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
/**
|
|
@@ -130,32 +135,27 @@ export function enrichWithGitDiff(
|
|
|
130
135
|
|
|
131
136
|
const enriched = files.map((file) => {
|
|
132
137
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
timeout: GIT_TIMEOUT,
|
|
138
|
-
}).trim();
|
|
138
|
+
// Delegate to the shared git tool module. The runner handles
|
|
139
|
+
// windowsHide, timeout, argv-array escaping (no shell), and the
|
|
140
|
+
// "no diff" exit-1 tolerance. See change: platform-command-executor.
|
|
141
|
+
const diff = git.diffOr({ cwd, path: file.path }).trim();
|
|
139
142
|
|
|
140
143
|
if (diff) {
|
|
141
144
|
return { ...file, gitDiff: diff };
|
|
142
145
|
}
|
|
143
146
|
|
|
144
147
|
// No diff from HEAD — try untracked (new file)
|
|
145
|
-
const status =
|
|
146
|
-
cwd,
|
|
147
|
-
encoding: "utf-8",
|
|
148
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
149
|
-
timeout: GIT_TIMEOUT,
|
|
150
|
-
}).trim();
|
|
148
|
+
const status = git.statusPorcelainOr({ cwd, path: file.path }).trim();
|
|
151
149
|
|
|
152
150
|
if (status.startsWith("??") || status.startsWith("A")) {
|
|
153
|
-
// Untracked or newly added — generate synthetic diff
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
151
|
+
// Untracked or newly added — generate synthetic diff.
|
|
152
|
+
// Read via fs.readFileSync rather than `cat` for cross-platform
|
|
153
|
+
// support (Windows has no `cat`). See change: fix-windows-server-parity.
|
|
154
|
+
const absPath = resolve(cwd, file.path);
|
|
155
|
+
if (!existsSync(absPath)) {
|
|
156
|
+
return file;
|
|
157
|
+
}
|
|
158
|
+
const content = readFileSync(absPath, "utf-8");
|
|
159
159
|
const lines = content.split("\n");
|
|
160
160
|
const diffLines = [
|
|
161
161
|
`diff --git a/${file.path} b/${file.path}`,
|
|
@@ -10,13 +10,19 @@ import type { WebSocket } from "ws";
|
|
|
10
10
|
|
|
11
11
|
const DEFAULT_BUFFER_SIZE = 256 * 1024; // 256KB
|
|
12
12
|
|
|
13
|
+
// Delegate shell detection to the shared platform primitive. Back-compat
|
|
14
|
+
// wrapper preserved so callers (and tests) that import `detectShell` from
|
|
15
|
+
// this module continue to work. See change: consolidate-platform-handlers.
|
|
16
|
+
import {
|
|
17
|
+
detectShell as platformDetectShell,
|
|
18
|
+
getTerminalEnvHints as platformTerminalEnvHints,
|
|
19
|
+
} from "@blackbelt-technology/pi-dashboard-shared/platform/shell.js";
|
|
20
|
+
import { killProcess } from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
|
|
21
|
+
|
|
13
22
|
/** Detect the appropriate shell for the current platform. */
|
|
14
23
|
export function detectShell(platform?: string): string {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return process.env.COMSPEC || "powershell.exe";
|
|
18
|
-
}
|
|
19
|
-
return process.env.SHELL || "/bin/bash";
|
|
24
|
+
// Keep the old `platform?: string` signature; coerce to the shared primitive's opts.
|
|
25
|
+
return platformDetectShell(platform ? { platform: platform as NodeJS.Platform } : undefined);
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
/** Circular buffer for PTY output replay. */
|
|
@@ -110,10 +116,7 @@ export function createTerminalManager(options?: TerminalManagerOptions): Termina
|
|
|
110
116
|
const shell = detectShell();
|
|
111
117
|
const id = generateId();
|
|
112
118
|
|
|
113
|
-
const env = { ...process.env } as Record<string, string>;
|
|
114
|
-
if (process.platform === "win32" && !env.TERM) {
|
|
115
|
-
env.TERM = "cygwin";
|
|
116
|
-
}
|
|
119
|
+
const env = { ...process.env, ...platformTerminalEnvHints() } as Record<string, string>;
|
|
117
120
|
|
|
118
121
|
const p = pty.spawn(shell, [], {
|
|
119
122
|
cwd,
|
|
@@ -211,18 +214,56 @@ export function createTerminalManager(options?: TerminalManagerOptions): Termina
|
|
|
211
214
|
function kill(id: string): void {
|
|
212
215
|
const entry = entries.get(id);
|
|
213
216
|
if (!entry) throw new Error(`Terminal ${id} not found`);
|
|
214
|
-
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
217
|
+
|
|
218
|
+
// Windows: node-pty's kill(signal) uses TerminateProcess on the shell
|
|
219
|
+
// handle, which (a) ignores the signal string, and (b) does not kill
|
|
220
|
+
// child processes of the shell (python.exe, node.exe, etc.). Worse, its
|
|
221
|
+
// onExit callback is not always fired after external kills, so the
|
|
222
|
+
// terminal entry would stay in the map forever — which is exactly why
|
|
223
|
+
// the X button "doesn't work" on Windows. Route through platform's
|
|
224
|
+
// killProcess() so taskkill /F /T /PID does a genuine tree kill.
|
|
225
|
+
//
|
|
226
|
+
// POSIX: keep the SIGHUP → SIGKILL idiom — interactive shells honor
|
|
227
|
+
// SIGHUP, giving them a chance to clean up tty state before we escalate.
|
|
228
|
+
if (process.platform === "win32") {
|
|
229
|
+
const pid = entry.pty.pid;
|
|
230
|
+
if (typeof pid === "number") {
|
|
231
|
+
void killProcess(pid, { timeoutMs: 2000 }).catch(() => { /* surfaced via onExit fallback below */ });
|
|
232
|
+
} else {
|
|
233
|
+
try { entry.pty.kill(); } catch { /* best-effort */ }
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
entry.pty.kill("SIGHUP");
|
|
237
|
+
const escalation = setTimeout(() => {
|
|
238
|
+
if (entries.has(id)) {
|
|
239
|
+
try { entry.pty.kill("SIGKILL"); } catch {}
|
|
240
|
+
}
|
|
241
|
+
}, 1000);
|
|
242
|
+
const disposeEsc = entry.pty.onExit(() => {
|
|
243
|
+
clearTimeout(escalation);
|
|
244
|
+
disposeEsc.dispose();
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Fallback cleanup: if node-pty's onExit doesn't fire within 3s (common
|
|
249
|
+
// on Windows ConPTY after external termination), simulate it so the
|
|
250
|
+
// terminal entry is removed, clients are disconnected, and the server
|
|
251
|
+
// broadcasts terminal_removed. Without this, the X click never
|
|
252
|
+
// completes from the user's perspective.
|
|
253
|
+
const fallback = setTimeout(() => {
|
|
254
|
+
const stale = entries.get(id);
|
|
255
|
+
if (!stale) return; // onExit already ran
|
|
256
|
+
stale.session = { ...stale.session, status: "ended" };
|
|
257
|
+
for (const ws of stale.clients) {
|
|
258
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
220
259
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
260
|
+
stale.clients.clear();
|
|
261
|
+
entries.delete(id);
|
|
262
|
+
options?.onExit?.(id);
|
|
263
|
+
}, 3000);
|
|
264
|
+
const disposeFb = entry.pty.onExit(() => {
|
|
265
|
+
clearTimeout(fallback);
|
|
266
|
+
disposeFb.dispose();
|
|
226
267
|
});
|
|
227
268
|
}
|
|
228
269
|
|
|
@@ -7,7 +7,15 @@
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import os from "node:os";
|
|
10
|
-
import { execSync, spawn, type ChildProcess } from "
|
|
10
|
+
import { execSync, spawn, type ChildProcess } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
|
|
11
|
+
import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js";
|
|
12
|
+
import {
|
|
13
|
+
isProcessAlive,
|
|
14
|
+
killProcess,
|
|
15
|
+
killPidWithGroup,
|
|
16
|
+
} from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
|
|
17
|
+
|
|
18
|
+
const zrokResolver = new ToolResolver({ processExecPath: process.execPath });
|
|
11
19
|
import type { TunnelStatus } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
|
|
12
20
|
import { CONFIG_FILE } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
13
21
|
|
|
@@ -37,13 +45,10 @@ let pendingCreate: Promise<string | null> | null = null;
|
|
|
37
45
|
// ── Binary Detection ────────────────────────────────────────────────
|
|
38
46
|
|
|
39
47
|
function checkZrokOnPath(): boolean {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
} catch {
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
48
|
+
// Delegate binary lookup to the shared platform primitive (handles the
|
|
49
|
+
// where/which split on Windows vs Unix, managed-bin search, and login
|
|
50
|
+
// shell fallback). See change: consolidate-platform-handlers.
|
|
51
|
+
return zrokResolver.which("zrok") !== null;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
/**
|
|
@@ -93,30 +98,22 @@ export function removeZrokPid(): void {
|
|
|
93
98
|
|
|
94
99
|
// ── Stale Process Cleanup ───────────────────────────────────────────
|
|
95
100
|
|
|
96
|
-
/**
|
|
97
|
-
* Check if a process is alive by sending signal 0.
|
|
98
|
-
*/
|
|
99
|
-
function isProcessAlive(pid: number): boolean {
|
|
100
|
-
try {
|
|
101
|
-
process.kill(pid, 0);
|
|
102
|
-
return true;
|
|
103
|
-
} catch {
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
101
|
/**
|
|
109
102
|
* Clean up stale zrok processes from previous server runs.
|
|
110
|
-
* Reads PID file, kills process if running
|
|
103
|
+
* Reads PID file, kills process if running (via the platform helper so
|
|
104
|
+
* Windows uses `taskkill /F /T /PID`), removes PID file.
|
|
105
|
+
* See change: route-kill-paths-through-platform.
|
|
111
106
|
*/
|
|
112
|
-
export function cleanupStaleZrok(): void {
|
|
107
|
+
export async function cleanupStaleZrok(): Promise<void> {
|
|
113
108
|
const pid = readZrokPid();
|
|
114
109
|
if (pid === null) return;
|
|
115
110
|
|
|
116
111
|
if (isProcessAlive(pid)) {
|
|
117
112
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
113
|
+
const result = await killProcess(pid, { timeoutMs: 2000 });
|
|
114
|
+
if (result.ok) {
|
|
115
|
+
console.log(`Killed stale zrok process (PID ${pid})`);
|
|
116
|
+
}
|
|
120
117
|
} catch (err: any) {
|
|
121
118
|
console.warn(`Failed to kill stale zrok process (PID ${pid}): ${err.message}`);
|
|
122
119
|
}
|
|
@@ -222,7 +219,10 @@ export function scavengeOrphanZrokProcesses(port: number): number[] {
|
|
|
222
219
|
if (!Number.isFinite(pid) || pid <= 0) continue;
|
|
223
220
|
if (pid === process.pid) continue; // never kill ourselves
|
|
224
221
|
try {
|
|
225
|
-
|
|
222
|
+
// Group-kill on Unix so zrok's child workers die with it; taskkill /T
|
|
223
|
+
// already handles the tree on Windows (killPidWithGroup routes the
|
|
224
|
+
// platform-correct path).
|
|
225
|
+
killPidWithGroup(pid, "SIGTERM");
|
|
226
226
|
killed.push(pid);
|
|
227
227
|
console.log(`Scavenged orphan zrok process (PID ${pid})`);
|
|
228
228
|
} catch {
|
|
@@ -336,8 +336,16 @@ function _createTunnelInner(
|
|
|
336
336
|
if (!resolved) {
|
|
337
337
|
resolved = true;
|
|
338
338
|
console.warn("zrok tunnel creation timed out (30s)");
|
|
339
|
-
try {
|
|
340
|
-
|
|
339
|
+
try {
|
|
340
|
+
if (child.pid != null) killPidWithGroup(child.pid, "SIGTERM");
|
|
341
|
+
else child.kill("SIGTERM");
|
|
342
|
+
} catch { /* already dead */ }
|
|
343
|
+
setTimeout(() => {
|
|
344
|
+
try {
|
|
345
|
+
if (child.pid != null) killPidWithGroup(child.pid, "SIGKILL");
|
|
346
|
+
else child.kill("SIGKILL");
|
|
347
|
+
} catch { /* already dead */ }
|
|
348
|
+
}, 2_000);
|
|
341
349
|
if (token && !callerProvidedToken) releaseShare(token);
|
|
342
350
|
removeZrokPid();
|
|
343
351
|
resolve(null);
|
|
@@ -417,7 +425,13 @@ export async function deleteTunnel(port?: number): Promise<void> {
|
|
|
417
425
|
|
|
418
426
|
if (child) {
|
|
419
427
|
try {
|
|
420
|
-
child.
|
|
428
|
+
if (child.pid != null) {
|
|
429
|
+
// Route through the platform helper so Windows gets taskkill
|
|
430
|
+
// semantics (tree-kill). See change: route-kill-paths-through-platform.
|
|
431
|
+
await killProcess(child.pid, { timeoutMs: 2000 });
|
|
432
|
+
} else {
|
|
433
|
+
child.kill("SIGTERM");
|
|
434
|
+
}
|
|
421
435
|
} catch (err: any) {
|
|
422
436
|
console.warn(`zrok tunnel cleanup failed: ${err.message}`);
|
|
423
437
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blackbelt-technology/pi-dashboard-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Shared types and utilities for pi-dashboard",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
6
9
|
"exports": {
|
|
7
10
|
"./*.js": "./src/*.ts",
|
|
8
11
|
"./*": "./src/*"
|
|
@@ -10,6 +13,10 @@
|
|
|
10
13
|
"files": [
|
|
11
14
|
"src/"
|
|
12
15
|
],
|
|
13
|
-
"dependencies": {
|
|
14
|
-
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"bonjour-service": "^1.3.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"memfs": "^4.57.2"
|
|
21
|
+
}
|
|
15
22
|
}
|
|
@@ -5,28 +5,37 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
|
|
8
|
-
const { mockExecSync, mockExistsSync } = vi.hoisted(() => ({
|
|
8
|
+
const { mockExecSync, mockSpawnSync, mockExistsSync } = vi.hoisted(() => ({
|
|
9
9
|
mockExecSync: vi.fn(),
|
|
10
|
+
mockSpawnSync: vi.fn(),
|
|
10
11
|
mockExistsSync: vi.fn(),
|
|
11
12
|
}));
|
|
12
13
|
|
|
13
|
-
vi.mock("node:child_process", () => ({ execSync: mockExecSync }));
|
|
14
|
+
vi.mock("node:child_process", () => ({ execSync: mockExecSync, spawnSync: mockSpawnSync }));
|
|
14
15
|
vi.mock("node:fs", () => ({ existsSync: mockExistsSync }));
|
|
15
16
|
|
|
16
|
-
import { ToolResolver } from "../
|
|
17
|
+
import { ToolResolver } from "../platform/binary-lookup.js";
|
|
17
18
|
|
|
18
19
|
const MANAGED_BIN = path.join(os.homedir(), ".pi-dashboard", "node_modules", ".bin");
|
|
19
20
|
|
|
21
|
+
// On Windows, ToolResolver.which() appends ".cmd" to the binary name when
|
|
22
|
+
// probing managed bin / extra dirs (shim convention for npm-installed bins).
|
|
23
|
+
// Unix has no extension. Tests must mirror this so assertions line up with
|
|
24
|
+
// what the implementation actually queries.
|
|
25
|
+
const BIN_EXT = process.platform === "win32" ? ".cmd" : "";
|
|
26
|
+
|
|
20
27
|
describe("ToolResolver", () => {
|
|
21
28
|
beforeEach(() => {
|
|
22
29
|
vi.clearAllMocks();
|
|
23
30
|
mockExistsSync.mockReturnValue(false);
|
|
24
31
|
mockExecSync.mockImplementation(() => { throw new Error("not found"); });
|
|
32
|
+
// Default: spawnSync (used by whereAllLines) reports not found.
|
|
33
|
+
mockSpawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
25
34
|
});
|
|
26
35
|
|
|
27
36
|
describe("which()", () => {
|
|
28
37
|
it("finds binary in managed bin first", () => {
|
|
29
|
-
const managedPi = path.join(MANAGED_BIN, "pi");
|
|
38
|
+
const managedPi = path.join(MANAGED_BIN, "pi" + BIN_EXT);
|
|
30
39
|
mockExistsSync.mockImplementation((p: string) => p === managedPi);
|
|
31
40
|
|
|
32
41
|
const resolver = new ToolResolver();
|
|
@@ -35,21 +44,28 @@ describe("ToolResolver", () => {
|
|
|
35
44
|
|
|
36
45
|
it("finds binary in extra bin dirs before system PATH", () => {
|
|
37
46
|
const extraDir = "/custom/bin";
|
|
38
|
-
const extraPi = path.join(extraDir, "pi");
|
|
47
|
+
const extraPi = path.join(extraDir, "pi" + BIN_EXT);
|
|
39
48
|
mockExistsSync.mockImplementation((p: string) => p === extraPi);
|
|
40
49
|
|
|
41
50
|
const resolver = new ToolResolver({ extraBinDirs: [extraDir] });
|
|
42
51
|
expect(resolver.which("pi")).toBe(extraPi);
|
|
43
52
|
});
|
|
44
53
|
|
|
45
|
-
it("falls back to system PATH via which", () => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
it("falls back to system PATH via which/where", () => {
|
|
55
|
+
// Resolver uses `where` on Windows, `which` on Unix via spawnSync
|
|
56
|
+
// (not execSync — see whereAllLines in platform/tools.ts).
|
|
57
|
+
const lookupCmd = process.platform === "win32" ? "where" : "which";
|
|
58
|
+
const expected = process.platform === "win32" ? "C:\\Windows\\pi.exe" : "/usr/bin/pi";
|
|
59
|
+
mockSpawnSync.mockImplementation((cmd: string, args: string[]) => {
|
|
60
|
+
// argv[0] is 'where'/'which', argv[1] is the target binary.
|
|
61
|
+
if (cmd === lookupCmd && args?.[0] === "pi") {
|
|
62
|
+
return { status: 0, stdout: expected + "\n", stderr: "" };
|
|
63
|
+
}
|
|
64
|
+
return { status: 1, stdout: "", stderr: "" };
|
|
49
65
|
});
|
|
50
66
|
|
|
51
67
|
const resolver = new ToolResolver();
|
|
52
|
-
expect(resolver.which("pi")).toBe(
|
|
68
|
+
expect(resolver.which("pi")).toBe(expected);
|
|
53
69
|
});
|
|
54
70
|
|
|
55
71
|
it("tries login shell when enabled and PATH fails", () => {
|
|
@@ -123,7 +139,7 @@ describe("ToolResolver", () => {
|
|
|
123
139
|
});
|
|
124
140
|
|
|
125
141
|
it("falls back to which(node) when no context paths", () => {
|
|
126
|
-
const managedNode = path.join(MANAGED_BIN, "node");
|
|
142
|
+
const managedNode = path.join(MANAGED_BIN, "node" + BIN_EXT);
|
|
127
143
|
mockExistsSync.mockImplementation((p: string) => p === managedNode);
|
|
128
144
|
|
|
129
145
|
const resolver = new ToolResolver();
|
|
@@ -143,7 +159,11 @@ describe("ToolResolver", () => {
|
|
|
143
159
|
|
|
144
160
|
it("does not duplicate managed bin if already present", () => {
|
|
145
161
|
const resolver = new ToolResolver();
|
|
146
|
-
|
|
162
|
+
// Use the platform's PATH delimiter (`;` on Windows, `:` on Unix) so
|
|
163
|
+
// MANAGED_BIN is parsed as its own PATH entry — otherwise on Windows
|
|
164
|
+
// `${MANAGED_BIN}:/usr/bin` is treated as one single (broken) path.
|
|
165
|
+
const existingPath = [MANAGED_BIN, "/usr/bin"].join(path.delimiter);
|
|
166
|
+
const env = resolver.buildSpawnEnv({ PATH: existingPath });
|
|
147
167
|
const count = env.PATH!.split(path.delimiter).filter(p => p === MANAGED_BIN).length;
|
|
148
168
|
expect(count).toBe(1);
|
|
149
169
|
});
|