@plenary/cli 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/dist/index.js +524 -89
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
250
|
+
async function request(ctx, method, path7, opts = {}) {
|
|
174
251
|
const base = requireBaseUrl(ctx);
|
|
175
|
-
const
|
|
176
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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)",
|
|
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,
|
|
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,
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
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)",
|
|
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)",
|
|
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
|
|
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)",
|
|
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
|
|
709
|
-
import
|
|
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 =
|
|
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)]),
|
|
1109
|
+
form.append("upload_file", new Blob([new Uint8Array(bytes)]), path3.basename(file));
|
|
730
1110
|
if (flags.folder) form.append("folder_id", flags.folder);
|
|
731
1111
|
if (flags.private) form.append("visibility", "private");
|
|
732
1112
|
try {
|
|
@@ -765,27 +1145,40 @@ function registerMedia(program, io) {
|
|
|
765
1145
|
}
|
|
766
1146
|
|
|
767
1147
|
// src/commands/metadata.ts
|
|
768
|
-
import
|
|
1148
|
+
import path5 from "path";
|
|
769
1149
|
|
|
770
1150
|
// src/metadata/dir.ts
|
|
771
|
-
import { existsSync as
|
|
772
|
-
import
|
|
773
|
-
var MANAGED_DIRS = ["models", "forms"];
|
|
1151
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
|
|
1152
|
+
import path4 from "path";
|
|
1153
|
+
var MANAGED_DIRS = ["models", "forms", "roles", "sites"];
|
|
1154
|
+
function parseKinds(spec) {
|
|
1155
|
+
if (!spec || spec === "all") return [...MANAGED_DIRS];
|
|
1156
|
+
const out = [];
|
|
1157
|
+
for (const raw of spec.split(",")) {
|
|
1158
|
+
const k = raw.trim();
|
|
1159
|
+
if (!MANAGED_DIRS.includes(k)) throw new Error(`unknown kind "${k}" (want ${MANAGED_DIRS.join(", ")} or all)`);
|
|
1160
|
+
if (!out.includes(k)) out.push(k);
|
|
1161
|
+
}
|
|
1162
|
+
return out;
|
|
1163
|
+
}
|
|
1164
|
+
function filterKinds(files, kinds) {
|
|
1165
|
+
return files.filter((f) => kinds.includes(f.path.split("/")[0]));
|
|
1166
|
+
}
|
|
774
1167
|
function readTree(root) {
|
|
775
1168
|
const out = [];
|
|
776
1169
|
for (const rel of managedFiles(root)) {
|
|
777
|
-
out.push({ path: rel, content:
|
|
1170
|
+
out.push({ path: rel, content: readFileSync5(path4.join(root, rel), "utf8") });
|
|
778
1171
|
}
|
|
779
1172
|
return out;
|
|
780
1173
|
}
|
|
781
|
-
function writeTree(root, files) {
|
|
782
|
-
const before = new Set(managedFiles(root));
|
|
1174
|
+
function writeTree(root, files, kinds = [...MANAGED_DIRS]) {
|
|
1175
|
+
const before = new Set(managedFiles(root).filter((p) => kinds.includes(p.split("/")[0])));
|
|
783
1176
|
const written = [];
|
|
784
1177
|
for (const f of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
785
1178
|
if (!isTreePath(f.path)) continue;
|
|
786
|
-
const abs =
|
|
787
|
-
|
|
788
|
-
|
|
1179
|
+
const abs = path4.join(root, f.path);
|
|
1180
|
+
mkdirSync2(path4.dirname(abs), { recursive: true });
|
|
1181
|
+
writeFileSync2(abs, f.content);
|
|
789
1182
|
written.push(f.path);
|
|
790
1183
|
before.delete(f.path);
|
|
791
1184
|
}
|
|
@@ -798,8 +1191,8 @@ function isTreePath(p) {
|
|
|
798
1191
|
function managedFiles(root) {
|
|
799
1192
|
const out = [];
|
|
800
1193
|
for (const dir of MANAGED_DIRS) {
|
|
801
|
-
const abs =
|
|
802
|
-
if (!
|
|
1194
|
+
const abs = path4.join(root, dir);
|
|
1195
|
+
if (!existsSync3(abs)) continue;
|
|
803
1196
|
for (const name of readdirSync(abs).sort()) {
|
|
804
1197
|
if (name.endsWith(".yaml")) out.push(`${dir}/${name}`);
|
|
805
1198
|
}
|
|
@@ -810,20 +1203,24 @@ function managedFiles(root) {
|
|
|
810
1203
|
// src/metadata/plan.ts
|
|
811
1204
|
function formatPlan(result, verbose = false) {
|
|
812
1205
|
const lines = [];
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
if (c.action === "create") creates++;
|
|
818
|
-
else if (c.action === "update") updates++;
|
|
819
|
-
else noops++;
|
|
1206
|
+
const n = { create: 0, update: 0, delete: 0, skip: 0, noop: 0 };
|
|
1207
|
+
const ordered = [...result.changes].sort((a, b) => Number(b.action === "delete") - Number(a.action === "delete"));
|
|
1208
|
+
for (const c of ordered) {
|
|
1209
|
+
n[c.action]++;
|
|
820
1210
|
if (c.action === "noop" && !verbose) continue;
|
|
821
|
-
const label = c.action === "noop" ? "unchanged" : c.action;
|
|
822
|
-
|
|
1211
|
+
const label = c.action === "noop" ? "unchanged" : c.action === "skip" ? "skipped" : c.action;
|
|
1212
|
+
const note = c.action === "skip" ? " (system-owned)" : detail(c);
|
|
1213
|
+
lines.push(`${label.padEnd(9)} ${c.kind.padEnd(10)} ${c.key}${note}`);
|
|
1214
|
+
}
|
|
1215
|
+
const parts = [`${n.create} to create`, `${n.update} to update`];
|
|
1216
|
+
if (n.delete) parts.push(`${n.delete} to delete`);
|
|
1217
|
+
if (n.skip) parts.push(`${n.skip} skipped`);
|
|
1218
|
+
parts.push(`${n.noop} unchanged`);
|
|
1219
|
+
if (n.create + n.update + n.delete === 0) {
|
|
1220
|
+
lines.push(`No changes: ${n.noop} unchanged${n.skip ? `, ${n.skip} skipped` : ""}.`);
|
|
1221
|
+
} else {
|
|
1222
|
+
lines.push(`${result.applied ? "Applied" : "Plan"}: ${parts.join(", ")}.`);
|
|
823
1223
|
}
|
|
824
|
-
const counts = `${creates} to create, ${updates} to update, ${noops} unchanged`;
|
|
825
|
-
if (creates + updates === 0) lines.push(`No changes: ${noops} unchanged.`);
|
|
826
|
-
else lines.push(result.applied ? `Applied: ${counts}.` : `Plan: ${counts}.`);
|
|
827
1224
|
return lines.join("\n");
|
|
828
1225
|
}
|
|
829
1226
|
function detail(c) {
|
|
@@ -839,40 +1236,68 @@ function detail(c) {
|
|
|
839
1236
|
}
|
|
840
1237
|
|
|
841
1238
|
// src/commands/metadata.ts
|
|
1239
|
+
function kindsOf(opts) {
|
|
1240
|
+
try {
|
|
1241
|
+
return parseKinds(opts.only);
|
|
1242
|
+
} catch (err) {
|
|
1243
|
+
throw usageError(`--only: ${err.message}`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
842
1246
|
function treeRoot(ctx, opts) {
|
|
843
|
-
return opts.dir ?
|
|
1247
|
+
return opts.dir ? path5.resolve(ctx.io.cwd, opts.dir) : path5.resolve(ctx.projectRoot, ctx.metadataDir);
|
|
844
1248
|
}
|
|
845
|
-
function readTreeOrFail(root) {
|
|
846
|
-
const files = readTree(root);
|
|
847
|
-
if (files.length === 0) throw usageError(`no YAML files under ${root}/
|
|
1249
|
+
function readTreeOrFail(root, kinds) {
|
|
1250
|
+
const files = filterKinds(readTree(root), kinds);
|
|
1251
|
+
if (files.length === 0) throw usageError(`no YAML files under ${root}/{${kinds.join(",")}}`);
|
|
848
1252
|
return files;
|
|
849
1253
|
}
|
|
850
|
-
async function runImport(ctx, root,
|
|
851
|
-
const
|
|
852
|
-
const
|
|
1254
|
+
async function runImport(ctx, root, opts, apply) {
|
|
1255
|
+
const kinds = kindsOf(opts);
|
|
1256
|
+
const files = readTreeOrFail(root, kinds);
|
|
1257
|
+
const prune = Boolean(opts.prune);
|
|
1258
|
+
const body = { files, apply: false, prune };
|
|
1259
|
+
let res = await request(ctx, "POST", "metadata/import", { body });
|
|
1260
|
+
const deletes = res.data.changes.filter((c) => c.action === "delete");
|
|
1261
|
+
if (apply) {
|
|
1262
|
+
if (deletes.length > 0) {
|
|
1263
|
+
if (!ctx.json) ctx.io.stdout.write(formatPlan(res.data, Boolean(opts.verbose)) + "\n");
|
|
1264
|
+
await confirm(ctx.io, `Delete ${deletes.length} ${deletes.length === 1 ? "entity" : "entities"} from the instance?`, Boolean(opts.yes));
|
|
1265
|
+
}
|
|
1266
|
+
res = await request(ctx, "POST", "metadata/import", { body: { ...body, apply: true } });
|
|
1267
|
+
}
|
|
853
1268
|
if (ctx.json) ctx.io.stdout.write(toJson(res.data) + "\n");
|
|
854
|
-
else ctx.io.stdout.write(formatPlan(res.data, verbose) + "\n");
|
|
1269
|
+
else ctx.io.stdout.write(formatPlan(res.data, Boolean(opts.verbose)) + "\n");
|
|
855
1270
|
}
|
|
1271
|
+
var onlyOption = ["--only <kinds>", "comma list of models, forms, roles, sites (default: all)"];
|
|
856
1272
|
function registerMetadata(program, io) {
|
|
857
1273
|
const metadata = program.command("metadata").description("Keep a directory of model and form YAML in step with the instance").addHelpText(
|
|
858
1274
|
"after",
|
|
859
1275
|
`
|
|
860
|
-
The tree is models
|
|
861
|
-
|
|
862
|
-
|
|
1276
|
+
The tree is models/, forms/, roles/ and sites/ under the metadata directory
|
|
1277
|
+
("metadataDir" in plenary.json, default ./plenary), one <key>.yaml per
|
|
1278
|
+
entity. Models and forms need an API key or a session; roles and sites need
|
|
1279
|
+
a user session (plenary login). See docs/CLI.md.`
|
|
863
1280
|
);
|
|
864
|
-
metadata.command("pull").description("Write every model and
|
|
1281
|
+
metadata.command("pull").description("Write every model, form, role and site the credential may read into the directory").option("--dir <path>", "metadata directory (default: the configured one)").option(...onlyOption).action(async (opts, cmd) => {
|
|
865
1282
|
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
866
1283
|
const root = treeRoot(ctx, opts);
|
|
867
|
-
const
|
|
868
|
-
const
|
|
1284
|
+
const kinds = kindsOf(opts);
|
|
1285
|
+
const res = await request(ctx, "GET", "metadata", {
|
|
1286
|
+
query: { only: kinds.join(",") }
|
|
1287
|
+
});
|
|
1288
|
+
const result = writeTree(root, res.data.files, kinds);
|
|
1289
|
+
const omitted = res.data.omitted ?? [];
|
|
869
1290
|
if (ctx.json) {
|
|
870
|
-
io.stdout.write(toJson({ dir: root, ...result }) + "\n");
|
|
1291
|
+
io.stdout.write(toJson({ dir: root, ...result, omitted }) + "\n");
|
|
871
1292
|
return;
|
|
872
1293
|
}
|
|
873
1294
|
io.stdout.write(`wrote ${result.written.length} file${result.written.length === 1 ? "" : "s"} to ${root}
|
|
874
1295
|
`);
|
|
875
|
-
if (
|
|
1296
|
+
if (omitted.length) {
|
|
1297
|
+
io.stderr.write(`not included, the credential may not read them: ${omitted.join(", ")}
|
|
1298
|
+
`);
|
|
1299
|
+
}
|
|
1300
|
+
if (result.written.length === 0 && omitted.length === 0) {
|
|
876
1301
|
io.stderr.write("the server sent no models: the key may lack content:models:read (a public key never has it)\n");
|
|
877
1302
|
}
|
|
878
1303
|
if (result.stale.length) {
|
|
@@ -881,19 +1306,26 @@ ${result.stale.map((p) => ` ${p}`).join("\n")}
|
|
|
881
1306
|
`);
|
|
882
1307
|
}
|
|
883
1308
|
});
|
|
884
|
-
metadata.command("diff").description("Preview what push would change, without writing anything").option("--dir <path>", "metadata directory (default: the configured one)").option("--verbose", "list unchanged
|
|
1309
|
+
metadata.command("diff").description("Preview what push would change, without writing anything").option("--dir <path>", "metadata directory (default: the configured one)").option(...onlyOption).option("--prune", "include what push --prune would delete").option("--verbose", "list unchanged entities too").action(async (opts, cmd) => {
|
|
885
1310
|
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
886
|
-
await runImport(ctx, treeRoot(ctx, opts),
|
|
1311
|
+
await runImport(ctx, treeRoot(ctx, opts), opts, false);
|
|
887
1312
|
});
|
|
888
|
-
metadata.command("push").description("Apply the directory to the instance (creates and updates;
|
|
1313
|
+
metadata.command("push").description("Apply the directory to the instance (creates and updates; deletes only with --prune)").option("--dir <path>", "metadata directory (default: the configured one)").option(...onlyOption).option("--prune", "also delete entities absent from the directory, within the kinds it holds").option("-y, --yes", "do not ask before pruning").option("--dry-run", "same as diff").option("--verbose", "list unchanged entities too").addHelpText(
|
|
1314
|
+
"after",
|
|
1315
|
+
`
|
|
1316
|
+
--prune deletes only within the kinds present in the directory: a tree with
|
|
1317
|
+
no roles/ cannot delete a role. System-owned roles are never touched and are
|
|
1318
|
+
reported as skipped. The plan is shown and confirmed before anything is
|
|
1319
|
+
deleted; -y skips the question, and a run with no terminal needs it.`
|
|
1320
|
+
).action(async (opts, cmd) => {
|
|
889
1321
|
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
890
|
-
await runImport(ctx, treeRoot(ctx, opts),
|
|
1322
|
+
await runImport(ctx, treeRoot(ctx, opts), opts, !opts.dryRun);
|
|
891
1323
|
});
|
|
892
1324
|
}
|
|
893
1325
|
|
|
894
1326
|
// src/commands/init.ts
|
|
895
|
-
import { existsSync as
|
|
896
|
-
import
|
|
1327
|
+
import { existsSync as existsSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
1328
|
+
import path6 from "path";
|
|
897
1329
|
import { createInterface as createInterface2 } from "readline/promises";
|
|
898
1330
|
function registerInit(program, io) {
|
|
899
1331
|
program.command("init").description(`Write a ${CONFIG_FILE} for this project (never a credential)`).option("--dir <path>", "metadata directory, relative to the config file", DEFAULT_METADATA_DIR).option("-f, --force", `overwrite an existing ${CONFIG_FILE}`).addHelpText(
|
|
@@ -902,12 +1334,12 @@ function registerInit(program, io) {
|
|
|
902
1334
|
The base URL, site and locale come from --base-url, --site and --locale (or
|
|
903
1335
|
their PLENARY_* variables); anything missing is prompted for on a terminal.`
|
|
904
1336
|
).action(async (opts, cmd) => {
|
|
905
|
-
const target =
|
|
906
|
-
if (
|
|
1337
|
+
const target = path6.join(io.cwd, CONFIG_FILE);
|
|
1338
|
+
if (existsSync4(target) && !opts.force) {
|
|
907
1339
|
throw usageError(`${target} already exists (pass --force to overwrite)`);
|
|
908
1340
|
}
|
|
909
1341
|
const globals = cmd.optsWithGlobals();
|
|
910
|
-
const ctx = resolveContext({ ...globals, config: void 0 }, { ...io, cwd:
|
|
1342
|
+
const ctx = resolveContext({ ...globals, config: void 0 }, { ...io, cwd: path6.parse(io.cwd).root });
|
|
911
1343
|
const baseUrl = globals.baseUrl ?? ctx.baseUrl ?? await ask(io, "Base URL (https://cms.example.com)");
|
|
912
1344
|
if (!baseUrl) throw usageError("a base URL is required: pass --base-url or set PLENARY_BASE_URL");
|
|
913
1345
|
const siteKey = globals.site ?? ctx.siteKey ?? await ask(io, "Site key", "default");
|
|
@@ -916,7 +1348,7 @@ their PLENARY_* variables); anything missing is prompted for on a terminal.`
|
|
|
916
1348
|
if (siteKey) config.siteKey = siteKey;
|
|
917
1349
|
if (locale) config.locale = locale;
|
|
918
1350
|
if (opts.dir !== DEFAULT_METADATA_DIR) config.metadataDir = opts.dir;
|
|
919
|
-
|
|
1351
|
+
writeFileSync3(target, JSON.stringify(config, null, 2) + "\n");
|
|
920
1352
|
io.stdout.write(`wrote ${target}
|
|
921
1353
|
`);
|
|
922
1354
|
});
|
|
@@ -941,9 +1373,10 @@ function registerStatus(program, io) {
|
|
|
941
1373
|
let me;
|
|
942
1374
|
let credentialError;
|
|
943
1375
|
let undescribed = false;
|
|
944
|
-
|
|
1376
|
+
const headers = await credentialHeaders(ctx, "optional");
|
|
1377
|
+
if (Object.keys(headers).length > 0) {
|
|
945
1378
|
try {
|
|
946
|
-
me = (await request(ctx, "GET", "/api/v1/auth/me")).data;
|
|
1379
|
+
me = (await request(ctx, "GET", "/api/v1/auth/me", { auth: "none", headers })).data;
|
|
947
1380
|
} catch (err) {
|
|
948
1381
|
if (!(err instanceof PlenaryError2)) throw err;
|
|
949
1382
|
if (err.status === 403) undescribed = true;
|
|
@@ -974,13 +1407,13 @@ function registerStatus(program, io) {
|
|
|
974
1407
|
if (me?.principal === "api_key") {
|
|
975
1408
|
lines.push(`Credential ${me.key_type} API key "${me.name}" (${me.grants.length} grant${me.grants.length === 1 ? "" : "s"}${me.sites.length ? `, sites ${me.sites.join(", ")}` : ""})`);
|
|
976
1409
|
} else if (me?.principal === "user") {
|
|
977
|
-
lines.push(`Credential user session, role ${me.role_name ?? me.role}`);
|
|
1410
|
+
lines.push(`Credential user session (stored login), role ${me.role_name ?? me.role}`);
|
|
978
1411
|
} else if (credentialError) {
|
|
979
1412
|
lines.push(`Credential rejected: ${credentialError.code} (HTTP ${credentialError.status})`);
|
|
980
1413
|
} else if (undescribed) {
|
|
981
1414
|
lines.push("Credential API key accepted (this server predates API-key support on /auth/me and cannot describe the key)");
|
|
982
1415
|
} else {
|
|
983
|
-
lines.push("Credential none (set PLENARY_API_KEY
|
|
1416
|
+
lines.push("Credential none (set PLENARY_API_KEY, pass --api-key, or run `plenary login`)");
|
|
984
1417
|
}
|
|
985
1418
|
if (ctx.configPath) lines.push(`Config ${ctx.configPath}`);
|
|
986
1419
|
io.stdout.write(lines.join("\n") + "\n");
|
|
@@ -991,7 +1424,7 @@ function registerStatus(program, io) {
|
|
|
991
1424
|
// src/cli.ts
|
|
992
1425
|
function version() {
|
|
993
1426
|
try {
|
|
994
|
-
const pkg = JSON.parse(
|
|
1427
|
+
const pkg = JSON.parse(readFileSync6(new URL("../package.json", import.meta.url), "utf8"));
|
|
995
1428
|
return pkg.version;
|
|
996
1429
|
} catch {
|
|
997
1430
|
return "0.0.0";
|
|
@@ -1003,12 +1436,14 @@ function createProgram(io) {
|
|
|
1003
1436
|
writeErr: (s) => io.stderr.write(s)
|
|
1004
1437
|
});
|
|
1005
1438
|
registerInit(program, io);
|
|
1439
|
+
registerAuth(program, io);
|
|
1006
1440
|
registerStatus(program, io);
|
|
1007
1441
|
registerMetadata(program, io);
|
|
1008
1442
|
registerCatalog(program, io, "models");
|
|
1009
1443
|
registerFormEntries(registerCatalog(program, io, "forms"), io);
|
|
1010
1444
|
registerEntries(program, io);
|
|
1011
1445
|
registerMedia(program, io);
|
|
1446
|
+
registerAdmin(program, io);
|
|
1012
1447
|
registerApi(program, io);
|
|
1013
1448
|
return program;
|
|
1014
1449
|
}
|