@kaynooo/cli-kit 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/package.json +1 -1
- package/src/args.ts +5 -2
- package/src/ci.ts +132 -0
- package/src/config.ts +11 -5
- package/src/git.ts +55 -0
- package/src/index.ts +5 -0
- package/src/logs.ts +72 -0
- package/src/process.ts +113 -12
- package/src/secrets.ts +10 -0
- package/src/shell.ts +467 -35
- package/src/tool.ts +71 -0
package/README.md
CHANGED
|
@@ -33,6 +33,9 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
|
|
|
33
33
|
## API
|
|
34
34
|
|
|
35
35
|
- `runCli({ name, version, about, commands, aliases })` dispatches `<cli> <command> [args]`: `--help` lists the commands, `--version` prints the version, a typo names the command it likely meant, and an `Exit` thrown by a command prints `error: <cli> <command>: <message>`, its hint under it, and exits with its code. Any other error exits 1, named by its class unless a plain `Error` (`error: tool sync: MigrationError: ...`), its cause's message on a `cause:` line under it when the cause is an `Error`; `redact(text)` rewrites both before they print, for errors that may quote what a log must not keep.
|
|
36
|
+
- `runTool(label, argv, { timeoutMs, cwd, onStart })` runs a command through `runCommand` with the current environment and returns `{ code, stdout, stderr }`; it throws, naming `label`, when the command cannot start (noting a removed working directory) or outlives `timeoutMs`. `isPermanentFailure(error)` is true for what a retry cannot mend: a missing binary, or an answer of 401, 403 or 404. `cwdGone()` tells that the working directory was deleted.
|
|
37
|
+
- `git(args, timeoutMs?)`, `gitOk`, `resolveCommit`, `fetchBranch`, `defaultBranch` and `leaveWorktree` are the git helpers the forge CLIs share, over `runTool`.
|
|
38
|
+
- `waitForCi({ tool, short, giveUpMs, noneMs, stalledMs, covering, finished, key, announce, links, settle, stall, lines })` is the wait loop of `ft ci` and `gl ci`: it polls `covering` until what it returns is `finished`, retries a poll that failed for a network reason, fails at once on a refusal (`Exit`) or a permanent failure, and gives up on a timeout, on nothing covering the commit for `noneMs`, or on a `stall` that is certain or lasts `stalledMs`.
|
|
36
39
|
- `parse(args, usage, options)` wraps `node:util` `parseArgs` with a generated `--help` (`usage` given with or without its `usage:`), which prints the help and ends the command with exit 0 (it throws `HelpShown`, which `runCli` and `runMain` take), so the command needs no check after it. A string option takes a next argument starting with a dash as its value (`--from "- item"`, `-n -3`) unless that argument names one of the command's options, which parseArgs alone refuses as ambiguous. `{ positionals: ['<key>', '[file]'] }` names the positionals the command takes: `<key>` or `KEY` required, `[file]` optional, a last one ending in `...` taking any more; one missing (`missing <key>`) or one too many (`unexpected argument x`) is a usage error, and `positionals` comes back typed as `[string, string?]`. `fail(message, code = 2, hint?)` ends a command, the hint printed under the error; `badUsage(message)` ends it with a `UsageError`, which `parse`, `wholeNumber` and `duration` throw too and which gets the hint `run <cli> <command> --help for its options`; `wholeNumber` reads a numeric option.
|
|
37
40
|
- `table`, `truncate`, `formatSeconds`, `formatAge` and `upTo` format output.
|
|
38
41
|
- `runMain({ name, version, run })` runs a CLI that is a single command: `--version` or `-v` alone prints the version, and an `Exit` or any error prints `error: <name>: <message>` and becomes the exit code. `await` it into `process.exitCode`.
|
|
@@ -41,8 +44,9 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
|
|
|
41
44
|
- `<bin> __complete <words>... <word>` is answered by `runCli` and `runMain` for shell completion: the subcommands with their summaries, a leaf command's options (read by running it until its `parse` call throws them, so every command parses its arguments first), or `__files__` where a path fits.
|
|
42
45
|
- `parse(args, usage, options, { rest: true })` stops at the first `--` and returns what follows untouched as `rest` (`out -n 200 -- <cmd>`), `undefined` without a `--`; `{ allowNegative: true }` takes `--no-<flag>` as `false` for a boolean flag.
|
|
43
46
|
- `readInput(path, { noStdin })` reads a file, or stdin when the path is `-`; a missing file or a stdin left on the terminal ends the command with a message.
|
|
44
|
-
- `runCommand(argv, { cwd, env, stdin })` runs a command and returns `{ code, text, output, chunks }`, the output of both streams (in arrival order, tagged by stream in `chunks`); it throws when the command cannot start. `exitCodeOf(code, signal)` is the exit code a shell reports.
|
|
47
|
+
- `runCommand(argv, { cwd, env, stdin, timeoutMs, signal, maxBytes, onChunk, onStart })` runs a command and returns `{ code, text, output, chunks, timedOut, truncated }`, the output of both streams (in arrival order, tagged by stream in `chunks`); it throws when the command cannot start. With `timeoutMs`, `signal`, `onChunk` or `onStart` the child leads its own process group, ended whole (SIGTERM, SIGKILL after 2 s) when the timeout passes, the signal aborts or `onChunk` throws, the pipes still read to the end: `timedOut` is then true, for the caller to exit 2. `maxBytes` caps what `chunks`, `output` and `text` keep (`truncated`), `onChunk` sees every chunk, and `onStart` hands over `{ kill }` for the whole group, to pass to `holdSignals().forward`. `exitCodeOf(code, signal)` is the exit code a shell reports.
|
|
45
48
|
- `holdSignals(cleanup?)` takes SIGINT, SIGTERM and SIGHUP over until `release()`: a signal goes to the child given to `forward(child)`, so the CLI exits with the child's code; without one, `cleanup(signal)` runs and the signal is raised again, ending the process as it would have; without either, it is only kept in `signal`, for the caller to wind down (`base` holds them while its worktree exists).
|
|
49
|
+
- `privateLogDirectory(name)` returns a folder for log files only this user reads (`$XDG_RUNTIME_DIR` or `~/.cache/agent-tools`, mode 0700, verified with `lstat` when it exists, else a fresh temp folder), `createLog(path)` opens a 0600 file that must not exist (returning its descriptor), and `sweepLogs(dir, maxAgeMs?)` removes what is older than a day, subfolders included.
|
|
46
50
|
- `openUrl(target, env?)` opens a URL, or a file path made absolute, with the command `BROWSER` names or the platform's opener (`xdg-open`, `open`, `start`), without waiting; it returns `false` when that command cannot start.
|
|
47
51
|
- Every command's `parse`, and `runCli` before the command name (`ft --plain runs`), takes `--color auto|always|never` and `--plain` (`--color never`), unless the command has its own option of that name. `always` sets `FORCE_COLOR=1` and `never` sets `NO_COLOR=1`, each dropping the other, so the kit's colours, marks and redrawn lines follow, and so do the processes the command starts; `always` colours a pipe but never redraws in one. `applyOutput(color, plain, env?)` is what they run.
|
|
48
52
|
- `--help` lists the commands under `Commands:`, then a heading per `group` a command names (`{ summary, group: 'Admin', run }`), then the level's `Options:` and the `Examples:` the CLI or a group's `examples` give (`['ft ci', ['ft ci HEAD~1', 'the commit before']]`, a note shown dimmed after `#`); a command's own examples go to `parse(args, usage, options, { examples })`, shown under its options.
|
|
@@ -53,6 +57,7 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
|
|
|
53
57
|
- `stripAnsi(text)` removes colour and cursor codes, and `distance(a, b)` is the edit distance between two names.
|
|
54
58
|
- `table(rows, { cellWidth, header })` also flattens each cell's line breaks and cuts it to `cellWidth`, and with `header` prints the first row bold on a terminal; and `formatDuration(ms)` formats milliseconds like `formatSeconds` but with one decimal under a minute (`0.3s`).
|
|
55
59
|
- `ok(text, detail?)`, `note`, `warn` and `problem` print a status line marked with a green ✓, a dimmed ·, a yellow ! or a red ✗ (`ok` and `note` on stdout, the others on stderr), the detail dimmed after the text; plain, the marks read `ok:`, `note:`, `warning:` and `error:`. `marked(mark, text, stream, detail?)` returns such a line, `paint(stream)` the colours for a stream and `takesControlCodes(stream)` whether it draws in place.
|
|
60
|
+
- `isSecretName(name)` is true for the name of a variable, column or JSON key that holds a secret (`API_TOKEN`, `DB_PASS`, `SMTP_PWD`, `clientSecret`, `session_id`, `ssn`), by whole words so `compass` and `author` stay plain; `ev`, `redact` and `db` share it.
|
|
56
61
|
- `capture(fn)` from `@kaynooo/cli-kit/test` runs a command in-process and returns `{ result, out, err }`, what it printed through `console` and `process.stdout`/`stderr`, so a test needs no spy.
|
|
57
62
|
- `findUp(names, from?)` is the nearest of `names` in the current folder (or `from`) or one above it; `loadConfig(names, { from?, path? })` imports the nearest config (`.ts`, `.js` or `.json`), or the file `path` names, and returns `{ path, dir, config }` with its default export for the caller to check, `undefined` when there is none. A named file missing, one failing to import or one without a default export ends the command with exit 1.
|
|
58
63
|
- `@kaynooo/cli-kit/prompt` asks on the terminal, on stderr:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaynooo/cli-kit",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.11.0",
|
|
5
5
|
"description": "What every @kaynooo CLI shares: argument parsing with --help, a command dispatcher with typo hints and shell completion, and output formatting.",
|
|
6
6
|
"author": "Kaynooo <contact@kaynooo.fr>",
|
|
7
7
|
"license": "MIT",
|
package/src/args.ts
CHANGED
|
@@ -276,14 +276,17 @@ function nearestOption(message: string, options: Options, allowNegative: boolean
|
|
|
276
276
|
return meant === undefined ? undefined : `did you mean ${known.find(flag => flag.replace(DASHES, '') === meant)}?`
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
const WHOLE = /^\d+$/
|
|
280
|
+
|
|
281
|
+
// A name written `<run id>` is a positional and is printed as is, any other is an option printed `--name`.
|
|
279
282
|
export function wholeNumber(value: string | undefined, name: string, fallback: number, min = 1): number {
|
|
280
283
|
if (value === undefined)
|
|
281
284
|
return fallback
|
|
282
285
|
|
|
283
286
|
const number = Number(value)
|
|
284
287
|
|
|
285
|
-
if (!Number.
|
|
286
|
-
badUsage(`--${name} takes a whole number from ${min}, not ${value}`)
|
|
288
|
+
if (!WHOLE.test(value) || !Number.isSafeInteger(number) || number < min)
|
|
289
|
+
badUsage(`${name.startsWith('<') ? name : `--${name}`} takes a whole number from ${min}, not ${value}`)
|
|
287
290
|
|
|
288
291
|
return number
|
|
289
292
|
}
|
package/src/ci.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Exit, fail } from './args'
|
|
2
|
+
import { formatDuration } from './format'
|
|
3
|
+
import { liveRegion } from './progress'
|
|
4
|
+
import { cwdGone, isPermanentFailure } from './tool'
|
|
5
|
+
|
|
6
|
+
export interface CiStall {
|
|
7
|
+
// The message the wait fails with.
|
|
8
|
+
message: string
|
|
9
|
+
// Fails at once instead of after `stalledMs`.
|
|
10
|
+
certain?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CiWait<T> {
|
|
14
|
+
// The command's name, as its warnings print it (`ft ci`).
|
|
15
|
+
tool: string
|
|
16
|
+
// The abbreviated sha the wait is for.
|
|
17
|
+
short: string
|
|
18
|
+
giveUpMs: number
|
|
19
|
+
// How long the wait goes on with nothing covering the commit.
|
|
20
|
+
noneMs: number
|
|
21
|
+
// How long queued work may sit while nothing moves.
|
|
22
|
+
stalledMs: number
|
|
23
|
+
pollMs?: number
|
|
24
|
+
// The message the wait fails with once nothing covered the commit for `noneMs`.
|
|
25
|
+
noneMessage: string
|
|
26
|
+
// What covers the commit now, or undefined; a refusal such as a commit not on the branch is thrown as an Exit.
|
|
27
|
+
covering: () => Promise<T | undefined>
|
|
28
|
+
finished: (found: T) => boolean
|
|
29
|
+
// Identifies what is followed, so `announce` runs again when it changes.
|
|
30
|
+
key: (found: T) => string
|
|
31
|
+
announce: (found: T) => void
|
|
32
|
+
// The links to what is followed, for the messages of a wait that ends unfinished.
|
|
33
|
+
links: (found: T) => string
|
|
34
|
+
// What a covering that failed leaves of the followed value once it can be read as over, or undefined to go on polling.
|
|
35
|
+
settle: (found: T) => Promise<T | undefined>
|
|
36
|
+
// Why the unfinished work is not moving, or undefined while it moves.
|
|
37
|
+
stall?: (found: T) => Promise<CiStall | undefined>
|
|
38
|
+
// The lines of a live region under the wait, on a terminal.
|
|
39
|
+
lines?: (spinner: string, found: T | undefined, startedAt: number) => string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const POLL = 15_000
|
|
43
|
+
|
|
44
|
+
// Waits until what covers a commit has finished, polling `covering`: a poll failing for a network reason is retried, one failing for good (a missing binary, a 401, 403 or 404) or a refusal ends the wait.
|
|
45
|
+
export async function waitForCi<T>(wait: CiWait<T>): Promise<T> {
|
|
46
|
+
const { tool, short, giveUpMs, noneMs, stalledMs, pollMs = POLL } = wait
|
|
47
|
+
const started = Date.now()
|
|
48
|
+
let seen = started
|
|
49
|
+
let moving = started
|
|
50
|
+
let followed = ''
|
|
51
|
+
let found: T | undefined
|
|
52
|
+
const region = wait.lines === undefined ? undefined : liveRegion(spinner => wait.lines?.(spinner, found, started) ?? [])
|
|
53
|
+
const suffix = () => found === undefined ? '' : `: ${wait.links(found)}`
|
|
54
|
+
|
|
55
|
+
// A failure to read the stall is retried on the next poll, and leaves the stall unknown.
|
|
56
|
+
async function stallOf(current: T): Promise<CiStall | undefined | 'unknown'> {
|
|
57
|
+
try {
|
|
58
|
+
return await wait.stall?.(current)
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error instanceof Exit || isPermanentFailure(error))
|
|
62
|
+
throw error
|
|
63
|
+
|
|
64
|
+
console.warn(`${tool}: ${error instanceof Error ? error.message : String(error)}, polling again`)
|
|
65
|
+
|
|
66
|
+
return 'unknown'
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
for (;;) {
|
|
72
|
+
if (Date.now() - started > giveUpMs)
|
|
73
|
+
fail(`CI has not finished on ${short} in ${formatDuration(giveUpMs)}${suffix()}`)
|
|
74
|
+
|
|
75
|
+
// The forge or origin missing one poll leaves the wait to the next, unless what is followed is over by then.
|
|
76
|
+
try {
|
|
77
|
+
found = await wait.covering()
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof Exit)
|
|
81
|
+
throw error
|
|
82
|
+
|
|
83
|
+
if (cwdGone())
|
|
84
|
+
fail(`the working directory was removed while waiting for CI on ${short}${suffix()}`)
|
|
85
|
+
|
|
86
|
+
if (isPermanentFailure(error))
|
|
87
|
+
fail(error instanceof Error ? error.message : String(error))
|
|
88
|
+
|
|
89
|
+
console.warn(`${tool}: ${error instanceof Error ? error.message : String(error)}, polling again`)
|
|
90
|
+
|
|
91
|
+
const latest = found === undefined ? undefined : await wait.settle(found)
|
|
92
|
+
|
|
93
|
+
if (latest !== undefined)
|
|
94
|
+
return latest
|
|
95
|
+
|
|
96
|
+
await Bun.sleep(pollMs)
|
|
97
|
+
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (found !== undefined) {
|
|
102
|
+
seen = Date.now()
|
|
103
|
+
|
|
104
|
+
const key = wait.key(found)
|
|
105
|
+
|
|
106
|
+
if (key !== followed) {
|
|
107
|
+
followed = key
|
|
108
|
+
moving = Date.now()
|
|
109
|
+
wait.announce(found)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (wait.finished(found))
|
|
113
|
+
return found
|
|
114
|
+
|
|
115
|
+
const stall = await stallOf(found)
|
|
116
|
+
|
|
117
|
+
if (stall === undefined)
|
|
118
|
+
moving = Date.now()
|
|
119
|
+
else if (stall !== 'unknown' && (stall.certain === true || Date.now() - moving > stalledMs))
|
|
120
|
+
fail(stall.message)
|
|
121
|
+
}
|
|
122
|
+
else if (Date.now() - seen > noneMs) {
|
|
123
|
+
fail(wait.noneMessage)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await Bun.sleep(pollMs)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
region?.stop()
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
2
3
|
import { dirname, join, resolve } from 'node:path'
|
|
3
4
|
import process from 'node:process'
|
|
4
5
|
import { fail } from './args'
|
|
5
6
|
|
|
6
|
-
// The nearest of `names` in `from` or a folder above it, the first name winning within one folder.
|
|
7
|
-
export function findUp(names: string | string[], from = process.cwd()): string | undefined {
|
|
7
|
+
// The nearest of `names` in `from` or a folder above it, the first name winning within one folder; the walk ends after a folder `stop` accepts.
|
|
8
|
+
export function findUp(names: string | string[], from = process.cwd(), stop: (dir: string) => boolean = () => false): string | undefined {
|
|
8
9
|
const wanted = typeof names === 'string' ? [names] : names
|
|
9
10
|
|
|
10
11
|
for (let dir = resolve(from); ; dir = dirname(dir)) {
|
|
@@ -13,11 +14,16 @@ export function findUp(names: string | string[], from = process.cwd()): string |
|
|
|
13
14
|
if (found !== undefined)
|
|
14
15
|
return found
|
|
15
16
|
|
|
16
|
-
if (dirname(dir) === dir)
|
|
17
|
+
if (stop(dir) || dirname(dir) === dir)
|
|
17
18
|
return undefined
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
// A config is looked for up to the repository root or the home folder, never in a folder above them where a stranger's file could sit.
|
|
23
|
+
function endsWalk(dir: string): boolean {
|
|
24
|
+
return dir === resolve(homedir()) || existsSync(join(dir, '.git'))
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
export interface ConfigSettings {
|
|
22
28
|
// Where the search starts, the current folder by default.
|
|
23
29
|
from?: string
|
|
@@ -33,9 +39,9 @@ export interface LoadedConfig {
|
|
|
33
39
|
config: unknown
|
|
34
40
|
}
|
|
35
41
|
|
|
36
|
-
// Imports the nearest config named `names` (`.ts`, `.js` or `.json`), or the one `path` names; undefined when none is found. A named file missing, one failing to import or one without a default export ends the command.
|
|
42
|
+
// Imports the nearest config named `names` (`.ts`, `.js` or `.json`) up to the git root or the home folder, or the one `path` names; undefined when none is found. A named file missing, one failing to import or one without a default export ends the command.
|
|
37
43
|
export async function loadConfig(names: string | string[], { from = process.cwd(), path }: ConfigSettings = {}): Promise<LoadedConfig | undefined> {
|
|
38
|
-
const file = path === undefined ? findUp(names, from) : resolve(from, path)
|
|
44
|
+
const file = path === undefined ? findUp(names, from, endsWalk) : resolve(from, path)
|
|
39
45
|
|
|
40
46
|
if (file === undefined)
|
|
41
47
|
return undefined
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { basename, dirname } from 'node:path'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import { fail } from './args'
|
|
4
|
+
import { cwdGone, runTool } from './tool'
|
|
5
|
+
|
|
6
|
+
const ORIGIN_PREFIX = /^origin\//
|
|
7
|
+
|
|
8
|
+
// How long a git call that reaches the network waits for origin.
|
|
9
|
+
export const GIT_NETWORK_MS = 60_000
|
|
10
|
+
|
|
11
|
+
// Runs git and returns its trimmed output; throws with git's own message on a failure, or when git cannot start or outlives `timeoutMs`.
|
|
12
|
+
export async function git(args: string[], timeoutMs?: number): Promise<string> {
|
|
13
|
+
const result = await runTool(`git ${args.join(' ')}`, ['git', ...args], { timeoutMs })
|
|
14
|
+
|
|
15
|
+
if (result.code !== 0)
|
|
16
|
+
throw new Error(`git ${args.join(' ')}: ${result.stderr.trim() || `exit ${result.code}`}${cwdGone() ? ' (the working directory was removed)' : ''}`)
|
|
17
|
+
|
|
18
|
+
return result.stdout.trim()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function gitOk(args: string[]): Promise<boolean> {
|
|
22
|
+
return (await runTool(`git ${args.join(' ')}`, ['git', ...args])).code === 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function resolveCommit(ref: string): Promise<string> {
|
|
26
|
+
const sha = (await runTool('git rev-parse', ['git', 'rev-parse', '--verify', '-q', '--end-of-options', `${ref}^{commit}`])).stdout.trim()
|
|
27
|
+
|
|
28
|
+
if (sha === '')
|
|
29
|
+
fail(`no commit ${ref}`)
|
|
30
|
+
|
|
31
|
+
return sha
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Fetches one branch of origin; a name that is no branch, such as `--upload-pack=<command>`, is refused before git reads it, and the refspec is spelled out so it can never be an option.
|
|
35
|
+
export async function fetchBranch(branch: string): Promise<void> {
|
|
36
|
+
if (branch.startsWith('-') || !await gitOk(['check-ref-format', '--branch', branch]))
|
|
37
|
+
fail(`${branch} is not a branch name`)
|
|
38
|
+
|
|
39
|
+
await git(['fetch', '-q', 'origin', `refs/heads/${branch}`], GIT_NETWORK_MS)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The branch origin's HEAD names, which is the repository's default branch once cloned or `git remote set-head origin -a` ran.
|
|
43
|
+
export async function defaultBranch(): Promise<string> {
|
|
44
|
+
const head = (await runTool('git symbolic-ref', ['git', 'symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD'])).stdout.trim()
|
|
45
|
+
|
|
46
|
+
return head === '' ? 'main' : head.replace(ORIGIN_PREFIX, '')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A linked worktree can be deleted while a wait runs in it, so the wait moves to the main checkout, which outlives it.
|
|
50
|
+
export async function leaveWorktree(): Promise<void> {
|
|
51
|
+
const common = await git(['rev-parse', '--path-format=absolute', '--git-common-dir'])
|
|
52
|
+
|
|
53
|
+
if (basename(common) === '.git')
|
|
54
|
+
process.chdir(dirname(common))
|
|
55
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
export * from './args'
|
|
2
2
|
export * from './chart'
|
|
3
|
+
export * from './ci'
|
|
3
4
|
export * from './cli'
|
|
4
5
|
export * from './config'
|
|
5
6
|
export * from './format'
|
|
7
|
+
export * from './git'
|
|
6
8
|
export * from './limit'
|
|
9
|
+
export * from './logs'
|
|
7
10
|
export * from './process'
|
|
11
|
+
export * from './secrets'
|
|
8
12
|
export * from './style'
|
|
9
13
|
export * from './text'
|
|
14
|
+
export * from './tool'
|
package/src/logs.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { lstatSync, openSync } from 'node:fs'
|
|
2
|
+
import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'
|
|
3
|
+
import { homedir, tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import process from 'node:process'
|
|
6
|
+
|
|
7
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
8
|
+
|
|
9
|
+
// Whether the folder is a real directory (not a link) that only this user can use.
|
|
10
|
+
function isPrivateDirectory(path: string): boolean {
|
|
11
|
+
try {
|
|
12
|
+
const info = lstatSync(path, { throwIfNoEntry: false })
|
|
13
|
+
|
|
14
|
+
return info !== undefined && info.isDirectory() && info.uid === (process.getuid?.() ?? info.uid) && (info.mode & 0o077) === 0
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A folder for log files that only this user can read: `<name>` under $XDG_RUNTIME_DIR, else ~/.cache/agent-tools. When one already exists it must be a private directory of this user, otherwise a fresh private folder in the temp directory is used instead of trusting it.
|
|
22
|
+
export async function privateLogDirectory(name: string, env: Record<string, string | undefined> = process.env): Promise<string> {
|
|
23
|
+
const runtime = env.XDG_RUNTIME_DIR
|
|
24
|
+
const base = runtime === undefined || runtime === '' ? join(homedir(), '.cache', 'agent-tools') : join(runtime, 'agent-tools')
|
|
25
|
+
const path = join(base, name)
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
await mkdir(path, { recursive: true, mode: 0o700 })
|
|
29
|
+
|
|
30
|
+
if (isPrivateDirectory(path))
|
|
31
|
+
return path
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Falls through to the temp folder below.
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return mkdtemp(join(tmpdir(), `${name.replaceAll('/', '-')}-`))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Removes what is older than `maxAgeMs` in the folder, and the empty subfolders it leaves, best effort since another run may be doing the same.
|
|
41
|
+
export async function sweepLogs(dir: string, maxAgeMs = DAY_MS, now = Date.now()): Promise<void> {
|
|
42
|
+
let names: string[]
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
names = await readdir(dir)
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const name of names) {
|
|
52
|
+
const path = join(dir, name)
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const info = await stat(path)
|
|
56
|
+
|
|
57
|
+
if (info.isDirectory())
|
|
58
|
+
await sweepLogs(path, maxAgeMs, now)
|
|
59
|
+
|
|
60
|
+
if (now - info.mtimeMs > maxAgeMs)
|
|
61
|
+
await rm(path, { force: true, recursive: info.isDirectory() })
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Creates a log file nobody else can read and that must not exist yet, so a planted link is never followed; returns its descriptor, for writeSync.
|
|
70
|
+
export function createLog(path: string): number {
|
|
71
|
+
return openSync(path, 'wx', 0o600)
|
|
72
|
+
}
|
package/src/process.ts
CHANGED
|
@@ -14,12 +14,26 @@ export interface Ran {
|
|
|
14
14
|
// The bytes of every chunk joined.
|
|
15
15
|
output: Buffer
|
|
16
16
|
text: string
|
|
17
|
+
// The command was ended because `timeoutMs` passed, its code then being the signal's.
|
|
18
|
+
timedOut: boolean
|
|
19
|
+
// More than `maxBytes` came, and the rest was left out of chunks, output and text.
|
|
20
|
+
truncated: boolean
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export interface RunSettings {
|
|
20
24
|
cwd?: string
|
|
21
25
|
env?: Record<string, string | undefined>
|
|
22
26
|
stdin?: 'inherit' | 'ignore'
|
|
27
|
+
// Ends the command and its children after this long.
|
|
28
|
+
timeoutMs?: number
|
|
29
|
+
// Ends the command and its children when aborted.
|
|
30
|
+
signal?: AbortSignal
|
|
31
|
+
// Bytes kept in the result; the pipes are still read to the end.
|
|
32
|
+
maxBytes?: number
|
|
33
|
+
// Called with each chunk as it arrives; throwing ends the command and rethrows once the pipes are drained.
|
|
34
|
+
onChunk?: (chunk: Chunk) => void
|
|
35
|
+
// Called once the command runs, with a handle that sends a signal to its whole process group, for `holdSignals().forward`.
|
|
36
|
+
onStart?: (child: { kill: (signal: NodeJS.Signals) => void }) => void
|
|
23
37
|
}
|
|
24
38
|
|
|
25
39
|
export interface HeldSignals {
|
|
@@ -31,16 +45,12 @@ export interface HeldSignals {
|
|
|
31
45
|
release: () => void
|
|
32
46
|
}
|
|
33
47
|
|
|
48
|
+
const KILL_GRACE_MS = 2000
|
|
34
49
|
const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
|
|
35
50
|
// A scheme of two letters or more, so a Windows drive is read as a path.
|
|
36
51
|
const SCHEME = /^[a-z][\w+.-]+:/i
|
|
37
52
|
const OPENERS: Partial<Record<NodeJS.Platform, string[]>> = { darwin: ['open'], win32: ['cmd', '/c', 'start', ''] }
|
|
38
53
|
|
|
39
|
-
async function collect(stream: ReadableStream<Uint8Array>, name: Chunk['stream'], chunks: Chunk[]): Promise<void> {
|
|
40
|
-
for await (const data of stream)
|
|
41
|
-
chunks.push({ stream: name, data })
|
|
42
|
-
}
|
|
43
|
-
|
|
44
54
|
// The exit code a shell would report: the code, or 128 plus the signal.
|
|
45
55
|
export function exitCodeOf(code: number | null, signal: NodeJS.Signals | number | null): number {
|
|
46
56
|
if (code !== null)
|
|
@@ -106,15 +116,106 @@ export function openUrl(target: string, env: Record<string, string | undefined>
|
|
|
106
116
|
}
|
|
107
117
|
}
|
|
108
118
|
|
|
109
|
-
// Runs a command with stdout and stderr captured; throws when it cannot be started.
|
|
110
|
-
export async function runCommand(argv: string[], { cwd, env, stdin = 'ignore' }: RunSettings = {}): Promise<Ran> {
|
|
119
|
+
// Runs a command with stdout and stderr captured; throws when it cannot be started. With `timeoutMs`, `signal`, `onStart` or `onChunk` the child leads its own process group, and the whole group is ended (SIGTERM, then SIGKILL after a short grace) on the timeout, an abort or a throwing `onChunk`; the pipes are drained to their end either way.
|
|
120
|
+
export async function runCommand(argv: string[], { cwd, env, stdin = 'ignore', timeoutMs, signal, maxBytes = Infinity, onChunk, onStart }: RunSettings = {}): Promise<Ran> {
|
|
111
121
|
const chunks: Chunk[] = []
|
|
112
|
-
const
|
|
122
|
+
const grouped = timeoutMs !== undefined || signal !== undefined || onStart !== undefined || onChunk !== undefined
|
|
123
|
+
const proc = Bun.spawn(argv, { ...(cwd === undefined ? {} : { cwd }), ...(env === undefined ? {} : { env }), stdin, stdout: 'pipe', stderr: 'pipe', ...(grouped ? { detached: true } : {}) })
|
|
124
|
+
const state = { kept: 0, truncated: false, timedOut: false, failure: undefined as unknown, done: false }
|
|
125
|
+
const timers: ReturnType<typeof setTimeout>[] = []
|
|
126
|
+
|
|
127
|
+
const killGroup = (killing: NodeJS.Signals) => {
|
|
128
|
+
if (state.done)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
if (grouped)
|
|
133
|
+
process.kill(-proc.pid, killing)
|
|
134
|
+
else
|
|
135
|
+
proc.kill(killing)
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// The group is already gone.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const stop = () => {
|
|
143
|
+
killGroup('SIGTERM')
|
|
144
|
+
timers.push(setTimeout(killGroup, KILL_GRACE_MS, 'SIGKILL'))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const onAbort = () => stop()
|
|
148
|
+
|
|
149
|
+
if (timeoutMs !== undefined) {
|
|
150
|
+
timers.push(setTimeout(() => {
|
|
151
|
+
state.timedOut = true
|
|
152
|
+
stop()
|
|
153
|
+
}, timeoutMs))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (signal?.aborted === true)
|
|
157
|
+
onAbort()
|
|
158
|
+
else
|
|
159
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
160
|
+
|
|
161
|
+
onStart?.({ kill: killing => killGroup(killing) })
|
|
162
|
+
|
|
163
|
+
const drain = async (stream: ReadableStream<Uint8Array>, name: Chunk['stream']) => {
|
|
164
|
+
for await (const data of stream) {
|
|
165
|
+
if (state.failure !== undefined)
|
|
166
|
+
continue
|
|
113
167
|
|
|
114
|
-
|
|
115
|
-
|
|
168
|
+
try {
|
|
169
|
+
onChunk?.({ stream: name, data })
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
state.failure = error
|
|
173
|
+
stop()
|
|
116
174
|
|
|
117
|
-
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
118
177
|
|
|
119
|
-
|
|
178
|
+
if (state.kept + data.length > maxBytes) {
|
|
179
|
+
state.truncated = true
|
|
180
|
+
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
state.kept += data.length
|
|
185
|
+
chunks.push({ stream: name, data })
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
await Promise.all([drain(proc.stdout, 'stdout'), drain(proc.stderr, 'stderr')])
|
|
191
|
+
await proc.exited
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
state.done = true
|
|
195
|
+
|
|
196
|
+
for (const timer of timers)
|
|
197
|
+
clearTimeout(timer)
|
|
198
|
+
|
|
199
|
+
signal?.removeEventListener('abort', onAbort)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (state.failure !== undefined)
|
|
203
|
+
throw state.failure
|
|
204
|
+
|
|
205
|
+
const joined = () => Buffer.concat(chunks.map(({ data }) => data))
|
|
206
|
+
let output: Buffer | undefined
|
|
207
|
+
let text: string | undefined
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
code: exitCodeOf(proc.exitCode, proc.signalCode),
|
|
211
|
+
chunks,
|
|
212
|
+
timedOut: state.timedOut,
|
|
213
|
+
truncated: state.truncated,
|
|
214
|
+
get output() {
|
|
215
|
+
return output ??= joined()
|
|
216
|
+
},
|
|
217
|
+
get text() {
|
|
218
|
+
return text ??= (output ??= joined()).toString()
|
|
219
|
+
},
|
|
220
|
+
}
|
|
120
221
|
}
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Parts of a name that make it secret wherever they sit in it (`DB_PASSWORD`, `PGPASSWORD`, `clientSecret`).
|
|
2
|
+
const SECRET_PART = /token|secret|passw|credential|private|dsn|apikey|accesskey|key$/i
|
|
3
|
+
// Whole words of a name that make it secret (`DB_PASS`, `SMTP_PWD`, `encryptionKey`, `session_id`), so `compass`, `author` or `keyword` stay plain.
|
|
4
|
+
const SECRET_WORDS = new Set(['key', 'pass', 'pwd', 'auth', 'salt', 'cookie', 'session', 'jwt', 'ssn', 'iban', 'card', 'cvv', 'otp'])
|
|
5
|
+
const WORD_BREAKS = /[^a-zA-Z\d]+|(?<=[a-z\d])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/
|
|
6
|
+
|
|
7
|
+
// True for the name of a variable, a column or a JSON key that holds a secret, by one definition shared by ev, redact and db.
|
|
8
|
+
export function isSecretName(name: string): boolean {
|
|
9
|
+
return SECRET_PART.test(name) || name.split(WORD_BREAKS).some(word => SECRET_WORDS.has(word.toLowerCase()))
|
|
10
|
+
}
|
package/src/shell.ts
CHANGED
|
@@ -1,20 +1,78 @@
|
|
|
1
|
-
// `text` is the piece as written, `command` the same less its heredoc bodies, which are data and never commands; `separator` is what ends it (`|`, `&&`, `;`, a newline...), empty for the last; `expanded` holds the bodies of its unquoted heredocs, where the shell still runs `$(...)
|
|
2
|
-
export interface Piece { text: string, start: number, end: number, command: string, separator: string, expanded: string }
|
|
1
|
+
// `text` is the piece as written, `command` the same less its heredoc bodies and comments, which are data and never commands; `separator` is what ends it (`|`, `&&`, `;`, a newline...), empty for the last; `expanded` holds the bodies of its unquoted heredocs, where the shell still runs `$(...)`, and `heredocs` those of all its heredocs.
|
|
2
|
+
export interface Piece { text: string, start: number, end: number, command: string, separator: string, expanded: string, heredocs: string[] }
|
|
3
3
|
export interface Invocation { program: string, args: string[], assignments: string[], isWrapped: boolean }
|
|
4
4
|
|
|
5
5
|
const SEPARATORS = new Set(['\n', ';', '|'])
|
|
6
6
|
const PLAIN_BREAKERS = new Set(['<', '>', '(', ')', '&', '|'])
|
|
7
|
+
const GLOB_CHARS = new Set(['*', '?', '['])
|
|
8
|
+
const COMMENT_PREFIXES = new Set([';', '&', '|', '(', ')'])
|
|
7
9
|
const WHITESPACE = /\s/
|
|
8
10
|
const ASSIGNMENT = /^\w+=/
|
|
9
11
|
const TRAILING_SLASHES = /\/+$/
|
|
12
|
+
const TRAILING_CR = /\r$/
|
|
10
13
|
const LEADING_TILDE = /^~(?=\/|$)/
|
|
11
14
|
const LEADING_TABS = /^\t+/
|
|
12
15
|
const OPENERS = /^[({]+/
|
|
13
16
|
const CLOSERS = /[)}]+$/
|
|
17
|
+
const CASE_LABEL = /^\(?[^\s()]*\)$/
|
|
18
|
+
const REDIRECT_ONLY = /^\d*(?:<|>|>>|<&|>&|&>|>\||<>)$/
|
|
19
|
+
const REDIRECT_START = /^\d*[<>]|^&>/
|
|
20
|
+
const SAFE_WORD = /^[\w@%+=:,./-]+$/
|
|
21
|
+
const LEADING_HEX = /^[\da-f]*/i
|
|
14
22
|
const DOUBLE_QUOTED_ESCAPES = new Set(['$', '`', '"', '\\'])
|
|
15
23
|
const HEREDOC = /^<<(-?)[ \t]*(?:'([^']*)'|"([^"]*)"|([^\s;&|<>()-][^\s;&|<>()]*))/
|
|
16
|
-
//
|
|
17
|
-
const
|
|
24
|
+
// The quote opened by `$'`, inside which a backslash escapes and decodes.
|
|
25
|
+
const ANSI_QUOTE = '$\''
|
|
26
|
+
const ANSI_ESCAPES: Record<string, string> = { 'n': '\n', 't': '\t', 'r': '\r', 'a': '\x07', 'b': '\b', 'f': '\f', 'v': '\v', 'e': '\x1B', 'E': '\x1B', '\\': '\\', '\'': '\'', '"': '"', '?': '?' }
|
|
27
|
+
const ANSI_DIGITS: Record<string, number> = { x: 2, u: 4, U: 8 }
|
|
28
|
+
const OCTAL = /^[0-7]{1,3}/
|
|
29
|
+
// Keywords opening a command of a compound statement, which the command following them is the one that counts.
|
|
30
|
+
const KEYWORDS = new Set(['then', 'do', 'else', 'if', 'elif', 'while', 'until', '!', '{', '}'])
|
|
31
|
+
const MAX_DEPTH = 8
|
|
32
|
+
const MAX_BRACES = 256
|
|
33
|
+
// Programs reading a command string with `-c`.
|
|
34
|
+
const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ash', 'ksh'])
|
|
35
|
+
const COMMAND_STRING_PROGRAMS = new Set([...SHELLS, 'su', 'runuser'])
|
|
36
|
+
const SHELL_VALUE_OPTIONS = new Set(['-o', '-O', '--rcfile', '--init-file'])
|
|
37
|
+
const PRINTERS = new Set(['echo', 'printf'])
|
|
38
|
+
const FIND_EXEC = new Set(['-exec', '-execdir', '-ok', '-okdir'])
|
|
39
|
+
|
|
40
|
+
// Programs that run the command following them, which is the one that counts, with the options taking a value and the operands before that command.
|
|
41
|
+
interface WrapperSpec { short: string, long: readonly string[], operands: number }
|
|
42
|
+
|
|
43
|
+
function wrapper(short = '', long: readonly string[] = [], operands = 0): WrapperSpec {
|
|
44
|
+
return { short, long, operands }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const WRAPPERS = new Map<string, WrapperSpec>([
|
|
48
|
+
['command', wrapper()],
|
|
49
|
+
['builtin', wrapper()],
|
|
50
|
+
['nohup', wrapper()],
|
|
51
|
+
['setsid', wrapper()],
|
|
52
|
+
['busybox', wrapper()],
|
|
53
|
+
['exec', wrapper('a')],
|
|
54
|
+
['time', wrapper('fo', ['--format', '--output'])],
|
|
55
|
+
['env', wrapper('uCS', ['--unset', '--chdir', '--split-string', '--argv0'])],
|
|
56
|
+
['sudo', wrapper('ughpCTrtUDR', ['--user', '--group', '--host', '--prompt', '--close-from', '--command-timeout', '--role', '--type', '--other-user', '--chdir', '--chroot'])],
|
|
57
|
+
['doas', wrapper('uC')],
|
|
58
|
+
['timeout', wrapper('sk', ['--signal', '--kill-after'], 1)],
|
|
59
|
+
['nice', wrapper('n', ['--adjustment'])],
|
|
60
|
+
['ionice', wrapper('cnpPu', ['--class', '--classdata', '--pid', '--pgid', '--uid'])],
|
|
61
|
+
['stdbuf', wrapper('ioe', ['--input', '--output', '--error'])],
|
|
62
|
+
['chroot', wrapper('', ['--userspec', '--groups'], 1)],
|
|
63
|
+
['xargs', wrapper('IndsEaPL', ['--max-args', '--max-procs', '--max-chars', '--delimiter', '--arg-file', '--eof', '--max-lines', '--process-slot-var'])],
|
|
64
|
+
])
|
|
65
|
+
|
|
66
|
+
function closerOf(quote: string): string {
|
|
67
|
+
return quote === ANSI_QUOTE ? '\'' : quote
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A `#` starts a comment at the start of a word, never inside one such as `$#` or `a#b`.
|
|
71
|
+
function startsComment(command: string, i: number): boolean {
|
|
72
|
+
const before = command[i - 1] ?? ''
|
|
73
|
+
|
|
74
|
+
return i === 0 || WHITESPACE.test(before) || COMMENT_PREFIXES.has(before)
|
|
75
|
+
}
|
|
18
76
|
|
|
19
77
|
// A lone `&` sends the command before it to the background, unless it belongs to a redirection such as `2>&1` or `&>`.
|
|
20
78
|
function separatorWidth(command: string, i: number): number {
|
|
@@ -31,7 +89,7 @@ function separatorWidth(command: string, i: number): number {
|
|
|
31
89
|
return SEPARATORS.has(char) ? 1 : 0
|
|
32
90
|
}
|
|
33
91
|
|
|
34
|
-
// Where the heredocs opened on a line end: from the newline closing that line to the end of the last delimiter line.
|
|
92
|
+
// Where the heredocs opened on a line end: from the newline closing that line to the end of the last delimiter line, a CRLF one included.
|
|
35
93
|
function heredocEnd(command: string, newline: number, words: readonly { word: string, stripsTabs: boolean }[]): number {
|
|
36
94
|
let lineStart = newline + 1
|
|
37
95
|
|
|
@@ -39,7 +97,7 @@ function heredocEnd(command: string, newline: number, words: readonly { word: st
|
|
|
39
97
|
while (lineStart <= command.length) {
|
|
40
98
|
const next = command.indexOf('\n', lineStart)
|
|
41
99
|
const lineEnd = next === -1 ? command.length : next
|
|
42
|
-
const line = command.slice(lineStart, lineEnd)
|
|
100
|
+
const line = command.slice(lineStart, lineEnd).replace(TRAILING_CR, '')
|
|
43
101
|
|
|
44
102
|
lineStart = lineEnd + 1
|
|
45
103
|
|
|
@@ -66,10 +124,11 @@ function withoutRanges(command: string, start: number, end: number, ranges: read
|
|
|
66
124
|
return text + command.slice(at, end)
|
|
67
125
|
}
|
|
68
126
|
|
|
69
|
-
// Splits on the shell separators outside quotes and heredocs, so the `;` and `|` of an inline script stay in its piece, each piece keeping where it sits in the command.
|
|
127
|
+
// Splits on the shell separators outside quotes, comments and heredocs, so the `;` and `|` of an inline script stay in its piece, each piece keeping where it sits in the command.
|
|
70
128
|
export function splitCommand(command: string): Piece[] {
|
|
71
129
|
const bounds: [number, number, string][] = []
|
|
72
130
|
const bodies: [number, number, boolean][] = []
|
|
131
|
+
const comments: [number, number][] = []
|
|
73
132
|
let heredocs: { word: string, stripsTabs: boolean, isQuoted: boolean }[] = []
|
|
74
133
|
let start = 0
|
|
75
134
|
let quote: string | null = null
|
|
@@ -84,18 +143,35 @@ export function splitCommand(command: string): Piece[] {
|
|
|
84
143
|
}
|
|
85
144
|
|
|
86
145
|
if (quote !== null) {
|
|
87
|
-
if (char === quote)
|
|
146
|
+
if (char === closerOf(quote))
|
|
88
147
|
quote = null
|
|
89
148
|
|
|
90
149
|
continue
|
|
91
150
|
}
|
|
92
151
|
|
|
152
|
+
if (char === '$' && command[i + 1] === '\'') {
|
|
153
|
+
quote = ANSI_QUOTE
|
|
154
|
+
i++
|
|
155
|
+
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
|
|
93
159
|
if (char === '\'' || char === '"') {
|
|
94
160
|
quote = char
|
|
95
161
|
|
|
96
162
|
continue
|
|
97
163
|
}
|
|
98
164
|
|
|
165
|
+
if (char === '#' && startsComment(command, i)) {
|
|
166
|
+
const newline = command.indexOf('\n', i)
|
|
167
|
+
const end = newline === -1 ? command.length : newline
|
|
168
|
+
|
|
169
|
+
comments.push([i, end])
|
|
170
|
+
i = end - 1
|
|
171
|
+
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
|
|
99
175
|
const heredoc = char === '<' && command[i + 1] === '<' && command[i + 2] !== '<' ? HEREDOC.exec(command.slice(i)) : null
|
|
100
176
|
|
|
101
177
|
if (heredoc) {
|
|
@@ -126,16 +202,21 @@ export function splitCommand(command: string): Piece[] {
|
|
|
126
202
|
}
|
|
127
203
|
bounds.push([start, command.length, ''])
|
|
128
204
|
|
|
129
|
-
const ranges = bodies.map(([from, to]): [number, number] => [from, to])
|
|
205
|
+
const ranges = [...bodies, ...comments].map(([from, to]): [number, number] => [from, to]).sort(([a], [b]) => a - b)
|
|
206
|
+
|
|
207
|
+
return bounds.map(([from, to, separator]) => {
|
|
208
|
+
const inside = bodies.filter(([body]) => body >= from && body < to)
|
|
130
209
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
210
|
+
return {
|
|
211
|
+
text: command.slice(from, to),
|
|
212
|
+
start: from,
|
|
213
|
+
end: to,
|
|
214
|
+
command: withoutRanges(command, from, to, ranges),
|
|
215
|
+
separator,
|
|
216
|
+
expanded: inside.filter(([, , expands]) => expands).map(([body, bodyEnd]) => command.slice(body, bodyEnd)).join(''),
|
|
217
|
+
heredocs: inside.map(([body, bodyEnd]) => command.slice(body, bodyEnd)),
|
|
218
|
+
}
|
|
219
|
+
})
|
|
139
220
|
}
|
|
140
221
|
|
|
141
222
|
// True when the text holds no redirect, substitution, subshell or group outside single quotes, and closes every quote it opens.
|
|
@@ -146,19 +227,74 @@ export function isPlain(text: string): boolean {
|
|
|
146
227
|
const char = text[i] ?? ''
|
|
147
228
|
const isSubstitution = char === '`' || (char === '$' && text[i + 1] === '(')
|
|
148
229
|
|
|
230
|
+
if (quote === null && char === '$' && text[i + 1] === '\'') {
|
|
231
|
+
quote = ANSI_QUOTE
|
|
232
|
+
i++
|
|
233
|
+
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
|
|
149
237
|
if (char === '\\' && quote !== '\'')
|
|
150
238
|
i++
|
|
151
|
-
else if (quote !== null && char === quote)
|
|
239
|
+
else if (quote !== null && char === closerOf(quote))
|
|
152
240
|
quote = null
|
|
153
241
|
else if (quote === null && (char === '"' || char === '\''))
|
|
154
242
|
quote = char
|
|
155
|
-
else if (quote !== '\'' && (isSubstitution || (quote === null && PLAIN_BREAKERS.has(char))))
|
|
243
|
+
else if (quote !== '\'' && quote !== ANSI_QUOTE && (isSubstitution || (quote === null && PLAIN_BREAKERS.has(char))))
|
|
156
244
|
return false
|
|
157
245
|
}
|
|
158
246
|
|
|
159
247
|
return quote === null
|
|
160
248
|
}
|
|
161
249
|
|
|
250
|
+
// How many `)` the text closes without opening, as the end of a subshell does.
|
|
251
|
+
function unmatchedClosers(text: string): number {
|
|
252
|
+
let depth = 0
|
|
253
|
+
let unmatched = 0
|
|
254
|
+
let quote: string | null = null
|
|
255
|
+
|
|
256
|
+
for (let i = 0; i < text.length; i++) {
|
|
257
|
+
const char = text[i] ?? ''
|
|
258
|
+
|
|
259
|
+
if (quote === null && char === '$' && text[i + 1] === '\'') {
|
|
260
|
+
quote = ANSI_QUOTE
|
|
261
|
+
i++
|
|
262
|
+
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (char === '\\' && quote !== '\'')
|
|
267
|
+
i++
|
|
268
|
+
else if (quote !== null && char === closerOf(quote))
|
|
269
|
+
quote = null
|
|
270
|
+
else if (quote === null && (char === '"' || char === '\''))
|
|
271
|
+
quote = char
|
|
272
|
+
else if (quote === null && char === '(')
|
|
273
|
+
depth++
|
|
274
|
+
else if (quote === null && char === ')' && depth > 0)
|
|
275
|
+
depth--
|
|
276
|
+
else if (quote === null && char === ')')
|
|
277
|
+
unmatched++
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return unmatched
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// A piece less the `(` opening its subshell and the `)` closing it, so the last word of `(cat .env)` is the file.
|
|
284
|
+
function withoutSubshell(text: string): string {
|
|
285
|
+
let result = text.trim()
|
|
286
|
+
let closers = unmatchedClosers(result)
|
|
287
|
+
|
|
288
|
+
while (result.startsWith('(')) result = result.slice(1).trimStart()
|
|
289
|
+
|
|
290
|
+
while (closers > 0 && result.endsWith(')')) {
|
|
291
|
+
result = result.slice(0, -1).trimEnd()
|
|
292
|
+
closers--
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return result
|
|
296
|
+
}
|
|
297
|
+
|
|
162
298
|
// The index of the `)` closing the `(` at `open`, past nested parentheses and quotes; the text's end when it never closes.
|
|
163
299
|
function closingParen(text: string, open: number): number {
|
|
164
300
|
let depth = 0
|
|
@@ -171,9 +307,13 @@ function closingParen(text: string, open: number): number {
|
|
|
171
307
|
i++
|
|
172
308
|
}
|
|
173
309
|
else if (quote !== null) {
|
|
174
|
-
if (char === quote)
|
|
310
|
+
if (char === closerOf(quote))
|
|
175
311
|
quote = null
|
|
176
312
|
}
|
|
313
|
+
else if (char === '$' && text[i + 1] === '\'') {
|
|
314
|
+
quote = ANSI_QUOTE
|
|
315
|
+
i++
|
|
316
|
+
}
|
|
177
317
|
else if (char === '\'' || char === '"') {
|
|
178
318
|
quote = char
|
|
179
319
|
}
|
|
@@ -200,22 +340,29 @@ function closingBackquote(text: string, open: number): number {
|
|
|
200
340
|
return text.length
|
|
201
341
|
}
|
|
202
342
|
|
|
203
|
-
// The bodies of the outermost `$(...)` and backquotes; quotes only count where `isQuoting`, as a heredoc body holds them as plain text.
|
|
343
|
+
// The bodies of the outermost `$(...)`, `<(...)`, `>(...)` and backquotes; quotes only count where `isQuoting`, as a heredoc body holds them as plain text.
|
|
204
344
|
function bodies(text: string, isQuoting: boolean): string[] {
|
|
205
345
|
const found: string[] = []
|
|
206
346
|
let quote: string | null = null
|
|
207
347
|
|
|
208
348
|
for (let i = 0; i < text.length; i++) {
|
|
209
349
|
const char = text[i] ?? ''
|
|
350
|
+
const next = text[i + 1]
|
|
210
351
|
|
|
211
352
|
if (quote === '\'') {
|
|
212
353
|
if (char === '\'')
|
|
213
354
|
quote = null
|
|
214
355
|
}
|
|
356
|
+
else if (quote === ANSI_QUOTE) {
|
|
357
|
+
if (char === '\\')
|
|
358
|
+
i++
|
|
359
|
+
else if (char === '\'')
|
|
360
|
+
quote = null
|
|
361
|
+
}
|
|
215
362
|
else if (char === '\\') {
|
|
216
363
|
i++
|
|
217
364
|
}
|
|
218
|
-
else if (char === '$' &&
|
|
365
|
+
else if ((char === '$' && next === '(') || (isQuoting && quote === null && (char === '<' || char === '>') && next === '(')) {
|
|
219
366
|
const end = closingParen(text, i + 1)
|
|
220
367
|
|
|
221
368
|
found.push(text.slice(i + 2, end))
|
|
@@ -230,6 +377,10 @@ function bodies(text: string, isQuoting: boolean): string[] {
|
|
|
230
377
|
else if (isQuoting && char === '"') {
|
|
231
378
|
quote = quote === null ? char : null
|
|
232
379
|
}
|
|
380
|
+
else if (isQuoting && char === '$' && next === '\'' && quote === null) {
|
|
381
|
+
quote = ANSI_QUOTE
|
|
382
|
+
i++
|
|
383
|
+
}
|
|
233
384
|
else if (isQuoting && char === '\'' && quote === null) {
|
|
234
385
|
quote = char
|
|
235
386
|
}
|
|
@@ -238,13 +389,34 @@ function bodies(text: string, isQuoting: boolean): string[] {
|
|
|
238
389
|
return found
|
|
239
390
|
}
|
|
240
391
|
|
|
241
|
-
// The commands each piece runs through `$(...)` or backquotes, inside double quotes and unquoted heredoc bodies too; a nested one is found by reading each result in turn.
|
|
392
|
+
// The commands each piece runs through `$(...)`, `<(...)` or backquotes, inside double quotes and unquoted heredoc bodies too; a nested one is found by reading each result in turn.
|
|
242
393
|
export function substitutions(command: string): string[] {
|
|
243
394
|
return splitCommand(command).flatMap(piece => [...bodies(piece.command, true), ...bodies(piece.expanded, false)])
|
|
244
395
|
}
|
|
245
396
|
|
|
246
|
-
// A word as the shell would pass it; `isProse` when a quoted part of it holds whitespace, a message or a pattern rather than a path.
|
|
247
|
-
export interface Word { text: string, isProse: boolean }
|
|
397
|
+
// A word as the shell would pass it; `isProse` when a quoted part of it holds whitespace, a message or a pattern rather than a path, `hasGlob` and `hasBrace` when it holds an unquoted `*?[` or `{` the shell would expand.
|
|
398
|
+
export interface Word { text: string, isProse: boolean, hasGlob: boolean, hasBrace: boolean }
|
|
399
|
+
|
|
400
|
+
// What the backslash escape at `at` inside `$'...'` stands for, and the index of its last character.
|
|
401
|
+
function ansiEscape(text: string, at: number): [string, number] {
|
|
402
|
+
const letter = text[at + 1] ?? ''
|
|
403
|
+
const simple = ANSI_ESCAPES[letter]
|
|
404
|
+
|
|
405
|
+
if (simple !== undefined)
|
|
406
|
+
return [simple, at + 1]
|
|
407
|
+
|
|
408
|
+
const hex = LEADING_HEX.exec(text.slice(at + 2, at + 2 + (ANSI_DIGITS[letter] ?? 0)))?.[0] ?? ''
|
|
409
|
+
|
|
410
|
+
if (hex !== '')
|
|
411
|
+
return [String.fromCodePoint(Number.parseInt(hex, 16)), at + 1 + hex.length]
|
|
412
|
+
|
|
413
|
+
const octal = OCTAL.exec(text.slice(at + 1))?.[0]
|
|
414
|
+
|
|
415
|
+
if (octal !== undefined)
|
|
416
|
+
return [String.fromCodePoint(Number.parseInt(octal, 8)), at + octal.length]
|
|
417
|
+
|
|
418
|
+
return [`\\${letter}`, at + 1]
|
|
419
|
+
}
|
|
248
420
|
|
|
249
421
|
// The words of a piece, quotes and line continuations removed.
|
|
250
422
|
export function words(text: string): Word[] {
|
|
@@ -252,6 +424,8 @@ export function words(text: string): Word[] {
|
|
|
252
424
|
let current = ''
|
|
253
425
|
let isStarted = false
|
|
254
426
|
let isProse = false
|
|
427
|
+
let hasGlob = false
|
|
428
|
+
let hasBrace = false
|
|
255
429
|
let quote: string | null = null
|
|
256
430
|
|
|
257
431
|
for (let i = 0; i < text.length; i++) {
|
|
@@ -263,11 +437,13 @@ export function words(text: string): Word[] {
|
|
|
263
437
|
|
|
264
438
|
if (quote === null && (isContinuation || WHITESPACE.test(char))) {
|
|
265
439
|
if (isStarted)
|
|
266
|
-
found.push({ text: current, isProse })
|
|
440
|
+
found.push({ text: current, isProse, hasGlob, hasBrace })
|
|
267
441
|
|
|
268
442
|
current = ''
|
|
269
443
|
isStarted = false
|
|
270
444
|
isProse = false
|
|
445
|
+
hasGlob = false
|
|
446
|
+
hasBrace = false
|
|
271
447
|
|
|
272
448
|
continue
|
|
273
449
|
}
|
|
@@ -278,22 +454,39 @@ export function words(text: string): Word[] {
|
|
|
278
454
|
isStarted = true
|
|
279
455
|
|
|
280
456
|
// within double quotes a backslash escapes only `$`, a backtick, `"` and itself, and stays before anything else (`"v\d"`)
|
|
281
|
-
if (char === '\\' && quote ===
|
|
457
|
+
if (char === '\\' && quote === ANSI_QUOTE) {
|
|
458
|
+
const [decoded, last] = ansiEscape(text, i)
|
|
459
|
+
|
|
460
|
+
current += decoded
|
|
461
|
+
i = last
|
|
462
|
+
}
|
|
463
|
+
else if (char === '\\' && quote === '"' && !DOUBLE_QUOTED_ESCAPES.has(text[i + 1] ?? '')) {
|
|
282
464
|
current += char
|
|
283
|
-
|
|
465
|
+
}
|
|
466
|
+
else if (char === '\\' && quote !== '\'') {
|
|
284
467
|
current += text[++i] ?? ''
|
|
285
|
-
|
|
468
|
+
}
|
|
469
|
+
else if (quote !== null && char === closerOf(quote)) {
|
|
286
470
|
quote = null
|
|
287
|
-
|
|
471
|
+
}
|
|
472
|
+
else if (quote === null && char === '$' && text[i + 1] === '\'') {
|
|
473
|
+
quote = ANSI_QUOTE
|
|
474
|
+
i++
|
|
475
|
+
}
|
|
476
|
+
else if (quote === null && (char === '"' || char === '\'')) {
|
|
288
477
|
quote = char
|
|
289
|
-
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
290
480
|
current += char
|
|
481
|
+
hasGlob ||= quote === null && GLOB_CHARS.has(char)
|
|
482
|
+
hasBrace ||= quote === null && char === '{'
|
|
483
|
+
}
|
|
291
484
|
|
|
292
485
|
isProse ||= quote !== null && WHITESPACE.test(char)
|
|
293
486
|
}
|
|
294
487
|
|
|
295
488
|
if (isStarted)
|
|
296
|
-
found.push({ text: current, isProse })
|
|
489
|
+
found.push({ text: current, isProse, hasGlob, hasBrace })
|
|
297
490
|
|
|
298
491
|
return found
|
|
299
492
|
}
|
|
@@ -303,7 +496,45 @@ export function tokenize(text: string): string[] {
|
|
|
303
496
|
return words(text).map(word => word.text)
|
|
304
497
|
}
|
|
305
498
|
|
|
306
|
-
//
|
|
499
|
+
// Drops the options of a wrapper and the values they take, then its leading operands; `env -S` splits its value into the command it runs.
|
|
500
|
+
function skipOptions(tokens: string[], spec: WrapperSpec): void {
|
|
501
|
+
const take = (name: string, attached: string) => {
|
|
502
|
+
const value = attached === '' ? (tokens.shift() ?? '') : attached
|
|
503
|
+
|
|
504
|
+
if (name === 'S' || name === '--split-string')
|
|
505
|
+
tokens.unshift(...tokenize(value))
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
while (tokens[0] !== undefined && tokens[0].startsWith('-') && tokens[0] !== '-') {
|
|
509
|
+
const option = tokens.shift() ?? ''
|
|
510
|
+
|
|
511
|
+
if (option === '--')
|
|
512
|
+
break
|
|
513
|
+
|
|
514
|
+
if (option.startsWith('--')) {
|
|
515
|
+
const [name = '', ...value] = option.split('=')
|
|
516
|
+
|
|
517
|
+
if (spec.long.includes(name))
|
|
518
|
+
take(name, value.join('='))
|
|
519
|
+
|
|
520
|
+
continue
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
for (const [index, letter] of [...option.slice(1)].entries()) {
|
|
524
|
+
if (!spec.short.includes(letter))
|
|
525
|
+
continue
|
|
526
|
+
|
|
527
|
+
// the value is what follows the letter, else the next word
|
|
528
|
+
take(letter, option.slice(index + 2))
|
|
529
|
+
|
|
530
|
+
break
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
for (let operand = 0; operand < spec.operands; operand++) tokens.shift()
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// The program a piece runs, by its base name, past any subshell opener, keyword, variable assignment or wrapper such as `env` or `sudo`.
|
|
307
538
|
export function parseCommand(text: string): Invocation {
|
|
308
539
|
const tokens = tokenize(text)
|
|
309
540
|
const assignments: string[] = []
|
|
@@ -311,6 +542,7 @@ export function parseCommand(text: string): Invocation {
|
|
|
311
542
|
|
|
312
543
|
for (;;) {
|
|
313
544
|
const first = tokens[0] ?? ''
|
|
545
|
+
const spec = WRAPPERS.get(first.split('/').pop() ?? '')
|
|
314
546
|
|
|
315
547
|
if (OPENERS.test(first)) {
|
|
316
548
|
isWrapped = true
|
|
@@ -323,11 +555,24 @@ export function parseCommand(text: string): Invocation {
|
|
|
323
555
|
else if (ASSIGNMENT.test(first)) {
|
|
324
556
|
assignments.push(tokens.shift() ?? '')
|
|
325
557
|
}
|
|
326
|
-
else if (
|
|
558
|
+
else if (KEYWORDS.has(first)) {
|
|
327
559
|
isWrapped = true
|
|
328
560
|
tokens.shift()
|
|
561
|
+
}
|
|
562
|
+
else if (first === 'case') {
|
|
563
|
+
isWrapped = true
|
|
564
|
+
const at = tokens.indexOf('in')
|
|
329
565
|
|
|
330
|
-
|
|
566
|
+
tokens.splice(0, at === -1 ? tokens.length : at + 1)
|
|
567
|
+
}
|
|
568
|
+
else if (tokens.length > 1 && CASE_LABEL.test(first)) {
|
|
569
|
+
isWrapped = true
|
|
570
|
+
tokens.shift()
|
|
571
|
+
}
|
|
572
|
+
else if (spec !== undefined) {
|
|
573
|
+
isWrapped = true
|
|
574
|
+
tokens.shift()
|
|
575
|
+
skipOptions(tokens, spec)
|
|
331
576
|
}
|
|
332
577
|
else {
|
|
333
578
|
break
|
|
@@ -347,6 +592,193 @@ export function parseCommand(text: string): Invocation {
|
|
|
347
592
|
return { program: program.split('/').pop() ?? '', args, assignments, isWrapped }
|
|
348
593
|
}
|
|
349
594
|
|
|
595
|
+
function quoted(word: string): string {
|
|
596
|
+
return SAFE_WORD.test(word) || word === '{}' ? word : `'${word.replaceAll('\'', '\'\\\'\'')}'`
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// The command strings a piece hands to a shell with `-c`, wherever the shell sits in it (`docker exec db sh -c '...'`, `su -c '...'`).
|
|
600
|
+
function commandStrings(tokens: readonly string[]): string[] {
|
|
601
|
+
const found: string[] = []
|
|
602
|
+
|
|
603
|
+
for (const [i, token] of tokens.entries()) {
|
|
604
|
+
const name = token.split('/').pop() ?? ''
|
|
605
|
+
|
|
606
|
+
if (!COMMAND_STRING_PROGRAMS.has(name))
|
|
607
|
+
continue
|
|
608
|
+
|
|
609
|
+
const isUser = name === 'su' || name === 'runuser'
|
|
610
|
+
let isValue = false
|
|
611
|
+
|
|
612
|
+
for (let j = i + 1; j < tokens.length; j++) {
|
|
613
|
+
const option = tokens[j] ?? ''
|
|
614
|
+
|
|
615
|
+
if (isValue) {
|
|
616
|
+
isValue = false
|
|
617
|
+
|
|
618
|
+
continue
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (SHELL_VALUE_OPTIONS.has(option)) {
|
|
622
|
+
isValue = true
|
|
623
|
+
|
|
624
|
+
continue
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (option.startsWith('--command=')) {
|
|
628
|
+
found.push(option.slice('--command='.length))
|
|
629
|
+
|
|
630
|
+
break
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (option === '--command' || (!option.startsWith('--') && option.startsWith('-') && option.slice(1).includes('c'))) {
|
|
634
|
+
found.push(tokens[j + 1] ?? '')
|
|
635
|
+
|
|
636
|
+
break
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (!option.startsWith('-') && !isUser)
|
|
640
|
+
break
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return found
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// A piece's arguments less its redirections, and the words fed to it as here-strings.
|
|
648
|
+
function redirections(args: readonly string[]): { operands: string[], hereStrings: string[] } {
|
|
649
|
+
const operands: string[] = []
|
|
650
|
+
const hereStrings: string[] = []
|
|
651
|
+
|
|
652
|
+
for (let i = 0; i < args.length; i++) {
|
|
653
|
+
const arg = args[i] ?? ''
|
|
654
|
+
|
|
655
|
+
if (arg.startsWith('<<<'))
|
|
656
|
+
hereStrings.push(arg.length > 3 ? arg.slice(3) : (args[++i] ?? ''))
|
|
657
|
+
else if (REDIRECT_ONLY.test(arg))
|
|
658
|
+
i++
|
|
659
|
+
else if (!REDIRECT_START.test(arg))
|
|
660
|
+
operands.push(arg)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return { operands, hereStrings }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// The argv `find` hands to each `-exec`, up to its `;` or `+`.
|
|
667
|
+
function findExecs(args: readonly string[]): string[] {
|
|
668
|
+
const found: string[] = []
|
|
669
|
+
|
|
670
|
+
for (const [i, arg] of args.entries()) {
|
|
671
|
+
if (!FIND_EXEC.has(arg))
|
|
672
|
+
continue
|
|
673
|
+
|
|
674
|
+
const rest = args.slice(i + 1)
|
|
675
|
+
const end = rest.findIndex(word => word === ';' || word === '+')
|
|
676
|
+
|
|
677
|
+
found.push((end === -1 ? rest : rest.slice(0, end)).map(quoted).join(' '))
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return found
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// The commands a piece makes a shell run besides its own: `sh -c` strings, `eval`, `find -exec`, and what is fed to a shell reading its input, heredocs, here-strings and an `echo` piped in.
|
|
684
|
+
function nestedCommands(piece: Piece, previous: Piece | undefined): string[] {
|
|
685
|
+
const tokens = tokenize(piece.command)
|
|
686
|
+
const { program, args } = parseCommand(piece.command)
|
|
687
|
+
const found = commandStrings(tokens)
|
|
688
|
+
|
|
689
|
+
if (program === 'eval')
|
|
690
|
+
found.push(args.join(' '))
|
|
691
|
+
|
|
692
|
+
if (program === 'find')
|
|
693
|
+
found.push(...findExecs(args))
|
|
694
|
+
|
|
695
|
+
if (!SHELLS.has(program))
|
|
696
|
+
return found
|
|
697
|
+
|
|
698
|
+
const { operands, hereStrings } = redirections(args)
|
|
699
|
+
|
|
700
|
+
if (operands.length > 0)
|
|
701
|
+
return found
|
|
702
|
+
|
|
703
|
+
found.push(...piece.heredocs, ...hereStrings)
|
|
704
|
+
|
|
705
|
+
if (previous?.separator === '|') {
|
|
706
|
+
const before = parseCommand(previous.command)
|
|
707
|
+
|
|
708
|
+
if (PRINTERS.has(before.program))
|
|
709
|
+
found.push(...before.args, before.args.join(' '))
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return found
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// The command and every command it makes the shell run: substitutions, subshells, `sh -c` and `eval` strings, `find -exec`, and what is fed to a shell; each is one to check.
|
|
716
|
+
export function commandsRun(command: string, depth = 0): string[] {
|
|
717
|
+
const found = [command]
|
|
718
|
+
|
|
719
|
+
if (depth >= MAX_DEPTH)
|
|
720
|
+
return found
|
|
721
|
+
|
|
722
|
+
const pieces = splitCommand(command)
|
|
723
|
+
|
|
724
|
+
for (const [index, piece] of pieces.entries()) {
|
|
725
|
+
const inner = withoutSubshell(piece.command)
|
|
726
|
+
const nested = [...bodies(piece.command, true), ...bodies(piece.expanded, false), ...nestedCommands(piece, pieces[index - 1])]
|
|
727
|
+
|
|
728
|
+
if (inner !== piece.command.trim())
|
|
729
|
+
nested.push(inner)
|
|
730
|
+
|
|
731
|
+
for (const each of nested)
|
|
732
|
+
found.push(...commandsRun(each, depth + 1))
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return found
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function expandBraces(word: string): string[] {
|
|
739
|
+
const open = word.indexOf('{')
|
|
740
|
+
|
|
741
|
+
if (open === -1)
|
|
742
|
+
return [word]
|
|
743
|
+
|
|
744
|
+
const alternatives: string[] = []
|
|
745
|
+
let depth = 0
|
|
746
|
+
let cut = open
|
|
747
|
+
|
|
748
|
+
for (let i = open; i < word.length; i++) {
|
|
749
|
+
const char = word[i]
|
|
750
|
+
|
|
751
|
+
if (char === '{') {
|
|
752
|
+
depth++
|
|
753
|
+
}
|
|
754
|
+
else if (char === ',' && depth === 1) {
|
|
755
|
+
alternatives.push(word.slice(cut + 1, i))
|
|
756
|
+
cut = i
|
|
757
|
+
}
|
|
758
|
+
else if (char === '}' && --depth === 0) {
|
|
759
|
+
if (alternatives.length === 0)
|
|
760
|
+
break
|
|
761
|
+
|
|
762
|
+
alternatives.push(word.slice(cut + 1, i))
|
|
763
|
+
|
|
764
|
+
const prefix = word.slice(0, open)
|
|
765
|
+
const suffix = word.slice(i + 1)
|
|
766
|
+
|
|
767
|
+
return alternatives.flatMap(alternative => expandBraces(prefix + alternative + suffix)).slice(0, MAX_BRACES + 1)
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// no list at this brace: keep it and expand what follows
|
|
772
|
+
return expandBraces(word.slice(open + 1)).map(rest => `${word.slice(0, open + 1)}${rest}`)
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// The words a word with `{a,b}` lists stands for, `.{e,}nv` being `.env` and `.nv`; a word with no list, or one growing past a sane size, is returned as it is.
|
|
776
|
+
export function braceExpand(word: string): string[] {
|
|
777
|
+
const found = expandBraces(word)
|
|
778
|
+
|
|
779
|
+
return found.length > MAX_BRACES ? [word] : found
|
|
780
|
+
}
|
|
781
|
+
|
|
350
782
|
// Swaps each piece's text for its replacement, keeping the separators between them.
|
|
351
783
|
export function replacePieces(command: string, replacements: ReadonlyMap<Piece, string>): string {
|
|
352
784
|
let result = command
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { RunSettings } from './process'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import { formatDuration } from './format'
|
|
4
|
+
import { runCommand } from './process'
|
|
5
|
+
|
|
6
|
+
export interface ToolRun {
|
|
7
|
+
code: number
|
|
8
|
+
stdout: string
|
|
9
|
+
stderr: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// What an answer says when retrying it can only give the same answer: an expired token, a missing permission, a route or repository that does not exist.
|
|
13
|
+
const PERMANENT = /\bHTTP (?:401|403|404)\b|unauthorized|forbidden|not found|couldn't be found|token is required/i
|
|
14
|
+
|
|
15
|
+
// True once the directory the process runs in was deleted, as a worktree is after its work lands.
|
|
16
|
+
export function cwdGone(): boolean {
|
|
17
|
+
try {
|
|
18
|
+
process.cwd()
|
|
19
|
+
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class SpawnError extends Error {
|
|
28
|
+
constructor(message: string, readonly missing: boolean) {
|
|
29
|
+
super(message)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A spawn failing before the command runs says only "No such file or directory", which names the working directory once it was deleted.
|
|
34
|
+
export function spawnFailure(command: string, error: unknown): Error {
|
|
35
|
+
const gone = cwdGone()
|
|
36
|
+
const missing = !gone && (error as { code?: unknown } | undefined)?.code === 'ENOENT'
|
|
37
|
+
|
|
38
|
+
return new SpawnError(`${command}: ${error instanceof Error ? error.message : String(error)}${gone ? ' (the working directory was removed)' : ''}`, missing)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// True for a failure a poll cannot recover from: a binary that is not installed, or an answer of 401, 403 or 404.
|
|
42
|
+
export function isPermanentFailure(error: unknown): boolean {
|
|
43
|
+
if (!(error instanceof Error))
|
|
44
|
+
return false
|
|
45
|
+
|
|
46
|
+
return (error instanceof SpawnError && error.missing) || PERMANENT.test(error.message)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Runs a command and returns its code and both streams; throws, naming `label`, when it cannot start or outlives `timeoutMs`.
|
|
50
|
+
export async function runTool(label: string, argv: string[], { timeoutMs, cwd, onStart }: { [K in 'timeoutMs' | 'cwd' | 'onStart']?: RunSettings[K] | undefined } = {}): Promise<ToolRun> {
|
|
51
|
+
let ran
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
// The environment is passed whole so the program is looked up on the PATH as it is now, not as the process started with.
|
|
55
|
+
ran = await runCommand(argv, { env: process.env, ...(timeoutMs === undefined ? {} : { timeoutMs }), ...(cwd === undefined ? {} : { cwd }), ...(onStart === undefined ? {} : { onStart }) })
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
throw spawnFailure(label, error)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (ran.timedOut)
|
|
62
|
+
throw new Error(`${label}: no answer in ${formatDuration(timeoutMs ?? 0)}`)
|
|
63
|
+
|
|
64
|
+
const decoder = new TextDecoder()
|
|
65
|
+
const streams = { stdout: '', stderr: '' }
|
|
66
|
+
|
|
67
|
+
for (const { stream, data } of ran.chunks)
|
|
68
|
+
streams[stream] += decoder.decode(data, { stream: true })
|
|
69
|
+
|
|
70
|
+
return { code: ran.code, ...streams }
|
|
71
|
+
}
|