@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
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { createReadStream } from 'node:fs';
|
|
11
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
12
|
+
import { CONFIG_SCHEMA, DEFAULT_CONFIG } from '@pellux/goodvibes-sdk/platform/config';
|
|
13
|
+
|
|
14
|
+
export type HomeFileOwner = 'tui' | 'daemon' | 'foreign-goodvibes-product' | 'unknown-root';
|
|
15
|
+
|
|
16
|
+
export type SettingsKeyClassification =
|
|
17
|
+
| 'current-schema'
|
|
18
|
+
| 'known-dynamic'
|
|
19
|
+
| 'default-config-dynamic'
|
|
20
|
+
| 'unknown-stale-candidate';
|
|
21
|
+
|
|
22
|
+
export interface HomeAuditOptions {
|
|
23
|
+
readonly homeDir: string;
|
|
24
|
+
readonly includeHashes?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HomeFileSummary {
|
|
28
|
+
readonly owner: HomeFileOwner;
|
|
29
|
+
readonly files: number;
|
|
30
|
+
readonly directories: number;
|
|
31
|
+
readonly bytes: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface HomeFileRecord {
|
|
35
|
+
readonly relativePath: string;
|
|
36
|
+
readonly owner: HomeFileOwner;
|
|
37
|
+
readonly bytes: number;
|
|
38
|
+
readonly mode: string;
|
|
39
|
+
readonly mtimeIso: string;
|
|
40
|
+
readonly sha256?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SettingsKeyAudit {
|
|
44
|
+
readonly key: string;
|
|
45
|
+
readonly classification: SettingsKeyClassification;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SettingsAudit {
|
|
49
|
+
readonly path: string;
|
|
50
|
+
readonly exists: boolean;
|
|
51
|
+
readonly schemaKeyCount: number;
|
|
52
|
+
readonly leafKeyCount: number;
|
|
53
|
+
readonly recognizedKeyCount: number;
|
|
54
|
+
readonly missingSchemaKeys: readonly string[];
|
|
55
|
+
readonly keys: readonly SettingsKeyAudit[];
|
|
56
|
+
readonly staleCandidates: readonly string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface HomeAuditFinding {
|
|
60
|
+
readonly severity: 'info' | 'warn' | 'error';
|
|
61
|
+
readonly code: string;
|
|
62
|
+
readonly path?: string;
|
|
63
|
+
readonly message: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DuplicateProfilePattern {
|
|
67
|
+
readonly normalizedName: string;
|
|
68
|
+
readonly count: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface GoodVibesHomeAudit {
|
|
72
|
+
readonly homeDir: string;
|
|
73
|
+
readonly generatedAt: string;
|
|
74
|
+
readonly summaries: readonly HomeFileSummary[];
|
|
75
|
+
readonly files: readonly HomeFileRecord[];
|
|
76
|
+
readonly settings: SettingsAudit;
|
|
77
|
+
readonly duplicateProfilePatterns: readonly DuplicateProfilePattern[];
|
|
78
|
+
readonly findings: readonly HomeAuditFinding[];
|
|
79
|
+
readonly allowedWriteRoots: readonly string[];
|
|
80
|
+
readonly readOnlyRoots: readonly string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface HomeSnapshotEntry {
|
|
84
|
+
readonly relativePath: string;
|
|
85
|
+
readonly bytes: number;
|
|
86
|
+
readonly mode: string;
|
|
87
|
+
readonly sha256: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type HomeSnapshot = Readonly<Record<string, HomeSnapshotEntry>>;
|
|
91
|
+
|
|
92
|
+
export interface HomeSnapshotDiff {
|
|
93
|
+
readonly added: readonly string[];
|
|
94
|
+
readonly removed: readonly string[];
|
|
95
|
+
readonly changed: readonly string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const KNOWN_FOREIGN_ROOTS = new Set([
|
|
99
|
+
'.backups',
|
|
100
|
+
'.exec-output',
|
|
101
|
+
'archive',
|
|
102
|
+
'companion-chat',
|
|
103
|
+
'events',
|
|
104
|
+
'full-suite',
|
|
105
|
+
'hooks',
|
|
106
|
+
'logs',
|
|
107
|
+
'memory',
|
|
108
|
+
'providers',
|
|
109
|
+
'scripts',
|
|
110
|
+
'sdk',
|
|
111
|
+
'skills',
|
|
112
|
+
'state',
|
|
113
|
+
'telemetry',
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const KNOWN_DYNAMIC_KEYS = [
|
|
117
|
+
/^featureFlags(?:\.|$)/,
|
|
118
|
+
/^notifications\.webhookUrls$/,
|
|
119
|
+
/^wrfc\.gates$/,
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
export const GOODVIBES_ALLOWED_WRITE_ROOTS = ['tui/', 'daemon/'] as const;
|
|
123
|
+
export const GOODVIBES_READ_ONLY_ROOTS = [
|
|
124
|
+
'*.api.json',
|
|
125
|
+
'archive/',
|
|
126
|
+
'sdk/',
|
|
127
|
+
'state/',
|
|
128
|
+
'events/',
|
|
129
|
+
'companion-chat/',
|
|
130
|
+
'full-suite/',
|
|
131
|
+
] as const;
|
|
132
|
+
|
|
133
|
+
function toPosixRelative(path: string): string {
|
|
134
|
+
return path.split(sep).join('/');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function flattenObject(value: unknown, prefix = '', out: Record<string, unknown> = {}): Record<string, unknown> {
|
|
138
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
139
|
+
if (prefix) out[prefix] = value;
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
144
|
+
const nextPrefix = prefix ? `${prefix}.${key}` : key;
|
|
145
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
146
|
+
flattenObject(entry, nextPrefix, out);
|
|
147
|
+
} else {
|
|
148
|
+
out[nextPrefix] = entry;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function classifyFile(relativePath: string): HomeFileOwner {
|
|
155
|
+
if (relativePath.startsWith('tui/')) return 'tui';
|
|
156
|
+
if (relativePath.startsWith('daemon/')) return 'daemon';
|
|
157
|
+
if (!relativePath.includes('/')) return 'foreign-goodvibes-product';
|
|
158
|
+
if (basename(relativePath).endsWith('.api.json')) return 'foreign-goodvibes-product';
|
|
159
|
+
const firstSegment = relativePath.split('/')[0] ?? '';
|
|
160
|
+
if (KNOWN_FOREIGN_ROOTS.has(firstSegment)) return 'foreign-goodvibes-product';
|
|
161
|
+
return 'unknown-root';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isKnownDynamicKey(key: string): boolean {
|
|
165
|
+
return KNOWN_DYNAMIC_KEYS.some((pattern) => pattern.test(key));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function classifySettingsKey(
|
|
169
|
+
key: string,
|
|
170
|
+
schemaKeys: ReadonlySet<string>,
|
|
171
|
+
defaultKeys: ReadonlySet<string>,
|
|
172
|
+
): SettingsKeyClassification {
|
|
173
|
+
if (schemaKeys.has(key)) return 'current-schema';
|
|
174
|
+
if (isKnownDynamicKey(key)) return 'known-dynamic';
|
|
175
|
+
if (defaultKeys.has(key)) return 'default-config-dynamic';
|
|
176
|
+
return 'unknown-stale-candidate';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function walkFiles(root: string): { files: string[]; directoryCountByOwner: Map<HomeFileOwner, number> } {
|
|
180
|
+
const files: string[] = [];
|
|
181
|
+
const directoryCountByOwner = new Map<HomeFileOwner, number>();
|
|
182
|
+
if (!existsSync(root)) return { files, directoryCountByOwner };
|
|
183
|
+
|
|
184
|
+
const visit = (dir: string): void => {
|
|
185
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
186
|
+
const absolute = join(dir, entry.name);
|
|
187
|
+
const rel = toPosixRelative(relative(root, absolute));
|
|
188
|
+
if (entry.isDirectory()) {
|
|
189
|
+
const owner = classifyFile(`${rel}/`);
|
|
190
|
+
directoryCountByOwner.set(owner, (directoryCountByOwner.get(owner) ?? 0) + 1);
|
|
191
|
+
visit(absolute);
|
|
192
|
+
} else if (entry.isFile()) {
|
|
193
|
+
files.push(absolute);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
visit(root);
|
|
199
|
+
files.sort((a, b) => a.localeCompare(b));
|
|
200
|
+
return { files, directoryCountByOwner };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function hashFile(path: string): Promise<string> {
|
|
204
|
+
const hash = createHash('sha256');
|
|
205
|
+
await new Promise<void>((resolvePromise, reject) => {
|
|
206
|
+
const stream = createReadStream(path);
|
|
207
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
208
|
+
stream.on('error', reject);
|
|
209
|
+
stream.on('end', resolvePromise);
|
|
210
|
+
});
|
|
211
|
+
return hash.digest('hex');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function readJsonObject(path: string): Record<string, unknown> | null {
|
|
215
|
+
if (!existsSync(path)) return null;
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
218
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
219
|
+
? parsed as Record<string, unknown>
|
|
220
|
+
: null;
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function auditSettings(homeDir: string): SettingsAudit {
|
|
227
|
+
const path = join(homeDir, 'tui', 'settings.json');
|
|
228
|
+
const settings = readJsonObject(path);
|
|
229
|
+
const flatSettings = flattenObject(settings ?? {});
|
|
230
|
+
const schemaKeys = new Set(CONFIG_SCHEMA.map((entry) => entry.key));
|
|
231
|
+
const defaultKeys = new Set(Object.keys(flattenObject(DEFAULT_CONFIG)));
|
|
232
|
+
const keys = Object.keys(flatSettings)
|
|
233
|
+
.sort((a, b) => a.localeCompare(b))
|
|
234
|
+
.map((key): SettingsKeyAudit => ({
|
|
235
|
+
key,
|
|
236
|
+
classification: classifySettingsKey(key, schemaKeys, defaultKeys),
|
|
237
|
+
}));
|
|
238
|
+
const missingSchemaKeys = [...schemaKeys]
|
|
239
|
+
.filter((key) => !(key in flatSettings))
|
|
240
|
+
.sort((a, b) => a.localeCompare(b));
|
|
241
|
+
const staleCandidates = keys
|
|
242
|
+
.filter((entry) => entry.classification === 'unknown-stale-candidate')
|
|
243
|
+
.map((entry) => entry.key);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
path,
|
|
247
|
+
exists: settings !== null,
|
|
248
|
+
schemaKeyCount: schemaKeys.size,
|
|
249
|
+
leafKeyCount: keys.length,
|
|
250
|
+
recognizedKeyCount: keys.filter((entry) => entry.classification === 'current-schema').length,
|
|
251
|
+
missingSchemaKeys,
|
|
252
|
+
keys,
|
|
253
|
+
staleCandidates,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function modeString(path: string): string {
|
|
258
|
+
return (statSync(path).mode & 0o777).toString(8).padStart(3, '0');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function collectPermissionFindings(homeDir: string): HomeAuditFinding[] {
|
|
262
|
+
const findings: HomeAuditFinding[] = [];
|
|
263
|
+
const sensitiveFiles = [
|
|
264
|
+
join(homeDir, 'tui', 'secrets.enc'),
|
|
265
|
+
join(homeDir, 'tui', 'auth-users.json'),
|
|
266
|
+
join(homeDir, 'daemon', 'operator-tokens.json'),
|
|
267
|
+
join(homeDir, 'daemon', '.goodvibes', 'daemon', 'operator-tokens.json'),
|
|
268
|
+
join(homeDir, 'daemon', '.goodvibes', 'tui', 'auth-users.json'),
|
|
269
|
+
join(homeDir, 'daemon', '.goodvibes', 'tui', 'auth-bootstrap.txt'),
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
for (const path of sensitiveFiles) {
|
|
273
|
+
if (!existsSync(path)) continue;
|
|
274
|
+
const mode = modeString(path);
|
|
275
|
+
if (mode !== '600') {
|
|
276
|
+
findings.push({
|
|
277
|
+
severity: 'warn',
|
|
278
|
+
code: 'sensitive-file-permissions',
|
|
279
|
+
path,
|
|
280
|
+
message: `Sensitive file mode is ${mode}; expected 600.`,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return findings;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function collectDuplicateProfilePatterns(homeDir: string): DuplicateProfilePattern[] {
|
|
289
|
+
const profilesDir = join(homeDir, 'tui', 'profiles');
|
|
290
|
+
if (!existsSync(profilesDir)) return [];
|
|
291
|
+
|
|
292
|
+
const counts = new Map<string, number>();
|
|
293
|
+
for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
|
|
294
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
295
|
+
const normalizedName = entry.name.replace(/^(team-)+/, 'team-');
|
|
296
|
+
counts.set(normalizedName, (counts.get(normalizedName) ?? 0) + 1);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return [...counts.entries()]
|
|
300
|
+
.filter(([, count]) => count > 1)
|
|
301
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
302
|
+
.map(([normalizedName, count]) => ({ normalizedName, count }));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function auditGoodVibesHome(options: HomeAuditOptions): Promise<GoodVibesHomeAudit> {
|
|
306
|
+
const homeDir = resolve(options.homeDir);
|
|
307
|
+
const { files, directoryCountByOwner } = walkFiles(homeDir);
|
|
308
|
+
const summaries = new Map<HomeFileOwner, { files: number; directories: number; bytes: number }>();
|
|
309
|
+
const records: HomeFileRecord[] = [];
|
|
310
|
+
|
|
311
|
+
for (const file of files) {
|
|
312
|
+
const stats = statSync(file);
|
|
313
|
+
const relativePath = toPosixRelative(relative(homeDir, file));
|
|
314
|
+
const owner = classifyFile(relativePath);
|
|
315
|
+
const summary = summaries.get(owner) ?? { files: 0, directories: directoryCountByOwner.get(owner) ?? 0, bytes: 0 };
|
|
316
|
+
summary.files += 1;
|
|
317
|
+
summary.bytes += stats.size;
|
|
318
|
+
summaries.set(owner, summary);
|
|
319
|
+
records.push({
|
|
320
|
+
relativePath,
|
|
321
|
+
owner,
|
|
322
|
+
bytes: stats.size,
|
|
323
|
+
mode: (stats.mode & 0o777).toString(8).padStart(3, '0'),
|
|
324
|
+
mtimeIso: stats.mtime.toISOString(),
|
|
325
|
+
sha256: options.includeHashes ? await hashFile(file) : undefined,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const settings = auditSettings(homeDir);
|
|
330
|
+
const findings = [
|
|
331
|
+
...collectPermissionFindings(homeDir),
|
|
332
|
+
...settings.staleCandidates.map((key): HomeAuditFinding => ({
|
|
333
|
+
severity: 'warn',
|
|
334
|
+
code: 'stale-settings-key',
|
|
335
|
+
path: settings.path,
|
|
336
|
+
message: `Setting '${key}' is not in CONFIG_SCHEMA, DEFAULT_CONFIG, or known dynamic config.`,
|
|
337
|
+
})),
|
|
338
|
+
];
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
homeDir,
|
|
342
|
+
generatedAt: new Date().toISOString(),
|
|
343
|
+
summaries: [...summaries.entries()]
|
|
344
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
345
|
+
.map(([owner, summary]) => ({ owner, ...summary })),
|
|
346
|
+
files: records,
|
|
347
|
+
settings,
|
|
348
|
+
duplicateProfilePatterns: collectDuplicateProfilePatterns(homeDir),
|
|
349
|
+
findings,
|
|
350
|
+
allowedWriteRoots: [...GOODVIBES_ALLOWED_WRITE_ROOTS],
|
|
351
|
+
readOnlyRoots: [...GOODVIBES_READ_ONLY_ROOTS],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function snapshotGoodVibesHome(homeDir: string): Promise<HomeSnapshot> {
|
|
356
|
+
const root = resolve(homeDir);
|
|
357
|
+
const { files } = walkFiles(root);
|
|
358
|
+
const entries: Record<string, HomeSnapshotEntry> = {};
|
|
359
|
+
for (const file of files) {
|
|
360
|
+
const stats = statSync(file);
|
|
361
|
+
const relativePath = toPosixRelative(relative(root, file));
|
|
362
|
+
entries[relativePath] = {
|
|
363
|
+
relativePath,
|
|
364
|
+
bytes: stats.size,
|
|
365
|
+
mode: (stats.mode & 0o777).toString(8).padStart(3, '0'),
|
|
366
|
+
sha256: await hashFile(file),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return entries;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function diffHomeSnapshots(before: HomeSnapshot, after: HomeSnapshot): HomeSnapshotDiff {
|
|
373
|
+
const beforeKeys = new Set(Object.keys(before));
|
|
374
|
+
const afterKeys = new Set(Object.keys(after));
|
|
375
|
+
const added = [...afterKeys].filter((key) => !beforeKeys.has(key)).sort((a, b) => a.localeCompare(b));
|
|
376
|
+
const removed = [...beforeKeys].filter((key) => !afterKeys.has(key)).sort((a, b) => a.localeCompare(b));
|
|
377
|
+
const changed = [...afterKeys]
|
|
378
|
+
.filter((key) => beforeKeys.has(key))
|
|
379
|
+
.filter((key) => {
|
|
380
|
+
const prior = before[key];
|
|
381
|
+
const next = after[key];
|
|
382
|
+
return prior.sha256 !== next.sha256 || prior.mode !== next.mode || prior.bytes !== next.bytes;
|
|
383
|
+
})
|
|
384
|
+
.sort((a, b) => a.localeCompare(b));
|
|
385
|
+
return { added, removed, changed };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function findDisallowedHomeMutations(
|
|
389
|
+
diff: HomeSnapshotDiff,
|
|
390
|
+
allowedRoots: readonly string[] = GOODVIBES_ALLOWED_WRITE_ROOTS,
|
|
391
|
+
): string[] {
|
|
392
|
+
const touched = [...diff.added, ...diff.removed, ...diff.changed];
|
|
393
|
+
return touched
|
|
394
|
+
.filter((relativePath) => !allowedRoots.some((root) => relativePath.startsWith(root)))
|
|
395
|
+
.sort((a, b) => a.localeCompare(b));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function renderGoodVibesHomeAuditMarkdown(audit: GoodVibesHomeAudit): string {
|
|
399
|
+
const lines: string[] = [
|
|
400
|
+
'# GoodVibes Home Audit',
|
|
401
|
+
'',
|
|
402
|
+
`Generated: ${audit.generatedAt}`,
|
|
403
|
+
`Home: \`${audit.homeDir}\``,
|
|
404
|
+
'',
|
|
405
|
+
'## Ownership Summary',
|
|
406
|
+
'',
|
|
407
|
+
'| Owner | Files | Directories | Bytes |',
|
|
408
|
+
'|---|---:|---:|---:|',
|
|
409
|
+
...audit.summaries.map((summary) => (
|
|
410
|
+
`| ${summary.owner} | ${summary.files} | ${summary.directories} | ${summary.bytes} |`
|
|
411
|
+
)),
|
|
412
|
+
'',
|
|
413
|
+
'## Settings',
|
|
414
|
+
'',
|
|
415
|
+
`Path: \`${audit.settings.path}\``,
|
|
416
|
+
'',
|
|
417
|
+
'| Metric | Count |',
|
|
418
|
+
'|---|---:|',
|
|
419
|
+
`| Schema keys | ${audit.settings.schemaKeyCount} |`,
|
|
420
|
+
`| Persisted leaf keys | ${audit.settings.leafKeyCount} |`,
|
|
421
|
+
`| Current-schema keys | ${audit.settings.recognizedKeyCount} |`,
|
|
422
|
+
`| Missing schema keys | ${audit.settings.missingSchemaKeys.length} |`,
|
|
423
|
+
`| Stale candidates | ${audit.settings.staleCandidates.length} |`,
|
|
424
|
+
'',
|
|
425
|
+
];
|
|
426
|
+
|
|
427
|
+
if (audit.settings.missingSchemaKeys.length > 0) {
|
|
428
|
+
lines.push('### Missing Schema Keys', '');
|
|
429
|
+
for (const key of audit.settings.missingSchemaKeys) lines.push(`- \`${key}\``);
|
|
430
|
+
lines.push('');
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (audit.settings.staleCandidates.length > 0) {
|
|
434
|
+
lines.push('### Stale Setting Candidates', '');
|
|
435
|
+
for (const key of audit.settings.staleCandidates) lines.push(`- \`${key}\``);
|
|
436
|
+
lines.push('');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (audit.duplicateProfilePatterns.length > 0) {
|
|
440
|
+
lines.push('## Duplicate Profile Patterns', '');
|
|
441
|
+
for (const pattern of audit.duplicateProfilePatterns) {
|
|
442
|
+
lines.push(`- \`${pattern.normalizedName}\`: ${pattern.count}`);
|
|
443
|
+
}
|
|
444
|
+
lines.push('');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
lines.push('## Findings', '');
|
|
448
|
+
if (audit.findings.length === 0) {
|
|
449
|
+
lines.push('No findings.');
|
|
450
|
+
} else {
|
|
451
|
+
for (const finding of audit.findings) {
|
|
452
|
+
const path = finding.path ? ` \`${finding.path}\`` : '';
|
|
453
|
+
lines.push(`- ${finding.severity.toUpperCase()} ${finding.code}:${path} ${finding.message}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
lines.push('');
|
|
457
|
+
|
|
458
|
+
return `${lines.join('\n')}\n`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function writeAuditReportFiles(audit: GoodVibesHomeAudit, outputDir: string): void {
|
|
462
|
+
mkdirSync(outputDir, { recursive: true });
|
|
463
|
+
writeFileSync(join(outputDir, 'goodvibes-home-audit.json'), `${JSON.stringify(audit, null, 2)}\n`, 'utf8');
|
|
464
|
+
writeFileSync(join(outputDir, 'goodvibes-home-audit.md'), renderGoodVibesHomeAuditMarkdown(audit), 'utf8');
|
|
465
|
+
}
|
|
@@ -37,6 +37,7 @@ import type { Panel } from '../panels/types.ts';
|
|
|
37
37
|
import type { PanelManager } from '../panels/panel-manager.ts';
|
|
38
38
|
import type { KeybindingsManager } from './keybindings.ts';
|
|
39
39
|
import type { ModelPickerTarget } from './model-picker.ts';
|
|
40
|
+
import type { PanelMouseLayout } from './handler-feed-routes.ts';
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* Initial mutable scalar values for InputFeedContext.
|
|
@@ -67,6 +68,7 @@ export interface FeedContextMutableInit {
|
|
|
67
68
|
mouseDownRow: number;
|
|
68
69
|
mouseDownCol: number;
|
|
69
70
|
contentWidth: number;
|
|
71
|
+
panelMouseLayout: PanelMouseLayout | null;
|
|
70
72
|
selectionCallback: ((result: SelectionResult | null) => void) | null;
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -241,4 +243,5 @@ export function syncFeedContextMutableFields(
|
|
|
241
243
|
ctx.mouseDownRow = fields.mouseDownRow;
|
|
242
244
|
ctx.mouseDownCol = fields.mouseDownCol;
|
|
243
245
|
ctx.contentWidth = fields.contentWidth;
|
|
246
|
+
ctx.panelMouseLayout = fields.panelMouseLayout;
|
|
244
247
|
}
|
|
@@ -481,6 +481,8 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
|
|
|
481
481
|
export type MouseRouteState = {
|
|
482
482
|
conversationManager: ConversationManager | null;
|
|
483
483
|
selection: SelectionManager;
|
|
484
|
+
panelManager: PanelManager;
|
|
485
|
+
panelMouseLayout: PanelMouseLayout | null;
|
|
484
486
|
mouseDownRow: number;
|
|
485
487
|
mouseDownCol: number;
|
|
486
488
|
scrollTop: number;
|
|
@@ -492,6 +494,71 @@ export type MouseRouteState = {
|
|
|
492
494
|
handleCopy: () => void;
|
|
493
495
|
};
|
|
494
496
|
|
|
497
|
+
export type PanelMouseLayout = {
|
|
498
|
+
x: number;
|
|
499
|
+
y: number;
|
|
500
|
+
width: number;
|
|
501
|
+
height: number;
|
|
502
|
+
hasBottomPane: boolean;
|
|
503
|
+
verticalSplitRatio: number;
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
function clampRatio(value: number): number {
|
|
507
|
+
return Math.max(0.2, Math.min(0.8, value));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function getActivePanelInPane(panelManager: PanelManager, pane: 'top' | 'bottom') {
|
|
511
|
+
const target = pane === 'top' ? panelManager.getTopPane() : panelManager.getBottomPane();
|
|
512
|
+
return target.panels[target.activeIndex] ?? null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function getPanelUnderMouse(
|
|
516
|
+
panelManager: PanelManager,
|
|
517
|
+
layout: PanelMouseLayout | null,
|
|
518
|
+
row: number,
|
|
519
|
+
col: number,
|
|
520
|
+
) {
|
|
521
|
+
if (
|
|
522
|
+
layout === null
|
|
523
|
+
|| !panelManager.isVisible()
|
|
524
|
+
|| panelManager.getAllOpen().length === 0
|
|
525
|
+
|| col < layout.x
|
|
526
|
+
|| col >= layout.x + layout.width
|
|
527
|
+
|| row < layout.y
|
|
528
|
+
|| row >= layout.y + layout.height
|
|
529
|
+
) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const panelRow = row - layout.y;
|
|
534
|
+
if (!layout.hasBottomPane) {
|
|
535
|
+
return getActivePanelInPane(panelManager, 'top');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const panelAreaRows = Math.max(0, layout.height - 1);
|
|
539
|
+
const contentRows = Math.max(0, panelAreaRows - 3);
|
|
540
|
+
const topContentRows = contentRows <= 1
|
|
541
|
+
? contentRows
|
|
542
|
+
: Math.max(1, Math.floor(contentRows * clampRatio(layout.verticalSplitRatio)));
|
|
543
|
+
const topLastRow = 2 + topContentRows;
|
|
544
|
+
|
|
545
|
+
return panelRow <= topLastRow
|
|
546
|
+
? getActivePanelInPane(panelManager, 'top')
|
|
547
|
+
: getActivePanelInPane(panelManager, 'bottom');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function scrollPanelUnderMouse(
|
|
551
|
+
state: MouseRouteState,
|
|
552
|
+
token: Extract<InputToken, { type: 'mouse' }>,
|
|
553
|
+
deltaRows: number,
|
|
554
|
+
): boolean {
|
|
555
|
+
const panel = getPanelUnderMouse(state.panelManager, state.panelMouseLayout, token.row, token.col);
|
|
556
|
+
if (!panel?.handleScroll) return false;
|
|
557
|
+
const consumed = panel.handleScroll(deltaRows);
|
|
558
|
+
if (consumed) state.requestRender();
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
|
|
495
562
|
export function handleMouseToken(state: MouseRouteState, token: InputToken): {
|
|
496
563
|
handled: boolean;
|
|
497
564
|
mouseDownRow: number;
|
|
@@ -507,10 +574,16 @@ export function handleMouseToken(state: MouseRouteState, token: InputToken): {
|
|
|
507
574
|
const viewportRow = token.row - headerH;
|
|
508
575
|
|
|
509
576
|
if (token.button === 64) {
|
|
577
|
+
if (scrollPanelUnderMouse(state, token, -3)) {
|
|
578
|
+
return { handled: true, mouseDownRow, mouseDownCol };
|
|
579
|
+
}
|
|
510
580
|
state.scroll(-3);
|
|
511
581
|
return { handled: true, mouseDownRow, mouseDownCol };
|
|
512
582
|
}
|
|
513
583
|
if (token.button === 65) {
|
|
584
|
+
if (scrollPanelUnderMouse(state, token, 3)) {
|
|
585
|
+
return { handled: true, mouseDownRow, mouseDownCol };
|
|
586
|
+
}
|
|
514
587
|
state.scroll(3);
|
|
515
588
|
return { handled: true, mouseDownRow, mouseDownCol };
|
|
516
589
|
}
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
handlePanelFocusToken,
|
|
32
32
|
handlePromptKeyToken,
|
|
33
33
|
handlePromptTextToken,
|
|
34
|
+
type PanelMouseLayout,
|
|
34
35
|
} from './handler-feed-routes.ts';
|
|
35
36
|
import type { WrappedPromptInfo } from './handler-prompt-buffer.ts';
|
|
36
37
|
import { handleModalTokenRoutes } from './handler-modal-token-routes.ts';
|
|
@@ -119,6 +120,7 @@ export interface InputFeedContext {
|
|
|
119
120
|
readonly blockActionsMenu: BlockActionsMenu;
|
|
120
121
|
readonly searchManager: SearchManager;
|
|
121
122
|
readonly panelManager: PanelManager;
|
|
123
|
+
panelMouseLayout: PanelMouseLayout | null;
|
|
122
124
|
readonly keybindingsManager: KeybindingsManager;
|
|
123
125
|
readonly modalStack: string[];
|
|
124
126
|
inputHistory: InputHistory | null;
|
|
@@ -385,6 +387,8 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
|
|
|
385
387
|
const mouseRoute = handleMouseToken({
|
|
386
388
|
conversationManager: context.conversationManager,
|
|
387
389
|
selection: context.selection,
|
|
390
|
+
panelManager: context.panelManager,
|
|
391
|
+
panelMouseLayout: context.panelMouseLayout,
|
|
388
392
|
mouseDownRow: context.mouseDownRow,
|
|
389
393
|
mouseDownCol: context.mouseDownCol,
|
|
390
394
|
scrollTop,
|
|
@@ -54,10 +54,12 @@ export function handleGlobalShortcutToken(
|
|
|
54
54
|
|
|
55
55
|
// Fast-path: bare pageup/pagedown have no keybinding entry.
|
|
56
56
|
if (token.logicalName === 'pageup') {
|
|
57
|
+
if (state.panelFocused) return false;
|
|
57
58
|
state.scroll(-Math.max(1, viewportHeight - 2));
|
|
58
59
|
return true;
|
|
59
60
|
}
|
|
60
61
|
if (token.logicalName === 'pagedown') {
|
|
62
|
+
if (state.panelFocused) return false;
|
|
61
63
|
state.scroll(Math.max(1, viewportHeight - 2));
|
|
62
64
|
return true;
|
|
63
65
|
}
|
package/src/input/handler.ts
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
handlePanelFocusToken,
|
|
65
65
|
handlePromptKeyToken,
|
|
66
66
|
handlePromptTextToken,
|
|
67
|
+
type PanelMouseLayout,
|
|
67
68
|
} from './handler-feed-routes.ts';
|
|
68
69
|
import {
|
|
69
70
|
ensureInputCursorVisible,
|
|
@@ -263,7 +264,8 @@ export class InputHandler {
|
|
|
263
264
|
shortcutsOverlayActive: this.shortcutsOverlayActive, shortcutsScrollOffset: this.shortcutsScrollOffset,
|
|
264
265
|
nextPasteId: this.nextPasteId, nextImageId: this.nextImageId,
|
|
265
266
|
mouseDownRow: this.mouseDownRow, mouseDownCol: this.mouseDownCol,
|
|
266
|
-
contentWidth: this.contentWidth,
|
|
267
|
+
contentWidth: this.contentWidth, panelMouseLayout: this.panelMouseLayout,
|
|
268
|
+
selectionCallback: this.selectionCallback,
|
|
267
269
|
},
|
|
268
270
|
{
|
|
269
271
|
selection: this.selection,
|
|
@@ -341,7 +343,8 @@ export class InputHandler {
|
|
|
341
343
|
helpScrollOffset: h.helpScrollOffset, shortcutsOverlayActive: h.shortcutsOverlayActive,
|
|
342
344
|
shortcutsScrollOffset: h.shortcutsScrollOffset, selectionCallback: h.selectionCallback,
|
|
343
345
|
nextPasteId: h.nextPasteId, nextImageId: h.nextImageId, mouseDownRow: h.mouseDownRow,
|
|
344
|
-
mouseDownCol: h.mouseDownCol, contentWidth: h.contentWidth
|
|
346
|
+
mouseDownCol: h.mouseDownCol, contentWidth: h.contentWidth,
|
|
347
|
+
panelMouseLayout: h.panelMouseLayout }, this.feedContext);
|
|
345
348
|
}
|
|
346
349
|
|
|
347
350
|
/** Wire in the InputHistory instance. Optional; disables history navigation if unset. */
|
|
@@ -455,6 +458,7 @@ export class InputHandler {
|
|
|
455
458
|
context.mouseDownRow = this.mouseDownRow;
|
|
456
459
|
context.mouseDownCol = this.mouseDownCol;
|
|
457
460
|
context.contentWidth = this.contentWidth;
|
|
461
|
+
context.panelMouseLayout = this.panelMouseLayout;
|
|
458
462
|
// Sync semi-stable refs that may be wired after construction.
|
|
459
463
|
context.commandRegistry = this.commandRegistry;
|
|
460
464
|
context.commandContext = this.commandContext;
|
|
@@ -516,12 +520,17 @@ export class InputHandler {
|
|
|
516
520
|
|
|
517
521
|
/** Content width for wrapping — set by main.ts via setContentWidth(). */
|
|
518
522
|
public contentWidth = 76;
|
|
523
|
+
public panelMouseLayout: PanelMouseLayout | null = null;
|
|
519
524
|
|
|
520
525
|
/** Set the content width used for wrapping calculations. Call from main.ts. */
|
|
521
526
|
public setContentWidth(w: number): void {
|
|
522
527
|
this.contentWidth = w;
|
|
523
528
|
}
|
|
524
529
|
|
|
530
|
+
public setPanelMouseLayout(layout: PanelMouseLayout | null): void {
|
|
531
|
+
this.panelMouseLayout = layout;
|
|
532
|
+
}
|
|
533
|
+
|
|
525
534
|
/**
|
|
526
535
|
* Move cursor up or down by one WRAPPED line.
|
|
527
536
|
* Uses the segment table to navigate visual lines, not raw \n lines.
|