@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
package/dist/cli.js ADDED
@@ -0,0 +1,497 @@
1
+ import { parseArgs } from "node:util";
2
+ import { ApiUrlError, DEFAULT_API_URL } from "./api-url.js";
3
+ import { bool, str } from "./command.js";
4
+ import { login, logins, logout, stores, whoami } from "./commands/auth.js";
5
+ import { themeDev } from "./commands/dev.js";
6
+ import { COMMERCE_COMMANDS } from "./commands/index.js";
7
+ import { deleteFiles, fileArg, fileHistory, fileRestore, sectionDelete, sectionDuplicate, sectionHistory, sectionNew, sectionRestore } from "./commands/section.js";
8
+ import { themeCheck, themeList, themePreview, themePull, themePush, themeSubmit } from "./commands/theme.js";
9
+ import { themeConsole, themeDeleteTheme, themeInfo, themeInit, themeOpen, themePackage, themeProfile, themePushNew, themeRename, themeShare } from "./commands/theme-tools.js";
10
+ import { AuthError, CliError, RateLimitError, StoreLoginError, ToolRefusal, retryAt } from "./errors.js";
11
+ import { envTokenSecrets } from "./env.js";
12
+ import { makeRedactor } from "./redact.js";
13
+ import { Session } from "./session.js";
14
+ const GLOBAL = {
15
+ api: { type: "string" },
16
+ help: { type: "boolean", short: "h" },
17
+ };
18
+ /** --theme / --store, when given. */
19
+ const themeFlags = (v) => ({
20
+ ...(str(v, "theme") ? { theme: str(v, "theme") } : {}),
21
+ ...(str(v, "store") ? { store: str(v, "store") } : {}),
22
+ });
23
+ function limitOf(v) {
24
+ const limit = str(v, "limit") ? Number(str(v, "limit")) : undefined;
25
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1))
26
+ throw new CliError("--limit must be a positive whole number.");
27
+ return limit ? { limit } : {};
28
+ }
29
+ const COMMANDS = {
30
+ login: {
31
+ usage: "cerkel login --store <slug> [--token]",
32
+ summary: "Sign in to one store through the browser (or paste a token with --token)",
33
+ help: [
34
+ `Opens the Cerkel admin in your browser (${new URL(DEFAULT_API_URL).host}, or the host this command uses: --api, the theme`,
35
+ "folder's recorded API, or CERKEL_API_URL): sign in as usual, choose the store (--store picks it for you),",
36
+ "press Allow, and you are logged in to that store. A login is for ONE store: to work on another",
37
+ "store, run `cerkel login --store <its slug>` too — your other logins are kept. Inside a theme folder,",
38
+ "`cerkel login` signs in to the folder's store.",
39
+ "Logins are saved to the system keychain, or to ~/.config/cerkel/credentials (0600) when there",
40
+ "is no keychain. They follow your staff access and can be revoked in Settings → AI assistant",
41
+ "connection (MCP).",
42
+ "--token: paste a token created by hand for one store instead (e.g. on a server with no browser).",
43
+ "In scripts: echo \"$TOKEN\" | cerkel login. With CERKEL_TOKEN set, it verifies that token and saves nothing.",
44
+ ],
45
+ options: { token: { type: "boolean" }, store: { type: "string" } },
46
+ run: (s, v) => login(s, { token: bool(v, "token"), ...(str(v, "store") ? { store: str(v, "store") } : {}) }),
47
+ },
48
+ logout: {
49
+ usage: "cerkel logout [--store <slug> | --all]",
50
+ summary: "Remove a stored login",
51
+ help: [
52
+ "Removes this folder's store login inside a theme folder, the one given with --store, or your only",
53
+ "login. --all removes every login for this API. The token itself is revoked in Settings → MCP.",
54
+ ],
55
+ options: { store: { type: "string" }, all: { type: "boolean" } },
56
+ run: (s, v) => logout(s, { all: bool(v, "all"), ...(str(v, "store") ? { store: str(v, "store") } : {}) }),
57
+ },
58
+ logins: {
59
+ usage: "cerkel logins",
60
+ summary: "List your own logins on this computer, one per store",
61
+ help: ["Each one is checked with the server using that login's own token, and shown with its status."],
62
+ options: {},
63
+ run: (s) => logins(s),
64
+ },
65
+ whoami: {
66
+ usage: "cerkel whoami [--store <slug>] [--all]",
67
+ summary: "Show who the login belongs to and the one store it is for",
68
+ help: ["Uses this folder's store login, the one given with --store, or your only login. --all is `cerkel logins`."],
69
+ options: { store: { type: "string" }, all: { type: "boolean" } },
70
+ run: (s, v) => whoami(s, { all: bool(v, "all"), ...(str(v, "store") ? { store: str(v, "store") } : {}) }),
71
+ },
72
+ stores: {
73
+ usage: "cerkel stores [--store <slug>]",
74
+ summary: "Show the store this login is for",
75
+ help: ["A login reaches exactly one store. Uses this folder's store login, the one given with --store, or your only login."],
76
+ options: { store: { type: "string" } },
77
+ run: (s, v) => stores(s, { ...(str(v, "store") ? { store: str(v, "store") } : {}) }),
78
+ },
79
+ "theme list": {
80
+ usage: "cerkel theme list [--store <slug>]",
81
+ summary: "List the store's live and draft themes",
82
+ help: ["The store is --store, else this folder's store, else the store of your only login."],
83
+ options: { store: { type: "string" } },
84
+ run: (s, v) => themeList(s, { ...(str(v, "store") ? { store: str(v, "store") } : {}) }),
85
+ },
86
+ "theme pull": {
87
+ usage: "cerkel theme pull [--store <slug>] [--theme <id>] [--yes] [--dir <path>] [--force]\n cerkel theme pull [--store <slug>] --live --dir <path>",
88
+ summary: "Download a draft theme into this folder",
89
+ help: [
90
+ "The store is --store, else this folder's store, else the store of your only login. A folder holds",
91
+ "one store's theme: pull another store into a folder of its own (--dir).",
92
+ "Picks the newest draft unless --theme is given; offers to create one from the live theme",
93
+ "when there is none (--yes answers for you). The live theme is never pulled for editing:",
94
+ "--live writes a READ-ONLY reference copy into --dir, where push, dev and submit refuse to run.",
95
+ "Refuses to overwrite local changes unless --force.",
96
+ ],
97
+ options: {
98
+ store: { type: "string" },
99
+ theme: { type: "string" },
100
+ live: { type: "boolean" },
101
+ yes: { type: "boolean", short: "y" },
102
+ dir: { type: "string" },
103
+ force: { type: "boolean" },
104
+ },
105
+ run: (s, v) => themePull(s, {
106
+ ...(str(v, "store") ? { store: str(v, "store") } : {}),
107
+ ...(str(v, "theme") ? { theme: str(v, "theme") } : {}),
108
+ ...(str(v, "dir") ? { dir: str(v, "dir") } : {}),
109
+ live: bool(v, "live"),
110
+ yes: bool(v, "yes"),
111
+ force: bool(v, "force"),
112
+ }),
113
+ },
114
+ "theme check": {
115
+ usage: "cerkel theme check [--all] [--store <slug>]",
116
+ summary: "Validate theme files on the server (nothing is saved)",
117
+ help: [
118
+ "Checks changed files by default, every file with --all. Prints path:line:col severity message",
119
+ "and exits 1 on any error (warnings exit 0). Outside a checkout (CI), --store <slug> checks every file.",
120
+ ],
121
+ options: { all: { type: "boolean" }, store: { type: "string" } },
122
+ run: (s, v) => themeCheck(s, { all: bool(v, "all"), ...(str(v, "store") ? { store: str(v, "store") } : {}) }),
123
+ },
124
+ "theme push": {
125
+ usage: "cerkel theme push [files…] [--force [--yes]] [--nodelete]\n cerkel theme push --new [--name \"…\"] [--store <slug>]",
126
+ summary: "Upload changed files to the draft (--new: this folder → a new draft)",
127
+ help: [
128
+ "Sends only files that changed since the last pull/push. A file someone else changed on the",
129
+ "server is refused as a conflict and shown as a diff.",
130
+ "A section, block, snippet or asset file you deleted locally is deleted from the draft too",
131
+ "(only the copy you last synced; a newer server copy is a conflict). --nodelete keeps them.",
132
+ "--force overwrites the server's copy of those files. It first lists, per file, how many server",
133
+ "lines would be lost and asks y/N; --yes answers for you (required when there is no terminal).",
134
+ "--new (in a folder of theme files that is not a checkout yet, e.g. after `cerkel theme init`):",
135
+ "creates a NEW draft copied from the live theme, pushes every theme file in the folder onto it",
136
+ "and makes the folder that draft's checkout. --name names the draft (default: the folder's name).",
137
+ ],
138
+ options: { force: { type: "boolean" }, yes: { type: "boolean", short: "y" }, nodelete: { type: "boolean" }, new: { type: "boolean" }, name: { type: "string" }, store: { type: "string" } },
139
+ run: (s, v, pos) => {
140
+ if (bool(v, "new")) {
141
+ if (pos.length > 0 || bool(v, "force") || bool(v, "nodelete"))
142
+ throw new CliError("--new pushes every theme file in this folder to a new draft; it takes no file names, --force or --nodelete.");
143
+ return themePushNew(s, { ...(str(v, "store") ? { store: str(v, "store") } : {}), ...(str(v, "name") ? { name: str(v, "name") } : {}) });
144
+ }
145
+ if (str(v, "name") || str(v, "store"))
146
+ throw new CliError("--name and --store go with --new. A checkout always pushes to its own draft.");
147
+ return themePush(s, { files: pos, force: bool(v, "force"), yes: bool(v, "yes"), noDelete: bool(v, "nodelete") });
148
+ },
149
+ },
150
+ "theme dev": {
151
+ usage: "cerkel theme dev",
152
+ summary: "Push on save and print a preview link on the real store domain",
153
+ help: [
154
+ "Pushes pending changes, then watches the folder and pushes each burst of saves as one batch. Ctrl-C stops.",
155
+ "Deleting a section, block, snippet or asset file deletes it from the draft (--nodelete keeps them).",
156
+ ],
157
+ options: { nodelete: { type: "boolean" } },
158
+ run: (s, v) => themeDev(s, { noDelete: bool(v, "nodelete") }),
159
+ },
160
+ "theme preview": {
161
+ usage: "cerkel theme preview [--open]",
162
+ summary: "Print a signed preview link (valid 30 minutes)",
163
+ help: [],
164
+ options: { open: { type: "boolean" } },
165
+ run: (s, v) => themePreview(s, { open: bool(v, "open") }),
166
+ },
167
+ "theme submit": {
168
+ usage: 'cerkel theme submit -m "title" [--description "…"]',
169
+ summary: "Send the draft for approval (for the store owner's login it publishes immediately)",
170
+ help: [
171
+ "Refuses while there are unpushed changes. Prints the request id and its admin link.",
172
+ "Sends the draft for approval; someone with publish rights publishes it in the Cerkel admin.",
173
+ "For the store owner's or an admin's login (it may publish in this store) and for logins of",
174
+ "Cerkel's own team (a @raizn.net or @raizn.com email) it",
175
+ "publishes immediately, and a rollback link is printed.",
176
+ ],
177
+ options: { message: { type: "string", short: "m" }, description: { type: "string" } },
178
+ run: (s, v) => themeSubmit(s, {
179
+ ...(str(v, "message") ? { title: str(v, "message") } : {}),
180
+ ...(str(v, "description") ? { description: str(v, "description") } : {}),
181
+ }),
182
+ },
183
+ "theme history": {
184
+ usage: "cerkel theme history <file> [--limit <n>]",
185
+ summary: "List a theme file's saved versions",
186
+ help: ["Every save, delete, restore and rollback of that path in any theme of the store, newest first."],
187
+ options: { limit: { type: "string" } },
188
+ run: async (s, v, pos) => {
189
+ const co = await s.requireCheckout({ editable: false });
190
+ return fileHistory(s, fileArg(s, co.root, pos[0]), limitOf(v));
191
+ },
192
+ },
193
+ "theme restore": {
194
+ usage: "cerkel theme restore <file> <version> [--force]",
195
+ summary: "Bring an old version of a file back into the draft",
196
+ help: ["History is never rewritten: the old content becomes a new version. The local file is refreshed."],
197
+ options: { force: { type: "boolean" } },
198
+ run: async (s, v, pos) => {
199
+ const co = await s.requireCheckout({ editable: true });
200
+ return fileRestore(s, fileArg(s, co.root, pos[0]), pos[1], { force: bool(v, "force") });
201
+ },
202
+ },
203
+ "theme delete": {
204
+ usage: "cerkel theme delete <file…>\n cerkel theme delete --theme <id> [--yes] [--store <slug>] [--json]",
205
+ summary: "Delete files from the draft, or (--theme) a whole draft theme",
206
+ help: [
207
+ "With file names: deletes those section, block, snippet or asset files from this folder's draft.",
208
+ "Refused for a section still placed by one of the draft's templates. Deleted files stay in their history.",
209
+ "With --theme <id>: deletes that DRAFT theme (never the live one, never one waiting for review).",
210
+ "On a terminal you type the theme's name to confirm; --yes skips that (required without a terminal).",
211
+ ],
212
+ options: { theme: { type: "string" }, yes: { type: "boolean", short: "y" }, store: { type: "string" }, json: { type: "boolean" } },
213
+ run: async (s, v, pos) => {
214
+ if (str(v, "theme")) {
215
+ if (pos.length > 0)
216
+ throw new CliError("Either delete files (`cerkel theme delete <file…>`) or a whole draft (`cerkel theme delete --theme <id>`), not both.");
217
+ return themeDeleteTheme(s, { theme: str(v, "theme"), yes: bool(v, "yes"), json: bool(v, "json"), ...(str(v, "store") ? { store: str(v, "store") } : {}) });
218
+ }
219
+ if (pos.length === 0)
220
+ throw new CliError("Which files? e.g. `cerkel theme delete snippets/old-badge.liquid`. To delete a whole draft: `cerkel theme delete --theme <id>`.");
221
+ const co = await s.requireCheckout({ editable: true });
222
+ return deleteFiles(s, pos.map((p) => fileArg(s, co.root, p)));
223
+ },
224
+ },
225
+ "theme info": {
226
+ usage: "cerkel theme info [--theme <id>] [--store <slug>] [--json]",
227
+ summary: "Show a theme: role, preset, templates, files, review state and its editor link",
228
+ help: ["The theme is --theme, else this folder's, else the store's live theme."],
229
+ options: { theme: { type: "string" }, store: { type: "string" }, json: { type: "boolean" } },
230
+ run: (s, v) => themeInfo(s, { json: bool(v, "json"), ...themeFlags(v) }),
231
+ },
232
+ "theme open": {
233
+ usage: "cerkel theme open [--theme <id>] [--code] [--print] [--store <slug>]",
234
+ summary: "Open the theme in the admin's visual editor (--code: the code editor)",
235
+ help: ["Prints the link and opens it in your browser (--print only prints it). The theme is --theme, else this folder's, else the live one."],
236
+ options: { theme: { type: "string" }, store: { type: "string" }, code: { type: "boolean" }, print: { type: "boolean" } },
237
+ run: (s, v) => themeOpen(s, { code: bool(v, "code"), print: bool(v, "print"), ...themeFlags(v) }),
238
+ },
239
+ "theme rename": {
240
+ usage: 'cerkel theme rename "<new name>" [--theme <id>] [--store <slug>] [--json]',
241
+ summary: "Rename a theme (this folder's draft, or --theme)",
242
+ help: ["The name is only shown to staff, so any theme may be renamed. A folder tracking that theme records the new name."],
243
+ options: { theme: { type: "string" }, store: { type: "string" }, json: { type: "boolean" } },
244
+ positionals: true,
245
+ run: (s, v, pos) => {
246
+ if (pos.length > 1)
247
+ throw new CliError('Put the name in quotes: cerkel theme rename "Ramadan 2027".');
248
+ return themeRename(s, pos[0], { json: bool(v, "json"), ...themeFlags(v) });
249
+ },
250
+ },
251
+ "theme share": {
252
+ usage: "cerkel theme share [--theme <id>] [--days <1-14>] [--path /products/<handle>] [--store <slug>] [--json]",
253
+ summary: "A preview link of the draft for someone else (7 days by default)",
254
+ help: [
255
+ "Anyone with the link sees the draft on the store's own domain until it expires (default 7 days, at most 14);",
256
+ "deleting the draft revokes it. It shows what is on the server: push first. The live theme needs no link.",
257
+ ],
258
+ options: { theme: { type: "string" }, store: { type: "string" }, days: { type: "string" }, path: { type: "string" }, json: { type: "boolean" } },
259
+ run: (s, v) => themeShare(s, { json: bool(v, "json"), ...themeFlags(v), ...(str(v, "days") ? { days: str(v, "days") } : {}), ...(str(v, "path") ? { path: str(v, "path") } : {}) }),
260
+ },
261
+ "theme console": {
262
+ usage: 'cerkel theme console [--path /products/<handle>] [--locale en] [--theme <id>] [-e "<liquid>"] [--json]',
263
+ summary: "A Liquid console against the store's real data",
264
+ help: [
265
+ "Evaluates Liquid on the store's own storefront with the objects a section on --path gets",
266
+ "(product, collection, page, article, the catalogue, menus, settings). Default page: the home page;",
267
+ "default theme: this folder's draft, else the live one. Type .help inside for its commands.",
268
+ "-e evaluates one expression and exits; piped stdin is evaluated as one template. Reads only.",
269
+ ],
270
+ options: { theme: { type: "string" }, store: { type: "string" }, path: { type: "string" }, locale: { type: "string" }, eval: { type: "string", short: "e" }, json: { type: "boolean" } },
271
+ run: (s, v) => themeConsole(s, {
272
+ json: bool(v, "json"), ...themeFlags(v),
273
+ ...(str(v, "path") ? { path: str(v, "path") } : {}), ...(str(v, "locale") ? { locale: str(v, "locale") } : {}), ...(str(v, "eval") !== undefined ? { eval: str(v, "eval") } : {}),
274
+ }),
275
+ },
276
+ "theme profile": {
277
+ usage: "cerkel theme profile [--path /products/<handle>] [--locale en] [--runs <1-5>] [--theme <id>] [--json]",
278
+ summary: "Server render time of each section on a page",
279
+ help: [
280
+ "Renders the page on the store's own storefront with the theme (this folder's draft, else the live one)",
281
+ "and lists every section in page order with its server time and what rendered it. --runs repeats it",
282
+ "and shows medians. Exits 1 when a section failed (its error is printed).",
283
+ ],
284
+ options: { theme: { type: "string" }, store: { type: "string" }, path: { type: "string" }, locale: { type: "string" }, runs: { type: "string" }, json: { type: "boolean" } },
285
+ run: (s, v) => themeProfile(s, {
286
+ json: bool(v, "json"), ...themeFlags(v),
287
+ ...(str(v, "path") ? { path: str(v, "path") } : {}), ...(str(v, "locale") ? { locale: str(v, "locale") } : {}), ...(str(v, "runs") ? { runs: str(v, "runs") } : {}),
288
+ }),
289
+ },
290
+ "theme package": {
291
+ usage: "cerkel theme package [--output <file>.zip] [--force] [--json]",
292
+ summary: "Zip this folder's theme files",
293
+ help: ["Packages the theme files of this folder as they are on disk (unpushed changes included) into a .zip. Nothing is sent."],
294
+ options: { output: { type: "string", short: "o" }, force: { type: "boolean" }, json: { type: "boolean" } },
295
+ run: (s, v) => themePackage(s, { force: bool(v, "force"), json: bool(v, "json"), ...(str(v, "output") ? { output: str(v, "output") } : {}) }),
296
+ },
297
+ "theme init": {
298
+ usage: "cerkel theme init [<folder>] [--starter <key>] [--list] [--json]",
299
+ summary: "Start a new theme folder from a starter theme",
300
+ help: [
301
+ "Writes a complete starter theme (Liquid sections, assets, templates and a README) into <folder>",
302
+ "(default ./cerkel-theme). Nothing is sent: `cerkel theme push --new` puts it on a new draft.",
303
+ "--list shows the starters.",
304
+ ],
305
+ options: { starter: { type: "string" }, list: { type: "boolean" }, store: { type: "string" }, json: { type: "boolean" } },
306
+ positionals: true,
307
+ run: (s, v, pos) => {
308
+ if (pos.length > 1)
309
+ throw new CliError("One folder at a time: cerkel theme init <folder>.");
310
+ return themeInit(s, pos[0], { list: bool(v, "list"), json: bool(v, "json"), ...(str(v, "starter") ? { starter: str(v, "starter") } : {}), ...(str(v, "store") ? { store: str(v, "store") } : {}) });
311
+ },
312
+ },
313
+ "section new": {
314
+ usage: "cerkel section new <type> [--starter <key>]",
315
+ summary: "Create a new kind of section locally from a starter",
316
+ help: [
317
+ "Writes sections/<type>.liquid — its Liquid, {% stylesheet %} and {% schema %} in ONE file.",
318
+ "The next push adds it to the draft; it goes live when the draft is approved and published.",
319
+ ],
320
+ options: { starter: { type: "string" } },
321
+ run: (s, v, pos) => sectionNew(s, pos[0], { ...(str(v, "starter") ? { starter: str(v, "starter") } : {}) }),
322
+ },
323
+ "section duplicate": {
324
+ usage: "cerkel section duplicate <type> <new-type>",
325
+ summary: "Copy a section file to a new type, to redesign it side by side",
326
+ help: ["Local only: the copy's name gets \"(copy)\". Push it, then place it instead of the original."],
327
+ options: {},
328
+ run: (s, _v, pos) => sectionDuplicate(s, pos[0], pos[1]),
329
+ },
330
+ "section delete": {
331
+ usage: "cerkel section delete <type>",
332
+ summary: "Delete a section file from the draft",
333
+ help: ["Refused while one of the draft's templates still places it. Its history stays."],
334
+ options: {},
335
+ run: (s, _v, pos) => sectionDelete(s, pos[0]),
336
+ },
337
+ "section history": {
338
+ usage: "cerkel section history <type> [--limit <n>]",
339
+ summary: "List a section file's versions",
340
+ help: ["Same as cerkel theme history sections/<type>.liquid."],
341
+ options: { limit: { type: "string" } },
342
+ run: (s, v, pos) => sectionHistory(s, pos[0], limitOf(v)),
343
+ },
344
+ "section restore": {
345
+ usage: "cerkel section restore <type> <version> [--force]",
346
+ summary: "Restore an old version of a section file into the draft",
347
+ help: ["History is never rewritten: the old file becomes a new version. The local file is refreshed."],
348
+ options: { force: { type: "boolean" } },
349
+ run: (s, v, pos) => sectionRestore(s, pos[0], pos[1], { force: bool(v, "force") }),
350
+ },
351
+ // The commerce commands (store, price, product, inventory, collection, … — commands/index.ts).
352
+ ...COMMERCE_COMMANDS,
353
+ };
354
+ /** Every word that starts a two-word command (`theme pull`, `price set`, …). */
355
+ const GROUPS = new Set(Object.keys(COMMANDS).filter((k) => k.includes(" ")).map((k) => k.split(" ")[0]));
356
+ /** Commands people type without their group (`cerkel pull`), and `cerkel help`. */
357
+ const HINTS = {
358
+ help: "cerkel --help",
359
+ ...Object.fromEntries(["pull", "push", "dev", "submit", "check", "preview", "list", "info", "open", "rename", "share", "console", "profile", "package", "init"].map((c) => [c, `cerkel theme ${c}`])),
360
+ ...Object.fromEntries(["new", "duplicate"].map((c) => [c, `cerkel section ${c}`])),
361
+ ...Object.fromEntries(["delete", "history", "restore"].map((c) => [c, `cerkel theme ${c}`])),
362
+ };
363
+ /** Words people reach for that this tool deliberately does not have. */
364
+ const NEVER = new Set(["publish", "unpublish", "rollback", "go-live", "golive", "release", "deploy"]);
365
+ const NEVER_MESSAGE = "does not exist: this CLI has no publish, rollback or deploy command.\n" +
366
+ '`cerkel theme submit -m "…"` sends the draft for approval; for the store owner\'s login it publishes immediately (a rollback link is printed).\n' +
367
+ "For everyone else, publishing happens in the Cerkel admin after approval, by someone with publish rights. Rollback is in the admin too.";
368
+ function mainHelp(version) {
369
+ const rows = Object.entries(COMMANDS).map(([name, c]) => [` ${name}`, c.summary]);
370
+ const w = Math.max(...rows.map((r) => r[0].length));
371
+ return [
372
+ `cerkel ${version} — run a Cerkel store from the terminal: its theme, pages and menus as local files, its catalogue, prices, stock, promo codes, delivery zones and reports.`,
373
+ "",
374
+ "Usage: cerkel <command> [options]",
375
+ "",
376
+ "Commands:",
377
+ ...rows.map(([a, b]) => `${a.padEnd(w)} ${b}`),
378
+ "",
379
+ "Global options:",
380
+ ` --api <url> API endpoint (env CERKEL_API_URL; default ${DEFAULT_API_URL})`,
381
+ " -h, --help Help for any command, e.g. cerkel theme pull --help",
382
+ " --version Print the version",
383
+ "",
384
+ "Environment: CERKEL_TOKEN is used instead of a stored login (CI). The old names RAIZN_TOKEN and",
385
+ "RAIZN_API_URL still work. `raizn` still runs this tool for now (it prints that it is now cerkel).",
386
+ "",
387
+ "Workflow: cerkel login --store <slug> → cerkel theme pull → cerkel theme dev → cerkel theme submit -m \"…\"",
388
+ "A new theme: cerkel theme init <folder> → cd <folder> → cerkel theme push --new → cerkel theme dev.",
389
+ "Each login is for one store, and each theme folder uses its own store's login. `cerkel logins` lists yours.",
390
+ "There is no theme publish command. submit sends the draft for approval; for the store owner's login it",
391
+ "publishes immediately (a rollback link is printed). Otherwise the owner approves and publishes it",
392
+ "in the Cerkel admin.",
393
+ ];
394
+ }
395
+ function commandHelp(c) {
396
+ return [`Usage: ${c.usage}`, "", c.summary + ".", ...(c.help.length ? ["", ...c.help] : []), "", "Global: --api <url>, -h/--help"];
397
+ }
398
+ /** Find the command words (`theme pull`) among argv, skipping flags and the value of `--api`. */
399
+ function splitCommand(argv) {
400
+ const words = [];
401
+ const rest = [];
402
+ for (let i = 0; i < argv.length; i++) {
403
+ const a = argv[i];
404
+ const wanted = words.length === 0 || (words.length === 1 && GROUPS.has(words[0]));
405
+ if (!wanted || a.startsWith("-")) {
406
+ rest.push(a);
407
+ if (a === "--api" && i + 1 < argv.length)
408
+ rest.push(argv[++i]);
409
+ continue;
410
+ }
411
+ words.push(a);
412
+ }
413
+ return { words, rest };
414
+ }
415
+ export async function main(argv, ctx) {
416
+ // Before a Session exists, still never echo anything token-shaped.
417
+ const redact = makeRedactor(() => envTokenSecrets(ctx.env));
418
+ const fail = (msg, code = 1) => {
419
+ ctx.io.err(redact(msg));
420
+ return code;
421
+ };
422
+ if (argv.includes("--version") || (argv.length === 1 && argv[0] === "-v")) {
423
+ ctx.io.out(ctx.version);
424
+ return 0;
425
+ }
426
+ const { words, rest } = splitCommand(argv);
427
+ if (words.length === 0) {
428
+ for (const line of mainHelp(ctx.version))
429
+ ctx.io.out(line);
430
+ return rest.includes("--help") || rest.includes("-h") || argv.length === 0 ? 0 : 1;
431
+ }
432
+ const name = words.join(" ");
433
+ const command = Object.hasOwn(COMMANDS, name) ? COMMANDS[name] : undefined;
434
+ // `cerkel product publish` is a real command; `cerkel theme publish` / `cerkel publish` never are.
435
+ if (!command && words.some((w) => NEVER.has(w)))
436
+ return fail(`\`cerkel ${words.join(" ")}\` ${NEVER_MESSAGE}`);
437
+ if (!command) {
438
+ if (words.length === 1 && GROUPS.has(words[0])) {
439
+ const subs = Object.keys(COMMANDS).filter((k) => k.startsWith(`${words[0]} `));
440
+ return fail(`Usage: cerkel ${words[0]} <${subs.map((k) => k.split(" ")[1]).join("|")}>. See cerkel --help.`);
441
+ }
442
+ const hint = Object.hasOwn(HINTS, name) ? HINTS[name] : undefined;
443
+ return fail(`Unknown command "${name}".${hint ? ` Did you mean \`${hint}\`?` : ""} See cerkel --help.`);
444
+ }
445
+ let values;
446
+ let positionals;
447
+ // A negative number (`cerkel inventory adjust JL-90 -2`) is an argument, not a flag: shield it from
448
+ // parseArgs, then hand it back unchanged (as a positional or as an option's value).
449
+ const NEG = /^-\d+(\.\d+)?$/;
450
+ const SHIELD = "\u0000";
451
+ const unshield = (x) => (x.startsWith(SHIELD) ? x.slice(1) : x);
452
+ try {
453
+ ({ values, positionals } = parseArgs({ args: rest.map((a) => (NEG.test(a) ? SHIELD + a : a)), options: { ...GLOBAL, ...command.options }, allowPositionals: true, strict: true }));
454
+ positionals = positionals.map(unshield);
455
+ values = Object.fromEntries(Object.entries(values).map(([k, x]) => [k, typeof x === "string" ? unshield(x) : Array.isArray(x) ? x.map((y) => (typeof y === "string" ? unshield(y) : y)) : x]));
456
+ }
457
+ catch (e) {
458
+ return fail(`${e instanceof Error ? e.message : String(e)}\nUsage: ${command.usage}`);
459
+ }
460
+ if (values["help"] === true) {
461
+ for (const line of commandHelp(command))
462
+ ctx.io.out(line);
463
+ return 0;
464
+ }
465
+ const takesFiles = name === "theme push" || name === "theme history" || name === "theme restore" || name === "theme delete" || name.startsWith("section ") || command.positionals === true;
466
+ if (!takesFiles && positionals.length > 0)
467
+ return fail(`Unexpected argument "${positionals[0]}".\nUsage: ${command.usage}`);
468
+ const s = new Session(ctx, typeof values["api"] === "string" ? values["api"] : undefined);
469
+ try {
470
+ return await command.run(s, values, positionals);
471
+ }
472
+ catch (e) {
473
+ return fail(s.redact(describeError(e, ctx.now())));
474
+ }
475
+ }
476
+ /** One friendly prefix, then the server's own words — never paraphrased, never swallowed. */
477
+ export function describeError(e, now) {
478
+ // Already one line that says what to run ("This login is for X — run `cerkel login --store Y`.").
479
+ if (e instanceof StoreLoginError)
480
+ return e.message;
481
+ if (e instanceof AuthError)
482
+ return `Not authorised: ${e.message}`;
483
+ if (e instanceof RateLimitError) {
484
+ return `Rate limited: ${e.message} Try again in ${e.retryAfterSeconds}s (after ${retryAt(e.retryAfterSeconds, now)}).`;
485
+ }
486
+ if (e instanceof ToolRefusal) {
487
+ const detail = e.details === undefined ? "" : `\n${JSON.stringify(e.details, null, 2)}`;
488
+ return `The server refused this (${e.tool}): ${e.code}: ${e.serverMessage}${detail}`;
489
+ }
490
+ if (e instanceof ApiUrlError || e instanceof CliError)
491
+ return e.message;
492
+ if (e instanceof Error)
493
+ return `Unexpected error: ${e.message}`;
494
+ return `Unexpected error: ${String(e)}`;
495
+ }
496
+ /** For tests and docs: every command this CLI has. */
497
+ export const COMMAND_NAMES = Object.keys(COMMANDS);
package/dist/client.js ADDED
@@ -0,0 +1,125 @@
1
+ import { AuthError, RateLimitError, ServerError, ToolRefusal } from "./errors.js";
2
+ /**
3
+ * The whole wire protocol: one JSON-RPC `tools/call` per POST, bearer token in the header,
4
+ * `structuredContent` back. See docs/theme-cli/contract.md.
5
+ */
6
+ export const PROTOCOL_VERSION = "2025-06-18";
7
+ const REQUEST_TIMEOUT_MS = 120_000;
8
+ const DEFAULT_RETRY_SECONDS = 60;
9
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
10
+ /** Tools that report per-file outcomes: `isError` there means "some file failed", not "refused". */
11
+ const BATCH_TOOLS = new Set(["put_theme_files", "validate_theme_files"]);
12
+ export class McpClient {
13
+ opts;
14
+ nextId = 1;
15
+ constructor(opts) {
16
+ this.opts = opts;
17
+ }
18
+ get url() {
19
+ return this.opts.url;
20
+ }
21
+ /** `tools/list`: every tool with its input schema and annotations (`readOnlyHint` decides `cerkel call`'s write gate). */
22
+ async listTools() {
23
+ const result = await this.rpc("tools/list", {});
24
+ const tools = isRecord(result) && Array.isArray(result["tools"]) ? result["tools"] : null;
25
+ if (!tools)
26
+ throw new ServerError(`Unexpected response from ${this.opts.url.origin}: tools/list returned no tools.`, null);
27
+ return tools;
28
+ }
29
+ async call(tool, args) {
30
+ const result = (await this.rpc("tools/call", { name: tool, arguments: args }));
31
+ const structured = result.structuredContent ?? fromText(result);
32
+ if (isRecord(structured) && typeof structured["error"] === "string") {
33
+ throw new ToolRefusal(tool, structured["error"], typeof structured["message"] === "string" ? structured["message"] : "", structured["details"]);
34
+ }
35
+ if (result.isError && !BATCH_TOOLS.has(tool)) {
36
+ const msg = result.content?.find((c) => typeof c.text === "string")?.text ?? "the tool reported an error";
37
+ throw new ToolRefusal(tool, "error", msg, undefined);
38
+ }
39
+ return structured;
40
+ }
41
+ /** One JSON-RPC request → its `result`, or the error the person should see. */
42
+ async rpc(method, params) {
43
+ const body = JSON.stringify({ jsonrpc: "2.0", id: this.nextId++, method, params });
44
+ let res;
45
+ try {
46
+ res = await this.opts.fetch(this.opts.url.href, {
47
+ method: "POST",
48
+ headers: {
49
+ authorization: `Bearer ${this.opts.token}`,
50
+ "content-type": "application/json",
51
+ accept: "application/json",
52
+ "mcp-protocol-version": PROTOCOL_VERSION,
53
+ ...(this.opts.userAgent ? { "user-agent": this.opts.userAgent } : {}),
54
+ },
55
+ body,
56
+ // A redirect could carry the request (and our Authorization header) somewhere other than
57
+ // the URL the person configured. The endpoint never redirects, so one is a misconfiguration.
58
+ redirect: "error",
59
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
60
+ });
61
+ }
62
+ catch (e) {
63
+ const cause = e instanceof Error ? (e.cause instanceof Error ? e.cause.message : e.message) : String(e);
64
+ throw new ServerError(`Could not reach ${this.opts.url.origin}: ${cause}`, null);
65
+ }
66
+ const text = await res.text();
67
+ let parsed;
68
+ try {
69
+ parsed = text ? JSON.parse(text) : null;
70
+ }
71
+ catch {
72
+ // A proxy's HTML error page: fall through to the status-based messages below.
73
+ parsed = null;
74
+ }
75
+ const rpcError = isRecord(parsed) && isRecord(parsed["error"]) ? parsed["error"] : null;
76
+ const serverMessage = typeof rpcError?.message === "string" ? rpcError.message : null;
77
+ if (res.status === 401) {
78
+ const reason = typeof rpcError?.data?.["reason"] === "string" ? rpcError.data["reason"] : null;
79
+ throw new AuthError(serverMessage ?? "The server rejected this token (401). Run `cerkel login --store <slug>` to sign in again.", reason);
80
+ }
81
+ if (res.status === 429) {
82
+ throw new RateLimitError(serverMessage ?? "Rate limit exceeded for this token.", retryAfterFrom(res, rpcError));
83
+ }
84
+ if (res.status === 413) {
85
+ throw new ServerError(serverMessage ?? "The request was too large for the server (413).", 413);
86
+ }
87
+ if (!res.ok) {
88
+ throw new ServerError(serverMessage ? `Server error ${res.status}: ${serverMessage}` : `Server error ${res.status} from ${this.opts.url.origin}.`, res.status);
89
+ }
90
+ if (rpcError) {
91
+ throw new ServerError(`Server error ${rpcError.code ?? ""}: ${serverMessage ?? "unknown error"}`.replace(/ {2,}/g, " "), res.status);
92
+ }
93
+ if (!isRecord(parsed) || !isRecord(parsed["result"])) {
94
+ throw new ServerError(`Unexpected response from ${this.opts.url.origin}: not a JSON-RPC result.`, res.status);
95
+ }
96
+ return parsed["result"];
97
+ }
98
+ }
99
+ /** Older servers put the JSON only in `content[0].text`; read it from there when that is all we have. */
100
+ function fromText(result) {
101
+ const text = result.content?.find((c) => c.type === "text" && typeof c.text === "string")?.text;
102
+ if (!text)
103
+ return undefined;
104
+ try {
105
+ return JSON.parse(text);
106
+ }
107
+ catch {
108
+ return undefined;
109
+ }
110
+ }
111
+ function retryAfterFrom(res, err) {
112
+ const header = res.headers.get("retry-after");
113
+ if (header) {
114
+ const n = Number(header);
115
+ if (Number.isFinite(n) && n >= 0)
116
+ return Math.ceil(n);
117
+ const at = Date.parse(header);
118
+ if (Number.isFinite(at))
119
+ return Math.max(1, Math.ceil((at - Date.now()) / 1000));
120
+ }
121
+ const fromBody = err?.data?.retryAfterSeconds;
122
+ if (typeof fromBody === "number" && Number.isFinite(fromBody) && fromBody >= 0)
123
+ return Math.ceil(fromBody);
124
+ return DEFAULT_RETRY_SECONDS;
125
+ }
@@ -0,0 +1,2 @@
1
+ export const str = (v, k) => (typeof v[k] === "string" ? v[k] : undefined);
2
+ export const bool = (v, k) => v[k] === true;