@blackbelt-technology/pi-agent-dashboard 0.4.5 → 0.5.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 +342 -267
- package/README.md +51 -2
- package/docs/architecture.md +266 -25
- package/package.json +14 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-bus.test.ts +44 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- package/packages/extension/src/__tests__/vcs-info-jj.test.ts +145 -0
- package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
- package/packages/extension/src/bridge-context.ts +7 -0
- package/packages/extension/src/bridge.ts +142 -4
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/model-tracker.ts +35 -1
- package/packages/extension/src/prompt-bus.ts +4 -3
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +117 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +6 -1
- package/packages/extension/src/vcs-info.ts +184 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- package/packages/server/src/__tests__/is-unread-trigger.test.ts +4 -2
- package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
- package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +58 -21
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/cli.ts +22 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +57 -2
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- package/packages/server/src/openspec-tasks.ts +50 -19
- package/packages/server/src/pi-core-updater.ts +65 -9
- package/packages/server/src/pi-gateway.ts +6 -0
- package/packages/server/src/process-manager.ts +62 -11
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +41 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/jj-routes.ts +386 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/session-routes.ts +12 -3
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +16 -9
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-diff.ts +118 -1
- package/packages/server/src/session-discovery.ts +10 -3
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +236 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +29 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/diff-types.ts +17 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/jj.ts +405 -0
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +60 -2
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +19 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +91 -0
- package/packages/extension/src/git-info.ts +0 -55
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor core — shared diagnostic primitives used by both the Electron app
|
|
3
|
+
* (`packages/electron/src/lib/doctor.ts`) and the dashboard server route
|
|
4
|
+
* (`packages/server/src/routes/doctor-routes.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Hosts the canonical type system, section taxonomy, suggestion mapping,
|
|
7
|
+
* fault-tolerance helpers (`safeCheck` / `safeExec` / `assumedMandatory`),
|
|
8
|
+
* a shared `runSharedChecks` for non-Electron checks, and the Markdown
|
|
9
|
+
* report formatter.
|
|
10
|
+
*
|
|
11
|
+
* See change: doctor-rich-output (proposal.md, design.md).
|
|
12
|
+
*/
|
|
13
|
+
import { execSync } from "./platform/exec.js";
|
|
14
|
+
import { existsSync, readFileSync, statSync, renameSync, appendFileSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export type DoctorSection = "runtime" | "pi-tooling" | "server" | "setup" | "diagnostics";
|
|
20
|
+
|
|
21
|
+
export type DoctorStatus = "ok" | "warning" | "error";
|
|
22
|
+
|
|
23
|
+
export type ExecFailureKind =
|
|
24
|
+
| "not-found"
|
|
25
|
+
| "permission-denied"
|
|
26
|
+
| "timeout"
|
|
27
|
+
| "non-zero-exit"
|
|
28
|
+
| "unknown";
|
|
29
|
+
|
|
30
|
+
export interface DoctorCheck {
|
|
31
|
+
name: string;
|
|
32
|
+
status: DoctorStatus;
|
|
33
|
+
section: DoctorSection;
|
|
34
|
+
message: string;
|
|
35
|
+
detail?: string;
|
|
36
|
+
suggestion?: string;
|
|
37
|
+
fixable?: boolean;
|
|
38
|
+
/** Populated when the check ran an external command and it failed. */
|
|
39
|
+
kind?: ExecFailureKind;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DoctorReport {
|
|
43
|
+
checks: DoctorCheck[];
|
|
44
|
+
summary: { ok: number; warnings: number; errors: number };
|
|
45
|
+
generatedAt?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── stripAnsi ─────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Strip standard ANSI CSI / OSC escape sequences. No external dependency.
|
|
52
|
+
*/
|
|
53
|
+
export function stripAnsi(input: string): string {
|
|
54
|
+
if (!input) return "";
|
|
55
|
+
// CSI sequences: ESC [ ... letter (incl. SGR colors, cursor moves)
|
|
56
|
+
// OSC sequences: ESC ] ... BEL or ESC \
|
|
57
|
+
// Plus a few standalone escapes (ESC = ESC + char like ESC ( B).
|
|
58
|
+
// eslint-disable-next-line no-control-regex
|
|
59
|
+
const csi = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
60
|
+
// eslint-disable-next-line no-control-regex
|
|
61
|
+
const osc = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
|
|
62
|
+
// eslint-disable-next-line no-control-regex
|
|
63
|
+
const single = /\u001b[@-Z\\-_]/g;
|
|
64
|
+
return input.replace(csi, "").replace(osc, "").replace(single, "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── safeExec ──────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export interface SafeExecOk {
|
|
70
|
+
ok: true;
|
|
71
|
+
stdout: string;
|
|
72
|
+
}
|
|
73
|
+
export interface SafeExecErr {
|
|
74
|
+
ok: false;
|
|
75
|
+
kind: ExecFailureKind;
|
|
76
|
+
message: string;
|
|
77
|
+
detail: string;
|
|
78
|
+
exitCode?: number;
|
|
79
|
+
stderrTail?: string;
|
|
80
|
+
/** Whatever timeoutMs was used for the call (ms). */
|
|
81
|
+
timeoutMs: number;
|
|
82
|
+
}
|
|
83
|
+
export type SafeExecResult = SafeExecOk | SafeExecErr;
|
|
84
|
+
|
|
85
|
+
export interface SafeExecOpts {
|
|
86
|
+
timeoutMs?: number;
|
|
87
|
+
env?: NodeJS.ProcessEnv;
|
|
88
|
+
cwd?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run a command via `execSync`, classify failures, and capture a stderr tail.
|
|
93
|
+
*
|
|
94
|
+
* Defaults: 5000 ms timeout, `windowsHide: true`. Cold-start probes (bundled
|
|
95
|
+
* Node, server-launch test) pass `timeoutMs: 15000`.
|
|
96
|
+
*/
|
|
97
|
+
export function safeExec(cmd: string, opts: SafeExecOpts = {}): SafeExecResult {
|
|
98
|
+
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
99
|
+
try {
|
|
100
|
+
const stdout = execSync(cmd, {
|
|
101
|
+
encoding: "utf-8",
|
|
102
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
103
|
+
timeout: timeoutMs,
|
|
104
|
+
env: opts.env,
|
|
105
|
+
cwd: opts.cwd,
|
|
106
|
+
});
|
|
107
|
+
return { ok: true, stdout: stdout.toString() };
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return classifyExecError(err, cmd, timeoutMs);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function classifyExecError(err: unknown, cmd: string, timeoutMs: number): SafeExecErr {
|
|
114
|
+
const e = err as NodeJS.ErrnoException & {
|
|
115
|
+
status?: number;
|
|
116
|
+
signal?: NodeJS.Signals | null;
|
|
117
|
+
stdout?: Buffer | string;
|
|
118
|
+
stderr?: Buffer | string;
|
|
119
|
+
};
|
|
120
|
+
const stderrRaw = e.stderr ? e.stderr.toString() : "";
|
|
121
|
+
const stderrTail = stripAnsi(stderrRaw).slice(-500);
|
|
122
|
+
const stdoutRaw = e.stdout ? e.stdout.toString() : "";
|
|
123
|
+
const code = e.code ?? "";
|
|
124
|
+
const errno = (e as { errno?: number }).errno;
|
|
125
|
+
const status = e.status;
|
|
126
|
+
const signal = e.signal;
|
|
127
|
+
const baseMsg = e.message || String(err);
|
|
128
|
+
|
|
129
|
+
// ENOENT — binary not found / file missing
|
|
130
|
+
if (code === "ENOENT") {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
kind: "not-found",
|
|
134
|
+
message: "Command not found",
|
|
135
|
+
detail: `${cmd}\n${baseMsg}`,
|
|
136
|
+
stderrTail: stderrTail || undefined,
|
|
137
|
+
timeoutMs,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// EACCES / EPERM — permission denied
|
|
141
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
kind: "permission-denied",
|
|
145
|
+
message: "Permission denied",
|
|
146
|
+
detail: `${cmd}\n${baseMsg}`,
|
|
147
|
+
stderrTail: stderrTail || undefined,
|
|
148
|
+
timeoutMs,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// Timeout — execSync throws ETIMEDOUT (errno -2 on linux, signal SIGTERM, code "ETIMEDOUT")
|
|
152
|
+
if (
|
|
153
|
+
code === "ETIMEDOUT" ||
|
|
154
|
+
signal === "SIGTERM" ||
|
|
155
|
+
errno === -2 ||
|
|
156
|
+
/timed?\s*out/i.test(baseMsg)
|
|
157
|
+
) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
kind: "timeout",
|
|
161
|
+
message: `Command did not respond within ${Math.round(timeoutMs / 1000)}s`,
|
|
162
|
+
detail: `${cmd}\nDeadline: ${timeoutMs}ms`,
|
|
163
|
+
stderrTail: stderrTail || undefined,
|
|
164
|
+
timeoutMs,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// Non-zero exit
|
|
168
|
+
if (typeof status === "number" && status !== 0) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
kind: "non-zero-exit",
|
|
172
|
+
message: `Command exited with status ${status}`,
|
|
173
|
+
detail: `${cmd}${stdoutRaw ? `\nstdout: ${stripAnsi(stdoutRaw).slice(-200)}` : ""}`,
|
|
174
|
+
exitCode: status,
|
|
175
|
+
stderrTail: stderrTail || undefined,
|
|
176
|
+
timeoutMs,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// Unknown
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
kind: "unknown",
|
|
183
|
+
message: "Command failed",
|
|
184
|
+
detail: `${cmd}\n${baseMsg}`,
|
|
185
|
+
stderrTail: stderrTail || undefined,
|
|
186
|
+
timeoutMs,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── safeCheck ─────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Per-check fault-isolation wrapper. Catches any throw / rejection from
|
|
194
|
+
* `fn` and returns a `diagnostics`-section error row that carries a
|
|
195
|
+
* non-empty `message` / `detail` / `suggestion`. Never propagates.
|
|
196
|
+
*/
|
|
197
|
+
export async function safeCheck(
|
|
198
|
+
name: string,
|
|
199
|
+
section: DoctorSection,
|
|
200
|
+
fn: () => DoctorCheck | Promise<DoctorCheck>,
|
|
201
|
+
): Promise<DoctorCheck> {
|
|
202
|
+
try {
|
|
203
|
+
const result = await fn();
|
|
204
|
+
// If caller forgot to set section, default it.
|
|
205
|
+
if (!result.section) result.section = section;
|
|
206
|
+
return result;
|
|
207
|
+
} catch (err) {
|
|
208
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
209
|
+
const stack = (e.stack || "").split("\n").slice(0, 4).join("\n");
|
|
210
|
+
return {
|
|
211
|
+
name,
|
|
212
|
+
section,
|
|
213
|
+
status: "error",
|
|
214
|
+
message: "Check failed to run",
|
|
215
|
+
detail: `${e.message}\n${stack}`,
|
|
216
|
+
suggestion:
|
|
217
|
+
"This is a doctor-internal failure. Please file an issue with the Markdown export attached.",
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── assumedMandatory ─────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
export interface AssumedDeps {
|
|
225
|
+
/** Managed install dir. `<managedDir>/doctor.log` is the log path. */
|
|
226
|
+
managedDir: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const DOCTOR_LOG_MAX_BYTES = 1 * 1024 * 1024; // 1 MB
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Wrap a "should-never-fail" operation. On throw:
|
|
233
|
+
* 1. Append a JSON line to `<managedDir>/doctor.log` (with prior ring rotation if >1MB).
|
|
234
|
+
* 2. Return a diagnostics-section error row labelled "Doctor internal: <label>".
|
|
235
|
+
*
|
|
236
|
+
* Both rotation and append are wrapped in try/catch and silently drop
|
|
237
|
+
* on failure — a broken log file MUST never cascade into the report.
|
|
238
|
+
*/
|
|
239
|
+
export function assumedMandatory<T>(
|
|
240
|
+
label: string,
|
|
241
|
+
fn: () => T,
|
|
242
|
+
deps: AssumedDeps,
|
|
243
|
+
): { ok: true; value: T } | { ok: false; row: DoctorCheck } {
|
|
244
|
+
try {
|
|
245
|
+
return { ok: true, value: fn() };
|
|
246
|
+
} catch (err) {
|
|
247
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
248
|
+
appendDoctorLog(deps.managedDir, label, e);
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
row: {
|
|
252
|
+
name: `Doctor internal: ${label}`,
|
|
253
|
+
section: "diagnostics",
|
|
254
|
+
status: "error",
|
|
255
|
+
message: "An assumed-safe operation failed",
|
|
256
|
+
detail: `${e.message}\n${(e.stack || "").split("\n").slice(0, 4).join("\n")}`,
|
|
257
|
+
suggestion:
|
|
258
|
+
"Open `~/.pi-dashboard/doctor.log` for full context, then file an issue with the Markdown export attached.",
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function appendDoctorLog(managedDir: string, label: string, err: Error): void {
|
|
265
|
+
try {
|
|
266
|
+
const logPath = path.join(managedDir, "doctor.log");
|
|
267
|
+
rotateDoctorLogIfNeeded(logPath);
|
|
268
|
+
const line =
|
|
269
|
+
JSON.stringify({
|
|
270
|
+
ts: new Date().toISOString(),
|
|
271
|
+
label,
|
|
272
|
+
message: err.message,
|
|
273
|
+
stack: (err.stack || "").split("\n").slice(0, 6).join(" | "),
|
|
274
|
+
}) + "\n";
|
|
275
|
+
appendFileSync(logPath, line, { encoding: "utf-8" });
|
|
276
|
+
} catch {
|
|
277
|
+
// logging failure must never propagate
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function rotateDoctorLogIfNeeded(logPath: string): void {
|
|
282
|
+
try {
|
|
283
|
+
if (!existsSync(logPath)) return;
|
|
284
|
+
const size = statSync(logPath).size;
|
|
285
|
+
if (size <= DOCTOR_LOG_MAX_BYTES) return;
|
|
286
|
+
const rotated = `${logPath}.1`;
|
|
287
|
+
try {
|
|
288
|
+
// Best-effort: rename overwrites on POSIX, but on Windows we may need to remove the old .1 first.
|
|
289
|
+
renameSync(logPath, rotated);
|
|
290
|
+
} catch {
|
|
291
|
+
// try once more after best-effort cleanup
|
|
292
|
+
try {
|
|
293
|
+
const fs = require("node:fs") as typeof import("node:fs");
|
|
294
|
+
if (existsSync(rotated)) fs.rmSync(rotated, { force: true });
|
|
295
|
+
renameSync(logPath, rotated);
|
|
296
|
+
} catch {
|
|
297
|
+
// give up silently
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
// never propagate
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─── Section + suggestion taxonomy ────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Canonical check-name → section. Every check name pushed by either
|
|
309
|
+
* `runSharedChecks` (here) or `runDoctor` (Electron) MUST appear here.
|
|
310
|
+
*/
|
|
311
|
+
export const SECTION_OF: Record<string, DoctorSection> = {
|
|
312
|
+
// runtime
|
|
313
|
+
Electron: "runtime",
|
|
314
|
+
"System Node.js": "runtime",
|
|
315
|
+
"Bundled Node.js": "runtime",
|
|
316
|
+
"Bundled npm": "runtime",
|
|
317
|
+
"Managed Node runtime": "runtime",
|
|
318
|
+
// pi-tooling
|
|
319
|
+
"pi CLI": "pi-tooling",
|
|
320
|
+
"openspec CLI": "pi-tooling",
|
|
321
|
+
// server
|
|
322
|
+
"Dashboard server code": "server",
|
|
323
|
+
"Offline packages bundle": "server",
|
|
324
|
+
"TypeScript loader (tsx)": "server",
|
|
325
|
+
"Dashboard server": "server",
|
|
326
|
+
"Server starter": "server",
|
|
327
|
+
"Installable list": "server",
|
|
328
|
+
"Server log (~/.pi-dashboard/server.log)": "server",
|
|
329
|
+
"Server launch test": "server",
|
|
330
|
+
// setup
|
|
331
|
+
"Setup wizard": "setup",
|
|
332
|
+
"API key": "setup",
|
|
333
|
+
// diagnostics
|
|
334
|
+
"Managed install (~/.pi-dashboard)": "diagnostics",
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Suggestion factories. Returns a remediation string tailored to the
|
|
339
|
+
* status / failure kind, or `undefined` for ok rows.
|
|
340
|
+
*
|
|
341
|
+
* Strings use only the small Markdown subset `**bold**`,
|
|
342
|
+
* single-backtick `code`, `[text](url)`. Lint-enforced in
|
|
343
|
+
* `doctor-core.test.ts`.
|
|
344
|
+
*/
|
|
345
|
+
export type SuggestionFn = (
|
|
346
|
+
status: DoctorStatus,
|
|
347
|
+
detail?: string,
|
|
348
|
+
kind?: ExecFailureKind,
|
|
349
|
+
) => string | undefined;
|
|
350
|
+
|
|
351
|
+
const reinstallPi = "Reinstall **PI Dashboard** or run the setup wizard from the App menu (Help → Setup).";
|
|
352
|
+
|
|
353
|
+
function execKindSuggestion(label: string, kind?: ExecFailureKind, timeoutSec = 5): string {
|
|
354
|
+
switch (kind) {
|
|
355
|
+
case "not-found":
|
|
356
|
+
return `${label} binary missing. Reinstall **PI Dashboard** or check your PATH.`;
|
|
357
|
+
case "permission-denied":
|
|
358
|
+
return `${label} binary not executable. On Linux run `+"`chmod +x <path>`"+`; on macOS run `+"`xattr -cr <Resources>`"+` to clear quarantine.`;
|
|
359
|
+
case "timeout":
|
|
360
|
+
return `${label} did not respond within ${timeoutSec}s. Antivirus or endpoint security is likely scanning the binary on first launch — wait 30s and re-run, or whitelist the app.`;
|
|
361
|
+
case "non-zero-exit":
|
|
362
|
+
return `${label} executed but reported failure. ${reinstallPi}`;
|
|
363
|
+
default:
|
|
364
|
+
return `${label} failed for an unknown reason. ${reinstallPi}`;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export const SUGGESTIONS: Record<string, SuggestionFn> = {
|
|
369
|
+
Electron: () => undefined, // never fails today
|
|
370
|
+
"System Node.js": (status) =>
|
|
371
|
+
status === "ok"
|
|
372
|
+
? undefined
|
|
373
|
+
: "System Node.js not on PATH. The bundled runtime will be used; this is fine for most users. To install, see [nodejs.org](https://nodejs.org).",
|
|
374
|
+
"Bundled Node.js": (status, _d, kind) =>
|
|
375
|
+
status === "ok" ? undefined : execKindSuggestion("Bundled Node", kind, 15),
|
|
376
|
+
"Bundled npm": (status, _d, kind) =>
|
|
377
|
+
status === "ok" ? undefined : execKindSuggestion("Bundled npm", kind, 5),
|
|
378
|
+
"Managed Node runtime": (status) =>
|
|
379
|
+
status === "ok"
|
|
380
|
+
? undefined
|
|
381
|
+
: "Managed Node runtime missing under `~/.pi-dashboard/node`. Re-run the setup wizard (Help → Setup).",
|
|
382
|
+
"pi CLI": (status, _d, kind) =>
|
|
383
|
+
status === "ok"
|
|
384
|
+
? undefined
|
|
385
|
+
: kind
|
|
386
|
+
? execKindSuggestion("pi CLI", kind, 5)
|
|
387
|
+
: "`pi` not found. Run the setup wizard (Help → Setup) to install it under `~/.pi-dashboard`.",
|
|
388
|
+
"openspec CLI": (status, _d, kind) =>
|
|
389
|
+
status === "ok"
|
|
390
|
+
? undefined
|
|
391
|
+
: kind
|
|
392
|
+
? execKindSuggestion("openspec CLI", kind, 5)
|
|
393
|
+
: "`openspec` not found. Optional, but required for OpenSpec workflows. Run the setup wizard.",
|
|
394
|
+
"Dashboard server code": (status) =>
|
|
395
|
+
status === "ok"
|
|
396
|
+
? undefined
|
|
397
|
+
: "Dashboard server code not found in app resources. Reinstall **PI Dashboard**.",
|
|
398
|
+
"Offline packages bundle": (status) =>
|
|
399
|
+
status === "ok"
|
|
400
|
+
? undefined
|
|
401
|
+
: "Offline packages bundle absent. First-run install will require network access to `registry.npmjs.org`.",
|
|
402
|
+
"TypeScript loader (tsx)": (status) =>
|
|
403
|
+
status === "ok"
|
|
404
|
+
? undefined
|
|
405
|
+
: "`tsx` not found. Required to run the dashboard server. Run the setup wizard (Help → Setup).",
|
|
406
|
+
"Dashboard server": (status) =>
|
|
407
|
+
status === "ok"
|
|
408
|
+
? undefined
|
|
409
|
+
: "Dashboard server not running on `http://localhost:8000`. It will be started automatically when needed.",
|
|
410
|
+
"Server starter": (status) =>
|
|
411
|
+
status === "ok"
|
|
412
|
+
? undefined
|
|
413
|
+
: "Server starter unknown — older server build. Restart the server.",
|
|
414
|
+
"Installable list": (status) =>
|
|
415
|
+
status === "ok"
|
|
416
|
+
? undefined
|
|
417
|
+
: "Some installable packages failed to install. Check `~/.pi-dashboard/server.log` for details.",
|
|
418
|
+
"Server log (~/.pi-dashboard/server.log)": (status) =>
|
|
419
|
+
status === "ok"
|
|
420
|
+
? undefined
|
|
421
|
+
: "Recent server log entries shown — the server may have failed to start. Open the log for full context.",
|
|
422
|
+
"Server launch test": (status, _d, kind) =>
|
|
423
|
+
status === "ok"
|
|
424
|
+
? undefined
|
|
425
|
+
: kind
|
|
426
|
+
? execKindSuggestion("Server launch test", kind, 15)
|
|
427
|
+
: "Server failed to start during the doctor's test launch. Check `detail` for the captured stderr.",
|
|
428
|
+
"Setup wizard": (status) =>
|
|
429
|
+
status === "ok"
|
|
430
|
+
? undefined
|
|
431
|
+
: "Setup wizard has not completed. Open **Help → Setup** in the app menu.",
|
|
432
|
+
"API key": (status) =>
|
|
433
|
+
status === "ok"
|
|
434
|
+
? undefined
|
|
435
|
+
: "No API key configured. Pi sessions need an LLM provider key. Configure one in **Settings → Providers**.",
|
|
436
|
+
"Managed install (~/.pi-dashboard)": (status) =>
|
|
437
|
+
status === "ok"
|
|
438
|
+
? undefined
|
|
439
|
+
: "Managed install incomplete. Run the setup wizard (**Help → Setup**) to finish first-run install.",
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// ─── runSharedChecks ──────────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
export interface SharedChecksDeps {
|
|
445
|
+
managedDir: string;
|
|
446
|
+
/** Detector for system Node ({path, found}). */
|
|
447
|
+
detectSystemNode: () => { found: boolean; path?: string };
|
|
448
|
+
/** Detector for pi CLI ({path, source, found}). */
|
|
449
|
+
detectPi: () => { found: boolean; path?: string; source?: string };
|
|
450
|
+
/** Detector for openspec CLI. */
|
|
451
|
+
detectOpenSpec: () => { found: boolean; path?: string; source?: string };
|
|
452
|
+
/** Optional: localhost server probe. Default uses curl-style fetch. */
|
|
453
|
+
probeServer?: () => Promise<{
|
|
454
|
+
running: boolean;
|
|
455
|
+
version?: string;
|
|
456
|
+
mode?: string;
|
|
457
|
+
starter?: string | null;
|
|
458
|
+
installable?: { total: number; installed: number; failed: string[] } | null;
|
|
459
|
+
}>;
|
|
460
|
+
/** Optional: api-key check. */
|
|
461
|
+
isApiKeyConfigured?: () => boolean;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export async function runSharedChecks(deps: SharedChecksDeps): Promise<DoctorCheck[]> {
|
|
465
|
+
const checks: DoctorCheck[] = [];
|
|
466
|
+
const managedDir = deps.managedDir;
|
|
467
|
+
|
|
468
|
+
// System Node
|
|
469
|
+
checks.push(
|
|
470
|
+
await safeCheck("System Node.js", "runtime", () => {
|
|
471
|
+
const sys = deps.detectSystemNode();
|
|
472
|
+
if (!sys.found) {
|
|
473
|
+
return {
|
|
474
|
+
name: "System Node.js",
|
|
475
|
+
section: "runtime",
|
|
476
|
+
status: "warning",
|
|
477
|
+
message: "Not found on PATH (bundled Node will be used)",
|
|
478
|
+
detail: "PATH searched without success",
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
const ver = safeExec(`"${sys.path}" --version`, { timeoutMs: 5000 });
|
|
482
|
+
if (!ver.ok) {
|
|
483
|
+
return {
|
|
484
|
+
name: "System Node.js",
|
|
485
|
+
section: "runtime",
|
|
486
|
+
status: "warning",
|
|
487
|
+
message: ver.message,
|
|
488
|
+
detail: `${ver.detail}${ver.stderrTail ? `\nstderr: ${ver.stderrTail}` : ""}`,
|
|
489
|
+
kind: ver.kind,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
name: "System Node.js",
|
|
494
|
+
section: "runtime",
|
|
495
|
+
status: "ok",
|
|
496
|
+
message: `${ver.stdout.trim()} at ${sys.path}`,
|
|
497
|
+
};
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
// pi CLI
|
|
502
|
+
checks.push(
|
|
503
|
+
await safeCheck("pi CLI", "pi-tooling", () => {
|
|
504
|
+
const pi = deps.detectPi();
|
|
505
|
+
if (!pi.found || !pi.path) {
|
|
506
|
+
return {
|
|
507
|
+
name: "pi CLI",
|
|
508
|
+
section: "pi-tooling",
|
|
509
|
+
status: "error",
|
|
510
|
+
message: "Not found — required to run agent sessions",
|
|
511
|
+
detail: "Searched system PATH and managed install",
|
|
512
|
+
fixable: true,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
const ver = safeExec(`"${pi.path}" --version`, { timeoutMs: 5000 });
|
|
516
|
+
const versionDisplay = ver.ok ? ver.stdout.trim() : "?";
|
|
517
|
+
return {
|
|
518
|
+
name: "pi CLI",
|
|
519
|
+
section: "pi-tooling",
|
|
520
|
+
status: "ok",
|
|
521
|
+
message: `${versionDisplay} (${pi.source ?? "unknown"}) at ${pi.path}`,
|
|
522
|
+
};
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
// openspec CLI
|
|
527
|
+
checks.push(
|
|
528
|
+
await safeCheck("openspec CLI", "pi-tooling", () => {
|
|
529
|
+
const os = deps.detectOpenSpec();
|
|
530
|
+
if (!os.found || !os.path) {
|
|
531
|
+
return {
|
|
532
|
+
name: "openspec CLI",
|
|
533
|
+
section: "pi-tooling",
|
|
534
|
+
status: "warning",
|
|
535
|
+
message: "Not found — optional, needed for OpenSpec workflows",
|
|
536
|
+
detail: "Searched system PATH and managed install",
|
|
537
|
+
fixable: true,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
const ver = safeExec(`"${os.path}" --version`, { timeoutMs: 5000 });
|
|
541
|
+
const versionDisplay = ver.ok ? ver.stdout.trim() : "?";
|
|
542
|
+
return {
|
|
543
|
+
name: "openspec CLI",
|
|
544
|
+
section: "pi-tooling",
|
|
545
|
+
status: "ok",
|
|
546
|
+
message: `${versionDisplay} (${os.source ?? "unknown"}) at ${os.path}`,
|
|
547
|
+
};
|
|
548
|
+
}),
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
// tsx (TypeScript loader)
|
|
552
|
+
checks.push(
|
|
553
|
+
await safeCheck("TypeScript loader (tsx)", "server", () => {
|
|
554
|
+
const managedTsxPkg = path.join(managedDir, "node_modules", "tsx", "package.json");
|
|
555
|
+
let tsxVersion: string | null = null;
|
|
556
|
+
try {
|
|
557
|
+
if (existsSync(managedTsxPkg)) {
|
|
558
|
+
const pkg = JSON.parse(readFileSync(managedTsxPkg, "utf-8"));
|
|
559
|
+
tsxVersion = pkg.version || null;
|
|
560
|
+
}
|
|
561
|
+
} catch {
|
|
562
|
+
// ignore
|
|
563
|
+
}
|
|
564
|
+
let systemTsx: string | null = null;
|
|
565
|
+
const lookupCmd = process.platform === "win32" ? "where tsx" : "which tsx"; // platform-branch-ok: localised PATH-lookup primitive
|
|
566
|
+
const lookup = safeExec(lookupCmd, { timeoutMs: 5000 });
|
|
567
|
+
if (lookup.ok) {
|
|
568
|
+
systemTsx = lookup.stdout.trim().split("\n")[0] || null;
|
|
569
|
+
}
|
|
570
|
+
const found = !!tsxVersion || !!systemTsx;
|
|
571
|
+
if (!found) {
|
|
572
|
+
return {
|
|
573
|
+
name: "TypeScript loader (tsx)",
|
|
574
|
+
section: "server",
|
|
575
|
+
status: "error",
|
|
576
|
+
message: "Not found — required to run the dashboard server",
|
|
577
|
+
detail: `Looked under ${managedTsxPkg} and on PATH`,
|
|
578
|
+
fixable: true,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
return {
|
|
582
|
+
name: "TypeScript loader (tsx)",
|
|
583
|
+
section: "server",
|
|
584
|
+
status: "ok",
|
|
585
|
+
message: tsxVersion
|
|
586
|
+
? `v${tsxVersion} (managed) at ${path.dirname(managedTsxPkg)}`
|
|
587
|
+
: `(system) at ${systemTsx}`,
|
|
588
|
+
};
|
|
589
|
+
}),
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
// Dashboard server probe
|
|
593
|
+
checks.push(
|
|
594
|
+
await safeCheck("Dashboard server", "server", async () => {
|
|
595
|
+
if (!deps.probeServer) {
|
|
596
|
+
return {
|
|
597
|
+
name: "Dashboard server",
|
|
598
|
+
section: "server",
|
|
599
|
+
status: "warning",
|
|
600
|
+
message: "Not probed (no probe configured)",
|
|
601
|
+
detail: "deps.probeServer was not provided",
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
const r = await deps.probeServer();
|
|
605
|
+
if (!r.running) {
|
|
606
|
+
return {
|
|
607
|
+
name: "Dashboard server",
|
|
608
|
+
section: "server",
|
|
609
|
+
status: "warning",
|
|
610
|
+
message: "Not running — will be started automatically when needed",
|
|
611
|
+
detail: "GET http://localhost:8000/api/health returned no response",
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
name: "Dashboard server",
|
|
616
|
+
section: "server",
|
|
617
|
+
status: "ok",
|
|
618
|
+
message: `Running${r.version ? " v" + r.version : ""}${r.mode ? " (" + r.mode + " mode)" : ""} at http://localhost:8000`,
|
|
619
|
+
};
|
|
620
|
+
}),
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
// Server log presence (filesystem read — assumedMandatory)
|
|
624
|
+
{
|
|
625
|
+
const logPath = path.join(managedDir, "server.log");
|
|
626
|
+
const result = assumedMandatory(
|
|
627
|
+
"read server.log tail",
|
|
628
|
+
() => {
|
|
629
|
+
if (!existsSync(logPath)) return null;
|
|
630
|
+
const content = readFileSync(logPath, "utf-8");
|
|
631
|
+
return content.split("\n").slice(-10).join("\n").trim();
|
|
632
|
+
},
|
|
633
|
+
{ managedDir },
|
|
634
|
+
);
|
|
635
|
+
if (!result.ok) {
|
|
636
|
+
checks.push(result.row);
|
|
637
|
+
} else if (result.value) {
|
|
638
|
+
checks.push({
|
|
639
|
+
name: "Server log (~/.pi-dashboard/server.log)",
|
|
640
|
+
section: "server",
|
|
641
|
+
status: "warning",
|
|
642
|
+
message: "Last entries:",
|
|
643
|
+
detail: result.value,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// API key
|
|
649
|
+
if (deps.isApiKeyConfigured) {
|
|
650
|
+
checks.push(
|
|
651
|
+
await safeCheck("API key", "setup", () => {
|
|
652
|
+
const has = deps.isApiKeyConfigured!();
|
|
653
|
+
return {
|
|
654
|
+
name: "API key",
|
|
655
|
+
section: "setup",
|
|
656
|
+
status: has ? "ok" : "warning",
|
|
657
|
+
message: has
|
|
658
|
+
? "Configured in pi settings"
|
|
659
|
+
: "Not configured — pi sessions will need a key to use LLM providers",
|
|
660
|
+
detail: has
|
|
661
|
+
? undefined
|
|
662
|
+
: `Looked at ~/.pi/agent/settings.json (anthropicApiKey / openaiApiKey / providers[].apiKey)`,
|
|
663
|
+
};
|
|
664
|
+
}),
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Managed install
|
|
669
|
+
checks.push(
|
|
670
|
+
await safeCheck("Managed install (~/.pi-dashboard)", "diagnostics", () => {
|
|
671
|
+
const managedExists = existsSync(managedDir);
|
|
672
|
+
const managedModules = existsSync(path.join(managedDir, "node_modules"));
|
|
673
|
+
const okState = managedExists && managedModules;
|
|
674
|
+
return {
|
|
675
|
+
name: "Managed install (~/.pi-dashboard)",
|
|
676
|
+
section: "diagnostics",
|
|
677
|
+
status: okState ? "ok" : "warning",
|
|
678
|
+
message: managedExists
|
|
679
|
+
? managedModules
|
|
680
|
+
? `Exists with node_modules at ${managedDir}`
|
|
681
|
+
: "Exists but no node_modules — may need reinstall"
|
|
682
|
+
: "Not created yet — will be set up on first run",
|
|
683
|
+
detail: okState ? undefined : `Path: ${managedDir}`,
|
|
684
|
+
};
|
|
685
|
+
}),
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
return checks;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ─── Stamping helper ──────────────────────────────────────────────────
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Single post-pass. Stamps `section` (using SECTION_OF when not already set)
|
|
695
|
+
* and `suggestion` (when status is non-ok). Mutates in place AND returns.
|
|
696
|
+
*/
|
|
697
|
+
export function stampSectionsAndSuggestions(checks: DoctorCheck[]): DoctorCheck[] {
|
|
698
|
+
for (const c of checks) {
|
|
699
|
+
if (!c.section) {
|
|
700
|
+
const inferred = SECTION_OF[c.name];
|
|
701
|
+
if (inferred) c.section = inferred;
|
|
702
|
+
else c.section = "diagnostics";
|
|
703
|
+
}
|
|
704
|
+
if (c.status !== "ok" && !c.suggestion) {
|
|
705
|
+
const fn = SUGGESTIONS[c.name];
|
|
706
|
+
const s = fn?.(c.status, c.detail, c.kind);
|
|
707
|
+
if (s) c.suggestion = s;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return checks;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ─── Markdown formatter ───────────────────────────────────────────────
|
|
714
|
+
|
|
715
|
+
const SECTION_ORDER: DoctorSection[] = [
|
|
716
|
+
"runtime",
|
|
717
|
+
"pi-tooling",
|
|
718
|
+
"server",
|
|
719
|
+
"setup",
|
|
720
|
+
"diagnostics",
|
|
721
|
+
];
|
|
722
|
+
const SECTION_LABEL: Record<DoctorSection, string> = {
|
|
723
|
+
runtime: "Runtime",
|
|
724
|
+
"pi-tooling": "PI Tooling",
|
|
725
|
+
server: "Server",
|
|
726
|
+
setup: "Setup",
|
|
727
|
+
diagnostics: "Diagnostics",
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
/** Escape pipe / newline / backtick so cell content cannot break the table. */
|
|
731
|
+
function fenceCell(text: string | undefined): string {
|
|
732
|
+
if (!text) return "";
|
|
733
|
+
// Wrap in fenced text inline. Markdown table cells don't honour real fences,
|
|
734
|
+
// but we wrap with backticks-as-code and replace bar / newline / backtick
|
|
735
|
+
// with safe substitutes so the column count stays intact.
|
|
736
|
+
const safe = text
|
|
737
|
+
.replace(/\\/g, "\\\\")
|
|
738
|
+
.replace(/`/g, "\\`")
|
|
739
|
+
.replace(/\|/g, "\\|")
|
|
740
|
+
.replace(/\r?\n/g, "<br>");
|
|
741
|
+
return "<code>" + safe + "</code>";
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function statusIcon(s: DoctorStatus): string {
|
|
745
|
+
return s === "ok" ? "✅" : s === "warning" ? "⚠️" : "❌";
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export function formatDoctorReportMarkdown(report: DoctorReport): string {
|
|
749
|
+
const lines: string[] = [];
|
|
750
|
+
const { ok, warnings, errors } = report.summary;
|
|
751
|
+
lines.push(`# PI Dashboard Doctor`);
|
|
752
|
+
lines.push("");
|
|
753
|
+
lines.push(`**Summary:** ${ok} ok · ${warnings} warning(s) · ${errors} error(s)`);
|
|
754
|
+
lines.push("");
|
|
755
|
+
|
|
756
|
+
for (const section of SECTION_ORDER) {
|
|
757
|
+
const rows = report.checks.filter((c) => c.section === section);
|
|
758
|
+
if (rows.length === 0) continue;
|
|
759
|
+
lines.push(`## ${SECTION_LABEL[section]}`);
|
|
760
|
+
lines.push("");
|
|
761
|
+
lines.push("| Status | Check | Message | Detail |");
|
|
762
|
+
lines.push("| --- | --- | --- | --- |");
|
|
763
|
+
for (const c of rows) {
|
|
764
|
+
const detailCell = c.detail ? fenceCell(c.detail) : "";
|
|
765
|
+
const messageCell = c.message.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
766
|
+
lines.push(`| ${statusIcon(c.status)} | ${c.name} | ${messageCell} | ${detailCell} |`);
|
|
767
|
+
}
|
|
768
|
+
lines.push("");
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const nonOk = report.checks.filter((c) => c.status !== "ok" && c.suggestion);
|
|
772
|
+
if (nonOk.length > 0) {
|
|
773
|
+
lines.push(`## Remediation`);
|
|
774
|
+
lines.push("");
|
|
775
|
+
for (const c of nonOk) {
|
|
776
|
+
lines.push(`- **${c.name}** — ${c.suggestion}`);
|
|
777
|
+
}
|
|
778
|
+
lines.push("");
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
return lines.join("\n");
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// ─── Plain-text formatter ─────────────────────────────────────────────
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Plain-text formatter, byte-compatible with the legacy
|
|
788
|
+
* `formatDoctorReport` in `packages/electron/src/lib/doctor.ts`.
|
|
789
|
+
* Re-exported from there so callers see no change.
|
|
790
|
+
*/
|
|
791
|
+
export function formatDoctorReportPlain(report: DoctorReport): string {
|
|
792
|
+
const lines: string[] = [];
|
|
793
|
+
lines.push("PI Dashboard Doctor");
|
|
794
|
+
lines.push("═".repeat(50));
|
|
795
|
+
lines.push("");
|
|
796
|
+
|
|
797
|
+
for (const check of report.checks) {
|
|
798
|
+
const icon = check.status === "ok" ? "✓" : check.status === "warning" ? "⚠" : "✗";
|
|
799
|
+
const fixHint = check.fixable ? " [fixable]" : "";
|
|
800
|
+
lines.push(` ${icon} ${check.name}${fixHint}`);
|
|
801
|
+
lines.push(` ${check.message}`);
|
|
802
|
+
if (check.detail) lines.push(` ${check.detail}`);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
lines.push("");
|
|
806
|
+
lines.push("─".repeat(50));
|
|
807
|
+
lines.push(
|
|
808
|
+
` ${report.summary.ok} passed, ${report.summary.warnings} warnings, ${report.summary.errors} errors`,
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
if (report.summary.errors > 0) {
|
|
812
|
+
const fixable = report.checks.filter((c) => c.status === "error" && c.fixable);
|
|
813
|
+
if (fixable.length > 0) {
|
|
814
|
+
lines.push("");
|
|
815
|
+
lines.push(` ${fixable.length} error(s) can be fixed automatically.`);
|
|
816
|
+
lines.push(" Run setup wizard to install missing components.");
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return lines.join("\n");
|
|
821
|
+
}
|