@cerkel/cli 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +502 -2
- package/dist/api-url.js +42 -0
- package/dist/api.js +48 -0
- package/dist/bin.js +157 -0
- package/dist/chunk.js +31 -0
- package/dist/cli.js +497 -0
- package/dist/client.js +125 -0
- package/dist/command.js +2 -0
- package/dist/commands/args.js +107 -0
- package/dist/commands/auth.js +255 -0
- package/dist/commands/call.js +67 -0
- package/dist/commands/collection.js +121 -0
- package/dist/commands/content.js +281 -0
- package/dist/commands/customer.js +126 -0
- package/dist/commands/dev.js +261 -0
- package/dist/commands/draft.js +101 -0
- package/dist/commands/file.js +53 -0
- package/dist/commands/index.js +43 -0
- package/dist/commands/inventory.js +103 -0
- package/dist/commands/offer.js +92 -0
- package/dist/commands/order-args.js +37 -0
- package/dist/commands/order.js +219 -0
- package/dist/commands/price.js +55 -0
- package/dist/commands/product.js +265 -0
- package/dist/commands/promo.js +140 -0
- package/dist/commands/redirect.js +94 -0
- package/dist/commands/report.js +93 -0
- package/dist/commands/section.js +233 -0
- package/dist/commands/store.js +23 -0
- package/dist/commands/theme-tools.js +432 -0
- package/dist/commands/theme.js +388 -0
- package/dist/commands/whatsapp.js +39 -0
- package/dist/commands/zone.js +90 -0
- package/dist/commerce.js +138 -0
- package/dist/content-files.js +246 -0
- package/dist/context.js +5 -0
- package/dist/credentials.js +201 -0
- package/dist/diff.js +146 -0
- package/dist/env.js +24 -0
- package/dist/errors.js +73 -0
- package/dist/format.js +13 -0
- package/dist/layout.js +122 -0
- package/dist/logins.js +49 -0
- package/dist/raizn-alias.js +6 -0
- package/dist/redact.js +27 -0
- package/dist/session.js +266 -0
- package/dist/state.js +153 -0
- package/dist/sync.js +121 -0
- package/dist/zip.js +88 -0
- package/package.json +37 -3
package/README.md
CHANGED
|
@@ -1,3 +1,503 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @cerkel/cli — the `cerkel` CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run a Cerkel store from the terminal, by hand or with Claude Code: its **theme** as local
|
|
4
|
+
files (preview it on the store's real domain and send it for approval), and its **catalogue** —
|
|
5
|
+
products, prices, bundles, stock and collections (see [Store commands](#store-commands-catalogue-prices-stock-collections)).
|
|
6
|
+
It works like Shopify CLI, with one difference: there is no theme publish, unpublish or rollback
|
|
7
|
+
command. **`submit` sends the draft for approval; for
|
|
8
|
+
the store owner's login it publishes immediately** (a rollback link is printed) — a login whose owner
|
|
9
|
+
holds `themes.publish` in that store (owner, admin), and Cerkel team logins. Everyone else's request
|
|
10
|
+
waits for the owner, who approves and publishes it in the Cerkel admin.
|
|
11
|
+
|
|
12
|
+
Requires Node.js 20 or newer.
|
|
13
|
+
|
|
14
|
+
## 1.0.1: published by GitHub, no npm token
|
|
15
|
+
|
|
16
|
+
Same commands as 1.0.0. This is the first release that GitHub Actions publishes through npm trusted publishing
|
|
17
|
+
(`publish-cli.yml`), so the package carries a provenance statement and no npm token exists anywhere.
|
|
18
|
+
|
|
19
|
+
## 1.0.0: the CLI is now `cerkel`
|
|
20
|
+
|
|
21
|
+
The package is **@cerkel/cli** and the command is **`cerkel`** (it was @raizn/cli and `raizn`).
|
|
22
|
+
|
|
23
|
+
- **Install:** `npm rm -g @raizn/cli`, then `npm i -g @cerkel/cli`. Both packages install a `raizn` command,
|
|
24
|
+
so remove the old one first, or npm refuses the second.
|
|
25
|
+
- **`raizn` still works for now.** It prints one line on stderr (`raizn is now cerkel …`) and then does exactly
|
|
26
|
+
what `cerkel` does, so old scripts keep running. It goes away in a later release.
|
|
27
|
+
- **Variables:** `CERKEL_TOKEN` and `CERKEL_API_URL`. The old `RAIZN_TOKEN` and `RAIZN_API_URL` still work; when
|
|
28
|
+
both are set, the `CERKEL_` one wins.
|
|
29
|
+
- **Your logins move by themselves.** The first command reads what `raizn` saved (keychain service `raizn-cli`, or
|
|
30
|
+
`~/.config/raizn/credentials`), saves it under `cerkel-cli` or `~/.config/cerkel/credentials`, and removes the old
|
|
31
|
+
copy. Nothing to do.
|
|
32
|
+
- **Folders you already pulled keep working.** A new folder keeps its state in `.cerkel/`; one pulled by `raizn`
|
|
33
|
+
keeps using its `.raizn/` where it is. Page files written by `raizn` (`<!--raizn …-->`) still push; new pulls write
|
|
34
|
+
`<!--cerkel …-->`.
|
|
35
|
+
- New logins appear in Settings → AI assistant connection (MCP) as "cerkel CLI — <computer> — <store>".
|
|
36
|
+
|
|
37
|
+
## 0.3.0
|
|
38
|
+
|
|
39
|
+
- **The default host is now admin.cerkel.ai** (`https://admin.cerkel.ai/api/mcp`). ecom.raizn.com keeps serving
|
|
40
|
+
the same admin and API.
|
|
41
|
+
- **Run `cerkel login` again.** Logins are kept per host, so a login made on ecom.raizn.com is not used for
|
|
42
|
+
admin.cerkel.ai.
|
|
43
|
+
- **A theme folder pulled from ecom.raizn.com keeps using it.** The folder's recorded API outranks
|
|
44
|
+
`CERKEL_API_URL` and the default, so its commands still go to ecom.raizn.com with that host's login.
|
|
45
|
+
- **To keep the old host everywhere else**, set `CERKEL_API_URL=https://ecom.raizn.com/api/mcp`.
|
|
46
|
+
|
|
47
|
+
## Install and log in
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
npm i -g @cerkel/cli # or run any command with npx @cerkel/cli …
|
|
51
|
+
cerkel login --store justloofah-jo # opens the browser: sign in, check the store, press Allow, done
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**A login is for one store.** It reaches exactly the store you chose and nothing else: `cerkel
|
|
55
|
+
stores` and `cerkel whoami` show that one store, and a command for any other store is refused
|
|
56
|
+
without naming it. To work on another store, log in to that one too
|
|
57
|
+
(`cerkel login --store nahleh-jo`). Your other logins are kept, so a Cerkel-team member who works on
|
|
58
|
+
several stores logs in once per store and then just works: each theme folder uses the login of its
|
|
59
|
+
own store. Switching store means using that store's login (or its folder), never widening one.
|
|
60
|
+
|
|
61
|
+
Like Shopify CLI, the package is public and holds no secrets. Everything is decided by the server:
|
|
62
|
+
what you can do follows your staff role and stores in the Cerkel admin, on every call. Removing
|
|
63
|
+
someone in Settings → Staff cuts their CLI off on the next command.
|
|
64
|
+
|
|
65
|
+
`cerkel login` opens `/cli/authorize` on admin.cerkel.ai (or on the host the command uses: `--api`, the folder's
|
|
66
|
+
recorded API, or `CERKEL_API_URL`). You sign in as usual (WhatsApp code or
|
|
67
|
+
password + authenticator), pick the store (only stores where you can edit themes are listed;
|
|
68
|
+
`--store` pre-selects it, and inside a theme folder `cerkel login` means the folder's store) and press
|
|
69
|
+
**Allow**. The browser then hands a one-time code back to `cerkel` on this computer (127.0.0.1 only),
|
|
70
|
+
and `cerkel` trades it for your token. The code is useless to anyone else, because it needs a secret
|
|
71
|
+
that never leaves this process (PKCE). The token lasts 90 days and appears in Settings → AI
|
|
72
|
+
assistant connection (MCP) as "cerkel CLI — <computer> — <store>" (logins made before 1.0 keep their "raizn CLI — …" name), where you can revoke it.
|
|
73
|
+
|
|
74
|
+
**Logins from raizn 0.1.x** reached every store. The server no longer accepts them: the first command
|
|
75
|
+
says `This login is not tied to a store any more — run cerkel login --store <slug>` (naming the
|
|
76
|
+
folder's store when you are in one), forgets that login, and `cerkel login --store <slug>` makes a
|
|
77
|
+
proper one. A one-store token you once pasted with `cerkel login --token` keeps working: it is filed
|
|
78
|
+
under its store the first time you use it.
|
|
79
|
+
|
|
80
|
+
**macOS: `EACCES: permission denied` on `npm i -g`.** Node's installer makes the global folder
|
|
81
|
+
belong to the system. Don't use `sudo`. Either run commands through `npx @cerkel/cli …`, or point
|
|
82
|
+
npm's global folder at your home directory once:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
mkdir -p ~/.npm-global
|
|
86
|
+
npm config set prefix ~/.npm-global
|
|
87
|
+
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc
|
|
88
|
+
npm i -g @cerkel/cli
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
No browser on this machine (a server over SSH)? Create a token for one store by hand in that
|
|
92
|
+
Settings page and run `cerkel login --token`, or pipe it in with `echo "$TOKEN" | cerkel login`. A token
|
|
93
|
+
that is not tied to exactly one store is refused and not saved.
|
|
94
|
+
|
|
95
|
+
**Releasing a new version** (maintainers): bump `version` in `packages/cli/package.json` in a PR,
|
|
96
|
+
merge it, then run the **Publish cerkel CLI** workflow (GitHub → Actions). There is no npm token to
|
|
97
|
+
manage: npm trusts that workflow in this repo directly (trusted publishing, set on npmjs.com →
|
|
98
|
+
`@cerkel/cli` → Settings).
|
|
99
|
+
|
|
100
|
+
Either way, `login` checks the token with the server before saving it, and prints who you are, the
|
|
101
|
+
one store it is for and its permissions. A token the server rejects is not saved.
|
|
102
|
+
|
|
103
|
+
Logins are stored in your OS keychain (service `cerkel-cli`, account = the API origin; the entry holds
|
|
104
|
+
one token per store). If there is no keychain, they go to `~/.config/cerkel/credentials`
|
|
105
|
+
(`%APPDATA%\cerkel\credentials` on Windows) with `0600` permissions.
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
cerkel logins # your own logins on this computer, one per store, each checked with the server
|
|
109
|
+
cerkel logout # inside a theme folder: that store's login; with one login: that one
|
|
110
|
+
cerkel logout --store nahleh-jo # one store's login
|
|
111
|
+
cerkel logout --all # every login for this API
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Which login a command uses: the theme folder's store (`.cerkel/state.json`), else `--store <slug>`,
|
|
115
|
+
else your only login. With several logins and no folder or `--store`, the command stops and asks you
|
|
116
|
+
to pass `--store`; it does not list your stores (`cerkel logins` does, on purpose — that data is on
|
|
117
|
+
your own disk). `cerkel logout` only forgets a login on this computer; revoke the token itself in
|
|
118
|
+
Settings → MCP.
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
## Workflow
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
cerkel login --store nahleh # once per store
|
|
125
|
+
cerkel stores # the store this login is for
|
|
126
|
+
cerkel theme list --store nahleh # live + draft themes
|
|
127
|
+
mkdir nahleh-theme && cd nahleh-theme
|
|
128
|
+
cerkel theme pull --store nahleh # newest draft (offers to create one from live if none)
|
|
129
|
+
cerkel theme dev # push on save + preview link on the real store domain
|
|
130
|
+
cerkel theme check # validate changed files (nothing saved)
|
|
131
|
+
cerkel theme submit -m "Ramadan homepage" # send for approval; prints the admin link
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- **pull** downloads a draft into the current folder (or `--dir`). Use `--theme <id>` to choose a
|
|
135
|
+
specific draft. If the store has no draft, `pull` asks whether to create one from the live theme
|
|
136
|
+
(`--yes` answers for you, and scripts must pass it). If you have local changes, `pull` refuses
|
|
137
|
+
unless you pass `--force`. A folder holds one store's theme: pulling another store into it is
|
|
138
|
+
refused, so pull it into a folder of its own (`--dir`). Without `--store` outside a folder, `pull`
|
|
139
|
+
uses the store of your only login.
|
|
140
|
+
- **dev** pushes pending changes first, then watches the folder. A burst of saves goes to the server
|
|
141
|
+
as one request, about a second after you stop typing. Errors show inline as
|
|
142
|
+
`path:line:col message`. It prints a signed preview link and fetches a fresh one before the
|
|
143
|
+
30-minute link expires. **Open each new link:** a tab still using the old one stops showing the
|
|
144
|
+
draft and quietly falls back to the live site after 30 minutes. Ctrl-C stops it.
|
|
145
|
+
- **push** uploads only the files you changed since the last pull or push. If someone else changed
|
|
146
|
+
a file on the server in the meantime (a teammate, or the admin editor), `push` refuses that file,
|
|
147
|
+
prints a diff (server vs yours) and exits 1. Merge the changes and push again, or use `--force`
|
|
148
|
+
to overwrite the server's copy. `--force` first lists each conflicting file with how many server
|
|
149
|
+
lines not in your file would be lost, and asks `y/N`. `--yes` answers for you, and scripts must
|
|
150
|
+
pass it: without a terminal, `--force` alone refuses. If the server's copy changes again while
|
|
151
|
+
you are answering, that file is refused as a new conflict instead of being overwritten.
|
|
152
|
+
- **preview** prints the signed preview link, and `--open` opens it in your browser.
|
|
153
|
+
- **submit** refuses while you have unpushed changes. For the **store's owner or an admin** (the
|
|
154
|
+
login carries `themes.publish` and its owner holds it in that store right now) and for **Cerkel's own
|
|
155
|
+
team** (a @raizn.net / @raizn.com email) the server approves
|
|
156
|
+
and publishes it in the same step: `submit` says the change is **LIVE** and prints the request link,
|
|
157
|
+
where Rollback is one click. Everyone else's request waits for someone with publish rights. It prints the request id and
|
|
158
|
+
`https://admin.cerkel.ai/themes/requests/<id>` (on the host the folder uses).
|
|
159
|
+
|
|
160
|
+
### More theme commands (Shopify CLI parity)
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
cerkel theme info # this folder's draft (or --theme <id>, or the live theme)
|
|
164
|
+
cerkel theme open # the admin's visual editor (--code: the code editor, --print: only the link)
|
|
165
|
+
cerkel theme rename "Ramadan 2027" # this folder's draft, or --theme <id>
|
|
166
|
+
cerkel theme delete --theme <id> # a DRAFT; type its name to confirm (--yes in scripts)
|
|
167
|
+
cerkel theme share --days 7 # a preview link for someone else, on the store's own domain (1–14 days)
|
|
168
|
+
cerkel theme console --path /products/x # Liquid against the store's real data: product.title, collections | size …
|
|
169
|
+
cerkel theme console -e "shop.name" # one shot (or pipe Liquid in)
|
|
170
|
+
cerkel theme profile --path / --runs 3 # each section's server render time, slowest marked
|
|
171
|
+
cerkel theme package # zip this folder's theme files (-o file.zip)
|
|
172
|
+
cerkel theme init my-theme # a new folder from a starter theme (--list shows them)
|
|
173
|
+
cd my-theme && cerkel theme push --new # → a NEW draft (copied from live) with these files; the folder becomes its checkout
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
- **console** evaluates on the store's own storefront with the objects a section on `--path` gets, in
|
|
177
|
+
the theme of this folder (else the live one). Inside: `.path /collections/x`, `.locale en`, `.help`,
|
|
178
|
+
`.exit` (or Ctrl-D). It only reads.
|
|
179
|
+
- **profile** renders the page exactly as a shopper gets it, but section by section, and prints the
|
|
180
|
+
server time of each with what rendered it (a theme file, a built-in section) and any Liquid error.
|
|
181
|
+
- **share** links show the draft as it is on the server: push first. Deleting the draft revokes them.
|
|
182
|
+
- **delete --theme** never deletes the live theme or a draft waiting for review.
|
|
183
|
+
|
|
184
|
+
### Example: Just Loofah
|
|
185
|
+
|
|
186
|
+
Every Just Loofah page is a store like Nahleh's — same file layout, same commands:
|
|
187
|
+
|
|
188
|
+
```sh
|
|
189
|
+
cerkel theme pull --store justloofah-jo
|
|
190
|
+
# templates/index.json templates/product.json templates/page.products.json
|
|
191
|
+
# templates/page.habit.json templates/404.json
|
|
192
|
+
# layout/header.json layout/footer.json
|
|
193
|
+
# config/settings.json assets/custom.css
|
|
194
|
+
# locales/ar.json locales/en.json
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Text settings are **localized**: one setting holds every language as an object, never a bare string.
|
|
198
|
+
Editing `templates/index.json`'s loyalty section:
|
|
199
|
+
|
|
200
|
+
```diff
|
|
201
|
+
{ "id": "loyalty", "type": "jl-loyalty", "settings": {
|
|
202
|
+
"label": { "ar": "مكافأة الولاء", "en": "Loyalty reward" },
|
|
203
|
+
- "headline": { "ar": "اطلب مرّة… ووفّر للأبد", "en": "Order once. Save forever." },
|
|
204
|
+
+ "headline": { "ar": "اطلب مرّة… ووفّر للأبد", "en": "Order once, save forever." },
|
|
205
|
+
"body": { "ar": "…", "en": "…" }
|
|
206
|
+
} }
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`cerkel theme push` validates and saves it to the draft; `cerkel theme dev` gives a live preview on the
|
|
210
|
+
store's own domain while you edit. If the store has never been seeded (no rows to pull), the admin's
|
|
211
|
+
Settings → Data sources → "تهيئة قالب Just Loofah" button (or re-running "استيراد باقات وإضافات Just
|
|
212
|
+
Loofah") creates them first — content identical to the built-in fallback, so nothing changes visually
|
|
213
|
+
until you edit something.
|
|
214
|
+
|
|
215
|
+
### Built-in texts: `locales/<lang>.json`
|
|
216
|
+
|
|
217
|
+
The storefront's own texts — "Add to cart", cart and checkout labels, the account and tracking
|
|
218
|
+
pages, search, the default policies, a Just Loofah theme's SEO title and WhatsApp prefill — are one
|
|
219
|
+
file per store language (Kurdish too, when the store sells in it). `pull` writes every text the
|
|
220
|
+
language shows: the theme's own wording where it has one, the default everywhere else.
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
{
|
|
224
|
+
"platform": { "addToCart": "Add to cart", "trackTitle": "Track your order", "…": "…" },
|
|
225
|
+
"theme": { "product.addToCart": "Add to Cart", "whatsapp.prefill": "Hi, I have a question about Just Loofah products 🌿", "…": "…" }
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`platform` holds the texts every theme shares; `theme` holds the theme's own (only a theme whose
|
|
230
|
+
preset has its own dictionary, today Just Loofah). Change a value and `push`: only texts that differ
|
|
231
|
+
from the default are stored, a line you delete goes back to its default, and a file that names an
|
|
232
|
+
unknown key, drops or adds a `{placeholder}`, or contains HTML is refused with the line. Kurdish
|
|
233
|
+
files leave out the texts nobody has translated yet (the store shows Arabic there); add the line to
|
|
234
|
+
translate one. Merchants edit the same texts in the admin: Themes → ⋯ → Edit theme content.
|
|
235
|
+
|
|
236
|
+
### Sections, blocks and snippets: new kinds of section with no deploy
|
|
237
|
+
|
|
238
|
+
A section is ONE file, `sections/<type>.liquid`: its Liquid, then `{% stylesheet %}`, then
|
|
239
|
+
`{% schema %}` (docs/liquid-themes.md §2). It belongs to the **theme** you pulled, like its templates:
|
|
240
|
+
it goes live when that draft is approved and published, and a rollback brings the previous files back.
|
|
241
|
+
|
|
242
|
+
```sh
|
|
243
|
+
cerkel section new promo-strip --starter blank # writes sections/promo-strip.liquid locally
|
|
244
|
+
cerkel section duplicate promo-strip promo-v2 # a copy to redesign side by side ("(copy)" in its name)
|
|
245
|
+
cerkel theme push # adds them to the draft (or keep `cerkel theme dev` running)
|
|
246
|
+
cerkel section history promo-strip # every save in every theme of the store, newest first
|
|
247
|
+
cerkel section restore promo-strip 3 # the old file becomes a NEW version; history kept
|
|
248
|
+
cerkel section delete promo-strip # refused while one of the draft's templates places it
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Then place the section with the admin's visual editor (or in `templates/*.json`) and
|
|
252
|
+
`cerkel theme submit -m "…"`. A file `sections/<type>.liquid` whose type is a built-in section (e.g.
|
|
253
|
+
`jl-hero-video`) REPLACES that built-in section in this theme only — that is how a section is
|
|
254
|
+
redesigned one at a time.
|
|
255
|
+
|
|
256
|
+
`blocks/<name>.liquid`, `snippets/<name>.liquid` and `assets/<name>.css|svg` are theme files too:
|
|
257
|
+
pull, push, dev, `cerkel theme history <file>`, `cerkel theme restore <file> <version>` and
|
|
258
|
+
`cerkel theme delete <file…>` work on every one of them. `assets/*.generated.css` is written by Cerkel
|
|
259
|
+
from the theme's files: pulled for reference, never pushed. JavaScript files are not theme files —
|
|
260
|
+
interactivity comes from the platform's components (docs/liquid-themes.md §7).
|
|
261
|
+
|
|
262
|
+
**Upgrading from CLI 0.1.** Sections used to be three files (`.liquid`, `.schema.json`, `.css`). In an
|
|
263
|
+
old checkout, `push` refuses and says so; `cerkel theme pull` writes the one-file sections and removes
|
|
264
|
+
the old side files you did not change (an edited one is kept — move its content into the
|
|
265
|
+
`{% schema %}` / `{% stylesheet %}` of the `.liquid` file, then delete it).
|
|
266
|
+
|
|
267
|
+
## Orders, customers and reviews
|
|
268
|
+
|
|
269
|
+
Every command runs against your login's store (or `--store <slug>`) and takes `--json`. Writes show
|
|
270
|
+
the server's change first and ask (`--yes` in scripts, `--dry-run` to only look); the server applies
|
|
271
|
+
the admin's rules and its refusal is printed as is.
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
cerkel order list --status new,confirming # the confirmation queue's orders
|
|
275
|
+
cerkel order get JL-1001 # items, totals, customer, courier, timeline, next steps
|
|
276
|
+
cerkel order transition JL-1001 confirmed # confirming / unreachable / cancelled (--note) / delivered …
|
|
277
|
+
cerkel order call JL-1001 no_answer # logs the call, schedules the next one
|
|
278
|
+
cerkel order hold JL-1001 --reason customer_later --until 2026-10-20
|
|
279
|
+
cerkel order release JL-1001
|
|
280
|
+
cerkel order note JL-1001 Customer asked for an evening delivery
|
|
281
|
+
cerkel order tag JL-1001 --add gift --remove old
|
|
282
|
+
cerkel order edit JL-1001 --qty JL-90=3 --add JL-SOAP=1 --reason "Customer called" # --dry-run to preview
|
|
283
|
+
cerkel draft create --name Laila --phone 0791234567 --governorate JO-AM --address "Street 7" --item JL-90=2
|
|
284
|
+
cerkel draft complete <draft id> # places the order (number, stock, COD checks)
|
|
285
|
+
cerkel customer get JL-1001 # by phone or by any of their orders
|
|
286
|
+
cerkel customer flag 0791234567 blocked --note "Fake orders" # --off lifts it
|
|
287
|
+
cerkel customer update JL-1001 --name "Laila Haddad" --note "Prefers evenings"
|
|
288
|
+
cerkel review list --status pending
|
|
289
|
+
cerkel review moderate <review id> approved
|
|
290
|
+
cerkel review reply <review id> Thank you for your order!
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
An order the courier already has takes no new items, and editing it needs an admin's approval:
|
|
294
|
+
`cerkel order edit` always submits the edit for approval (`--reason` required) — from any login, an
|
|
295
|
+
admin's included — and nothing changes until an admin approves it in the admin. Phones are masked unless your login may see
|
|
296
|
+
customer details.
|
|
297
|
+
|
|
298
|
+
## Folder layout
|
|
299
|
+
|
|
300
|
+
```
|
|
301
|
+
templates/index.json templates/<name>.json (product, product.<suffix>, collection…, page…, …)
|
|
302
|
+
layout/header.json layout/footer.json
|
|
303
|
+
config/settings.json theme settings (merged on write: deleting a key does not remove it)
|
|
304
|
+
assets/custom.css the theme's custom CSS
|
|
305
|
+
locales/<ar|en|ckb>.json the theme's built-in texts per language (the whole language: a deleted line goes back to its default)
|
|
306
|
+
sections/<type>.liquid a section: Liquid + {% stylesheet %} + {% schema %}, one file
|
|
307
|
+
blocks/<name>.liquid a theme block (Horizon-style; `_name` = private)
|
|
308
|
+
snippets/<name>.liquid a snippet for {% render %}
|
|
309
|
+
assets/<name>.css|svg plain CSS (loaded on every page of the theme) and sanitised SVG
|
|
310
|
+
assets/<name>.generated.css written by Cerkel (read-only)
|
|
311
|
+
.cerkel/state.json what was pulled: api, store, theme, and each file's server tag + local hash
|
|
312
|
+
.cerkel/.gitignore "*", so .cerkel never lands in git (a folder pulled before 1.0 keeps its .raizn/)
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Anything else in the folder, such as a README or a `.git` directory, is ignored. Files are written
|
|
316
|
+
exactly as the server sends them, so Arabic text round-trips byte for byte. A git checkout with
|
|
317
|
+
`core.autocrlf` does not count as modified, because line endings are normalised to LF before
|
|
318
|
+
comparing and sending. A leading UTF-8 BOM (Windows PowerShell 5.1 `Set-Content -Encoding utf8`
|
|
319
|
+
adds one) is dropped before comparing and sending, so a file that differs only by a BOM is not a
|
|
320
|
+
change. A section, block, snippet or asset file you delete locally is deleted from the draft on the
|
|
321
|
+
next `push` or by `dev` — only if the server still holds the copy you last synced, and after the
|
|
322
|
+
templates in the same push (so a template can stop placing a section and the section can go in one
|
|
323
|
+
push). `--nodelete` keeps them. Templates, layout, settings and `assets/custom.css` are never deleted.
|
|
324
|
+
|
|
325
|
+
A read-only reference copy of the **live** theme can be pulled into its own folder:
|
|
326
|
+
|
|
327
|
+
```sh
|
|
328
|
+
cerkel theme pull --store nahleh --live --dir ../nahleh-live
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
`push`, `dev`, `submit` and the section write commands refuse to run in that folder. The live theme
|
|
332
|
+
is never pulled for editing.
|
|
333
|
+
|
|
334
|
+
## Store commands (catalogue, prices, stock, collections)
|
|
335
|
+
|
|
336
|
+
Each command is one MCP tool (`docs/mcp.md`, phase A); the server decides permission, store and
|
|
337
|
+
money rules and its refusal is printed verbatim. The store is your login's store (or `--store`).
|
|
338
|
+
|
|
339
|
+
**Every write shows the change and asks first.** It sends a dry run, prints the server's diff, asks
|
|
340
|
+
`Apply this change to <store>? [y/N]`, and only then applies. `--yes` answers for you, `--dry-run`
|
|
341
|
+
stops after the diff, `--json` prints the server's answer. Without a terminal (scripts, CI) a write
|
|
342
|
+
needs `--yes` and is refused before anything is sent.
|
|
343
|
+
|
|
344
|
+
```sh
|
|
345
|
+
cerkel store info # currency (decimals, cash step), languages, domains, COD policy
|
|
346
|
+
cerkel price set JL-90 18.000 # shows "price: 15.000 JOD → 18.000 JOD", asks, applies
|
|
347
|
+
cerkel price set JL-90 - --compare-at none # "-" keeps the price; none removes the compare-at price
|
|
348
|
+
cerkel price import prices.csv --dry-run # sku,price,compare_at,cost — all or nothing, bad rows listed
|
|
349
|
+
cerkel product list rose --low # search; --status, --collection, --category, --published|--hidden
|
|
350
|
+
cerkel product get loofah-90 # variants with this store's prices and stock
|
|
351
|
+
cerkel product create --title "en:Rose soap" --title "ar:صابون الورد" --price 4.500 --sku JL-ROSE --quantity 12
|
|
352
|
+
cerkel product update rose-soap --tags soap,rose --category skin/soap --field scent=rose
|
|
353
|
+
cerkel product status active rose-soap body-soap # one or many
|
|
354
|
+
cerkel product publish rose-soap # show it in THIS store (unpublish hides it)
|
|
355
|
+
cerkel product duplicate rose-soap
|
|
356
|
+
cerkel variant options knee --option "Size=S,M,L" --lang en --create-variants
|
|
357
|
+
cerkel variant add knee --option Size=L --sku KS-L
|
|
358
|
+
cerkel image add loofah-90 https://…/a.jpg # or a media library id; image remove / image reorder
|
|
359
|
+
cerkel category set rose-soap skin/soap
|
|
360
|
+
cerkel offer set loofah-90 --label "en:5 + 1" --price 15.000 --line JL-SQ:5 --line JL-CYL:1
|
|
361
|
+
cerkel offer set loofah-90 deal-6 --inactive # change an existing tier by its key
|
|
362
|
+
cerkel addon set loofah-90 JL-SOAP --price 0 # a free gift with the order
|
|
363
|
+
cerkel inventory get loofah-90 # on hand / reserved / available per location
|
|
364
|
+
cerkel inventory adjust JL-SQ +24 --reason received # or -2 --reason damage; reasons: correction, damage, theft, received, return, count
|
|
365
|
+
cerkel inventory set JL-SQ 40 # an exact count
|
|
366
|
+
cerkel inventory tracking loofah-90 --track on --continue-selling off
|
|
367
|
+
cerkel inventory low
|
|
368
|
+
cerkel collection create --title "en:Loofahs" --product square-loofah --product loofah-90
|
|
369
|
+
cerkel collection reorder loofahs loofah-90 # listed first, the rest after
|
|
370
|
+
cerkel collection unpublish loofahs
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Amounts are typed in the store's currency exactly as you would write them — `18.000` or `18`
|
|
374
|
+
(JOD, 3 decimals), `12500` (IQD, steps of 250) — and parsed by the server; too many decimals or an
|
|
375
|
+
off-step IQD amount is refused, never rounded. Text flags take `en:Text` / `ar:نص` (repeat per
|
|
376
|
+
language) or plain text in `--lang` (default `ar`). `cerkel <command> --help` lists every flag.
|
|
377
|
+
|
|
378
|
+
| Group | Commands | Needs (in the admin) |
|
|
379
|
+
|---|---|---|
|
|
380
|
+
| `store` | `info` | any login |
|
|
381
|
+
| `product` | `list`, `get`, `create`, `update`, `status`, `publish`, `unpublish`, `duplicate` | view / edit products |
|
|
382
|
+
| `variant`, `image`, `category` | `set`, `add`, `delete`, `options` · `add`, `remove`, `reorder` · `list`, `set` | edit products |
|
|
383
|
+
| `price` | `set`, `import` | edit products |
|
|
384
|
+
| `offer`, `addon` | `list`, `set`, `delete` · `set`, `delete` | edit products + manage offers |
|
|
385
|
+
| `inventory` | `get`, `locations`, `low` · `adjust`, `set`, `tracking` | view stock · adjust stock |
|
|
386
|
+
| `collection` | `list`, `get` · `create`, `update`, `add`, `remove`, `reorder`, `publish`, `unpublish` | view products · manage content |
|
|
387
|
+
## Store commands: content, discounts, delivery, reports
|
|
388
|
+
|
|
389
|
+
Every command acts on your login's store (or `--store <slug>`), prints a table (`--json` for
|
|
390
|
+
everything), and a command that changes something **shows the server's diff and asks first**:
|
|
391
|
+
`--yes` applies without asking (required in scripts), `--dry-run` only shows it. Amounts are in the
|
|
392
|
+
store's currency, as you would write them: `2.000` (JOD), `5000` (IQD, steps of 250).
|
|
393
|
+
|
|
394
|
+
```sh
|
|
395
|
+
# Pages, policies and blog articles as files — like theme files
|
|
396
|
+
cerkel page pull --store justloofah-jo # pages/*.html, policies/*.html, blogs/<blog>/*.html
|
|
397
|
+
$EDITOR policies/refund-policy.html # settings in <!--cerkel {…} -->, bodies after <!--lang:ar--> / <!--lang:en-->
|
|
398
|
+
cerkel page push # only changed files; a page changed in the admin meanwhile is a conflict
|
|
399
|
+
cerkel page list --kind policy
|
|
400
|
+
cerkel page delete /pages/old-offer
|
|
401
|
+
|
|
402
|
+
cerkel menu pull && cerkel menu push # menus/<handle>.json — { "title", "items": [ … ] }
|
|
403
|
+
|
|
404
|
+
cerkel redirect add /old-offer /collections/all # --temporary for 302
|
|
405
|
+
cerkel redirect import redirects.csv # from,to[,301|302] per line
|
|
406
|
+
cerkel redirect list --sort hits
|
|
407
|
+
|
|
408
|
+
cerkel file upload https://example.com/hero.jpg # copied into the media library; prints its URL
|
|
409
|
+
cerkel file list --kind image
|
|
410
|
+
|
|
411
|
+
cerkel promo create EID10 --percent 10 --expires 2026-10-31 --once
|
|
412
|
+
cerkel promo create SHIP --free-delivery --min 20
|
|
413
|
+
cerkel promo deactivate EID10
|
|
414
|
+
|
|
415
|
+
cerkel zone list
|
|
416
|
+
cerkel zone set --name-ar "إربد" --name-en Irbid --governorates JO-IR --fee 3.000 --eta 2
|
|
417
|
+
cerkel zone set <zone-id> --fee 2.500
|
|
418
|
+
|
|
419
|
+
cerkel report sales --since 2026-10-01 --until 2026-10-07 [--grain week] [--compare] [--csv]
|
|
420
|
+
cerkel report products|governorates|funnel|couriers|customers|discounts|growth
|
|
421
|
+
cerkel report live # visitors right now
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
**`cerkel call <tool> --args '{…}' [--allow-writes]`** calls any MCP tool with JSON arguments, like
|
|
425
|
+
`shopify store execute`. It is read-only by default: a tool the server marks as a write is refused
|
|
426
|
+
unless you pass `--allow-writes` (add `"dry_run": true` to see the change first). `cerkel call --list`
|
|
427
|
+
shows every tool and whether it reads or writes.
|
|
428
|
+
|
|
429
|
+
Reports need the reports permission; growth/ROAS and courier cash need finance access, `--csv` the
|
|
430
|
+
download permission, and customer phone numbers the permission to see them — exactly as in the admin.
|
|
431
|
+
|
|
432
|
+
**`cerkel whatsapp test <template> --to <+962…> [--values "v1|v2|…"] [--language ar]`** sends one approved
|
|
433
|
+
WhatsApp template from the store's number to a phone, with the values in `{{1}}, {{2}}…` order separated
|
|
434
|
+
by `|` — to try a template before customers get it. No order and no mapping are needed, and the store's
|
|
435
|
+
"Enable sending" switch does not apply; the store's token never leaves the server. It prints WhatsApp's
|
|
436
|
+
message id, or its error code (132001 = no such template in that language, 132000 = wrong number of
|
|
437
|
+
values). Needs the WhatsApp scope (`whatsapp.templates.manage`). A tap on the test message's buttons moves
|
|
438
|
+
no order (since 14 Oct 2026). Review buttons still rate that phone's latest real review request (72 h), so
|
|
439
|
+
tap those only from a phone with no recent order.
|
|
440
|
+
|
|
441
|
+
```sh
|
|
442
|
+
cerkel whatsapp test raizn_confirmation_request --to +962780095281 --store justloofah \
|
|
443
|
+
--values "Qutiba|JL-TEST|13.000 د.أ|لوفة اسطوانية × 2"
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
## What this can never do
|
|
447
|
+
|
|
448
|
+
- Publish, unpublish or roll back a theme or a section with a command of its own. `cerkel theme
|
|
449
|
+
publish` explains that publishing happens in the admin after approval, or, for the store owner's
|
|
450
|
+
login and Cerkel team logins, immediately on `submit` (which prints the rollback link).
|
|
451
|
+
- Write to the live theme. The server refuses it (`not_a_draft`); that is the real check. As a
|
|
452
|
+
convenience, the CLI also refuses before sending anything when the folder's `.cerkel/state.json`
|
|
453
|
+
says it holds the live theme or anything other than a draft.
|
|
454
|
+
- Reach staff, roles or tokens, payments, refunds or courier cash, open or pause a store, delete an
|
|
455
|
+
order, or take an order to the courier (`dispatched`). The store commands below cover the catalogue,
|
|
456
|
+
prices, stock and collections, pages and menus, redirects, files, promo codes, delivery zones and
|
|
457
|
+
reports, and orders, customers and reviews — in the login's own store,
|
|
458
|
+
within what you may do there in the admin.
|
|
459
|
+
- Talk to any host but the configured API. `*.vercel.app` is refused (Jordanian ISPs reset those
|
|
460
|
+
connections), and plain `http` is only allowed for `localhost`.
|
|
461
|
+
|
|
462
|
+
The server authorises every call. The CLI's own checks are there for convenience, and when the
|
|
463
|
+
server refuses (`permission_denied`, `not_a_draft`, …), its message is shown verbatim. Two refusals
|
|
464
|
+
are about which login you used, and are said in one line instead, naming only this login's store and
|
|
465
|
+
the store you asked for: `This login is for X — run cerkel login --store Y` (`store_not_allowed`), and
|
|
466
|
+
`This login is not tied to a store any more — run cerkel login --store <slug>` (a 0.1.x login). A 401
|
|
467
|
+
(revoked or expired token) is never retried. On a 429, the CLI prints when you can retry: `dev` waits
|
|
468
|
+
it out and keeps your queued changes, and other commands exit 1.
|
|
469
|
+
|
|
470
|
+
## CI
|
|
471
|
+
|
|
472
|
+
```yaml
|
|
473
|
+
- run: npx @cerkel/cli theme check --store nahleh
|
|
474
|
+
env:
|
|
475
|
+
CERKEL_TOKEN: ${{ secrets.CERKEL_TOKEN }}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
When `CERKEL_TOKEN` is set, it is used instead of any stored login and is never saved. Create it in
|
|
479
|
+
Settings → MCP for the one store the pipeline checks. Without a `.cerkel/state.json`, `theme check
|
|
480
|
+
--store <slug>` validates every theme file in the folder. It exits 1 on any error; warnings alone
|
|
481
|
+
exit 0.
|
|
482
|
+
|
|
483
|
+
## Options
|
|
484
|
+
|
|
485
|
+
| | |
|
|
486
|
+
|---|---|
|
|
487
|
+
| `--api <url>` / `CERKEL_API_URL` | API endpoint. The default is `https://admin.cerkel.ai/api/mcp` (ecom.raizn.com still serves). The URL a folder was pulled from is recorded in its state, and later commands in that folder reuse it. |
|
|
488
|
+
| `--store <slug>` | Which store's login to use (`login`, `logout`, `whoami`, `stores`, `theme list`, `theme pull`, `theme check`). Inside a theme folder the folder's store is used. |
|
|
489
|
+
| `CERKEL_TOKEN` | Token for CI (one store); takes precedence over every stored login. |
|
|
490
|
+
| `cerkel <command> --help` | Help for any command. |
|
|
491
|
+
|
|
492
|
+
The token is never printed, logged or written to project files. Every line the CLI prints passes
|
|
493
|
+
through a redactor.
|
|
494
|
+
|
|
495
|
+
## Development
|
|
496
|
+
|
|
497
|
+
```sh
|
|
498
|
+
pnpm --filter @cerkel/cli build # tsc → dist/, bin = dist/bin.js
|
|
499
|
+
pnpm --filter @cerkel/cli test # vitest, against a mocked endpoint (no network, no keychain)
|
|
500
|
+
node packages/cli/dist/bin.js --help
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
The wire contract with the server is in `docs/theme-cli/contract.md`.
|
package/dist/api-url.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Where the CLI is allowed to send a token, decided once, before any request is built. */
|
|
2
|
+
export const DEFAULT_API_URL = "https://admin.cerkel.ai/api/mcp";
|
|
3
|
+
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
4
|
+
export class ApiUrlError extends Error {
|
|
5
|
+
name = "ApiUrlError";
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Parse and vet an API URL. Refuses:
|
|
9
|
+
* - `*.vercel.app` — Jordanian ISPs reset any connection whose SNI or Host names it, so the
|
|
10
|
+
* command would hang or fail in the market we serve; custom domains only.
|
|
11
|
+
* - plain http anywhere but loopback — the token rides in a header and must not cross a network
|
|
12
|
+
* in clear text. Loopback http is allowed for local development against `pnpm dev:admin`.
|
|
13
|
+
* - credentials embedded in the URL, which would be sent alongside (and logged with) the request.
|
|
14
|
+
*/
|
|
15
|
+
export function parseApiUrl(raw) {
|
|
16
|
+
let url;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(raw.trim());
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new ApiUrlError(`"${raw}" is not a valid URL. Expected something like ${DEFAULT_API_URL}.`);
|
|
22
|
+
}
|
|
23
|
+
const host = url.hostname.toLowerCase().replace(/\.$/, "");
|
|
24
|
+
if (host === "vercel.app" || host.endsWith(".vercel.app")) {
|
|
25
|
+
throw new ApiUrlError(`Refusing ${url.origin}: *.vercel.app addresses are blocked by Jordanian ISPs. Use the store platform's custom domain (default ${DEFAULT_API_URL}).`);
|
|
26
|
+
}
|
|
27
|
+
if (url.username || url.password) {
|
|
28
|
+
throw new ApiUrlError("Refusing an API URL with a username or password in it. Use `cerkel login` for the token.");
|
|
29
|
+
}
|
|
30
|
+
if (url.protocol === "http:") {
|
|
31
|
+
if (!LOOPBACK.has(host)) {
|
|
32
|
+
throw new ApiUrlError(`Refusing ${url.origin}: the API must use https (plain http is only allowed for localhost).`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else if (url.protocol !== "https:") {
|
|
36
|
+
throw new ApiUrlError(`Refusing ${url.protocol} URL: the API must use https.`);
|
|
37
|
+
}
|
|
38
|
+
url.hash = "";
|
|
39
|
+
return url;
|
|
40
|
+
}
|
|
41
|
+
/** Credentials are keyed by origin so `/api/mcp` vs `/api/mcp/` never splits one login in two. */
|
|
42
|
+
export const apiOrigin = (url) => url.origin;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ServerError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* The one store a token is for, or null when it is not tied to exactly one (an old token that
|
|
4
|
+
* still reaches several stores, or none right now). Works with servers from before `store` existed.
|
|
5
|
+
*/
|
|
6
|
+
export function oneStoreOf(r) {
|
|
7
|
+
if (r.store && typeof r.store.slug === "string")
|
|
8
|
+
return r.store;
|
|
9
|
+
if (r.store === undefined && Array.isArray(r.stores) && r.stores.length === 1)
|
|
10
|
+
return r.stores[0];
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
export const listStores = (c) => c.call("list_stores", {});
|
|
14
|
+
/**
|
|
15
|
+
* The server answers `list_themes` with a bare array today; accept `{ themes }` too so a later
|
|
16
|
+
* server that wraps it (structuredContent is meant to be an object) does not break old CLIs.
|
|
17
|
+
*/
|
|
18
|
+
export async function listThemes(c, store) {
|
|
19
|
+
const r = await c.call("list_themes", { store });
|
|
20
|
+
if (Array.isArray(r))
|
|
21
|
+
return r;
|
|
22
|
+
if (r && typeof r === "object" && Array.isArray(r.themes))
|
|
23
|
+
return r.themes;
|
|
24
|
+
throw new ServerError("Unexpected list_themes answer from the server.", 200);
|
|
25
|
+
}
|
|
26
|
+
export const createDraft = (c, store, name, fromThemeId) => c.call("create_draft", { store, name, ...(fromThemeId ? { fromThemeId } : {}) });
|
|
27
|
+
export const getThemeFiles = (c, store, themeId) => c.call("get_theme_files", { store, ...(themeId ? { themeId } : {}) });
|
|
28
|
+
export const putThemeFiles = (c, store, themeId, files, force) => c.call("put_theme_files", { store, themeId, files, ...(force ? { force: true } : {}) });
|
|
29
|
+
export const validateThemeFiles = (c, store, themeId, files) => c.call("validate_theme_files", { store, ...(themeId ? { themeId } : {}), files });
|
|
30
|
+
export const getPreviewUrl = (c, store, themeId) => c.call("get_preview_url", { store, themeId });
|
|
31
|
+
export const submitForApproval = (c, store, themeId, title, description) => c.call("submit_for_approval", {
|
|
32
|
+
store,
|
|
33
|
+
themeId,
|
|
34
|
+
title,
|
|
35
|
+
...(description ? { description } : {}),
|
|
36
|
+
});
|
|
37
|
+
export const listSectionStarters = (c) => c.call("list_section_starters", {});
|
|
38
|
+
export const deleteThemeFiles = (c, store, themeId, files) => c.call("delete_theme_files", { store, themeId, files });
|
|
39
|
+
export const listThemeFileVersions = (c, store, filePath, limit) => c.call("list_theme_file_versions", { store, path: filePath, ...(limit ? { limit } : {}) });
|
|
40
|
+
export const restoreThemeFileVersion = (c, store, themeId, filePath, version) => c.call("restore_theme_file_version", { store, themeId, path: filePath, version });
|
|
41
|
+
/** A localized name in the reader's language: English for a terminal, else Arabic, else whatever is there. */
|
|
42
|
+
export function nameOf(name) {
|
|
43
|
+
if (!name)
|
|
44
|
+
return "";
|
|
45
|
+
if (typeof name === "string")
|
|
46
|
+
return name;
|
|
47
|
+
return name["en"] ?? name["ar"] ?? Object.values(name)[0] ?? "";
|
|
48
|
+
}
|