@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.
Files changed (50) hide show
  1. package/README.md +502 -2
  2. package/dist/api-url.js +42 -0
  3. package/dist/api.js +48 -0
  4. package/dist/bin.js +157 -0
  5. package/dist/chunk.js +31 -0
  6. package/dist/cli.js +497 -0
  7. package/dist/client.js +125 -0
  8. package/dist/command.js +2 -0
  9. package/dist/commands/args.js +107 -0
  10. package/dist/commands/auth.js +255 -0
  11. package/dist/commands/call.js +67 -0
  12. package/dist/commands/collection.js +121 -0
  13. package/dist/commands/content.js +281 -0
  14. package/dist/commands/customer.js +126 -0
  15. package/dist/commands/dev.js +261 -0
  16. package/dist/commands/draft.js +101 -0
  17. package/dist/commands/file.js +53 -0
  18. package/dist/commands/index.js +43 -0
  19. package/dist/commands/inventory.js +103 -0
  20. package/dist/commands/offer.js +92 -0
  21. package/dist/commands/order-args.js +37 -0
  22. package/dist/commands/order.js +219 -0
  23. package/dist/commands/price.js +55 -0
  24. package/dist/commands/product.js +265 -0
  25. package/dist/commands/promo.js +140 -0
  26. package/dist/commands/redirect.js +94 -0
  27. package/dist/commands/report.js +93 -0
  28. package/dist/commands/section.js +233 -0
  29. package/dist/commands/store.js +23 -0
  30. package/dist/commands/theme-tools.js +432 -0
  31. package/dist/commands/theme.js +388 -0
  32. package/dist/commands/whatsapp.js +39 -0
  33. package/dist/commands/zone.js +90 -0
  34. package/dist/commerce.js +138 -0
  35. package/dist/content-files.js +246 -0
  36. package/dist/context.js +5 -0
  37. package/dist/credentials.js +201 -0
  38. package/dist/diff.js +146 -0
  39. package/dist/env.js +24 -0
  40. package/dist/errors.js +73 -0
  41. package/dist/format.js +13 -0
  42. package/dist/layout.js +122 -0
  43. package/dist/logins.js +49 -0
  44. package/dist/raizn-alias.js +6 -0
  45. package/dist/redact.js +27 -0
  46. package/dist/session.js +266 -0
  47. package/dist/state.js +153 -0
  48. package/dist/sync.js +121 -0
  49. package/dist/zip.js +88 -0
  50. package/package.json +37 -3
@@ -0,0 +1,388 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { apiOrigin } from "../api-url.js";
4
+ import { createDraft, getPreviewUrl, getThemeFiles, listThemes, submitForApproval, validateThemeFiles, } from "../api.js";
5
+ import { chunkFiles } from "../chunk.js";
6
+ import { diffStats } from "../diff.js";
7
+ import { CliError } from "../errors.js";
8
+ import { formatIssue, plural, table } from "../format.js";
9
+ import { bytesOf, hashBytes, isGeneratedPath, isLayoutPath, legacySectionPartOf, textOf, toDiskPath, toLayoutPath } from "../layout.js";
10
+ import { findRoot, listLegacySectionParts, listLocalFiles, localChanges, readLocal, readState, writeState } from "../state.js";
11
+ import { conflictAdvice, pushChanges, pushDeletes } from "../sync.js";
12
+ const byNewest = (a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
13
+ // --- list --------------------------------------------------------------------
14
+ export async function themeList(s, opts) {
15
+ const co = await s.checkout();
16
+ // --store, else this folder's store, else the store of your one login.
17
+ const store = opts.store ?? co?.state.store ?? (await s.defaultStore());
18
+ // The folder's recorded API (its theme ids only mean something there), and the login for `store`.
19
+ const themes = await listThemes(await s.client(co?.state, store), store);
20
+ const rank = (t) => (t.role === "live" ? 0 : t.role === "draft" ? 1 : 2);
21
+ const rows = [...themes].sort((a, b) => rank(a) - rank(b) || byNewest(a, b));
22
+ if (rows.length === 0) {
23
+ s.err(`${store} has no themes.`);
24
+ return 0;
25
+ }
26
+ for (const line of table([["ID", "NAME", "ROLE", "UPDATED"], ...rows.map((t) => [t.id, t.name, t.role, t.updatedAt ?? "—"])]))
27
+ s.out(line);
28
+ return 0;
29
+ }
30
+ export async function themePull(s, opts) {
31
+ const root = opts.dir ? path.resolve(s.ctx.cwd, opts.dir) : ((await findRoot(s.ctx.cwd)) ?? s.ctx.cwd);
32
+ const existing = await readState(root);
33
+ if (opts.live) {
34
+ if (!opts.dir) {
35
+ throw new CliError("--live needs --dir <folder>. The live theme is only ever pulled as a read-only reference, into a folder of its own.");
36
+ }
37
+ const enclosing = await findRoot(root);
38
+ const enclosingState = enclosing ? await readState(enclosing) : null;
39
+ if (enclosingState && !enclosingState.readOnly) {
40
+ throw new CliError(`${enclosing} is an editable draft checkout. Pull the live theme into a different folder, e.g. --dir ../live-reference.`);
41
+ }
42
+ if (opts.theme)
43
+ throw new CliError("--live and --theme cannot be combined: --live always means the store's live theme.");
44
+ }
45
+ else if (existing?.readOnly) {
46
+ throw new CliError(`${root} holds a read-only copy of the live theme. Pull a draft into a different folder (--dir <folder>).`);
47
+ }
48
+ // --store, else this folder's store, else the store of your one login.
49
+ const store = opts.store ?? existing?.store ?? (await s.defaultStore());
50
+ // One theme folder is bound to one store (and so to that store's login): another store gets its own folder.
51
+ if (existing && existing.store !== store) {
52
+ throw new CliError(`${root} is the theme folder of ${existing.store}. Pull ${store} into a folder of its own: \`cerkel theme pull --store ${store} --dir <folder>\`.`);
53
+ }
54
+ const client = await s.client(existing, store);
55
+ let files;
56
+ if (opts.live) {
57
+ files = await getThemeFiles(client, store);
58
+ }
59
+ else {
60
+ const themeId = await chooseDraft(s, client, store, opts, existing);
61
+ files = await getThemeFiles(client, store, themeId);
62
+ if (files.theme.role === "live") {
63
+ // The server decides what a theme is; if it says live, we will not open it for editing.
64
+ throw new CliError(`Theme ${themeId} is the LIVE theme. It is never pulled for editing; use --live --dir <folder> for a read-only copy.`);
65
+ }
66
+ }
67
+ // Refuse before touching anything if local work would be overwritten.
68
+ const incoming = new Map();
69
+ const skipped = [];
70
+ for (const f of files.files) {
71
+ if (!isLayoutPath(f.path)) {
72
+ skipped.push(f.path);
73
+ continue;
74
+ }
75
+ incoming.set(f.path, { content: f.content, tag: f.tag, hash: hashBytes(bytesOf(f.content)) });
76
+ }
77
+ const { changes } = await localChanges(root, existing);
78
+ const atRisk = changes.filter((c) => incoming.get(c.path)?.hash !== c.hash);
79
+ if (atRisk.length > 0 && !opts.force) {
80
+ const list = atRisk.slice(0, 20).map((c) => ` ${c.kind === "new" ? "new " : "modified"} ${c.path}`);
81
+ throw new CliError([
82
+ `${root} has ${plural(atRisk.length, "local change")} that pulling would overwrite or leave behind:`,
83
+ ...list,
84
+ ...(atRisk.length > 20 ? [` … and ${atRisk.length - 20} more`] : []),
85
+ "Push them first (`cerkel theme push`), or re-run with --force to replace them with the server's copy.",
86
+ ].join("\n"));
87
+ }
88
+ let written = 0;
89
+ let same = 0;
90
+ const nextFiles = {};
91
+ for (const [p, f] of incoming) {
92
+ const disk = toDiskPath(root, p);
93
+ const current = await readLocal(root, p);
94
+ if (current && hashBytes(current) === f.hash)
95
+ same++;
96
+ else {
97
+ await fs.mkdir(path.dirname(disk), { recursive: true });
98
+ await fs.writeFile(disk, bytesOf(f.content));
99
+ written++;
100
+ }
101
+ nextFiles[p] = { tag: f.tag, hash: f.hash };
102
+ }
103
+ // Files the server no longer has: remove only what we can prove is ours and untouched.
104
+ let removed = 0;
105
+ const kept = [];
106
+ const known = existing?.files ?? {};
107
+ const candidates = new Set([...Object.keys(known), ...changes.map((c) => c.path)]);
108
+ for (const p of candidates) {
109
+ if (incoming.has(p))
110
+ continue;
111
+ const bytes = await readLocal(root, p);
112
+ if (!bytes)
113
+ continue;
114
+ if (known[p] && known[p].hash === hashBytes(bytes)) {
115
+ await fs.rm(toDiskPath(root, p), { force: true });
116
+ removed++;
117
+ }
118
+ else
119
+ kept.push(p);
120
+ }
121
+ const readOnly = opts.live;
122
+ await writeState(root, {
123
+ version: 1,
124
+ api: client.url.href,
125
+ store,
126
+ themeId: files.theme.id,
127
+ themeName: files.theme.name,
128
+ role: files.theme.role,
129
+ readOnly,
130
+ pulledAt: s.ctx.now().toISOString(),
131
+ files: nextFiles,
132
+ });
133
+ s.out(`Pulled "${files.theme.name}" (${files.theme.role}, ${files.theme.id}) from ${store} into ${root}`);
134
+ s.out(` ${plural(incoming.size, "file")}: ${written} written, ${same} already up to date${removed ? `, ${removed} removed` : ""}`);
135
+ for (const p of skipped)
136
+ s.err(` skipped ${p}: not a theme file path this CLI knows how to store`);
137
+ for (const p of kept) {
138
+ const legacy = legacySectionPartOf(p);
139
+ if (legacy)
140
+ s.err(` kept ${p}: sections are ONE file now — move your changes into the {% ${p.endsWith(".css") ? "stylesheet" : "schema"} %} of sections/${legacy}.liquid, then delete ${p}`);
141
+ else
142
+ s.err(` kept ${p}: the server no longer has it, but your copy has changes`);
143
+ }
144
+ if (readOnly) {
145
+ s.out("This is a READ-ONLY copy of the live theme, for reference. push, dev and submit refuse to run here.");
146
+ }
147
+ else {
148
+ s.out("Next: `cerkel theme dev` to edit with a live preview, then `cerkel theme submit -m \"…\"`.");
149
+ }
150
+ return 0;
151
+ }
152
+ async function chooseDraft(s, client, store, opts, existing) {
153
+ const themes = await listThemes(client, store);
154
+ const live = themes.find((t) => t.role === "live");
155
+ if (opts.theme) {
156
+ const t = themes.find((x) => x.id === opts.theme);
157
+ if (!t)
158
+ throw new CliError(`No theme ${opts.theme} on ${store}. See \`cerkel theme list --store ${store}\`.`);
159
+ if (t.role === "live") {
160
+ throw new CliError(`Theme ${t.id} ("${t.name}") is the LIVE theme. It is never pulled for editing. Pull a draft (or omit --theme to create one), or use --live --dir <folder> for a read-only copy.`);
161
+ }
162
+ if (t.role !== "draft")
163
+ throw new CliError(`Theme ${t.id} is ${t.role}; only drafts can be pulled for editing.`);
164
+ return t.id;
165
+ }
166
+ // Re-running pull in a checkout refreshes the draft it already tracks.
167
+ if (existing && existing.store === store && themes.some((t) => t.id === existing.themeId && t.role === "draft"))
168
+ return existing.themeId;
169
+ const drafts = themes.filter((t) => t.role === "draft").sort(byNewest);
170
+ if (drafts[0]) {
171
+ if (drafts.length > 1)
172
+ s.err(`Using the newest draft "${drafts[0].name}" (${drafts[0].id}); pass --theme <id> for another.`);
173
+ return drafts[0].id;
174
+ }
175
+ const from = live ? ` from the live theme "${live.name}"` : "";
176
+ if (!opts.yes) {
177
+ if (!s.ctx.io.isTTY) {
178
+ throw new CliError(`${store} has no draft theme. Re-run with --yes to create one${from}.`);
179
+ }
180
+ const answer = (await s.ctx.io.prompt(`${store} has no draft theme. Create a draft${from}? [y/N] `)).trim().toLowerCase();
181
+ if (answer !== "y" && answer !== "yes")
182
+ throw new CliError("No draft created; nothing pulled.");
183
+ }
184
+ const name = live ? `${live.name} (draft)` : "Draft";
185
+ const made = await createDraft(client, store, name, live?.id);
186
+ s.err(`Created draft "${made.name}" (${made.themeId}).`);
187
+ return made.themeId;
188
+ }
189
+ // --- check -------------------------------------------------------------------
190
+ export async function themeCheck(s, opts) {
191
+ const co = await s.checkout();
192
+ let root;
193
+ let store;
194
+ let themeId;
195
+ let paths;
196
+ if (co) {
197
+ root = co.root;
198
+ store = opts.store ?? co.state.store;
199
+ themeId = store === co.state.store ? co.state.themeId : undefined;
200
+ paths = opts.all ? await listLocalFiles(root) : (await localChanges(root, co.state)).changes.map((c) => c.path);
201
+ }
202
+ else {
203
+ // CI: a plain git checkout of theme files, no state — validate everything against the store.
204
+ if (!opts.store)
205
+ throw new CliError("This folder is not a theme checkout. Pass --store <slug> to validate every theme file in it.");
206
+ root = s.ctx.cwd;
207
+ store = opts.store;
208
+ paths = await listLocalFiles(root);
209
+ }
210
+ if (paths.length === 0) {
211
+ s.out(co && !opts.all ? "Nothing to check: no changed files. Use --all to check every file." : "No theme files found in this folder.");
212
+ return 0;
213
+ }
214
+ const files = [];
215
+ for (const p of paths) {
216
+ const bytes = await readLocal(root, p);
217
+ if (bytes)
218
+ files.push({ path: p, content: textOf(bytes) });
219
+ }
220
+ const client = await s.client(co?.state, store);
221
+ let errors = 0;
222
+ let warnings = 0;
223
+ for (const chunk of chunkFiles(files)) {
224
+ const r = await validateThemeFiles(client, store, themeId, chunk);
225
+ for (const res of r.results ?? []) {
226
+ for (const issue of res.errors ?? []) {
227
+ s.out(formatIssue(res.path, issue));
228
+ if (issue.severity === "error")
229
+ errors++;
230
+ else
231
+ warnings++;
232
+ }
233
+ }
234
+ }
235
+ s.err(`Checked ${plural(files.length, "file")}: ${plural(errors, "error")}, ${plural(warnings, "warning")}.`);
236
+ return errors > 0 ? 1 : 0;
237
+ }
238
+ // --- push --------------------------------------------------------------------
239
+ export async function themePush(s, opts) {
240
+ const { root, state } = await s.requireCheckout({ editable: true });
241
+ const legacy = await listLegacySectionParts(root);
242
+ if (legacy.length > 0) {
243
+ throw new CliError([
244
+ "This checkout still has section files in the old three-file layout:",
245
+ ...legacy.map((p) => ` ${p}`),
246
+ "A section is ONE file now: sections/<type>.liquid holds its Liquid, its {% stylesheet %} and its {% schema %}.",
247
+ "Run `cerkel theme pull` to get the new layout (it removes the old files you did not change), move any changes into the .liquid file, then push.",
248
+ ].join("\n"));
249
+ }
250
+ let only;
251
+ if (opts.files.length > 0) {
252
+ only = [];
253
+ for (const f of opts.files) {
254
+ const p = toLayoutPath(root, path.resolve(s.ctx.cwd, f));
255
+ if (!p || !isLayoutPath(p))
256
+ throw new CliError(`${f} is not a theme file in this checkout (${root}).`);
257
+ only.push(p);
258
+ }
259
+ }
260
+ const found = await localChanges(root, state, only);
261
+ const changes = found.changes.filter((c) => !isGeneratedPath(c.path));
262
+ for (const c of found.changes)
263
+ if (isGeneratedPath(c.path))
264
+ s.err(` ${c.path} is generated by Cerkel from your theme's files; your edit to it is not pushed.`);
265
+ const deleted = opts.noDelete ? [] : found.deleted;
266
+ if (opts.noDelete)
267
+ for (const p of found.deleted)
268
+ s.err(` ${p} was deleted locally; kept on the server (--nodelete).`);
269
+ if (changes.length === 0 && deleted.length === 0) {
270
+ s.out("Nothing to push.");
271
+ return 0;
272
+ }
273
+ // The folder's store login first: without one there is nothing to push with.
274
+ const client = await s.client(state);
275
+ // Writes first, deletes last: a template that stops placing a section must land before that
276
+ // section's file can be deleted (the server refuses deleting a section a template still places).
277
+ const del = async () => {
278
+ if (deleted.length === 0)
279
+ return { deleted: 0, failed: 0 };
280
+ const d = await pushDeletes(s, client, root, state, deleted);
281
+ return { deleted: d.deleted, failed: d.failed.length };
282
+ };
283
+ if (changes.length === 0) {
284
+ const d = await del();
285
+ s.err(`${d.deleted} deleted, ${d.failed} failed.`);
286
+ return d.failed > 0 ? 1 : 0;
287
+ }
288
+ s.err(`Pushing ${plural(changes.length, "file")} to "${state.themeName}" on ${state.store}${opts.force ? " (--force: server changes in conflicting files are listed and confirmed before they are overwritten)" : ""}…`);
289
+ // Even with --force, the first attempt is a normal one: files nobody else touched land as
290
+ // usual, and each conflict comes back with the server's copy, so we can say what would be lost.
291
+ const r = await pushChanges(s, client, root, state, changes, { force: false });
292
+ let saved = r.saved;
293
+ let unchanged = r.unchanged;
294
+ let failed = r.invalid.length + r.refused.length;
295
+ if (opts.force && r.conflicts.length > 0) {
296
+ const targets = changes.filter((c) => r.conflicts.includes(c.path));
297
+ await confirmOverwrite(s, targets, r.serverCopies, opts.yes === true);
298
+ // Claim the exact server version that was shown: if it changes again while the person was
299
+ // reading, that is a new conflict, not one more silent loss. Only a server that sent no copy
300
+ // at all leaves us with a plain force.
301
+ const blind = targets.some((c) => r.serverCopies.get(c.path) === undefined);
302
+ const baseTags = new Map(targets.map((c) => [c.path, r.serverCopies.get(c.path)?.tag ?? null]));
303
+ const r2 = await pushChanges(s, client, root, state, targets, blind ? { force: true } : { force: false, baseTags });
304
+ conflictAdvice(s, r2.conflicts);
305
+ saved += r2.saved;
306
+ unchanged += r2.unchanged;
307
+ failed += r2.conflicts.length + r2.invalid.length + r2.refused.length;
308
+ }
309
+ else {
310
+ conflictAdvice(s, r.conflicts);
311
+ failed += r.conflicts.length;
312
+ }
313
+ const d = await del();
314
+ failed += d.failed;
315
+ s.err(`${saved} saved, ${unchanged} unchanged${d.deleted ? `, ${d.deleted} deleted` : ""}, ${failed} failed.`);
316
+ return failed > 0 ? 1 : 0;
317
+ }
318
+ /** One line per file: what overwriting the server's copy with ours throws away. */
319
+ export function overwriteSummary(server, local) {
320
+ if (server === null)
321
+ return "deleted on the server since you last synced it; pushing puts your copy back";
322
+ if (server === undefined)
323
+ return "the server did not send its copy; every server change to it since you last synced will be lost";
324
+ const d = diffStats(server.content, local);
325
+ if (d.removed === 0)
326
+ return `no server lines will be lost (your file adds ${plural(d.added, "line")} the server's copy does not have)`;
327
+ return `${plural(d.removed, "server line")} not in your file will be lost (${plural(d.places, "place")})`;
328
+ }
329
+ async function confirmOverwrite(s, targets, serverCopies, yes) {
330
+ s.err(`--force would overwrite the server's copy of ${plural(targets.length, "file")} (diffs above: - is the server's, + is yours):`);
331
+ for (const c of targets)
332
+ s.err(` ${c.path}: ${overwriteSummary(serverCopies.get(c.path), textOf(c.bytes))}`);
333
+ if (yes)
334
+ return;
335
+ if (!s.ctx.io.isTTY) {
336
+ throw new CliError(`Not overwritten: --force needs confirmation, and there is no terminal to ask in. Re-run with --force --yes to discard those server changes, or merge them into your files and push again.`);
337
+ }
338
+ const answer = (await s.ctx.io.prompt(`Overwrite ${targets.length === 1 ? "it" : "them"} and lose those server changes? [y/N] `)).trim().toLowerCase();
339
+ if (answer !== "y" && answer !== "yes") {
340
+ throw new CliError("Nothing overwritten. Merge the server's changes into your files and push again, or `cerkel theme pull --force` to take the server's version.");
341
+ }
342
+ }
343
+ // --- preview -----------------------------------------------------------------
344
+ export async function themePreview(s, opts) {
345
+ const { state } = await s.requireCheckout({ editable: false });
346
+ const r = await getPreviewUrl(await s.client(state), state.store, state.themeId);
347
+ s.out(r.url);
348
+ if (r.expiresAt)
349
+ s.err(`Signed link; expires ${r.expiresAt}.`);
350
+ if (opts.open)
351
+ await s.ctx.openUrl(r.url);
352
+ return 0;
353
+ }
354
+ // --- submit ------------------------------------------------------------------
355
+ export async function themeSubmit(s, opts) {
356
+ const title = opts.title?.trim();
357
+ if (!title)
358
+ throw new CliError('A title is required: cerkel theme submit -m "What this change does".');
359
+ const { root, state } = await s.requireCheckout({ editable: true });
360
+ const { changes } = await localChanges(root, state);
361
+ if (changes.length > 0) {
362
+ throw new CliError([
363
+ `You have ${plural(changes.length, "unpushed change")}; the approval would not include them:`,
364
+ ...changes.slice(0, 20).map((c) => ` ${c.path}`),
365
+ "Run `cerkel theme push` first, then submit.",
366
+ ].join("\n"));
367
+ }
368
+ const client = await s.client(state);
369
+ const r = await submitForApproval(client, state.store, state.themeId, title, opts.description);
370
+ const link = `${apiOrigin(client.url)}/themes/requests/${encodeURIComponent(r.requestId)}`;
371
+ // The theme's own files that differ from the live theme (sections, blocks, snippets, assets…).
372
+ const files = r.files && r.files.length > 0 ? r.files.map((x) => `${x.path} (${x.change})`).join(", ") : null;
373
+ if (r.published) {
374
+ // The store's owner/admin (a login carrying themes.publish, held in this store) and Raizn's own
375
+ // team skip review: the server approved and published it in the same step.
376
+ s.out(`Published "${state.themeName}" — it is LIVE now (you can publish in this store, so it skipped review). Request ${r.requestId}`);
377
+ if (files)
378
+ s.out(`Files that went live with it: ${files}.`);
379
+ s.out(`Something wrong? Roll it back in one click: ${link}`);
380
+ return 0;
381
+ }
382
+ s.out(`Submitted "${state.themeName}" for approval: request ${r.requestId}`);
383
+ s.out(`Review it: ${link}`);
384
+ if (files)
385
+ s.out(`Files in this request: ${files}.`);
386
+ s.out("Nothing is live yet. Someone with publish rights approves and publishes it in the Cerkel admin.");
387
+ return 0;
388
+ }
@@ -0,0 +1,39 @@
1
+ import { str } from "../command.js";
2
+ import { WRITE_HELP, WRITE_OPTIONS, flagsOf, runWrite } from "../commerce.js";
3
+ import { CliError } from "../errors.js";
4
+ /** `cerkel whatsapp test` — one approved template to a phone, from the store's number (send_whatsapp_test). */
5
+ export const WHATSAPP_COMMANDS = {
6
+ "whatsapp test": {
7
+ usage: "cerkel whatsapp test <template> --to <+962…> [--values \"v1|v2|…\"] [--language ar] [--store <slug>] [--yes] [--dry-run] [--json]",
8
+ summary: "Send an approved WhatsApp template to a phone, to try it",
9
+ help: [
10
+ "Sends the template from the store's WhatsApp number with the values in {{1}}, {{2}}… order, separated by | (a value may contain commas).",
11
+ "The template needs no mapping on the store and no order; the store's Enable sending switch does not apply. A tap on the",
12
+ "test message's buttons moves no order. Review buttons still rate that phone's latest real review request (72 h): tap them",
13
+ "only from a phone with no recent order.",
14
+ "WhatsApp's answer is printed: the message id, or its error (132001 = no such template in that language, 132000 = wrong number of values).",
15
+ WRITE_HELP,
16
+ ],
17
+ options: { ...WRITE_OPTIONS, to: { type: "string" }, values: { type: "string" }, language: { type: "string" } },
18
+ positionals: true,
19
+ run: (s, v, pos) => {
20
+ if (pos.length !== 1)
21
+ throw new CliError("Usage: cerkel whatsapp test <template> --to <+962…> [--values \"v1|v2\"]");
22
+ const to = str(v, "to");
23
+ if (!to)
24
+ throw new CliError("--to is required: the phone to send to, e.g. +962791234567.");
25
+ const values = (str(v, "values") ?? "").split("|").map((x) => x.trim()).filter(Boolean);
26
+ const language = str(v, "language");
27
+ return runWrite(s, flagsOf(v), "send_whatsapp_test", { template: pos[0], to: to.replace(/\s+/g, ""), values, ...(language ? { language } : {}) }, {
28
+ after: (a) => {
29
+ const r = a.afterCommit;
30
+ if (!r)
31
+ throw new CliError("The server did not say whether WhatsApp accepted the message. Check the store's WhatsApp thread.");
32
+ if (!r.ok)
33
+ throw new CliError(`WhatsApp refused it${r.code !== undefined ? ` (${r.code})` : ""}: ${r.message ?? r.error}`);
34
+ return [`Sent: ${a.summary}`, `WhatsApp message id: ${r.waMessageId ?? "—"}`];
35
+ },
36
+ });
37
+ },
38
+ },
39
+ };
@@ -0,0 +1,90 @@
1
+ import { bool, str } from "../command.js";
2
+ import { READ_HELP, READ_OPTIONS, WRITE_HELP, WRITE_OPTIONS, flagsOf, moneyFlag, runRead, runWrite, show } from "../commerce.js";
3
+ import { CliError } from "../errors.js";
4
+ import { table } from "../format.js";
5
+ const ZONE_OPTIONS = {
6
+ "name-ar": { type: "string" }, "name-en": { type: "string" }, "name-ckb": { type: "string" },
7
+ governorates: { type: "string" }, fee: { type: "string" }, "free-over": { type: "string" }, eta: { type: "string" },
8
+ active: { type: "boolean" }, inactive: { type: "boolean" },
9
+ };
10
+ function zoneArgs(v) {
11
+ const out = {};
12
+ const name = {};
13
+ for (const l of ["ar", "en", "ckb"]) {
14
+ const n = str(v, `name-${l}`);
15
+ if (n !== undefined)
16
+ name[l] = n;
17
+ }
18
+ if (Object.keys(name).length)
19
+ out["name"] = name;
20
+ const govs = str(v, "governorates");
21
+ if (govs !== undefined)
22
+ out["governorates"] = govs.split(",").map((g) => g.trim()).filter(Boolean);
23
+ if (str(v, "fee") !== undefined)
24
+ out["fee"] = str(v, "fee").trim();
25
+ const free = moneyFlag(str(v, "free-over"));
26
+ if (free !== undefined)
27
+ out["freeOver"] = free;
28
+ const eta = str(v, "eta");
29
+ if (eta !== undefined) {
30
+ if (["", "none", "null", "-"].includes(eta.trim().toLowerCase()))
31
+ out["etaDays"] = null;
32
+ else {
33
+ const n = Number(eta);
34
+ if (!Number.isInteger(n) || n < 0 || n > 30)
35
+ throw new CliError("--eta is a whole number of days, 0–30 (or none).");
36
+ out["etaDays"] = n;
37
+ }
38
+ }
39
+ if (bool(v, "active") && bool(v, "inactive"))
40
+ throw new CliError("--active or --inactive, not both.");
41
+ if (bool(v, "active"))
42
+ out["active"] = true;
43
+ if (bool(v, "inactive"))
44
+ out["active"] = false;
45
+ return out;
46
+ }
47
+ /** `cerkel zone …` — delivery zones (list_delivery_zones, upsert_delivery_zone, delete_delivery_zone). */
48
+ export const ZONE_COMMANDS = {
49
+ "zone list": {
50
+ usage: "cerkel zone list [--store <slug>] [--json]",
51
+ summary: "List the store's delivery zones and fees",
52
+ help: ["--json also lists every governorate of the store's country and the zone that prices it.", READ_HELP],
53
+ options: READ_OPTIONS,
54
+ run: (s, v) => runRead(s, flagsOf(v), "list_delivery_zones", {}, (d) => table([["ID", "NAME", "GOVERNORATES", "FEE", "FREE OVER", "DAYS", "ON"], ...d.zones.map((z) => [
55
+ z.id, z.name["en"] ?? z.name["ar"] ?? "", z.governorates.map((g) => g.code).join(","), show(z.fee), show(z.freeOver), z.etaDays == null ? "—" : String(z.etaDays), z.active ? "yes" : "no",
56
+ ])])),
57
+ },
58
+ "zone set": {
59
+ usage: "cerkel zone set [<zone-id>] [--name-ar <name>] [--name-en <name>] [--governorates JO-AM,JO-IR|*] [--fee <money>] [--free-over <money|none>] [--eta <days|none>] [--active|--inactive] [--store <slug>] [--yes] [--dry-run] [--json]",
60
+ summary: "Create a delivery zone, or change one (with its id)",
61
+ help: [
62
+ "Without an id: creates a zone (needs --name-ar, --governorates and --fee). With an id: changes only what you pass.",
63
+ "Governorates are ISO codes of the store's country (JO-AM, IQ-BG…; cerkel zone list --json shows them) or * for every other governorate.",
64
+ "Fees in the store's currency: 2.000 (JOD), 5000 (IQD, steps of 250).",
65
+ WRITE_HELP,
66
+ ],
67
+ options: { ...WRITE_OPTIONS, ...ZONE_OPTIONS },
68
+ positionals: true,
69
+ run: (s, v, pos) => {
70
+ if (pos.length > 1)
71
+ throw new CliError("Usage: cerkel zone set [<zone-id>] [options]");
72
+ const args = zoneArgs(v);
73
+ if (!Object.keys(args).length)
74
+ throw new CliError("Nothing to set: pass --fee, --governorates, --name-ar … (see cerkel zone set --help).");
75
+ return runWrite(s, flagsOf(v), "upsert_delivery_zone", { ...(pos[0] ? { zone: pos[0] } : {}), ...args });
76
+ },
77
+ },
78
+ "zone delete": {
79
+ usage: "cerkel zone delete <zone-id> [--store <slug>] [--yes] [--dry-run] [--json]",
80
+ summary: "Delete a delivery zone",
81
+ help: ["Its governorates fall to the * zone, or can no longer check out without one. To pause it: cerkel zone set <id> --inactive.", WRITE_HELP],
82
+ options: WRITE_OPTIONS,
83
+ positionals: true,
84
+ run: (s, v, pos) => {
85
+ if (pos.length !== 1)
86
+ throw new CliError("Usage: cerkel zone delete <zone-id>");
87
+ return runWrite(s, flagsOf(v), "delete_delivery_zone", { zone: pos[0] });
88
+ },
89
+ },
90
+ };
@@ -0,0 +1,138 @@
1
+ import { listStores } from "./api.js";
2
+ import { bool, str } from "./command.js";
3
+ import { CliError } from "./errors.js";
4
+ // ---------------------------------------------------------------------------
5
+ // The commerce command framework (`cerkel product …`, `cerkel price …`, `cerkel inventory …`, and the
6
+ // phase B/C groups). Every command is a thin layer over ONE MCP tool:
7
+ //
8
+ // * reads — `runRead`: call the tool, print a table or `--json`;
9
+ // * writes — `runWrite`: call the tool with `dry_run: true`, print the diff the SERVER computed,
10
+ // ask to confirm on a terminal (`--yes` answers for you; `--dry-run` stops after the diff), then
11
+ // call it for real. Without a terminal a write needs `--yes` and is refused before anything is
12
+ // sent. The server decides everything — permission, store, money rules — and its refusal is
13
+ // shown verbatim; the CLI never does money arithmetic (amounts go to the server as typed and
14
+ // are parsed there by @raizn/money; what comes back is shown by its `formatted`).
15
+ //
16
+ // The store is the login's own store (one store per login), or `--store`, or the theme folder's.
17
+ // ---------------------------------------------------------------------------
18
+ /** Flags every commerce command takes. */
19
+ export const READ_OPTIONS = {
20
+ store: { type: "string" },
21
+ json: { type: "boolean" },
22
+ };
23
+ /** Flags every commerce WRITE command takes. */
24
+ export const WRITE_OPTIONS = {
25
+ ...READ_OPTIONS,
26
+ yes: { type: "boolean", short: "y" },
27
+ "dry-run": { type: "boolean" },
28
+ };
29
+ export const READ_HELP = "--store <slug> (default: your login's store), --json for machine-readable output.";
30
+ export const WRITE_HELP = "Shows the change and asks before applying it. --yes applies without asking (required without a terminal), --dry-run only shows it. --json prints the server's answer as JSON.";
31
+ export const flagsOf = (v) => ({
32
+ ...(str(v, "store") ? { store: str(v, "store") } : {}),
33
+ json: bool(v, "json"),
34
+ yes: bool(v, "yes"),
35
+ dryRun: bool(v, "dry-run"),
36
+ });
37
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
38
+ /** A value as a person reads it: money by its `formatted`, nothing as "—", text as is. */
39
+ export function show(v) {
40
+ if (v === null || v === undefined)
41
+ return "—";
42
+ if (isRecord(v) && typeof v["formatted"] === "string" && typeof v["amount"] === "number")
43
+ return v["formatted"];
44
+ if (typeof v === "string")
45
+ return v === "" ? '""' : v;
46
+ if (typeof v === "number" || typeof v === "boolean")
47
+ return String(v);
48
+ return JSON.stringify(v);
49
+ }
50
+ /** ` price: 15.000 JOD → 18.000 JOD` per changed field. */
51
+ export const diffLines = (diff) => diff.map((d) => ` ${d.path}: ${show(d.before)} → ${show(d.after)}`);
52
+ /**
53
+ * The store a command acts on, and a client for it. `--store`, else the theme folder's store, else
54
+ * the login's store when it has exactly one. Never lists stores in an error: the server is the
55
+ * authority on what this login may reach, and other stores are not this command's business.
56
+ */
57
+ export async function storeAndClient(s, flags) {
58
+ const state = (await s.checkout())?.state ?? null;
59
+ const client = await s.client(state);
60
+ if (flags.store)
61
+ return { client, store: flags.store };
62
+ if (state?.store)
63
+ return { client, store: state.store };
64
+ const who = await listStores(client);
65
+ if (who.stores.length === 1)
66
+ return { client, store: who.stores[0].slug };
67
+ if (who.stores.length === 0)
68
+ throw new CliError("This login cannot reach any store. Run `cerkel login --store <slug>`.");
69
+ throw new CliError("Which store? Pass --store <slug>.");
70
+ }
71
+ /** A read command: one tool call, then a table (or `--json`). */
72
+ export async function runRead(s, flags, tool, args, render) {
73
+ const { client, store } = await storeAndClient(s, flags);
74
+ const data = await client.call(tool, { store, ...args });
75
+ if (flags.json)
76
+ s.out(JSON.stringify(data, null, 2));
77
+ else
78
+ for (const line of render(data, store))
79
+ s.out(line);
80
+ return 0;
81
+ }
82
+ /**
83
+ * A write command: dry run → diff → confirm → apply. Returns the exit code.
84
+ * `after` renders the applied outcome (default: "Done." plus the summary).
85
+ */
86
+ export async function runWrite(s, flags, tool, args, opts = {}) {
87
+ // Scripts: refuse before sending anything, so a forgotten flag never half-runs.
88
+ if (!flags.yes && !flags.dryRun && !s.ctx.io.isTTY) {
89
+ throw new CliError("This changes the store, and there is no terminal to confirm in. Re-run with --yes to apply it, or --dry-run to only see the change.");
90
+ }
91
+ const { client, store } = await storeAndClient(s, flags);
92
+ const preview = await client.call(tool, { store, ...args, dry_run: true });
93
+ if (!preview.changed) {
94
+ if (flags.json)
95
+ s.out(JSON.stringify(preview, null, 2));
96
+ else
97
+ s.out(`Nothing to change: ${preview.summary}`);
98
+ return 0;
99
+ }
100
+ if (flags.json && (flags.dryRun || !flags.yes)) {
101
+ // JSON callers see the server's own preview; only --yes applies in JSON mode.
102
+ s.out(JSON.stringify(preview, null, 2));
103
+ if (flags.dryRun)
104
+ return 0;
105
+ }
106
+ else {
107
+ s.out(preview.summary);
108
+ for (const line of diffLines(preview.diff))
109
+ s.out(line);
110
+ if (flags.dryRun) {
111
+ s.err("Dry run: nothing was changed.");
112
+ return 0;
113
+ }
114
+ }
115
+ if (!flags.yes) {
116
+ const answer = (await s.ctx.io.prompt(`Apply this change to ${store}? [y/N] `)).trim().toLowerCase();
117
+ if (answer !== "y" && answer !== "yes") {
118
+ s.err("Cancelled. Nothing was changed.");
119
+ return 1;
120
+ }
121
+ }
122
+ const applied = await client.call(tool, { store, ...args, dry_run: false });
123
+ if (flags.json) {
124
+ s.out(JSON.stringify(applied, null, 2));
125
+ return 0;
126
+ }
127
+ if (JSON.stringify(applied.diff) !== JSON.stringify(preview.diff)) {
128
+ // Someone changed the same thing while this was waiting for an answer: say what really happened.
129
+ s.err("The store changed while you were confirming. This is what was applied:");
130
+ for (const line of diffLines(applied.diff))
131
+ s.err(line);
132
+ }
133
+ for (const line of opts.after ? opts.after(applied) : [applied.changed ? `Done: ${applied.summary}` : `Nothing to change: ${applied.summary}`])
134
+ s.out(line);
135
+ return 0;
136
+ }
137
+ /** `--compare-at none` / `--cost none` / an empty value → null (remove it); anything else is sent as typed. */
138
+ export const moneyFlag = (v) => v === undefined ? undefined : ["", "none", "null", "-"].includes(v.trim().toLowerCase()) ? null : v.trim();