@dashclaw/cli 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  }