@liebstoeckel/cli 0.3.7

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/src/add.ts ADDED
@@ -0,0 +1,369 @@
1
+ import { defineCommand } from "citty";
2
+ import { existsSync } from "node:fs";
3
+ import { resolve, join } from "node:path";
4
+ import {
5
+ validateItem,
6
+ assertSafeTarget,
7
+ CATEGORIES,
8
+ type RegistryItem,
9
+ } from "@liebstoeckel/registry/schema";
10
+
11
+ /**
12
+ * `liebstoeckel add`, scaffold registry items into a deck as owned source
13
+ * (ADR 0040), resolved over a pluggable transport (ADR 0041). The resolver core
14
+ * (`resolveScaffold`) is pure given a transport, so it is unit-tested with an
15
+ * in-memory transport; only `runAdd` touches disk / spawns `bun add`.
16
+ */
17
+
18
+ // ── transport ───────────────────────────────────────────────────────────────
19
+
20
+ export interface RegistryTransport {
21
+ /** Namespace label, for messages. */
22
+ readonly id: string;
23
+ /** Read an item manifest by name (`items/<name>.json`). */
24
+ readItem(name: string): Promise<unknown>;
25
+ /** Read a source file by its registry-relative path (`files/…`). */
26
+ readFile(path: string): Promise<string>;
27
+ }
28
+
29
+ function assertSafeRelPath(p: string): void {
30
+ if (p.startsWith("/") || p.split(/[\\/]/).includes("..")) {
31
+ throw new Error(`unsafe registry file path "${p}"`);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Reads a registry laid out as a local directory, the default/workspace registry,
37
+ * and also how an npm/git carrier is read once installed to a temp dir (ADR 0041).
38
+ */
39
+ export function localTransport(root: string, id = "@liebstoeckel"): RegistryTransport {
40
+ return {
41
+ id,
42
+ async readItem(name) {
43
+ const f = Bun.file(join(root, "items", `${name}.json`));
44
+ if (!(await f.exists())) throw new Error(`item "${name}" not found in registry ${id}`);
45
+ return f.json();
46
+ },
47
+ readFile(path) {
48
+ assertSafeRelPath(path);
49
+ return Bun.file(join(root, path)).text();
50
+ },
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Reads a registry over authenticated HTTP, an org's cloud registry, or any
56
+ * `https://…` registry configured in `liebstoeckel.json` (ADR 0041/0059). Speaks
57
+ * the same protocol: `<base>/items/<name>.json` and `<base>/files/<path>`.
58
+ */
59
+ export function httpTransport(baseUrl: string, headers: Record<string, string>, id: string): RegistryTransport {
60
+ const base = baseUrl.replace(/\/+$/, "");
61
+ const fail = (what: string, res: Response): Error => {
62
+ if (res.status === 401) return new Error(`registry ${id}: not signed in, run \`liebstoeckel login\``);
63
+ if (res.status === 403) return new Error(`registry ${id}: forbidden (membership/plan), check \`liebstoeckel orgs\``);
64
+ return new Error(`registry ${id}: ${what} (HTTP ${res.status})`);
65
+ };
66
+ return {
67
+ id,
68
+ async readItem(name) {
69
+ const res = await fetch(`${base}/items/${encodeURIComponent(name)}.json`, { headers });
70
+ if (!res.ok) throw fail(`item "${name}" not found`, res);
71
+ return res.json();
72
+ },
73
+ async readFile(path) {
74
+ assertSafeRelPath(path);
75
+ // `path` already includes its `files/…` prefix (same as localTransport's
76
+ // join(root, path)); the registry base serves it directly.
77
+ const safe = path.split("/").map(encodeURIComponent).join("/");
78
+ const res = await fetch(`${base}/${safe}`, { headers });
79
+ if (!res.ok) throw fail(`file "${path}" not found`, res);
80
+ return res.text();
81
+ },
82
+ };
83
+ }
84
+
85
+ /** Auth headers for the logged-in org's registry (`@org`). Throws if not logged in. */
86
+ export async function orgRegistryAuth(): Promise<{ baseUrl: string; headers: Record<string, string> }> {
87
+ const { loadCreds } = await import("./creds");
88
+ const creds = await loadCreds();
89
+ if (!creds?.token || !creds.api) {
90
+ throw new Error("the @org registry needs login, run `liebstoeckel login --api <host>`");
91
+ }
92
+ const headers: Record<string, string> = { authorization: `Bearer ${creds.token}` };
93
+ if (creds.org) headers["x-org-slug"] = creds.org;
94
+ return { baseUrl: `${creds.api.replace(/\/+$/, "")}/api/v1/orgs/registry`, headers };
95
+ }
96
+
97
+ // ── resolver core (pure given a transport) ───────────────────────────────────
98
+
99
+ export interface ResolvedFile {
100
+ target: string;
101
+ content: string;
102
+ item: string;
103
+ }
104
+
105
+ export interface ResolvedScaffold {
106
+ /** Item names in resolution order (registryDependencies before dependents). */
107
+ items: string[];
108
+ files: ResolvedFile[];
109
+ /** Union of leaf npm deps to add to the deck, sorted. */
110
+ npmDependencies: string[];
111
+ }
112
+
113
+ /**
114
+ * Transitively resolve items + their `registryDependencies` into a flat, deduped
115
+ * plan. Each manifest is validated (which also enforces the banned-visx gate) and
116
+ * each `target` is checked for path-escape before it can be written.
117
+ */
118
+ export async function resolveScaffold(
119
+ transport: RegistryTransport,
120
+ names: string[],
121
+ ): Promise<ResolvedScaffold> {
122
+ const seen = new Set<string>();
123
+ const items: string[] = [];
124
+ const files: ResolvedFile[] = [];
125
+ const deps = new Set<string>();
126
+
127
+ async function visit(name: string): Promise<void> {
128
+ if (seen.has(name)) return;
129
+ seen.add(name);
130
+ const raw = await transport.readItem(name);
131
+ validateItem(raw);
132
+ const item = raw as RegistryItem;
133
+ // deps first, so registryDependencies land before dependents (axes before chart)
134
+ for (const dep of item.registryDependencies ?? []) await visit(dep);
135
+ for (const d of item.dependencies ?? []) deps.add(d);
136
+ for (const f of item.files) {
137
+ assertSafeTarget(f.target);
138
+ files.push({ target: f.target, content: await transport.readFile(f.path), item: item.name });
139
+ }
140
+ items.push(item.name);
141
+ }
142
+
143
+ for (const n of names) await visit(n);
144
+ return { items, files, npmDependencies: [...deps].sort() };
145
+ }
146
+
147
+ // ── command ──────────────────────────────────────────────────────────────────
148
+
149
+ interface DeckConfig {
150
+ registries: Record<string, string>;
151
+ }
152
+
153
+ async function loadConfig(deckDir: string): Promise<DeckConfig> {
154
+ const f = Bun.file(join(deckDir, "liebstoeckel.json"));
155
+ if (await f.exists()) {
156
+ try {
157
+ const j = (await f.json()) as Partial<DeckConfig>;
158
+ return { registries: j.registries ?? {} };
159
+ } catch {
160
+ /* fall through to defaults */
161
+ }
162
+ }
163
+ return { registries: {} };
164
+ }
165
+
166
+ function parseRef(ref: string): { ns: string; name: string } {
167
+ if (ref.startsWith("@")) {
168
+ const slash = ref.indexOf("/");
169
+ if (slash > 0) return { ns: ref.slice(0, slash), name: ref.slice(slash + 1) };
170
+ }
171
+ return { ns: "@liebstoeckel", name: ref };
172
+ }
173
+
174
+ /** True iff two URLs share an exact origin (scheme + host + port). Used to gate
175
+ * attaching the stored cloud bearer token to a registry request. A parsed-origin
176
+ * compare (not a string prefix) prevents a deck-controlled registry URL that merely
177
+ * *starts with* the API host from siphoning the token to an attacker. */
178
+ export function sameOrigin(a: string, b: string): boolean {
179
+ try {
180
+ return new URL(a).origin === new URL(b).origin;
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ async function transportFor(ns: string, deckDir: string, config: DeckConfig): Promise<RegistryTransport> {
187
+ const spec = config.registries[ns];
188
+ // default registry: bundled @liebstoeckel/registry, read as a local file tree
189
+ if ((ns === "@liebstoeckel" && spec == null) || spec === "default") {
190
+ const { REGISTRY_ROOT } = await import("@liebstoeckel/registry");
191
+ return localTransport(REGISTRY_ROOT, ns);
192
+ }
193
+ // @org: the logged-in org's authenticated cloud registry (ADR 0059), no config needed
194
+ if (ns === "@org" && (spec == null || spec === "org")) {
195
+ const { baseUrl, headers } = await orgRegistryAuth();
196
+ return httpTransport(baseUrl, headers, ns);
197
+ }
198
+ if (spec == null) {
199
+ throw new Error(`no registry configured for namespace "${ns}", add it to liebstoeckel.json "registries"`);
200
+ }
201
+ if (spec.startsWith(".") || spec.startsWith("/")) {
202
+ return localTransport(resolve(deckDir, spec), ns);
203
+ }
204
+ if (spec.startsWith("http://") || spec.startsWith("https://")) {
205
+ // Authenticated HTTP registry (ADR 0041/0059). Auto-attach the stored bearer token
206
+ // ONLY when the registry's origin is *exactly* the host we logged into. The registry
207
+ // URL comes from the deck's own liebstoeckel.json, so it is attacker-controlled for a
208
+ // cloned/third-party deck — compare parsed origins (scheme + host + port), never a
209
+ // string prefix: `startsWith` also matched a sibling-suffix host like
210
+ // `api.example.com.evil.com` or a `https://api.example.com@evil.com` userinfo URL,
211
+ // leaking the token to the attacker.
212
+ const headers: Record<string, string> = {};
213
+ const { loadCreds } = await import("./creds");
214
+ const creds = await loadCreds();
215
+ if (creds?.token && creds.api && sameOrigin(spec, creds.api)) {
216
+ headers.authorization = `Bearer ${creds.token}`;
217
+ }
218
+ return httpTransport(spec, headers, ns);
219
+ }
220
+ // ADR 0041 also lists npm/git transports as planned; not wired yet.
221
+ throw new Error(
222
+ `registry transport "${spec}" (namespace ${ns}) is not implemented yet, ` +
223
+ `bundled default, local-path, @org, and http(s) registries are wired (ADR 0041 plans npm/git).`,
224
+ );
225
+ }
226
+
227
+ /** Optional `add <category> <name>...` sugar: strip a leading **singular** category
228
+ * keyword (`chart`, `hook`, …) when at least one item name follows it. Pure, the
229
+ * category words are exactly `CATEGORIES`, never their plurals (ticket 0030). */
230
+ export function stripCategory(positionals: string[]): string[] {
231
+ if (positionals.length >= 2 && (CATEGORIES as readonly string[]).includes(positionals[0]!)) {
232
+ return positionals.slice(1);
233
+ }
234
+ return positionals;
235
+ }
236
+
237
+ export const addCommand = defineCommand({
238
+ meta: {
239
+ name: "add",
240
+ description: "scaffold registry items (chart, hook, …) into a deck as owned source",
241
+ },
242
+ args: {
243
+ items: {
244
+ type: "positional",
245
+ required: false,
246
+ description: "registry item name(s), optionally after a category keyword",
247
+ valueHint: "[category] name...",
248
+ },
249
+ dir: { type: "string", description: "target deck directory (default: cwd)", valueHint: "deck" },
250
+ dry: { type: "boolean", description: "print the plan without writing anything" },
251
+ force: { type: "boolean", description: "overwrite existing files" },
252
+ install: {
253
+ type: "boolean",
254
+ default: true,
255
+ description: "install npm dependencies the items need",
256
+ negativeDescription: "do not run bun add for dependencies",
257
+ },
258
+ json: { type: "boolean", description: "machine-readable JSON output (default when piped)" },
259
+ },
260
+ run: (ctx) => runAdd(ctx.args),
261
+ });
262
+
263
+ async function runAdd(args: {
264
+ _: string[];
265
+ dir?: string;
266
+ dry?: boolean;
267
+ force?: boolean;
268
+ install?: boolean;
269
+ json?: boolean;
270
+ }): Promise<void> {
271
+ const refs = stripCategory(args._);
272
+ if (refs.length === 0) {
273
+ console.error(
274
+ "usage: liebstoeckel add [<category>] <name>... [--dir <deck>] [--dry] [--force] [--no-install]",
275
+ );
276
+ process.exit(1);
277
+ }
278
+
279
+ const deckDir = resolve(args.dir ?? ".");
280
+ const dry = !!args.dry;
281
+ const force = !!args.force;
282
+ const noInstall = args.install === false;
283
+ // JSON when asked, or when piped (an agent), pretty only on an interactive TTY (ADR 0045).
284
+ const json = !!args.json || !process.stdout.isTTY;
285
+
286
+ try {
287
+ const config = await loadConfig(deckDir);
288
+
289
+ // group refs by namespace; each namespace resolves through its own transport
290
+ const groups = new Map<string, { transport: RegistryTransport; names: string[] }>();
291
+ for (const ref of refs) {
292
+ const { ns, name } = parseRef(ref);
293
+ if (!groups.has(ns)) groups.set(ns, { transport: await transportFor(ns, deckDir, config), names: [] });
294
+ groups.get(ns)!.names.push(name);
295
+ }
296
+
297
+ const items: string[] = [];
298
+ const files: ResolvedFile[] = [];
299
+ const deps = new Set<string>();
300
+ for (const g of groups.values()) {
301
+ const plan = await resolveScaffold(g.transport, g.names);
302
+ items.push(...plan.items);
303
+ files.push(...plan.files);
304
+ for (const d of plan.npmDependencies) deps.add(d);
305
+ }
306
+
307
+ // plan, computed before writing (ADR 0041 review-before-write)
308
+ const plan = files.map((f) => {
309
+ const exists = existsSync(join(deckDir, f.target));
310
+ const action = exists && !force ? "skip" : exists ? "overwrite" : "create";
311
+ return { target: f.target, item: f.item, action, file: f };
312
+ });
313
+ const writes = plan.filter((p) => p.action !== "skip").map((p) => p.file);
314
+ const dependencies = [...deps];
315
+
316
+ if (!json) {
317
+ console.log(`\nliebstoeckel add ${items.join(", ")} → ${deckDir}\n`);
318
+ for (const p of plan) console.log(` ${(p.action === "skip" ? "skip (exists)" : p.action).padEnd(14)} ${p.target} [${p.item}]`);
319
+ if (dependencies.length) console.log(`\n dependencies: ${dependencies.join(", ")}`);
320
+ }
321
+
322
+ if (dry) {
323
+ if (json) {
324
+ console.log(JSON.stringify({ action: "plan", dir: deckDir, items, files: plan.map(({ file, ...p }) => p), dependencies }, null, 2));
325
+ } else {
326
+ console.log(`\n (dry run, nothing written)\n`);
327
+ }
328
+ return;
329
+ }
330
+
331
+ for (const f of writes) await Bun.write(join(deckDir, f.target), f.content);
332
+
333
+ let installed = false;
334
+ if (deps.size && !noInstall) {
335
+ const { $ } = await import("bun");
336
+ // pin the interpreter; --ignore-scripts per the registry trust model (ADR 0041)
337
+ const proc = $`${process.execPath} add --ignore-scripts ${dependencies}`.cwd(deckDir);
338
+ if (json) await proc.quiet();
339
+ else {
340
+ console.log(`\n ✓ wrote ${writes.length} file(s)` + (writes.length < files.length ? ` (${files.length - writes.length} skipped, use --force to overwrite)` : ""));
341
+ console.log(` installing: bun add --ignore-scripts ${dependencies.join(" ")}`);
342
+ await proc;
343
+ console.log(` ✓ dependencies installed`);
344
+ }
345
+ installed = true;
346
+ } else if (!json) {
347
+ console.log(`\n ✓ wrote ${writes.length} file(s)` + (writes.length < files.length ? ` (${files.length - writes.length} skipped, use --force to overwrite)` : ""));
348
+ if (deps.size) console.log(` → install deps yourself: bun add --ignore-scripts ${dependencies.join(" ")}`);
349
+ }
350
+
351
+ if (json) {
352
+ console.log(JSON.stringify({
353
+ action: "add",
354
+ dir: deckDir,
355
+ items,
356
+ wrote: writes.map((f) => f.target),
357
+ skipped: plan.filter((p) => p.action === "skip").map((p) => p.target),
358
+ dependencies,
359
+ installed,
360
+ }, null, 2));
361
+ } else {
362
+ console.log();
363
+ }
364
+ } catch (e) {
365
+ if (json) console.log(JSON.stringify({ error: (e as Error).message }));
366
+ else console.error(`✕ ${(e as Error).message}`);
367
+ process.exit(1);
368
+ }
369
+ }