@dashclaw/cli 0.3.1 → 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 +86 -7
- package/bin/dashclaw.js +495 -367
- package/lib/claude/install.js +386 -0
- package/lib/code/ingest.js +17 -1
- package/lib/config.js +2 -2
- package/lib/cost.js +108 -0
- package/lib/doctor.js +255 -93
- package/lib/env.js +69 -0
- package/lib/local-doctor.js +532 -0
- package/package.json +34 -21
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
|
|
|
@@ -61,114 +83,183 @@ function nextStepFor(check, { noFix }) {
|
|
|
61
83
|
return GUIDANCE[check.id] || null;
|
|
62
84
|
}
|
|
63
85
|
|
|
64
|
-
|
|
65
|
-
* Run doctor via the API and render results.
|
|
66
|
-
* @param {{ baseUrl: string, apiKey: string, json?: boolean, noFix?: boolean, category?: string }} options
|
|
67
|
-
*/
|
|
68
|
-
export async function runDoctor({ baseUrl, apiKey, json, noFix, category }) {
|
|
69
|
-
const base = baseUrl.replace(/\/+$/, '');
|
|
70
|
-
const headers = { 'Content-Type': 'application/json', 'x-api-key': apiKey };
|
|
71
|
-
|
|
86
|
+
function doctorUrl(base, category) {
|
|
72
87
|
let url = `${base}/api/doctor?include_fixes=true`;
|
|
73
88
|
if (category) url += `&category=${encodeURIComponent(category)}`;
|
|
89
|
+
return url;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function doctorHeaders(apiKey) {
|
|
93
|
+
return { 'Content-Type': 'application/json', 'x-api-key': apiKey };
|
|
94
|
+
}
|
|
95
|
+
|
|
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
|
+
}
|
|
74
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
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
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 }) {
|
|
123
|
+
const headers = doctorHeaders(apiKey);
|
|
75
124
|
let res;
|
|
76
125
|
try {
|
|
77
|
-
res = await
|
|
126
|
+
res = await fetchImpl(doctorUrl(base, category), { headers });
|
|
78
127
|
} catch (err) {
|
|
79
|
-
|
|
80
|
-
console.error(dim(` ${err.cause?.code || err.message}`));
|
|
81
|
-
console.error(dim(` Check DASHCLAW_BASE_URL and confirm your instance is running.\n`));
|
|
82
|
-
process.exit(1);
|
|
128
|
+
return { result: null, degraded: unreachableCheck(base, err.cause?.code || err.message), headers };
|
|
83
129
|
}
|
|
84
130
|
|
|
85
131
|
if (res.status === 401 || res.status === 403) {
|
|
86
|
-
|
|
87
|
-
console.error(dim(` Check DASHCLAW_API_KEY matches the key on your instance.\n`));
|
|
88
|
-
process.exit(1);
|
|
132
|
+
return { result: null, degraded: authRejectedCheck(base, res.status), headers };
|
|
89
133
|
}
|
|
90
|
-
|
|
91
134
|
if (!res.ok && res.status !== 503) {
|
|
92
135
|
const errText = await res.text().catch(() => '');
|
|
93
|
-
|
|
94
|
-
process.exit(1);
|
|
136
|
+
return { result: null, degraded: unreachableCheck(base, `${res.status} ${errText.slice(0, 120)}`), headers };
|
|
95
137
|
}
|
|
96
138
|
|
|
97
|
-
|
|
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
|
+
}
|
|
98
145
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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]++;
|
|
102
150
|
}
|
|
151
|
+
return summary;
|
|
152
|
+
}
|
|
153
|
+
|
|
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() };
|
|
161
|
+
}
|
|
103
162
|
|
|
104
|
-
|
|
163
|
+
function renderHeader(base) {
|
|
105
164
|
const hostLabel = base.replace(/^https?:\/\//, '');
|
|
106
165
|
console.log();
|
|
107
166
|
console.log(` ${BRAND('[DashClaw]')} ${bold('Doctor')} ${dim(hostLabel)}`);
|
|
108
167
|
console.log();
|
|
168
|
+
}
|
|
109
169
|
|
|
110
|
-
|
|
170
|
+
function groupChecks(checks) {
|
|
111
171
|
const grouped = {};
|
|
112
|
-
for (const check of
|
|
172
|
+
for (const check of checks) {
|
|
113
173
|
if (!grouped[check.category]) grouped[check.category] = [];
|
|
114
174
|
grouped[check.category].push(check);
|
|
115
175
|
}
|
|
176
|
+
return grouped;
|
|
177
|
+
}
|
|
116
178
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
179
|
+
function renderCheck(check, fixMode) {
|
|
180
|
+
const icon = ICONS[check.status] || '?';
|
|
181
|
+
const titleStyled = check.status === 'pass' ? check.title : bold(check.title);
|
|
182
|
+
console.log(` ${icon} ${titleStyled}`);
|
|
183
|
+
|
|
184
|
+
if (check.status !== 'pass') {
|
|
185
|
+
console.log(` ${dim(check.message)}`);
|
|
186
|
+
const tip = nextStepFor(check, { fixMode });
|
|
187
|
+
if (tip) {
|
|
188
|
+
console.log(` ${dim('\u2192')} ${tip}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function renderGroupedChecks(checks, fixMode) {
|
|
194
|
+
const grouped = groupChecks(checks);
|
|
195
|
+
const orderedCategories = [
|
|
196
|
+
...CATEGORY_ORDER,
|
|
197
|
+
...Object.keys(grouped).filter((c) => !CATEGORY_ORDER.includes(c)),
|
|
198
|
+
];
|
|
199
|
+
for (const cat of orderedCategories) {
|
|
200
|
+
const categoryChecks = grouped[cat];
|
|
201
|
+
if (!categoryChecks || categoryChecks.length === 0) continue;
|
|
120
202
|
|
|
121
203
|
console.log(` ${bold(CATEGORY_LABELS[cat] || cat)}`);
|
|
122
|
-
for (const check of
|
|
123
|
-
|
|
124
|
-
const titleStyled = check.status === 'pass' ? check.title : bold(check.title);
|
|
125
|
-
console.log(` ${icon} ${titleStyled}`);
|
|
126
|
-
|
|
127
|
-
if (check.status !== 'pass') {
|
|
128
|
-
console.log(` ${dim(check.message)}`);
|
|
129
|
-
const tip = nextStepFor(check, { noFix });
|
|
130
|
-
if (tip) {
|
|
131
|
-
console.log(` ${dim('\u2192')} ${tip}`);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
204
|
+
for (const check of categoryChecks) {
|
|
205
|
+
renderCheck(check, fixMode);
|
|
134
206
|
}
|
|
135
207
|
console.log();
|
|
136
208
|
}
|
|
209
|
+
}
|
|
210
|
+
|
|
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);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function applyRemoteFix({ base, headers, check, fetchImpl }) {
|
|
226
|
+
try {
|
|
227
|
+
const fixRes = await fetchImpl(`${base}/api/doctor/fix`, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers,
|
|
230
|
+
body: JSON.stringify({ action: check.fix.action }),
|
|
231
|
+
});
|
|
232
|
+
const fixResult = await fixRes.json();
|
|
233
|
+
return { id: check.id, action: check.fix.action, applied: !!fixResult.applied, description: fixResult.description };
|
|
234
|
+
} catch (err) {
|
|
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
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
137
243
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
body: JSON.stringify({ action: check.fix.action }),
|
|
151
|
-
});
|
|
152
|
-
const fixResult = await fixRes.json();
|
|
153
|
-
if (fixResult.applied) {
|
|
154
|
-
console.log(` ${green('\u2192')} Fixed: ${fixResult.description}`);
|
|
155
|
-
fixCount++;
|
|
156
|
-
if (fixResult.recheck) latestRecheck = fixResult.recheck;
|
|
157
|
-
} else {
|
|
158
|
-
console.log(` ${dim('\u2192')} Skipped: ${fixResult.description}`);
|
|
159
|
-
}
|
|
160
|
-
} catch (err) {
|
|
161
|
-
console.log(` ${red('\u2717')} Fix "${check.fix.action}" failed: ${err.cause?.code || err.message}`);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
console.log();
|
|
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}`);
|
|
165
256
|
}
|
|
166
257
|
}
|
|
258
|
+
console.log();
|
|
259
|
+
}
|
|
167
260
|
|
|
168
|
-
|
|
169
|
-
const reporting = latestRecheck || result;
|
|
261
|
+
function renderSummary(reporting) {
|
|
170
262
|
const { pass, warn, fail } = reporting.summary;
|
|
171
|
-
|
|
172
263
|
console.log(hr());
|
|
173
264
|
console.log();
|
|
174
265
|
|
|
@@ -179,31 +270,102 @@ export async function runDoctor({ baseUrl, apiKey, json, noFix, category }) {
|
|
|
179
270
|
];
|
|
180
271
|
console.log(` ${segments.join(' ' + dim('\u00b7') + ' ')}`);
|
|
181
272
|
console.log();
|
|
273
|
+
}
|
|
182
274
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (
|
|
187
|
-
console.log(
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (noFix) {
|
|
191
|
-
const autoFixable = result.checks.filter(
|
|
192
|
-
(c) => c.status !== 'pass' && c.fix?.type === 'auto',
|
|
193
|
-
).length;
|
|
194
|
-
if (autoFixable > 0) {
|
|
195
|
-
console.log(
|
|
196
|
-
` ${BRAND('\u2192')} ${autoFixable} issue${autoFixable !== 1 ? 's' : ''} can be auto-fixed. Run: ${bold('dashclaw doctor')}`,
|
|
197
|
-
);
|
|
198
|
-
}
|
|
275
|
+
function renderFixHint(report, fixMode) {
|
|
276
|
+
if (fixMode) return;
|
|
277
|
+
const fixable = fixableChecks(report.checks).length;
|
|
278
|
+
if (fixable > 0) {
|
|
279
|
+
console.log(
|
|
280
|
+
` ${BRAND('\u2192')} ${fixable} issue${fixable !== 1 ? 's' : ''} can be auto-fixed. Run: ${bold('dashclaw doctor --fix')}`,
|
|
281
|
+
);
|
|
199
282
|
}
|
|
283
|
+
}
|
|
200
284
|
|
|
285
|
+
function renderHealthFooter(base, healthy) {
|
|
201
286
|
if (!healthy) {
|
|
202
287
|
console.log(` ${dim('Docs:')} ${base}/setup`);
|
|
203
288
|
} else {
|
|
204
289
|
console.log(` ${green('\u2713')} ${bold('All systems healthy')} — ${dim('ready to govern actions')}`);
|
|
205
290
|
}
|
|
206
291
|
console.log();
|
|
292
|
+
}
|
|
207
293
|
|
|
208
|
-
|
|
294
|
+
/**
|
|
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
|
|
302
|
+
*/
|
|
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;
|
|
310
|
+
const base = baseUrl.replace(/\/+$/, '');
|
|
311
|
+
|
|
312
|
+
const ctx = local.buildContext({ cliVersion });
|
|
313
|
+
ctx.repoRoot = repo || local.detectRepoRoot({ cwd: ctx.cwd, fs: ctx.fs });
|
|
314
|
+
|
|
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
|
+
]);
|
|
321
|
+
|
|
322
|
+
let report = mergeReport(remote.result, remote.degraded, localChecks);
|
|
323
|
+
let fixResults = [];
|
|
324
|
+
|
|
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
|
+
);
|
|
330
|
+
|
|
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
|
+
}
|
|
335
|
+
|
|
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);
|
|
358
|
+
renderHealthFooter(base, healthy);
|
|
359
|
+
|
|
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);
|
|
209
371
|
}
|
package/lib/env.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// cli/lib/env.js
|
|
2
|
+
//
|
|
3
|
+
// `dashclaw env [--agent <id>] [-- <command...>]` — fetch the delivery-enabled
|
|
4
|
+
// managed-secret bundle from GET /api/secrets/env and inject it into a child
|
|
5
|
+
// process environment MEMORY-ONLY. Secret VALUES are never written to disk,
|
|
6
|
+
// never printed, and never echoed in error paths — only NAMES are ever shown.
|
|
7
|
+
// Fail-closed: if the bundle fetch fails, the child command is NOT run.
|
|
8
|
+
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { apiRequest } from './api.js';
|
|
11
|
+
import { dim } from './render.js';
|
|
12
|
+
|
|
13
|
+
/** GET /api/secrets/env for one agent. Returns { env, count, delivered }. */
|
|
14
|
+
export async function fetchAgentEnv(config, agentId) {
|
|
15
|
+
return apiRequest(config, 'GET', '/api/secrets/env', { query: { agent_id: agentId } });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Split the CLI argv at the `--` separator.
|
|
20
|
+
* Tokens before `--` are flags for `dashclaw env`; tokens after are the
|
|
21
|
+
* child command + its args (left untouched, including any `--print`).
|
|
22
|
+
*/
|
|
23
|
+
export function splitEnvArgv(argv) {
|
|
24
|
+
const sep = argv.indexOf('--');
|
|
25
|
+
if (sep === -1) return { flags: argv, command: [] };
|
|
26
|
+
return { flags: argv.slice(0, sep), command: argv.slice(sep + 1) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Names-only listing — never values. */
|
|
30
|
+
export function formatEnvNames(bundle) {
|
|
31
|
+
const names = Array.isArray(bundle.delivered) ? bundle.delivered : Object.keys(bundle.env || {});
|
|
32
|
+
const lines = [];
|
|
33
|
+
if (names.length === 0) {
|
|
34
|
+
lines.push(dim(' No delivery-enabled secrets for this agent.'));
|
|
35
|
+
} else {
|
|
36
|
+
for (const name of names) lines.push(` ${name}`);
|
|
37
|
+
}
|
|
38
|
+
lines.push('');
|
|
39
|
+
lines.push(dim(` ${names.length} secret(s). Values are never printed — run: dashclaw env -- <command>`));
|
|
40
|
+
return lines.join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Spawn the child command with the secret bundle merged into its environment.
|
|
45
|
+
* Memory-only: the merged env object lives only in this process and the
|
|
46
|
+
* child's process table — nothing is written anywhere. Resolves with the
|
|
47
|
+
* exit code to assign to process.exitCode (never calls process.exit — a hard
|
|
48
|
+
* exit can trip a libuv teardown assert on Windows).
|
|
49
|
+
*/
|
|
50
|
+
export function runWithEnv(bundle, commandArgv) {
|
|
51
|
+
const [cmd, ...cmdArgs] = commandArgv;
|
|
52
|
+
// No shell: args pass through verbatim (shell:true concatenates them
|
|
53
|
+
// unescaped — mangles quoted args and is deprecated, DEP0190).
|
|
54
|
+
const child = spawn(cmd, cmdArgs, {
|
|
55
|
+
stdio: 'inherit',
|
|
56
|
+
env: { ...process.env, ...(bundle.env || {}) },
|
|
57
|
+
});
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
child.on('error', (err) => {
|
|
60
|
+
// err.message is a spawn error (ENOENT/EINVAL etc.) — never contains values.
|
|
61
|
+
console.error(`Error: could not start "${cmd}": ${err.message}`);
|
|
62
|
+
if (process.platform === 'win32' && (err.code === 'EINVAL' || err.code === 'ENOENT')) {
|
|
63
|
+
console.error(`Hint: Windows .cmd shims (npm, npx) cannot be spawned directly — try: dashclaw env -- cmd /c ${cmd} ...`);
|
|
64
|
+
}
|
|
65
|
+
resolve(1);
|
|
66
|
+
});
|
|
67
|
+
child.on('exit', (code, signal) => resolve(signal ? 1 : code ?? 1));
|
|
68
|
+
});
|
|
69
|
+
}
|