@plenary/cli 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { readFileSync as readFileSync5 } from "fs";
4
+ import { readFileSync as readFileSync6 } from "fs";
5
5
  import { Command, CommanderError as CommanderError2 } from "commander";
6
6
 
7
7
  // src/output.ts
@@ -158,25 +158,99 @@ function requireBaseUrl(ctx) {
158
158
  }
159
159
  return ctx.baseUrl;
160
160
  }
161
- function requireApiKey(ctx) {
162
- if (!ctx.apiKey) {
163
- throw usageError("no credential: pass --api-key or set PLENARY_API_KEY");
161
+
162
+ // src/api/http.ts
163
+ import { errorFromResponse } from "@plenary/client";
164
+
165
+ // src/session.ts
166
+ import { chmodSync, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "fs";
167
+ import { homedir } from "os";
168
+ import path2 from "path";
169
+ function credentialsPath(io) {
170
+ const base = io.env.XDG_CONFIG_HOME || path2.join(io.env.HOME || homedir(), ".config");
171
+ return path2.join(base, "plenary", "credentials.json");
172
+ }
173
+ function readStore(io) {
174
+ const file = credentialsPath(io);
175
+ if (!existsSync2(file)) return {};
176
+ try {
177
+ const parsed = JSON.parse(readFileSync2(file, "utf8"));
178
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
179
+ } catch {
180
+ return {};
164
181
  }
165
- return ctx.apiKey;
182
+ }
183
+ function writeStore(io, store) {
184
+ const file = credentialsPath(io);
185
+ mkdirSync(path2.dirname(file), { recursive: true, mode: 448 });
186
+ const tmp = `${file}.${process.pid}.tmp`;
187
+ writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", { mode: 384 });
188
+ chmodSync(tmp, 384);
189
+ renameSync(tmp, file);
190
+ }
191
+ function loadSession(io, baseUrl) {
192
+ return readStore(io)[baseUrl];
193
+ }
194
+ function saveSession(io, baseUrl, session) {
195
+ const store = readStore(io);
196
+ store[baseUrl] = { ...session, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
197
+ writeStore(io, store);
198
+ }
199
+ function clearSession(io, baseUrl) {
200
+ const store = readStore(io);
201
+ if (!(baseUrl in store)) return false;
202
+ delete store[baseUrl];
203
+ writeStore(io, store);
204
+ return true;
205
+ }
206
+ async function sessionAccessToken(ctx) {
207
+ if (ctx.sessionToken) return ctx.sessionToken;
208
+ const baseUrl = ctx.baseUrl;
209
+ if (!baseUrl) return void 0;
210
+ const stored = loadSession(ctx.io, baseUrl);
211
+ if (!stored) return void 0;
212
+ const response = await ctx.io.fetch(`${baseUrl}/api/v1/auth/refresh`, {
213
+ method: "POST",
214
+ headers: { "Content-Type": "application/json" },
215
+ body: JSON.stringify({ refresh_token: stored.refresh_token })
216
+ });
217
+ const body = await response.json().catch(() => null);
218
+ if (!response.ok || !body || !("access_token" in body)) {
219
+ const code = body?.error?.code ?? `http_${response.status}`;
220
+ if (response.status === 401) {
221
+ clearSession(ctx.io, baseUrl);
222
+ throw usageError(`session for ${baseUrl} is no longer valid (${code}): run \`plenary login\``);
223
+ }
224
+ throw usageError(`could not refresh the session for ${baseUrl} (${code})`);
225
+ }
226
+ saveSession(ctx.io, baseUrl, { refresh_token: body.refresh_token, identifier: stored.identifier });
227
+ ctx.sessionToken = body.access_token;
228
+ return body.access_token;
166
229
  }
167
230
 
168
231
  // src/api/http.ts
169
- import { errorFromResponse } from "@plenary/client";
232
+ async function credentialHeaders(ctx, mode) {
233
+ if (mode === "none") return {};
234
+ if (mode === "session") {
235
+ const token2 = await sessionAccessToken(ctx);
236
+ if (!token2) throw usageError("this command needs a user session: run `plenary login`");
237
+ return { Authorization: `Bearer ${token2}` };
238
+ }
239
+ if (ctx.apiKey) return { "X-API-KEY": ctx.apiKey };
240
+ const token = await sessionAccessToken(ctx);
241
+ if (token) return { Authorization: `Bearer ${token}` };
242
+ if (mode === "required") {
243
+ throw usageError("no credential: pass --api-key, set PLENARY_API_KEY, or run `plenary login`");
244
+ }
245
+ return {};
246
+ }
170
247
  function apiPath(p) {
171
248
  return p.startsWith("/") ? p : `/api/v1/${p}`;
172
249
  }
173
- async function request(ctx, method, path6, opts = {}) {
250
+ async function request(ctx, method, path7, opts = {}) {
174
251
  const base = requireBaseUrl(ctx);
175
- const auth = opts.auth ?? "required";
176
- const headers = { ...opts.headers };
177
- if (auth === "required") headers["X-API-KEY"] = requireApiKey(ctx);
178
- else if (auth === "optional" && ctx.apiKey) headers["X-API-KEY"] = ctx.apiKey;
179
- const url = new URL(base + apiPath(path6));
252
+ const headers = { ...opts.headers, ...await credentialHeaders(ctx, opts.auth ?? "required") };
253
+ const url = new URL(base + apiPath(path7));
180
254
  for (const [k, v] of Object.entries(opts.query ?? {})) {
181
255
  if (v !== void 0) url.searchParams.set(k, v);
182
256
  }
@@ -214,7 +288,7 @@ async function parseBody(response) {
214
288
  }
215
289
 
216
290
  // src/input.ts
217
- import { readFileSync as readFileSync2 } from "fs";
291
+ import { readFileSync as readFileSync3 } from "fs";
218
292
  import { createInterface } from "readline/promises";
219
293
  function parsePairs(pairs, sep, what) {
220
294
  const out = {};
@@ -229,7 +303,7 @@ async function readText(data, stdin) {
229
303
  if (data === "-") return readStream(stdin);
230
304
  if (data.startsWith("@")) {
231
305
  try {
232
- return readFileSync2(data.slice(1), "utf8");
306
+ return readFileSync3(data.slice(1), "utf8");
233
307
  } catch (err) {
234
308
  throw usageError(`cannot read ${data.slice(1)}: ${err.message}`);
235
309
  }
@@ -267,15 +341,194 @@ async function confirm(io, question, yes) {
267
341
  rl.close();
268
342
  }
269
343
  }
344
+ async function readLine(io, prompt) {
345
+ const rl = createInterface({ input: io.stdin, output: io.isTTY ? io.stderr : void 0, terminal: io.isTTY });
346
+ try {
347
+ if (!io.isTTY) {
348
+ const it = rl[Symbol.asyncIterator]();
349
+ const { value } = await it.next();
350
+ return (value ?? "").trim();
351
+ }
352
+ return (await rl.question(prompt)).trim();
353
+ } finally {
354
+ rl.close();
355
+ }
356
+ }
357
+ async function readSecret(io, prompt) {
358
+ const stdin = io.stdin;
359
+ if (!io.isTTY || typeof stdin.setRawMode !== "function") return readLine(io, prompt);
360
+ io.stderr.write(prompt);
361
+ stdin.setRawMode(true);
362
+ stdin.resume();
363
+ try {
364
+ return await new Promise((resolve, reject) => {
365
+ let value = "";
366
+ const onData = (chunk) => {
367
+ for (const ch of String(chunk)) {
368
+ const code = ch.charCodeAt(0);
369
+ if (code === 3) {
370
+ cleanup();
371
+ reject(usageError("aborted"));
372
+ return;
373
+ }
374
+ if (code === 13 || code === 10) {
375
+ cleanup();
376
+ io.stderr.write("\n");
377
+ resolve(value);
378
+ return;
379
+ }
380
+ if (code === 127 || code === 8) value = value.slice(0, -1);
381
+ else value += ch;
382
+ }
383
+ };
384
+ const cleanup = () => {
385
+ stdin.off("data", onData);
386
+ stdin.setRawMode?.(false);
387
+ stdin.pause();
388
+ };
389
+ stdin.on("data", onData);
390
+ });
391
+ } finally {
392
+ stdin.setRawMode?.(false);
393
+ }
394
+ }
395
+
396
+ // src/commands/admin.ts
397
+ function collect(value, previous) {
398
+ return [...previous, value];
399
+ }
400
+ function parseGrant(spec) {
401
+ const [key, ...filters] = spec.split(",").map((s) => s.trim());
402
+ if (!key) throw usageError(`invalid --grant "${spec}"`);
403
+ const grant = { key };
404
+ for (const f of filters) {
405
+ const [name, value] = f.split("=");
406
+ if (name === "site" && value) grant.site_key = value;
407
+ else if (name === "model" && value) grant.content_model_id = value;
408
+ else throw usageError(`invalid --grant "${spec}": expected key[,site=<key>][,model=<uuid>]`);
409
+ }
410
+ return grant;
411
+ }
412
+ function registerAdmin(program, io) {
413
+ program.command("sites").description("Sites").command("list").description("List sites").action(async (_flags, cmd) => {
414
+ const ctx = resolveContext(cmd.optsWithGlobals(), io);
415
+ const res = await request(ctx, "GET", "sites");
416
+ if (ctx.json) {
417
+ io.stdout.write(toJson(res.data) + "\n");
418
+ return;
419
+ }
420
+ const rows = res.data.map((s) => [
421
+ s.key,
422
+ s.name,
423
+ s.default_locale,
424
+ s.locales.join(","),
425
+ s.timezone,
426
+ s.is_active ? "" : "inactive"
427
+ ]);
428
+ io.stdout.write(formatTable(rows, ["KEY", "NAME", "DEFAULT", "LOCALES", "TIMEZONE", ""]) + "\n");
429
+ });
430
+ const roles = program.command("roles").description("Roles (needs a user session)");
431
+ roles.command("list").description("List roles").action(async (_flags, cmd) => {
432
+ const ctx = resolveContext(cmd.optsWithGlobals(), io);
433
+ const res = await request(ctx, "GET", "roles", { auth: "session" });
434
+ if (ctx.json) {
435
+ io.stdout.write(toJson(res.data) + "\n");
436
+ return;
437
+ }
438
+ const rows = res.data.map((r) => [
439
+ r.key,
440
+ r.name,
441
+ String(r.grant_count),
442
+ String(r.assigned_user_count),
443
+ r.is_system ? "system" : ""
444
+ ]);
445
+ io.stdout.write(formatTable(rows, ["KEY", "NAME", "GRANTS", "USERS", ""]) + "\n");
446
+ });
447
+ roles.command("get").description("Show a role and its grants").argument("<key>").action(async (key, _flags, cmd) => {
448
+ const ctx = resolveContext(cmd.optsWithGlobals(), io);
449
+ const res = await request(ctx, "GET", `roles/${encodeURIComponent(key)}`, { auth: "session" });
450
+ if (ctx.json) {
451
+ io.stdout.write(toJson(res.data) + "\n");
452
+ return;
453
+ }
454
+ const r = res.data;
455
+ const head = [
456
+ ["Key", r.key],
457
+ ["Name", r.name],
458
+ ["System", r.is_system ? "yes" : "no"]
459
+ ];
460
+ if (r.description) head.push(["Description", r.description]);
461
+ const grants = r.grants.map((g) => [g.key, g.site_key ?? "", g.content_model_id ?? ""]);
462
+ io.stdout.write(formatTable(head) + "\n\n" + formatTable(grants, ["GRANT", "SITE", "MODEL"]) + "\n");
463
+ });
464
+ const keys = program.command("api-keys").description("API keys (needs a user session)");
465
+ keys.command("list").description("List API keys").action(async (_flags, cmd) => {
466
+ const ctx = resolveContext(cmd.optsWithGlobals(), io);
467
+ const res = await request(ctx, "GET", "api-keys", { auth: "session" });
468
+ if (ctx.json) {
469
+ io.stdout.write(toJson(res.data) + "\n");
470
+ return;
471
+ }
472
+ const rows = res.data.map((k) => [
473
+ k.id,
474
+ k.name,
475
+ k.key_type,
476
+ k.revoked_at ? "revoked" : k.is_active ? "active" : "inactive",
477
+ k.last_used_at ?? "never",
478
+ k.expires_at ?? ""
479
+ ]);
480
+ io.stdout.write(formatTable(rows, ["ID", "NAME", "TYPE", "STATE", "LAST USED", "EXPIRES"]) + "\n");
481
+ });
482
+ keys.command("get").description("Show one API key (never its secret)").argument("<id>").action(async (id, _flags, cmd) => {
483
+ const ctx = resolveContext(cmd.optsWithGlobals(), io);
484
+ const res = await request(ctx, "GET", `api-keys/${encodeURIComponent(id)}`, { auth: "session" });
485
+ io.stdout.write(toJson(res.data) + "\n");
486
+ });
487
+ keys.command("create").description("Create an API key; the secret is printed once, alone on stdout").requiredOption("-n, --name <name>").option("-t, --type <private|public>", "key type", "private").option("--description <text>").option("-g, --grant <spec>", "private keys: key[,site=<key>][,model=<uuid>] (repeatable)", collect, []).option("--site <key>", "public keys: a site it may read (repeatable)", collect, []).option("--expires <iso>", "expiry timestamp").option("--origin <origin>", "an allowed browser origin (repeatable)", collect, []).option("--rate-limit <n>", "requests per minute, 0 = unlimited", "0").option("--include-drafts", "public keys: read drafts too").action(
488
+ async (flags, cmd) => {
489
+ const ctx = resolveContext(cmd.optsWithGlobals(), io);
490
+ if (flags.type !== "private" && flags.type !== "public") throw usageError("--type must be private or public");
491
+ const body = {
492
+ name: flags.name,
493
+ description: flags.description ?? null,
494
+ key_type: flags.type,
495
+ is_active: true,
496
+ expires_at: flags.expires ?? null,
497
+ grants: flags.grant.map(parseGrant),
498
+ sites: flags.site,
499
+ allowed_origins: flags.origin,
500
+ rate_limit_per_minute: Number(flags.rateLimit),
501
+ include_drafts: Boolean(flags.includeDrafts)
502
+ };
503
+ const res = await request(ctx, "POST", "api-keys", { auth: "session", body });
504
+ if (ctx.json) {
505
+ io.stdout.write(toJson(res.data) + "\n");
506
+ return;
507
+ }
508
+ io.stderr.write(
509
+ `created ${flags.type} key "${flags.name}" (${res.data.id}); the secret follows and is not shown again
510
+ `
511
+ );
512
+ io.stdout.write(res.data.key + "\n");
513
+ }
514
+ );
515
+ keys.command("delete").description("Delete an API key").argument("<id>").option("-y, --yes", "do not ask").action(async (id, flags, cmd) => {
516
+ const ctx = resolveContext(cmd.optsWithGlobals(), io);
517
+ await confirm(io, `Delete API key ${id}?`, Boolean(flags.yes));
518
+ await request(ctx, "DELETE", `api-keys/${encodeURIComponent(id)}`, { auth: "session" });
519
+ io.stdout.write(`deleted ${id}
520
+ `);
521
+ });
522
+ }
270
523
 
271
524
  // src/commands/api.ts
272
525
  import { errorFromResponse as errorFromResponse2 } from "@plenary/client";
273
526
  var METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]);
274
- function collect(value, previous) {
527
+ function collect2(value, previous) {
275
528
  return [...previous, value];
276
529
  }
277
530
  function registerApi(program, io) {
278
- program.command("api").description("Call any API endpoint and print the response body").argument("<method>", "GET, POST, PUT, PATCH, DELETE, or HEAD").argument("<path>", "a path such as models or /api/v1/models").option("-d, --data <json>", "request body: inline JSON, @file, or - for stdin").option("-q, --query <key=value>", "query parameter (repeatable)", collect, []).option("-H, --header <key:value>", "request header (repeatable)", collect, []).option("-i, --include", "print the status line and response headers to stderr").addHelpText(
531
+ program.command("api").description("Call any API endpoint and print the response body").argument("<method>", "GET, POST, PUT, PATCH, DELETE, or HEAD").argument("<path>", "a path such as models or /api/v1/models").option("-d, --data <json>", "request body: inline JSON, @file, or - for stdin").option("-q, --query <key=value>", "query parameter (repeatable)", collect2, []).option("-H, --header <key:value>", "request header (repeatable)", collect2, []).option("-i, --include", "print the status line and response headers to stderr").addHelpText(
279
532
  "after",
280
533
  `
281
534
  Examples:
@@ -284,12 +537,12 @@ Examples:
284
537
  plenary api POST content/post -q site_key=main -d '{"fields":{"title":"Hello"}}'
285
538
  plenary api PUT models/<uuid> -d @model.json`
286
539
  ).action(
287
- async (methodArg, path6, opts, cmd) => {
540
+ async (methodArg, path7, opts, cmd) => {
288
541
  const ctx = resolveContext(cmd.optsWithGlobals(), io);
289
542
  const method = methodArg.toUpperCase();
290
543
  if (!METHODS.has(method)) throw usageError(`unsupported method "${methodArg}"`);
291
544
  const body = opts.data !== void 0 ? await readData(opts.data, io.stdin) : void 0;
292
- const res = await request(ctx, method, path6, {
545
+ const res = await request(ctx, method, path7, {
293
546
  query: parsePairs(opts.query, "=", "query parameter"),
294
547
  headers: parsePairs(opts.header, ":", "header"),
295
548
  body,
@@ -314,6 +567,130 @@ Examples:
314
567
  );
315
568
  }
316
569
 
570
+ // src/commands/auth.ts
571
+ async function login(ctx, identifier, password, otpMethod) {
572
+ const io = ctx.io;
573
+ const first = await request(ctx, "POST", "auth/login", {
574
+ auth: "none",
575
+ body: { identifier, password }
576
+ });
577
+ const res = first.data;
578
+ if (res.status === "success") return res;
579
+ const challengeId = res.challenge_id;
580
+ const methods = Object.keys(res.otp_methods);
581
+ if (res.status === "otp_required") {
582
+ let method = otpMethod ?? (methods.length === 1 ? methods[0] : void 0);
583
+ if (!method) {
584
+ if (!io.isTTY) throw usageError(`2FA needs a delivery method: pass --otp-method ${methods.join("|")}`);
585
+ const shown = methods.map((m) => `${m} (${res.otp_methods[m]})`).join(", ");
586
+ method = await readLine(io, `Send the code by ${shown}? [${methods[0]}] `);
587
+ if (method === "") method = methods[0];
588
+ }
589
+ if (!methods.includes(method)) throw usageError(`--otp-method must be one of ${methods.join(", ")}`);
590
+ await request(ctx, "POST", "auth/login/otp/send", {
591
+ auth: "none",
592
+ body: { challenge_id: challengeId, otp_method: method }
593
+ });
594
+ io.stderr.write(`code sent by ${method}
595
+ `);
596
+ } else {
597
+ io.stderr.write(`code sent to ${Object.values(res.otp_methods).join(", ")}
598
+ `);
599
+ }
600
+ if (!io.isTTY) throw usageError("2FA needs a terminal to enter the code");
601
+ const code = await readLine(io, "Code: ");
602
+ const verified = await request(ctx, "POST", "auth/login/otp/verify", {
603
+ auth: "none",
604
+ body: { challenge_id: challengeId, otp_code: code }
605
+ });
606
+ return verified.data;
607
+ }
608
+ function registerAuth(program, io) {
609
+ program.command("login").description("Sign in as a user and keep the session for admin commands").option("-u, --identifier <email-or-username>", "or PLENARY_USERNAME").option("--otp-method <email|sms>", "how to receive a 2FA code when more than one way is offered").addHelpText(
610
+ "after",
611
+ `
612
+ The password is prompted for (never a flag); PLENARY_PASSWORD supplies it
613
+ without a terminal. Only the refresh token is stored, in
614
+ $XDG_CONFIG_HOME/plenary/credentials.json, keyed by instance.`
615
+ ).action(async (flags, cmd) => {
616
+ const ctx = resolveContext(cmd.optsWithGlobals(), io);
617
+ const baseUrl = requireBaseUrl(ctx);
618
+ let identifier = flags.identifier ?? io.env.PLENARY_USERNAME;
619
+ if (!identifier) {
620
+ if (!io.isTTY) throw usageError("no identifier: pass --identifier or set PLENARY_USERNAME");
621
+ identifier = await readLine(io, "Email or username: ");
622
+ }
623
+ let password = io.env.PLENARY_PASSWORD;
624
+ if (!password) {
625
+ if (!io.isTTY) throw usageError("no password: set PLENARY_PASSWORD or run on a terminal");
626
+ password = await readSecret(io, "Password: ");
627
+ }
628
+ const tokens = await login(ctx, identifier, password, flags.otpMethod);
629
+ saveSession(io, baseUrl, { refresh_token: tokens.refresh_token, identifier });
630
+ ctx.sessionToken = tokens.access_token;
631
+ const me = await request(ctx, "GET", "auth/me", { auth: "session" });
632
+ if (ctx.json) {
633
+ io.stdout.write(
634
+ toJson({ base_url: baseUrl, identifier, role: me.data.role, credentials: credentialsPath(io) }) + "\n"
635
+ );
636
+ return;
637
+ }
638
+ io.stdout.write(`logged in to ${baseUrl} as ${identifier} (${me.data.role_name ?? me.data.role})
639
+ `);
640
+ });
641
+ program.command("logout").description("Revoke the stored session for this instance").action(async (_flags, cmd) => {
642
+ const ctx = resolveContext(cmd.optsWithGlobals(), io);
643
+ const baseUrl = requireBaseUrl(ctx);
644
+ const stored = loadSession(io, baseUrl);
645
+ if (!stored) {
646
+ io.stdout.write(`no session for ${baseUrl}
647
+ `);
648
+ return;
649
+ }
650
+ try {
651
+ await request(ctx, "POST", "auth/logout", { auth: "none", body: { refresh_token: stored.refresh_token } });
652
+ } finally {
653
+ clearSession(io, baseUrl);
654
+ }
655
+ io.stdout.write(`logged out of ${baseUrl}
656
+ `);
657
+ });
658
+ program.command("whoami").description("Show the credentials this run would use").action(async (_flags, cmd) => {
659
+ const ctx = resolveContext(cmd.optsWithGlobals(), io);
660
+ const baseUrl = requireBaseUrl(ctx);
661
+ const stored = loadSession(io, baseUrl);
662
+ const out = { base_url: baseUrl, api_key: null, session: null };
663
+ const lines = [];
664
+ if (ctx.apiKey) {
665
+ const me = await request(ctx, "GET", "auth/me", {
666
+ auth: "none",
667
+ headers: { "X-API-KEY": ctx.apiKey },
668
+ allowError: true
669
+ });
670
+ out.api_key = me.ok ? me.data : { error: me.data?.error ?? me.status };
671
+ if (me.ok && me.data.key_type) {
672
+ lines.push(
673
+ `API key ${me.data.key_type} "${me.data.name}" (${me.data.grants?.length ?? 0} grants), used for content commands`
674
+ );
675
+ } else if (me.status === 403) {
676
+ lines.push("API key accepted (this server cannot describe keys), used for content commands");
677
+ } else {
678
+ lines.push(`API key rejected (HTTP ${me.status})`);
679
+ }
680
+ }
681
+ if (stored) {
682
+ const headers = await credentialHeaders(ctx, "session");
683
+ const me = await request(ctx, "GET", "auth/me", { auth: "none", headers });
684
+ out.session = { identifier: stored.identifier ?? null, role: me.data.role, role_name: me.data.role_name ?? null };
685
+ lines.push(
686
+ `Session ${stored.identifier ?? "user"} (${me.data.role_name ?? me.data.role}), used for admin commands${ctx.apiKey ? "" : " and content"}`
687
+ );
688
+ }
689
+ if (lines.length === 0) lines.push("no credentials: set PLENARY_API_KEY or run `plenary login`");
690
+ io.stdout.write((ctx.json ? toJson(out) : lines.join("\n")) + "\n");
691
+ });
692
+ }
693
+
317
694
  // src/commands/catalog.ts
318
695
  function registerCatalog(program, io, name) {
319
696
  const group = program.command(name).description(name === "models" ? "Content models and objects" : "Forms");
@@ -386,13 +763,16 @@ async function resolveLocale(ctx, siteKey) {
386
763
  }
387
764
  async function contentClient(ctx) {
388
765
  const siteKey = requireSiteKey(ctx);
389
- return createClient({
390
- baseUrl: requireBaseUrl(ctx),
391
- siteKey,
392
- apiKey: requireApiKey(ctx),
393
- locale: await resolveLocale(ctx, siteKey),
394
- fetch: ctx.io.fetch
395
- });
766
+ const baseUrl = requireBaseUrl(ctx);
767
+ const auth = await credentialHeaders(ctx, "required");
768
+ const apiKey = auth["X-API-KEY"] ?? "";
769
+ const fetch = apiKey ? ctx.io.fetch : (input, init = {}) => {
770
+ const headers = new Headers(init.headers);
771
+ headers.delete("X-API-KEY");
772
+ for (const [k, v] of Object.entries(auth)) headers.set(k, v);
773
+ return ctx.io.fetch(input, { ...init, headers });
774
+ };
775
+ return createClient({ baseUrl, siteKey, apiKey, locale: await resolveLocale(ctx, siteKey), fetch });
396
776
  }
397
777
 
398
778
  // src/entries/query.ts
@@ -468,11 +848,11 @@ async function toListOptions(flags, stdin) {
468
848
  }
469
849
 
470
850
  // src/commands/entries.ts
471
- function collect2(value, previous) {
851
+ function collect3(value, previous) {
472
852
  return [...previous, value];
473
853
  }
474
854
  function withListFlags(cmd) {
475
- return cmd.option("-w, --where <field=value>", "filter: field=value or field=$op:value (repeatable)", collect2, []).option("--filters <json>", "a filter tree (inline JSON, @file, or -); excludes --where").option("-s, --sort <field[:dir]>", "sort field, asc unless :desc (repeatable, in precedence order)", collect2, []).option("--limit <n>", "page size").option("--offset <n>", "rows to skip").option("--include <fields>", "comma-separated data fields to return").option("--exclude <fields>", "comma-separated data fields to omit").option("--hydrate <field[=subfields]>", "hydrate a reference field (repeatable)", collect2, []).option("--count", "ask for the total and show it");
855
+ return cmd.option("-w, --where <field=value>", "filter: field=value or field=$op:value (repeatable)", collect3, []).option("--filters <json>", "a filter tree (inline JSON, @file, or -); excludes --where").option("-s, --sort <field[:dir]>", "sort field, asc unless :desc (repeatable, in precedence order)", collect3, []).option("--limit <n>", "page size").option("--offset <n>", "rows to skip").option("--include <fields>", "comma-separated data fields to return").option("--exclude <fields>", "comma-separated data fields to omit").option("--hydrate <field[=subfields]>", "hydrate a reference field (repeatable)", collect3, []).option("--count", "ask for the total and show it");
476
856
  }
477
857
  function withWriteFlags(cmd) {
478
858
  return cmd.option("-d, --data <json>", "the field values as a JSON object (inline, @file, or -)").option("--slug <slug>").option("--folder <id>", "content folder id").option("--note <text>", "revision note (required by models with Require Change Notes)").option("--publish-at <iso>", "schedule a publish").option("--unpublish-at <iso>", "schedule an unpublish");
@@ -511,7 +891,7 @@ function registerEntries(program, io) {
511
891
  `);
512
892
  }
513
893
  );
514
- entries.command("get").description("Print one entry as JSON; omit the slug for a singleton").argument("<model>").argument("[slug-or-id]").option("--include <fields>").option("--exclude <fields>").option("--hydrate <field[=subfields]>", "hydrate a reference field (repeatable)", collect2, []).action(async (model, slug, flags, cmd) => {
894
+ entries.command("get").description("Print one entry as JSON; omit the slug for a singleton").argument("<model>").argument("[slug-or-id]").option("--include <fields>").option("--exclude <fields>").option("--hydrate <field[=subfields]>", "hydrate a reference field (repeatable)", collect3, []).action(async (model, slug, flags, cmd) => {
515
895
  const ctx = resolveContext(cmd.optsWithGlobals(), io);
516
896
  const client = await contentClient(ctx);
517
897
  const opts = await toReadOptions(flags);
@@ -639,7 +1019,7 @@ function report(ctx, entry, verb) {
639
1019
  }
640
1020
 
641
1021
  // src/commands/formentries.ts
642
- function collect3(value, previous) {
1022
+ function collect4(value, previous) {
643
1023
  return [...previous, value];
644
1024
  }
645
1025
  function toQuery(ctx, opts) {
@@ -664,7 +1044,7 @@ function toQuery(ctx, opts) {
664
1044
  var PAGE2 = 100;
665
1045
  function registerFormEntries(forms, io) {
666
1046
  const entries = forms.command("entries").description("Form entries (what visitors submitted)");
667
- const listFlags = (cmd) => cmd.option("-w, --where <field=value>", "filter: field=value or field=$op:value (repeatable)", collect3, []).option("--filters <json>", "a filter tree (inline JSON, @file, or -)").option("-s, --sort <field[:dir]>", "sort field, asc unless :desc (repeatable)", collect3, []).option("--limit <n>").option("--offset <n>").option("--count", "ask for the total and show it");
1047
+ const listFlags = (cmd) => cmd.option("-w, --where <field=value>", "filter: field=value or field=$op:value (repeatable)", collect4, []).option("--filters <json>", "a filter tree (inline JSON, @file, or -)").option("-s, --sort <field[:dir]>", "sort field, asc unless :desc (repeatable)", collect4, []).option("--limit <n>").option("--offset <n>").option("--count", "ask for the total and show it");
668
1048
  listFlags(entries.command("list").description("List a form's entries").argument("<form>")).action(
669
1049
  async (form, flags, cmd) => {
670
1050
  const ctx = resolveContext(cmd.optsWithGlobals(), io);
@@ -705,8 +1085,8 @@ function summary(data) {
705
1085
  }
706
1086
 
707
1087
  // src/commands/media.ts
708
- import { readFileSync as readFileSync3 } from "fs";
709
- import path2 from "path";
1088
+ import { readFileSync as readFileSync4 } from "fs";
1089
+ import path3 from "path";
710
1090
  function size(bytes) {
711
1091
  if (bytes < 1024) return `${bytes} B`;
712
1092
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -721,12 +1101,12 @@ function registerMedia(program, io) {
721
1101
  for (const file of files) {
722
1102
  let bytes;
723
1103
  try {
724
- bytes = readFileSync3(file);
1104
+ bytes = readFileSync4(file);
725
1105
  } catch (err) {
726
1106
  throw usageError(`cannot read ${file}: ${err.message}`);
727
1107
  }
728
1108
  const form = new FormData();
729
- form.append("upload_file", new Blob([new Uint8Array(bytes)]), path2.basename(file));
1109
+ form.append("upload_file", new Blob([new Uint8Array(bytes)]), path3.basename(file));
730
1110
  if (flags.folder) form.append("folder_id", flags.folder);
731
1111
  if (flags.private) form.append("visibility", "private");
732
1112
  try {
@@ -765,27 +1145,40 @@ function registerMedia(program, io) {
765
1145
  }
766
1146
 
767
1147
  // src/commands/metadata.ts
768
- import path4 from "path";
1148
+ import path5 from "path";
769
1149
 
770
1150
  // src/metadata/dir.ts
771
- import { existsSync as existsSync2, mkdirSync, readdirSync, readFileSync as readFileSync4, writeFileSync } from "fs";
772
- import path3 from "path";
773
- var MANAGED_DIRS = ["models", "forms"];
1151
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
1152
+ import path4 from "path";
1153
+ var MANAGED_DIRS = ["models", "forms", "roles", "sites"];
1154
+ function parseKinds(spec) {
1155
+ if (!spec || spec === "all") return [...MANAGED_DIRS];
1156
+ const out = [];
1157
+ for (const raw of spec.split(",")) {
1158
+ const k = raw.trim();
1159
+ if (!MANAGED_DIRS.includes(k)) throw new Error(`unknown kind "${k}" (want ${MANAGED_DIRS.join(", ")} or all)`);
1160
+ if (!out.includes(k)) out.push(k);
1161
+ }
1162
+ return out;
1163
+ }
1164
+ function filterKinds(files, kinds) {
1165
+ return files.filter((f) => kinds.includes(f.path.split("/")[0]));
1166
+ }
774
1167
  function readTree(root) {
775
1168
  const out = [];
776
1169
  for (const rel of managedFiles(root)) {
777
- out.push({ path: rel, content: readFileSync4(path3.join(root, rel), "utf8") });
1170
+ out.push({ path: rel, content: readFileSync5(path4.join(root, rel), "utf8") });
778
1171
  }
779
1172
  return out;
780
1173
  }
781
- function writeTree(root, files) {
782
- const before = new Set(managedFiles(root));
1174
+ function writeTree(root, files, kinds = [...MANAGED_DIRS]) {
1175
+ const before = new Set(managedFiles(root).filter((p) => kinds.includes(p.split("/")[0])));
783
1176
  const written = [];
784
1177
  for (const f of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
785
1178
  if (!isTreePath(f.path)) continue;
786
- const abs = path3.join(root, f.path);
787
- mkdirSync(path3.dirname(abs), { recursive: true });
788
- writeFileSync(abs, f.content);
1179
+ const abs = path4.join(root, f.path);
1180
+ mkdirSync2(path4.dirname(abs), { recursive: true });
1181
+ writeFileSync2(abs, f.content);
789
1182
  written.push(f.path);
790
1183
  before.delete(f.path);
791
1184
  }
@@ -798,8 +1191,8 @@ function isTreePath(p) {
798
1191
  function managedFiles(root) {
799
1192
  const out = [];
800
1193
  for (const dir of MANAGED_DIRS) {
801
- const abs = path3.join(root, dir);
802
- if (!existsSync2(abs)) continue;
1194
+ const abs = path4.join(root, dir);
1195
+ if (!existsSync3(abs)) continue;
803
1196
  for (const name of readdirSync(abs).sort()) {
804
1197
  if (name.endsWith(".yaml")) out.push(`${dir}/${name}`);
805
1198
  }
@@ -810,20 +1203,24 @@ function managedFiles(root) {
810
1203
  // src/metadata/plan.ts
811
1204
  function formatPlan(result, verbose = false) {
812
1205
  const lines = [];
813
- let creates = 0;
814
- let updates = 0;
815
- let noops = 0;
816
- for (const c of result.changes) {
817
- if (c.action === "create") creates++;
818
- else if (c.action === "update") updates++;
819
- else noops++;
1206
+ const n = { create: 0, update: 0, delete: 0, skip: 0, noop: 0 };
1207
+ const ordered = [...result.changes].sort((a, b) => Number(b.action === "delete") - Number(a.action === "delete"));
1208
+ for (const c of ordered) {
1209
+ n[c.action]++;
820
1210
  if (c.action === "noop" && !verbose) continue;
821
- const label = c.action === "noop" ? "unchanged" : c.action;
822
- lines.push(`${label.padEnd(9)} ${c.kind.padEnd(10)} ${c.key}${detail(c)}`);
1211
+ const label = c.action === "noop" ? "unchanged" : c.action === "skip" ? "skipped" : c.action;
1212
+ const note = c.action === "skip" ? " (system-owned)" : detail(c);
1213
+ lines.push(`${label.padEnd(9)} ${c.kind.padEnd(10)} ${c.key}${note}`);
1214
+ }
1215
+ const parts = [`${n.create} to create`, `${n.update} to update`];
1216
+ if (n.delete) parts.push(`${n.delete} to delete`);
1217
+ if (n.skip) parts.push(`${n.skip} skipped`);
1218
+ parts.push(`${n.noop} unchanged`);
1219
+ if (n.create + n.update + n.delete === 0) {
1220
+ lines.push(`No changes: ${n.noop} unchanged${n.skip ? `, ${n.skip} skipped` : ""}.`);
1221
+ } else {
1222
+ lines.push(`${result.applied ? "Applied" : "Plan"}: ${parts.join(", ")}.`);
823
1223
  }
824
- const counts = `${creates} to create, ${updates} to update, ${noops} unchanged`;
825
- if (creates + updates === 0) lines.push(`No changes: ${noops} unchanged.`);
826
- else lines.push(result.applied ? `Applied: ${counts}.` : `Plan: ${counts}.`);
827
1224
  return lines.join("\n");
828
1225
  }
829
1226
  function detail(c) {
@@ -839,40 +1236,68 @@ function detail(c) {
839
1236
  }
840
1237
 
841
1238
  // src/commands/metadata.ts
1239
+ function kindsOf(opts) {
1240
+ try {
1241
+ return parseKinds(opts.only);
1242
+ } catch (err) {
1243
+ throw usageError(`--only: ${err.message}`);
1244
+ }
1245
+ }
842
1246
  function treeRoot(ctx, opts) {
843
- return opts.dir ? path4.resolve(ctx.io.cwd, opts.dir) : path4.resolve(ctx.projectRoot, ctx.metadataDir);
1247
+ return opts.dir ? path5.resolve(ctx.io.cwd, opts.dir) : path5.resolve(ctx.projectRoot, ctx.metadataDir);
844
1248
  }
845
- function readTreeOrFail(root) {
846
- const files = readTree(root);
847
- if (files.length === 0) throw usageError(`no YAML files under ${root}/models or ${root}/forms`);
1249
+ function readTreeOrFail(root, kinds) {
1250
+ const files = filterKinds(readTree(root), kinds);
1251
+ if (files.length === 0) throw usageError(`no YAML files under ${root}/{${kinds.join(",")}}`);
848
1252
  return files;
849
1253
  }
850
- async function runImport(ctx, root, apply, verbose) {
851
- const files = readTreeOrFail(root);
852
- const res = await request(ctx, "POST", "metadata/models/import", { body: { files, apply } });
1254
+ async function runImport(ctx, root, opts, apply) {
1255
+ const kinds = kindsOf(opts);
1256
+ const files = readTreeOrFail(root, kinds);
1257
+ const prune = Boolean(opts.prune);
1258
+ const body = { files, apply: false, prune };
1259
+ let res = await request(ctx, "POST", "metadata/import", { body });
1260
+ const deletes = res.data.changes.filter((c) => c.action === "delete");
1261
+ if (apply) {
1262
+ if (deletes.length > 0) {
1263
+ if (!ctx.json) ctx.io.stdout.write(formatPlan(res.data, Boolean(opts.verbose)) + "\n");
1264
+ await confirm(ctx.io, `Delete ${deletes.length} ${deletes.length === 1 ? "entity" : "entities"} from the instance?`, Boolean(opts.yes));
1265
+ }
1266
+ res = await request(ctx, "POST", "metadata/import", { body: { ...body, apply: true } });
1267
+ }
853
1268
  if (ctx.json) ctx.io.stdout.write(toJson(res.data) + "\n");
854
- else ctx.io.stdout.write(formatPlan(res.data, verbose) + "\n");
1269
+ else ctx.io.stdout.write(formatPlan(res.data, Boolean(opts.verbose)) + "\n");
855
1270
  }
1271
+ var onlyOption = ["--only <kinds>", "comma list of models, forms, roles, sites (default: all)"];
856
1272
  function registerMetadata(program, io) {
857
1273
  const metadata = program.command("metadata").description("Keep a directory of model and form YAML in step with the instance").addHelpText(
858
1274
  "after",
859
1275
  `
860
- The tree is models/<key>.yaml and forms/<key>.yaml under the metadata
861
- directory ("metadataDir" in plenary.json, default ./plenary). See
862
- docs/CONTENT_MODELS.md, "YAML import and export".`
1276
+ The tree is models/, forms/, roles/ and sites/ under the metadata directory
1277
+ ("metadataDir" in plenary.json, default ./plenary), one <key>.yaml per
1278
+ entity. Models and forms need an API key or a session; roles and sites need
1279
+ a user session (plenary login). See docs/CLI.md.`
863
1280
  );
864
- metadata.command("pull").description("Write every model and form the key may read into the directory").option("--dir <path>", "metadata directory (default: the configured one)").action(async (opts, cmd) => {
1281
+ metadata.command("pull").description("Write every model, form, role and site the credential may read into the directory").option("--dir <path>", "metadata directory (default: the configured one)").option(...onlyOption).action(async (opts, cmd) => {
865
1282
  const ctx = resolveContext(cmd.optsWithGlobals(), io);
866
1283
  const root = treeRoot(ctx, opts);
867
- const res = await request(ctx, "GET", "metadata/models");
868
- const result = writeTree(root, res.data.files);
1284
+ const kinds = kindsOf(opts);
1285
+ const res = await request(ctx, "GET", "metadata", {
1286
+ query: { only: kinds.join(",") }
1287
+ });
1288
+ const result = writeTree(root, res.data.files, kinds);
1289
+ const omitted = res.data.omitted ?? [];
869
1290
  if (ctx.json) {
870
- io.stdout.write(toJson({ dir: root, ...result }) + "\n");
1291
+ io.stdout.write(toJson({ dir: root, ...result, omitted }) + "\n");
871
1292
  return;
872
1293
  }
873
1294
  io.stdout.write(`wrote ${result.written.length} file${result.written.length === 1 ? "" : "s"} to ${root}
874
1295
  `);
875
- if (result.written.length === 0) {
1296
+ if (omitted.length) {
1297
+ io.stderr.write(`not included, the credential may not read them: ${omitted.join(", ")}
1298
+ `);
1299
+ }
1300
+ if (result.written.length === 0 && omitted.length === 0) {
876
1301
  io.stderr.write("the server sent no models: the key may lack content:models:read (a public key never has it)\n");
877
1302
  }
878
1303
  if (result.stale.length) {
@@ -881,19 +1306,26 @@ ${result.stale.map((p) => ` ${p}`).join("\n")}
881
1306
  `);
882
1307
  }
883
1308
  });
884
- metadata.command("diff").description("Preview what push would change, without writing anything").option("--dir <path>", "metadata directory (default: the configured one)").option("--verbose", "list unchanged models too").action(async (opts, cmd) => {
1309
+ metadata.command("diff").description("Preview what push would change, without writing anything").option("--dir <path>", "metadata directory (default: the configured one)").option(...onlyOption).option("--prune", "include what push --prune would delete").option("--verbose", "list unchanged entities too").action(async (opts, cmd) => {
885
1310
  const ctx = resolveContext(cmd.optsWithGlobals(), io);
886
- await runImport(ctx, treeRoot(ctx, opts), false, Boolean(opts.verbose));
1311
+ await runImport(ctx, treeRoot(ctx, opts), opts, false);
887
1312
  });
888
- metadata.command("push").description("Apply the directory to the instance (creates and updates; never deletes)").option("--dir <path>", "metadata directory (default: the configured one)").option("--dry-run", "same as diff").option("--verbose", "list unchanged models too").action(async (opts, cmd) => {
1313
+ metadata.command("push").description("Apply the directory to the instance (creates and updates; deletes only with --prune)").option("--dir <path>", "metadata directory (default: the configured one)").option(...onlyOption).option("--prune", "also delete entities absent from the directory, within the kinds it holds").option("-y, --yes", "do not ask before pruning").option("--dry-run", "same as diff").option("--verbose", "list unchanged entities too").addHelpText(
1314
+ "after",
1315
+ `
1316
+ --prune deletes only within the kinds present in the directory: a tree with
1317
+ no roles/ cannot delete a role. System-owned roles are never touched and are
1318
+ reported as skipped. The plan is shown and confirmed before anything is
1319
+ deleted; -y skips the question, and a run with no terminal needs it.`
1320
+ ).action(async (opts, cmd) => {
889
1321
  const ctx = resolveContext(cmd.optsWithGlobals(), io);
890
- await runImport(ctx, treeRoot(ctx, opts), !opts.dryRun, Boolean(opts.verbose));
1322
+ await runImport(ctx, treeRoot(ctx, opts), opts, !opts.dryRun);
891
1323
  });
892
1324
  }
893
1325
 
894
1326
  // src/commands/init.ts
895
- import { existsSync as existsSync3, writeFileSync as writeFileSync2 } from "fs";
896
- import path5 from "path";
1327
+ import { existsSync as existsSync4, writeFileSync as writeFileSync3 } from "fs";
1328
+ import path6 from "path";
897
1329
  import { createInterface as createInterface2 } from "readline/promises";
898
1330
  function registerInit(program, io) {
899
1331
  program.command("init").description(`Write a ${CONFIG_FILE} for this project (never a credential)`).option("--dir <path>", "metadata directory, relative to the config file", DEFAULT_METADATA_DIR).option("-f, --force", `overwrite an existing ${CONFIG_FILE}`).addHelpText(
@@ -902,12 +1334,12 @@ function registerInit(program, io) {
902
1334
  The base URL, site and locale come from --base-url, --site and --locale (or
903
1335
  their PLENARY_* variables); anything missing is prompted for on a terminal.`
904
1336
  ).action(async (opts, cmd) => {
905
- const target = path5.join(io.cwd, CONFIG_FILE);
906
- if (existsSync3(target) && !opts.force) {
1337
+ const target = path6.join(io.cwd, CONFIG_FILE);
1338
+ if (existsSync4(target) && !opts.force) {
907
1339
  throw usageError(`${target} already exists (pass --force to overwrite)`);
908
1340
  }
909
1341
  const globals = cmd.optsWithGlobals();
910
- const ctx = resolveContext({ ...globals, config: void 0 }, { ...io, cwd: path5.parse(io.cwd).root });
1342
+ const ctx = resolveContext({ ...globals, config: void 0 }, { ...io, cwd: path6.parse(io.cwd).root });
911
1343
  const baseUrl = globals.baseUrl ?? ctx.baseUrl ?? await ask(io, "Base URL (https://cms.example.com)");
912
1344
  if (!baseUrl) throw usageError("a base URL is required: pass --base-url or set PLENARY_BASE_URL");
913
1345
  const siteKey = globals.site ?? ctx.siteKey ?? await ask(io, "Site key", "default");
@@ -916,7 +1348,7 @@ their PLENARY_* variables); anything missing is prompted for on a terminal.`
916
1348
  if (siteKey) config.siteKey = siteKey;
917
1349
  if (locale) config.locale = locale;
918
1350
  if (opts.dir !== DEFAULT_METADATA_DIR) config.metadataDir = opts.dir;
919
- writeFileSync2(target, JSON.stringify(config, null, 2) + "\n");
1351
+ writeFileSync3(target, JSON.stringify(config, null, 2) + "\n");
920
1352
  io.stdout.write(`wrote ${target}
921
1353
  `);
922
1354
  });
@@ -941,9 +1373,10 @@ function registerStatus(program, io) {
941
1373
  let me;
942
1374
  let credentialError;
943
1375
  let undescribed = false;
944
- if (ctx.apiKey) {
1376
+ const headers = await credentialHeaders(ctx, "optional");
1377
+ if (Object.keys(headers).length > 0) {
945
1378
  try {
946
- me = (await request(ctx, "GET", "/api/v1/auth/me")).data;
1379
+ me = (await request(ctx, "GET", "/api/v1/auth/me", { auth: "none", headers })).data;
947
1380
  } catch (err) {
948
1381
  if (!(err instanceof PlenaryError2)) throw err;
949
1382
  if (err.status === 403) undescribed = true;
@@ -974,13 +1407,13 @@ function registerStatus(program, io) {
974
1407
  if (me?.principal === "api_key") {
975
1408
  lines.push(`Credential ${me.key_type} API key "${me.name}" (${me.grants.length} grant${me.grants.length === 1 ? "" : "s"}${me.sites.length ? `, sites ${me.sites.join(", ")}` : ""})`);
976
1409
  } else if (me?.principal === "user") {
977
- lines.push(`Credential user session, role ${me.role_name ?? me.role}`);
1410
+ lines.push(`Credential user session (stored login), role ${me.role_name ?? me.role}`);
978
1411
  } else if (credentialError) {
979
1412
  lines.push(`Credential rejected: ${credentialError.code} (HTTP ${credentialError.status})`);
980
1413
  } else if (undescribed) {
981
1414
  lines.push("Credential API key accepted (this server predates API-key support on /auth/me and cannot describe the key)");
982
1415
  } else {
983
- lines.push("Credential none (set PLENARY_API_KEY or pass --api-key)");
1416
+ lines.push("Credential none (set PLENARY_API_KEY, pass --api-key, or run `plenary login`)");
984
1417
  }
985
1418
  if (ctx.configPath) lines.push(`Config ${ctx.configPath}`);
986
1419
  io.stdout.write(lines.join("\n") + "\n");
@@ -991,7 +1424,7 @@ function registerStatus(program, io) {
991
1424
  // src/cli.ts
992
1425
  function version() {
993
1426
  try {
994
- const pkg = JSON.parse(readFileSync5(new URL("../package.json", import.meta.url), "utf8"));
1427
+ const pkg = JSON.parse(readFileSync6(new URL("../package.json", import.meta.url), "utf8"));
995
1428
  return pkg.version;
996
1429
  } catch {
997
1430
  return "0.0.0";
@@ -1003,12 +1436,14 @@ function createProgram(io) {
1003
1436
  writeErr: (s) => io.stderr.write(s)
1004
1437
  });
1005
1438
  registerInit(program, io);
1439
+ registerAuth(program, io);
1006
1440
  registerStatus(program, io);
1007
1441
  registerMetadata(program, io);
1008
1442
  registerCatalog(program, io, "models");
1009
1443
  registerFormEntries(registerCatalog(program, io, "forms"), io);
1010
1444
  registerEntries(program, io);
1011
1445
  registerMedia(program, io);
1446
+ registerAdmin(program, io);
1012
1447
  registerApi(program, io);
1013
1448
  return program;
1014
1449
  }