@cerkel/cli 0.0.0-stage → 1.0.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.
Files changed (50) hide show
  1. package/README.md +497 -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,219 @@
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
+ import { discountFlag, listFlag, need, pairsFlag, textOf } from "./order-args.js";
6
+ const day = (iso) => iso.slice(0, 16).replace("T", " ");
7
+ const pct = (r) => (r === null ? "—" : `${Math.round(r * 100)}%`);
8
+ const LIST_OPTIONS = {
9
+ ...READ_OPTIONS,
10
+ status: { type: "string", multiple: true }, q: { type: "string" }, phone: { type: "string" }, number: { type: "string" },
11
+ tag: { type: "string", multiple: true }, risk: { type: "string", multiple: true }, fulfillment: { type: "string", multiple: true },
12
+ since: { type: "string" }, until: { type: "string" }, archived: { type: "boolean" }, test: { type: "boolean" },
13
+ sort: { type: "string" }, asc: { type: "boolean" }, limit: { type: "string" }, page: { type: "string" },
14
+ };
15
+ const intFlag = (v, name) => {
16
+ if (v === undefined)
17
+ return undefined;
18
+ const n = Number(v);
19
+ if (!Number.isInteger(n) || n < 1)
20
+ throw new CliError(`--${name} takes a whole number.`);
21
+ return n;
22
+ };
23
+ const EDIT_OPTIONS = {
24
+ ...WRITE_OPTIONS,
25
+ qty: { type: "string", multiple: true }, remove: { type: "string", multiple: true }, add: { type: "string", multiple: true },
26
+ "add-offer": { type: "string", multiple: true }, "add-addon": { type: "string", multiple: true },
27
+ "delivery-fee": { type: "string" }, discount: { type: "string" },
28
+ name: { type: "string" }, phone: { type: "string" }, "phone-alt": { type: "string" }, governorate: { type: "string" }, city: { type: "string" },
29
+ district: { type: "string" }, landmark: { type: "string" }, address: { type: "string" },
30
+ reason: { type: "string" }, notify: { type: "boolean" },
31
+ };
32
+ export const ORDER_COMMANDS = {
33
+ "order list": {
34
+ usage: "cerkel order list [--status <s>]… [--q <text>] [--phone <phone>] [--number <n>] [--tag <t>]… [--risk <band>]… [--since <day>] [--until <day>] [--archived] [--limit <n>] [--page <n>] [--store <slug>] [--json]",
35
+ summary: "Search the store's orders",
36
+ help: [
37
+ "--status takes cash-on-delivery statuses (new, confirming, confirmed, packed, dispatched, out_for_delivery, delivered, …), repeatable or comma-separated.",
38
+ "--fulfillment unfulfilled|in_progress|on_hold|fulfilled. Phones are masked unless your login may see customer details.",
39
+ READ_HELP,
40
+ ],
41
+ options: LIST_OPTIONS,
42
+ run: (s, v) => {
43
+ const sort = str(v, "sort");
44
+ return runRead(s, flagsOf(v), "search_orders", {
45
+ ...(listFlag(v, "status") ? { status: listFlag(v, "status") } : {}),
46
+ ...(str(v, "q") ? { q: str(v, "q") } : {}), ...(str(v, "phone") ? { phone: str(v, "phone") } : {}), ...(str(v, "number") ? { number: str(v, "number") } : {}),
47
+ ...(listFlag(v, "tag") ? { tags: listFlag(v, "tag") } : {}), ...(listFlag(v, "risk") ? { risk: listFlag(v, "risk") } : {}),
48
+ ...(listFlag(v, "fulfillment") ? { fulfillment: listFlag(v, "fulfillment") } : {}),
49
+ ...(str(v, "since") ? { since: str(v, "since") } : {}), ...(str(v, "until") ? { until: str(v, "until") } : {}),
50
+ ...(bool(v, "archived") ? { archived: true } : {}), ...(bool(v, "test") ? { test: true } : {}),
51
+ ...(sort ? { sort } : {}), ...(bool(v, "asc") ? { dir: "asc" } : {}),
52
+ ...(intFlag(str(v, "limit"), "limit") ? { limit: intFlag(str(v, "limit"), "limit") } : {}),
53
+ ...(intFlag(str(v, "page"), "page") ? { page: intFlag(str(v, "page"), "page") } : {}),
54
+ }, (d) => [
55
+ ...table([
56
+ ["Number", "Placed", "Status", "Total", "Items", "Customer", "Phone", "Governorate", "Tags"],
57
+ ...d.orders.map((o) => [o.number, day(o.placedAt), o.status + (o.held ? " (held)" : ""), o.total.formatted, String(o.items), o.customer.name ?? "—", o.customer.phone ?? "—", o.governorate ?? "—", o.tags.join(", ")]),
58
+ ]),
59
+ `${d.orders.length} of ${d.total} (page ${d.page}).`,
60
+ ]);
61
+ },
62
+ },
63
+ "order get": {
64
+ usage: "cerkel order get <number|id> [--store <slug>] [--json]",
65
+ summary: "Show one order: items, totals, status, customer, courier and timeline",
66
+ help: [READ_HELP],
67
+ options: READ_OPTIONS,
68
+ positionals: true,
69
+ run: (s, v, pos) => {
70
+ need(pos, 1, "cerkel order get <number|id>");
71
+ return runRead(s, flagsOf(v), "get_order", { order: pos[0] }, (d) => [
72
+ `${d.number} — ${d.status}${d.hold ? ` (on hold: ${d.hold.reason}${d.hold.until ? ` until ${d.hold.until}` : ""})` : ""}`,
73
+ ...table([
74
+ ["Placed", day(d.placedAt)],
75
+ ["Payment / fulfillment / delivery", `${d.payment} / ${d.fulfillment ?? "—"} / ${d.delivery ?? "—"}`],
76
+ ["Customer", `${d.customer.name ?? "—"} ${d.customer.phone ?? ""}${d.customer.phoneAlt ? ` / ${d.customer.phoneAlt}` : ""}`],
77
+ ["Address", [d.address.governorate, d.address.city, d.address.district, d.address.landmark, d.address.address].filter(Boolean).join(", ") || "—"],
78
+ ["Courier", d.courier.name ? `${d.courier.name}${d.courier.trackingNumber ? ` · ${d.courier.trackingNumber}` : ""}` : "—"],
79
+ ["Refusal rate", d.refusal ? `${pct(d.refusal.rate)} (${d.refusal.refused} refused, ${d.refusal.delivered} delivered)` : "—"],
80
+ ["Risk", d.risk ? `${d.risk.band} (${d.risk.score})` : "—"],
81
+ ["Tags", d.tags.join(", ") || "—"],
82
+ ]),
83
+ "",
84
+ ...table([["Line", "Item", "SKU", "Qty", "Total"], ...d.lines.map((l) => [l.id, l.variant && !/^default/i.test(l.variant) ? `${l.title} — ${l.variant}` : l.title, l.sku ?? "—", String(l.quantity), l.total.formatted])]),
85
+ `Subtotal ${show(d.totals.subtotal)} · discount ${show(d.totals.discount)} · delivery ${show(d.totals.delivery)} · total ${show(d.totals.total)}`,
86
+ ...(d.editWaitingForApproval ? [`An edit is waiting for an admin's approval: ${d.editWaitingForApproval.reason}`] : []),
87
+ `Next steps: ${d.allowedTransitions.map((t) => (t.permitted ? t.to : `${t.to} (not allowed for you)`)).join(", ") || "none"}`,
88
+ "",
89
+ ...d.timeline.slice(0, 15).map((e) => ` ${day(e.at)} ${e.type === "status" ? `${e.from ?? "—"} → ${e.to}` : e.type === "comment" ? `comment: ${e.body}` : e.type === "call" ? `call: ${e.outcome}` : e.type}${e.by ? ` (${e.by})` : ""}`),
90
+ ]);
91
+ },
92
+ },
93
+ "order transition": {
94
+ usage: "cerkel order transition <number|id> <status> [--note <reason>] [--store <slug>] [--yes] [--dry-run] [--json]",
95
+ summary: "Move an order one cash-on-delivery step (confirmed, cancelled, delivered, …)",
96
+ help: [
97
+ "The admin's rules: confirming / confirmed / unreachable need permission to confirm, cancelled needs permission to cancel and --note.",
98
+ "Handing an order to the courier (dispatched) happens on a hand-over list in the admin, and cash steps in the courier payments — never here.",
99
+ WRITE_HELP,
100
+ ],
101
+ options: { ...WRITE_OPTIONS, note: { type: "string" } },
102
+ positionals: true,
103
+ run: (s, v, pos) => {
104
+ need(pos, 2, "cerkel order transition <number> <status> [--note <reason>]");
105
+ return runWrite(s, flagsOf(v), "transition_order", { order: pos[0], to: pos[1], ...(str(v, "note") ? { note: str(v, "note") } : {}) });
106
+ },
107
+ },
108
+ "order note": {
109
+ usage: "cerkel order note <number|id> <text…> [--store <slug>] [--yes] [--dry-run] [--json]",
110
+ summary: "Add a staff comment to an order's timeline",
111
+ help: ["Never shown to the customer.", WRITE_HELP],
112
+ options: WRITE_OPTIONS,
113
+ positionals: true,
114
+ run: (s, v, pos) => {
115
+ need(pos, 2, "cerkel order note <number> <text…>");
116
+ return runWrite(s, flagsOf(v), "add_order_note", { order: pos[0], body: textOf(pos, 1) });
117
+ },
118
+ },
119
+ "order tag": {
120
+ usage: "cerkel order tag <number|id> [--add <t>]… [--remove <t>]… [--set <t,t>] [--store <slug>] [--yes] [--dry-run] [--json]",
121
+ summary: "Add, remove or replace an order's tags",
122
+ help: ["--set replaces every staff tag (--set \"\" clears them).", WRITE_HELP],
123
+ options: { ...WRITE_OPTIONS, add: { type: "string", multiple: true }, remove: { type: "string", multiple: true }, set: { type: "string" } },
124
+ positionals: true,
125
+ run: (s, v, pos) => {
126
+ need(pos, 1, "cerkel order tag <number> --add <tag> | --remove <tag> | --set <tags>");
127
+ const set = str(v, "set");
128
+ return runWrite(s, flagsOf(v), "set_order_tags", {
129
+ order: pos[0],
130
+ ...(set !== undefined ? { tags: set.split(",").map((t) => t.trim()).filter(Boolean) } : {}),
131
+ ...(listFlag(v, "add") ? { add: listFlag(v, "add") } : {}), ...(listFlag(v, "remove") ? { remove: listFlag(v, "remove") } : {}),
132
+ });
133
+ },
134
+ },
135
+ "order hold": {
136
+ usage: "cerkel order hold <number|id> --reason <reason> [--note <text>] [--until <YYYY-MM-DD>] [--store <slug>] [--yes] [--dry-run] [--json]",
137
+ summary: "Put an order's fulfillment on hold",
138
+ help: [
139
+ "Reasons: incorrect_address, out_of_stock, high_risk, awaiting_deposit, customer_later (with --until), other (with --note).",
140
+ "A held order cannot be packed or handed to the courier until `cerkel order release`.",
141
+ WRITE_HELP,
142
+ ],
143
+ options: { ...WRITE_OPTIONS, reason: { type: "string" }, note: { type: "string" }, until: { type: "string" } },
144
+ positionals: true,
145
+ run: (s, v, pos) => {
146
+ need(pos, 1, "cerkel order hold <number> --reason <reason>");
147
+ if (!str(v, "reason"))
148
+ throw new CliError("--reason is required: incorrect_address, out_of_stock, high_risk, awaiting_deposit, customer_later or other.");
149
+ return runWrite(s, flagsOf(v), "hold_order", {
150
+ order: pos[0], reason: str(v, "reason"), ...(str(v, "note") ? { note: str(v, "note") } : {}), ...(str(v, "until") ? { until: str(v, "until") } : {}),
151
+ });
152
+ },
153
+ },
154
+ "order release": {
155
+ usage: "cerkel order release <number|id> [--store <slug>] [--yes] [--dry-run] [--json]",
156
+ summary: "Release an order's fulfillment hold",
157
+ help: [WRITE_HELP],
158
+ options: WRITE_OPTIONS,
159
+ positionals: true,
160
+ run: (s, v, pos) => {
161
+ need(pos, 1, "cerkel order release <number>");
162
+ return runWrite(s, flagsOf(v), "release_order", { order: pos[0] });
163
+ },
164
+ },
165
+ "order call": {
166
+ usage: "cerkel order call <number|id> <outcome> [--note <text>] [--at <ISO time>] [--store <slug>] [--yes] [--dry-run] [--json]",
167
+ summary: "Log a confirmation call (answered_confirmed, no_answer, busy, wrong_number, reschedule, cancelled)",
168
+ help: [
169
+ "no_answer / busy schedule the next call (+2 h, then +24 h; the third makes the order unreachable). reschedule needs --at.",
170
+ "cancelled cancels the order and needs --note with the reason.",
171
+ WRITE_HELP,
172
+ ],
173
+ options: { ...WRITE_OPTIONS, note: { type: "string" }, at: { type: "string" } },
174
+ positionals: true,
175
+ run: (s, v, pos) => {
176
+ need(pos, 2, "cerkel order call <number> <outcome>");
177
+ return runWrite(s, flagsOf(v), "record_call_attempt", {
178
+ order: pos[0], outcome: pos[1], ...(str(v, "note") ? { note: str(v, "note") } : {}), ...(str(v, "at") ? { reschedule_at: str(v, "at") } : {}),
179
+ });
180
+ },
181
+ },
182
+ "order edit": {
183
+ usage: "cerkel order edit <number|id> [--qty <sku|line>=<n>]… [--remove <sku|line>]… [--add <sku>=<n>]… [--add-offer <id>=<n>]… [--add-addon <id>=<n>]… [--delivery-fee <amount|zone>] [--discount <amount|n%|none>] [--name …] [--phone …] [--phone-alt …] [--governorate <code>] [--city …] [--district …] [--landmark …] [--address …] [--reason <text>] [--notify] [--store <slug>] [--yes] [--dry-run] [--json]",
184
+ summary: "Edit an order's items, discount, delivery fee or address (the admin's Edit order)",
185
+ help: [
186
+ "Name a line by its SKU (when only one line has it) or by its line id from `cerkel order get` (the first 8 characters are enough).",
187
+ "Once the courier has the order, nothing can be added and the edit needs an admin's approval: without that right it is submitted for approval (--reason required).",
188
+ "--delivery-fee zone puts the fee back under the delivery zone; --notify sends the customer the WhatsApp update when the total changes.",
189
+ WRITE_HELP,
190
+ ],
191
+ options: EDIT_OPTIONS,
192
+ positionals: true,
193
+ run: (s, v, pos) => {
194
+ need(pos, 1, "cerkel order edit <number> [changes…]");
195
+ const lines = [
196
+ ...pairsFlag(v, "qty", "<sku|line>").map((p) => ({ line_id: p.key, quantity: p.quantity })),
197
+ ...(listFlag(v, "remove") ?? []).map((id) => ({ line_id: id, quantity: 0 })),
198
+ ];
199
+ const add = [
200
+ ...pairsFlag(v, "add", "<sku>").map((p) => ({ variant: p.key, quantity: p.quantity })),
201
+ ...pairsFlag(v, "add-offer", "<offer id>").map((p) => ({ offer: p.key, quantity: p.quantity })),
202
+ ...pairsFlag(v, "add-addon", "<add-on id>").map((p) => ({ addon: p.key, quantity: p.quantity })),
203
+ ];
204
+ const fee = str(v, "delivery-fee");
205
+ const discount = discountFlag(v);
206
+ const contactKeys = [["name", "name"], ["phone", "phone"], ["phone-alt", "phone_alt"], ["governorate", "governorate"], ["city", "city"], ["district", "district"], ["landmark", "landmark"], ["address", "address"]];
207
+ const contact = Object.fromEntries(contactKeys.filter(([flag]) => str(v, flag) !== undefined).map(([flag, key]) => [key, str(v, flag).trim() || null]));
208
+ return runWrite(s, flagsOf(v), "edit_order", {
209
+ order: pos[0],
210
+ ...(lines.length ? { lines } : {}), ...(add.length ? { add } : {}),
211
+ ...(fee !== undefined ? { delivery_fee: fee.trim().toLowerCase() === "zone" ? null : moneyFlag(fee) } : {}),
212
+ ...(discount !== undefined ? { discount } : {}),
213
+ ...(Object.keys(contact).length ? { contact } : {}),
214
+ ...(str(v, "reason") ? { reason: str(v, "reason") } : {}),
215
+ ...(bool(v, "notify") ? { notify_customer: true } : {}),
216
+ });
217
+ },
218
+ },
219
+ };
@@ -0,0 +1,55 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { str } from "../command.js";
4
+ import { WRITE_HELP, WRITE_OPTIONS, flagsOf, moneyFlag, runWrite } from "../commerce.js";
5
+ import { CliError } from "../errors.js";
6
+ /** `cerkel price …` — set_variant_price and bulk_set_prices. */
7
+ export const PRICE_COMMANDS = {
8
+ "price set": {
9
+ usage: "cerkel price set <sku|barcode|variant-id> <price> [--compare-at <amount|none>] [--cost <amount|none>] [--store <slug>] [--yes] [--dry-run] [--json]",
10
+ summary: "Set a variant's price (and compare-at price / cost) in the store",
11
+ help: [
12
+ "Amounts are in the store's currency, as you would write them: 18.000 or 18 (JOD), 12500 (IQD, steps of 250).",
13
+ "Give the price as \"-\" to change only --compare-at or --cost. --compare-at none removes the compare-at price.",
14
+ WRITE_HELP,
15
+ ],
16
+ options: { ...WRITE_OPTIONS, "compare-at": { type: "string" }, cost: { type: "string" } },
17
+ positionals: true,
18
+ run: (s, v, pos) => {
19
+ const [variant, price, extra] = pos;
20
+ if (!variant || price === undefined || extra !== undefined)
21
+ throw new CliError("Usage: cerkel price set <sku> <price> [--compare-at <amount>] [--cost <amount>]");
22
+ const compareAt = moneyFlag(str(v, "compare-at"));
23
+ const cost = moneyFlag(str(v, "cost"));
24
+ return runWrite(s, flagsOf(v), "set_variant_price", {
25
+ variant,
26
+ ...(price.trim() === "-" ? {} : { price: price.trim() }),
27
+ ...(compareAt !== undefined ? { compareAt } : {}),
28
+ ...(cost !== undefined ? { cost } : {}),
29
+ });
30
+ },
31
+ }, "price import": {
32
+ usage: "cerkel price import <prices.csv|-> [--store <slug>] [--yes] [--dry-run] [--json]",
33
+ summary: "Set many prices from a CSV (sku, price, compare_at, cost)",
34
+ help: [
35
+ "Columns: sku (or barcode / variant_id), then any of price, compare_at, cost. A blank cell keeps that value; \"none\" clears compare_at or cost.",
36
+ "Commas, semicolons or tabs; Arabic headers work. All or nothing: one bad row refuses the whole file and the rows are listed. - reads the CSV from stdin.",
37
+ WRITE_HELP,
38
+ ],
39
+ options: WRITE_OPTIONS,
40
+ positionals: true,
41
+ run: async (s, v, pos) => {
42
+ const [file, extra] = pos;
43
+ if (!file || extra !== undefined)
44
+ throw new CliError("Usage: cerkel price import <prices.csv>");
45
+ let csv;
46
+ try {
47
+ csv = file === "-" ? await s.ctx.io.readStdin() : await fs.readFile(path.resolve(s.ctx.cwd, file), "utf8");
48
+ }
49
+ catch {
50
+ throw new CliError(`Cannot read ${file}.`);
51
+ }
52
+ return runWrite(s, flagsOf(v), "bulk_set_prices", { csv });
53
+ },
54
+ },
55
+ };
@@ -0,0 +1,265 @@
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
+ import { LANG_OPTION, TEXT_HELP, csvList, defined, int, list, localized, nullable, pairs, textOf } from "./args.js";
6
+ const one = (pos, usage) => {
7
+ if (pos.length !== 1 || !pos[0])
8
+ throw new CliError(`Usage: ${usage}`);
9
+ return pos[0];
10
+ };
11
+ const priceRange = (p) => !p?.min ? "—" : p.min.amount === p.max?.amount ? show(p.min) : `${show(p.min)} – ${show(p.max)}`;
12
+ const PRODUCT_TEXT_OPTIONS = {
13
+ ...LANG_OPTION,
14
+ title: { type: "string", multiple: true },
15
+ description: { type: "string", multiple: true },
16
+ handle: { type: "string" },
17
+ vendor: { type: "string" },
18
+ type: { type: "string" },
19
+ tags: { type: "string" },
20
+ };
21
+ export const PRODUCT_COMMANDS = {
22
+ "product list": {
23
+ usage: "cerkel product list [search] [--status active|draft|archived] [--collection <handle>] [--category <path>] [--low|--out] [--published|--hidden] [--page N] [--store <slug>] [--json]",
24
+ summary: "Search the store's products",
25
+ help: ["Matches titles in any language, handles, SKUs, barcodes, vendor, type and tags.", READ_HELP],
26
+ options: {
27
+ ...READ_OPTIONS, status: { type: "string" }, collection: { type: "string" }, category: { type: "string" },
28
+ low: { type: "boolean" }, out: { type: "boolean" }, published: { type: "boolean" }, hidden: { type: "boolean" }, page: { type: "string" }, limit: { type: "string" },
29
+ },
30
+ positionals: true,
31
+ run: (s, v, pos) => runRead(s, flagsOf(v), "search_products", defined({
32
+ q: pos.join(" ").trim() || undefined,
33
+ status: str(v, "status"), collection: str(v, "collection"), category: str(v, "category"),
34
+ stock: bool(v, "low") && bool(v, "out") ? "low_or_out" : bool(v, "low") ? "low" : bool(v, "out") ? "out" : undefined,
35
+ published: bool(v, "published") ? true : bool(v, "hidden") ? false : undefined,
36
+ page: int(str(v, "page"), "--page"), limit: int(str(v, "limit"), "--limit"),
37
+ }), (d) => d.products.length === 0 ? ["No products match."] : [
38
+ ...table([["HANDLE", "STATUS", "SHOWN", "PRICE", "STOCK", "TITLE"], ...d.products.map((p) => [
39
+ p.handle, p.status, p.published ? "yes" : "no", priceRange(p.price),
40
+ p.stock ? (p.stock.out ? "out" : p.stock.available === null ? "—" : `${p.stock.available}${p.stock.low ? " (low)" : ""}`) : "—",
41
+ textOf(p.title),
42
+ ])]),
43
+ `${d.products.length} of ${d.total} (page ${d.page})`,
44
+ ]),
45
+ },
46
+ "product get": {
47
+ usage: "cerkel product get <handle|id> [--store <slug>] [--json]",
48
+ summary: "Show a product: variants, this store's prices, stock, bundles and add-ons",
49
+ help: [READ_HELP],
50
+ options: READ_OPTIONS,
51
+ positionals: true,
52
+ run: (s, v, pos) => runRead(s, flagsOf(v), "get_product", { product: one(pos, "cerkel product get <handle>") }, (p) => [
53
+ ...table([
54
+ ["Product", `${textOf(p.title)} (${p.handle})`],
55
+ ["Status", `${p.status}, ${p.publication.published ? "shown in this store" : "hidden in this store"}`],
56
+ ["Vendor / type", `${p.vendor ?? "—"} / ${p.productType ?? "—"}`],
57
+ ["Category", p.category?.path ?? "—"],
58
+ ["Tags", p.tags.join(", ") || "—"],
59
+ ["Options", p.options.map((o) => `${textOf(o.name)}: ${o.values.map(textOf).join(", ")}`).join("; ") || "—"],
60
+ ["Images", String(p.images.length)],
61
+ ["Collections", p.collections.map((c) => c.handle).join(", ") || "—"],
62
+ ["Bundles / add-ons", `${p.offers.length} / ${p.addons.length}`],
63
+ ]),
64
+ "",
65
+ ...table([["SKU", "VARIANT", "PRICE", "COMPARE-AT", "COST", "AVAILABLE"], ...p.variants.map((x) => [
66
+ x.sku ?? "—", x.title || "—", show(x.price), show(x.compareAt), show(x.cost), x.stock ? (x.stock.tracked ? String(x.stock.available) : "not tracked") : "—",
67
+ ])]),
68
+ ]),
69
+ },
70
+ "product create": {
71
+ usage: "cerkel product create --title <text> --price <amount> [--compare-at <amount>] [--cost <amount>] [--sku <sku>] [--quantity N] [--image <url>]… [--active] [--publish] [--yes] [--dry-run] [--json]",
72
+ summary: "Create a product with one variant (a hidden draft unless --active --publish)",
73
+ help: ["Amounts are in the store's currency (18.000 JOD, 12500 IQD in steps of 250). --quantity tracks stock from that number.", TEXT_HELP, WRITE_HELP],
74
+ options: {
75
+ ...WRITE_OPTIONS, ...PRODUCT_TEXT_OPTIONS, price: { type: "string" }, "compare-at": { type: "string" }, cost: { type: "string" },
76
+ sku: { type: "string" }, barcode: { type: "string" }, quantity: { type: "string" }, image: { type: "string", multiple: true },
77
+ active: { type: "boolean" }, publish: { type: "boolean" },
78
+ },
79
+ run: (s, v) => {
80
+ const title = localized(v, "title");
81
+ const price = str(v, "price");
82
+ if (!title || !price)
83
+ throw new CliError("cerkel product create needs --title and --price.");
84
+ return runWrite(s, flagsOf(v), "create_product", defined({
85
+ title, descriptionHtml: localized(v, "description"), handle: str(v, "handle"), price, compareAt: moneyFlag(str(v, "compare-at")) ?? undefined,
86
+ cost: moneyFlag(str(v, "cost")) ?? undefined, sku: str(v, "sku"), barcode: str(v, "barcode"), vendor: str(v, "vendor"), productType: str(v, "type"),
87
+ tags: csvList(str(v, "tags")), quantity: int(str(v, "quantity"), "--quantity"), images: list(v, "image").length ? list(v, "image") : undefined,
88
+ status: bool(v, "active") ? "active" : undefined, publish: bool(v, "publish") || undefined,
89
+ }));
90
+ },
91
+ },
92
+ "product update": {
93
+ usage: "cerkel product update <handle|id> [--title …] [--description …] [--seo-title …] [--seo-description …] [--handle <new>] [--vendor <v|none>] [--type <t|none>] [--tags a,b] [--category <path|none>] [--field key=value]… [--yes] [--dry-run] [--json]",
94
+ summary: "Change a product's title, description, SEO, handle, vendor, type, tags, category or custom fields",
95
+ help: ["Only what you pass changes. These details are shared by every store of the brand.", TEXT_HELP, "--field takes key=value (JSON values allowed; key=null clears it).", WRITE_HELP],
96
+ options: {
97
+ ...WRITE_OPTIONS, ...PRODUCT_TEXT_OPTIONS, "seo-title": { type: "string", multiple: true }, "seo-description": { type: "string", multiple: true },
98
+ category: { type: "string" }, field: { type: "string", multiple: true },
99
+ },
100
+ positionals: true,
101
+ run: (s, v, pos) => runWrite(s, flagsOf(v), "update_product", defined({
102
+ product: one(pos, "cerkel product update <handle> [flags]"),
103
+ title: localized(v, "title"), descriptionHtml: localized(v, "description"), seoTitle: localized(v, "seo-title"), seoDescription: localized(v, "seo-description"),
104
+ handle: str(v, "handle"), vendor: nullable(str(v, "vendor")), productType: nullable(str(v, "type")), tags: csvList(str(v, "tags")),
105
+ category: nullable(str(v, "category")), customFields: pairs(v, "field"),
106
+ })),
107
+ },
108
+ "product status": {
109
+ usage: "cerkel product status <active|draft|archived> <handle|id>… [--yes] [--dry-run] [--json]",
110
+ summary: "Set one or more products to active, draft or archived",
111
+ help: ["Applies in every store of the brand; several products are all-or-nothing.", WRITE_HELP],
112
+ options: WRITE_OPTIONS,
113
+ positionals: true,
114
+ run: (s, v, pos) => {
115
+ const [status, ...refs] = pos;
116
+ if (!status || !refs.length)
117
+ throw new CliError("Usage: cerkel product status <active|draft|archived> <handle>…");
118
+ return refs.length === 1
119
+ ? runWrite(s, flagsOf(v), "set_product_status", { product: refs[0], status })
120
+ : runWrite(s, flagsOf(v), "bulk_update_product_status", { products: refs, status });
121
+ },
122
+ },
123
+ "product publish": {
124
+ usage: "cerkel product publish <handle|id> [--yes] [--dry-run] [--json]",
125
+ summary: "Show a product in this store's storefront",
126
+ help: ["Customers see it while its status is active. Other stores are not touched.", WRITE_HELP],
127
+ options: WRITE_OPTIONS,
128
+ positionals: true,
129
+ run: (s, v, pos) => runWrite(s, flagsOf(v), "publish_product", { product: one(pos, "cerkel product publish <handle>") }),
130
+ },
131
+ "product unpublish": {
132
+ usage: "cerkel product unpublish <handle|id> [--yes] [--dry-run] [--json]",
133
+ summary: "Hide a product from this store's storefront",
134
+ help: [WRITE_HELP],
135
+ options: WRITE_OPTIONS,
136
+ positionals: true,
137
+ run: (s, v, pos) => runWrite(s, flagsOf(v), "unpublish_product", { product: one(pos, "cerkel product unpublish <handle>") }),
138
+ },
139
+ "product duplicate": {
140
+ usage: "cerkel product duplicate <handle|id> [--yes] [--dry-run] [--json]",
141
+ summary: "Copy a product into a new draft (<handle>-copy)",
142
+ help: ["SKUs and barcodes are not copied; nothing is priced or published yet.", WRITE_HELP],
143
+ options: WRITE_OPTIONS,
144
+ positionals: true,
145
+ run: (s, v, pos) => runWrite(s, flagsOf(v), "duplicate_product", { product: one(pos, "cerkel product duplicate <handle>") }),
146
+ },
147
+ "variant set": {
148
+ usage: "cerkel variant set <sku|barcode|id> [--sku <new|none>] [--barcode <code|none>] [--weight <grams|none>] [--yes] [--dry-run] [--json]",
149
+ summary: "Change a variant's SKU, barcode or weight",
150
+ help: ["Prices: cerkel price set. Stock: cerkel inventory.", WRITE_HELP],
151
+ options: { ...WRITE_OPTIONS, sku: { type: "string" }, barcode: { type: "string" }, weight: { type: "string" } },
152
+ positionals: true,
153
+ run: (s, v, pos) => {
154
+ const weight = nullable(str(v, "weight"));
155
+ return runWrite(s, flagsOf(v), "upsert_variant", defined({
156
+ variant: one(pos, "cerkel variant set <sku> [flags]"), sku: nullable(str(v, "sku")), barcode: nullable(str(v, "barcode")),
157
+ weightGrams: weight === undefined || weight === null ? weight : int(weight, "--weight"),
158
+ }));
159
+ },
160
+ },
161
+ "variant add": {
162
+ usage: "cerkel variant add <product> --option <Name=Value>… [--sku <sku>] [--barcode <code>] [--weight <grams>] [--yes] [--dry-run] [--json]",
163
+ summary: "Add a variant for a combination of existing option values",
164
+ help: ["e.g. cerkel variant add knee-support --option Size=L --option Color=Black --sku KS-L-BLK. New values: cerkel variant options first. Price it with cerkel price set.", WRITE_HELP],
165
+ options: { ...WRITE_OPTIONS, option: { type: "string", multiple: true }, sku: { type: "string" }, barcode: { type: "string" }, weight: { type: "string" } },
166
+ positionals: true,
167
+ run: (s, v, pos) => runWrite(s, flagsOf(v), "upsert_variant", defined({
168
+ product: one(pos, "cerkel variant add <product> --option Name=Value"), optionValues: pairs(v, "option", { loose: false }),
169
+ sku: str(v, "sku"), barcode: str(v, "barcode"), weightGrams: int(str(v, "weight"), "--weight"),
170
+ })),
171
+ },
172
+ "variant delete": {
173
+ usage: "cerkel variant delete <sku|barcode|id> [--yes] [--dry-run] [--json]",
174
+ summary: "Delete a variant (refused while it is on an unfinished order)",
175
+ help: [WRITE_HELP],
176
+ options: WRITE_OPTIONS,
177
+ positionals: true,
178
+ run: (s, v, pos) => runWrite(s, flagsOf(v), "delete_variant", { variant: one(pos, "cerkel variant delete <sku>") }),
179
+ },
180
+ "variant options": {
181
+ usage: "cerkel variant options <product> --option \"Size=S,M,L\" [--option \"Color=Red,Blue\"]… [--create-variants] [--lang en] [--yes] [--dry-run] [--json]",
182
+ summary: "Set a product's options and values (the variant matrix)",
183
+ help: [
184
+ "List every option with all its values, in order. Values you leave out must not be used by a variant. --create-variants adds a variant for every missing combination.",
185
+ "Names and values are in --lang (default ar); other languages stored on them are kept. Renames: use the admin or cerkel call set_product_options.",
186
+ WRITE_HELP,
187
+ ],
188
+ options: { ...WRITE_OPTIONS, ...LANG_OPTION, option: { type: "string", multiple: true }, "create-variants": { type: "boolean" } },
189
+ positionals: true,
190
+ run: (s, v, pos) => {
191
+ const lang = str(v, "lang") ?? "ar";
192
+ const options = list(v, "option").map((o) => {
193
+ const i = o.indexOf("=");
194
+ if (i < 1)
195
+ throw new CliError(`--option "${o}": write it as Name=Value1,Value2.`);
196
+ return { name: { [lang]: o.slice(0, i).trim() }, values: (csvList(o.slice(i + 1)) ?? []).map((x) => ({ label: { [lang]: x } })) };
197
+ });
198
+ return runWrite(s, flagsOf(v), "set_product_options", defined({
199
+ product: one(pos, "cerkel variant options <product> --option Name=V1,V2"), options, createVariants: bool(v, "create-variants") || undefined,
200
+ }));
201
+ },
202
+ },
203
+ "image add": {
204
+ usage: "cerkel image add <product> <https-url|media-id>… [--alt <text>] [--yes] [--dry-run] [--json]",
205
+ summary: "Add images to a product (by URL or media library id)",
206
+ help: ["Images are shared by every store of the brand and go after the existing ones.", WRITE_HELP],
207
+ options: { ...WRITE_OPTIONS, alt: { type: "string" } },
208
+ positionals: true,
209
+ run: (s, v, pos) => {
210
+ const [product, ...refs] = pos;
211
+ if (!product || !refs.length)
212
+ throw new CliError("Usage: cerkel image add <product> <url>…");
213
+ const alt = str(v, "alt");
214
+ return runWrite(s, flagsOf(v), "add_product_images", {
215
+ product, images: refs.map((r) => ({ ...(/^[0-9a-f-]{36}$/i.test(r) ? { mediaId: r } : { url: r }), ...(alt ? { alt } : {}) })),
216
+ });
217
+ },
218
+ },
219
+ "image remove": {
220
+ usage: "cerkel image remove <product> <image-id|url>… [--yes] [--dry-run] [--json]",
221
+ summary: "Remove images from a product",
222
+ help: [WRITE_HELP],
223
+ options: WRITE_OPTIONS,
224
+ positionals: true,
225
+ run: (s, v, pos) => {
226
+ const [product, ...images] = pos;
227
+ if (!product || !images.length)
228
+ throw new CliError("Usage: cerkel image remove <product> <image-id|url>…");
229
+ return runWrite(s, flagsOf(v), "remove_product_images", { product, images });
230
+ },
231
+ },
232
+ "image reorder": {
233
+ usage: "cerkel image reorder <product> <image-id|url>… [--yes] [--dry-run] [--json]",
234
+ summary: "Put a product's images in this order (the first is the main image)",
235
+ help: [WRITE_HELP],
236
+ options: WRITE_OPTIONS,
237
+ positionals: true,
238
+ run: (s, v, pos) => {
239
+ const [product, ...images] = pos;
240
+ if (!product || !images.length)
241
+ throw new CliError("Usage: cerkel image reorder <product> <image-id|url>…");
242
+ return runWrite(s, flagsOf(v), "reorder_product_images", { product, images });
243
+ },
244
+ },
245
+ "category list": {
246
+ usage: "cerkel category list [--store <slug>] [--json]",
247
+ summary: "Show the category tree",
248
+ help: [READ_HELP],
249
+ options: READ_OPTIONS,
250
+ run: (s, v) => runRead(s, flagsOf(v), "list_categories", {}, (d) => d.categories.length === 0 ? ["No categories yet."] : table([["PATH", "NAME", "PRODUCTS"], ...d.categories.map((c) => [c.path, textOf(c.name), String(c.products.total)])])),
251
+ },
252
+ "category set": {
253
+ usage: "cerkel category set <product> <category-path|none> [--yes] [--dry-run] [--json]",
254
+ summary: "Put a product in a category (or none)",
255
+ help: [WRITE_HELP],
256
+ options: WRITE_OPTIONS,
257
+ positionals: true,
258
+ run: (s, v, pos) => {
259
+ const [product, category, extra] = pos;
260
+ if (!product || category === undefined || extra !== undefined)
261
+ throw new CliError("Usage: cerkel category set <product> <category-path|none>");
262
+ return runWrite(s, flagsOf(v), "set_product_category", { product, category: nullable(category) ?? null });
263
+ },
264
+ },
265
+ };