@tokenoftrust/cli 3.10.0 → 3.10.2
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 +1 -1
- package/src/commands/hotfix.mjs +10 -2
- package/src/commands/ship.mjs +5 -4
- package/src/plan.mjs +18 -15
- package/src/token-store.mjs +29 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.2",
|
|
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",
|
package/src/commands/hotfix.mjs
CHANGED
|
@@ -39,7 +39,7 @@ import { openBrowser } from "../open.mjs";
|
|
|
39
39
|
import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
|
|
40
40
|
// Reuse `tot ship`'s operator-secret precedence + live-url derivation verbatim so
|
|
41
41
|
// ship + hotfix speak ONE operator-auth contract.
|
|
42
|
-
import { resolveOperatorSecret, liveUrlFor } from "./ship.mjs";
|
|
42
|
+
import { resolveOperatorSecret, liveUrlFor, normalizeLiveDrift } from "./ship.mjs";
|
|
43
43
|
|
|
44
44
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
45
45
|
|
|
@@ -126,6 +126,7 @@ export function normalizeHotfixPlan(data) {
|
|
|
126
126
|
bypassedPreviewSha: typeof o.bypassedPreviewSha === "string" ? o.bypassedPreviewSha : null,
|
|
127
127
|
bypassedPrs: Array.isArray(o.bypassedPrs) ? o.bypassedPrs : [],
|
|
128
128
|
rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
|
|
129
|
+
warning: normalizeLiveDrift(o.warning),
|
|
129
130
|
paywall:
|
|
130
131
|
o.paywall && typeof o.paywall === "object"
|
|
131
132
|
? { allowed: o.paywall.allowed === true, message: typeof o.paywall.message === "string" ? o.paywall.message : null }
|
|
@@ -157,6 +158,7 @@ export function normalizeHotfixResult(data) {
|
|
|
157
158
|
artifactDigest: typeof o.artifactDigest === "string" ? o.artifactDigest : null,
|
|
158
159
|
receiptId: typeof o.receiptId === "string" ? o.receiptId : null,
|
|
159
160
|
rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
|
|
161
|
+
retriable: o.retriable === false ? false : o.retriable === true ? true : null,
|
|
160
162
|
forwardIntegration: fwd
|
|
161
163
|
? {
|
|
162
164
|
ok: fwd.ok === true,
|
|
@@ -284,6 +286,7 @@ export async function runHotfix(
|
|
|
284
286
|
bypassedPrs: plan.bypassedPrs,
|
|
285
287
|
bypassedPreviewSha: plan.bypassedPreviewSha,
|
|
286
288
|
rollbackTarget: plan.rollbackTarget,
|
|
289
|
+
liveDrift: plan.warning,
|
|
287
290
|
paywall: plan.paywall,
|
|
288
291
|
targets: { live: liveUrl },
|
|
289
292
|
});
|
|
@@ -371,10 +374,15 @@ export function reportHotfixResult(result, { tenant, liveUrl, noOpen, openUrl })
|
|
|
371
374
|
result.state === "promote_failed" ||
|
|
372
375
|
result.state === "record_failed"
|
|
373
376
|
) {
|
|
377
|
+
// Same rule as ship: a compare-and-set refusal, or any outcome that says a retry cannot
|
|
378
|
+
// change it, must not be offered as safe to retry.
|
|
379
|
+
const deterministic = result.retriable === false || result.reason === "live_moved";
|
|
374
380
|
console.error(
|
|
375
381
|
fail(
|
|
376
382
|
result.message || `hotfix ${result.state.replace("_", " ")}`,
|
|
377
|
-
|
|
383
|
+
deterministic
|
|
384
|
+
? "do not re-run `tot hotfix` — this refusal is deterministic and will not change"
|
|
385
|
+
: "re-run `tot hotfix` — the release is idempotent and safe to retry",
|
|
378
386
|
),
|
|
379
387
|
);
|
|
380
388
|
return 1;
|
package/src/commands/ship.mjs
CHANGED
|
@@ -245,8 +245,8 @@ 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
|
|
249
|
-
function normalizeLiveDrift(warning) {
|
|
248
|
+
/** The plan CDN drift warning, or null when the ledger and the CDN name the same release. */
|
|
249
|
+
export function normalizeLiveDrift(warning) {
|
|
250
250
|
if (!warning || typeof warning !== "object" || typeof warning.reason !== "string") return null;
|
|
251
251
|
const target = warning.correctedRollbackTarget;
|
|
252
252
|
if (!target || typeof target !== "object" || typeof target.aggregateSha !== "string") return null;
|
|
@@ -734,11 +734,12 @@ function reportShipResult(result, { tenant, liveUrl, noOpen, openUrl }) {
|
|
|
734
734
|
return 1;
|
|
735
735
|
}
|
|
736
736
|
if (result.state === "publishing") {
|
|
737
|
-
//
|
|
737
|
+
// pointerWritten false means the job step has not confirmed a write. For s3-overwrite, CI can
|
|
738
|
+
// write the pointer during awaiting-external, before prove, so do not say the pointer is absent.
|
|
738
739
|
console.log(
|
|
739
740
|
result.pointerWritten
|
|
740
741
|
? `\n ⏳ ${tenant} is publishing — the live pointer moved, CDN delivery is still confirming.`
|
|
741
|
-
: `\n ⏳ ${tenant} is publishing — dispatched; the live pointer is not written
|
|
742
|
+
: `\n ⏳ ${tenant} is publishing — dispatched; the live pointer is not confirmed written.`,
|
|
742
743
|
);
|
|
743
744
|
if (result.pinnedSha) console.log(` pinned: ${result.pinnedSha}`);
|
|
744
745
|
if (result.receiptId) console.log(` receipt: ${result.receiptId}`);
|
package/src/plan.mjs
CHANGED
|
@@ -40,6 +40,20 @@ function targetLabel({ pr, changeId }) {
|
|
|
40
40
|
return "this change";
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Empty when the CDN and the ledger name the same release. */
|
|
44
|
+
function cdnDriftLines(liveDrift) {
|
|
45
|
+
if (!liveDrift?.reason) return [];
|
|
46
|
+
return [
|
|
47
|
+
` ⚠ ${liveDrift.reason}: the CDN serves ${liveDrift.served ?? "an unknown revision"}, not the ledger's live ${liveDrift.live ?? "nothing"}.`,
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function rollbackToLine(rollbackTarget, liveDrift) {
|
|
52
|
+
return rollbackTarget
|
|
53
|
+
? ` rollback to: ${rollbackTarget.aggregateSha} (receipt ${rollbackTarget.receiptId})${liveDrift?.reason ? " — corrected target" : ""}`
|
|
54
|
+
: " rollback to: (none — first-ever ship)";
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
/**
|
|
44
58
|
* Build the EXACT plan for a mutating operator verb — structured lines ready
|
|
45
59
|
* to print verbatim (CLI) or join for a confirm() dialog (admin). Pure: no
|
|
@@ -137,16 +151,8 @@ export function planForAction({
|
|
|
137
151
|
lines.push(` - ${prLabel}${shortSha}`);
|
|
138
152
|
}
|
|
139
153
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
` ⚠ ${liveDrift.reason}: the CDN serves ${liveDrift.served ?? "an unknown revision"}, not the ledger's live ${liveDrift.live ?? "nothing"}.`,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
lines.push(
|
|
146
|
-
rollbackTarget
|
|
147
|
-
? ` rollback to: ${rollbackTarget.aggregateSha} (receipt ${rollbackTarget.receiptId})${liveDrift?.reason ? " — corrected target" : ""}`
|
|
148
|
-
: " rollback to: (none — first-ever ship)",
|
|
149
|
-
);
|
|
154
|
+
lines.push(...cdnDriftLines(liveDrift));
|
|
155
|
+
lines.push(rollbackToLine(rollbackTarget, liveDrift));
|
|
150
156
|
if (paywall && paywall.allowed === false) {
|
|
151
157
|
lines.push(` ⚠ paywall: ${paywall.message || "go-live is blocked by the storefront subscription gate"}`);
|
|
152
158
|
}
|
|
@@ -206,11 +212,8 @@ export function planForAction({
|
|
|
206
212
|
}
|
|
207
213
|
}
|
|
208
214
|
if (bypassedPreviewSha) lines.push(` preview head (bypassed): ${bypassedPreviewSha}`);
|
|
209
|
-
lines.push(
|
|
210
|
-
|
|
211
|
-
? ` rollback to: ${rollbackTarget.aggregateSha} (receipt ${rollbackTarget.receiptId})`
|
|
212
|
-
: " rollback to: (none — first-ever ship)",
|
|
213
|
-
);
|
|
215
|
+
lines.push(...cdnDriftLines(liveDrift));
|
|
216
|
+
lines.push(rollbackToLine(rollbackTarget, liveDrift));
|
|
214
217
|
if (paywall && paywall.allowed === false) {
|
|
215
218
|
lines.push(` ⚠ paywall: ${paywall.message || "go-live is blocked by the storefront subscription gate"}`);
|
|
216
219
|
}
|
package/src/token-store.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* TOT_HOME selects a private home and TOT_PROFILE selects an independent identity.
|
|
5
5
|
*/
|
|
6
6
|
import {
|
|
7
|
-
readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, existsSync, rmSync, linkSync, unlinkSync,
|
|
7
|
+
readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, existsSync, rmSync, linkSync, unlinkSync, statSync,
|
|
8
8
|
} from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -55,22 +55,45 @@ export function normalizeAuthority(value) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
* `git config --get` exit 1 means the key is unset
|
|
59
|
-
* only when stderr says so. Permission denied
|
|
60
|
-
*
|
|
58
|
+
* `git config --get` exit 1 means the key is unset only when the config was readable.
|
|
59
|
+
* Exit 128 is "not a checkout" only when stderr says so. Permission denied, at any
|
|
60
|
+
* exit code, and a missing git binary are not "no authority".
|
|
61
61
|
* @param {unknown} error
|
|
62
62
|
*/
|
|
63
63
|
export function isAbsentGitConfig(error) {
|
|
64
|
+
const stderr = `${/** @type {{ stderr?: unknown }} */ (error)?.stderr ?? ""}\n${/** @type {{ message?: unknown }} */ (error)?.message ?? ""}`;
|
|
65
|
+
if (/Permission denied|unable to access/i.test(stderr)) return false;
|
|
64
66
|
const status = error && typeof error === "object" ? /** @type {{ status?: number }} */ (error).status : undefined;
|
|
65
67
|
if (status === 1) return true;
|
|
66
68
|
if (status !== 128) return false;
|
|
67
|
-
const stderr = `${/** @type {{ stderr?: unknown, message?: unknown }} */ (error).stderr ?? ""}\n${/** @type {{ message?: unknown }} */ (error).message ?? ""}`;
|
|
68
|
-
if (/Permission denied|unable to access/i.test(stderr)) return false;
|
|
69
69
|
return /not a git repository|can only be used inside a git repository/i.test(stderr);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** A mode with no read bits is unreadable even when a privileged process can still open it. */
|
|
73
|
+
function refuseUnreadableGitConfig(cwd) {
|
|
74
|
+
const config = join(cwd, ".git", "config");
|
|
75
|
+
let mode;
|
|
76
|
+
try {
|
|
77
|
+
mode = statSync(config).mode;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const code = error && typeof error === "object" ? /** @type {{ code?: string }} */ (error).code : undefined;
|
|
80
|
+
if (code === "ENOENT" || code === "ENOTDIR") return;
|
|
81
|
+
if (code === "EACCES") throw gitConfigDenied(config);
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
if ((mode & 0o444) === 0) throw gitConfigDenied(config);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function gitConfigDenied(config) {
|
|
88
|
+
const denied = new Error(`unable to access '${config}': Permission denied`);
|
|
89
|
+
/** @type {Error & { status?: number, stderr?: string }} */ (denied).status = 128;
|
|
90
|
+
/** @type {Error & { stderr?: string }} */ (denied).stderr = denied.message;
|
|
91
|
+
return denied;
|
|
92
|
+
}
|
|
93
|
+
|
|
72
94
|
/** Local checkout provenance, never inferred from a shared forge hostname. */
|
|
73
95
|
export function repositoryAuthority(cwd = process.cwd()) {
|
|
96
|
+
refuseUnreadableGitConfig(cwd);
|
|
74
97
|
let value;
|
|
75
98
|
try {
|
|
76
99
|
value = execFileSync("git", ["-C", cwd, "config", "--local", "--get", MCP_AUTHORITY_KEY], {
|