@cerkel/cli 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +502 -2
  2. package/dist/api-url.js +42 -0
  3. package/dist/api.js +48 -0
  4. package/dist/bin.js +157 -0
  5. package/dist/chunk.js +31 -0
  6. package/dist/cli.js +497 -0
  7. package/dist/client.js +125 -0
  8. package/dist/command.js +2 -0
  9. package/dist/commands/args.js +107 -0
  10. package/dist/commands/auth.js +255 -0
  11. package/dist/commands/call.js +67 -0
  12. package/dist/commands/collection.js +121 -0
  13. package/dist/commands/content.js +281 -0
  14. package/dist/commands/customer.js +126 -0
  15. package/dist/commands/dev.js +261 -0
  16. package/dist/commands/draft.js +101 -0
  17. package/dist/commands/file.js +53 -0
  18. package/dist/commands/index.js +43 -0
  19. package/dist/commands/inventory.js +103 -0
  20. package/dist/commands/offer.js +92 -0
  21. package/dist/commands/order-args.js +37 -0
  22. package/dist/commands/order.js +219 -0
  23. package/dist/commands/price.js +55 -0
  24. package/dist/commands/product.js +265 -0
  25. package/dist/commands/promo.js +140 -0
  26. package/dist/commands/redirect.js +94 -0
  27. package/dist/commands/report.js +93 -0
  28. package/dist/commands/section.js +233 -0
  29. package/dist/commands/store.js +23 -0
  30. package/dist/commands/theme-tools.js +432 -0
  31. package/dist/commands/theme.js +388 -0
  32. package/dist/commands/whatsapp.js +39 -0
  33. package/dist/commands/zone.js +90 -0
  34. package/dist/commerce.js +138 -0
  35. package/dist/content-files.js +246 -0
  36. package/dist/context.js +5 -0
  37. package/dist/credentials.js +201 -0
  38. package/dist/diff.js +146 -0
  39. package/dist/env.js +24 -0
  40. package/dist/errors.js +73 -0
  41. package/dist/format.js +13 -0
  42. package/dist/layout.js +122 -0
  43. package/dist/logins.js +49 -0
  44. package/dist/raizn-alias.js +6 -0
  45. package/dist/redact.js +27 -0
  46. package/dist/session.js +266 -0
  47. package/dist/state.js +153 -0
  48. package/dist/sync.js +121 -0
  49. package/dist/zip.js +88 -0
  50. package/package.json +37 -3
@@ -0,0 +1,246 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import { CliError } from "./errors.js";
5
+ import { stateDirOf } from "./state.js";
6
+ export const CONTENT_FILE = "content.json";
7
+ /** `<dir>/.cerkel/content.json`, or `.raizn/` for a folder pulled before 1.0 (state.ts stateDirOf). */
8
+ export const contentStatePath = (dir) => path.join(dir, stateDirOf(dir, CONTENT_FILE), CONTENT_FILE);
9
+ export const hashText = (s) => createHash("sha256").update(s.replace(/\r\n/g, "\n"), "utf8").digest("hex");
10
+ /** Languages in a stable order: Arabic first (the default), then the rest alphabetically. */
11
+ const langOrder = (keys) => [...keys].sort((a, b) => (a === "ar" ? -1 : b === "ar" ? 1 : a.localeCompare(b)));
12
+ export const pagePathOf = (p) => p.kind === "article" ? `blogs/${p.blog ?? "news"}/${p.handle}.html` : p.kind === "policy" ? `policies/${p.handle}.html` : `pages/${p.handle}.html`;
13
+ export const menuPathOf = (handle) => `menus/${handle}.json`;
14
+ /** A relative POSIX path → what it holds, or null when it is not a content file. */
15
+ export function addressOf(rel) {
16
+ const seg = rel.split("/");
17
+ const strip = (f, ext) => (f.endsWith(ext) && f.length > ext.length ? f.slice(0, -ext.length) : null);
18
+ if (seg.length === 2 && seg[0] === "pages") {
19
+ const h = strip(seg[1], ".html");
20
+ return h ? { type: "page", page: { kind: "page", blog: null, handle: h } } : null;
21
+ }
22
+ if (seg.length === 2 && seg[0] === "policies") {
23
+ const h = strip(seg[1], ".html");
24
+ return h ? { type: "page", page: { kind: "policy", blog: null, handle: h } } : null;
25
+ }
26
+ if (seg.length === 3 && seg[0] === "blogs") {
27
+ const h = strip(seg[2], ".html");
28
+ return h ? { type: "page", page: { kind: "article", blog: seg[1], handle: h } } : null;
29
+ }
30
+ if (seg.length === 2 && seg[0] === "menus") {
31
+ const h = strip(seg[1], ".json");
32
+ return h ? { type: "menu", handle: h } : null;
33
+ }
34
+ return null;
35
+ }
36
+ export function pageFileOf(p) {
37
+ return {
38
+ title: p.title, bodyHtml: p.bodyHtml, summary: p.summary, blogTitle: p.blogTitle, seoTitle: p.seoTitle, seoDescription: p.seoDescription,
39
+ imageUrl: p.imageUrl, author: p.author, tags: p.tags, templateSuffix: p.templateSuffix, published: p.published, publishedAt: p.publishedAt,
40
+ };
41
+ }
42
+ /** The file text for a page. Its settings (everything but the body) are pretty JSON; bodies follow per language. */
43
+ export function serializePage(p) {
44
+ const { bodyHtml, ...settings } = p;
45
+ const head = `<!--cerkel\n${JSON.stringify(settings, null, 2)}\n-->\n`;
46
+ const bodies = langOrder(Object.keys(bodyHtml)).map((l) => `<!--lang:${l}-->\n${bodyHtml[l].replace(/\n+$/, "")}\n`).join("");
47
+ return head + bodies;
48
+ }
49
+ const LANG_MARKER = /^<!--lang:([a-z]{2,3}(?:-[A-Za-z0-9]{2,8})?)-->$/gm;
50
+ const isLocalized = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
51
+ /** Parse a page file. Throws CliError naming the file and what is wrong. */
52
+ export function parsePage(text, file) {
53
+ const src = text.replace(/\r\n/g, "\n").replace(/^\uFEFF/, "");
54
+ // `<!--raizn` is what `raizn` wrote before 1.0: such a file still pushes as it is.
55
+ const open = ["<!--cerkel\n", "<!--raizn\n"].find((m) => src.startsWith(m));
56
+ if (!open)
57
+ throw new CliError(`${file}: a page file starts with its settings in <!--cerkel … --> (pull a page to see the layout).`);
58
+ const end = src.indexOf("\n-->", open.length - 1);
59
+ if (end < 0)
60
+ throw new CliError(`${file}: the <!--cerkel settings block is not closed with -->.`);
61
+ let settings;
62
+ try {
63
+ settings = JSON.parse(src.slice(open.length, end));
64
+ }
65
+ catch (e) {
66
+ throw new CliError(`${file}: the settings block is not valid JSON (${e instanceof Error ? e.message : String(e)}).`);
67
+ }
68
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings))
69
+ throw new CliError(`${file}: the settings block must be a JSON object.`);
70
+ const rest = src.slice(end + 4).replace(/^\n/, "");
71
+ const bodyHtml = {};
72
+ const marks = [...rest.matchAll(LANG_MARKER)];
73
+ if (!marks.length && rest.trim())
74
+ throw new CliError(`${file}: put the body after a language marker, e.g. <!--lang:ar--> on its own line.`);
75
+ if (marks.length && rest.slice(0, marks[0].index).trim())
76
+ throw new CliError(`${file}: text before the first <!--lang:xx--> marker belongs to no language.`);
77
+ marks.forEach((m, i) => {
78
+ const start = m.index + m[0].length;
79
+ const stop = i + 1 < marks.length ? marks[i + 1].index : rest.length;
80
+ const body = rest.slice(start, stop).replace(/^\n/, "").replace(/\n+$/, "");
81
+ if (bodyHtml[m[1]] !== undefined)
82
+ throw new CliError(`${file}: the language "${m[1]}" appears twice.`);
83
+ if (body.trim())
84
+ bodyHtml[m[1]] = body;
85
+ });
86
+ const loc = (k, required) => {
87
+ const v = settings[k];
88
+ if (v === null || v === undefined) {
89
+ if (required)
90
+ throw new CliError(`${file}: "${k}" is required (text per language, e.g. {"ar": "…", "en": "…"}).`);
91
+ return null;
92
+ }
93
+ if (!isLocalized(v))
94
+ throw new CliError(`${file}: "${k}" must be text per language, e.g. {"ar": "…", "en": "…"}.`);
95
+ return v;
96
+ };
97
+ const str = (k) => {
98
+ const v = settings[k];
99
+ if (v === null || v === undefined || v === "")
100
+ return null;
101
+ if (typeof v !== "string")
102
+ throw new CliError(`${file}: "${k}" must be text or null.`);
103
+ return v;
104
+ };
105
+ const known = new Set(["title", "summary", "blogTitle", "seoTitle", "seoDescription", "imageUrl", "author", "tags", "templateSuffix", "published", "publishedAt"]);
106
+ const unknown = Object.keys(settings).filter((k) => !known.has(k));
107
+ if (unknown.length)
108
+ throw new CliError(`${file}: unknown setting(s) ${unknown.map((k) => `"${k}"`).join(", ")}. The handle comes from the file name.`);
109
+ const tags = settings["tags"] ?? [];
110
+ if (!Array.isArray(tags) || tags.some((t) => typeof t !== "string"))
111
+ throw new CliError(`${file}: "tags" must be a list of text.`);
112
+ const published = settings["published"] ?? true;
113
+ if (typeof published !== "boolean")
114
+ throw new CliError(`${file}: "published" must be true or false.`);
115
+ return {
116
+ title: loc("title", true), bodyHtml, summary: loc("summary", false), blogTitle: loc("blogTitle", false), seoTitle: loc("seoTitle", false),
117
+ seoDescription: loc("seoDescription", false), imageUrl: str("imageUrl"), author: str("author"), tags: tags, templateSuffix: str("templateSuffix"),
118
+ published, publishedAt: str("publishedAt"),
119
+ };
120
+ }
121
+ export const serializeMenu = (m) => `${JSON.stringify({ title: m.title, items: m.items }, null, 2)}\n`;
122
+ export function parseMenu(text, file) {
123
+ let v;
124
+ try {
125
+ v = JSON.parse(text.replace(/^\uFEFF/, ""));
126
+ }
127
+ catch (e) {
128
+ throw new CliError(`${file}: not valid JSON (${e instanceof Error ? e.message : String(e)}).`);
129
+ }
130
+ const o = v;
131
+ if (typeof o !== "object" || o === null || Array.isArray(o) || typeof o["title"] !== "string" || !Array.isArray(o["items"])) {
132
+ throw new CliError(`${file}: a menu file is { "title": "…", "items": [ … ] }.`);
133
+ }
134
+ const unknown = Object.keys(o).filter((k) => k !== "title" && k !== "items");
135
+ if (unknown.length)
136
+ throw new CliError(`${file}: unknown key(s) ${unknown.map((k) => `"${k}"`).join(", ")}. The handle comes from the file name.`);
137
+ return { title: o["title"], items: o["items"] };
138
+ }
139
+ /**
140
+ * update_page arguments that make the server's page equal the file: per-language fields send every
141
+ * local language and "" for a language the server has but the file dropped (update_page merges).
142
+ */
143
+ export function pageUpdateArgs(id, addr, file, server) {
144
+ const withRemovals = (local, remote) => {
145
+ const out = { ...local };
146
+ for (const k of Object.keys(remote ?? {}))
147
+ if (!(k in local))
148
+ out[k] = "";
149
+ return out;
150
+ };
151
+ const nullable = (local, remote) => (local === null ? null : withRemovals(local, remote));
152
+ return {
153
+ page: id,
154
+ ...(addr.handle !== server.handle ? { handle: addr.handle } : {}),
155
+ ...(addr.kind === "article" && addr.blog !== server.blog ? { newBlog: addr.blog } : {}),
156
+ title: withRemovals(file.title, server.title),
157
+ bodyHtml: withRemovals(file.bodyHtml, server.bodyHtml),
158
+ summary: nullable(file.summary, server.summary),
159
+ blogTitle: nullable(file.blogTitle, server.blogTitle),
160
+ seoTitle: nullable(file.seoTitle, server.seoTitle),
161
+ seoDescription: nullable(file.seoDescription, server.seoDescription),
162
+ imageUrl: file.imageUrl, author: file.author, tags: file.tags, templateSuffix: file.templateSuffix,
163
+ published: file.published, publishedAt: file.publishedAt,
164
+ };
165
+ }
166
+ /** create_page arguments for a new file. */
167
+ export function pageCreateArgs(addr, file) {
168
+ const out = { kind: addr.kind, handle: addr.handle, title: file.title, bodyHtml: file.bodyHtml, tags: file.tags, published: file.published };
169
+ if (addr.kind === "article")
170
+ out["blog"] = addr.blog;
171
+ for (const k of ["summary", "blogTitle", "seoTitle", "seoDescription", "imageUrl", "author", "templateSuffix", "publishedAt"])
172
+ if (file[k] !== null)
173
+ out[k] = file[k];
174
+ return out;
175
+ }
176
+ // --- the folder ----------------------------------------------------------------------
177
+ export async function readContentState(dir) {
178
+ let raw;
179
+ try {
180
+ raw = await fs.readFile(contentStatePath(dir), "utf8");
181
+ }
182
+ catch (e) {
183
+ if (e.code === "ENOENT")
184
+ return null;
185
+ throw e;
186
+ }
187
+ const s = JSON.parse(raw);
188
+ if (s.version !== 1 || typeof s.store !== "string" || typeof s.files !== "object" || s.files === null) {
189
+ throw new CliError(`${contentStatePath(dir)} is not a content state file this version of cerkel understands. Pull again with --force.`);
190
+ }
191
+ return s;
192
+ }
193
+ export async function writeContentState(dir, state) {
194
+ const d = path.join(dir, stateDirOf(dir, CONTENT_FILE));
195
+ await fs.mkdir(d, { recursive: true });
196
+ await fs.writeFile(path.join(d, ".gitignore"), "*\n");
197
+ const sorted = {};
198
+ for (const k of Object.keys(state.files).sort())
199
+ sorted[k] = state.files[k];
200
+ const file = path.join(d, CONTENT_FILE);
201
+ await fs.writeFile(`${file}.tmp`, JSON.stringify({ ...state, files: sorted }, null, 2) + "\n");
202
+ await fs.rename(`${file}.tmp`, file);
203
+ }
204
+ /** Every content file under `dir` of the given types, as POSIX relative paths. */
205
+ export async function listContentFiles(dir, types) {
206
+ const out = [];
207
+ const walk = async (rel, depth) => {
208
+ let entries;
209
+ try {
210
+ entries = await fs.readdir(path.join(dir, rel), { withFileTypes: true });
211
+ }
212
+ catch (e) {
213
+ if (e.code === "ENOENT")
214
+ return;
215
+ throw e;
216
+ }
217
+ for (const e of entries) {
218
+ const r = rel ? `${rel}/${e.name}` : e.name;
219
+ if (e.isDirectory() && depth < 2)
220
+ await walk(r, depth + 1);
221
+ else if (e.isFile()) {
222
+ const a = addressOf(r);
223
+ if (a && types.includes(a.type))
224
+ out.push(r);
225
+ }
226
+ }
227
+ };
228
+ for (const top of ["pages", "policies", "blogs", "menus"])
229
+ await walk(top, 1);
230
+ return out.sort();
231
+ }
232
+ export async function readText(dir, rel) {
233
+ try {
234
+ return await fs.readFile(path.join(dir, ...rel.split("/")), "utf8");
235
+ }
236
+ catch (e) {
237
+ if (e.code === "ENOENT")
238
+ return null;
239
+ throw e;
240
+ }
241
+ }
242
+ export async function writeText(dir, rel, text) {
243
+ const full = path.join(dir, ...rel.split("/"));
244
+ await fs.mkdir(path.dirname(full), { recursive: true });
245
+ await fs.writeFile(full, text);
246
+ }
@@ -0,0 +1,5 @@
1
+ /** Timers that read the globals at call time, so vitest's fake timers apply to them. */
2
+ export const globalTimers = {
3
+ setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
4
+ clearTimeout: (h) => globalThis.clearTimeout(h),
5
+ };
@@ -0,0 +1,201 @@
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ /**
5
+ * Where a token lives between runs. The OS keychain first (encrypted at rest, unlocked with the
6
+ * login session); a 0600 file when there is no keychain — a headless Linux box, a container, a
7
+ * platform `@napi-rs/keyring` has no binary for. Both are keyed by API origin so a staging login
8
+ * never shadows production.
9
+ *
10
+ * Until 1.0 the CLI was `raizn`: its logins sit under the keychain service "raizn-cli" and in
11
+ * ~/.config/raizn/credentials. Each is read once, moved to the cerkel place, and removed there.
12
+ */
13
+ export const KEYCHAIN_SERVICE = "cerkel-cli";
14
+ export const LEGACY_KEYCHAIN_SERVICE = "raizn-cli";
15
+ /**
16
+ * Loaded lazily and inside try/catch: the native addon is an optional convenience, and a missing
17
+ * binary must degrade to the file store instead of crashing every command.
18
+ */
19
+ async function loadKeyring() {
20
+ try {
21
+ const mod = (await import("@napi-rs/keyring"));
22
+ return typeof mod.Entry === "function" ? mod : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ export function credentialsFilePath(env = process.env, platform = process.platform, app = "cerkel") {
29
+ if (platform === "win32") {
30
+ const base = env["APPDATA"] || path.join(os.homedir(), "AppData", "Roaming");
31
+ return path.join(base, app, "credentials");
32
+ }
33
+ return path.join(os.homedir(), ".config", app, "credentials");
34
+ }
35
+ /** Where `raizn` (before 1.0) kept the file: read once and moved (FileCredentialStore). */
36
+ export const legacyCredentialsFilePath = (env = process.env, platform = process.platform) => credentialsFilePath(env, platform, "raizn");
37
+ async function readJsonFile(file) {
38
+ try {
39
+ const parsed = JSON.parse(await fs.readFile(file, "utf8"));
40
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
41
+ }
42
+ catch (e) {
43
+ return e.code === "ENOENT" ? null : {};
44
+ }
45
+ }
46
+ export class FileCredentialStore {
47
+ file;
48
+ legacyFile;
49
+ migrated = false;
50
+ /** `legacyFile`: the old cerkel file. While the cerkel file does not exist yet, its logins move over once. */
51
+ constructor(file, legacyFile = null) {
52
+ this.file = file;
53
+ this.legacyFile = legacyFile;
54
+ }
55
+ async readAll() {
56
+ const current = await readJsonFile(this.file);
57
+ if (current !== null || !this.legacyFile || this.migrated)
58
+ return current ?? {};
59
+ this.migrated = true;
60
+ const old = await readJsonFile(this.legacyFile);
61
+ if (!old || Object.keys(old).length === 0)
62
+ return {};
63
+ await this.writeAll(old);
64
+ await fs.rm(this.legacyFile, { force: true });
65
+ return old;
66
+ }
67
+ async writeAll(all) {
68
+ await fs.mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
69
+ const tmp = `${this.file}.${process.pid}.tmp`;
70
+ await fs.writeFile(tmp, JSON.stringify(all, null, 2) + "\n", { mode: 0o600 });
71
+ // `mode` only applies when the file is created; chmod covers a pre-existing temp name and
72
+ // filesystems that ignore the create mode. No-op on Windows, where %APPDATA% is per-user.
73
+ await fs.chmod(tmp, 0o600).catch(() => { });
74
+ await fs.rename(tmp, this.file);
75
+ }
76
+ async get(origin) {
77
+ const v = (await this.readAll())[origin];
78
+ return typeof v === "string" && v ? v : null;
79
+ }
80
+ async set(origin, token) {
81
+ const all = await this.readAll();
82
+ all[origin] = token;
83
+ await this.writeAll(all);
84
+ return "file";
85
+ }
86
+ async delete(origin) {
87
+ const all = await this.readAll();
88
+ if (!(origin in all))
89
+ return false;
90
+ delete all[origin];
91
+ if (Object.keys(all).length === 0) {
92
+ await fs.rm(this.file, { force: true });
93
+ }
94
+ else {
95
+ await this.writeAll(all);
96
+ }
97
+ return true;
98
+ }
99
+ }
100
+ export class SystemCredentialStore {
101
+ file;
102
+ keyring = null;
103
+ constructor(file) {
104
+ this.file = file;
105
+ }
106
+ entry(origin, service = KEYCHAIN_SERVICE) {
107
+ this.keyring ??= loadKeyring();
108
+ return this.keyring.then((k) => {
109
+ if (!k)
110
+ return null;
111
+ try {
112
+ return new k.Entry(service, origin);
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ });
118
+ }
119
+ async get(origin) {
120
+ const e = await this.entry(origin);
121
+ if (e) {
122
+ try {
123
+ const v = e.getPassword();
124
+ if (v)
125
+ return v;
126
+ }
127
+ catch {
128
+ /* locked or unavailable keychain: fall through to the file */
129
+ }
130
+ const moved = await this.moveLegacy(origin, e);
131
+ if (moved)
132
+ return moved;
133
+ }
134
+ return this.file.get(origin);
135
+ }
136
+ /** A login `raizn` (before 1.0) saved under "raizn-cli": copied to "cerkel-cli" once, then removed there. */
137
+ async moveLegacy(origin, target) {
138
+ const old = await this.entry(origin, LEGACY_KEYCHAIN_SERVICE);
139
+ if (!old)
140
+ return null;
141
+ try {
142
+ const v = old.getPassword();
143
+ if (!v)
144
+ return null;
145
+ target.setPassword(v);
146
+ try {
147
+ old.deletePassword();
148
+ }
149
+ catch { /* the copy is in place; a stale old entry is only read once more */ }
150
+ return v;
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ async set(origin, token) {
157
+ const e = await this.entry(origin);
158
+ if (e) {
159
+ try {
160
+ e.setPassword(token);
161
+ // A stale copy in the file would outlive a later `logout` that only reached the keychain.
162
+ await this.file.delete(origin);
163
+ return "keychain";
164
+ }
165
+ catch {
166
+ /* no usable keychain (e.g. no Secret Service on a headless Linux box) */
167
+ }
168
+ }
169
+ return this.file.set(origin, token);
170
+ }
171
+ async delete(origin) {
172
+ let removed = false;
173
+ // Logging out also removes a login `raizn` saved that was never moved.
174
+ for (const service of [KEYCHAIN_SERVICE, LEGACY_KEYCHAIN_SERVICE]) {
175
+ const e = await this.entry(origin, service);
176
+ if (!e)
177
+ continue;
178
+ try {
179
+ removed = e.deletePassword() || removed;
180
+ }
181
+ catch {
182
+ /* nothing to delete, or keychain unavailable */
183
+ }
184
+ }
185
+ return (await this.file.delete(origin)) || removed;
186
+ }
187
+ }
188
+ /** In-memory store for tests and for callers that must never touch the machine's keychain. */
189
+ export class MemoryCredentialStore {
190
+ map = new Map();
191
+ async get(origin) {
192
+ return this.map.get(origin) ?? null;
193
+ }
194
+ async set(origin, token) {
195
+ this.map.set(origin, token);
196
+ return "file";
197
+ }
198
+ async delete(origin) {
199
+ return this.map.delete(origin);
200
+ }
201
+ }
package/dist/diff.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * A unified diff for conflicts: what the server holds now vs what you have locally. Myers'
3
+ * O(ND) algorithm rather than an LCS table, because a 5,000-line template against itself with a
4
+ * handful of edits should take microseconds, not a 100 MB matrix.
5
+ */
6
+ /** Beyond this many edits the files are simply different; a full replace reads just as well. */
7
+ const MAX_EDITS = 2000;
8
+ function myers(a, b) {
9
+ const n = a.length;
10
+ const m = b.length;
11
+ const max = n + m;
12
+ const offset = max + 1;
13
+ const v = new Int32Array(2 * max + 3);
14
+ // trace[d] = the frontier before round d, trimmed to the diagonals round d can read.
15
+ const trace = [];
16
+ const at = (d, k) => trace[d][k + d + 1];
17
+ let found = false;
18
+ for (let d = 0; d <= max && !found; d++) {
19
+ if (d > MAX_EDITS)
20
+ return [...a.map((line) => ({ kind: "-", line })), ...b.map((line) => ({ kind: "+", line }))];
21
+ trace.push(v.slice(offset - d - 1, offset + d + 2));
22
+ for (let k = -d; k <= d; k += 2) {
23
+ let x;
24
+ if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]))
25
+ x = v[offset + k + 1];
26
+ else
27
+ x = v[offset + k - 1] + 1;
28
+ let y = x - k;
29
+ while (x < n && y < m && a[x] === b[y]) {
30
+ x++;
31
+ y++;
32
+ }
33
+ v[offset + k] = x;
34
+ if (x >= n && y >= m) {
35
+ found = true;
36
+ break;
37
+ }
38
+ }
39
+ }
40
+ // Walk the trace backwards to recover the edit script.
41
+ const ops = [];
42
+ let x = n;
43
+ let y = m;
44
+ for (let d = trace.length - 1; d >= 0; d--) {
45
+ const k = x - y;
46
+ let prevK;
47
+ if (k === -d || (k !== d && at(d, k - 1) < at(d, k + 1)))
48
+ prevK = k + 1;
49
+ else
50
+ prevK = k - 1;
51
+ const prevX = d === 0 ? 0 : at(d, prevK);
52
+ const prevY = d === 0 ? 0 : prevX - prevK;
53
+ while (x > prevX && y > prevY) {
54
+ ops.push({ kind: " ", line: a[x - 1] });
55
+ x--;
56
+ y--;
57
+ }
58
+ if (d === 0)
59
+ break;
60
+ if (x === prevX)
61
+ ops.push({ kind: "+", line: b[y - 1] });
62
+ else
63
+ ops.push({ kind: "-", line: a[x - 1] });
64
+ x = prevX;
65
+ y = prevY;
66
+ }
67
+ return ops.reverse();
68
+ }
69
+ const splitLines = (s) => {
70
+ const lines = s.replace(/\r\n/g, "\n").split("\n");
71
+ if (lines.length > 0 && lines[lines.length - 1] === "")
72
+ lines.pop();
73
+ return lines;
74
+ };
75
+ export function unifiedDiff(oldText, newText, oldLabel, newLabel, context = 3) {
76
+ const a = splitLines(oldText);
77
+ const b = splitLines(newText);
78
+ const ops = myers(a, b);
79
+ if (ops.every((o) => o.kind === " "))
80
+ return "";
81
+ const out = [`--- ${oldLabel}`, `+++ ${newLabel}`];
82
+ // Positions (1-based) in a and b at each op, to label hunks.
83
+ let ai = 1;
84
+ let bi = 1;
85
+ const pos = ops.map((o) => {
86
+ const p = { a: ai, b: bi };
87
+ if (o.kind !== "+")
88
+ ai++;
89
+ if (o.kind !== "-")
90
+ bi++;
91
+ return p;
92
+ });
93
+ let i = 0;
94
+ while (i < ops.length) {
95
+ if (ops[i].kind === " ") {
96
+ i++;
97
+ continue;
98
+ }
99
+ const start = Math.max(0, i - context);
100
+ let end = i;
101
+ // Extend the hunk while the next change is within 2×context unchanged lines.
102
+ for (;;) {
103
+ while (end < ops.length && ops[end].kind !== " ")
104
+ end++;
105
+ let gap = end;
106
+ while (gap < ops.length && ops[gap].kind === " ")
107
+ gap++;
108
+ if (gap < ops.length && gap - end <= context * 2)
109
+ end = gap;
110
+ else {
111
+ end = Math.min(ops.length, end + context);
112
+ break;
113
+ }
114
+ }
115
+ const hunk = ops.slice(start, end);
116
+ const aStart = pos[start].a;
117
+ const bStart = pos[start].b;
118
+ const aLen = hunk.filter((o) => o.kind !== "+").length;
119
+ const bLen = hunk.filter((o) => o.kind !== "-").length;
120
+ out.push(`@@ -${aLen === 0 ? aStart - 1 : aStart},${aLen} +${bLen === 0 ? bStart - 1 : bStart},${bLen} @@`);
121
+ for (const o of hunk)
122
+ out.push(`${o.kind}${o.line}`);
123
+ i = end;
124
+ }
125
+ return out.join("\n") + "\n";
126
+ }
127
+ /** How much of `oldText` would disappear if it were replaced by `newText`, line by line. */
128
+ export function diffStats(oldText, newText) {
129
+ const ops = myers(splitLines(oldText), splitLines(newText));
130
+ const stats = { removed: 0, added: 0, places: 0 };
131
+ let runRemoves = false;
132
+ for (const o of [...ops, { kind: " ", line: "" }]) {
133
+ if (o.kind === " ") {
134
+ if (runRemoves)
135
+ stats.places++;
136
+ runRemoves = false;
137
+ }
138
+ else if (o.kind === "-") {
139
+ stats.removed++;
140
+ runRemoves = true;
141
+ }
142
+ else
143
+ stats.added++;
144
+ }
145
+ return stats;
146
+ }
package/dist/env.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The CLI's environment variables. CERKEL_TOKEN and CERKEL_API_URL are the names since 1.0; the old
3
+ * RAIZN_TOKEN and RAIZN_API_URL (the CLI was `raizn` until then) still work, and a CERKEL_* one wins
4
+ * when both are set.
5
+ */
6
+ export const TOKEN_VARS = ["CERKEL_TOKEN", "RAIZN_TOKEN"];
7
+ export const API_URL_VARS = ["CERKEL_API_URL", "RAIZN_API_URL"];
8
+ function first(env, names) {
9
+ for (const name of names) {
10
+ const value = env[name]?.trim();
11
+ if (value)
12
+ return { name, value };
13
+ }
14
+ return null;
15
+ }
16
+ /** The token a pipeline injected, and the name it came under (for messages). */
17
+ export const envToken = (env) => first(env, TOKEN_VARS);
18
+ /** The API URL the environment asks for, or null. */
19
+ export const envApiUrl = (env) => first(env, API_URL_VARS)?.value ?? null;
20
+ /** Every token the environment holds under either name, raw and trimmed, so the redactor hides them all. */
21
+ export const envTokenSecrets = (env) => TOKEN_VARS.flatMap((name) => {
22
+ const v = env[name];
23
+ return v ? [v, v.trim()] : [];
24
+ });