run402 4.97.0 → 4.99.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 +1 -1
- package/core-dist/binding-file.js +28 -1
- package/core-dist/wallet.js +3 -3
- package/lib/auth.mjs +1 -2
- package/lib/buzz-doctor.mjs +21 -806
- package/lib/buzz-doctor.test.mjs +7 -6
- package/lib/buzz.test.mjs +13 -2
- package/lib/doctor.mjs +72 -811
- package/lib/init.mjs +35 -576
- package/lib/org-context.mjs +39 -376
- package/lib/orgs.mjs +22 -106
- package/lib/projects.mjs +1 -2
- package/lib/repos.mjs +6 -6
- package/lib/rooms.mjs +1 -2
- package/lib/sdk-errors.mjs +26 -0
- package/lib/status.mjs +14 -154
- package/lib/up.mjs +1 -2
- package/lib/vault-scaffold.mjs +2 -3
- package/lib/vault-target.mjs +14 -141
- package/lib/wallet-context.mjs +61 -227
- package/lib/wallets.mjs +47 -310
- package/package.json +1 -2
- package/sdk/core-dist/binding-file.js +28 -1
- package/sdk/core-dist/wallet.js +3 -3
- package/sdk/dist/errors.d.ts +24 -2
- package/sdk/dist/errors.d.ts.map +1 -1
- package/sdk/dist/errors.js +44 -2
- package/sdk/dist/errors.js.map +1 -1
- package/sdk/dist/index.d.ts +18 -3
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +12 -1
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/kernel.d.ts +34 -0
- package/sdk/dist/kernel.d.ts.map +1 -1
- package/sdk/dist/kernel.js +15 -1
- package/sdk/dist/kernel.js.map +1 -1
- package/sdk/dist/namespaces/admin.d.ts.map +1 -1
- package/sdk/dist/namespaces/admin.js +8 -0
- package/sdk/dist/namespaces/admin.js.map +1 -1
- package/sdk/dist/namespaces/agent.d.ts +2 -2
- package/sdk/dist/namespaces/agent.d.ts.map +1 -1
- package/sdk/dist/namespaces/agent.js +4 -0
- package/sdk/dist/namespaces/agent.js.map +1 -1
- package/sdk/dist/namespaces/agent.types.d.ts +5 -0
- package/sdk/dist/namespaces/agent.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/apps.d.ts.map +1 -1
- package/sdk/dist/namespaces/apps.js +2 -0
- package/sdk/dist/namespaces/apps.js.map +1 -1
- package/sdk/dist/namespaces/auth.d.ts.map +1 -1
- package/sdk/dist/namespaces/auth.js +9 -0
- package/sdk/dist/namespaces/auth.js.map +1 -1
- package/sdk/dist/namespaces/branches.d.ts.map +1 -1
- package/sdk/dist/namespaces/branches.js +2 -0
- package/sdk/dist/namespaces/branches.js.map +1 -1
- package/sdk/dist/namespaces/ci.d.ts.map +1 -1
- package/sdk/dist/namespaces/ci.js +2 -0
- package/sdk/dist/namespaces/ci.js.map +1 -1
- package/sdk/dist/namespaces/credentials.d.ts +0 -23
- package/sdk/dist/namespaces/credentials.d.ts.map +1 -1
- package/sdk/dist/namespaces/credentials.js +29 -0
- package/sdk/dist/namespaces/credentials.js.map +1 -1
- package/sdk/dist/namespaces/escalations.d.ts.map +1 -1
- package/sdk/dist/namespaces/escalations.js +2 -0
- package/sdk/dist/namespaces/escalations.js.map +1 -1
- package/sdk/dist/namespaces/grants.d.ts.map +1 -1
- package/sdk/dist/namespaces/grants.js +4 -0
- package/sdk/dist/namespaces/grants.js.map +1 -1
- package/sdk/dist/namespaces/live.d.ts +0 -16
- package/sdk/dist/namespaces/live.d.ts.map +1 -1
- package/sdk/dist/namespaces/live.js +19 -0
- package/sdk/dist/namespaces/live.js.map +1 -1
- package/sdk/dist/namespaces/me.d.ts.map +1 -1
- package/sdk/dist/namespaces/me.js +3 -0
- package/sdk/dist/namespaces/me.js.map +1 -1
- package/sdk/dist/namespaces/org.d.ts.map +1 -1
- package/sdk/dist/namespaces/org.js +3 -0
- package/sdk/dist/namespaces/org.js.map +1 -1
- package/sdk/dist/namespaces/projects.d.ts.map +1 -1
- package/sdk/dist/namespaces/projects.js +5 -0
- package/sdk/dist/namespaces/projects.js.map +1 -1
- package/sdk/dist/namespaces/repos.d.ts.map +1 -1
- package/sdk/dist/namespaces/repos.js +5 -0
- package/sdk/dist/namespaces/repos.js.map +1 -1
- package/sdk/dist/namespaces/rooms.d.ts.map +1 -1
- package/sdk/dist/namespaces/rooms.js +3 -0
- package/sdk/dist/namespaces/rooms.js.map +1 -1
- package/sdk/dist/namespaces/session.d.ts.map +1 -1
- package/sdk/dist/namespaces/session.js +18 -0
- package/sdk/dist/namespaces/session.js.map +1 -1
- package/sdk/dist/namespaces/transfers.d.ts.map +1 -1
- package/sdk/dist/namespaces/transfers.js +3 -0
- package/sdk/dist/namespaces/transfers.js.map +1 -1
- package/sdk/dist/namespaces/wallets.d.ts.map +1 -1
- package/sdk/dist/namespaces/wallets.js +2 -0
- package/sdk/dist/namespaces/wallets.js.map +1 -1
- package/sdk/dist/namespaces/write-approval.d.ts.map +1 -1
- package/sdk/dist/namespaces/write-approval.js +3 -0
- package/sdk/dist/namespaces/write-approval.js.map +1 -1
- package/sdk/dist/node/actions-node.d.ts.map +1 -1
- package/sdk/dist/node/actions-node.js +3 -0
- package/sdk/dist/node/actions-node.js.map +1 -1
- package/sdk/dist/node/buzz-doctor-contract.d.ts +155 -0
- package/sdk/dist/node/buzz-doctor-contract.d.ts.map +1 -0
- package/sdk/dist/node/buzz-doctor-contract.js +385 -0
- package/sdk/dist/node/buzz-doctor-contract.js.map +1 -0
- package/sdk/dist/node/buzz-doctor.d.ts +155 -0
- package/sdk/dist/node/buzz-doctor.d.ts.map +1 -0
- package/sdk/dist/node/buzz-doctor.js +810 -0
- package/sdk/dist/node/buzz-doctor.js.map +1 -0
- package/sdk/dist/node/credentials.d.ts +9 -4
- package/sdk/dist/node/credentials.d.ts.map +1 -1
- package/sdk/dist/node/credentials.js +1 -1
- package/sdk/dist/node/credentials.js.map +1 -1
- package/sdk/dist/node/diagnostics.d.ts +45 -0
- package/sdk/dist/node/diagnostics.d.ts.map +1 -0
- package/sdk/dist/node/diagnostics.js +67 -0
- package/sdk/dist/node/diagnostics.js.map +1 -0
- package/sdk/dist/node/doctor.d.ts +101 -0
- package/sdk/dist/node/doctor.d.ts.map +1 -0
- package/sdk/dist/node/doctor.js +618 -0
- package/sdk/dist/node/doctor.js.map +1 -0
- package/sdk/dist/node/index.d.ts +57 -5
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +36 -1
- package/sdk/dist/node/index.js.map +1 -1
- package/sdk/dist/node/init.d.ts +137 -0
- package/sdk/dist/node/init.d.ts.map +1 -0
- package/sdk/dist/node/init.js +507 -0
- package/sdk/dist/node/init.js.map +1 -0
- package/sdk/dist/node/lightning-wallet.d.ts +61 -0
- package/sdk/dist/node/lightning-wallet.d.ts.map +1 -0
- package/sdk/dist/node/lightning-wallet.js +126 -0
- package/sdk/dist/node/lightning-wallet.js.map +1 -0
- package/sdk/dist/node/local-actions.d.ts +23 -0
- package/sdk/dist/node/local-actions.d.ts.map +1 -0
- package/sdk/dist/node/local-actions.js +38 -0
- package/sdk/dist/node/local-actions.js.map +1 -0
- package/sdk/dist/node/org-context.d.ts +169 -0
- package/sdk/dist/node/org-context.d.ts.map +1 -0
- package/sdk/dist/node/org-context.js +403 -0
- package/sdk/dist/node/org-context.js.map +1 -0
- package/sdk/dist/node/status.d.ts +62 -0
- package/sdk/dist/node/status.d.ts.map +1 -0
- package/sdk/dist/node/status.js +132 -0
- package/sdk/dist/node/status.js.map +1 -0
- package/sdk/dist/node/vault-target.d.ts +49 -0
- package/sdk/dist/node/vault-target.d.ts.map +1 -0
- package/sdk/dist/node/vault-target.js +109 -0
- package/sdk/dist/node/vault-target.js.map +1 -0
- package/sdk/dist/node/wallets.d.ts +242 -0
- package/sdk/dist/node/wallets.d.ts.map +1 -0
- package/sdk/dist/node/wallets.js +484 -0
- package/sdk/dist/node/wallets.js.map +1 -0
- package/sdk/dist/secret-gate.d.ts +377 -0
- package/sdk/dist/secret-gate.d.ts.map +1 -0
- package/sdk/dist/secret-gate.js +273 -0
- package/sdk/dist/secret-gate.js.map +1 -0
- package/vault-surface.json +1 -1
- package/fixtures/buzz-v0.5.2-cli-capabilities.json +0 -92
- package/fixtures/run402-buzz-doctor-v1-contract.json +0 -140
- package/lib/buzz-doctor-contract.mjs +0 -97
- package/lib/buzz-doctor-contract.test.mjs +0 -134
- package/lib/lightning-wallet.mjs +0 -115
package/lib/doctor.mjs
CHANGED
|
@@ -1,63 +1,23 @@
|
|
|
1
|
-
import { resolveApplicationScope, loadApplicationScanInput, scanDeploymentSources } from "#sdk/node";
|
|
2
1
|
/**
|
|
3
|
-
* run402 doctor —
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* Capability `astro-ssr-runtime` (Run402 v1.52). Part of the agent-DX
|
|
11
|
-
* contract — agents run `run402 doctor` first to verify the environment
|
|
12
|
-
* before attempting other commands.
|
|
2
|
+
* run402 doctor — health and config diagnostics: the CLI edge of `r.doctor()`
|
|
3
|
+
* (`@run402/sdk/node`), which owns every ordinary-mode check and the
|
|
4
|
+
* `{ ok, blocking[], warnings[], checks[] }` fold. This module parses argv,
|
|
5
|
+
* supplies what only the CLI knows — its own update check and its resident
|
|
6
|
+
* vault helper — prints the report, and exits 0 exactly when `ok`.
|
|
7
|
+
* `--buzz` runs the Buzz setup preflight instead (`buzz-doctor.mjs`).
|
|
13
8
|
*/
|
|
14
9
|
|
|
15
|
-
import {
|
|
16
|
-
import { VAULT_BYO_NO_PAYLOAD_COPY_STATEMENT } from "#sdk";
|
|
17
|
-
import { configDir, readWallet, loadKeyStore } from "./config.mjs";
|
|
18
|
-
import { resolveVaultTarget } from "./vault-target.mjs";
|
|
10
|
+
import { DOCTOR_CHECK_NAMES, assertDoctorCheckNames } from "#sdk/node";
|
|
19
11
|
import { getSdk } from "./sdk.mjs";
|
|
20
|
-
import {
|
|
21
|
-
resolveScanRoot,
|
|
22
|
-
scanSourceTree,
|
|
23
|
-
SCAN_SEVERITY,
|
|
24
|
-
} from "./doctor-source-scan.mjs";
|
|
25
12
|
import { doctorUpdateCheck } from "./update-check.mjs";
|
|
26
13
|
import { buildBuzzDoctorReport, parseBuzzDoctorArgs } from "./buzz-doctor.mjs";
|
|
27
14
|
import { queueBuzzDoctorTelemetry } from "./diagnostic-telemetry.mjs";
|
|
28
|
-
import { fail } from "./sdk-errors.mjs";
|
|
15
|
+
import { fail, failLocal, reportLocalOrSdkError } from "./sdk-errors.mjs";
|
|
29
16
|
import { normalizeArgv, assertKnownFlags, flagValue } from "./argparse.mjs";
|
|
30
17
|
|
|
31
18
|
/** Value-taking flags — the flag set doctor actually parses; anything else is BAD_USAGE via assertKnownFlags, never silently ignored. */
|
|
32
19
|
const DOCTOR_VALUE_FLAGS = ["--scan-dir", "--dir", "--manifest", "--buzz-agent", "--project", "--only"];
|
|
33
20
|
|
|
34
|
-
/**
|
|
35
|
-
* The stable, complete registry of ordinary-mode check names. One entry
|
|
36
|
-
* per `checks.push({ name: ... })` call below,
|
|
37
|
-
* in the order each check normally runs. This is the ONE place `--only`
|
|
38
|
-
* validates its argument against and the ONE place its help text is derived
|
|
39
|
-
* from, so a check can never be selectable-but-undocumented or
|
|
40
|
-
* documented-but-unselectable.
|
|
41
|
-
*
|
|
42
|
-
* Deliberately excludes buzz mode's own check names (`session_shell`,
|
|
43
|
-
* `node_runtime`, …) — buzz mode is a wholly separate report shape that
|
|
44
|
-
* returns before this array is ever consulted; see the `--only`/`--buzz`
|
|
45
|
-
* mutual-exclusion check in `run()`.
|
|
46
|
-
*/
|
|
47
|
-
const DOCTOR_CHECK_NAMES = [
|
|
48
|
-
"config_dir",
|
|
49
|
-
"cli_update",
|
|
50
|
-
"wallet",
|
|
51
|
-
"projects",
|
|
52
|
-
"api_reachable",
|
|
53
|
-
"tier",
|
|
54
|
-
"account_health",
|
|
55
|
-
"runtime_staleness",
|
|
56
|
-
"recovery_posture",
|
|
57
|
-
"vault",
|
|
58
|
-
"source_scan",
|
|
59
|
-
];
|
|
60
|
-
|
|
61
21
|
/** Every value passed to a repeatable flag, in argv order (mirrors the pattern in buzz-notifications.mjs). */
|
|
62
22
|
function collectRepeatableFlag(args, flag) {
|
|
63
23
|
const values = [];
|
|
@@ -186,112 +146,50 @@ Exit codes:
|
|
|
186
146
|
`;
|
|
187
147
|
|
|
188
148
|
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* fails closed as blocking instead of silently passing.
|
|
193
|
-
*/
|
|
194
|
-
const INFO_STATUSES = new Set(["ok", "skipped", "unknown"]);
|
|
195
|
-
|
|
196
|
-
/** Non-active organization lifecycle states the gateway can report (`organization-lifecycle` spec); each is its own blocking tier status. */
|
|
197
|
-
const TIER_LIFECYCLE_STATUSES = new Set(["past_due", "frozen", "dormant", "purged"]);
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* The severity contract, in one place: `blocking` = would stop this agent
|
|
201
|
-
* from shipping; `advisory` = a gap worth surfacing that never stops a
|
|
202
|
-
* deploy; `info` = nothing to act on. A check may pin its own `severity`
|
|
203
|
-
* explicitly (the tier check does, for the missing-but-can-reach-projects
|
|
204
|
-
* shape); otherwise it is derived from `status`.
|
|
205
|
-
*/
|
|
206
|
-
function severityOf(check) {
|
|
207
|
-
if (check.severity === "blocking" || check.severity === "advisory" || check.severity === "info") return check.severity;
|
|
208
|
-
if (INFO_STATUSES.has(check.status)) return "info";
|
|
209
|
-
if (check.status === "warning") return "advisory";
|
|
210
|
-
return "blocking";
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** Stable codes for advisory checks that report ONE finding (no `value.gaps`), keyed by check name. */
|
|
214
|
-
const ADVISORY_CODES = {
|
|
215
|
-
cli_update: "CLI_UPDATE_AVAILABLE",
|
|
216
|
-
runtime_staleness: "FUNCTION_RUNTIME_STALE",
|
|
217
|
-
source_scan: "SOURCE_SCAN_WARNINGS",
|
|
218
|
-
tier: "TIER_MISSING_ON_OWN_ORG",
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Fold the per-check report into the agent-facing envelope:
|
|
223
|
-
* `{ ok, blocking[], warnings[], checks[] }`. `warnings[]` carries one entry
|
|
224
|
-
* per gap string of every advisory check (plus one for an advisory check
|
|
225
|
-
* with no gaps); `blocking[]` carries one entry per blocking check and is
|
|
226
|
-
* empty exactly when `ok` is true. Pure — the same fold serves a full run,
|
|
227
|
-
* `--only`, and `--refresh`.
|
|
149
|
+
* A bounded LOCAL probe of the resident vault helper engine: `{running:false}`
|
|
150
|
+
* is a fine answer, never a finding (the helper is an accelerator, not a
|
|
151
|
+
* dependency). Reported inside the vault check's `value.daemon`.
|
|
228
152
|
*/
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
153
|
+
async function daemonStatus() {
|
|
154
|
+
try {
|
|
155
|
+
const { daemonSocketPath } = await import("./daemon-path.mjs");
|
|
156
|
+
const { connect: netConnect } = await import("node:net");
|
|
157
|
+
return await new Promise((resolve) => {
|
|
158
|
+
let settled = false;
|
|
159
|
+
const done = (v) => {
|
|
160
|
+
if (!settled) {
|
|
161
|
+
settled = true;
|
|
162
|
+
resolve(v);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const socket = netConnect(daemonSocketPath());
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
socket.destroy();
|
|
168
|
+
done({ running: false });
|
|
169
|
+
}, 500);
|
|
170
|
+
let data = "";
|
|
171
|
+
socket.on("data", (c) => {
|
|
172
|
+
data += c.toString("utf8");
|
|
173
|
+
const nl = data.indexOf("\n");
|
|
174
|
+
if (nl === -1) return;
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
try {
|
|
177
|
+
const { t: _t, ...rest } = JSON.parse(data.slice(0, nl));
|
|
178
|
+
done({ running: true, ...rest });
|
|
179
|
+
} catch {
|
|
180
|
+
done({ running: false });
|
|
181
|
+
}
|
|
182
|
+
socket.end();
|
|
243
183
|
});
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const gaps = Array.isArray(check.value?.gaps) ? check.value.gaps.filter((g) => typeof g === "string" && g.length > 0) : [];
|
|
248
|
-
if (gaps.length > 0) {
|
|
249
|
-
for (const gap of gaps) {
|
|
250
|
-
warnings.push({ check: check.name, message: gap, ...(check.hint && { hint: check.hint }) });
|
|
251
|
-
}
|
|
252
|
-
} else {
|
|
253
|
-
const code = check.code ?? ADVISORY_CODES[check.name];
|
|
254
|
-
const message = check.message ?? check.hint ?? `${check.name}: ${check.status}`;
|
|
255
|
-
warnings.push({
|
|
256
|
-
check: check.name,
|
|
257
|
-
...(code && { code }),
|
|
258
|
-
message,
|
|
259
|
-
// Only carry `hint` when it adds something the message does not.
|
|
260
|
-
...(check.hint && check.hint !== message && { hint: check.hint }),
|
|
184
|
+
socket.once("error", () => {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
done({ running: false });
|
|
261
187
|
});
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function redactWalletForDiagnostics(localWallet) {
|
|
268
|
-
if (!localWallet || typeof localWallet !== "object") return localWallet;
|
|
269
|
-
const safe = { ...localWallet };
|
|
270
|
-
delete safe.privateKey;
|
|
271
|
-
if (typeof safe.funded === "boolean") safe.faucet_used = safe.funded;
|
|
272
|
-
delete safe.funded;
|
|
273
|
-
return safe;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Compose a check-failure message context-first.
|
|
278
|
-
*
|
|
279
|
-
* The SDK kernel composes thrown messages as `<envelope message> while
|
|
280
|
-
* <context>`, which reads as two jammed fragments when the envelope message
|
|
281
|
-
* ends with a period ("…header. while checking tier status"). Strip the
|
|
282
|
-
* SDK's trailing ` while <context>` segment (using the error's own
|
|
283
|
-
* `context` field) and lead with the check label instead.
|
|
284
|
-
*/
|
|
285
|
-
function describeCheckFailure(label, err) {
|
|
286
|
-
const raw = err instanceof Error ? err.message : String(err);
|
|
287
|
-
const context = typeof err?.context === "string" && err.context.length > 0 ? err.context : null;
|
|
288
|
-
let reason = raw;
|
|
289
|
-
if (context) {
|
|
290
|
-
const marker = ` while ${context}`;
|
|
291
|
-
const idx = raw.indexOf(marker);
|
|
292
|
-
if (idx !== -1) reason = (raw.slice(0, idx) + raw.slice(idx + marker.length)).trim();
|
|
188
|
+
socket.once("connect", () => socket.write('{"t":"status"}\n'));
|
|
189
|
+
});
|
|
190
|
+
} catch {
|
|
191
|
+
return { running: false };
|
|
293
192
|
}
|
|
294
|
-
return `${label} failed: ${reason}`;
|
|
295
193
|
}
|
|
296
194
|
|
|
297
195
|
export async function run(sub, args = []) {
|
|
@@ -300,38 +198,18 @@ export async function run(sub, args = []) {
|
|
|
300
198
|
console.log(HELP);
|
|
301
199
|
return;
|
|
302
200
|
}
|
|
303
|
-
// Accepting ANY flag silently means an unrecognized one (a typo) is
|
|
304
|
-
// never looked at. Any flag doctor actually parses is listed here;
|
|
305
|
-
// anything else is a structured BAD_USAGE/UNKNOWN_FLAG rejection instead
|
|
306
|
-
// of quietly doing nothing.
|
|
307
201
|
assertKnownFlags(all, ["--verbose", "--refresh", "--no-scan", "--buzz", ...DOCTOR_VALUE_FLAGS], DOCTOR_VALUE_FLAGS);
|
|
308
|
-
const verbose = all.includes("--verbose");
|
|
309
|
-
const refresh = all.includes("--refresh");
|
|
310
|
-
const skipScan = all.includes("--no-scan");
|
|
311
|
-
const scanDirArgIdx = all.indexOf("--scan-dir");
|
|
312
|
-
const scanDirOverride = scanDirArgIdx >= 0 ? all[scanDirArgIdx + 1] : null;
|
|
313
|
-
// Scoped to the vault check (see HELP): every other check is
|
|
314
|
-
// wallet/machine-wide, not per-project.
|
|
315
|
-
const projectOverride = flagValue(all, "--project");
|
|
316
202
|
|
|
317
|
-
// --only <check>, repeatable. Validated
|
|
318
|
-
//
|
|
319
|
-
// of which mode was also requested — the same "every accepted flag must
|
|
320
|
-
// work or BAD_USAGE" bar as every other doctor flag.
|
|
203
|
+
// --only <check>, repeatable. Validated ABOVE the buzz branch, so an unknown
|
|
204
|
+
// name is BAD_USAGE whichever mode was also requested.
|
|
321
205
|
const onlyChecks = collectRepeatableFlag(all, "--only");
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
message: `Unknown doctor check: '${name}'.`,
|
|
327
|
-
hint: `Valid check names: ${DOCTOR_CHECK_NAMES.join(", ")}.`,
|
|
328
|
-
details: { check: name, known_checks: DOCTOR_CHECK_NAMES },
|
|
329
|
-
});
|
|
330
|
-
}
|
|
206
|
+
try {
|
|
207
|
+
assertDoctorCheckNames(onlyChecks);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
failLocal(err);
|
|
331
210
|
}
|
|
332
|
-
// Buzz mode is a
|
|
333
|
-
//
|
|
334
|
-
// — an accepted-but-inert flag. Reject the combination instead.
|
|
211
|
+
// Buzz mode is a separate, always-complete report shape: an --only there
|
|
212
|
+
// would be accepted but inert, so the combination is refused.
|
|
335
213
|
if (onlyChecks.length > 0 && all.includes("--buzz")) {
|
|
336
214
|
fail({
|
|
337
215
|
code: "BAD_USAGE",
|
|
@@ -340,9 +218,6 @@ export async function run(sub, args = []) {
|
|
|
340
218
|
details: { only: onlyChecks },
|
|
341
219
|
});
|
|
342
220
|
}
|
|
343
|
-
const only = new Set(onlyChecks);
|
|
344
|
-
/** `true` when `name` should run — every check when --only was not passed, otherwise exactly the named ones. */
|
|
345
|
-
const wanted = (name) => only.size === 0 || only.has(name);
|
|
346
221
|
|
|
347
222
|
const buzzArgs = parseBuzzDoctorArgs(all);
|
|
348
223
|
if (buzzArgs.error) fail(buzzArgs.error);
|
|
@@ -355,639 +230,25 @@ export async function run(sub, args = []) {
|
|
|
355
230
|
return;
|
|
356
231
|
}
|
|
357
232
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
} catch (err) {
|
|
374
|
-
checks.push({
|
|
375
|
-
name: "config_dir",
|
|
376
|
-
status: "error",
|
|
377
|
-
message: err instanceof Error ? err.message : String(err),
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// 1b. CLI version/update state. This is advisory: stale or unknown version
|
|
382
|
-
// state should help the user, not hide the rest of doctor.
|
|
383
|
-
if (wanted("cli_update")) try {
|
|
384
|
-
checks.push(await doctorUpdateCheck({ refresh }));
|
|
385
|
-
} catch (err) {
|
|
386
|
-
checks.push({
|
|
387
|
-
name: "cli_update",
|
|
388
|
-
status: "unknown",
|
|
389
|
-
message: err instanceof Error ? err.message : String(err),
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// 2. Wallet.
|
|
394
|
-
let walletConfigured = false;
|
|
395
|
-
if (wanted("wallet")) try {
|
|
396
|
-
const localWallet = readWallet();
|
|
397
|
-
if (localWallet) {
|
|
398
|
-
walletConfigured = true;
|
|
399
|
-
checks.push({
|
|
400
|
-
name: "wallet",
|
|
401
|
-
status: "ok",
|
|
402
|
-
value: {
|
|
403
|
-
rail: localWallet.rail,
|
|
404
|
-
// Don't surface amounts or addresses unless --verbose; agents
|
|
405
|
-
// checking for config presence don't need wallet details. Never
|
|
406
|
-
// include keystore secrets in diagnostics, even in verbose mode.
|
|
407
|
-
...(verbose && { details: redactWalletForDiagnostics(localWallet) }),
|
|
408
|
-
},
|
|
409
|
-
});
|
|
410
|
-
} else {
|
|
411
|
-
checks.push({
|
|
412
|
-
name: "wallet",
|
|
413
|
-
status: "missing",
|
|
414
|
-
hint: "Run 'run402 init' to create a wallet.",
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
} catch (err) {
|
|
418
|
-
checks.push({
|
|
419
|
-
name: "wallet",
|
|
420
|
-
status: "error",
|
|
421
|
-
message: err instanceof Error ? err.message : String(err),
|
|
422
|
-
});
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// 3. Project keystore. The wallet itself lives in wallet.json (verified
|
|
426
|
-
// by check 2 above); this checks the per-project keys (anon_key /
|
|
427
|
-
// service_key) that `run402 projects provision` writes. An empty store is
|
|
428
|
-
// normal for fresh installs that haven't provisioned a project yet, so
|
|
429
|
-
// report informationally as `ok` rather than warning.
|
|
430
|
-
if (wanted("projects")) try {
|
|
431
|
-
const keystore = loadKeyStore();
|
|
432
|
-
const projectCount = Object.keys(keystore?.projects ?? {}).length;
|
|
433
|
-
checks.push({
|
|
434
|
-
name: "projects",
|
|
435
|
-
status: "ok",
|
|
436
|
-
value: { project_count: projectCount },
|
|
437
|
-
// State-aware parenthetical: only claim the wallet is set up when the
|
|
438
|
-
// wallet check above actually passed; pre-init installs are pointed
|
|
439
|
-
// at `run402 init` first.
|
|
440
|
-
...(projectCount === 0 && {
|
|
441
|
-
hint: walletConfigured
|
|
442
|
-
? "No projects yet — run 'run402 projects provision' to create one (wallet is already set up)."
|
|
443
|
-
: "No projects yet — run 'run402 init' to set up the wallet first, then 'run402 projects provision'.",
|
|
444
|
-
}),
|
|
445
|
-
});
|
|
446
|
-
} catch (err) {
|
|
447
|
-
checks.push({
|
|
448
|
-
name: "projects",
|
|
449
|
-
status: "error",
|
|
450
|
-
message: err instanceof Error ? err.message : String(err),
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// 4. API base reachability.
|
|
455
|
-
if (wanted("api_reachable")) try {
|
|
456
|
-
const sdk = getSdk();
|
|
457
|
-
// Use the service.status endpoint (read-only, unauthenticated).
|
|
458
|
-
const t0 = Date.now();
|
|
459
|
-
await sdk.service.status();
|
|
460
|
-
const elapsed = Date.now() - t0;
|
|
461
|
-
checks.push({
|
|
462
|
-
name: "api_reachable",
|
|
463
|
-
status: "ok",
|
|
464
|
-
...(verbose && { value: { elapsed_ms: elapsed } }),
|
|
233
|
+
let report;
|
|
234
|
+
try {
|
|
235
|
+
report = await getSdk().doctor({
|
|
236
|
+
verbose: all.includes("--verbose"),
|
|
237
|
+
refresh: all.includes("--refresh"),
|
|
238
|
+
noScan: all.includes("--no-scan"),
|
|
239
|
+
scanDir: flagValue(all, "--scan-dir"),
|
|
240
|
+
dir: flagValue(all, "--dir"),
|
|
241
|
+
manifest: flagValue(all, "--manifest"),
|
|
242
|
+
// Scoped to the vault check: every other check is wallet/machine-wide.
|
|
243
|
+
project: flagValue(all, "--project"),
|
|
244
|
+
only: onlyChecks,
|
|
245
|
+
cliUpdate: doctorUpdateCheck,
|
|
246
|
+
daemonStatus,
|
|
465
247
|
});
|
|
466
248
|
} catch (err) {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
status: "error",
|
|
470
|
-
message: err instanceof Error ? err.message : String(err),
|
|
471
|
-
hint: "Check the RUN402_API_BASE env var and your network connection.",
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// 5. Active tier.
|
|
476
|
-
//
|
|
477
|
-
// `status` is a FIXED vocabulary — ok | inactive | frozen | past_due |
|
|
478
|
-
// dormant | purged | missing | unknown | error — never the tier name and
|
|
479
|
-
// never a raw gateway string: the old derivation fell through to
|
|
480
|
-
// `tierName ?? "missing"`, which could put a tier NAME ("team") in the
|
|
481
|
-
// status slot, and any status outside the ok-allowlist silently flipped
|
|
482
|
-
// the whole report to ok:false while looking benign. The tier name and the
|
|
483
|
-
// raw lifecycle ride in value.tier / value.lifecycle instead.
|
|
484
|
-
if (wanted("tier")) try {
|
|
485
|
-
const sdk = getSdk();
|
|
486
|
-
const tier = await sdk.tier.status();
|
|
487
|
-
const tierName = typeof tier?.tier === "string" && tier.tier.length > 0 ? tier.tier : null;
|
|
488
|
-
const lifecycle = typeof tier?.organization_lifecycle_state === "string" && tier.organization_lifecycle_state.length > 0
|
|
489
|
-
? tier.organization_lifecycle_state
|
|
490
|
-
: null;
|
|
491
|
-
const active = tier?.active === true;
|
|
492
|
-
// Projects this wallet can reach through org membership or a grant —
|
|
493
|
-
// the tier read already carries them. A wallet whose OWN org-of-one
|
|
494
|
-
// never bought a tier can still ship into another org's projects, so
|
|
495
|
-
// "missing" is only a blocker when there is nowhere to ship.
|
|
496
|
-
const reachableProjects = Array.isArray(tier?.projects) ? tier.projects.length : 0;
|
|
497
|
-
let status;
|
|
498
|
-
if (lifecycle !== null && lifecycle !== "active") {
|
|
499
|
-
status = TIER_LIFECYCLE_STATUSES.has(lifecycle) ? lifecycle : "inactive";
|
|
500
|
-
} else if (tierName === null) {
|
|
501
|
-
status = "missing";
|
|
502
|
-
} else if (!active) {
|
|
503
|
-
status = "inactive";
|
|
504
|
-
} else if (lifecycle === null) {
|
|
505
|
-
status = "unknown";
|
|
506
|
-
} else {
|
|
507
|
-
status = "ok";
|
|
508
|
-
}
|
|
509
|
-
const value = {
|
|
510
|
-
tier: tierName,
|
|
511
|
-
lifecycle,
|
|
512
|
-
active,
|
|
513
|
-
organization_lifecycle_state: lifecycle,
|
|
514
|
-
lease_expires_at: tier?.lease_perpetual === true ? null : tier?.lease_expires_at ?? null,
|
|
515
|
-
reachable_projects: reachableProjects,
|
|
516
|
-
};
|
|
517
|
-
if (status === "ok") {
|
|
518
|
-
checks.push({ name: "tier", status, value });
|
|
519
|
-
} else if (status === "missing" && reachableProjects > 0) {
|
|
520
|
-
checks.push({
|
|
521
|
-
name: "tier",
|
|
522
|
-
status,
|
|
523
|
-
severity: "advisory",
|
|
524
|
-
value,
|
|
525
|
-
message: `this wallet's own organization holds no tier, but it can reach ${reachableProjects} project(s) owned by another organization (membership or grant) — deploys to those are unaffected`,
|
|
526
|
-
hint: "Run 'run402 tier set prototype' only if you want to provision projects under this wallet's own organization.",
|
|
527
|
-
});
|
|
528
|
-
} else {
|
|
529
|
-
checks.push({
|
|
530
|
-
name: "tier",
|
|
531
|
-
status,
|
|
532
|
-
value,
|
|
533
|
-
message: status === "unknown"
|
|
534
|
-
? "tier resolved, but the organization lifecycle could not be determined"
|
|
535
|
-
: status === "missing"
|
|
536
|
-
? "no tier on this wallet's organization and no reachable project — nothing can be provisioned or deployed from here"
|
|
537
|
-
: status === "inactive"
|
|
538
|
-
? `tier '${tierName ?? "(none)"}' is not active${lifecycle && lifecycle !== "active" ? ` (organization lifecycle '${lifecycle}')` : ""}`
|
|
539
|
-
: `organization lifecycle is '${lifecycle}' — the control plane is gated until the tier is reactivated`,
|
|
540
|
-
hint: status === "unknown"
|
|
541
|
-
? "Tier resolved, but organization lifecycle could not be determined. Check `run402 tier status` before assuming the account is healthy."
|
|
542
|
-
: "Run 'run402 tier set prototype' to set, renew, or reactivate the tier.",
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
} catch (err) {
|
|
546
|
-
checks.push({
|
|
547
|
-
name: "tier",
|
|
548
|
-
status: "error",
|
|
549
|
-
message: describeCheckFailure("tier status check", err),
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// 6. Account health snapshot (v1.55 + v1.56 verification attempt detail).
|
|
554
|
-
// The checks below all ride the SAME `GET /agent/v1/me/status` read
|
|
555
|
-
// (runtime_staleness and recovery_posture reuse the response account_health
|
|
556
|
-
// already pulled),
|
|
557
|
-
// so the whole block is gated on wanting ANY — --only runtime_staleness
|
|
558
|
-
// alone still needs this read, but --only-ing none skips it entirely, same
|
|
559
|
-
// "don't do the work of a check nobody asked for" discipline the rest of
|
|
560
|
-
// --only follows.
|
|
561
|
-
if (wanted("account_health") || wanted("runtime_staleness") || wanted("recovery_posture")) try {
|
|
562
|
-
const sdk = getSdk();
|
|
563
|
-
const status = await sdk.me.status();
|
|
564
|
-
const gaps = [];
|
|
565
|
-
if (status.contact.email_status !== "verified") {
|
|
566
|
-
// v1.56: prefer the structured email_verification.last_challenge.hint
|
|
567
|
-
// over the generic "email not verified" message. The gateway computes
|
|
568
|
-
// a per-reason remediation hint that's actionable for the reader.
|
|
569
|
-
const ev = status.email_verification;
|
|
570
|
-
const ch = ev?.last_challenge;
|
|
571
|
-
if (ch && ch.hint) {
|
|
572
|
-
const attemptsLine = ch.attempt_count > 0
|
|
573
|
-
? ` (${ch.attempt_count}/${ch.attempt_count + ch.remaining_attempts} attempts used, ${ch.remaining_attempts} remaining)`
|
|
574
|
-
: "";
|
|
575
|
-
gaps.push(`contact email not verified${attemptsLine}: ${ch.hint}`);
|
|
576
|
-
} else {
|
|
577
|
-
gaps.push(`contact email not verified (${status.contact.email_status}) — run 'run402 agent contact --email ...' then reply to the challenge`);
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
if (status.contact.passkey_status !== "verified") {
|
|
581
|
-
gaps.push("contact passkey not bound — run 'run402 login' (or 'run402 agent passkey enroll') after email verification");
|
|
582
|
-
}
|
|
583
|
-
// recovery-event-reachability: org-level reachability of mandatory
|
|
584
|
-
// (recovery/security) notifications. Distinct from the per-wallet contact
|
|
585
|
-
// check above — an org can be reachable via a member's verified email
|
|
586
|
-
// even when this wallet has no contact, and vice versa. Omitted by older
|
|
587
|
-
// gateways.
|
|
588
|
-
const reach = status.reachability;
|
|
589
|
-
if (reach && reach.reachable === false) {
|
|
590
|
-
const skipped = reach.skipped_last_90d > 0
|
|
591
|
-
? ` (${reach.skipped_last_90d} notification(s) already skipped in the last 90 days)`
|
|
592
|
-
: "";
|
|
593
|
-
gaps.push(`no verified notification recipient — mandatory recovery/security notifications currently reach nobody${skipped}; run 'run402 agent contact --email ...' then reply to the challenge`);
|
|
594
|
-
}
|
|
595
|
-
if (Array.isArray(status.skipped_notifications) && status.skipped_notifications.length > 0) {
|
|
596
|
-
gaps.push(`${status.skipped_notifications.length} notification(s) skipped due to missing verified recipient`);
|
|
597
|
-
}
|
|
598
|
-
if (Array.isArray(status.critical_items) && status.critical_items.length > 0) {
|
|
599
|
-
for (const item of status.critical_items) {
|
|
600
|
-
gaps.push(`${item.kind}: ${item.detail}`);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
if (wanted("account_health")) {
|
|
604
|
-
if (gaps.length > 0) {
|
|
605
|
-
checks.push({
|
|
606
|
-
name: "account_health",
|
|
607
|
-
status: "warning",
|
|
608
|
-
value: { gaps },
|
|
609
|
-
hint: "Address the above gaps; they're what 'run402 notifications' is designed to surface.",
|
|
610
|
-
});
|
|
611
|
-
} else {
|
|
612
|
-
checks.push({ name: "account_health", status: "ok" });
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// 6b. Function runtime staleness (v1.69, capability
|
|
617
|
-
// function-runtime-rebuild). A deployed function is stale when its Lambda
|
|
618
|
-
// zip carries an older platform entry wrapper / bundled runtime than the
|
|
619
|
-
// gateway's current build — a plain redeploy with unchanged source does
|
|
620
|
-
// NOT refresh it (apply's release diff keys on the source code_hash, not
|
|
621
|
-
// the wrapper). Read-only signal; refreshing is strictly opt-in. Reuses
|
|
622
|
-
// the account status fetched above to avoid a second round-trip.
|
|
623
|
-
if (wanted("runtime_staleness")) {
|
|
624
|
-
const runtime = status.runtime;
|
|
625
|
-
if (runtime && typeof runtime.stale_function_count === "number") {
|
|
626
|
-
if (runtime.stale_function_count > 0) {
|
|
627
|
-
checks.push({
|
|
628
|
-
name: "runtime_staleness",
|
|
629
|
-
status: "warning",
|
|
630
|
-
value: {
|
|
631
|
-
stale_function_count: runtime.stale_function_count,
|
|
632
|
-
stale_functions: runtime.stale_functions ?? [],
|
|
633
|
-
},
|
|
634
|
-
hint: `${runtime.stale_function_count} function(s) are running an older platform runtime. Run 'run402 functions rebuild --all' to refresh (re-bundles from your stored source; no source change).`,
|
|
635
|
-
});
|
|
636
|
-
} else {
|
|
637
|
-
checks.push({
|
|
638
|
-
name: "runtime_staleness",
|
|
639
|
-
status: "ok",
|
|
640
|
-
value: { stale_function_count: 0 },
|
|
641
|
-
});
|
|
642
|
-
}
|
|
643
|
-
} else {
|
|
644
|
-
// Gateway older than v1.69 doesn't surface the runtime block.
|
|
645
|
-
checks.push({
|
|
646
|
-
name: "runtime_staleness",
|
|
647
|
-
status: "skipped",
|
|
648
|
-
...(verbose && { hint: "account status has no 'runtime' block; requires v1.69+ gateway." }),
|
|
649
|
-
});
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// 6c. Org recovery posture (vault-recovery-custody). One entry per
|
|
654
|
-
// vault-owning org the caller can see; rides the same account-status
|
|
655
|
-
// read. Evidence levels, not guarantees: "configured" names what the
|
|
656
|
-
// platform VERIFIED — it can never observe whether an off-platform
|
|
657
|
-
// passkey or saved code still exists. The two headline facts mirror the
|
|
658
|
-
// feed events org_recovery_posture_degraded/_recovered; each gap line
|
|
659
|
-
// carries its remedy (Anticipatory), same shape as the reachability gaps
|
|
660
|
-
// above.
|
|
661
|
-
if (wanted("recovery_posture")) {
|
|
662
|
-
const posture = status.recovery_posture;
|
|
663
|
-
if (!Array.isArray(posture)) {
|
|
664
|
-
// Gateway older than vault-recovery-custody doesn't surface it.
|
|
665
|
-
checks.push({
|
|
666
|
-
name: "recovery_posture",
|
|
667
|
-
status: "skipped",
|
|
668
|
-
...(verbose && { hint: "account status has no 'recovery_posture' block; requires a vault-recovery-custody gateway." }),
|
|
669
|
-
});
|
|
670
|
-
} else if (posture.length === 0) {
|
|
671
|
-
// No vault-owning org in the caller's view — nothing to lose, nothing to advise.
|
|
672
|
-
checks.push({ name: "recovery_posture", status: "ok", value: { orgs: [] } });
|
|
673
|
-
} else {
|
|
674
|
-
const gaps = [];
|
|
675
|
-
for (const org of posture) {
|
|
676
|
-
const label = `org ${org.org_id} (${org.vault_count} vault${org.vault_count === 1 ? "" : "s"})`;
|
|
677
|
-
if (org.control_plane_configured === false) {
|
|
678
|
-
gaps.push(`${label}: no human owner with a working control-plane login — if this org's agent machine dies, nobody can sign in to recover it. Invite a backup human (run402 orgs invite create ${org.org_id} --email <their-email> --role owner) and have them complete login at console.run402.com.`);
|
|
679
|
-
}
|
|
680
|
-
if (org.source_backup_configured === false) {
|
|
681
|
-
gaps.push(`${label}: no human member holds a working source-access key — vault history has no member-side decryption backup. Have a member complete source enrollment at console.run402.com/account → Source access.`);
|
|
682
|
-
}
|
|
683
|
-
if (org.custody_legacy_present === true) {
|
|
684
|
-
gaps.push(`${label}: a member key is still on single-credential legacy custody (one passkey, no recovery code — losing that one credential loses source access). Re-enroll at console.run402.com/account to move to wrapper custody with a recovery code.`);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
checks.push(
|
|
688
|
-
gaps.length > 0
|
|
689
|
-
? {
|
|
690
|
-
name: "recovery_posture",
|
|
691
|
-
status: "warning",
|
|
692
|
-
value: { orgs: posture, gaps },
|
|
693
|
-
hint: "These are the org's disaster-recovery backstops — the same facts arrive as org_recovery_posture_degraded/_recovered feed events. After enrolling, export the recovery bundle (run402 repos recovery-bundle) and store it separately from the code.",
|
|
694
|
-
}
|
|
695
|
-
: { name: "recovery_posture", status: "ok", value: { orgs: posture } },
|
|
696
|
-
);
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
} catch (err) {
|
|
700
|
-
// The account status endpoint may not be reachable on the target API. Don't fail the whole
|
|
701
|
-
// doctor over it — emit as a soft warning. The runtime-staleness check
|
|
702
|
-
// rides on the same fetch, so skip it for the same reason.
|
|
703
|
-
if (wanted("account_health")) checks.push({
|
|
704
|
-
name: "account_health",
|
|
705
|
-
status: "skipped",
|
|
706
|
-
message: describeCheckFailure("account status check", err),
|
|
707
|
-
...(verbose && { hint: "GET /agent/v1/me/status not reachable." }),
|
|
708
|
-
});
|
|
709
|
-
if (wanted("runtime_staleness")) checks.push({
|
|
710
|
-
name: "runtime_staleness",
|
|
711
|
-
status: "skipped",
|
|
712
|
-
message: describeCheckFailure("account status check", err),
|
|
713
|
-
});
|
|
714
|
-
if (wanted("recovery_posture")) checks.push({
|
|
715
|
-
name: "recovery_posture",
|
|
716
|
-
status: "skipped",
|
|
717
|
-
message: describeCheckFailure("account status check", err),
|
|
718
|
-
});
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
// 6c. vault (add-vault). Doctor was completely silent about the vault
|
|
722
|
-
// even when `vault_policy: required` was the single thing that would break
|
|
723
|
-
// the project's next deploy (dogfood #1, finding D1) — and doctor is where a
|
|
724
|
-
// user looks when something is wrong. It also prints WHERE the keystore is:
|
|
725
|
-
// "whole-keystore loss is terminal" was stated three times across this
|
|
726
|
-
// surface while the directory to back up was stated nowhere (finding D2).
|
|
727
|
-
//
|
|
728
|
-
// Read-only and best-effort in every branch: no project, no vault, or a
|
|
729
|
-
// gateway that does not know vault are all ordinary and report `skipped`
|
|
730
|
-
// or `ok`, never a doctor failure. A vault-only project that has never
|
|
731
|
-
// deployed is a first-class shape (protocol D183), so its mere absence of a
|
|
732
|
-
// deploy raises nothing.
|
|
733
|
-
//
|
|
734
|
-
// TARGETING: when cwd is a repository
|
|
735
|
-
// with its own pinned repo id or run402/origin remote, doctor checks THAT
|
|
736
|
-
// vault, not the profile's active project — the same pin > remote >
|
|
737
|
-
// RUN402_PROJECT_ID env > active-project order every other vault verb
|
|
738
|
-
// follows (`vault-target.mjs`). An explicit `--project <project_id>` outranks
|
|
739
|
-
// all of that (the resolver's own top tier), same as every other vault
|
|
740
|
-
// verb's `--project`.
|
|
741
|
-
if (wanted("vault")) {
|
|
742
|
-
// vault-persistent-helper: a bounded LOCAL probe of the resident
|
|
743
|
-
// helper engine — {running:false} is a fine answer, never a finding
|
|
744
|
-
// (the daemon is an accelerator, not a dependency).
|
|
745
|
-
const daemonInfo = await (async () => {
|
|
746
|
-
try {
|
|
747
|
-
const { daemonSocketPath } = await import("./daemon-path.mjs");
|
|
748
|
-
const { connect: netConnect } = await import("node:net");
|
|
749
|
-
return await new Promise((resolve) => {
|
|
750
|
-
let settled = false;
|
|
751
|
-
const done = (v) => {
|
|
752
|
-
if (!settled) {
|
|
753
|
-
settled = true;
|
|
754
|
-
resolve(v);
|
|
755
|
-
}
|
|
756
|
-
};
|
|
757
|
-
const socket = netConnect(daemonSocketPath());
|
|
758
|
-
const timer = setTimeout(() => {
|
|
759
|
-
socket.destroy();
|
|
760
|
-
done({ running: false });
|
|
761
|
-
}, 500);
|
|
762
|
-
let data = "";
|
|
763
|
-
socket.on("data", (c) => {
|
|
764
|
-
data += c.toString("utf8");
|
|
765
|
-
const nl = data.indexOf("\n");
|
|
766
|
-
if (nl === -1) return;
|
|
767
|
-
clearTimeout(timer);
|
|
768
|
-
try {
|
|
769
|
-
const { t: _t, ...rest } = JSON.parse(data.slice(0, nl));
|
|
770
|
-
done({ running: true, ...rest });
|
|
771
|
-
} catch {
|
|
772
|
-
done({ running: false });
|
|
773
|
-
}
|
|
774
|
-
socket.end();
|
|
775
|
-
});
|
|
776
|
-
socket.once("error", () => {
|
|
777
|
-
clearTimeout(timer);
|
|
778
|
-
done({ running: false });
|
|
779
|
-
});
|
|
780
|
-
socket.once("connect", () => socket.write('{"t":"status"}\n'));
|
|
781
|
-
});
|
|
782
|
-
} catch {
|
|
783
|
-
return { running: false };
|
|
784
|
-
}
|
|
785
|
-
})();
|
|
786
|
-
const target = await resolveVaultTarget({ repoDir: process.cwd(), explicitProjectId: projectOverride ?? undefined });
|
|
787
|
-
const projectId = target.project_id ?? null;
|
|
788
|
-
const repoId = target.repo_id ?? null;
|
|
789
|
-
if (!projectId && !repoId) {
|
|
790
|
-
checks.push({
|
|
791
|
-
name: "vault",
|
|
792
|
-
status: "skipped",
|
|
793
|
-
...(verbose && { hint: "no active project — run 'run402 projects use <project_id>' to check its vault." }),
|
|
794
|
-
});
|
|
795
|
-
} else {
|
|
796
|
-
try {
|
|
797
|
-
const gv = await getSdk().repos.status({
|
|
798
|
-
...(repoId ? { repo_id: repoId } : { project_id: projectId }),
|
|
799
|
-
repo_dir: process.cwd(),
|
|
800
|
-
});
|
|
801
|
-
const value = {
|
|
802
|
-
project_id: gv.project_id ?? projectId,
|
|
803
|
-
repo_id: gv.repo_id,
|
|
804
|
-
vault: gv.vault === null ? null : "allocated",
|
|
805
|
-
// vault-byo-primary-bucket task 3.5 — absent-or-"managed" is
|
|
806
|
-
// byte-identical to before this fold for every non-BYO vault.
|
|
807
|
-
storage_profile: gv.vault?.storage_profile ?? null,
|
|
808
|
-
byo_destination: gv.vault?.byo_destination ?? null,
|
|
809
|
-
vault_policy: gv.vault_policy,
|
|
810
|
-
keystore_root: gv.keystore.root,
|
|
811
|
-
can_sign: gv.keystore.can_sign,
|
|
812
|
-
holds_repo_key: gv.keystore.holds_repo_key,
|
|
813
|
-
pending_overrides: gv.pending_overrides,
|
|
814
|
-
pins: gv.pins,
|
|
815
|
-
remote: gv.remote,
|
|
816
|
-
// dogfood item 2: `null` (unknown) or <= 1 means the single-principal
|
|
817
|
-
// V0-A terminal-loss statement below is honest; >= 2 means the SDK
|
|
818
|
-
// already proved a second covering recipient, so the hint switches
|
|
819
|
-
// to the durability sentence instead of the terminal-loss claim.
|
|
820
|
-
covering_recipients: gv.covering_recipients ?? null,
|
|
821
|
-
daemon: daemonInfo,
|
|
822
|
-
// vault-multi-writer (rev 47) task 6.2 — this machine's own
|
|
823
|
-
// standing on the vault's chain-verified writer set. `null` only
|
|
824
|
-
// when there is no vault at all (nothing to be a writer OF).
|
|
825
|
-
// `read_only_vault` takes priority over the caller's own standing
|
|
826
|
-
// — the D228 terminal state blocks EVERY push regardless of who
|
|
827
|
-
// is asking.
|
|
828
|
-
writer: gv.vault === null
|
|
829
|
-
? null
|
|
830
|
-
: gv.vault.read_only_terminal
|
|
831
|
-
? "read_only_vault"
|
|
832
|
-
: gv.vault.writer_set?.writers.some((w) => w.writer_key_id === gv.keystore.identity_fingerprint)
|
|
833
|
-
? "active"
|
|
834
|
-
: gv.vault.pending_writers?.some((p) => p.writer_key_id === gv.keystore.identity_fingerprint)
|
|
835
|
-
? "pending"
|
|
836
|
-
: "not_admitted",
|
|
837
|
-
};
|
|
838
|
-
const gaps = [];
|
|
839
|
-
if (value.writer === "read_only_vault") {
|
|
840
|
-
gaps.push("this vault has lost its last writer (D228 read-only terminal) — it still serves reads, but no push can be admitted until a new writer is admitted through a recovery path");
|
|
841
|
-
} else if (value.writer === "pending") {
|
|
842
|
-
gaps.push("this machine's key is an eligible writer candidate but not yet admitted — run 'run402 repos access sync' if you already hold writer standing on this vault, or ask a current writer to run any vault operation");
|
|
843
|
-
} else if (value.writer === "not_admitted") {
|
|
844
|
-
gaps.push("this machine's key is not an active writer on this vault — a push from here is refused VAULT_WRITER_NOT_ADMITTED; ask a current writer to admit you (org membership at role developer+ and a published signing key make you eligible)");
|
|
845
|
-
}
|
|
846
|
-
// The one that actually breaks the next deploy: the project demands a
|
|
847
|
-
// vaulted capture and THIS machine cannot produce one.
|
|
848
|
-
if (gv.vault_policy === "required" && !gv.keystore.holds_repo_key) {
|
|
849
|
-
gaps.push(
|
|
850
|
-
"vault_policy is 'required' but this machine holds no key for the vault — a deploy from here is refused with VAULT_CLIENT_UPGRADE_REQUIRED. " +
|
|
851
|
-
"Run 'run402 repos create --project <project_id>' (idempotent; resolves to the existing repo), or 'run402 repos policy grandfathered --reason <why>' to un-gate the project.",
|
|
852
|
-
);
|
|
853
|
-
} else if (gv.vault_policy === "required" && !gv.keystore.can_sign) {
|
|
854
|
-
gaps.push("vault_policy is 'required' and this keystore is read-only (no signing key) — it can verify but cannot publish the capture a deploy needs");
|
|
855
|
-
}
|
|
856
|
-
if (gv.pending_overrides > 0) {
|
|
857
|
-
gaps.push(`${gv.pending_overrides} unvaulted-override journal(s) are still open — run 'run402 repos capture' to drain them`);
|
|
858
|
-
}
|
|
859
|
-
// `matches` is a TRI-STATE: `false` alone is
|
|
860
|
-
// a real mismatch. `null` (a slug-form remote not yet resolved on
|
|
861
|
-
// this machine) is not evidence of anything wrong — a bare
|
|
862
|
-
// `!gv.remote.matches` would treat null the same as false and warn.
|
|
863
|
-
if (gv.remote && gv.remote.matches === false) {
|
|
864
|
-
gaps.push(`the '${gv.remote.name}' git remote points at a different project than ${value.project_id} (${gv.remote.url})`);
|
|
865
|
-
}
|
|
866
|
-
// Echoed exactly as the SDK reported them — including the
|
|
867
|
-
// doctor-persistent `grandfathered` advisory it owns.
|
|
868
|
-
for (const w of gv.warnings ?? []) gaps.push(`${w.kind}: ${w.message}`);
|
|
869
|
-
|
|
870
|
-
// vault-mirror-and-recover task 4.3 + vault-mirror-default:
|
|
871
|
-
// mirror currency, reported ALONGSIDE (never in place of) the
|
|
872
|
-
// deploy-related gaps above, and never blocking `run402 deploy`'s
|
|
873
|
-
// own gate — the vault lane's outcome is unaffected regardless of
|
|
874
|
-
// mirror state (design D6). `mirror_currency` mirrors `mirror
|
|
875
|
-
// status`'s own tri-state: `current` / `stale` / `unknown` (mirror
|
|
876
|
-
// unreachable or vault unread). Only STALE is actionable enough to
|
|
877
|
-
// become a warning gap; a vault with no successful mirror copy yet
|
|
878
|
-
// carries the SDK-computed `vault_unmirrored` finding — named and
|
|
879
|
-
// standing (vault-mirror-default supersedes the old anonymous
|
|
880
|
-
// `advisory` string), echoed verbatim, and deliberately NOT pushed
|
|
881
|
-
// into `gaps`: informational, never blocking, computed client-side
|
|
882
|
-
// only, cleared by the first successful mirror write or sync.
|
|
883
|
-
if (gv.vault !== null && value.repo_id) {
|
|
884
|
-
try {
|
|
885
|
-
const mirrorStatus = await getSdk().repos.mirrorStatus({ repo_id: value.repo_id, is_byo: value.storage_profile === "byo" });
|
|
886
|
-
value.vault_mirror = {
|
|
887
|
-
configured: mirrorStatus.configured,
|
|
888
|
-
destination: mirrorStatus.destination,
|
|
889
|
-
mirrored_generation: mirrorStatus.mirrored_generation,
|
|
890
|
-
newest_generation: mirrorStatus.newest_generation,
|
|
891
|
-
is_current: mirrorStatus.is_current,
|
|
892
|
-
last_success_at: mirrorStatus.last_success_at,
|
|
893
|
-
finding: mirrorStatus.finding,
|
|
894
|
-
validity_not_freshness: mirrorStatus.validity_not_freshness,
|
|
895
|
-
keystore_still_required: mirrorStatus.keystore_still_required,
|
|
896
|
-
};
|
|
897
|
-
if (mirrorStatus.is_current === false) {
|
|
898
|
-
gaps.push(`the ciphertext mirror at ${mirrorStatus.destination} is STALE (mirrored generation ${mirrorStatus.mirrored_generation ?? "(none)"}, vault newest ${mirrorStatus.newest_generation ?? "(none)"}) — ${mirrorStatus.closing_command}`);
|
|
899
|
-
}
|
|
900
|
-
} catch {
|
|
901
|
-
// Best-effort: a mirror status read failing is never a doctor
|
|
902
|
-
// failure, and never touches the deploy-related gaps above.
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
// vault-byo-primary-bucket task 3.5 — unconditional, independent
|
|
907
|
-
// of mirror status (D7); imported from the canonical constants, never
|
|
908
|
-
// paraphrased.
|
|
909
|
-
const byoDisclosure = value.storage_profile === "byo" ? ` Storage: byo (${value.byo_destination ?? "(unknown)"}) — ${VAULT_BYO_NO_PAYLOAD_COPY_STATEMENT}` : "";
|
|
910
|
-
checks.push({
|
|
911
|
-
name: "vault",
|
|
912
|
-
status: gaps.length > 0 ? "warning" : "ok",
|
|
913
|
-
value: gaps.length > 0 ? { ...value, gaps } : value,
|
|
914
|
-
hint: (gv.vault === null
|
|
915
|
-
? `No vault for this project (that is a normal shape). Allocate one with 'run402 repos create --project <project_id>'. Keystore: ${gv.keystore.root}`
|
|
916
|
-
: gv.durability_statement
|
|
917
|
-
? `Back up ${gv.keystore.root} anyway — ${gv.durability_statement} (covering_recipients: ${gv.covering_recipients})`
|
|
918
|
-
: `Back up ${gv.keystore.root} — whole-machine or whole-keystore loss is terminal for vault history.`) + byoDisclosure,
|
|
919
|
-
});
|
|
920
|
-
} catch (err) {
|
|
921
|
-
// A gateway without vault, an unreachable API, or a project this
|
|
922
|
-
// wallet cannot see. None of those is a local health problem.
|
|
923
|
-
checks.push({
|
|
924
|
-
name: "vault",
|
|
925
|
-
status: "skipped",
|
|
926
|
-
message: describeCheckFailure("vault status check", err),
|
|
927
|
-
});
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
// 7. Source-tree scan (auth-aware-ssr Section 9). Detects hallucinated
|
|
933
|
-
// SDK names, state-changing GETs, auth.* in prerendered pages, and
|
|
934
|
-
// direct mutation of internal.sessions.authz_version. Hits with severity
|
|
935
|
-
// `error` block deploy (`run402 deploy` wraps doctor and respects exit
|
|
936
|
-
// code). Skipped via --no-scan when the user wants config-only checks, and
|
|
937
|
-
// by any --only that omits it — on a monorepo this check's findings can
|
|
938
|
-
// otherwise bury the vault diagnosis under thousands of hits.
|
|
939
|
-
if (!skipScan && wanted("source_scan")) {
|
|
940
|
-
try {
|
|
941
|
-
const scope = await resolveApplicationScope({ dir: flagValue(all, "--dir") ?? undefined, manifest: flagValue(all, "--manifest") ?? undefined });
|
|
942
|
-
if (!scanDirOverride && !scope.selected) {
|
|
943
|
-
checks.push({ name: "source_scan", status: "skipped", value: { scope: "unscoped", app_root: scope.app_root }, message: "No application selected. Run doctor --dir <app> or --manifest <path>; sibling applications are not deployment blockers." });
|
|
944
|
-
} else {
|
|
945
|
-
const scanRoot = scanDirOverride ?? resolveScanRoot(scope.app_root);
|
|
946
|
-
const scanContext = { scope: scanDirOverride ? "explicit_scan_directory" : "application", app_root: scope.app_root, manifest_path: scope.manifest_path };
|
|
947
|
-
let findings;
|
|
948
|
-
if (scanDirOverride) findings = scanSourceTree(scanRoot, { cwd: scope.app_root });
|
|
949
|
-
else {
|
|
950
|
-
const selected = await loadApplicationScanInput(scope.manifest_path);
|
|
951
|
-
scanContext.build_outputs = selected.build_deferred ? "deferred_until_build" : "not_deferred";
|
|
952
|
-
findings = scanDeploymentSources(selected.spec, scope.app_root).findings;
|
|
953
|
-
}
|
|
954
|
-
const errorFindings = findings.filter((f) => f.severity === SCAN_SEVERITY.ERROR);
|
|
955
|
-
const warnFindings = findings.filter((f) => f.severity === SCAN_SEVERITY.WARN);
|
|
956
|
-
if (findings.length === 0) {
|
|
957
|
-
checks.push({ name: "source_scan", status: "ok", value: { ...scanContext, scan_root: scanRoot, file_count_with_findings: 0 } });
|
|
958
|
-
} else {
|
|
959
|
-
checks.push({
|
|
960
|
-
name: "source_scan",
|
|
961
|
-
status: errorFindings.length > 0 ? "error" : "warning",
|
|
962
|
-
...(scanDirOverride ? { severity: "advisory" } : {}),
|
|
963
|
-
value: {
|
|
964
|
-
...scanContext,
|
|
965
|
-
scan_root: scanRoot,
|
|
966
|
-
findings: errorFindings.length + warnFindings.length,
|
|
967
|
-
errors: errorFindings.length,
|
|
968
|
-
warnings: warnFindings.length,
|
|
969
|
-
details: findings,
|
|
970
|
-
},
|
|
971
|
-
hint: errorFindings.length > 0
|
|
972
|
-
? scanDirOverride ? "Findings are from the explicit arbitrary scan directory; they do not establish that an application deploy will be refused." : "Fix the findings in this application. The same scoped source scan gates up and deploy."
|
|
973
|
-
: "Source scan emitted warnings (non-blocking). Review and address when convenient.",
|
|
974
|
-
});
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
} catch (err) {
|
|
978
|
-
checks.push({
|
|
979
|
-
name: "source_scan",
|
|
980
|
-
status: "error",
|
|
981
|
-
value: { code: err?.code ?? "APPLICATION_SCAN_FAILED", details: err?.details ?? null },
|
|
982
|
-
message: err instanceof Error ? err.message : String(err),
|
|
983
|
-
});
|
|
984
|
-
}
|
|
249
|
+
reportLocalOrSdkError(err);
|
|
250
|
+
return;
|
|
985
251
|
}
|
|
986
|
-
|
|
987
|
-
// `ok` is structural — "no check is blocking" — never an allowlist of
|
|
988
|
-
// status strings. Advisory warnings ride in `warnings[]` and never change
|
|
989
|
-
// the exit code; `blocking[]` is what to fix when `ok` is false.
|
|
990
|
-
const report = buildDoctorReport(checks);
|
|
991
252
|
console.log(JSON.stringify(report, null, 2));
|
|
992
253
|
process.exit(report.ok ? 0 : 1);
|
|
993
254
|
}
|