@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 +10 -6
- package/bin/dashclaw.js +13 -3
- package/lib/doctor.js +191 -90
- package/lib/local-doctor.js +532 -0
- package/package.json +1 -1
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
|
|
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 #
|
|
90
|
-
dashclaw doctor --
|
|
91
|
-
dashclaw doctor --
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = [
|
|
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
|
}
|
|
@@ -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
|
+
}
|