@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.
@@ -0,0 +1,297 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { apiOrigin } from "../api-url.js";
4
+ import { createDraft, getPreviewUrl, getThemeFiles, listThemes, submitForApproval, validateThemeFiles, } from "../api.js";
5
+ import { chunkFiles } from "../chunk.js";
6
+ import { CliError } from "../errors.js";
7
+ import { formatIssue, plural, table } from "../format.js";
8
+ import { bytesOf, hashBytes, isLayoutPath, textOf, toDiskPath, toLayoutPath } from "../layout.js";
9
+ import { findRoot, listLocalFiles, localChanges, readLocal, readState, writeState } from "../state.js";
10
+ import { conflictAdvice, pushChanges } from "../sync.js";
11
+ const byNewest = (a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
12
+ // --- list --------------------------------------------------------------------
13
+ export async function themeList(s, opts) {
14
+ const co = await s.checkout();
15
+ const store = opts.store ?? co?.state.store;
16
+ if (!store)
17
+ throw new CliError("Which store? Pass --store <slug> (see `raizn stores`).");
18
+ const themes = await listThemes(await s.client(co?.state), store);
19
+ const rank = (t) => (t.role === "live" ? 0 : t.role === "draft" ? 1 : 2);
20
+ const rows = [...themes].sort((a, b) => rank(a) - rank(b) || byNewest(a, b));
21
+ if (rows.length === 0) {
22
+ s.err(`${store} has no themes.`);
23
+ return 0;
24
+ }
25
+ for (const line of table([["ID", "NAME", "ROLE", "UPDATED"], ...rows.map((t) => [t.id, t.name, t.role, t.updatedAt ?? "—"])]))
26
+ s.out(line);
27
+ return 0;
28
+ }
29
+ export async function themePull(s, opts) {
30
+ const root = opts.dir ? path.resolve(s.ctx.cwd, opts.dir) : ((await findRoot(s.ctx.cwd)) ?? s.ctx.cwd);
31
+ const existing = await readState(root);
32
+ if (opts.live) {
33
+ if (!opts.dir) {
34
+ throw new CliError("--live needs --dir <folder>. The live theme is only ever pulled as a read-only reference, into a folder of its own.");
35
+ }
36
+ const enclosing = await findRoot(root);
37
+ const enclosingState = enclosing ? await readState(enclosing) : null;
38
+ if (enclosingState && !enclosingState.readOnly) {
39
+ throw new CliError(`${enclosing} is an editable draft checkout. Pull the live theme into a different folder, e.g. --dir ../live-reference.`);
40
+ }
41
+ if (opts.theme)
42
+ throw new CliError("--live and --theme cannot be combined: --live always means the store's live theme.");
43
+ }
44
+ else if (existing?.readOnly) {
45
+ throw new CliError(`${root} holds a read-only copy of the live theme. Pull a draft into a different folder (--dir <folder>).`);
46
+ }
47
+ const store = opts.store ?? existing?.store;
48
+ if (!store)
49
+ throw new CliError("Which store? Pass --store <slug> (see `raizn stores`).");
50
+ // A different store is a fresh checkout: the folder's recorded API still applies unless it was the same store.
51
+ const client = await s.client(existing && existing.store === store ? existing : null);
52
+ let files;
53
+ if (opts.live) {
54
+ files = await getThemeFiles(client, store);
55
+ }
56
+ else {
57
+ const themeId = await chooseDraft(s, client, store, opts, existing);
58
+ files = await getThemeFiles(client, store, themeId);
59
+ if (files.theme.role === "live") {
60
+ // The server decides what a theme is; if it says live, we will not open it for editing.
61
+ throw new CliError(`Theme ${themeId} is the LIVE theme. It is never pulled for editing; use --live --dir <folder> for a read-only copy.`);
62
+ }
63
+ }
64
+ // Refuse before touching anything if local work would be overwritten.
65
+ const incoming = new Map();
66
+ const skipped = [];
67
+ for (const f of files.files) {
68
+ if (!isLayoutPath(f.path)) {
69
+ skipped.push(f.path);
70
+ continue;
71
+ }
72
+ incoming.set(f.path, { content: f.content, tag: f.tag, hash: hashBytes(bytesOf(f.content)) });
73
+ }
74
+ const { changes } = await localChanges(root, existing);
75
+ const atRisk = changes.filter((c) => incoming.get(c.path)?.hash !== c.hash);
76
+ if (atRisk.length > 0 && !opts.force) {
77
+ const list = atRisk.slice(0, 20).map((c) => ` ${c.kind === "new" ? "new " : "modified"} ${c.path}`);
78
+ throw new CliError([
79
+ `${root} has ${plural(atRisk.length, "local change")} that pulling would overwrite or leave behind:`,
80
+ ...list,
81
+ ...(atRisk.length > 20 ? [` … and ${atRisk.length - 20} more`] : []),
82
+ "Push them first (`raizn theme push`), or re-run with --force to replace them with the server's copy.",
83
+ ].join("\n"));
84
+ }
85
+ let written = 0;
86
+ let same = 0;
87
+ const nextFiles = {};
88
+ for (const [p, f] of incoming) {
89
+ const disk = toDiskPath(root, p);
90
+ const current = await readLocal(root, p);
91
+ if (current && hashBytes(current) === f.hash)
92
+ same++;
93
+ else {
94
+ await fs.mkdir(path.dirname(disk), { recursive: true });
95
+ await fs.writeFile(disk, bytesOf(f.content));
96
+ written++;
97
+ }
98
+ nextFiles[p] = { tag: f.tag, hash: f.hash };
99
+ }
100
+ // Files the server no longer has: remove only what we can prove is ours and untouched.
101
+ let removed = 0;
102
+ const kept = [];
103
+ const known = existing?.files ?? {};
104
+ const candidates = new Set([...Object.keys(known), ...changes.map((c) => c.path)]);
105
+ for (const p of candidates) {
106
+ if (incoming.has(p))
107
+ continue;
108
+ const bytes = await readLocal(root, p);
109
+ if (!bytes)
110
+ continue;
111
+ if (known[p] && known[p].hash === hashBytes(bytes)) {
112
+ await fs.rm(toDiskPath(root, p), { force: true });
113
+ removed++;
114
+ }
115
+ else
116
+ kept.push(p);
117
+ }
118
+ const readOnly = opts.live;
119
+ await writeState(root, {
120
+ version: 1,
121
+ api: client.url.href,
122
+ store,
123
+ themeId: files.theme.id,
124
+ themeName: files.theme.name,
125
+ role: files.theme.role,
126
+ readOnly,
127
+ pulledAt: s.ctx.now().toISOString(),
128
+ files: nextFiles,
129
+ });
130
+ s.out(`Pulled "${files.theme.name}" (${files.theme.role}, ${files.theme.id}) from ${store} into ${root}`);
131
+ s.out(` ${plural(incoming.size, "file")}: ${written} written, ${same} already up to date${removed ? `, ${removed} removed` : ""}`);
132
+ for (const p of skipped)
133
+ s.err(` skipped ${p}: not a theme file path this CLI knows how to store`);
134
+ for (const p of kept)
135
+ s.err(` kept ${p}: the server no longer has it, but your copy has changes`);
136
+ const pending = (files.sections ?? []).filter((x) => x.unpublishedChanges);
137
+ if (pending.length > 0) {
138
+ s.out(` Custom sections with unpublished changes (store-wide): ${pending.map((x) => `${x.type} v${x.version}${x.publishedVersion ? ` (live v${x.publishedVersion})` : " (never published)"}`).join(", ")}`);
139
+ }
140
+ if (readOnly) {
141
+ s.out("This is a READ-ONLY copy of the live theme, for reference. push, dev and submit refuse to run here.");
142
+ }
143
+ else {
144
+ s.out("Next: `raizn theme dev` to edit with a live preview, then `raizn theme submit -m \"…\"`.");
145
+ }
146
+ return 0;
147
+ }
148
+ async function chooseDraft(s, client, store, opts, existing) {
149
+ const themes = await listThemes(client, store);
150
+ const live = themes.find((t) => t.role === "live");
151
+ if (opts.theme) {
152
+ const t = themes.find((x) => x.id === opts.theme);
153
+ if (!t)
154
+ throw new CliError(`No theme ${opts.theme} on ${store}. See \`raizn theme list --store ${store}\`.`);
155
+ if (t.role === "live") {
156
+ throw new CliError(`Theme ${t.id} ("${t.name}") is the LIVE theme. It is never pulled for editing. Pull a draft (or omit --theme to create one), or use --live --dir <folder> for a read-only copy.`);
157
+ }
158
+ if (t.role !== "draft")
159
+ throw new CliError(`Theme ${t.id} is ${t.role}; only drafts can be pulled for editing.`);
160
+ return t.id;
161
+ }
162
+ // Re-running pull in a checkout refreshes the draft it already tracks.
163
+ if (existing && existing.store === store && themes.some((t) => t.id === existing.themeId && t.role === "draft"))
164
+ return existing.themeId;
165
+ const drafts = themes.filter((t) => t.role === "draft").sort(byNewest);
166
+ if (drafts[0]) {
167
+ if (drafts.length > 1)
168
+ s.err(`Using the newest draft "${drafts[0].name}" (${drafts[0].id}); pass --theme <id> for another.`);
169
+ return drafts[0].id;
170
+ }
171
+ const from = live ? ` from the live theme "${live.name}"` : "";
172
+ if (!opts.yes) {
173
+ if (!s.ctx.io.isTTY) {
174
+ throw new CliError(`${store} has no draft theme. Re-run with --yes to create one${from}.`);
175
+ }
176
+ const answer = (await s.ctx.io.prompt(`${store} has no draft theme. Create a draft${from}? [y/N] `)).trim().toLowerCase();
177
+ if (answer !== "y" && answer !== "yes")
178
+ throw new CliError("No draft created; nothing pulled.");
179
+ }
180
+ const name = live ? `${live.name} (draft)` : "Draft";
181
+ const made = await createDraft(client, store, name, live?.id);
182
+ s.err(`Created draft "${made.name}" (${made.themeId}).`);
183
+ return made.themeId;
184
+ }
185
+ // --- check -------------------------------------------------------------------
186
+ export async function themeCheck(s, opts) {
187
+ const co = await s.checkout();
188
+ let root;
189
+ let store;
190
+ let themeId;
191
+ let paths;
192
+ if (co) {
193
+ root = co.root;
194
+ store = opts.store ?? co.state.store;
195
+ themeId = store === co.state.store ? co.state.themeId : undefined;
196
+ paths = opts.all ? await listLocalFiles(root) : (await localChanges(root, co.state)).changes.map((c) => c.path);
197
+ }
198
+ else {
199
+ // CI: a plain git checkout of theme files, no state — validate everything against the store.
200
+ if (!opts.store)
201
+ throw new CliError("This folder is not a theme checkout. Pass --store <slug> to validate every theme file in it.");
202
+ root = s.ctx.cwd;
203
+ store = opts.store;
204
+ paths = await listLocalFiles(root);
205
+ }
206
+ if (paths.length === 0) {
207
+ s.out(co && !opts.all ? "Nothing to check: no changed files. Use --all to check every file." : "No theme files found in this folder.");
208
+ return 0;
209
+ }
210
+ const files = [];
211
+ for (const p of paths) {
212
+ const bytes = await readLocal(root, p);
213
+ if (bytes)
214
+ files.push({ path: p, content: textOf(bytes) });
215
+ }
216
+ const client = await s.client(co?.state);
217
+ let errors = 0;
218
+ let warnings = 0;
219
+ for (const chunk of chunkFiles(files)) {
220
+ const r = await validateThemeFiles(client, store, themeId, chunk);
221
+ for (const res of r.results ?? []) {
222
+ for (const issue of res.errors ?? []) {
223
+ s.out(formatIssue(res.path, issue));
224
+ if (issue.severity === "error")
225
+ errors++;
226
+ else
227
+ warnings++;
228
+ }
229
+ }
230
+ }
231
+ s.err(`Checked ${plural(files.length, "file")}: ${plural(errors, "error")}, ${plural(warnings, "warning")}.`);
232
+ return errors > 0 ? 1 : 0;
233
+ }
234
+ // --- push --------------------------------------------------------------------
235
+ export async function themePush(s, opts) {
236
+ const { root, state } = await s.requireCheckout({ editable: true });
237
+ let only;
238
+ if (opts.files.length > 0) {
239
+ only = [];
240
+ for (const f of opts.files) {
241
+ const p = toLayoutPath(root, path.resolve(s.ctx.cwd, f));
242
+ if (!p || !isLayoutPath(p))
243
+ throw new CliError(`${f} is not a theme file in this checkout (${root}).`);
244
+ only.push(p);
245
+ }
246
+ }
247
+ const { changes, deleted } = await localChanges(root, state, only);
248
+ for (const p of deleted) {
249
+ s.err(` ${p} was deleted locally; push never deletes on the server${p.startsWith("sections/") ? " (use `raizn section delete`)" : ""}.`);
250
+ }
251
+ if (changes.length === 0) {
252
+ s.out("Nothing to push.");
253
+ return 0;
254
+ }
255
+ s.err(`Pushing ${plural(changes.length, "file")} to "${state.themeName}" on ${state.store}${opts.force ? " (--force: overwriting server changes)" : ""}…`);
256
+ const client = await s.client(state);
257
+ const r = await pushChanges(s, client, root, state, changes, { force: opts.force });
258
+ conflictAdvice(s, r.conflicts);
259
+ const failed = r.conflicts.length + r.invalid.length + r.refused.length;
260
+ s.err(`${r.saved} saved, ${r.unchanged} unchanged, ${failed} failed.`);
261
+ return failed > 0 ? 1 : 0;
262
+ }
263
+ // --- preview -----------------------------------------------------------------
264
+ export async function themePreview(s, opts) {
265
+ const { state } = await s.requireCheckout({ editable: false });
266
+ const r = await getPreviewUrl(await s.client(state), state.store, state.themeId);
267
+ s.out(r.url);
268
+ if (r.expiresAt)
269
+ s.err(`Signed link; expires ${r.expiresAt}.`);
270
+ if (opts.open)
271
+ await s.ctx.openUrl(r.url);
272
+ return 0;
273
+ }
274
+ // --- submit ------------------------------------------------------------------
275
+ export async function themeSubmit(s, opts) {
276
+ const title = opts.title?.trim();
277
+ if (!title)
278
+ throw new CliError('A title is required: raizn theme submit -m "What this change does".');
279
+ const { root, state } = await s.requireCheckout({ editable: true });
280
+ const { changes } = await localChanges(root, state);
281
+ if (changes.length > 0) {
282
+ throw new CliError([
283
+ `You have ${plural(changes.length, "unpushed change")}; the approval would not include them:`,
284
+ ...changes.slice(0, 20).map((c) => ` ${c.path}`),
285
+ "Run `raizn theme push` first, then submit.",
286
+ ].join("\n"));
287
+ }
288
+ const client = await s.client(state);
289
+ const r = await submitForApproval(client, state.store, state.themeId, title, opts.description);
290
+ s.out(`Submitted "${state.themeName}" for approval: request ${r.requestId}`);
291
+ s.out(`Review it: ${apiOrigin(client.url)}/themes/requests/${encodeURIComponent(r.requestId)}`);
292
+ if (r.sections && r.sections.length > 0) {
293
+ s.out(`These custom sections will go live with this request when it is published: ${r.sections.map((x) => `${x.type} v${x.version}`).join(", ")}.`);
294
+ }
295
+ s.out("Nothing is live yet. Someone with publish rights approves and publishes it in the Raizn admin.");
296
+ return 0;
297
+ }
@@ -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,153 @@
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
+ export const KEYCHAIN_SERVICE = "raizn-cli";
11
+ /**
12
+ * Loaded lazily and inside try/catch: the native addon is an optional convenience, and a missing
13
+ * binary must degrade to the file store instead of crashing every command.
14
+ */
15
+ async function loadKeyring() {
16
+ try {
17
+ const mod = (await import("@napi-rs/keyring"));
18
+ return typeof mod.Entry === "function" ? mod : null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export function credentialsFilePath(env = process.env, platform = process.platform) {
25
+ if (platform === "win32") {
26
+ const base = env["APPDATA"] || path.join(os.homedir(), "AppData", "Roaming");
27
+ return path.join(base, "raizn", "credentials");
28
+ }
29
+ return path.join(os.homedir(), ".config", "raizn", "credentials");
30
+ }
31
+ export class FileCredentialStore {
32
+ file;
33
+ constructor(file) {
34
+ this.file = file;
35
+ }
36
+ async readAll() {
37
+ try {
38
+ const parsed = JSON.parse(await fs.readFile(this.file, "utf8"));
39
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
40
+ }
41
+ catch {
42
+ return {};
43
+ }
44
+ }
45
+ async writeAll(all) {
46
+ await fs.mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
47
+ const tmp = `${this.file}.${process.pid}.tmp`;
48
+ await fs.writeFile(tmp, JSON.stringify(all, null, 2) + "\n", { mode: 0o600 });
49
+ // `mode` only applies when the file is created; chmod covers a pre-existing temp name and
50
+ // filesystems that ignore the create mode. No-op on Windows, where %APPDATA% is per-user.
51
+ await fs.chmod(tmp, 0o600).catch(() => { });
52
+ await fs.rename(tmp, this.file);
53
+ }
54
+ async get(origin) {
55
+ const v = (await this.readAll())[origin];
56
+ return typeof v === "string" && v ? v : null;
57
+ }
58
+ async set(origin, token) {
59
+ const all = await this.readAll();
60
+ all[origin] = token;
61
+ await this.writeAll(all);
62
+ return "file";
63
+ }
64
+ async delete(origin) {
65
+ const all = await this.readAll();
66
+ if (!(origin in all))
67
+ return false;
68
+ delete all[origin];
69
+ if (Object.keys(all).length === 0) {
70
+ await fs.rm(this.file, { force: true });
71
+ }
72
+ else {
73
+ await this.writeAll(all);
74
+ }
75
+ return true;
76
+ }
77
+ }
78
+ export class SystemCredentialStore {
79
+ file;
80
+ keyring = null;
81
+ constructor(file) {
82
+ this.file = file;
83
+ }
84
+ entry(origin) {
85
+ this.keyring ??= loadKeyring();
86
+ return this.keyring.then((k) => {
87
+ if (!k)
88
+ return null;
89
+ try {
90
+ return new k.Entry(KEYCHAIN_SERVICE, origin);
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ });
96
+ }
97
+ async get(origin) {
98
+ const e = await this.entry(origin);
99
+ if (e) {
100
+ try {
101
+ const v = e.getPassword();
102
+ if (v)
103
+ return v;
104
+ }
105
+ catch {
106
+ /* locked or unavailable keychain: fall through to the file */
107
+ }
108
+ }
109
+ return this.file.get(origin);
110
+ }
111
+ async set(origin, token) {
112
+ const e = await this.entry(origin);
113
+ if (e) {
114
+ try {
115
+ e.setPassword(token);
116
+ // A stale copy in the file would outlive a later `logout` that only reached the keychain.
117
+ await this.file.delete(origin);
118
+ return "keychain";
119
+ }
120
+ catch {
121
+ /* no usable keychain (e.g. no Secret Service on a headless Linux box) */
122
+ }
123
+ }
124
+ return this.file.set(origin, token);
125
+ }
126
+ async delete(origin) {
127
+ let removed = false;
128
+ const e = await this.entry(origin);
129
+ if (e) {
130
+ try {
131
+ removed = e.deletePassword() || removed;
132
+ }
133
+ catch {
134
+ /* nothing to delete, or keychain unavailable */
135
+ }
136
+ }
137
+ return (await this.file.delete(origin)) || removed;
138
+ }
139
+ }
140
+ /** In-memory store for tests and for callers that must never touch the machine's keychain. */
141
+ export class MemoryCredentialStore {
142
+ map = new Map();
143
+ async get(origin) {
144
+ return this.map.get(origin) ?? null;
145
+ }
146
+ async set(origin, token) {
147
+ this.map.set(origin, token);
148
+ return "file";
149
+ }
150
+ async delete(origin) {
151
+ return this.map.delete(origin);
152
+ }
153
+ }
package/dist/diff.js ADDED
@@ -0,0 +1,126 @@
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
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Every failure the CLI reports is one of these. Commands throw them; `main` turns them into one
3
+ * redacted line and an exit code. Nothing else prints an error.
4
+ */
5
+ export class CliError extends Error {
6
+ exitCode;
7
+ name = "CliError";
8
+ constructor(message, exitCode = 1) {
9
+ super(message);
10
+ this.exitCode = exitCode;
11
+ }
12
+ }
13
+ /** 401 from the server. Never retried: a revoked token does not come back by asking again. */
14
+ export class AuthError extends CliError {
15
+ name = "AuthError";
16
+ }
17
+ /** 429 from the server. `retryAfterSeconds` is what the server asked for, not a guess. */
18
+ export class RateLimitError extends CliError {
19
+ retryAfterSeconds;
20
+ name = "RateLimitError";
21
+ constructor(message, retryAfterSeconds) {
22
+ super(message);
23
+ this.retryAfterSeconds = retryAfterSeconds;
24
+ }
25
+ }
26
+ /**
27
+ * The server looked at the request and said no (`permission_denied`, `not_a_draft`, …). The code
28
+ * and message are the server's, shown verbatim: the CLI's own checks are a convenience, the
29
+ * server's answer is the truth.
30
+ */
31
+ export class ToolRefusal extends CliError {
32
+ tool;
33
+ code;
34
+ serverMessage;
35
+ details;
36
+ name = "ToolRefusal";
37
+ constructor(tool, code, serverMessage, details) {
38
+ super(`The server refused ${tool}: ${code}: ${serverMessage}`);
39
+ this.tool = tool;
40
+ this.code = code;
41
+ this.serverMessage = serverMessage;
42
+ this.details = details;
43
+ }
44
+ }
45
+ /** Transport failures: network down, 413, 5xx, a body that is not JSON-RPC. */
46
+ export class ServerError extends CliError {
47
+ status;
48
+ name = "ServerError";
49
+ constructor(message, status) {
50
+ super(message);
51
+ this.status = status;
52
+ }
53
+ }
54
+ export const retryAt = (seconds, now) => new Date(now.getTime() + seconds * 1000).toLocaleTimeString("en-GB", { hour12: false });
package/dist/format.js ADDED
@@ -0,0 +1,13 @@
1
+ /** Left-aligned columns, two spaces apart. Plain text so it pipes into grep/awk cleanly. */
2
+ export function table(rows) {
3
+ const widths = [];
4
+ for (const r of rows)
5
+ r.forEach((c, i) => (widths[i] = Math.max(widths[i] ?? 0, [...c].length)));
6
+ return rows.map((r) => r.map((c, i) => (i === r.length - 1 ? c : c + " ".repeat((widths[i] ?? 0) - [...c].length))).join(" ").trimEnd());
7
+ }
8
+ /** `path:line:col severity message` — the shape editors and CI annotators already parse. */
9
+ export function formatIssue(path, issue, withSeverity = true) {
10
+ const loc = issue.line ? `${path}:${issue.line}:${issue.column ?? 1}` : path;
11
+ return withSeverity ? `${loc} ${issue.severity} ${issue.message}` : `${loc} ${issue.message}`;
12
+ }
13
+ export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;