@tokenoftrust/cli 1.4.0-rc.11 → 1.4.0-rc.13

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.
@@ -0,0 +1,401 @@
1
+ /**
2
+ * `tot rollback [<versionId>]` — instant re-point to a prior live version.
3
+ *
4
+ * The CLI counterpart to `tot ship`, for the moment ship goes wrong:
5
+ *
6
+ * tot rollback list promotion history (the rollback targets)
7
+ * tot rollback <versionId> re-point the live channel back to that version
8
+ *
9
+ * This does NOT reimplement pointer moves. Every selection and every move goes
10
+ * through u3's MCP-side live-pointer seam (`promotion_status` /
11
+ * `promotion_rollback`, tot-mcp `src/modules/mcp/change/promotion-pointer-tools.ts`
12
+ * + `promotion-pointer-store.ts`) — the SAME primitive `tot ship`'s eventual
13
+ * orchestrator composes (decision storefront-atomic-accept-is-ship-orchestrator).
14
+ * There is no dg4 ship-orchestrator service in-tree yet, so this calls the u3 seam
15
+ * DIRECTLY; when the orchestrator lands it should absorb the physical
16
+ * publish+verify this command currently leaves to it (see the seam's own
17
+ * "NOT a live claim" caveat, echoed in `reportRolledBack` below) — a follow-on can
18
+ * route through it instead without changing this file's UX.
19
+ *
20
+ * Fail-closed (decision static-publish-fails-closed-reviewed-sha): rollback selects
21
+ * an immutable prior digest/versionId recorded by a PRIOR promote; a missing or
22
+ * ineligible target is refused with a clear next step, never a silent no-op dressed
23
+ * up as success. The eligibility check and the pointer move both come from the
24
+ * seam's own dry-run preview (`promotion_rollback` with `dryRun:true`) rather than
25
+ * a second, CLI-side reimplementation of "what counts as a prior version" — so the
26
+ * two can never drift.
27
+ *
28
+ * Confirm-before-acting UX mirrors `ship.mjs`: ALWAYS render what would change,
29
+ * require ONE explicit [y/N] (default NO), no --yes/--force, and refuse outright in
30
+ * a non-TTY. On confirm, the REAL terminal state comes back from the seam's
31
+ * dryRun:false response — never assumed from the confirm alone.
32
+ *
33
+ * Dependency-free (global fetch via the MCP client); pure helpers are exported and
34
+ * unit-tested with a mock client, no network, no TTY.
35
+ */
36
+ import { createMcpClient } from "../mcp.mjs";
37
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
38
+ import { fail } from "../errors.mjs";
39
+ import { isInteractive, promptYesNo } from "../prompt.mjs";
40
+ import { startProgress } from "../progress.mjs";
41
+
42
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
43
+
44
+ // The only publish channel the seam is exercised against today (see the u3 tests
45
+ // and `promotion_set`'s own default). A future multi-target store can override with
46
+ // `--target`; nothing here assumes there's only ever one.
47
+ const DEFAULT_TARGET = "production";
48
+
49
+ const USAGE = `tot rollback [<versionId>] — instant re-point to a prior live version
50
+
51
+ tot rollback list promotion history (the rollback targets)
52
+ tot rollback <versionId> re-point the live channel back to that version
53
+ tot rollback --target <t> the publish channel to act on (default: production)
54
+ tot rollback --identity <id> sign in as a specific identity for this rollback
55
+ tot rollback --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
56
+
57
+ With no versionId, rollback lists the versions you can roll back to — read the
58
+ first column and re-run \`tot rollback <that versionId>\`. Rollback ALWAYS shows
59
+ you what would change and asks for a single y/N confirmation first; there is no
60
+ --yes/--force, and it refuses to run without an interactive terminal. It only
61
+ ever selects an immutable version this channel previously had live — never an
62
+ arbitrary or rebuilt one.`;
63
+
64
+ /** Parse `tot rollback` argv. Pure. Deliberately NO --yes/--force (see the header). */
65
+ export function parseRollbackArgs(argv) {
66
+ const a = { versionId: null, target: null, mcp: null, identity: null, help: false };
67
+ const positional = [];
68
+ for (let i = 0; i < argv.length; i++) {
69
+ const t = argv[i];
70
+ if (t === "--mcp") a.mcp = argv[++i];
71
+ else if (t === "--target") a.target = argv[++i];
72
+ else if (t === "--identity") a.identity = argv[++i];
73
+ else if (t === "--help" || t === "-h") a.help = true;
74
+ else positional.push(t);
75
+ }
76
+ a.versionId = positional[0] ?? null;
77
+ return a;
78
+ }
79
+
80
+ // ─── Response normalisation (defensive — one MCP, but shapes may vary) ───────────
81
+
82
+ /**
83
+ * Normalise a `promotion_status` result to the fields rollback lists on. The
84
+ * action-tool harness merges the tool's `data` fields flat into the response
85
+ * (alongside `summary`/`status`), so `found`/`pointer` are read at the top level —
86
+ * mirrors how `ship.mjs`'s normalizers read `change_accept`'s flattened response.
87
+ * Pure — unit-tested.
88
+ * @param {any} r
89
+ * @returns {{found:boolean, current:Record<string,unknown>|null,
90
+ * history:Record<string,unknown>[], raw:any}}
91
+ */
92
+ export function normalizeStatus(r) {
93
+ const o = r && typeof r === "object" ? r : {};
94
+ const pointer = o.pointer && typeof o.pointer === "object" ? o.pointer : null;
95
+ const current = pointer && pointer.current && typeof pointer.current === "object" ? pointer.current : null;
96
+ return {
97
+ found: o.found === true,
98
+ current,
99
+ history: pointer && Array.isArray(pointer.history) ? pointer.history : [],
100
+ raw: r,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Normalise a `promotion_rollback` dry-run (`dryRun:true`) preview response — the
106
+ * eligibility verdict THIS command gates confirmation on, straight from the seam
107
+ * (never recomputed here). Pure — unit-tested.
108
+ * @param {any} r
109
+ * @returns {{eligible:boolean, wouldSelectVersionId:string|null, summary:string|null,
110
+ * pointer:Record<string,unknown>|null}}
111
+ */
112
+ export function normalizeRollbackPreview(r) {
113
+ const o = r && typeof r === "object" ? r : {};
114
+ return {
115
+ eligible: o.eligible === true,
116
+ wouldSelectVersionId: typeof o.wouldSelectVersionId === "string" ? o.wouldSelectVersionId : null,
117
+ summary: typeof o.summary === "string" ? o.summary : null,
118
+ pointer: o.pointer && typeof o.pointer === "object" ? o.pointer : null,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Normalise a `promotion_rollback` commit (`dryRun:false`) response — the REAL
124
+ * terminal state (`changed` is false only for an idempotent no-op: the requested
125
+ * version was already designated live). Pure — unit-tested.
126
+ * @param {any} r
127
+ * @returns {{changed:boolean, versionId:string|null, digest:string|null,
128
+ * rolledBackFrom:string|null, summary:string|null}}
129
+ */
130
+ export function normalizeRollbackResult(r) {
131
+ const o = r && typeof r === "object" ? r : {};
132
+ return {
133
+ changed: o.changed === true,
134
+ versionId: typeof o.designatedVersionId === "string" ? o.designatedVersionId : null,
135
+ digest: typeof o.pinnedDigest === "string" ? o.pinnedDigest : null,
136
+ rolledBackFrom: typeof o.rolledBackFrom === "string" ? o.rolledBackFrom : null,
137
+ summary: typeof o.summary === "string" ? o.summary : null,
138
+ };
139
+ }
140
+
141
+ // ─── Revision links (pure, unit u5) ───────────────────────────────────────────────
142
+
143
+ /**
144
+ * The immutable `/preview/<tenant>/rev/<sha>/` deep-link for a promoted
145
+ * `versionId` — u4's route, and per `gitea-only-tenant-content-authority` the
146
+ * `versionId` recorded by a promote IS the tenant Gitea sha, so no extra lookup
147
+ * is needed to build it. `null` when there's no `base` (storefront origin) or no
148
+ * `versionId` to link, so a caller can render the history line either way.
149
+ * Pure — unit-tested.
150
+ * @param {string|null|undefined} base storefront origin, e.g. `https://acme.com`
151
+ * @param {string} tenant
152
+ * @param {string|null|undefined} versionId
153
+ * @returns {string|null}
154
+ */
155
+ export function revisionUrl(base, tenant, versionId) {
156
+ if (!base || !versionId) return null;
157
+ return `${String(base).replace(/\/+$/, "")}/preview/${encodeURIComponent(tenant)}/rev/${encodeURIComponent(versionId)}/`;
158
+ }
159
+
160
+ // ─── Candidate selection + rendering (pure) ──────────────────────────────────────
161
+
162
+ /**
163
+ * The prior versions eligible as a rollback target: every PROMOTE entry in history
164
+ * other than the one currently live (rolling back to the live version is a no-op,
165
+ * not a "prior" target), newest first. Mirrors — but doesn't replace — the seam's
166
+ * own default selection (`promotion_rollback` with no `toVersionId`); this is
167
+ * listing surface only, the seam still decides eligibility when you act. Pure —
168
+ * unit-tested.
169
+ * @param {Record<string,unknown>[]} history
170
+ * @param {string|null} currentVersionId
171
+ * @returns {Record<string,unknown>[]}
172
+ */
173
+ export function rollbackCandidates(history, currentVersionId) {
174
+ return [...history]
175
+ .filter((e) => e && e.op === "promote" && e.versionId !== currentVersionId)
176
+ .reverse();
177
+ }
178
+
179
+ /** Render the `tot rollback` (no args) history listing — the rollback-target
180
+ * picker (unit u2), each entry annotated with its immutable `/rev/<sha>` link
181
+ * (unit u5) when a storefront origin is available. Pure — unit-tested.
182
+ * @param {{ tenant:string, target:string, current:Record<string,unknown>|null,
183
+ * candidates:Record<string,unknown>[], revisionBase?:string|null }} input
184
+ * @returns {string[]}
185
+ */
186
+ export function renderHistory({ tenant, target, current, candidates, revisionBase }) {
187
+ const lines = ["", ` Promotion history for ${tenant} on "${target}":`];
188
+ if (current) {
189
+ const link = revisionUrl(revisionBase, tenant, current.versionId);
190
+ lines.push(` live now: ${current.versionId} (pinned tree ${current.digest})${link ? ` ${link}` : ""}`);
191
+ }
192
+ if (!candidates.length) {
193
+ lines.push(" (no prior version recorded yet — nothing to roll back to)");
194
+ return lines;
195
+ }
196
+ lines.push("", " rollback targets, newest first:");
197
+ for (const e of candidates) {
198
+ const link = revisionUrl(revisionBase, tenant, e.versionId);
199
+ lines.push(` ${e.versionId} ${e.digest} ${e.at}${link ? ` ${link}` : ""}`);
200
+ }
201
+ lines.push("", ` tot rollback <versionId> to roll ${target} back to one of these`);
202
+ return lines;
203
+ }
204
+
205
+ /** Render the "what goes live" block for a rollback preview. Pure — unit-tested.
206
+ * @param {{ tenant:string, target:string, toVersionId:string,
207
+ * preview:ReturnType<typeof normalizeRollbackPreview> }} input
208
+ * @returns {string[]}
209
+ */
210
+ export function renderRollbackPreview({ tenant, target, toVersionId, preview }) {
211
+ const lines = ["", ` This rollback will change the LIVE site for ${tenant}:`];
212
+ const current = preview.pointer?.current;
213
+ if (current) lines.push(` ${target}: ${current.versionId} → ${toVersionId}`);
214
+ else lines.push(` ${target}: → ${toVersionId}`);
215
+ return lines;
216
+ }
217
+
218
+ // ─── Orchestration ───────────────────────────────────────────────────────────────
219
+
220
+ /**
221
+ * The rollback flow after a session is established — list (no target) or preview →
222
+ * confirm → commit (with a target). Split out from `run` so it's driven in tests
223
+ * with a mock client + injected confirm/interactive, no network/TTY.
224
+ *
225
+ * @param {{callTool:Function}} client an MCP client (real or mock)
226
+ * @param {{ tenant:string, target:string, toVersionId:string|null,
227
+ * revisionBase?:string|null }} params `revisionBase` is the storefront origin
228
+ * the listing's `/rev/<sha>` links resolve against (unit u5); omit to list
229
+ * without links.
230
+ * @param {{ interactive?:()=>boolean, confirm?:(q:string,d:boolean)=>Promise<boolean>,
231
+ * progress?:boolean }} [deps]
232
+ * @returns {Promise<number>} process exit code
233
+ */
234
+ export async function runRollback(client, { tenant, target, toVersionId, revisionBase }, deps = {}) {
235
+ const interactive = deps.interactive || isInteractive;
236
+ const confirm = deps.confirm || promptYesNo;
237
+
238
+ // 0. No target given — list promotion history as the set of rollback targets.
239
+ if (!toVersionId) {
240
+ let status;
241
+ try {
242
+ status = normalizeStatus(await client.callTool("promotion_status", { target }));
243
+ } catch (e) {
244
+ console.error(
245
+ fail(
246
+ `couldn't read promotion history: ${String(e?.message || e)}`,
247
+ "check your connection and that you're signed in, then re-run",
248
+ ),
249
+ );
250
+ return 1;
251
+ }
252
+ if (!status.found) {
253
+ console.error(
254
+ fail(
255
+ `no live pointer yet for ${tenant} on "${target}"`,
256
+ "ship a version first with `tot ship`, then rollback targets will appear here",
257
+ ),
258
+ );
259
+ return 1;
260
+ }
261
+ const candidates = rollbackCandidates(status.history, status.current?.versionId ?? null);
262
+ for (const line of renderHistory({ tenant, target, current: status.current, candidates, revisionBase })) {
263
+ console.log(line);
264
+ }
265
+ return 0;
266
+ }
267
+
268
+ // 1. Preview the selection through the seam itself (dryRun:true) — the ONE place
269
+ // eligibility is decided; we never recompute "is this a valid prior version".
270
+ let preview;
271
+ try {
272
+ preview = normalizeRollbackPreview(
273
+ await client.callTool("promotion_rollback", { target, toVersionId, dryRun: true }),
274
+ );
275
+ } catch (e) {
276
+ console.error(
277
+ fail(
278
+ `couldn't preview this rollback: ${String(e?.message || e)}`,
279
+ "check your connection and that you're signed in, then re-run",
280
+ ),
281
+ );
282
+ return 1;
283
+ }
284
+ // Fail-closed: an ineligible target (missing / never promoted here) is refused,
285
+ // never silently treated as a no-op.
286
+ if (!preview.eligible) {
287
+ console.error(
288
+ fail(
289
+ preview.summary || `${toVersionId} isn't an eligible rollback target for ${tenant} on "${target}"`,
290
+ "run `tot rollback` with no arguments to see eligible prior versions",
291
+ ),
292
+ );
293
+ return 1;
294
+ }
295
+
296
+ // 2. ALWAYS render what would change, then require ONE explicit y/N confirm.
297
+ for (const line of renderRollbackPreview({ tenant, target, toVersionId, preview })) console.log(line);
298
+
299
+ // NON-TTY: refuse rather than auto-confirm — nothing rolls back without a human yes.
300
+ if (!interactive()) {
301
+ console.error(
302
+ fail(
303
+ "`tot rollback` needs an interactive terminal to confirm the live change",
304
+ "run it from a terminal (there is intentionally no --yes/--force)",
305
+ ),
306
+ );
307
+ return 2;
308
+ }
309
+ const proceed = await confirm(`\n Roll ${tenant} on "${target}" back to ${toVersionId}?`, false);
310
+ if (!proceed) {
311
+ console.log(" Rollback cancelled — nothing changed.");
312
+ return 0;
313
+ }
314
+
315
+ // 3. Commit — the REAL terminal state comes back from this call, never assumed
316
+ // from the confirm.
317
+ const progress = deps.progress === false ? null : startProgress("rolling back…");
318
+ let result;
319
+ try {
320
+ result = normalizeRollbackResult(
321
+ await client.callTool("promotion_rollback", { target, toVersionId, dryRun: false }),
322
+ );
323
+ } catch (e) {
324
+ console.error(
325
+ fail(
326
+ `the rollback seam refused this change: ${String(e?.message || e)}`,
327
+ "run `tot rollback` with no arguments to recheck eligible versions, then retry",
328
+ ),
329
+ );
330
+ return 1;
331
+ } finally {
332
+ progress?.stop();
333
+ }
334
+ return reportRolledBack(result, { tenant, target, toVersionId });
335
+ }
336
+
337
+ /** Report the rollback result honestly — a pointer DESIGNATION, not a live claim
338
+ * (the seam's own boundary; see the module header on the orchestrator gap). */
339
+ function reportRolledBack(result, { tenant, target, toVersionId }) {
340
+ const versionId = result.versionId || toVersionId;
341
+ const pin = result.digest ? ` (pinned tree ${result.digest})` : "";
342
+ if (result.changed) {
343
+ console.log(`\n ✓ rolled back ${tenant} on "${target}" to ${versionId}${pin}.`);
344
+ } else {
345
+ console.log(`\n ~ ${versionId} was already the designated-live version for ${tenant} on "${target}" (no-op).`);
346
+ }
347
+ console.log(
348
+ " This designates the pointer — the storefront ship orchestrator performs the physical" +
349
+ " publish and is the only authority that confirms it's serving live.",
350
+ );
351
+ return 0;
352
+ }
353
+
354
+ /**
355
+ * @param {string[]} argv
356
+ * @param {any} ctx
357
+ */
358
+ export async function run(argv, ctx) {
359
+ const env = process.env;
360
+ const args = parseRollbackArgs(argv);
361
+ if (args.help) {
362
+ console.log(USAGE);
363
+ return 0;
364
+ }
365
+ if (ctx.mode !== "checkout") {
366
+ console.error(
367
+ fail(
368
+ "`tot rollback` runs from inside a tenant checkout",
369
+ "tot clone <tenant> <dir> (then `cd` in, and `tot rollback`)",
370
+ ),
371
+ );
372
+ return 2;
373
+ }
374
+
375
+ const tenant = ctx.tenant;
376
+ const target = args.target?.trim() || DEFAULT_TARGET;
377
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
378
+ // The storefront origin the listing's /rev/<sha> links resolve against (unit
379
+ // u5) — owner == appDomain on this platform, mirroring go-live.mjs's default.
380
+ const revisionBase = env.TOT_STOREFRONT_URL || (tenant ? `https://${tenant}` : null);
381
+ const client = createMcpClient(baseUrl);
382
+ try {
383
+ await establishSession(client, { env, prefer: args.identity || undefined });
384
+ // Bind the active tenant so the promotion tools read/write the right scope.
385
+ await client.callTool("client_switch", { tenant });
386
+
387
+ return await runRollback(client, { tenant, target, toVersionId: args.versionId, revisionBase });
388
+ } catch (e) {
389
+ if (e instanceof AuthUnavailableError) {
390
+ console.error(fail("sign in to roll back", e.hint || "run `tot login`, then re-run `tot rollback`"));
391
+ return 1;
392
+ }
393
+ console.error(
394
+ fail(
395
+ `couldn't reach the rollback service: ${String(e?.message || e)}`,
396
+ "check your connection and that you're signed in, then re-run",
397
+ ),
398
+ );
399
+ return 1;
400
+ }
401
+ }