@blackbelt-technology/pi-agent-dashboard 0.2.8 → 0.3.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 +114 -9
- package/README.md +218 -97
- package/docs/architecture.md +107 -7
- package/package.json +9 -4
- package/packages/extension/package.json +1 -1
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
- package/packages/extension/src/ask-user-tool.ts +289 -20
- package/packages/extension/src/bridge.ts +38 -4
- package/packages/extension/src/command-handler.ts +34 -39
- package/packages/extension/src/prompt-expander.ts +25 -4
- package/packages/server/package.json +2 -1
- 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__/browse-endpoint.test.ts +229 -10
- package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
- package/packages/server/src/__tests__/cors.test.ts +34 -2
- 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 +3 -2
- package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
- 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__/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 +122 -0
- 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__/provider-auth-routes.test.ts +13 -3
- package/packages/server/src/__tests__/recommended-routes.test.ts +389 -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__/test-server-canary.test.ts +31 -0
- package/packages/server/src/__tests__/tunnel.test.ts +91 -0
- package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
- package/packages/server/src/browse.ts +100 -6
- package/packages/server/src/browser-gateway.ts +16 -3
- package/packages/server/src/editor-manager.ts +20 -1
- package/packages/server/src/editor-pid-registry.ts +198 -0
- package/packages/server/src/fix-pty-permissions.ts +44 -0
- package/packages/server/src/headless-pid-registry.ts +9 -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 +31 -0
- package/packages/server/src/pi-core-checker.ts +290 -0
- package/packages/server/src/pi-core-updater.ts +166 -0
- package/packages/server/src/pi-gateway.ts +7 -0
- package/packages/server/src/process-manager.ts +1 -1
- package/packages/server/src/routes/file-routes.ts +30 -3
- package/packages/server/src/routes/openspec-routes.ts +83 -1
- package/packages/server/src/routes/pi-core-routes.ts +117 -0
- package/packages/server/src/routes/provider-auth-routes.ts +4 -2
- package/packages/server/src/routes/provider-routes.ts +12 -2
- package/packages/server/src/routes/recommended-routes.ts +227 -0
- package/packages/server/src/routes/system-routes.ts +10 -1
- package/packages/server/src/server.ts +151 -15
- package/packages/server/src/terminal-manager.ts +4 -0
- 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 +132 -8
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/config.test.ts +3 -3
- package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +123 -0
- package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
- package/packages/shared/src/browser-protocol.ts +23 -1
- package/packages/shared/src/openspec-poller.ts +8 -3
- package/packages/shared/src/recommended-extensions.ts +180 -0
- package/packages/shared/src/rest-api.ts +71 -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/types.ts +7 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime fix for node-pty spawn-helper permissions.
|
|
3
|
+
*
|
|
4
|
+
* On macOS/Linux, the prebuilt spawn-helper binary may lack the execute bit
|
|
5
|
+
* (especially in Electron bundles where npm hoisting skips the postinstall fix).
|
|
6
|
+
* This module finds and fixes all spawn-helper binaries at runtime.
|
|
7
|
+
*
|
|
8
|
+
* Called once when the terminal manager is created.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
|
|
14
|
+
let fixed = false;
|
|
15
|
+
|
|
16
|
+
export function fixPtyPermissions(): void {
|
|
17
|
+
if (fixed || process.platform === "win32") return;
|
|
18
|
+
fixed = true;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
// Resolve node-pty's actual location (works with hoisting)
|
|
22
|
+
const require_ = createRequire(import.meta.url);
|
|
23
|
+
const ptyMain = require_.resolve("node-pty");
|
|
24
|
+
const ptyDir = path.dirname(ptyMain);
|
|
25
|
+
const prebuildsDir = path.join(ptyDir, "..", "prebuilds");
|
|
26
|
+
|
|
27
|
+
if (!fs.existsSync(prebuildsDir)) return;
|
|
28
|
+
|
|
29
|
+
for (const dir of fs.readdirSync(prebuildsDir)) {
|
|
30
|
+
const helper = path.join(prebuildsDir, dir, "spawn-helper");
|
|
31
|
+
try {
|
|
32
|
+
const stat = fs.statSync(helper);
|
|
33
|
+
if (!(stat.mode & 0o111)) {
|
|
34
|
+
fs.chmodSync(helper, 0o755);
|
|
35
|
+
console.log(`[pty] Fixed spawn-helper permissions: ${helper}`);
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// spawn-helper doesn't exist for this platform, skip
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// node-pty not installed or not resolvable, skip silently
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -8,6 +8,7 @@ import { EventEmitter } from "node:events";
|
|
|
8
8
|
import { readJsonFile, writeJsonFile } from "./json-store.js";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import os from "node:os";
|
|
11
|
+
import { isUnsafeTestHomeScan } from "./test-env-guard.js";
|
|
11
12
|
|
|
12
13
|
/** Default PID file path */
|
|
13
14
|
const DEFAULT_PID_FILE = path.join(os.homedir(), ".pi", "dashboard", "headless-pids.json");
|
|
@@ -148,6 +149,10 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
|
|
|
148
149
|
},
|
|
149
150
|
|
|
150
151
|
killAll() {
|
|
152
|
+
if (isUnsafeTestHomeScan()) {
|
|
153
|
+
console.warn("[headless-pid-registry] killAll() blocked: running under vitest with real HOME");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
151
156
|
const useGroup = process.platform !== "win32";
|
|
152
157
|
for (const [pid] of entries) {
|
|
153
158
|
try {
|
|
@@ -166,6 +171,10 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
|
|
|
166
171
|
},
|
|
167
172
|
|
|
168
173
|
cleanupOrphans() {
|
|
174
|
+
if (isUnsafeTestHomeScan()) {
|
|
175
|
+
console.warn("[headless-pid-registry] cleanupOrphans() blocked: running under vitest with real HOME");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
169
178
|
const persisted = loadFromDisk();
|
|
170
179
|
const now = Date.now();
|
|
171
180
|
|
|
@@ -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
|
+
}
|
|
@@ -30,6 +30,19 @@ async function loadPiPackageManager() {
|
|
|
30
30
|
}
|
|
31
31
|
} catch { /* fall through to global resolution */ }
|
|
32
32
|
|
|
33
|
+
// Try managed install at ~/.pi-dashboard/node_modules/ (Electron portable/standalone)
|
|
34
|
+
const managedDir = path.join(os.homedir(), ".pi-dashboard");
|
|
35
|
+
for (const pkgName of ["@mariozechner/pi-coding-agent", "@oh-my-pi/pi-coding-agent"]) {
|
|
36
|
+
try {
|
|
37
|
+
const entryPath = path.join(managedDir, "node_modules", pkgName, "dist", "index.js");
|
|
38
|
+
const mod = await import(pathToFileURL(entryPath).href);
|
|
39
|
+
if (mod.DefaultPackageManager) {
|
|
40
|
+
piModuleCache = { DefaultPackageManager: mod.DefaultPackageManager, SettingsManager: mod.SettingsManager };
|
|
41
|
+
return piModuleCache;
|
|
42
|
+
}
|
|
43
|
+
} catch { /* fall through */ }
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
// Resolve from global npm install (pi is typically installed globally)
|
|
34
47
|
for (const pkgName of ["@mariozechner/pi-coding-agent", "@oh-my-pi/pi-coding-agent"]) {
|
|
35
48
|
try {
|
|
@@ -95,6 +108,24 @@ export class PackageManagerWrapper {
|
|
|
95
108
|
return this.busy;
|
|
96
109
|
}
|
|
97
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Run an arbitrary async operation under the wrapper's busy-lock.
|
|
113
|
+
* Used by adjacent subsystems (e.g. PiCoreUpdater) to coordinate with
|
|
114
|
+
* extension install/update operations. Throws PackageOperationBusyError
|
|
115
|
+
* if a package operation is already running.
|
|
116
|
+
*/
|
|
117
|
+
async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
|
118
|
+
if (this.busy) {
|
|
119
|
+
throw new PackageOperationBusyError();
|
|
120
|
+
}
|
|
121
|
+
this.busy = true;
|
|
122
|
+
try {
|
|
123
|
+
return await fn();
|
|
124
|
+
} finally {
|
|
125
|
+
this.busy = false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
98
129
|
/**
|
|
99
130
|
* Start a package operation. Returns the operationId immediately.
|
|
100
131
|
* Progress and completion are delivered via listeners.
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi core version checker.
|
|
3
|
+
*
|
|
4
|
+
* Discovers installed pi-ecosystem CORE packages (pi-coding-agent itself,
|
|
5
|
+
* pi-agent-dashboard, pi-model-proxy, and similar globally-installed CLI
|
|
6
|
+
* tooling) and compares their versions against the npm registry.
|
|
7
|
+
*
|
|
8
|
+
* Complements the existing PackageManagerWrapper, which only manages
|
|
9
|
+
* packages listed in `settings.json packages[]` (extensions, skills,
|
|
10
|
+
* prompts, themes).
|
|
11
|
+
*
|
|
12
|
+
* Discovery sources:
|
|
13
|
+
* 1. Global npm (`npm list -g --depth=0 --json`)
|
|
14
|
+
* 2. Managed install (`~/.pi-dashboard/node_modules/`) — Electron path
|
|
15
|
+
*
|
|
16
|
+
* Version fetch reuses `fetchPackageMeta()` from the npm-search proxy.
|
|
17
|
+
* Results are cached for 5 minutes.
|
|
18
|
+
*/
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
20
|
+
import { promisify } from "node:util";
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import { fetchPackageMeta } from "./npm-search-proxy.js";
|
|
25
|
+
|
|
26
|
+
const execFileAsync = promisify(execFile);
|
|
27
|
+
|
|
28
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
29
|
+
const NPM_LIST_TIMEOUT_MS = 30_000;
|
|
30
|
+
|
|
31
|
+
/** ~/.pi-dashboard/ — Electron managed install dir */
|
|
32
|
+
const MANAGED_DIR = path.join(os.homedir(), ".pi-dashboard");
|
|
33
|
+
const MANAGED_NODE_MODULES = path.join(MANAGED_DIR, "node_modules");
|
|
34
|
+
|
|
35
|
+
/** Known core packages (not extensions). Order matters for display. */
|
|
36
|
+
export const CORE_PACKAGE_NAMES: readonly string[] = [
|
|
37
|
+
"@mariozechner/pi-coding-agent",
|
|
38
|
+
"@oh-my-pi/pi-coding-agent",
|
|
39
|
+
"@blackbelt-technology/pi-agent-dashboard",
|
|
40
|
+
"@blackbelt-technology/pi-model-proxy",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Display name mapping for known packages. Falls back to package name. */
|
|
44
|
+
const DISPLAY_NAMES: Readonly<Record<string, string>> = {
|
|
45
|
+
"@mariozechner/pi-coding-agent": "pi (core agent)",
|
|
46
|
+
"@oh-my-pi/pi-coding-agent": "pi (core agent — fork)",
|
|
47
|
+
"@blackbelt-technology/pi-agent-dashboard": "pi-dashboard",
|
|
48
|
+
"@blackbelt-technology/pi-model-proxy": "pi-model-proxy",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export interface PiCorePackage {
|
|
52
|
+
name: string;
|
|
53
|
+
displayName: string;
|
|
54
|
+
currentVersion: string;
|
|
55
|
+
latestVersion: string | null;
|
|
56
|
+
updateAvailable: boolean;
|
|
57
|
+
installSource: "global" | "managed";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface PiCoreStatus {
|
|
61
|
+
packages: PiCorePackage[];
|
|
62
|
+
updatesAvailable: number;
|
|
63
|
+
lastChecked: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Resolve display name for a package. */
|
|
67
|
+
function resolveDisplayName(name: string): string {
|
|
68
|
+
return DISPLAY_NAMES[name] ?? name;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Heuristic to decide if a package is part of the pi ecosystem but NOT in
|
|
73
|
+
* the known-names list above. Matches bare-name pi packages on npm:
|
|
74
|
+
* - bare `pi-<name>`
|
|
75
|
+
* - scoped `@<scope>/pi-<name>`
|
|
76
|
+
* Note: extensions already managed by PackageManagerWrapper (via
|
|
77
|
+
* `settings.json packages[]`) are deliberately included if they are ALSO
|
|
78
|
+
* installed globally — the PiCoreChecker's discovery is a superset, and
|
|
79
|
+
* the UI layer decides which surface to show a package in.
|
|
80
|
+
*/
|
|
81
|
+
function looksLikePiEcosystem(name: string): boolean {
|
|
82
|
+
if (CORE_PACKAGE_NAMES.includes(name)) return true;
|
|
83
|
+
// `pi-foo` or `pi` bare-scoped
|
|
84
|
+
if (/^pi-[a-z0-9-]+$/i.test(name)) return true;
|
|
85
|
+
// scoped variant: `@scope/pi-foo`
|
|
86
|
+
if (/^@[^/]+\/pi-[a-z0-9-]+$/i.test(name)) return true;
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface NpmListRunner {
|
|
91
|
+
/** Run `npm list -g --depth=0 --json` and return stdout. */
|
|
92
|
+
(): Promise<string>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface PiCoreCheckerOptions {
|
|
96
|
+
/** Inject npm-list runner (for tests). */
|
|
97
|
+
npmList?: NpmListRunner;
|
|
98
|
+
/** Inject version fetcher (for tests). */
|
|
99
|
+
fetchLatest?: (packageName: string) => Promise<string | null>;
|
|
100
|
+
/** Override managed directory (for tests). */
|
|
101
|
+
managedDir?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Default npm runner uses execFile for safety. */
|
|
105
|
+
const defaultNpmList: NpmListRunner = async () => {
|
|
106
|
+
const { stdout } = await execFileAsync("npm", ["list", "-g", "--depth=0", "--json"], {
|
|
107
|
+
timeout: NPM_LIST_TIMEOUT_MS,
|
|
108
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
109
|
+
});
|
|
110
|
+
return stdout;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const defaultFetchLatest = async (packageName: string): Promise<string | null> => {
|
|
114
|
+
const meta = await fetchPackageMeta(packageName);
|
|
115
|
+
return meta?.version ?? null;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export class PiCoreChecker {
|
|
119
|
+
private cache: { at: number; data: PiCoreStatus } | null = null;
|
|
120
|
+
private readonly npmList: NpmListRunner;
|
|
121
|
+
private readonly fetchLatest: (packageName: string) => Promise<string | null>;
|
|
122
|
+
private readonly managedNodeModules: string;
|
|
123
|
+
|
|
124
|
+
constructor(opts: PiCoreCheckerOptions = {}) {
|
|
125
|
+
this.npmList = opts.npmList ?? defaultNpmList;
|
|
126
|
+
this.fetchLatest = opts.fetchLatest ?? defaultFetchLatest;
|
|
127
|
+
this.managedNodeModules = opts.managedDir
|
|
128
|
+
? path.join(opts.managedDir, "node_modules")
|
|
129
|
+
: MANAGED_NODE_MODULES;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Invalidate the cache (e.g. after an update completes). */
|
|
133
|
+
invalidate(): void {
|
|
134
|
+
this.cache = null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Get version status. Returns cached data within 5 min unless `refresh`. */
|
|
138
|
+
async getStatus(refresh = false): Promise<PiCoreStatus> {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
if (!refresh && this.cache && now - this.cache.at < CACHE_TTL_MS) {
|
|
141
|
+
return this.cache.data;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Discover packages from both sources. Managed takes precedence on conflict.
|
|
145
|
+
const global = await this.discoverGlobal();
|
|
146
|
+
const managed = this.discoverManaged();
|
|
147
|
+
|
|
148
|
+
const byName = new Map<string, { version: string; source: "global" | "managed" }>();
|
|
149
|
+
for (const entry of global) byName.set(entry.name, { version: entry.version, source: "global" });
|
|
150
|
+
for (const entry of managed) byName.set(entry.name, { version: entry.version, source: "managed" });
|
|
151
|
+
|
|
152
|
+
// Fetch latest versions in parallel.
|
|
153
|
+
const entries = Array.from(byName.entries());
|
|
154
|
+
const withLatest = await Promise.all(
|
|
155
|
+
entries.map(async ([name, info]) => {
|
|
156
|
+
let latest: string | null = null;
|
|
157
|
+
try {
|
|
158
|
+
latest = await this.fetchLatest(name);
|
|
159
|
+
} catch {
|
|
160
|
+
latest = null;
|
|
161
|
+
}
|
|
162
|
+
const updateAvailable = latest !== null && latest !== info.version;
|
|
163
|
+
const pkg: PiCorePackage = {
|
|
164
|
+
name,
|
|
165
|
+
displayName: resolveDisplayName(name),
|
|
166
|
+
currentVersion: info.version,
|
|
167
|
+
latestVersion: latest,
|
|
168
|
+
updateAvailable,
|
|
169
|
+
installSource: info.source,
|
|
170
|
+
};
|
|
171
|
+
return pkg;
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Sort: known core packages first (in CORE_PACKAGE_NAMES order), then
|
|
176
|
+
// alphabetically. Then updates-available bubble up.
|
|
177
|
+
withLatest.sort((a, b) => {
|
|
178
|
+
const ai = CORE_PACKAGE_NAMES.indexOf(a.name);
|
|
179
|
+
const bi = CORE_PACKAGE_NAMES.indexOf(b.name);
|
|
180
|
+
if (ai !== -1 || bi !== -1) {
|
|
181
|
+
if (ai === -1) return 1;
|
|
182
|
+
if (bi === -1) return -1;
|
|
183
|
+
return ai - bi;
|
|
184
|
+
}
|
|
185
|
+
return a.name.localeCompare(b.name);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const status: PiCoreStatus = {
|
|
189
|
+
packages: withLatest,
|
|
190
|
+
updatesAvailable: withLatest.filter((p) => p.updateAvailable).length,
|
|
191
|
+
lastChecked: new Date().toISOString(),
|
|
192
|
+
};
|
|
193
|
+
this.cache = { at: now, data: status };
|
|
194
|
+
return status;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Discover pi-ecosystem packages installed via `npm -g`. */
|
|
198
|
+
private async discoverGlobal(): Promise<Array<{ name: string; version: string }>> {
|
|
199
|
+
let stdout = "";
|
|
200
|
+
try {
|
|
201
|
+
stdout = await this.npmList();
|
|
202
|
+
} catch (err) {
|
|
203
|
+
// `npm list` exits non-zero when it has warnings — stdout may still be valid JSON.
|
|
204
|
+
// execFile throws with .stdout attached in that case.
|
|
205
|
+
const maybe = (err as { stdout?: string })?.stdout;
|
|
206
|
+
if (typeof maybe === "string" && maybe.length > 0) {
|
|
207
|
+
stdout = maybe;
|
|
208
|
+
} else {
|
|
209
|
+
console.warn("[pi-core-checker] npm list -g failed:", (err as Error).message);
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let parsed: unknown;
|
|
215
|
+
try {
|
|
216
|
+
parsed = JSON.parse(stdout);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.warn("[pi-core-checker] npm list -g: failed to parse JSON:", (err as Error).message);
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const deps = (parsed as { dependencies?: Record<string, { version?: string; resolved?: string }> })?.dependencies;
|
|
223
|
+
if (!deps || typeof deps !== "object") return [];
|
|
224
|
+
|
|
225
|
+
const out: Array<{ name: string; version: string }> = [];
|
|
226
|
+
for (const [name, info] of Object.entries(deps)) {
|
|
227
|
+
if (!looksLikePiEcosystem(name)) continue;
|
|
228
|
+
const version = typeof info?.version === "string" ? info.version : undefined;
|
|
229
|
+
if (!version) continue;
|
|
230
|
+
out.push({ name, version });
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Discover pi-ecosystem packages in ~/.pi-dashboard/node_modules/. */
|
|
236
|
+
private discoverManaged(): Array<{ name: string; version: string }> {
|
|
237
|
+
if (!existsSync(this.managedNodeModules)) return [];
|
|
238
|
+
const out: Array<{ name: string; version: string }> = [];
|
|
239
|
+
let entries: string[];
|
|
240
|
+
try {
|
|
241
|
+
entries = readdirSync(this.managedNodeModules);
|
|
242
|
+
} catch {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const entry of entries) {
|
|
247
|
+
if (entry.startsWith(".")) continue;
|
|
248
|
+
const full = path.join(this.managedNodeModules, entry);
|
|
249
|
+
if (entry.startsWith("@")) {
|
|
250
|
+
// Scoped: iterate one level deeper.
|
|
251
|
+
let sub: string[];
|
|
252
|
+
try {
|
|
253
|
+
sub = readdirSync(full);
|
|
254
|
+
} catch {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
for (const pkg of sub) {
|
|
258
|
+
const pkgName = `${entry}/${pkg}`;
|
|
259
|
+
if (!looksLikePiEcosystem(pkgName)) continue;
|
|
260
|
+
const v = this.readVersion(path.join(full, pkg));
|
|
261
|
+
if (v) out.push({ name: pkgName, version: v });
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
if (!looksLikePiEcosystem(entry)) continue;
|
|
265
|
+
const v = this.readVersion(full);
|
|
266
|
+
if (v) out.push({ name: entry, version: v });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private readVersion(pkgDir: string): string | null {
|
|
273
|
+
try {
|
|
274
|
+
const pj = path.join(pkgDir, "package.json");
|
|
275
|
+
if (!existsSync(pj)) return null;
|
|
276
|
+
if (!statSync(pj).isFile()) return null;
|
|
277
|
+
const parsed = JSON.parse(readFileSync(pj, "utf-8"));
|
|
278
|
+
return typeof parsed?.version === "string" ? parsed.version : null;
|
|
279
|
+
} catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export const _internal = {
|
|
286
|
+
looksLikePiEcosystem,
|
|
287
|
+
resolveDisplayName,
|
|
288
|
+
DISPLAY_NAMES,
|
|
289
|
+
MANAGED_NODE_MODULES,
|
|
290
|
+
};
|