@plenary/cli 0.3.0 → 0.4.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,16 +1145,16 @@ 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";
1151
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
1152
+ import path4 from "path";
773
1153
  var MANAGED_DIRS = ["models", "forms"];
774
1154
  function readTree(root) {
775
1155
  const out = [];
776
1156
  for (const rel of managedFiles(root)) {
777
- out.push({ path: rel, content: readFileSync4(path3.join(root, rel), "utf8") });
1157
+ out.push({ path: rel, content: readFileSync5(path4.join(root, rel), "utf8") });
778
1158
  }
779
1159
  return out;
780
1160
  }
@@ -783,9 +1163,9 @@ function writeTree(root, files) {
783
1163
  const written = [];
784
1164
  for (const f of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
785
1165
  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);
1166
+ const abs = path4.join(root, f.path);
1167
+ mkdirSync2(path4.dirname(abs), { recursive: true });
1168
+ writeFileSync2(abs, f.content);
789
1169
  written.push(f.path);
790
1170
  before.delete(f.path);
791
1171
  }
@@ -798,8 +1178,8 @@ function isTreePath(p) {
798
1178
  function managedFiles(root) {
799
1179
  const out = [];
800
1180
  for (const dir of MANAGED_DIRS) {
801
- const abs = path3.join(root, dir);
802
- if (!existsSync2(abs)) continue;
1181
+ const abs = path4.join(root, dir);
1182
+ if (!existsSync3(abs)) continue;
803
1183
  for (const name of readdirSync(abs).sort()) {
804
1184
  if (name.endsWith(".yaml")) out.push(`${dir}/${name}`);
805
1185
  }
@@ -840,7 +1220,7 @@ function detail(c) {
840
1220
 
841
1221
  // src/commands/metadata.ts
842
1222
  function treeRoot(ctx, opts) {
843
- return opts.dir ? path4.resolve(ctx.io.cwd, opts.dir) : path4.resolve(ctx.projectRoot, ctx.metadataDir);
1223
+ return opts.dir ? path5.resolve(ctx.io.cwd, opts.dir) : path5.resolve(ctx.projectRoot, ctx.metadataDir);
844
1224
  }
845
1225
  function readTreeOrFail(root) {
846
1226
  const files = readTree(root);
@@ -892,8 +1272,8 @@ ${result.stale.map((p) => ` ${p}`).join("\n")}
892
1272
  }
893
1273
 
894
1274
  // src/commands/init.ts
895
- import { existsSync as existsSync3, writeFileSync as writeFileSync2 } from "fs";
896
- import path5 from "path";
1275
+ import { existsSync as existsSync4, writeFileSync as writeFileSync3 } from "fs";
1276
+ import path6 from "path";
897
1277
  import { createInterface as createInterface2 } from "readline/promises";
898
1278
  function registerInit(program, io) {
899
1279
  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 +1282,12 @@ function registerInit(program, io) {
902
1282
  The base URL, site and locale come from --base-url, --site and --locale (or
903
1283
  their PLENARY_* variables); anything missing is prompted for on a terminal.`
904
1284
  ).action(async (opts, cmd) => {
905
- const target = path5.join(io.cwd, CONFIG_FILE);
906
- if (existsSync3(target) && !opts.force) {
1285
+ const target = path6.join(io.cwd, CONFIG_FILE);
1286
+ if (existsSync4(target) && !opts.force) {
907
1287
  throw usageError(`${target} already exists (pass --force to overwrite)`);
908
1288
  }
909
1289
  const globals = cmd.optsWithGlobals();
910
- const ctx = resolveContext({ ...globals, config: void 0 }, { ...io, cwd: path5.parse(io.cwd).root });
1290
+ const ctx = resolveContext({ ...globals, config: void 0 }, { ...io, cwd: path6.parse(io.cwd).root });
911
1291
  const baseUrl = globals.baseUrl ?? ctx.baseUrl ?? await ask(io, "Base URL (https://cms.example.com)");
912
1292
  if (!baseUrl) throw usageError("a base URL is required: pass --base-url or set PLENARY_BASE_URL");
913
1293
  const siteKey = globals.site ?? ctx.siteKey ?? await ask(io, "Site key", "default");
@@ -916,7 +1296,7 @@ their PLENARY_* variables); anything missing is prompted for on a terminal.`
916
1296
  if (siteKey) config.siteKey = siteKey;
917
1297
  if (locale) config.locale = locale;
918
1298
  if (opts.dir !== DEFAULT_METADATA_DIR) config.metadataDir = opts.dir;
919
- writeFileSync2(target, JSON.stringify(config, null, 2) + "\n");
1299
+ writeFileSync3(target, JSON.stringify(config, null, 2) + "\n");
920
1300
  io.stdout.write(`wrote ${target}
921
1301
  `);
922
1302
  });
@@ -941,9 +1321,10 @@ function registerStatus(program, io) {
941
1321
  let me;
942
1322
  let credentialError;
943
1323
  let undescribed = false;
944
- if (ctx.apiKey) {
1324
+ const headers = await credentialHeaders(ctx, "optional");
1325
+ if (Object.keys(headers).length > 0) {
945
1326
  try {
946
- me = (await request(ctx, "GET", "/api/v1/auth/me")).data;
1327
+ me = (await request(ctx, "GET", "/api/v1/auth/me", { auth: "none", headers })).data;
947
1328
  } catch (err) {
948
1329
  if (!(err instanceof PlenaryError2)) throw err;
949
1330
  if (err.status === 403) undescribed = true;
@@ -974,13 +1355,13 @@ function registerStatus(program, io) {
974
1355
  if (me?.principal === "api_key") {
975
1356
  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
1357
  } else if (me?.principal === "user") {
977
- lines.push(`Credential user session, role ${me.role_name ?? me.role}`);
1358
+ lines.push(`Credential user session (stored login), role ${me.role_name ?? me.role}`);
978
1359
  } else if (credentialError) {
979
1360
  lines.push(`Credential rejected: ${credentialError.code} (HTTP ${credentialError.status})`);
980
1361
  } else if (undescribed) {
981
1362
  lines.push("Credential API key accepted (this server predates API-key support on /auth/me and cannot describe the key)");
982
1363
  } else {
983
- lines.push("Credential none (set PLENARY_API_KEY or pass --api-key)");
1364
+ lines.push("Credential none (set PLENARY_API_KEY, pass --api-key, or run `plenary login`)");
984
1365
  }
985
1366
  if (ctx.configPath) lines.push(`Config ${ctx.configPath}`);
986
1367
  io.stdout.write(lines.join("\n") + "\n");
@@ -991,7 +1372,7 @@ function registerStatus(program, io) {
991
1372
  // src/cli.ts
992
1373
  function version() {
993
1374
  try {
994
- const pkg = JSON.parse(readFileSync5(new URL("../package.json", import.meta.url), "utf8"));
1375
+ const pkg = JSON.parse(readFileSync6(new URL("../package.json", import.meta.url), "utf8"));
995
1376
  return pkg.version;
996
1377
  } catch {
997
1378
  return "0.0.0";
@@ -1003,12 +1384,14 @@ function createProgram(io) {
1003
1384
  writeErr: (s) => io.stderr.write(s)
1004
1385
  });
1005
1386
  registerInit(program, io);
1387
+ registerAuth(program, io);
1006
1388
  registerStatus(program, io);
1007
1389
  registerMetadata(program, io);
1008
1390
  registerCatalog(program, io, "models");
1009
1391
  registerFormEntries(registerCatalog(program, io, "forms"), io);
1010
1392
  registerEntries(program, io);
1011
1393
  registerMedia(program, io);
1394
+ registerAdmin(program, io);
1012
1395
  registerApi(program, io);
1013
1396
  return program;
1014
1397
  }