@plenary/cli 0.3.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/LICENSE +21 -0
- package/README.md +33 -0
- package/dist/index.js +1043 -0
- package/dist/index.js.map +1 -0
- package/package.json +30 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
5
|
+
import { Command, CommanderError as CommanderError2 } from "commander";
|
|
6
|
+
|
|
7
|
+
// src/output.ts
|
|
8
|
+
import { PlenaryError } from "@plenary/client";
|
|
9
|
+
import { CommanderError } from "commander";
|
|
10
|
+
|
|
11
|
+
// src/errors.ts
|
|
12
|
+
var CliError = class extends Error {
|
|
13
|
+
exitCode;
|
|
14
|
+
constructor(message, exitCode = 1) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "CliError";
|
|
17
|
+
this.exitCode = exitCode;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
function usageError(message) {
|
|
21
|
+
return new CliError(message, 2);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/output.ts
|
|
25
|
+
function formatTable(rows, header) {
|
|
26
|
+
const all = header ? [header, ...rows] : rows;
|
|
27
|
+
if (all.length === 0) return "";
|
|
28
|
+
const widths = [];
|
|
29
|
+
for (const row of all) {
|
|
30
|
+
row.forEach((cell, i) => {
|
|
31
|
+
widths[i] = Math.max(widths[i] ?? 0, cell.length);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return all.map(
|
|
35
|
+
(row) => row.map((cell, i) => i === row.length - 1 ? cell : cell.padEnd(widths[i] ?? 0)).join(" ").trimEnd()
|
|
36
|
+
).join("\n");
|
|
37
|
+
}
|
|
38
|
+
function toJson(value) {
|
|
39
|
+
return JSON.stringify(value, null, 2);
|
|
40
|
+
}
|
|
41
|
+
function describeError(err, debug = false) {
|
|
42
|
+
if (err instanceof PlenaryError) {
|
|
43
|
+
let line = `plenary: ${err.code} (HTTP ${err.status})`;
|
|
44
|
+
if (err.vars && Object.keys(err.vars).length > 0) {
|
|
45
|
+
line += "\n" + Object.entries(err.vars).map(([k, v]) => ` ${k}: ${fmtVar(v)}`).join("\n");
|
|
46
|
+
}
|
|
47
|
+
return line;
|
|
48
|
+
}
|
|
49
|
+
if (err instanceof CliError) return `plenary: ${err.message}`;
|
|
50
|
+
if (err instanceof Error) {
|
|
51
|
+
const cause = err.cause instanceof Error ? `: ${err.cause.message}` : "";
|
|
52
|
+
return debug && err.stack ? err.stack : `plenary: ${err.message}${cause}`;
|
|
53
|
+
}
|
|
54
|
+
return `plenary: ${String(err)}`;
|
|
55
|
+
}
|
|
56
|
+
function exitCodeFor(err) {
|
|
57
|
+
if (err instanceof CliError) return err.exitCode;
|
|
58
|
+
if (err instanceof CommanderError) return err.exitCode === 0 ? 0 : 2;
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
function fmtVar(v) {
|
|
62
|
+
return typeof v === "string" ? v : JSON.stringify(v);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/config.ts
|
|
66
|
+
import { existsSync, readFileSync } from "fs";
|
|
67
|
+
import path from "path";
|
|
68
|
+
var CONFIG_FILE = "plenary.json";
|
|
69
|
+
var DEFAULT_METADATA_DIR = "plenary";
|
|
70
|
+
function findConfigFile(from) {
|
|
71
|
+
let dir = path.resolve(from);
|
|
72
|
+
for (; ; ) {
|
|
73
|
+
const candidate = path.join(dir, CONFIG_FILE);
|
|
74
|
+
if (existsSync(candidate)) return candidate;
|
|
75
|
+
const parent = path.dirname(dir);
|
|
76
|
+
if (parent === dir) return void 0;
|
|
77
|
+
dir = parent;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
var CONFIG_KEYS = ["baseUrl", "siteKey", "locale", "metadataDir"];
|
|
81
|
+
function readProjectConfig(file) {
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
85
|
+
} catch (err) {
|
|
86
|
+
throw usageError(`${file}: ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
89
|
+
throw usageError(`${file}: expected a JSON object`);
|
|
90
|
+
}
|
|
91
|
+
const out = {};
|
|
92
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
93
|
+
if (!CONFIG_KEYS.includes(key)) {
|
|
94
|
+
throw usageError(`${file}: unknown key "${key}"`);
|
|
95
|
+
}
|
|
96
|
+
if (typeof value !== "string") {
|
|
97
|
+
throw usageError(`${file}: "${key}" must be a string`);
|
|
98
|
+
}
|
|
99
|
+
out[key] = value;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
function readDotEnv(file) {
|
|
104
|
+
const out = {};
|
|
105
|
+
let text;
|
|
106
|
+
try {
|
|
107
|
+
text = readFileSync(file, "utf8");
|
|
108
|
+
} catch {
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
for (const raw of text.split("\n")) {
|
|
112
|
+
const line = raw.trim();
|
|
113
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
114
|
+
const m = /^(?:export\s+)?(PLENARY_[A-Z0-9_]+)=(.*)$/.exec(line);
|
|
115
|
+
if (!m) continue;
|
|
116
|
+
let value = (m[2] ?? "").trim();
|
|
117
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
118
|
+
value = value.slice(1, -1);
|
|
119
|
+
}
|
|
120
|
+
out[m[1]] = value;
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
function resolveContext(opts, io) {
|
|
125
|
+
const configPath = opts.config ? path.resolve(io.cwd, opts.config) : findConfigFile(io.cwd);
|
|
126
|
+
if (opts.config && !existsSync(configPath)) {
|
|
127
|
+
throw usageError(`config file not found: ${configPath}`);
|
|
128
|
+
}
|
|
129
|
+
const file = configPath ? readProjectConfig(configPath) : {};
|
|
130
|
+
const projectRoot = configPath ? path.dirname(configPath) : io.cwd;
|
|
131
|
+
const env = { ...readDotEnv(path.join(projectRoot, ".env")), ...pick(io.env) };
|
|
132
|
+
const baseUrl = opts.baseUrl ?? env.PLENARY_BASE_URL ?? file.baseUrl;
|
|
133
|
+
return {
|
|
134
|
+
io,
|
|
135
|
+
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, "") : void 0,
|
|
136
|
+
apiKey: opts.apiKey ?? env.PLENARY_API_KEY,
|
|
137
|
+
siteKey: opts.site ?? env.PLENARY_SITE_KEY ?? file.siteKey,
|
|
138
|
+
locale: opts.locale ?? env.PLENARY_LOCALE ?? file.locale,
|
|
139
|
+
metadataDir: env.PLENARY_METADATA_DIR ?? file.metadataDir ?? DEFAULT_METADATA_DIR,
|
|
140
|
+
projectRoot,
|
|
141
|
+
configPath,
|
|
142
|
+
json: opts.json ?? false,
|
|
143
|
+
debug: opts.debug ?? false
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function pick(env) {
|
|
147
|
+
const out = {};
|
|
148
|
+
for (const [k, v] of Object.entries(env)) {
|
|
149
|
+
if (k.startsWith("PLENARY_") && v !== void 0 && v !== "") out[k] = v;
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
function requireBaseUrl(ctx) {
|
|
154
|
+
if (!ctx.baseUrl) {
|
|
155
|
+
throw usageError(
|
|
156
|
+
"no base URL: pass --base-url, set PLENARY_BASE_URL, or run `plenary init`"
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return ctx.baseUrl;
|
|
160
|
+
}
|
|
161
|
+
function requireApiKey(ctx) {
|
|
162
|
+
if (!ctx.apiKey) {
|
|
163
|
+
throw usageError("no credential: pass --api-key or set PLENARY_API_KEY");
|
|
164
|
+
}
|
|
165
|
+
return ctx.apiKey;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/api/http.ts
|
|
169
|
+
import { errorFromResponse } from "@plenary/client";
|
|
170
|
+
function apiPath(p) {
|
|
171
|
+
return p.startsWith("/") ? p : `/api/v1/${p}`;
|
|
172
|
+
}
|
|
173
|
+
async function request(ctx, method, path6, opts = {}) {
|
|
174
|
+
const base = requireBaseUrl(ctx);
|
|
175
|
+
const auth = opts.auth ?? "required";
|
|
176
|
+
const headers = { ...opts.headers };
|
|
177
|
+
if (auth === "required") headers["X-API-KEY"] = requireApiKey(ctx);
|
|
178
|
+
else if (auth === "optional" && ctx.apiKey) headers["X-API-KEY"] = ctx.apiKey;
|
|
179
|
+
const url = new URL(base + apiPath(path6));
|
|
180
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
181
|
+
if (v !== void 0) url.searchParams.set(k, v);
|
|
182
|
+
}
|
|
183
|
+
let body;
|
|
184
|
+
if (opts.form) {
|
|
185
|
+
body = opts.form;
|
|
186
|
+
} else if (opts.body !== void 0) {
|
|
187
|
+
headers["Content-Type"] = "application/json";
|
|
188
|
+
body = JSON.stringify(opts.body);
|
|
189
|
+
}
|
|
190
|
+
if (ctx.debug) ctx.io.stderr.write(`> ${method} ${url}
|
|
191
|
+
`);
|
|
192
|
+
const response = await ctx.io.fetch(url, { method, headers, body });
|
|
193
|
+
const data = await parseBody(response);
|
|
194
|
+
if (ctx.debug) ctx.io.stderr.write(`< ${response.status}
|
|
195
|
+
`);
|
|
196
|
+
if (!response.ok && !opts.allowError) {
|
|
197
|
+
throw errorFromResponse(response.status, data);
|
|
198
|
+
}
|
|
199
|
+
return { status: response.status, ok: response.ok, headers: response.headers, data };
|
|
200
|
+
}
|
|
201
|
+
async function parseBody(response) {
|
|
202
|
+
if (response.status === 204) return null;
|
|
203
|
+
const text = await response.text();
|
|
204
|
+
if (text === "") return null;
|
|
205
|
+
const type = response.headers.get("content-type") ?? "";
|
|
206
|
+
if (type.includes("json")) {
|
|
207
|
+
try {
|
|
208
|
+
return JSON.parse(text);
|
|
209
|
+
} catch {
|
|
210
|
+
return text;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return text;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// src/input.ts
|
|
217
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
218
|
+
import { createInterface } from "readline/promises";
|
|
219
|
+
function parsePairs(pairs, sep, what) {
|
|
220
|
+
const out = {};
|
|
221
|
+
for (const pair of pairs) {
|
|
222
|
+
const i = pair.indexOf(sep);
|
|
223
|
+
if (i <= 0) throw usageError(`invalid ${what} "${pair}": expected key${sep}value`);
|
|
224
|
+
out[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
async function readText(data, stdin) {
|
|
229
|
+
if (data === "-") return readStream(stdin);
|
|
230
|
+
if (data.startsWith("@")) {
|
|
231
|
+
try {
|
|
232
|
+
return readFileSync2(data.slice(1), "utf8");
|
|
233
|
+
} catch (err) {
|
|
234
|
+
throw usageError(`cannot read ${data.slice(1)}: ${err.message}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return data;
|
|
238
|
+
}
|
|
239
|
+
async function readData(data, stdin, flag = "--data") {
|
|
240
|
+
const text = await readText(data, stdin);
|
|
241
|
+
try {
|
|
242
|
+
return JSON.parse(text);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
throw usageError(`${flag} is not valid JSON: ${err.message}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async function readObject(data, stdin, flag = "--data") {
|
|
248
|
+
const value = await readData(data, stdin, flag);
|
|
249
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
250
|
+
throw usageError(`${flag} must be a JSON object`);
|
|
251
|
+
}
|
|
252
|
+
return value;
|
|
253
|
+
}
|
|
254
|
+
async function readStream(stream) {
|
|
255
|
+
const chunks = [];
|
|
256
|
+
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
|
257
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
258
|
+
}
|
|
259
|
+
async function confirm(io, question, yes) {
|
|
260
|
+
if (yes) return;
|
|
261
|
+
if (!io.isTTY) throw usageError(`${question} \u2014 pass -y to confirm without a terminal`);
|
|
262
|
+
const rl = createInterface({ input: io.stdin, output: io.stderr });
|
|
263
|
+
try {
|
|
264
|
+
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
265
|
+
if (answer !== "y" && answer !== "yes") throw usageError("aborted");
|
|
266
|
+
} finally {
|
|
267
|
+
rl.close();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/commands/api.ts
|
|
272
|
+
import { errorFromResponse as errorFromResponse2 } from "@plenary/client";
|
|
273
|
+
var METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]);
|
|
274
|
+
function collect(value, previous) {
|
|
275
|
+
return [...previous, value];
|
|
276
|
+
}
|
|
277
|
+
function registerApi(program, io) {
|
|
278
|
+
program.command("api").description("Call any API endpoint and print the response body").argument("<method>", "GET, POST, PUT, PATCH, DELETE, or HEAD").argument("<path>", "a path such as models or /api/v1/models").option("-d, --data <json>", "request body: inline JSON, @file, or - for stdin").option("-q, --query <key=value>", "query parameter (repeatable)", collect, []).option("-H, --header <key:value>", "request header (repeatable)", collect, []).option("-i, --include", "print the status line and response headers to stderr").addHelpText(
|
|
279
|
+
"after",
|
|
280
|
+
`
|
|
281
|
+
Examples:
|
|
282
|
+
plenary api GET models
|
|
283
|
+
plenary api GET content/post -q site_key=main -q limit=5
|
|
284
|
+
plenary api POST content/post -q site_key=main -d '{"fields":{"title":"Hello"}}'
|
|
285
|
+
plenary api PUT models/<uuid> -d @model.json`
|
|
286
|
+
).action(
|
|
287
|
+
async (methodArg, path6, opts, cmd) => {
|
|
288
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
289
|
+
const method = methodArg.toUpperCase();
|
|
290
|
+
if (!METHODS.has(method)) throw usageError(`unsupported method "${methodArg}"`);
|
|
291
|
+
const body = opts.data !== void 0 ? await readData(opts.data, io.stdin) : void 0;
|
|
292
|
+
const res = await request(ctx, method, path6, {
|
|
293
|
+
query: parsePairs(opts.query, "=", "query parameter"),
|
|
294
|
+
headers: parsePairs(opts.header, ":", "header"),
|
|
295
|
+
body,
|
|
296
|
+
auth: "optional",
|
|
297
|
+
allowError: true
|
|
298
|
+
});
|
|
299
|
+
if (opts.include) {
|
|
300
|
+
io.stderr.write(`HTTP ${res.status}
|
|
301
|
+
`);
|
|
302
|
+
res.headers.forEach((v, k) => io.stderr.write(`${k}: ${v}
|
|
303
|
+
`));
|
|
304
|
+
io.stderr.write("\n");
|
|
305
|
+
}
|
|
306
|
+
if (res.data !== null) {
|
|
307
|
+
io.stdout.write((typeof res.data === "string" ? res.data : toJson(res.data)) + "\n");
|
|
308
|
+
}
|
|
309
|
+
if (!res.ok) {
|
|
310
|
+
io.stderr.write(describeError(errorFromResponse2(res.status, res.data)) + "\n");
|
|
311
|
+
throw new CliError("", 1);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/commands/catalog.ts
|
|
318
|
+
function registerCatalog(program, io, name) {
|
|
319
|
+
const group = program.command(name).description(name === "models" ? "Content models and objects" : "Forms");
|
|
320
|
+
group.command("list").description(`List ${name}`).action(async (_opts, cmd) => {
|
|
321
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
322
|
+
const res = await request(ctx, "GET", name);
|
|
323
|
+
if (ctx.json) {
|
|
324
|
+
io.stdout.write(toJson(res.data) + "\n");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const rows = res.data.map((m) => [
|
|
328
|
+
m.key,
|
|
329
|
+
m.kind,
|
|
330
|
+
m.name_singular,
|
|
331
|
+
String(m.fields_count),
|
|
332
|
+
m.active ? "" : "inactive"
|
|
333
|
+
]);
|
|
334
|
+
io.stdout.write(formatTable(rows, ["KEY", "KIND", "NAME", "FIELDS", ""]) + "\n");
|
|
335
|
+
});
|
|
336
|
+
group.command("get").description(`Show one of the ${name} by key or id`).argument("<key>").option("--yaml", "print the YAML file the metadata commands use").action(async (key, opts, cmd) => {
|
|
337
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
338
|
+
const res = await request(ctx, "GET", `${name}/${encodeURIComponent(key)}`);
|
|
339
|
+
const m = res.data;
|
|
340
|
+
if (opts.yaml) {
|
|
341
|
+
const yaml = await request(ctx, "GET", `${name}/${m.id}/yaml`);
|
|
342
|
+
io.stdout.write(yaml.data.endsWith("\n") ? yaml.data : yaml.data + "\n");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (ctx.json) {
|
|
346
|
+
io.stdout.write(toJson(m) + "\n");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const head = [
|
|
350
|
+
["Key", m.key],
|
|
351
|
+
["Kind", m.kind],
|
|
352
|
+
["Name", `${m.name_singular} / ${m.name_plural}`],
|
|
353
|
+
["Id", m.id],
|
|
354
|
+
["Sites", (m.sites ?? []).join(", ") || "-"],
|
|
355
|
+
["Active", m.active ? "yes" : "no"]
|
|
356
|
+
];
|
|
357
|
+
if (m.description) head.push(["Description", m.description]);
|
|
358
|
+
if (m.entry_count !== void 0) head.push(["Entries", String(m.entry_count)]);
|
|
359
|
+
const fields = m.fields.map((f) => [
|
|
360
|
+
f.key,
|
|
361
|
+
f.type,
|
|
362
|
+
f.name,
|
|
363
|
+
[f.required ? "required" : "", f.localized ? "localized" : ""].filter(Boolean).join(", ")
|
|
364
|
+
]);
|
|
365
|
+
io.stdout.write(
|
|
366
|
+
formatTable(head) + "\n\n" + formatTable(fields, ["FIELD", "TYPE", "NAME", ""]) + "\n"
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
return group;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/entries/client.ts
|
|
373
|
+
import { createClient } from "@plenary/client";
|
|
374
|
+
function requireSiteKey(ctx) {
|
|
375
|
+
if (!ctx.siteKey) {
|
|
376
|
+
throw usageError("no site key: pass --site, set PLENARY_SITE_KEY, or set siteKey in plenary.json");
|
|
377
|
+
}
|
|
378
|
+
return ctx.siteKey;
|
|
379
|
+
}
|
|
380
|
+
async function resolveLocale(ctx, siteKey) {
|
|
381
|
+
if (ctx.locale) return ctx.locale;
|
|
382
|
+
const sites = await request(ctx, "GET", "sites");
|
|
383
|
+
const site = sites.data.find((s) => s.key === siteKey);
|
|
384
|
+
if (!site) throw usageError(`site "${siteKey}" not found on the instance`);
|
|
385
|
+
return site.default_locale;
|
|
386
|
+
}
|
|
387
|
+
async function contentClient(ctx) {
|
|
388
|
+
const siteKey = requireSiteKey(ctx);
|
|
389
|
+
return createClient({
|
|
390
|
+
baseUrl: requireBaseUrl(ctx),
|
|
391
|
+
siteKey,
|
|
392
|
+
apiKey: requireApiKey(ctx),
|
|
393
|
+
locale: await resolveLocale(ctx, siteKey),
|
|
394
|
+
fetch: ctx.io.fetch
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// src/entries/query.ts
|
|
399
|
+
var OPERATORS = /* @__PURE__ */ new Set(["$eq", "$ne", "$lt", "$lte", "$gt", "$gte", "$in", "$nin", "$like", "$ilike", "$is_null"]);
|
|
400
|
+
function parseWhere(specs) {
|
|
401
|
+
const where = {};
|
|
402
|
+
for (const spec of specs) {
|
|
403
|
+
const eq = spec.indexOf("=");
|
|
404
|
+
if (eq <= 0) throw usageError(`invalid --where "${spec}": expected field=value or field=$op:value`);
|
|
405
|
+
const field = spec.slice(0, eq).trim();
|
|
406
|
+
const raw = spec.slice(eq + 1);
|
|
407
|
+
if (!raw.startsWith("$")) {
|
|
408
|
+
where[field] = raw;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const colon = raw.indexOf(":");
|
|
412
|
+
const op = colon === -1 ? raw : raw.slice(0, colon);
|
|
413
|
+
const value = colon === -1 ? "" : raw.slice(colon + 1);
|
|
414
|
+
if (!OPERATORS.has(op)) throw usageError(`invalid --where "${spec}": unknown operator ${op}`);
|
|
415
|
+
let typed = value;
|
|
416
|
+
if (op === "$in" || op === "$nin") typed = value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
417
|
+
else if (op === "$is_null") typed = value === "" || value === "true";
|
|
418
|
+
const existing = where[field] ?? {};
|
|
419
|
+
where[field] = { ...typeof existing === "object" ? existing : {}, [op]: typed };
|
|
420
|
+
}
|
|
421
|
+
return where;
|
|
422
|
+
}
|
|
423
|
+
function parseSort(specs) {
|
|
424
|
+
const sort = {};
|
|
425
|
+
for (const spec of specs) {
|
|
426
|
+
const [field, dir = "asc"] = spec.split(":");
|
|
427
|
+
if (!field || dir !== "asc" && dir !== "desc") {
|
|
428
|
+
throw usageError(`invalid --sort "${spec}": expected field or field:asc|desc`);
|
|
429
|
+
}
|
|
430
|
+
sort[field] = dir;
|
|
431
|
+
}
|
|
432
|
+
return sort;
|
|
433
|
+
}
|
|
434
|
+
function parseHydrate(specs) {
|
|
435
|
+
const out = {};
|
|
436
|
+
for (const spec of specs) {
|
|
437
|
+
const [field, list] = spec.split("=");
|
|
438
|
+
if (!field) throw usageError(`invalid --hydrate "${spec}"`);
|
|
439
|
+
out[field] = list === void 0 || list === "*" ? "*" : list.split(",");
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
function parseInt10(value, flag) {
|
|
444
|
+
if (value === void 0) return void 0;
|
|
445
|
+
const n = Number(value);
|
|
446
|
+
if (!Number.isInteger(n) || n < 0) throw usageError(`${flag} must be a non-negative integer`);
|
|
447
|
+
return n;
|
|
448
|
+
}
|
|
449
|
+
async function toReadOptions(flags) {
|
|
450
|
+
const opts = {};
|
|
451
|
+
if (flags.include) opts.include = flags.include.split(",");
|
|
452
|
+
if (flags.exclude) opts.exclude = flags.exclude.split(",");
|
|
453
|
+
if (flags.hydrate?.length) opts.hydrate = parseHydrate(flags.hydrate);
|
|
454
|
+
return opts;
|
|
455
|
+
}
|
|
456
|
+
async function toListOptions(flags, stdin) {
|
|
457
|
+
const opts = await toReadOptions(flags);
|
|
458
|
+
if (flags.where?.length && flags.filters) throw usageError("--where and --filters are mutually exclusive");
|
|
459
|
+
if (flags.where?.length) opts.where = parseWhere(flags.where);
|
|
460
|
+
if (flags.filters) opts.filters = await readData(flags.filters, stdin, "--filters");
|
|
461
|
+
if (flags.sort?.length) opts.sort = parseSort(flags.sort);
|
|
462
|
+
const limit = parseInt10(flags.limit, "--limit");
|
|
463
|
+
const offset = parseInt10(flags.offset, "--offset");
|
|
464
|
+
if (limit !== void 0) opts.limit = limit;
|
|
465
|
+
if (offset !== void 0) opts.offset = offset;
|
|
466
|
+
if (flags.count) opts.count = true;
|
|
467
|
+
return opts;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/commands/entries.ts
|
|
471
|
+
function collect2(value, previous) {
|
|
472
|
+
return [...previous, value];
|
|
473
|
+
}
|
|
474
|
+
function withListFlags(cmd) {
|
|
475
|
+
return cmd.option("-w, --where <field=value>", "filter: field=value or field=$op:value (repeatable)", collect2, []).option("--filters <json>", "a filter tree (inline JSON, @file, or -); excludes --where").option("-s, --sort <field[:dir]>", "sort field, asc unless :desc (repeatable, in precedence order)", collect2, []).option("--limit <n>", "page size").option("--offset <n>", "rows to skip").option("--include <fields>", "comma-separated data fields to return").option("--exclude <fields>", "comma-separated data fields to omit").option("--hydrate <field[=subfields]>", "hydrate a reference field (repeatable)", collect2, []).option("--count", "ask for the total and show it");
|
|
476
|
+
}
|
|
477
|
+
function withWriteFlags(cmd) {
|
|
478
|
+
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");
|
|
479
|
+
}
|
|
480
|
+
function entryRow(e) {
|
|
481
|
+
return [e.id, e.status, e.slug ?? "", e.title ?? "", e.updated_at];
|
|
482
|
+
}
|
|
483
|
+
var ENTRY_HEADER = ["ID", "STATUS", "SLUG", "TITLE", "UPDATED"];
|
|
484
|
+
var PAGE = 100;
|
|
485
|
+
async function* pageAll(client, model, opts) {
|
|
486
|
+
const limit = opts.limit ?? PAGE;
|
|
487
|
+
let offset = opts.offset ?? 0;
|
|
488
|
+
for (; ; ) {
|
|
489
|
+
const { items } = await client.listEntries(model, { ...opts, limit, offset, count: false });
|
|
490
|
+
for (const e of items) yield e;
|
|
491
|
+
if (items.length < limit) return;
|
|
492
|
+
offset += items.length;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
async function publish(ctx, id, action) {
|
|
496
|
+
return (await request(ctx, "POST", `content/${encodeURIComponent(id)}/${action}`)).data;
|
|
497
|
+
}
|
|
498
|
+
function registerEntries(program, io) {
|
|
499
|
+
const entries = program.command("entries").description("Content entries");
|
|
500
|
+
withListFlags(entries.command("list").description("List a model's entries").argument("<model>")).action(
|
|
501
|
+
async (model, flags, cmd) => {
|
|
502
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
503
|
+
const client = await contentClient(ctx);
|
|
504
|
+
const result = await client.listEntries(model, await toListOptions(flags, io.stdin));
|
|
505
|
+
if (ctx.json) {
|
|
506
|
+
io.stdout.write(toJson(result) + "\n");
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
io.stdout.write(formatTable(result.items.map(entryRow), ENTRY_HEADER) + "\n");
|
|
510
|
+
if (result.total !== void 0) io.stdout.write(`${result.items.length} of ${result.total}
|
|
511
|
+
`);
|
|
512
|
+
}
|
|
513
|
+
);
|
|
514
|
+
entries.command("get").description("Print one entry as JSON; omit the slug for a singleton").argument("<model>").argument("[slug-or-id]").option("--include <fields>").option("--exclude <fields>").option("--hydrate <field[=subfields]>", "hydrate a reference field (repeatable)", collect2, []).action(async (model, slug, flags, cmd) => {
|
|
515
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
516
|
+
const client = await contentClient(ctx);
|
|
517
|
+
const opts = await toReadOptions(flags);
|
|
518
|
+
const entry = slug === void 0 ? await client.getSingleton(model, opts) : await client.getEntry(model, slug, opts);
|
|
519
|
+
io.stdout.write(toJson(entry) + "\n");
|
|
520
|
+
});
|
|
521
|
+
withWriteFlags(entries.command("create").description("Create a draft entry").argument("<model>")).option("--publish", "publish it right after creating").action(async (model, flags, cmd) => {
|
|
522
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
523
|
+
const siteKey = requireSiteKey(ctx);
|
|
524
|
+
if (flags.data === void 0) throw usageError("--data is required");
|
|
525
|
+
const body = { fields: await readObject(flags.data, io.stdin) };
|
|
526
|
+
if (ctx.locale) body.locale = ctx.locale;
|
|
527
|
+
applyWriteFlags(body, flags);
|
|
528
|
+
let entry = (await request(ctx, "POST", `content/${encodeURIComponent(model)}`, { query: { site_key: siteKey }, body })).data;
|
|
529
|
+
if (flags.publish) entry = await publish(ctx, entry.id, "publish");
|
|
530
|
+
report(ctx, entry, "created");
|
|
531
|
+
});
|
|
532
|
+
withWriteFlags(
|
|
533
|
+
entries.command("update").description("Update an entry: --data is merged into its fields (a null removes one)").argument("<model>").argument("<slug-or-id>")
|
|
534
|
+
).action(async (model, slug, flags, cmd) => {
|
|
535
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
536
|
+
const client = await contentClient(ctx);
|
|
537
|
+
const current = await client.getEntry(model, slug);
|
|
538
|
+
const fields = { ...current.data };
|
|
539
|
+
if (flags.data !== void 0) {
|
|
540
|
+
for (const [k, v] of Object.entries(await readObject(flags.data, io.stdin))) {
|
|
541
|
+
if (v === null) delete fields[k];
|
|
542
|
+
else fields[k] = v;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const body = {
|
|
546
|
+
fields,
|
|
547
|
+
slug: current.slug ?? void 0,
|
|
548
|
+
locale: current.locale ?? void 0,
|
|
549
|
+
folder_id: current.folder_id,
|
|
550
|
+
publish_at: current.publish_at,
|
|
551
|
+
unpublish_at: current.unpublish_at
|
|
552
|
+
};
|
|
553
|
+
applyWriteFlags(body, flags);
|
|
554
|
+
const entry = (await request(ctx, "PUT", `content/${encodeURIComponent(current.id)}`, { body })).data;
|
|
555
|
+
report(ctx, entry, "updated");
|
|
556
|
+
});
|
|
557
|
+
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) => {
|
|
558
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
559
|
+
await confirm(io, `Delete entry ${id}?`, Boolean(flags.yes));
|
|
560
|
+
await request(ctx, "DELETE", `content/${encodeURIComponent(id)}`, { query: { note: flags.note } });
|
|
561
|
+
if (ctx.json) io.stdout.write(toJson({ id, deleted: true }) + "\n");
|
|
562
|
+
else io.stdout.write(`deleted ${id}
|
|
563
|
+
`);
|
|
564
|
+
});
|
|
565
|
+
for (const action of ["publish", "unpublish"]) {
|
|
566
|
+
entries.command(action).description(`${action === "publish" ? "Publish" : "Unpublish"} an entry now`).argument("<id>").action(async (id, _flags, cmd) => {
|
|
567
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
568
|
+
report(ctx, await publish(ctx, id, action), action === "publish" ? "published" : "unpublished");
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
withListFlags(
|
|
572
|
+
entries.command("export").description("Write every matching entry to stdout as NDJSON").argument("<model>")
|
|
573
|
+
).action(async (model, flags, cmd) => {
|
|
574
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
575
|
+
const client = await contentClient(ctx);
|
|
576
|
+
for await (const e of pageAll(client, model, await toListOptions(flags, io.stdin))) {
|
|
577
|
+
io.stdout.write(JSON.stringify(e) + "\n");
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
entries.command("import").description("Create one entry per NDJSON line (exported entries or {fields, slug, \u2026} bodies)").argument("<model>").argument("<file>", "a file, or - for stdin").option("--publish", "publish each entry after creating it").addHelpText(
|
|
581
|
+
"after",
|
|
582
|
+
`
|
|
583
|
+
Every line is tried; a line that fails is reported with its number and the
|
|
584
|
+
run continues, exiting 1 at the end if any failed.`
|
|
585
|
+
).action(async (model, file, flags, cmd) => {
|
|
586
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
587
|
+
const siteKey = requireSiteKey(ctx);
|
|
588
|
+
const text = await readText(file === "-" ? "-" : `@${file}`, io.stdin);
|
|
589
|
+
let failed = 0;
|
|
590
|
+
let n = 0;
|
|
591
|
+
for (const raw of text.split("\n")) {
|
|
592
|
+
const line = raw.trim();
|
|
593
|
+
if (line === "") continue;
|
|
594
|
+
n++;
|
|
595
|
+
try {
|
|
596
|
+
const body = importBody(JSON.parse(line), ctx);
|
|
597
|
+
let entry = (await request(ctx, "POST", `content/${encodeURIComponent(model)}`, {
|
|
598
|
+
query: { site_key: siteKey },
|
|
599
|
+
body
|
|
600
|
+
})).data;
|
|
601
|
+
if (flags.publish) entry = await publish(ctx, entry.id, "publish");
|
|
602
|
+
io.stdout.write(`ok ${entry.id}${entry.slug ? ` ${entry.slug}` : ""}
|
|
603
|
+
`);
|
|
604
|
+
} catch (err) {
|
|
605
|
+
failed++;
|
|
606
|
+
const why = err instanceof Error ? err.code ?? err.message : String(err);
|
|
607
|
+
io.stdout.write(`fail line ${n}: ${why}
|
|
608
|
+
`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
io.stderr.write(`${n - failed} created, ${failed} failed
|
|
612
|
+
`);
|
|
613
|
+
if (failed) throw new CliError("", 1);
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
function applyWriteFlags(body, flags) {
|
|
617
|
+
if (flags.slug !== void 0) body.slug = flags.slug;
|
|
618
|
+
if (flags.folder !== void 0) body.folder_id = flags.folder;
|
|
619
|
+
if (flags.publishAt !== void 0) body.publish_at = flags.publishAt;
|
|
620
|
+
if (flags.unpublishAt !== void 0) body.unpublish_at = flags.unpublishAt;
|
|
621
|
+
if (flags.note !== void 0) body.note = flags.note;
|
|
622
|
+
}
|
|
623
|
+
function importBody(line, ctx) {
|
|
624
|
+
if ("fields" in line) return line;
|
|
625
|
+
if (!("data" in line)) throw usageError("expected an object with fields or data");
|
|
626
|
+
const e = line;
|
|
627
|
+
const body = { fields: e.data ?? {} };
|
|
628
|
+
if (e.slug) body.slug = e.slug;
|
|
629
|
+
if (e.locale ?? ctx.locale) body.locale = e.locale ?? ctx.locale;
|
|
630
|
+
if (e.folder_id) body.folder_id = e.folder_id;
|
|
631
|
+
if (e.publish_at) body.publish_at = e.publish_at;
|
|
632
|
+
if (e.unpublish_at) body.unpublish_at = e.unpublish_at;
|
|
633
|
+
return body;
|
|
634
|
+
}
|
|
635
|
+
function report(ctx, entry, verb) {
|
|
636
|
+
if (ctx.json) ctx.io.stdout.write(toJson(entry) + "\n");
|
|
637
|
+
else ctx.io.stdout.write(`${verb} ${entry.id}${entry.slug ? ` ${entry.slug}` : ""} (${entry.status})
|
|
638
|
+
`);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// src/commands/formentries.ts
|
|
642
|
+
function collect3(value, previous) {
|
|
643
|
+
return [...previous, value];
|
|
644
|
+
}
|
|
645
|
+
function toQuery(ctx, opts) {
|
|
646
|
+
const q = {};
|
|
647
|
+
if (ctx.siteKey) q.site_key = ctx.siteKey;
|
|
648
|
+
for (const [field, value] of Object.entries(opts.where ?? {})) {
|
|
649
|
+
if (typeof value === "object" && value !== null) {
|
|
650
|
+
for (const [op, v] of Object.entries(value)) {
|
|
651
|
+
q[`where:${field}.${op}`] = Array.isArray(v) ? v.join(",") : String(v);
|
|
652
|
+
}
|
|
653
|
+
} else {
|
|
654
|
+
q[`where:${field}`] = String(value);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (opts.filters) q.filters = JSON.stringify(opts.filters);
|
|
658
|
+
for (const [field, dir] of Object.entries(opts.sort ?? {})) q[`sort:${field}`] = dir;
|
|
659
|
+
if (opts.limit !== void 0) q.limit = String(opts.limit);
|
|
660
|
+
if (opts.offset !== void 0) q.offset = String(opts.offset);
|
|
661
|
+
if (opts.count) q.count = "true";
|
|
662
|
+
return q;
|
|
663
|
+
}
|
|
664
|
+
var PAGE2 = 100;
|
|
665
|
+
function registerFormEntries(forms, io) {
|
|
666
|
+
const entries = forms.command("entries").description("Form entries (what visitors submitted)");
|
|
667
|
+
const listFlags = (cmd) => cmd.option("-w, --where <field=value>", "filter: field=value or field=$op:value (repeatable)", collect3, []).option("--filters <json>", "a filter tree (inline JSON, @file, or -)").option("-s, --sort <field[:dir]>", "sort field, asc unless :desc (repeatable)", collect3, []).option("--limit <n>").option("--offset <n>").option("--count", "ask for the total and show it");
|
|
668
|
+
listFlags(entries.command("list").description("List a form's entries").argument("<form>")).action(
|
|
669
|
+
async (form, flags, cmd) => {
|
|
670
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
671
|
+
const opts = await toListOptions(flags, io.stdin);
|
|
672
|
+
const res = await request(ctx, "GET", `forms/${encodeURIComponent(form)}/entries`, {
|
|
673
|
+
query: toQuery(ctx, opts)
|
|
674
|
+
});
|
|
675
|
+
const total = res.headers.get("X-Total-Count");
|
|
676
|
+
if (ctx.json) {
|
|
677
|
+
io.stdout.write(toJson({ items: res.data, ...total !== null ? { total: Number(total) } : {} }) + "\n");
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const rows = res.data.map((e) => [e.id, e.status, e.site_key, e.created_at, summary(e.data)]);
|
|
681
|
+
io.stdout.write(formatTable(rows, ["ID", "STATUS", "SITE", "CREATED", "DATA"]) + "\n");
|
|
682
|
+
if (total !== null) io.stdout.write(`${res.data.length} of ${total}
|
|
683
|
+
`);
|
|
684
|
+
}
|
|
685
|
+
);
|
|
686
|
+
listFlags(entries.command("export").description("Write every matching entry to stdout as NDJSON").argument("<form>")).action(
|
|
687
|
+
async (form, flags, cmd) => {
|
|
688
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
689
|
+
const opts = await toListOptions(flags, io.stdin);
|
|
690
|
+
const limit = opts.limit ?? PAGE2;
|
|
691
|
+
let offset = opts.offset ?? 0;
|
|
692
|
+
for (; ; ) {
|
|
693
|
+
const res = await request(ctx, "GET", `forms/${encodeURIComponent(form)}/entries`, {
|
|
694
|
+
query: toQuery(ctx, { ...opts, limit, offset, count: false })
|
|
695
|
+
});
|
|
696
|
+
for (const e of res.data) io.stdout.write(JSON.stringify(e) + "\n");
|
|
697
|
+
if (res.data.length < limit) return;
|
|
698
|
+
offset += res.data.length;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
function summary(data) {
|
|
704
|
+
return Object.entries(data).slice(0, 3).map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" ");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// src/commands/media.ts
|
|
708
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
709
|
+
import path2 from "path";
|
|
710
|
+
function size(bytes) {
|
|
711
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
712
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
713
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
714
|
+
}
|
|
715
|
+
function registerMedia(program, io) {
|
|
716
|
+
const media = program.command("media").description("Media library");
|
|
717
|
+
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) => {
|
|
718
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
719
|
+
const uploaded = [];
|
|
720
|
+
let failed = 0;
|
|
721
|
+
for (const file of files) {
|
|
722
|
+
let bytes;
|
|
723
|
+
try {
|
|
724
|
+
bytes = readFileSync3(file);
|
|
725
|
+
} catch (err) {
|
|
726
|
+
throw usageError(`cannot read ${file}: ${err.message}`);
|
|
727
|
+
}
|
|
728
|
+
const form = new FormData();
|
|
729
|
+
form.append("upload_file", new Blob([new Uint8Array(bytes)]), path2.basename(file));
|
|
730
|
+
if (flags.folder) form.append("folder_id", flags.folder);
|
|
731
|
+
if (flags.private) form.append("visibility", "private");
|
|
732
|
+
try {
|
|
733
|
+
const res = await request(ctx, "POST", "media", { form });
|
|
734
|
+
uploaded.push(res.data);
|
|
735
|
+
if (!ctx.json) io.stdout.write(`${res.data.media_id} ${res.data.file_name}
|
|
736
|
+
`);
|
|
737
|
+
} catch (err) {
|
|
738
|
+
if (files.length === 1) throw err;
|
|
739
|
+
failed++;
|
|
740
|
+
const code = err.code ?? err.message;
|
|
741
|
+
io.stdout.write(`fail ${file}: ${code}
|
|
742
|
+
`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (ctx.json) io.stdout.write(toJson(uploaded) + "\n");
|
|
746
|
+
if (failed) throw new CliError("", 1);
|
|
747
|
+
});
|
|
748
|
+
media.command("list").description("List media").option("--search <text>", "match file names and captions").option("--path <prefix>", "only assets under this folder path").option("--limit <n>").option("--page <n>").action(async (flags, cmd) => {
|
|
749
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
750
|
+
const res = await request(ctx, "GET", "media", {
|
|
751
|
+
query: { search: flags.search, path_prefix: flags.path, limit: flags.limit, page: flags.page }
|
|
752
|
+
});
|
|
753
|
+
if (ctx.json) {
|
|
754
|
+
io.stdout.write(toJson(res.data) + "\n");
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const rows = res.data.map((m) => [m.media_id, m.media_type, m.file_name, size(m.file_size), m.path, m.visibility]);
|
|
758
|
+
io.stdout.write(formatTable(rows, ["ID", "TYPE", "FILE", "SIZE", "PATH", "VISIBILITY"]) + "\n");
|
|
759
|
+
});
|
|
760
|
+
media.command("get").description("Print one media item as JSON").argument("<id>").action(async (id, _flags, cmd) => {
|
|
761
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
762
|
+
const res = await request(ctx, "GET", `media/${encodeURIComponent(id)}`);
|
|
763
|
+
io.stdout.write(toJson(res.data) + "\n");
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// src/commands/metadata.ts
|
|
768
|
+
import path4 from "path";
|
|
769
|
+
|
|
770
|
+
// src/metadata/dir.ts
|
|
771
|
+
import { existsSync as existsSync2, mkdirSync, readdirSync, readFileSync as readFileSync4, writeFileSync } from "fs";
|
|
772
|
+
import path3 from "path";
|
|
773
|
+
var MANAGED_DIRS = ["models", "forms"];
|
|
774
|
+
function readTree(root) {
|
|
775
|
+
const out = [];
|
|
776
|
+
for (const rel of managedFiles(root)) {
|
|
777
|
+
out.push({ path: rel, content: readFileSync4(path3.join(root, rel), "utf8") });
|
|
778
|
+
}
|
|
779
|
+
return out;
|
|
780
|
+
}
|
|
781
|
+
function writeTree(root, files) {
|
|
782
|
+
const before = new Set(managedFiles(root));
|
|
783
|
+
const written = [];
|
|
784
|
+
for (const f of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
785
|
+
if (!isTreePath(f.path)) continue;
|
|
786
|
+
const abs = path3.join(root, f.path);
|
|
787
|
+
mkdirSync(path3.dirname(abs), { recursive: true });
|
|
788
|
+
writeFileSync(abs, f.content);
|
|
789
|
+
written.push(f.path);
|
|
790
|
+
before.delete(f.path);
|
|
791
|
+
}
|
|
792
|
+
return { written, stale: [...before].sort() };
|
|
793
|
+
}
|
|
794
|
+
function isTreePath(p) {
|
|
795
|
+
const parts = p.split("/");
|
|
796
|
+
return parts.length === 2 && MANAGED_DIRS.includes(parts[0]) && parts[1].endsWith(".yaml");
|
|
797
|
+
}
|
|
798
|
+
function managedFiles(root) {
|
|
799
|
+
const out = [];
|
|
800
|
+
for (const dir of MANAGED_DIRS) {
|
|
801
|
+
const abs = path3.join(root, dir);
|
|
802
|
+
if (!existsSync2(abs)) continue;
|
|
803
|
+
for (const name of readdirSync(abs).sort()) {
|
|
804
|
+
if (name.endsWith(".yaml")) out.push(`${dir}/${name}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return out.sort();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// src/metadata/plan.ts
|
|
811
|
+
function formatPlan(result, verbose = false) {
|
|
812
|
+
const lines = [];
|
|
813
|
+
let creates = 0;
|
|
814
|
+
let updates = 0;
|
|
815
|
+
let noops = 0;
|
|
816
|
+
for (const c of result.changes) {
|
|
817
|
+
if (c.action === "create") creates++;
|
|
818
|
+
else if (c.action === "update") updates++;
|
|
819
|
+
else noops++;
|
|
820
|
+
if (c.action === "noop" && !verbose) continue;
|
|
821
|
+
const label = c.action === "noop" ? "unchanged" : c.action;
|
|
822
|
+
lines.push(`${label.padEnd(9)} ${c.kind.padEnd(10)} ${c.key}${detail(c)}`);
|
|
823
|
+
}
|
|
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
|
+
return lines.join("\n");
|
|
828
|
+
}
|
|
829
|
+
function detail(c) {
|
|
830
|
+
const d = c.detail;
|
|
831
|
+
if (!d) return "";
|
|
832
|
+
const parts = [];
|
|
833
|
+
if (d.fields_added.length) parts.push(`+${d.fields_added.join(",")}`);
|
|
834
|
+
if (d.fields_changed.length) parts.push(`~${d.fields_changed.join(",")}`);
|
|
835
|
+
if (d.fields_removed.length) parts.push(`-${d.fields_removed.join(",")}`);
|
|
836
|
+
if (d.attributes_changed) parts.push("settings");
|
|
837
|
+
if (d.transformers_changed) parts.push("transformers");
|
|
838
|
+
return parts.length ? ` (${parts.join(" ")})` : "";
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// src/commands/metadata.ts
|
|
842
|
+
function treeRoot(ctx, opts) {
|
|
843
|
+
return opts.dir ? path4.resolve(ctx.io.cwd, opts.dir) : path4.resolve(ctx.projectRoot, ctx.metadataDir);
|
|
844
|
+
}
|
|
845
|
+
function readTreeOrFail(root) {
|
|
846
|
+
const files = readTree(root);
|
|
847
|
+
if (files.length === 0) throw usageError(`no YAML files under ${root}/models or ${root}/forms`);
|
|
848
|
+
return files;
|
|
849
|
+
}
|
|
850
|
+
async function runImport(ctx, root, apply, verbose) {
|
|
851
|
+
const files = readTreeOrFail(root);
|
|
852
|
+
const res = await request(ctx, "POST", "metadata/models/import", { body: { files, apply } });
|
|
853
|
+
if (ctx.json) ctx.io.stdout.write(toJson(res.data) + "\n");
|
|
854
|
+
else ctx.io.stdout.write(formatPlan(res.data, verbose) + "\n");
|
|
855
|
+
}
|
|
856
|
+
function registerMetadata(program, io) {
|
|
857
|
+
const metadata = program.command("metadata").description("Keep a directory of model and form YAML in step with the instance").addHelpText(
|
|
858
|
+
"after",
|
|
859
|
+
`
|
|
860
|
+
The tree is models/<key>.yaml and forms/<key>.yaml under the metadata
|
|
861
|
+
directory ("metadataDir" in plenary.json, default ./plenary). See
|
|
862
|
+
docs/CONTENT_MODELS.md, "YAML import and export".`
|
|
863
|
+
);
|
|
864
|
+
metadata.command("pull").description("Write every model and form the key may read into the directory").option("--dir <path>", "metadata directory (default: the configured one)").action(async (opts, cmd) => {
|
|
865
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
866
|
+
const root = treeRoot(ctx, opts);
|
|
867
|
+
const res = await request(ctx, "GET", "metadata/models");
|
|
868
|
+
const result = writeTree(root, res.data.files);
|
|
869
|
+
if (ctx.json) {
|
|
870
|
+
io.stdout.write(toJson({ dir: root, ...result }) + "\n");
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
io.stdout.write(`wrote ${result.written.length} file${result.written.length === 1 ? "" : "s"} to ${root}
|
|
874
|
+
`);
|
|
875
|
+
if (result.written.length === 0) {
|
|
876
|
+
io.stderr.write("the server sent no models: the key may lack content:models:read (a public key never has it)\n");
|
|
877
|
+
}
|
|
878
|
+
if (result.stale.length) {
|
|
879
|
+
io.stdout.write(`not on the server (left in place):
|
|
880
|
+
${result.stale.map((p) => ` ${p}`).join("\n")}
|
|
881
|
+
`);
|
|
882
|
+
}
|
|
883
|
+
});
|
|
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 models too").action(async (opts, cmd) => {
|
|
885
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
886
|
+
await runImport(ctx, treeRoot(ctx, opts), false, Boolean(opts.verbose));
|
|
887
|
+
});
|
|
888
|
+
metadata.command("push").description("Apply the directory to the instance (creates and updates; never deletes)").option("--dir <path>", "metadata directory (default: the configured one)").option("--dry-run", "same as diff").option("--verbose", "list unchanged models too").action(async (opts, cmd) => {
|
|
889
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
890
|
+
await runImport(ctx, treeRoot(ctx, opts), !opts.dryRun, Boolean(opts.verbose));
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// src/commands/init.ts
|
|
895
|
+
import { existsSync as existsSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
896
|
+
import path5 from "path";
|
|
897
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
898
|
+
function registerInit(program, io) {
|
|
899
|
+
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(
|
|
900
|
+
"after",
|
|
901
|
+
`
|
|
902
|
+
The base URL, site and locale come from --base-url, --site and --locale (or
|
|
903
|
+
their PLENARY_* variables); anything missing is prompted for on a terminal.`
|
|
904
|
+
).action(async (opts, cmd) => {
|
|
905
|
+
const target = path5.join(io.cwd, CONFIG_FILE);
|
|
906
|
+
if (existsSync3(target) && !opts.force) {
|
|
907
|
+
throw usageError(`${target} already exists (pass --force to overwrite)`);
|
|
908
|
+
}
|
|
909
|
+
const globals = cmd.optsWithGlobals();
|
|
910
|
+
const ctx = resolveContext({ ...globals, config: void 0 }, { ...io, cwd: path5.parse(io.cwd).root });
|
|
911
|
+
const baseUrl = globals.baseUrl ?? ctx.baseUrl ?? await ask(io, "Base URL (https://cms.example.com)");
|
|
912
|
+
if (!baseUrl) throw usageError("a base URL is required: pass --base-url or set PLENARY_BASE_URL");
|
|
913
|
+
const siteKey = globals.site ?? ctx.siteKey ?? await ask(io, "Site key", "default");
|
|
914
|
+
const locale = globals.locale ?? ctx.locale ?? await ask(io, "Locale", "en-us");
|
|
915
|
+
const config = { baseUrl: baseUrl.replace(/\/+$/, "") };
|
|
916
|
+
if (siteKey) config.siteKey = siteKey;
|
|
917
|
+
if (locale) config.locale = locale;
|
|
918
|
+
if (opts.dir !== DEFAULT_METADATA_DIR) config.metadataDir = opts.dir;
|
|
919
|
+
writeFileSync2(target, JSON.stringify(config, null, 2) + "\n");
|
|
920
|
+
io.stdout.write(`wrote ${target}
|
|
921
|
+
`);
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
async function ask(io, label, fallback) {
|
|
925
|
+
if (!io.isTTY) return fallback;
|
|
926
|
+
const rl = createInterface2({ input: io.stdin, output: io.stderr });
|
|
927
|
+
try {
|
|
928
|
+
const answer = (await rl.question(fallback ? `${label} [${fallback}]: ` : `${label}: `)).trim();
|
|
929
|
+
return answer === "" ? fallback : answer;
|
|
930
|
+
} finally {
|
|
931
|
+
rl.close();
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// src/commands/status.ts
|
|
936
|
+
import { PlenaryError as PlenaryError2 } from "@plenary/client";
|
|
937
|
+
function registerStatus(program, io) {
|
|
938
|
+
program.command("status").description("Show the instance's health and which credential this run holds").action(async (_opts, cmd) => {
|
|
939
|
+
const ctx = resolveContext(cmd.optsWithGlobals(), io);
|
|
940
|
+
const health = await request(ctx, "GET", "/api/v1/health", { auth: "none", allowError: true });
|
|
941
|
+
let me;
|
|
942
|
+
let credentialError;
|
|
943
|
+
let undescribed = false;
|
|
944
|
+
if (ctx.apiKey) {
|
|
945
|
+
try {
|
|
946
|
+
me = (await request(ctx, "GET", "/api/v1/auth/me")).data;
|
|
947
|
+
} catch (err) {
|
|
948
|
+
if (!(err instanceof PlenaryError2)) throw err;
|
|
949
|
+
if (err.status === 403) undescribed = true;
|
|
950
|
+
else credentialError = err;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
const failed = !health.ok || credentialError !== void 0;
|
|
954
|
+
if (ctx.json) {
|
|
955
|
+
io.stdout.write(
|
|
956
|
+
toJson({
|
|
957
|
+
base_url: ctx.baseUrl,
|
|
958
|
+
health: health.data,
|
|
959
|
+
credential: me ?? null,
|
|
960
|
+
credential_error: credentialError ? { status: credentialError.status, code: credentialError.code } : null
|
|
961
|
+
}) + "\n"
|
|
962
|
+
);
|
|
963
|
+
if (failed) throw new CliError("", 1);
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
const lines = [`Instance ${ctx.baseUrl}`];
|
|
967
|
+
if (health.ok || health.status === 503 && typeof health.data === "object") {
|
|
968
|
+
const h = health.data;
|
|
969
|
+
lines.push(`Version ${h.version} (${h.edition})`);
|
|
970
|
+
lines.push(`Status ${h.status}, database ${h.database}${h.license ? `, license ${h.license}` : ""}`);
|
|
971
|
+
} else {
|
|
972
|
+
lines.push(`Status unreachable (HTTP ${health.status})`);
|
|
973
|
+
}
|
|
974
|
+
if (me?.principal === "api_key") {
|
|
975
|
+
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
|
+
} else if (me?.principal === "user") {
|
|
977
|
+
lines.push(`Credential user session, role ${me.role_name ?? me.role}`);
|
|
978
|
+
} else if (credentialError) {
|
|
979
|
+
lines.push(`Credential rejected: ${credentialError.code} (HTTP ${credentialError.status})`);
|
|
980
|
+
} else if (undescribed) {
|
|
981
|
+
lines.push("Credential API key accepted (this server predates API-key support on /auth/me and cannot describe the key)");
|
|
982
|
+
} else {
|
|
983
|
+
lines.push("Credential none (set PLENARY_API_KEY or pass --api-key)");
|
|
984
|
+
}
|
|
985
|
+
if (ctx.configPath) lines.push(`Config ${ctx.configPath}`);
|
|
986
|
+
io.stdout.write(lines.join("\n") + "\n");
|
|
987
|
+
if (failed) throw new CliError("", 1);
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// src/cli.ts
|
|
992
|
+
function version() {
|
|
993
|
+
try {
|
|
994
|
+
const pkg = JSON.parse(readFileSync5(new URL("../package.json", import.meta.url), "utf8"));
|
|
995
|
+
return pkg.version;
|
|
996
|
+
} catch {
|
|
997
|
+
return "0.0.0";
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
function createProgram(io) {
|
|
1001
|
+
const program = new Command("plenary").description("Command-line client for the Plenary API").version(version(), "-v, --version").option("--base-url <url>", "instance URL (PLENARY_BASE_URL)").option("--api-key <key>", "API key (PLENARY_API_KEY)").option("--site <key>", "site key (PLENARY_SITE_KEY)").option("--locale <code>", "locale (PLENARY_LOCALE)").option("--config <path>", "plenary.json to use (default: nearest, searching upward)").option("--json", "print machine-readable JSON").option("--debug", "print requests and stack traces to stderr").showHelpAfterError("(run with --help for usage)").exitOverride().configureOutput({
|
|
1002
|
+
writeOut: (s) => io.stdout.write(s),
|
|
1003
|
+
writeErr: (s) => io.stderr.write(s)
|
|
1004
|
+
});
|
|
1005
|
+
registerInit(program, io);
|
|
1006
|
+
registerStatus(program, io);
|
|
1007
|
+
registerMetadata(program, io);
|
|
1008
|
+
registerCatalog(program, io, "models");
|
|
1009
|
+
registerFormEntries(registerCatalog(program, io, "forms"), io);
|
|
1010
|
+
registerEntries(program, io);
|
|
1011
|
+
registerMedia(program, io);
|
|
1012
|
+
registerApi(program, io);
|
|
1013
|
+
return program;
|
|
1014
|
+
}
|
|
1015
|
+
async function run(argv, io) {
|
|
1016
|
+
const program = createProgram(io);
|
|
1017
|
+
try {
|
|
1018
|
+
await program.parseAsync(argv, { from: "user" });
|
|
1019
|
+
return 0;
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
if (err instanceof CommanderError2) {
|
|
1022
|
+
return exitCodeFor(err);
|
|
1023
|
+
}
|
|
1024
|
+
const debug = Boolean(program.opts().debug);
|
|
1025
|
+
const message = describeError(err, debug);
|
|
1026
|
+
if (message !== "plenary: ") io.stderr.write(message + "\n");
|
|
1027
|
+
return exitCodeFor(err);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// src/index.ts
|
|
1032
|
+
run(process.argv.slice(2), {
|
|
1033
|
+
stdout: process.stdout,
|
|
1034
|
+
stderr: process.stderr,
|
|
1035
|
+
stdin: process.stdin,
|
|
1036
|
+
env: process.env,
|
|
1037
|
+
cwd: process.cwd(),
|
|
1038
|
+
fetch: globalThis.fetch,
|
|
1039
|
+
isTTY: Boolean(process.stdin.isTTY)
|
|
1040
|
+
}).then((code) => {
|
|
1041
|
+
process.exitCode = code;
|
|
1042
|
+
});
|
|
1043
|
+
//# sourceMappingURL=index.js.map
|