@tokenoftrust/cli 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +5 -0
  2. package/bin/tot.mjs +58 -79
  3. package/package.json +6 -1
  4. package/src/activity.mjs +15 -14
  5. package/src/app-scaffold.mjs +4 -4
  6. package/src/auth.mjs +13 -5
  7. package/src/candidate-state.mjs +3 -3
  8. package/src/commands/accept.mjs +65 -38
  9. package/src/commands/app/dev.mjs +8 -4
  10. package/src/commands/app/index.mjs +3 -3
  11. package/src/commands/app/scaffold.mjs +1 -1
  12. package/src/commands/branches.mjs +4 -3
  13. package/src/commands/cleanup.mjs +7 -11
  14. package/src/commands/clone.mjs +23 -20
  15. package/src/commands/dev.mjs +42 -24
  16. package/src/commands/doctor.mjs +4 -4
  17. package/src/commands/git-credential.mjs +2 -2
  18. package/src/commands/go-live.mjs +9 -5
  19. package/src/commands/grants.mjs +7 -5
  20. package/src/commands/hotfix.mjs +1 -1
  21. package/src/commands/ideas.mjs +2 -2
  22. package/src/commands/link.mjs +2 -2
  23. package/src/commands/login.mjs +5 -6
  24. package/src/commands/pr.mjs +33 -19
  25. package/src/commands/preview-build.mjs +6 -6
  26. package/src/commands/preview-doctor.mjs +225 -0
  27. package/src/commands/preview-retry-evidence.mjs +156 -0
  28. package/src/commands/preview.mjs +19 -3
  29. package/src/commands/revert.mjs +5 -5
  30. package/src/commands/rollback.mjs +18 -16
  31. package/src/commands/ship.mjs +29 -12
  32. package/src/commands/start.mjs +61 -51
  33. package/src/commands/submit.mjs +360 -50
  34. package/src/commands/sync.mjs +2 -2
  35. package/src/commands/validate.mjs +4 -3
  36. package/src/commands/whoami.mjs +1 -1
  37. package/src/dev-heartbeat.mjs +3 -2
  38. package/src/dev-logs.mjs +2 -2
  39. package/src/errors.mjs +11 -4
  40. package/src/git-credential.mjs +94 -21
  41. package/src/last-tenant.mjs +1 -1
  42. package/src/mcp.mjs +6 -1
  43. package/src/merge-doctor-report.mjs +208 -0
  44. package/src/oauth.mjs +18 -14
  45. package/src/obstacle-beacon.cjs +2 -2
  46. package/src/obstacle.mjs +1 -1
  47. package/src/plan.mjs +16 -21
  48. package/src/sample.mjs +4 -4
  49. package/src/validate.mjs +135 -15
  50. package/src/vendor/private-apps-devkit.mjs +3 -3
  51. package/src/viewer-session.mjs +118 -0
  52. package/template/private-app/README.md +12 -6
  53. package/src/commands/retire.mjs +0 -203
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * `tot accept --tenant <t> --pr <N>` (alias: `tot merge`) — OPERATOR verb: QUEUE a
3
3
  * PR's integration into the protected `preview` AGGREGATE. This SUPERSEDES the
4
- * retired `tot accept` = merge-PR→main semantics (operator-console U5): accepting a
4
+ * retired `tot accept` = merge-PR→main semantics: accepting a
5
5
  * change no longer merges it to main — it enqueues it into the tenant's shared
6
- * `preview` aggregate, where b07's tenant-serialized queue merges it (b06
6
+ * `preview` aggregate, where the tenant-serialized queue merges it (via
7
7
  * `candidate_accept`, preview-base only), rebuilds the aggregate, and moves the
8
8
  * shared preview pointer ONLY when combined evidence is green.
9
9
  *
@@ -14,20 +14,20 @@
14
14
  * see.
15
15
  *
16
16
  * TARGET RESOLUTION — `--pr N --tenant t` names the PR; the server resolves it to its
17
- * candidate from the tenant's ReviewEnvironment index (unit b04 — tenant + PR, NO
17
+ * candidate from the tenant's ReviewEnvironment index (tenant + PR, NO
18
18
  * chg-id). An explicit `--change-id` is still accepted (targets a specific record),
19
- * but is no longer REQUIRED — the whole point of b08 is that a PR number is enough.
19
+ * but is no longer REQUIRED — a PR number alone is enough.
20
20
  *
21
21
  * TRANSPORT — the honest accept path is `POST /api/changes/integrate` (the ONE call
22
- * site of b07's `TenantIntegrationQueue.enqueue`). Like `tot ship --pr` (U16) and
23
- * `tot pr list --tenant` (U17), the CLI reaches it with the OPERATOR-SECRET Bearer
22
+ * site of `TenantIntegrationQueue.enqueue`). Like `tot ship --pr` and
23
+ * `tot pr list --tenant`, the CLI reaches it with the OPERATOR-SECRET Bearer
24
24
  * transport (`resolveOperatorSecret` + `X-Tot-Owner` + `x-tot-capability`), since the
25
25
  * CLI holds no storefront cookie. The response is the honest `IntegrateOutcome` —
26
26
  * `queueState` / `runState` / `pointerMoved` / `aggregateSha` / `statusMessage` —
27
27
  * which this verb renders VERBATIM, never a bare "merged".
28
28
  *
29
29
  * HUMAN GATE — integrating into the shared preview is a decision a human makes, so
30
- * this ALWAYS states the EXACT plan (shared `planForAction`, unit U10: which PR,
30
+ * this ALWAYS states the EXACT plan (shared `planForAction`: which PR,
31
31
  * which tenant, "queue for integration into the preview aggregate — NO merge, NO
32
32
  * go-live") and requires an explicit confirm. `--yes` is an explicit affirmative; a
33
33
  * non-TTY without `--yes` is refused (mirrors `tot ship`'s non-TTY refusal).
@@ -39,17 +39,24 @@ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
39
39
  // Reuse `tot ship`'s operator-secret precedence verbatim so accept + ship + pr-list
40
40
  // speak ONE operator-auth contract, not three.
41
41
  import { resolveOperatorSecret } from "./ship.mjs";
42
- // Automate-first conflict recovery (workstream tot-merge-conflict-resolution-ux, unit
43
- // u5): the ONE-CLICK `--refresh` path calls the `candidate_refresh` MCP tool (u1)
42
+ // Automate-first conflict recovery: the ONE-CLICK `--refresh` path calls the
43
+ // `candidate_refresh` MCP tool —
44
44
  // which has NO operator-secret HTTP route, so it is reached over the MCP client the
45
45
  // same way `tot pr` reaches `candidate_status`/`candidate_close`.
46
46
  import { createMcpClient } from "../mcp.mjs";
47
47
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
48
+ // No-operator-secret path: mint a viewer session from the developer's OWN `tot`
49
+ // login and integrate as themselves (server gates on their live ship-on-behalf grant).
50
+ import { resolveViewerTransport } from "../viewer-session.mjs";
51
+ // The doctor PUSH: on a failed accept/integrate, auto-append the COMPACT
52
+ // "here's what's blocking you" summary over the SAME transport we just used, so a
53
+ // developer doesn't have to remember to run `tot preview doctor` themselves.
54
+ import { autoSurfaceDoctor } from "../merge-doctor-report.mjs";
48
55
 
49
56
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
50
57
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
51
58
 
52
- // The three refresh strategies `candidate_refresh` (u1) accepts, mirroring the admin
59
+ // The three refresh strategies `candidate_refresh` accepts, mirroring the admin
53
60
  // panel's one-click resolver (keep-mine / keep-current / smart-merge). `merge` is the
54
61
  // DEFAULT: a real forge three-way merge that refuses cleanly (with the diverged file
55
62
  // list) on a genuine same-line overlap rather than silently clobbering either side —
@@ -170,7 +177,7 @@ function candidateLabel(pr, changeId) {
170
177
 
171
178
  /**
172
179
  * Read the forge `mergeable` verdict for a candidate out of a `GET /api/changes`
173
- * body (b07's operator queue — its BUILT entries carry the full ReviewEnvironment
180
+ * body (the operator queue — its BUILT entries carry the full ReviewEnvironment
174
181
  * shape, incl. `mergeable`). This is the mergeable PREFLIGHT source: one read over the
175
182
  * SAME operator-secret transport accept already uses, so a not-mergeable candidate is
176
183
  * caught BEFORE a doomed integrate round-trips to the forge. Best-effort by design —
@@ -212,7 +219,7 @@ export function readCandidateVerdict(data, { pr, changeId }) {
212
219
  }
213
220
 
214
221
  /**
215
- * Normalise a `candidate_refresh` (u1) result read back over the MCP client.
222
+ * Normalise a `candidate_refresh` result read back over the MCP client.
216
223
  * `status:"committed"` is the ONLY success (the candidate was rebuilt and is now
217
224
  * mergeable); a `merge_failed` refusal carries `unresolved` (the diverged files);
218
225
  * any other status is an honest refusal/error surfaced by its `message`. Reads
@@ -260,7 +267,7 @@ export function notMergeableOfferLines(tenant, { pr, changeId }) {
260
267
  }
261
268
 
262
269
  /**
263
- * Run `candidate_refresh` (u1) over the MCP client — the ONE-CLICK rebuild of a
270
+ * Run `candidate_refresh` over the MCP client — the ONE-CLICK rebuild of a
264
271
  * not-mergeable candidate onto the current preview tip. `candidate_refresh` has no
265
272
  * operator-secret HTTP route, so this reaches it exactly as `tot pr` reaches
266
273
  * `candidate_status`: an OAuth developer session (`tot login`) + `client_switch` to
@@ -453,8 +460,10 @@ export function reportIntegrated(result, { tenant, label }) {
453
460
  * unit-tested with no network/TTY.
454
461
  *
455
462
  * @param {{ tenant:string, pr:number|null, changeId:string|null, headSha:string|null,
456
- * secret:string, storefrontUrl?:string|null, yes?:boolean }} params
457
- * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm }} [deps]
463
+ * secret:string, storefrontUrl?:string|null, yes?:boolean, refresh?:boolean,
464
+ * strategy?:string, mcpUrl?:string|null, identity?:string|null, env?:NodeJS.ProcessEnv }} params
465
+ * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
466
+ * resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
458
467
  * @returns {Promise<number>} process exit code
459
468
  */
460
469
  export async function runIntegrate(
@@ -476,10 +485,11 @@ export async function runIntegrate(
476
485
  ) {
477
486
  const fetchImpl = deps.fetch || globalThis.fetch;
478
487
  const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
479
- const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
488
+ const resolveViewer = deps.resolveViewerTransport || resolveViewerTransport;
489
+ let base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
480
490
  const label = pr != null ? `PR #${pr}` : changeId;
481
491
 
482
- // 1. State the EXACT plan (shared U10 affordance) — queue-integrate-into-preview,
492
+ // 1. State the EXACT plan (shared affordance) — queue-integrate-into-preview,
483
493
  // NO merge, NO go-live — and gate on an explicit confirm.
484
494
  const planLines = planForAction({ action: "accept", tenant, pr, changeId, headSha });
485
495
  const { confirmed, reason } = await confirmPlan(planLines, {
@@ -502,25 +512,39 @@ export async function runIntegrate(
502
512
  return 1;
503
513
  }
504
514
 
505
- // 2. Operator-secret transport — the CLI holds no storefront cookie, so the
506
- // Bearer + X-Tot-Owner path is its route (same as `tot ship --pr`). No
507
- // `content-type` here: it's added per-POST; the mergeable preflight GET wants none.
508
- if (!secret) {
509
- console.error(
510
- fail(
511
- "integrating a PR is an OPERATOR action — it needs an operator secret",
512
- "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
513
- ),
514
- );
515
- return 2;
515
+ // 2. Resolve the transport. Two routes to the SAME `/api/changes/integrate`:
516
+ // - OPERATOR SECRET (operators/CI): Bearer + X-Tot-Owner on the generic host.
517
+ // - VIEWER SESSION (an invited developer, no secret): mint a `tot_session` from
518
+ // their OWN `tot` login on the TENANT'S host and send it as a cookie. The
519
+ // server authorizes on their live ship-on-behalf grant either way.
520
+ // No `content-type` here: it's added per-POST; the mergeable preflight GET wants none.
521
+ let authHeaders;
522
+ if (secret) {
523
+ authHeaders = {
524
+ authorization: `Bearer ${secret}`,
525
+ "x-tot-owner": tenant,
526
+ "x-tot-capability": "ship-on-behalf",
527
+ };
528
+ } else {
529
+ const viewer = await resolveViewer({ tenant, env, fetchImpl });
530
+ if (!viewer.ok) {
531
+ console.error(fail(viewer.message, viewer.hint));
532
+ return 2;
533
+ }
534
+ base = viewer.base; // the tenant's own host — the dev-viewer admission is host-scoped
535
+ authHeaders = viewer.authHeaders;
516
536
  }
517
- const authHeaders = {
518
- authorization: `Bearer ${secret}`,
519
- "x-tot-owner": tenant,
520
- "x-tot-capability": "ship-on-behalf",
537
+
538
+ // Auto-surface the merge-doctor on a failed accept. Best-effort: fetches the
539
+ // hosted doctor over the SAME transport we just resolved and prints the COMPACT
540
+ // summary; never throws and never touches the exit code (it's called AFTER the
541
+ // outcome is decided, purely to append diagnostic context to where the failure
542
+ // already surfaced). A clean/unreachable doctor prints nothing.
543
+ const surfaceDoctor = async () => {
544
+ for (const line of await autoSurfaceDoctor({ base, authHeaders, fetchImpl })) console.error(line);
521
545
  };
522
546
 
523
- // 2.5. MERGEABLE PREFLIGHT — read the candidate's forge verdict from b07's queue
547
+ // 2.5. MERGEABLE PREFLIGHT — read the candidate's forge verdict from the queue
524
548
  // (`GET /api/changes`, SAME transport) so a doomed accept never round-trips to
525
549
  // the forge. Best-effort: on any listing gap it's `known:false` → proceed as
526
550
  // normal. When it KNOWS the candidate isn't mergeable, take the automate-first
@@ -543,13 +567,13 @@ export async function runIntegrate(
543
567
  },
544
568
  deps,
545
569
  );
546
- if (!handled.integrate) return handled.code;
570
+ if (!handled.integrate) return /** @type {number} */ (handled.code);
547
571
  curPr = handled.pr ?? curPr;
548
572
  curChangeId = handled.changeId ?? curChangeId;
549
573
  curHead = null; // the rebuilt PR has a fresh head; let the server re-resolve.
550
574
  }
551
575
 
552
- // 3. POST the honest accept path (b07 queue enqueue) and render the terminal state.
576
+ // 3. POST the honest accept path (queue enqueue) and render the terminal state.
553
577
  let res;
554
578
  let result;
555
579
  try {
@@ -579,7 +603,7 @@ export async function runIntegrate(
579
603
  { tenant, pr: curPr, changeId: result.changeId ?? curChangeId, strategy, refresh, mcpUrl, identity, env },
580
604
  deps,
581
605
  );
582
- if (!handled.integrate) return handled.code;
606
+ if (!handled.integrate) return /** @type {number} */ (handled.code);
583
607
  try {
584
608
  ({ res, result } = await postIntegrate(
585
609
  {
@@ -614,10 +638,13 @@ export async function runIntegrate(
614
638
  for (const line of notMergeableOfferLines(tenant, { pr: curPr, changeId: curChangeId })) {
615
639
  console.log(line);
616
640
  }
641
+ await surfaceDoctor();
617
642
  return 1;
618
643
  }
619
644
 
620
- return reportIntegrated(result, { tenant, label });
645
+ const code = reportIntegrated(result, { tenant, label: /** @type {string} */ (label) });
646
+ if (code !== 0) await surfaceDoctor();
647
+ return code;
621
648
  }
622
649
 
623
650
  /** The mergeable PREFLIGHT read (best-effort). See readCandidateVerdict. */
@@ -646,7 +673,7 @@ function reportIntegrateHttpError(result, res, { tenant, label }) {
646
673
  fail(
647
674
  `the integration queue refused the request: ${msg}`,
648
675
  res.status === 401 || res.status === 403
649
- ? "check the operator secret and that it's authorised for this tenant"
676
+ ? "you need a live ship-on-behalf grant on this tenant (ask the store owner) or an operator secret authorised for it"
650
677
  : res.status === 404
651
678
  ? `check that ${label} has a built candidate in ${tenant}'s queue (\`tot pr list --tenant ${tenant}\`)`
652
679
  : "check --tenant / --url / --pr, then re-run",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `tot app dev` — the local Private App harness (PrivateApps epic D6 Chunk D).
2
+ * `tot app dev` — the local Private App harness (PrivateApps epic).
3
3
  * Everything here is OFFLINE: no network beyond the localhost URL you point
4
4
  * it at, no MCP, no real ToT credentials. It generates and reuses its own
5
5
  * throwaway RS256 keypair per app directory (`.tot/dev-keys.json`) so signing
@@ -49,7 +49,10 @@ function randHex(bytes) {
49
49
  return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("hex");
50
50
  }
51
51
 
52
- /** Load or create the app's throwaway RS256 keypair at `<appDir>/.tot/dev-keys.json`. */
52
+ /**
53
+ * Load or create the app's throwaway RS256 keypair at `<appDir>/.tot/dev-keys.json`.
54
+ * @param {string} appDir @param {{ kid?: string }} [opts]
55
+ */
53
56
  export async function ensureDevKeys(appDir, { kid } = {}) {
54
57
  const keysPath = join(appDir, ".tot", "dev-keys.json");
55
58
  const alg = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };
@@ -107,8 +110,9 @@ function runValidate(argv, { appDir }) {
107
110
  console.log(`✔ ${manifestPath} is a valid tot-app.json (contract v${result.manifest.contractVersion})`);
108
111
  return 0;
109
112
  }
110
- console.log(`✖ ${manifestPath} ${result.errors.length} error(s):`);
111
- for (const e of result.errors) console.log(` - ${e}`);
113
+ const errs = result.errors || [];
114
+ console.log(`✖ ${manifestPath} — ${errs.length} error(s):`);
115
+ for (const e of errs) console.log(` - ${e}`);
112
116
  return 1;
113
117
  }
114
118
 
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `tot app` — the Storefront Private App developer subcommand group
3
- * (PrivateApps epic D6 Chunks C+D): scaffold a new app, then exercise its
3
+ * (PrivateApps epic): scaffold a new app, then exercise its
4
4
  * webhook/manifest/widget-launch loop entirely offline.
5
5
  *
6
6
  * tot app scaffold <name> materialize a runnable Private App skeleton
@@ -23,11 +23,11 @@ export async function run(argv, ctx) {
23
23
 
24
24
  if (sub === "scaffold") {
25
25
  const { run: runScaffold } = await import("./scaffold.mjs");
26
- return runScaffold(rest, ctx);
26
+ return /** @type {any} */ (runScaffold)(rest, ctx);
27
27
  }
28
28
  if (sub === "dev") {
29
29
  const { run: runDev } = await import("./dev.mjs");
30
- return runDev(rest, ctx);
30
+ return /** @type {any} */ (runDev)(rest, ctx);
31
31
  }
32
32
 
33
33
  console.error(fail(`unknown \`tot app\` subcommand: ${sub}`, "tot app --help"));
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `tot app scaffold <name>` — materialize a runnable Storefront Private App
3
- * skeleton on disk (PrivateApps epic D6 Chunk C). See ../../app-scaffold.mjs
3
+ * skeleton on disk (PrivateApps epic). See ../../app-scaffold.mjs
4
4
  * for the offline, idempotent copy logic this wraps.
5
5
  *
6
6
  * tot app scaffold <name> scaffold ./<name>
@@ -1,13 +1,13 @@
1
1
  /**
2
- * `tot branches` — the full branch-cleanup REPORT for one tenant's repo (P1
3
- * item 9 of the branch-lifecycle-and-integration-preview contract: "join
2
+ * `tot branches` — the full branch-cleanup REPORT for one tenant's repo (per
3
+ * the branch-lifecycle-and-integration-preview contract: "join
4
4
  * branch, PR, candidate, head SHA, actor, full zoned timestamp, age, evidence,
5
5
  * aggregate status, and cleanup eligibility"). Distinct from `tot pr list`,
6
6
  * which only shows OPEN PRs — this shows EVERY branch on the repo (protected,
7
7
  * active, stale, integrated, closed, orphaned), because that is what a
8
8
  * cleanup decision needs.
9
9
  *
10
- * The report itself is server-side classification (b13's `candidate_list` MCP
10
+ * The report itself is server-side classification (the `candidate_list` MCP
11
11
  * tool → `classifyBranches`); this command is presentation only — it never
12
12
  * re-derives eligibility, an "aggregate" verdict, or a classification bucket
13
13
  * client-side. The contract's complaint about a "truncated human table" (see
@@ -58,6 +58,7 @@ Options:
58
58
 
59
59
  /** Parse `tot branches` argv. Pure — unit-testable. */
60
60
  export function parseBranchesArgs(argv) {
61
+ /** @type {{ tenant: string|null, repo: string|null, staleAfterDays: number|null, mcp: string|null, identity: string|null, json: boolean, help: boolean }} */
61
62
  const a = {
62
63
  tenant: null,
63
64
  repo: null,
@@ -1,9 +1,9 @@
1
1
  /**
2
- * `tot cleanup` — owner-confirmed branch GC (P1 items 9+10 of the
3
- * branch-lifecycle-and-integration-preview contract's "Current gaps").
2
+ * `tot cleanup` — owner-confirmed branch GC, per the
3
+ * branch-lifecycle-and-integration-preview contract's "Current gaps".
4
4
  *
5
5
  * `tot cleanup --dry-run` runs the SAME fresh server-side classification
6
- * `tot branches` shows (b13's `candidate_list` MCP tool) and prints exactly
6
+ * `tot branches` shows (the `candidate_list` MCP tool) and prints exactly
7
7
  * which refs are eligible to delete and WHY — no writes, ever, no matter what
8
8
  * else is passed. An owner-confirmed execution mode (`--yes`, or an
9
9
  * interactive y/N via the shared plan affordance) then deletes ONLY that
@@ -12,10 +12,6 @@
12
12
  * — this CLI's dry-run read is a convenience for the human, never the source
13
13
  * of truth the delete trusts.
14
14
  *
15
- * Distinct from `tot retire` (evicts a HOSTED PREVIEW artifact, rebuildable,
16
- * touches no git ref) — `tot cleanup` is branch GC: an irreversible ref
17
- * delete. See retire.mjs's header for that distinction.
18
- *
19
15
  * THE CONTRACT'S HARD RULES, ENFORCED SERVER-SIDE AND NEVER RELAXED HERE:
20
16
  * - age alone NEVER makes an open candidate deletable (`stale-open` is a
21
17
  * WARN bucket only);
@@ -45,9 +41,8 @@ const USAGE = `tot cleanup — owner-confirmed branch GC (deletes terminal candi
45
41
  Deletes ONLY branches the server just (re-)classified as "integrated" (PR
46
42
  merged) or "closed-or-rejected" (PR closed without merging). NEVER deletes
47
43
  by age alone, NEVER touches \`main\`/\`preview\`, and NEVER deletes an orphan
48
- (no PR record) — orphans are reported, not removed. \`tot retire\` is a
49
- DIFFERENT verb: it evicts a hosted preview artifact (rebuildable), not a
50
- git branch.
44
+ (no PR record) — orphans are reported, not removed. Reject closes the PR and
45
+ removes its hosted candidate artifact; cleanup deletes the terminal git ref.
51
46
 
52
47
  Options:
53
48
  --tenant <appDomain> Target tenant/repo. Defaults to the current
@@ -65,6 +60,7 @@ Options:
65
60
 
66
61
  /** Parse `tot cleanup` argv. Pure — unit-testable. */
67
62
  export function parseCleanupArgs(argv) {
63
+ /** @type {{ tenant: string|null, repo: string|null, staleAfterDays: number|null, ref: string|null, dryRun: boolean, yes: boolean, mcp: string|null, identity: string|null, help: boolean }} */
68
64
  const a = {
69
65
  tenant: null,
70
66
  repo: null,
@@ -206,7 +202,7 @@ export async function run(argv, ctx) {
206
202
 
207
203
  const planLines = planForAction({
208
204
  action: "cleanup",
209
- tenant: tenant || repo,
205
+ tenant: /** @type {string} */ (tenant || repo),
210
206
  refs: eligible.map((b) => ({ ref: b.ref, sha: b.sha, reason: b.reason })),
211
207
  });
212
208
  const { confirmed, reason } = await printPlanAndConfirm(planLines, {
@@ -23,7 +23,7 @@
23
23
  * resolved via src/auth.mjs). When there's no session yet and we're on a TTY, we
24
24
  * offer to sign in right here and retry — no "run tot login, then re-run".
25
25
  *
26
- * OPERATOR CROSS-TENANT PR CLONE (unit u6). `tot clone <tenant> --pr <N> [dir]`
26
+ * OPERATOR CROSS-TENANT PR CLONE. `tot clone <tenant> --pr <N> [dir]`
27
27
  * materializes a PARTICULAR pull request's head — including on a tenant the
28
28
  * operator does NOT own/isn't a member of (e.g. inspecting someone else's PR
29
29
  * #7). This does NOT go through `tenant_checkout` — that tool mints a PUSH
@@ -61,6 +61,7 @@ const execFileP = promisify(execFile);
61
61
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
62
62
 
63
63
  function parseArgs(argv) {
64
+ /** @type {{ tenant: string|null, dir: string|null, tag: string, pr: string|null, remoteOnly: boolean, mcp: string|null, printRemote: boolean, help: boolean }} */
64
65
  const a = {
65
66
  tenant: null,
66
67
  dir: null,
@@ -325,10 +326,10 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
325
326
 
326
327
  /**
327
328
  * git clone the authenticated remote into `dir` — WITHOUT ever writing the
328
- * live token to `.git/config` (unit u10). The MCP mints `gitRemote` as a
329
+ * live token to `.git/config`. The MCP mints `gitRemote` as a
329
330
  * basic-auth URL (`user:token@host`); rather than passing that straight to
330
331
  * `git clone` (which records exactly the URL it was given as `origin`, token
331
- * and all — the pre-u10 shape this fixes), the token is handed to git
332
+ * and all), the token is handed to git
332
333
  * EPHEMERALLY via a one-shot `http.extraheader` (same mechanism
333
334
  * pushPreviewRef in submit.mjs uses for a push) while the clone SOURCE is
334
335
  * already the tokenless public URL — so `origin` comes out tokenless from
@@ -377,7 +378,7 @@ async function cloneRepo(gitRemote, dir, redact) {
377
378
  }
378
379
 
379
380
  /**
380
- * The cross-tenant PR-clone core (unit u6), composable in-process exactly like
381
+ * The cross-tenant PR-clone core, composable in-process exactly like
381
382
  * `checkoutTenant`: mint a READ-ONLY credential via `repo_read_credential`, then
382
383
  * (optionally) clone it and materialize PR #<pr>'s head. This is the path `--pr`
383
384
  * ALWAYS takes — own tenant or foreign — because tenant_checkout has no PR-ref
@@ -474,7 +475,7 @@ export async function cloneRepoAtPrRef(gitRemote, dir, pr, redact, deps = {}) {
474
475
  * @returns {{ message: string, next: string }|null}
475
476
  */
476
477
  export function readCredentialError(minted) {
477
- const c = minted && typeof minted === "object" && !Array.isArray(minted) ? minted : null;
478
+ const c = /** @type {any} */ (minted && typeof minted === "object" && !Array.isArray(minted) ? minted : null);
478
479
  if (c && Array.isArray(c.repos) && c.repos.some((r) => r && r.gitRemote)) return null;
479
480
  const msg =
480
481
  (c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
@@ -521,7 +522,7 @@ export function readCredentialError(minted) {
521
522
  * @returns {{ message: string, next: string }|null}
522
523
  */
523
524
  export function checkoutError(checkout) {
524
- const c = checkout && typeof checkout === "object" && !Array.isArray(checkout) ? checkout : null;
525
+ const c = /** @type {any} */ (checkout && typeof checkout === "object" && !Array.isArray(checkout) ? checkout : null);
525
526
  if (c && c.gitRemote) return null; // a usable checkout — never an error
526
527
  const msg =
527
528
  (c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
@@ -564,7 +565,8 @@ export function checkoutError(checkout) {
564
565
  * @returns {Array<{ id: string, name: string, raw: any }>}
565
566
  */
566
567
  export function normalizeStores(list) {
567
- const rows = Array.isArray(list) ? list : list?.clients || list?.tenants || [];
568
+ const l = /** @type {any} */ (list);
569
+ const rows = Array.isArray(l) ? l : l?.clients || l?.tenants || [];
568
570
  if (!Array.isArray(rows)) return [];
569
571
  return rows
570
572
  .map((r) => ({
@@ -590,23 +592,24 @@ export function normalizeStores(list) {
590
592
  */
591
593
  export function storeListError(list) {
592
594
  if (list == null || typeof list !== "object" || Array.isArray(list)) return null;
595
+ const l = /** @type {any} */ (list);
593
596
  // A resolvable store array present → it succeeded, never an error.
594
- if (Array.isArray(list.clients) || Array.isArray(list.tenants)) return null;
597
+ if (Array.isArray(l.clients) || Array.isArray(l.tenants)) return null;
595
598
  const msg =
596
- list.message ||
597
- (typeof list.error === "string" ? list.error : list.error?.message) ||
599
+ l.message ||
600
+ (typeof l.error === "string" ? l.error : l.error?.message) ||
598
601
  null;
599
- if (list.isError) return msg || "the store list request returned an error";
600
- if (typeof list.status === "string" && !/^(ok|success)$/i.test(list.status)) {
601
- return msg || `the store list request returned status "${list.status}"`;
602
+ if (l.isError) return msg || "the store list request returned an error";
603
+ if (typeof l.status === "string" && !/^(ok|success)$/i.test(l.status)) {
604
+ return msg || `the store list request returned status "${l.status}"`;
602
605
  }
603
- if (list.error) return msg || "the store list request returned an error";
604
- if (typeof list.raw === "string" && list.raw.trim()) return list.raw.trim();
606
+ if (l.error) return msg || "the store list request returned an error";
607
+ if (typeof l.raw === "string" && l.raw.trim()) return l.raw.trim();
605
608
  return null;
606
609
  }
607
610
 
608
611
  /**
609
- * Extract card c1's broker-identity remediation signal from a `client_list`
612
+ * Extract the broker-identity remediation signal from a `client_list`
610
613
  * result. When the identity resolved ZERO stores, the server MAY carry:
611
614
  * - `brokerStatus`: 'unlinked' | 'unconfigured' | 'broker_error' — present only
612
615
  * when the empty scope is a BROKER-IDENTITY problem (not an entitlement one);
@@ -620,7 +623,7 @@ export function storeListError(list) {
620
623
  * @returns {{ brokerStatus: string|null, nextAction: string|null }}
621
624
  */
622
625
  export function brokerRemediation(list) {
623
- const c = list && typeof list === "object" && !Array.isArray(list) ? list : null;
626
+ const c = /** @type {any} */ (list && typeof list === "object" && !Array.isArray(list) ? list : null);
624
627
  const brokerStatus = c && typeof c.brokerStatus === "string" ? c.brokerStatus : null;
625
628
  const nextAction =
626
629
  c && typeof c.nextAction === "string" && c.nextAction.trim() ? c.nextAction.trim() : null;
@@ -629,8 +632,8 @@ export function brokerRemediation(list) {
629
632
 
630
633
  /**
631
634
  * Status-aware human guidance for an authenticated identity that resolved ZERO
632
- * stores (card c2 — the fix for the misleading "may still be propagating / ask for
633
- * a store invite" copy that dead-ended an UNLINKED identity). Driven by card c1's
635
+ * stores (fixes the misleading "may still be propagating / ask for
636
+ * a store invite" copy that dead-ended an UNLINKED identity). Driven by
634
637
  * `brokerStatus`/`nextAction`:
635
638
  * - `unlinked` → the identity isn't linked to the ToT broker yet, so no scope can
636
639
  * resolve. This is NOT an entitlement problem — point at `tot link` (the
@@ -655,7 +658,7 @@ export function noStoresGuidance(list, { linkHint = "tot link" } = {}) {
655
658
 
656
659
  if (brokerStatus === "unlinked") {
657
660
  // The actionable terminal step beats the server's identity_link_begin/poll
658
- // MCP-tool wording for a CLI user, so lead with `tot link` (c2 optional-arm
661
+ // MCP-tool wording for a CLI user, so lead with `tot link` (the optional-arm
659
662
  // directive). Fall back to the server string only if there's no link action.
660
663
  return {
661
664
  brokerStatus,