@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 +285 -70
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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.
|
|
408
|
-
else throw usageError(`invalid --grant "${spec}": expected key[,site=<key>][,model=<
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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(
|
|
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
|
|
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
|
-
...
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
753
|
-
import {
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
1467
|
+
if (site) return kinds;
|
|
1255
1468
|
if (opts.only && opts.only !== "all" && kinds.includes("roles")) {
|
|
1256
|
-
throw usageError(
|
|
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
|
|
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
|
|
1319
|
-
|
|
1320
|
-
instance. Models and forms need an API key or a
|
|
1321
|
-
a user session
|
|
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),
|
|
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);
|