@blackbelt-technology/pi-agent-dashboard 0.2.9 → 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 +64 -8
- package/README.md +308 -101
- package/docs/architecture.md +515 -16
- package/package.json +14 -7
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -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/ask-user-tool.ts +289 -20
- package/packages/extension/src/bridge.ts +107 -6
- package/packages/extension/src/command-handler.ts +34 -39
- 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/prompt-expander.ts +25 -4
- 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 +17 -2
- package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
- package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -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 +246 -10
- package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -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__/cors.test.ts +34 -2
- 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-manager-pid-registry.test.ts +168 -0
- package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
- package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
- package/packages/server/src/__tests__/editor-registry.test.ts +29 -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__/fix-pty-permissions.test.ts +59 -0
- package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
- package/packages/server/src/__tests__/git-operations.test.ts +9 -7
- package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
- 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__/openspec-tasks-parser.test.ts +178 -0
- package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
- package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
- package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
- package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
- package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
- 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-auth-routes.test.ts +13 -3
- 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__/recommended-routes.test.ts +389 -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__/session-file-dedup.test.ts +10 -10
- package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
- package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
- package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
- package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
- package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
- 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 +103 -6
- package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
- 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 +108 -9
- package/packages/server/src/browser-gateway.ts +16 -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 +39 -5
- package/packages/server/src/editor-pid-registry.ts +199 -0
- package/packages/server/src/editor-registry.ts +22 -25
- package/packages/server/src/fix-pty-permissions.ts +44 -0
- package/packages/server/src/git-operations.ts +1 -1
- package/packages/server/src/headless-pid-registry.ts +16 -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/npm-search-proxy.ts +71 -0
- package/packages/server/src/openspec-tasks.ts +158 -0
- package/packages/server/src/package-manager-wrapper.ts +225 -34
- package/packages/server/src/pi-core-checker.ts +290 -0
- package/packages/server/src/pi-core-updater.ts +172 -0
- package/packages/server/src/pi-gateway.ts +7 -0
- 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/file-routes.ts +30 -3
- package/packages/server/src/routes/openspec-routes.ts +107 -1
- package/packages/server/src/routes/pi-core-routes.ts +140 -0
- package/packages/server/src/routes/provider-auth-routes.ts +12 -10
- package/packages/server/src/routes/provider-routes.ts +55 -2
- package/packages/server/src/routes/recommended-routes.ts +225 -0
- package/packages/server/src/routes/system-routes.ts +30 -34
- 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 +363 -26
- 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 +65 -20
- package/packages/server/src/test-env-guard.ts +26 -0
- package/packages/server/src/test-support/test-server.ts +63 -0
- package/packages/server/src/tunnel.ts +172 -34
- 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 +59 -3
- 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__/openspec-poller.test.ts +44 -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 +156 -0
- 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__/source-matching.test.ts +143 -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 +93 -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 +71 -49
- 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 +196 -0
- package/packages/shared/src/resolve-jiti.ts +62 -3
- package/packages/shared/src/rest-api.ts +97 -0
- package/packages/shared/src/semaphore.ts +83 -0
- package/packages/shared/src/source-matching.ts +126 -0
- package/packages/shared/src/test-support/setup-home.ts +74 -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
- package/packages/shared/src/types.ts +7 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-HOME advisory lock for the dashboard server.
|
|
3
|
+
*
|
|
4
|
+
* Ensures one dashboard instance per HOME (`<realpath(os.homedir())>/.pi/`).
|
|
5
|
+
* See change: single-dashboard-per-home.
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities:
|
|
8
|
+
* - Canonicalize HOME (avoid symlink/Git-Bash drift)
|
|
9
|
+
* - Acquire the lock via `proper-lockfile` (non-blocking, stale-aware)
|
|
10
|
+
* - Write / read an atomic metadata sidecar
|
|
11
|
+
* - Verify a held lock's liveness via identity-checked health probe
|
|
12
|
+
* - Return an `acquired` or `attach` result for the caller to dispatch
|
|
13
|
+
*
|
|
14
|
+
* Signal handlers and release-on-exit plumbing live in
|
|
15
|
+
* `home-lock-release.ts` to keep this module pure + testable.
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
import properLockfile from "proper-lockfile";
|
|
22
|
+
import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
|
|
23
|
+
import { isProcessAlive } from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
|
|
24
|
+
|
|
25
|
+
// ──────────────────────────────────────────────────────────
|
|
26
|
+
// Types
|
|
27
|
+
// ──────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** Metadata written alongside the lock file. JSON-serialized. */
|
|
30
|
+
export interface LockMetadata {
|
|
31
|
+
pid: number;
|
|
32
|
+
ppid: number;
|
|
33
|
+
httpPort: number;
|
|
34
|
+
piPort: number;
|
|
35
|
+
startedAt: number;
|
|
36
|
+
/** Stable per-instance identifier. Verified against /api/health to detect
|
|
37
|
+
* "port in use by unrelated dashboard or stale process with same pid." */
|
|
38
|
+
identity: string;
|
|
39
|
+
version: string;
|
|
40
|
+
url: string;
|
|
41
|
+
hostname: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Result of `acquireOrAttach`. Callers branch on `mode`. */
|
|
45
|
+
export type LockAcquireResult =
|
|
46
|
+
| {
|
|
47
|
+
mode: "acquired";
|
|
48
|
+
meta: LockMetadata;
|
|
49
|
+
/** Release the lock + remove the metadata sidecar. Idempotent. */
|
|
50
|
+
release: () => Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
| {
|
|
53
|
+
mode: "attach";
|
|
54
|
+
meta: LockMetadata;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Thrown when port is held by an unrelated process. Non-fatal to this
|
|
58
|
+
* module; caller decides (exit with message / retry / override). */
|
|
59
|
+
export class InstanceLockMismatchError extends Error {
|
|
60
|
+
readonly code = "E_INSTANCE_MISMATCH";
|
|
61
|
+
constructor(readonly meta: LockMetadata, readonly observedIdentity: string | null) {
|
|
62
|
+
super(
|
|
63
|
+
`Port ${meta.httpPort} is in use by an unrelated process (PID ${meta.pid}). ` +
|
|
64
|
+
`Configure a different port or stop that process.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AcquireConfig {
|
|
70
|
+
httpPort: number;
|
|
71
|
+
piPort: number;
|
|
72
|
+
version: string;
|
|
73
|
+
identity?: string;
|
|
74
|
+
/** Injection hooks for tests. Production callers pass no options. */
|
|
75
|
+
hooks?: AcquireHooks;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AcquireHooks {
|
|
79
|
+
now?: () => number;
|
|
80
|
+
hostname?: () => string;
|
|
81
|
+
lockPath?: string;
|
|
82
|
+
metaPath?: string;
|
|
83
|
+
probeHealth?: (port: number) => Promise<{ running: boolean; pid?: number; identity?: string } | null>;
|
|
84
|
+
isProcessAlive?: (pid: number) => boolean;
|
|
85
|
+
/** Stale threshold forwarded to `proper-lockfile`. Default 10s. */
|
|
86
|
+
staleMs?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ──────────────────────────────────────────────────────────
|
|
90
|
+
// Paths
|
|
91
|
+
// ──────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Canonical HOME directory.
|
|
95
|
+
*
|
|
96
|
+
* Uses `os.userInfo().homedir` in preference to `os.homedir()` because on
|
|
97
|
+
* POSIX the latter honors the `$HOME` environment variable (Node docs say:
|
|
98
|
+
* "On POSIX, it uses the `$HOME` environment variable if defined"), which
|
|
99
|
+
* the design (§4) explicitly prohibits — a GUI-launched process and a
|
|
100
|
+
* shell-launched process would otherwise disagree on "where HOME is".
|
|
101
|
+
* `userInfo().homedir` consults `getpwuid(3)` on POSIX, immune to `$HOME`.
|
|
102
|
+
*
|
|
103
|
+
* On Windows, both APIs ultimately use `USERPROFILE`, so the Git Bash
|
|
104
|
+
* drift case (`$HOME=/c/Users/R` vs `USERPROFILE=C:\Users\R`) is handled
|
|
105
|
+
* either way; keeping `userInfo().homedir` first is still correct.
|
|
106
|
+
*
|
|
107
|
+
* Result is then passed through `fs.realpathSync` to collapse symlinks,
|
|
108
|
+
* FileVault migrations, and other canonicalization drift. Tolerant: falls
|
|
109
|
+
* back to the raw path if realpath fails.
|
|
110
|
+
*/
|
|
111
|
+
export function canonicalHomedir(): string {
|
|
112
|
+
let raw: string;
|
|
113
|
+
try {
|
|
114
|
+
raw = os.userInfo().homedir;
|
|
115
|
+
} catch {
|
|
116
|
+
raw = os.homedir();
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return fs.realpathSync(raw);
|
|
120
|
+
} catch {
|
|
121
|
+
return raw;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Lock file path. This is what `proper-lockfile` locks.
|
|
127
|
+
*/
|
|
128
|
+
export function getLockPath(homedir: string = canonicalHomedir()): string {
|
|
129
|
+
return path.join(homedir, ".pi", "dashboard", "server.lock");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Metadata sidecar path (`<lockPath>.meta.json`).
|
|
134
|
+
*/
|
|
135
|
+
export function getMetaPath(lockPath: string = getLockPath()): string {
|
|
136
|
+
return `${lockPath}.meta.json`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ──────────────────────────────────────────────────────────
|
|
140
|
+
// Metadata I/O
|
|
141
|
+
// ──────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Atomically write the metadata sidecar via tmp + rename.
|
|
145
|
+
* Never leaves a partial file visible.
|
|
146
|
+
*/
|
|
147
|
+
export function writeMetadataAtomic(meta: LockMetadata, metaPath: string = getMetaPath()): void {
|
|
148
|
+
const dir = path.dirname(metaPath);
|
|
149
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
150
|
+
const tmpPath = `${metaPath}.tmp-${process.pid}-${Date.now()}`;
|
|
151
|
+
fs.writeFileSync(tmpPath, JSON.stringify(meta, null, 2));
|
|
152
|
+
fs.renameSync(tmpPath, metaPath);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Read the metadata sidecar. Returns null on any failure (missing, corrupt,
|
|
157
|
+
* permission-denied). Callers MUST treat null as "assume stale."
|
|
158
|
+
*/
|
|
159
|
+
export function readMetadata(metaPath: string = getMetaPath()): LockMetadata | null {
|
|
160
|
+
try {
|
|
161
|
+
const raw = fs.readFileSync(metaPath, "utf-8");
|
|
162
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
163
|
+
if (!isLockMetadata(parsed)) return null;
|
|
164
|
+
return parsed;
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isLockMetadata(value: unknown): value is LockMetadata {
|
|
171
|
+
if (!value || typeof value !== "object") return false;
|
|
172
|
+
const m = value as Record<string, unknown>;
|
|
173
|
+
return (
|
|
174
|
+
typeof m.pid === "number" &&
|
|
175
|
+
typeof m.httpPort === "number" &&
|
|
176
|
+
typeof m.piPort === "number" &&
|
|
177
|
+
typeof m.startedAt === "number" &&
|
|
178
|
+
typeof m.identity === "string" &&
|
|
179
|
+
typeof m.version === "string" &&
|
|
180
|
+
typeof m.url === "string"
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Remove the metadata sidecar. Silent on any error (missing is fine).
|
|
186
|
+
*/
|
|
187
|
+
export function removeMetadata(metaPath: string = getMetaPath()): void {
|
|
188
|
+
try {
|
|
189
|
+
fs.unlinkSync(metaPath);
|
|
190
|
+
} catch {
|
|
191
|
+
/* ignore */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ──────────────────────────────────────────────────────────
|
|
196
|
+
// Liveness
|
|
197
|
+
// ──────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Determine if the recorded lock holder is a responsive, identity-matching
|
|
201
|
+
* dashboard. Returns:
|
|
202
|
+
* - `"alive-match"`: attach to it
|
|
203
|
+
* - `"alive-mismatch"`: someone else is on that port
|
|
204
|
+
* - `"dead"`: treat as stale, proceed to acquire
|
|
205
|
+
*/
|
|
206
|
+
export async function isLockHolderResponsive(
|
|
207
|
+
meta: LockMetadata,
|
|
208
|
+
hooks: Pick<AcquireHooks, "probeHealth" | "isProcessAlive"> = {},
|
|
209
|
+
): Promise<"alive-match" | "alive-mismatch" | "dead"> {
|
|
210
|
+
const aliveCheck = hooks.isProcessAlive ?? isProcessAlive;
|
|
211
|
+
if (!aliveCheck(meta.pid)) return "dead";
|
|
212
|
+
|
|
213
|
+
const probe = hooks.probeHealth ?? defaultProbeHealth;
|
|
214
|
+
const res = await probe(meta.httpPort);
|
|
215
|
+
if (!res || !res.running) return "dead";
|
|
216
|
+
|
|
217
|
+
// Identity check: `identity` field is preferred; fall back to PID match
|
|
218
|
+
// to stay compatible with older dashboards that predate identity.
|
|
219
|
+
if (res.identity) {
|
|
220
|
+
return res.identity === meta.identity ? "alive-match" : "alive-mismatch";
|
|
221
|
+
}
|
|
222
|
+
if (typeof res.pid === "number") {
|
|
223
|
+
return res.pid === meta.pid ? "alive-match" : "alive-mismatch";
|
|
224
|
+
}
|
|
225
|
+
// Running but no verifiable identity — conservative: mismatch.
|
|
226
|
+
return "alive-mismatch";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function defaultProbeHealth(port: number) {
|
|
230
|
+
const status = await isDashboardRunning(port);
|
|
231
|
+
if (!status.running) return { running: false };
|
|
232
|
+
// `isDashboardRunning` doesn't expose identity today. Re-fetch to peek at
|
|
233
|
+
// the full health body for the `identity` field. Best-effort.
|
|
234
|
+
try {
|
|
235
|
+
const res = await fetch(`http://localhost:${port}/api/health`, {
|
|
236
|
+
signal: AbortSignal.timeout(1500),
|
|
237
|
+
});
|
|
238
|
+
if (res.ok) {
|
|
239
|
+
const body = (await res.json()) as { pid?: number; identity?: string };
|
|
240
|
+
return { running: true, pid: body.pid, identity: body.identity };
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
/* fall through */
|
|
244
|
+
}
|
|
245
|
+
return { running: true, pid: status.pid };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ──────────────────────────────────────────────────────────
|
|
249
|
+
// Acquire
|
|
250
|
+
// ──────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Acquire the per-HOME lock, or fall back to attach semantics if a live
|
|
254
|
+
* dashboard already holds it.
|
|
255
|
+
*
|
|
256
|
+
* Flow:
|
|
257
|
+
* 1. Ensure `~/.pi/dashboard/` exists (proper-lockfile requires parent).
|
|
258
|
+
* 2. `proper-lockfile.lock(path, { stale, retries: 0 })`
|
|
259
|
+
* ↪ on success: write metadata, return { mode: "acquired", release }
|
|
260
|
+
* ↪ on ELOCKED: read metadata, check liveness
|
|
261
|
+
* - dead: steal via `proper-lockfile.lock({ realpath:false, stale: 0 })`
|
|
262
|
+
* (Note: proper-lockfile already does stale-stealing when
|
|
263
|
+
* `stale` is configured — we just retry once.)
|
|
264
|
+
* - alive-match: return { mode: "attach", meta }
|
|
265
|
+
* - alive-mismatch: throw InstanceLockMismatchError
|
|
266
|
+
*/
|
|
267
|
+
export async function acquireOrAttach(config: AcquireConfig): Promise<LockAcquireResult> {
|
|
268
|
+
const hooks = config.hooks ?? {};
|
|
269
|
+
const lockPath = hooks.lockPath ?? getLockPath();
|
|
270
|
+
const metaPath = hooks.metaPath ?? getMetaPath(lockPath);
|
|
271
|
+
const staleMs = hooks.staleMs ?? 10_000;
|
|
272
|
+
const now = hooks.now ?? Date.now;
|
|
273
|
+
const hostname = hooks.hostname ?? os.hostname;
|
|
274
|
+
|
|
275
|
+
// Ensure the lock file's parent directory exists. proper-lockfile wants
|
|
276
|
+
// either the target file (which it creates alongside as `<path>.lock/`)
|
|
277
|
+
// or an existing file — we create an empty sentinel so the API is
|
|
278
|
+
// deterministic.
|
|
279
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
280
|
+
if (!fs.existsSync(lockPath)) {
|
|
281
|
+
fs.writeFileSync(lockPath, "# pi-dashboard per-HOME advisory lock\n");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const buildMeta = (): LockMetadata => ({
|
|
285
|
+
pid: process.pid,
|
|
286
|
+
ppid: process.ppid,
|
|
287
|
+
httpPort: config.httpPort,
|
|
288
|
+
piPort: config.piPort,
|
|
289
|
+
startedAt: now(),
|
|
290
|
+
identity: config.identity ?? randomUUID(),
|
|
291
|
+
version: config.version,
|
|
292
|
+
url: `http://localhost:${config.httpPort}`,
|
|
293
|
+
hostname: hostname(),
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const tryAcquire = async () => {
|
|
297
|
+
const release = await properLockfile.lock(lockPath, {
|
|
298
|
+
stale: staleMs,
|
|
299
|
+
retries: 0,
|
|
300
|
+
// proper-lockfile uses realpath by default; we already pass a
|
|
301
|
+
// realpath-based directory, so this is a no-op but kept explicit.
|
|
302
|
+
realpath: false,
|
|
303
|
+
});
|
|
304
|
+
const meta = buildMeta();
|
|
305
|
+
writeMetadataAtomic(meta, metaPath);
|
|
306
|
+
const releaseOnce = (() => {
|
|
307
|
+
let released = false;
|
|
308
|
+
return async () => {
|
|
309
|
+
if (released) return;
|
|
310
|
+
released = true;
|
|
311
|
+
try {
|
|
312
|
+
await release();
|
|
313
|
+
} catch {
|
|
314
|
+
/* ignore — lock may have been compromised */
|
|
315
|
+
}
|
|
316
|
+
removeMetadata(metaPath);
|
|
317
|
+
};
|
|
318
|
+
})();
|
|
319
|
+
return { mode: "acquired" as const, meta, release: releaseOnce };
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
return await tryAcquire();
|
|
324
|
+
} catch (err: unknown) {
|
|
325
|
+
if (!isELocked(err)) throw err;
|
|
326
|
+
// Someone else holds the lock. Decide: attach or error.
|
|
327
|
+
//
|
|
328
|
+
// Concurrent-launch race: if two callers race, the winner writes the
|
|
329
|
+
// metadata sidecar a few ms after acquiring. The loser hits ELOCKED
|
|
330
|
+
// faster and can read the sidecar BEFORE the winner has written it.
|
|
331
|
+
// Short-poll for metadata to land before concluding "no metadata = stale."
|
|
332
|
+
let meta: LockMetadata | null = null;
|
|
333
|
+
for (let i = 0; i < 20; i++) {
|
|
334
|
+
meta = readMetadata(metaPath);
|
|
335
|
+
if (meta) break;
|
|
336
|
+
await new Promise(r => setTimeout(r, 25));
|
|
337
|
+
}
|
|
338
|
+
if (!meta) {
|
|
339
|
+
// Truly no metadata after 500ms → assume stale/corrupt. Force steal.
|
|
340
|
+
removeMetadata(metaPath);
|
|
341
|
+
try {
|
|
342
|
+
return await tryAcquire();
|
|
343
|
+
} catch (err2) {
|
|
344
|
+
if (!isELocked(err2)) throw err2;
|
|
345
|
+
try {
|
|
346
|
+
await properLockfile.unlock(lockPath, { realpath: false });
|
|
347
|
+
} catch {
|
|
348
|
+
/* ignore */
|
|
349
|
+
}
|
|
350
|
+
return await tryAcquire();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const liveness = await isLockHolderResponsive(meta, hooks);
|
|
355
|
+
if (liveness === "alive-match") {
|
|
356
|
+
return { mode: "attach", meta };
|
|
357
|
+
}
|
|
358
|
+
if (liveness === "alive-mismatch") {
|
|
359
|
+
throw new InstanceLockMismatchError(meta, null);
|
|
360
|
+
}
|
|
361
|
+
// Dead holder — steal.
|
|
362
|
+
try {
|
|
363
|
+
await properLockfile.unlock(lockPath, { realpath: false });
|
|
364
|
+
} catch {
|
|
365
|
+
/* ignore */
|
|
366
|
+
}
|
|
367
|
+
removeMetadata(metaPath);
|
|
368
|
+
return await tryAcquire();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function isELocked(err: unknown): boolean {
|
|
373
|
+
if (!err || typeof err !== "object") return false;
|
|
374
|
+
const code = (err as { code?: string }).code;
|
|
375
|
+
return code === "ELOCKED";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ──────────────────────────────────────────────────────────
|
|
379
|
+
// Escape hatch
|
|
380
|
+
// ──────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* True when the user has opted out of the per-HOME lock. Caller should
|
|
384
|
+
* log a warning and skip acquireOrAttach when set.
|
|
385
|
+
*/
|
|
386
|
+
export function isLockDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
387
|
+
const raw = env.PI_DASHBOARD_ALLOW_MULTIPLE;
|
|
388
|
+
return raw === "1" || raw === "true";
|
|
389
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure predicate + message builder for nodejs/node#58515 affected versions.
|
|
3
|
+
*
|
|
4
|
+
* The bug (`ERR_INTERNAL_ASSERTION: Unexpected module status 3`) fires when
|
|
5
|
+
* Fastify loads its internal ajv-compiler under affected Node versions.
|
|
6
|
+
*
|
|
7
|
+
* Affected: Node v22.0–v22.17 and v24.1–v24.2.
|
|
8
|
+
* Fixed in: v22.18+, v24.3+, v25.x.
|
|
9
|
+
*
|
|
10
|
+
* Rationale for a preflight refuse-to-start (instead of a preload workaround):
|
|
11
|
+
* see openspec/changes/adapt-windows-integration-pr9/proposal.md and
|
|
12
|
+
* BRANCH-COMPARISON.md §10 on origin/windows-integration.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export function isAffectedNode(version: string): boolean {
|
|
16
|
+
const m = version.match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
|
17
|
+
if (!m) return false;
|
|
18
|
+
const major = Number(m[1]);
|
|
19
|
+
const minor = Number(m[2]);
|
|
20
|
+
if (major === 22 && minor < 18) return true;
|
|
21
|
+
if (major === 24 && minor >= 1 && minor < 3) return true;
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildNodeUpgradeMessage(version: string): string {
|
|
26
|
+
return [
|
|
27
|
+
``,
|
|
28
|
+
`❌ pi-dashboard cannot start on Node ${version}.`,
|
|
29
|
+
``,
|
|
30
|
+
` This Node version has a bug that crashes Fastify at startup:`,
|
|
31
|
+
` https://github.com/nodejs/node/issues/58515`,
|
|
32
|
+
``,
|
|
33
|
+
` Fix: upgrade Node to >=22.18.0 (LTS) or >=24.3.0.`,
|
|
34
|
+
` Install:`,
|
|
35
|
+
` nvm: nvm install 22 && nvm use 22`,
|
|
36
|
+
` brew: brew upgrade node`,
|
|
37
|
+
` Win: https://nodejs.org/ -> current 22.x LTS installer`,
|
|
38
|
+
``,
|
|
39
|
+
].join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Call at the top of every server entry point (cmdStart, runForeground).
|
|
44
|
+
* Writes the upgrade message to stderr and exits with code 1 when the
|
|
45
|
+
* running Node is in the affected range.
|
|
46
|
+
*/
|
|
47
|
+
export function assertNodeVersionSupported(): void {
|
|
48
|
+
if (isAffectedNode(process.version)) {
|
|
49
|
+
console.error(buildNodeUpgradeMessage(process.version));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -136,8 +136,79 @@ export class PackageNotFoundError extends Error {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// ── Lightweight metadata helpers for recommended-extensions enrichment ──
|
|
140
|
+
|
|
141
|
+
export interface PackageMeta {
|
|
142
|
+
description?: string;
|
|
143
|
+
version?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const metaCache = new Map<string, CacheEntry<PackageMeta | null>>();
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Fetch a minimal package.json-ish blob for an npm package (description,
|
|
150
|
+
* version) from the registry's `/<name>/latest` endpoint. Returns `null`
|
|
151
|
+
* on any network or parse failure.
|
|
152
|
+
*/
|
|
153
|
+
export async function fetchPackageMeta(packageName: string): Promise<PackageMeta | null> {
|
|
154
|
+
const cached = metaCache.get(`npm:${packageName}`);
|
|
155
|
+
if (isFresh(cached)) return cached.data;
|
|
156
|
+
try {
|
|
157
|
+
const url = `${NPM_REGISTRY_URL}/${encodeURIComponent(packageName)}/latest`;
|
|
158
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
metaCache.set(`npm:${packageName}`, { data: null, timestamp: Date.now() });
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const json: any = await res.json();
|
|
164
|
+
const meta: PackageMeta = {
|
|
165
|
+
description: typeof json?.description === "string" ? json.description : undefined,
|
|
166
|
+
version: typeof json?.version === "string" ? json.version : undefined,
|
|
167
|
+
};
|
|
168
|
+
metaCache.set(`npm:${packageName}`, { data: meta, timestamp: Date.now() });
|
|
169
|
+
return meta;
|
|
170
|
+
} catch {
|
|
171
|
+
metaCache.set(`npm:${packageName}`, { data: null, timestamp: Date.now() });
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Fetch `package.json` from a public GitHub repository's default branch
|
|
178
|
+
* via `raw.githubusercontent.com`. Returns `{description, version}` or
|
|
179
|
+
* `null` on any failure.
|
|
180
|
+
*/
|
|
181
|
+
export async function fetchGithubPackageJson(
|
|
182
|
+
owner: string,
|
|
183
|
+
repo: string,
|
|
184
|
+
): Promise<PackageMeta | null> {
|
|
185
|
+
const key = `gh:${owner}/${repo}`;
|
|
186
|
+
const cached = metaCache.get(key);
|
|
187
|
+
if (isFresh(cached)) return cached.data;
|
|
188
|
+
// HEAD resolves to the default branch on raw.githubusercontent.com.
|
|
189
|
+
const url = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/package.json`;
|
|
190
|
+
try {
|
|
191
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
192
|
+
if (!res.ok) {
|
|
193
|
+
metaCache.set(key, { data: null, timestamp: Date.now() });
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const json: any = await res.json();
|
|
197
|
+
const meta: PackageMeta = {
|
|
198
|
+
description: typeof json?.description === "string" ? json.description : undefined,
|
|
199
|
+
version: typeof json?.version === "string" ? json.version : undefined,
|
|
200
|
+
};
|
|
201
|
+
metaCache.set(key, { data: meta, timestamp: Date.now() });
|
|
202
|
+
return meta;
|
|
203
|
+
} catch {
|
|
204
|
+
metaCache.set(key, { data: null, timestamp: Date.now() });
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
139
209
|
/** Clear all caches (for testing). */
|
|
140
210
|
export function clearCaches() {
|
|
141
211
|
searchCache.clear();
|
|
142
212
|
readmeCache.clear();
|
|
213
|
+
metaCache.clear();
|
|
143
214
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser + writer for an OpenSpec change's `tasks.md` file.
|
|
3
|
+
*
|
|
4
|
+
* `tasks.md` uses a rigid line-level format:
|
|
5
|
+
* ## 1. Group heading
|
|
6
|
+
* - [ ] 1.1 Task text
|
|
7
|
+
* - [x] 1.2 Done task
|
|
8
|
+
*
|
|
9
|
+
* We parse top-level `- [ ]` / `- [x]` lines only; anything else is ignored
|
|
10
|
+
* (indented sublists, free-form prose, etc.).
|
|
11
|
+
*
|
|
12
|
+
* Writes rewrite exactly one line's checkbox marker and preserve everything
|
|
13
|
+
* else byte-for-byte; atomic via write-then-rename.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs/promises";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
export interface OpenSpecTask {
|
|
19
|
+
/** e.g. "1.1", "8.3" */
|
|
20
|
+
id: string;
|
|
21
|
+
/** Text after the id, trimmed. */
|
|
22
|
+
text: string;
|
|
23
|
+
done: boolean;
|
|
24
|
+
/** 1-indexed line number in `tasks.md` — used as an optimistic-concurrency token. */
|
|
25
|
+
line: number;
|
|
26
|
+
/** Nearest preceding `## ` heading text (without the leading "## "). Empty string if none. */
|
|
27
|
+
group: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class NotFoundError extends Error {
|
|
31
|
+
readonly code = "NOT_FOUND" as const;
|
|
32
|
+
constructor(message = "tasks.md not found") {
|
|
33
|
+
super(message);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export class LineMismatchError extends Error {
|
|
37
|
+
readonly code = "LINE_MISMATCH" as const;
|
|
38
|
+
constructor(message = "line mismatch") {
|
|
39
|
+
super(message);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export class NotACheckboxError extends Error {
|
|
43
|
+
readonly code = "NOT_A_CHECKBOX" as const;
|
|
44
|
+
constructor(message = "target line is not a checkbox") {
|
|
45
|
+
super(message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Top-level checkbox: allow a single leading `- ` with optional `[ ]`/`[x]`/`[X]`,
|
|
50
|
+
// followed by an id-like token (digits and dots) and remaining text.
|
|
51
|
+
const CHECKBOX_RE = /^- \[([ xX])\] +([0-9]+(?:\.[0-9]+)*)\s+(.*)$/;
|
|
52
|
+
const HEADING_RE = /^##\s+(.*)$/;
|
|
53
|
+
|
|
54
|
+
export function parseTasksMarkdown(content: string): OpenSpecTask[] {
|
|
55
|
+
// Split on \n only; trailing \r is trimmed so we handle CRLF inputs too.
|
|
56
|
+
const lines = content.split("\n");
|
|
57
|
+
const out: OpenSpecTask[] = [];
|
|
58
|
+
let currentGroup = "";
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
const raw = lines[i];
|
|
61
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
62
|
+
const h = HEADING_RE.exec(line);
|
|
63
|
+
if (h) {
|
|
64
|
+
currentGroup = h[1].trim();
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const m = CHECKBOX_RE.exec(line);
|
|
68
|
+
if (!m) continue;
|
|
69
|
+
const done = m[1] === "x" || m[1] === "X";
|
|
70
|
+
out.push({
|
|
71
|
+
id: m[2],
|
|
72
|
+
text: m[3].trim(),
|
|
73
|
+
done,
|
|
74
|
+
line: i + 1,
|
|
75
|
+
group: currentGroup,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function tasksMdPath(cwd: string, change: string): string {
|
|
82
|
+
return path.join(cwd, "openspec", "changes", change, "tasks.md");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function readTasks(cwd: string, change: string): Promise<OpenSpecTask[]> {
|
|
86
|
+
const p = tasksMdPath(cwd, change);
|
|
87
|
+
let content: string;
|
|
88
|
+
try {
|
|
89
|
+
content = await fs.readFile(p, "utf-8");
|
|
90
|
+
} catch (err: any) {
|
|
91
|
+
if (err?.code === "ENOENT") throw new NotFoundError();
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
return parseTasksMarkdown(content);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function toggleTask(
|
|
98
|
+
cwd: string,
|
|
99
|
+
change: string,
|
|
100
|
+
id: string,
|
|
101
|
+
done: boolean,
|
|
102
|
+
line: number,
|
|
103
|
+
): Promise<OpenSpecTask> {
|
|
104
|
+
const p = tasksMdPath(cwd, change);
|
|
105
|
+
let content: string;
|
|
106
|
+
try {
|
|
107
|
+
content = await fs.readFile(p, "utf-8");
|
|
108
|
+
} catch (err: any) {
|
|
109
|
+
if (err?.code === "ENOENT") throw new NotFoundError();
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Preserve original line endings by splitting on \n and tracking \r individually.
|
|
114
|
+
const lines = content.split("\n");
|
|
115
|
+
if (line < 1 || line > lines.length) throw new LineMismatchError();
|
|
116
|
+
|
|
117
|
+
const idx = line - 1;
|
|
118
|
+
const raw = lines[idx];
|
|
119
|
+
const hadCR = raw.endsWith("\r");
|
|
120
|
+
const bare = hadCR ? raw.slice(0, -1) : raw;
|
|
121
|
+
|
|
122
|
+
const m = CHECKBOX_RE.exec(bare);
|
|
123
|
+
if (!m) throw new NotACheckboxError();
|
|
124
|
+
if (m[2] !== id) throw new LineMismatchError();
|
|
125
|
+
|
|
126
|
+
const currentDone = m[1] === "x" || m[1] === "X";
|
|
127
|
+
// Optimistic concurrency: the caller's `done` is the *target* state; the line
|
|
128
|
+
// must currently hold the opposite state. If it already matches, we treat
|
|
129
|
+
// that as a line-mismatch — the file changed under us.
|
|
130
|
+
if (currentDone === done) throw new LineMismatchError();
|
|
131
|
+
|
|
132
|
+
const marker = done ? "x" : " ";
|
|
133
|
+
const rewritten = bare.replace(CHECKBOX_RE, `- [${marker}] ${m[2]} ${m[3]}`);
|
|
134
|
+
lines[idx] = hadCR ? rewritten + "\r" : rewritten;
|
|
135
|
+
|
|
136
|
+
const newContent = lines.join("\n");
|
|
137
|
+
const tmp = p + ".tmp";
|
|
138
|
+
await fs.writeFile(tmp, newContent, "utf-8");
|
|
139
|
+
await fs.rename(tmp, p);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
id,
|
|
143
|
+
text: m[3].trim(),
|
|
144
|
+
done,
|
|
145
|
+
line,
|
|
146
|
+
group: findGroupForLine(lines, idx),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function findGroupForLine(lines: string[], idx: number): string {
|
|
151
|
+
for (let i = idx; i >= 0; i--) {
|
|
152
|
+
const raw = lines[i];
|
|
153
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
154
|
+
const h = HEADING_RE.exec(line);
|
|
155
|
+
if (h) return h[1].trim();
|
|
156
|
+
}
|
|
157
|
+
return "";
|
|
158
|
+
}
|