@dashclaw/cli 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,16 +83,20 @@ Outputs an aligned table (total, sessions, cache savings, by-project breakdown f
83
83
 
84
84
  ### `dashclaw doctor`
85
85
 
86
- Diagnose your DashClaw instance and auto-fix safe issues. Checks database, configuration, auth, deployment, SDK reachability, and governance.
86
+ Diagnose your DashClaw instance **and this machine**. Remote checks cover database, configuration, auth, deployment, SDK reachability, governance, and data hygiene; local checks cover what the server can't see — a stale compiled `mcp-server/lib`, `.gitattributes` drift, a local DB schema behind code, a disabled OpenClaw runtime plugin, a stale global CLI shim, broken Claude hook installs, and leaked machine-scope `DASHCLAW_*` env vars.
87
87
 
88
88
  ```bash
89
- dashclaw doctor # rich terminal output, auto-fix what it can
90
- dashclaw doctor --json # JSON output for CI/scripts
91
- dashclaw doctor --no-fix # diagnose only
92
- dashclaw doctor --category database,config
89
+ dashclaw doctor # report-only (DEFAULT applies nothing)
90
+ dashclaw doctor --fix # apply safe auto-fixes, re-check, report what changed
91
+ dashclaw doctor --json # JSON output for CI/scripts (includes local checks)
92
+ dashclaw doctor --category database,config # filter remote checks
93
+ dashclaw doctor --repo /path/to/dashclaw # point repo checks at a checkout
94
+ dashclaw doctor --no-fix # accepted no-op alias (report-only is the default)
93
95
  ```
94
96
 
95
- The CLI invokes your instance's `/api/doctor` endpoints, so fixes that need filesystem access (env writes) are handled separately by self-hosters running `npm run doctor` locally.
97
+ > **Changed in 0.4.0:** `dashclaw doctor` no longer applies remote auto-fixes by default it reports and prints would-fix entries. Pass `--fix` to apply. Exit codes are unchanged (0 healthy, 1 otherwise).
98
+
99
+ Detect-only classes are never auto-fixed: leaked machine env vars (removal instructions printed) and OpenClaw gateway configs (remediation text printed). The `.gitattributes` restore runs only when the diff is provably line-ending/whitespace-only. Remote fixes go through `POST /api/doctor/fix`; fixes that need server filesystem access remain self-hoster territory via `npm run doctor` (also report-only by default now, same `--fix` opt-in).
96
100
 
97
101
  ### `dashclaw code`
98
102
 
package/bin/dashclaw.js CHANGED
@@ -81,10 +81,12 @@ ${bold('Usage:')}
81
81
  dashclaw approvals Interactive approval inbox
82
82
  dashclaw approve <actionId> [--reason] Approve an action
83
83
  dashclaw deny <actionId> [--reason] Deny an action
84
- dashclaw doctor Diagnose and auto-fix your DashClaw instance
84
+ dashclaw doctor Diagnose your instance + this machine (report-only)
85
+ --fix Apply safe auto-fixes, then re-check and report
85
86
  --json Output as JSON (for CI/scripts)
86
- --no-fix Diagnose only, skip auto-fixes
87
- --category <list> Filter checks (e.g., database,config)
87
+ --no-fix Accepted no-op alias (report-only is the default)
88
+ --category <list> Filter remote checks (e.g., database,config)
89
+ --repo <path> Treat <path> as the DashClaw checkout for repo checks
88
90
  dashclaw code ingest [--dry-run] Backfill Claude Code transcripts from ~/.claude/projects
89
91
  --projects-dir <path> Override the default scan directory
90
92
  dashclaw code ingest-codex [--dry-run] Backfill Codex transcripts from ~/.codex/sessions
@@ -164,15 +166,23 @@ async function cmdLogout() {
164
166
 
165
167
  async function cmdDoctor() {
166
168
  const jsonFlag = args.includes('--json');
169
+ const fixFlag = args.includes('--fix');
167
170
  const noFixFlag = args.includes('--no-fix');
168
171
  const catIdx = args.indexOf('--category');
169
172
  const catValue = catIdx !== -1 ? args[catIdx + 1] : undefined;
173
+ const repoIdx = args.indexOf('--repo');
174
+ const repoValue = repoIdx !== -1 ? args[repoIdx + 1] : undefined;
175
+ const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
176
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
170
177
  await runDoctorCommand({
171
178
  baseUrl,
172
179
  apiKey,
173
180
  json: jsonFlag,
181
+ fix: fixFlag,
174
182
  noFix: noFixFlag,
175
183
  category: catValue,
184
+ repo: repoValue,
185
+ cliVersion: pkg.version,
176
186
  });
177
187
  }
178
188
 
package/lib/doctor.js CHANGED
@@ -1,5 +1,11 @@
1
1
  // cli/lib/doctor.js
2
2
  import { bold, dim, green, yellow, red } from './render.js';
3
+ import {
4
+ buildContext,
5
+ detectRepoRoot,
6
+ runLocalChecks,
7
+ applyLocalFixes,
8
+ } from './local-doctor.js';
3
9
 
4
10
  // Brand orange (256-color ANSI). Used sparingly — header + key accents only.
5
11
  const BRAND = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
@@ -19,9 +25,17 @@ const CATEGORY_LABELS = {
19
25
  governance: 'Governance',
20
26
  shape: 'Shape (generated)',
21
27
  drift: 'Drift',
28
+ 'openclaw-plugin': 'OpenClaw Plugin',
29
+ hosted: 'Hosted',
30
+ 'data-hygiene': 'Data Hygiene',
31
+ 'local-repo': 'Local Repo (this machine)',
32
+ 'local-machine': 'Machine Setup (this machine)',
22
33
  };
23
34
 
24
- const CATEGORY_ORDER = ['database', 'config', 'auth', 'deployment', 'sdk', 'governance', 'shape', 'drift'];
35
+ const CATEGORY_ORDER = [
36
+ 'database', 'config', 'auth', 'deployment', 'sdk', 'governance', 'shape', 'drift',
37
+ 'openclaw-plugin', 'hosted', 'data-hygiene', 'local-repo', 'local-machine',
38
+ ];
25
39
 
26
40
  // Next-step guidance by check ID, used when the check has no auto-fix.
27
41
  // Keep each line short and actionable.
@@ -36,6 +50,8 @@ const GUIDANCE = {
36
50
  sdk_auth: 'API key rejected — verify DASHCLAW_API_KEY matches the key on your instance.',
37
51
  gov_actions: 'Send your first governed action with claw.guard() via the SDK.',
38
52
  gov_stale: "Agents haven't reported in 7 days — check your agent pairings.",
53
+ remote_unreachable: 'Check DASHCLAW_BASE_URL and confirm your instance is deployed and awake.',
54
+ remote_auth: 'API key rejected — verify DASHCLAW_API_KEY matches the key on your instance.',
39
55
  };
40
56
 
41
57
  function hr(width = 72) {
@@ -44,16 +60,22 @@ function hr(width = 72) {
44
60
 
45
61
  /**
46
62
  * Return an actionable next-step string for a non-passing check, or null.
63
+ * In report-only mode (the default), fixable checks point at --fix.
47
64
  */
48
- function nextStepFor(check, { noFix }) {
65
+ function nextStepFor(check, { fixMode }) {
49
66
  if (!check || check.status === 'pass') return null;
50
67
 
51
68
  // Auto-fixable check: direct the user to the fix.
52
69
  if (check.fix?.type === 'auto') {
53
- if (noFix) {
54
- return `${check.fix.description}. Re-run without ${bold('--no-fix')} to apply.`;
70
+ if (!isFixableByCli(check)) {
71
+ // Remote warn-status fix — POST /api/doctor/fix only applies on fail, so
72
+ // --fix won't attempt it; don't promise what this CLI won't do.
73
+ return `${check.fix.description} — run ${bold('npm run doctor -- --fix')} on the instance host.`;
55
74
  }
56
- // Fix was attempted (or will run in this invocation); describe what it does.
75
+ if (!fixMode) {
76
+ return `would fix: ${check.fix.description}. Run ${bold('dashclaw doctor --fix')} to apply.`;
77
+ }
78
+ // Fix runs in this invocation; describe what it does.
57
79
  return check.fix.description + '.';
58
80
  }
59
81
 
@@ -71,42 +93,71 @@ function doctorHeaders(apiKey) {
71
93
  return { 'Content-Type': 'application/json', 'x-api-key': apiKey };
72
94
  }
73
95
 
74
- function exitFetchError(base, err) {
75
- console.error(red(`\nError: Could not reach DashClaw at ${base}`));
76
- console.error(dim(` ${err.cause?.code || err.message}`));
77
- console.error(dim(` Check DASHCLAW_BASE_URL and confirm your instance is running.\n`));
78
- process.exit(1);
96
+ function unreachableCheck(base, detail) {
97
+ return {
98
+ id: 'remote_unreachable',
99
+ category: 'sdk',
100
+ status: 'fail',
101
+ title: 'Instance Reachable',
102
+ message: `Could not reach DashClaw at ${base} (${detail})`,
103
+ fix: null,
104
+ };
105
+ }
106
+
107
+ function authRejectedCheck(base, status) {
108
+ return {
109
+ id: 'remote_auth',
110
+ category: 'sdk',
111
+ status: 'fail',
112
+ title: 'API Key Accepted',
113
+ message: `API key rejected by ${base} (${status})`,
114
+ fix: null,
115
+ };
79
116
  }
80
117
 
81
- async function fetchDoctorResult({ base, apiKey, category }) {
118
+ /**
119
+ * Fetch /api/doctor, degrading to a synthetic fail check instead of exiting —
120
+ * local checks still run and report when the instance is down.
121
+ */
122
+ async function fetchRemoteResult({ base, apiKey, category, fetchImpl }) {
82
123
  const headers = doctorHeaders(apiKey);
83
124
  let res;
84
125
  try {
85
- res = await fetch(doctorUrl(base, category), { headers });
126
+ res = await fetchImpl(doctorUrl(base, category), { headers });
86
127
  } catch (err) {
87
- exitFetchError(base, err);
128
+ return { result: null, degraded: unreachableCheck(base, err.cause?.code || err.message), headers };
88
129
  }
89
- await handleDoctorResponse(base, res);
90
- return { result: await res.json(), headers };
91
- }
92
130
 
93
- async function handleDoctorResponse(base, res) {
94
131
  if (res.status === 401 || res.status === 403) {
95
- console.error(red(`\nError: API key rejected by ${base} (${res.status}).`));
96
- console.error(dim(` Check DASHCLAW_API_KEY matches the key on your instance.\n`));
97
- process.exit(1);
132
+ return { result: null, degraded: authRejectedCheck(base, res.status), headers };
98
133
  }
99
-
100
134
  if (!res.ok && res.status !== 503) {
101
135
  const errText = await res.text().catch(() => '');
102
- console.error(red(`Doctor check failed (${res.status}): ${errText}`));
103
- process.exit(1);
136
+ return { result: null, degraded: unreachableCheck(base, `${res.status} ${errText.slice(0, 120)}`), headers };
137
+ }
138
+
139
+ try {
140
+ return { result: await res.json(), degraded: null, headers };
141
+ } catch (err) {
142
+ return { result: null, degraded: unreachableCheck(base, `unparseable response: ${err.message}`), headers };
143
+ }
144
+ }
145
+
146
+ function computeSummary(checks) {
147
+ const summary = { pass: 0, warn: 0, fail: 0 };
148
+ for (const check of checks) {
149
+ if (check.status in summary) summary[check.status]++;
104
150
  }
151
+ return summary;
105
152
  }
106
153
 
107
- function exitJson(result) {
108
- console.log(JSON.stringify(result, null, 2));
109
- process.exit(result.status === 'healthy' ? 0 : 1);
154
+ /** Merge remote result + local checks into one report (engine status semantics). */
155
+ function mergeReport(remoteResult, degradedCheck, localChecks) {
156
+ const remoteChecks = remoteResult?.checks || [];
157
+ const checks = [...remoteChecks, ...(degradedCheck ? [degradedCheck] : []), ...localChecks];
158
+ const summary = computeSummary(checks);
159
+ const status = summary.fail > 0 ? 'unhealthy' : summary.warn > 0 ? 'needs_attention' : 'healthy';
160
+ return { status, summary, checks, timestamp: remoteResult?.timestamp || new Date().toISOString() };
110
161
  }
111
162
 
112
163
  function renderHeader(base) {
@@ -125,73 +176,86 @@ function groupChecks(checks) {
125
176
  return grouped;
126
177
  }
127
178
 
128
- function renderCheck(check, noFix) {
179
+ function renderCheck(check, fixMode) {
129
180
  const icon = ICONS[check.status] || '?';
130
181
  const titleStyled = check.status === 'pass' ? check.title : bold(check.title);
131
182
  console.log(` ${icon} ${titleStyled}`);
132
183
 
133
184
  if (check.status !== 'pass') {
134
185
  console.log(` ${dim(check.message)}`);
135
- const tip = nextStepFor(check, { noFix });
186
+ const tip = nextStepFor(check, { fixMode });
136
187
  if (tip) {
137
188
  console.log(` ${dim('\u2192')} ${tip}`);
138
189
  }
139
190
  }
140
191
  }
141
192
 
142
- function renderGroupedChecks(checks, noFix) {
193
+ function renderGroupedChecks(checks, fixMode) {
143
194
  const grouped = groupChecks(checks);
144
- for (const cat of CATEGORY_ORDER) {
195
+ const orderedCategories = [
196
+ ...CATEGORY_ORDER,
197
+ ...Object.keys(grouped).filter((c) => !CATEGORY_ORDER.includes(c)),
198
+ ];
199
+ for (const cat of orderedCategories) {
145
200
  const categoryChecks = grouped[cat];
146
201
  if (!categoryChecks || categoryChecks.length === 0) continue;
147
202
 
148
203
  console.log(` ${bold(CATEGORY_LABELS[cat] || cat)}`);
149
204
  for (const check of categoryChecks) {
150
- renderCheck(check, noFix);
205
+ renderCheck(check, fixMode);
151
206
  }
152
207
  console.log();
153
208
  }
154
209
  }
155
210
 
156
- function autoFixableChecks(result) {
157
- return result.checks.filter((c) => c.status === 'fail' && c.fix?.type === 'auto');
211
+ /**
212
+ * A check this invocation's --fix would actually attempt: any non-pass local
213
+ * check with an auto fix, or a remote FAIL with an auto fix (the remote apply
214
+ * path posts fail-status checks only — warn-status remote fixes are server-side).
215
+ */
216
+ function isFixableByCli(check) {
217
+ if (!check?.fix || check.fix.type !== 'auto' || check.status === 'pass') return false;
218
+ return check.local ? true : check.status === 'fail';
219
+ }
220
+
221
+ function fixableChecks(checks) {
222
+ return checks.filter(isFixableByCli);
158
223
  }
159
224
 
160
- async function applyDoctorFix({ base, headers, check }) {
225
+ async function applyRemoteFix({ base, headers, check, fetchImpl }) {
161
226
  try {
162
- const fixRes = await fetch(`${base}/api/doctor/fix`, {
227
+ const fixRes = await fetchImpl(`${base}/api/doctor/fix`, {
163
228
  method: 'POST',
164
229
  headers,
165
230
  body: JSON.stringify({ action: check.fix.action }),
166
231
  });
167
232
  const fixResult = await fixRes.json();
168
- if (fixResult.applied) {
169
- console.log(` ${green('\u2192')} Fixed: ${fixResult.description}`);
170
- return { fixed: 1, recheck: fixResult.recheck || null };
171
- }
172
- console.log(` ${dim('\u2192')} Skipped: ${fixResult.description}`);
233
+ return { id: check.id, action: check.fix.action, applied: !!fixResult.applied, description: fixResult.description };
173
234
  } catch (err) {
174
- console.log(` ${red('\u2717')} Fix "${check.fix.action}" failed: ${err.cause?.code || err.message}`);
235
+ return {
236
+ id: check.id,
237
+ action: check.fix.action,
238
+ applied: false,
239
+ description: `Fix request failed: ${err.cause?.code || err.message}`,
240
+ };
175
241
  }
176
- return { fixed: 0, recheck: null };
177
242
  }
178
243
 
179
- async function applyAutoFixes({ base, headers, result, noFix }) {
180
- let fixCount = 0;
181
- let latestRecheck = null;
182
- if (noFix) return { fixCount, latestRecheck };
183
-
184
- const fixable = autoFixableChecks(result);
185
- if (fixable.length === 0) return { fixCount, latestRecheck };
186
-
187
- console.log(` ${bold('Applying auto-fixes...')}`);
188
- for (const check of fixable) {
189
- const outcome = await applyDoctorFix({ base, headers, check });
190
- fixCount += outcome.fixed;
191
- latestRecheck = outcome.recheck || latestRecheck;
244
+ function renderWhatChanged(fixResults) {
245
+ console.log(` ${bold('What changed')}`);
246
+ if (fixResults.length === 0) {
247
+ console.log(` ${dim('Nothing \u2014 no auto-fixable issues found.')}`);
248
+ console.log();
249
+ return;
250
+ }
251
+ for (const result of fixResults) {
252
+ if (result.applied) {
253
+ console.log(` ${green('\u2713')} Applied ${bold(result.action)}: ${result.description}`);
254
+ } else {
255
+ console.log(` ${yellow('\u26a0')} Skipped ${bold(result.action)}: ${result.description}`);
256
+ }
192
257
  }
193
258
  console.log();
194
- return { fixCount, latestRecheck };
195
259
  }
196
260
 
197
261
  function renderSummary(reporting) {
@@ -208,20 +272,12 @@ function renderSummary(reporting) {
208
272
  console.log();
209
273
  }
210
274
 
211
- function renderFixCount(fixCount) {
212
- if (fixCount > 0) {
213
- console.log(` ${green('\u2713')} ${fixCount} issue${fixCount !== 1 ? 's' : ''} auto-fixed this run.`);
214
- }
215
- }
216
-
217
- function renderNoFixHint(result, noFix) {
218
- if (!noFix) return;
219
- const autoFixable = result.checks.filter(
220
- (c) => c.status !== 'pass' && c.fix?.type === 'auto',
221
- ).length;
222
- if (autoFixable > 0) {
275
+ function renderFixHint(report, fixMode) {
276
+ if (fixMode) return;
277
+ const fixable = fixableChecks(report.checks).length;
278
+ if (fixable > 0) {
223
279
  console.log(
224
- ` ${BRAND('\u2192')} ${autoFixable} issue${autoFixable !== 1 ? 's' : ''} can be auto-fixed. Run: ${bold('dashclaw doctor')}`,
280
+ ` ${BRAND('\u2192')} ${fixable} issue${fixable !== 1 ? 's' : ''} can be auto-fixed. Run: ${bold('dashclaw doctor --fix')}`,
225
281
  );
226
282
  }
227
283
  }
@@ -236,35 +292,80 @@ function renderHealthFooter(base, healthy) {
236
292
  }
237
293
 
238
294
  /**
239
- * Run doctor via the API and render results.
240
- * @param {{ baseUrl: string, apiKey: string, json?: boolean, noFix?: boolean, category?: string }} options
295
+ * Doctor flow: merged local + remote report; report-only by default; --fix
296
+ * applies local fixes locally and remote auto-fixes via POST /api/doctor/fix,
297
+ * re-checks, and prints a what-changed report. Returns the exit code.
298
+ *
299
+ * @param {{ baseUrl: string, apiKey: string, json?: boolean, fix?: boolean,
300
+ * noFix?: boolean, category?: string, repo?: string, cliVersion?: string }} options
301
+ * @param {{ fetchImpl?: typeof fetch, local?: object }} [deps] - injectable for tests
241
302
  */
242
- export async function runDoctor({ baseUrl, apiKey, json, noFix, category }) {
303
+ export async function runDoctorFlow(options, deps = {}) {
304
+ const { baseUrl, apiKey, json, fix, noFix, category, repo, cliVersion = '0.0.0' } = options;
305
+ const fetchImpl = deps.fetchImpl || fetch;
306
+ const local = deps.local || { buildContext, detectRepoRoot, runLocalChecks, applyLocalFixes };
307
+
308
+ // --no-fix is a no-op alias for the (report-only) default; it wins over --fix.
309
+ const fixMode = !!fix && !noFix;
243
310
  const base = baseUrl.replace(/\/+$/, '');
244
- const { result, headers } = await fetchDoctorResult({ base, apiKey, category });
245
311
 
246
- if (json) {
247
- exitJson(result);
248
- }
312
+ const ctx = local.buildContext({ cliVersion });
313
+ ctx.repoRoot = repo || local.detectRepoRoot({ cwd: ctx.cwd, fs: ctx.fs });
249
314
 
250
- // --- Header ----------------------------------------------------------------
251
- renderHeader(base);
315
+ // Local checks run concurrently with the remote fetch — added wall-clock is
316
+ // max(local, remote) - remote, not the sum.
317
+ const [remote, localChecks] = await Promise.all([
318
+ fetchRemoteResult({ base, apiKey, category, fetchImpl }),
319
+ local.runLocalChecks(ctx),
320
+ ]);
252
321
 
253
- // --- Grouped checks --------------------------------------------------------
254
- renderGroupedChecks(result.checks, noFix);
322
+ let report = mergeReport(remote.result, remote.degraded, localChecks);
323
+ let fixResults = [];
255
324
 
256
- // --- Auto-fix (remote fixes only; local-only fixes blocked by API) --------
257
- const { fixCount, latestRecheck } = await applyAutoFixes({ base, headers, result, noFix });
325
+ if (fixMode) {
326
+ const localFixable = fixableChecks(localChecks);
327
+ const remoteFixable = (remote.result?.checks || []).filter(
328
+ (c) => c.status === 'fail' && c.fix?.type === 'auto',
329
+ );
258
330
 
259
- // --- Summary ---------------------------------------------------------------
260
- const reporting = latestRecheck || result;
261
- renderSummary(reporting);
331
+ fixResults = await local.applyLocalFixes(localFixable, ctx);
332
+ for (const check of remoteFixable) {
333
+ fixResults.push(await applyRemoteFix({ base, headers: remote.headers, check, fetchImpl }));
334
+ }
262
335
 
263
- // --- Contextual footer -----------------------------------------------------
264
- const healthy = reporting.status === 'healthy';
265
- renderFixCount(fixCount);
266
- renderNoFixHint(result, noFix);
336
+ // Re-check after applying so the report reflects the new state.
337
+ if (fixResults.some((r) => r.applied)) {
338
+ const [remote2, localChecks2] = await Promise.all([
339
+ fetchRemoteResult({ base, apiKey, category, fetchImpl }),
340
+ local.runLocalChecks(ctx),
341
+ ]);
342
+ report = mergeReport(remote2.result, remote2.degraded, localChecks2);
343
+ }
344
+ }
345
+
346
+ const healthy = report.status === 'healthy';
347
+
348
+ if (json) {
349
+ console.log(JSON.stringify(fixMode ? { ...report, fixes: fixResults } : report, null, 2));
350
+ return healthy ? 0 : 1;
351
+ }
352
+
353
+ renderHeader(base);
354
+ renderGroupedChecks(report.checks, fixMode);
355
+ if (fixMode) renderWhatChanged(fixResults);
356
+ renderSummary(report);
357
+ renderFixHint(report, fixMode);
267
358
  renderHealthFooter(base, healthy);
268
359
 
269
- process.exit(healthy ? 0 : 1);
360
+ return healthy ? 0 : 1;
361
+ }
362
+
363
+ /**
364
+ * Run doctor and exit with its code (CLI entry).
365
+ * @param {{ baseUrl: string, apiKey: string, json?: boolean, fix?: boolean,
366
+ * noFix?: boolean, category?: string, repo?: string, cliVersion?: string }} options
367
+ */
368
+ export async function runDoctor(options) {
369
+ const code = await runDoctorFlow(options);
370
+ process.exit(code);
270
371
  }
@@ -0,0 +1,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
+ /** 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dashclaw/cli",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "DashClaw terminal client — approve agent actions and diagnose your instance",
5
5
  "type": "module",
6
6
  "keywords": [