@cerkel/cli 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +497 -2
  2. package/dist/api-url.js +42 -0
  3. package/dist/api.js +48 -0
  4. package/dist/bin.js +157 -0
  5. package/dist/chunk.js +31 -0
  6. package/dist/cli.js +497 -0
  7. package/dist/client.js +125 -0
  8. package/dist/command.js +2 -0
  9. package/dist/commands/args.js +107 -0
  10. package/dist/commands/auth.js +255 -0
  11. package/dist/commands/call.js +67 -0
  12. package/dist/commands/collection.js +121 -0
  13. package/dist/commands/content.js +281 -0
  14. package/dist/commands/customer.js +126 -0
  15. package/dist/commands/dev.js +261 -0
  16. package/dist/commands/draft.js +101 -0
  17. package/dist/commands/file.js +53 -0
  18. package/dist/commands/index.js +43 -0
  19. package/dist/commands/inventory.js +103 -0
  20. package/dist/commands/offer.js +92 -0
  21. package/dist/commands/order-args.js +37 -0
  22. package/dist/commands/order.js +219 -0
  23. package/dist/commands/price.js +55 -0
  24. package/dist/commands/product.js +265 -0
  25. package/dist/commands/promo.js +140 -0
  26. package/dist/commands/redirect.js +94 -0
  27. package/dist/commands/report.js +93 -0
  28. package/dist/commands/section.js +233 -0
  29. package/dist/commands/store.js +23 -0
  30. package/dist/commands/theme-tools.js +432 -0
  31. package/dist/commands/theme.js +388 -0
  32. package/dist/commands/whatsapp.js +39 -0
  33. package/dist/commands/zone.js +90 -0
  34. package/dist/commerce.js +138 -0
  35. package/dist/content-files.js +246 -0
  36. package/dist/context.js +5 -0
  37. package/dist/credentials.js +201 -0
  38. package/dist/diff.js +146 -0
  39. package/dist/env.js +24 -0
  40. package/dist/errors.js +73 -0
  41. package/dist/format.js +13 -0
  42. package/dist/layout.js +122 -0
  43. package/dist/logins.js +49 -0
  44. package/dist/raizn-alias.js +6 -0
  45. package/dist/redact.js +27 -0
  46. package/dist/session.js +266 -0
  47. package/dist/state.js +153 -0
  48. package/dist/sync.js +121 -0
  49. package/dist/zip.js +88 -0
  50. package/package.json +37 -3
package/dist/state.js ADDED
@@ -0,0 +1,153 @@
1
+ import { existsSync, promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { CliError } from "./errors.js";
4
+ import { LAYOUT_DIRS, hashBytes, isLayoutPath, legacySectionPartOf, toDiskPath } from "./layout.js";
5
+ /**
6
+ * `.cerkel/state.json` — what this folder was pulled from and the server tag + local hash of every
7
+ * file at that moment. It never holds the token: a checkout gets committed, zipped and shared.
8
+ */
9
+ export const STATE_DIR = ".cerkel";
10
+ /** Where `raizn` (before 1.0) kept a folder's state. A folder pulled then keeps using it; nothing is moved. */
11
+ export const LEGACY_STATE_DIR = ".raizn";
12
+ export const STATE_FILE = "state.json";
13
+ /**
14
+ * The state folder of `root` for `file`: `.cerkel`, unless the folder was pulled by `raizn` (before 1.0)
15
+ * and so holds `.raizn/<file>`, or a `.raizn` folder and no `.cerkel` one (so its state stays together).
16
+ */
17
+ export function stateDirOf(root, file = STATE_FILE) {
18
+ if (existsSync(path.join(root, STATE_DIR, file)))
19
+ return STATE_DIR;
20
+ if (existsSync(path.join(root, LEGACY_STATE_DIR, file)))
21
+ return LEGACY_STATE_DIR;
22
+ if (existsSync(path.join(root, LEGACY_STATE_DIR)) && !existsSync(path.join(root, STATE_DIR)))
23
+ return LEGACY_STATE_DIR;
24
+ return STATE_DIR;
25
+ }
26
+ export const statePath = (root) => path.join(root, stateDirOf(root), STATE_FILE);
27
+ export async function readState(root) {
28
+ let raw;
29
+ try {
30
+ raw = await fs.readFile(statePath(root), "utf8");
31
+ }
32
+ catch (e) {
33
+ if (e.code === "ENOENT")
34
+ return null;
35
+ throw e;
36
+ }
37
+ let s;
38
+ try {
39
+ s = JSON.parse(raw);
40
+ }
41
+ catch {
42
+ throw new CliError(`${statePath(root)} is not valid JSON. Pull again with \`cerkel theme pull --force\`.`);
43
+ }
44
+ const o = s;
45
+ if (o.version !== 1 || typeof o.store !== "string" || typeof o.themeId !== "string" || typeof o.files !== "object" || o.files === null) {
46
+ throw new CliError(`${statePath(root)} is not a state file this version of cerkel understands. Pull again with \`cerkel theme pull --force\`.`);
47
+ }
48
+ return { ...o, readOnly: o.readOnly === true };
49
+ }
50
+ /** Written to a temp file and renamed, so a Ctrl-C mid-write never leaves half a state file. */
51
+ export async function writeState(root, state) {
52
+ const dir = path.join(root, stateDirOf(root));
53
+ await fs.mkdir(dir, { recursive: true });
54
+ // `*` keeps the whole folder — state, tags, anything added later — out of the theme's git repo.
55
+ await fs.writeFile(path.join(dir, ".gitignore"), "*\n");
56
+ const file = statePath(root);
57
+ const tmp = `${file}.${process.pid}.tmp`;
58
+ const sorted = {};
59
+ for (const k of Object.keys(state.files).sort())
60
+ sorted[k] = state.files[k];
61
+ await fs.writeFile(tmp, JSON.stringify({ ...state, files: sorted }, null, 2) + "\n");
62
+ await fs.rename(tmp, file);
63
+ }
64
+ /** Walk up from `start` to the nearest folder holding `.cerkel/state.json` (or `.raizn/`), like git finds `.git`. */
65
+ export async function findRoot(start) {
66
+ let dir = path.resolve(start);
67
+ for (;;) {
68
+ try {
69
+ await fs.access(statePath(dir));
70
+ return dir;
71
+ }
72
+ catch {
73
+ /* keep walking */
74
+ }
75
+ const up = path.dirname(dir);
76
+ if (up === dir)
77
+ return null;
78
+ dir = up;
79
+ }
80
+ }
81
+ /** Every layout file under `root`, as POSIX layout paths. Anything else in the folder is ignored. */
82
+ export async function listLocalFiles(root) {
83
+ const out = [];
84
+ for (const top of LAYOUT_DIRS) {
85
+ let entries;
86
+ try {
87
+ entries = await fs.readdir(path.join(root, top), { withFileTypes: true });
88
+ }
89
+ catch {
90
+ continue;
91
+ }
92
+ for (const e of entries) {
93
+ if (!e.isFile())
94
+ continue;
95
+ const p = `${top}/${e.name}`;
96
+ if (isLayoutPath(p))
97
+ out.push(p);
98
+ }
99
+ }
100
+ return out.sort();
101
+ }
102
+ export async function readLocal(root, layoutPath) {
103
+ try {
104
+ return await fs.readFile(toDiskPath(root, layoutPath));
105
+ }
106
+ catch (e) {
107
+ if (e.code === "ENOENT" || e.code === "EISDIR")
108
+ return null;
109
+ throw e;
110
+ }
111
+ }
112
+ /**
113
+ * `sections/<type>.schema.json` / `.css` files of a checkout pulled before sections became ONE file
114
+ * (migration 0079). The server refuses them; they are listed so push can say what to do instead.
115
+ */
116
+ export async function listLegacySectionParts(root) {
117
+ let entries;
118
+ try {
119
+ entries = await fs.readdir(path.join(root, "sections"), { withFileTypes: true });
120
+ }
121
+ catch {
122
+ return [];
123
+ }
124
+ return entries.filter((e) => e.isFile() && legacySectionPartOf(`sections/${e.name}`)).map((e) => `sections/${e.name}`).sort();
125
+ }
126
+ /**
127
+ * Files whose bytes differ from what the state recorded, plus layout files the state never saw.
128
+ * `deleted` = files the state tracks that are gone from disk (legacy section parts never count).
129
+ */
130
+ export async function localChanges(root, state, only) {
131
+ const files = state?.files ?? {};
132
+ const onDisk = only ? [...only] : await listLocalFiles(root);
133
+ const changes = [];
134
+ for (const p of onDisk) {
135
+ const bytes = await readLocal(root, p);
136
+ if (!bytes)
137
+ continue;
138
+ const hash = hashBytes(bytes);
139
+ const known = files[p];
140
+ if (!known)
141
+ changes.push({ path: p, kind: "new", bytes, hash });
142
+ else if (known.hash !== hash)
143
+ changes.push({ path: p, kind: "modified", bytes, hash });
144
+ }
145
+ const deleted = [];
146
+ if (!only) {
147
+ const present = new Set(onDisk);
148
+ for (const p of Object.keys(files))
149
+ if (!present.has(p) && isLayoutPath(p))
150
+ deleted.push(p);
151
+ }
152
+ return { changes, deleted: deleted.sort() };
153
+ }
package/dist/sync.js ADDED
@@ -0,0 +1,121 @@
1
+ import { createHash } from "node:crypto";
2
+ import { deleteThemeFiles, putThemeFiles } from "./api.js";
3
+ import { chunkFiles } from "./chunk.js";
4
+ import { unifiedDiff } from "./diff.js";
5
+ import { formatIssue } from "./format.js";
6
+ import { writeState } from "./state.js";
7
+ import { isDeletablePath, isGeneratedPath, textOf } from "./layout.js";
8
+ /** Contract: tag = first 16 hex of SHA-256 over the UTF-8 content. Only a fallback; the server's tag wins. */
9
+ const tagOf = (content) => createHash("sha256").update(content, "utf8").digest("hex").slice(0, 16);
10
+ /**
11
+ * Send `changes` with the tag each was based on, then record what the server accepted. State is
12
+ * written after every chunk, so a 429 or a dropped connection halfway through never forgets the
13
+ * files that did land, and never marks as pushed a file that did not.
14
+ */
15
+ export async function pushChanges(s, client, root, state, changes, opts) {
16
+ const outcome = { saved: 0, unchanged: 0, conflicts: [], serverCopies: new Map(), invalid: [], refused: [] };
17
+ const byPath = new Map(changes.map((c) => [c.path, c]));
18
+ // `baseTags` lets a confirmed `push --force` claim the exact server version the person was shown.
19
+ const baseOf = (p) => (opts.baseTags?.has(p) ? opts.baseTags.get(p) : (state.files[p]?.tag ?? null));
20
+ const payload = changes.map((c) => ({ path: c.path, content: textOf(c.bytes), baseTag: baseOf(c.path) }));
21
+ for (const chunk of chunkFiles(payload)) {
22
+ const r = await putThemeFiles(client, state.store, state.themeId, chunk, opts.force);
23
+ for (const res of r.results ?? []) {
24
+ const local = byPath.get(res.path);
25
+ if (!local)
26
+ continue;
27
+ const sent = chunk.find((f) => f.path === res.path);
28
+ report(s, res, sent?.content ?? "", opts.compact === true);
29
+ if (res.status === "saved" || res.status === "unchanged") {
30
+ // Keep OUR bytes' hash with the server's tag: the server may store a normalised form,
31
+ // but what is on disk is what we sent, so that is what "unmodified" must mean.
32
+ state.files[res.path] = { tag: res.tag ?? tagOf(sent?.content ?? ""), hash: local.hash };
33
+ if (res.status === "saved")
34
+ outcome.saved++;
35
+ else
36
+ outcome.unchanged++;
37
+ }
38
+ else if (res.status === "conflict") {
39
+ outcome.conflicts.push(res.path);
40
+ outcome.serverCopies.set(res.path, res.current);
41
+ }
42
+ else if (res.status === "invalid")
43
+ outcome.invalid.push(res.path);
44
+ else
45
+ outcome.refused.push(res.path);
46
+ }
47
+ await writeState(root, state);
48
+ }
49
+ return outcome;
50
+ }
51
+ function report(s, res, localContent, compact) {
52
+ const pad = (status) => ` ${status.padEnd(9)} ${res.path}`;
53
+ switch (res.status) {
54
+ case "saved":
55
+ s.out(pad("saved"));
56
+ return;
57
+ case "unchanged":
58
+ if (!compact)
59
+ s.out(pad("unchanged"));
60
+ return;
61
+ case "conflict": {
62
+ s.out(`${pad("conflict")} — changed on the server since you last pulled or pushed it`);
63
+ if (res.current) {
64
+ const diff = unifiedDiff(res.current.content, localContent, `server/${res.path}`, `local/${res.path}`);
65
+ for (const line of diff.trimEnd().split("\n"))
66
+ s.out(line);
67
+ }
68
+ return;
69
+ }
70
+ case "invalid":
71
+ s.out(pad("invalid"));
72
+ for (const issue of res.errors ?? [])
73
+ s.out(` ${formatIssue(res.path, issue, !compact)}`);
74
+ return;
75
+ default:
76
+ s.out(`${pad(res.status)}${res.message ? ` — ${res.message}` : ""}`);
77
+ for (const issue of res.errors ?? [])
78
+ s.out(` ${formatIssue(res.path, issue, !compact)}`);
79
+ }
80
+ }
81
+ export function conflictAdvice(s, conflicts) {
82
+ if (conflicts.length === 0)
83
+ return;
84
+ s.err(`${conflicts.length} file(s) changed on the server since you last synced them (someone else, or the admin editor, saved a newer version). ` +
85
+ "Merge the server's changes into your file and push again, run `cerkel theme pull --force` to take the server's version, or `cerkel theme push --force` to overwrite it.");
86
+ }
87
+ /**
88
+ * Files deleted locally that the state still tracks → deleted from the draft, each only if the
89
+ * server still holds the copy we last synced (`baseTag`): a newer server copy is a conflict, never a
90
+ * silent loss. A section still placed by a template is refused by the server with the templates named.
91
+ */
92
+ export async function pushDeletes(s, client, root, state, paths, opts = {}) {
93
+ const out = { deleted: 0, kept: [], failed: [] };
94
+ const targets = paths.filter((p) => isDeletablePath(p) && !isGeneratedPath(p));
95
+ out.kept = paths.filter((p) => !targets.includes(p));
96
+ for (const p of out.kept)
97
+ s.err(` ${p} was deleted locally but cannot be deleted from the draft (it always exists); \`cerkel theme pull\` brings it back.`);
98
+ if (targets.length === 0)
99
+ return out;
100
+ const r = await deleteThemeFiles(client, state.store, state.themeId, targets.map((p) => ({ path: p, baseTag: state.files[p]?.tag ?? null })));
101
+ for (const res of r.results ?? []) {
102
+ if (res.status === "deleted" || res.status === "unchanged") {
103
+ delete state.files[res.path];
104
+ if (res.status === "deleted")
105
+ out.deleted++;
106
+ if (res.status === "deleted" || !opts.compact)
107
+ s.out(` ${"deleted".padEnd(9)} ${res.path}`);
108
+ }
109
+ else {
110
+ out.failed.push(res.path);
111
+ const why = res.status === "in_use"
112
+ ? `still placed by ${(res.usedIn ?? []).join(", ") || "a template"} — remove it from there first`
113
+ : res.status === "conflict"
114
+ ? "changed on the server since you last synced it — pull first"
115
+ : (res.message ?? "refused");
116
+ s.out(` ${res.status.padEnd(9)} ${res.path} — ${why}`);
117
+ }
118
+ }
119
+ await writeState(root, state);
120
+ return out;
121
+ }
package/dist/zip.js ADDED
@@ -0,0 +1,88 @@
1
+ import { deflateRawSync } from "node:zlib";
2
+ // ---------------------------------------------------------------------------
3
+ // A minimal ZIP writer for `cerkel theme package` — no dependency (the CLI ships with one, the
4
+ // keychain). PKZIP 2.0: one local header + data per file, then the central directory. Each file is
5
+ // DEFLATE-compressed unless that would make it larger (then stored), names are UTF-8 (flag bit 11,
6
+ // so Arabic file names would survive — theme paths are ASCII today), timestamps are the given date
7
+ // in DOS format. Readable by every unzip, macOS Archive Utility, Windows Explorer and Shopify-style
8
+ // tooling. Files and archives here are tiny (a theme is well under 4 GB), so no ZIP64.
9
+ // ---------------------------------------------------------------------------
10
+ const CRC_TABLE = (() => {
11
+ const t = new Uint32Array(256);
12
+ for (let n = 0; n < 256; n++) {
13
+ let c = n;
14
+ for (let k = 0; k < 8; k++)
15
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
16
+ t[n] = c >>> 0;
17
+ }
18
+ return t;
19
+ })();
20
+ export function crc32(buf) {
21
+ let c = 0xffffffff;
22
+ for (let i = 0; i < buf.length; i++)
23
+ c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
24
+ return (c ^ 0xffffffff) >>> 0;
25
+ }
26
+ /** A Date as the DOS time and date fields (local time, 2-second resolution, 1980 or later). */
27
+ function dosTime(d) {
28
+ const year = Math.max(1980, d.getFullYear());
29
+ return {
30
+ time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
31
+ date: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
32
+ };
33
+ }
34
+ export function createZip(entries, modified) {
35
+ const { time, date } = dosTime(modified);
36
+ const locals = [];
37
+ const centrals = [];
38
+ let offset = 0;
39
+ for (const e of entries) {
40
+ const name = Buffer.from(e.name, "utf8");
41
+ const raw = Buffer.from(e.data);
42
+ const deflated = deflateRawSync(raw, { level: 9 });
43
+ const store = deflated.length >= raw.length;
44
+ const body = store ? raw : deflated;
45
+ const crc = crc32(raw);
46
+ const local = Buffer.alloc(30);
47
+ local.writeUInt32LE(0x04034b50, 0);
48
+ local.writeUInt16LE(20, 4); // version needed: 2.0
49
+ local.writeUInt16LE(0x0800, 6); // UTF-8 names
50
+ local.writeUInt16LE(store ? 0 : 8, 8);
51
+ local.writeUInt16LE(time, 10);
52
+ local.writeUInt16LE(date, 12);
53
+ local.writeUInt32LE(crc, 14);
54
+ local.writeUInt32LE(body.length, 18);
55
+ local.writeUInt32LE(raw.length, 22);
56
+ local.writeUInt16LE(name.length, 26);
57
+ local.writeUInt16LE(0, 28);
58
+ locals.push(local, name, body);
59
+ const central = Buffer.alloc(46);
60
+ central.writeUInt32LE(0x02014b50, 0);
61
+ central.writeUInt16LE((3 << 8) | 20, 4); // made by: Unix, 2.0 — so the mode below is read
62
+ central.writeUInt16LE(20, 6);
63
+ central.writeUInt16LE(0x0800, 8);
64
+ central.writeUInt16LE(store ? 0 : 8, 10);
65
+ central.writeUInt16LE(time, 12);
66
+ central.writeUInt16LE(date, 14);
67
+ central.writeUInt32LE(crc, 16);
68
+ central.writeUInt32LE(body.length, 20);
69
+ central.writeUInt32LE(raw.length, 24);
70
+ central.writeUInt16LE(name.length, 28);
71
+ central.writeUInt16LE(0, 30); // extra
72
+ central.writeUInt16LE(0, 32); // comment
73
+ central.writeUInt16LE(0, 34); // disk
74
+ central.writeUInt16LE(0, 36); // internal attributes
75
+ central.writeUInt32LE((0o100644 << 16) >>> 0, 38); // -rw-r--r--
76
+ central.writeUInt32LE(offset, 42);
77
+ centrals.push(central, name);
78
+ offset += local.length + name.length + body.length;
79
+ }
80
+ const dir = Buffer.concat(centrals);
81
+ const end = Buffer.alloc(22);
82
+ end.writeUInt32LE(0x06054b50, 0);
83
+ end.writeUInt16LE(entries.length, 8);
84
+ end.writeUInt16LE(entries.length, 10);
85
+ end.writeUInt32LE(dir.length, 12);
86
+ end.writeUInt32LE(offset, 16);
87
+ return Buffer.concat([...locals, dir, end]);
88
+ }
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@cerkel/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0",
4
+ "description": "Run a Cerkel store from the terminal: its theme as local files (preview, send for approval) and its catalogue, prices, stock and collections.",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Raizn-Global/raizn-commerce.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "type": "module",
12
+ "bin": {
13
+ "cerkel": "dist/bin.js",
14
+ "raizn": "dist/raizn-alias.js"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.20.3",
25
+ "typescript": "~5.9.3",
26
+ "vitest": "^4.1.8"
27
+ },
28
+ "dependencies": {
29
+ "@napi-rs/keyring": "^2.1.0"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "registry": "https://registry.npmjs.org/"
34
+ },
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.build.json",
37
+ "typecheck": "tsc -p tsconfig.json --noEmit",
38
+ "test": "vitest run"
39
+ }
6
40
  }