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/errors.js ADDED
@@ -0,0 +1,66 @@
1
+ /*
2
+ * The CLI's errors and how each ends: a CliError has a kind (what went wrong, for scripts) and is
3
+ * exit code 2; a view's findings are exit code 1 (cli.js). No message holds the content of a file:
4
+ * a path is named by its base name, and text from a file or a view is cleaned before it is printed.
5
+ */
6
+
7
+ import { basename } from "node:path";
8
+ import { PivotkitError } from "pivotkit-core";
9
+ import { cleanText } from "pivotkit-core/import";
10
+
11
+ /** @typedef {"usage" | "io" | "limit" | "template" | "internal"} ErrorKind */
12
+
13
+ export class CliError extends Error {
14
+ /**
15
+ * @param {ErrorKind} kind
16
+ * @param {string} code a stable name, e.g. NOT_FOUND, OUTSIDE_ALLOWED_DIR, UNKNOWN_OPTION
17
+ * @param {string} message
18
+ */
19
+ constructor(kind, code, message) {
20
+ super(message);
21
+ this.name = "CliError";
22
+ this.kind = kind;
23
+ this.code = code;
24
+ }
25
+ }
26
+
27
+ /** The most characters of a name or message from outside that the CLI prints. */
28
+ export const PRINT_LENGTH = 200;
29
+
30
+ /** Text from a file, a view or the data, safe to print: no control or direction characters, capped. */
31
+ export function printable(text) {
32
+ const clean = cleanText(String(text ?? ""));
33
+ return clean.length > PRINT_LENGTH ? `${clean.slice(0, PRINT_LENGTH - 1)}…` : clean;
34
+ }
35
+
36
+ /** A path as messages name it: its base name only. */
37
+ export const shortName = (path) => printable(path === "-" ? "stdin" : basename(String(path)));
38
+
39
+ /**
40
+ * Any error as a CliError: pivotkit-core's by their code, Node's file errors by theirs (with the
41
+ * file's base name, never its content), anything else as internal.
42
+ * @param {unknown} error
43
+ * @param {string} [path] the file it is about
44
+ * @returns {CliError}
45
+ */
46
+ export function asCliError(error, path) {
47
+ if (error instanceof CliError) {
48
+ return error;
49
+ }
50
+ const name = path ? shortName(path) : "";
51
+ if (error instanceof PivotkitError) {
52
+ const code = /** @type {string} */ (error.code);
53
+ // a reader over a limit throws LIMIT_EXCEEDED; pivotkit-xlsx 0.1.0 threw INVALID_FILE with reason
54
+ // LIMIT and details.limit, and a host may still have it installed next to this CLI
55
+ const overLimit = code === "LIMIT_EXCEEDED" || (code === "INVALID_FILE" && (error.details?.reason === "LIMIT" || typeof error.details?.limit === "string"));
56
+ const kind = overLimit ? "limit" : code === "INVALID_TEMPLATE" ? "template" : code === "INVALID_FILE" ? "io" : code === "INVALID_OPTION" ? "usage" : "internal";
57
+ const reason = typeof error.details?.reason === "string" ? error.details.reason : code;
58
+ return new CliError(kind, reason, printable(name && !error.message.includes(name) ? `${name}: ${error.message}` : error.message));
59
+ }
60
+ const e = /** @type {NodeJS.ErrnoException} */ (error);
61
+ const fileErrors = { ENOENT: "not found", EACCES: "not allowed to read or write", EPERM: "not allowed to read or write", EISDIR: "is a directory", ENOTDIR: "a part of the path is not a directory", ELOOP: "is a symbolic link", EEXIST: "exists", ENOSPC: "no space left on the disk", EROFS: "the file system is read-only" };
62
+ if (e && typeof e.code === "string" && Object.hasOwn(fileErrors, e.code)) {
63
+ return new CliError("io", e.code, `${name || "file"}: ${fileErrors[e.code]}`);
64
+ }
65
+ return new CliError("internal", "INTERNAL", printable(e && e.message ? e.message : String(error)));
66
+ }
package/src/help.js ADDED
@@ -0,0 +1,116 @@
1
+ /*
2
+ * The help texts and the shell completions, both made from the command table (commands.js).
3
+ */
4
+
5
+ import { COMMANDS, GLOBAL_OPTIONS, SHELLS, optionsOf } from "./commands.js";
6
+
7
+ /** One option as the help writes it: "-o, --output <file>". */
8
+ function optionLine(name, spec) {
9
+ const flag = `${spec.short ? `-${spec.short}, ` : " "}--${name}${spec.type === "string" ? ` <${spec.value ?? "value"}>` : ""}`;
10
+ const choices = spec.choices ? ` (${spec.choices.join(", ")})` : "";
11
+ const env = spec.env ? ` [env ${spec.env}]` : "";
12
+ return { flag, text: `${spec.text}${choices}${env}` };
13
+ }
14
+
15
+ /** Lines of two columns, the first padded. */
16
+ const columns = (rows) => {
17
+ const width = Math.min(32, Math.max(...rows.map((r) => r.flag.length)));
18
+ return rows.map((r) => ` ${r.flag.padEnd(width)} ${r.text}`).join("\n");
19
+ };
20
+
21
+ /** The help of pivotkit, or of one command. */
22
+ export function helpText(command) {
23
+ if (!command || !Object.hasOwn(COMMANDS, command)) {
24
+ const list = Object.entries(COMMANDS).map(([name, c]) => ({ flag: name, text: c.text }));
25
+ return `pivotkit - run pivotkit views without a browser\n\nUsage: pivotkit <command> [options]\n\nCommands:\n${columns(list)}\n\nOptions:\n${columns([...Object.entries(GLOBAL_OPTIONS).map(([n, s]) => optionLine(n, s)), { flag: " --version", text: "Show the versions of pivotkit-cli, pivotkit-report and pivotkit-core." }])}\n\nExit codes: 0 done, 1 findings (errors in the view, or warnings with --strict), 2 usage, file, limit or template errors.\nRun "pivotkit help <command>" for a command's options. Documentation: https://pivotkit.eize.dev/reference/cli/\n`;
26
+ }
27
+ const c = COMMANDS[command];
28
+ const args = c.args.length ? `\n\nArguments:\n${columns(c.args.map((a) => ({ flag: a.optional ? `[${a.name}]` : `<${a.name}>`, text: a.text })))}` : "";
29
+ const own = Object.entries(c.options).map(([n, s]) => optionLine(n, s));
30
+ const global = Object.entries(GLOBAL_OPTIONS).map(([n, s]) => optionLine(n, s));
31
+ const examples = c.examples?.length ? `\n\nExamples:\n${c.examples.map((e) => ` ${e}`).join("\n")}` : "";
32
+ return `${c.text}\n\nUsage: ${c.usage}${args}${own.length ? `\n\nOptions:\n${columns(own)}` : ""}\n\nCommon options:\n${columns(global)}${examples}\n`;
33
+ }
34
+
35
+ /** Every command's long options, with whether each takes a value and how a shell completes it. */
36
+ function table() {
37
+ return Object.keys(COMMANDS).map((name) => ({
38
+ name,
39
+ args: COMMANDS[name].args,
40
+ options: Object.entries(optionsOf(name)).map(([option, spec]) => ({ name: option, short: spec.short, value: spec.type === "string", choices: spec.choices ?? null, complete: spec.complete ?? null }))
41
+ }));
42
+ }
43
+
44
+ /** Quotes a word for a POSIX shell script. */
45
+ const sh = (word) => `'${String(word).replace(/'/g, "'\\''")}'`;
46
+
47
+ /**
48
+ * The completion script of a shell.
49
+ * @param {"bash" | "zsh" | "fish" | "powershell"} shell
50
+ */
51
+ export function completionScript(shell) {
52
+ const commands = table();
53
+ const names = commands.map((c) => c.name).join(" ");
54
+ if (shell === "bash" || shell === "zsh") {
55
+ const cases = commands
56
+ .map((c) => {
57
+ const opts = c.options.flatMap((o) => [`--${o.name}`, ...(o.short ? [`-${o.short}`] : [])]).join(" ");
58
+ const values = c.options
59
+ .filter((o) => o.value)
60
+ .map((o) => {
61
+ const flags = [`--${o.name}`, ...(o.short ? [`-${o.short}`] : [])].join("|");
62
+ const reply = o.choices ? `COMPREPLY=( $(compgen -W ${sh(o.choices.join(" "))} -- "$cur") )` : o.complete === "dir" ? 'COMPREPLY=( $(compgen -d -- "$cur") )' : o.complete === "file" ? 'COMPREPLY=( $(compgen -f -- "$cur") )' : "COMPREPLY=()";
63
+ return ` ${flags}) ${reply}; return 0 ;;`;
64
+ })
65
+ .join("\n");
66
+ const arg = c.args[0]?.complete === "shell" ? `compgen -W ${sh(SHELLS.join(" "))} -- "$cur"` : c.args[0]?.complete === "command" ? `compgen -W ${sh(names)} -- "$cur"` : 'compgen -f -- "$cur"';
67
+ return ` ${c.name})\n case "$prev" in\n${values || " *) ;;"}\n esac\n if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W ${sh(opts)} -- "$cur") ); else COMPREPLY=( $(${arg}) ); fi ;;`;
68
+ })
69
+ .join("\n");
70
+ const body = `_pivotkit() {\n local cur prev cmd\n cur="\${COMP_WORDS[COMP_CWORD]}"\n prev="\${COMP_WORDS[COMP_CWORD-1]}"\n cmd="\${COMP_WORDS[1]}"\n if [[ $COMP_CWORD -eq 1 ]]; then\n COMPREPLY=( $(compgen -W ${sh(`${names} --help --version`)} -- "$cur") )\n return 0\n fi\n case "$cmd" in\n${cases}\n esac\n}\n`;
71
+ return shell === "bash"
72
+ ? `# pivotkit completion for bash: eval "$(pivotkit completion bash)"\n${body}complete -o default -F _pivotkit pivotkit\n`
73
+ : `#compdef pivotkit\n# pivotkit completion for zsh: eval "$(pivotkit completion zsh)" (uses bash completion)\nautoload -U +X bashcompinit && bashcompinit\n${body}complete -o default -F _pivotkit pivotkit\n`;
74
+ }
75
+ if (shell === "fish") {
76
+ const lines = ["# pivotkit completion for fish: pivotkit completion fish | source", "complete -c pivotkit -f"];
77
+ for (const c of commands) {
78
+ lines.push(`complete -c pivotkit -n __fish_use_subcommand -a ${sh(c.name)} -d ${sh(COMMANDS[c.name].text)}`);
79
+ for (const o of c.options) {
80
+ const values = o.choices ? ` -x -a ${sh(o.choices.join(" "))}` : o.complete === "file" ? " -r -F" : o.complete === "dir" ? ` -x -a "(__fish_complete_directories)"` : o.value ? " -x" : "";
81
+ lines.push(`complete -c pivotkit -n ${sh(`__fish_seen_subcommand_from ${c.name}`)} -l ${o.name}${o.short ? ` -s ${o.short}` : ""}${values}`);
82
+ }
83
+ if (c.args[0]?.complete === "shell") lines.push(`complete -c pivotkit -n ${sh("__fish_seen_subcommand_from completion")} -a ${sh(SHELLS.join(" "))}`);
84
+ if (c.args[0]?.complete === "file") lines.push(`complete -c pivotkit -n ${sh(`__fish_seen_subcommand_from ${c.name}`)} -F`);
85
+ }
86
+ return `${lines.join("\n")}\n`;
87
+ }
88
+ // PowerShell: Register-ArgumentCompleter -Native
89
+ const ps = (word) => `'${String(word).replace(/'/g, "''")}'`;
90
+ const map = commands
91
+ .map((c) => ` ${ps(c.name)} = @{ options = @(${c.options.flatMap((o) => [`--${o.name}`, ...(o.short ? [`-${o.short}`] : [])]).map(ps).join(", ")}); choices = @{ ${c.options.filter((o) => o.choices).map((o) => `${ps(`--${o.name}`)} = @(${o.choices.map(ps).join(", ")})`).join("; ")} } }`)
92
+ .join("\n");
93
+ return `# pivotkit completion for PowerShell: pivotkit completion powershell | Out-String | Invoke-Expression
94
+ $pivotkitCommands = @{
95
+ ${map}
96
+ }
97
+ Register-ArgumentCompleter -Native -CommandName pivotkit -ScriptBlock {
98
+ param($wordToComplete, $commandAst, $cursorPosition)
99
+ $words = @($commandAst.CommandElements | ForEach-Object { $_.ToString() })
100
+ if ($words.Count -le 1 -or ($words.Count -eq 2 -and $wordToComplete)) {
101
+ $candidates = @(${commands.map((c) => ps(c.name)).join(", ")}, '--help', '--version')
102
+ } else {
103
+ $entry = $pivotkitCommands[$words[1]]
104
+ if (-not $entry) { return }
105
+ $previous = if ($wordToComplete) { $words[-2] } else { $words[-1] }
106
+ if ($entry.choices.ContainsKey($previous)) { $candidates = $entry.choices[$previous] }
107
+ elseif ($words[1] -eq 'completion') { $candidates = @(${SHELLS.map(ps).join(", ")}) }
108
+ elseif ($wordToComplete -like '-*') { $candidates = $entry.options }
109
+ else { return }
110
+ }
111
+ $candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
112
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
113
+ }
114
+ }
115
+ `;
116
+ }
@@ -0,0 +1,190 @@
1
+ /*
2
+ * Reading and writing files the same safe way, with or without allowed directories:
3
+ *
4
+ * - `--allow-dir` is a hard limit: every file read or written must lie inside one of the directories
5
+ * after its real path is resolved (symbolic links and ".." followed), checked on the path and again
6
+ * on the opened file (its device and inode against the real path's). Without it the CLI reads and
7
+ * writes with the user's own rights, as any command-line tool does.
8
+ * - A file is opened once, without following a symbolic link at its end where the system can
9
+ * (O_NOFOLLOW) and without waiting for a writer (O_NONBLOCK, so a FIFO cannot hang the CLI), then
10
+ * checked on the handle: a regular file only, and its size against the limit before a byte is read.
11
+ * - A file is written to a new temporary file next to it (exclusive create), then renamed over the
12
+ * target: no half-written output, and a symbolic link, a directory or another non-regular file at
13
+ * the target is refused, never replaced.
14
+ *
15
+ * Not a sandbox against another process of the same user that changes the directories while the CLI
16
+ * runs: the checks narrow that window, they do not close it. A hard link inside an allowed directory
17
+ * to a file outside it is that file's name inside: no path check can tell it apart. A temporary file
18
+ * of a run that is killed between its creation and the rename stays behind (".<name>.<pid>-<random>.tmp").
19
+ * The MCP server of step 3 reuses this module.
20
+ */
21
+
22
+ import { constants } from "node:fs";
23
+ import { lstat, open, realpath, rename, stat, unlink } from "node:fs/promises";
24
+ import { basename, dirname, join, relative, resolve, isAbsolute, sep } from "node:path";
25
+ import { CliError, shortName } from "./errors.js";
26
+
27
+ const READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
28
+
29
+ /** True when `real` is `dir` or lies inside it (both real paths). */
30
+ export function isInside(real, dir) {
31
+ const rel = relative(dir, real);
32
+ // "..foo" is a name inside the directory; only ".." as the first part leaves it
33
+ return rel === "" || (!isAbsolute(rel) && rel.split(sep)[0] !== "..");
34
+ }
35
+
36
+ /**
37
+ * The allowed directories as real paths; each must exist and be a directory.
38
+ * @param {string[]} dirs
39
+ * @param {string} cwd
40
+ * @returns {Promise<string[]>}
41
+ */
42
+ export async function allowedDirs(dirs, cwd) {
43
+ const out = [];
44
+ for (const dir of dirs) {
45
+ let real;
46
+ try {
47
+ real = await realpath(resolve(cwd, dir));
48
+ } catch {
49
+ throw new CliError("usage", "ALLOW_DIR_NOT_FOUND", `--allow-dir ${shortName(dir)}: not found`);
50
+ }
51
+ if (!(await stat(real)).isDirectory()) {
52
+ throw new CliError("usage", "ALLOW_DIR_NOT_DIRECTORY", `--allow-dir ${shortName(dir)}: not a directory`);
53
+ }
54
+ out.push(real);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** Refuses a real path outside the allowed directories (none given: every path is allowed). */
60
+ function checkAllowed(real, allow, path) {
61
+ if (allow.length > 0 && !allow.some((dir) => isInside(real, dir))) {
62
+ // the directories by their base names, as every path in a message
63
+ throw new CliError("io", "OUTSIDE_ALLOWED_DIR", `${shortName(path)}: outside the allowed directories (--allow-dir ${allow.map(shortName).join(", ")})`);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Reads a regular file of at most `maxBytes` bytes.
69
+ * @param {string} path
70
+ * @param {{ cwd: string, allow: string[], maxBytes: number, limit?: string }} options `limit`: the limit's name for the message
71
+ * @returns {Promise<Uint8Array>}
72
+ */
73
+ export async function readSafe(path, { cwd, allow, maxBytes, limit = "fileBytes" }) {
74
+ const full = resolve(cwd, path);
75
+ let real;
76
+ try {
77
+ real = await realpath(full);
78
+ } catch (e) {
79
+ throw e?.code === "ENOENT" ? new CliError("io", "NOT_FOUND", `${shortName(path)}: not found`) : e;
80
+ }
81
+ checkAllowed(real, allow, path);
82
+ const handle = await open(real, READ_FLAGS);
83
+ try {
84
+ const info = await handle.stat();
85
+ if (!info.isFile()) {
86
+ throw new CliError("io", "NOT_REGULAR_FILE", `${shortName(path)}: not a regular file`);
87
+ }
88
+ const now = await stat(real);
89
+ if (now.dev !== info.dev || now.ino !== info.ino) {
90
+ throw new CliError("io", "CHANGED", `${shortName(path)}: changed while it was opened`);
91
+ }
92
+ if (info.size > maxBytes) {
93
+ throw tooLarge(path, info.size, maxBytes, limit);
94
+ }
95
+ // the size the file had at the check, plus one byte: a file that has more by then grew (or, as
96
+ // a file of /proc, says it has none): over the limit it is refused as too large, else as changed,
97
+ // never cut
98
+ const buffer = new Uint8Array(Math.min(maxBytes + 1, info.size + 1));
99
+ let length = 0;
100
+ for (;;) {
101
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
102
+ if (bytesRead === 0) {
103
+ break;
104
+ }
105
+ length += bytesRead;
106
+ if (length > maxBytes) {
107
+ throw tooLarge(path, length, maxBytes, limit);
108
+ }
109
+ if (length > info.size) {
110
+ throw new CliError("io", "CHANGED", `${shortName(path)}: changed while it was read (it has more than the ${info.size.toLocaleString("en-US")} bytes it had)`);
111
+ }
112
+ }
113
+ return buffer.subarray(0, length);
114
+ } finally {
115
+ await handle.close();
116
+ }
117
+ }
118
+
119
+ /** A file over its limit, named by its base name. */
120
+ const tooLarge = (path, size, max, limit) => new CliError("limit", "LIMIT_EXCEEDED", `${shortName(path)}: ${size.toLocaleString("en-US")} bytes exceed the limit of ${max.toLocaleString("en-US")} (limits.${limit})`);
121
+
122
+ /**
123
+ * Reads stdin up to `maxBytes` bytes.
124
+ * @param {AsyncIterable<Uint8Array>} stdin
125
+ * @param {number} maxBytes
126
+ * @param {string} [limit]
127
+ */
128
+ export async function readStdin(stdin, maxBytes, limit = "fileBytes") {
129
+ const chunks = [];
130
+ let length = 0;
131
+ for await (const chunk of stdin) {
132
+ length += chunk.length;
133
+ if (length > maxBytes) {
134
+ throw tooLarge("-", length, maxBytes, limit);
135
+ }
136
+ chunks.push(chunk);
137
+ }
138
+ const out = new Uint8Array(length);
139
+ let at = 0;
140
+ for (const chunk of chunks) {
141
+ out.set(chunk, at);
142
+ at += chunk.length;
143
+ }
144
+ return out;
145
+ }
146
+
147
+ /**
148
+ * Writes `bytes` to `path`: into a new temporary file in the target's directory, then renamed over it.
149
+ * @param {string} path
150
+ * @param {Uint8Array} bytes
151
+ * @param {{ cwd: string, allow: string[] }} options
152
+ * @returns {Promise<string>} the real path written
153
+ */
154
+ export async function writeSafe(path, bytes, { cwd, allow }) {
155
+ const full = resolve(cwd, path);
156
+ let dir;
157
+ try {
158
+ dir = await realpath(dirname(full));
159
+ } catch {
160
+ throw new CliError("io", "NOT_FOUND", `${shortName(path)}: its directory is not found`);
161
+ }
162
+ const target = join(dir, basename(full));
163
+ checkAllowed(target, allow, path);
164
+ const existing = await lstat(target).catch((e) => (e.code === "ENOENT" ? null : Promise.reject(e)));
165
+ if (existing && !existing.isFile()) {
166
+ const what = existing.isSymbolicLink() ? "a symbolic link" : existing.isDirectory() ? "a directory" : "a device or another special file";
167
+ throw new CliError("io", existing.isSymbolicLink() || existing.isDirectory() ? "NOT_REGULAR_FILE" : "SPECIAL_FILE", `${shortName(path)}: exists and is ${what}; not replaced`);
168
+ }
169
+ // the temporary name stays within a file name's 255 bytes, however long the target's name is: 50
170
+ // characters of at most 4 bytes each, then about 25 for the process, a random part and ".tmp"
171
+ const stem = Array.from(basename(full)).slice(0, 50).join("");
172
+ const temp = join(dir, `.${stem}.${process.pid}-${Math.random().toString(36).slice(2, 10)}.tmp`);
173
+ const handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), existing ? existing.mode & 0o777 : 0o644);
174
+ try {
175
+ await handle.writeFile(bytes);
176
+ await handle.sync();
177
+ } catch (e) {
178
+ await handle.close();
179
+ await unlink(temp).catch(() => undefined);
180
+ throw e;
181
+ }
182
+ await handle.close();
183
+ try {
184
+ await rename(temp, target);
185
+ } catch (e) {
186
+ await unlink(temp).catch(() => undefined);
187
+ throw e;
188
+ }
189
+ return target;
190
+ }
package/types/cli.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ export type Io = {
2
+ stdin: AsyncIterable<Uint8Array> & {
3
+ isTTY?: boolean;
4
+ };
5
+ stdout: {
6
+ write(chunk: string | Uint8Array): unknown;
7
+ isTTY?: boolean;
8
+ };
9
+ stderr: {
10
+ write(chunk: string | Uint8Array): unknown;
11
+ };
12
+ env: Record<string, string | undefined>;
13
+ cwd: string;
14
+ };
15
+ /** The versions of this package and the two it renders with. */
16
+ export declare function versions(): {
17
+ "pivotkit-cli": any;
18
+ "pivotkit-report": any;
19
+ "pivotkit-core": any;
20
+ };
21
+ /**
22
+ * Runs one command. Never throws: every error ends in its exit code and message.
23
+ * @param {string[]} argv the arguments after "pivotkit"
24
+ * @param {Io} io
25
+ * @returns {Promise<number>} the exit code
26
+ */
27
+ export declare function main(argv: string[], io: Io): Promise<number>;
@@ -0,0 +1,80 @@
1
+ export type OptionSpec = {
2
+ type: "string" | "boolean";
3
+ /**
4
+ * one letter
5
+ */
6
+ short?: string;
7
+ /**
8
+ * may be given more than once
9
+ */
10
+ multiple?: boolean;
11
+ /**
12
+ * the value's name in the help ("file", "dir")
13
+ */
14
+ value?: string;
15
+ /**
16
+ * the values it takes (checked, and offered by the completions)
17
+ */
18
+ choices?: string[];
19
+ /**
20
+ * what a shell completes for its value
21
+ */
22
+ complete?: "file" | "dir";
23
+ /**
24
+ * the environment variable that gives its default
25
+ */
26
+ env?: string;
27
+ /**
28
+ * render: the output formats it acts in (given with another, a warning)
29
+ */
30
+ formats?: string[];
31
+ /**
32
+ * one line of help
33
+ */
34
+ text: string;
35
+ };
36
+ /**
37
+ * @typedef {object} OptionSpec
38
+ * @property {"string" | "boolean"} type
39
+ * @property {string} [short] one letter
40
+ * @property {boolean} [multiple] may be given more than once
41
+ * @property {string} [value] the value's name in the help ("file", "dir")
42
+ * @property {string[]} [choices] the values it takes (checked, and offered by the completions)
43
+ * @property {"file" | "dir"} [complete] what a shell completes for its value
44
+ * @property {string} [env] the environment variable that gives its default
45
+ * @property {string[]} [formats] render: the output formats it acts in (given with another, a warning)
46
+ * @property {string} text one line of help
47
+ */
48
+ /** @type {Readonly<Record<string, OptionSpec>>} Options every command takes. */
49
+ export declare const GLOBAL_OPTIONS: Readonly<Record<string, OptionSpec>>;
50
+ export type CommandSpec = {
51
+ usage: string;
52
+ text: string;
53
+ args: Array<{
54
+ name: string;
55
+ text: string;
56
+ optional?: boolean;
57
+ complete?: "file" | "shell" | "command";
58
+ }>;
59
+ /**
60
+ * besides GLOBAL_OPTIONS
61
+ */
62
+ options: Readonly<Record<string, OptionSpec>>;
63
+ examples?: string[];
64
+ };
65
+ /**
66
+ * @typedef {object} CommandSpec
67
+ * @property {string} usage
68
+ * @property {string} text
69
+ * @property {Array<{ name: string, text: string, optional?: boolean, complete?: "file" | "shell" | "command" }>} args
70
+ * @property {Readonly<Record<string, OptionSpec>>} options besides GLOBAL_OPTIONS
71
+ * @property {string[]} [examples]
72
+ */
73
+ /** @type {Readonly<Record<string, CommandSpec>>} */
74
+ export declare const COMMANDS: Readonly<Record<string, CommandSpec>>;
75
+ /** The shells completion() writes for. */
76
+ export declare const SHELLS: readonly string[];
77
+ /** The options of a command: its own and the global ones. */
78
+ export declare const optionsOf: (command: any) => {
79
+ [x: string]: OptionSpec;
80
+ };
@@ -0,0 +1,26 @@
1
+ export type ErrorKind = "usage" | "io" | "limit" | "template" | "internal";
2
+ /** @typedef {"usage" | "io" | "limit" | "template" | "internal"} ErrorKind */
3
+ export declare class CliError extends Error {
4
+ kind: ErrorKind;
5
+ code: string;
6
+ /**
7
+ * @param {ErrorKind} kind
8
+ * @param {string} code a stable name, e.g. NOT_FOUND, OUTSIDE_ALLOWED_DIR, UNKNOWN_OPTION
9
+ * @param {string} message
10
+ */
11
+ constructor(kind: ErrorKind, code: string, message: string);
12
+ }
13
+ /** The most characters of a name or message from outside that the CLI prints. */
14
+ export declare const PRINT_LENGTH = 200;
15
+ /** Text from a file, a view or the data, safe to print: no control or direction characters, capped. */
16
+ export declare function printable(text: any): string;
17
+ /** A path as messages name it: its base name only. */
18
+ export declare const shortName: (path: any) => string;
19
+ /**
20
+ * Any error as a CliError: pivotkit-core's by their code, Node's file errors by theirs (with the
21
+ * file's base name, never its content), anything else as internal.
22
+ * @param {unknown} error
23
+ * @param {string} [path] the file it is about
24
+ * @returns {CliError}
25
+ */
26
+ export declare function asCliError(error: unknown, path?: string): CliError;
@@ -0,0 +1,7 @@
1
+ /** The help of pivotkit, or of one command. */
2
+ export declare function helpText(command: any): string;
3
+ /**
4
+ * The completion script of a shell.
5
+ * @param {"bash" | "zsh" | "fish" | "powershell"} shell
6
+ */
7
+ export declare function completionScript(shell: "bash" | "zsh" | "fish" | "powershell"): string;
@@ -0,0 +1,39 @@
1
+ /** True when `real` is `dir` or lies inside it (both real paths). */
2
+ export declare function isInside(real: any, dir: any): boolean;
3
+ /**
4
+ * The allowed directories as real paths; each must exist and be a directory.
5
+ * @param {string[]} dirs
6
+ * @param {string} cwd
7
+ * @returns {Promise<string[]>}
8
+ */
9
+ export declare function allowedDirs(dirs: string[], cwd: string): Promise<string[]>;
10
+ /**
11
+ * Reads a regular file of at most `maxBytes` bytes.
12
+ * @param {string} path
13
+ * @param {{ cwd: string, allow: string[], maxBytes: number, limit?: string }} options `limit`: the limit's name for the message
14
+ * @returns {Promise<Uint8Array>}
15
+ */
16
+ export declare function readSafe(path: string, { cwd, allow, maxBytes, limit }: {
17
+ cwd: string;
18
+ allow: string[];
19
+ maxBytes: number;
20
+ limit?: string;
21
+ }): Promise<Uint8Array>;
22
+ /**
23
+ * Reads stdin up to `maxBytes` bytes.
24
+ * @param {AsyncIterable<Uint8Array>} stdin
25
+ * @param {number} maxBytes
26
+ * @param {string} [limit]
27
+ */
28
+ export declare function readStdin(stdin: AsyncIterable<Uint8Array>, maxBytes: number, limit?: string): Promise<Uint8Array<ArrayBuffer>>;
29
+ /**
30
+ * Writes `bytes` to `path`: into a new temporary file in the target's directory, then renamed over it.
31
+ * @param {string} path
32
+ * @param {Uint8Array} bytes
33
+ * @param {{ cwd: string, allow: string[] }} options
34
+ * @returns {Promise<string>} the real path written
35
+ */
36
+ export declare function writeSafe(path: string, bytes: Uint8Array, { cwd, allow }: {
37
+ cwd: string;
38
+ allow: string[];
39
+ }): Promise<string>;