@plenary/cli 1.0.1 → 1.1.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
@@ -393,6 +393,35 @@ async function readSecret(io, prompt) {
393
393
  }
394
394
  }
395
395
 
396
+ // src/entries/client.ts
397
+ import { createClient } from "@plenary/client";
398
+ function requireSiteKey(ctx) {
399
+ if (!ctx.siteKey) {
400
+ throw usageError("no site key: pass --site, set PLENARY_SITE_KEY, or set siteKey in plenary.json");
401
+ }
402
+ return ctx.siteKey;
403
+ }
404
+ async function resolveLocale(ctx, siteKey) {
405
+ if (ctx.locale) return ctx.locale;
406
+ const sites = await request(ctx, "GET", "sites");
407
+ const site = sites.data.find((s) => s.key === siteKey);
408
+ if (!site) throw usageError(`site "${siteKey}" not found on the instance`);
409
+ return site.default_locale;
410
+ }
411
+ async function contentClient(ctx) {
412
+ const siteKey = requireSiteKey(ctx);
413
+ const baseUrl = requireBaseUrl(ctx);
414
+ const auth = await credentialHeaders(ctx, "required");
415
+ const apiKey = auth["X-API-KEY"] ?? "";
416
+ const fetch = apiKey ? ctx.io.fetch : (input, init = {}) => {
417
+ const headers = new Headers(init.headers);
418
+ headers.delete("X-API-KEY");
419
+ for (const [k, v] of Object.entries(auth)) headers.set(k, v);
420
+ return ctx.io.fetch(input, { ...init, headers });
421
+ };
422
+ return createClient({ baseUrl, siteKey, apiKey, locale: await resolveLocale(ctx, siteKey), fetch });
423
+ }
424
+
396
425
  // src/commands/admin.ts
397
426
  function collect(value, previous) {
398
427
  return [...previous, value];
@@ -404,8 +433,8 @@ function parseGrant(spec) {
404
433
  for (const f of filters) {
405
434
  const [name, value] = f.split("=");
406
435
  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>]`);
436
+ else if (name === "model" && value) grant.content_model_key = value;
437
+ else throw usageError(`invalid --grant "${spec}": expected key[,site=<key>][,model=<key>]`);
409
438
  }
410
439
  return grant;
411
440
  }
@@ -427,10 +456,11 @@ function registerAdmin(program, io) {
427
456
  ]);
428
457
  io.stdout.write(formatTable(rows, ["KEY", "NAME", "DEFAULT", "LOCALES", "TIMEZONE", ""]) + "\n");
429
458
  });
430
- const roles = program.command("roles").description("Roles (needs a user session)");
431
- roles.command("list").description("List roles").action(async (_flags, cmd) => {
459
+ const roles = program.command("roles").description("The configured site's roles (needs a super user session)");
460
+ roles.command("list").description("List the site's roles").action(async (_flags, cmd) => {
432
461
  const ctx = resolveContext(cmd.optsWithGlobals(), io);
433
- const res = await request(ctx, "GET", "roles", { auth: "session" });
462
+ const site = requireSiteKey(ctx);
463
+ const res = await request(ctx, "GET", `sites/${encodeURIComponent(site)}/roles`, { auth: "session" });
434
464
  if (ctx.json) {
435
465
  io.stdout.write(toJson(res.data) + "\n");
436
466
  return;
@@ -444,21 +474,25 @@ function registerAdmin(program, io) {
444
474
  ]);
445
475
  io.stdout.write(formatTable(rows, ["KEY", "NAME", "GRANTS", "USERS", ""]) + "\n");
446
476
  });
447
- roles.command("get").description("Show a role and its grants").argument("<key>").action(async (key, _flags, cmd) => {
477
+ roles.command("get").description("Show one of the site's roles and its grants").argument("<key>").action(async (key, _flags, cmd) => {
448
478
  const ctx = resolveContext(cmd.optsWithGlobals(), io);
449
- const res = await request(ctx, "GET", `roles/${encodeURIComponent(key)}`, { auth: "session" });
479
+ const site = requireSiteKey(ctx);
480
+ const res = await request(ctx, "GET", `sites/${encodeURIComponent(site)}/roles/${encodeURIComponent(key)}`, {
481
+ auth: "session"
482
+ });
450
483
  if (ctx.json) {
451
484
  io.stdout.write(toJson(res.data) + "\n");
452
485
  return;
453
486
  }
454
487
  const r = res.data;
455
488
  const head = [
489
+ ["Site", r.site_key],
456
490
  ["Key", r.key],
457
491
  ["Name", r.name],
458
492
  ["System", r.is_system ? "yes" : "no"]
459
493
  ];
460
494
  if (r.description) head.push(["Description", r.description]);
461
- const grants = r.grants.map((g) => [g.key, g.site_key ?? "", g.content_model_id ?? ""]);
495
+ const grants = r.grants.map((g) => [g.key, g.site_key ?? "", g.content_model_key ?? ""]);
462
496
  io.stdout.write(formatTable(head) + "\n\n" + formatTable(grants, ["GRANT", "SITE", "MODEL"]) + "\n");
463
497
  });
464
498
  const keys = program.command("api-keys").description("API keys (needs a user session)");
@@ -484,10 +518,14 @@ function registerAdmin(program, io) {
484
518
  const res = await request(ctx, "GET", `api-keys/${encodeURIComponent(id)}`, { auth: "session" });
485
519
  io.stdout.write(toJson(res.data) + "\n");
486
520
  });
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>", "the site the key belongs to: a public key reads it, a private key's grants are held to it").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(
521
+ keys.command("create").description(
522
+ "Create an API key; the secret is printed once, alone on stdout. --site names the site the key belongs to: a public key reads it (default: the configured site), a private key's grants are held to it"
523
+ ).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=<model key>] (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
524
  async (flags, cmd) => {
489
- const ctx = resolveContext(cmd.optsWithGlobals(), io);
525
+ const globals = cmd.optsWithGlobals();
526
+ const ctx = resolveContext(globals, io);
490
527
  if (flags.type !== "private" && flags.type !== "public") throw usageError("--type must be private or public");
528
+ const site = globals.site ?? (flags.type === "public" ? ctx.siteKey : void 0);
491
529
  const body = {
492
530
  name: flags.name,
493
531
  description: flags.description ?? null,
@@ -495,7 +533,7 @@ function registerAdmin(program, io) {
495
533
  is_active: true,
496
534
  expires_at: flags.expires ?? null,
497
535
  grants: flags.grant.map(parseGrant),
498
- ...flags.site ? { site_key: flags.site } : {},
536
+ ...site ? { site_key: site } : {},
499
537
  allowed_origins: flags.origin,
500
538
  rate_limit_per_minute: Number(flags.rateLimit),
501
539
  include_drafts: Boolean(flags.includeDrafts)
@@ -568,6 +606,12 @@ Examples:
568
606
  }
569
607
 
570
608
  // src/commands/auth.ts
609
+ function describeUser(me) {
610
+ if (me.is_super_user) return "super user";
611
+ const roles = /* @__PURE__ */ new Set();
612
+ for (const g of me.grants) if (g.role_key && g.site_key) roles.add(`${g.role_key} on ${g.site_key}`);
613
+ return roles.size ? [...roles].join(", ") : "no site roles";
614
+ }
571
615
  async function login(ctx, identifier, password, otpMethod) {
572
616
  const io = ctx.io;
573
617
  const first = await request(ctx, "POST", "auth/login", {
@@ -631,11 +675,11 @@ $XDG_CONFIG_HOME/plenary/credentials.json, keyed by instance.`
631
675
  const me = await request(ctx, "GET", "auth/me", { auth: "session" });
632
676
  if (ctx.json) {
633
677
  io.stdout.write(
634
- toJson({ base_url: baseUrl, identifier, role: me.data.role, credentials: credentialsPath(io) }) + "\n"
678
+ toJson({ base_url: baseUrl, identifier, is_super_user: me.data.is_super_user, credentials: credentialsPath(io) }) + "\n"
635
679
  );
636
680
  return;
637
681
  }
638
- io.stdout.write(`logged in to ${baseUrl} as ${identifier} (${me.data.role_name ?? me.data.role})
682
+ io.stdout.write(`logged in to ${baseUrl} as ${identifier} (${describeUser(me.data)})
639
683
  `);
640
684
  });
641
685
  program.command("logout").description("Revoke the stored session for this instance").action(async (_flags, cmd) => {
@@ -681,9 +725,9 @@ $XDG_CONFIG_HOME/plenary/credentials.json, keyed by instance.`
681
725
  if (stored) {
682
726
  const headers = await credentialHeaders(ctx, "session");
683
727
  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 };
728
+ out.session = { identifier: stored.identifier ?? null, is_super_user: me.data.is_super_user };
685
729
  lines.push(
686
- `Session ${stored.identifier ?? "user"} (${me.data.role_name ?? me.data.role}), used for admin commands${ctx.apiKey ? "" : " and content"}`
730
+ `Session ${stored.identifier ?? "user"} (${describeUser(me.data)}), used for admin commands${ctx.apiKey ? "" : " and content"}`
687
731
  );
688
732
  }
689
733
  if (lines.length === 0) lines.push("no credentials: set PLENARY_CLI_API_KEY or run `plenary login`");
@@ -749,34 +793,8 @@ function registerCatalog(program, io, name) {
749
793
  return group;
750
794
  }
751
795
 
752
- // src/entries/client.ts
753
- import { createClient } from "@plenary/client";
754
- function requireSiteKey(ctx) {
755
- if (!ctx.siteKey) {
756
- throw usageError("no site key: pass --site, set PLENARY_SITE_KEY, or set siteKey in plenary.json");
757
- }
758
- return ctx.siteKey;
759
- }
760
- async function resolveLocale(ctx, siteKey) {
761
- if (ctx.locale) return ctx.locale;
762
- const sites = await request(ctx, "GET", "sites");
763
- const site = sites.data.find((s) => s.key === siteKey);
764
- if (!site) throw usageError(`site "${siteKey}" not found on the instance`);
765
- return site.default_locale;
766
- }
767
- async function contentClient(ctx) {
768
- const siteKey = requireSiteKey(ctx);
769
- const baseUrl = requireBaseUrl(ctx);
770
- const auth = await credentialHeaders(ctx, "required");
771
- const apiKey = auth["X-API-KEY"] ?? "";
772
- const fetch = apiKey ? ctx.io.fetch : (input, init = {}) => {
773
- const headers = new Headers(init.headers);
774
- headers.delete("X-API-KEY");
775
- for (const [k, v] of Object.entries(auth)) headers.set(k, v);
776
- return ctx.io.fetch(input, { ...init, headers });
777
- };
778
- return createClient({ baseUrl, siteKey, apiKey, locale: await resolveLocale(ctx, siteKey), fetch });
779
- }
796
+ // src/commands/entries.ts
797
+ import { PlenaryNotFoundError } from "@plenary/client";
780
798
 
781
799
  // src/entries/query.ts
782
800
  var OPERATORS = /* @__PURE__ */ new Set(["$eq", "$ne", "$lt", "$lte", "$gt", "$gte", "$in", "$nin", "$like", "$ilike", "$is_null"]);
@@ -850,6 +868,96 @@ async function toListOptions(flags, stdin) {
850
868
  return opts;
851
869
  }
852
870
 
871
+ // src/commands/folders.ts
872
+ function contentFolders(ctx, model) {
873
+ return { path: `content/${encodeURIComponent(model)}/folders`, siteKey: requireSiteKey(ctx) };
874
+ }
875
+ function mediaFolders(ctx) {
876
+ return { path: "media/folders", siteKey: ctx.siteKey };
877
+ }
878
+ function normalizePath(p) {
879
+ const trimmed = p.trim().replace(/\/+$/, "");
880
+ return trimmed === "" ? "/" : trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
881
+ }
882
+ async function listFolders(ctx, scope) {
883
+ const res = await request(ctx, "GET", scope.path, { query: { site_key: scope.siteKey } });
884
+ return res.data.folders;
885
+ }
886
+ async function findFolder(ctx, scope, ref) {
887
+ const folders = await listFolders(ctx, scope);
888
+ const byPath = ref.startsWith("/");
889
+ const path7 = normalizePath(ref);
890
+ const matches = folders.filter((f) => byPath ? f.path === path7 : f.id === ref);
891
+ if (matches.length === 0) throw usageError(`no folder ${ref}`);
892
+ if (matches.length > 1) throw usageError(`${ref} is a folder on several sites; pass --site`);
893
+ return matches[0];
894
+ }
895
+ async function folderIdAt(ctx, scope, path7) {
896
+ if (normalizePath(path7) === "/") return null;
897
+ return (await findFolder(ctx, scope, normalizePath(path7))).id;
898
+ }
899
+ function registerFolderCommands(parent, io, kind) {
900
+ const scopeFor = (ctx, model) => kind === "content" ? contentFolders(ctx, model) : mediaFolders(ctx);
901
+ const withModel = (cmd) => kind === "content" ? cmd.argument("<model>") : cmd;
902
+ const split = (args) => {
903
+ const cmd = args[args.length - 1];
904
+ const flags = args[args.length - 2];
905
+ const positional = args.slice(0, -2);
906
+ const model = kind === "content" ? positional.shift() : void 0;
907
+ return { ctx: resolveContext(cmd.optsWithGlobals(), io), model, positional, flags };
908
+ };
909
+ const countHeader = kind === "content" ? "ENTRIES" : "MEDIA";
910
+ withModel(parent.command("list").description("List folders by path")).action(async (...args) => {
911
+ const { ctx, model } = split(args);
912
+ const folders = await listFolders(ctx, scopeFor(ctx, model));
913
+ if (ctx.json) {
914
+ io.stdout.write(toJson(folders) + "\n");
915
+ return;
916
+ }
917
+ const rows = folders.map((f) => [f.id, f.path, f.name, String(f.entry_count ?? f.media_count ?? "")]);
918
+ io.stdout.write(formatTable(rows, ["ID", "PATH", "NAME", countHeader]) + "\n");
919
+ });
920
+ withModel(parent.command("get").description("Print one folder as JSON")).argument("<id-or-path>", "a folder id, or its path such as /company/team").action(async (...args) => {
921
+ const { ctx, model, positional } = split(args);
922
+ io.stdout.write(toJson(await findFolder(ctx, scopeFor(ctx, model), positional[0])) + "\n");
923
+ });
924
+ withModel(parent.command("create").description("Create a folder; its parent must exist")).argument("<path>", "the new folder's path, such as /company/team; the last segment is its slug").option("--name <name>", "display name (default: the slug)").action(async (...args) => {
925
+ const { ctx, model, positional, flags } = split(args);
926
+ const scope = scopeFor(ctx, model);
927
+ const path7 = normalizePath(positional[0]);
928
+ if (path7 === "/") throw usageError("the root folder always exists");
929
+ const slug = path7.slice(path7.lastIndexOf("/") + 1);
930
+ const parentId = await folderIdAt(ctx, scope, path7.slice(0, path7.lastIndexOf("/")));
931
+ const res = await request(ctx, "POST", scope.path, {
932
+ query: { site_key: scope.siteKey },
933
+ body: { parent_id: parentId, name: flags.name ?? slug, slug }
934
+ });
935
+ if (ctx.json) io.stdout.write(toJson(res.data) + "\n");
936
+ else io.stdout.write(`created ${res.data.id} ${res.data.path}
937
+ `);
938
+ });
939
+ withModel(
940
+ parent.command("delete").description(`Delete a folder; its sub-folders and ${kind === "content" ? "entries" : "media"} move up to its parent`)
941
+ ).argument("<id-or-path>").option("-y, --yes", "do not ask").action(async (...args) => {
942
+ const { ctx, model, positional, flags } = split(args);
943
+ const scope = scopeFor(ctx, model);
944
+ const folder = await findFolder(ctx, scope, positional[0]);
945
+ await confirm(io, `Delete folder ${folder.path}?`, Boolean(flags.yes));
946
+ await request(ctx, "DELETE", `${scope.path}/${encodeURIComponent(folder.id)}`, {
947
+ query: { site_key: kind === "content" ? scope.siteKey : void 0 }
948
+ });
949
+ if (ctx.json) io.stdout.write(toJson({ id: folder.id, deleted: true }) + "\n");
950
+ else io.stdout.write(`deleted ${folder.id} ${folder.path}
951
+ `);
952
+ });
953
+ }
954
+ function registerContentFolders(program, io) {
955
+ registerFolderCommands(program.command("folders").description("A model's content folders"), io, "content");
956
+ }
957
+ function registerMediaFolders(media, io) {
958
+ registerFolderCommands(media.command("folders").description("Media library folders"), io, "media");
959
+ }
960
+
853
961
  // src/commands/entries.ts
854
962
  function collect3(value, previous) {
855
963
  return [...previous, value];
@@ -858,10 +966,13 @@ function withListFlags(cmd) {
858
966
  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");
859
967
  }
860
968
  function withWriteFlags(cmd) {
861
- 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");
969
+ 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("--folder-path <path>", "content folder path, such as /company (instead of --folder; / is the root)").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");
862
970
  }
863
971
  function entryRow(e) {
864
- return [e.id, e.status, e.slug ?? "", e.title ?? "", e.updated_at];
972
+ return [e.id ?? "", statusLabel(e), e.slug ?? "", e.title ?? "", e.updated_at ?? ""];
973
+ }
974
+ function statusLabel(e) {
975
+ return `${e.status ?? ""}${e.draft ? "*" : ""}`;
865
976
  }
866
977
  var ENTRY_HEADER = ["ID", "STATUS", "SLUG", "TITLE", "UPDATED"];
867
978
  var PAGE = 100;
@@ -885,6 +996,9 @@ function registerEntries(program, io) {
885
996
  const ctx = resolveContext(cmd.optsWithGlobals(), io);
886
997
  const client = await contentClient(ctx);
887
998
  const result = await client.listEntries(model, await toListOptions(flags, io.stdin));
999
+ if (!Array.isArray(result.items)) {
1000
+ throw usageError(`${model} is a singleton; use 'plenary entries get ${model}'`);
1001
+ }
888
1002
  if (ctx.json) {
889
1003
  io.stdout.write(toJson(result) + "\n");
890
1004
  return;
@@ -908,17 +1022,26 @@ function registerEntries(program, io) {
908
1022
  const body = { fields: await readObject(flags.data, io.stdin) };
909
1023
  if (ctx.locale) body.locale = ctx.locale;
910
1024
  applyWriteFlags(body, flags);
1025
+ await applyFolderPath(ctx, model, body, flags);
911
1026
  let entry = (await request(ctx, "POST", `content/${encodeURIComponent(model)}`, { query: { site_key: siteKey }, body })).data;
912
1027
  if (flags.publish) entry = await publish(ctx, entry.id, "publish");
913
1028
  report(ctx, entry, "created");
914
1029
  });
915
1030
  withWriteFlags(
916
1031
  entries.command("update").description("Update an entry: --data is merged into its fields (a null removes one)").argument("<model>").argument("<slug-or-id>")
1032
+ ).option("--publish", "publish it right after updating (on a published entry, publish the pending changes)").addHelpText(
1033
+ "after",
1034
+ `
1035
+ On a published entry the update is saved as pending changes and the live
1036
+ copy stays as it was until the entry is published again; --data is then
1037
+ merged into the pending copy. --publish makes the change live in a second
1038
+ call.`
917
1039
  ).action(async (model, slug, flags, cmd) => {
918
1040
  const ctx = resolveContext(cmd.optsWithGlobals(), io);
919
1041
  const client = await contentClient(ctx);
920
1042
  const current = await client.getEntry(model, slug);
921
- const fields = { ...current.data };
1043
+ const working = current.draft ?? current;
1044
+ const fields = { ...working.data };
922
1045
  if (flags.data !== void 0) {
923
1046
  for (const [k, v] of Object.entries(await readObject(flags.data, io.stdin))) {
924
1047
  if (v === null) delete fields[k];
@@ -927,14 +1050,16 @@ function registerEntries(program, io) {
927
1050
  }
928
1051
  const body = {
929
1052
  fields,
930
- slug: current.slug ?? void 0,
1053
+ slug: working.slug ?? void 0,
931
1054
  locale: current.locale ?? void 0,
932
1055
  folder_id: current.folder_id,
933
1056
  publish_at: current.publish_at,
934
1057
  unpublish_at: current.unpublish_at
935
1058
  };
936
1059
  applyWriteFlags(body, flags);
937
- const entry = (await request(ctx, "PUT", `content/${encodeURIComponent(current.id)}`, { body })).data;
1060
+ await applyFolderPath(ctx, model, body, flags);
1061
+ let entry = (await request(ctx, "PUT", `content/${encodeURIComponent(current.id)}`, { body })).data;
1062
+ if (flags.publish) entry = await publish(ctx, entry.id, "publish");
938
1063
  report(ctx, entry, "updated");
939
1064
  });
940
1065
  entries.command("delete").description("Delete an entry (recoverable from history while the model tracks revisions)").argument("<id>").option("--note <text>", "revision note").option("-y, --yes", "do not ask").action(async (id, flags, cmd) => {
@@ -945,12 +1070,20 @@ function registerEntries(program, io) {
945
1070
  else io.stdout.write(`deleted ${id}
946
1071
  `);
947
1072
  });
948
- for (const action of ["publish", "unpublish"]) {
949
- entries.command(action).description(`${action === "publish" ? "Publish" : "Unpublish"} an entry now`).argument("<id>").action(async (id, _flags, cmd) => {
950
- const ctx = resolveContext(cmd.optsWithGlobals(), io);
951
- report(ctx, await publish(ctx, id, action), action === "publish" ? "published" : "unpublished");
952
- });
953
- }
1073
+ entries.command("publish").description("Publish an entry now; on a published entry, make its pending changes live").argument("<id>").action(async (id, _flags, cmd) => {
1074
+ const ctx = resolveContext(cmd.optsWithGlobals(), io);
1075
+ report(ctx, await publish(ctx, id, "publish"), "published");
1076
+ });
1077
+ entries.command("unpublish").description("Unpublish an entry now").argument("<id>").option("--draft <merge|discard>", "with changes pending: keep them as the draft's content (merge) or drop them (discard)").action(async (id, flags, cmd) => {
1078
+ const ctx = resolveContext(cmd.optsWithGlobals(), io);
1079
+ const entry = (await request(ctx, "POST", `content/${encodeURIComponent(id)}/unpublish`, { query: { draft: flags.draft } })).data;
1080
+ report(ctx, entry, "unpublished");
1081
+ });
1082
+ entries.command("discard").description("Drop the pending changes on a published entry, leaving the live copy as it is").argument("<id>").option("--note <text>", "revision note").action(async (id, flags, cmd) => {
1083
+ const ctx = resolveContext(cmd.optsWithGlobals(), io);
1084
+ const entry = (await request(ctx, "DELETE", `content/${encodeURIComponent(id)}/draft`, { query: { note: flags.note } })).data;
1085
+ report(ctx, entry, "discarded");
1086
+ });
954
1087
  withListFlags(
955
1088
  entries.command("export").description("Write every matching entry to stdout as NDJSON").argument("<model>")
956
1089
  ).action(async (model, flags, cmd) => {
@@ -960,16 +1093,23 @@ function registerEntries(program, io) {
960
1093
  io.stdout.write(JSON.stringify(e) + "\n");
961
1094
  }
962
1095
  });
963
- entries.command("import").description("Create one entry per NDJSON line (exported entries or {fields, slug, \u2026} bodies)").argument("<model>").argument("<file>", "a file, or - for stdin").option("--publish", "publish each entry after creating it").addHelpText(
1096
+ entries.command("import").description("Create one entry per NDJSON line (exported entries or {fields, slug, \u2026} bodies)").argument("<model>").argument("<file>", "a file, or - for stdin").option("--publish", "publish each entry after writing it").option("--upsert", "update the entry with the line's slug when it exists, instead of failing").addHelpText(
964
1097
  "after",
965
1098
  `
966
1099
  Every line is tried; a line that fails is reported with its number and the
967
- run continues, exiting 1 at the end if any failed.`
1100
+ run continues, exiting 1 at the end if any failed.
1101
+
1102
+ With --upsert a line is matched by its slug (unique per site, model and
1103
+ locale), so a seed file can be imported again. A match has its fields
1104
+ replaced by the line's; the slug, folder and schedule it doesn't state are
1105
+ kept. A line without a slug fails.`
968
1106
  ).action(async (model, file, flags, cmd) => {
969
1107
  const ctx = resolveContext(cmd.optsWithGlobals(), io);
970
1108
  const siteKey = requireSiteKey(ctx);
1109
+ const client = flags.upsert ? await contentClient(ctx) : void 0;
971
1110
  const text = await readText(file === "-" ? "-" : `@${file}`, io.stdin);
972
1111
  let failed = 0;
1112
+ let updated = 0;
973
1113
  let n = 0;
974
1114
  for (const raw of text.split("\n")) {
975
1115
  const line = raw.trim();
@@ -977,12 +1117,23 @@ run continues, exiting 1 at the end if any failed.`
977
1117
  n++;
978
1118
  try {
979
1119
  const body = importBody(JSON.parse(line), ctx);
980
- let entry = (await request(ctx, "POST", `content/${encodeURIComponent(model)}`, {
1120
+ const current = client && await findBySlug(client, model, body);
1121
+ let entry = current ? (await request(ctx, "PUT", `content/${encodeURIComponent(current.id)}`, {
1122
+ body: {
1123
+ slug: current.slug ?? void 0,
1124
+ locale: current.locale ?? void 0,
1125
+ folder_id: current.folder_id,
1126
+ publish_at: current.publish_at,
1127
+ unpublish_at: current.unpublish_at,
1128
+ ...body
1129
+ }
1130
+ })).data : (await request(ctx, "POST", `content/${encodeURIComponent(model)}`, {
981
1131
  query: { site_key: siteKey },
982
1132
  body
983
1133
  })).data;
1134
+ if (current) updated++;
984
1135
  if (flags.publish) entry = await publish(ctx, entry.id, "publish");
985
- io.stdout.write(`ok ${entry.id}${entry.slug ? ` ${entry.slug}` : ""}
1136
+ io.stdout.write(`ok ${entry.id}${entry.slug ? ` ${entry.slug}` : ""}${current ? " (updated)" : ""}
986
1137
  `);
987
1138
  } catch (err) {
988
1139
  failed++;
@@ -991,7 +1142,7 @@ run continues, exiting 1 at the end if any failed.`
991
1142
  `);
992
1143
  }
993
1144
  }
994
- io.stderr.write(`${n - failed} created, ${failed} failed
1145
+ io.stderr.write(`${n - failed - updated} created, ${updated ? `${updated} updated, ` : ""}${failed} failed
995
1146
  `);
996
1147
  if (failed) throw new CliError("", 1);
997
1148
  });
@@ -1003,6 +1154,11 @@ function applyWriteFlags(body, flags) {
1003
1154
  if (flags.unpublishAt !== void 0) body.unpublish_at = flags.unpublishAt;
1004
1155
  if (flags.note !== void 0) body.note = flags.note;
1005
1156
  }
1157
+ async function applyFolderPath(ctx, model, body, flags) {
1158
+ if (flags.folderPath === void 0) return;
1159
+ if (flags.folder !== void 0) throw usageError("pass --folder or --folder-path, not both");
1160
+ body.folder_id = await folderIdAt(ctx, contentFolders(ctx, model), flags.folderPath);
1161
+ }
1006
1162
  function importBody(line, ctx) {
1007
1163
  if ("fields" in line) return line;
1008
1164
  if (!("data" in line)) throw usageError("expected an object with fields or data");
@@ -1015,9 +1171,18 @@ function importBody(line, ctx) {
1015
1171
  if (e.unpublish_at) body.unpublish_at = e.unpublish_at;
1016
1172
  return body;
1017
1173
  }
1174
+ async function findBySlug(client, model, body) {
1175
+ if (!body.slug) throw usageError("--upsert needs a slug on every line");
1176
+ try {
1177
+ return await client.getEntry(model, body.slug, body.locale ? { locale: body.locale } : {});
1178
+ } catch (err) {
1179
+ if (err instanceof PlenaryNotFoundError) return void 0;
1180
+ throw err;
1181
+ }
1182
+ }
1018
1183
  function report(ctx, entry, verb) {
1019
1184
  if (ctx.json) ctx.io.stdout.write(toJson(entry) + "\n");
1020
- else ctx.io.stdout.write(`${verb} ${entry.id}${entry.slug ? ` ${entry.slug}` : ""} (${entry.status})
1185
+ else ctx.io.stdout.write(`${verb} ${entry.id}${entry.slug ? ` ${entry.slug}` : ""} (${statusLabel(entry)})
1021
1186
  `);
1022
1187
  }
1023
1188
 
@@ -1090,15 +1255,53 @@ function summary(data) {
1090
1255
  // src/commands/media.ts
1091
1256
  import { readFileSync as readFileSync4 } from "fs";
1092
1257
  import path3 from "path";
1258
+ var MIME_BY_EXT = {
1259
+ ".jpg": "image/jpeg",
1260
+ ".jpeg": "image/jpeg",
1261
+ ".png": "image/png",
1262
+ ".gif": "image/gif",
1263
+ ".webp": "image/webp",
1264
+ ".avif": "image/avif",
1265
+ ".svg": "image/svg+xml",
1266
+ ".tif": "image/tiff",
1267
+ ".tiff": "image/tiff",
1268
+ ".bmp": "image/bmp",
1269
+ ".mp4": "video/mp4",
1270
+ ".webm": "video/webm",
1271
+ ".ogv": "video/ogg",
1272
+ ".mov": "video/quicktime",
1273
+ ".mp3": "audio/mpeg",
1274
+ ".ogg": "audio/ogg",
1275
+ ".wav": "audio/wav",
1276
+ ".flac": "audio/flac",
1277
+ ".pdf": "application/pdf",
1278
+ ".zip": "application/zip",
1279
+ ".txt": "text/plain",
1280
+ ".csv": "text/csv"
1281
+ };
1282
+ function mimeForFile(file) {
1283
+ return MIME_BY_EXT[path3.extname(file).toLowerCase()] ?? "application/octet-stream";
1284
+ }
1093
1285
  function size(bytes) {
1094
1286
  if (bytes < 1024) return `${bytes} B`;
1095
1287
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1096
1288
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1097
1289
  }
1290
+ async function findExisting(ctx, name, folder) {
1291
+ const pattern = `^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`;
1292
+ const res = await request(ctx, "GET", "media", {
1293
+ query: { search: pattern, site_key: ctx.siteKey, limit: "100" }
1294
+ });
1295
+ return res.data.find((m) => m.file_name === name && (m.folder_id ?? void 0) === folder);
1296
+ }
1098
1297
  function registerMedia(program, io) {
1099
1298
  const media = program.command("media").description("Media library");
1100
- media.command("upload").description("Upload one or more files").argument("<files...>").option("--folder <id>", "media folder id").option("--private", "store as a private asset").action(async (files, flags, cmd) => {
1299
+ media.command("upload").description("Upload one or more files").argument("<files...>").option("--folder <id>", "media folder id").option("--folder-path <path>", "media folder path, such as /brand/logos (instead of --folder)").option("--private", "store as a private asset").option("--skip-existing", "skip a file whose name is already in the folder").action(async (files, flags, cmd) => {
1101
1300
  const ctx = resolveContext(cmd.optsWithGlobals(), io);
1301
+ if (flags.folderPath !== void 0) {
1302
+ if (flags.folder !== void 0) throw usageError("pass --folder or --folder-path, not both");
1303
+ flags.folder = await folderIdAt(ctx, mediaFolders(ctx), flags.folderPath) ?? void 0;
1304
+ }
1102
1305
  const uploaded = [];
1103
1306
  let failed = 0;
1104
1307
  for (const file of files) {
@@ -1108,8 +1311,17 @@ function registerMedia(program, io) {
1108
1311
  } catch (err) {
1109
1312
  throw usageError(`cannot read ${file}: ${err.message}`);
1110
1313
  }
1314
+ if (flags.skipExisting) {
1315
+ const existing = await findExisting(ctx, path3.basename(file), flags.folder);
1316
+ if (existing) {
1317
+ uploaded.push(existing);
1318
+ if (!ctx.json) io.stdout.write(`${existing.media_id} ${existing.file_name} (exists)
1319
+ `);
1320
+ continue;
1321
+ }
1322
+ }
1111
1323
  const form = new FormData();
1112
- form.append("upload_file", new Blob([new Uint8Array(bytes)]), path3.basename(file));
1324
+ form.append("upload_file", new Blob([new Uint8Array(bytes)], { type: mimeForFile(file) }), path3.basename(file));
1113
1325
  if (ctx.siteKey) form.append("site_key", ctx.siteKey);
1114
1326
  if (flags.folder) form.append("folder_id", flags.folder);
1115
1327
  if (flags.private) form.append("visibility", "private");
@@ -1146,6 +1358,7 @@ function registerMedia(program, io) {
1146
1358
  const res = await request(ctx, "GET", `media/${encodeURIComponent(id)}`);
1147
1359
  io.stdout.write(toJson(res.data) + "\n");
1148
1360
  });
1361
+ registerMediaFolders(media, io);
1149
1362
  }
1150
1363
 
1151
1364
  // src/commands/metadata.ts
@@ -1251,9 +1464,9 @@ function kindsOf(opts, site) {
1251
1464
  } catch (err) {
1252
1465
  throw usageError(`--only: ${err.message}`);
1253
1466
  }
1254
- if (!site) return kinds;
1467
+ if (site) return kinds;
1255
1468
  if (opts.only && opts.only !== "all" && kinds.includes("roles")) {
1256
- throw usageError(`roles are instance-wide and not part of site ${site}'s tree; use --all-sites`);
1469
+ throw usageError("roles belong to a site and are not part of the instance-wide tree; drop --all-sites");
1257
1470
  }
1258
1471
  return kinds.filter((k) => k !== "roles");
1259
1472
  }
@@ -1307,7 +1520,7 @@ async function runImport(ctx, root, opts, apply) {
1307
1520
  else ctx.io.stdout.write(formatPlan(res.data, Boolean(opts.verbose)) + "\n");
1308
1521
  }
1309
1522
  var onlyOption = ["--only <kinds>", "comma list of models, forms, roles, sites (default: all)"];
1310
- var allSitesOption = ["--all-sites", "the whole instance, not just the configured site (roles need this)"];
1523
+ var allSitesOption = ["--all-sites", "the whole instance, not just the configured site (no roles)"];
1311
1524
  function registerMetadata(program, io) {
1312
1525
  const metadata = program.command("metadata").description("Keep a directory of model and form YAML in step with the instance").addHelpText(
1313
1526
  "after",
@@ -1315,10 +1528,11 @@ function registerMetadata(program, io) {
1315
1528
  The tree is models/, forms/, roles/ and sites/ under the metadata directory
1316
1529
  ("metadataDir" in plenary.json, default ./plenary), one <key>.yaml per
1317
1530
  entity. With a site configured (--site, PLENARY_SITE_KEY or "siteKey" in
1318
- plenary.json) the tree is that site's alone: its models, forms and site file,
1319
- never roles, and --prune deletes only within it. --all-sites moves the whole
1320
- instance. Models and forms need an API key or a session; roles and sites need
1321
- a user session (plenary login). See docs/CLI.md.`
1531
+ plenary.json) the tree is that site's alone: its models, forms, roles and
1532
+ site file, and --prune deletes only within it. --all-sites moves the whole
1533
+ instance, which holds no roles. Models and forms need an API key or a
1534
+ session; roles need a super user session and sites a session that may update
1535
+ them (plenary login). See docs/CLI.md.`
1322
1536
  );
1323
1537
  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).option(...allSitesOption).action(async (opts, cmd) => {
1324
1538
  const ctx = resolveContext(cmd.optsWithGlobals(), io);
@@ -1455,7 +1669,7 @@ function registerStatus(program, io) {
1455
1669
  if (me?.principal === "api_key") {
1456
1670
  lines.push(`Credential ${me.key_type} API key "${me.name}" (${me.grants.length} grant${me.grants.length === 1 ? "" : "s"}${me.site_key ? `, site ${me.site_key}` : ", instance-wide"})`);
1457
1671
  } else if (me?.principal === "user") {
1458
- lines.push(`Credential user session (stored login), role ${me.role_name ?? me.role}`);
1672
+ lines.push(`Credential user session (stored login), ${describeUser(me)}`);
1459
1673
  } else if (credentialError) {
1460
1674
  lines.push(`Credential rejected: ${credentialError.code} (HTTP ${credentialError.status})`);
1461
1675
  } else if (undescribed) {
@@ -1490,6 +1704,7 @@ function createProgram(io) {
1490
1704
  registerCatalog(program, io, "models");
1491
1705
  registerFormEntries(registerCatalog(program, io, "forms"), io);
1492
1706
  registerEntries(program, io);
1707
+ registerContentFolders(program, io);
1493
1708
  registerMedia(program, io);
1494
1709
  registerAdmin(program, io);
1495
1710
  registerApi(program, io);