@headroom-cms/cli 0.1.5

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 (2) hide show
  1. package/dist/index.js +1596 -0
  2. package/package.json +49 -0
package/dist/index.js ADDED
@@ -0,0 +1,1596 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/api-keys.ts
7
+ import * as p from "@clack/prompts";
8
+
9
+ // src/config.ts
10
+ import { readFile, writeFile, mkdir, access } from "fs/promises";
11
+ import { join } from "path";
12
+ import { execSync } from "child_process";
13
+ import { homedir } from "os";
14
+ function tokenFileName(apiUrl) {
15
+ return apiUrl.replace(/^https?:\/\//, "").replace(/[^a-zA-Z0-9.-]/g, "_");
16
+ }
17
+ function createConfigStore(baseDir) {
18
+ const configFile = join(baseDir, "config.json");
19
+ const tokensDir = join(baseDir, "tokens");
20
+ const gitignoreFile = join(baseDir, ".gitignore");
21
+ async function ensureDir() {
22
+ await mkdir(baseDir, { recursive: true });
23
+ await mkdir(tokensDir, { recursive: true });
24
+ try {
25
+ await access(gitignoreFile);
26
+ } catch {
27
+ await writeFile(gitignoreFile, "*\n");
28
+ }
29
+ }
30
+ function tokenPath(apiUrl) {
31
+ return join(tokensDir, `${tokenFileName(apiUrl)}.json`);
32
+ }
33
+ return {
34
+ async loadConfig() {
35
+ try {
36
+ const raw = await readFile(configFile, "utf-8");
37
+ return JSON.parse(raw);
38
+ } catch {
39
+ return null;
40
+ }
41
+ },
42
+ async saveConfig(config) {
43
+ await ensureDir();
44
+ await writeFile(configFile, JSON.stringify(config, null, 2) + "\n");
45
+ },
46
+ async updateConfig(updates) {
47
+ const existing = await this.loadConfig() ?? {};
48
+ const merged = { ...existing, ...updates };
49
+ await this.saveConfig(merged);
50
+ return merged;
51
+ },
52
+ async loadToken(apiUrl) {
53
+ try {
54
+ const raw = await readFile(tokenPath(apiUrl), "utf-8");
55
+ return JSON.parse(raw);
56
+ } catch {
57
+ return null;
58
+ }
59
+ },
60
+ async saveToken(apiUrl, token) {
61
+ await ensureDir();
62
+ await writeFile(
63
+ tokenPath(apiUrl),
64
+ JSON.stringify(token, null, 2) + "\n",
65
+ { mode: 384 }
66
+ );
67
+ }
68
+ };
69
+ }
70
+ function findGitRoot() {
71
+ try {
72
+ return execSync("git rev-parse --show-toplevel", {
73
+ encoding: "utf-8",
74
+ stdio: ["pipe", "pipe", "pipe"]
75
+ }).trim();
76
+ } catch {
77
+ return homedir();
78
+ }
79
+ }
80
+ var _defaultStore = null;
81
+ function getDefaultStore() {
82
+ if (!_defaultStore) {
83
+ _defaultStore = createConfigStore(join(findGitRoot(), ".headroom"));
84
+ }
85
+ return _defaultStore;
86
+ }
87
+ var loadConfig = (...args) => getDefaultStore().loadConfig(...args);
88
+ var saveConfig = (...args) => getDefaultStore().saveConfig(...args);
89
+ var updateConfig = (...args) => getDefaultStore().updateConfig(...args);
90
+ var loadToken = (...args) => getDefaultStore().loadToken(...args);
91
+ var saveToken = (...args) => getDefaultStore().saveToken(...args);
92
+
93
+ // src/context.ts
94
+ import { HeadroomAdminClient } from "@headroom-cms/admin-api";
95
+
96
+ // src/output.ts
97
+ import { HeadroomApiError } from "@headroom-cms/admin-api";
98
+
99
+ // src/table.ts
100
+ import pc from "picocolors";
101
+ var DEFAULT_MAX_WIDTH = 40;
102
+ function formatTable(rows, columns) {
103
+ const cellValues = rows.map(
104
+ (row) => columns.map((col) => {
105
+ const raw = row[col.key];
106
+ if (col.format) return col.format(raw);
107
+ return raw == null ? "" : String(raw);
108
+ })
109
+ );
110
+ const widths = columns.map((col, i) => {
111
+ const maxContent = Math.max(
112
+ col.header.length,
113
+ ...cellValues.map((vals) => vals[i].length)
114
+ );
115
+ const limit = col.width ?? DEFAULT_MAX_WIDTH;
116
+ return Math.min(maxContent, limit);
117
+ });
118
+ const truncate = (s, w) => {
119
+ if (s.length <= w) return s.padEnd(w);
120
+ return s.slice(0, w - 1) + "\u2026";
121
+ };
122
+ const header = columns.map((col, i) => pc.bold(truncate(col.header, widths[i]))).join(" ");
123
+ const dataLines = cellValues.map(
124
+ (vals) => vals.map((v, i) => truncate(v, widths[i])).join(" ")
125
+ );
126
+ return [header, ...dataLines].join("\n");
127
+ }
128
+ function formatTimestamp(value) {
129
+ if (typeof value === "number" && value > 0) {
130
+ return new Date(value).toISOString().slice(0, 10);
131
+ }
132
+ return String(value ?? "");
133
+ }
134
+
135
+ // src/output.ts
136
+ var quietMode = false;
137
+ var debugEnabled = false;
138
+ var tableMode = false;
139
+ function setQuiet(quiet) {
140
+ quietMode = quiet;
141
+ }
142
+ function setDebug(enabled) {
143
+ debugEnabled = enabled;
144
+ }
145
+ function setTable(table) {
146
+ tableMode = table;
147
+ }
148
+ function exitCodeForError(err) {
149
+ if (err instanceof HeadroomApiError) {
150
+ if (err.status === 401) return 3;
151
+ return err.status >= 500 ? 2 : 1;
152
+ }
153
+ return 3;
154
+ }
155
+ function outputJson(data) {
156
+ if (quietMode) return;
157
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
158
+ }
159
+ function outputList(data, columns) {
160
+ if (quietMode) return;
161
+ if (tableMode) {
162
+ process.stdout.write(formatTable(data, columns) + "\n");
163
+ } else {
164
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
165
+ }
166
+ }
167
+ function outputError(err) {
168
+ if (err instanceof Error) {
169
+ const obj = { error: err.message };
170
+ if ("status" in err) obj.status = err.status;
171
+ if ("code" in err) obj.code = err.code;
172
+ process.stderr.write(JSON.stringify(obj) + "\n");
173
+ } else {
174
+ process.stderr.write(JSON.stringify({ error: String(err) }) + "\n");
175
+ }
176
+ }
177
+ function debugLog(message) {
178
+ if (!debugEnabled) return;
179
+ process.stderr.write(`[debug] ${message}
180
+ `);
181
+ }
182
+
183
+ // src/context.ts
184
+ var RefreshingTokenProvider = class {
185
+ tokenData;
186
+ apiUrl;
187
+ constructor(apiUrl, tokenData) {
188
+ this.apiUrl = apiUrl;
189
+ this.tokenData = tokenData;
190
+ }
191
+ async getToken() {
192
+ if (this.tokenData.expiresAt && Date.now() > this.tokenData.expiresAt - 6e4) {
193
+ await this.refresh();
194
+ }
195
+ return this.tokenData.accessToken;
196
+ }
197
+ async onUnauthorized() {
198
+ await this.refresh();
199
+ return this.tokenData.accessToken;
200
+ }
201
+ async refresh() {
202
+ const res = await fetch(`${this.apiUrl}/auth/refresh`, {
203
+ method: "POST",
204
+ headers: { "Content-Type": "application/json" },
205
+ body: JSON.stringify({ refreshToken: this.tokenData.refreshToken })
206
+ });
207
+ if (!res.ok) {
208
+ throw new Error("Token expired. Run: headroom login <api-url>");
209
+ }
210
+ const body = await res.json();
211
+ this.tokenData = {
212
+ ...this.tokenData,
213
+ accessToken: body.accessToken,
214
+ expiresAt: Date.now() + body.expiresIn * 1e3
215
+ };
216
+ await saveToken(this.apiUrl, this.tokenData);
217
+ }
218
+ };
219
+ async function resolveAuth(opts) {
220
+ const config = await loadConfig();
221
+ if (!config) {
222
+ throw new Error("Not logged in. Run: headroom login <api-url>");
223
+ }
224
+ const apiUrl = opts.apiUrl ?? config.apiUrl;
225
+ const tokenData = await loadToken(apiUrl);
226
+ if (!tokenData) {
227
+ throw new Error("No token found. Run: headroom login <api-url>");
228
+ }
229
+ const tokenProvider = new RefreshingTokenProvider(apiUrl, tokenData);
230
+ const client = new HeadroomAdminClient(apiUrl, tokenProvider);
231
+ if (opts.debug) {
232
+ const origApiFetch = client.apiFetch.bind(client);
233
+ client.apiFetch = async (path, options) => {
234
+ const method = options?.method ?? "GET";
235
+ debugLog(`\u2192 ${method} ${apiUrl}${path}`);
236
+ if (options?.body) debugLog(` body: ${String(options.body).slice(0, 500)}`);
237
+ try {
238
+ const result = await origApiFetch(path, options);
239
+ debugLog(`\u2190 OK`);
240
+ return result;
241
+ } catch (err) {
242
+ debugLog(`\u2190 ERROR: ${err instanceof Error ? err.message : String(err)}`);
243
+ throw err;
244
+ }
245
+ };
246
+ }
247
+ return { config, token: tokenData, apiUrl, client };
248
+ }
249
+ async function resolveAuthContext(opts) {
250
+ return resolveAuth(opts);
251
+ }
252
+ async function resolveContext(opts) {
253
+ const auth = await resolveAuth(opts);
254
+ const site = opts.site ?? auth.config.activeSite;
255
+ if (!site) {
256
+ throw new Error("No site selected. Run: headroom site <host>");
257
+ }
258
+ return { ...auth, site };
259
+ }
260
+
261
+ // src/stdin.ts
262
+ async function readJsonPayload(opts, commandName) {
263
+ if (opts.data) {
264
+ try {
265
+ return JSON.parse(opts.data);
266
+ } catch {
267
+ throw new Error(`Invalid JSON in --data: ${opts.data.slice(0, 100)}`);
268
+ }
269
+ }
270
+ if (process.stdin.isTTY) {
271
+ throw new Error(
272
+ `No input provided. Pass JSON via --data or pipe to stdin.
273
+ Run: headroom ${commandName} --help`
274
+ );
275
+ }
276
+ const chunks = [];
277
+ for await (const chunk of process.stdin) {
278
+ chunks.push(chunk);
279
+ }
280
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
281
+ if (!raw) {
282
+ throw new Error("Empty stdin \u2014 expected JSON input.");
283
+ }
284
+ try {
285
+ return JSON.parse(raw);
286
+ } catch {
287
+ throw new Error(`Invalid JSON from stdin: ${raw.slice(0, 100)}`);
288
+ }
289
+ }
290
+
291
+ // src/commands/api-keys.ts
292
+ var API_KEY_COLUMNS = [
293
+ { key: "keyId", header: "ID", width: 26 },
294
+ { key: "label", header: "LABEL", width: 30 },
295
+ { key: "prefix", header: "PREFIX", width: 12 },
296
+ { key: "createdAt", header: "CREATED", width: 12, format: formatTimestamp }
297
+ ];
298
+ function registerApiKeysCommand(program2) {
299
+ const apiKeys = program2.command("api-keys").description("Manage API keys");
300
+ apiKeys.command("list").description("List API keys for the active site").action(async () => {
301
+ const ctx = await resolveContext(apiKeys.optsWithGlobals());
302
+ const result = await ctx.client.listApiKeys(ctx.site);
303
+ outputList(result, API_KEY_COLUMNS);
304
+ });
305
+ apiKeys.command("create").description("Create a new API key (plaintext key shown only once)").option("--data <json>", 'JSON payload: { label: "key-name" }').action(async (opts) => {
306
+ const ctx = await resolveContext(apiKeys.optsWithGlobals());
307
+ const payload = await readJsonPayload(opts, "api-keys create");
308
+ if (!payload.label) {
309
+ throw new Error("Payload must include 'label' field.");
310
+ }
311
+ const result = await ctx.client.createApiKey(ctx.site, payload.label);
312
+ outputJson(result);
313
+ });
314
+ apiKeys.command("update").description("Update an API key label").argument("<id>", "API key ID").option("--data <json>", 'JSON payload: { label: "new-name" }').action(async (id, opts) => {
315
+ const ctx = await resolveContext(apiKeys.optsWithGlobals());
316
+ const payload = await readJsonPayload(opts, "api-keys update");
317
+ if (!payload.label) {
318
+ throw new Error("Payload must include 'label' field.");
319
+ }
320
+ await ctx.client.updateApiKey(ctx.site, id, payload.label);
321
+ outputJson({ updated: id });
322
+ });
323
+ apiKeys.command("delete").description("Delete an API key").argument("<id>", "API key ID").option("--force", "skip confirmation prompt").action(async (id, opts) => {
324
+ if (!opts.force) {
325
+ if (!process.stdin.isTTY) {
326
+ throw new Error("Use --force to delete non-interactively.");
327
+ }
328
+ const confirmed = await p.confirm({
329
+ message: `Delete API key ${id}? This cannot be undone.`
330
+ });
331
+ if (p.isCancel(confirmed) || !confirmed) {
332
+ process.exit(0);
333
+ }
334
+ }
335
+ const ctx = await resolveContext(apiKeys.optsWithGlobals());
336
+ await ctx.client.deleteApiKey(ctx.site, id);
337
+ outputJson({ deleted: id });
338
+ });
339
+ }
340
+
341
+ // src/commands/audit.ts
342
+ var AUDIT_COLUMNS = [
343
+ { key: "action", header: "ACTION", width: 25 },
344
+ { key: "adminId", header: "ADMIN", width: 30 },
345
+ { key: "resourceType", header: "RESOURCE", width: 15 },
346
+ { key: "resourceId", header: "RESOURCE ID", width: 26 },
347
+ { key: "createdAt", header: "TIME", width: 12, format: formatTimestamp }
348
+ ];
349
+ function registerAuditCommand(program2) {
350
+ const audit = program2.command("audit").description("View audit log");
351
+ audit.command("list").description("List audit events").option("--action <action>", "filter by action (e.g. content.published)").option("--admin <id>", "filter by admin ID").option("--before <timestamp>", "show events before this epoch-ms timestamp", parseInt).option("--limit <n>", "max results (default 50, max 100)", parseInt).action(async (opts) => {
352
+ const globalOpts = audit.optsWithGlobals();
353
+ const ctx = await resolveContext(globalOpts);
354
+ const result = await ctx.client.listAuditEvents(ctx.site, {
355
+ action: opts.action,
356
+ admin: opts.admin,
357
+ before: opts.before,
358
+ limit: opts.limit
359
+ });
360
+ if (globalOpts.table) {
361
+ outputList(
362
+ result.items,
363
+ AUDIT_COLUMNS
364
+ );
365
+ if (result.hasMore) {
366
+ process.stderr.write(
367
+ "(more results available \u2014 use --before or JSON mode for pagination)\n"
368
+ );
369
+ }
370
+ } else {
371
+ outputJson(result);
372
+ }
373
+ });
374
+ }
375
+
376
+ // src/commands/block-types.ts
377
+ import * as p2 from "@clack/prompts";
378
+
379
+ // src/introspection.ts
380
+ import pc2 from "picocolors";
381
+ function describeField(field) {
382
+ const opts = field.options ?? {};
383
+ const type = field.type;
384
+ let constraints = {};
385
+ let children;
386
+ switch (type) {
387
+ case "text":
388
+ case "textarea":
389
+ case "url":
390
+ case "email":
391
+ case "date":
392
+ if (opts.placeholder != null) constraints.placeholder = opts.placeholder;
393
+ if (opts.maxLength != null) constraints.maxLength = opts.maxLength;
394
+ break;
395
+ case "number":
396
+ if (opts.min != null) constraints.min = opts.min;
397
+ if (opts.max != null) constraints.max = opts.max;
398
+ if (opts.step != null) constraints.step = opts.step;
399
+ break;
400
+ case "select":
401
+ if (opts.choices != null) constraints.choices = opts.choices;
402
+ break;
403
+ case "media":
404
+ if (opts.allowedTypes != null) constraints.allowedTypes = opts.allowedTypes;
405
+ if (opts.folder != null) constraints.folder = opts.folder;
406
+ if (opts.maxSize != null) constraints.maxSize = opts.maxSize;
407
+ if (opts.multiple != null) constraints.multiple = opts.multiple;
408
+ break;
409
+ case "content":
410
+ if (opts.collections != null) constraints.collections = opts.collections;
411
+ if (opts.multiple != null) constraints.multiple = opts.multiple;
412
+ break;
413
+ case "array":
414
+ if (Array.isArray(opts.itemFields)) {
415
+ children = opts.itemFields.map(describeField);
416
+ }
417
+ break;
418
+ case "container":
419
+ if (Array.isArray(opts.fields)) {
420
+ children = opts.fields.map(describeField);
421
+ }
422
+ break;
423
+ }
424
+ const noRequired = type === "boolean" || type === "blocks";
425
+ const required = noRequired ? false : !!opts.required;
426
+ const desc = { name: field.name, type, label: field.label, required, constraints };
427
+ if (children) desc.children = children;
428
+ return desc;
429
+ }
430
+ function describeCollection(collection) {
431
+ const fields = (collection.fields ?? []).map(describeField);
432
+ const desc = {
433
+ name: collection.name,
434
+ label: collection.label,
435
+ singleton: !!collection.singleton,
436
+ fields
437
+ };
438
+ if (collection.mode) desc.mode = collection.mode;
439
+ if (collection.relationships && collection.relationships.length > 0) {
440
+ desc.relationships = collection.relationships.map((r) => ({
441
+ name: r.name,
442
+ label: r.label,
443
+ targetCollection: r.targetCollection,
444
+ multiple: r.multiple
445
+ }));
446
+ }
447
+ return desc;
448
+ }
449
+ function describeBlockType(blockType) {
450
+ const fields = (blockType.fields ?? []).map(describeField);
451
+ return { name: blockType.name, label: blockType.label, fields };
452
+ }
453
+ function generateExample(fields) {
454
+ const result = {};
455
+ for (const field of fields) {
456
+ result[field.name] = exampleValue(field);
457
+ }
458
+ return result;
459
+ }
460
+ function exampleValue(field) {
461
+ const opts = field.options ?? {};
462
+ switch (field.type) {
463
+ case "text":
464
+ case "url":
465
+ case "email":
466
+ case "date":
467
+ return "example";
468
+ case "textarea":
469
+ return "example text";
470
+ case "number":
471
+ return 0;
472
+ case "boolean":
473
+ return false;
474
+ case "select": {
475
+ const choices = opts.choices;
476
+ return choices && choices.length > 0 ? choices[0] : "";
477
+ }
478
+ case "media":
479
+ return "media-id";
480
+ case "content":
481
+ return opts.multiple ? ["content-id"] : "content-id";
482
+ case "blocks":
483
+ return [];
484
+ case "array": {
485
+ const itemFields = opts.itemFields;
486
+ if (itemFields && itemFields.length > 0) {
487
+ return [generateExample(itemFields)];
488
+ }
489
+ return [];
490
+ }
491
+ case "container": {
492
+ const containerFields = opts.fields;
493
+ if (containerFields && containerFields.length > 0) {
494
+ return generateExample(containerFields);
495
+ }
496
+ return {};
497
+ }
498
+ default:
499
+ return null;
500
+ }
501
+ }
502
+ function formatDescribeTable(description) {
503
+ const rows = [];
504
+ function addRows(fields, indent) {
505
+ for (const f of fields) {
506
+ const constraintStr = Object.entries(f.constraints).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`).join(", ");
507
+ rows.push({
508
+ name: indent + f.name,
509
+ type: f.type,
510
+ required: f.type === "boolean" || f.type === "blocks" ? "-" : f.required ? "yes" : "no",
511
+ constraints: constraintStr
512
+ });
513
+ if (f.children) {
514
+ addRows(f.children, indent + "\u21B3 ");
515
+ }
516
+ }
517
+ }
518
+ addRows(description.fields, "");
519
+ const cols = [
520
+ { key: "name", header: "NAME", width: 20 },
521
+ { key: "type", header: "TYPE", width: 12 },
522
+ { key: "required", header: "REQUIRED", width: 10 },
523
+ { key: "constraints", header: "CONSTRAINTS", width: 50 }
524
+ ];
525
+ const widths = cols.map((col, i) => {
526
+ const maxContent = Math.max(
527
+ col.header.length,
528
+ ...rows.map((r) => r[col.key].length)
529
+ );
530
+ return Math.min(maxContent, col.width);
531
+ });
532
+ const truncate = (s, w) => {
533
+ if (s.length <= w) return s.padEnd(w);
534
+ return s.slice(0, w - 1) + "\u2026";
535
+ };
536
+ const header = cols.map((col, i) => pc2.bold(truncate(col.header, widths[i]))).join(" ");
537
+ const dataLines = rows.map(
538
+ (row) => cols.map((col, i) => truncate(row[col.key], widths[i])).join(" ")
539
+ );
540
+ return [header, ...dataLines].join("\n");
541
+ }
542
+
543
+ // src/commands/block-types.ts
544
+ var BLOCK_TYPES_COLUMNS = [
545
+ { key: "name", header: "NAME", width: 25 },
546
+ { key: "label", header: "LABEL", width: 30 },
547
+ { key: "fieldCount", header: "FIELDS", width: 8 }
548
+ ];
549
+ function registerBlockTypesCommand(program2) {
550
+ const blockTypes = program2.command("block-types").description("Manage block types");
551
+ blockTypes.command("list").description("List all block types").action(async () => {
552
+ const ctx = await resolveContext(
553
+ blockTypes.optsWithGlobals()
554
+ );
555
+ const result = await ctx.client.listBlockTypes(ctx.site);
556
+ const rows = result.map((bt) => ({ ...bt, fieldCount: bt.fields?.length ?? 0 }));
557
+ outputList(rows, BLOCK_TYPES_COLUMNS);
558
+ });
559
+ blockTypes.command("get").description("Get block type details").argument("<name>", "block type name").action(async (name) => {
560
+ const ctx = await resolveContext(
561
+ blockTypes.optsWithGlobals()
562
+ );
563
+ const blockType = await ctx.client.getBlockType(ctx.site, name);
564
+ outputJson(blockType);
565
+ });
566
+ blockTypes.command("create").description("Create a new block type").option("--data <json>", "JSON payload: { name, label, fields }").action(async (opts) => {
567
+ const ctx = await resolveContext(
568
+ blockTypes.optsWithGlobals()
569
+ );
570
+ const payload = await readJsonPayload(opts, "block-types create");
571
+ if (!payload.name || !payload.label || !payload.fields) {
572
+ throw new Error("JSON must include 'name', 'label', and 'fields'.");
573
+ }
574
+ const blockType = await ctx.client.createBlockType(ctx.site, payload);
575
+ outputJson(blockType);
576
+ });
577
+ blockTypes.command("update").description("Update a block type").argument("<name>", "block type name to update").option("--data <json>", "JSON payload: { name, label, fields }").action(async (name, opts) => {
578
+ const ctx = await resolveContext(
579
+ blockTypes.optsWithGlobals()
580
+ );
581
+ const payload = await readJsonPayload(opts, "block-types update");
582
+ if (!payload.name || !payload.label || !payload.fields) {
583
+ throw new Error("JSON must include 'name', 'label', and 'fields'.");
584
+ }
585
+ const blockType = await ctx.client.updateBlockType(ctx.site, name, payload);
586
+ outputJson(blockType);
587
+ });
588
+ blockTypes.command("delete").description("Delete a block type").argument("<name>", "block type name to delete").option("--force", "skip confirmation prompt").action(async (name, opts) => {
589
+ if (!opts.force) {
590
+ if (!process.stdin.isTTY) {
591
+ throw new Error("Use --force to delete non-interactively.");
592
+ }
593
+ const confirmed = await p2.confirm({
594
+ message: `Delete block type ${name}? This cannot be undone.`
595
+ });
596
+ if (p2.isCancel(confirmed) || !confirmed) {
597
+ process.exit(0);
598
+ }
599
+ }
600
+ const ctx = await resolveContext(
601
+ blockTypes.optsWithGlobals()
602
+ );
603
+ await ctx.client.deleteBlockType(ctx.site, name);
604
+ outputJson({ deleted: name });
605
+ });
606
+ blockTypes.command("describe").description("Show block type field schema").argument("<name>", "block type name").action(async (name) => {
607
+ const globalOpts = blockTypes.optsWithGlobals();
608
+ const ctx = await resolveContext(globalOpts);
609
+ const blockType = await ctx.client.getBlockType(ctx.site, name);
610
+ const description = describeBlockType(blockType);
611
+ if (globalOpts.table) {
612
+ process.stdout.write(formatDescribeTable(description) + "\n");
613
+ } else {
614
+ outputJson(description);
615
+ }
616
+ });
617
+ }
618
+
619
+ // src/commands/collections.ts
620
+ import * as p3 from "@clack/prompts";
621
+ var COLLECTIONS_COLUMNS = [
622
+ { key: "name", header: "NAME", width: 25 },
623
+ { key: "label", header: "LABEL", width: 30 },
624
+ { key: "singleton", header: "SINGLETON", width: 10 },
625
+ { key: "fieldCount", header: "FIELDS", width: 8 }
626
+ ];
627
+ function registerCollectionsCommand(program2) {
628
+ const collections = program2.command("collections").description("Manage collections and inspect schemas");
629
+ collections.command("list").description("List all collections").action(async () => {
630
+ const ctx = await resolveContext(
631
+ collections.optsWithGlobals()
632
+ );
633
+ const result = await ctx.client.listCollections(ctx.site);
634
+ const rows = result.map((c) => ({ ...c, fieldCount: c.fields?.length ?? 0 }));
635
+ outputList(rows, COLLECTIONS_COLUMNS);
636
+ });
637
+ collections.command("get").description("Get collection details").argument("<name>", "collection name").action(async (name) => {
638
+ const ctx = await resolveContext(
639
+ collections.optsWithGlobals()
640
+ );
641
+ const collection = await ctx.client.getCollection(ctx.site, name);
642
+ outputJson(collection);
643
+ });
644
+ collections.command("create").description("Create a new collection").option("--data <json>", "JSON payload: { name, label, fields, ... }").action(async (opts) => {
645
+ const ctx = await resolveContext(
646
+ collections.optsWithGlobals()
647
+ );
648
+ const payload = await readJsonPayload(opts, "collections create");
649
+ if (!payload.name || !payload.label || !payload.fields) {
650
+ throw new Error("JSON must include 'name', 'label', and 'fields'.");
651
+ }
652
+ const collection = await ctx.client.createCollection(ctx.site, payload);
653
+ outputJson(collection);
654
+ });
655
+ collections.command("update").description("Update a collection").argument("<name>", "collection name to update").option("--data <json>", "JSON payload: { name, label, fields, ... }").action(async (name, opts) => {
656
+ const ctx = await resolveContext(
657
+ collections.optsWithGlobals()
658
+ );
659
+ const payload = await readJsonPayload(opts, "collections update");
660
+ if (!payload.name || !payload.label || !payload.fields) {
661
+ throw new Error("JSON must include 'name', 'label', and 'fields'.");
662
+ }
663
+ const collection = await ctx.client.updateCollection(ctx.site, name, payload);
664
+ outputJson(collection);
665
+ });
666
+ collections.command("delete").description("Delete a collection").argument("<name>", "collection name to delete").option("--force", "skip confirmation prompt").action(async (name, opts) => {
667
+ if (!opts.force) {
668
+ if (!process.stdin.isTTY) {
669
+ throw new Error("Use --force to delete non-interactively.");
670
+ }
671
+ const confirmed = await p3.confirm({
672
+ message: `Delete collection ${name}? This cannot be undone.`
673
+ });
674
+ if (p3.isCancel(confirmed) || !confirmed) {
675
+ process.exit(0);
676
+ }
677
+ }
678
+ const ctx = await resolveContext(
679
+ collections.optsWithGlobals()
680
+ );
681
+ await ctx.client.deleteCollection(ctx.site, name);
682
+ outputJson({ deleted: name });
683
+ });
684
+ collections.command("describe").description("Show collection field schema").argument("<name>", "collection name").action(async (name) => {
685
+ const globalOpts = collections.optsWithGlobals();
686
+ const ctx = await resolveContext(globalOpts);
687
+ const collection = await ctx.client.getCollection(ctx.site, name);
688
+ const description = describeCollection(collection);
689
+ if (globalOpts.table) {
690
+ process.stdout.write(formatDescribeTable(description) + "\n");
691
+ } else {
692
+ outputJson(description);
693
+ }
694
+ });
695
+ collections.command("example").description("Generate example JSON payload for content create").argument("<name>", "collection name").action(async (name) => {
696
+ const ctx = await resolveContext(
697
+ collections.optsWithGlobals()
698
+ );
699
+ const collection = await ctx.client.getCollection(ctx.site, name);
700
+ const example = generateExample(collection.fields ?? []);
701
+ outputJson(example);
702
+ });
703
+ }
704
+
705
+ // src/commands/content.ts
706
+ import * as p4 from "@clack/prompts";
707
+ var CONTENT_COLUMNS = [
708
+ { key: "contentId", header: "ID", width: 26 },
709
+ { key: "collection", header: "COLLECTION", width: 20 },
710
+ { key: "title", header: "TITLE", width: 35 },
711
+ { key: "status", header: "STATUS", width: 12 },
712
+ { key: "lastDraftAt", header: "UPDATED", width: 12, format: formatTimestamp }
713
+ ];
714
+ var VERSIONS_COLUMNS = [
715
+ { key: "blockId", header: "BLOCK ID", width: 26 },
716
+ { key: "createdAt", header: "CREATED", width: 12, format: formatTimestamp },
717
+ { key: "createdBy", header: "CREATED BY", width: 30 }
718
+ ];
719
+ var REFERENCES_COLUMNS = [
720
+ { key: "contentId", header: "CONTENT ID", width: 26 },
721
+ { key: "collection", header: "COLLECTION", width: 20 },
722
+ { key: "title", header: "TITLE", width: 30 },
723
+ { key: "fieldName", header: "FIELD", width: 20 }
724
+ ];
725
+ function registerContentCommand(program2) {
726
+ const content = program2.command("content").description("Manage content lifecycle");
727
+ content.command("list").description("List content").option("--collection <name>", "filter by collection").option("--status <status>", "filter by status (draft, published, changed, unpublished, scheduled)").option("--tag <tag>", "filter by tag").option("--query <query>", "search query").option("--search <mode>", "search mode: prefix or full (used with --query)").option("--sort <field>", "sort field").option("--related-to <ref>", "filter by relationship (field:contentId)").option("--limit <n>", "max results", parseInt).option("--cursor <token>", "pagination cursor").action(async (opts) => {
728
+ const globalOpts = content.optsWithGlobals();
729
+ const ctx = await resolveContext(globalOpts);
730
+ const result = await ctx.client.listContent(ctx.site, {
731
+ collection: opts.collection,
732
+ status: opts.status,
733
+ tag: opts.tag,
734
+ q: opts.query,
735
+ search: opts.search,
736
+ sort: opts.sort,
737
+ relatedTo: opts.relatedTo,
738
+ limit: opts.limit,
739
+ cursor: opts.cursor
740
+ });
741
+ if (globalOpts.table) {
742
+ const rows = result.items.map((item) => {
743
+ const r = item;
744
+ if (!r.status) {
745
+ const published = r.lastPublishedAt;
746
+ const drafted = r.lastDraftAt;
747
+ if (published && drafted && drafted > published) r.status = "changed";
748
+ else if (published) r.status = "published";
749
+ else r.status = "draft";
750
+ }
751
+ return r;
752
+ });
753
+ outputList(rows, CONTENT_COLUMNS);
754
+ if (result.hasMore) {
755
+ process.stderr.write(
756
+ "(more results available \u2014 use --cursor or JSON mode for pagination)\n"
757
+ );
758
+ }
759
+ } else {
760
+ outputJson(result);
761
+ }
762
+ });
763
+ content.command("get").description("Get content with body").argument("<id>", "content ID").action(async (id) => {
764
+ const ctx = await resolveContext(content.optsWithGlobals());
765
+ const item = await ctx.client.getContent(ctx.site, id);
766
+ outputJson(item);
767
+ });
768
+ content.command("create").description("Create new content in a collection").requiredOption("--collection <name>", "target collection").option("--data <json>", "JSON payload (SaveDraftParams)").addHelpText("after", `
769
+ Examples:
770
+ Discover the schema for a collection:
771
+ $ headroom collections describe blog_posts
772
+
773
+ Generate a JSON template and pipe it into create:
774
+ $ headroom collections example blog_posts | jq '.title = "Hello"' | headroom content create --collection blog_posts
775
+ `).action(async (opts) => {
776
+ const ctx = await resolveContext(content.optsWithGlobals());
777
+ const payload = await readJsonPayload(opts, "content create");
778
+ const result = await ctx.client.createContent(ctx.site, {
779
+ collection: opts.collection,
780
+ params: payload
781
+ });
782
+ outputJson(result);
783
+ });
784
+ content.command("draft").description("Save draft for existing content").argument("<id>", "content ID").option("--data <json>", "JSON payload (SaveDraftParams)").action(async (id, opts) => {
785
+ const ctx = await resolveContext(content.optsWithGlobals());
786
+ const payload = await readJsonPayload(opts, "content draft");
787
+ const result = await ctx.client.saveDraft(ctx.site, id, payload);
788
+ outputJson(result);
789
+ });
790
+ content.command("publish").description("Publish content").argument("<id>", "content ID").action(async (id) => {
791
+ const ctx = await resolveContext(content.optsWithGlobals());
792
+ const result = await ctx.client.publishContent(ctx.site, id);
793
+ outputJson(result);
794
+ });
795
+ content.command("unpublish").description("Unpublish content").argument("<id>", "content ID").action(async (id) => {
796
+ const ctx = await resolveContext(content.optsWithGlobals());
797
+ await ctx.client.unpublishContent(ctx.site, id);
798
+ outputJson({ unpublished: id });
799
+ });
800
+ content.command("discard").description("Discard draft and revert to published version").argument("<id>", "content ID").option("--force", "skip confirmation prompt").action(async (id, opts) => {
801
+ if (!opts.force) {
802
+ if (!process.stdin.isTTY) {
803
+ throw new Error("Use --force to discard non-interactively.");
804
+ }
805
+ const confirmed = await p4.confirm({
806
+ message: `Discard draft for ${id}? Draft changes will be permanently lost.`
807
+ });
808
+ if (p4.isCancel(confirmed) || !confirmed) {
809
+ process.exit(0);
810
+ }
811
+ }
812
+ const ctx = await resolveContext(content.optsWithGlobals());
813
+ await ctx.client.discardDraft(ctx.site, id);
814
+ outputJson({ discarded: id });
815
+ });
816
+ content.command("delete").description("Delete content").argument("<id>", "content ID").option("--force", "skip confirmation prompt").action(async (id, opts) => {
817
+ if (!opts.force) {
818
+ if (!process.stdin.isTTY) {
819
+ throw new Error("Use --force to delete non-interactively.");
820
+ }
821
+ const confirmed = await p4.confirm({
822
+ message: `Delete content ${id}? This cannot be undone.`
823
+ });
824
+ if (p4.isCancel(confirmed) || !confirmed) {
825
+ process.exit(0);
826
+ }
827
+ }
828
+ const ctx = await resolveContext(content.optsWithGlobals());
829
+ await ctx.client.deleteContent(ctx.site, id);
830
+ outputJson({ deleted: id });
831
+ });
832
+ content.command("versions").description("List version history for content").argument("<id>", "content ID").action(async (id) => {
833
+ const globalOpts = content.optsWithGlobals();
834
+ const ctx = await resolveContext(globalOpts);
835
+ const result = await ctx.client.listVersions(ctx.site, id);
836
+ if (globalOpts.table) {
837
+ outputList(
838
+ result.versions,
839
+ VERSIONS_COLUMNS
840
+ );
841
+ } else {
842
+ outputJson(result);
843
+ }
844
+ });
845
+ content.command("references").description("Show what references this content").argument("<id>", "content ID").action(async (id) => {
846
+ const globalOpts = content.optsWithGlobals();
847
+ const ctx = await resolveContext(globalOpts);
848
+ const result = await ctx.client.getReferences(ctx.site, id);
849
+ if (globalOpts.table) {
850
+ outputList(
851
+ result.items,
852
+ REFERENCES_COLUMNS
853
+ );
854
+ } else {
855
+ outputJson(result);
856
+ }
857
+ });
858
+ }
859
+
860
+ // src/commands/login.ts
861
+ import { createInterface } from "readline";
862
+ import * as p5 from "@clack/prompts";
863
+ import pc3 from "picocolors";
864
+ async function apiLogin(apiUrl, email, password2) {
865
+ const res = await fetch(`${apiUrl}/auth/login`, {
866
+ method: "POST",
867
+ headers: { "Content-Type": "application/json" },
868
+ body: JSON.stringify({ email, password: password2 })
869
+ });
870
+ if (!res.ok) {
871
+ const body = await res.json().catch(() => ({}));
872
+ const detail = body.error ?? `HTTP ${res.status}`;
873
+ if (res.status === 401) {
874
+ throw new Error(`Authentication failed: ${detail}`);
875
+ }
876
+ if (res.status === 422) {
877
+ throw new Error(
878
+ `Account setup required (MFA or password change). Use the admin UI to complete setup, then retry.`
879
+ );
880
+ }
881
+ throw new Error(`Login request failed: ${detail}`);
882
+ }
883
+ return res.json();
884
+ }
885
+ async function readStdin() {
886
+ const rl = createInterface({ input: process.stdin });
887
+ for await (const line of rl) {
888
+ rl.close();
889
+ return line.trim();
890
+ }
891
+ throw new Error("No input received on stdin");
892
+ }
893
+ function registerLoginCommand(program2) {
894
+ program2.command("login").argument("<api-url>", "Headroom API URL (e.g. https://xxx.lambda-url.us-east-1.on.aws)").argument("[site]", "site host to set as active (e.g. mysite.com)").option("--email <email>", "admin email address").option("--password <password>", "admin password (visible in shell history \u2014 prefer --password-stdin)").option("--password-stdin", "read password from stdin (recommended for CI)").description("Authenticate with a Headroom API").addHelpText(
895
+ "after",
896
+ `
897
+ Examples:
898
+ # Interactive login (prompts for email and password)
899
+ $ headroom login https://your-api.example.com mysite.com
900
+
901
+ # Non-interactive login (secure \u2014 password not in shell history)
902
+ $ echo "$PASSWORD" | headroom login https://your-api.example.com mysite.com \\
903
+ --email admin@example.com --password-stdin
904
+
905
+ # Non-interactive login (convenience, password in shell history)
906
+ $ headroom login https://your-api.example.com mysite.com \\
907
+ --email admin@example.com --password 'MyPass123!'
908
+
909
+ # Login without setting a site (set it later with 'headroom site')
910
+ $ headroom login https://your-api.example.com
911
+
912
+ See also: site, whoami`
913
+ ).action(async (apiUrl, site, opts) => {
914
+ await runLogin(apiUrl.replace(/\/+$/, ""), site, opts);
915
+ });
916
+ }
917
+ async function runLogin(apiUrl, site, opts) {
918
+ let email = opts.email;
919
+ let password2 = opts.password;
920
+ if (opts.passwordStdin) {
921
+ password2 = await readStdin();
922
+ }
923
+ const isTTY = process.stdin.isTTY;
924
+ if (!email || !password2) {
925
+ if (!isTTY) {
926
+ throw new Error(
927
+ "Non-interactive mode requires --email and (--password or --password-stdin)."
928
+ );
929
+ }
930
+ p5.intro(pc3.bold("Headroom Login"));
931
+ const values = await p5.group({
932
+ email: () => email ? Promise.resolve(email) : p5.text({
933
+ message: "Email:",
934
+ validate: (v) => v.includes("@") ? void 0 : "Enter a valid email"
935
+ }),
936
+ password: () => password2 ? Promise.resolve(password2) : p5.password({ message: "Password:" })
937
+ });
938
+ if (p5.isCancel(values)) {
939
+ p5.cancel("Login cancelled.");
940
+ process.exit(0);
941
+ }
942
+ email = values.email;
943
+ password2 = values.password;
944
+ }
945
+ const { accessToken, refreshToken, expiresIn } = await apiLogin(
946
+ apiUrl,
947
+ email,
948
+ password2
949
+ );
950
+ await saveConfig({
951
+ apiUrl,
952
+ email,
953
+ activeSite: site ?? (await loadConfig())?.activeSite
954
+ });
955
+ await saveToken(apiUrl, {
956
+ accessToken,
957
+ refreshToken,
958
+ expiresAt: Date.now() + expiresIn * 1e3
959
+ });
960
+ const result = {
961
+ apiUrl,
962
+ email,
963
+ ...site ? { site } : {}
964
+ };
965
+ if (isTTY) {
966
+ p5.outro(pc3.green(`Logged in as ${email}`));
967
+ }
968
+ outputJson(result);
969
+ }
970
+
971
+ // src/commands/media.ts
972
+ import * as p6 from "@clack/prompts";
973
+ var MEDIA_COLUMNS = [
974
+ { key: "mediaId", header: "ID", width: 26 },
975
+ { key: "filename", header: "FILENAME", width: 30 },
976
+ { key: "mimeType", header: "TYPE", width: 20 },
977
+ { key: "size", header: "SIZE", width: 10, format: formatFileSize },
978
+ { key: "uploadedAt", header: "UPLOADED", width: 12, format: formatTimestamp }
979
+ ];
980
+ function formatFileSize(v) {
981
+ const bytes = Number(v);
982
+ if (bytes < 1024) return `${bytes} B`;
983
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
984
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
985
+ }
986
+ var FOLDER_COLUMNS = [
987
+ { key: "folderId", header: "ID", width: 26 },
988
+ { key: "name", header: "NAME", width: 30 },
989
+ { key: "count", header: "FILES", width: 8 },
990
+ { key: "createdAt", header: "CREATED", width: 12, format: formatTimestamp }
991
+ ];
992
+ var USAGE_COLUMNS = [
993
+ { key: "contentId", header: "CONTENT ID", width: 26 },
994
+ { key: "collection", header: "COLLECTION", width: 20 },
995
+ { key: "title", header: "TITLE", width: 35 }
996
+ ];
997
+ function registerMediaCommand(program2) {
998
+ const media = program2.command("media").description("Manage media files and folders");
999
+ media.command("list").description("List media files").option("--folder <id>", "filter by folder ID").option("--tag <tag>", "filter by tag").option("--query <query>", "search by filename").option("--sort <field>", "sort field").option("--limit <n>", "max results", parseInt).option("--cursor <token>", "pagination cursor").action(async (opts) => {
1000
+ const globalOpts = media.optsWithGlobals();
1001
+ const ctx = await resolveContext(globalOpts);
1002
+ const result = await ctx.client.listMedia(ctx.site, {
1003
+ folderId: opts.folder,
1004
+ tag: opts.tag,
1005
+ q: opts.query,
1006
+ sort: opts.sort,
1007
+ limit: opts.limit,
1008
+ cursor: opts.cursor
1009
+ });
1010
+ if (globalOpts.table) {
1011
+ outputList(
1012
+ result.items,
1013
+ MEDIA_COLUMNS
1014
+ );
1015
+ if (result.hasMore) {
1016
+ process.stderr.write(
1017
+ "(more results available \u2014 use --cursor or JSON mode for pagination)\n"
1018
+ );
1019
+ }
1020
+ } else {
1021
+ outputJson(result);
1022
+ }
1023
+ });
1024
+ media.command("get").description("Get media item metadata").argument("<id>", "media ID").action(async (id) => {
1025
+ const ctx = await resolveContext(media.optsWithGlobals());
1026
+ const item = await ctx.client.getMedia(ctx.site, id);
1027
+ outputJson(item);
1028
+ });
1029
+ media.command("update").description("Update media metadata (alt, caption, tags, folder)").argument("<id>", "media ID").option("--data <json>", "JSON payload: { alt?, caption?, userTags?, folderId? }").action(async (id, opts) => {
1030
+ const ctx = await resolveContext(media.optsWithGlobals());
1031
+ const payload = await readJsonPayload(opts, "media update");
1032
+ const result = await ctx.client.updateMedia(ctx.site, id, payload);
1033
+ outputJson(result);
1034
+ });
1035
+ media.command("delete").description("Delete a media item").argument("<id>", "media ID").option("--force", "skip confirmation prompt").action(async (id, opts) => {
1036
+ if (!opts.force) {
1037
+ if (!process.stdin.isTTY) {
1038
+ throw new Error("Use --force to delete non-interactively.");
1039
+ }
1040
+ const confirmed = await p6.confirm({
1041
+ message: `Delete media ${id}? This cannot be undone.`
1042
+ });
1043
+ if (p6.isCancel(confirmed) || !confirmed) {
1044
+ process.exit(0);
1045
+ }
1046
+ }
1047
+ const ctx = await resolveContext(media.optsWithGlobals());
1048
+ await ctx.client.deleteMedia(ctx.site, id);
1049
+ outputJson({ deleted: id });
1050
+ });
1051
+ media.command("upload").description("Upload a local file").argument("<file>", "path to local file").option("--alt <text>", "alt text for the media item").option("--caption <text>", "caption for the media item").option("--folder <id>", "folder ID to place the file in").option("--tags <tags>", "comma-separated tags").action(async (file, opts) => {
1052
+ const ctx = await resolveContext(media.optsWithGlobals());
1053
+ const { constants } = await import("fs");
1054
+ const { access: accessAsync } = await import("fs/promises");
1055
+ await accessAsync(file, constants.R_OK).catch(() => {
1056
+ throw new Error(`File not found or not readable: ${file}`);
1057
+ });
1058
+ const { uploadFile } = await import("@headroom-cms/admin-api/node");
1059
+ debugLog(`Uploading ${file}...`);
1060
+ let result = await uploadFile(ctx.client, ctx.site, file, opts.alt);
1061
+ const updates = {};
1062
+ if (opts.caption) updates.caption = opts.caption;
1063
+ if (opts.folder) updates.folderId = opts.folder;
1064
+ if (opts.tags) updates.userTags = opts.tags.split(",").map((t) => t.trim());
1065
+ if (Object.keys(updates).length > 0) {
1066
+ result = await ctx.client.updateMedia(ctx.site, result.mediaId, updates);
1067
+ }
1068
+ outputJson(result);
1069
+ });
1070
+ media.command("upload-url").description("Upload a file from a remote URL").argument("<url>", "URL to download and upload").option("--filename <name>", "override filename (default: derived from URL)").option("--alt <text>", "alt text for the media item").option("--caption <text>", "caption for the media item").option("--folder <id>", "folder ID to place the file in").option("--tags <tags>", "comma-separated tags").action(async (url, opts) => {
1071
+ const ctx = await resolveContext(media.optsWithGlobals());
1072
+ const filename = opts.filename || new URL(url).pathname.split("/").pop() || "download.bin";
1073
+ debugLog(`Uploading from ${url} as ${filename}...`);
1074
+ let result = await ctx.client.uploadFromUrl(ctx.site, url, filename, opts.alt);
1075
+ const updates = {};
1076
+ if (opts.caption) updates.caption = opts.caption;
1077
+ if (opts.folder) updates.folderId = opts.folder;
1078
+ if (opts.tags) updates.userTags = opts.tags.split(",").map((t) => t.trim());
1079
+ if (Object.keys(updates).length > 0) {
1080
+ result = await ctx.client.updateMedia(ctx.site, result.mediaId, updates);
1081
+ }
1082
+ outputJson(result);
1083
+ });
1084
+ media.command("usage").description("Show content that references this media item").argument("<id>", "media ID").action(async (id) => {
1085
+ const globalOpts = media.optsWithGlobals();
1086
+ const ctx = await resolveContext(globalOpts);
1087
+ const result = await ctx.client.getMediaUsage(ctx.site, id);
1088
+ if (globalOpts.table) {
1089
+ outputList(
1090
+ result.references,
1091
+ USAGE_COLUMNS
1092
+ );
1093
+ } else {
1094
+ outputJson(result);
1095
+ }
1096
+ });
1097
+ media.command("bulk").description("Perform bulk operations on media items").option("--data <json>", "JSON payload: { mediaIds, action, folderId?, tags? }").action(async (opts) => {
1098
+ const ctx = await resolveContext(media.optsWithGlobals());
1099
+ const payload = await readJsonPayload(opts, "media bulk");
1100
+ if (!payload.mediaIds?.length) {
1101
+ throw new Error("Payload must include 'mediaIds' array.");
1102
+ }
1103
+ if (!payload.action) {
1104
+ throw new Error("Payload must include 'action' (delete, move, addTags, removeTags).");
1105
+ }
1106
+ const result = await ctx.client.bulkMediaOperation(ctx.site, payload);
1107
+ outputJson(result);
1108
+ });
1109
+ const folders = media.command("folders").description("Manage media folders");
1110
+ folders.command("list").description("List media folders").action(async () => {
1111
+ const globalOpts = media.optsWithGlobals();
1112
+ const ctx = await resolveContext(globalOpts);
1113
+ const result = await ctx.client.listMediaFolders(ctx.site);
1114
+ outputList(result, FOLDER_COLUMNS);
1115
+ });
1116
+ folders.command("create").description("Create a media folder").option("--data <json>", 'JSON payload: { name: "folder-name" }').action(async (opts) => {
1117
+ const ctx = await resolveContext(media.optsWithGlobals());
1118
+ const payload = await readJsonPayload(opts, "media folders create");
1119
+ if (!payload.name) {
1120
+ throw new Error("Payload must include 'name' field.");
1121
+ }
1122
+ const result = await ctx.client.createMediaFolder(ctx.site, payload.name);
1123
+ outputJson(result);
1124
+ });
1125
+ folders.command("update").description("Rename a media folder").argument("<id>", "folder ID").option("--data <json>", 'JSON payload: { name: "new-name" }').action(async (id, opts) => {
1126
+ const ctx = await resolveContext(media.optsWithGlobals());
1127
+ const payload = await readJsonPayload(opts, "media folders update");
1128
+ if (!payload.name) {
1129
+ throw new Error("Payload must include 'name' field.");
1130
+ }
1131
+ const result = await ctx.client.updateMediaFolder(ctx.site, id, payload.name);
1132
+ outputJson(result);
1133
+ });
1134
+ folders.command("delete").description("Delete a media folder").argument("<id>", "folder ID").option("--force", "skip confirmation prompt").action(async (id, opts) => {
1135
+ if (!opts.force) {
1136
+ if (!process.stdin.isTTY) {
1137
+ throw new Error("Use --force to delete non-interactively.");
1138
+ }
1139
+ const confirmed = await p6.confirm({
1140
+ message: `Delete folder ${id}? Files in the folder will be moved to the root.`
1141
+ });
1142
+ if (p6.isCancel(confirmed) || !confirmed) {
1143
+ process.exit(0);
1144
+ }
1145
+ }
1146
+ const ctx = await resolveContext(media.optsWithGlobals());
1147
+ await ctx.client.deleteMediaFolder(ctx.site, id);
1148
+ outputJson({ deleted: id });
1149
+ });
1150
+ }
1151
+
1152
+ // src/commands/site.ts
1153
+ function registerSiteCommand(program2) {
1154
+ program2.command("site").argument("[host]", "site host to set as active").description("Get or set the active site").addHelpText(
1155
+ "after",
1156
+ `
1157
+ Examples:
1158
+ # Show current active site
1159
+ $ headroom site
1160
+
1161
+ # Set active site
1162
+ $ headroom site mysite.com
1163
+
1164
+ See also: login, whoami`
1165
+ ).action(async (host) => {
1166
+ if (host) {
1167
+ const config = await updateConfig({ activeSite: host });
1168
+ outputJson({ activeSite: config.activeSite });
1169
+ } else {
1170
+ const config = await loadConfig();
1171
+ if (!config?.activeSite) {
1172
+ throw new Error("No active site. Run: headroom site <host>");
1173
+ }
1174
+ outputJson({ activeSite: config.activeSite });
1175
+ }
1176
+ });
1177
+ }
1178
+
1179
+ // src/commands/sites.ts
1180
+ import * as p7 from "@clack/prompts";
1181
+ var SITES_COLUMNS = [
1182
+ { key: "host", header: "HOST", width: 30 },
1183
+ { key: "name", header: "NAME", width: 30 },
1184
+ { key: "status", header: "STATUS", width: 12 },
1185
+ { key: "createdAt", header: "CREATED", width: 12, format: formatTimestamp }
1186
+ ];
1187
+ function registerSitesCommand(program2) {
1188
+ const sites = program2.command("sites").description("Manage sites");
1189
+ sites.command("list").description("List all sites").option("--archived", "include archived sites").action(async (opts) => {
1190
+ const ctx = await resolveAuthContext(
1191
+ sites.optsWithGlobals()
1192
+ );
1193
+ const result = await ctx.client.listSites(!!opts.archived);
1194
+ outputList(result, SITES_COLUMNS);
1195
+ });
1196
+ sites.command("get").description("Get site details").argument("<host>", "site host").action(async (host) => {
1197
+ const ctx = await resolveAuthContext(
1198
+ sites.optsWithGlobals()
1199
+ );
1200
+ const site = await ctx.client.getSite(host);
1201
+ outputJson(site);
1202
+ });
1203
+ sites.command("create").description("Create a new site").option("--data <json>", "JSON payload: { host, name }").action(async (opts) => {
1204
+ const ctx = await resolveAuthContext(
1205
+ sites.optsWithGlobals()
1206
+ );
1207
+ const payload = await readJsonPayload(opts, "sites create");
1208
+ if (!payload.host || !payload.name) {
1209
+ throw new Error("JSON must include 'host' and 'name' fields.");
1210
+ }
1211
+ const site = await ctx.client.createSite(payload.host, payload.name);
1212
+ outputJson(site);
1213
+ });
1214
+ sites.command("update").description("Update a site").argument("<host>", "site host to update").option("--data <json>", "JSON payload: { name?, status? }").action(async (host, opts) => {
1215
+ const ctx = await resolveAuthContext(
1216
+ sites.optsWithGlobals()
1217
+ );
1218
+ const payload = await readJsonPayload(opts, "sites update");
1219
+ const site = await ctx.client.updateSite(host, payload);
1220
+ outputJson(site);
1221
+ });
1222
+ sites.command("delete").description("Delete a site").argument("<host>", "site host to delete").option("--force", "skip confirmation prompt").action(async (host, opts) => {
1223
+ if (!opts.force) {
1224
+ if (!process.stdin.isTTY) {
1225
+ throw new Error("Use --force to delete non-interactively.");
1226
+ }
1227
+ const confirmed = await p7.confirm({
1228
+ message: `Delete site ${host}? This cannot be undone.`
1229
+ });
1230
+ if (p7.isCancel(confirmed) || !confirmed) {
1231
+ process.exit(0);
1232
+ }
1233
+ }
1234
+ const ctx = await resolveAuthContext(
1235
+ sites.optsWithGlobals()
1236
+ );
1237
+ await ctx.client.deleteSite(host);
1238
+ outputJson({ deleted: host });
1239
+ });
1240
+ }
1241
+
1242
+ // src/commands/site-users.ts
1243
+ import * as p8 from "@clack/prompts";
1244
+ var SITE_USER_COLUMNS = [
1245
+ { key: "userId", header: "ID", width: 26 },
1246
+ { key: "email", header: "EMAIL", width: 30 },
1247
+ { key: "status", header: "STATUS", width: 10 },
1248
+ { key: "name", header: "NAME", width: 20 },
1249
+ { key: "createdAt", header: "CREATED", width: 12, format: formatTimestamp }
1250
+ ];
1251
+ function registerSiteUsersCommand(program2) {
1252
+ const siteUsers = program2.command("site-users").description("Manage site users");
1253
+ siteUsers.command("list").description("List site users").option("--tag <tag>", "filter by tag").option("--limit <n>", "max results", parseInt).option("--cursor <token>", "pagination cursor").action(async (opts) => {
1254
+ const globalOpts = siteUsers.optsWithGlobals();
1255
+ const ctx = await resolveContext(globalOpts);
1256
+ const result = await ctx.client.listSiteUsers(ctx.site, {
1257
+ tag: opts.tag,
1258
+ limit: opts.limit,
1259
+ cursor: opts.cursor
1260
+ });
1261
+ if (globalOpts.table) {
1262
+ outputList(
1263
+ result.items,
1264
+ SITE_USER_COLUMNS
1265
+ );
1266
+ if (result.hasMore) {
1267
+ process.stderr.write(
1268
+ "(more results available \u2014 use --cursor or JSON mode for pagination)\n"
1269
+ );
1270
+ }
1271
+ } else {
1272
+ outputJson(result);
1273
+ }
1274
+ });
1275
+ siteUsers.command("get").description("Get site user details").argument("<id>", "site user ID").action(async (id) => {
1276
+ const ctx = await resolveContext(siteUsers.optsWithGlobals());
1277
+ const user = await ctx.client.getSiteUser(ctx.site, id);
1278
+ outputJson(user);
1279
+ });
1280
+ siteUsers.command("create").description("Create a site user").option("--data <json>", "JSON payload: { email, name?, tags?, fields? }").action(async (opts) => {
1281
+ const ctx = await resolveContext(siteUsers.optsWithGlobals());
1282
+ const payload = await readJsonPayload(opts, "site-users create");
1283
+ if (!payload.email) {
1284
+ throw new Error("Payload must include 'email' field.");
1285
+ }
1286
+ const result = await ctx.client.createSiteUser(ctx.site, payload);
1287
+ outputJson(result);
1288
+ });
1289
+ siteUsers.command("update").description("Update a site user").argument("<id>", "site user ID").option("--data <json>", "JSON payload: { name?, status?, tags?, fields? }").action(async (id, opts) => {
1290
+ const ctx = await resolveContext(siteUsers.optsWithGlobals());
1291
+ const payload = await readJsonPayload(opts, "site-users update");
1292
+ const result = await ctx.client.updateSiteUser(ctx.site, id, payload);
1293
+ outputJson(result);
1294
+ });
1295
+ siteUsers.command("delete").description("Delete a site user").argument("<id>", "site user ID").option("--force", "skip confirmation prompt").action(async (id, opts) => {
1296
+ if (!opts.force) {
1297
+ if (!process.stdin.isTTY) {
1298
+ throw new Error("Use --force to delete non-interactively.");
1299
+ }
1300
+ const confirmed = await p8.confirm({
1301
+ message: `Delete site user ${id}? This cannot be undone.`
1302
+ });
1303
+ if (p8.isCancel(confirmed) || !confirmed) {
1304
+ process.exit(0);
1305
+ }
1306
+ }
1307
+ const ctx = await resolveContext(siteUsers.optsWithGlobals());
1308
+ await ctx.client.deleteSiteUser(ctx.site, id);
1309
+ outputJson({ deleted: id });
1310
+ });
1311
+ siteUsers.command("update-email").description("Change a site user's email address").argument("<id>", "site user ID").option("--data <json>", 'JSON payload: { email: "new@example.com" }').action(async (id, opts) => {
1312
+ const ctx = await resolveContext(siteUsers.optsWithGlobals());
1313
+ const payload = await readJsonPayload(opts, "site-users update-email");
1314
+ if (!payload.email) {
1315
+ throw new Error("Payload must include 'email' field.");
1316
+ }
1317
+ const result = await ctx.client.changeSiteUserEmail(ctx.site, id, payload);
1318
+ outputJson(result);
1319
+ });
1320
+ }
1321
+
1322
+ // src/commands/tags.ts
1323
+ import * as p9 from "@clack/prompts";
1324
+ var TAG_COLUMNS = [
1325
+ { key: "tag", header: "TAG", width: 30 },
1326
+ { key: "count", header: "COUNT", width: 10 }
1327
+ ];
1328
+ function registerTagsCommand(program2) {
1329
+ const tags = program2.command("tags").description("Manage content tags");
1330
+ tags.command("list").description("List all tags with usage counts").action(async () => {
1331
+ const ctx = await resolveContext(tags.optsWithGlobals());
1332
+ const result = await ctx.client.listTags(ctx.site);
1333
+ outputList(result.tags, TAG_COLUMNS);
1334
+ });
1335
+ tags.command("create").description("Create a tag").argument("<tag>", "tag name").action(async (tag) => {
1336
+ const ctx = await resolveContext(tags.optsWithGlobals());
1337
+ await ctx.client.createTag(ctx.site, tag);
1338
+ outputJson({ created: tag });
1339
+ });
1340
+ tags.command("delete").description("Delete a tag").argument("<tag>", "tag name").option("--force", "skip confirmation prompt").action(async (tag, opts) => {
1341
+ if (!opts.force) {
1342
+ if (!process.stdin.isTTY) {
1343
+ throw new Error("Use --force to delete non-interactively.");
1344
+ }
1345
+ const confirmed = await p9.confirm({
1346
+ message: `Delete tag "${tag}"? This will remove it from all content.`
1347
+ });
1348
+ if (p9.isCancel(confirmed) || !confirmed) {
1349
+ process.exit(0);
1350
+ }
1351
+ }
1352
+ const ctx = await resolveContext(tags.optsWithGlobals());
1353
+ await ctx.client.deleteTag(ctx.site, tag);
1354
+ outputJson({ deleted: tag });
1355
+ });
1356
+ }
1357
+
1358
+ // src/commands/users.ts
1359
+ import * as p10 from "@clack/prompts";
1360
+ var USER_COLUMNS = [
1361
+ { key: "sub", header: "SUB", width: 38 },
1362
+ { key: "email", header: "EMAIL", width: 30 },
1363
+ { key: "status", header: "STATUS", width: 12 },
1364
+ { key: "mfaEnabled", header: "MFA", width: 5, format: (v) => v ? "yes" : "no" },
1365
+ { key: "createdAt", header: "CREATED", width: 12, format: formatTimestamp }
1366
+ ];
1367
+ function registerUsersCommand(program2) {
1368
+ const users = program2.command("users").description("Manage admin users (global, not site-scoped)");
1369
+ users.command("list").description("List all admin users").action(async () => {
1370
+ const ctx = await resolveAuthContext(users.optsWithGlobals());
1371
+ const result = await ctx.client.listUsers();
1372
+ outputList(result.items, USER_COLUMNS);
1373
+ });
1374
+ users.command("delete").description("Delete an admin user").argument("<sub>", "user sub (Cognito ID)").option("--force", "skip confirmation prompt").action(async (sub, opts) => {
1375
+ if (!opts.force) {
1376
+ if (!process.stdin.isTTY) {
1377
+ throw new Error("Use --force to delete non-interactively.");
1378
+ }
1379
+ const confirmed = await p10.confirm({
1380
+ message: `Delete user ${sub}? This cannot be undone.`
1381
+ });
1382
+ if (p10.isCancel(confirmed) || !confirmed) {
1383
+ process.exit(0);
1384
+ }
1385
+ }
1386
+ const ctx = await resolveAuthContext(users.optsWithGlobals());
1387
+ await ctx.client.deleteUser(sub);
1388
+ outputJson({ deleted: sub });
1389
+ });
1390
+ users.command("disable-mfa").description("Disable MFA for an admin user").argument("<sub>", "user sub (Cognito ID)").action(async (sub) => {
1391
+ const ctx = await resolveAuthContext(users.optsWithGlobals());
1392
+ await ctx.client.disableUserMfa(sub);
1393
+ outputJson({ mfaDisabled: sub });
1394
+ });
1395
+ users.command("resolve").description("Resolve user subs to email addresses").argument("<subs...>", "one or more user subs (Cognito IDs)").action(async (subs) => {
1396
+ const ctx = await resolveAuthContext(users.optsWithGlobals());
1397
+ const result = await ctx.client.resolveAdmins(subs);
1398
+ outputJson(result);
1399
+ });
1400
+ }
1401
+
1402
+ // src/commands/webhooks.ts
1403
+ import * as p11 from "@clack/prompts";
1404
+ var WEBHOOK_COLUMNS = [
1405
+ { key: "webhookId", header: "ID", width: 26 },
1406
+ { key: "url", header: "URL", width: 40 },
1407
+ { key: "events", header: "EVENTS", width: 30, format: (v) => Array.isArray(v) ? v.join(", ") : String(v) },
1408
+ { key: "createdAt", header: "CREATED", width: 12, format: formatTimestamp }
1409
+ ];
1410
+ var DELIVERY_COLUMNS = [
1411
+ { key: "deliveryId", header: "ID", width: 26 },
1412
+ { key: "event", header: "EVENT", width: 20 },
1413
+ { key: "statusCode", header: "STATUS", width: 8 },
1414
+ { key: "success", header: "OK", width: 5, format: (v) => v ? "yes" : "no" },
1415
+ { key: "deliveredAt", header: "DELIVERED", width: 12, format: formatTimestamp }
1416
+ ];
1417
+ function registerWebhooksCommand(program2) {
1418
+ const webhooks = program2.command("webhooks").description("Manage webhooks and deliveries");
1419
+ webhooks.command("list").description("List webhooks").action(async () => {
1420
+ const ctx = await resolveContext(webhooks.optsWithGlobals());
1421
+ const result = await ctx.client.listWebhooks(ctx.site);
1422
+ outputList(result, WEBHOOK_COLUMNS);
1423
+ });
1424
+ webhooks.command("get").description("Get webhook details").argument("<id>", "webhook ID").action(async (id) => {
1425
+ const ctx = await resolveContext(webhooks.optsWithGlobals());
1426
+ const webhook = await ctx.client.getWebhook(ctx.site, id);
1427
+ outputJson(webhook);
1428
+ });
1429
+ webhooks.command("create").description("Create a webhook").option("--data <json>", 'JSON payload: { url, events: ["content.published", ...] }').action(async (opts) => {
1430
+ const ctx = await resolveContext(webhooks.optsWithGlobals());
1431
+ const payload = await readJsonPayload(opts, "webhooks create");
1432
+ if (!payload.url || !payload.events?.length) {
1433
+ throw new Error("Payload must include 'url' and 'events' array.");
1434
+ }
1435
+ const result = await ctx.client.createWebhook(ctx.site, payload);
1436
+ outputJson(result);
1437
+ });
1438
+ webhooks.command("update").description("Update a webhook").argument("<id>", "webhook ID").option("--data <json>", "JSON payload: { url?, events? }").action(async (id, opts) => {
1439
+ const ctx = await resolveContext(webhooks.optsWithGlobals());
1440
+ const payload = await readJsonPayload(opts, "webhooks update");
1441
+ const result = await ctx.client.updateWebhook(ctx.site, id, payload);
1442
+ outputJson(result);
1443
+ });
1444
+ webhooks.command("delete").description("Delete a webhook").argument("<id>", "webhook ID").option("--force", "skip confirmation prompt").action(async (id, opts) => {
1445
+ if (!opts.force) {
1446
+ if (!process.stdin.isTTY) {
1447
+ throw new Error("Use --force to delete non-interactively.");
1448
+ }
1449
+ const confirmed = await p11.confirm({
1450
+ message: `Delete webhook ${id}? This cannot be undone.`
1451
+ });
1452
+ if (p11.isCancel(confirmed) || !confirmed) {
1453
+ process.exit(0);
1454
+ }
1455
+ }
1456
+ const ctx = await resolveContext(webhooks.optsWithGlobals());
1457
+ await ctx.client.deleteWebhook(ctx.site, id);
1458
+ outputJson({ deleted: id });
1459
+ });
1460
+ webhooks.command("test").description("Send a test delivery").argument("<id>", "webhook ID").action(async (id) => {
1461
+ const ctx = await resolveContext(webhooks.optsWithGlobals());
1462
+ await ctx.client.testWebhook(ctx.site, id);
1463
+ outputJson({ tested: id });
1464
+ });
1465
+ webhooks.command("rotate-secret").description("Rotate the signing secret (outputs new secret)").argument("<id>", "webhook ID").action(async (id) => {
1466
+ const ctx = await resolveContext(webhooks.optsWithGlobals());
1467
+ const result = await ctx.client.rotateWebhookSecret(ctx.site, id);
1468
+ outputJson(result);
1469
+ });
1470
+ const deliveries = webhooks.command("deliveries").description("Manage webhook deliveries");
1471
+ deliveries.command("list").description("List deliveries for a webhook").argument("<webhookId>", "webhook ID").action(async (webhookId) => {
1472
+ const globalOpts = webhooks.optsWithGlobals();
1473
+ const ctx = await resolveContext(globalOpts);
1474
+ const result = await ctx.client.listWebhookDeliveries(ctx.site, webhookId);
1475
+ if (globalOpts.table) {
1476
+ outputList(
1477
+ result.deliveries,
1478
+ DELIVERY_COLUMNS
1479
+ );
1480
+ if (result.hasMore) {
1481
+ process.stderr.write(
1482
+ "(more results available \u2014 use JSON mode for pagination)\n"
1483
+ );
1484
+ }
1485
+ } else {
1486
+ outputJson(result);
1487
+ }
1488
+ });
1489
+ deliveries.command("get").description("Get delivery details").argument("<webhookId>", "webhook ID").argument("<deliveryId>", "delivery ID").action(async (webhookId, deliveryId) => {
1490
+ const ctx = await resolveContext(webhooks.optsWithGlobals());
1491
+ const delivery = await ctx.client.getWebhookDelivery(ctx.site, webhookId, deliveryId);
1492
+ outputJson(delivery);
1493
+ });
1494
+ deliveries.command("retry").description("Retry a failed delivery").argument("<webhookId>", "webhook ID").argument("<deliveryId>", "delivery ID").action(async (webhookId, deliveryId) => {
1495
+ const ctx = await resolveContext(webhooks.optsWithGlobals());
1496
+ await ctx.client.retryDelivery(ctx.site, webhookId, deliveryId);
1497
+ outputJson({ retried: deliveryId });
1498
+ });
1499
+ }
1500
+
1501
+ // src/commands/whoami.ts
1502
+ function registerWhoamiCommand(program2) {
1503
+ program2.command("whoami").description("Show current user, site, and API URL").addHelpText(
1504
+ "after",
1505
+ `
1506
+ Examples:
1507
+ $ headroom whoami
1508
+ {
1509
+ "email": "admin@example.com",
1510
+ "apiUrl": "https://xxx.lambda-url.us-east-1.on.aws",
1511
+ "site": "mysite.com",
1512
+ "authenticated": true
1513
+ }
1514
+
1515
+ See also: login, site`
1516
+ ).action(async () => {
1517
+ const config = await loadConfig();
1518
+ if (!config) {
1519
+ throw new Error("Not logged in. Run: headroom login <api-url>");
1520
+ }
1521
+ const token = await loadToken(config.apiUrl);
1522
+ outputJson({
1523
+ email: config.email,
1524
+ apiUrl: config.apiUrl,
1525
+ site: config.activeSite ?? null,
1526
+ authenticated: token !== null
1527
+ });
1528
+ });
1529
+ }
1530
+
1531
+ // src/cli.ts
1532
+ function createProgram() {
1533
+ const program2 = new Command();
1534
+ program2.name("headroom").description("Headroom CMS command-line interface").version("0.1.5").option("--site <host>", "override active site for this command").option("--api-url <url>", "override API URL for this command").option("--debug", "print HTTP request/response details to stderr").option("-q, --quiet", "suppress output, exit code only").option("--table", "output as formatted table instead of JSON").option("--no-color", "disable colored output");
1535
+ program2.hook("preAction", (_thisCommand, actionCommand) => {
1536
+ const opts = actionCommand.optsWithGlobals();
1537
+ setQuiet(!!opts.quiet);
1538
+ setDebug(!!opts.debug);
1539
+ setTable(!!opts.table);
1540
+ if (opts.color === false) {
1541
+ process.env.NO_COLOR = "1";
1542
+ }
1543
+ });
1544
+ registerApiKeysCommand(program2);
1545
+ registerAuditCommand(program2);
1546
+ registerBlockTypesCommand(program2);
1547
+ registerCollectionsCommand(program2);
1548
+ registerContentCommand(program2);
1549
+ registerLoginCommand(program2);
1550
+ registerMediaCommand(program2);
1551
+ registerSiteCommand(program2);
1552
+ registerSitesCommand(program2);
1553
+ registerSiteUsersCommand(program2);
1554
+ registerTagsCommand(program2);
1555
+ registerUsersCommand(program2);
1556
+ registerWebhooksCommand(program2);
1557
+ registerWhoamiCommand(program2);
1558
+ program2.addHelpText("after", getGettingStartedText());
1559
+ return program2;
1560
+ }
1561
+ function getGettingStartedText() {
1562
+ return `
1563
+ Getting Started:
1564
+ 1. Authenticate with your Headroom API:
1565
+ $ headroom login https://your-api-url.example.com mysite.com
1566
+
1567
+ 2. Verify your session:
1568
+ $ headroom whoami
1569
+
1570
+ 3. Discover your collection schemas:
1571
+ $ headroom collections list --table
1572
+ $ headroom collections describe blog_posts
1573
+ $ headroom collections example blog_posts
1574
+
1575
+ 4. Create and publish content:
1576
+ $ headroom collections example blog_posts | jq '.title = "Hello"' | headroom content create --collection blog_posts
1577
+ $ headroom content list --collection blog_posts --table
1578
+ $ headroom content publish <id>
1579
+
1580
+ 5. Manage media:
1581
+ $ headroom media upload ./photo.jpg --alt "A photo"
1582
+ $ headroom media list --table
1583
+
1584
+ Tips:
1585
+ All commands output JSON by default (pipe to jq for filtering).
1586
+ Use --table for human-readable output.
1587
+ Use --help on any command for examples.
1588
+ `;
1589
+ }
1590
+
1591
+ // src/index.ts
1592
+ var program = createProgram();
1593
+ program.parseAsync(process.argv).catch((err) => {
1594
+ outputError(err);
1595
+ process.exit(exitCodeForError(err));
1596
+ });