@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.
- package/bin/tot.mjs +33 -0
- package/package.json +1 -1
- package/src/commands/accept.mjs +247 -0
- package/src/commands/go-live.mjs +482 -0
- package/src/commands/preview-build.mjs +225 -0
- package/src/commands/preview.mjs +9 -0
- package/src/commands/retire.mjs +203 -0
- package/src/commands/rollback.mjs +401 -0
- package/src/commands/ship.mjs +753 -43
- package/src/commands/submit.mjs +146 -46
- package/src/plan.mjs +160 -0
- package/src/sample.mjs +27 -1
|
@@ -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
|
+
}
|