@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
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { WebSocketServer, WebSocket } from "ws";
|
|
6
6
|
import type {
|
|
7
7
|
ServerToBrowserMessage,
|
|
8
|
+
BrowserOpenSpecUpdateMessage,
|
|
8
9
|
BrowserToServerMessage,
|
|
9
10
|
} from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
|
|
10
11
|
import type { SessionManager } from "./memory-session-manager.js";
|
|
@@ -15,7 +16,7 @@ import { createHeadlessPidRegistry, type HeadlessPidRegistry } from "./headless-
|
|
|
15
16
|
import type { PendingForkRegistry } from "./pending-fork-registry.js";
|
|
16
17
|
import type { SessionOrderManager } from "./session-order-manager.js";
|
|
17
18
|
import type { PreferencesStore } from "./preferences-store.js";
|
|
18
|
-
import { hasOpenSpecDir, type DirectoryService } from "./directory-service.js";
|
|
19
|
+
import { hasOpenSpecDir, hasOpenSpecRoot, type DirectoryService } from "./directory-service.js";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Pure helper: build the per-cwd `openspec_update` messages a freshly
|
|
@@ -31,23 +32,30 @@ import { hasOpenSpecDir, type DirectoryService } from "./directory-service.js";
|
|
|
31
32
|
export function buildOpenSpecConnectSnapshot(
|
|
32
33
|
directoryService: Pick<DirectoryService, "knownDirectories" | "getOpenSpecData">,
|
|
33
34
|
hasDir: (cwd: string) => boolean,
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
hasRoot: (cwd: string) => boolean = hasDir,
|
|
36
|
+
): Array<BrowserOpenSpecUpdateMessage> {
|
|
37
|
+
const out: Array<BrowserOpenSpecUpdateMessage> = [];
|
|
36
38
|
for (const cwd of directoryService.knownDirectories()) {
|
|
37
39
|
const cached = directoryService.getOpenSpecData(cwd);
|
|
40
|
+
const root = hasRoot(cwd);
|
|
38
41
|
if (cached && cached.initialized) {
|
|
39
|
-
|
|
42
|
+
// Cached payload already carries `hasOpenspecDir` set by `pollOne`; if
|
|
43
|
+
// an old cache entry predates that field, fill it from the live probe.
|
|
44
|
+
const data = cached.hasOpenspecDir === undefined
|
|
45
|
+
? { ...cached, hasOpenspecDir: root }
|
|
46
|
+
: cached;
|
|
47
|
+
out.push({ type: "openspec_update", cwd, data });
|
|
40
48
|
} else if (hasDir(cwd)) {
|
|
41
49
|
out.push({
|
|
42
50
|
type: "openspec_update",
|
|
43
51
|
cwd,
|
|
44
|
-
data: { initialized: false, pending: true, changes: [] },
|
|
52
|
+
data: { initialized: false, pending: true, changes: [], hasOpenspecDir: root },
|
|
45
53
|
});
|
|
46
54
|
} else {
|
|
47
55
|
out.push({
|
|
48
56
|
type: "openspec_update",
|
|
49
57
|
cwd,
|
|
50
|
-
data: { initialized: false, pending: false, changes: [] },
|
|
58
|
+
data: { initialized: false, pending: false, changes: [], hasOpenspecDir: root },
|
|
51
59
|
});
|
|
52
60
|
}
|
|
53
61
|
}
|
|
@@ -68,7 +76,7 @@ import { handlePinDirectory, handleUnpinDirectory, handleReorderPinnedDirs, hand
|
|
|
68
76
|
export interface BrowserGateway {
|
|
69
77
|
wss: WebSocketServer;
|
|
70
78
|
broadcastEvent(sessionId: string, seq: number, event: any): void;
|
|
71
|
-
broadcastSessionAdded(session: any): void;
|
|
79
|
+
broadcastSessionAdded(session: any, opts?: { spawnRequestId?: string }): void;
|
|
72
80
|
broadcastSessionUpdated(sessionId: string, updates: any): void;
|
|
73
81
|
broadcastSessionRemoved(sessionId: string): void;
|
|
74
82
|
sendToSubscribers(sessionId: string, msg: ServerToBrowserMessage): void;
|
|
@@ -119,6 +127,7 @@ export function createBrowserGateway(
|
|
|
119
127
|
maxWsBufferBytes?: number,
|
|
120
128
|
pendingAttachRegistry?: import("./pending-attach-registry.js").PendingAttachRegistry,
|
|
121
129
|
pendingResumeIntents?: import("./pending-resume-intent-registry.js").PendingResumeIntentRegistry,
|
|
130
|
+
pendingClientCorrelations?: import("./pending-client-correlations.js").PendingClientCorrelations,
|
|
122
131
|
): BrowserGateway {
|
|
123
132
|
const wss = new WebSocketServer({ noServer: true });
|
|
124
133
|
|
|
@@ -271,7 +280,7 @@ export function createBrowserGateway(
|
|
|
271
280
|
// `openspec_update` per cwd, never silently omit.
|
|
272
281
|
// See change: fix-cold-boot-openspec-protocol.
|
|
273
282
|
if (directoryService) {
|
|
274
|
-
for (const msg of buildOpenSpecConnectSnapshot(directoryService, hasOpenSpecDir)) {
|
|
283
|
+
for (const msg of buildOpenSpecConnectSnapshot(directoryService, hasOpenSpecDir, hasOpenSpecRoot)) {
|
|
275
284
|
sendTo(ws, msg);
|
|
276
285
|
}
|
|
277
286
|
}
|
|
@@ -307,6 +316,7 @@ export function createBrowserGateway(
|
|
|
307
316
|
headlessPidRegistry, pendingResumeRegistry, pendingDashboardSpawns,
|
|
308
317
|
pendingAttachRegistry,
|
|
309
318
|
pendingResumeIntents,
|
|
319
|
+
pendingClientCorrelations,
|
|
310
320
|
sendTo, broadcast, getSubscribers, replayPendingUiRequests,
|
|
311
321
|
trackUiRequest: trackUiRequest,
|
|
312
322
|
markReplaying(targetWs, sessionId) {
|
|
@@ -574,8 +584,15 @@ export function createBrowserGateway(
|
|
|
574
584
|
}
|
|
575
585
|
},
|
|
576
586
|
|
|
577
|
-
broadcastSessionAdded(session: any) {
|
|
578
|
-
|
|
587
|
+
broadcastSessionAdded(session: any, opts?: { spawnRequestId?: string }) {
|
|
588
|
+
// Carry the originating client `requestId` (when known) so the
|
|
589
|
+
// browser can auto-select / dismiss its placeholder by exact
|
|
590
|
+
// correlation. See change: spawn-correlation-token.
|
|
591
|
+
broadcast({
|
|
592
|
+
type: "session_added",
|
|
593
|
+
session,
|
|
594
|
+
...(opts?.spawnRequestId ? { spawnRequestId: opts.spawnRequestId } : {}),
|
|
595
|
+
});
|
|
579
596
|
},
|
|
580
597
|
|
|
581
598
|
broadcastSessionUpdated(sessionId: string, updates: any) {
|
|
@@ -16,6 +16,7 @@ import type { HeadlessPidRegistry } from "../headless-pid-registry.js";
|
|
|
16
16
|
import type { PendingResumeRegistry } from "../pending-resume-registry.js";
|
|
17
17
|
import type { PendingAttachRegistry } from "../pending-attach-registry.js";
|
|
18
18
|
import type { PendingResumeIntentRegistry } from "../pending-resume-intent-registry.js";
|
|
19
|
+
import type { PendingClientCorrelations } from "../pending-client-correlations.js";
|
|
19
20
|
|
|
20
21
|
export interface BrowserHandlerContext {
|
|
21
22
|
ws: WebSocket;
|
|
@@ -43,6 +44,14 @@ export interface BrowserHandlerContext {
|
|
|
43
44
|
* See change: preserve-session-order-on-reboot.
|
|
44
45
|
*/
|
|
45
46
|
pendingResumeIntents?: PendingResumeIntentRegistry;
|
|
47
|
+
/**
|
|
48
|
+
* Optional registry mapping `spawnToken → requestId` for client-side
|
|
49
|
+
* correlation. When set, browser-initiated spawns/resumes that carry a
|
|
50
|
+
* `requestId` are recorded so the eventual `session_added` broadcast
|
|
51
|
+
* carries `spawnRequestId` for auto-select / placeholder dismissal.
|
|
52
|
+
* See change: spawn-correlation-token.
|
|
53
|
+
*/
|
|
54
|
+
pendingClientCorrelations?: PendingClientCorrelations;
|
|
46
55
|
/** Send message to a specific WebSocket */
|
|
47
56
|
sendTo(ws: WebSocket, msg: ServerToBrowserMessage): void;
|
|
48
57
|
/** Broadcast to all connected browsers */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session action handlers: send_prompt, abort, resume, spawn, shutdown, flow_control.
|
|
3
3
|
*/
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
4
5
|
import type { BrowserToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
|
|
5
6
|
import type { BrowserHandlerContext } from "./handler-context.js";
|
|
6
7
|
import { spawnPiSession } from "../process-manager.js";
|
|
@@ -18,6 +19,20 @@ import {
|
|
|
18
19
|
findPidByMarker,
|
|
19
20
|
} from "@blackbelt-technology/pi-dashboard-shared/platform/process-identify.js";
|
|
20
21
|
import { shouldInterceptReload } from "./session-action-helpers.js";
|
|
22
|
+
import { keeperOptsFromSpawnResult } from "../headless-pid-registry.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Status message + code emitted when fork is attempted on a session whose
|
|
26
|
+
* `.jsonl` does not exist on disk yet (empty session, no persisted entries).
|
|
27
|
+
* The dashboard silently degrades to a fresh spawn in the same cwd — fork
|
|
28
|
+
* has no history to copy, so the user-meaningful semantic of "fork" and
|
|
29
|
+
* "new" is identical here. The structured code lets the client surface a
|
|
30
|
+
* non-blocking toast.
|
|
31
|
+
* See change: fix-fork-empty-session-silent-timeout.
|
|
32
|
+
*/
|
|
33
|
+
export const FORK_DEGRADED_TO_NEW_MESSAGE =
|
|
34
|
+
"Started a fresh session \u2014 the source had no persisted history to fork from.";
|
|
35
|
+
export const FORK_DEGRADED_TO_NEW_CODE = "FORK_DEGRADED_TO_NEW";
|
|
21
36
|
|
|
22
37
|
/**
|
|
23
38
|
* Find headless pi PIDs associated with a session-id marker and kill them.
|
|
@@ -162,7 +177,13 @@ export async function handleHeadlessReload(
|
|
|
162
177
|
}
|
|
163
178
|
|
|
164
179
|
if (spawnResult.pid && spawnResult.process) {
|
|
165
|
-
headlessPidRegistry.register(
|
|
180
|
+
headlessPidRegistry.register(
|
|
181
|
+
spawnResult.pid,
|
|
182
|
+
session.cwd,
|
|
183
|
+
spawnResult.process,
|
|
184
|
+
spawnResult.spawnToken,
|
|
185
|
+
keeperOptsFromSpawnResult(spawnResult),
|
|
186
|
+
);
|
|
166
187
|
}
|
|
167
188
|
|
|
168
189
|
emitCommandFeedback(ctx, msg.sessionId, "completed");
|
|
@@ -221,7 +242,13 @@ export async function handleSendPrompt(
|
|
|
221
242
|
pendingDashboardSpawns?.set(promptSession.cwd, (pendingDashboardSpawns?.get(promptSession.cwd) ?? 0) + 1);
|
|
222
243
|
}
|
|
223
244
|
if (spawnResult.process && spawnResult.pid) {
|
|
224
|
-
headlessPidRegistry.register(
|
|
245
|
+
headlessPidRegistry.register(
|
|
246
|
+
spawnResult.pid,
|
|
247
|
+
promptSession.cwd,
|
|
248
|
+
spawnResult.process,
|
|
249
|
+
spawnResult.spawnToken,
|
|
250
|
+
keeperOptsFromSpawnResult(spawnResult),
|
|
251
|
+
);
|
|
225
252
|
}
|
|
226
253
|
} else {
|
|
227
254
|
const sent = piGateway.sendToSession(msg.sessionId, {
|
|
@@ -240,10 +267,10 @@ export async function handleResumeSession(
|
|
|
240
267
|
msg: Extract<BrowserToServerMessage, { type: "resume_session" }>,
|
|
241
268
|
ctx: BrowserHandlerContext,
|
|
242
269
|
): Promise<void> {
|
|
243
|
-
const { ws, sessionManager, pendingForkRegistry, headlessPidRegistry, pendingDashboardSpawns, pendingResumeIntents, sendTo } = ctx;
|
|
270
|
+
const { ws, sessionManager, pendingForkRegistry, headlessPidRegistry, pendingDashboardSpawns, pendingResumeIntents, pendingClientCorrelations, sendTo } = ctx;
|
|
244
271
|
const session = sessionManager.get(msg.sessionId);
|
|
245
272
|
if (!session) {
|
|
246
|
-
sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session not found" });
|
|
273
|
+
sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session not found", requestId: msg.requestId });
|
|
247
274
|
return;
|
|
248
275
|
}
|
|
249
276
|
// Resolve placement intent. Old browsers omit the field; default to
|
|
@@ -252,28 +279,72 @@ export async function handleResumeSession(
|
|
|
252
279
|
// See change: differentiate-resume-intent-by-trigger.
|
|
253
280
|
const placement: "front" | "keep" = msg.placement ?? "front";
|
|
254
281
|
if (!session.sessionFile) {
|
|
255
|
-
sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session file is unknown (pre-migration session)" });
|
|
282
|
+
sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session file is unknown (pre-migration session)", requestId: msg.requestId });
|
|
256
283
|
return;
|
|
257
284
|
}
|
|
258
285
|
if (msg.mode === "continue" && session.status !== "ended") {
|
|
259
|
-
sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session is already active" });
|
|
286
|
+
sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session is already active", requestId: msg.requestId });
|
|
260
287
|
return;
|
|
261
288
|
}
|
|
262
289
|
if (session.resuming) {
|
|
263
|
-
sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session is already being resumed" });
|
|
290
|
+
sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session is already being resumed", requestId: msg.requestId });
|
|
264
291
|
return;
|
|
265
292
|
}
|
|
266
|
-
|
|
267
|
-
|
|
293
|
+
// Fork preflight: silent-degrade when the source session has no on-disk
|
|
294
|
+
// JSONL yet (empty session, no persisted entries). `pi --fork <missing>`
|
|
295
|
+
// would crash silently and produce a 30s register-timeout; instead we
|
|
296
|
+
// spawn a fresh pi in the same cwd and surface `code: FORK_DEGRADED_TO_NEW`
|
|
297
|
+
// so the client can render a non-blocking toast. The parent's
|
|
298
|
+
// attachedProposal (if any) is inherited via `pendingAttachRegistry`
|
|
299
|
+
// since fork's own inheritance path doesn't run on this branch.
|
|
300
|
+
// See change: fix-fork-empty-session-silent-timeout.
|
|
301
|
+
if (msg.mode === "fork" && session.sessionFile && !existsSync(session.sessionFile)) {
|
|
302
|
+
// Inherit attachedProposal from parent so the new session still
|
|
303
|
+
// tracks the change the user was working on.
|
|
304
|
+
const pendingAttachRegistry = ctx.pendingAttachRegistry;
|
|
305
|
+
if (session.attachedProposal && pendingAttachRegistry) {
|
|
306
|
+
pendingAttachRegistry.enqueue(session.cwd, session.attachedProposal);
|
|
307
|
+
}
|
|
308
|
+
const degradeConfig = loadConfig();
|
|
309
|
+
// Fresh spawn: no sessionFile, no mode — just `pi --mode rpc`.
|
|
310
|
+
const degradeResult = await spawnPiSession(session.cwd, {
|
|
311
|
+
strategy: degradeConfig.spawnStrategy,
|
|
312
|
+
});
|
|
313
|
+
if (degradeResult.process && degradeResult.pid) {
|
|
314
|
+
headlessPidRegistry.register(
|
|
315
|
+
degradeResult.pid,
|
|
316
|
+
session.cwd,
|
|
317
|
+
degradeResult.process,
|
|
318
|
+
degradeResult.spawnToken,
|
|
319
|
+
keeperOptsFromSpawnResult(degradeResult),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (msg.requestId && degradeResult.spawnToken && pendingClientCorrelations) {
|
|
323
|
+
pendingClientCorrelations.record(degradeResult.spawnToken, msg.requestId);
|
|
324
|
+
}
|
|
325
|
+
if (degradeResult.dashboardSpawned && degradeResult.success) {
|
|
326
|
+
pendingDashboardSpawns?.set(
|
|
327
|
+
session.cwd,
|
|
328
|
+
(pendingDashboardSpawns?.get(session.cwd) ?? 0) + 1,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
sendTo(ws, {
|
|
332
|
+
type: "resume_result",
|
|
333
|
+
sessionId: msg.sessionId,
|
|
334
|
+
success: degradeResult.success,
|
|
335
|
+
message: degradeResult.success ? FORK_DEGRADED_TO_NEW_MESSAGE : degradeResult.message,
|
|
336
|
+
requestId: msg.requestId,
|
|
337
|
+
...(degradeResult.success ? { code: FORK_DEGRADED_TO_NEW_CODE } : {}),
|
|
338
|
+
});
|
|
339
|
+
return;
|
|
268
340
|
}
|
|
269
|
-
|
|
270
341
|
// For fork-from-message: create a pruned session file first
|
|
271
342
|
let forkSessionFile = session.sessionFile;
|
|
272
343
|
if (msg.mode === "fork" && msg.entryId) {
|
|
273
344
|
try {
|
|
274
345
|
forkSessionFile = createBranchedSessionFile(session.sessionFile, msg.entryId);
|
|
275
346
|
} catch (err: any) {
|
|
276
|
-
sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: `Fork from entry failed: ${err.message}
|
|
347
|
+
sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: `Fork from entry failed: ${err.message}`, requestId: msg.requestId });
|
|
277
348
|
return;
|
|
278
349
|
}
|
|
279
350
|
}
|
|
@@ -293,26 +364,45 @@ export async function handleResumeSession(
|
|
|
293
364
|
mode: msg.mode,
|
|
294
365
|
strategy: resumeConfig.spawnStrategy,
|
|
295
366
|
});
|
|
367
|
+
// Record fork parent keyed by spawn token (was: keyed by cwd, racy on
|
|
368
|
+
// multi-fork-in-same-cwd). See change: spawn-correlation-token.
|
|
369
|
+
if (msg.mode === "fork" && pendingForkRegistry && result.spawnToken) {
|
|
370
|
+
pendingForkRegistry.recordFork(result.spawnToken, msg.sessionId);
|
|
371
|
+
}
|
|
372
|
+
// Record client-correlation so the eventual session_added carries
|
|
373
|
+
// spawnRequestId. See change: spawn-correlation-token.
|
|
374
|
+
if (msg.requestId && result.spawnToken && pendingClientCorrelations) {
|
|
375
|
+
pendingClientCorrelations.record(result.spawnToken, msg.requestId);
|
|
376
|
+
}
|
|
296
377
|
if (result.dashboardSpawned && result.success) {
|
|
297
378
|
pendingDashboardSpawns?.set(session.cwd, (pendingDashboardSpawns?.get(session.cwd) ?? 0) + 1);
|
|
298
379
|
}
|
|
299
380
|
if (result.process && result.pid) {
|
|
300
|
-
headlessPidRegistry.register(
|
|
381
|
+
headlessPidRegistry.register(
|
|
382
|
+
result.pid,
|
|
383
|
+
session.cwd,
|
|
384
|
+
result.process,
|
|
385
|
+
result.spawnToken,
|
|
386
|
+
keeperOptsFromSpawnResult(result),
|
|
387
|
+
);
|
|
301
388
|
}
|
|
302
|
-
sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: result.success, message: result.message });
|
|
389
|
+
sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: result.success, message: result.message, requestId: msg.requestId });
|
|
303
390
|
}
|
|
304
391
|
|
|
305
392
|
export async function handleSpawnSession(
|
|
306
393
|
msg: Extract<BrowserToServerMessage, { type: "spawn_session" }>,
|
|
307
394
|
ctx: BrowserHandlerContext,
|
|
308
395
|
): Promise<void> {
|
|
309
|
-
const { ws, headlessPidRegistry, pendingDashboardSpawns, pendingAttachRegistry, sendTo } = ctx;
|
|
396
|
+
const { ws, headlessPidRegistry, pendingDashboardSpawns, pendingAttachRegistry, pendingClientCorrelations, sendTo } = ctx;
|
|
310
397
|
const config = loadConfig();
|
|
311
398
|
const strategy = config.spawnStrategy ?? "tmux";
|
|
312
399
|
|
|
313
400
|
// Queue the optional attach intent BEFORE awaiting the spawn so a fast
|
|
314
401
|
// bridge `session_register` cannot lose the intent. See change:
|
|
315
|
-
// add-folder-task-checker-and-spawn-attach.
|
|
402
|
+
// add-folder-task-checker-and-spawn-attach. NOTE: at this point we don't
|
|
403
|
+
// yet have a spawnToken (spawn hasn't run); we enqueue by cwd-FIFO and
|
|
404
|
+
// re-record by token after spawnPiSession returns. See change:
|
|
405
|
+
// spawn-correlation-token.
|
|
316
406
|
if (typeof msg.attachProposal === "string" && msg.attachProposal.length > 0) {
|
|
317
407
|
pendingAttachRegistry?.enqueue(msg.cwd, msg.attachProposal);
|
|
318
408
|
}
|
|
@@ -322,7 +412,7 @@ export async function handleSpawnSession(
|
|
|
322
412
|
const preflight = preflightSpawn(msg.cwd, { resolver: preflightResolver });
|
|
323
413
|
if (!preflight.ok) {
|
|
324
414
|
const message = preflight.reasons.map((r) => r.message).join("; ");
|
|
325
|
-
sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message });
|
|
415
|
+
sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message, requestId: msg.requestId });
|
|
326
416
|
sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message, code: "PREFLIGHT_FAILED", reasons: preflight.reasons });
|
|
327
417
|
appendSpawnFailure({
|
|
328
418
|
ts: new Date().toISOString(),
|
|
@@ -342,12 +432,30 @@ export async function handleSpawnSession(
|
|
|
342
432
|
try {
|
|
343
433
|
const spawnResult = await spawnPiSession(msg.cwd, { strategy });
|
|
344
434
|
if (spawnResult.process && spawnResult.pid) {
|
|
345
|
-
headlessPidRegistry.register(
|
|
435
|
+
headlessPidRegistry.register(
|
|
436
|
+
spawnResult.pid,
|
|
437
|
+
msg.cwd,
|
|
438
|
+
spawnResult.process,
|
|
439
|
+
spawnResult.spawnToken,
|
|
440
|
+
keeperOptsFromSpawnResult(spawnResult),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
// Record client-correlation so the eventual session_added carries
|
|
444
|
+
// spawnRequestId. See change: spawn-correlation-token.
|
|
445
|
+
if (msg.requestId && spawnResult.spawnToken && pendingClientCorrelations) {
|
|
446
|
+
pendingClientCorrelations.record(spawnResult.spawnToken, msg.requestId);
|
|
346
447
|
}
|
|
347
448
|
if (spawnResult.dashboardSpawned && spawnResult.success) {
|
|
348
449
|
pendingDashboardSpawns?.set(msg.cwd, (pendingDashboardSpawns?.get(msg.cwd) ?? 0) + 1);
|
|
349
450
|
}
|
|
350
|
-
sendTo(ws, {
|
|
451
|
+
sendTo(ws, {
|
|
452
|
+
type: "spawn_result",
|
|
453
|
+
cwd: msg.cwd,
|
|
454
|
+
success: spawnResult.success,
|
|
455
|
+
message: spawnResult.message,
|
|
456
|
+
requestId: msg.requestId,
|
|
457
|
+
...(spawnResult.pid ? { pid: spawnResult.pid } : {}),
|
|
458
|
+
});
|
|
351
459
|
if (!spawnResult.success) {
|
|
352
460
|
sendTo(ws, {
|
|
353
461
|
type: "spawn_error",
|
|
@@ -377,12 +485,13 @@ export async function handleSpawnSession(
|
|
|
377
485
|
// on the next spawn without a server restart. See change: spawn-failure-diagnostics (fix W1).
|
|
378
486
|
timeoutMs: config.spawnRegisterTimeoutMs,
|
|
379
487
|
ws,
|
|
488
|
+
spawnToken: spawnResult.spawnToken,
|
|
380
489
|
});
|
|
381
490
|
}
|
|
382
491
|
} catch (err) {
|
|
383
492
|
const message = err instanceof Error ? err.message : String(err);
|
|
384
493
|
const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr).slice(-2048) : undefined;
|
|
385
|
-
sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message });
|
|
494
|
+
sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message, requestId: msg.requestId });
|
|
386
495
|
sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message, code: "SPAWN_ERRNO", stderr });
|
|
387
496
|
appendSpawnFailure({
|
|
388
497
|
ts: new Date().toISOString(),
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem helpers for locating an installed package's
|
|
3
|
+
* `CHANGELOG.md` and `package.json`, plus deriving a public GitHub
|
|
4
|
+
* URL from the `repository` field.
|
|
5
|
+
*
|
|
6
|
+
* Search order matches the ToolRegistry resolution chain for `pi`:
|
|
7
|
+
* 1. Managed install (`~/.pi-dashboard/node_modules/<pkg>/`)
|
|
8
|
+
* 2. Bare-import via `createRequire` (process resolves the package
|
|
9
|
+
* through its own node_modules — covers npm-global on Unix when
|
|
10
|
+
* the symlink lands inside this Node prefix, and dev-checkout
|
|
11
|
+
* paths during local builds).
|
|
12
|
+
*
|
|
13
|
+
* Both helpers return `null` rather than throwing on absence so route
|
|
14
|
+
* handlers can degrade to the empty-changelog response per spec
|
|
15
|
+
* `pi-changelog-display#Scenario: Package not installed returns empty`.
|
|
16
|
+
*
|
|
17
|
+
* See change: pi-update-whats-new-panel.
|
|
18
|
+
*/
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import { createRequire } from "node:module";
|
|
23
|
+
|
|
24
|
+
/** Default managed install root. Test seam: caller may override. */
|
|
25
|
+
function defaultManagedDir(): string {
|
|
26
|
+
return path.join(os.homedir(), ".pi-dashboard");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Locate a package's CHANGELOG.md on disk.
|
|
31
|
+
*
|
|
32
|
+
* Returns `{ changelogPath, packageDir }` so callers can read the
|
|
33
|
+
* adjacent `package.json` without re-resolving.
|
|
34
|
+
*
|
|
35
|
+
* Strategy:
|
|
36
|
+
* 1. `<managedDir>/node_modules/<pkg>/CHANGELOG.md` (Electron + CLI bootstrap).
|
|
37
|
+
* 2. `createRequire(import.meta.url).resolve("<pkg>/package.json")` then
|
|
38
|
+
* look for `CHANGELOG.md` next to it (covers bare-import / npm-global
|
|
39
|
+
* via the standard Node resolution mechanism).
|
|
40
|
+
*
|
|
41
|
+
* Returns `null` when no readable CHANGELOG can be located.
|
|
42
|
+
*/
|
|
43
|
+
export interface ChangelogLocation {
|
|
44
|
+
changelogPath: string;
|
|
45
|
+
packageDir: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FindOptions {
|
|
49
|
+
/** Override managed dir for tests. */
|
|
50
|
+
managedDir?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Override the require-resolver used for bare-import lookup. Tests
|
|
53
|
+
* pass a stub that throws to force the managed path; production
|
|
54
|
+
* uses `createRequire(import.meta.url).resolve`.
|
|
55
|
+
*/
|
|
56
|
+
resolveBareImport?: (pkgJsonSpec: string) => string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function findChangelogPath(
|
|
60
|
+
pkg: string,
|
|
61
|
+
opts: FindOptions = {},
|
|
62
|
+
): ChangelogLocation | null {
|
|
63
|
+
// Strategy 1: managed install.
|
|
64
|
+
const managedDir = opts.managedDir ?? defaultManagedDir();
|
|
65
|
+
const managedPkg = path.join(managedDir, "node_modules", pkg);
|
|
66
|
+
const managedCl = path.join(managedPkg, "CHANGELOG.md");
|
|
67
|
+
if (fs.existsSync(managedCl)) {
|
|
68
|
+
return { changelogPath: managedCl, packageDir: managedPkg };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Strategy 2: bare-import via require.resolve.
|
|
72
|
+
const resolver =
|
|
73
|
+
opts.resolveBareImport ??
|
|
74
|
+
((spec: string) => createRequire(import.meta.url).resolve(spec));
|
|
75
|
+
try {
|
|
76
|
+
const pkgJsonPath = resolver(`${pkg}/package.json`);
|
|
77
|
+
const dir = path.dirname(pkgJsonPath);
|
|
78
|
+
const cl = path.join(dir, "CHANGELOG.md");
|
|
79
|
+
if (fs.existsSync(cl)) {
|
|
80
|
+
return { changelogPath: cl, packageDir: dir };
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
/* not resolvable — fall through */
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read and parse `package.json` next to a previously-located
|
|
91
|
+
* CHANGELOG. Returns the parsed object or `null` on read/parse error.
|
|
92
|
+
*/
|
|
93
|
+
export function readPackageJson(packageDir: string): Record<string, unknown> | null {
|
|
94
|
+
const p = path.join(packageDir, "package.json");
|
|
95
|
+
try {
|
|
96
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
97
|
+
const parsed = JSON.parse(raw);
|
|
98
|
+
if (parsed && typeof parsed === "object") {
|
|
99
|
+
return parsed as Record<string, unknown>;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Derive a public GitHub URL pointing at the CHANGELOG given a
|
|
109
|
+
* `package.json#repository` field. Returns `null` when the field is
|
|
110
|
+
* missing, not GitHub-hosted, or unparseable.
|
|
111
|
+
*
|
|
112
|
+
* Accepted shapes (per spec `pi-changelog-display#Requirement: Changelog URL derivation`):
|
|
113
|
+
* - `"github:org/repo"` shorthand
|
|
114
|
+
* - `"https://github.com/org/repo.git"` URL string
|
|
115
|
+
* - `{ "type": "git", "url": "git+https://github.com/org/repo.git" }`
|
|
116
|
+
* - same object form with optional `"directory": "packages/foo"` (monorepo)
|
|
117
|
+
*/
|
|
118
|
+
export function deriveChangelogUrl(repository: unknown): string | null {
|
|
119
|
+
if (!repository) return null;
|
|
120
|
+
|
|
121
|
+
let urlStr: string | null = null;
|
|
122
|
+
let directory: string | null = null;
|
|
123
|
+
|
|
124
|
+
if (typeof repository === "string") {
|
|
125
|
+
urlStr = repository;
|
|
126
|
+
} else if (typeof repository === "object" && repository !== null) {
|
|
127
|
+
const rec = repository as Record<string, unknown>;
|
|
128
|
+
if (typeof rec.url === "string") urlStr = rec.url;
|
|
129
|
+
if (typeof rec.directory === "string" && rec.directory.length > 0) {
|
|
130
|
+
directory = rec.directory.replace(/^\/+|\/+$/g, "");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!urlStr) return null;
|
|
134
|
+
|
|
135
|
+
const m = parseGitHubUrl(urlStr);
|
|
136
|
+
if (!m) return null;
|
|
137
|
+
|
|
138
|
+
const subPath = directory ? `${directory}/` : "";
|
|
139
|
+
return `https://github.com/${m.org}/${m.repo}/blob/main/${subPath}CHANGELOG.md`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse the various GitHub URL forms used in `package.json#repository`
|
|
144
|
+
* into `{ org, repo }`. Returns null for non-GitHub or unparseable
|
|
145
|
+
* inputs.
|
|
146
|
+
*/
|
|
147
|
+
function parseGitHubUrl(s: string): { org: string; repo: string } | null {
|
|
148
|
+
const trimmed = s.trim();
|
|
149
|
+
|
|
150
|
+
// github:org/repo shorthand
|
|
151
|
+
let m = trimmed.match(/^github:([^/]+)\/([^/#]+)/i);
|
|
152
|
+
if (m) return { org: m[1], repo: stripGitSuffix(m[2]) };
|
|
153
|
+
|
|
154
|
+
// git+https://github.com/org/repo.git
|
|
155
|
+
// https://github.com/org/repo
|
|
156
|
+
// git://github.com/org/repo.git
|
|
157
|
+
// ssh://git@github.com/org/repo.git
|
|
158
|
+
// git@github.com:org/repo.git
|
|
159
|
+
m = trimmed.match(/(?:^|[/@:])github\.com[/:]([^/]+)\/([^/#?]+)/i);
|
|
160
|
+
if (m) return { org: m[1], repo: stripGitSuffix(m[2]) };
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function stripGitSuffix(repo: string): string {
|
|
166
|
+
return repo.replace(/\.git$/i, "");
|
|
167
|
+
}
|