@pellux/goodvibes-tui 0.19.62 → 0.19.63
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/CHANGELOG.md +20 -0
- package/README.md +7 -4
- package/bin/goodvibes +1 -1
- package/bin/goodvibes-daemon +1 -1
- package/package.json +8 -3
- package/scripts/check-bun.sh +20 -0
- package/scripts/postinstall.js +1 -1
- package/src/cli/package-verification.ts +4 -0
- package/src/cli/service-command.ts +5 -1
- package/src/cli/service-posture.ts +170 -6
- package/src/config/goodvibes-home-audit.ts +465 -0
- package/src/input/feed-context-factory.ts +3 -0
- package/src/input/handler-feed-routes.ts +73 -0
- package/src/input/handler-feed.ts +4 -0
- package/src/input/handler-shortcuts.ts +2 -0
- package/src/input/handler.ts +11 -2
- package/src/main.ts +13 -17
- package/src/panels/file-explorer-panel.ts +8 -0
- package/src/panels/scrollable-list-panel.ts +12 -0
- package/src/panels/types.ts +4 -0
- package/src/renderer/help-overlay.ts +1 -1
- package/src/verification/live-verifier.ts +430 -0
- package/src/verification/verification-ledger.ts +242 -0
- package/src/version.ts +1 -1
package/src/main.ts
CHANGED
|
@@ -25,9 +25,7 @@ import { GitStatusProvider } from './renderer/git-status.ts';
|
|
|
25
25
|
import type { GitHeaderInfo } from './renderer/git-status.ts';
|
|
26
26
|
import { createShellLayout } from './renderer/layout-engine.ts';
|
|
27
27
|
import { buildShellFooter, estimateShellFooterHeight } from './renderer/shell-surface.ts';
|
|
28
|
-
import {
|
|
29
|
-
buildConversationViewport,
|
|
30
|
-
} from './renderer/conversation-layout.ts';
|
|
28
|
+
import { buildConversationViewport } from './renderer/conversation-layout.ts';
|
|
31
29
|
import { applyConversationOverlays } from './renderer/conversation-overlays.ts';
|
|
32
30
|
import { buildPanelCompositeData } from './renderer/panel-composite.ts';
|
|
33
31
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
@@ -52,10 +50,7 @@ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
|
52
50
|
import { prepareShellCliRuntime } from './cli/entrypoint.ts';
|
|
53
51
|
import { applyInitialTuiCliState } from './cli/tui-startup.ts';
|
|
54
52
|
import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
|
|
55
|
-
import {
|
|
56
|
-
attachSpokenTurnModelRouting,
|
|
57
|
-
createSpokenTurnInputOptions,
|
|
58
|
-
} from './audio/spoken-turn-model-routing.ts';
|
|
53
|
+
import { attachSpokenTurnModelRouting, createSpokenTurnInputOptions } from './audio/spoken-turn-model-routing.ts';
|
|
59
54
|
import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/terminal-output-guard.ts';
|
|
60
55
|
import { ProjectPlanningCoordinator } from './planning/project-planning-coordinator.ts';
|
|
61
56
|
import { buildCommandArgsHint } from './input/command-args-hint.ts';
|
|
@@ -394,7 +389,6 @@ async function main() {
|
|
|
394
389
|
render();
|
|
395
390
|
});
|
|
396
391
|
|
|
397
|
-
// ── InputHandler — created here so getViewportHeight can reference it ──────
|
|
398
392
|
const input: InputHandler = new InputHandler(
|
|
399
393
|
() => render(),
|
|
400
394
|
selection,
|
|
@@ -449,7 +443,6 @@ async function main() {
|
|
|
449
443
|
},
|
|
450
444
|
);
|
|
451
445
|
|
|
452
|
-
// Wire orchestratorRefs now that InputHandler is created
|
|
453
446
|
orchestratorRefs.getViewportHeight = getViewportHeight;
|
|
454
447
|
orchestratorRefs.scrollToEnd = scrollToEnd;
|
|
455
448
|
|
|
@@ -460,13 +453,9 @@ async function main() {
|
|
|
460
453
|
input.agentDetailModal.setOnRefresh(() => render());
|
|
461
454
|
input.processModal.setOnRefresh(() => render());
|
|
462
455
|
|
|
463
|
-
//
|
|
464
|
-
// Model picker callback is handled in bootstrap.ts — do not duplicate here
|
|
465
|
-
|
|
466
|
-
// inputHistory comes from bootstrap, already set up — wire it to the input handler
|
|
456
|
+
// Model picker callback is handled in bootstrap.ts — do not duplicate here.
|
|
467
457
|
input.setHistory(inputHistory);
|
|
468
458
|
|
|
469
|
-
// --- Splash options ---
|
|
470
459
|
const toolCount = toolRegistry.list().length;
|
|
471
460
|
conversation.splashOptions = {
|
|
472
461
|
workingDir,
|
|
@@ -475,8 +464,6 @@ async function main() {
|
|
|
475
464
|
toolCount,
|
|
476
465
|
};
|
|
477
466
|
|
|
478
|
-
|
|
479
|
-
// --- Render function ---
|
|
480
467
|
const render = () => {
|
|
481
468
|
const width = stdout.columns || 80;
|
|
482
469
|
const height = stdout.rows || 24;
|
|
@@ -486,7 +473,6 @@ async function main() {
|
|
|
486
473
|
const sessionSnapshot = uiServices.readModels.session.getSnapshot();
|
|
487
474
|
const agentSnapshot = uiServices.readModels.agents.getSnapshot();
|
|
488
475
|
|
|
489
|
-
// Build header and footer FIRST so we know the exact viewport height
|
|
490
476
|
const headerLines = UIFactory.createHeader(width, currentModel.id, currentModel.provider, conversation.title || undefined, lastGitInfoRef.value);
|
|
491
477
|
const managerAgents = agentManager.list().filter(
|
|
492
478
|
(a) => a.status === 'running' || a.status === 'pending',
|
|
@@ -569,6 +555,16 @@ async function main() {
|
|
|
569
555
|
footerHeight: shellFooterLines.length,
|
|
570
556
|
panelWidth,
|
|
571
557
|
});
|
|
558
|
+
input.setPanelMouseLayout(shellLayout.panel
|
|
559
|
+
? {
|
|
560
|
+
x: shellLayout.panel.x,
|
|
561
|
+
y: shellLayout.panel.y,
|
|
562
|
+
width: shellLayout.panel.width,
|
|
563
|
+
height: shellLayout.panel.height,
|
|
564
|
+
hasBottomPane: panelManager.isBottomPaneVisible() && panelManager.getBottomPane().panels.length > 0,
|
|
565
|
+
verticalSplitRatio: panelManager.getVerticalSplitRatio(),
|
|
566
|
+
}
|
|
567
|
+
: null);
|
|
572
568
|
const vHeight = shellLayout.body.height;
|
|
573
569
|
const conversationWidth = shellLayout.conversation.width;
|
|
574
570
|
activeConversationWidth = conversationWidth;
|
|
@@ -196,6 +196,14 @@ export class FileExplorerPanel extends BasePanel {
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
handleScroll(deltaRows: number): boolean {
|
|
200
|
+
const rows = Math.trunc(deltaRows);
|
|
201
|
+
if (this.flat.length === 0 || rows === 0) return false;
|
|
202
|
+
const previous = this.cursor;
|
|
203
|
+
this._setCursor(this.cursor + rows);
|
|
204
|
+
return this.cursor !== previous;
|
|
205
|
+
}
|
|
206
|
+
|
|
199
207
|
// ── Render ─────────────────────────────────────────────────────────────────
|
|
200
208
|
|
|
201
209
|
render(width: number, height: number): Line[] {
|
|
@@ -200,6 +200,18 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
|
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
handleScroll(deltaRows: number): boolean {
|
|
204
|
+
if (this.lastError !== null) this.clearError();
|
|
205
|
+
const total = this.getItems().length;
|
|
206
|
+
const rows = Math.trunc(deltaRows);
|
|
207
|
+
if (total === 0 || rows === 0) return false;
|
|
208
|
+
const next = Math.max(0, Math.min(total - 1, this.selectedIndex + rows));
|
|
209
|
+
if (next === this.selectedIndex) return false;
|
|
210
|
+
this.selectedIndex = next;
|
|
211
|
+
this.needsRender = true;
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
203
215
|
// -------------------------------------------------------------------------
|
|
204
216
|
// Scroll state helpers
|
|
205
217
|
// -------------------------------------------------------------------------
|
package/src/panels/types.ts
CHANGED
|
@@ -36,6 +36,10 @@ export interface Panel {
|
|
|
36
36
|
|
|
37
37
|
// Input (optional)
|
|
38
38
|
handleInput?(key: string): boolean;
|
|
39
|
+
|
|
40
|
+
// Scroll input (optional)
|
|
41
|
+
// Positive delta scrolls down; negative delta scrolls up.
|
|
42
|
+
handleScroll?(deltaRows: number): boolean;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export interface PanelRegistration extends Pick<Panel, 'id' | 'name' | 'icon' | 'category'> {
|
|
@@ -202,7 +202,7 @@ export function renderShortcutsOverlay(
|
|
|
202
202
|
row('PageUp / PageDn', 'Scroll by full page'),
|
|
203
203
|
row('Home / End', 'Jump to start / end of line'),
|
|
204
204
|
row(kb('search'), 'Search conversation'),
|
|
205
|
-
row('Mouse wheel', 'Scroll conversation'),
|
|
205
|
+
row('Mouse wheel', 'Scroll conversation or hovered panel'),
|
|
206
206
|
'',
|
|
207
207
|
' Editing',
|
|
208
208
|
' ' + '\u2500'.repeat(40),
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { auditGoodVibesHome } from '../config/goodvibes-home-audit.ts';
|
|
5
|
+
import { buildVerificationLedger } from './verification-ledger.ts';
|
|
6
|
+
|
|
7
|
+
export type LiveVerificationStatus = 'pass' | 'warn' | 'fail' | 'skip';
|
|
8
|
+
|
|
9
|
+
export interface LiveVerificationCheck {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
status: LiveVerificationStatus;
|
|
13
|
+
summary: string;
|
|
14
|
+
detail?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LiveVerificationOptions {
|
|
18
|
+
homeDir: string;
|
|
19
|
+
binaryPath: string;
|
|
20
|
+
projectRoot: string;
|
|
21
|
+
daemonBaseUrl?: string;
|
|
22
|
+
token?: string;
|
|
23
|
+
strict?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LiveVerificationReport {
|
|
27
|
+
generatedAt: string;
|
|
28
|
+
homeDir: string;
|
|
29
|
+
binaryPath: string;
|
|
30
|
+
daemonBaseUrl: string;
|
|
31
|
+
strict: boolean;
|
|
32
|
+
checks: LiveVerificationCheck[];
|
|
33
|
+
counts: Record<LiveVerificationStatus, number>;
|
|
34
|
+
ok: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface CommandResult {
|
|
38
|
+
exitCode: number | null;
|
|
39
|
+
stdout: string;
|
|
40
|
+
stderr: string;
|
|
41
|
+
timedOut: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readJsonFile(path: string): unknown {
|
|
45
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function redact(text: string): string {
|
|
49
|
+
return text
|
|
50
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, 'Bearer [redacted]')
|
|
51
|
+
.replace(/"token"\s*:\s*"[^"]+"/g, '"token":"[redacted]"');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function compact(text: string, maxLength = 900): string {
|
|
55
|
+
const trimmed = redact(text.trim());
|
|
56
|
+
if (trimmed.length <= maxLength) return trimmed;
|
|
57
|
+
return `${trimmed.slice(0, maxLength - 16)}... [truncated]`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readDaemonToken(homeDir: string): string | undefined {
|
|
61
|
+
if (process.env.GOODVIBES_DAEMON_TOKEN) return process.env.GOODVIBES_DAEMON_TOKEN;
|
|
62
|
+
const tokenPath = join(homeDir, 'daemon', 'operator-tokens.json');
|
|
63
|
+
if (!existsSync(tokenPath)) return undefined;
|
|
64
|
+
try {
|
|
65
|
+
const data = readJsonFile(tokenPath);
|
|
66
|
+
if (data && typeof data === 'object' && typeof (data as { token?: unknown }).token === 'string') {
|
|
67
|
+
return (data as { token: string }).token;
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resolveDaemonBaseUrl(homeDir: string, explicit?: string): string {
|
|
76
|
+
if (explicit) return explicit.replace(/\/+$/, '');
|
|
77
|
+
if (process.env.GOODVIBES_DAEMON_URL) return process.env.GOODVIBES_DAEMON_URL.replace(/\/+$/, '');
|
|
78
|
+
const settingsPath = join(homeDir, 'tui', 'settings.json');
|
|
79
|
+
let port = 3421;
|
|
80
|
+
if (existsSync(settingsPath)) {
|
|
81
|
+
try {
|
|
82
|
+
const settings = readJsonFile(settingsPath);
|
|
83
|
+
const configuredPort = (settings as { controlPlane?: { port?: unknown } })?.controlPlane?.port;
|
|
84
|
+
if (typeof configuredPort === 'number' && Number.isFinite(configuredPort)) port = configuredPort;
|
|
85
|
+
} catch {
|
|
86
|
+
// Keep the default; this verifier should report daemon state, not fail before checks run.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return `http://127.0.0.1:${port}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function runCommand(command: string, args: string[], cwd: string, timeoutMs = 15_000): Promise<CommandResult> {
|
|
93
|
+
return new Promise((resolveCommand) => {
|
|
94
|
+
const child = spawn(command, args, {
|
|
95
|
+
cwd,
|
|
96
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
97
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
98
|
+
});
|
|
99
|
+
const stdout: Buffer[] = [];
|
|
100
|
+
const stderr: Buffer[] = [];
|
|
101
|
+
let timedOut = false;
|
|
102
|
+
const timeout = setTimeout(() => {
|
|
103
|
+
timedOut = true;
|
|
104
|
+
child.kill('SIGTERM');
|
|
105
|
+
setTimeout(() => child.kill('SIGKILL'), 1000).unref();
|
|
106
|
+
}, timeoutMs);
|
|
107
|
+
child.stdout?.on('data', (chunk) => stdout.push(Buffer.from(chunk)));
|
|
108
|
+
child.stderr?.on('data', (chunk) => stderr.push(Buffer.from(chunk)));
|
|
109
|
+
child.on('error', (error) => {
|
|
110
|
+
clearTimeout(timeout);
|
|
111
|
+
resolveCommand({
|
|
112
|
+
exitCode: -1,
|
|
113
|
+
stdout: '',
|
|
114
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
115
|
+
timedOut,
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
child.on('exit', (exitCode) => {
|
|
119
|
+
clearTimeout(timeout);
|
|
120
|
+
resolveCommand({
|
|
121
|
+
exitCode,
|
|
122
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
123
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
124
|
+
timedOut,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function commandCheck(
|
|
131
|
+
id: string,
|
|
132
|
+
title: string,
|
|
133
|
+
result: CommandResult,
|
|
134
|
+
passSummary: string,
|
|
135
|
+
options?: { warnOnNonZero?: boolean; parseJson?: boolean },
|
|
136
|
+
): LiveVerificationCheck {
|
|
137
|
+
if (result.timedOut) {
|
|
138
|
+
return {
|
|
139
|
+
id,
|
|
140
|
+
title,
|
|
141
|
+
status: options?.warnOnNonZero ? 'warn' : 'fail',
|
|
142
|
+
summary: 'Command timed out.',
|
|
143
|
+
detail: compact(`${result.stdout}\n${result.stderr}`),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (result.exitCode !== 0) {
|
|
147
|
+
return {
|
|
148
|
+
id,
|
|
149
|
+
title,
|
|
150
|
+
status: options?.warnOnNonZero ? 'warn' : 'fail',
|
|
151
|
+
summary: `Command exited ${result.exitCode}.`,
|
|
152
|
+
detail: compact(`${result.stdout}\n${result.stderr}`),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (options?.parseJson) {
|
|
156
|
+
try {
|
|
157
|
+
JSON.parse(result.stdout);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return {
|
|
160
|
+
id,
|
|
161
|
+
title,
|
|
162
|
+
status: 'fail',
|
|
163
|
+
summary: 'Command succeeded but did not return valid JSON.',
|
|
164
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
id,
|
|
170
|
+
title,
|
|
171
|
+
status: 'pass',
|
|
172
|
+
summary: passSummary,
|
|
173
|
+
detail: compact(result.stdout || result.stderr),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function fetchCheck(
|
|
178
|
+
id: string,
|
|
179
|
+
title: string,
|
|
180
|
+
url: string,
|
|
181
|
+
token: string | undefined,
|
|
182
|
+
validate: (status: number, body: string) => { status: LiveVerificationStatus; summary: string; detail?: string },
|
|
183
|
+
): Promise<LiveVerificationCheck> {
|
|
184
|
+
if (!token) {
|
|
185
|
+
return {
|
|
186
|
+
id,
|
|
187
|
+
title,
|
|
188
|
+
status: 'skip',
|
|
189
|
+
summary: 'No daemon bearer token was available.',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const response = await fetch(url, {
|
|
194
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
195
|
+
signal: AbortSignal.timeout(5000),
|
|
196
|
+
});
|
|
197
|
+
const body = await response.text();
|
|
198
|
+
const validated = validate(response.status, body);
|
|
199
|
+
return {
|
|
200
|
+
id,
|
|
201
|
+
title,
|
|
202
|
+
...validated,
|
|
203
|
+
detail: validated.detail ?? compact(body),
|
|
204
|
+
};
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return {
|
|
207
|
+
id,
|
|
208
|
+
title,
|
|
209
|
+
status: 'fail',
|
|
210
|
+
summary: 'Request failed.',
|
|
211
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function countStatuses(checks: readonly LiveVerificationCheck[]): Record<LiveVerificationStatus, number> {
|
|
217
|
+
return checks.reduce<Record<LiveVerificationStatus, number>>(
|
|
218
|
+
(counts, check) => {
|
|
219
|
+
counts[check.status] += 1;
|
|
220
|
+
return counts;
|
|
221
|
+
},
|
|
222
|
+
{ pass: 0, warn: 0, fail: 0, skip: 0 },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function buildLiveVerificationReport(options: LiveVerificationOptions): Promise<LiveVerificationReport> {
|
|
227
|
+
const homeDir = resolve(options.homeDir);
|
|
228
|
+
const projectRoot = resolve(options.projectRoot);
|
|
229
|
+
const binaryPath = resolve(options.binaryPath);
|
|
230
|
+
const daemonBaseUrl = resolveDaemonBaseUrl(homeDir, options.daemonBaseUrl);
|
|
231
|
+
const token = options.token ?? readDaemonToken(homeDir);
|
|
232
|
+
const checks: LiveVerificationCheck[] = [];
|
|
233
|
+
|
|
234
|
+
const ledger = buildVerificationLedger(projectRoot);
|
|
235
|
+
checks.push({
|
|
236
|
+
id: 'verification-ledger',
|
|
237
|
+
title: 'Verification inventory ledger',
|
|
238
|
+
status: ledger.totals.localSignalPercent >= 90 ? 'pass' : 'fail',
|
|
239
|
+
summary: `${ledger.totals.localSignalPercent}% local verification signal across ${ledger.totals.total} inventory items.`,
|
|
240
|
+
detail: `${ledger.totals.localBehaviorPercent}% local behavior verified; ${ledger.totals.externalOutcomeRequired} item(s) require external outcomes.`,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const audit = await auditGoodVibesHome({ homeDir });
|
|
244
|
+
const staleCandidates = audit.settings?.staleCandidates?.length ?? 0;
|
|
245
|
+
checks.push({
|
|
246
|
+
id: 'goodvibes-home-audit',
|
|
247
|
+
title: 'GoodVibes home ownership/settings audit',
|
|
248
|
+
status: audit.findings.length === 0 && staleCandidates === 0 ? 'pass' : 'warn',
|
|
249
|
+
summary: audit.findings.length === 0
|
|
250
|
+
? 'No ownership, stale-setting, or secret-permission findings.'
|
|
251
|
+
: `${audit.findings.length} audit finding(s).`,
|
|
252
|
+
detail: audit.findings.length === 0
|
|
253
|
+
? `${audit.settings?.recognizedKeyCount ?? 0} current schema key(s), ${staleCandidates} stale candidate(s).`
|
|
254
|
+
: audit.findings.map((finding) => `${finding.severity}: ${finding.message}`).join('\n'),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
checks.push({
|
|
258
|
+
id: 'compiled-cli-present',
|
|
259
|
+
title: 'Compiled GoodVibes CLI binary',
|
|
260
|
+
status: existsSync(binaryPath) ? 'pass' : 'fail',
|
|
261
|
+
summary: existsSync(binaryPath) ? `Found ${binaryPath}.` : `Missing ${binaryPath}.`,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (existsSync(binaryPath)) {
|
|
265
|
+
checks.push(commandCheck(
|
|
266
|
+
'cli-version',
|
|
267
|
+
'CLI version command',
|
|
268
|
+
await runCommand(binaryPath, ['version'], projectRoot),
|
|
269
|
+
'CLI version returned successfully.',
|
|
270
|
+
));
|
|
271
|
+
checks.push(commandCheck(
|
|
272
|
+
'cli-status-json',
|
|
273
|
+
'CLI status JSON command',
|
|
274
|
+
await runCommand(binaryPath, ['status', '--output', 'json'], projectRoot),
|
|
275
|
+
'CLI status returned parseable JSON.',
|
|
276
|
+
{ parseJson: true },
|
|
277
|
+
));
|
|
278
|
+
checks.push(commandCheck(
|
|
279
|
+
'cli-providers',
|
|
280
|
+
'CLI providers command',
|
|
281
|
+
await runCommand(binaryPath, ['providers'], projectRoot),
|
|
282
|
+
'Provider inventory rendered successfully.',
|
|
283
|
+
));
|
|
284
|
+
checks.push(commandCheck(
|
|
285
|
+
'cli-control-plane-status',
|
|
286
|
+
'CLI control-plane status command',
|
|
287
|
+
await runCommand(binaryPath, ['control-plane', 'status'], projectRoot),
|
|
288
|
+
'Control-plane status rendered successfully.',
|
|
289
|
+
{ warnOnNonZero: true },
|
|
290
|
+
));
|
|
291
|
+
checks.push(commandCheck(
|
|
292
|
+
'cli-listener-test',
|
|
293
|
+
'CLI listener readiness command',
|
|
294
|
+
await runCommand(binaryPath, ['listener', 'test'], projectRoot),
|
|
295
|
+
'HTTP listener readiness rendered successfully.',
|
|
296
|
+
{ warnOnNonZero: true },
|
|
297
|
+
));
|
|
298
|
+
checks.push(commandCheck(
|
|
299
|
+
'cli-surfaces-check',
|
|
300
|
+
'CLI surfaces readiness command',
|
|
301
|
+
await runCommand(binaryPath, ['surfaces', 'check'], projectRoot),
|
|
302
|
+
'Surface readiness rendered successfully.',
|
|
303
|
+
{ warnOnNonZero: true },
|
|
304
|
+
));
|
|
305
|
+
checks.push(commandCheck(
|
|
306
|
+
'cli-service-check',
|
|
307
|
+
'CLI service posture command',
|
|
308
|
+
await runCommand(binaryPath, ['service', 'check'], projectRoot),
|
|
309
|
+
'Service posture rendered successfully.',
|
|
310
|
+
{ warnOnNonZero: true },
|
|
311
|
+
));
|
|
312
|
+
checks.push(commandCheck(
|
|
313
|
+
'cli-doctor',
|
|
314
|
+
'CLI doctor command',
|
|
315
|
+
await runCommand(binaryPath, ['doctor', '--output', 'text'], projectRoot),
|
|
316
|
+
'Doctor completed without findings.',
|
|
317
|
+
{ warnOnNonZero: true },
|
|
318
|
+
));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
checks.push(await fetchCheck(
|
|
322
|
+
'daemon-status',
|
|
323
|
+
'Authenticated daemon /status',
|
|
324
|
+
`${daemonBaseUrl}/status`,
|
|
325
|
+
token,
|
|
326
|
+
(status, body) => {
|
|
327
|
+
if (status !== 200) return { status: 'fail', summary: `/status returned ${status}.` };
|
|
328
|
+
try {
|
|
329
|
+
const parsed = JSON.parse(body) as { version?: unknown; sdkVersion?: unknown };
|
|
330
|
+
const version = typeof parsed.sdkVersion === 'string'
|
|
331
|
+
? parsed.sdkVersion
|
|
332
|
+
: typeof parsed.version === 'string' ? parsed.version : 'unknown';
|
|
333
|
+
return { status: 'pass', summary: `/status returned 200, version ${version}.` };
|
|
334
|
+
} catch {
|
|
335
|
+
return { status: 'warn', summary: '/status returned 200 but was not parseable JSON.' };
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
));
|
|
339
|
+
|
|
340
|
+
checks.push(await fetchCheck(
|
|
341
|
+
'daemon-health',
|
|
342
|
+
'Authenticated daemon /api/health',
|
|
343
|
+
`${daemonBaseUrl}/api/health`,
|
|
344
|
+
token,
|
|
345
|
+
(status, body) => {
|
|
346
|
+
if (status !== 200) return { status: 'fail', summary: `/api/health returned ${status}.` };
|
|
347
|
+
try {
|
|
348
|
+
const parsed = JSON.parse(body) as { overall?: unknown };
|
|
349
|
+
return {
|
|
350
|
+
status: parsed.overall === 'healthy' ? 'pass' : 'warn',
|
|
351
|
+
summary: `Health overall=${String(parsed.overall ?? 'unknown')}.`,
|
|
352
|
+
};
|
|
353
|
+
} catch {
|
|
354
|
+
return { status: 'warn', summary: '/api/health returned 200 but was not parseable JSON.' };
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
));
|
|
358
|
+
|
|
359
|
+
checks.push(await fetchCheck(
|
|
360
|
+
'openai-compatible-models',
|
|
361
|
+
'OpenAI-compatible /v1/models route',
|
|
362
|
+
`${daemonBaseUrl}/v1/models`,
|
|
363
|
+
token,
|
|
364
|
+
(status, body) => {
|
|
365
|
+
if (status !== 200) return { status: 'fail', summary: `/v1/models returned ${status}.` };
|
|
366
|
+
try {
|
|
367
|
+
const parsed = JSON.parse(body) as { data?: unknown };
|
|
368
|
+
const models = Array.isArray(parsed.data) ? parsed.data.length : 0;
|
|
369
|
+
return {
|
|
370
|
+
status: models > 0 ? 'pass' : 'warn',
|
|
371
|
+
summary: `/v1/models returned ${models} model(s).`,
|
|
372
|
+
};
|
|
373
|
+
} catch {
|
|
374
|
+
return { status: 'warn', summary: '/v1/models returned 200 but was not parseable JSON.' };
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
));
|
|
378
|
+
|
|
379
|
+
const counts = countStatuses(checks);
|
|
380
|
+
const ok = counts.fail === 0 && (!options.strict || counts.warn === 0);
|
|
381
|
+
return {
|
|
382
|
+
generatedAt: new Date().toISOString(),
|
|
383
|
+
homeDir,
|
|
384
|
+
binaryPath,
|
|
385
|
+
daemonBaseUrl,
|
|
386
|
+
strict: options.strict ?? false,
|
|
387
|
+
checks,
|
|
388
|
+
counts,
|
|
389
|
+
ok,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function renderLiveVerificationReportMarkdown(report: LiveVerificationReport): string {
|
|
394
|
+
const lines: string[] = [
|
|
395
|
+
'# GoodVibes Live Verification',
|
|
396
|
+
'',
|
|
397
|
+
`Generated: ${report.generatedAt}`,
|
|
398
|
+
`Home: \`${report.homeDir}\``,
|
|
399
|
+
`Binary: \`${report.binaryPath}\``,
|
|
400
|
+
`Daemon: \`${report.daemonBaseUrl}\``,
|
|
401
|
+
'',
|
|
402
|
+
'| Status | Count |',
|
|
403
|
+
'|---|---:|',
|
|
404
|
+
`| pass | ${report.counts.pass} |`,
|
|
405
|
+
`| warn | ${report.counts.warn} |`,
|
|
406
|
+
`| fail | ${report.counts.fail} |`,
|
|
407
|
+
`| skip | ${report.counts.skip} |`,
|
|
408
|
+
'',
|
|
409
|
+
'| Check | Status | Summary |',
|
|
410
|
+
'|---|---|---|',
|
|
411
|
+
];
|
|
412
|
+
for (const check of report.checks) {
|
|
413
|
+
lines.push(`| ${check.title} | ${check.status} | ${check.summary.replace(/\|/g, '\\|')} |`);
|
|
414
|
+
}
|
|
415
|
+
const detailed = report.checks.filter((check) => check.detail?.trim());
|
|
416
|
+
if (detailed.length > 0) {
|
|
417
|
+
lines.push('', '## Details', '');
|
|
418
|
+
for (const check of detailed) {
|
|
419
|
+
lines.push(`### ${check.title}`, '', '```text', check.detail?.trim() ?? '', '```', '');
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
lines.push(report.ok ? 'Result: PASS' : 'Result: FAIL', '');
|
|
423
|
+
return lines.join('\n');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function writeLiveVerificationReportFiles(report: LiveVerificationReport, outputDir: string): void {
|
|
427
|
+
mkdirSync(outputDir, { recursive: true });
|
|
428
|
+
writeFileSync(join(outputDir, 'live-verification.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
429
|
+
writeFileSync(join(outputDir, 'live-verification.md'), renderLiveVerificationReportMarkdown(report), 'utf8');
|
|
430
|
+
}
|