@shop-on/theme-cli 0.1.0 → 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.
@@ -6,7 +6,7 @@ import { BAR, formatReport } from "./report.mjs";
6
6
  import { auditRoutes, checkRoutes } from "./routes.mjs";
7
7
  import { startBuiltServer } from "./server.mjs";
8
8
  import { fetchStoreData, storeProblems } from "./store.mjs";
9
- import { spawn } from "node:child_process";
9
+ import { runThemeBuild } from "../theme/build.mjs";
10
10
  import { existsSync } from "node:fs";
11
11
  import path from "node:path";
12
12
  import { parseArgs, styleText } from "node:util";
@@ -30,23 +30,7 @@ const processIo = {
30
30
  };
31
31
  const defaultDeps = {
32
32
  fetchStoreData,
33
- build: (themeDir) => new Promise((resolve, reject) => {
34
- const child = spawn("pnpm", ["run", "build"], {
35
- cwd: themeDir,
36
- stdio: [
37
- "ignore",
38
- process.stderr,
39
- process.stderr
40
- ]
41
- });
42
- const kill = () => child.kill();
43
- process.once("exit", kill);
44
- child.once("error", reject);
45
- child.once("exit", (code) => {
46
- process.off("exit", kill);
47
- resolve(code ?? 1);
48
- });
49
- }),
33
+ build: runThemeBuild,
50
34
  startServer: startBuiltServer,
51
35
  openBrowser
52
36
  };
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { redact, secretValues } from "./env.mjs";
3
3
  import { createRequire } from "node:module";
4
- import { spawn } from "node:child_process";
5
4
  import { closeSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
5
  import path from "node:path";
6
+ import { spawn } from "node:child_process";
7
7
  import net from "node:net";
8
8
  import os from "node:os";
9
9
  //#region src/audit/server.ts
package/dist/index.mjs CHANGED
@@ -4,6 +4,7 @@ import { runLogin } from "./login.mjs";
4
4
  import { runCredentialsRevoke } from "./theme/credentials.mjs";
5
5
  import { runDev } from "./theme/dev.mjs";
6
6
  import { runInit } from "./theme/init.mjs";
7
+ import { runPack } from "./theme/pack.mjs";
7
8
  //#region src/index.ts
8
9
  const USAGE = `usage: shop-on <command>
9
10
 
@@ -12,6 +13,7 @@ const USAGE = `usage: shop-on <command>
12
13
  theme dev run this theme in the hosted theme editor
13
14
  theme credentials revoke <client-id> revoke a dev storefront credential
14
15
  theme audit measure this theme against the quality bar
16
+ theme pack pack the built theme into its version tarball
15
17
 
16
18
  Each command takes --help.`;
17
19
  const [group, command, ...rest] = process.argv.slice(2);
@@ -20,6 +22,7 @@ else if (group === "theme" && command === "init") process.exitCode = await runIn
20
22
  else if (group === "theme" && command === "dev") process.exitCode = await runDev({ argv: rest });
21
23
  else if (group === "theme" && command === "credentials" && rest[0] === "revoke") process.exitCode = await runCredentialsRevoke({ argv: rest.slice(1) });
22
24
  else if (group === "theme" && command === "audit") process.exitCode = await runAudit({ argv: rest });
25
+ else if (group === "theme" && command === "pack") process.exitCode = await runPack({ argv: rest });
23
26
  else {
24
27
  const help = group === void 0 || group === "--help" || group === "-h";
25
28
  (help ? console.log : console.error)(USAGE);
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ //#region src/theme/build.ts
4
+ /** Runs the theme's `pnpm run build` and resolves with its exit code. Build
5
+ * output goes to stderr, so a command's own stdout stays parseable. */
6
+ function runThemeBuild(themeDir) {
7
+ return new Promise((resolve, reject) => {
8
+ const child = spawn("pnpm", ["run", "build"], {
9
+ cwd: themeDir,
10
+ stdio: [
11
+ "ignore",
12
+ process.stderr,
13
+ process.stderr
14
+ ]
15
+ });
16
+ const kill = () => child.kill();
17
+ process.once("exit", kill);
18
+ child.once("error", reject);
19
+ child.once("exit", (code) => {
20
+ process.off("exit", kill);
21
+ resolve(code ?? 1);
22
+ });
23
+ });
24
+ }
25
+ //#endregion
26
+ export { runThemeBuild };
@@ -6,10 +6,10 @@ import { adminOrigin, discover, ensureLoggedIn, resolveApiUrl } from "../auth/oa
6
6
  import { openUrl } from "../open-url.mjs";
7
7
  import { graphql } from "../auth/api.mjs";
8
8
  import { createRequire } from "node:module";
9
- import { spawn } from "node:child_process";
10
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
10
  import path from "node:path";
12
11
  import { parseArgs } from "node:util";
12
+ import { spawn } from "node:child_process";
13
13
  //#region src/theme/dev.ts
14
14
  const DEV_USAGE = `usage: shop-on theme dev [--api <url>] [--env dev|staging|prod]
15
15
 
@@ -5,10 +5,10 @@ import { apiFlag, ensureLoggedIn, resolveApiUrl } from "../auth/oauth.mjs";
5
5
  import { openUrl } from "../open-url.mjs";
6
6
  import { graphql } from "../auth/api.mjs";
7
7
  import { formatCredentialList } from "./credentials.mjs";
8
- import { spawn } from "node:child_process";
9
8
  import { existsSync, readdirSync, rmSync, writeFileSync } from "node:fs";
10
9
  import path from "node:path";
11
10
  import { parseArgs } from "node:util";
11
+ import { spawn } from "node:child_process";
12
12
  import os from "node:os";
13
13
  import { randomBytes } from "node:crypto";
14
14
  import { packageManagerCommands, scaffoldTheme } from "@shop-on/create-theme";
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ import { runThemeBuild } from "./build.mjs";
3
+ import { processIo } from "../audit/command.mjs";
4
+ import { existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { parseArgs } from "node:util";
7
+ import { create } from "tar";
8
+ //#region src/theme/pack.ts
9
+ const USAGE = `usage: shop-on theme pack [--theme <dir>] [--out <path>] [--build]
10
+
11
+ --theme <dir> the theme to pack (default: the current directory)
12
+ --out <path> where to write the tarball, a file or a directory
13
+ (default: <handle>-<version>.tar.gz in the theme directory)
14
+ --build run \`pnpm run build\` first`;
15
+ /** The entries the theme store refuses a version without
16
+ * (`Theme::Versions::Create::REQUIRED_FILES` in the api). */
17
+ const REQUIRED_FILES = [
18
+ "theme.json",
19
+ "release-notes.md",
20
+ "server/index.mjs",
21
+ "config/settings-schema.json"
22
+ ];
23
+ const SEMVER = /^\d+\.\d+\.\d+$/;
24
+ /** Packs the theme and returns the exit code. */
25
+ async function runPack({ argv, cwd = process.cwd(), io = processIo, deps: overrides = {} }) {
26
+ const deps = {
27
+ build: runThemeBuild,
28
+ ...overrides
29
+ };
30
+ let values;
31
+ try {
32
+ ({values} = parseArgs({
33
+ args: argv,
34
+ options: {
35
+ theme: { type: "string" },
36
+ out: { type: "string" },
37
+ build: {
38
+ type: "boolean",
39
+ default: false
40
+ },
41
+ help: {
42
+ type: "boolean",
43
+ short: "h",
44
+ default: false
45
+ }
46
+ }
47
+ }));
48
+ } catch (err) {
49
+ io.err(`${err.message}\n\n${USAGE}`);
50
+ return 2;
51
+ }
52
+ if (values.help) {
53
+ io.out(USAGE);
54
+ return 0;
55
+ }
56
+ const themeDir = path.resolve(cwd, values.theme ?? ".");
57
+ const fail = (message) => {
58
+ io.err(message);
59
+ return 1;
60
+ };
61
+ const pkgPath = path.join(themeDir, "package.json");
62
+ if (!existsSync(pkgPath)) return fail(`${themeDir} is not a theme: it has no package.json`);
63
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
64
+ if (values.build) {
65
+ const code = await deps.build(themeDir);
66
+ if (code !== 0) return fail(`the build failed (exit ${code})`);
67
+ }
68
+ const outputDir = path.join(themeDir, ".output");
69
+ if (!existsSync(outputDir)) return fail(`${themeDir} has no .output: run \`pnpm build\` first, or pass --build`);
70
+ const missing = REQUIRED_FILES.filter((rel) => !isFile(path.join(outputDir, rel)));
71
+ if (missing.length > 0) return fail(`.output is missing ${missing.join(", ")}; the theme store refuses a version without them`);
72
+ let version;
73
+ try {
74
+ ({version} = JSON.parse(readFileSync(path.join(outputDir, "theme.json"), "utf8")));
75
+ } catch (err) {
76
+ return fail(`.output/theme.json is not valid JSON: ${err.message}`);
77
+ }
78
+ if (typeof version !== "string" || !SEMVER.test(version)) return fail(`.output/theme.json#version ${JSON.stringify(version)} is not semver (X.Y.Z)`);
79
+ if (pkg.version !== void 0 && pkg.version !== version) return fail(`.output is version ${version} but package.json says ${pkg.version}: rebuild, or pass --build`);
80
+ const fileName = `${themeHandle(pkg.name, themeDir)}-${version}.tar.gz`;
81
+ let outPath = path.join(themeDir, fileName);
82
+ if (values.out !== void 0) {
83
+ const out = path.resolve(cwd, values.out);
84
+ outPath = isDirectory(out) || values.out.endsWith("/") ? path.join(out, fileName) : out;
85
+ }
86
+ const tmpPath = `${outPath}.partial`;
87
+ let entries = 0;
88
+ try {
89
+ await create({
90
+ gzip: true,
91
+ file: tmpPath,
92
+ cwd: outputDir,
93
+ portable: true,
94
+ filter: (entryPath, stat) => {
95
+ const base = path.basename(entryPath);
96
+ if (base === ".DS_Store" || base.startsWith("._")) return false;
97
+ const keep = "isFile" in stat && (stat.isFile() || stat.isDirectory());
98
+ if (keep) entries += 1;
99
+ return keep;
100
+ }
101
+ }, readdirSync(outputDir).sort());
102
+ renameSync(tmpPath, outPath);
103
+ } catch (err) {
104
+ rmSync(tmpPath, { force: true });
105
+ return fail(`could not write ${outPath}: ${err.message}`);
106
+ }
107
+ const kb = Math.round(statSync(outPath).size / 1024);
108
+ const shown = path.relative(cwd, outPath);
109
+ io.out(`${shown.startsWith("..") ? outPath : shown} (${entries} entries, ${kb} KB)`);
110
+ return 0;
111
+ }
112
+ /** `@shop-on/themes/kito` → `kito`; the directory name when there's no name. */
113
+ function themeHandle(name, themeDir) {
114
+ const last = name?.split("/").pop();
115
+ return last && last.length > 0 ? last : path.basename(themeDir);
116
+ }
117
+ function isFile(p) {
118
+ return existsSync(p) && statSync(p).isFile();
119
+ }
120
+ function isDirectory(p) {
121
+ return existsSync(p) && statSync(p).isDirectory();
122
+ }
123
+ //#endregion
124
+ export { REQUIRED_FILES, USAGE, runPack };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shop-on/theme-cli",
3
- "version": "0.1.0",
4
- "description": "The Shop-On theme developer CLI: `shop-on login`, `shop-on theme init`, `shop-on theme dev` and `shop-on theme audit`.",
3
+ "version": "0.2.0",
4
+ "description": "The Shop-On theme developer CLI: `shop-on login`, `shop-on theme init`, `shop-on theme dev`, `shop-on theme audit` and `shop-on theme pack`.",
5
5
  "license": "SEE LICENSE IN dist/LICENSE.md",
6
6
  "bin": {
7
7
  "shop-on": "./dist/index.mjs"
@@ -13,21 +13,22 @@
13
13
  "dependencies": {
14
14
  "chrome-launcher": "^1.2.2",
15
15
  "lighthouse": "^13.5.0",
16
- "wrangler": "4.145.0",
17
- "@shop-on/create-theme": "0.1.0",
18
- "@shop-on/theme-dev-host": "0.1.0"
16
+ "tar": "^7.5.22",
17
+ "wrangler": "4.151.0",
18
+ "@shop-on/create-theme": "0.2.0",
19
+ "@shop-on/theme-dev-host": "0.2.0"
19
20
  },
20
21
  "devDependencies": {
21
- "@types/node": "26.6.3",
22
+ "@types/node": "26.6.5",
22
23
  "tsx": "^4.23.15",
23
24
  "typescript": "7.0.2",
24
- "vitest": "5.0.3"
25
+ "vitest": "5.0.5"
25
26
  },
26
27
  "engines": {
27
28
  "node": "^20.19.0 || >=22.12.0"
28
29
  },
29
30
  "scripts": {
30
31
  "check-types": "tsc --noEmit",
31
- "test": "vitest run"
32
+ "test": "vitest run --maxWorkers=2"
32
33
  }
33
34
  }