pivotkit-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js ADDED
@@ -0,0 +1,495 @@
1
+ /*
2
+ * pivotkit: the command line of pivotkit-report. `main()` runs one command with its input and output
3
+ * streams given (bin/pivotkit.js passes the process's), so tests can run it in the process too.
4
+ *
5
+ * Contract (docs/reference/cli.md): exit code 0 done, 1 findings (an error in the view, or a warning
6
+ * with --strict), 2 a usage, file, limit or template error. --json writes one object on stdout:
7
+ * { ok, exitCode, command, findings: [{ severity, code, path, message, suggestion }], error?: { kind,
8
+ * code, message }, output?, ... }. No network; with --now the output is the same on every run.
9
+ */
10
+
11
+ import { createRequire } from "node:module";
12
+ import { basename, extname } from "node:path";
13
+ import { parseArgs } from "node:util";
14
+ import { LIMITS, OUTSIDE_LIMITS, VIEW_FILE_FORMAT, closest, describeSource, describeView, encode, fromViewFile, outsideLimits, validate } from "pivotkit-core";
15
+ import { readFile } from "pivotkit-core/import";
16
+ import { readViewLink } from "pivotkit-core/link";
17
+ import { RENDER_FORMATS, render } from "pivotkit-report";
18
+ import xlsxReader from "pivotkit-xlsx";
19
+ import { COMMANDS, SHELLS, optionsOf } from "./commands.js";
20
+ import { CliError, asCliError, printable, shortName } from "./errors.js";
21
+ import { completionScript, helpText } from "./help.js";
22
+ import { allowedDirs, readSafe, readStdin, writeSafe } from "./safe-files.js";
23
+
24
+ /**
25
+ * @typedef {object} Io
26
+ * @property {AsyncIterable<Uint8Array> & { isTTY?: boolean }} stdin
27
+ * @property {{ write(chunk: string | Uint8Array): unknown, isTTY?: boolean }} stdout
28
+ * @property {{ write(chunk: string | Uint8Array): unknown }} stderr
29
+ * @property {Record<string, string | undefined>} env
30
+ * @property {string} cwd
31
+ */
32
+
33
+ /** The most bytes a view or a template file may have (a view file's characters as UTF-8). */
34
+ const VIEW_BYTES = OUTSIDE_LIMITS.json * 4;
35
+ const TEMPLATE_BYTES = 262_144 * 4;
36
+
37
+ /** The versions of this package and the two it renders with. */
38
+ export function versions() {
39
+ const require = createRequire(import.meta.url);
40
+ const of = (name) => require(`${name}/package.json`).version;
41
+ return { "pivotkit-cli": of("pivotkit-cli"), "pivotkit-report": of("pivotkit-report"), "pivotkit-core": of("pivotkit-core") };
42
+ }
43
+
44
+ /** "; did you mean --x?" or "" (pivotkit-core's closest(), as the view's and the template's hints). */
45
+ const didYouMean = (word, known, prefix = "") => {
46
+ const near = closest(word, known);
47
+ return near ? `; did you mean ${prefix}${near}?` : "";
48
+ };
49
+
50
+ /** Parses a command's arguments with the table's options; unknown ones are a usage error with a hint. */
51
+ function parse(command, args) {
52
+ const specs = optionsOf(command);
53
+ /** @type {Record<string, any>} */
54
+ const options = Object.fromEntries(Object.entries(specs).map(([name, s]) => [name, { type: s.type, ...(s.short ? { short: s.short } : {}), ...(s.multiple ? { multiple: true } : {}) }]));
55
+ try {
56
+ return parseArgs({ args, options, allowPositionals: true, strict: true });
57
+ } catch (e) {
58
+ const flag = /'(-{1,2}[^'\s]+)'/.exec(String(e.message))?.[1] ?? "";
59
+ if (e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
60
+ throw new CliError("usage", "UNKNOWN_OPTION", `unknown option ${printable(flag)} for "${command}"${didYouMean(flag.replace(/^-+/, ""), Object.keys(specs), "--")}`);
61
+ }
62
+ throw new CliError("usage", "BAD_OPTION", printable(String(e.message).split("\n")[0]));
63
+ }
64
+ }
65
+
66
+ /** Checks the values of options that take one of a list. */
67
+ function checkChoices(command, values) {
68
+ for (const [name, spec] of Object.entries(optionsOf(command))) {
69
+ if (spec.choices && values[name] !== undefined && !spec.choices.includes(values[name])) {
70
+ throw new CliError("usage", "BAD_VALUE", `--${name} takes ${spec.choices.join(", ")}, not ${printable(JSON.stringify(values[name]))}${didYouMean(String(values[name]), spec.choices)}`);
71
+ }
72
+ }
73
+ }
74
+
75
+ /** --limit key=n, checked against pivotkit-core's LIMITS. */
76
+ function limitsOf(list) {
77
+ /** @type {Record<string, number>} */
78
+ const limits = {};
79
+ for (const entry of list ?? []) {
80
+ const m = /^([A-Za-z]+)=(\d+)$/.exec(entry);
81
+ if (!m) {
82
+ throw new CliError("usage", "BAD_LIMIT", `--limit takes key=number, not ${printable(JSON.stringify(entry))}`);
83
+ }
84
+ if (!Object.hasOwn(LIMITS, m[1])) {
85
+ throw new CliError("usage", "UNKNOWN_LIMIT", `unknown limit "${printable(m[1])}"${didYouMean(m[1], Object.keys(LIMITS))} (one of ${Object.keys(LIMITS).join(", ")})`);
86
+ }
87
+ const value = Number(m[2]);
88
+ if (!Number.isSafeInteger(value) || value < 1) {
89
+ throw new CliError("usage", "BAD_LIMIT", `--limit ${m[1]} must be a whole number of 1 or more`);
90
+ }
91
+ limits[m[1]] = value;
92
+ }
93
+ return limits;
94
+ }
95
+
96
+ /** A whole number option within bounds. */
97
+ function intOption(name, value, min, max) {
98
+ if (value === undefined) {
99
+ return undefined;
100
+ }
101
+ const n = Number(value);
102
+ if (!/^\d+$/.test(value) || n < min || n > max) {
103
+ throw new CliError("usage", "BAD_VALUE", `--${name} takes a whole number from ${min} to ${max}`);
104
+ }
105
+ return n;
106
+ }
107
+
108
+ /** The findings of a run, in the --json shape. */
109
+ const findingOf = (issue) => ({
110
+ severity: issue.severity === "warning" ? "warning" : "error",
111
+ code: issue.code ?? "issue",
112
+ path: printable(issue.path ?? ""),
113
+ message: printable(issue.message ?? ""),
114
+ ...(issue.params?.suggestion ? { suggestion: printable(issue.params.suggestion) } : {})
115
+ });
116
+
117
+ /**
118
+ * Runs one command. Never throws: every error ends in its exit code and message.
119
+ * @param {string[]} argv the arguments after "pivotkit"
120
+ * @param {Io} io
121
+ * @returns {Promise<number>} the exit code
122
+ */
123
+ export async function main(argv, io) {
124
+ const json = argv.includes("--json");
125
+ /** @type {any[]} */
126
+ let findings = [];
127
+ let command = argv.find((a) => !a.startsWith("-")) ?? "";
128
+ const say = (text) => io.stderr.write(`${text}\n`);
129
+ try {
130
+ if (argv.length === 0) {
131
+ io.stdout.write(helpText());
132
+ return 0;
133
+ }
134
+ if (argv[0] === "--version" || argv[0] === "-v") {
135
+ const v = versions();
136
+ io.stdout.write(json ? `${JSON.stringify(v)}\n` : `pivotkit-cli ${v["pivotkit-cli"]} (pivotkit-report ${v["pivotkit-report"]}, pivotkit-core ${v["pivotkit-core"]})\n`);
137
+ return 0;
138
+ }
139
+ if (argv[0] === "--help" || argv[0] === "-h") {
140
+ io.stdout.write(helpText());
141
+ return 0;
142
+ }
143
+ command = argv[0];
144
+ if (!Object.hasOwn(COMMANDS, command)) {
145
+ throw new CliError("usage", "UNKNOWN_COMMAND", `unknown command ${printable(JSON.stringify(command))}${didYouMean(command, Object.keys(COMMANDS))}; run "pivotkit help"`);
146
+ }
147
+ const { values, positionals } = parse(command, argv.slice(1));
148
+ if (values.help) {
149
+ io.stdout.write(helpText(command));
150
+ return 0;
151
+ }
152
+ checkChoices(command, values);
153
+ if (command === "help") {
154
+ if (positionals[0] && !Object.hasOwn(COMMANDS, positionals[0])) {
155
+ throw new CliError("usage", "UNKNOWN_COMMAND", `unknown command ${printable(JSON.stringify(positionals[0]))}${didYouMean(positionals[0], Object.keys(COMMANDS))}`);
156
+ }
157
+ io.stdout.write(helpText(positionals[0]));
158
+ return 0;
159
+ }
160
+ if (command === "completion") {
161
+ const shell = positionals[0];
162
+ if (!SHELLS.includes(shell)) {
163
+ throw new CliError("usage", "BAD_VALUE", `completion takes one of ${SHELLS.join(", ")}`);
164
+ }
165
+ io.stdout.write(completionScript(/** @type {any} */ (shell)));
166
+ return 0;
167
+ }
168
+ // defaults from the environment; an option given wins
169
+ /** @type {Record<string, string>} the options whose value came from an environment variable */
170
+ const fromEnv = {};
171
+ for (const [name, spec] of Object.entries(optionsOf(command))) {
172
+ if (spec.env && values[name] === undefined && io.env[spec.env]) {
173
+ values[name] = io.env[spec.env];
174
+ fromEnv[name] = spec.env;
175
+ }
176
+ }
177
+ checkValues(values, fromEnv);
178
+ const ctx = await context(values, io);
179
+ const outcome = command === "render" ? await renderCommand(positionals, values, ctx) : command === "validate" ? await validateCommand(positionals, values, ctx) : await describeCommand(positionals, values, ctx);
180
+ findings = outcome.findings;
181
+ const failing = findings.filter((f) => f.severity === "error" || (values.strict && f.severity === "warning"));
182
+ const exitCode = failing.length > 0 ? 1 : 0;
183
+ if (json) {
184
+ io.stdout.write(`${JSON.stringify({ ok: exitCode === 0, exitCode, command, findings, ...outcome.extra })}\n`);
185
+ } else {
186
+ for (const f of findings) {
187
+ if (f.severity === "error" || (f.severity === "warning" && !values.quiet)) {
188
+ say(`${f.severity} ${f.code}${f.path ? ` at ${f.path}` : ""}: ${f.message}${f.suggestion ? ` (did you mean "${f.suggestion}"?)` : ""}`);
189
+ }
190
+ }
191
+ if (outcome.note && !values.quiet) {
192
+ say(outcome.note);
193
+ }
194
+ }
195
+ return exitCode;
196
+ } catch (e) {
197
+ const error = asCliError(e);
198
+ if (json) {
199
+ io.stdout.write(`${JSON.stringify({ ok: false, exitCode: 2, command, findings, error: { kind: error.kind, code: error.code, message: error.message } })}\n`);
200
+ } else {
201
+ say(`pivotkit: ${error.message}`);
202
+ }
203
+ return 2;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Checks the options every command takes the same way, so a wrong one (or a wrong environment
209
+ * variable) is an error in every command, not only in the one that uses it.
210
+ * @param {Record<string, any>} values
211
+ * @param {Record<string, string>} fromEnv
212
+ */
213
+ function checkValues(values, fromEnv) {
214
+ const bad = (name, what) => new CliError("usage", "BAD_VALUE", `--${name} ${printable(JSON.stringify(values[name]))} is not ${what}${fromEnv[name] ? ` (from ${fromEnv[name]})` : ""}`);
215
+ if (values.locale !== undefined) {
216
+ try {
217
+ Intl.getCanonicalLocales(values.locale);
218
+ } catch {
219
+ throw bad("locale", "a locale tag such as en-US or de-DE");
220
+ }
221
+ }
222
+ if (values["time-zone"] !== undefined) {
223
+ try {
224
+ new Intl.DateTimeFormat("en-US", { timeZone: values["time-zone"] });
225
+ } catch {
226
+ throw bad("time-zone", "a time zone such as UTC or Europe/Berlin");
227
+ }
228
+ }
229
+ if (values.now !== undefined && Number.isNaN(new Date(values.now).getTime())) {
230
+ throw bad("now", "a date or an ISO time such as 2026-11-13 or 2026-11-13T09:00:00Z");
231
+ }
232
+ }
233
+
234
+ /** What every command needs: the limits, the allowed directories and how to read a file. */
235
+ async function context(values, io) {
236
+ const limits = limitsOf(values.limit);
237
+ const allow = await allowedDirs(values["allow-dir"] ?? [], io.cwd);
238
+ let stdinUsed = null;
239
+ /** Reads a file or stdin ("-"), at most one input from stdin. */
240
+ const readInput = async (path, what, maxBytes, limit) => {
241
+ if (path === "-") {
242
+ if (stdinUsed) {
243
+ throw new CliError("usage", "STDIN_TWICE", `only one input can come from stdin (${stdinUsed} does already)`);
244
+ }
245
+ stdinUsed = what;
246
+ return readStdin(io.stdin, maxBytes, limit);
247
+ }
248
+ try {
249
+ return await readSafe(path, { cwd: io.cwd, allow, maxBytes, limit });
250
+ } catch (e) {
251
+ throw asCliError(e, path);
252
+ }
253
+ };
254
+ const fileLimits = { ...LIMITS, ...limits };
255
+ return { limits, allow, readInput, io, fileLimits, now: values.now, locale: values.locale, timeZone: values["time-zone"] };
256
+ }
257
+
258
+ /**
259
+ * Reader notes that only say how a file was read (its encoding, separator, sheet): "info", in --json
260
+ * only. The others say the data differs from the file (a column renamed or dropped, text cut, values
261
+ * not converted): warnings.
262
+ */
263
+ const INFO_NOTES = new Set(["ENCODING", "SEPARATOR", "SHEET", "USED_RANGE", "JSON_FLATTENED", "LINES_SKIPPED", "ROWS_EMPTY"]);
264
+
265
+ /** The text of a view or template file, as UTF-8. */
266
+ const textOf = (bytes) => new TextDecoder("utf-8", { fatal: false }).decode(bytes).replace(/^\uFEFF/, "");
267
+
268
+ /** The view of a command: from --link, a file, stdin or JSON text. */
269
+ async function readView(positionals, values, ctx) {
270
+ if (values.link !== undefined) {
271
+ const url = String(values.link);
272
+ const hash = url.includes("#") ? url.slice(url.indexOf("#")) : url;
273
+ const read = readViewLink(hash, values.key ?? null, { limits: ctx.limits });
274
+ if (!read.view) {
275
+ throw new CliError("usage", "BAD_LINK", `--link: not a pivotkit link (${read.failed})`);
276
+ }
277
+ return { view: read.view, name: read.name, source: "link" };
278
+ }
279
+ const arg = positionals[0];
280
+ if (arg === undefined) {
281
+ throw new CliError("usage", "MISSING_VIEW", "a view is needed: a file, - for stdin, JSON text or --link");
282
+ }
283
+ if (arg.trimStart().startsWith("{")) {
284
+ return { view: arg, name: "", source: "argument" };
285
+ }
286
+ const text = textOf(await ctx.readInput(arg, "the view", VIEW_BYTES, "json"));
287
+ return { view: text, name: "", source: arg };
288
+ }
289
+
290
+ /** The data of --data as an import table, with the reader's notes as warnings. */
291
+ async function readData(values, ctx) {
292
+ const path = values.data;
293
+ if (path === undefined) {
294
+ return null;
295
+ }
296
+ const format = values["data-format"] ?? (path === "-" ? null : extname(path).slice(1).toLowerCase());
297
+ if (!format || !["csv", "json", "xlsx", "tsv", "txt"].includes(format)) {
298
+ throw new CliError("usage", "DATA_FORMAT", path === "-" ? "--data - needs --data-format csv, json or xlsx" : `${shortName(path)}: unknown kind of data file; give --data-format csv, json or xlsx`);
299
+ }
300
+ const bytes = await ctx.readInput(path, "the data", ctx.fileLimits.fileBytes, "fileBytes");
301
+ const name = path === "-" ? `stdin.${format}` : basename(path).endsWith(`.${format}`) ? basename(path) : `${basename(path)}.${format}`;
302
+ try {
303
+ const table = await readFile(new File([/** @type {BlobPart} */ (bytes)], name), { plugins: [xlsxReader], limits: ctx.limits, ...(values.sheet ? { sheet: values.sheet } : {}), ...(ctx.timeZone ? { timeZone: ctx.timeZone } : {}) });
304
+ return { table, name: path === "-" ? "stdin" : basename(path), notes: table.notes.map((n) => ({ severity: INFO_NOTES.has(n.code) ? "info" : "warning", code: n.code, path: "", message: printable(`${shortName(path)}: ${n.message}`) })) };
305
+ } catch (e) {
306
+ throw asCliError(e, path);
307
+ }
308
+ }
309
+
310
+ /** The render options common to the commands. */
311
+ const renderBase = (ctx) => ({ ...(ctx.locale ? { locale: ctx.locale } : {}), ...(ctx.timeZone ? { timeZone: ctx.timeZone } : {}), ...(ctx.now ? { now: ctx.now } : {}), ...(Object.keys(ctx.limits).length ? { limits: ctx.limits } : {}) });
312
+
313
+ /** The extensions -o names a format by (".htm" is HTML too). */
314
+ const EXTENSIONS = Object.freeze({ ...Object.fromEntries(Object.keys(RENDER_FORMATS).map((f) => [f, f])), htm: "html" });
315
+
316
+ /** The format of an output file by its extension; html on stdout. Another extension is an error, not HTML in a .pdf. */
317
+ function formatOf(output) {
318
+ if (!output) {
319
+ return "html";
320
+ }
321
+ const ext = extname(output).slice(1).toLowerCase();
322
+ if (!Object.hasOwn(EXTENSIONS, ext)) {
323
+ throw new CliError("usage", "UNKNOWN_EXTENSION", `${shortName(output)}: ${ext ? `.${printable(ext)} is no format pivotkit writes` : "no extension to take the format from"}; give -f ${COMMANDS.render.options.format.choices?.join(", ")}, or an extension of one`);
324
+ }
325
+ return EXTENSIONS[ext];
326
+ }
327
+
328
+ /** Warnings for options given that do nothing in the format (their `formats` in the table). */
329
+ function ignoredOptions(values, format) {
330
+ return Object.entries(optionsOf("render"))
331
+ .filter(([name, spec]) => spec.formats && values[name] !== undefined && !spec.formats.includes(format))
332
+ .map(([name, spec]) => ({ severity: "warning", code: "ignoredOption", path: "", message: `--${name} does nothing in ${format} (only in ${spec.formats.join(", ")})` }));
333
+ }
334
+
335
+ async function renderCommand(positionals, values, ctx) {
336
+ if (positionals.length > 1) {
337
+ throw new CliError("usage", "EXTRA_ARGUMENT", `render takes one view, not ${positionals.length}`);
338
+ }
339
+ if (values.json && !values.output) {
340
+ throw new CliError("usage", "JSON_NEEDS_OUTPUT", "--json writes the result on stdout: give the output a file with -o");
341
+ }
342
+ const format = values.format ?? formatOf(values.output);
343
+ if (!values.output && format === "xlsx" && ctx.io.stdout.isTTY) {
344
+ throw new CliError("usage", "BINARY_TO_TERMINAL", "an XLSX file is not shown in a terminal: write it to a file with -o, or redirect stdout");
345
+ }
346
+ const view = await readView(positionals, values, ctx);
347
+ const data = await readData(values, ctx);
348
+ if (!data) {
349
+ throw new CliError("usage", "MISSING_DATA", "render needs data: --data <file> or --data -");
350
+ }
351
+ const findings = [...data.notes, ...ignoredOptions(values, format)];
352
+ const template = values.template !== undefined ? textOf(await ctx.readInput(values.template, "the template", TEMPLATE_BYTES, "template")) : undefined;
353
+ const echarts = format === "svg" || format === "html" ? await import("echarts") : undefined;
354
+ const width = intOption("width", values.width, 100, 8000);
355
+ const height = intOption("height", values.height, 100, 8000);
356
+ let result;
357
+ try {
358
+ result = await render(view.view, data.table, {
359
+ ...renderBase(ctx),
360
+ format: /** @type {any} */ (format),
361
+ strict: !values.lenient,
362
+ source: data.name,
363
+ ...(values.title !== undefined ? { title: values.title } : {}),
364
+ ...(template !== undefined ? { template } : {}),
365
+ ...(values.chart ? { chart: values.chart === "auto" ? "auto" : values.chart === "on" } : {}),
366
+ // a byte order mark lets Excel read a CSV file as UTF-8; a program reading stdout gets none
367
+ ...(format === "csv" ? { bom: values.bom === "on" || ((values.bom ?? "auto") === "auto" && Boolean(values.output)) } : {}),
368
+ ...(echarts ? { echarts } : {}),
369
+ ...(width || height ? { size: { ...(width ? { width } : {}), ...(height ? { height } : {}) } } : {})
370
+ });
371
+ } catch (e) {
372
+ throw asCliError(e, values.template);
373
+ }
374
+ findings.push(...result.issues.map(findingOf));
375
+ const failed = findings.some((f) => f.severity === "error" || (values.strict && f.severity === "warning"));
376
+ /** @type {any} */
377
+ const extra = { summary: result.summary };
378
+ if (result.body && !(failed && !values.lenient)) {
379
+ if (values.output) {
380
+ try {
381
+ await writeSafe(values.output, result.body, { cwd: ctx.io.cwd, allow: ctx.allow });
382
+ } catch (e) {
383
+ const error = asCliError(e, values.output);
384
+ // -o /dev/stdout: a device is never replaced, and stdout is where the output goes without -o
385
+ throw error.code === "SPECIAL_FILE" ? new CliError(error.kind, error.code, `${error.message}; to write to stdout, leave out -o`) : error;
386
+ }
387
+ extra.output = { file: shortName(values.output), format, mediaType: result.mediaType, bytes: result.body.length };
388
+ } else {
389
+ ctx.io.stdout.write(result.body);
390
+ }
391
+ } else {
392
+ extra.output = null;
393
+ }
394
+ return { findings, extra, note: extra.output === null ? "nothing written: the view has errors (--lenient writes it as the report shows it)" : null };
395
+ }
396
+
397
+ async function validateCommand(positionals, values, ctx) {
398
+ const view = await readView(positionals, values, ctx);
399
+ const data = await readData(values, ctx);
400
+ const read = fromViewFile(typeof view.view === "string" ? view.view : JSON.stringify(view.view));
401
+ if (read.failed) {
402
+ return { findings: [{ severity: "error", code: "viewFile", path: "", message: printable(`not a view: ${read.issues.at(-1)?.message ?? read.failed}`) }], extra: { valid: false } };
403
+ }
404
+ let schema;
405
+ if (data) {
406
+ schema = encode(data.table, { ...(ctx.timeZone ? { timeZone: ctx.timeZone } : {}), ...(Object.keys(ctx.limits).length ? { limits: ctx.limits } : {}) });
407
+ } else if (read.source?.fields?.length) {
408
+ schema = read.source.fields;
409
+ } else {
410
+ throw new CliError("usage", "MISSING_DATA", "validate needs data (--data) or a view file that lists its fields (source.fields)");
411
+ }
412
+ const { errors, warnings, valid } = validate(read.view, schema, { limits: outsideLimits(ctx.limits) });
413
+ const fileNotes = read.issues.map((i) => ({ severity: "warning", code: "viewFile", path: printable(i.path ?? ""), message: printable(i.message) }));
414
+ return { findings: [...(data?.notes ?? []), ...fileNotes, ...errors.map(findingOf), ...warnings.map(findingOf)], extra: { valid } };
415
+ }
416
+
417
+ async function describeCommand(positionals, values, ctx) {
418
+ const input = positionals[0];
419
+ if (input === undefined) {
420
+ throw new CliError("usage", "MISSING_INPUT", "describe needs a view or a data file");
421
+ }
422
+ const items = intOption("items", values.items, 0, 1000) ?? 0;
423
+ const ext = input === "-" ? values["data-format"] ?? "" : extname(input).slice(1).toLowerCase();
424
+ let kind = values.as;
425
+ let text = null;
426
+ /** @type {Uint8Array | null} a JSON file read to tell a view from data, kept for reading it as data */
427
+ let bytes = null;
428
+ if (!kind) {
429
+ if (input.trimStart().startsWith("{")) {
430
+ text = input;
431
+ kind = isView(text) ? "view" : "data";
432
+ } else if (ext === "json" || (input === "-" && !values["data-format"])) {
433
+ bytes = await ctx.readInput(input, "the input", Math.max(VIEW_BYTES, ctx.fileLimits.fileBytes), "fileBytes");
434
+ // a file larger than a view may be is data: not decoded and parsed only to tell
435
+ text = bytes.length <= VIEW_BYTES ? textOf(bytes) : null;
436
+ kind = text !== null && isView(text) ? "view" : "data";
437
+ } else {
438
+ kind = "data";
439
+ }
440
+ }
441
+ if (kind === "view") {
442
+ const view = text ?? (input.trimStart().startsWith("{") ? input : textOf(await ctx.readInput(input, "the view", VIEW_BYTES, "json")));
443
+ const read = fromViewFile(view);
444
+ if (read.failed) {
445
+ return { findings: [{ severity: "error", code: "viewFile", path: "", message: printable(`not a view: ${read.issues.at(-1)?.message ?? read.failed}`) }], extra: { kind } };
446
+ }
447
+ const data = await readData(values, ctx);
448
+ if (!data && !read.source?.fields?.length) {
449
+ throw new CliError("usage", "MISSING_DATA", "describing a view needs its data (--data) or a view file that lists its fields (source.fields)");
450
+ }
451
+ const schema = data ? encode(data.table, { ...(ctx.timeZone ? { timeZone: ctx.timeZone } : {}) }) : read.source?.fields;
452
+ const described = describeView(read.view, schema, { limits: outsideLimits(ctx.limits) });
453
+ if (!values.json) {
454
+ ctx.io.stdout.write(`${printable(read.name || "view")}: ${printable(described.text)}\n`);
455
+ }
456
+ return { findings: [...(data?.notes ?? []), ...[...described.errors, ...described.warnings].map(findingOf)], extra: { kind, name: printable(read.name), description: described } };
457
+ }
458
+ // data: the bytes or the text read above (JSON that is no view), else the file
459
+ const known = bytes ?? (text !== null ? new TextEncoder().encode(text) : null);
460
+ const data = await readData({ ...values, data: input, "data-format": values["data-format"] ?? (known ? "json" : undefined) }, known ? withBytes(ctx, input, known) : ctx);
461
+ const encoded = encode(data.table, { ...(ctx.timeZone ? { timeZone: ctx.timeZone } : {}) });
462
+ const full = describeSource(encoded, { items });
463
+ // what is shown by default is names, types and counts: values (items, the smallest and largest) only with --items
464
+ const summary = items > 0 ? full : { ...full, fields: full.fields.map(({ min: _min, max: _max, ...f }) => ({ ...f, text: f.text.replace(/, from .*$/, "") })) };
465
+ if (!values.json) {
466
+ ctx.io.stdout.write(`${printable(summary.text)}\n`);
467
+ for (const f of summary.fields) {
468
+ ctx.io.stdout.write(` ${printable(f.text)}${f.items?.length ? `; e.g. ${f.items.map((v) => printable(JSON.stringify(v))).join(", ")}` : ""}\n`);
469
+ }
470
+ }
471
+ return { findings: data.notes, extra: { kind: "data", description: summary } };
472
+ }
473
+
474
+ /** A context whose reader hands back the bytes already read (stdin is read once). */
475
+ function withBytes(ctx, input, bytes) {
476
+ return { ...ctx, readInput: async (path, ...rest) => (path === input ? bytes : ctx.readInput(path, ...rest)) };
477
+ }
478
+
479
+ /** Whether a JSON text is a view: a view file, a template { name, view }, or an object with a view's keys. */
480
+ function isView(text) {
481
+ let doc;
482
+ try {
483
+ doc = JSON.parse(text);
484
+ } catch {
485
+ return false;
486
+ }
487
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
488
+ return false;
489
+ }
490
+ if (doc.format === VIEW_FILE_FORMAT || (typeof doc.name === "string" && doc.view && typeof doc.view === "object")) {
491
+ return true;
492
+ }
493
+ const viewKeys = ["rows", "cols", "values", "filters", "derived", "display", "chart", "version"];
494
+ return Object.keys(doc).length > 0 && Object.keys(doc).every((k) => viewKeys.includes(k) || ["sort", "totals", "collapsed", "formatting"].includes(k));
495
+ }
@@ -0,0 +1,113 @@
1
+ /*
2
+ * The one table of the CLI: its commands, their arguments and their options with what each takes. The
3
+ * argument parser (node:util parseArgs), `pivotkit help` and the shell completions (completion.js)
4
+ * are all made from it, so they cannot drift apart.
5
+ */
6
+
7
+ /**
8
+ * @typedef {object} OptionSpec
9
+ * @property {"string" | "boolean"} type
10
+ * @property {string} [short] one letter
11
+ * @property {boolean} [multiple] may be given more than once
12
+ * @property {string} [value] the value's name in the help ("file", "dir")
13
+ * @property {string[]} [choices] the values it takes (checked, and offered by the completions)
14
+ * @property {"file" | "dir"} [complete] what a shell completes for its value
15
+ * @property {string} [env] the environment variable that gives its default
16
+ * @property {string[]} [formats] render: the output formats it acts in (given with another, a warning)
17
+ * @property {string} text one line of help
18
+ */
19
+
20
+ /** @type {Readonly<Record<string, OptionSpec>>} Options every command takes. */
21
+ export const GLOBAL_OPTIONS = Object.freeze({
22
+ json: { type: "boolean", text: "Write the result as one JSON object on stdout (render: needs -o)." },
23
+ quiet: { type: "boolean", short: "q", text: "Write no notes and warnings to stderr; errors still." },
24
+ locale: { type: "string", value: "tag", env: "PIVOTKIT_LOCALE", text: "render: numbers, dates and texts in this locale when the view names none (default en-US); checked by every command." },
25
+ "time-zone": { type: "string", value: "zone", env: "PIVOTKIT_TIME_ZONE", text: "Time zone of the data's dates and of the generated time (default UTC)." },
26
+ now: { type: "string", value: "date", env: "PIVOTKIT_NOW", text: 'render: the "today" of relative date filters and the generated time; fix it for the same output on every run; checked by every command.' },
27
+ limit: { type: "string", multiple: true, value: "key=n", text: "Set one of pivotkit-core's limits (rows=200000); may be given more than once." },
28
+ "allow-dir": { type: "string", multiple: true, value: "dir", complete: "dir", text: "Read and write only inside this directory (symbolic links and .. resolved); may be given more than once." },
29
+ help: { type: "boolean", short: "h", text: "Show the help of the command." }
30
+ });
31
+
32
+ /** @type {Readonly<Record<string, OptionSpec>>} Options of commands that read data. */
33
+ const DATA_OPTIONS = Object.freeze({
34
+ data: { type: "string", value: "file", complete: "file", text: "The data: a CSV, JSON or XLSX file, or - for stdin." },
35
+ "data-format": { type: "string", value: "format", choices: ["csv", "json", "xlsx"], text: "The data's format; needed for stdin, else taken from the file's extension." },
36
+ sheet: { type: "string", value: "name", text: "The sheet of a workbook to read (default the first visible one)." }
37
+ });
38
+
39
+ /** @type {Readonly<Record<string, OptionSpec>>} Options of commands that read a view. */
40
+ const VIEW_OPTIONS = Object.freeze({
41
+ link: { type: "string", value: "url", text: "Read the view from a pivotkit link (an address or its #fragment) instead of <view>." },
42
+ key: { type: "string", value: "key", text: "The report's key in the link (when its page has more than one report)." }
43
+ });
44
+
45
+ /**
46
+ * @typedef {object} CommandSpec
47
+ * @property {string} usage
48
+ * @property {string} text
49
+ * @property {Array<{ name: string, text: string, optional?: boolean, complete?: "file" | "shell" | "command" }>} args
50
+ * @property {Readonly<Record<string, OptionSpec>>} options besides GLOBAL_OPTIONS
51
+ * @property {string[]} [examples]
52
+ */
53
+
54
+ /** @type {Readonly<Record<string, CommandSpec>>} */
55
+ export const COMMANDS = Object.freeze({
56
+ render: {
57
+ usage: "pivotkit render <view> --data <file> [-o <file>] [options]",
58
+ text: "Run a view over data and write the result: an HTML report, CSV, JSON, XLSX or the chart as SVG.",
59
+ args: [{ name: "view", text: "A view file, a view as JSON text, or - for stdin.", optional: true, complete: "file" }],
60
+ options: {
61
+ ...VIEW_OPTIONS,
62
+ ...DATA_OPTIONS,
63
+ format: { type: "string", short: "f", value: "format", choices: ["html", "csv", "json", "xlsx", "svg"], text: "The output's format; default -o's extension (another one is an error), html on stdout." },
64
+ output: { type: "string", short: "o", value: "file", complete: "file", text: "Write to this file (replaced if it is a file) instead of stdout." },
65
+ template: { type: "string", value: "file", complete: "file", formats: ["html"], text: "html: the print template with {{placeholders}} (default the built-in one)." },
66
+ title: { type: "string", value: "text", formats: ["html", "xlsx"], text: "html, xlsx: the report's title (default the view file's name)." },
67
+ chart: { type: "string", value: "when", choices: ["auto", "on", "off"], formats: ["html"], text: "html: the chart above the table: auto (when the view shows the chart), on or off." },
68
+ width: { type: "string", value: "px", formats: ["svg", "html"], text: "svg, html: the chart's width (default 800)." },
69
+ height: { type: "string", value: "px", formats: ["svg", "html"], text: "svg, html: the chart's height (default 480)." },
70
+ bom: { type: "string", value: "when", choices: ["auto", "on", "off"], formats: ["csv"], text: "csv: a byte order mark first, so Excel reads UTF-8: auto (in a file of -o, not on stdout), on or off." },
71
+ lenient: { type: "boolean", text: "Write the output also when the view has errors (as the report shows it); the exit code is still 1." },
72
+ strict: { type: "boolean", text: "Warnings are findings too (exit 1)." }
73
+ },
74
+ examples: ["pivotkit render sales.pivot.json --data sales.csv -o sales.html", "pivotkit render view.json --data - --data-format csv --format csv < sales.csv > out.csv", "pivotkit render view.json --data sales.xlsx -o chart.svg --now 2026-11-13"]
75
+ },
76
+ validate: {
77
+ usage: "pivotkit validate <view> [--data <file>] [options]",
78
+ text: "Check a view against data (or the fields its view file lists): errors exit 1, warnings too with --strict.",
79
+ args: [{ name: "view", text: "A view file, a view as JSON text, or - for stdin.", optional: true, complete: "file" }],
80
+ options: { ...VIEW_OPTIONS, ...DATA_OPTIONS, strict: { type: "boolean", text: "Warnings are findings too (exit 1)." } },
81
+ examples: ["pivotkit validate sales.pivot.json --data sales.csv --strict"]
82
+ },
83
+ describe: {
84
+ usage: "pivotkit describe <view | data> [options]",
85
+ text: "Describe a view (what it shows and filters) or a data file (its fields, types and counts).",
86
+ args: [{ name: "input", text: "A view file or a data file (CSV, JSON, XLSX), or - for stdin.", complete: "file" }],
87
+ options: {
88
+ ...DATA_OPTIONS,
89
+ as: { type: "string", value: "kind", choices: ["view", "data"], text: "Read the input as a view or as data (default: a view when it is one)." },
90
+ items: { type: "string", value: "n", text: "data: list up to n values of each field (default none: names, types and counts only)." }
91
+ },
92
+ examples: ["pivotkit describe sales.csv --json", "pivotkit describe sales.pivot.json --data sales.csv"]
93
+ },
94
+ completion: {
95
+ usage: "pivotkit completion <bash | zsh | fish | powershell>",
96
+ text: "Print the shell's completion script for pivotkit.",
97
+ args: [{ name: "shell", text: "bash, zsh, fish or powershell.", complete: "shell" }],
98
+ options: {},
99
+ examples: ['eval "$(pivotkit completion bash)"', "pivotkit completion powershell | Out-String | Invoke-Expression"]
100
+ },
101
+ help: {
102
+ usage: "pivotkit help [command]",
103
+ text: "Show the help of pivotkit or of a command.",
104
+ args: [{ name: "command", text: "A command.", optional: true, complete: "command" }],
105
+ options: {}
106
+ }
107
+ });
108
+
109
+ /** The shells completion() writes for. */
110
+ export const SHELLS = Object.freeze(["bash", "zsh", "fish", "powershell"]);
111
+
112
+ /** The options of a command: its own and the global ones. */
113
+ export const optionsOf = (command) => ({ ...GLOBAL_OPTIONS, ...(COMMANDS[command]?.options ?? {}) });