@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -9
- package/bin/tot.mjs +191 -14
- package/package.json +2 -2
- package/src/activity.mjs +378 -0
- package/src/candidate-state.mjs +137 -0
- package/src/commands/accept.mjs +313 -0
- package/src/commands/branches.mjs +296 -0
- package/src/commands/cleanup.mjs +268 -0
- package/src/commands/clone.mjs +682 -0
- package/src/commands/dev.mjs +414 -84
- package/src/commands/doctor.mjs +4 -3
- package/src/commands/go-live.mjs +482 -0
- package/src/commands/grants.mjs +8 -3
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/link.mjs +225 -0
- package/src/commands/login.mjs +9 -4
- package/src/commands/pr.mjs +424 -0
- package/src/commands/preview-build.mjs +225 -0
- package/src/commands/preview.mjs +80 -0
- package/src/commands/retire.mjs +203 -0
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +401 -0
- package/src/commands/ship.mjs +517 -0
- package/src/commands/start.mjs +35 -21
- package/src/commands/submit.mjs +1129 -129
- package/src/commands/sync.mjs +192 -0
- package/src/commands/validate.mjs +2 -2
- package/src/commands/whoami.mjs +6 -2
- package/src/context.mjs +2 -2
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +14 -3
- package/src/obstacle-beacon.cjs +1 -1
- package/src/plan.mjs +262 -0
- package/src/sample.mjs +27 -1
- package/src/commands/checkout.mjs +0 -330
|
@@ -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
|
+
}
|