@tokenoftrust/cli 3.8.0 → 3.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "3.8.0",
3
+ "version": "3.9.1",
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",
@@ -39,6 +39,7 @@ import { fail } from "../errors.mjs";
39
39
  import { scopeToTenant } from "../tenant-scope.mjs";
40
40
  import { isInteractive, promptYesNo } from "../prompt.mjs";
41
41
  import { startProgress } from "../progress.mjs";
42
+ import { resolveViewerTransport } from "../viewer-session.mjs";
42
43
 
43
44
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
44
45
 
@@ -179,19 +180,56 @@ export function rollbackCandidates(history, currentVersionId) {
179
180
  .reverse();
180
181
  }
181
182
 
183
+ /**
184
+ * Ledger/CDN mismatch shown before a rollback, or null when they match.
185
+ * Null shows neither the warning nor a corrected target.
186
+ * @typedef {{ reason: string, live: string|null, served: string|null, correctedTarget: string } | null} RollbackLiveDrift
187
+ */
188
+
189
+ /** Lines naming a ledger/CDN mismatch. Empty when there is none.
190
+ * @param {RollbackLiveDrift} liveDrift
191
+ * @returns {string[]}
192
+ */
193
+ export function liveDriftLines(liveDrift) {
194
+ if (!liveDrift?.reason || !liveDrift?.correctedTarget) return [];
195
+ return [
196
+ ` ⚠ ${liveDrift.reason}: ledger live is ${liveDrift.live ?? "nothing"}; the CDN serves ${liveDrift.served ?? "an unknown revision"}.`,
197
+ ` corrected rollback target: ${liveDrift.correctedTarget}`,
198
+ ];
199
+ }
200
+
201
+ /**
202
+ * The ship plan's CDN drift warning, or null. A missing or matching plan is null —
203
+ * the list then shows neither the warning nor a corrected target.
204
+ * @param {any} body
205
+ * @returns {RollbackLiveDrift}
206
+ */
207
+ export function liveDriftFromShipPlan(body) {
208
+ const warning = body?.warning;
209
+ const corrected = warning?.correctedRollbackTarget?.aggregateSha;
210
+ if (!warning || typeof warning.reason !== "string" || typeof corrected !== "string") return null;
211
+ return {
212
+ reason: warning.reason,
213
+ live: typeof warning.live === "string" ? warning.live : null,
214
+ served: typeof warning.served === "string" ? warning.served : null,
215
+ correctedTarget: corrected,
216
+ };
217
+ }
218
+
182
219
  /** Render the `tot rollback` (no args) history listing — the rollback-target
183
220
  * picker, each entry annotated with its immutable `/rev/<sha>` link
184
221
  * when a storefront origin is available. Pure — unit-tested.
185
222
  * @param {{ tenant:string, target:string, current:any,
186
- * candidates:any[], revisionBase?:string|null }} input
223
+ * candidates:any[], revisionBase?:string|null, liveDrift?: RollbackLiveDrift }} input
187
224
  * @returns {string[]}
188
225
  */
189
- export function renderHistory({ tenant, target, current, candidates, revisionBase }) {
226
+ export function renderHistory({ tenant, target, current, candidates, revisionBase, liveDrift = null }) {
190
227
  const lines = ["", ` Promotion history for ${tenant} on "${target}":`];
191
228
  if (current) {
192
229
  const link = revisionUrl(revisionBase, tenant, current.versionId);
193
230
  lines.push(` live now: ${current.versionId} (pinned tree ${current.digest})${link ? ` ${link}` : ""}`);
194
231
  }
232
+ lines.push(...liveDriftLines(liveDrift));
195
233
  if (!candidates.length) {
196
234
  lines.push(" (no prior version recorded yet — nothing to roll back to)");
197
235
  return lines;
@@ -207,11 +245,12 @@ export function renderHistory({ tenant, target, current, candidates, revisionBas
207
245
 
208
246
  /** Render the "what goes live" block for a rollback preview. Pure — unit-tested.
209
247
  * @param {{ tenant:string, target:string, toVersionId:string,
210
- * preview:ReturnType<typeof normalizeRollbackPreview> }} input
248
+ * preview:ReturnType<typeof normalizeRollbackPreview>, liveDrift?: RollbackLiveDrift }} input
211
249
  * @returns {string[]}
212
250
  */
213
- export function renderRollbackPreview({ tenant, target, toVersionId, preview }) {
251
+ export function renderRollbackPreview({ tenant, target, toVersionId, preview, liveDrift = null }) {
214
252
  const lines = ["", ` This rollback will change the LIVE site for ${tenant}:`];
253
+ lines.push(...liveDriftLines(liveDrift));
215
254
  const current = /** @type {any} */ (preview.pointer?.current);
216
255
  if (current) lines.push(` ${target}: ${current.versionId} → ${toVersionId}`);
217
256
  else lines.push(` ${target}: → ${toVersionId}`);
@@ -231,12 +270,21 @@ export function renderRollbackPreview({ tenant, target, toVersionId, preview })
231
270
  * the listing's `/rev/<sha>` links resolve against; omit to list
232
271
  * without links.
233
272
  * @param {{ interactive?:()=>boolean, confirm?:(q:string,d:boolean)=>Promise<boolean>,
234
- * progress?:boolean }} [deps]
273
+ * progress?:boolean, readLiveDrift?:()=>Promise<ReturnType<typeof liveDriftFromShipPlan>> }} [deps]
235
274
  * @returns {Promise<number>} process exit code
236
275
  */
237
276
  export async function runRollback(scoped, { tenant, target, toVersionId, revisionBase }, deps = {}) {
238
277
  const interactive = deps.interactive || isInteractive;
239
278
  const confirm = deps.confirm || promptYesNo;
279
+ /** @type {RollbackLiveDrift} */
280
+ let liveDrift = null;
281
+ if (deps.readLiveDrift) {
282
+ try {
283
+ liveDrift = await deps.readLiveDrift();
284
+ } catch {
285
+ liveDrift = null;
286
+ }
287
+ }
240
288
 
241
289
  // 0. No target given — list promotion history as the set of rollback targets.
242
290
  if (!toVersionId) {
@@ -262,7 +310,7 @@ export async function runRollback(scoped, { tenant, target, toVersionId, revisio
262
310
  return 1;
263
311
  }
264
312
  const candidates = rollbackCandidates(status.history, /** @type {any} */ (status).current?.versionId ?? null);
265
- for (const line of renderHistory({ tenant, target, current: status.current, candidates, revisionBase })) {
313
+ for (const line of renderHistory({ tenant, target, current: status.current, candidates, revisionBase, liveDrift })) {
266
314
  console.log(line);
267
315
  }
268
316
  return 0;
@@ -297,7 +345,7 @@ export async function runRollback(scoped, { tenant, target, toVersionId, revisio
297
345
  }
298
346
 
299
347
  // 2. ALWAYS render what would change, then require ONE explicit y/N confirm.
300
- for (const line of renderRollbackPreview({ tenant, target, toVersionId, preview })) console.log(line);
348
+ for (const line of renderRollbackPreview({ tenant, target, toVersionId, preview, liveDrift })) console.log(line);
301
349
 
302
350
  // NON-TTY: refuse rather than auto-confirm — nothing rolls back without a human yes.
303
351
  if (!interactive()) {
@@ -354,6 +402,21 @@ function reportRolledBack(result, { tenant, target, toVersionId }) {
354
402
  return 0;
355
403
  }
356
404
 
405
+ /**
406
+ * Best-effort read of the ship plan's CDN drift. A refusal, a network fault, or a
407
+ * matching ledger returns null — the list still renders, and it shows neither a
408
+ * warning nor a corrected target.
409
+ * @param {{ tenant: string, env: NodeJS.ProcessEnv, fetchImpl?: typeof fetch }} args
410
+ */
411
+ async function readShipPlanDrift({ tenant, env, fetchImpl = globalThis.fetch }) {
412
+ const storefrontUrl = env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || null;
413
+ const viewer = await resolveViewerTransport({ tenant, env, fetchImpl, storefrontUrl });
414
+ if (!viewer.ok) return null;
415
+ const res = await fetchImpl(`${viewer.base}/api/changes/ship`, { method: "GET", headers: viewer.authHeaders });
416
+ if (!res.ok) return null;
417
+ return liveDriftFromShipPlan(await res.json());
418
+ }
419
+
357
420
  /**
358
421
  * @param {string[]} argv
359
422
  * @param {any} ctx
@@ -386,7 +449,9 @@ export async function run(argv, ctx) {
386
449
  await establishSession(client, { env, prefer: args.identity || undefined });
387
450
  const scoped = await scopeToTenant(client, tenant);
388
451
 
389
- return await runRollback(scoped, { tenant, target, toVersionId: args.versionId, revisionBase });
452
+ return await runRollback(scoped, { tenant, target, toVersionId: args.versionId, revisionBase }, {
453
+ readLiveDrift: () => readShipPlanDrift({ tenant, env }),
454
+ });
390
455
  } catch (e) {
391
456
  if (e instanceof AuthUnavailableError) {
392
457
  console.error(fail("sign in to roll back", e.hint || "run `tot login`, then re-run `tot rollback`"));
@@ -245,6 +245,23 @@ export function liveUrlFor(tenant) {
245
245
  * paywall:{allowed:boolean,message:string|null}, alreadyShipped:boolean} |
246
246
  * {ok:false, reason:string, message:string}}
247
247
  */
248
+ /** The ship-plan CDN drift warning, or null when the ledger and the CDN name the same release. */
249
+ function normalizeLiveDrift(warning) {
250
+ if (!warning || typeof warning !== "object" || typeof warning.reason !== "string") return null;
251
+ const target = warning.correctedRollbackTarget;
252
+ if (!target || typeof target !== "object" || typeof target.aggregateSha !== "string") return null;
253
+ return {
254
+ reason: warning.reason,
255
+ live: typeof warning.live === "string" ? warning.live : null,
256
+ served: typeof warning.served === "string" ? warning.served : null,
257
+ correctedRollbackTarget: {
258
+ receiptId: typeof target.receiptId === "string" ? target.receiptId : "",
259
+ aggregateSha: target.aggregateSha,
260
+ artifactDigest: typeof target.artifactDigest === "string" ? target.artifactDigest : "",
261
+ },
262
+ };
263
+ }
264
+
248
265
  export function normalizeShipPlan(data) {
249
266
  const o = data && typeof data === "object" ? data : {};
250
267
  if (o.ok === true) {
@@ -257,6 +274,7 @@ export function normalizeShipPlan(data) {
257
274
  integrationRunId: typeof o.integrationRunId === "string" ? o.integrationRunId : null,
258
275
  receiptId: typeof o.receiptId === "string" ? o.receiptId : null,
259
276
  rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
277
+ warning: normalizeLiveDrift(o.warning),
260
278
  paywall:
261
279
  o.paywall && typeof o.paywall === "object"
262
280
  ? {
@@ -546,6 +564,7 @@ export async function runShip(
546
564
  artifactDigest: plan.artifactDigest,
547
565
  includedPrs: plan.includedPrs,
548
566
  rollbackTarget: plan.rollbackTarget,
567
+ liveDrift: plan.warning,
549
568
  paywall: plan.paywall,
550
569
  targets: { live: liveUrl },
551
570
  })
package/src/plan.mjs CHANGED
@@ -59,6 +59,7 @@ function targetLabel({ pr, changeId }) {
59
59
  * artifactDigest?: string|null,
60
60
  * includedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>|null,
61
61
  * rollbackTarget?: { receiptId: string, aggregateSha: string } | null,
62
+ * liveDrift?: { reason: string, live?: string|null, served?: string|null } | null,
62
63
  * paywall?: { allowed: boolean, message?: string|null } | null,
63
64
  * refs?: Array<{ ref: string, sha?: string|null, reason?: string|null }>|null,
64
65
  * bypassedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>|null,
@@ -80,6 +81,7 @@ export function planForAction({
80
81
  artifactDigest = null,
81
82
  includedPrs = null,
82
83
  rollbackTarget = null,
84
+ liveDrift = null,
83
85
  paywall = null,
84
86
  refs = null,
85
87
  bypassedPrs = null,
@@ -135,9 +137,14 @@ export function planForAction({
135
137
  lines.push(` - ${prLabel}${shortSha}`);
136
138
  }
137
139
  }
140
+ if (liveDrift?.reason) {
141
+ lines.push(
142
+ ` ⚠ ${liveDrift.reason}: the CDN serves ${liveDrift.served ?? "an unknown revision"}, not the ledger's live ${liveDrift.live ?? "nothing"}.`,
143
+ );
144
+ }
138
145
  lines.push(
139
146
  rollbackTarget
140
- ? ` rollback to: ${rollbackTarget.aggregateSha} (receipt ${rollbackTarget.receiptId})`
147
+ ? ` rollback to: ${rollbackTarget.aggregateSha} (receipt ${rollbackTarget.receiptId})${liveDrift?.reason ? " — corrected target" : ""}`
141
148
  : " rollback to: (none — first-ever ship)",
142
149
  );
143
150
  if (paywall && paywall.allowed === false) {