@tokenoftrust/cli 4.4.2 → 4.5.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 +1 -1
- package/src/commands/hotfix.mjs +3 -2
- package/src/commands/ship.mjs +18 -10
- package/src/sku.mjs +146 -0
- package/src/validate.mjs +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.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",
|
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,
|
|
42
|
+
import { resolveOperatorSecret, normalizeLiveDrift } from "./ship.mjs";
|
|
43
43
|
|
|
44
44
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
45
45
|
|
|
@@ -127,6 +127,7 @@ export function normalizeHotfixPlan(data) {
|
|
|
127
127
|
bypassedPrs: Array.isArray(o.bypassedPrs) ? o.bypassedPrs : [],
|
|
128
128
|
rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
|
|
129
129
|
warning: normalizeLiveDrift(o.warning),
|
|
130
|
+
liveUrl: typeof o.liveUrl === "string" && o.liveUrl ? o.liveUrl : null,
|
|
130
131
|
paywall:
|
|
131
132
|
o.paywall && typeof o.paywall === "object"
|
|
132
133
|
? { allowed: o.paywall.allowed === true, message: typeof o.paywall.message === "string" ? o.paywall.message : null }
|
|
@@ -277,7 +278,7 @@ export async function runHotfix(
|
|
|
277
278
|
|
|
278
279
|
// 2. Print the EXACT plan — including the unshipped preview work it BYPASSES —
|
|
279
280
|
// then require ONE explicit confirm (default NO).
|
|
280
|
-
const liveUrl =
|
|
281
|
+
const liveUrl = plan.liveUrl;
|
|
281
282
|
const planLines = planForAction({
|
|
282
283
|
action: "hotfix",
|
|
283
284
|
tenant,
|
package/src/commands/ship.mjs
CHANGED
|
@@ -223,14 +223,6 @@ export async function pollChangeShipped(scoped, { id }, { attempts = 6, delayMs
|
|
|
223
223
|
return last;
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
// ─── URL derivation (pure) ─────────────────────────────────────────────────────
|
|
227
|
-
|
|
228
|
-
/** The live site URL for a tenant (the tenant IS its apex domain, e.g. tokenoftrust.com). Pure. */
|
|
229
|
-
export function liveUrlFor(tenant) {
|
|
230
|
-
const t = (tenant || "").trim();
|
|
231
|
-
return t ? `https://${t}` : null;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
226
|
// ─── Response normalisation (defensive — one endpoint, but shapes may vary) ─────
|
|
235
227
|
|
|
236
228
|
/**
|
|
@@ -242,7 +234,8 @@ export function liveUrlFor(tenant) {
|
|
|
242
234
|
* includedPrs:Array<{prNumber?:number|null,changeId?:string|null,headSha?:string|null}>,
|
|
243
235
|
* integrationRunId:string|null, receiptId:string|null,
|
|
244
236
|
* rollbackTarget:{receiptId:string,aggregateSha:string,artifactDigest?:string}|null,
|
|
245
|
-
* paywall:{allowed:boolean,message:string|null}, alreadyShipped:boolean
|
|
237
|
+
* paywall:{allowed:boolean,message:string|null}, alreadyShipped:boolean,
|
|
238
|
+
* provedAt:string|null, liveUrl:string|null} |
|
|
246
239
|
* {ok:false, reason:string, message:string}}
|
|
247
240
|
*/
|
|
248
241
|
/** The plan CDN drift warning, or null when the ledger and the CDN name the same release. */
|
|
@@ -283,6 +276,8 @@ export function normalizeShipPlan(data) {
|
|
|
283
276
|
}
|
|
284
277
|
: { allowed: true, message: null },
|
|
285
278
|
alreadyShipped: o.alreadyShipped === true,
|
|
279
|
+
provedAt: typeof o.provedAt === "string" && o.provedAt ? o.provedAt : null,
|
|
280
|
+
liveUrl: typeof o.liveUrl === "string" && o.liveUrl ? o.liveUrl : null,
|
|
286
281
|
};
|
|
287
282
|
}
|
|
288
283
|
return {
|
|
@@ -553,9 +548,22 @@ export async function runShip(
|
|
|
553
548
|
return 1;
|
|
554
549
|
}
|
|
555
550
|
|
|
551
|
+
// The pinned release is already what live serves and nothing else is pending: there is nothing
|
|
552
|
+
// to confirm or POST. A CDN-drift warning means live is not what the ledger says, so that
|
|
553
|
+
// still goes through the plan.
|
|
554
|
+
if (plan.ok && plan.alreadyShipped && !plan.warning && !spaces.ready.length) {
|
|
555
|
+
const detail = [
|
|
556
|
+
plan.receiptId ? `receipt ${plan.receiptId}` : null,
|
|
557
|
+
plan.provedAt ? `proved ${plan.provedAt}` : null,
|
|
558
|
+
].filter(Boolean).join(", ");
|
|
559
|
+
console.log(` Already live: ${plan.pinnedSha}${detail ? ` (${detail})` : ""} — nothing to ship`);
|
|
560
|
+
return 0;
|
|
561
|
+
}
|
|
562
|
+
|
|
556
563
|
// 2. Print the EXACT plan via the shared affordance, then require ONE
|
|
557
564
|
// explicit confirm (`--yes` non-interactive; a non-TTY without it refuses).
|
|
558
|
-
|
|
565
|
+
// The live URL is the server's served truth; the client derives none.
|
|
566
|
+
const liveUrl = plan.ok ? plan.liveUrl : null;
|
|
559
567
|
const planLines = plan.ok
|
|
560
568
|
? planForAction({
|
|
561
569
|
action: "ship",
|
package/src/sku.mjs
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SKU contract: one rule for every reader and writer of a merchant's SKU.
|
|
3
|
+
*
|
|
4
|
+
* A SKU is merchant data, kept exactly as the merchant wrote it. The only rewrite is trimming
|
|
5
|
+
* leading and trailing whitespace on the way in. A SKU is refused only when it is empty, carries a
|
|
6
|
+
* control character, or is longer than {@link SKU_MAX_LENGTH} characters. Anything else, a space
|
|
7
|
+
* included, is a valid SKU; a space is a lint warning, never an error.
|
|
8
|
+
*
|
|
9
|
+
* Two SKUs are the same SKU when their {@link skuKey} is equal: trimmed, NBSP read as a space, and
|
|
10
|
+
* case-folded. The key is for comparison only and is never stored.
|
|
11
|
+
*
|
|
12
|
+
* A SKU is never used raw where a boundary needs a safe token (a path, a file name, a URL segment,
|
|
13
|
+
* a checkout product code). {@link skuToken} is the one encoding for those boundaries: reversible,
|
|
14
|
+
* injective, and the identity for a SKU of letters, digits, `.`, `_` and `-`.
|
|
15
|
+
*
|
|
16
|
+
* Pure: no filesystem, no network, no dependencies. `packages/cli/src/sku.mjs` is a byte-identical
|
|
17
|
+
* copy (the CLI installs without the kit), pinned by `packages/cli/test/sku-copy.test.mjs`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Longest SKU accepted, in characters (code points). */
|
|
21
|
+
export const SKU_MAX_LENGTH = 250;
|
|
22
|
+
/** The checkout provider's product-code limit; a longer {@link skuToken} is a lint warning. */
|
|
23
|
+
export const CHECKOUT_CODE_MAX_LENGTH = 200;
|
|
24
|
+
/** Longest {@link skuToken} any SKU can produce (four UTF-8 bytes, three characters each). */
|
|
25
|
+
export const SKU_TOKEN_MAX_LENGTH = SKU_MAX_LENGTH * 12;
|
|
26
|
+
|
|
27
|
+
const CONTROL = /\p{Cc}/u;
|
|
28
|
+
const WHITESPACE = /\s/u;
|
|
29
|
+
const TOKEN_SAFE = /^[A-Za-z0-9._-]$/;
|
|
30
|
+
const TOKEN = /^(?:[A-Za-z0-9._-]|%[0-9A-F]{2})+$/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a SKU as it arrives from a merchant, a file or an import.
|
|
34
|
+
*
|
|
35
|
+
* @param {unknown} value
|
|
36
|
+
* @returns {{ ok: true, sku: string } | { ok: false, reason: "not-string" | "empty" | "control-character" | "too-long" }}
|
|
37
|
+
*/
|
|
38
|
+
export function parseSku(value) {
|
|
39
|
+
if (typeof value !== "string") return { ok: false, reason: "not-string" };
|
|
40
|
+
const sku = value.trim();
|
|
41
|
+
if (sku === "") return { ok: false, reason: "empty" };
|
|
42
|
+
if (CONTROL.test(sku)) return { ok: false, reason: "control-character" };
|
|
43
|
+
if ([...sku].length > SKU_MAX_LENGTH) return { ok: false, reason: "too-long" };
|
|
44
|
+
return { ok: true, sku };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when `value` is a SKU in its stored form: valid and already trimmed. */
|
|
48
|
+
export function isStoredSku(value) {
|
|
49
|
+
const parsed = parseSku(value);
|
|
50
|
+
return parsed.ok && parsed.sku === value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The comparison key two SKUs share when they are the same SKU. Never stored. */
|
|
54
|
+
export function skuKey(sku) {
|
|
55
|
+
return String(sku).replace(/ /g, " ").trim().toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The SKU as a token safe in a path, file name, URL segment or checkout product code. Letters,
|
|
60
|
+
* digits, `.`, `_` and `-` pass through; every other character is its UTF-8 bytes as `%XX`.
|
|
61
|
+
*/
|
|
62
|
+
export function skuToken(sku) {
|
|
63
|
+
let out = "";
|
|
64
|
+
for (const char of String(sku)) {
|
|
65
|
+
if (TOKEN_SAFE.test(char)) {
|
|
66
|
+
out += char;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
for (const byte of new TextEncoder().encode(char)) out += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The stored SKU a {@link skuToken} encodes, or null when `token` is not one. */
|
|
75
|
+
export function skuFromToken(token) {
|
|
76
|
+
if (typeof token !== "string" || token.length > SKU_TOKEN_MAX_LENGTH || !TOKEN.test(token)) return null;
|
|
77
|
+
const bytes = [];
|
|
78
|
+
for (let i = 0; i < token.length; i += 1) {
|
|
79
|
+
if (token[i] === "%") {
|
|
80
|
+
bytes.push(Number.parseInt(token.slice(i + 1, i + 3), 16));
|
|
81
|
+
i += 2;
|
|
82
|
+
} else {
|
|
83
|
+
bytes.push(token.charCodeAt(i));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Bytes that are not UTF-8 decode to U+FFFD, whose token differs, so the round trip refuses them.
|
|
87
|
+
const sku = new TextDecoder().decode(new Uint8Array(bytes));
|
|
88
|
+
return isStoredSku(sku) && skuToken(sku) === token ? sku : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const REASONS = {
|
|
92
|
+
"not-string": "a SKU must be text",
|
|
93
|
+
empty: "a SKU cannot be empty",
|
|
94
|
+
"control-character": "a SKU cannot contain a control character (tab, newline, ...)",
|
|
95
|
+
"too-long": `a SKU is at most ${SKU_MAX_LENGTH} characters`,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Lint findings for one SKU. `error` means the SKU is refused; `warning` means it works.
|
|
100
|
+
*
|
|
101
|
+
* @param {unknown} value
|
|
102
|
+
* @returns {Array<{ level: "error" | "warning", code: string, message: string }>}
|
|
103
|
+
*/
|
|
104
|
+
export function skuFindings(value) {
|
|
105
|
+
const parsed = parseSku(value);
|
|
106
|
+
if (!parsed.ok) return [{ level: "error", code: `sku-${parsed.reason}`, message: REASONS[parsed.reason] }];
|
|
107
|
+
/** @type {Array<{ level: "error" | "warning", code: string, message: string }>} */
|
|
108
|
+
const findings = [];
|
|
109
|
+
if (parsed.sku !== value) {
|
|
110
|
+
findings.push({ level: "warning", code: "sku-untrimmed", message: "leading or trailing whitespace is trimmed when the SKU is read" });
|
|
111
|
+
}
|
|
112
|
+
if (WHITESPACE.test(parsed.sku)) {
|
|
113
|
+
findings.push({
|
|
114
|
+
level: "warning",
|
|
115
|
+
code: "sku-whitespace",
|
|
116
|
+
message: "works, but we recommend against spaces in a SKU: spreadsheets, scanners and other systems often trim or split on them",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (skuToken(parsed.sku).length > CHECKOUT_CODE_MAX_LENGTH) {
|
|
120
|
+
findings.push({
|
|
121
|
+
level: "warning",
|
|
122
|
+
code: "sku-checkout-code-long",
|
|
123
|
+
message: `its checkout product code is longer than ${CHECKOUT_CODE_MAX_LENGTH} characters, which the checkout provider may refuse`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return findings;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* SKUs that are the same SKU under {@link skuKey}. Each group lists every entry that shares a key.
|
|
131
|
+
*
|
|
132
|
+
* @template {{ sku: string }} T
|
|
133
|
+
* @param {Iterable<T>} entries
|
|
134
|
+
* @returns {Array<{ key: string, entries: T[] }>}
|
|
135
|
+
*/
|
|
136
|
+
export function skuCollisions(entries) {
|
|
137
|
+
/** @type {Map<string, T[]>} */
|
|
138
|
+
const groups = new Map();
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
const key = skuKey(entry.sku);
|
|
141
|
+
const group = groups.get(key);
|
|
142
|
+
if (group) group.push(entry);
|
|
143
|
+
else groups.set(key, [entry]);
|
|
144
|
+
}
|
|
145
|
+
return [...groups].filter(([, group]) => group.length > 1).map(([key, group]) => ({ key, entries: group }));
|
|
146
|
+
}
|
package/src/validate.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
workspacePathForTenantRelative,
|
|
25
25
|
} from "./tot-repo-mappings.mjs";
|
|
26
26
|
import { isForbiddenPublicPath, publicBinaryFindings } from "./public-binaries.mjs";
|
|
27
|
+
import { parseSku, skuCollisions, skuFindings } from "./sku.mjs";
|
|
27
28
|
|
|
28
29
|
export {
|
|
29
30
|
TOT_CONFIG_KEYS,
|
|
@@ -74,6 +75,57 @@ export function detectConflictMarkers(content) {
|
|
|
74
75
|
const CONFLICT_SCAN_EXT = new Set([".html", ".htm", ".json", ".md", ".txt", ".css", ".js", ".mjs", ".svg", ".xml"]);
|
|
75
76
|
const hasScanExt = (p) => CONFLICT_SCAN_EXT.has((p.match(/\.[^./\\]+$/) || [""])[0].toLowerCase());
|
|
76
77
|
|
|
78
|
+
// --- catalog SKUs ---------------------------------------------------------------
|
|
79
|
+
// The SKU contract (sku.mjs, a copy of the kit's catalog-model/sku.mjs). A refused SKU is an
|
|
80
|
+
// error (the catalog reader refuses its file); a space is a warning ("works, but recommend
|
|
81
|
+
// against"); two SKUs that are the same SKU ignoring case and edge whitespace are an error.
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* SKU findings for a store's product records. Pure — exported for focused testing.
|
|
85
|
+
* @param {Array<{ file: string, record: unknown }>} products
|
|
86
|
+
* @returns {Finding[]}
|
|
87
|
+
*/
|
|
88
|
+
export function catalogSkuFindings(products) {
|
|
89
|
+
/** @type {Finding[]} */
|
|
90
|
+
const out = [];
|
|
91
|
+
/** @type {Array<{ sku: string, file: string }>} */
|
|
92
|
+
const skued = [];
|
|
93
|
+
for (const { file, record } of products) {
|
|
94
|
+
const doc = /** @type {any} */ (record);
|
|
95
|
+
const variants = doc && typeof doc === "object" && Array.isArray(doc.variants) ? doc.variants : [];
|
|
96
|
+
variants.forEach((variant, index) => {
|
|
97
|
+
if (!variant || typeof variant !== "object" || variant.sku === undefined) return;
|
|
98
|
+
for (const f of skuFindings(variant.sku)) {
|
|
99
|
+
if (f.code === "sku-empty") continue; // a blank SKU is no SKU
|
|
100
|
+
out.push(mk(f.level === "error" ? ERROR : WARN, f.code, file, `variants[${index}].sku ${JSON.stringify(variant.sku)}: ${f.message}`));
|
|
101
|
+
}
|
|
102
|
+
const parsed = parseSku(variant.sku);
|
|
103
|
+
if (parsed.ok) skued.push({ sku: parsed.sku, file });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
for (const collision of skuCollisions(skued)) {
|
|
107
|
+
const names = collision.entries.map((e) => `${JSON.stringify(e.sku)} (${e.file})`).join(", ");
|
|
108
|
+
out.push(mk(ERROR, "sku-duplicate", collision.entries[0].file, `the same SKU, ignoring case and edge whitespace, is used ${collision.entries.length} times: ${names}`,
|
|
109
|
+
"give each variant its own SKU"));
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function validateCatalogSkus(contentDir, rel) {
|
|
115
|
+
const dir = join(contentDir, "catalog", "products");
|
|
116
|
+
if (!existsSync(dir)) return [];
|
|
117
|
+
/** @type {Finding[]} */
|
|
118
|
+
const parseErrors = [];
|
|
119
|
+
const products = [];
|
|
120
|
+
for (const name of readdirSync(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
121
|
+
const path = join(dir, name);
|
|
122
|
+
const { value, error } = readJsonSafe(path);
|
|
123
|
+
if (error) parseErrors.push(mk(ERROR, "catalog-parse", rel(path), `invalid JSON: ${error}`));
|
|
124
|
+
else products.push({ file: rel(path), record: value });
|
|
125
|
+
}
|
|
126
|
+
return [...parseErrors, ...catalogSkuFindings(products)];
|
|
127
|
+
}
|
|
128
|
+
|
|
77
129
|
// --- canonical `.tot/config.json` shape (the #24/#25 regression guard) --------
|
|
78
130
|
const KNOWN_KINDS = new Set(["file", "tree"]);
|
|
79
131
|
const REQUIRED_WORKSPACES = ["content/", "public/", "theme.json"];
|
|
@@ -1247,6 +1299,8 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1247
1299
|
}
|
|
1248
1300
|
}
|
|
1249
1301
|
|
|
1302
|
+
findings.push(...validateCatalogSkus(contentDir, rel));
|
|
1303
|
+
|
|
1250
1304
|
const capabilitiesPath = located("capabilities.json");
|
|
1251
1305
|
if (existsSync(capabilitiesPath)) {
|
|
1252
1306
|
const { value, error } = readJsonSafe(capabilitiesPath);
|