@shop-on/theme-cli 0.0.0-stage → 0.2.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.
@@ -0,0 +1,39 @@
1
+ # Cody Theme Development License
2
+
3
+ Copyright (c) 2026 Cody LLC. All rights reserved except as granted below.
4
+
5
+ "The Software" is this package, its source, and its documentation. "The
6
+ Platform" is Cody, the commerce platform operated by Cody LLC.
7
+
8
+ ## 1. Grant
9
+
10
+ You may use, copy and modify the Software, and distribute modified copies, solely
11
+ to develop, test, and distribute themes, apps and other extensions that run on
12
+ the Platform.
13
+
14
+ ## 2. Restrictions
15
+
16
+ You may not:
17
+
18
+ 1. use the Software, or anything derived from it, with any commerce platform,
19
+ storefront or hosting service other than the Platform;
20
+ 2. use the Software to build a product that competes with the Platform;
21
+ 3. sublicense the Software, or distribute it other than as part of a theme,
22
+ app or extension for the Platform;
23
+ 4. remove or alter this license or any copyright notice in the Software.
24
+
25
+ ## 3. Your work
26
+
27
+ Themes, apps and extensions you build with the Software are yours. The parts of
28
+ the Software they include stay under this license.
29
+
30
+ ## 4. Termination
31
+
32
+ This license ends automatically if you break any of its terms. You must then
33
+ stop using the Software and delete your copies of it.
34
+
35
+ ## 5. No warranty
36
+
37
+ The Software is provided "as is", without warranty of any kind. Cody LLC is not
38
+ liable for any claim, damages or other liability arising from the Software or
39
+ its use.
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ import { MissingEnvError, loadThemeEnv } from "./env.mjs";
3
+ import { openBrowser } from "./lighthouse.mjs";
4
+ import { average, pageScore, verdict } from "./score.mjs";
5
+ import { BAR, formatReport } from "./report.mjs";
6
+ import { auditRoutes, checkRoutes } from "./routes.mjs";
7
+ import { startBuiltServer } from "./server.mjs";
8
+ import { fetchStoreData, storeProblems } from "./store.mjs";
9
+ import { runThemeBuild } from "../theme/build.mjs";
10
+ import { existsSync } from "node:fs";
11
+ import path from "node:path";
12
+ import { parseArgs, styleText } from "node:util";
13
+ //#region src/audit/command.ts
14
+ const USAGE = `usage: shop-on theme audit [--theme <dir>] [--runs 3] [--json] [--skip-build]
15
+
16
+ --theme <dir> the theme to audit (default: the current directory)
17
+ --runs <n> Lighthouse runs per page and form factor; the median counts (default: 3)
18
+ --json print the report as JSON, for CI
19
+ --skip-build serve the existing .output instead of running \`pnpm build\``;
20
+ const LIGHTHOUSE_PAGES = [
21
+ "index",
22
+ "product",
23
+ "collection"
24
+ ];
25
+ const FORM_FACTORS = ["mobile", "desktop"];
26
+ const progress = (text) => styleText("dim", `› ${text}`);
27
+ const processIo = {
28
+ out: (text) => process.stdout.write(`${text}\n`),
29
+ err: (text) => process.stderr.write(`${text}\n`)
30
+ };
31
+ const defaultDeps = {
32
+ fetchStoreData,
33
+ build: runThemeBuild,
34
+ startServer: startBuiltServer,
35
+ openBrowser
36
+ };
37
+ /** Runs the audit and returns the exit code. */
38
+ async function runAudit({ argv, cwd = process.cwd(), io = processIo, deps: overrides = {}, processEnv = process.env }) {
39
+ const deps = {
40
+ ...defaultDeps,
41
+ ...overrides
42
+ };
43
+ let values;
44
+ try {
45
+ ({values} = parseArgs({
46
+ args: argv,
47
+ options: {
48
+ theme: { type: "string" },
49
+ runs: {
50
+ type: "string",
51
+ default: "3"
52
+ },
53
+ json: {
54
+ type: "boolean",
55
+ default: false
56
+ },
57
+ "skip-build": {
58
+ type: "boolean",
59
+ default: false
60
+ },
61
+ help: {
62
+ type: "boolean",
63
+ short: "h",
64
+ default: false
65
+ }
66
+ }
67
+ }));
68
+ } catch (err) {
69
+ io.err(`${err.message}\n\n${USAGE}`);
70
+ return 2;
71
+ }
72
+ if (values.help) {
73
+ io.out(USAGE);
74
+ return 0;
75
+ }
76
+ const runs = Number(values.runs);
77
+ if (!Number.isInteger(runs) || runs < 1) {
78
+ io.err(`--runs must be a whole number of 1 or more\n\n${USAGE}`);
79
+ return 2;
80
+ }
81
+ const themeDir = path.resolve(cwd, values.theme ?? ".");
82
+ const report = {
83
+ ok: false,
84
+ problems: [],
85
+ routes: [],
86
+ skippedRoutes: [],
87
+ lighthouse: null
88
+ };
89
+ const finish = () => {
90
+ report.ok = report.problems.length === 0;
91
+ io.out(values.json ? JSON.stringify(report, null, 2) : formatReport(report));
92
+ return report.ok ? 0 : 1;
93
+ };
94
+ const fail = (...problems) => {
95
+ report.problems.push(...problems);
96
+ return finish();
97
+ };
98
+ if (!existsSync(path.join(themeDir, "package.json"))) return fail(`${themeDir} is not a theme: it has no package.json`);
99
+ let env;
100
+ try {
101
+ ({env} = loadThemeEnv(themeDir, processEnv));
102
+ } catch (err) {
103
+ if (err instanceof MissingEnvError) return fail(`the theme's storefront credentials are incomplete: ${err.message}`);
104
+ throw err;
105
+ }
106
+ io.err(progress(`reading the store at ${env.STOREFRONT_API_URL}`));
107
+ let store;
108
+ try {
109
+ store = await deps.fetchStoreData(env);
110
+ } catch (err) {
111
+ return fail(err.message);
112
+ }
113
+ const missing = storeProblems(store);
114
+ if (missing.length) return fail(...missing.map((m) => `store ${env.STORE_SLUG}: ${m}`));
115
+ const cleanups = [];
116
+ const cleanUp = () => Promise.allSettled(cleanups.splice(0).reverse().map((c) => c()));
117
+ const exitCode = (signal) => signal === "SIGINT" ? 130 : 143;
118
+ const force = (signal) => process.exit(exitCode(signal));
119
+ const onSignal = (signal) => {
120
+ process.on("SIGINT", force);
121
+ process.on("SIGTERM", force);
122
+ cleanUp().finally(() => process.exit(exitCode(signal)));
123
+ };
124
+ process.once("SIGINT", onSignal);
125
+ process.once("SIGTERM", onSignal);
126
+ try {
127
+ if (!values["skip-build"]) {
128
+ io.err(progress("building"));
129
+ const code = await deps.build(themeDir);
130
+ if (code !== 0) return fail(`the build failed (exit ${code})`);
131
+ }
132
+ io.err(progress("starting the built server"));
133
+ let server;
134
+ try {
135
+ server = await deps.startServer(themeDir, env);
136
+ } catch (err) {
137
+ return fail(`the built server did not start: ${err.message}`);
138
+ }
139
+ cleanups.push(() => server.stop());
140
+ const { routes, skipped } = auditRoutes(store);
141
+ report.routes = await checkRoutes(server.url, routes);
142
+ report.skippedRoutes = skipped;
143
+ for (const r of report.routes.filter((r) => !r.ok)) report.problems.push(`${r.template} ${r.path} answered ${r.status ?? r.error}, expected ${r.expect}`);
144
+ const pages = LIGHTHOUSE_PAGES.map((page) => report.routes.find((r) => r.template === page));
145
+ if (pages.some((p) => !p.ok)) {
146
+ report.problems.push("Lighthouse skipped: a page it measures did not answer 200");
147
+ return finish();
148
+ }
149
+ const browser = await deps.openBrowser();
150
+ cleanups.push(() => browser.close());
151
+ const measured = [];
152
+ for (const page of pages) for (const formFactor of FORM_FACTORS) {
153
+ const url = new URL(page.path, server.url).href;
154
+ const entry = {
155
+ page: page.template,
156
+ url,
157
+ formFactor,
158
+ runs: []
159
+ };
160
+ for (let i = 1; i <= runs; i++) {
161
+ io.err(progress(`lighthouse ${page.template} ${formFactor} ${i}/${runs}`));
162
+ entry.runs.push(await browser.run(url, formFactor));
163
+ }
164
+ measured.push(entry);
165
+ }
166
+ const scored = measured.map(pageScore);
167
+ const avg = average(scored);
168
+ report.lighthouse = {
169
+ runs,
170
+ pages: scored,
171
+ average: avg,
172
+ bar: BAR
173
+ };
174
+ report.problems.push(...verdict(avg));
175
+ return finish();
176
+ } catch (err) {
177
+ return fail(err.message);
178
+ } finally {
179
+ process.off("SIGINT", onSignal);
180
+ process.off("SIGTERM", onSignal);
181
+ await cleanUp();
182
+ }
183
+ }
184
+ //#endregion
185
+ export { USAGE, processIo, runAudit };
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { parseEnv } from "node:util";
5
+ //#region src/audit/env.ts
6
+ /** What the theme server reads from its environment — the starter's `.env.example`. */
7
+ const THEME_ENV_KEYS = [
8
+ "STOREFRONT_API_URL",
9
+ "STOREFRONT_CLIENT_ID",
10
+ "STOREFRONT_CLIENT_SECRET",
11
+ "STORE_SLUG",
12
+ "STOREFRONT_CUSTOMER_SESSION_SECRET"
13
+ ];
14
+ var MissingEnvError = class extends Error {
15
+ missing;
16
+ files;
17
+ constructor(missing, files) {
18
+ const where = files.length ? files.join(", ") : "no .env file found";
19
+ super(`missing ${missing.join(", ")} (read: ${where})`);
20
+ this.missing = missing;
21
+ this.files = files;
22
+ this.name = "MissingEnvError";
23
+ }
24
+ };
25
+ /**
26
+ * The `.env` files `pnpm dev` reads for this theme, in precedence order: the
27
+ * theme's own, then — for a theme inside a pnpm workspace (this repo's
28
+ * `themes/*`) — the workspace root's. Same rule as `createThemeViteConfig`.
29
+ */
30
+ function themeEnvFiles(themeDir) {
31
+ return [".env", ...existsSync(path.join(themeDir, "../../pnpm-workspace.yaml")) ? ["../../.env"] : []].map((f) => path.resolve(themeDir, f));
32
+ }
33
+ /**
34
+ * The theme server's variables only: the process environment first (CI sets
35
+ * them there), then the first `.env` file that sets each one. Throws naming
36
+ * the missing variables, never a value.
37
+ */
38
+ function loadThemeEnv(themeDir, processEnv = process.env) {
39
+ const files = themeEnvFiles(themeDir).filter((f) => existsSync(f));
40
+ const parsed = files.map((f) => parseEnv(readFileSync(f, "utf-8")));
41
+ const env = {};
42
+ for (const key of THEME_ENV_KEYS) {
43
+ const value = processEnv[key] || parsed.find((p) => p[key])?.[key];
44
+ if (value) env[key] = value;
45
+ }
46
+ const missing = THEME_ENV_KEYS.filter((key) => !env[key]);
47
+ if (missing.length) throw new MissingEnvError(missing, files);
48
+ return {
49
+ env,
50
+ files
51
+ };
52
+ }
53
+ /** The values to scrub from anything a child process printed. */
54
+ function secretValues(env) {
55
+ return [
56
+ env.STOREFRONT_CLIENT_ID,
57
+ env.STOREFRONT_CLIENT_SECRET,
58
+ env.STOREFRONT_CUSTOMER_SESSION_SECRET
59
+ ];
60
+ }
61
+ function redact(text, secrets) {
62
+ return secrets.filter(Boolean).reduce((out, s) => out.split(s).join("[redacted]"), text);
63
+ }
64
+ //#endregion
65
+ export { MissingEnvError, THEME_ENV_KEYS, loadThemeEnv, redact, secretValues, themeEnvFiles };
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ //#region src/audit/lighthouse.ts
3
+ /**
4
+ * Headless Chrome plus Lighthouse, both loaded on first use so the rest of the
5
+ * CLI never pays for them. Chrome is launched once; Lighthouse resets storage
6
+ * before every run, so runs don't share a warm cache.
7
+ */
8
+ async function openBrowser() {
9
+ const [{ launch }, { default: lighthouse, defaultConfig, desktopConfig }, constants] = await Promise.all([
10
+ import("chrome-launcher"),
11
+ import("lighthouse"),
12
+ import("lighthouse/core/config/constants.js")
13
+ ]);
14
+ const chrome = await launch({
15
+ chromeFlags: [
16
+ "--headless=new",
17
+ "--no-first-run",
18
+ "--no-default-browser-check"
19
+ ],
20
+ handleSIGINT: false,
21
+ logLevel: "silent"
22
+ });
23
+ const presets = {
24
+ mobile: defaultConfig,
25
+ desktop: desktopConfig
26
+ };
27
+ const config = (formFactor) => ({
28
+ ...presets[formFactor],
29
+ settings: {
30
+ ...presets[formFactor].settings,
31
+ formFactor,
32
+ screenEmulation: constants.screenEmulationMetrics[formFactor],
33
+ onlyCategories: ["performance", "accessibility"]
34
+ }
35
+ });
36
+ return {
37
+ async run(url, formFactor) {
38
+ const result = await lighthouse(url, {
39
+ port: chrome.port,
40
+ logLevel: "error",
41
+ output: "json"
42
+ }, config(formFactor));
43
+ if (!result) throw new Error(`lighthouse returned nothing for ${url}`);
44
+ return scoresOf(result.lhr, formFactor);
45
+ },
46
+ async close() {
47
+ chrome.kill();
48
+ }
49
+ };
50
+ }
51
+ /** A run's scores, after checking the report ran the way it was asked to. */
52
+ function scoresOf(lhr, formFactor) {
53
+ if (lhr.runtimeError) throw new Error(`lighthouse ${lhr.finalDisplayedUrl}: ${lhr.runtimeError.message}`);
54
+ const { screenEmulation, formFactor: ran } = lhr.configSettings;
55
+ if (screenEmulation.disabled || ran !== formFactor || screenEmulation.mobile !== (formFactor === "mobile")) throw new Error(`lighthouse ran without ${formFactor} screen emulation (formFactor ${ran}, emulation ${screenEmulation.disabled ? "disabled" : `${screenEmulation.width}px`})`);
56
+ const score = (id) => {
57
+ const value = lhr.categories[id]?.score;
58
+ if (value == null) throw new Error(`lighthouse ${lhr.finalDisplayedUrl}: no ${id} score`);
59
+ return Math.round(value * 100);
60
+ };
61
+ return {
62
+ performance: score("performance"),
63
+ accessibility: score("accessibility")
64
+ };
65
+ }
66
+ //#endregion
67
+ export { openBrowser, scoresOf };
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import { display } from "./score.mjs";
3
+ import { styleText } from "node:util";
4
+ //#region src/audit/report.ts
5
+ const BAR = {
6
+ performance: 60,
7
+ accessibility: 90
8
+ };
9
+ const scoreCell = (value, bar, width) => styleText(value < bar ? "red" : "green", String(display(value)).padStart(width));
10
+ function formatReport(report) {
11
+ const out = [styleText("bold", "Routes")];
12
+ const label = Math.max(...report.routes.map((r) => r.template.length), 0);
13
+ for (const r of report.routes) {
14
+ const mark = r.ok ? styleText("green", "✓") : styleText("red", "✖");
15
+ const status = r.status === null ? styleText("red", "ERR") : String(r.status);
16
+ const why = r.ok ? "" : styleText("dim", ` expected ${r.expect}${r.error ? `: ${r.error}` : ""}`);
17
+ out.push(` ${mark} ${status} ${styleText("yellow", r.template.padEnd(label))} ${styleText("cyan", r.path)}${why}`);
18
+ }
19
+ for (const s of report.skippedRoutes) out.push(styleText("dim", ` - skipped ${s.template}: ${s.reason}`));
20
+ if (report.lighthouse) {
21
+ const { pages, average, runs } = report.lighthouse;
22
+ out.push("", styleText("bold", `Lighthouse (median of ${runs} run${runs === 1 ? "" : "s"})`));
23
+ out.push(styleText("dim", ` ${"page".padEnd(12)} ${"form".padEnd(8)} ${"perf".padStart(5)} ${"a11y".padStart(5)}`));
24
+ for (const p of pages) out.push(` ${styleText("yellow", p.page.padEnd(12))} ${p.formFactor.padEnd(8)} ${scoreCell(p.performance, 60, 5)} ${scoreCell(p.accessibility, 90, 5)}`);
25
+ out.push(` ${styleText("bold", "average".padEnd(21))} ${scoreCell(average.performance, 60, 5)} ${scoreCell(average.accessibility, 90, 5)}` + styleText("dim", ` bar: perf ≥ 60, a11y ≥ 90`));
26
+ }
27
+ out.push("");
28
+ if (report.ok) out.push(styleText(["green", "bold"], "✓ meets the quality bar"));
29
+ else {
30
+ for (const p of report.problems) out.push(`${styleText("red", "✖")} ${p}`);
31
+ const n = report.problems.length;
32
+ out.push(styleText(["red", "bold"], `✖ ${n} problem${n === 1 ? "" : "s"}`));
33
+ }
34
+ return out.join("\n");
35
+ }
36
+ //#endregion
37
+ export { BAR, formatReport };
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ //#region src/audit/routes.ts
3
+ /** An unknown path, so a theme that answers 200 for everything is caught. */
4
+ const UNKNOWN_PATH = "/shop-on-audit-no-such-page";
5
+ /**
6
+ * Every root template the starter skeleton defines, at a URL this store can
7
+ * fill. `password` is left out: it renders only while protection is on, and
8
+ * the audit refuses to run then.
9
+ */
10
+ function auditRoutes(store) {
11
+ const routes = [{
12
+ template: "index",
13
+ path: "/",
14
+ expect: 200
15
+ }];
16
+ const skipped = [];
17
+ const add = (template, handle, path) => {
18
+ if (handle) routes.push({
19
+ template,
20
+ path: path(encodeURIComponent(handle)),
21
+ expect: 200
22
+ });
23
+ else skipped.push({
24
+ template,
25
+ reason: `the store has no ${template}`
26
+ });
27
+ };
28
+ add("product", store.product, (h) => `/products/${h}`);
29
+ add("collection", store.collection, (h) => `/collections/${h}`);
30
+ routes.push({
31
+ template: "list-collections",
32
+ path: "/collections",
33
+ expect: 200
34
+ });
35
+ routes.push({
36
+ template: "search",
37
+ path: `/search?q=${encodeURIComponent(store.product ?? "a")}`,
38
+ expect: 200
39
+ });
40
+ routes.push({
41
+ template: "cart",
42
+ path: "/cart",
43
+ expect: 200
44
+ });
45
+ add("page", store.page, (h) => `/pages/${h}`);
46
+ add("blog", store.blog, (h) => `/blogs/${h}`);
47
+ add("article", store.article, (h) => `/blogs/${encodeURIComponent(store.blog ?? "")}/articles/${h}`);
48
+ add("policy", store.policy, (h) => `/policies/${h}`);
49
+ routes.push({
50
+ template: "404",
51
+ path: UNKNOWN_PATH,
52
+ expect: 404
53
+ });
54
+ return {
55
+ routes,
56
+ skipped
57
+ };
58
+ }
59
+ /**
60
+ * Exactly the expected status passes. A redirect is a failure, not a pass: a
61
+ * template that bounces to `/` or `/password` never rendered.
62
+ */
63
+ function evaluateRoute(route, outcome) {
64
+ if (outcome instanceof Error) return {
65
+ ...route,
66
+ status: null,
67
+ error: outcome.message,
68
+ ok: false
69
+ };
70
+ return {
71
+ ...route,
72
+ status: outcome,
73
+ ok: outcome === route.expect
74
+ };
75
+ }
76
+ async function checkRoutes(baseUrl, routes, fetchImpl = fetch) {
77
+ const results = [];
78
+ for (const route of routes) {
79
+ const outcome = await fetchImpl(new URL(route.path, baseUrl), {
80
+ redirect: "manual",
81
+ signal: AbortSignal.timeout(3e4)
82
+ }).then(async (res) => {
83
+ await res.body?.cancel();
84
+ return res.status;
85
+ }, (err) => err instanceof Error ? err : new Error(String(err)));
86
+ results.push(evaluateRoute(route, outcome));
87
+ }
88
+ return results;
89
+ }
90
+ //#endregion
91
+ export { UNKNOWN_PATH, auditRoutes, checkRoutes, evaluateRoute };
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The median, so one outlier run (a GC pause, a cold cache) can't move the
4
+ * result: a single run varies 5–10 points. An even count takes the mean of the
5
+ * middle two.
6
+ */
7
+ function median(values) {
8
+ if (!values.length) throw new Error("median of no values");
9
+ const sorted = [...values].sort((a, b) => a - b);
10
+ const mid = Math.floor(sorted.length / 2);
11
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
12
+ }
13
+ /** Each category's median per page and form factor, taken independently. */
14
+ function pageScore(p) {
15
+ return {
16
+ ...p,
17
+ performance: median(p.runs.map((r) => r.performance)),
18
+ accessibility: median(p.runs.map((r) => r.accessibility))
19
+ };
20
+ }
21
+ function average(scores) {
22
+ if (!scores.length) throw new Error("average of no scores");
23
+ const mean = (pick) => scores.reduce((sum, s) => sum + pick(s), 0) / scores.length;
24
+ return {
25
+ performance: mean((s) => s.performance),
26
+ accessibility: mean((s) => s.accessibility)
27
+ };
28
+ }
29
+ /** What falls short of the bar, one line each; empty when it clears. */
30
+ function verdict(avg) {
31
+ const failures = [];
32
+ if (avg.performance < 60) failures.push(`performance ${display(avg.performance)} is under 60`);
33
+ if (avg.accessibility < 90) failures.push(`accessibility ${display(avg.accessibility)} is under 90`);
34
+ return failures;
35
+ }
36
+ /**
37
+ * One decimal for display, rounded DOWN: a 59.96 average fails, so it must not
38
+ * print as a passing 60. The verdict compares the exact mean.
39
+ */
40
+ const display = (n) => Math.floor(n * 10 + 1e-9) / 10;
41
+ //#endregion
42
+ export { average, display, median, pageScore, verdict };