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.
Files changed (163) hide show
  1. package/README.md +1 -1
  2. package/core-dist/binding-file.js +28 -1
  3. package/core-dist/wallet.js +3 -3
  4. package/lib/auth.mjs +1 -2
  5. package/lib/buzz-doctor.mjs +21 -806
  6. package/lib/buzz-doctor.test.mjs +7 -6
  7. package/lib/buzz.test.mjs +13 -2
  8. package/lib/doctor.mjs +72 -811
  9. package/lib/init.mjs +35 -576
  10. package/lib/org-context.mjs +39 -376
  11. package/lib/orgs.mjs +22 -106
  12. package/lib/projects.mjs +1 -2
  13. package/lib/repos.mjs +6 -6
  14. package/lib/rooms.mjs +1 -2
  15. package/lib/sdk-errors.mjs +26 -0
  16. package/lib/status.mjs +14 -154
  17. package/lib/up.mjs +1 -2
  18. package/lib/vault-scaffold.mjs +2 -3
  19. package/lib/vault-target.mjs +14 -141
  20. package/lib/wallet-context.mjs +61 -227
  21. package/lib/wallets.mjs +47 -310
  22. package/package.json +1 -2
  23. package/sdk/core-dist/binding-file.js +28 -1
  24. package/sdk/core-dist/wallet.js +3 -3
  25. package/sdk/dist/errors.d.ts +24 -2
  26. package/sdk/dist/errors.d.ts.map +1 -1
  27. package/sdk/dist/errors.js +44 -2
  28. package/sdk/dist/errors.js.map +1 -1
  29. package/sdk/dist/index.d.ts +18 -3
  30. package/sdk/dist/index.d.ts.map +1 -1
  31. package/sdk/dist/index.js +12 -1
  32. package/sdk/dist/index.js.map +1 -1
  33. package/sdk/dist/kernel.d.ts +34 -0
  34. package/sdk/dist/kernel.d.ts.map +1 -1
  35. package/sdk/dist/kernel.js +15 -1
  36. package/sdk/dist/kernel.js.map +1 -1
  37. package/sdk/dist/namespaces/admin.d.ts.map +1 -1
  38. package/sdk/dist/namespaces/admin.js +8 -0
  39. package/sdk/dist/namespaces/admin.js.map +1 -1
  40. package/sdk/dist/namespaces/agent.d.ts +2 -2
  41. package/sdk/dist/namespaces/agent.d.ts.map +1 -1
  42. package/sdk/dist/namespaces/agent.js +4 -0
  43. package/sdk/dist/namespaces/agent.js.map +1 -1
  44. package/sdk/dist/namespaces/agent.types.d.ts +5 -0
  45. package/sdk/dist/namespaces/agent.types.d.ts.map +1 -1
  46. package/sdk/dist/namespaces/apps.d.ts.map +1 -1
  47. package/sdk/dist/namespaces/apps.js +2 -0
  48. package/sdk/dist/namespaces/apps.js.map +1 -1
  49. package/sdk/dist/namespaces/auth.d.ts.map +1 -1
  50. package/sdk/dist/namespaces/auth.js +9 -0
  51. package/sdk/dist/namespaces/auth.js.map +1 -1
  52. package/sdk/dist/namespaces/branches.d.ts.map +1 -1
  53. package/sdk/dist/namespaces/branches.js +2 -0
  54. package/sdk/dist/namespaces/branches.js.map +1 -1
  55. package/sdk/dist/namespaces/ci.d.ts.map +1 -1
  56. package/sdk/dist/namespaces/ci.js +2 -0
  57. package/sdk/dist/namespaces/ci.js.map +1 -1
  58. package/sdk/dist/namespaces/credentials.d.ts +0 -23
  59. package/sdk/dist/namespaces/credentials.d.ts.map +1 -1
  60. package/sdk/dist/namespaces/credentials.js +29 -0
  61. package/sdk/dist/namespaces/credentials.js.map +1 -1
  62. package/sdk/dist/namespaces/escalations.d.ts.map +1 -1
  63. package/sdk/dist/namespaces/escalations.js +2 -0
  64. package/sdk/dist/namespaces/escalations.js.map +1 -1
  65. package/sdk/dist/namespaces/grants.d.ts.map +1 -1
  66. package/sdk/dist/namespaces/grants.js +4 -0
  67. package/sdk/dist/namespaces/grants.js.map +1 -1
  68. package/sdk/dist/namespaces/live.d.ts +0 -16
  69. package/sdk/dist/namespaces/live.d.ts.map +1 -1
  70. package/sdk/dist/namespaces/live.js +19 -0
  71. package/sdk/dist/namespaces/live.js.map +1 -1
  72. package/sdk/dist/namespaces/me.d.ts.map +1 -1
  73. package/sdk/dist/namespaces/me.js +3 -0
  74. package/sdk/dist/namespaces/me.js.map +1 -1
  75. package/sdk/dist/namespaces/org.d.ts.map +1 -1
  76. package/sdk/dist/namespaces/org.js +3 -0
  77. package/sdk/dist/namespaces/org.js.map +1 -1
  78. package/sdk/dist/namespaces/projects.d.ts.map +1 -1
  79. package/sdk/dist/namespaces/projects.js +5 -0
  80. package/sdk/dist/namespaces/projects.js.map +1 -1
  81. package/sdk/dist/namespaces/repos.d.ts.map +1 -1
  82. package/sdk/dist/namespaces/repos.js +5 -0
  83. package/sdk/dist/namespaces/repos.js.map +1 -1
  84. package/sdk/dist/namespaces/rooms.d.ts.map +1 -1
  85. package/sdk/dist/namespaces/rooms.js +3 -0
  86. package/sdk/dist/namespaces/rooms.js.map +1 -1
  87. package/sdk/dist/namespaces/session.d.ts.map +1 -1
  88. package/sdk/dist/namespaces/session.js +18 -0
  89. package/sdk/dist/namespaces/session.js.map +1 -1
  90. package/sdk/dist/namespaces/transfers.d.ts.map +1 -1
  91. package/sdk/dist/namespaces/transfers.js +3 -0
  92. package/sdk/dist/namespaces/transfers.js.map +1 -1
  93. package/sdk/dist/namespaces/wallets.d.ts.map +1 -1
  94. package/sdk/dist/namespaces/wallets.js +2 -0
  95. package/sdk/dist/namespaces/wallets.js.map +1 -1
  96. package/sdk/dist/namespaces/write-approval.d.ts.map +1 -1
  97. package/sdk/dist/namespaces/write-approval.js +3 -0
  98. package/sdk/dist/namespaces/write-approval.js.map +1 -1
  99. package/sdk/dist/node/actions-node.d.ts.map +1 -1
  100. package/sdk/dist/node/actions-node.js +3 -0
  101. package/sdk/dist/node/actions-node.js.map +1 -1
  102. package/sdk/dist/node/buzz-doctor-contract.d.ts +155 -0
  103. package/sdk/dist/node/buzz-doctor-contract.d.ts.map +1 -0
  104. package/sdk/dist/node/buzz-doctor-contract.js +385 -0
  105. package/sdk/dist/node/buzz-doctor-contract.js.map +1 -0
  106. package/sdk/dist/node/buzz-doctor.d.ts +155 -0
  107. package/sdk/dist/node/buzz-doctor.d.ts.map +1 -0
  108. package/sdk/dist/node/buzz-doctor.js +810 -0
  109. package/sdk/dist/node/buzz-doctor.js.map +1 -0
  110. package/sdk/dist/node/credentials.d.ts +9 -4
  111. package/sdk/dist/node/credentials.d.ts.map +1 -1
  112. package/sdk/dist/node/credentials.js +1 -1
  113. package/sdk/dist/node/credentials.js.map +1 -1
  114. package/sdk/dist/node/diagnostics.d.ts +45 -0
  115. package/sdk/dist/node/diagnostics.d.ts.map +1 -0
  116. package/sdk/dist/node/diagnostics.js +67 -0
  117. package/sdk/dist/node/diagnostics.js.map +1 -0
  118. package/sdk/dist/node/doctor.d.ts +101 -0
  119. package/sdk/dist/node/doctor.d.ts.map +1 -0
  120. package/sdk/dist/node/doctor.js +618 -0
  121. package/sdk/dist/node/doctor.js.map +1 -0
  122. package/sdk/dist/node/index.d.ts +57 -5
  123. package/sdk/dist/node/index.d.ts.map +1 -1
  124. package/sdk/dist/node/index.js +36 -1
  125. package/sdk/dist/node/index.js.map +1 -1
  126. package/sdk/dist/node/init.d.ts +137 -0
  127. package/sdk/dist/node/init.d.ts.map +1 -0
  128. package/sdk/dist/node/init.js +507 -0
  129. package/sdk/dist/node/init.js.map +1 -0
  130. package/sdk/dist/node/lightning-wallet.d.ts +61 -0
  131. package/sdk/dist/node/lightning-wallet.d.ts.map +1 -0
  132. package/sdk/dist/node/lightning-wallet.js +126 -0
  133. package/sdk/dist/node/lightning-wallet.js.map +1 -0
  134. package/sdk/dist/node/local-actions.d.ts +23 -0
  135. package/sdk/dist/node/local-actions.d.ts.map +1 -0
  136. package/sdk/dist/node/local-actions.js +38 -0
  137. package/sdk/dist/node/local-actions.js.map +1 -0
  138. package/sdk/dist/node/org-context.d.ts +169 -0
  139. package/sdk/dist/node/org-context.d.ts.map +1 -0
  140. package/sdk/dist/node/org-context.js +403 -0
  141. package/sdk/dist/node/org-context.js.map +1 -0
  142. package/sdk/dist/node/status.d.ts +62 -0
  143. package/sdk/dist/node/status.d.ts.map +1 -0
  144. package/sdk/dist/node/status.js +132 -0
  145. package/sdk/dist/node/status.js.map +1 -0
  146. package/sdk/dist/node/vault-target.d.ts +49 -0
  147. package/sdk/dist/node/vault-target.d.ts.map +1 -0
  148. package/sdk/dist/node/vault-target.js +109 -0
  149. package/sdk/dist/node/vault-target.js.map +1 -0
  150. package/sdk/dist/node/wallets.d.ts +242 -0
  151. package/sdk/dist/node/wallets.d.ts.map +1 -0
  152. package/sdk/dist/node/wallets.js +484 -0
  153. package/sdk/dist/node/wallets.js.map +1 -0
  154. package/sdk/dist/secret-gate.d.ts +377 -0
  155. package/sdk/dist/secret-gate.d.ts.map +1 -0
  156. package/sdk/dist/secret-gate.js +273 -0
  157. package/sdk/dist/secret-gate.js.map +1 -0
  158. package/vault-surface.json +1 -1
  159. package/fixtures/buzz-v0.5.2-cli-capabilities.json +0 -92
  160. package/fixtures/run402-buzz-doctor-v1-contract.json +0 -140
  161. package/lib/buzz-doctor-contract.mjs +0 -97
  162. package/lib/buzz-doctor-contract.test.mjs +0 -134
  163. 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 — Health and config diagnostics.
4
- *
5
- * Reports the state of the local Run402 setup: config dir, wallet,
6
- * tier, project selection, API reachability. Agent-friendly: with
7
- * `--json`, emits a structured report the agent can branch on without
8
- * parsing English output.
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 { existsSync, statSync } from "node:fs";
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
- * Check statuses that carry no finding at all. Everything else is either the
190
- * one advisory status (`warning`) or a blocking status — so a NEW status
191
- * string that slips into a check (a lifecycle name, a tier name, a typo)
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
- export function buildDoctorReport(rawChecks) {
230
- const checks = rawChecks.map((check) => {
231
- const { name, status, severity: _pinned, ...rest } = check;
232
- return { name, status, severity: severityOf(check), ...rest };
233
- });
234
- const blocking = [];
235
- const warnings = [];
236
- for (const check of checks) {
237
- if (check.severity === "blocking") {
238
- blocking.push({
239
- check: check.name,
240
- status: check.status,
241
- message: check.message ?? `${check.name}: ${check.status}`,
242
- ...(check.hint && { hint: check.hint }),
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
- continue;
245
- }
246
- if (check.severity !== "advisory") continue;
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
- return { ok: blocking.length === 0, blocking, warnings, checks };
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 against the stable registry
318
- // ABOVE the buzz early-return, so an unknown name is BAD_USAGE regardless
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
- for (const name of onlyChecks) {
323
- if (!DOCTOR_CHECK_NAMES.includes(name)) {
324
- fail({
325
- code: "BAD_USAGE",
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 wholly separate, always-complete report shape — an --only
333
- // that named ordinary-mode checks would be silently ignored under --buzz
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
- const checks = [];
359
- const CONFIG_DIR = configDir();
360
-
361
- // 1. Config directory.
362
- if (wanted("config_dir")) try {
363
- if (existsSync(CONFIG_DIR) && statSync(CONFIG_DIR).isDirectory()) {
364
- checks.push({ name: "config_dir", status: "ok", value: CONFIG_DIR });
365
- } else {
366
- checks.push({
367
- name: "config_dir",
368
- status: "missing",
369
- value: CONFIG_DIR,
370
- hint: "Run 'run402 init' to set up the config directory.",
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
- checks.push({
468
- name: "api_reachable",
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
  }