@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.2
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 +26 -5
- package/README.md +49 -7
- package/docs/architecture.md +129 -1
- package/package.json +15 -15
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
- package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
- package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
- package/packages/extension/src/ask-user-tool.ts +1 -1
- package/packages/extension/src/bridge-context.ts +68 -4
- package/packages/extension/src/bridge.ts +79 -11
- package/packages/extension/src/command-handler.ts +95 -15
- package/packages/extension/src/flow-event-wiring.ts +1 -1
- package/packages/extension/src/multiselect-list.ts +1 -1
- package/packages/extension/src/pi-env.d.ts +16 -9
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/provider-register.ts +16 -9
- package/packages/extension/src/retry-tracker.ts +123 -0
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/session-sync.ts +10 -1
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/extension/src/usage-limit-orderer.ts +76 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +8 -7
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
- package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
- package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service.test.ts +2 -2
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
- package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
- package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
- package/packages/server/src/__tests__/package-routes.test.ts +1 -1
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
- package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
- package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
- package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
- package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +27 -10
- package/packages/server/src/browser-handlers/handler-context.ts +9 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
- package/packages/server/src/changelog-fs.ts +167 -0
- package/packages/server/src/changelog-parser.ts +321 -0
- package/packages/server/src/changelog-remote.ts +134 -0
- package/packages/server/src/cli.ts +62 -82
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +90 -6
- package/packages/server/src/headless-pid-registry.ts +344 -37
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/pending-client-correlations.ts +73 -0
- package/packages/server/src/pending-fork-registry.ts +24 -12
- package/packages/server/src/pi-core-checker.ts +77 -17
- package/packages/server/src/pi-core-updater.ts +16 -6
- package/packages/server/src/pi-dev-version-check.ts +145 -0
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/pi-version-skew.ts +12 -4
- package/packages/server/src/process-manager.ts +182 -11
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/provider-catalogue-cache.ts +24 -18
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
- package/packages/server/src/routes/pi-core-routes.ts +1 -1
- package/packages/server/src/routes/provider-auth-routes.ts +8 -1
- package/packages/server/src/routes/provider-routes.ts +28 -5
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +254 -60
- package/packages/server/src/session-api.ts +63 -4
- package/packages/server/src/session-discovery.ts +1 -1
- package/packages/server/src/session-file-reader.ts +1 -1
- package/packages/server/src/spawn-register-watchdog.ts +62 -7
- package/packages/server/src/spawn-token.ts +20 -0
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
- package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +70 -0
- package/packages/shared/src/changelog-types.ts +111 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +71 -26
- package/packages/shared/src/protocol.ts +27 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/skill-block-parser.ts +1 -1
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/tool-registry/definitions.ts +15 -3
- package/packages/shared/src/types.ts +62 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
- package/packages/shared/src/resolve-jiti.ts +0 -102
|
@@ -11,6 +11,18 @@ import path from "node:path";
|
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import { isUnsafeTestHomeScan } from "./test-env-guard.js";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Minimal interface the registry depends on for keeper-mediated writes
|
|
16
|
+
* and orphan reconciliation. Implemented by `KeeperManager` in
|
|
17
|
+
* `rpc-keeper/keeper-manager.ts`. Injected via
|
|
18
|
+
* `HeadlessPidRegistryOptions.keeperManager` to avoid a circular dep at
|
|
19
|
+
* module load. See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6).
|
|
20
|
+
*/
|
|
21
|
+
export interface KeeperWriter {
|
|
22
|
+
writeRpcToSockPath(sockPath: string, line: string): Promise<boolean>;
|
|
23
|
+
discoverExistingKeepers(): Promise<Array<{ sessionId: string; keeperPid: number; sockPath: string }>>;
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
/** Default PID file path */
|
|
15
27
|
const DEFAULT_PID_FILE = path.join(os.homedir(), ".pi", "dashboard", "headless-pids.json");
|
|
16
28
|
|
|
@@ -23,27 +35,134 @@ export interface HeadlessEntry {
|
|
|
23
35
|
process: ChildProcess;
|
|
24
36
|
sessionId?: string;
|
|
25
37
|
spawnedAt: number;
|
|
38
|
+
/**
|
|
39
|
+
* Server-minted spawn correlation token. Stored at `register` time.
|
|
40
|
+
* Used by `linkByToken` (tier 1) to resolve sessionId↔pid mapping
|
|
41
|
+
* deterministically, replacing the racy cwd-FIFO `linkSession`.
|
|
42
|
+
* See change: spawn-correlation-token.
|
|
43
|
+
*/
|
|
44
|
+
spawnToken?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Pi process PID, distinct from the spawn-time PID when an RPC keeper
|
|
47
|
+
* sidecar owns pi's stdin (see `rpc-keeper-sidecar`). Set by
|
|
48
|
+
* `linkByToken` from `session_register.pid` once the bridge connects.
|
|
49
|
+
* In non-keeper mode it remains undefined; consumers fall back to
|
|
50
|
+
* `entry.pid`. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
51
|
+
*/
|
|
52
|
+
piPid?: number;
|
|
53
|
+
/**
|
|
54
|
+
* RPC keeper sidecar PID. Set at register-time when the entry was
|
|
55
|
+
* spawned through `spawnHeadlessViaKeeper`. In keeper mode this equals
|
|
56
|
+
* `entry.pid` (the keeper IS the spawned child); the explicit field
|
|
57
|
+
* makes the keeper-vs-non-keeper branch unambiguous in `killBySessionId`
|
|
58
|
+
* and `writeRpc`. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
59
|
+
*/
|
|
60
|
+
keeperPid?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Absolute UDS / named-pipe path the keeper listens on. Set at
|
|
63
|
+
* register-time alongside `keeperPid`. Used by `writeRpc` to forward
|
|
64
|
+
* `dispatch_extension_command` lines without re-deriving the path.
|
|
65
|
+
*/
|
|
66
|
+
keeperSockPath?: string;
|
|
26
67
|
}
|
|
27
68
|
|
|
28
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Serialized format for disk persistence. ALL identity-bearing fields
|
|
71
|
+
* must round-trip across server restart so `linkByToken` / `linkByPid`
|
|
72
|
+
* keep their precise matching after `cleanupOrphans` reclaims entries.
|
|
73
|
+
*
|
|
74
|
+
* Without this, after a dashboard restart the rebuilt entry only has
|
|
75
|
+
* (pid, cwd, spawnedAt). The bridge then re-registers with
|
|
76
|
+
* `{pid: piPid, spawnToken: <omitted-on-reattach>}` and the registry
|
|
77
|
+
* falls all the way through to the cwd-FIFO tier, which mis-maps
|
|
78
|
+
* sessionIds to keeper entries when two sessions share a cwd. Symptom:
|
|
79
|
+
* `/ctx-stats` in session A dispatches to pi-B's keeper; killing A
|
|
80
|
+
* SIGTERMs B's pi. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
81
|
+
*/
|
|
29
82
|
interface PersistedEntry {
|
|
30
83
|
pid: number;
|
|
31
84
|
cwd: string;
|
|
32
85
|
spawnedAt: string;
|
|
86
|
+
spawnToken?: string;
|
|
87
|
+
piPid?: number;
|
|
88
|
+
keeperPid?: number;
|
|
89
|
+
keeperSockPath?: string;
|
|
33
90
|
}
|
|
34
91
|
|
|
35
92
|
interface PidFileData {
|
|
36
93
|
entries: PersistedEntry[];
|
|
37
94
|
}
|
|
38
95
|
|
|
96
|
+
export interface KeeperRegisterOptions {
|
|
97
|
+
keeperPid: number;
|
|
98
|
+
keeperSockPath: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Pure helper: derive the optional `KeeperRegisterOptions` from a
|
|
103
|
+
* SpawnResult-shaped object. Returns `undefined` when the spawn was not
|
|
104
|
+
* keeper-mediated (no `keeperSockPath`). Lets registration call sites
|
|
105
|
+
* stay one-liners across all spawn paths.
|
|
106
|
+
* See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
107
|
+
*/
|
|
108
|
+
export function keeperOptsFromSpawnResult(
|
|
109
|
+
result: { pid?: number; keeperSockPath?: string },
|
|
110
|
+
): KeeperRegisterOptions | undefined {
|
|
111
|
+
if (typeof result.pid !== "number" || !result.keeperSockPath) return undefined;
|
|
112
|
+
return { keeperPid: result.pid, keeperSockPath: result.keeperSockPath };
|
|
113
|
+
}
|
|
114
|
+
|
|
39
115
|
export interface HeadlessPidRegistry {
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Register a newly spawned headless process. The optional `spawnToken`
|
|
118
|
+
* is the server-minted UUID injected into the spawned process's env;
|
|
119
|
+
* storing it lets `linkByToken` resolve identity precisely later.
|
|
120
|
+
* The optional `keeperOpts` marks this entry as keeper-mediated and
|
|
121
|
+
* stores the keeper PID + socket path for `writeRpc` / `killBySessionId`.
|
|
122
|
+
* See change: spawn-correlation-token, add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
123
|
+
*/
|
|
124
|
+
register(
|
|
125
|
+
pid: number,
|
|
126
|
+
cwd: string,
|
|
127
|
+
proc: ChildProcess,
|
|
128
|
+
spawnToken?: string,
|
|
129
|
+
keeperOpts?: KeeperRegisterOptions,
|
|
130
|
+
): void;
|
|
131
|
+
/**
|
|
132
|
+
* Tier 1 link: find entry by `spawnToken`, set its `sessionId`. Returns
|
|
133
|
+
* `true` on match. The strongest identity — used when the bridge sent
|
|
134
|
+
* `session_register.spawnToken`. When `pid` is provided AND the entry
|
|
135
|
+
* is keeper-mediated, `entry.piPid` is set so `killBySessionId` can
|
|
136
|
+
* SIGTERM pi directly (the keeper auto-exits on pi exit). See change:
|
|
137
|
+
* spawn-correlation-token, add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
138
|
+
*/
|
|
139
|
+
linkByToken(spawnToken: string, sessionId: string, pid?: number): boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Tier 2 link: find entry by `pid` (where `!sessionId`), set its
|
|
142
|
+
* `sessionId`. Returns `true` on match. Used when the bridge sent
|
|
143
|
+
* `session_register.pid` but no token. See change: spawn-correlation-token.
|
|
144
|
+
*/
|
|
145
|
+
linkByPid(sessionId: string, pid: number): boolean;
|
|
146
|
+
/**
|
|
147
|
+
* Tier 3 (legacy) link: find first entry by `cwd` where `!sessionId`,
|
|
148
|
+
* set its `sessionId`. Returns `true` on match. Cwd-FIFO fallback for
|
|
149
|
+
* old bridges that send neither token nor pid. Race-prone for
|
|
150
|
+
* concurrent same-cwd spawns; tiers 1–2 should pre-empt this.
|
|
151
|
+
*/
|
|
43
152
|
linkSession(sessionId: string, cwd: string): boolean;
|
|
44
|
-
/**
|
|
153
|
+
/**
|
|
154
|
+
* Get the PID linked to a session ID. In keeper mode returns the pi
|
|
155
|
+
* PID once linked (`entry.piPid`), falling back to `entry.pid` (= keeper
|
|
156
|
+
* PID at spawn) if the bridge hasn't connected yet. In non-keeper mode
|
|
157
|
+
* returns `entry.pid` unchanged.
|
|
158
|
+
*/
|
|
45
159
|
getPid(sessionId: string): number | undefined;
|
|
46
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Send SIGTERM to the process linked to a session ID. Returns true if
|
|
162
|
+
* killed. In keeper mode kills pi first (so the keeper's auto-exit-on-
|
|
163
|
+
* pi-exit fires) and falls back to killing the keeper after a brief
|
|
164
|
+
* delay if it survives. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
165
|
+
*/
|
|
47
166
|
killBySessionId(sessionId: string): boolean;
|
|
48
167
|
/** Remove a tracked process by PID. */
|
|
49
168
|
remove(pid: number): void;
|
|
@@ -53,23 +172,69 @@ export interface HeadlessPidRegistry {
|
|
|
53
172
|
size(): number;
|
|
54
173
|
/** Clean up orphan processes from a previous server instance. */
|
|
55
174
|
cleanupOrphans(): void;
|
|
175
|
+
/**
|
|
176
|
+
* Connect to the keeper UDS for `sessionId` and write `line + \n`.
|
|
177
|
+
* Returns false if no entry, no keeper for this session, or if the
|
|
178
|
+
* 3-attempt write to the socket fails. Never throws. Used by the
|
|
179
|
+
* server's dispatch handler to forward extension slash commands to pi.
|
|
180
|
+
* See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6/8).
|
|
181
|
+
*/
|
|
182
|
+
writeRpc(sessionId: string, line: string): Promise<boolean>;
|
|
183
|
+
/**
|
|
184
|
+
* Async startup pass: scan the sessions dir for live keeper sidecars
|
|
185
|
+
* (via the injected `KeeperManager.discoverExistingKeepers`) and
|
|
186
|
+
* reconcile them with the in-memory registry. Live keepers whose
|
|
187
|
+
* registry entry doesn't yet exist are skipped (the bridge will
|
|
188
|
+
* register them on connect). No-op when no `keeperManager` was injected.
|
|
189
|
+
* See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6).
|
|
190
|
+
*/
|
|
191
|
+
cleanupKeeperOrphans(): Promise<void>;
|
|
192
|
+
/**
|
|
193
|
+
* Inject the keeper writer / discoverer after construction. Necessary
|
|
194
|
+
* because `browser-gateway.ts` constructs the registry before the
|
|
195
|
+
* server creates the `KeeperManager`. Pass `null` to clear (used by tests).
|
|
196
|
+
*/
|
|
197
|
+
setKeeperWriter(writer: KeeperWriter | null): void;
|
|
56
198
|
}
|
|
57
199
|
|
|
58
200
|
export interface HeadlessPidRegistryOptions {
|
|
59
201
|
pidFilePath?: string;
|
|
202
|
+
/**
|
|
203
|
+
* Optional `KeeperWriter` (typically a `KeeperManager`) wired so the
|
|
204
|
+
* registry can delegate UDS writes and orphan reconciliation. May be
|
|
205
|
+
* supplied after construction via `setKeeperWriter` instead.
|
|
206
|
+
* See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6).
|
|
207
|
+
*/
|
|
208
|
+
keeperManager?: KeeperWriter;
|
|
60
209
|
}
|
|
61
210
|
|
|
62
211
|
export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions): HeadlessPidRegistry {
|
|
63
212
|
const entries = new Map<number, HeadlessEntry>();
|
|
64
213
|
const pidFilePath = options?.pidFilePath ?? DEFAULT_PID_FILE;
|
|
214
|
+
let keeperWriter: KeeperWriter | null = options?.keeperManager ?? null;
|
|
215
|
+
|
|
216
|
+
/** Internal: locate entry by sessionId. */
|
|
217
|
+
function findBySessionId(sessionId: string): HeadlessEntry | undefined {
|
|
218
|
+
for (const entry of entries.values()) {
|
|
219
|
+
if (entry.sessionId === sessionId) return entry;
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
65
223
|
|
|
66
224
|
function persist() {
|
|
67
225
|
const data: PidFileData = {
|
|
68
|
-
entries: [...entries.values()].map((e) =>
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
226
|
+
entries: [...entries.values()].map((e) => {
|
|
227
|
+
const out: PersistedEntry = {
|
|
228
|
+
pid: e.pid,
|
|
229
|
+
cwd: e.cwd,
|
|
230
|
+
spawnedAt: new Date(e.spawnedAt).toISOString(),
|
|
231
|
+
};
|
|
232
|
+
if (e.spawnToken) out.spawnToken = e.spawnToken;
|
|
233
|
+
if (e.piPid !== undefined) out.piPid = e.piPid;
|
|
234
|
+
if (e.keeperPid !== undefined) out.keeperPid = e.keeperPid;
|
|
235
|
+
if (e.keeperSockPath) out.keeperSockPath = e.keeperSockPath;
|
|
236
|
+
return out;
|
|
237
|
+
}),
|
|
73
238
|
};
|
|
74
239
|
try {
|
|
75
240
|
writeJsonFile(pidFilePath, data);
|
|
@@ -84,8 +249,25 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
|
|
|
84
249
|
}
|
|
85
250
|
|
|
86
251
|
return {
|
|
87
|
-
register(
|
|
88
|
-
|
|
252
|
+
register(
|
|
253
|
+
pid: number,
|
|
254
|
+
cwd: string,
|
|
255
|
+
proc: ChildProcess,
|
|
256
|
+
spawnToken?: string,
|
|
257
|
+
keeperOpts?: KeeperRegisterOptions,
|
|
258
|
+
) {
|
|
259
|
+
const entry: HeadlessEntry = {
|
|
260
|
+
pid,
|
|
261
|
+
cwd,
|
|
262
|
+
process: proc,
|
|
263
|
+
spawnedAt: Date.now(),
|
|
264
|
+
spawnToken,
|
|
265
|
+
};
|
|
266
|
+
if (keeperOpts) {
|
|
267
|
+
entry.keeperPid = keeperOpts.keeperPid;
|
|
268
|
+
entry.keeperSockPath = keeperOpts.keeperSockPath;
|
|
269
|
+
}
|
|
270
|
+
entries.set(pid, entry);
|
|
89
271
|
proc.on("exit", () => {
|
|
90
272
|
entries.delete(pid);
|
|
91
273
|
persist();
|
|
@@ -93,43 +275,120 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
|
|
|
93
275
|
persist();
|
|
94
276
|
},
|
|
95
277
|
|
|
96
|
-
|
|
278
|
+
linkByToken(spawnToken: string, sessionId: string, pid?: number): boolean {
|
|
279
|
+
if (!spawnToken) return false;
|
|
97
280
|
for (const entry of entries.values()) {
|
|
98
|
-
if (entry.
|
|
281
|
+
if (entry.spawnToken === spawnToken && !entry.sessionId) {
|
|
99
282
|
entry.sessionId = sessionId;
|
|
283
|
+
// Keeper-mode: store pi's PID separately so killBySessionId can
|
|
284
|
+
// SIGTERM pi directly (the keeper auto-exits on pi exit).
|
|
285
|
+
// Non-keeper mode leaves piPid undefined; getPid falls back to
|
|
286
|
+
// entry.pid. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
287
|
+
if (entry.keeperPid !== undefined && pid !== undefined && pid !== entry.pid) {
|
|
288
|
+
entry.piPid = pid;
|
|
289
|
+
// Persist immediately so a future cleanupOrphans reclaim has
|
|
290
|
+
// piPid available for linkByPid to match against (the token
|
|
291
|
+
// is omitted by the bridge on reattach).
|
|
292
|
+
persist();
|
|
293
|
+
}
|
|
100
294
|
return true;
|
|
101
295
|
}
|
|
102
296
|
}
|
|
103
297
|
return false;
|
|
104
298
|
},
|
|
105
299
|
|
|
106
|
-
|
|
300
|
+
linkByPid(sessionId: string, pid: number): boolean {
|
|
301
|
+
// Tier 2a: match by Map key (= spawn-time pid). Always tried first
|
|
302
|
+
// because it's O(1) and exact for non-keeper mode.
|
|
303
|
+
const direct = entries.get(pid);
|
|
304
|
+
if (direct && !direct.sessionId) {
|
|
305
|
+
direct.sessionId = sessionId;
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
// Tier 2b: keeper-mode reattach. After server restart, the Map is
|
|
309
|
+
// keyed by `keeperPid` (the spawn-time pid in keeper mode); pi's
|
|
310
|
+
// actual PID lives in `entry.piPid`. The bridge's session_register
|
|
311
|
+
// sends pi's PID, so direct Map lookup misses. Iterate to find a
|
|
312
|
+
// matching piPid — critical for correct sessionId↔pi mapping when
|
|
313
|
+
// multiple sessions share a cwd (otherwise the cwd-FIFO fallback
|
|
314
|
+
// mis-maps and `/ctx-stats` dispatches to the wrong keeper).
|
|
315
|
+
// See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
107
316
|
for (const entry of entries.values()) {
|
|
108
|
-
if (entry.
|
|
109
|
-
|
|
317
|
+
if (entry.piPid === pid && !entry.sessionId) {
|
|
318
|
+
entry.sessionId = sessionId;
|
|
319
|
+
return true;
|
|
110
320
|
}
|
|
111
321
|
}
|
|
112
|
-
return
|
|
322
|
+
return false;
|
|
113
323
|
},
|
|
114
324
|
|
|
115
|
-
|
|
325
|
+
linkSession(sessionId: string, cwd: string): boolean {
|
|
116
326
|
for (const entry of entries.values()) {
|
|
117
|
-
if (entry.
|
|
327
|
+
if (entry.cwd === cwd && !entry.sessionId) {
|
|
328
|
+
entry.sessionId = sessionId;
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
getPid(sessionId: string): number | undefined {
|
|
336
|
+
const entry = findBySessionId(sessionId);
|
|
337
|
+
if (!entry) return undefined;
|
|
338
|
+
// Keeper mode: prefer the linked pi PID; fall back to entry.pid (=
|
|
339
|
+
// keeper PID) when the bridge hasn't connected yet. Non-keeper mode
|
|
340
|
+
// returns entry.pid unchanged.
|
|
341
|
+
// See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
342
|
+
return entry.piPid ?? entry.pid;
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
killBySessionId(sessionId: string): boolean {
|
|
346
|
+
const entry = findBySessionId(sessionId);
|
|
347
|
+
if (!entry) return false;
|
|
348
|
+
|
|
349
|
+
// Keeper-mediated entry: kill pi first so the keeper's
|
|
350
|
+
// auto-exit-on-pi-exit handler fires; schedule a fallback SIGTERM
|
|
351
|
+
// to the keeper if it survives the brief grace window.
|
|
352
|
+
// See change: add-rpc-stdin-dispatch-with-keeper-sidecar (task 6.4).
|
|
353
|
+
if (entry.keeperPid !== undefined) {
|
|
354
|
+
const piPid = entry.piPid;
|
|
355
|
+
const keeperPid = entry.keeperPid;
|
|
356
|
+
let killedSomething = false;
|
|
357
|
+
if (piPid !== undefined) {
|
|
118
358
|
try {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return false;
|
|
359
|
+
killPidWithGroup(piPid, "SIGTERM");
|
|
360
|
+
killedSomething = true;
|
|
361
|
+
} catch { /* pi may already be dead */ }
|
|
362
|
+
}
|
|
363
|
+
// Fallback: 200 ms grace for the keeper's auto-exit; SIGTERM if it
|
|
364
|
+
// survives. Fire-and-forget to keep killBySessionId synchronous.
|
|
365
|
+
setTimeout(() => {
|
|
366
|
+
if (isProcessAlive(keeperPid)) {
|
|
367
|
+
try { killPidWithGroup(keeperPid, "SIGTERM"); } catch { /* ignore */ }
|
|
129
368
|
}
|
|
369
|
+
}, 200).unref?.();
|
|
370
|
+
// If pi was unknown (bridge never connected), fall through to
|
|
371
|
+
// killing the keeper directly so the spawn cleanup completes.
|
|
372
|
+
if (!killedSomething) {
|
|
373
|
+
try { killPidWithGroup(keeperPid, "SIGTERM"); killedSomething = true; }
|
|
374
|
+
catch { /* ignore */ }
|
|
130
375
|
}
|
|
376
|
+
entries.delete(entry.pid);
|
|
377
|
+
persist();
|
|
378
|
+
return killedSomething;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Non-keeper path (legacy): kill the spawn-time PID directly.
|
|
382
|
+
try {
|
|
383
|
+
killPidWithGroup(entry.pid, "SIGTERM");
|
|
384
|
+
entries.delete(entry.pid);
|
|
385
|
+
persist();
|
|
386
|
+
return true;
|
|
387
|
+
} catch {
|
|
388
|
+
entries.delete(entry.pid);
|
|
389
|
+
persist();
|
|
390
|
+
return false;
|
|
131
391
|
}
|
|
132
|
-
return false;
|
|
133
392
|
},
|
|
134
393
|
|
|
135
394
|
remove(pid: number) {
|
|
@@ -158,6 +417,46 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
|
|
|
158
417
|
return entries.size;
|
|
159
418
|
},
|
|
160
419
|
|
|
420
|
+
setKeeperWriter(writer: KeeperWriter | null) {
|
|
421
|
+
keeperWriter = writer;
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
async writeRpc(sessionId: string, line: string): Promise<boolean> {
|
|
425
|
+
const entry = findBySessionId(sessionId);
|
|
426
|
+
if (!entry || !entry.keeperSockPath || !keeperWriter) return false;
|
|
427
|
+
return keeperWriter.writeRpcToSockPath(entry.keeperSockPath, line);
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
async cleanupKeeperOrphans(): Promise<void> {
|
|
431
|
+
if (isUnsafeTestHomeScan()) {
|
|
432
|
+
console.warn("[headless-pid-registry] cleanupKeeperOrphans() blocked: running under vitest with real HOME");
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (!keeperWriter) return;
|
|
436
|
+
// KeeperManager.discoverExistingKeepers does the heavy lifting:
|
|
437
|
+
// unlinks stale sockets, SIGTERMs orphans whose pi child is dead.
|
|
438
|
+
// The registry only needs to know about live pairs so it can
|
|
439
|
+
// attach keeper info to existing entries (matched by spawn-time
|
|
440
|
+
// pid via the persisted sidecar PID).
|
|
441
|
+
try {
|
|
442
|
+
const live = await keeperWriter.discoverExistingKeepers();
|
|
443
|
+
for (const k of live) {
|
|
444
|
+
// Reattach to any existing entry whose spawn-time PID matches
|
|
445
|
+
// the keeper PID (set when the previous server instance ran
|
|
446
|
+
// spawnHeadlessViaKeeper). Defensive: do not blow away entries
|
|
447
|
+
// that already have keeperPid set.
|
|
448
|
+
const existing = entries.get(k.keeperPid);
|
|
449
|
+
if (existing && existing.keeperPid === undefined) {
|
|
450
|
+
existing.keeperPid = k.keeperPid;
|
|
451
|
+
existing.keeperSockPath = k.sockPath;
|
|
452
|
+
persist();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
} catch (err) {
|
|
456
|
+
console.warn("[headless-pid-registry] cleanupKeeperOrphans failed", err);
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
|
|
161
460
|
cleanupOrphans() {
|
|
162
461
|
if (isUnsafeTestHomeScan()) {
|
|
163
462
|
console.warn("[headless-pid-registry] cleanupOrphans() blocked: running under vitest with real HOME");
|
|
@@ -185,16 +484,24 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
|
|
|
185
484
|
continue;
|
|
186
485
|
}
|
|
187
486
|
|
|
188
|
-
// Alive and not too old — reclaim into registry
|
|
189
|
-
//
|
|
190
|
-
//
|
|
487
|
+
// Alive and not too old — reclaim into registry.
|
|
488
|
+
// ALL identity fields are restored so the post-restart three-tier
|
|
489
|
+
// link (token → pid → cwd-FIFO) keeps its precision; without piPid
|
|
490
|
+
// / spawnToken, keeper-mode sessions mis-map under cwd-FIFO when
|
|
491
|
+
// two sessions share a cwd. See change:
|
|
492
|
+
// add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
191
493
|
const dummyProc = new EventEmitter() as ChildProcess;
|
|
192
|
-
|
|
494
|
+
const reclaimed: HeadlessEntry = {
|
|
193
495
|
pid: entry.pid,
|
|
194
496
|
cwd: entry.cwd,
|
|
195
497
|
process: dummyProc,
|
|
196
498
|
spawnedAt,
|
|
197
|
-
}
|
|
499
|
+
};
|
|
500
|
+
if (entry.spawnToken) reclaimed.spawnToken = entry.spawnToken;
|
|
501
|
+
if (entry.piPid !== undefined) reclaimed.piPid = entry.piPid;
|
|
502
|
+
if (entry.keeperPid !== undefined) reclaimed.keeperPid = entry.keeperPid;
|
|
503
|
+
if (entry.keeperSockPath) reclaimed.keeperSockPath = entry.keeperSockPath;
|
|
504
|
+
entries.set(entry.pid, reclaimed);
|
|
198
505
|
}
|
|
199
506
|
|
|
200
507
|
persist();
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects and removes legacy `@mariozechner/pi-coding-agent` installs.
|
|
3
|
+
*
|
|
4
|
+
* Pi was renamed to `@earendil-works/pi-coding-agent` at v0.74. The legacy
|
|
5
|
+
* scope is published only up to v0.73.x and conflicts with the new scope's
|
|
6
|
+
* `bin/pi` symlink in npm-global (EEXIST). This module surfaces legacy
|
|
7
|
+
* installs so the UI can offer a one-click cleanup.
|
|
8
|
+
*
|
|
9
|
+
* Detection is read-only and cheap (3 fs.stat calls + optional `npm root
|
|
10
|
+
* -g`). Cleanup is gated behind a POST endpoint — never silent.
|
|
11
|
+
*
|
|
12
|
+
* Scopes scanned:
|
|
13
|
+
* - npm-global: `$(npm root -g)/@mariozechner/pi-coding-agent`
|
|
14
|
+
* - npx-cache: `~/.npm/_npx/<hash>/node_modules/@mariozechner/pi-coding-agent`
|
|
15
|
+
* - managed: `~/.pi-dashboard/node_modules/@mariozechner/pi-coding-agent`
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import { execSync } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
|
|
21
|
+
|
|
22
|
+
export const LEGACY_PI_PACKAGE = "@mariozechner/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
export type LegacyPiScope = "npm-global" | "npx-cache" | "managed";
|
|
25
|
+
|
|
26
|
+
export interface LegacyPiInstall {
|
|
27
|
+
scope: LegacyPiScope;
|
|
28
|
+
path: string;
|
|
29
|
+
version: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LegacyPiCleanupResult {
|
|
33
|
+
scope: LegacyPiScope;
|
|
34
|
+
path: string;
|
|
35
|
+
removed: boolean;
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Pure helpers (no I/O) ──────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** Build the legacy package path under a given node_modules root. */
|
|
42
|
+
export function legacyPathUnder(nodeModulesDir: string): string {
|
|
43
|
+
return path.join(nodeModulesDir, ...LEGACY_PI_PACKAGE.split("/"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read `version` from a package.json blob; returns null on any parse failure. */
|
|
47
|
+
export function parseVersion(packageJsonRaw: string): string | null {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(packageJsonRaw);
|
|
50
|
+
return typeof parsed.version === "string" ? parsed.version : null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Detection ──────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function safeStatDir(p: string): boolean {
|
|
59
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readVersionOf(packageDir: string): string | null {
|
|
63
|
+
try {
|
|
64
|
+
const raw = fs.readFileSync(path.join(packageDir, "package.json"), "utf-8");
|
|
65
|
+
return parseVersion(raw);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function detectNpmGlobal(): LegacyPiInstall | null {
|
|
72
|
+
let globalRoot: string;
|
|
73
|
+
try {
|
|
74
|
+
globalRoot = execSync("npm root -g", { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
75
|
+
} catch {
|
|
76
|
+
return null; // npm not available, or call failed — treat as no install
|
|
77
|
+
}
|
|
78
|
+
if (!globalRoot) return null;
|
|
79
|
+
const pkgDir = legacyPathUnder(globalRoot);
|
|
80
|
+
if (!safeStatDir(pkgDir)) return null;
|
|
81
|
+
return { scope: "npm-global", path: pkgDir, version: readVersionOf(pkgDir) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function detectNpxCache(): LegacyPiInstall[] {
|
|
85
|
+
const root = path.join(os.homedir(), ".npm", "_npx");
|
|
86
|
+
let entries: string[];
|
|
87
|
+
try { entries = fs.readdirSync(root); } catch { return []; }
|
|
88
|
+
const found: LegacyPiInstall[] = [];
|
|
89
|
+
for (const hash of entries) {
|
|
90
|
+
const pkgDir = legacyPathUnder(path.join(root, hash, "node_modules"));
|
|
91
|
+
if (safeStatDir(pkgDir)) {
|
|
92
|
+
found.push({ scope: "npx-cache", path: pkgDir, version: readVersionOf(pkgDir) });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function detectManaged(): LegacyPiInstall | null {
|
|
99
|
+
const pkgDir = legacyPathUnder(path.join(os.homedir(), ".pi-dashboard", "node_modules"));
|
|
100
|
+
if (!safeStatDir(pkgDir)) return null;
|
|
101
|
+
return { scope: "managed", path: pkgDir, version: readVersionOf(pkgDir) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Scan all three locations for legacy pi installs. Synchronous because
|
|
106
|
+
* the cost is dominated by one `npm root -g` invocation (~50ms once);
|
|
107
|
+
* everything else is fs.statSync. Called at startup and on POST refresh.
|
|
108
|
+
*/
|
|
109
|
+
export function detectLegacyPiInstalls(): LegacyPiInstall[] {
|
|
110
|
+
const found: LegacyPiInstall[] = [];
|
|
111
|
+
const g = detectNpmGlobal();
|
|
112
|
+
if (g) found.push(g);
|
|
113
|
+
found.push(...detectNpxCache());
|
|
114
|
+
const m = detectManaged();
|
|
115
|
+
if (m) found.push(m);
|
|
116
|
+
return found;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Cleanup ────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function rmrf(target: string): void {
|
|
122
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function removeOne(install: LegacyPiInstall): LegacyPiCleanupResult {
|
|
126
|
+
const base: Pick<LegacyPiCleanupResult, "scope" | "path"> = { scope: install.scope, path: install.path };
|
|
127
|
+
try {
|
|
128
|
+
if (install.scope === "npm-global") {
|
|
129
|
+
// npm-global needs the package-manager call so any bin symlinks are
|
|
130
|
+
// cleaned up too. Using `--no-fund --no-audit` to keep output quiet.
|
|
131
|
+
execSync(`npm uninstall -g --no-fund --no-audit ${LEGACY_PI_PACKAGE}`, {
|
|
132
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
133
|
+
encoding: "utf-8",
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
// npx-cache and managed are plain node_modules subtrees we can rm.
|
|
137
|
+
rmrf(install.path);
|
|
138
|
+
}
|
|
139
|
+
return { ...base, removed: true };
|
|
140
|
+
} catch (err: any) {
|
|
141
|
+
return { ...base, removed: false, error: err?.message ?? String(err) };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Remove all detected legacy installs. Each scope is attempted
|
|
147
|
+
* independently; one failure does not abort the others.
|
|
148
|
+
*/
|
|
149
|
+
export function uninstallLegacyPi(installs: readonly LegacyPiInstall[]): LegacyPiCleanupResult[] {
|
|
150
|
+
return installs.map(removeOne);
|
|
151
|
+
}
|