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