@spindlecommerce/storefront-cli 0.0.0-stage → 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The SpindleCommerce authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,17 @@
1
- # Temporary Holding Version
1
+ # @spindlecommerce/storefront-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Page builder components defined in a [SpindleCommerce](https://spindlecommerce.com) storefront's code: deploy them to the platform, and generate TypeScript types for their content.
4
+
5
+ ```bash
6
+ pnpm add -D @spindlecommerce/storefront-cli
7
+
8
+ spindle-storefront components pull --out components.json # export the platform's library
9
+ spindle-storefront components push components.json [--prune] # deploy definitions
10
+ spindle-storefront components types --from components.json --out lib/builder-components.ts
11
+ ```
12
+
13
+ `push` updates components by code and marks them as managed in code; the admin warns before anyone edits those. `--prune` deletes code-managed components missing from the file, and is refused while pages use them. The whole push is one transaction.
14
+
15
+ The commands call the Admin API with an admin API key: `content:read` to pull, `content:write` to push. Pass `--url` and `--key`, or set `SPINDLE_API_URL` and `SPINDLE_API_KEY`. `--env-file .env.local` also reads them from that file, if it exists.
16
+
17
+ `generateTypes(definitions)` is also exported, for build scripts.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { parseArgs } from "node:util";
5
+ import { generateTypes } from "./generate.js";
6
+ const usage = `Usage:
7
+ spindle-storefront components pull [--out components.json]
8
+ spindle-storefront components push [components.json] [--prune]
9
+ spindle-storefront components types [--from components.json] [--out builder-components.ts]
10
+ Options:
11
+ --url <url> The platform (SPINDLE_API_URL), by default http://localhost:4000
12
+ --key <key> An admin API key (SPINDLE_API_KEY): content:read to pull, content:write to push
13
+ --env-file <path> Also read those variables from this file, if it exists`;
14
+ const { values, positionals } = parseArgs({
15
+ allowPositionals: true,
16
+ options: {
17
+ url: { type: "string" },
18
+ key: { type: "string" },
19
+ out: { type: "string" },
20
+ from: { type: "string" },
21
+ prune: { type: "boolean", default: false },
22
+ "env-file": { type: "string" },
23
+ help: { type: "boolean", short: "h", default: false },
24
+ },
25
+ });
26
+ const envFile = values["env-file"];
27
+ if (envFile && existsSync(envFile))
28
+ process.loadEnvFile(envFile);
29
+ const url = (values.url ?? process.env.SPINDLE_API_URL ?? "http://localhost:4000").replace(/\/+$/, "");
30
+ const key = values.key ?? process.env.SPINDLE_API_KEY;
31
+ const [group, command, file] = positionals;
32
+ const admin = async (query, variables = {}) => {
33
+ if (!key)
34
+ throw new Error("Pass an admin API key with --key or SPINDLE_API_KEY (System › API keys in the admin).");
35
+ const response = await fetch(`${url}/admin/graphql`, {
36
+ method: "POST",
37
+ headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
38
+ body: JSON.stringify({ query, variables }),
39
+ });
40
+ const body = (await response.json().catch(() => null));
41
+ if (!response.ok || !body || body.errors?.length)
42
+ throw new Error(`The API refused the request: ${body?.errors?.map((e) => e.message).join("; ") ?? `HTTP ${response.status}`}`);
43
+ return body.data;
44
+ };
45
+ const pullDefinitions = async () => (await admin(`{ components { definition } }`)).components.map((c) => c.definition);
46
+ const readDefinitions = async (path) => {
47
+ const parsed = JSON.parse(await readFile(path, "utf8"));
48
+ const list = Array.isArray(parsed) ? parsed : parsed.components;
49
+ if (!Array.isArray(list))
50
+ throw new Error(`${path} should hold an array of component definitions (or { "components": [...] })`);
51
+ return list;
52
+ };
53
+ const run = async () => {
54
+ if (values.help || !group)
55
+ return console.log(usage);
56
+ if (group !== "components")
57
+ throw new Error(`Unknown command ${group}.\n${usage}`);
58
+ if (command === "pull") {
59
+ const definitions = await pullDefinitions();
60
+ const json = `${JSON.stringify(definitions, null, 2)}\n`;
61
+ if (values.out) {
62
+ await writeFile(values.out, json);
63
+ console.log(`Wrote ${definitions.length} definitions to ${values.out}.`);
64
+ }
65
+ else
66
+ process.stdout.write(json);
67
+ return;
68
+ }
69
+ if (command === "push") {
70
+ const path = file ?? "components.json";
71
+ const definitions = await readDefinitions(path);
72
+ const { componentsSync: result } = await admin(`mutation($d: JSON!, $p: Boolean) { componentsSync(definitions: $d, prune: $p) { __typename ... on ComponentsSyncPayload { created updated unchanged removed } ... on Error { message } ... on ValidationError { fieldErrors { path message } } } }`, { d: definitions, p: values.prune });
73
+ if (result.__typename !== "ComponentsSyncPayload") {
74
+ const details = result.fieldErrors?.map((f) => ` ${f.path.join(".")}: ${f.message}`).join("\n");
75
+ throw new Error(`${result.message ?? result.__typename}${details ? `\n${details}` : ""}`);
76
+ }
77
+ const count = (codes) => codes?.length ?? 0;
78
+ console.log(`Deployed ${definitions.length} definitions from ${path}: ${count(result.created)} created, ${count(result.updated)} updated, ${count(result.unchanged)} unchanged, ${count(result.removed)} removed.`);
79
+ for (const [label, codes] of [["created", result.created], ["updated", result.updated], ["removed", result.removed]])
80
+ if (codes?.length)
81
+ console.log(` ${label}: ${codes.join(", ")}`);
82
+ return;
83
+ }
84
+ if (command === "types") {
85
+ const definitions = values.from ? await readDefinitions(values.from) : await pullDefinitions();
86
+ const source = generateTypes(definitions);
87
+ if (values.out) {
88
+ await writeFile(values.out, source);
89
+ console.log(`Wrote types for ${definitions.length} components to ${values.out}.`);
90
+ }
91
+ else
92
+ process.stdout.write(source);
93
+ return;
94
+ }
95
+ throw new Error(`Unknown command components ${command ?? ""}.\n${usage}`);
96
+ };
97
+ run().catch((error) => {
98
+ console.error(error instanceof Error ? error.message : error);
99
+ process.exit(1);
100
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Component definitions as the admin API takes and returns them (componentsSync, components). The
3
+ * platform's own model must stay assignable to these: test/unit/page-builder.test.ts checks it.
4
+ */
5
+ export type FieldType = "text" | "richText" | "number" | "boolean" | "select" | "color" | "image" | "link" | "date" | "product" | "products" | "category" | "blocks" | "list";
6
+ export type TemplateFor = "product" | "category";
7
+ export type FieldOption = {
8
+ value: string;
9
+ label: string;
10
+ };
11
+ export type FieldDefinition = {
12
+ key: string;
13
+ type: FieldType;
14
+ label: string;
15
+ description?: string;
16
+ required?: boolean;
17
+ /** Text and rich text only: one value per locale. */
18
+ localized?: boolean;
19
+ /** Inspector tab, e.g. "Content", "Style". */
20
+ group?: string;
21
+ /** Only shown for these variations; all when absent. */
22
+ variations?: string[];
23
+ placeholder?: string;
24
+ multiline?: boolean;
25
+ options?: FieldOption[];
26
+ /** Numbers: value range. Text: length. blocks, list, products: item count. */
27
+ min?: number;
28
+ max?: number;
29
+ /** blocks only: component codes allowed here; empty allows every component. */
30
+ allowedComponents?: string[];
31
+ /** list only: the fields of each item. */
32
+ fields?: FieldDefinition[];
33
+ defaultValue?: unknown;
34
+ };
35
+ export type Variation = {
36
+ code: string;
37
+ name: string;
38
+ description?: string;
39
+ previewImageUrl?: string;
40
+ };
41
+ export type ComponentDefinition = {
42
+ code: string;
43
+ kind: "content_type" | "component";
44
+ name: string;
45
+ description?: string | null;
46
+ groupName?: string | null;
47
+ icon?: string | null;
48
+ previewImageUrl?: string | null;
49
+ fields: FieldDefinition[];
50
+ variations: Variation[];
51
+ routable?: boolean;
52
+ singleton?: boolean;
53
+ /** A content type with it is a product or category template; a component with it shows that object's data. */
54
+ templateFor?: TemplateFor | null;
55
+ /** Content types only: pages of this type are one product's or category's own sections. */
56
+ sectionsFor?: TemplateFor | null;
57
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Component definitions as the admin API takes and returns them (componentsSync, components). The
3
+ * platform's own model must stay assignable to these: test/unit/page-builder.test.ts checks it.
4
+ */
5
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { ComponentDefinition } from "./definitions.ts";
2
+ /** Types for content as the Storefront API delivers it: one locale, references as global ids. */
3
+ export declare const generateTypes: (definitions: ComponentDefinition[]) => string;
@@ -0,0 +1,89 @@
1
+ const pascal = (code) => code
2
+ .split(/[_-]/)
3
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
4
+ .join("");
5
+ const quote = (value) => JSON.stringify(value);
6
+ const fieldType = (field, nodeType, all) => {
7
+ switch (field.type) {
8
+ case "text":
9
+ case "richText":
10
+ case "color":
11
+ case "date":
12
+ return "string | null";
13
+ case "number":
14
+ return "number | null";
15
+ case "boolean":
16
+ return "boolean";
17
+ case "select":
18
+ return `${(field.options ?? []).map((o) => quote(o.value)).join(" | ") || "string"} | null`;
19
+ case "image":
20
+ return "BuilderImage | null";
21
+ case "link":
22
+ return "BuilderLink | null";
23
+ case "product":
24
+ case "category":
25
+ return "string | null";
26
+ case "products":
27
+ return "string[]";
28
+ case "blocks": {
29
+ const allowed = field.allowedComponents?.length ? field.allowedComponents : all.filter((c) => c.kind === "component").map((c) => c.code);
30
+ return `Array<${allowed.map(nodeType).join(" | ") || "never"}>`;
31
+ }
32
+ case "list":
33
+ return `Array<BuilderItem<{ ${(field.fields ?? []).map((f) => `${f.key}: ${fieldType(f, nodeType, all)}`).join("; ")} }>>`;
34
+ }
35
+ };
36
+ /** Types for content as the Storefront API delivers it: one locale, references as global ids. */
37
+ export const generateTypes = (definitions) => {
38
+ const nodeType = (code) => `${pascal(code)}Node`;
39
+ const lines = [
40
+ "// Generated by `spindle-storefront components types`; regenerate after changing components.",
41
+ "// Content as the Storefront API delivers it: text in the request locale, links with hrefs,",
42
+ "// products and categories as global ids (resolve them through Page.references).",
43
+ "",
44
+ "export type BuilderImage = { url: string; alt: string | null; width: number | null; height: number | null };",
45
+ 'export type BuilderLink = { kind: "url" | "page" | "product" | "category"; href: string | null; id: string | null; newTab: boolean };',
46
+ "export type BuilderItem<F> = { id: string; fields: F };",
47
+ "export type BuilderNode<C extends string, F, V extends string | null = null> = { id: string; component: C; variation: V; fields: F };",
48
+ "",
49
+ ];
50
+ for (const def of [...definitions].sort((a, b) => a.code.localeCompare(b.code))) {
51
+ const variations = def.variations.length ? def.variations.map((v) => quote(v.code)).join(" | ") : "null";
52
+ const binding = def.templateFor
53
+ ? def.kind === "content_type"
54
+ ? ` A template for ${def.templateFor} pages.`
55
+ : ` Shows ${def.templateFor} data, so only in ${def.templateFor} templates and sections.`
56
+ : def.sectionsFor
57
+ ? ` Belongs to one ${def.sectionsFor}: render it where the ${def.sectionsFor}'s template places its sections.`
58
+ : "";
59
+ lines.push(`/** ${def.name}${def.description ? `: ${def.description}` : ""}${binding} */`);
60
+ if (def.fields.length) {
61
+ lines.push(`export type ${pascal(def.code)}Fields = {`);
62
+ for (const field of def.fields) {
63
+ const optional = field.variations?.length ? "?" : "";
64
+ const doc = [field.label, field.description, field.variations?.length ? `Only for ${field.variations.join(", ")}.` : ""].filter(Boolean).join(". ");
65
+ lines.push(` /** ${doc} */`);
66
+ lines.push(` ${field.key}${optional}: ${fieldType(field, nodeType, definitions)};`);
67
+ }
68
+ lines.push("};");
69
+ }
70
+ else
71
+ lines.push(`export type ${pascal(def.code)}Fields = Record<string, never>;`);
72
+ lines.push(`export type ${nodeType(def.code)} = BuilderNode<${quote(def.code)}, ${pascal(def.code)}Fields, ${variations}>;`, "");
73
+ }
74
+ const components = definitions.filter((d) => d.kind === "component").map((d) => nodeType(d.code));
75
+ const contentTypes = definitions.filter((d) => d.kind === "content_type").map((d) => nodeType(d.code));
76
+ lines.push(`/** Any component that can appear inside a blocks field. */`);
77
+ lines.push(`export type BuilderComponentNode = ${components.join(" | ") || "never"};`);
78
+ lines.push(`/** The root of a page, by content type. */`);
79
+ lines.push(`export type BuilderContentTypeNode = ${contentTypes.join(" | ") || "never"};`);
80
+ lines.push(`export type BuilderComponentCode = BuilderComponentNode["component"];`);
81
+ lines.push(`export type BuilderContentTypeCode = BuilderContentTypeNode["component"];`, "");
82
+ for (const kind of ["product", "category"]) {
83
+ const codes = definitions.filter((d) => d.kind === "component" && d.templateFor === kind).map((d) => quote(d.code));
84
+ lines.push(`/** Components that show ${kind} data: they render inside ${kind} templates and a ${kind}'s own sections, with the ${kind}. */`);
85
+ lines.push(`export type Builder${pascal(kind)}ComponentCode = ${codes.join(" | ") || "never"};`);
86
+ }
87
+ lines.push("");
88
+ return lines.join("\n");
89
+ };
@@ -0,0 +1,2 @@
1
+ export type { ComponentDefinition, FieldDefinition, FieldOption, FieldType, TemplateFor, Variation } from "./definitions.ts";
2
+ export { generateTypes } from "./generate.ts";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { generateTypes } from "./generate.js";
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@spindlecommerce/storefront-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Deploy a SpindleCommerce storefront's page-builder components from its code, and generate TypeScript types for their content.",
5
+ "keywords": [
6
+ "spindlecommerce",
7
+ "storefront"
8
+ ],
9
+ "homepage": "https://github.com/spindlecommerce/storefront-sdk/tree/main/packages/storefront-cli#readme",
10
+ "bugs": "https://github.com/spindlecommerce/storefront-sdk/issues",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/spindlecommerce/storefront-sdk.git",
14
+ "directory": "packages/storefront-cli"
15
+ },
16
+ "license": "MIT",
17
+ "type": "module",
18
+ "sideEffects": false,
19
+ "bin": {
20
+ "spindle-storefront": "./dist/cli.js"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ }
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "engines": {
35
+ "node": ">=20.12"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "26.6.5"
39
+ },
40
+ "scripts": {
41
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
42
+ "typecheck": "tsc -p tsconfig.json"
43
+ }
6
44
  }