@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/README.md +210 -179
- package/bin/dashclaw.js +99 -4
- package/lib/config.js +27 -0
- package/lib/doctor.js +191 -90
- package/lib/local-doctor.js +532 -0
- package/lib/up/args.js +25 -0
- package/lib/up/db.js +160 -0
- package/lib/up/fetch-app.js +82 -0
- package/lib/up/index.js +325 -0
- package/lib/up/instance.js +46 -0
- package/lib/up/run.js +52 -0
- package/package.json +36 -34
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 = [
|
|
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, {
|
|
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 (
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
126
|
+
res = await fetchImpl(doctorUrl(base, category), { headers });
|
|
86
127
|
} catch (err) {
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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,
|
|
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, {
|
|
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,
|
|
193
|
+
function renderGroupedChecks(checks, fixMode) {
|
|
143
194
|
const grouped = groupChecks(checks);
|
|
144
|
-
|
|
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,
|
|
205
|
+
renderCheck(check, fixMode);
|
|
151
206
|
}
|
|
152
207
|
console.log();
|
|
153
208
|
}
|
|
154
209
|
}
|
|
155
210
|
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
225
|
+
async function applyRemoteFix({ base, headers, check, fetchImpl }) {
|
|
161
226
|
try {
|
|
162
|
-
const fixRes = await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
212
|
-
if (
|
|
213
|
-
|
|
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')} ${
|
|
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
|
-
*
|
|
240
|
-
*
|
|
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
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
312
|
+
const ctx = local.buildContext({ cliVersion });
|
|
313
|
+
ctx.repoRoot = repo || local.detectRepoRoot({ cwd: ctx.cwd, fs: ctx.fs });
|
|
249
314
|
|
|
250
|
-
//
|
|
251
|
-
|
|
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
|
-
|
|
254
|
-
|
|
322
|
+
let report = mergeReport(remote.result, remote.degraded, localChecks);
|
|
323
|
+
let fixResults = [];
|
|
255
324
|
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
}
|