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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/bin/pivotkit.js +5 -0
- package/package.json +66 -4
- package/src/cli.js +495 -0
- package/src/commands.js +113 -0
- package/src/errors.js +66 -0
- package/src/help.js +116 -0
- package/src/safe-files.js +190 -0
- package/types/cli.d.ts +27 -0
- package/types/commands.d.ts +80 -0
- package/types/errors.d.ts +26 -0
- package/types/help.d.ts +7 -0
- package/types/safe-files.d.ts +39 -0
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;
|
package/types/help.d.ts
ADDED
|
@@ -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>;
|