@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 +21 -0
- package/README.md +16 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +100 -0
- package/dist/definitions.d.ts +57 -0
- package/dist/definitions.js +5 -0
- package/dist/generate.d.ts +3 -0
- package/dist/generate.js +89 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +41 -3
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
|
-
#
|
|
1
|
+
# @spindlecommerce/storefront-cli
|
|
2
2
|
|
|
3
|
-
|
|
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
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
|
+
};
|
package/dist/generate.js
ADDED
|
@@ -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
|
+
};
|
package/dist/index.d.ts
ADDED
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|