@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,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi core package updater.
|
|
3
|
+
*
|
|
4
|
+
* Runs `npm update -g <pkg>` for globally-installed packages or
|
|
5
|
+
* `npm update <pkg>` in `~/.pi-dashboard/` for managed installs.
|
|
6
|
+
* Coordinates with PackageManagerWrapper's busy-lock so extension
|
|
7
|
+
* operations and core updates can't run concurrently.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import type { PiCorePackage, PiCoreUpdateResult } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
|
|
14
|
+
import type { PackageManagerWrapper } from "./package-manager-wrapper.js";
|
|
15
|
+
|
|
16
|
+
const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 min per package
|
|
17
|
+
|
|
18
|
+
const MANAGED_DIR = path.join(os.homedir(), ".pi-dashboard");
|
|
19
|
+
|
|
20
|
+
export interface UpdateProgressEvent {
|
|
21
|
+
name: string;
|
|
22
|
+
phase: "start" | "output" | "complete" | "error";
|
|
23
|
+
message?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type UpdateProgressListener = (event: UpdateProgressEvent) => void;
|
|
27
|
+
|
|
28
|
+
export interface PiCoreUpdaterOptions {
|
|
29
|
+
packageManagerWrapper: PackageManagerWrapper;
|
|
30
|
+
/** Test seam: override spawner. */
|
|
31
|
+
runNpmUpdate?: (pkg: PiCorePackage, onOutput: (line: string) => void) => Promise<void>;
|
|
32
|
+
/** Optional: called after successful update of at least one package. */
|
|
33
|
+
onAllComplete?: () => Promise<number>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Default npm-update runner. */
|
|
37
|
+
function defaultRunNpmUpdate(
|
|
38
|
+
pkg: PiCorePackage,
|
|
39
|
+
onOutput: (line: string) => void,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const args =
|
|
43
|
+
pkg.installSource === "global"
|
|
44
|
+
? ["update", "-g", pkg.name]
|
|
45
|
+
: ["update", pkg.name];
|
|
46
|
+
const cwd = pkg.installSource === "managed" ? MANAGED_DIR : process.cwd();
|
|
47
|
+
|
|
48
|
+
if (pkg.installSource === "managed" && !existsSync(MANAGED_DIR)) {
|
|
49
|
+
reject(new Error(`Managed install directory not found: ${MANAGED_DIR}`));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const child = spawn("npm", args, {
|
|
54
|
+
cwd,
|
|
55
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
56
|
+
env: process.env,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const timer = setTimeout(() => {
|
|
60
|
+
child.kill("SIGKILL");
|
|
61
|
+
reject(new Error(`npm update timed out after ${UPDATE_TIMEOUT_MS / 1000}s`));
|
|
62
|
+
}, UPDATE_TIMEOUT_MS);
|
|
63
|
+
|
|
64
|
+
let stderrBuf = "";
|
|
65
|
+
|
|
66
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
67
|
+
const lines = chunk.toString().split("\n").filter((l) => l.trim());
|
|
68
|
+
for (const line of lines) onOutput(line);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
72
|
+
const text = chunk.toString();
|
|
73
|
+
stderrBuf += text;
|
|
74
|
+
const lines = text.split("\n").filter((l) => l.trim());
|
|
75
|
+
for (const line of lines) onOutput(line);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
child.on("error", (err) => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
reject(err);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
child.on("close", (code) => {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
if (code === 0) {
|
|
86
|
+
resolve();
|
|
87
|
+
} else {
|
|
88
|
+
const hint =
|
|
89
|
+
pkg.installSource === "global" && /permission|EACCES|EPERM|EROFS/i.test(stderrBuf)
|
|
90
|
+
? ` (permission error — try: sudo npm update -g ${pkg.name})`
|
|
91
|
+
: "";
|
|
92
|
+
reject(new Error(`npm update exited with code ${code}${hint}`));
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class PiCoreUpdater {
|
|
99
|
+
private listener: UpdateProgressListener | undefined;
|
|
100
|
+
private readonly pmWrapper: PackageManagerWrapper;
|
|
101
|
+
private readonly runNpmUpdate: (
|
|
102
|
+
pkg: PiCorePackage,
|
|
103
|
+
onOutput: (line: string) => void,
|
|
104
|
+
) => Promise<void>;
|
|
105
|
+
private readonly onAllComplete: (() => Promise<number>) | undefined;
|
|
106
|
+
|
|
107
|
+
constructor(opts: PiCoreUpdaterOptions) {
|
|
108
|
+
this.pmWrapper = opts.packageManagerWrapper;
|
|
109
|
+
this.runNpmUpdate = opts.runNpmUpdate ?? defaultRunNpmUpdate;
|
|
110
|
+
this.onAllComplete = opts.onAllComplete;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
setProgressListener(listener: UpdateProgressListener | undefined): void {
|
|
114
|
+
this.listener = listener;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Update a set of core packages sequentially. Acquires the shared
|
|
119
|
+
* busy-lock via PackageManagerWrapper.runExclusive — will throw
|
|
120
|
+
* PackageOperationBusyError if an extension operation is running.
|
|
121
|
+
*
|
|
122
|
+
* Returns per-package results plus the count of sessions reloaded
|
|
123
|
+
* after a successful update.
|
|
124
|
+
*/
|
|
125
|
+
async update(
|
|
126
|
+
packages: PiCorePackage[],
|
|
127
|
+
): Promise<{ results: PiCoreUpdateResult[]; sessionsReloaded: number }> {
|
|
128
|
+
return this.pmWrapper.runExclusive(async () => {
|
|
129
|
+
const results: PiCoreUpdateResult[] = [];
|
|
130
|
+
|
|
131
|
+
for (const pkg of packages) {
|
|
132
|
+
this.emit({ name: pkg.name, phase: "start", message: `Updating ${pkg.name}...` });
|
|
133
|
+
try {
|
|
134
|
+
await this.runNpmUpdate(pkg, (line) => {
|
|
135
|
+
this.emit({ name: pkg.name, phase: "output", message: line });
|
|
136
|
+
});
|
|
137
|
+
results.push({ name: pkg.name, success: true });
|
|
138
|
+
this.emit({ name: pkg.name, phase: "complete", message: `Updated ${pkg.name}` });
|
|
139
|
+
} catch (err) {
|
|
140
|
+
const msg = (err as Error).message ?? String(err);
|
|
141
|
+
results.push({ name: pkg.name, success: false, error: msg });
|
|
142
|
+
this.emit({ name: pkg.name, phase: "error", message: msg });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let sessionsReloaded = 0;
|
|
147
|
+
if (results.some((r) => r.success) && this.onAllComplete) {
|
|
148
|
+
try {
|
|
149
|
+
sessionsReloaded = await this.onAllComplete();
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error("[pi-core-updater] session reload failed:", err);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { results, sessionsReloaded };
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private emit(event: UpdateProgressEvent): void {
|
|
160
|
+
try {
|
|
161
|
+
this.listener?.(event);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error("[pi-core-updater] progress listener error:", err);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -17,6 +17,8 @@ export interface PiGatewayOptions {
|
|
|
17
17
|
export interface PiGateway {
|
|
18
18
|
start(port: number): void;
|
|
19
19
|
stop(): void;
|
|
20
|
+
/** Resolved listening port after start() (useful when start(0) is used). Returns null if not started or closed. */
|
|
21
|
+
address(): number | null;
|
|
20
22
|
sendToSession(sessionId: string, msg: ServerToExtensionMessage): boolean;
|
|
21
23
|
broadcast(msg: ServerToExtensionMessage): void;
|
|
22
24
|
connectionCount(): number;
|
|
@@ -172,6 +174,11 @@ export function createPiGateway(
|
|
|
172
174
|
onSessionCreated = handler;
|
|
173
175
|
},
|
|
174
176
|
|
|
177
|
+
address() {
|
|
178
|
+
const addr = wss?.address();
|
|
179
|
+
if (addr && typeof addr === "object") return addr.port;
|
|
180
|
+
return null;
|
|
181
|
+
},
|
|
175
182
|
start(port: number) {
|
|
176
183
|
wss = new WebSocketServer({ port });
|
|
177
184
|
|
|
@@ -6,7 +6,7 @@ import type { SessionManager } from "../memory-session-manager.js";
|
|
|
6
6
|
import type { PreferencesStore } from "../preferences-store.js";
|
|
7
7
|
import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
8
8
|
import type { NetworkGuard } from "./route-deps.js";
|
|
9
|
-
import { listDirectories } from "../browse.js";
|
|
9
|
+
import { listDirectories, createDirectory } from "../browse.js";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import fs from "node:fs/promises";
|
|
12
12
|
|
|
@@ -21,12 +21,15 @@ export function registerFileRoutes(
|
|
|
21
21
|
const { sessionManager, preferencesStore, networkGuard } = deps;
|
|
22
22
|
|
|
23
23
|
// Directory browse endpoint
|
|
24
|
-
fastify.get<{ Querystring: { path?: string } }>(
|
|
24
|
+
fastify.get<{ Querystring: { path?: string; q?: string } }>(
|
|
25
25
|
"/api/browse",
|
|
26
26
|
{ preHandler: networkGuard },
|
|
27
27
|
async (request) => {
|
|
28
28
|
try {
|
|
29
|
-
const result = await listDirectories(
|
|
29
|
+
const result = await listDirectories(
|
|
30
|
+
request.query.path || undefined,
|
|
31
|
+
request.query.q || undefined,
|
|
32
|
+
);
|
|
30
33
|
return { success: true, data: result } satisfies ApiResponse;
|
|
31
34
|
} catch {
|
|
32
35
|
return { success: false, error: "directory not found" } satisfies ApiResponse;
|
|
@@ -34,6 +37,30 @@ export function registerFileRoutes(
|
|
|
34
37
|
},
|
|
35
38
|
);
|
|
36
39
|
|
|
40
|
+
// Directory create endpoint
|
|
41
|
+
fastify.post<{ Body: { parent?: unknown; name?: unknown } }>(
|
|
42
|
+
"/api/browse/mkdir",
|
|
43
|
+
{ preHandler: networkGuard },
|
|
44
|
+
async (request, reply) => {
|
|
45
|
+
const body = request.body ?? {};
|
|
46
|
+
const parent = typeof body.parent === "string" ? body.parent : "";
|
|
47
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
48
|
+
try {
|
|
49
|
+
const newPath = await createDirectory(parent, name);
|
|
50
|
+
return { success: true, data: { path: newPath } } satisfies ApiResponse;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
const msg = err instanceof Error ? err.message : "mkdir failed";
|
|
53
|
+
// Map known errors to status codes; unknown → 500
|
|
54
|
+
if (msg === "invalid name") reply.code(400);
|
|
55
|
+
else if (msg === "parent not found") reply.code(404);
|
|
56
|
+
else if (msg === "parent is not a directory") reply.code(400);
|
|
57
|
+
else if (msg === "already exists") reply.code(409);
|
|
58
|
+
else reply.code(500);
|
|
59
|
+
return { success: false, error: msg } satisfies ApiResponse;
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
|
|
37
64
|
// File read endpoint — read file content or list directory
|
|
38
65
|
fastify.get<{ Querystring: { cwd?: string; path?: string } }>(
|
|
39
66
|
"/api/file",
|
|
@@ -8,9 +8,19 @@ import type { DirectoryService } from "../directory-service.js";
|
|
|
8
8
|
import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
9
9
|
import type { NetworkGuard } from "./route-deps.js";
|
|
10
10
|
import { scanOpenSpecArchive } from "../openspec-archive.js";
|
|
11
|
+
import {
|
|
12
|
+
readTasks,
|
|
13
|
+
toggleTask,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
LineMismatchError,
|
|
16
|
+
NotACheckboxError,
|
|
17
|
+
} from "../openspec-tasks.js";
|
|
11
18
|
import path from "node:path";
|
|
12
19
|
import fs from "node:fs/promises";
|
|
13
20
|
|
|
21
|
+
/** Callback to broadcast an openspec_update after a successful toggle. */
|
|
22
|
+
export type OpenSpecBroadcaster = (cwd: string) => void;
|
|
23
|
+
|
|
14
24
|
export function registerOpenSpecRoutes(
|
|
15
25
|
fastify: FastifyInstance,
|
|
16
26
|
deps: {
|
|
@@ -18,9 +28,11 @@ export function registerOpenSpecRoutes(
|
|
|
18
28
|
preferencesStore: PreferencesStore;
|
|
19
29
|
directoryService: DirectoryService;
|
|
20
30
|
networkGuard: NetworkGuard;
|
|
31
|
+
/** Optional — called after a successful toggle to trigger openspec_update. */
|
|
32
|
+
onOpenSpecChanged?: OpenSpecBroadcaster;
|
|
21
33
|
},
|
|
22
34
|
) {
|
|
23
|
-
const { sessionManager, preferencesStore, directoryService, networkGuard } = deps;
|
|
35
|
+
const { sessionManager, preferencesStore, directoryService, networkGuard, onOpenSpecChanged } = deps;
|
|
24
36
|
|
|
25
37
|
// OpenSpec archive listing endpoint
|
|
26
38
|
fastify.get<{ Querystring: { cwd?: string } }>(
|
|
@@ -96,4 +108,74 @@ export function registerOpenSpecRoutes(
|
|
|
96
108
|
}
|
|
97
109
|
},
|
|
98
110
|
);
|
|
111
|
+
|
|
112
|
+
// --- Tasks.md list + toggle ---
|
|
113
|
+
|
|
114
|
+
fastify.get<{ Querystring: { cwd?: string; change?: string } }>(
|
|
115
|
+
"/api/openspec/tasks",
|
|
116
|
+
{ preHandler: networkGuard },
|
|
117
|
+
async (request, reply) => {
|
|
118
|
+
const { cwd, change } = request.query;
|
|
119
|
+
if (!cwd || !change) {
|
|
120
|
+
reply.code(400);
|
|
121
|
+
return { success: false, error: "cwd and change query params required" } satisfies ApiResponse;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const tasks = await readTasks(cwd, change);
|
|
125
|
+
const groups = Array.from(new Set(tasks.map((t) => t.group).filter((g) => g.length > 0)));
|
|
126
|
+
return { success: true, data: { tasks, groups } } satisfies ApiResponse;
|
|
127
|
+
} catch (err: any) {
|
|
128
|
+
if (err instanceof NotFoundError) {
|
|
129
|
+
reply.code(404);
|
|
130
|
+
return { success: false, error: "tasks.md not found" } satisfies ApiResponse;
|
|
131
|
+
}
|
|
132
|
+
reply.code(500);
|
|
133
|
+
return { success: false, error: err?.message ?? "read error" } satisfies ApiResponse;
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
fastify.post<{
|
|
139
|
+
Body: { cwd?: string; change?: string; id?: string; done?: boolean; line?: number };
|
|
140
|
+
}>(
|
|
141
|
+
"/api/openspec/tasks/toggle",
|
|
142
|
+
{ preHandler: networkGuard },
|
|
143
|
+
async (request, reply) => {
|
|
144
|
+
const body = request.body ?? {};
|
|
145
|
+
const { cwd, change, id, done, line } = body;
|
|
146
|
+
if (
|
|
147
|
+
typeof cwd !== "string" ||
|
|
148
|
+
typeof change !== "string" ||
|
|
149
|
+
typeof id !== "string" ||
|
|
150
|
+
typeof done !== "boolean" ||
|
|
151
|
+
typeof line !== "number"
|
|
152
|
+
) {
|
|
153
|
+
reply.code(400);
|
|
154
|
+
return { success: false, error: "invalid body" } satisfies ApiResponse;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const task = await toggleTask(cwd, change, id, done, line);
|
|
158
|
+
// Fire-and-forget: refresh cache + broadcast openspec_update.
|
|
159
|
+
directoryService.refreshOpenSpec(cwd).then(() => {
|
|
160
|
+
onOpenSpecChanged?.(cwd);
|
|
161
|
+
}).catch(() => {});
|
|
162
|
+
return { success: true, data: { task } } satisfies ApiResponse;
|
|
163
|
+
} catch (err: any) {
|
|
164
|
+
if (err instanceof NotFoundError) {
|
|
165
|
+
reply.code(404);
|
|
166
|
+
return { success: false, error: "tasks.md not found" } satisfies ApiResponse;
|
|
167
|
+
}
|
|
168
|
+
if (err instanceof LineMismatchError) {
|
|
169
|
+
reply.code(409);
|
|
170
|
+
return { success: false, error: "line mismatch" } satisfies ApiResponse;
|
|
171
|
+
}
|
|
172
|
+
if (err instanceof NotACheckboxError) {
|
|
173
|
+
reply.code(400);
|
|
174
|
+
return { success: false, error: "target line is not a checkbox" } satisfies ApiResponse;
|
|
175
|
+
}
|
|
176
|
+
reply.code(500);
|
|
177
|
+
return { success: false, error: err?.message ?? "toggle error" } satisfies ApiResponse;
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
);
|
|
99
181
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST routes for pi core version check and update.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/pi-core/versions[?refresh=true]
|
|
5
|
+
* POST /api/pi-core/update { packages?: string[] }
|
|
6
|
+
*
|
|
7
|
+
* Complements /api/packages/* (extension management): this endpoint covers
|
|
8
|
+
* globally-installed pi CLI packages like @mariozechner/pi-coding-agent,
|
|
9
|
+
* pi-dashboard itself, pi-model-proxy, etc.
|
|
10
|
+
*/
|
|
11
|
+
import type { FastifyInstance } from "fastify";
|
|
12
|
+
import type {
|
|
13
|
+
ApiResponse,
|
|
14
|
+
PiCoreStatus,
|
|
15
|
+
PiCoreUpdateRequest,
|
|
16
|
+
PiCoreUpdateResponse,
|
|
17
|
+
} from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
|
|
18
|
+
import type { PiCoreChecker } from "../pi-core-checker.js";
|
|
19
|
+
import type { PiCoreUpdater } from "../pi-core-updater.js";
|
|
20
|
+
import { PackageOperationBusyError } from "../package-manager-wrapper.js";
|
|
21
|
+
|
|
22
|
+
export interface PiCoreRouteDeps {
|
|
23
|
+
piCoreChecker: PiCoreChecker;
|
|
24
|
+
piCoreUpdater: PiCoreUpdater;
|
|
25
|
+
/**
|
|
26
|
+
* Called after the updater finishes a batch (success or per-package failure).
|
|
27
|
+
* The server wires this to broadcast a `pi_core_update_complete` WS message
|
|
28
|
+
* so listeners (PiUpdateBadge, PiCoreVersionsSection, usePiCoreVersions
|
|
29
|
+
* hook instances in other open tabs) refetch their state.
|
|
30
|
+
*/
|
|
31
|
+
onUpdateComplete?: (payload: {
|
|
32
|
+
results: Array<{ name: string; success: boolean; error?: string }>;
|
|
33
|
+
sessionsReloaded: number;
|
|
34
|
+
}) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function registerPiCoreRoutes(
|
|
38
|
+
fastify: FastifyInstance,
|
|
39
|
+
deps: PiCoreRouteDeps,
|
|
40
|
+
): void {
|
|
41
|
+
const { piCoreChecker, piCoreUpdater } = deps;
|
|
42
|
+
|
|
43
|
+
// ── GET /api/pi-core/versions ──────────────────────────────────
|
|
44
|
+
|
|
45
|
+
fastify.get<{ Querystring: { refresh?: string } }>(
|
|
46
|
+
"/api/pi-core/versions",
|
|
47
|
+
async (request) => {
|
|
48
|
+
const refresh = request.query.refresh === "true";
|
|
49
|
+
try {
|
|
50
|
+
const status = await piCoreChecker.getStatus(refresh);
|
|
51
|
+
return { success: true, data: status } satisfies ApiResponse<PiCoreStatus>;
|
|
52
|
+
} catch (err: any) {
|
|
53
|
+
return { success: false, error: err?.message ?? String(err) } satisfies ApiResponse;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// ── POST /api/pi-core/update ───────────────────────────────────
|
|
59
|
+
|
|
60
|
+
fastify.post<{ Body: PiCoreUpdateRequest }>(
|
|
61
|
+
"/api/pi-core/update",
|
|
62
|
+
async (request, reply) => {
|
|
63
|
+
const requested = request.body?.packages ?? [];
|
|
64
|
+
|
|
65
|
+
// Load current status to determine install source and eligibility.
|
|
66
|
+
const status = await piCoreChecker.getStatus();
|
|
67
|
+
const allByName = new Map(status.packages.map((p) => [p.name, p]));
|
|
68
|
+
|
|
69
|
+
const targetNames =
|
|
70
|
+
requested.length > 0
|
|
71
|
+
? requested
|
|
72
|
+
: status.packages.filter((p) => p.updateAvailable).map((p) => p.name);
|
|
73
|
+
|
|
74
|
+
const resolved = [];
|
|
75
|
+
const unknown: string[] = [];
|
|
76
|
+
for (const name of targetNames) {
|
|
77
|
+
const pkg = allByName.get(name);
|
|
78
|
+
if (!pkg) {
|
|
79
|
+
unknown.push(name);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
resolved.push(pkg);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (unknown.length > 0) {
|
|
86
|
+
reply.code(400);
|
|
87
|
+
return {
|
|
88
|
+
success: false,
|
|
89
|
+
error: `Unknown package(s): ${unknown.join(", ")}`,
|
|
90
|
+
} satisfies ApiResponse;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (resolved.length === 0) {
|
|
94
|
+
return {
|
|
95
|
+
success: true,
|
|
96
|
+
data: { results: [], sessionsReloaded: 0 },
|
|
97
|
+
} satisfies ApiResponse<PiCoreUpdateResponse>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const out = await piCoreUpdater.update(resolved);
|
|
102
|
+
// Invalidate cache so next version check reflects new versions.
|
|
103
|
+
piCoreChecker.invalidate();
|
|
104
|
+
// Notify other browser tabs / the header badge hook instance so
|
|
105
|
+
// their independent usePiCoreVersions state refetches.
|
|
106
|
+
deps.onUpdateComplete?.(out);
|
|
107
|
+
return { success: true, data: out } satisfies ApiResponse<PiCoreUpdateResponse>;
|
|
108
|
+
} catch (err: any) {
|
|
109
|
+
if (err instanceof PackageOperationBusyError) {
|
|
110
|
+
reply.code(409);
|
|
111
|
+
return { success: false, error: err.message } satisfies ApiResponse;
|
|
112
|
+
}
|
|
113
|
+
return { success: false, error: err?.message ?? String(err) } satisfies ApiResponse;
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "../provider-auth-storage.js";
|
|
22
22
|
import { startCallbackServer } from "../oauth-callback-server.js";
|
|
23
23
|
import type { PiGateway } from "../pi-gateway.js";
|
|
24
|
+
import type { BrowserGateway } from "../browser-gateway.js";
|
|
24
25
|
|
|
25
26
|
// ── In-memory flow store (short-lived PKCE + device code state) ──────────────
|
|
26
27
|
|
|
@@ -81,12 +82,13 @@ function openInBrowser(url: string): void {
|
|
|
81
82
|
|
|
82
83
|
export function registerProviderAuthRoutes(
|
|
83
84
|
fastify: FastifyInstance,
|
|
84
|
-
deps: { piGateway: PiGateway },
|
|
85
|
+
deps: { piGateway: PiGateway; browserGateway: BrowserGateway },
|
|
85
86
|
) {
|
|
86
|
-
const { piGateway } = deps;
|
|
87
|
+
const { piGateway, browserGateway } = deps;
|
|
87
88
|
|
|
88
89
|
function notifyBridges() {
|
|
89
90
|
piGateway.broadcast({ type: "credentials_updated" });
|
|
91
|
+
browserGateway.broadcastToAll({ type: "models_refreshed" });
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
// List OAuth providers
|
|
@@ -6,6 +6,8 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { join, dirname } from "node:path";
|
|
8
8
|
import type { NetworkGuard } from "./route-deps.js";
|
|
9
|
+
import type { PiGateway } from "../pi-gateway.js";
|
|
10
|
+
import type { BrowserGateway } from "../browser-gateway.js";
|
|
9
11
|
|
|
10
12
|
const REDACTED = "***";
|
|
11
13
|
const CONFIG_PATH = join(homedir(), ".pi", "agent", "providers.json");
|
|
@@ -44,8 +46,8 @@ function redactProviders(
|
|
|
44
46
|
return redacted;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
export function registerProviderRoutes(fastify: FastifyInstance, deps: { networkGuard: NetworkGuard }): void {
|
|
48
|
-
const { networkGuard } = deps;
|
|
49
|
+
export function registerProviderRoutes(fastify: FastifyInstance, deps: { networkGuard: NetworkGuard; piGateway?: PiGateway; browserGateway?: BrowserGateway }): void {
|
|
50
|
+
const { networkGuard, piGateway } = deps;
|
|
49
51
|
fastify.get(
|
|
50
52
|
"/api/providers",
|
|
51
53
|
{ preHandler: networkGuard },
|
|
@@ -95,6 +97,14 @@ export function registerProviderRoutes(fastify: FastifyInstance, deps: { network
|
|
|
95
97
|
mkdirSync(dir, { recursive: true });
|
|
96
98
|
writeFileSync(CONFIG_PATH, JSON.stringify(fileData, null, 2) + "\n", "utf-8");
|
|
97
99
|
|
|
100
|
+
// Broadcast credentials_updated so all sessions refresh their model registries
|
|
101
|
+
if (piGateway) {
|
|
102
|
+
piGateway.broadcast({ type: "credentials_updated" });
|
|
103
|
+
}
|
|
104
|
+
if (deps.browserGateway) {
|
|
105
|
+
deps.browserGateway.broadcastToAll({ type: "models_refreshed" });
|
|
106
|
+
}
|
|
107
|
+
|
|
98
108
|
return { success: true };
|
|
99
109
|
},
|
|
100
110
|
);
|