@raizn/cli 0.1.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.
package/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # @raizn/cli — the `raizn` theme CLI
2
+
3
+ Edit a Raizn Commerce store's theme as local files, by hand or with Claude Code. Preview it on the
4
+ store's real domain and send it for approval. It works like Shopify CLI, with one difference:
5
+ **it can never make anything live.** There is no publish, unpublish or rollback command, and there
6
+ never will be. A person with publish rights approves and publishes in the Raizn admin.
7
+
8
+ Requires Node.js 20 or newer.
9
+
10
+ ## Install and log in
11
+
12
+ ```sh
13
+ npm i -g @raizn/cli # or run any command with npx @raizn/cli …
14
+ raizn login # opens the browser: sign in to Raizn, press Allow, done
15
+ ```
16
+
17
+ Like Shopify CLI, the package is public and holds no secrets. Everything is decided by the server:
18
+ what you can do follows your staff role and stores in the Raizn admin, on every call. Removing
19
+ someone in Settings → Staff cuts their CLI off on the next command.
20
+
21
+ `raizn login` opens `/cli/authorize` on ecom.raizn.com. You sign in as usual (WhatsApp code or
22
+ password + authenticator) and press **Allow**. The browser then hands a one-time code back to
23
+ `raizn` on this computer (127.0.0.1 only), and `raizn` trades it for your token. The code is
24
+ useless to anyone else, because it needs a secret that never leaves this process (PKCE). The token
25
+ lasts 90 days and appears in Settings → AI assistant connection (MCP) as "raizn CLI — <computer>",
26
+ where you can revoke it.
27
+
28
+ No browser on this machine (a server over SSH)? Create a token by hand in that Settings page and run
29
+ `raizn login --token`, or pipe it in with `echo "$TOKEN" | raizn login`.
30
+
31
+ **Releasing a new version** (maintainers): bump `version` in `packages/cli/package.json` in a PR,
32
+ merge it, then run the **Publish raizn CLI** workflow (GitHub → Actions). It needs the repository
33
+ secret `NPM_TOKEN`: an npm granular access token with read-and-write access to the `@raizn` scope.
34
+
35
+ Either way, `login` checks the token with the server before saving it, and prints who you are, the
36
+ stores the token can reach and its permissions. A token the server rejects is not saved.
37
+
38
+ The token is stored in your OS keychain (service `raizn-cli`, account = the API origin). If there is
39
+ no keychain, it goes to `~/.config/raizn/credentials` (`%APPDATA%\raizn\credentials` on Windows)
40
+ with `0600` permissions. `raizn logout` removes it from both places.
41
+
42
+
43
+ ## Workflow
44
+
45
+ ```sh
46
+ raizn stores # which stores this token can reach
47
+ raizn theme list --store nahleh # live + draft themes
48
+ mkdir nahleh-theme && cd nahleh-theme
49
+ raizn theme pull --store nahleh # newest draft (offers to create one from live if none)
50
+ raizn theme dev # push on save + preview link on the real store domain
51
+ raizn theme check # validate changed files (nothing saved)
52
+ raizn theme submit -m "Ramadan homepage" # send for approval; prints the admin link
53
+ ```
54
+
55
+ - **pull** downloads a draft into the current folder (or `--dir`). Use `--theme <id>` to choose a
56
+ specific draft. If the store has no draft, `pull` asks whether to create one from the live theme
57
+ (`--yes` answers for you, and scripts must pass it). If you have local changes, `pull` refuses
58
+ unless you pass `--force`.
59
+ - **dev** pushes pending changes first, then watches the folder. A burst of saves goes to the server
60
+ as one request, about a second after you stop typing. Errors show inline as
61
+ `path:line:col message`. It prints a signed preview link and fetches a fresh one before the
62
+ 30-minute link expires. Ctrl-C stops it.
63
+ - **push** uploads only the files you changed since the last pull or push. If someone else changed
64
+ a file on the server in the meantime (a teammate, or the admin editor), `push` refuses that file,
65
+ prints a diff (server vs yours) and exits 1. Merge the changes and push again, or use `--force`
66
+ to overwrite the server's copy.
67
+ - **preview** prints the signed preview link, and `--open` opens it in your browser.
68
+ - **submit** refuses while you have unpushed changes. It prints the request id and
69
+ `https://ecom.raizn.com/themes/requests/<id>`.
70
+
71
+ ### Custom sections
72
+
73
+ ```sh
74
+ raizn section new promo-strip --starter blank # writes sections/promo-strip.* locally
75
+ raizn theme push # creates it on the store
76
+ raizn section history promo-strip
77
+ raizn section restore promo-strip 3 # old body becomes a NEW version; history kept
78
+ raizn section delete promo-strip # refused while any template uses it
79
+ ```
80
+
81
+ Custom sections belong to the **store**, not to one draft. Every draft previews the same draft copy
82
+ of a section. Customers keep seeing the published copy until a theme approval that uses the section
83
+ is published (the request pins the section versions at submission), or until someone presses
84
+ Publish in the admin. `submit` lists the custom sections that will go live with the request.
85
+
86
+ ## Folder layout
87
+
88
+ ```
89
+ templates/index.json templates/<name>.json (product, product.<suffix>, collection…, page…, …)
90
+ layout/header.json layout/footer.json
91
+ config/settings.json theme settings (merged on write: deleting a key does not remove it)
92
+ assets/custom.css the theme's custom CSS
93
+ sections/<type>.liquid custom section template
94
+ sections/<type>.schema.json its schema (name and group live inside)
95
+ sections/<type>.css its CSS, when it has any
96
+ .raizn/state.json what was pulled: api, store, theme, and each file's server tag + local hash
97
+ .raizn/.gitignore "*", so .raizn never lands in git
98
+ ```
99
+
100
+ Anything else in the folder, such as a README or a `.git` directory, is ignored. Files are written
101
+ exactly as the server sends them, so Arabic text round-trips byte for byte. A git checkout with
102
+ `core.autocrlf` does not count as modified, because line endings are normalised to LF before
103
+ comparing and sending. `push` never deletes a file on the server. Use `raizn section delete` for
104
+ sections.
105
+
106
+ A read-only reference copy of the **live** theme can be pulled into its own folder:
107
+
108
+ ```sh
109
+ raizn theme pull --store nahleh --live --dir ../nahleh-live
110
+ ```
111
+
112
+ `push`, `dev`, `submit` and the section write commands refuse to run in that folder. The live theme
113
+ is never pulled for editing.
114
+
115
+ ## What this can never do
116
+
117
+ - Publish, unpublish or roll back a theme or a section. `raizn theme publish` explains that
118
+ publishing happens in the admin after approval.
119
+ - Write to the live theme. The server refuses it (`not_a_draft`), and the CLI refuses first.
120
+ - Reach anything but the theme: no orders, customers, staff or payments.
121
+ - Talk to any host but the configured API. `*.vercel.app` is refused (Jordanian ISPs reset those
122
+ connections), and plain `http` is only allowed for `localhost`.
123
+
124
+ The server authorises every call. The CLI's own checks are there for convenience, and when the
125
+ server refuses (`permission_denied`, `store_not_allowed`, `not_a_draft`, …), its message is shown
126
+ verbatim. A 401 (revoked or expired token) is never retried. On a 429, the CLI prints when you can
127
+ retry: `dev` waits it out and keeps your queued changes, and other commands exit 1.
128
+
129
+ ## CI
130
+
131
+ ```yaml
132
+ - run: npx @raizn/cli theme check --store nahleh
133
+ env:
134
+ RAIZN_TOKEN: ${{ secrets.RAIZN_TOKEN }}
135
+ ```
136
+
137
+ When `RAIZN_TOKEN` is set, it is used instead of any stored token and is never saved. Without a
138
+ `.raizn/state.json`, `theme check --store <slug>` validates every theme file in the folder. It exits
139
+ 1 on any error; warnings alone exit 0.
140
+
141
+ ## Options
142
+
143
+ | | |
144
+ |---|---|
145
+ | `--api <url>` / `RAIZN_API_URL` | API endpoint. The default is `https://ecom.raizn.com/api/mcp`. The URL a folder was pulled from is recorded in its state, and later commands in that folder reuse it. |
146
+ | `RAIZN_TOKEN` | Token for CI; takes precedence over the stored one. |
147
+ | `raizn <command> --help` | Help for any command. |
148
+
149
+ The token is never printed, logged or written to project files. Every line the CLI prints passes
150
+ through a redactor.
151
+
152
+ ## Development
153
+
154
+ ```sh
155
+ pnpm --filter @raizn/cli build # tsc → dist/, bin = dist/bin.js
156
+ pnpm --filter @raizn/cli test # vitest, against a mocked endpoint (no network, no keychain)
157
+ node packages/cli/dist/bin.js --help
158
+ ```
159
+
160
+ The wire contract with the server is in `docs/theme-cli/contract.md`.
@@ -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://ecom.raizn.com/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 `raizn 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,29 @@
1
+ import { ServerError } from "./errors.js";
2
+ export const listStores = (c) => c.call("list_stores", {});
3
+ /**
4
+ * The server answers `list_themes` with a bare array today; accept `{ themes }` too so a later
5
+ * server that wraps it (structuredContent is meant to be an object) does not break old CLIs.
6
+ */
7
+ export async function listThemes(c, store) {
8
+ const r = await c.call("list_themes", { store });
9
+ if (Array.isArray(r))
10
+ return r;
11
+ if (r && typeof r === "object" && Array.isArray(r.themes))
12
+ return r.themes;
13
+ throw new ServerError("Unexpected list_themes answer from the server.", 200);
14
+ }
15
+ export const createDraft = (c, store, name, fromThemeId) => c.call("create_draft", { store, name, ...(fromThemeId ? { fromThemeId } : {}) });
16
+ export const getThemeFiles = (c, store, themeId) => c.call("get_theme_files", { store, ...(themeId ? { themeId } : {}) });
17
+ export const putThemeFiles = (c, store, themeId, files, force) => c.call("put_theme_files", { store, themeId, files, ...(force ? { force: true } : {}) });
18
+ export const validateThemeFiles = (c, store, themeId, files) => c.call("validate_theme_files", { store, ...(themeId ? { themeId } : {}), files });
19
+ export const getPreviewUrl = (c, store, themeId) => c.call("get_preview_url", { store, themeId });
20
+ export const submitForApproval = (c, store, themeId, title, description) => c.call("submit_for_approval", {
21
+ store,
22
+ themeId,
23
+ title,
24
+ ...(description ? { description } : {}),
25
+ });
26
+ export const listSectionStarters = (c) => c.call("list_section_starters", {});
27
+ export const deleteCustomSection = (c, store, type) => c.call("delete_custom_section", { store, type });
28
+ export const listCustomSectionVersions = (c, store, type, limit) => c.call("list_custom_section_versions", { store, type, ...(limit ? { limit } : {}) });
29
+ export const restoreCustomSectionVersion = (c, store, type, version) => c.call("restore_custom_section_version", { store, type, version });
package/dist/bin.js ADDED
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { createServer } from "node:http";
4
+ import { hostname } from "node:os";
5
+ import { watch } from "node:fs";
6
+ import { readFileSync } from "node:fs";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { Writable } from "node:stream";
9
+ import { main } from "./cli.js";
10
+ import { globalTimers } from "./context.js";
11
+ import { FileCredentialStore, SystemCredentialStore, credentialsFilePath } from "./credentials.js";
12
+ function version() {
13
+ try {
14
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
15
+ return pkg.version ?? "0.0.0";
16
+ }
17
+ catch {
18
+ return "0.0.0";
19
+ }
20
+ }
21
+ async function ask(question, hidden) {
22
+ // Prompts go to stderr so `raizn … > out.txt` still shows them and never captures them.
23
+ let muted = false;
24
+ const output = new Writable({
25
+ write(chunk, _enc, cb) {
26
+ if (!muted)
27
+ process.stderr.write(chunk);
28
+ cb();
29
+ },
30
+ });
31
+ const rl = createInterface({ input: process.stdin, output, terminal: process.stdin.isTTY === true });
32
+ try {
33
+ const pending = rl.question(question);
34
+ // Mute only after the prompt itself is written, so the question shows but keystrokes do not.
35
+ muted = hidden;
36
+ return await pending;
37
+ }
38
+ finally {
39
+ rl.close();
40
+ if (hidden)
41
+ process.stderr.write("\n");
42
+ }
43
+ }
44
+ async function readStdin() {
45
+ const chunks = [];
46
+ for await (const c of process.stdin)
47
+ chunks.push(typeof c === "string" ? Buffer.from(c) : c);
48
+ return Buffer.concat(chunks).toString("utf8");
49
+ }
50
+ const io = {
51
+ out: (line) => process.stdout.write(line + "\n"),
52
+ err: (line) => process.stderr.write(line + "\n"),
53
+ isTTY: process.stdin.isTTY === true && process.stderr.isTTY === true,
54
+ prompt: (q) => ask(q, false),
55
+ promptSecret: (q) => ask(q, true),
56
+ readStdin,
57
+ };
58
+ function openUrl(url) {
59
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
60
+ return new Promise((resolve) => {
61
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true, windowsVerbatimArguments: process.platform === "win32" });
62
+ child.on("error", () => {
63
+ process.stderr.write(`Could not open a browser; open the link above yourself.\n`);
64
+ resolve();
65
+ });
66
+ child.unref();
67
+ resolve();
68
+ });
69
+ }
70
+ const CALLBACK_PAGE = `<!doctype html><meta charset="utf-8"><title>raizn</title>
71
+ <body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;line-height:1.5">
72
+ <h1 style="font-size:1.25rem">You can close this tab</h1><p>Go back to your terminal — raizn has what it needs.</p></body>`;
73
+ /**
74
+ * The browser half of `raizn login` lands here: 127.0.0.1 only (never 0.0.0.0, so nothing on the
75
+ * network can reach it), a random free port, one callback, then it closes.
76
+ */
77
+ function listenLoopback() {
78
+ return new Promise((resolve, reject) => {
79
+ let deliver = null;
80
+ let got = null;
81
+ const server = createServer((req, res) => {
82
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
83
+ if (req.method !== "GET" || url.pathname !== "/callback") {
84
+ res.writeHead(404).end();
85
+ return;
86
+ }
87
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }).end(CALLBACK_PAGE);
88
+ if (!got) {
89
+ got = url.searchParams;
90
+ deliver?.(got);
91
+ }
92
+ });
93
+ server.on("error", reject);
94
+ server.listen(0, "127.0.0.1", () => {
95
+ const addr = server.address();
96
+ const port = typeof addr === "object" && addr ? addr.port : 0;
97
+ resolve({
98
+ port,
99
+ waitForCallback: (timeoutMs) => new Promise((res, rej) => {
100
+ if (got)
101
+ return res(got);
102
+ const t = setTimeout(() => rej(new Error("timeout")), timeoutMs);
103
+ deliver = (q) => { clearTimeout(t); res(q); };
104
+ }),
105
+ close: () => { server.close(); server.closeAllConnections?.(); },
106
+ });
107
+ });
108
+ });
109
+ }
110
+ const ctx = {
111
+ cwd: process.cwd(),
112
+ env: process.env,
113
+ fetch: (input, init) => fetch(input, init),
114
+ credentials: new SystemCredentialStore(new FileCredentialStore(credentialsFilePath())),
115
+ io,
116
+ now: () => new Date(),
117
+ timers: globalTimers,
118
+ watch: (dir, onChange) => {
119
+ const w = watch(dir, { recursive: true }, (_event, filename) => onChange(filename ? filename.toString() : null));
120
+ w.on("error", (e) => process.stderr.write(`Watch error: ${e.message}\n`));
121
+ return w;
122
+ },
123
+ openUrl,
124
+ listenLoopback,
125
+ hostname: hostname(),
126
+ onInterrupt: (handler) => {
127
+ process.on("SIGINT", handler);
128
+ return () => process.off("SIGINT", handler);
129
+ },
130
+ version: version(),
131
+ };
132
+ main(process.argv.slice(2), ctx).then((code) => {
133
+ process.exitCode = code;
134
+ }, (e) => {
135
+ // main() already redacts and reports every expected failure; this is the last-ditch net.
136
+ process.stderr.write(`Unexpected error: ${e instanceof Error ? e.message.replace(/rzn_[A-Za-z0-9_-]{16,}/g, "***") : "unknown"}\n`);
137
+ process.exitCode = 1;
138
+ });
package/dist/chunk.js ADDED
@@ -0,0 +1,31 @@
1
+ import { writeRank } from "./layout.js";
2
+ /**
3
+ * The server refuses bodies over 1,000,000 bytes. Aim well under it so the envelope, JSON
4
+ * escaping of Arabic text and a proxy's own headers never tip a request over.
5
+ */
6
+ export const MAX_CHUNK_BYTES = 800_000;
7
+ const ENVELOPE_BYTES = 512;
8
+ /**
9
+ * Split files into requests, keeping the server's write order across requests: every section
10
+ * goes out before any settings/CSS, and those before any template. A single file larger than
11
+ * the limit travels alone and the server's 413 explains itself.
12
+ */
13
+ export function chunkFiles(files, limit = MAX_CHUNK_BYTES) {
14
+ const ordered = [...files].sort((a, b) => writeRank(a.path) - writeRank(b.path) || a.path.localeCompare(b.path));
15
+ const chunks = [];
16
+ let current = [];
17
+ let size = ENVELOPE_BYTES;
18
+ for (const f of ordered) {
19
+ const bytes = Buffer.byteLength(JSON.stringify(f), "utf8") + 1;
20
+ if (current.length > 0 && size + bytes > limit) {
21
+ chunks.push(current);
22
+ current = [];
23
+ size = ENVELOPE_BYTES;
24
+ }
25
+ current.push(f);
26
+ size += bytes;
27
+ }
28
+ if (current.length > 0)
29
+ chunks.push(current);
30
+ return chunks;
31
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,262 @@
1
+ import { parseArgs } from "node:util";
2
+ import { ApiUrlError } from "./api-url.js";
3
+ import { login, logout, stores, whoami } from "./commands/auth.js";
4
+ import { themeDev } from "./commands/dev.js";
5
+ import { sectionDelete, sectionHistory, sectionNew, sectionRestore } from "./commands/section.js";
6
+ import { themeCheck, themeList, themePreview, themePull, themePush, themeSubmit } from "./commands/theme.js";
7
+ import { AuthError, CliError, RateLimitError, ToolRefusal, retryAt } from "./errors.js";
8
+ import { makeRedactor } from "./redact.js";
9
+ import { Session } from "./session.js";
10
+ const GLOBAL = {
11
+ api: { type: "string" },
12
+ help: { type: "boolean", short: "h" },
13
+ };
14
+ const str = (v, k) => (typeof v[k] === "string" ? v[k] : undefined);
15
+ const bool = (v, k) => v[k] === true;
16
+ const COMMANDS = {
17
+ login: {
18
+ usage: "raizn login [--token]",
19
+ summary: "Sign in through the browser (or paste a token with --token)",
20
+ help: [
21
+ "Opens ecom.raizn.com in your browser: sign in as usual, press Allow, and you are logged in.",
22
+ "The token is saved to the system keychain, or to ~/.config/raizn/credentials (0600) when there",
23
+ "is no keychain. It follows your staff access and can be revoked in Settings → AI assistant",
24
+ "connection (MCP).",
25
+ "--token: paste a token created by hand instead (e.g. on a server with no browser).",
26
+ "In scripts: echo \"$TOKEN\" | raizn login. With RAIZN_TOKEN set, it verifies that token and saves nothing.",
27
+ ],
28
+ options: { token: { type: "boolean" } },
29
+ run: (s, v) => login(s, { token: bool(v, "token") }),
30
+ },
31
+ logout: { usage: "raizn logout", summary: "Remove the stored token", help: [], options: {}, run: (s) => logout(s) },
32
+ whoami: { usage: "raizn whoami", summary: "Show who the token belongs to and what it can reach", help: [], options: {}, run: (s) => whoami(s) },
33
+ stores: { usage: "raizn stores", summary: "List the stores this token can reach", help: [], options: {}, run: (s) => stores(s) },
34
+ "theme list": {
35
+ usage: "raizn theme list --store <slug>",
36
+ summary: "List the store's live and draft themes",
37
+ help: [],
38
+ options: { store: { type: "string" } },
39
+ run: (s, v) => themeList(s, { ...(str(v, "store") ? { store: str(v, "store") } : {}) }),
40
+ },
41
+ "theme pull": {
42
+ usage: "raizn theme pull --store <slug> [--theme <id>] [--yes] [--dir <path>] [--force]\n raizn theme pull --store <slug> --live --dir <path>",
43
+ summary: "Download a draft theme into this folder",
44
+ help: [
45
+ "Picks the newest draft unless --theme is given; offers to create one from the live theme",
46
+ "when there is none (--yes answers for you). The live theme is never pulled for editing:",
47
+ "--live writes a READ-ONLY reference copy into --dir, where push, dev and submit refuse to run.",
48
+ "Refuses to overwrite local changes unless --force.",
49
+ ],
50
+ options: {
51
+ store: { type: "string" },
52
+ theme: { type: "string" },
53
+ live: { type: "boolean" },
54
+ yes: { type: "boolean", short: "y" },
55
+ dir: { type: "string" },
56
+ force: { type: "boolean" },
57
+ },
58
+ run: (s, v) => themePull(s, {
59
+ ...(str(v, "store") ? { store: str(v, "store") } : {}),
60
+ ...(str(v, "theme") ? { theme: str(v, "theme") } : {}),
61
+ ...(str(v, "dir") ? { dir: str(v, "dir") } : {}),
62
+ live: bool(v, "live"),
63
+ yes: bool(v, "yes"),
64
+ force: bool(v, "force"),
65
+ }),
66
+ },
67
+ "theme check": {
68
+ usage: "raizn theme check [--all] [--store <slug>]",
69
+ summary: "Validate theme files on the server (nothing is saved)",
70
+ help: [
71
+ "Checks changed files by default, every file with --all. Prints path:line:col severity message",
72
+ "and exits 1 on any error (warnings exit 0). Outside a checkout (CI), --store <slug> checks every file.",
73
+ ],
74
+ options: { all: { type: "boolean" }, store: { type: "string" } },
75
+ run: (s, v) => themeCheck(s, { all: bool(v, "all"), ...(str(v, "store") ? { store: str(v, "store") } : {}) }),
76
+ },
77
+ "theme push": {
78
+ usage: "raizn theme push [files…] [--force]",
79
+ summary: "Upload changed files to the draft",
80
+ help: [
81
+ "Sends only files that changed since the last pull/push. A file someone else changed on the",
82
+ "server is refused as a conflict and shown as a diff; --force overwrites the server's copy.",
83
+ ],
84
+ options: { force: { type: "boolean" } },
85
+ run: (s, v, pos) => themePush(s, { files: pos, force: bool(v, "force") }),
86
+ },
87
+ "theme dev": {
88
+ usage: "raizn theme dev",
89
+ summary: "Push on save and print a preview link on the real store domain",
90
+ help: ["Pushes pending changes, then watches the folder and pushes each burst of saves as one batch. Ctrl-C stops."],
91
+ options: {},
92
+ run: (s) => themeDev(s),
93
+ },
94
+ "theme preview": {
95
+ usage: "raizn theme preview [--open]",
96
+ summary: "Print a signed preview link (valid 30 minutes)",
97
+ help: [],
98
+ options: { open: { type: "boolean" } },
99
+ run: (s, v) => themePreview(s, { open: bool(v, "open") }),
100
+ },
101
+ "theme submit": {
102
+ usage: 'raizn theme submit -m "title" [--description "…"]',
103
+ summary: "Send the draft for approval (a person publishes it in the admin)",
104
+ help: ["Refuses while there are unpushed changes. Prints the request id and its admin link."],
105
+ options: { message: { type: "string", short: "m" }, description: { type: "string" } },
106
+ run: (s, v) => themeSubmit(s, {
107
+ ...(str(v, "message") ? { title: str(v, "message") } : {}),
108
+ ...(str(v, "description") ? { description: str(v, "description") } : {}),
109
+ }),
110
+ },
111
+ "section new": {
112
+ usage: "raizn section new <type> [--starter <key>]",
113
+ summary: "Create a custom section's files locally from a starter",
114
+ help: ["Writes sections/<type>.liquid, .schema.json and .css (when the starter has CSS). The next push creates it on the store."],
115
+ options: { starter: { type: "string" } },
116
+ run: (s, v, pos) => sectionNew(s, pos[0], { ...(str(v, "starter") ? { starter: str(v, "starter") } : {}) }),
117
+ },
118
+ "section delete": {
119
+ usage: "raizn section delete <type>",
120
+ summary: "Delete a custom section from the store",
121
+ help: ["Refused by the server while any theme's template still places it."],
122
+ options: {},
123
+ run: (s, _v, pos) => sectionDelete(s, pos[0]),
124
+ },
125
+ "section history": {
126
+ usage: "raizn section history <type> [--limit <n>]",
127
+ summary: "List a custom section's versions",
128
+ help: [],
129
+ options: { limit: { type: "string" } },
130
+ run: (s, v, pos) => {
131
+ const limit = str(v, "limit") ? Number(str(v, "limit")) : undefined;
132
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1))
133
+ throw new CliError("--limit must be a positive whole number.");
134
+ return sectionHistory(s, pos[0], { ...(limit ? { limit } : {}) });
135
+ },
136
+ },
137
+ "section restore": {
138
+ usage: "raizn section restore <type> <version> [--force]",
139
+ summary: "Restore an old version as a new draft version",
140
+ help: ["History is never rewritten: the old body becomes a new version. Local files are refreshed."],
141
+ options: { force: { type: "boolean" } },
142
+ run: (s, v, pos) => sectionRestore(s, pos[0], pos[1], { force: bool(v, "force") }),
143
+ },
144
+ };
145
+ const GROUPS = new Set(["theme", "section"]);
146
+ /** Words people reach for that this tool deliberately does not have. */
147
+ const NEVER = new Set(["publish", "unpublish", "rollback", "go-live", "golive", "release", "deploy"]);
148
+ const NEVER_MESSAGE = "does not exist. This CLI can never make anything live: publishing happens in the Raizn admin after approval.\n" +
149
+ 'Run `raizn theme submit -m "…"`, then someone with publish rights reviews and publishes it in the admin.';
150
+ function mainHelp(version) {
151
+ const rows = Object.entries(COMMANDS).map(([name, c]) => [` ${name}`, c.summary]);
152
+ const w = Math.max(...rows.map((r) => r[0].length));
153
+ return [
154
+ `raizn ${version} — edit a Raizn Commerce store's theme as local files.`,
155
+ "",
156
+ "Usage: raizn <command> [options]",
157
+ "",
158
+ "Commands:",
159
+ ...rows.map(([a, b]) => `${a.padEnd(w)} ${b}`),
160
+ "",
161
+ "Global options:",
162
+ " --api <url> API endpoint (env RAIZN_API_URL; default https://ecom.raizn.com/api/mcp)",
163
+ " -h, --help Help for any command, e.g. raizn theme pull --help",
164
+ " --version Print the version",
165
+ "",
166
+ "Environment: RAIZN_TOKEN is used instead of a stored token (CI).",
167
+ "",
168
+ "Workflow: raizn login → raizn theme pull --store <slug> → raizn theme dev → raizn theme submit -m \"…\"",
169
+ "Nothing here can publish. A person approves and publishes in the Raizn admin.",
170
+ ];
171
+ }
172
+ function commandHelp(c) {
173
+ return [`Usage: ${c.usage}`, "", c.summary + ".", ...(c.help.length ? ["", ...c.help] : []), "", "Global: --api <url>, -h/--help"];
174
+ }
175
+ /** Find the command words (`theme pull`) among argv, skipping flags and the value of `--api`. */
176
+ function splitCommand(argv) {
177
+ const words = [];
178
+ const rest = [];
179
+ for (let i = 0; i < argv.length; i++) {
180
+ const a = argv[i];
181
+ const wanted = words.length === 0 || (words.length === 1 && GROUPS.has(words[0]));
182
+ if (!wanted || a.startsWith("-")) {
183
+ rest.push(a);
184
+ if (a === "--api" && i + 1 < argv.length)
185
+ rest.push(argv[++i]);
186
+ continue;
187
+ }
188
+ words.push(a);
189
+ }
190
+ return { words, rest };
191
+ }
192
+ export async function main(argv, ctx) {
193
+ // Before a Session exists, still never echo anything token-shaped.
194
+ const redact = makeRedactor(() => [ctx.env["RAIZN_TOKEN"]]);
195
+ const fail = (msg, code = 1) => {
196
+ ctx.io.err(redact(msg));
197
+ return code;
198
+ };
199
+ if (argv.includes("--version") || (argv.length === 1 && argv[0] === "-v")) {
200
+ ctx.io.out(ctx.version);
201
+ return 0;
202
+ }
203
+ const { words, rest } = splitCommand(argv);
204
+ if (words.length === 0) {
205
+ for (const line of mainHelp(ctx.version))
206
+ ctx.io.out(line);
207
+ return rest.includes("--help") || rest.includes("-h") || argv.length === 0 ? 0 : 1;
208
+ }
209
+ if (words.some((w) => NEVER.has(w)))
210
+ return fail(`\`raizn ${words.join(" ")}\` ${NEVER_MESSAGE}`);
211
+ const name = words.join(" ");
212
+ const command = COMMANDS[name];
213
+ if (!command) {
214
+ if (words.length === 1 && GROUPS.has(words[0])) {
215
+ const subs = Object.keys(COMMANDS).filter((k) => k.startsWith(`${words[0]} `));
216
+ return fail(`Usage: raizn ${words[0]} <${subs.map((k) => k.split(" ")[1]).join("|")}>. See raizn --help.`);
217
+ }
218
+ return fail(`Unknown command "${name}". See raizn --help.`);
219
+ }
220
+ let values;
221
+ let positionals;
222
+ try {
223
+ ({ values, positionals } = parseArgs({ args: [...rest], options: { ...GLOBAL, ...command.options }, allowPositionals: true, strict: true }));
224
+ }
225
+ catch (e) {
226
+ return fail(`${e instanceof Error ? e.message : String(e)}\nUsage: ${command.usage}`);
227
+ }
228
+ if (values["help"] === true) {
229
+ for (const line of commandHelp(command))
230
+ ctx.io.out(line);
231
+ return 0;
232
+ }
233
+ const takesFiles = name === "theme push" || name.startsWith("section ");
234
+ if (!takesFiles && positionals.length > 0)
235
+ return fail(`Unexpected argument "${positionals[0]}".\nUsage: ${command.usage}`);
236
+ const s = new Session(ctx, typeof values["api"] === "string" ? values["api"] : undefined);
237
+ try {
238
+ return await command.run(s, values, positionals);
239
+ }
240
+ catch (e) {
241
+ return fail(s.redact(describeError(e, ctx.now())));
242
+ }
243
+ }
244
+ /** One friendly prefix, then the server's own words — never paraphrased, never swallowed. */
245
+ export function describeError(e, now) {
246
+ if (e instanceof AuthError)
247
+ return `Not authorised: ${e.message}`;
248
+ if (e instanceof RateLimitError) {
249
+ return `Rate limited: ${e.message} Try again in ${e.retryAfterSeconds}s (after ${retryAt(e.retryAfterSeconds, now)}).`;
250
+ }
251
+ if (e instanceof ToolRefusal) {
252
+ const detail = e.details === undefined ? "" : `\n${JSON.stringify(e.details, null, 2)}`;
253
+ return `The server refused this (${e.tool}): ${e.code}: ${e.serverMessage}${detail}`;
254
+ }
255
+ if (e instanceof ApiUrlError || e instanceof CliError)
256
+ return e.message;
257
+ if (e instanceof Error)
258
+ return `Unexpected error: ${e.message}`;
259
+ return `Unexpected error: ${String(e)}`;
260
+ }
261
+ /** For tests and docs: every command this CLI has. */
262
+ export const COMMAND_NAMES = Object.keys(COMMANDS);