@tokenoftrust/cli 2.0.27 → 2.1.0
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 +24 -0
- package/package.json +1 -1
- package/src/capability-registry.mjs +295 -0
- package/src/commands/capabilities.mjs +120 -0
- package/src/commands/doctor.mjs +19 -12
- package/src/commands/login.mjs +2 -3
- package/src/commands/report.mjs +308 -0
- package/src/go-live-report.mjs +1064 -0
- package/src/platform.mjs +91 -0
- package/src/readiness-checks.mjs +254 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot report go-live` — the merchant-facing go-live readiness report for one store.
|
|
3
|
+
*
|
|
4
|
+
* tot report go-live --pages <dir> [--style <json>] [--workspace <dir>] [--out <md>]
|
|
5
|
+
*
|
|
6
|
+
* It is generated FROM measurements, never authored:
|
|
7
|
+
*
|
|
8
|
+
* --pages <dir> the page-check results for this store: each page's JSON from the devkit page
|
|
9
|
+
* checks (validate-page.sh and validate-page-browser.mjs, saved one file per run).
|
|
10
|
+
* A site-wide validation run's directory (site-summary.json + browser/) works too.
|
|
11
|
+
* --style <json> the style-concept audit's JSON (default: <pages>/style.json when present).
|
|
12
|
+
*
|
|
13
|
+
* plus `tot validate` on the store itself, and the store's own records:
|
|
14
|
+
* `.tot/migration-state.json` (lifecycle, add-ons, commerce-sync and go-live confirmations) and
|
|
15
|
+
* `.tot/lighthouse-source-baseline.json` (the source site's pre-migration scores).
|
|
16
|
+
*
|
|
17
|
+
* Writes <out> (default <pages>/go-live-report.md) and its implementor companion
|
|
18
|
+
* <out>.internal.md. No network, no credentials: reproducible from the same inputs.
|
|
19
|
+
* Exit 0 written; 2 usage; 3 refused (no measurements to report on).
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
23
|
+
import { buildGoLiveReport, buildImplementorReport } from "../go-live-report.mjs";
|
|
24
|
+
import { buildReadinessChecks } from "../readiness-checks.mjs";
|
|
25
|
+
import { validateTenant, ERROR } from "../validate.mjs";
|
|
26
|
+
import { fail } from "../errors.mjs";
|
|
27
|
+
import { tenantDirSegments } from "../tenant-dirs.mjs";
|
|
28
|
+
|
|
29
|
+
export const TEASER = "Site-wide validation with triage across every page, and the full migration method, are in the Agency Kit.";
|
|
30
|
+
|
|
31
|
+
export const USAGE = `tot report go-live — the go-live readiness report for your store
|
|
32
|
+
|
|
33
|
+
tot report go-live --pages <dir> report from your page-check results
|
|
34
|
+
[--style <json>] the style-concept audit's JSON (default <pages>/style.json)
|
|
35
|
+
[--workspace <dir>] the store checkout (default: this checkout)
|
|
36
|
+
[--out <md>] where to write it (default <pages>/go-live-report.md)
|
|
37
|
+
|
|
38
|
+
Save each page's JSON from the devkit page checks into <dir> first, e.g.
|
|
39
|
+
validate-page.sh <base> / > <dir>/home.json
|
|
40
|
+
validate-page-browser.mjs <base> / --provider cloudflare > <dir>/home.browser.json
|
|
41
|
+
|
|
42
|
+
${TEASER}`;
|
|
43
|
+
|
|
44
|
+
/** @param {string} p */
|
|
45
|
+
function readJson(p) {
|
|
46
|
+
if (!p || !existsSync(p)) return null;
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @param {string[]} argv */
|
|
55
|
+
export function parseArgs(argv) {
|
|
56
|
+
/** @type {Record<string, any>} */
|
|
57
|
+
const a = { sub: null, help: false };
|
|
58
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
59
|
+
const t = argv[i];
|
|
60
|
+
if (t === "--help" || t === "-h") { a.help = true; continue; }
|
|
61
|
+
if (!t.startsWith("--")) {
|
|
62
|
+
if (a.sub === null) { a.sub = t; continue; }
|
|
63
|
+
return { ...a, error: `unexpected argument: ${t}` };
|
|
64
|
+
}
|
|
65
|
+
const key = t.slice(2);
|
|
66
|
+
if (!["pages", "style", "workspace", "out", "state", "baseline", "tenant"].includes(key)) {
|
|
67
|
+
return { ...a, error: `unknown option: ${t}` };
|
|
68
|
+
}
|
|
69
|
+
const value = argv[i + 1];
|
|
70
|
+
if (!value || value.startsWith("--")) return { ...a, error: `missing value for ${t}` };
|
|
71
|
+
a[key] = value;
|
|
72
|
+
i += 1;
|
|
73
|
+
}
|
|
74
|
+
return a;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A gate's verdict as a page result carries it: true, false, or null when not measured. */
|
|
78
|
+
function gateVerdict(gate) {
|
|
79
|
+
if (gate === true || gate === false) return gate;
|
|
80
|
+
if (!gate || typeof gate !== "object") return null;
|
|
81
|
+
if (gate.skipped === true || gate.applicable === false) return null;
|
|
82
|
+
return gate.pass === true ? true : gate.pass === false ? false : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Page-check JSON files → one result per slug. A page checked by both validate-page.sh and
|
|
87
|
+
* validate-page-browser.mjs appears in two files; their gates merge, so each gate is judged once.
|
|
88
|
+
* @param {{ file: string, json: any }[]} docs
|
|
89
|
+
*/
|
|
90
|
+
export function pageResultsFromDocs(docs) {
|
|
91
|
+
/** @type {Map<string, { slug: string, gates: Record<string, boolean|null>, perf: any }>} */
|
|
92
|
+
const bySlug = new Map();
|
|
93
|
+
for (const { file, json } of docs) {
|
|
94
|
+
if (!json || typeof json !== "object" || !json.gates || typeof json.gates !== "object") continue;
|
|
95
|
+
const slug = typeof json.slug === "string" ? json.slug : basename(file).replace(/\.json$/, "");
|
|
96
|
+
const entry = bySlug.get(slug) ?? { slug, gates: {}, perf: null };
|
|
97
|
+
for (const [name, gate] of Object.entries(json.gates)) {
|
|
98
|
+
const verdict = gateVerdict(gate);
|
|
99
|
+
const prior = entry.gates[name];
|
|
100
|
+
// A failure anywhere is a failure; a gate measured anywhere beats one not measured.
|
|
101
|
+
if (verdict === false || prior === undefined || prior === null) entry.gates[name] = verdict;
|
|
102
|
+
}
|
|
103
|
+
if (json.gates.perf && typeof json.gates.perf === "object" && !json.gates.perf.skipped) entry.perf = json.gates.perf;
|
|
104
|
+
bySlug.set(slug, entry);
|
|
105
|
+
}
|
|
106
|
+
return [...bySlug.values()].sort((x, y) => x.slug.localeCompare(y.slug));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The Lighthouse facts the report shows, from a page's perf gate. */
|
|
110
|
+
function perfPage(slug, perf) {
|
|
111
|
+
return {
|
|
112
|
+
slug,
|
|
113
|
+
scores: perf.scores ?? {},
|
|
114
|
+
dimensions: perf.parity?.dimensions ?? {},
|
|
115
|
+
// Mobile and desktop are separate measurements judged against separate baselines.
|
|
116
|
+
profiles: Object.fromEntries(
|
|
117
|
+
Object.entries(perf.profiles ?? {}).map(([name, run]) => [name, {
|
|
118
|
+
scores: run?.scores ?? {},
|
|
119
|
+
dimensions: run?.parity?.dimensions ?? {},
|
|
120
|
+
}]),
|
|
121
|
+
),
|
|
122
|
+
findings: perf.findings ?? [],
|
|
123
|
+
tolerance: perf.parity?.tolerance,
|
|
124
|
+
ceiling: perf.parity?.ceiling,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The store's own lint as a readiness check. */
|
|
129
|
+
export function storeConfigCheck(lint) {
|
|
130
|
+
const errors = (lint?.findings ?? []).filter((f) => f.level === ERROR);
|
|
131
|
+
return {
|
|
132
|
+
area: "Store configuration",
|
|
133
|
+
status: lint ? (errors.length === 0 ? "PASS" : "FAIL") : "UNKNOWN",
|
|
134
|
+
why: "The store's content, theme and configuration must be valid for the platform to build and serve it.",
|
|
135
|
+
problem: !lint
|
|
136
|
+
? "The store checkout could not be linted."
|
|
137
|
+
: errors.length === 0
|
|
138
|
+
? "tot validate found no errors."
|
|
139
|
+
: `tot validate found ${errors.length} error(s): ${[...new Set(errors.map((f) => f.rule))].slice(0, 5).join(", ")}.`,
|
|
140
|
+
fix: "Run tot validate in the store checkout and fix each error it names.",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Everything the report needs, from a page-results directory. A site-wide run's summary is used as
|
|
146
|
+
* is; otherwise the checks are derived here from the single-page results.
|
|
147
|
+
* @param {{ pagesDir: string, stylePath?: string|null, lint?: any }} input
|
|
148
|
+
*/
|
|
149
|
+
export function evidenceFromDir({ pagesDir, stylePath = null, lint = null }) {
|
|
150
|
+
const summary = readJson(join(pagesDir, "site-summary.json"));
|
|
151
|
+
const styleJson = readJson(stylePath ?? join(pagesDir, "style.json"));
|
|
152
|
+
const browserDir = join(pagesDir, "browser");
|
|
153
|
+
const files = [
|
|
154
|
+
...(existsSync(pagesDir) ? readdirSync(pagesDir).map((f) => join(pagesDir, f)) : []),
|
|
155
|
+
...(existsSync(browserDir) ? readdirSync(browserDir).map((f) => join(browserDir, f)) : []),
|
|
156
|
+
].filter((f) => f.endsWith(".json") && !/(^|\/)(site-summary|style)\.json$/.test(f));
|
|
157
|
+
const docs = files.map((file) => ({ file, json: readJson(file) }));
|
|
158
|
+
const results = pageResultsFromDocs(docs);
|
|
159
|
+
const pages = results.filter((r) => r.perf).map((r) => perfPage(r.slug, r.perf));
|
|
160
|
+
const style = summary?.style?.gates ?? styleJson?.gates ?? {};
|
|
161
|
+
const styleFacts = {
|
|
162
|
+
unsourcedComponents: style.ecommerceStyleGuide?.unsourcedComponents ?? [],
|
|
163
|
+
styleGuideApproved: (style.styleGuide?.approvedCurrentCount ?? 0) > 0,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
if (summary) {
|
|
167
|
+
return {
|
|
168
|
+
measured: true,
|
|
169
|
+
generatedAt: summary.generatedAt ?? null,
|
|
170
|
+
checks: summary.readiness?.checks ?? [],
|
|
171
|
+
totals: summary.totals ?? {},
|
|
172
|
+
pages,
|
|
173
|
+
...styleFacts,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const checks = results.length === 0 ? [] : [
|
|
178
|
+
storeConfigCheck(lint),
|
|
179
|
+
...buildReadinessChecks({
|
|
180
|
+
aggregate: { pages: results, totals: { issues: { lowRiskOpen: 0 } } },
|
|
181
|
+
styleJson,
|
|
182
|
+
args: { manifest: "(none supplied to the style-concept audit)" },
|
|
183
|
+
}).filter((c) => c.area !== "Low-risk fix loop"),
|
|
184
|
+
];
|
|
185
|
+
const failing = results.filter((r) => Object.values(r.gates).some((v) => v === false)).length;
|
|
186
|
+
return {
|
|
187
|
+
measured: results.length > 0,
|
|
188
|
+
generatedAt: null,
|
|
189
|
+
checks,
|
|
190
|
+
totals: { pages: results.length, passing: results.length - failing, failing, issues: {} },
|
|
191
|
+
pages,
|
|
192
|
+
...styleFacts,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The store's records: lifecycle, add-ons, and the confirmations only a person can record. */
|
|
197
|
+
export function readStoreState(statePath) {
|
|
198
|
+
const state = readJson(statePath);
|
|
199
|
+
const track = state?.tracks?.addons?.steps ?? state?.addons ?? [];
|
|
200
|
+
/** @type {Record<string, string>} */
|
|
201
|
+
const addOns = {};
|
|
202
|
+
for (const step of Array.isArray(track) ? track : []) {
|
|
203
|
+
if (step?.id) addOns[step.id] = step.status ?? "available";
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
addOns,
|
|
207
|
+
lifecycle: state?.lifecycle ?? "hot-standby",
|
|
208
|
+
commerceSync: state?.commerceSync ?? {},
|
|
209
|
+
goLive: state?.goLive ?? {},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Resolve the store checkout the report is about. */
|
|
214
|
+
function resolveStore(args, ctx) {
|
|
215
|
+
if (args.workspace) return resolve(args.workspace);
|
|
216
|
+
if (ctx?.mode === "checkout" && ctx.workspacePath) return ctx.workspacePath;
|
|
217
|
+
if (ctx?.mode === "monorepo" && args.tenant) return join(ctx.repoRoot, ...tenantDirSegments(args.tenant));
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @param {string} dir */
|
|
222
|
+
function storeTenant(dir) {
|
|
223
|
+
const config = readJson(join(dir, ".tot", "config.json"));
|
|
224
|
+
return typeof config?.tenant === "string" && config.tenant ? config.tenant : basename(dir);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* @param {string[]} argv
|
|
229
|
+
* @param {any} ctx
|
|
230
|
+
* @param {{ out?: (s: string) => void, err?: (s: string) => void, now?: () => string }} [io]
|
|
231
|
+
*/
|
|
232
|
+
export function run(argv, ctx, io = {}) {
|
|
233
|
+
const out = io.out ?? ((s) => process.stdout.write(s));
|
|
234
|
+
const err = io.err ?? ((s) => process.stderr.write(s));
|
|
235
|
+
const now = io.now ?? (() => new Date().toISOString());
|
|
236
|
+
const args = parseArgs(argv);
|
|
237
|
+
if (args.help || (!args.sub && !args.error)) {
|
|
238
|
+
out(`${USAGE}\n`);
|
|
239
|
+
return args.help ? 0 : 2;
|
|
240
|
+
}
|
|
241
|
+
if (args.error) {
|
|
242
|
+
err(`${args.error}\n\n${USAGE}\n`);
|
|
243
|
+
return 2;
|
|
244
|
+
}
|
|
245
|
+
if (args.sub !== "go-live") {
|
|
246
|
+
err(`unknown report: ${args.sub}\n\n${USAGE}\n`);
|
|
247
|
+
return 2;
|
|
248
|
+
}
|
|
249
|
+
if (!args.pages) {
|
|
250
|
+
err(`${fail("missing --pages <dir>", "save each page's check JSON into a directory, then pass it as --pages")}\n`);
|
|
251
|
+
return 2;
|
|
252
|
+
}
|
|
253
|
+
const pagesDir = resolve(args.pages);
|
|
254
|
+
const store = resolveStore(args, ctx);
|
|
255
|
+
if (!store) {
|
|
256
|
+
err(`${fail("no store to report on", "run inside your store checkout, or pass --workspace <dir>")}\n`);
|
|
257
|
+
return 2;
|
|
258
|
+
}
|
|
259
|
+
const storeExists = existsSync(store);
|
|
260
|
+
const tenant = args.tenant ?? (storeExists ? storeTenant(store) : basename(store));
|
|
261
|
+
const lint = storeExists ? validateTenant(store, { tenantId: tenant, mode: args.workspace ? "workspace" : "monorepo" }) : null;
|
|
262
|
+
const evidence = evidenceFromDir({ pagesDir, stylePath: args.style ? resolve(args.style) : null, lint });
|
|
263
|
+
if (!evidence.measured) {
|
|
264
|
+
err(`${fail(
|
|
265
|
+
`no page-check results under ${pagesDir}`,
|
|
266
|
+
"run the page checks first and save their JSON there — a report without them would assert a readiness nobody measured",
|
|
267
|
+
)}\n`);
|
|
268
|
+
return 3;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const baseline = readJson(args.baseline ? resolve(args.baseline) : join(store, ".tot", "lighthouse-source-baseline.json"));
|
|
272
|
+
const { addOns, lifecycle, commerceSync, goLive } = readStoreState(
|
|
273
|
+
args.state ? resolve(args.state) : join(store, ".tot", "migration-state.json"),
|
|
274
|
+
);
|
|
275
|
+
const generatedAt = evidence.generatedAt ?? now();
|
|
276
|
+
const report = buildGoLiveReport({
|
|
277
|
+
tenant,
|
|
278
|
+
generatedAt,
|
|
279
|
+
lifecycle,
|
|
280
|
+
pages: evidence.pages,
|
|
281
|
+
checks: evidence.checks,
|
|
282
|
+
totals: evidence.totals,
|
|
283
|
+
unsourcedComponents: evidence.unsourcedComponents,
|
|
284
|
+
styleGuideApproved: evidence.styleGuideApproved,
|
|
285
|
+
addOns,
|
|
286
|
+
baselineRoutes: Object.keys(baseline?.results ?? {}).length,
|
|
287
|
+
baselineCapturedAt: baseline?.capturedAt ?? null,
|
|
288
|
+
commerceSync,
|
|
289
|
+
goLive,
|
|
290
|
+
});
|
|
291
|
+
const outPath = resolve(args.out ?? join(pagesDir, "go-live-report.md"));
|
|
292
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
293
|
+
writeFileSync(outPath, report);
|
|
294
|
+
// The implementor's companion, always written alongside: the merchant document leaves out gate
|
|
295
|
+
// counts and tooling names, and that detail has to land somewhere.
|
|
296
|
+
const internalPath = `${outPath.replace(/\.md$/, "")}.internal.md`;
|
|
297
|
+
writeFileSync(internalPath, buildImplementorReport({
|
|
298
|
+
tenant,
|
|
299
|
+
generatedAt,
|
|
300
|
+
lifecycle,
|
|
301
|
+
checks: evidence.checks,
|
|
302
|
+
totals: evidence.totals,
|
|
303
|
+
pages: evidence.pages,
|
|
304
|
+
commerceSync,
|
|
305
|
+
}));
|
|
306
|
+
err(`wrote ${outPath}\nwrote ${internalPath}\n${TEASER}\n`);
|
|
307
|
+
return 0;
|
|
308
|
+
}
|