@tokenoftrust/cli 1.4.0-rc.15 → 1.4.0-rc.17

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/bin/tot.mjs CHANGED
@@ -16,8 +16,11 @@
16
16
  * tot preview push your store to a reviewable preview ← built (validate + push preview ref; MCP preview_status read-back). `submit`/`deploy` are teaching aliases.
17
17
  * tot ship publish the current green aggregate live ← built (GET/POST /api/changes/ship → b09 orchestrator; exact plan + y/N confirm; waits for VERIFIED live truth)
18
18
  * tot accept / tot merge queue a PR into the preview aggregate ← built (b08: plan + y/N confirm → POST /api/changes/integrate = b07 queue enqueue; NO merge-to-main, NO go-live; refuses non-TTY without --yes)
19
+ * tot sync fetch `preview` + merge it into your branch ← built (b16: the common accept-conflict recovery path; local-only, stops safely on conflict)
19
20
  * tot rollback [<version>] instant re-point to a prior live version ← built (u3 promotion_status/promotion_rollback seam; diff + y/N confirm; refuses non-TTY / ineligible)
20
21
  * tot pr list / view / close your candidate PRs ← built (candidate_status/candidate_close; gh-pr-shaped)
22
+ * tot branches full branch-cleanup report (every branch, not just open PRs) ← built (b14: candidate_list)
23
+ * tot cleanup owner-confirmed branch GC (deletes terminal branches only) ← built (b14: candidate_list + candidate_delete; --dry-run classifies only)
21
24
  * tot doctor check this machine is ready
22
25
  * tot ideas copy-paste AI prompts that reliably wow
23
26
  * tot feedback send a note to ToT + your recent CLI activity ← built (activity-log.mjs → feedback_submit MCP tool)
@@ -66,10 +69,15 @@ tot — Token of Trust developer CLI
66
69
  tot preview push your store to a reviewable preview
67
70
  tot ship publish the tenant's current green aggregate live (plan → confirm → ship)
68
71
  tot accept / tot merge queue a PR into the preview aggregate — operator verb, no go-live (plan → confirm → integrate)
72
+ tot sync fetch \`preview\` and merge it into your local branch (accept-conflict recovery)
69
73
  tot rollback [<version>] instant re-point to a prior live version (list → confirm → rollback)
74
+ tot revert --preview <PR|sha> remove already-integrated content from the preview aggregate via a new revert commit — no force-reset (plan → confirm → revert)
75
+ tot hotfix --pr <N> OWNER-ONLY exception: release an urgent fix from main to live, bypassing unshipped preview work (plan → confirm → release → auto forward-integrate)
70
76
  tot retire evict a candidate PR's preview to reclaim space — operator verb, rebuildable (plan → confirm → evict)
71
77
  tot go-live cut the apex domain over to the storefront (readiness → confirm → cutover)
72
78
  tot pr list / view / close your candidate PRs
79
+ tot branches full branch-cleanup report — every branch, any PR state, cleanup eligibility
80
+ tot cleanup owner-confirmed branch GC (--dry-run to classify only; deletes terminal branches only)
73
81
  tot doctor check this machine is ready
74
82
  tot ideas copy-paste AI prompts that reliably wow
75
83
  tot feedback "<msg>" send feedback to Token of Trust (attaches recent activity)
@@ -184,11 +192,42 @@ async function dispatch(cmd, rest, ctx) {
184
192
  return run(rest, ctx);
185
193
  }
186
194
 
195
+ // `sync` is the common accept-conflict recovery path (P1 item 12): fetches the
196
+ // protected `preview` branch and merges it into the developer's local branch so
197
+ // they can resolve locally, then re-`tot preview`. Purely local — no push, no
198
+ // MCP call, never touches the shared `preview`/`main` refs themselves. See
199
+ // sync.mjs's header for the full contract.
200
+ if (cmd === "sync") {
201
+ const { run } = await import("../src/commands/sync.mjs");
202
+ return run(rest, ctx);
203
+ }
204
+
187
205
  if (cmd === "rollback") {
188
206
  const { run } = await import("../src/commands/rollback.mjs");
189
207
  return run(rest, ctx);
190
208
  }
191
209
 
210
+ // `revert --preview <PR|integration-sha>` removes already-integrated content from
211
+ // the protected `preview` aggregate via a NEW auditable revert commit (b21) — NO
212
+ // force-reset, NO touch to main/live. Backed by POST /api/changes/revert (b07's
213
+ // TenantIntegrationQueue.enqueueRevert). To undo something already LIVE, that's
214
+ // `tot rollback`, not this. See revert.mjs's header.
215
+ if (cmd === "revert") {
216
+ const { run } = await import("../src/commands/revert.mjs");
217
+ return run(rest, ctx);
218
+ }
219
+
220
+ // `hotfix --pr <N>` is the OWNER-ONLY EXCEPTION lane (b22): release an urgent fix
221
+ // from `main` to live while `preview` still holds other unshipped work, EXCLUDING
222
+ // that unshipped preview head, then automatically forward-integrate main → preview
223
+ // and re-validate. It is DELIBERATELY a distinct verb — never a `--base main` flag
224
+ // on `tot ship` (which publishes the whole green preview aggregate). Backed by
225
+ // GET/POST /api/changes/hotfix → b22's HotfixOrchestrator. See hotfix.mjs's header.
226
+ if (cmd === "hotfix") {
227
+ const { run } = await import("../src/commands/hotfix.mjs");
228
+ return run(rest, ctx);
229
+ }
230
+
192
231
  // `retire` evicts a candidate PR's hosted preview to reclaim space (unit U7) —
193
232
  // a DISTINCT operator verb from `accept`/reject: retire touches no change
194
233
  // lifecycle and is reversible-by-rebuild (`tot preview build`). See retire.mjs.
@@ -207,6 +246,24 @@ async function dispatch(cmd, rest, ctx) {
207
246
  return run(rest, ctx);
208
247
  }
209
248
 
249
+ // `branches` is the FULL branch-cleanup report (every branch, any PR state) —
250
+ // distinct from `tot pr list`, which only shows OPEN PRs (P1 item 9). Read-only;
251
+ // always safe to run, and the report `tot cleanup` reuses before deleting anything.
252
+ if (cmd === "branches") {
253
+ const { run } = await import("../src/commands/branches.mjs");
254
+ return run(rest, ctx);
255
+ }
256
+
257
+ // `cleanup` is owner-confirmed BRANCH GC (P1 items 9/10) — an irreversible git-ref
258
+ // delete, distinct from `tot retire`'s hosted-preview eviction (rebuildable, no
259
+ // branch touched). `--dry-run` classifies and prints; it never deletes. Age alone
260
+ // never makes a branch eligible; main/preview are always kept; orphans are
261
+ // quarantined, never auto-deleted. See cleanup.mjs's header.
262
+ if (cmd === "cleanup") {
263
+ const { run } = await import("../src/commands/cleanup.mjs");
264
+ return run(rest, ctx);
265
+ }
266
+
210
267
  if (cmd === "app") {
211
268
  const { run } = await import("../src/commands/app/index.mjs");
212
269
  return run(rest, ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.4.0-rc.15",
3
+ "version": "1.4.0-rc.17",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -0,0 +1,296 @@
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
4
+ * branch, PR, candidate, head SHA, actor, full zoned timestamp, age, evidence,
5
+ * aggregate status, and cleanup eligibility"). Distinct from `tot pr list`,
6
+ * which only shows OPEN PRs — this shows EVERY branch on the repo (protected,
7
+ * active, stale, integrated, closed, orphaned), because that is what a
8
+ * cleanup decision needs.
9
+ *
10
+ * The report itself is server-side classification (b13's `candidate_list` MCP
11
+ * tool → `classifyBranches`); this command is presentation only — it never
12
+ * re-derives eligibility, an "aggregate" verdict, or a classification bucket
13
+ * client-side. The contract's complaint about a "truncated human table" (see
14
+ * "Candidate branch retirement") is about hidden precision, not columns — so
15
+ * every SHA and timestamp is printed in full, never shortened.
16
+ *
17
+ * KNOWN GAP: the contract asks for an "actor" column, but neither Gitea's
18
+ * branch record nor its PR record (see `../../gitea/gitea-rest.ts`) carries a
19
+ * PR author — `candidate_list` does not surface one. Rather than fabricate it
20
+ * client-side, every row prints `actor: —` and the report states the gap once
21
+ * up top. Fixing this is a tot-mcp follow-up (surface the PR author from
22
+ * Gitea), not something this CLI can honestly synthesize.
23
+ *
24
+ * `tot cleanup` (cleanup.mjs) reuses `resolveRepoAndTenant` from here so the
25
+ * two commands resolve "which repo" identically.
26
+ *
27
+ * Dependency-free (global fetch via mcp.mjs's client + `git`).
28
+ */
29
+ import { execFileSync } from "node:child_process";
30
+ import { createMcpClient } from "../mcp.mjs";
31
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
32
+ import { fail } from "../errors.mjs";
33
+ import { repoNameFromRemote } from "./submit.mjs";
34
+
35
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
36
+
37
+ const USAGE = `tot branches — full branch-cleanup report (every branch, not just open PRs)
38
+
39
+ tot branches report for the current checkout's repo
40
+ tot branches --tenant <t> report for <t> (works without a checkout)
41
+ tot branches --json emit the raw classification report as JSON
42
+
43
+ Joins branch + PR (any state) + candidate ref + head SHA + full zoned
44
+ timestamp + age + evidence + aggregate status + cleanup eligibility for
45
+ EVERY branch on the repo — read-only, always safe to run, and the report
46
+ \`tot cleanup\` reuses before it deletes anything.
47
+
48
+ Options:
49
+ --tenant <appDomain> Target tenant/repo. Defaults to the current
50
+ checkout's tenant when run inside one.
51
+ --repo <name> Explicit repo name, if it differs from --tenant.
52
+ --stale-after-days <n> Idle-day threshold for the "stale-open" warn
53
+ bucket. Defaults to the server's policy (14).
54
+ --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
55
+ --identity <id> Prefer this cached identity over the default.
56
+ --json Print the raw classification report as JSON.
57
+ --help, -h Show this help.`;
58
+
59
+ /** Parse `tot branches` argv. Pure — unit-testable. */
60
+ export function parseBranchesArgs(argv) {
61
+ const a = {
62
+ tenant: null,
63
+ repo: null,
64
+ staleAfterDays: null,
65
+ mcp: null,
66
+ identity: null,
67
+ json: false,
68
+ help: false,
69
+ };
70
+ for (let i = 0; i < argv.length; i++) {
71
+ const t = argv[i];
72
+ if (t === "--tenant") a.tenant = argv[++i];
73
+ else if (t === "--repo") a.repo = argv[++i];
74
+ else if (t === "--stale-after-days") a.staleAfterDays = Number(argv[++i]);
75
+ else if (t === "--mcp") a.mcp = argv[++i];
76
+ else if (t === "--identity") a.identity = argv[++i];
77
+ else if (t === "--json") a.json = true;
78
+ else if (t === "--help" || t === "-h") a.help = true;
79
+ }
80
+ return a;
81
+ }
82
+
83
+ /** Best-effort `git remote get-url origin` from a checkout ctx. Never throws. */
84
+ function defaultReadRemoteUrl(ctx) {
85
+ try {
86
+ return execFileSync("git", ["-C", ctx.workspacePath, "remote", "get-url", "origin"], {
87
+ stdio: ["ignore", "pipe", "pipe"],
88
+ })
89
+ .toString()
90
+ .trim();
91
+ } catch {
92
+ return "";
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Resolve which repo + tenant a branches/cleanup call targets: an explicit
98
+ * `--repo`/`--tenant` wins; otherwise, inside a checkout, the repo is derived
99
+ * from the checkout's forge remote (same derivation `tot pr` uses) and the
100
+ * tenant from the detected context. `repo` (the Gitea repo/tenant-appDomain
101
+ * name candidate_list expects) falls back to the tenant when neither an
102
+ * explicit `--repo` nor a checkout is available — this codebase's own naming
103
+ * convention is that the repo name IS the tenant appDomain. Pure given
104
+ * `deps.readRemoteUrl` — unit-testable with no real git/network.
105
+ * @param {{tenant?:string|null, repo?:string|null}} args
106
+ * @param {any} ctx
107
+ * @param {{ readRemoteUrl?: (ctx:any) => string }} [deps]
108
+ * @returns {{ repo: string|null, tenant: string|null, error: string|null }}
109
+ */
110
+ export function resolveRepoAndTenant(args, ctx, deps = {}) {
111
+ const readRemoteUrl = deps.readRemoteUrl || defaultReadRemoteUrl;
112
+ const tenant = (args.tenant || ctx?.tenant || "").trim() || null;
113
+ let repo = (args.repo || "").trim() || null;
114
+ if (!repo && ctx?.mode === "checkout" && ctx?.workspacePath) {
115
+ const remote = readRemoteUrl(ctx);
116
+ repo = remote ? repoNameFromRemote(remote) : null;
117
+ }
118
+ if (!repo) repo = tenant;
119
+ if (!repo) {
120
+ return {
121
+ repo: null,
122
+ tenant,
123
+ error: fail(
124
+ "no target repo/tenant.",
125
+ "pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), or run inside a store checkout.",
126
+ ),
127
+ };
128
+ }
129
+ return { repo, tenant: tenant || repo, error: null };
130
+ }
131
+
132
+ const COLUMNS = [
133
+ ["branch", "BRANCH"],
134
+ ["pr", "PR"],
135
+ ["candidate", "CANDIDATE"],
136
+ ["sha", "HEAD SHA"],
137
+ ["actor", "ACTOR"],
138
+ ["updated", "UPDATED (UTC)"],
139
+ ["age", "AGE"],
140
+ ["evidence", "EVIDENCE"],
141
+ ["status", "STATUS"],
142
+ ["aggregate", "AGGREGATE"],
143
+ ["cleanup", "CLEANUP"],
144
+ ];
145
+
146
+ /**
147
+ * Adapt one `candidate_list` `BranchCleanupRecord` to this table's row shape.
148
+ * Every value is printed in full — no truncated SHAs, no truncated
149
+ * timestamps (see module header re: the contract's "truncated human table"
150
+ * complaint). Pure — unit-tested.
151
+ * @param {any} b - a BranchCleanupRecord from the MCP `candidate_list` tool
152
+ */
153
+ export function formatBranchRow(b) {
154
+ const pr = typeof b.prNumber === "number" ? `#${b.prNumber}` : "#—";
155
+ const candidate = b.changeId || "—";
156
+ const sha = b.sha || "—";
157
+ // KNOWN GAP — see module header: candidate_list does not surface a PR
158
+ // author today, so this is honestly "—", never fabricated.
159
+ const actor = "—";
160
+ const updated = b.commitTimestampUtc || "(unknown)";
161
+ const age = b.idleDays != null ? `${b.idleDays}d` : "(unknown)";
162
+ const evidence = b.evidence
163
+ ? `${b.evidence.checksTotal} check(s) ${b.evidence.allPass ? "all pass" : "NOT all pass"} @ ${b.evidence.generatedAtUtc}`
164
+ : "no evidence";
165
+ const aggregate =
166
+ b.classification === "protected" ? "—" : b.integratedIntoAggregate ? "in preview aggregate" : "not in aggregate";
167
+ const cleanup = b.eligibleForDelete ? "ELIGIBLE" : b.quarantine ? "QUARANTINED" : "keep";
168
+ return {
169
+ branch: b.ref,
170
+ pr,
171
+ candidate,
172
+ sha,
173
+ actor,
174
+ updated,
175
+ age,
176
+ evidence,
177
+ status: b.classification ?? "?",
178
+ aggregate,
179
+ cleanup,
180
+ reason: b.reason ?? "",
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Render already-formatted rows as one padded, legible table (header + one
186
+ * row per branch + an indented `reason:` line — the reason text is often
187
+ * longer than any fixed column would comfortably hold). Pure — unit-tested.
188
+ * @param {ReturnType<typeof formatBranchRow>[]} rows
189
+ * @returns {string[]}
190
+ */
191
+ export function renderBranchesTable(rows) {
192
+ if (!rows.length) return ["(no branches)"];
193
+ const widths = COLUMNS.map(([key, label]) =>
194
+ Math.max(label.length, ...rows.map((r) => String(r[key] ?? "").length)),
195
+ );
196
+ const renderLine = (vals) =>
197
+ COLUMNS.map(([key], i) => String(vals[key] ?? "").padEnd(widths[i])).join(" ").trimEnd();
198
+ const lines = [renderLine(Object.fromEntries(COLUMNS.map(([key, label]) => [key, label])))];
199
+ for (const r of rows) {
200
+ lines.push(renderLine(r));
201
+ if (r.reason) lines.push(` reason: ${r.reason}`);
202
+ }
203
+ return lines;
204
+ }
205
+
206
+ /**
207
+ * Render the full `candidate_list` report: header, the table, then the
208
+ * server-computed summary/quarantine counts (never recomputed client-side —
209
+ * `candidate_list` already returns `summary` and `quarantined`). Pure —
210
+ * unit-tested.
211
+ * @param {any} report - the raw `candidate_list` result
212
+ * @returns {string[]}
213
+ */
214
+ export function renderBranchesReport(report) {
215
+ const lines = [];
216
+ const repoLabel = report?.repo || "(unknown repo)";
217
+ lines.push(
218
+ `Branches for ${repoLabel} (org ${report?.org || "?"}) — generated ${report?.generatedAtUtc || "(unknown)"} ` +
219
+ `(stale-after ${report?.staleAfterDays ?? "?"}d)`,
220
+ );
221
+ lines.push('NOTE: "actor" is not yet surfaced by the server\'s branch-cleanup report — shown as "—" below.');
222
+ lines.push("");
223
+ lines.push(...renderBranchesTable((report?.branches || []).map(formatBranchRow)));
224
+ lines.push("");
225
+ const summary = report?.summary || {};
226
+ const summaryParts = Object.entries(summary)
227
+ .map(([k, v]) => `${k}: ${v}`)
228
+ .join(", ");
229
+ lines.push(`Summary — ${report?.branches?.length ?? 0} branch(es): ${summaryParts || "(none)"}`);
230
+ if (Array.isArray(report?.quarantined) && report.quarantined.length) {
231
+ lines.push(
232
+ `Quarantined orphans (${report.quarantined.length}) — no PR record in any state; NEVER auto-deleted: ` +
233
+ report.quarantined.join(", "),
234
+ );
235
+ }
236
+ return lines;
237
+ }
238
+
239
+ /** @param {string[]} argv @param {any} ctx */
240
+ export async function run(argv, ctx) {
241
+ const env = process.env;
242
+ const args = parseBranchesArgs(argv);
243
+ if (args.help) {
244
+ console.log(USAGE);
245
+ return 0;
246
+ }
247
+
248
+ const { repo, tenant, error } = resolveRepoAndTenant(args, ctx);
249
+ if (error) {
250
+ console.error(error);
251
+ return 2;
252
+ }
253
+
254
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
255
+ const client = createMcpClient(baseUrl);
256
+ try {
257
+ await establishSession(client, { env, prefer: args.identity || undefined });
258
+ if (tenant) await client.callTool("client_switch", { tenant });
259
+
260
+ const report = await client.callTool("candidate_list", {
261
+ repo,
262
+ ...(args.staleAfterDays != null && Number.isFinite(args.staleAfterDays)
263
+ ? { staleAfterDays: args.staleAfterDays }
264
+ : {}),
265
+ });
266
+
267
+ if (report?.status === "error" || report?.status === "invalid_input" || report?.error) {
268
+ console.error(
269
+ fail(
270
+ `couldn't list branches for ${repo}: ${report.error || report.message || "unknown error"}`,
271
+ "check --tenant / --repo, then re-run",
272
+ ),
273
+ );
274
+ return 1;
275
+ }
276
+
277
+ if (args.json) {
278
+ console.log(JSON.stringify(report, null, 2));
279
+ return 0;
280
+ }
281
+ console.log(renderBranchesReport(report).join("\n"));
282
+ return 0;
283
+ } catch (e) {
284
+ if (e instanceof AuthUnavailableError) {
285
+ console.error(fail("sign in to list branches", e.hint || "run `tot login`, then re-run"));
286
+ return 1;
287
+ }
288
+ console.error(
289
+ fail(
290
+ `couldn't reach the candidate service: ${String(e?.message || e)}`,
291
+ "check your connection and that you're signed in, then re-run",
292
+ ),
293
+ );
294
+ return 1;
295
+ }
296
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * `tot cleanup` — owner-confirmed branch GC (P1 items 9+10 of the
3
+ * branch-lifecycle-and-integration-preview contract's "Current gaps").
4
+ *
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
7
+ * which refs are eligible to delete and WHY — no writes, ever, no matter what
8
+ * else is passed. An owner-confirmed execution mode (`--yes`, or an
9
+ * interactive y/N via the shared plan affordance) then deletes ONLY that
10
+ * exact eligible set: one `candidate_delete` call per ref. The server
11
+ * RE-CLASSIFIES FRESH before every single delete (see branch-cleanup-tools.ts)
12
+ * — this CLI's dry-run read is a convenience for the human, never the source
13
+ * of truth the delete trusts.
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
+ * THE CONTRACT'S HARD RULES, ENFORCED SERVER-SIDE AND NEVER RELAXED HERE:
20
+ * - age alone NEVER makes an open candidate deletable (`stale-open` is a
21
+ * WARN bucket only);
22
+ * - `main`/`preview` classify as `protected` and are never eligible;
23
+ * - an `orphan` (no PR record in any state) is quarantined for owner
24
+ * review, never auto-deleted.
25
+ * This CLI does not re-derive eligibility — it only ever deletes a ref the
26
+ * server's `candidate_list` marked `eligibleForDelete: true` moments earlier,
27
+ * and the server independently re-checks that before acting.
28
+ *
29
+ * Dependency-free (global fetch via mcp.mjs's client + the shared plan module).
30
+ */
31
+ import { createMcpClient } from "../mcp.mjs";
32
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
33
+ import { fail } from "../errors.mjs";
34
+ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
35
+ import { resolveRepoAndTenant, renderBranchesTable, formatBranchRow } from "./branches.mjs";
36
+
37
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
38
+
39
+ const USAGE = `tot cleanup — owner-confirmed branch GC (deletes terminal candidate branches only)
40
+
41
+ tot cleanup --dry-run classify + print the eligible-to-delete set; delete nothing
42
+ tot cleanup classify, print the plan, then ask to confirm before deleting
43
+ tot cleanup --yes classify and delete the eligible set without prompting
44
+
45
+ Deletes ONLY branches the server just (re-)classified as "integrated" (PR
46
+ merged) or "closed-or-rejected" (PR closed without merging). NEVER deletes
47
+ 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.
51
+
52
+ Options:
53
+ --tenant <appDomain> Target tenant/repo. Defaults to the current
54
+ checkout's tenant when run inside one.
55
+ --repo <name> Explicit repo name, if it differs from --tenant.
56
+ --stale-after-days <n> Idle-day threshold for the "stale-open" warn
57
+ bucket. Defaults to the server's policy (14).
58
+ --ref <ref> Narrow deletion to exactly this one eligible ref
59
+ (still refused if it isn't eligible).
60
+ --dry-run Classify and print the eligible set; delete nothing.
61
+ --yes, -y Skip the confirmation prompt.
62
+ --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
63
+ --identity <id> Prefer this cached identity over the default.
64
+ --help, -h Show this help.`;
65
+
66
+ /** Parse `tot cleanup` argv. Pure — unit-testable. */
67
+ export function parseCleanupArgs(argv) {
68
+ const a = {
69
+ tenant: null,
70
+ repo: null,
71
+ staleAfterDays: null,
72
+ ref: null,
73
+ dryRun: false,
74
+ yes: false,
75
+ mcp: null,
76
+ identity: null,
77
+ help: false,
78
+ };
79
+ for (let i = 0; i < argv.length; i++) {
80
+ const t = argv[i];
81
+ if (t === "--tenant") a.tenant = argv[++i];
82
+ else if (t === "--repo") a.repo = argv[++i];
83
+ else if (t === "--stale-after-days") a.staleAfterDays = Number(argv[++i]);
84
+ else if (t === "--ref") a.ref = argv[++i];
85
+ else if (t === "--dry-run") a.dryRun = true;
86
+ else if (t === "--yes" || t === "-y") a.yes = true;
87
+ else if (t === "--mcp") a.mcp = argv[++i];
88
+ else if (t === "--identity") a.identity = argv[++i];
89
+ else if (t === "--help" || t === "-h") a.help = true;
90
+ }
91
+ return a;
92
+ }
93
+
94
+ /** Branches the fresh classification marked eligible. Pure. */
95
+ export function eligibleRefs(report) {
96
+ return (report?.branches || []).filter((b) => b.eligibleForDelete === true);
97
+ }
98
+
99
+ /** Branches the fresh classification quarantined (orphans). Pure. */
100
+ export function quarantinedRefs(report) {
101
+ return (report?.branches || []).filter((b) => b.quarantine === true);
102
+ }
103
+
104
+ /**
105
+ * Render the dry-run view: the same table `tot branches` renders, then the
106
+ * exact eligible set + why, then the quarantined orphans + why they are NOT
107
+ * in that set. Pure — unit-tested.
108
+ * @param {any} report
109
+ * @param {ReturnType<typeof eligibleRefs>} eligible
110
+ * @returns {string[]}
111
+ */
112
+ export function renderCleanupDryRun(report, eligible) {
113
+ const lines = [];
114
+ const repoLabel = report?.repo || "(unknown repo)";
115
+ lines.push(
116
+ `Branch cleanup classification for ${repoLabel} — generated ${report?.generatedAtUtc || "(unknown)"} ` +
117
+ `(stale-after ${report?.staleAfterDays ?? "?"}d)`,
118
+ );
119
+ lines.push("");
120
+ lines.push(...renderBranchesTable((report?.branches || []).map(formatBranchRow)));
121
+ lines.push("");
122
+ if (!eligible.length) {
123
+ lines.push("Eligible to delete: none.");
124
+ } else {
125
+ lines.push(`Eligible to delete (${eligible.length}):`);
126
+ for (const b of eligible) {
127
+ lines.push(` ${b.ref} (${b.sha || "—"})`);
128
+ lines.push(` reason: ${b.reason}`);
129
+ }
130
+ }
131
+ const quarantined = quarantinedRefs(report);
132
+ if (quarantined.length) {
133
+ lines.push("");
134
+ lines.push(
135
+ `Quarantined orphans (${quarantined.length}) — no PR record in any state; NOT deleted, needs owner review:`,
136
+ );
137
+ for (const b of quarantined) lines.push(` ${b.ref} (${b.sha || "—"})`);
138
+ }
139
+ return lines;
140
+ }
141
+
142
+ /** @param {string[]} argv @param {any} ctx */
143
+ export async function run(argv, ctx) {
144
+ const env = process.env;
145
+ const args = parseCleanupArgs(argv);
146
+ if (args.help) {
147
+ console.log(USAGE);
148
+ return 0;
149
+ }
150
+
151
+ const { repo, tenant, error } = resolveRepoAndTenant(args, ctx);
152
+ if (error) {
153
+ console.error(error);
154
+ return 2;
155
+ }
156
+
157
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
158
+ const client = createMcpClient(baseUrl);
159
+ try {
160
+ await establishSession(client, { env, prefer: args.identity || undefined });
161
+ if (tenant) await client.callTool("client_switch", { tenant });
162
+
163
+ const report = await client.callTool("candidate_list", {
164
+ repo,
165
+ ...(args.staleAfterDays != null && Number.isFinite(args.staleAfterDays)
166
+ ? { staleAfterDays: args.staleAfterDays }
167
+ : {}),
168
+ });
169
+
170
+ if (report?.status === "error" || report?.status === "invalid_input" || report?.error) {
171
+ console.error(
172
+ fail(
173
+ `couldn't classify branches for ${repo}: ${report.error || report.message || "unknown error"}`,
174
+ "check --tenant / --repo, then re-run",
175
+ ),
176
+ );
177
+ return 1;
178
+ }
179
+
180
+ let eligible = eligibleRefs(report);
181
+
182
+ if (args.ref) {
183
+ const only = eligible.find((b) => b.ref === args.ref);
184
+ if (!only) {
185
+ console.error(
186
+ fail(
187
+ `"${args.ref}" is not currently eligible for deletion`,
188
+ "run `tot cleanup --dry-run` to see the current eligible set",
189
+ ),
190
+ );
191
+ return 1;
192
+ }
193
+ eligible = [only];
194
+ }
195
+
196
+ console.log(renderCleanupDryRun(report, eligible).join("\n"));
197
+
198
+ if (args.dryRun) {
199
+ console.log("\nDry run — nothing was deleted.");
200
+ return 0;
201
+ }
202
+ if (!eligible.length) {
203
+ console.log("\nNothing eligible to delete.");
204
+ return 0;
205
+ }
206
+
207
+ const planLines = planForAction({
208
+ action: "cleanup",
209
+ tenant: tenant || repo,
210
+ refs: eligible.map((b) => ({ ref: b.ref, sha: b.sha, reason: b.reason })),
211
+ });
212
+ const { confirmed, reason } = await printPlanAndConfirm(planLines, {
213
+ yes: args.yes,
214
+ question: `Delete these ${eligible.length} eligible branch(es) now?`,
215
+ });
216
+ if (!confirmed) {
217
+ if (reason === "non-tty") {
218
+ console.error(
219
+ fail(
220
+ "refusing to delete branches without confirmation on a non-TTY.",
221
+ "re-run with --yes (an explicit human affirmative), or from an interactive terminal.",
222
+ ),
223
+ );
224
+ return 2;
225
+ }
226
+ console.log("Aborted — nothing was deleted.");
227
+ return 1;
228
+ }
229
+
230
+ let failures = 0;
231
+ for (const b of eligible) {
232
+ try {
233
+ const res = await client.callTool("candidate_delete", {
234
+ repo,
235
+ ref: b.ref,
236
+ expectedSha: b.sha,
237
+ });
238
+ if (res?.deleted) {
239
+ console.log(`✓ deleted ${b.ref}${res.alreadyAbsent ? " (already absent)" : ""}`);
240
+ } else {
241
+ failures++;
242
+ console.error(`✗ ${b.ref}: ${res?.error || res?.message || res?.status || "not deleted"}`);
243
+ }
244
+ } catch (e) {
245
+ failures++;
246
+ console.error(`✗ ${b.ref}: ${String(e?.message || e)}`);
247
+ }
248
+ }
249
+ if (failures) {
250
+ console.error(`\n${failures} of ${eligible.length} deletion(s) failed — see above.`);
251
+ return 1;
252
+ }
253
+ console.log(`\n✓ deleted ${eligible.length} branch(es).`);
254
+ return 0;
255
+ } catch (e) {
256
+ if (e instanceof AuthUnavailableError) {
257
+ console.error(fail("sign in to clean up branches", e.hint || "run `tot login`, then re-run"));
258
+ return 1;
259
+ }
260
+ console.error(
261
+ fail(
262
+ `couldn't reach the candidate service: ${String(e?.message || e)}`,
263
+ "check your connection and that you're signed in, then re-run",
264
+ ),
265
+ );
266
+ return 1;
267
+ }
268
+ }