@dashclaw/cli 0.7.1 → 0.7.3

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.
@@ -1,532 +1,563 @@
1
- // cli/lib/local-doctor.js
2
- // W4: local doctor checks that run on the operator machine — the server can't
3
- // see these. Repo-aware checks need the cwd (or --repo) to be a DashClaw
4
- // checkout; machine checks always run. Every fix is idempotent; detect-only
5
- // classes (env leak, OpenClaw plugin) NEVER mutate anything.
6
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
7
- import { join } from 'node:path';
8
- import { homedir as osHomedir } from 'node:os';
9
- import { execFile } from 'node:child_process';
10
-
11
- // ---------------------------------------------------------------------------
12
- // Context + adapters (injected in tests)
13
- // ---------------------------------------------------------------------------
14
-
15
- function realExec(cmd, args = [], opts = {}) {
16
- // With shell:true, node concatenates args unescaped (DEP0190) — pass a
17
- // single command string instead. Shell mode is only used for trusted,
18
- // hardcoded commands (npm/dashclaw), never user input.
19
- const useShell = opts.shell ?? false;
20
- const command = useShell ? [cmd, ...args].join(' ') : cmd;
21
- const commandArgs = useShell ? [] : args;
22
- return new Promise((resolvePromise) => {
23
- execFile(
24
- command,
25
- commandArgs,
26
- {
27
- timeout: opts.timeout ?? 30_000,
28
- shell: useShell,
29
- cwd: opts.cwd,
30
- windowsHide: true,
31
- ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}),
32
- },
33
- (err, stdout, stderr) => {
34
- if (err && err.code === 'ENOENT') {
35
- resolvePromise({ code: -1, stdout: '', stderr: 'ENOENT', notFound: true });
36
- return;
37
- }
38
- resolvePromise({
39
- code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
40
- stdout: String(stdout || ''),
41
- stderr: String(stderr || ''),
42
- });
43
- },
44
- );
45
- });
46
- }
47
-
48
- function newestMtimeReal(dir) {
49
- if (!existsSync(dir)) return null;
50
- let newest = null;
51
- const walk = (d) => {
52
- let entries;
53
- try {
54
- entries = readdirSync(d, { withFileTypes: true });
55
- } catch {
56
- return;
57
- }
58
- for (const entry of entries) {
59
- const full = join(d, entry.name);
60
- if (entry.isDirectory()) {
61
- if (entry.name === 'node_modules' || entry.name === '.git') continue;
62
- walk(full);
63
- } else {
64
- try {
65
- const m = statSync(full).mtimeMs;
66
- if (newest === null || m > newest) newest = m;
67
- } catch {
68
- // file vanished mid-walk — skip
69
- }
70
- }
71
- }
72
- };
73
- walk(dir);
74
- return newest;
75
- }
76
-
77
- const realFs = { existsSync, readFileSync, newestMtime: newestMtimeReal };
78
-
79
- export function buildContext(overrides = {}) {
80
- return {
81
- cwd: process.cwd(),
82
- env: process.env,
83
- platform: process.platform,
84
- homedir: osHomedir(),
85
- exec: realExec,
86
- fs: realFs,
87
- repoRoot: null,
88
- cliVersion: '0.0.0',
89
- ...overrides,
90
- };
91
- }
92
-
93
- /**
94
- * A directory is a DashClaw checkout if its package.json carries the platform
95
- * name, or (renamed forks) it has the structural markers drizzle/ + mcp-server/.
96
- */
97
- export function detectRepoRoot({ cwd, fs = realFs }) {
98
- try {
99
- const pkg = JSON.parse(fs.readFileSync(join(cwd, 'package.json'), 'utf8'));
100
- if (pkg?.name === 'dashclaw-platform' || pkg?.name === 'dashclaw') return cwd;
101
- if (fs.existsSync(join(cwd, 'drizzle')) && fs.existsSync(join(cwd, 'mcp-server'))) return cwd;
102
- return null;
103
- } catch {
104
- return null;
105
- }
106
- }
107
-
108
- function check(id, category, status, title, message, fix = null) {
109
- return { id, category, status, title, message, fix, local: true };
110
- }
111
-
112
- // ---------------------------------------------------------------------------
113
- // Repo-aware checks
114
- // ---------------------------------------------------------------------------
115
-
116
- async function checkMcpLibStale(ctx) {
117
- const srcDir = join(ctx.repoRoot, 'mcp-server', 'src');
118
- const libDir = join(ctx.repoRoot, 'mcp-server', 'lib');
119
- const srcNewest = ctx.fs.newestMtime(srcDir);
120
- if (srcNewest === null) return null; // no mcp-server src — nothing to verify
121
- const libNewest = ctx.fs.newestMtime(libDir);
122
-
123
- if (libNewest === null || srcNewest > libNewest) {
124
- return check(
125
- 'local_mcp_lib_stale',
126
- 'local-repo',
127
- 'fail',
128
- 'Compiled mcp-server lib',
129
- libNewest === null
130
- ? 'mcp-server/lib is missing — the MCP server cannot serve current tools'
131
- : 'mcp-server/lib is older than mcp-server/src — served tools are stale',
132
- { type: 'auto', description: 'Rebuild mcp-server (npm run build in mcp-server/)', action: 'rebuild_mcp_lib' },
133
- );
134
- }
135
- return check('local_mcp_lib_stale', 'local-repo', 'pass', 'Compiled mcp-server lib', 'lib is newer than src');
136
- }
137
-
138
- async function checkGitattributesDrift(ctx) {
139
- const status = await ctx.exec('git', ['status', '--porcelain', '--', '.gitattributes'], { cwd: ctx.repoRoot });
140
- if (status.code !== 0) return null; // not a git checkout — skip
141
- if (!/^\s?M/m.test(status.stdout)) {
142
- return check('local_gitattributes_drift', 'local-repo', 'pass', '.gitattributes drift', '.gitattributes is clean');
143
- }
144
-
145
- // Provable line-ending/whitespace-only proof: the whitespace-insensitive diff
146
- // is empty while the file is modified.
147
- const wsDiff = await ctx.exec(
148
- 'git',
149
- ['diff', '--ignore-cr-at-eol', '--ignore-all-space', '--', '.gitattributes'],
150
- { cwd: ctx.repoRoot },
151
- );
152
- if (wsDiff.stdout.trim() === '') {
153
- return check(
154
- 'local_gitattributes_drift',
155
- 'local-repo',
156
- 'fail',
157
- '.gitattributes drift',
158
- '.gitattributes is modified but the diff is line-ending/whitespace-only — this silently blocks pull/push/worktree ops',
159
- { type: 'auto', description: 'Restore .gitattributes from the index (git checkout -- .gitattributes)', action: 'restore_gitattributes' },
160
- );
161
- }
162
- return check(
163
- 'local_gitattributes_drift',
164
- 'local-repo',
165
- 'warn',
166
- '.gitattributes drift',
167
- '.gitattributes has real content changes — review and commit or discard it manually (auto-restore refused)',
168
- );
169
- }
170
-
171
- async function checkSchemaBehind(ctx) {
172
- const dbUrl = ctx.env.DATABASE_URL || readRepoEnvVar(ctx, 'DATABASE_URL');
173
- if (!dbUrl) {
174
- return check(
175
- 'local_schema_behind',
176
- 'local-repo',
177
- 'pass',
178
- 'Local DB schema',
179
- 'Skipped — no DATABASE_URL configured for this checkout',
180
- );
181
- }
182
-
183
- // Reuse the repo's own engine probe via the npm script (report-only) — the
184
- // script carries the tsx loader the engine's extensionless .ts imports need.
185
- const probe = await ctx.exec(
186
- 'npm',
187
- ['run', 'doctor', '--', '--json', '--no-fix', '--category', 'database'],
188
- { cwd: ctx.repoRoot, shell: ctx.platform === 'win32', timeout: 60_000, env: { ...ctx.env, DATABASE_URL: dbUrl } },
189
- );
190
- let result = null;
191
- try {
192
- // npm prepends a script banner before the JSON — parse from the first brace.
193
- const stdout = probe.stdout || '';
194
- result = JSON.parse(stdout.slice(stdout.indexOf('{')));
195
- } catch {
196
- return check(
197
- 'local_schema_behind',
198
- 'local-repo',
199
- 'warn',
200
- 'Local DB schema',
201
- `Could not verify schema state (probe unreadable: ${(probe.stderr || probe.stdout || 'no output').slice(0, 120)}) — if you recently pulled schema changes, run npm run db:migrate`,
202
- );
203
- }
204
-
205
- const schemaCheck = (result.checks || []).find((c) => c.id === 'db_schema');
206
- if (schemaCheck && schemaCheck.status === 'fail') {
207
- return check(
208
- 'local_schema_behind',
209
- 'local-repo',
210
- 'fail',
211
- 'Local DB schema',
212
- `Local database schema is behind code: ${schemaCheck.message}. Until migrated, authenticated requests can 401`,
213
- { type: 'auto', description: 'Apply pending schema (npm run db:migrate — idempotent)', action: 'run_db_migrate' },
214
- );
215
- }
216
- return check('local_schema_behind', 'local-repo', 'pass', 'Local DB schema', 'Database schema matches code');
217
- }
218
-
219
- function readRepoEnvVar(ctx, name) {
220
- for (const file of ['.env.local', '.env']) {
221
- try {
222
- const content = ctx.fs.readFileSync(join(ctx.repoRoot, file), 'utf8');
223
- const match = content.match(new RegExp(`^\\s*${name}\\s*=\\s*(.+)$`, 'm'));
224
- if (match) return match[1].trim().replace(/^["']|["']$/g, '');
225
- } catch {
226
- // file absent — keep looking
227
- }
228
- }
229
- return null;
230
- }
231
-
232
- /** DETECT-ONLY: never mutates a gateway config. */
233
- async function checkOpenclawPlugin(ctx) {
234
- const candidates = [
235
- ctx.env.DASHCLAW_OPENCLAW_CONFIG,
236
- join(ctx.homedir, '.openclaw', 'openclaw.json'),
237
- join(ctx.cwd, 'openclaw.plugin.json'),
238
- ].filter(Boolean);
239
-
240
- let path = null;
241
- for (const candidate of candidates) {
242
- if (ctx.fs.existsSync(candidate)) {
243
- path = candidate;
244
- break;
245
- }
246
- }
247
- if (!path) return null; // no gateway here — silent skip (matches server check)
248
-
249
- let doc;
250
- try {
251
- doc = JSON.parse(ctx.fs.readFileSync(path, 'utf8'));
252
- } catch (err) {
253
- return check('local_openclaw_plugin', 'local-repo', 'warn', 'OpenClaw runtime plugin', `${path}: unparseable (${err.message}) — fix the JSON by hand; auto-repair of gateway configs is deliberately not supported`);
254
- }
255
-
256
- const entry = doc?.plugins?.entries?.['dashclaw-governance'] ?? (doc?.id === 'dashclaw-governance' ? doc : null);
257
- if (!entry) return null; // plugin not installed here — silent skip
258
-
259
- if (entry.enabled === false) {
260
- return check(
261
- 'local_openclaw_plugin',
262
- 'local-repo',
263
- 'warn',
264
- 'OpenClaw runtime plugin',
265
- `${path}: dashclaw-governance is disabled — gateway actions are NOT being governed. Re-enable it in the gateway config (set "enabled": true), then restart the gateway. Auto-mutation of gateway configs is deliberately not supported`,
266
- );
267
- }
268
-
269
- const pluginPath = entry.path || entry.source || null;
270
- if (pluginPath && !ctx.fs.existsSync(pluginPath)) {
271
- return check(
272
- 'local_openclaw_plugin',
273
- 'local-repo',
274
- 'warn',
275
- 'OpenClaw runtime plugin',
276
- `${path}: plugin path ${pluginPath} does not exist — the gateway will fail to load dashclaw-governance. Point it at a valid plugin checkout and restart the gateway`,
277
- );
278
- }
279
-
280
- return check('local_openclaw_plugin', 'local-repo', 'pass', 'OpenClaw runtime plugin', 'dashclaw-governance entry looks healthy');
281
- }
282
-
283
- // ---------------------------------------------------------------------------
284
- // Machine checks
285
- // ---------------------------------------------------------------------------
286
-
287
- async function checkCliShimStale(ctx) {
288
- let result;
289
- try {
290
- result = await ctx.exec('dashclaw', ['--version'], { shell: ctx.platform === 'win32', timeout: 15_000 });
291
- } catch {
292
- result = { notFound: true, code: -1, stdout: '' };
293
- }
294
- if (result.notFound || result.code !== 0) {
295
- return check('local_cli_shim_stale', 'local-machine', 'pass', 'Global CLI shim', 'No global dashclaw shim on PATH');
296
- }
297
- const found = (result.stdout.match(/\d+\.\d+\.\d+/) || [])[0];
298
- if (!found) {
299
- return check('local_cli_shim_stale', 'local-machine', 'pass', 'Global CLI shim', 'Global shim did not report a version — skipped');
300
- }
301
- if (found !== ctx.cliVersion) {
302
- return check(
303
- 'local_cli_shim_stale',
304
- 'local-machine',
305
- 'fail',
306
- 'Global CLI shim',
307
- `PATH dashclaw is ${found}, current CLI is ${ctx.cliVersion} — stale shims shadow new commands`,
308
- { type: 'auto', description: 'Reinstall the global CLI (npm i -g @dashclaw/cli)', action: 'reinstall_cli' },
309
- );
310
- }
311
- return check('local_cli_shim_stale', 'local-machine', 'pass', 'Global CLI shim', `PATH dashclaw matches ${ctx.cliVersion}`);
312
- }
313
-
314
- function extractHookScriptPaths(command) {
315
- // Tokenize respecting double quotes; keep tokens that look like file paths.
316
- const tokens = command.match(/"[^"]+"|\S+/g) || [];
317
- return tokens
318
- .map((t) => t.replace(/^"|"$/g, ''))
319
- .filter((t) => /[\\/]/.test(t) && /dashclaw/i.test(t) && /\.(py|cjs|mjs|js)$/i.test(t));
320
- }
321
-
322
- async function checkHooksTrust(ctx) {
323
- const settingsPath = join(ctx.homedir, '.claude', 'settings.json');
324
- let settings;
325
- try {
326
- settings = JSON.parse(ctx.fs.readFileSync(settingsPath, 'utf8'));
327
- } catch {
328
- return check('local_hooks_trust', 'local-machine', 'pass', 'DashClaw Claude hooks', 'No global Claude settings — hooks not installed (skipped)');
329
- }
330
-
331
- const commands = [];
332
- for (const eventEntries of Object.values(settings?.hooks || {})) {
333
- if (!Array.isArray(eventEntries)) continue;
334
- for (const entry of eventEntries) {
335
- for (const hook of entry?.hooks || []) {
336
- if (typeof hook?.command === 'string' && /dashclaw/i.test(hook.command)) {
337
- commands.push(hook.command);
338
- }
339
- }
340
- }
341
- }
342
- if (commands.length === 0) {
343
- return check('local_hooks_trust', 'local-machine', 'pass', 'DashClaw Claude hooks', 'No DashClaw hooks installed (skipped)');
344
- }
345
-
346
- const missing = [];
347
- for (const command of commands) {
348
- for (const scriptPath of extractHookScriptPaths(command)) {
349
- if (!ctx.fs.existsSync(scriptPath)) missing.push(scriptPath);
350
- }
351
- }
352
- if (missing.length > 0) {
353
- const installer = ctx.repoRoot
354
- ? 'node scripts/install-hooks.mjs --global --governance'
355
- : 'dashclaw install claude';
356
- return check(
357
- 'local_hooks_trust',
358
- 'local-machine',
359
- 'fail',
360
- 'DashClaw Claude hooks',
361
- `Hook script(s) missing: ${missing.join(', ')} — hooks silently no-op`,
362
- { type: 'auto', description: `Re-run the hook installer (${installer})`, action: 'reinstall_hooks' },
363
- );
364
- }
365
- return check('local_hooks_trust', 'local-machine', 'pass', 'DashClaw Claude hooks', `${commands.length} DashClaw hook(s) installed, scripts present`);
366
- }
367
-
368
- /** DETECT-ONLY: deleting user env vars is not trivially safe. */
369
- async function checkEnvLeak(ctx) {
370
- const names = new Set();
371
- const sources = [];
372
-
373
- if (ctx.platform === 'win32') {
374
- const scopes = [
375
- { args: ['query', 'HKCU\\Environment'], label: 'User' },
376
- { args: ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment'], label: 'Machine' },
377
- ];
378
- for (const scope of scopes) {
379
- let result;
380
- try {
381
- result = await ctx.exec('reg', scope.args, { timeout: 15_000 });
382
- } catch {
383
- continue;
384
- }
385
- if (result.code !== 0) continue;
386
- for (const match of result.stdout.matchAll(/^\s+(DASHCLAW_\w+)\s+REG_/gim)) {
387
- names.add(match[1]);
388
- sources.push(`${match[1]} (${scope.label} scope)`);
389
- }
390
- }
391
- if (names.size > 0) {
392
- const removal = [...names]
393
- .map((n) => `[Environment]::SetEnvironmentVariable('${n}', $null, 'User')`)
394
- .join('; ');
395
- return check(
396
- 'local_env_leak',
397
- 'local-machine',
398
- 'warn',
399
- 'Leaked DASHCLAW_* env',
400
- `Machine/user-scope env vars can shadow per-project config: ${sources.join(', ')}. To remove (PowerShell): ${removal}. Removal is manual by design`,
401
- );
402
- }
403
- } else {
404
- const profiles = ['.bashrc', '.zshrc', '.profile', '.bash_profile'];
405
- for (const profile of profiles) {
406
- let content;
407
- try {
408
- content = ctx.fs.readFileSync(join(ctx.homedir, profile), 'utf8');
409
- } catch {
410
- continue;
411
- }
412
- for (const match of content.matchAll(/^\s*(?:export\s+)?(DASHCLAW_\w+)\s*=/gm)) {
413
- names.add(match[1]);
414
- sources.push(`${match[1]} (~/${profile})`);
415
- }
416
- }
417
- if (names.size > 0) {
418
- return check(
419
- 'local_env_leak',
420
- 'local-machine',
421
- 'warn',
422
- 'Leaked DASHCLAW_* env',
423
- `Shell-profile env vars can shadow per-project config: ${sources.join(', ')}. Remove the export lines from the listed profile(s) and restart your shell. Removal is manual by design`,
424
- );
425
- }
426
- }
427
-
428
- return check('local_env_leak', 'local-machine', 'pass', 'Leaked DASHCLAW_* env', 'No machine-scope DASHCLAW_* env vars found');
429
- }
430
-
431
- // ---------------------------------------------------------------------------
432
- // Runner + fixes
433
- // ---------------------------------------------------------------------------
434
-
435
- /**
436
- * Run every applicable local check. Repo-aware checks run only when
437
- * ctx.repoRoot is a DashClaw checkout; machine checks always run.
438
- */
439
- export async function runLocalChecks(ctx) {
440
- const checks = [];
441
-
442
- if (ctx.repoRoot) {
443
- for (const runner of [checkMcpLibStale, checkGitattributesDrift, checkSchemaBehind, checkOpenclawPlugin]) {
444
- try {
445
- const result = await runner(ctx);
446
- if (result) checks.push(result);
447
- } catch (err) {
448
- checks.push(check(runner.name, 'local-repo', 'warn', runner.name, `Check errored: ${err.message}`));
449
- }
450
- }
451
- }
452
-
453
- for (const runner of [checkCliShimStale, checkHooksTrust, checkEnvLeak]) {
454
- try {
455
- const result = await runner(ctx);
456
- if (result) checks.push(result);
457
- } catch (err) {
458
- checks.push(check(runner.name, 'local-machine', 'warn', runner.name, `Check errored: ${err.message}`));
459
- }
460
- }
461
-
462
- return checks;
463
- }
464
-
465
- const LOCAL_FIX_HANDLERS = {
466
- rebuild_mcp_lib: async (ctx) => {
467
- const result = await ctx.exec('npm', ['run', 'build'], {
468
- cwd: join(ctx.repoRoot, 'mcp-server'),
469
- shell: ctx.platform === 'win32',
470
- timeout: 300_000,
471
- });
472
- return result.code === 0
473
- ? { applied: true, description: 'Rebuilt mcp-server/lib from src' }
474
- : { applied: false, description: `mcp-server build failed: ${(result.stderr || result.stdout).slice(0, 200)}` };
475
- },
476
- restore_gitattributes: async (ctx) => {
477
- const result = await ctx.exec('git', ['checkout', '--', '.gitattributes'], { cwd: ctx.repoRoot });
478
- return result.code === 0
479
- ? { applied: true, description: 'Restored .gitattributes from the index' }
480
- : { applied: false, description: `git checkout failed: ${(result.stderr || '').slice(0, 200)}` };
481
- },
482
- run_db_migrate: async (ctx) => {
483
- const result = await ctx.exec('npm', ['run', 'db:migrate'], {
484
- cwd: ctx.repoRoot,
485
- shell: ctx.platform === 'win32',
486
- timeout: 300_000,
487
- });
488
- return result.code === 0
489
- ? { applied: true, description: 'Applied pending schema via npm run db:migrate' }
490
- : { applied: false, description: `db:migrate failed: ${(result.stderr || result.stdout).slice(0, 200)}` };
491
- },
492
- reinstall_cli: async (ctx) => {
493
- const result = await ctx.exec('npm', ['i', '-g', '@dashclaw/cli'], {
494
- shell: ctx.platform === 'win32',
495
- timeout: 300_000,
496
- });
497
- return result.code === 0
498
- ? { applied: true, description: 'Reinstalled the global @dashclaw/cli' }
499
- : { applied: false, description: `npm i -g failed: ${(result.stderr || result.stdout).slice(0, 200)}` };
500
- },
501
- reinstall_hooks: async (ctx) => {
502
- const result = ctx.repoRoot
503
- ? await ctx.exec(process.execPath ?? 'node', ['scripts/install-hooks.mjs', '--global', '--governance'], {
504
- cwd: ctx.repoRoot,
505
- timeout: 120_000,
506
- })
507
- : await ctx.exec('dashclaw', ['install', 'claude'], { shell: ctx.platform === 'win32', timeout: 120_000 });
508
- return result.code === 0
509
- ? { applied: true, description: 'Re-ran the DashClaw hook installer' }
510
- : { applied: false, description: `Hook installer failed: ${(result.stderr || result.stdout).slice(0, 200)}` };
511
- },
512
- };
513
-
514
- /**
515
- * Apply local auto-fixes for failing checks. Returns one result per attempted
516
- * fix: { id, action, applied, description }.
517
- */
518
- export async function applyLocalFixes(checks, ctx) {
519
- const results = [];
520
- for (const item of checks) {
521
- if (!item?.fix || item.fix.type !== 'auto') continue;
522
- const handler = LOCAL_FIX_HANDLERS[item.fix.action];
523
- if (!handler) continue;
524
- try {
525
- const result = await handler(ctx);
526
- results.push({ id: item.id, action: item.fix.action, ...result });
527
- } catch (err) {
528
- results.push({ id: item.id, action: item.fix.action, applied: false, description: `Fix errored: ${err.message}` });
529
- }
530
- }
531
- return results;
532
- }
1
+ // cli/lib/local-doctor.js
2
+ // W4: local doctor checks that run on the operator machine — the server can't
3
+ // see these. Repo-aware checks need the cwd (or --repo) to be a DashClaw
4
+ // checkout; machine checks always run. Every fix is idempotent; detect-only
5
+ // classes (env leak, OpenClaw plugin) NEVER mutate anything.
6
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { homedir as osHomedir } from 'node:os';
9
+ import { execFile } from 'node:child_process';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Context + adapters (injected in tests)
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function realExec(cmd, args = [], opts = {}) {
16
+ // With shell:true, node concatenates args unescaped (DEP0190) — pass a
17
+ // single command string instead. Shell mode is only used for trusted,
18
+ // hardcoded commands (npm/dashclaw), never user input.
19
+ const useShell = opts.shell ?? false;
20
+ const command = useShell ? [cmd, ...args].join(' ') : cmd;
21
+ const commandArgs = useShell ? [] : args;
22
+ return new Promise((resolvePromise) => {
23
+ execFile(
24
+ command,
25
+ commandArgs,
26
+ {
27
+ timeout: opts.timeout ?? 30_000,
28
+ shell: useShell,
29
+ cwd: opts.cwd,
30
+ windowsHide: true,
31
+ ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}),
32
+ },
33
+ (err, stdout, stderr) => {
34
+ if (err && err.code === 'ENOENT') {
35
+ resolvePromise({ code: -1, stdout: '', stderr: 'ENOENT', notFound: true });
36
+ return;
37
+ }
38
+ resolvePromise({
39
+ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
40
+ stdout: String(stdout || ''),
41
+ stderr: String(stderr || ''),
42
+ });
43
+ },
44
+ );
45
+ });
46
+ }
47
+
48
+ function newestMtimeReal(dir) {
49
+ if (!existsSync(dir)) return null;
50
+ let newest = null;
51
+ const walk = (d) => {
52
+ let entries;
53
+ try {
54
+ entries = readdirSync(d, { withFileTypes: true });
55
+ } catch {
56
+ return;
57
+ }
58
+ for (const entry of entries) {
59
+ const full = join(d, entry.name);
60
+ if (entry.isDirectory()) {
61
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
62
+ walk(full);
63
+ } else {
64
+ try {
65
+ const m = statSync(full).mtimeMs;
66
+ if (newest === null || m > newest) newest = m;
67
+ } catch {
68
+ // file vanished mid-walk — skip
69
+ }
70
+ }
71
+ }
72
+ };
73
+ walk(dir);
74
+ return newest;
75
+ }
76
+
77
+ const realFs = { existsSync, readFileSync, newestMtime: newestMtimeReal };
78
+
79
+ export function buildContext(overrides = {}) {
80
+ return {
81
+ cwd: process.cwd(),
82
+ env: process.env,
83
+ platform: process.platform,
84
+ homedir: osHomedir(),
85
+ exec: realExec,
86
+ fs: realFs,
87
+ repoRoot: null,
88
+ cliVersion: '0.0.0',
89
+ ...overrides,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * A directory is a DashClaw checkout if its package.json carries the platform
95
+ * name, or (renamed forks) it has the structural markers drizzle/ + mcp-server/.
96
+ */
97
+ export function detectRepoRoot({ cwd, fs = realFs }) {
98
+ try {
99
+ const pkg = JSON.parse(fs.readFileSync(join(cwd, 'package.json'), 'utf8'));
100
+ if (pkg?.name === 'dashclaw-platform' || pkg?.name === 'dashclaw') return cwd;
101
+ if (fs.existsSync(join(cwd, 'drizzle')) && fs.existsSync(join(cwd, 'mcp-server'))) return cwd;
102
+ return null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ function check(id, category, status, title, message, fix = null) {
109
+ return { id, category, status, title, message, fix, local: true };
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Repo-aware checks
114
+ // ---------------------------------------------------------------------------
115
+
116
+ async function checkMcpLibStale(ctx) {
117
+ const srcDir = join(ctx.repoRoot, 'mcp-server', 'src');
118
+ const libDir = join(ctx.repoRoot, 'mcp-server', 'lib');
119
+ const srcNewest = ctx.fs.newestMtime(srcDir);
120
+ if (srcNewest === null) return null; // no mcp-server src — nothing to verify
121
+ const libNewest = ctx.fs.newestMtime(libDir);
122
+
123
+ if (libNewest === null || srcNewest > libNewest) {
124
+ return check(
125
+ 'local_mcp_lib_stale',
126
+ 'local-repo',
127
+ 'fail',
128
+ 'Compiled mcp-server lib',
129
+ libNewest === null
130
+ ? 'mcp-server/lib is missing — the MCP server cannot serve current tools'
131
+ : 'mcp-server/lib is older than mcp-server/src — served tools are stale',
132
+ { type: 'auto', description: 'Rebuild mcp-server (npm run build in mcp-server/)', action: 'rebuild_mcp_lib' },
133
+ );
134
+ }
135
+ return check('local_mcp_lib_stale', 'local-repo', 'pass', 'Compiled mcp-server lib', 'lib is newer than src');
136
+ }
137
+
138
+ async function checkGitattributesDrift(ctx) {
139
+ const status = await ctx.exec('git', ['status', '--porcelain', '--', '.gitattributes'], { cwd: ctx.repoRoot });
140
+ if (status.code !== 0) return null; // not a git checkout — skip
141
+ if (!/^\s?M/m.test(status.stdout)) {
142
+ return check('local_gitattributes_drift', 'local-repo', 'pass', '.gitattributes drift', '.gitattributes is clean');
143
+ }
144
+
145
+ // Provable line-ending/whitespace-only proof: the whitespace-insensitive diff
146
+ // is empty while the file is modified.
147
+ const wsDiff = await ctx.exec(
148
+ 'git',
149
+ ['diff', '--ignore-cr-at-eol', '--ignore-all-space', '--', '.gitattributes'],
150
+ { cwd: ctx.repoRoot },
151
+ );
152
+ if (wsDiff.stdout.trim() === '') {
153
+ return check(
154
+ 'local_gitattributes_drift',
155
+ 'local-repo',
156
+ 'fail',
157
+ '.gitattributes drift',
158
+ '.gitattributes is modified but the diff is line-ending/whitespace-only — this silently blocks pull/push/worktree ops',
159
+ { type: 'auto', description: 'Restore .gitattributes from the index (git checkout -- .gitattributes)', action: 'restore_gitattributes' },
160
+ );
161
+ }
162
+ return check(
163
+ 'local_gitattributes_drift',
164
+ 'local-repo',
165
+ 'warn',
166
+ '.gitattributes drift',
167
+ '.gitattributes has real content changes — review and commit or discard it manually (auto-restore refused)',
168
+ );
169
+ }
170
+
171
+ async function checkSchemaBehind(ctx) {
172
+ const dbUrl = ctx.env.DATABASE_URL || readRepoEnvVar(ctx, 'DATABASE_URL');
173
+ if (!dbUrl) {
174
+ return check(
175
+ 'local_schema_behind',
176
+ 'local-repo',
177
+ 'pass',
178
+ 'Local DB schema',
179
+ 'Skipped — no DATABASE_URL configured for this checkout',
180
+ );
181
+ }
182
+
183
+ // Reuse the repo's own engine probe via the npm script (report-only) — the
184
+ // script carries the tsx loader the engine's extensionless .ts imports need.
185
+ const probe = await ctx.exec(
186
+ 'npm',
187
+ ['run', 'doctor', '--', '--json', '--no-fix', '--category', 'database'],
188
+ { cwd: ctx.repoRoot, shell: ctx.platform === 'win32', timeout: 60_000, env: { ...ctx.env, DATABASE_URL: dbUrl } },
189
+ );
190
+ let result = null;
191
+ try {
192
+ // npm prepends a script banner before the JSON — parse from the first brace.
193
+ const stdout = probe.stdout || '';
194
+ result = JSON.parse(stdout.slice(stdout.indexOf('{')));
195
+ } catch {
196
+ return check(
197
+ 'local_schema_behind',
198
+ 'local-repo',
199
+ 'warn',
200
+ 'Local DB schema',
201
+ `Could not verify schema state (probe unreadable: ${(probe.stderr || probe.stdout || 'no output').slice(0, 120)}) — if you recently pulled schema changes, run npm run db:migrate`,
202
+ );
203
+ }
204
+
205
+ const schemaCheck = (result.checks || []).find((c) => c.id === 'db_schema');
206
+ if (schemaCheck && schemaCheck.status === 'fail') {
207
+ return check(
208
+ 'local_schema_behind',
209
+ 'local-repo',
210
+ 'fail',
211
+ 'Local DB schema',
212
+ `Local database schema is behind code: ${schemaCheck.message}. Until migrated, authenticated requests can 401`,
213
+ { type: 'auto', description: 'Apply pending schema (npm run db:migrate — idempotent)', action: 'run_db_migrate' },
214
+ );
215
+ }
216
+ return check('local_schema_behind', 'local-repo', 'pass', 'Local DB schema', 'Database schema matches code');
217
+ }
218
+
219
+ function readRepoEnvVar(ctx, name) {
220
+ for (const file of ['.env.local', '.env']) {
221
+ try {
222
+ const content = ctx.fs.readFileSync(join(ctx.repoRoot, file), 'utf8');
223
+ const match = content.match(new RegExp(`^\\s*${name}\\s*=\\s*(.+)$`, 'm'));
224
+ if (match) return match[1].trim().replace(/^["']|["']$/g, '');
225
+ } catch {
226
+ // file absent — keep looking
227
+ }
228
+ }
229
+ return null;
230
+ }
231
+
232
+ /** DETECT-ONLY: never mutates a gateway config. */
233
+ async function checkOpenclawPlugin(ctx) {
234
+ const candidates = [
235
+ ctx.env.DASHCLAW_OPENCLAW_CONFIG,
236
+ join(ctx.homedir, '.openclaw', 'openclaw.json'),
237
+ join(ctx.cwd, 'openclaw.plugin.json'),
238
+ ].filter(Boolean);
239
+
240
+ let path = null;
241
+ for (const candidate of candidates) {
242
+ if (ctx.fs.existsSync(candidate)) {
243
+ path = candidate;
244
+ break;
245
+ }
246
+ }
247
+ if (!path) return null; // no gateway here — silent skip (matches server check)
248
+
249
+ let doc;
250
+ try {
251
+ doc = JSON.parse(ctx.fs.readFileSync(path, 'utf8'));
252
+ } catch (err) {
253
+ return check('local_openclaw_plugin', 'local-repo', 'warn', 'OpenClaw runtime plugin', `${path}: unparseable (${err.message}) — fix the JSON by hand; auto-repair of gateway configs is deliberately not supported`);
254
+ }
255
+
256
+ const entry = doc?.plugins?.entries?.['dashclaw-governance'] ?? (doc?.id === 'dashclaw-governance' ? doc : null);
257
+ if (!entry) return null; // plugin not installed here — silent skip
258
+
259
+ if (entry.enabled === false) {
260
+ return check(
261
+ 'local_openclaw_plugin',
262
+ 'local-repo',
263
+ 'warn',
264
+ 'OpenClaw runtime plugin',
265
+ `${path}: dashclaw-governance is disabled — gateway actions are NOT being governed. Re-enable it in the gateway config (set "enabled": true), then restart the gateway. Auto-mutation of gateway configs is deliberately not supported`,
266
+ );
267
+ }
268
+
269
+ const pluginPath = entry.path || entry.source || null;
270
+ if (pluginPath && !ctx.fs.existsSync(pluginPath)) {
271
+ return check(
272
+ 'local_openclaw_plugin',
273
+ 'local-repo',
274
+ 'warn',
275
+ 'OpenClaw runtime plugin',
276
+ `${path}: plugin path ${pluginPath} does not exist — the gateway will fail to load dashclaw-governance. Point it at a valid plugin checkout and restart the gateway`,
277
+ );
278
+ }
279
+
280
+ return check('local_openclaw_plugin', 'local-repo', 'pass', 'OpenClaw runtime plugin', 'dashclaw-governance entry looks healthy');
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Machine checks
285
+ // ---------------------------------------------------------------------------
286
+
287
+ async function checkCliShimStale(ctx) {
288
+ let result;
289
+ try {
290
+ result = await ctx.exec('dashclaw', ['--version'], { shell: ctx.platform === 'win32', timeout: 15_000 });
291
+ } catch {
292
+ result = { notFound: true, code: -1, stdout: '' };
293
+ }
294
+ if (result.notFound || result.code !== 0) {
295
+ return check('local_cli_shim_stale', 'local-machine', 'pass', 'Global CLI shim', 'No global dashclaw shim on PATH');
296
+ }
297
+ const found = (result.stdout.match(/\d+\.\d+\.\d+/) || [])[0];
298
+ if (!found) {
299
+ return check('local_cli_shim_stale', 'local-machine', 'pass', 'Global CLI shim', 'Global shim did not report a version — skipped');
300
+ }
301
+ if (found !== ctx.cliVersion) {
302
+ return check(
303
+ 'local_cli_shim_stale',
304
+ 'local-machine',
305
+ 'fail',
306
+ 'Global CLI shim',
307
+ `PATH dashclaw is ${found}, current CLI is ${ctx.cliVersion} — stale shims shadow new commands`,
308
+ { type: 'auto', description: 'Reinstall the global CLI (npm i -g @dashclaw/cli)', action: 'reinstall_cli' },
309
+ );
310
+ }
311
+ return check('local_cli_shim_stale', 'local-machine', 'pass', 'Global CLI shim', `PATH dashclaw matches ${ctx.cliVersion}`);
312
+ }
313
+
314
+ function extractHookScriptPaths(command) {
315
+ // Tokenize respecting double quotes; keep tokens that look like file paths.
316
+ const tokens = command.match(/"[^"]+"|\S+/g) || [];
317
+ return tokens
318
+ .map((t) => t.replace(/^"|"$/g, ''))
319
+ .filter((t) => /[\\/]/.test(t) && /dashclaw/i.test(t) && /\.(py|cjs|mjs|js)$/i.test(t));
320
+ }
321
+
322
+ async function checkHooksTrust(ctx) {
323
+ const settingsPath = join(ctx.homedir, '.claude', 'settings.json');
324
+ let settings;
325
+ try {
326
+ settings = JSON.parse(ctx.fs.readFileSync(settingsPath, 'utf8'));
327
+ } catch {
328
+ return check('local_hooks_trust', 'local-machine', 'pass', 'DashClaw Claude hooks', 'No global Claude settings — hooks not installed (skipped)');
329
+ }
330
+
331
+ const commands = [];
332
+ for (const eventEntries of Object.values(settings?.hooks || {})) {
333
+ if (!Array.isArray(eventEntries)) continue;
334
+ for (const entry of eventEntries) {
335
+ for (const hook of entry?.hooks || []) {
336
+ if (typeof hook?.command === 'string' && /dashclaw/i.test(hook.command)) {
337
+ commands.push(hook.command);
338
+ }
339
+ }
340
+ }
341
+ }
342
+ if (commands.length === 0) {
343
+ return check('local_hooks_trust', 'local-machine', 'pass', 'DashClaw Claude hooks', 'No DashClaw hooks installed (skipped)');
344
+ }
345
+
346
+ const missing = [];
347
+ for (const command of commands) {
348
+ for (const scriptPath of extractHookScriptPaths(command)) {
349
+ if (!ctx.fs.existsSync(scriptPath)) missing.push(scriptPath);
350
+ }
351
+ }
352
+ if (missing.length > 0) {
353
+ const installer = ctx.repoRoot
354
+ ? 'node scripts/install-hooks.mjs --global --governance'
355
+ : 'dashclaw install claude';
356
+ return check(
357
+ 'local_hooks_trust',
358
+ 'local-machine',
359
+ 'fail',
360
+ 'DashClaw Claude hooks',
361
+ `Hook script(s) missing: ${missing.join(', ')} — hooks silently no-op`,
362
+ { type: 'auto', description: `Re-run the hook installer (${installer})`, action: 'reinstall_hooks' },
363
+ );
364
+ }
365
+ return check('local_hooks_trust', 'local-machine', 'pass', 'DashClaw Claude hooks', `${commands.length} DashClaw hook(s) installed, scripts present`);
366
+ }
367
+
368
+ /**
369
+ * Standing enforcement-mode surface: `dashclaw install claude` writes
370
+ * DASHCLAW_HOOK_MODE=observe and announces it exactly once in the install
371
+ * output. Nothing else ever mentioned it again, so a deployment can sit in
372
+ * audit-only mode forever while the operator sees "Blocked by policy"-shaped
373
+ * log lines and believes blocks are real (observe prints "[observe] Would
374
+ * block" and lets the tool call proceed).
375
+ */
376
+ async function checkHookMode(ctx) {
377
+ const envPath = join(ctx.homedir, '.dashclaw', 'claude-hooks', '.env');
378
+ let content;
379
+ try {
380
+ content = ctx.fs.readFileSync(envPath, 'utf8');
381
+ } catch {
382
+ return null; // installer-managed hooks not present — nothing to report
383
+ }
384
+ // Env vars override the file, same precedence the hooks apply.
385
+ const fileMode = (content.match(/^\s*DASHCLAW_HOOK_MODE\s*=\s*(\S+)/m) || [])[1];
386
+ const mode = (process.env.DASHCLAW_HOOK_MODE || fileMode || 'enforce').toLowerCase();
387
+ if (mode === 'enforce') {
388
+ return check('local_hook_mode', 'local-machine', 'pass', 'Hook enforcement mode', 'DASHCLAW_HOOK_MODE=enforce — policy blocks and approval gates physically stop tool calls');
389
+ }
390
+ return check(
391
+ 'local_hook_mode',
392
+ 'local-machine',
393
+ 'warn',
394
+ 'Hook enforcement mode',
395
+ `DASHCLAW_HOOK_MODE=${mode} — hooks LOG decisions but do not stop anything: a "block" lets the tool call proceed. Set DASHCLAW_HOOK_MODE=enforce in ${envPath} when you are ready to enforce.`,
396
+ );
397
+ }
398
+
399
+ /** DETECT-ONLY: deleting user env vars is not trivially safe. */
400
+ async function checkEnvLeak(ctx) {
401
+ const names = new Set();
402
+ const sources = [];
403
+
404
+ if (ctx.platform === 'win32') {
405
+ const scopes = [
406
+ { args: ['query', 'HKCU\\Environment'], label: 'User' },
407
+ { args: ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment'], label: 'Machine' },
408
+ ];
409
+ for (const scope of scopes) {
410
+ let result;
411
+ try {
412
+ result = await ctx.exec('reg', scope.args, { timeout: 15_000 });
413
+ } catch {
414
+ continue;
415
+ }
416
+ if (result.code !== 0) continue;
417
+ for (const match of result.stdout.matchAll(/^\s+(DASHCLAW_\w+)\s+REG_/gim)) {
418
+ names.add(match[1]);
419
+ sources.push(`${match[1]} (${scope.label} scope)`);
420
+ }
421
+ }
422
+ if (names.size > 0) {
423
+ const removal = [...names]
424
+ .map((n) => `[Environment]::SetEnvironmentVariable('${n}', $null, 'User')`)
425
+ .join('; ');
426
+ return check(
427
+ 'local_env_leak',
428
+ 'local-machine',
429
+ 'warn',
430
+ 'Leaked DASHCLAW_* env',
431
+ `Machine/user-scope env vars can shadow per-project config: ${sources.join(', ')}. To remove (PowerShell): ${removal}. Removal is manual by design`,
432
+ );
433
+ }
434
+ } else {
435
+ const profiles = ['.bashrc', '.zshrc', '.profile', '.bash_profile'];
436
+ for (const profile of profiles) {
437
+ let content;
438
+ try {
439
+ content = ctx.fs.readFileSync(join(ctx.homedir, profile), 'utf8');
440
+ } catch {
441
+ continue;
442
+ }
443
+ for (const match of content.matchAll(/^\s*(?:export\s+)?(DASHCLAW_\w+)\s*=/gm)) {
444
+ names.add(match[1]);
445
+ sources.push(`${match[1]} (~/${profile})`);
446
+ }
447
+ }
448
+ if (names.size > 0) {
449
+ return check(
450
+ 'local_env_leak',
451
+ 'local-machine',
452
+ 'warn',
453
+ 'Leaked DASHCLAW_* env',
454
+ `Shell-profile env vars can shadow per-project config: ${sources.join(', ')}. Remove the export lines from the listed profile(s) and restart your shell. Removal is manual by design`,
455
+ );
456
+ }
457
+ }
458
+
459
+ return check('local_env_leak', 'local-machine', 'pass', 'Leaked DASHCLAW_* env', 'No machine-scope DASHCLAW_* env vars found');
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // Runner + fixes
464
+ // ---------------------------------------------------------------------------
465
+
466
+ /**
467
+ * Run every applicable local check. Repo-aware checks run only when
468
+ * ctx.repoRoot is a DashClaw checkout; machine checks always run.
469
+ */
470
+ export async function runLocalChecks(ctx) {
471
+ const checks = [];
472
+
473
+ if (ctx.repoRoot) {
474
+ for (const runner of [checkMcpLibStale, checkGitattributesDrift, checkSchemaBehind, checkOpenclawPlugin]) {
475
+ try {
476
+ const result = await runner(ctx);
477
+ if (result) checks.push(result);
478
+ } catch (err) {
479
+ checks.push(check(runner.name, 'local-repo', 'warn', runner.name, `Check errored: ${err.message}`));
480
+ }
481
+ }
482
+ }
483
+
484
+ for (const runner of [checkCliShimStale, checkHooksTrust, checkHookMode, checkEnvLeak]) {
485
+ try {
486
+ const result = await runner(ctx);
487
+ if (result) checks.push(result);
488
+ } catch (err) {
489
+ checks.push(check(runner.name, 'local-machine', 'warn', runner.name, `Check errored: ${err.message}`));
490
+ }
491
+ }
492
+
493
+ return checks;
494
+ }
495
+
496
+ const LOCAL_FIX_HANDLERS = {
497
+ rebuild_mcp_lib: async (ctx) => {
498
+ const result = await ctx.exec('npm', ['run', 'build'], {
499
+ cwd: join(ctx.repoRoot, 'mcp-server'),
500
+ shell: ctx.platform === 'win32',
501
+ timeout: 300_000,
502
+ });
503
+ return result.code === 0
504
+ ? { applied: true, description: 'Rebuilt mcp-server/lib from src' }
505
+ : { applied: false, description: `mcp-server build failed: ${(result.stderr || result.stdout).slice(0, 200)}` };
506
+ },
507
+ restore_gitattributes: async (ctx) => {
508
+ const result = await ctx.exec('git', ['checkout', '--', '.gitattributes'], { cwd: ctx.repoRoot });
509
+ return result.code === 0
510
+ ? { applied: true, description: 'Restored .gitattributes from the index' }
511
+ : { applied: false, description: `git checkout failed: ${(result.stderr || '').slice(0, 200)}` };
512
+ },
513
+ run_db_migrate: async (ctx) => {
514
+ const result = await ctx.exec('npm', ['run', 'db:migrate'], {
515
+ cwd: ctx.repoRoot,
516
+ shell: ctx.platform === 'win32',
517
+ timeout: 300_000,
518
+ });
519
+ return result.code === 0
520
+ ? { applied: true, description: 'Applied pending schema via npm run db:migrate' }
521
+ : { applied: false, description: `db:migrate failed: ${(result.stderr || result.stdout).slice(0, 200)}` };
522
+ },
523
+ reinstall_cli: async (ctx) => {
524
+ const result = await ctx.exec('npm', ['i', '-g', '@dashclaw/cli'], {
525
+ shell: ctx.platform === 'win32',
526
+ timeout: 300_000,
527
+ });
528
+ return result.code === 0
529
+ ? { applied: true, description: 'Reinstalled the global @dashclaw/cli' }
530
+ : { applied: false, description: `npm i -g failed: ${(result.stderr || result.stdout).slice(0, 200)}` };
531
+ },
532
+ reinstall_hooks: async (ctx) => {
533
+ const result = ctx.repoRoot
534
+ ? await ctx.exec(process.execPath ?? 'node', ['scripts/install-hooks.mjs', '--global', '--governance'], {
535
+ cwd: ctx.repoRoot,
536
+ timeout: 120_000,
537
+ })
538
+ : await ctx.exec('dashclaw', ['install', 'claude'], { shell: ctx.platform === 'win32', timeout: 120_000 });
539
+ return result.code === 0
540
+ ? { applied: true, description: 'Re-ran the DashClaw hook installer' }
541
+ : { applied: false, description: `Hook installer failed: ${(result.stderr || result.stdout).slice(0, 200)}` };
542
+ },
543
+ };
544
+
545
+ /**
546
+ * Apply local auto-fixes for failing checks. Returns one result per attempted
547
+ * fix: { id, action, applied, description }.
548
+ */
549
+ export async function applyLocalFixes(checks, ctx) {
550
+ const results = [];
551
+ for (const item of checks) {
552
+ if (!item?.fix || item.fix.type !== 'auto') continue;
553
+ const handler = LOCAL_FIX_HANDLERS[item.fix.action];
554
+ if (!handler) continue;
555
+ try {
556
+ const result = await handler(ctx);
557
+ results.push({ id: item.id, action: item.fix.action, ...result });
558
+ } catch (err) {
559
+ results.push({ id: item.id, action: item.fix.action, applied: false, description: `Fix errored: ${err.message}` });
560
+ }
561
+ }
562
+ return results;
563
+ }