@cerkel/cli 0.0.0-stage → 1.0.1
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/README.md +502 -2
- package/dist/api-url.js +42 -0
- package/dist/api.js +48 -0
- package/dist/bin.js +157 -0
- package/dist/chunk.js +31 -0
- package/dist/cli.js +497 -0
- package/dist/client.js +125 -0
- package/dist/command.js +2 -0
- package/dist/commands/args.js +107 -0
- package/dist/commands/auth.js +255 -0
- package/dist/commands/call.js +67 -0
- package/dist/commands/collection.js +121 -0
- package/dist/commands/content.js +281 -0
- package/dist/commands/customer.js +126 -0
- package/dist/commands/dev.js +261 -0
- package/dist/commands/draft.js +101 -0
- package/dist/commands/file.js +53 -0
- package/dist/commands/index.js +43 -0
- package/dist/commands/inventory.js +103 -0
- package/dist/commands/offer.js +92 -0
- package/dist/commands/order-args.js +37 -0
- package/dist/commands/order.js +219 -0
- package/dist/commands/price.js +55 -0
- package/dist/commands/product.js +265 -0
- package/dist/commands/promo.js +140 -0
- package/dist/commands/redirect.js +94 -0
- package/dist/commands/report.js +93 -0
- package/dist/commands/section.js +233 -0
- package/dist/commands/store.js +23 -0
- package/dist/commands/theme-tools.js +432 -0
- package/dist/commands/theme.js +388 -0
- package/dist/commands/whatsapp.js +39 -0
- package/dist/commands/zone.js +90 -0
- package/dist/commerce.js +138 -0
- package/dist/content-files.js +246 -0
- package/dist/context.js +5 -0
- package/dist/credentials.js +201 -0
- package/dist/diff.js +146 -0
- package/dist/env.js +24 -0
- package/dist/errors.js +73 -0
- package/dist/format.js +13 -0
- package/dist/layout.js +122 -0
- package/dist/logins.js +49 -0
- package/dist/raizn-alias.js +6 -0
- package/dist/redact.js +27 -0
- package/dist/session.js +266 -0
- package/dist/state.js +153 -0
- package/dist/sync.js +121 -0
- package/dist/zip.js +88 -0
- package/package.json +37 -3
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { apiOrigin } from "../api-url.js";
|
|
4
|
+
import { createDraft, getThemeFiles, listThemes, nameOf, putThemeFiles } from "../api.js";
|
|
5
|
+
import { chunkFiles } from "../chunk.js";
|
|
6
|
+
import { CliError } from "../errors.js";
|
|
7
|
+
import { plural, table } from "../format.js";
|
|
8
|
+
import { bytesOf, hashBytes, isLayoutPath, textOf, toDiskPath } from "../layout.js";
|
|
9
|
+
import { findRoot, listLocalFiles, localChanges, readLocal, readState, writeState } from "../state.js";
|
|
10
|
+
import { createZip } from "../zip.js";
|
|
11
|
+
async function target(s, opts) {
|
|
12
|
+
const co = await s.checkout();
|
|
13
|
+
const store = opts.store ?? co?.state.store ?? (await s.defaultStore());
|
|
14
|
+
const client = await s.client(co?.state, store);
|
|
15
|
+
const fromFolder = co && co.state.store === store ? co.state.themeId : undefined;
|
|
16
|
+
return { client, store, themeId: opts.theme ?? fromFolder, checkout: co };
|
|
17
|
+
}
|
|
18
|
+
/** A theme write needs a named theme: `--theme`, or this folder's. Never the live theme by default. */
|
|
19
|
+
function needTheme(t, verb) {
|
|
20
|
+
if (t.themeId)
|
|
21
|
+
return t.themeId;
|
|
22
|
+
throw new CliError(`Which theme ${verb}? Pass --theme <id> (\`cerkel theme list\` shows them), or run this in a theme folder.`);
|
|
23
|
+
}
|
|
24
|
+
const json = (s, data) => s.out(JSON.stringify(data, null, 2));
|
|
25
|
+
const kb = (bytes) => (bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`);
|
|
26
|
+
const getInfo = (t) => t.client.call("get_theme_info", { store: t.store, ...(t.themeId ? { themeId: t.themeId } : {}) });
|
|
27
|
+
export async function themeInfo(s, opts) {
|
|
28
|
+
const t = await target(s, opts);
|
|
29
|
+
const d = await getInfo(t);
|
|
30
|
+
if (opts.json) {
|
|
31
|
+
json(s, { ...d, editorUrl: `${apiOrigin(t.client.url)}${d.editorPath}`, codeUrl: `${apiOrigin(t.client.url)}${d.codePath}` });
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
const files = Object.entries(d.files).sort(([a], [b]) => a.localeCompare(b)).map(([k, n]) => `${k} ${n}`).join(", ");
|
|
35
|
+
for (const line of table([
|
|
36
|
+
["Name", d.name],
|
|
37
|
+
["ID", d.id],
|
|
38
|
+
["Role", d.role === "live" ? "live (what customers see)" : "draft"],
|
|
39
|
+
["Store", t.store],
|
|
40
|
+
["Preset", d.preset],
|
|
41
|
+
["Updated", d.updatedAt],
|
|
42
|
+
["Created by", d.createdBy ?? "—"],
|
|
43
|
+
["Published", d.publishedAt ? `${d.publishedAt}${d.publishedBy ? ` by ${d.publishedBy}` : ""}` : "—"],
|
|
44
|
+
["Templates", d.templates.join(", ") || "—"],
|
|
45
|
+
["Files", files ? `${files} (${kb(d.bytes)})` : "—"],
|
|
46
|
+
["In review", d.review ? `${d.review.status}: "${d.review.title}" (${apiOrigin(t.client.url)}/themes/requests/${d.review.requestId})` : "—"],
|
|
47
|
+
["Editor", `${apiOrigin(t.client.url)}${d.editorPath}`],
|
|
48
|
+
["Code", `${apiOrigin(t.client.url)}${d.codePath}`],
|
|
49
|
+
["Preview host", d.previewHost ?? "— (no Cerkel-served domain yet)"],
|
|
50
|
+
]))
|
|
51
|
+
s.out(line);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
export async function themeOpen(s, opts) {
|
|
55
|
+
const t = await target(s, opts);
|
|
56
|
+
const d = await getInfo(t);
|
|
57
|
+
const url = `${apiOrigin(t.client.url)}${opts.code ? d.codePath : d.editorPath}`;
|
|
58
|
+
s.out(url);
|
|
59
|
+
if (d.role === "live")
|
|
60
|
+
s.err("This is the live theme: the editor opens it read-only. Edit a draft (`cerkel theme pull` makes one).");
|
|
61
|
+
if (!opts.print)
|
|
62
|
+
await s.ctx.openUrl(url);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
// --- rename / delete -------------------------------------------------------------------------------
|
|
66
|
+
export async function themeRename(s, name, opts) {
|
|
67
|
+
const clean = name?.trim();
|
|
68
|
+
if (!clean)
|
|
69
|
+
throw new CliError('What name? e.g. `cerkel theme rename "Ramadan 2027"`.');
|
|
70
|
+
const t = await target(s, opts);
|
|
71
|
+
const themeId = needTheme(t, "to rename");
|
|
72
|
+
const r = await t.client.call("rename_theme", { store: t.store, themeId, name: clean });
|
|
73
|
+
// This folder tracks that theme: keep its recorded name in step.
|
|
74
|
+
if (t.checkout && t.checkout.state.themeId === r.id && t.checkout.state.themeName !== r.name) {
|
|
75
|
+
await writeState(t.checkout.root, { ...t.checkout.state, themeName: r.name });
|
|
76
|
+
}
|
|
77
|
+
if (opts.json)
|
|
78
|
+
json(s, r);
|
|
79
|
+
else
|
|
80
|
+
s.out(r.before === r.name ? `"${r.name}" already has that name.` : `Renamed "${r.before}" → "${r.name}" (${r.role}, ${r.id}).`);
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
export async function themeDeleteTheme(s, opts) {
|
|
84
|
+
if (!opts.theme)
|
|
85
|
+
throw new CliError("Which draft? Pass --theme <id> (`cerkel theme list` shows them). To delete files from this folder's draft, name them: `cerkel theme delete snippets/old.liquid`.");
|
|
86
|
+
const t = await target(s, opts);
|
|
87
|
+
const d = await getInfo(t);
|
|
88
|
+
if (d.role === "live")
|
|
89
|
+
throw new CliError(`"${d.name}" is the LIVE theme — what customers see. It can never be deleted. Only drafts can.`);
|
|
90
|
+
if (!opts.yes) {
|
|
91
|
+
if (!s.ctx.io.isTTY)
|
|
92
|
+
throw new CliError(`Deleting "${d.name}" cannot be undone, and there is no terminal to confirm in. Re-run with --yes.`);
|
|
93
|
+
s.err(`This deletes the draft "${d.name}" (${d.id}) on ${t.store}: its templates, settings and files (their history stays). Every shared preview link to it stops working. It cannot be undone.`);
|
|
94
|
+
const typed = (await s.ctx.io.prompt(`Type the theme's name to delete it: `)).trim();
|
|
95
|
+
if (typed !== d.name.trim())
|
|
96
|
+
throw new CliError("The name did not match; nothing was deleted.");
|
|
97
|
+
}
|
|
98
|
+
const r = await t.client.call("delete_theme", { store: t.store, themeId: d.id });
|
|
99
|
+
if (opts.json)
|
|
100
|
+
json(s, r);
|
|
101
|
+
else
|
|
102
|
+
s.out(`Deleted the draft "${r.name}" (${r.id}).`);
|
|
103
|
+
if (t.checkout && t.checkout.state.themeId === r.id) {
|
|
104
|
+
s.err(`${t.checkout.root} was a checkout of that draft. Run \`cerkel theme pull\` there to work on another draft (your local files are kept).`);
|
|
105
|
+
}
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
// --- share -----------------------------------------------------------------------------------------
|
|
109
|
+
export async function themeShare(s, opts) {
|
|
110
|
+
const days = opts.days === undefined ? undefined : Number(opts.days);
|
|
111
|
+
if (days !== undefined && (!Number.isInteger(days) || days < 1 || days > 14))
|
|
112
|
+
throw new CliError("--days is a whole number from 1 to 14.");
|
|
113
|
+
const t = await target(s, opts);
|
|
114
|
+
const themeId = needTheme(t, "to share");
|
|
115
|
+
if (t.checkout && t.checkout.state.themeId === themeId) {
|
|
116
|
+
const pending = (await localChanges(t.checkout.root, t.checkout.state)).changes.length;
|
|
117
|
+
if (pending > 0)
|
|
118
|
+
s.err(`Note: ${plural(pending, "local change")} not pushed yet; the link shows the draft as it is on the server (\`cerkel theme push\` first).`);
|
|
119
|
+
}
|
|
120
|
+
const r = await t.client.call("share_theme", {
|
|
121
|
+
store: t.store, themeId, ...(days !== undefined ? { days } : {}), ...(opts.path ? { path: opts.path } : {}),
|
|
122
|
+
});
|
|
123
|
+
if (opts.json) {
|
|
124
|
+
json(s, r);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
s.out(r.url);
|
|
128
|
+
s.err(`Preview of "${r.theme.name}" — anyone with this link sees the draft until ${r.expiresAt} (${plural(r.days, "day")}). Deleting the draft revokes it.`);
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
/** `product.title` → `{{ product.title }}`; anything that is already Liquid markup is sent as typed. */
|
|
132
|
+
export const asLiquid = (input) => (/\{\{|\{%/.test(input) ? input : `{{ ${input} }}`);
|
|
133
|
+
const CONSOLE_HELP = [
|
|
134
|
+
"Type Liquid and press Enter: `product.title`, `{{ collections | size }}`, `{% for p in all_products %}{{ p.title }} {% endfor %}`.",
|
|
135
|
+
"A bare expression is wrapped in {{ }}. Commands: .path /products/<handle> (the page whose data you see), .locale en, .help, .exit (or Ctrl-D).",
|
|
136
|
+
];
|
|
137
|
+
export async function themeConsole(s, opts) {
|
|
138
|
+
const t = await target(s, opts);
|
|
139
|
+
let pagePath = opts.path ?? "/";
|
|
140
|
+
let locale = opts.locale;
|
|
141
|
+
const evaluate = async (liquid) => t.client.call("evaluate_liquid", {
|
|
142
|
+
store: t.store, liquid, path: pagePath, ...(t.themeId ? { themeId: t.themeId } : {}), ...(locale ? { locale } : {}),
|
|
143
|
+
});
|
|
144
|
+
const show = (r) => {
|
|
145
|
+
if (opts.json) {
|
|
146
|
+
json(s, r);
|
|
147
|
+
return r.liquidError === null;
|
|
148
|
+
}
|
|
149
|
+
if (r.liquidError) {
|
|
150
|
+
s.err(`Liquid error: ${r.liquidError}`);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
s.out(r.output === "" ? "(empty)" : r.output);
|
|
154
|
+
return true;
|
|
155
|
+
};
|
|
156
|
+
// One shot: `-e/--eval`, or Liquid piped in on stdin.
|
|
157
|
+
if (opts.eval !== undefined || !s.ctx.io.isTTY) {
|
|
158
|
+
const liquid = opts.eval ?? (await s.ctx.io.readStdin());
|
|
159
|
+
if (!liquid.trim())
|
|
160
|
+
throw new CliError('Nothing to evaluate. Pass -e "{{ product.title }}", pipe Liquid in, or run it in a terminal for the interactive console.');
|
|
161
|
+
return show(await evaluate(opts.eval !== undefined ? asLiquid(liquid) : liquid)) ? 0 : 1;
|
|
162
|
+
}
|
|
163
|
+
// The REPL.
|
|
164
|
+
const first = await evaluate("");
|
|
165
|
+
s.err(`Liquid console — "${first.theme.name}" (${first.theme.role}) on ${t.store}, page ${first.page.path} (${first.page.template}${first.page.found ? "" : ", not found"}), language ${first.locale}.`);
|
|
166
|
+
for (const line of CONSOLE_HELP)
|
|
167
|
+
s.err(line);
|
|
168
|
+
try {
|
|
169
|
+
for (;;) {
|
|
170
|
+
const input = await s.ctx.io.readLine(`${pagePath}> `);
|
|
171
|
+
if (input === null)
|
|
172
|
+
break;
|
|
173
|
+
const line = input.trim();
|
|
174
|
+
if (!line)
|
|
175
|
+
continue;
|
|
176
|
+
if (line === ".exit" || line === "exit" || line === ".quit")
|
|
177
|
+
break;
|
|
178
|
+
if (line === ".help") {
|
|
179
|
+
for (const l of CONSOLE_HELP)
|
|
180
|
+
s.err(l);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (line.startsWith(".path")) {
|
|
184
|
+
const next = line.slice(5).trim() || "/";
|
|
185
|
+
const was = pagePath;
|
|
186
|
+
pagePath = next;
|
|
187
|
+
const check = await evaluate("").catch((e) => {
|
|
188
|
+
s.err(e instanceof Error ? e.message : String(e));
|
|
189
|
+
return null;
|
|
190
|
+
});
|
|
191
|
+
if (check)
|
|
192
|
+
s.err(`Page ${check.page.path} (${check.page.template}${check.page.found ? "" : ", not found — that page's objects are empty"}).`);
|
|
193
|
+
else
|
|
194
|
+
pagePath = was;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (line.startsWith(".locale")) {
|
|
198
|
+
const next = line.slice(7).trim();
|
|
199
|
+
if (!next) {
|
|
200
|
+
s.err("Which language? e.g. .locale en");
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const was = locale;
|
|
204
|
+
locale = next;
|
|
205
|
+
const check = await evaluate("").catch((e) => {
|
|
206
|
+
s.err(e instanceof Error ? e.message : String(e));
|
|
207
|
+
return null;
|
|
208
|
+
});
|
|
209
|
+
if (check)
|
|
210
|
+
s.err(`Language ${check.locale}.`);
|
|
211
|
+
else
|
|
212
|
+
locale = was;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
show(await evaluate(asLiquid(line)));
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
// A refusal (a typo'd path, a dropped connection) never ends the session.
|
|
220
|
+
s.err(e instanceof Error ? e.message : String(e));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
s.ctx.io.closeLines();
|
|
226
|
+
}
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
const SOURCE = { file: "theme file", compiled: "built-in", legacy: "custom section", none: "nothing" };
|
|
230
|
+
export async function themeProfile(s, opts) {
|
|
231
|
+
const runs = opts.runs === undefined ? undefined : Number(opts.runs);
|
|
232
|
+
if (runs !== undefined && (!Number.isInteger(runs) || runs < 1 || runs > 5))
|
|
233
|
+
throw new CliError("--runs is a whole number from 1 to 5.");
|
|
234
|
+
const t = await target(s, opts);
|
|
235
|
+
const p = await t.client.call("profile_theme", {
|
|
236
|
+
store: t.store, ...(t.themeId ? { themeId: t.themeId } : {}), ...(opts.path ? { path: opts.path } : {}), ...(opts.locale ? { locale: opts.locale } : {}), ...(runs ? { runs } : {}),
|
|
237
|
+
});
|
|
238
|
+
if (opts.json) {
|
|
239
|
+
json(s, p);
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
const median = [...p.pageMs].sort((a, b) => a - b)[Math.floor(p.pageMs.length / 2)] ?? 0;
|
|
243
|
+
s.out(`"${p.theme.name}" (${p.theme.role}) — ${p.path} in ${p.locale}, HTTP ${p.status}, ${plural(p.pageMs.length, "run")}${p.pageMs.length > 1 ? " (medians)" : ""}`);
|
|
244
|
+
const slowest = Math.max(...p.sections.map((x) => x.ms), 0);
|
|
245
|
+
for (const line of table([
|
|
246
|
+
["#", "SECTION", "TYPE", "WHERE", "RENDERED BY", "MS"],
|
|
247
|
+
...p.sections.map((x, n) => [
|
|
248
|
+
String(n + 1), x.id, x.type, x.template ?? "—",
|
|
249
|
+
`${SOURCE[x.source] ?? x.source}${x.client ? " (browser)" : ""}`,
|
|
250
|
+
`${x.ms.toFixed(1)}${x.ms === slowest && slowest > 0 ? " ← slowest" : ""}${x.error ? " ERROR" : ""}`,
|
|
251
|
+
]),
|
|
252
|
+
]))
|
|
253
|
+
s.out(line);
|
|
254
|
+
s.out(`Sections ${p.sectionsMs.toFixed(1)} ms · whole page ${median.toFixed(1)} ms (request to last byte, from the Cerkel server).`);
|
|
255
|
+
for (const x of p.sections.filter((y) => y.error))
|
|
256
|
+
s.err(`${x.id} (${x.type}): ${x.error}`);
|
|
257
|
+
if (p.sections.some((x) => x.client))
|
|
258
|
+
s.err("(browser) = a built-in section drawn in the shopper's browser: its server time is only its setup.");
|
|
259
|
+
return p.sections.some((x) => x.error) ? 1 : 0;
|
|
260
|
+
}
|
|
261
|
+
// --- package ---------------------------------------------------------------------------------------
|
|
262
|
+
const slug = (v) => v.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
263
|
+
export async function themePackage(s, opts) {
|
|
264
|
+
const root = (await findRoot(s.ctx.cwd)) ?? s.ctx.cwd;
|
|
265
|
+
const state = await readState(root);
|
|
266
|
+
const paths = await listLocalFiles(root);
|
|
267
|
+
if (paths.length === 0)
|
|
268
|
+
throw new CliError(`No theme files in ${root}. Run this in a theme folder (\`cerkel theme pull\` or \`cerkel theme init\`).`);
|
|
269
|
+
const base = (state ? slug(`${state.store}-${state.themeName}`) : slug(path.basename(root))) || "theme";
|
|
270
|
+
const out = path.resolve(s.ctx.cwd, opts.output ?? `${base}.zip`);
|
|
271
|
+
if (!out.toLowerCase().endsWith(".zip"))
|
|
272
|
+
throw new CliError("--output must end in .zip.");
|
|
273
|
+
try {
|
|
274
|
+
await fs.access(out);
|
|
275
|
+
if (!opts.force)
|
|
276
|
+
throw new CliError(`${out} already exists. Pass --force to replace it, or --output <file>.zip.`);
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
if (e instanceof CliError)
|
|
280
|
+
throw e;
|
|
281
|
+
}
|
|
282
|
+
const entries = [];
|
|
283
|
+
let bytes = 0;
|
|
284
|
+
for (const p of paths) {
|
|
285
|
+
const b = await readLocal(root, p);
|
|
286
|
+
if (!b)
|
|
287
|
+
continue;
|
|
288
|
+
entries.push({ name: p, data: b });
|
|
289
|
+
bytes += b.length;
|
|
290
|
+
}
|
|
291
|
+
const zip = createZip(entries, s.ctx.now());
|
|
292
|
+
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
293
|
+
await fs.writeFile(out, zip);
|
|
294
|
+
if (opts.json) {
|
|
295
|
+
json(s, { file: out, files: entries.map((e) => e.name), bytes: zip.length });
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
s.out(out);
|
|
299
|
+
s.err(`Packaged ${plural(entries.length, "theme file")} (${kb(bytes)}, ${kb(zip.length)} zipped) from ${root}${state ? ` — "${state.themeName}" as it is in this folder, including changes not pushed yet` : ""}.`);
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
302
|
+
export async function themeInit(s, dir, opts) {
|
|
303
|
+
const co = await s.checkout();
|
|
304
|
+
const client = await s.client(co?.state, opts.store ?? co?.state.store ?? null);
|
|
305
|
+
const { starters } = await client.call("list_theme_starters", {});
|
|
306
|
+
if (opts.list) {
|
|
307
|
+
if (opts.json)
|
|
308
|
+
json(s, starters.map((x) => ({ key: x.key, name: x.name, description: x.description, files: x.files.map((f) => f.path) })));
|
|
309
|
+
else
|
|
310
|
+
for (const line of table([["STARTER", "NAME", "WHAT IT IS"], ...starters.map((x) => [x.key, nameOf(x.name), nameOf(x.description)])]))
|
|
311
|
+
s.out(line);
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
const starter = opts.starter ? starters.find((x) => x.key === opts.starter) : starters[0];
|
|
315
|
+
if (!starter)
|
|
316
|
+
throw new CliError(`No starter "${opts.starter}". \`cerkel theme init --list\` shows them.`);
|
|
317
|
+
const target = path.resolve(s.ctx.cwd, dir ?? "cerkel-theme");
|
|
318
|
+
const enclosing = await findRoot(target);
|
|
319
|
+
if (enclosing)
|
|
320
|
+
throw new CliError(`${target} is inside the theme folder ${enclosing}. Start a new theme in a folder of its own.`);
|
|
321
|
+
let existing = [];
|
|
322
|
+
try {
|
|
323
|
+
existing = (await fs.readdir(target)).filter((n) => !n.startsWith("."));
|
|
324
|
+
}
|
|
325
|
+
catch { /* a new folder */ }
|
|
326
|
+
if (existing.length > 0)
|
|
327
|
+
throw new CliError(`${target} is not empty. Choose a new folder: \`cerkel theme init <folder>\`.`);
|
|
328
|
+
for (const f of starter.files) {
|
|
329
|
+
if (!isLayoutPath(f.path))
|
|
330
|
+
throw new CliError(`The server sent a starter file this CLI cannot store (${f.path}). Update the CLI.`);
|
|
331
|
+
const disk = toDiskPath(target, f.path);
|
|
332
|
+
await fs.mkdir(path.dirname(disk), { recursive: true });
|
|
333
|
+
await fs.writeFile(disk, bytesOf(f.content));
|
|
334
|
+
}
|
|
335
|
+
await fs.writeFile(path.join(target, "README.md"), starter.readme);
|
|
336
|
+
if (opts.json) {
|
|
337
|
+
json(s, { folder: target, starter: starter.key, files: starter.files.map((f) => f.path) });
|
|
338
|
+
return 0;
|
|
339
|
+
}
|
|
340
|
+
s.out(`Created ${target} from the "${nameOf(starter.name)}" starter: ${plural(starter.files.length, "theme file")} and a README.`);
|
|
341
|
+
s.out("Next:");
|
|
342
|
+
s.out(` cd ${path.relative(s.ctx.cwd, target) || "."}`);
|
|
343
|
+
s.out(` cerkel theme push --new [--store <slug>] # a new draft (copied from the live theme) with these files`);
|
|
344
|
+
s.out(" cerkel theme dev # edit with a live preview on the store's own domain");
|
|
345
|
+
s.out(' cerkel theme submit -m "New design" # send it for approval');
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
// --- push --new ------------------------------------------------------------------------------------
|
|
349
|
+
/**
|
|
350
|
+
* `cerkel theme push --new`: this folder (an init folder, or any folder of theme files that is not a
|
|
351
|
+
* checkout) → a NEW draft copied from the live theme, with these files on top. The folder then
|
|
352
|
+
* becomes that draft's checkout: the draft's other files (the live theme's templates, settings,
|
|
353
|
+
* texts) are written next to yours, so `dev`, `push` and `submit` work as after a pull.
|
|
354
|
+
*/
|
|
355
|
+
export async function themePushNew(s, opts) {
|
|
356
|
+
const enclosing = await findRoot(s.ctx.cwd);
|
|
357
|
+
if (enclosing) {
|
|
358
|
+
throw new CliError(`${enclosing} is already the checkout of a draft. \`cerkel theme push\` sends its changes; --new is for a folder of theme files with no draft yet (e.g. after \`cerkel theme init\`).`);
|
|
359
|
+
}
|
|
360
|
+
const root = s.ctx.cwd;
|
|
361
|
+
const paths = await listLocalFiles(root);
|
|
362
|
+
if (paths.length === 0)
|
|
363
|
+
throw new CliError(`No theme files in ${root} to push. \`cerkel theme init\` creates a starter theme.`);
|
|
364
|
+
const store = opts.store ?? (await s.defaultStore());
|
|
365
|
+
const client = await s.client(null, store);
|
|
366
|
+
const live = (await listThemes(client, store)).find((t) => t.role === "live");
|
|
367
|
+
const name = opts.name?.trim() || `${path.basename(root)} (draft)`;
|
|
368
|
+
const made = await createDraft(client, store, name, live?.id);
|
|
369
|
+
s.err(`Created the draft "${made.name}" (${made.themeId}) on ${store}${live ? `, copied from the live theme "${live.name}"` : ""}.`);
|
|
370
|
+
// The new draft already holds the live theme's copy of files like templates/index.json: claim
|
|
371
|
+
// exactly that version (its tag), so the push replaces it instead of reporting a conflict.
|
|
372
|
+
const server = await getThemeFiles(client, store, made.themeId);
|
|
373
|
+
const serverTag = new Map(server.files.map((f) => [f.path, f.tag]));
|
|
374
|
+
const local = new Map();
|
|
375
|
+
for (const p of paths) {
|
|
376
|
+
const b = await readLocal(root, p);
|
|
377
|
+
if (b)
|
|
378
|
+
local.set(p, b);
|
|
379
|
+
}
|
|
380
|
+
const payload = [...local].map(([p, b]) => ({ path: p, content: textOf(b), baseTag: serverTag.get(p) ?? null }));
|
|
381
|
+
const results = new Map();
|
|
382
|
+
let saved = 0;
|
|
383
|
+
let failed = 0;
|
|
384
|
+
for (const chunk of chunkFiles(payload)) {
|
|
385
|
+
const r = await putThemeFiles(client, store, made.themeId, chunk, false);
|
|
386
|
+
for (const res of r.results ?? []) {
|
|
387
|
+
results.set(res.path, res);
|
|
388
|
+
if (res.status === "saved" || res.status === "unchanged") {
|
|
389
|
+
if (res.status === "saved")
|
|
390
|
+
saved++;
|
|
391
|
+
s.out(` ${res.status.padEnd(9)} ${res.path}`);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
failed++;
|
|
395
|
+
s.out(` ${res.status.padEnd(9)} ${res.path}${res.message ? ` — ${res.message}` : ""}`);
|
|
396
|
+
for (const issue of res.errors ?? [])
|
|
397
|
+
s.out(` ${res.path}:${issue.line ?? 1}:${issue.column ?? 1} ${issue.severity} ${issue.message}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// The folder becomes the draft's checkout.
|
|
402
|
+
const after = await getThemeFiles(client, store, made.themeId);
|
|
403
|
+
const files = {};
|
|
404
|
+
let added = 0;
|
|
405
|
+
for (const f of after.files) {
|
|
406
|
+
if (!isLayoutPath(f.path))
|
|
407
|
+
continue;
|
|
408
|
+
const mine = local.get(f.path);
|
|
409
|
+
const pushed = results.get(f.path);
|
|
410
|
+
if (mine && (pushed?.status === "saved" || pushed?.status === "unchanged")) {
|
|
411
|
+
files[f.path] = { tag: f.tag, hash: hashBytes(mine) };
|
|
412
|
+
}
|
|
413
|
+
else if (mine) {
|
|
414
|
+
// Refused: keep the local file as an unpushed change against the server's version.
|
|
415
|
+
files[f.path] = { tag: f.tag, hash: hashBytes(bytesOf(f.content)) };
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
const disk = toDiskPath(root, f.path);
|
|
419
|
+
await fs.mkdir(path.dirname(disk), { recursive: true });
|
|
420
|
+
await fs.writeFile(disk, bytesOf(f.content));
|
|
421
|
+
files[f.path] = { tag: f.tag, hash: hashBytes(bytesOf(f.content)) };
|
|
422
|
+
added++;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
await writeState(root, {
|
|
426
|
+
version: 1, api: client.url.href, store, themeId: made.themeId, themeName: made.name, role: "draft", readOnly: false,
|
|
427
|
+
pulledAt: s.ctx.now().toISOString(), files,
|
|
428
|
+
});
|
|
429
|
+
s.err(`${saved} saved, ${failed} failed. ${plural(added, "file")} of the draft written next to yours; this folder is now the checkout of "${made.name}".`);
|
|
430
|
+
s.err(failed > 0 ? "Fix the files above and run `cerkel theme push`." : "Next: `cerkel theme dev`, then `cerkel theme submit -m \"…\"`.");
|
|
431
|
+
return failed > 0 ? 1 : 0;
|
|
432
|
+
}
|