@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/cloud.ts ADDED
@@ -0,0 +1,570 @@
1
+ // `liebstoeckel login` + `liebstoeckel push` + `liebstoeckel orgs`, the cloud
2
+ // path (ADR 0047/0053). login runs the OAuth 2.0 device-authorization grant
3
+ // (RFC 8628) against the control plane's /api/auth/device/* endpoints; push
4
+ // uploads a single-file deck to the versioned /api/v1/decks with the resulting
5
+ // bearer token; orgs lists/sets the active organization decks are pushed into.
6
+ import { defineCommand } from "citty";
7
+ import { existsSync, readdirSync } from "node:fs";
8
+ import { basename, dirname, join, resolve, sep } from "node:path";
9
+ import { loadCreds, saveCreds } from "./creds";
10
+
11
+ const CLIENT_ID = "liebstoeckel-cli";
12
+
13
+ /** Shared `--api` / `--org` args for the cloud commands. */
14
+ const CLOUD_ARGS = {
15
+ api: { type: "string" as const, description: "control-plane host (or LIEBSTOECKEL_API)", valueHint: "https://app-host" },
16
+ org: { type: "string" as const, description: "organization slug", valueHint: "slug" },
17
+ };
18
+
19
+ /** Exit for a cloud command that has no API to talk to. The hosted control plane
20
+ * is not generally available yet, so OSS users see "coming soon" instead of a
21
+ * bare auth error that looks like a bug. */
22
+ function notLoggedIn(): never {
23
+ // An agent (stdout not a TTY) gets the failure as JSON it can act on, per the
24
+ // agent-readable contract (ADR 0045); a human at a terminal gets the prose.
25
+ if (!process.stdout.isTTY) {
26
+ console.log(
27
+ JSON.stringify({
28
+ error: "not logged in",
29
+ hint: "liebstoeckel cloud is coming soon; run `liebstoeckel login` once a control plane is available",
30
+ }),
31
+ );
32
+ } else {
33
+ console.error("✕ not logged in, run: liebstoeckel login --api <https://app-host>");
34
+ console.error(" (liebstoeckel cloud is coming soon; this command needs a hosted control plane)");
35
+ }
36
+ process.exit(1);
37
+ }
38
+
39
+ /** Uniform cloud org-targeting (ADR 0057): an explicit `--org <slug>` wins, else
40
+ * the stored default (`creds.org`), else undefined (= the personal workspace,
41
+ * the server's no-`x-org-slug` default). */
42
+ export function resolveOrg(args: { org?: string }, defaultOrg?: string): string | undefined {
43
+ return args.org ?? defaultOrg;
44
+ }
45
+
46
+
47
+ /** Default `push` target when no path is given: the single built `.html` in
48
+ * `./dist` (the deck slug, e.g. `dist/poll-demo.html`), preferring `index.html`
49
+ * if several exist. The server derives the title from the file's own `<title>`,
50
+ * so the CLI no longer parses it (ADR 0068, superseding 0054's client-side parse). */
51
+ function defaultDeckHtml(): string | null {
52
+ const dir = resolve("dist");
53
+ if (!existsSync(dir)) return null;
54
+ const htmls = readdirSync(dir).filter((f) => /\.html?$/i.test(f));
55
+ if (htmls.length === 1) return join("dist", htmls[0]!);
56
+ if (htmls.includes("index.html")) return join("dist", "index.html");
57
+ return null;
58
+ }
59
+
60
+ /** The deck name from the build path: the folder above `dist/`, else the parent
61
+ * folder (so `…/my-talk/dist/index.html` → "my-talk", never "index"). */
62
+ function deckNameFromPath(absFile: string): string | null {
63
+ const parts = absFile.split(sep);
64
+ const di = parts.lastIndexOf("dist");
65
+ if (di > 0) return parts[di - 1] ?? null;
66
+ return basename(dirname(absFile)) || null;
67
+ }
68
+
69
+ /** A stable, url-safe deck key from a name (ADR 0058: re-push upserts by it). */
70
+ function slugifyKey(name: string): string {
71
+ return (
72
+ name
73
+ .toLowerCase()
74
+ .replace(/[^a-z0-9]+/g, "-")
75
+ .replace(/^-+|-+$/g, "")
76
+ .slice(0, 60) || "deck"
77
+ );
78
+ }
79
+
80
+ async function runLogin(api: string): Promise<void> {
81
+ if (!api) {
82
+ console.error("usage: liebstoeckel login --api <https://app-host>");
83
+ console.error(" (liebstoeckel cloud is coming soon; this command needs a hosted control plane)");
84
+ process.exit(1);
85
+ }
86
+
87
+ const codeRes = await fetch(`${api}/api/auth/device/code`, {
88
+ method: "POST",
89
+ headers: { "content-type": "application/json" },
90
+ body: JSON.stringify({ client_id: CLIENT_ID, scope: "decks" }),
91
+ });
92
+ if (!codeRes.ok) {
93
+ console.error(`✕ could not start login: ${codeRes.status} ${await codeRes.text()}`);
94
+ process.exit(1);
95
+ }
96
+ const dc = (await codeRes.json()) as {
97
+ device_code: string;
98
+ user_code: string;
99
+ verification_uri: string;
100
+ verification_uri_complete?: string;
101
+ expires_in?: number;
102
+ interval?: number;
103
+ };
104
+
105
+ const verify = dc.verification_uri_complete ?? dc.verification_uri;
106
+ console.log(`\n To sign in, open this URL in your browser:\n\n ${verify}\n`);
107
+ if (!dc.verification_uri_complete) console.log(` and enter the code: ${dc.user_code}\n`);
108
+ console.log(" Waiting for approval…");
109
+
110
+ const intervalMs = (dc.interval ?? 5) * 1000;
111
+ const deadline = Date.now() + (dc.expires_in ?? 600) * 1000;
112
+ while (Date.now() < deadline) {
113
+ await Bun.sleep(intervalMs);
114
+ const tRes = await fetch(`${api}/api/auth/device/token`, {
115
+ method: "POST",
116
+ headers: { "content-type": "application/json" },
117
+ body: JSON.stringify({
118
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
119
+ device_code: dc.device_code,
120
+ client_id: CLIENT_ID,
121
+ }),
122
+ });
123
+ const t = (await tRes.json().catch(() => ({}))) as {
124
+ access_token?: string;
125
+ error?: string;
126
+ error_description?: string;
127
+ };
128
+ if (tRes.ok && t.access_token) {
129
+ // Preserve any previously chosen default org.
130
+ const prev = await loadCreds();
131
+ await saveCreds({ api, token: t.access_token, org: prev?.api === api ? prev.org : undefined });
132
+ console.log("\n✓ logged in. Credentials saved to ~/.config/liebstoeckel/credentials.json\n");
133
+ return;
134
+ }
135
+ // RFC 8628: keep polling while pending / told to slow down.
136
+ if (t.error && t.error !== "authorization_pending" && t.error !== "slow_down") {
137
+ console.error(`\n✕ ${t.error}${t.error_description ? `: ${t.error_description}` : ""}`);
138
+ process.exit(1);
139
+ }
140
+ }
141
+ console.error("\n✕ login timed out, run `liebstoeckel login` again.");
142
+ process.exit(1);
143
+ }
144
+
145
+ export const loginCommand = defineCommand({
146
+ meta: { name: "login", description: "sign in to liebstoeckel cloud (device flow), coming soon" },
147
+ args: { api: CLOUD_ARGS.api },
148
+ run: ({ args }) => runLogin((args.api ?? process.env.LIEBSTOECKEL_API ?? "").replace(/\/+$/, "")),
149
+ });
150
+
151
+ export const pushCommand = defineCommand({
152
+ meta: { name: "push", description: "upload/update a deck to liebstoeckel cloud, coming soon" },
153
+ args: {
154
+ deck: { type: "positional", required: false, description: "deck .html (default: ./dist)", valueHint: "deck.html" },
155
+ title: { type: "string", description: "override the deck title", valueHint: "t" },
156
+ name: { type: "string", description: "deck key for re-push upsert", valueHint: "key" },
157
+ new: { type: "boolean", description: "force a fresh deck (new key)" },
158
+ org: CLOUD_ARGS.org,
159
+ api: CLOUD_ARGS.api,
160
+ },
161
+ run: ({ args }) => runPush(args),
162
+ });
163
+
164
+ async function runPush(args: {
165
+ deck?: string;
166
+ title?: string;
167
+ name?: string;
168
+ new?: boolean;
169
+ org?: string;
170
+ api?: string;
171
+ }): Promise<void> {
172
+ const creds = await loadCreds();
173
+ const org = resolveOrg(args, creds?.org);
174
+ // With no path, push the built deck in ./dist (ADR 0068), matching `liebstoeckel build`.
175
+ const file = args.deck ?? defaultDeckHtml();
176
+ if (!file) {
177
+ console.error(
178
+ "usage: liebstoeckel push [deck.html] [--title <t>] [--name <key>] [--new] [--org <slug>] [--api <host>]\n" +
179
+ " (no path → the built deck in ./dist; run `liebstoeckel build` first)",
180
+ );
181
+ process.exit(1);
182
+ }
183
+ const api = (args.api ?? creds?.api ?? "").replace(/\/+$/, "");
184
+ if (!creds || !api) notLoggedIn();
185
+
186
+ const path = resolve(file);
187
+ if (!(await Bun.file(path).exists())) {
188
+ console.error(`✕ no such file: ${file}`);
189
+ process.exit(1);
190
+ }
191
+ const html = await Bun.file(path).text();
192
+ const deckName = args.name ?? deckNameFromPath(path) ?? basename(file).replace(/\.html?$/i, "");
193
+ // Deck key (ADR 0058): re-push upserts by it. `--new` forces a fresh deck by
194
+ // uniquifying the key, so the same folder can also start a separate deck.
195
+ const fresh = !!args.new;
196
+ const deckKey = fresh ? `${slugifyKey(deckName)}-${Math.random().toString(36).slice(2, 8)}` : slugifyKey(deckName);
197
+
198
+ const headers: Record<string, string> = {
199
+ authorization: `Bearer ${creds.token}`,
200
+ "content-type": "text/html",
201
+ "x-deck-key": deckKey,
202
+ };
203
+ // Title precedence (ADR 0068): the server parses the deck's own `<title>`, so we
204
+ // only send a header when the user explicitly overrides it with `--title`. It's
205
+ // URL-encoded because non-Latin1 chars (em-dash, smart quotes, emoji) are illegal
206
+ // in HTTP header values.
207
+ const titleOverride = args.title;
208
+ if (titleOverride) headers["x-deck-title"] = encodeURIComponent(titleOverride);
209
+ if (org) headers["x-org-slug"] = org;
210
+
211
+ const res = await fetch(`${api}/api/v1/decks`, { method: "POST", headers, body: html });
212
+ if (res.status === 401) {
213
+ console.error("✕ session expired, run `liebstoeckel login` again.");
214
+ process.exit(1);
215
+ }
216
+ if (res.status === 403) {
217
+ console.error(`✕ you're not a member of org "${org}". Run \`liebstoeckel orgs\` to see your teams.`);
218
+ process.exit(1);
219
+ }
220
+ if (!res.ok) {
221
+ console.error(`✕ upload failed: ${res.status} ${await res.text()}`);
222
+ process.exit(1);
223
+ }
224
+ const { deck, version, isNew } = (await res.json()) as {
225
+ deck: { id: string; title: string };
226
+ version: number;
227
+ isNew: boolean;
228
+ };
229
+ const what = isNew ? "created" : `updated to v${version}`;
230
+ console.log(`\n✓ pushed "${deck.title}" (${what})${org ? ` in ${org}` : ""}, view it at ${api}\n`);
231
+ }
232
+
233
+ interface OrgList {
234
+ active: { slug: string; name: string; role: string; personal: boolean; plan: string };
235
+ orgs: Array<{ slug: string; name: string; personal: boolean }>;
236
+ }
237
+
238
+ async function fetchOrgs(api: string, token: string): Promise<OrgList> {
239
+ const res = await fetch(`${api}/api/v1/orgs`, { headers: { authorization: `Bearer ${token}` } });
240
+ if (res.status === 401) {
241
+ console.error("✕ session expired, run `liebstoeckel login` again.");
242
+ process.exit(1);
243
+ }
244
+ if (!res.ok) {
245
+ console.error(`✕ could not list orgs: ${res.status} ${await res.text()}`);
246
+ process.exit(1);
247
+ }
248
+ return (await res.json()) as OrgList;
249
+ }
250
+
251
+ /** Auth preamble shared by the org/deck/brand commands: load creds, resolve the
252
+ * API host (`--api` > stored), and bail with the "coming soon" notice if absent. */
253
+ async function requireCreds(apiArg?: string) {
254
+ const creds = await loadCreds();
255
+ const api = (apiArg ?? creds?.api ?? "").replace(/\/+$/, "");
256
+ if (!creds || !api) notLoggedIn();
257
+ return { creds, api };
258
+ }
259
+
260
+ const orgsUseCommand = defineCommand({
261
+ meta: { name: "use", description: "set the default org for `push`" },
262
+ args: { slug: { type: "positional", required: false, description: "org slug", valueHint: "slug" }, api: CLOUD_ARGS.api },
263
+ async run({ args }) {
264
+ const { creds, api } = await requireCreds(args.api);
265
+ if (!args.slug) {
266
+ console.error("usage: liebstoeckel orgs use <slug>");
267
+ process.exit(1);
268
+ }
269
+ const { orgs } = await fetchOrgs(api, creds.token);
270
+ const match = orgs.find((o) => o.slug === args.slug);
271
+ if (!match) {
272
+ console.error(`✕ no org "${args.slug}", you're a member of: ${orgs.map((o) => o.slug).join(", ")}`);
273
+ process.exit(1);
274
+ }
275
+ await saveCreds({ ...creds, org: match.personal ? undefined : match.slug });
276
+ console.log(`\n✓ pushes now go to ${match.name} (${match.slug})\n`);
277
+ },
278
+ });
279
+
280
+ const orgsListCommand = defineCommand({
281
+ meta: { name: "list", description: "list your workspaces" },
282
+ args: { api: CLOUD_ARGS.api },
283
+ async run({ args }) {
284
+ const { creds, api } = await requireCreds(args.api);
285
+ const { active, orgs } = await fetchOrgs(api, creds.token);
286
+ const def = creds.org;
287
+ console.log("\n your workspaces:\n");
288
+ for (const o of orgs) {
289
+ const marker = (def ? o.slug === def : o.personal) ? "→" : " ";
290
+ const tags = o.personal ? " (personal)" : "";
291
+ console.log(` ${marker} ${o.slug.padEnd(24)} ${o.name}${tags}`);
292
+ }
293
+ console.log(`\n plan: ${active.plan} → = default for \`push\` (change: liebstoeckel orgs use <slug>)\n`);
294
+ },
295
+ });
296
+
297
+ export const orgsCommand = defineCommand({
298
+ meta: { name: "orgs", description: "list your workspaces / set the default org, coming soon" },
299
+ subCommands: { list: orgsListCommand, use: orgsUseCommand },
300
+ default: "list",
301
+ });
302
+
303
+ interface CloudDeck {
304
+ id: string;
305
+ title: string;
306
+ version: number;
307
+ shared: boolean;
308
+ shareSlug: string | null;
309
+ views: number;
310
+ uniqueViews: number;
311
+ }
312
+
313
+ /** `liebstoeckel decks [--org <slug>]`, list the active org's decks + views. */
314
+ export const decksCommand = defineCommand({
315
+ meta: { name: "decks", description: "list your cloud decks (with view counts), coming soon" },
316
+ args: { org: CLOUD_ARGS.org, api: CLOUD_ARGS.api },
317
+ run: ({ args }) => runDecks(args),
318
+ });
319
+
320
+ async function runDecks(args: { org?: string; api?: string }): Promise<void> {
321
+ const { creds, api } = await requireCreds(args.api);
322
+ const org = resolveOrg(args, creds?.org);
323
+ const headers: Record<string, string> = { authorization: `Bearer ${creds.token}` };
324
+ if (org) headers["x-org-slug"] = org;
325
+ const res = await fetch(`${api}/api/v1/decks`, { headers });
326
+ if (res.status === 401) {
327
+ console.error("✕ session expired, run `liebstoeckel login` again.");
328
+ process.exit(1);
329
+ }
330
+ if (res.status === 403) {
331
+ console.error(`✕ you're not a member of org "${org}".`);
332
+ process.exit(1);
333
+ }
334
+ if (!res.ok) {
335
+ console.error(`✕ could not list decks: ${res.status} ${await res.text()}`);
336
+ process.exit(1);
337
+ }
338
+ const { decks } = (await res.json()) as { decks: CloudDeck[] };
339
+ if (!decks.length) {
340
+ console.log(`\n no decks${org ? ` in ${org}` : ""} yet, push one with: liebstoeckel push\n`);
341
+ return;
342
+ }
343
+ console.log(`\n decks${org ? ` in ${org}` : ""}:\n`);
344
+ for (const d of decks) {
345
+ const share = d.shared ? "shared" : "private";
346
+ const ver = `v${d.version}`.padStart(4);
347
+ console.log(` ${d.title.slice(0, 36).padEnd(36)} ${ver} ${String(d.views).padStart(5)} views ${share}`);
348
+ }
349
+ console.log();
350
+ }
351
+
352
+ // ── brand registry (ADR 0059): the org is an authenticated registry; brands are
353
+ // items pulled into decks as owned source (brands/<name>.ts), baked at build. ──
354
+
355
+ interface BrandRow {
356
+ name: string;
357
+ isDefault: boolean;
358
+ tokens: Record<string, string>;
359
+ }
360
+
361
+ /** Map a `defineTheme(...)` Theme (or a flat tokens object) → the server's flat
362
+ * token shape. Used by `brand push`. */
363
+ export function themeToTokens(input: unknown): Record<string, unknown> {
364
+ const m = input as {
365
+ colors?: Record<string, string>;
366
+ fonts?: Record<string, string>;
367
+ glow?: { a?: string; b?: string };
368
+ viz?: string[];
369
+ };
370
+ if (!m.colors) return (input ?? {}) as Record<string, unknown>; // already flat
371
+ const c = m.colors, f = m.fonts ?? {}, g = m.glow ?? {};
372
+ return {
373
+ bg: c.bg, surface: c.surface, border: c.border ?? "", text: c.text, muted: c.muted,
374
+ primary: c.primary, accent: c.accent, accent2: c.accent2 ?? "", onPrimary: c.onPrimary,
375
+ fontHeading: f.heading ?? "", fontBody: f.body ?? "", fontMono: f.mono ?? "",
376
+ glowA: g.a ?? "", glowB: g.b ?? "",
377
+ // The viz palette rides along (ticket 0029); the server normalizes/bounds it.
378
+ ...(Array.isArray(m.viz) ? { viz: m.viz } : {}),
379
+ };
380
+ }
381
+
382
+ async function brandApi(args: { api?: string; org?: string }): Promise<{ api: string; token: string; org?: string }> {
383
+ const { creds, api } = await requireCreds(args.api);
384
+ return { api, token: creds.token, org: resolveOrg(args, creds.org) };
385
+ }
386
+
387
+ function brandHeaders(token: string, org?: string): Record<string, string> {
388
+ const h: Record<string, string> = { authorization: `Bearer ${token}` };
389
+ if (org) h["x-org-slug"] = org;
390
+ return h;
391
+ }
392
+
393
+ const brandPushCommand = defineCommand({
394
+ meta: { name: "push", description: "push a brand (theme token set) to the org registry" },
395
+ args: {
396
+ file: { type: "positional", required: false, description: "brand .ts or tokens .json", valueHint: "brand.ts|tokens.json" },
397
+ name: { type: "string", description: "brand key (default: the theme name / filename)", valueHint: "key" },
398
+ default: { type: "boolean", description: "mark this the org default brand" },
399
+ org: CLOUD_ARGS.org,
400
+ api: CLOUD_ARGS.api,
401
+ },
402
+ async run({ args }) {
403
+ const { api, token, org } = await brandApi(args);
404
+ const file = args.file;
405
+ if (!file) {
406
+ console.error("usage: liebstoeckel brand push <brand.ts|tokens.json> [--name <key>] [--default] [--org <slug>]");
407
+ process.exit(1);
408
+ }
409
+ const path = resolve(file);
410
+ if (!(await Bun.file(path).exists())) {
411
+ console.error(`✕ no such file: ${file}`);
412
+ process.exit(1);
413
+ }
414
+ let parsed: unknown;
415
+ if (/\.json$/i.test(path)) parsed = await Bun.file(path).json();
416
+ else parsed = (await import(path)).default; // a defineTheme(...) module
417
+ const tokens = themeToTokens(parsed);
418
+ const name =
419
+ args.name ??
420
+ (parsed as { name?: string })?.name ??
421
+ basename(file).replace(/\.(ts|tsx|js|json)$/i, "");
422
+ const res = await fetch(`${api}/api/v1/orgs/brands/${encodeURIComponent(name)}`, {
423
+ method: "PUT",
424
+ headers: { ...brandHeaders(token, org), "content-type": "application/json" },
425
+ body: JSON.stringify({ tokens, default: !!args.default }),
426
+ });
427
+ if (res.status === 403) {
428
+ console.error("✕ forbidden, managing brands needs an admin/owner role on a paid org.");
429
+ process.exit(1);
430
+ }
431
+ if (!res.ok) {
432
+ console.error(`✕ push failed: ${res.status} ${await res.text()}`);
433
+ process.exit(1);
434
+ }
435
+ console.log(`\n✓ pushed brand "${name}"${args.default ? " (default)" : ""}${org ? ` to ${org}` : ""}`);
436
+ // Warn about fonts the catalog can't ship a webfont for (ADR 0074); they fall
437
+ // back to system fonts on pull unless the deck supplies its own @font-face.
438
+ const { warnings } = (await res.json().catch(() => ({}))) as {
439
+ warnings?: { field: string; family: string; suggestion?: string }[];
440
+ };
441
+ if (warnings?.length) {
442
+ console.log("⚠ these fonts aren't in the catalog, they'll fall back to system fonts on pull");
443
+ console.log(" unless the deck supplies @font-face:");
444
+ for (const w of warnings) {
445
+ console.log(` ${w.field}: ${w.family}${w.suggestion ? ` (did you mean "${w.suggestion}"?)` : ""}`);
446
+ }
447
+ }
448
+ console.log();
449
+ },
450
+ });
451
+
452
+ const brandPullCommand = defineCommand({
453
+ meta: { name: "pull", description: "pull a brand into a deck as owned source" },
454
+ args: {
455
+ name: { type: "positional", required: false, description: "brand name (default: the org default)", valueHint: "name" },
456
+ dir: { type: "string", description: "target deck directory (default: cwd)", valueHint: "deck" },
457
+ install: { type: "boolean", default: true, description: "install the brand's catalog fonts", negativeDescription: "do not install fonts" },
458
+ org: CLOUD_ARGS.org,
459
+ api: CLOUD_ARGS.api,
460
+ },
461
+ async run({ args }) {
462
+ const { api, token, org } = await brandApi(args);
463
+ let name = args.name;
464
+ const dir = args.dir ?? ".";
465
+ if (!name) {
466
+ const def = (await fetchBrands(api, token, org)).find((b) => b.isDefault);
467
+ if (!def) {
468
+ console.error("✕ no default brand set, run `liebstoeckel brand list` or pass a name.");
469
+ process.exit(1);
470
+ }
471
+ name = def.name;
472
+ }
473
+ // The brand IS a registry item, resolve it through the @org transport and
474
+ // write it as owned source, exactly like `add @org/<name>` (ADR 0059).
475
+ const { httpTransport, resolveScaffold } = await import("./add");
476
+ const transport = httpTransport(`${api}/api/v1/orgs/registry`, brandHeaders(token, org), "@org");
477
+ const plan = await resolveScaffold(transport, [name]);
478
+ const deckDir = resolve(dir);
479
+ for (const f of plan.files) await Bun.write(join(deckDir, f.target), f.content);
480
+ console.log(`\n✓ pulled brand "${name}" → ${plan.files.map((f) => f.target).join(", ")}\n`);
481
+ // The brand's catalog fonts (ADR 0074) ride along as npm deps; install them so the
482
+ // deck bundles the webfont at build (the brand file `import`s the package).
483
+ const deps = plan.npmDependencies;
484
+ const noInstall = args.install === false;
485
+ if (deps.length && !noInstall) {
486
+ const { $ } = await import("bun");
487
+ console.log(` installing fonts: bun add --ignore-scripts ${deps.join(" ")}`);
488
+ // pin the interpreter; --ignore-scripts per the registry trust model (ADR 0041)
489
+ await $`${process.execPath} add --ignore-scripts ${deps}`.cwd(deckDir);
490
+ console.log(` ✓ fonts installed\n`);
491
+ } else if (deps.length) {
492
+ console.log(` → install its fonts: bun add --ignore-scripts ${deps.join(" ")}\n`);
493
+ }
494
+ console.log(` wire it in main.tsx:\n import ${camel(name)} from "./brands/${name}";`);
495
+ console.log(` <Present brands={["${name}"]} brandThemes={[${camel(name)}]} … />\n`);
496
+ },
497
+ });
498
+
499
+ const brandListCommand = defineCommand({
500
+ meta: { name: "list", description: "list the org's shared brands" },
501
+ args: { org: CLOUD_ARGS.org, api: CLOUD_ARGS.api },
502
+ async run({ args }) {
503
+ const { api, token, org } = await brandApi(args);
504
+ const brands = await fetchBrands(api, token, org);
505
+ if (!brands.length) {
506
+ console.log(`\n no brands${org ? ` in ${org}` : ""} yet, push one: liebstoeckel brand push ./brand.ts --default\n`);
507
+ return;
508
+ }
509
+ console.log(`\n brands${org ? ` in ${org}` : ""}:\n`);
510
+ for (const b of brands) console.log(` ${b.isDefault ? "→" : " "} ${b.name}`);
511
+ console.log(`\n → = default (applied by \`liebstoeckel new\`). Pull one: liebstoeckel brand pull <name>\n`);
512
+ },
513
+ });
514
+
515
+ /** `liebstoeckel brand list|push|pull`, share org brands (registry, ADR 0059). */
516
+ export const brandCommand = defineCommand({
517
+ meta: { name: "brand", description: "share org brands: push/pull theme token sets, coming soon" },
518
+ subCommands: { list: brandListCommand, push: brandPushCommand, pull: brandPullCommand },
519
+ default: "list",
520
+ });
521
+
522
+ async function fetchBrands(api: string, token: string, org?: string): Promise<BrandRow[]> {
523
+ const res = await fetch(`${api}/api/v1/orgs/brands`, { headers: brandHeaders(token, org) });
524
+ if (res.status === 401) {
525
+ console.error("✕ session expired, run `liebstoeckel login` again.");
526
+ process.exit(1);
527
+ }
528
+ if (res.status === 403) {
529
+ console.error("✕ the org registry is a paid feature for this workspace.");
530
+ process.exit(1);
531
+ }
532
+ if (!res.ok) {
533
+ console.error(`✕ could not list brands: ${res.status} ${await res.text()}`);
534
+ process.exit(1);
535
+ }
536
+ return (await res.json() as { brands: BrandRow[] }).brands;
537
+ }
538
+
539
+ const camel = (s: string) => s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
540
+
541
+ /** For `liebstoeckel new`: the org default brand's source + name (+ its `@fontsource`
542
+ * deps, ADR 0074, so the scaffolded package.json installs them), or null. Best
543
+ * effort, never blocks scaffolding if not logged in / no default. */
544
+ export async function fetchDefaultBrand(): Promise<{ name: string; source: string; dependencies: string[] } | null> {
545
+ try {
546
+ const creds = await loadCreds();
547
+ if (!creds?.token || !creds.api) return null;
548
+ const api = creds.api.replace(/\/+$/, "");
549
+ const list = await fetch(`${api}/api/v1/orgs/brands`, { headers: brandHeaders(creds.token, creds.org) });
550
+ if (!list.ok) return null;
551
+ const def = ((await list.json()) as { brands: BrandRow[] }).brands.find((b) => b.isDefault);
552
+ if (!def) return null;
553
+ const src = await fetch(`${api}/api/v1/orgs/registry/files/brands/${encodeURIComponent(def.name)}.ts`, {
554
+ headers: brandHeaders(creds.token, creds.org),
555
+ });
556
+ if (!src.ok) return null;
557
+ const source = await src.text();
558
+ return { name: def.name, source, dependencies: fontPackagesFromSource(source) };
559
+ } catch {
560
+ return null;
561
+ }
562
+ }
563
+
564
+ /** The `@fontsource` side-effect imports a pulled brand source declares (ADR 0074), * the deck's font deps. Inlined here (not imported from control-core) to keep the
565
+ * OSS CLI free of the private control plane. */
566
+ export function fontPackagesFromSource(source: string): string[] {
567
+ const pkgs = new Set<string>();
568
+ for (const m of source.matchAll(/^import\s+"(@fontsource[^"]+)";/gm)) pkgs.add(m[1]!);
569
+ return [...pkgs].sort();
570
+ }
package/src/creds.ts ADDED
@@ -0,0 +1,31 @@
1
+ // Cloud credentials, shared by the cloud commands (login/push/orgs/brand) and the
2
+ // registry transport (`@org`). Kept in its own module so `add.ts` and `cloud.ts`
3
+ // can both read creds without an import cycle.
4
+ import { mkdir } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ export const CONFIG_DIR = join(homedir(), ".config", "liebstoeckel");
9
+ export const CONFIG_FILE = join(CONFIG_DIR, "credentials.json");
10
+
11
+ export interface Creds {
12
+ api: string;
13
+ token: string;
14
+ /** Default organization slug for push/brand/@org (ADR 0053); omitted = personal. */
15
+ org?: string;
16
+ }
17
+
18
+ export async function loadCreds(): Promise<Creds | null> {
19
+ try {
20
+ return JSON.parse(await Bun.file(CONFIG_FILE).text()) as Creds;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ export async function saveCreds(c: Creds): Promise<void> {
27
+ await mkdir(CONFIG_DIR, { recursive: true });
28
+ await Bun.write(CONFIG_FILE, JSON.stringify(c, null, 2));
29
+ // Best-effort lock-down of the token file.
30
+ await Bun.$`chmod 600 ${CONFIG_FILE}`.quiet().catch(() => {});
31
+ }