@kaynooo/cli-kit 0.19.0 → 0.21.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 +30 -1
- package/package.json +3 -2
- package/src/advisory.ts +22 -0
- package/src/args.ts +31 -136
- package/src/ci.ts +10 -4
- package/src/cli.ts +79 -7
- package/src/compose.ts +43 -0
- package/src/fzf.ts +186 -0
- package/src/help.ts +91 -0
- package/src/index.ts +5 -0
- package/src/input.ts +64 -0
- package/src/shell.ts +73 -18
- package/src/size.ts +14 -0
- package/src/tool.ts +5 -2
- package/src/usage.ts +129 -0
- package/src/user-config.ts +34 -0
package/src/fzf.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
|
|
6
|
+
export interface Row {
|
|
7
|
+
hidden: string[]
|
|
8
|
+
cells: string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FzfOptions {
|
|
12
|
+
prompt: string
|
|
13
|
+
header?: string
|
|
14
|
+
// Keys that pick the highlighted row too, telling which one was pressed.
|
|
15
|
+
expect?: string[]
|
|
16
|
+
// Called with the hidden fields as `{1}`, `{2}`...
|
|
17
|
+
preview?: string
|
|
18
|
+
// How many leading cells are searched, so a word of a description does not outrank the name it was typed for.
|
|
19
|
+
searched: number
|
|
20
|
+
// Whole height by default, for a list of a few candidates asked about under the command.
|
|
21
|
+
height?: string
|
|
22
|
+
bind?: string[]
|
|
23
|
+
// How many leading fields are hidden, taken from the first row by default, which a list that starts empty and reloads has none of.
|
|
24
|
+
hidden?: number
|
|
25
|
+
// The query comes back too, and an expected key pressed with no row matching still picks, with no hidden fields.
|
|
26
|
+
query?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Picked {
|
|
30
|
+
key: string
|
|
31
|
+
hidden: string[]
|
|
32
|
+
query: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A row as term lists it with `--list` in place of the fzf window, in the pane's folder: what tmux_pick types back for Enter and for the picker's other keys.
|
|
36
|
+
export interface Listed {
|
|
37
|
+
label: string
|
|
38
|
+
hint: string
|
|
39
|
+
// The words typed after the picker's name to find the same choice again; for a row asking for a text, the ones before it.
|
|
40
|
+
typed: string
|
|
41
|
+
// Set for a row asking for a text first, typed after its words: ctrl-n's new branch.
|
|
42
|
+
asks?: string
|
|
43
|
+
// The picker's arguments printing the row's preview, its `--show` as fzf calls it.
|
|
44
|
+
preview?: string[]
|
|
45
|
+
// ctrl-e leaves the picker's name and these words on the prompt rather than the command they run.
|
|
46
|
+
editsTyped?: boolean
|
|
47
|
+
actions?: ListedAction[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ListedAction {
|
|
51
|
+
label: string
|
|
52
|
+
// fzf's name for the key doing it there.
|
|
53
|
+
key: string
|
|
54
|
+
typed?: string
|
|
55
|
+
// The picker run with these arguments, then listed again: ctrl-r's fetch.
|
|
56
|
+
reload?: string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Run {
|
|
60
|
+
id: string
|
|
61
|
+
args: string[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A word of the command: fixed, an argument given by the user, or the name of one to prompt for; the last two are what a run is remembered with.
|
|
65
|
+
export type Arg = string | { given: string } | { ask: string }
|
|
66
|
+
|
|
67
|
+
const REMEMBERED = 20
|
|
68
|
+
|
|
69
|
+
// Every cell but the last padded to its column, one space short of the two between columns since a tab renders as one, see `--tabstop`.
|
|
70
|
+
// Widths are measured without the color codes.
|
|
71
|
+
export function table(rows: Row[]): string[] {
|
|
72
|
+
const widths = rows[0]?.cells.map((_, index) => Math.max(...rows.map(row => Bun.stringWidth(row.cells[index]!)))) ?? []
|
|
73
|
+
|
|
74
|
+
return rows.map(row => [
|
|
75
|
+
...row.hidden,
|
|
76
|
+
...row.cells.map((cell, index) => (index === row.cells.length - 1 ? cell.trimEnd() : `${cell}${' '.repeat(widths[index]! + 1 - Bun.stringWidth(cell))}`)),
|
|
77
|
+
].join('\t'))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function fzf(rows: Row[], options: FzfOptions): Picked | undefined {
|
|
81
|
+
const hidden = options.hidden ?? rows[0]?.hidden.length ?? 0
|
|
82
|
+
const proc = Bun.spawnSync([
|
|
83
|
+
'fzf',
|
|
84
|
+
'--prompt',
|
|
85
|
+
options.prompt,
|
|
86
|
+
...options.header === undefined ? [] : ['--header', options.header],
|
|
87
|
+
...(options.expect ?? []).length > 0 ? ['--expect', (options.expect ?? []).join(',')] : [],
|
|
88
|
+
'--ansi',
|
|
89
|
+
'--reverse',
|
|
90
|
+
'--height',
|
|
91
|
+
options.height ?? '100%',
|
|
92
|
+
'--delimiter',
|
|
93
|
+
'\t',
|
|
94
|
+
'--with-nth',
|
|
95
|
+
`${hidden + 1}..`,
|
|
96
|
+
'--nth',
|
|
97
|
+
`1..${options.searched}`,
|
|
98
|
+
'--tabstop',
|
|
99
|
+
'1',
|
|
100
|
+
// The shortest word holding the match wins a tie, `install` before `install-hooks`, where the default length would count the description too.
|
|
101
|
+
'--tiebreak',
|
|
102
|
+
'chunk,index',
|
|
103
|
+
...options.preview === undefined ? [] : ['--preview', options.preview, '--preview-window', 'down,40%'],
|
|
104
|
+
...(options.bind ?? []).flatMap(bind => ['--bind', bind]),
|
|
105
|
+
...options.query === true ? ['--print-query'] : [],
|
|
106
|
+
], {
|
|
107
|
+
stdin: new TextEncoder().encode(table(rows).join('\n')),
|
|
108
|
+
stdout: 'pipe',
|
|
109
|
+
stderr: 'inherit',
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
return picked(proc.stdout.toString(), hidden, options)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// What fzf printed: the query when asked for, the key when keys are expected, then the line picked.
|
|
116
|
+
export function picked(output: string, hidden: number, options: Pick<FzfOptions, 'expect' | 'query'>): Picked | undefined {
|
|
117
|
+
const lines = output.split('\n')
|
|
118
|
+
const query = options.query === true ? lines.shift()! : ''
|
|
119
|
+
const [key, line] = (options.expect ?? []).length > 0 ? [lines[0]!, lines[1]] : ['', lines[0]]
|
|
120
|
+
|
|
121
|
+
if (line !== undefined && line !== '')
|
|
122
|
+
return { key, hidden: line.split('\t').slice(0, hidden), query }
|
|
123
|
+
|
|
124
|
+
return options.query === true && key !== '' ? { key, hidden: [], query } : undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A few candidates, as tall as they need under the command with the preview below them.
|
|
128
|
+
export function heightFor(count: number): string {
|
|
129
|
+
return String(Math.max(15, Math.ceil((count + 4) / 0.6)))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function historyFile(name: string): string {
|
|
133
|
+
return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), name, 'history.json')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Keyed by folder, a broken or missing file reading as nothing run yet.
|
|
137
|
+
function history(name: string): Record<string, Run[]> {
|
|
138
|
+
try {
|
|
139
|
+
const file = historyFile(name)
|
|
140
|
+
|
|
141
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf-8')) as Record<string, Run[]> : {}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return {}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function runs(name: string): Run[] {
|
|
149
|
+
return history(name)[process.cwd()] ?? []
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function record(name: string, run: Run): void {
|
|
153
|
+
const all = history(name)
|
|
154
|
+
const file = historyFile(name)
|
|
155
|
+
|
|
156
|
+
all[process.cwd()] = [run, ...(all[process.cwd()] ?? []).filter(earlier => earlier.id !== run.id)].slice(0, REMEMBERED)
|
|
157
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
158
|
+
writeFileSync(file, JSON.stringify(all))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The ones run last come first, most recent at the top, the rest keeping their order.
|
|
162
|
+
export function byRecency<T>(items: T[], previous: Run[], idOf: (item: T) => string): T[] {
|
|
163
|
+
const rank = new Map(previous.map((run, index) => [run.id, index]))
|
|
164
|
+
const recent = items.filter(item => rank.has(idOf(item))).sort((a, b) => rank.get(idOf(a))! - rank.get(idOf(b))!)
|
|
165
|
+
|
|
166
|
+
return [...recent, ...items.filter(item => !rank.has(idOf(item)))]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function word(arg: Arg): string {
|
|
170
|
+
if (typeof arg === 'string')
|
|
171
|
+
return `=${arg}`
|
|
172
|
+
|
|
173
|
+
return 'given' in arg ? `+${arg.given}` : `?${arg.ask}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The lines `__pick_run` and tmux_pick read: `run`, or `edit` to leave the command on the prompt; the words typed after the picker's name to find the same choice again; the id the run is called back with once the prompts are answered, or nothing; then one line per word of the command, `=fixed`, `+given` or `?name` to prompt for.
|
|
177
|
+
export function emit(mode: 'run' | 'edit', typed: string, recorded: string, command: Arg[]): void {
|
|
178
|
+
const words = command.map(word)
|
|
179
|
+
|
|
180
|
+
console.log([mode, typed, recorded, ...words].join('\n'))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// What `--list` prints, term reading it back.
|
|
184
|
+
export function list(rows: Listed[]): void {
|
|
185
|
+
console.log(JSON.stringify(rows))
|
|
186
|
+
}
|
package/src/help.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Described, Example, Options } from './args'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import { paint } from './style'
|
|
4
|
+
|
|
5
|
+
// Optional parts of a usage line, `[options]` or `[tag]`, dimmed in the help.
|
|
6
|
+
const OPTIONAL = /\[[^\]]+\]/g
|
|
7
|
+
const USAGE_PREFIX = /^usage:\s*/i
|
|
8
|
+
const FULL_STOP = /\.$/
|
|
9
|
+
|
|
10
|
+
// `Usage: <usage>`, the usage given with or without its own `usage:`, optional parts dimmed.
|
|
11
|
+
export function usageLine(usage: string, colors = paint(process.stdout)): string {
|
|
12
|
+
return `${colors.bold('Usage:')} ${usage.replace(USAGE_PREFIX, '').replaceAll(OPTIONAL, part => colors.dim(part))}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function helpText(usage: string, options: Options, colors = paint(process.stdout), { examples = [], replaces, exits = [] }: Described = {}): string {
|
|
16
|
+
return [usageLine(usage, colors), ...replacesRows(replaces, colors), '', colors.bold('Options:'), ...optionRows(options, colors), ...exitRows(exits, colors), ...exampleRows(examples, colors)].join('\n')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// An exit code and what it means.
|
|
20
|
+
export type ExitRow = [code: number | string, meaning: string]
|
|
21
|
+
|
|
22
|
+
// An `Exits:` block, after a blank line, a line per code with the codes aligned; nothing without one.
|
|
23
|
+
export function exitRows(exits: ExitRow[], colors = paint(process.stdout)): string[] {
|
|
24
|
+
if (exits.length === 0)
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
const width = Math.max(...exits.map(([code]) => String(code).length))
|
|
28
|
+
|
|
29
|
+
return ['', colors.bold('Exits:'), ...exits.map(([code, meaning]) => ` ${colors.cyan(String(code).padEnd(width))} ${meaning}`)]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// An `Instead of:` line, after a blank line; nothing without one.
|
|
33
|
+
export function replacesRows(replaces: string | undefined, colors = paint(process.stdout)): string[] {
|
|
34
|
+
return replaces === undefined ? [] : ['', `${colors.bold('Instead of:')} ${replaces}`]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// What a bin's --help prints under KIT_HELP=json, for `cli list` to read without parsing the text.
|
|
38
|
+
export interface Description {
|
|
39
|
+
synopsis: string
|
|
40
|
+
summary: string
|
|
41
|
+
replaces?: string
|
|
42
|
+
examples: { line: string, note?: string }[]
|
|
43
|
+
exits?: { code: number | string, meaning: string }[]
|
|
44
|
+
// The flags of a command with no subcommands, for a test comparing them with what a README documents.
|
|
45
|
+
options?: { name: string, short?: string, description?: string }[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function wantsHelpJson(env: Record<string, string | undefined> = process.env): boolean {
|
|
49
|
+
return env.KIT_HELP === 'json'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The synopsis and the summary a usage's first line splits into at its first `: `, else the first line after the synopses as the summary, with the examples spelled out.
|
|
53
|
+
export function description(usage: string, { examples = [], replaces, exits }: Described = {}, options?: Options): Description {
|
|
54
|
+
const lines = usage.replace(USAGE_PREFIX, '').split('\n')
|
|
55
|
+
const first = lines[0]!
|
|
56
|
+
const colon = first.indexOf(': ')
|
|
57
|
+
const prose = lines[lines.indexOf('') + 1] ?? ''
|
|
58
|
+
const fallback = lines.includes('') ? `${prose.charAt(0).toLowerCase()}${prose.slice(1)}`.replace(FULL_STOP, '') : ''
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
synopsis: colon === -1 ? first : first.slice(0, colon),
|
|
62
|
+
summary: colon === -1 ? fallback : first.slice(colon + 2),
|
|
63
|
+
...replaces === undefined ? {} : { replaces },
|
|
64
|
+
examples: examples.map(example => typeof example === 'string' ? { line: example } : { line: example[0], note: example[1] }),
|
|
65
|
+
...exits === undefined ? {} : { exits: exits.map(([code, meaning]) => ({ code, meaning })) },
|
|
66
|
+
...options === undefined ? {} : { options: Object.entries(options).map(([name, { short, description }]) => ({ name, ...short === undefined ? {} : { short }, ...description === undefined ? {} : { description } })) },
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// An `Examples:` section, after a blank line, with a line per example; nothing without one.
|
|
71
|
+
export function exampleRows(examples: Example[], colors = paint(process.stdout)): string[] {
|
|
72
|
+
if (examples.length === 0)
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
return ['', colors.bold('Examples:'), ...examples.map(example => typeof example === 'string' ? ` ${example}` : ` ${example[0]} ${colors.dim(`# ${example[1]}`)}`)]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A line per option, `-n, --times <value> how many`, aligned on the visible text.
|
|
79
|
+
export function optionRows(options: Options, colors = paint(process.stdout)): string[] {
|
|
80
|
+
const rows = Object.entries(options).map(([name, option]) => {
|
|
81
|
+
const short = option.short === undefined ? ' ' : `${colors.cyan(`-${option.short}`)}, `
|
|
82
|
+
const named = option.choices === undefined ? '<value>' : `<${option.choices.join('|')}>`
|
|
83
|
+
const placeholder = option.optional === undefined ? named : `[${named}]`
|
|
84
|
+
const value = option.type === 'string' ? ` ${colors.dim(placeholder)}` : ''
|
|
85
|
+
|
|
86
|
+
return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ? placeholder.length + 1 : 0), description: option.description ?? '' }
|
|
87
|
+
})
|
|
88
|
+
const width = Math.max(0, ...rows.map(row => row.width))
|
|
89
|
+
|
|
90
|
+
return rows.map(row => row.description === '' ? ` ${row.flag}` : ` ${row.flag}${' '.repeat(width - row.width)} ${colors.dim(row.description)}`)
|
|
91
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
export * from './advisory'
|
|
1
2
|
export * from './args'
|
|
2
3
|
export * from './chart'
|
|
3
4
|
export * from './ci'
|
|
4
5
|
export * from './cli'
|
|
6
|
+
export * from './compose'
|
|
5
7
|
export * from './config'
|
|
6
8
|
export * from './format'
|
|
7
9
|
export * from './git'
|
|
@@ -10,6 +12,9 @@ export * from './logs'
|
|
|
10
12
|
export * from './process'
|
|
11
13
|
export * from './secrets'
|
|
12
14
|
export * from './sessions'
|
|
15
|
+
export * from './size'
|
|
13
16
|
export * from './style'
|
|
14
17
|
export * from './text'
|
|
15
18
|
export * from './tool'
|
|
19
|
+
export * from './usage'
|
|
20
|
+
export * from './user-config'
|
package/src/input.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { fstatSync } from 'node:fs'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import { badUsage, fail } from './args'
|
|
4
|
+
|
|
5
|
+
const WHOLE = /^\d+$/
|
|
6
|
+
|
|
7
|
+
// A name written `<run id>` is a positional and is printed as is, any other is an option printed `--name`.
|
|
8
|
+
export function wholeNumber(value: string | undefined, name: string, fallback: number, min = 1): number {
|
|
9
|
+
if (value === undefined)
|
|
10
|
+
return fallback
|
|
11
|
+
|
|
12
|
+
const number = Number(value)
|
|
13
|
+
|
|
14
|
+
if (!WHOLE.test(value) || !Number.isSafeInteger(number) || number < min)
|
|
15
|
+
badUsage(`${name.startsWith('<') ? name : `--${name}`} takes a whole number from ${min}, not ${value}`)
|
|
16
|
+
|
|
17
|
+
return number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DURATION = /^(\d+(?:\.\d+)?)(ms|[smhd])?$/
|
|
21
|
+
const UNIT_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const
|
|
22
|
+
|
|
23
|
+
// A duration such as `500ms`, `90s`, `5m`, `1h` or `7d` in milliseconds, a bare number counting in `unit`.
|
|
24
|
+
export function duration(value: string | undefined, name: string, fallback: number, unit: 's' | 'm' | 'h' = 's'): number {
|
|
25
|
+
if (value === undefined)
|
|
26
|
+
return fallback
|
|
27
|
+
|
|
28
|
+
const match = DURATION.exec(value.trim())
|
|
29
|
+
const ms = match === null ? 0 : Number(match[1]) * UNIT_MS[(match[2] ?? unit) as keyof typeof UNIT_MS]
|
|
30
|
+
|
|
31
|
+
if (ms < 1)
|
|
32
|
+
badUsage(`--${name} takes a duration such as 30s, 5m, 1h or 7d, not ${value}`)
|
|
33
|
+
|
|
34
|
+
return ms
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Whether stdin is a pipe or a file to read, rather than a terminal or /dev/null that would leave a command waiting or reading nothing.
|
|
38
|
+
export function isStdinPiped(): boolean {
|
|
39
|
+
const stat = fstatSync(0)
|
|
40
|
+
|
|
41
|
+
return stat.isFIFO() || stat.isFile()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface InputSettings {
|
|
45
|
+
// What to say when stdin is a terminal, as nothing is piped.
|
|
46
|
+
noStdin?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Text from a file, or from stdin when the path is `-`. A missing file or a stdin left on the terminal ends the command.
|
|
50
|
+
export async function readInput(path: string, { noStdin = 'nothing is piped on stdin' }: InputSettings = {}): Promise<string> {
|
|
51
|
+
if (path === '-') {
|
|
52
|
+
if (process.stdin.isTTY)
|
|
53
|
+
fail(noStdin)
|
|
54
|
+
|
|
55
|
+
return Bun.stdin.text()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const file = Bun.file(path)
|
|
59
|
+
|
|
60
|
+
if (!await file.exists())
|
|
61
|
+
fail(`no file ${path}`)
|
|
62
|
+
|
|
63
|
+
return file.text()
|
|
64
|
+
}
|
package/src/shell.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
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
2
|
export interface Piece { text: string, start: number, end: number, command: string, separator: string, expanded: string, heredocs: string[] }
|
|
3
|
-
export interface Invocation { program: string, args: string[], assignments: string[], isWrapped: boolean }
|
|
3
|
+
export interface Invocation { program: string, args: string[], assignments: string[], isWrapped: boolean, strings: string[] }
|
|
4
4
|
|
|
5
5
|
const SEPARATORS = new Set(['\n', ';', '|'])
|
|
6
6
|
const PLAIN_BREAKERS = new Set(['<', '>', '(', ')', '&', '|'])
|
|
@@ -30,18 +30,30 @@ const OCTAL = /^[0-7]{1,3}/
|
|
|
30
30
|
const KEYWORDS = new Set(['then', 'do', 'else', 'if', 'elif', 'while', 'until', '!', '{', '}'])
|
|
31
31
|
const MAX_DEPTH = 8
|
|
32
32
|
const MAX_BRACES = 256
|
|
33
|
+
|
|
33
34
|
// Programs reading a command string with `-c`.
|
|
34
|
-
const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ash', 'ksh'])
|
|
35
|
-
|
|
35
|
+
export const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ash', 'ksh', 'fish', 'csh', 'tcsh', 'nu', 'xonsh', 'elvish', 'pwsh', 'powershell'])
|
|
36
|
+
// Programs that take their command string past other operands, so every word is scanned for the option.
|
|
37
|
+
const SCANNED_PROGRAMS = new Set(['su', 'runuser', 'flock'])
|
|
38
|
+
const COMMAND_STRING_PROGRAMS = new Set([...SHELLS, ...SCANNED_PROGRAMS])
|
|
39
|
+
// PowerShell's `-Command`, which it accepts abbreviated to any prefix.
|
|
40
|
+
const POWERSHELL_COMMAND = /^-c(?:o(?:m(?:m(?:a(?:nd?)?)?)?)?)?$/i
|
|
41
|
+
const POWERSHELL = new Set(['pwsh', 'powershell'])
|
|
42
|
+
const FUNCTION_DEFINITION = /^[\w.:-]+\(\)\{?$/
|
|
43
|
+
const FUNCTION_NAME = /^[\w.:-]+$/
|
|
36
44
|
const SHELL_VALUE_OPTIONS = new Set(['-o', '-O', '--rcfile', '--init-file'])
|
|
45
|
+
const FISH_VALUE_OPTIONS = new Set(['-C', '--init-command'])
|
|
46
|
+
// A `let` builtin, whose `<<` is a shift.
|
|
47
|
+
const LET_PIECE = /^\s*let\s/
|
|
37
48
|
const PRINTERS = new Set(['echo', 'printf'])
|
|
38
49
|
const FIND_EXEC = new Set(['-exec', '-execdir', '-ok', '-okdir'])
|
|
39
50
|
|
|
40
51
|
// 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 }
|
|
52
|
+
interface WrapperSpec { short: string, long: readonly string[], operands: number, runsString: boolean }
|
|
42
53
|
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
// `runsString` for a wrapper that hands its command to a shell, so a quoted `'git stash'` is a command line.
|
|
55
|
+
function wrapper(short = '', long: readonly string[] = [], operands = 0, runsString = false): WrapperSpec {
|
|
56
|
+
return { short, long, operands, runsString }
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
const WRAPPERS = new Map<string, WrapperSpec>([
|
|
@@ -60,6 +72,11 @@ const WRAPPERS = new Map<string, WrapperSpec>([
|
|
|
60
72
|
['ionice', wrapper('cnpPu', ['--class', '--classdata', '--pid', '--pgid', '--uid'])],
|
|
61
73
|
['stdbuf', wrapper('ioe', ['--input', '--output', '--error'])],
|
|
62
74
|
['chroot', wrapper('', ['--userspec', '--groups'], 1)],
|
|
75
|
+
['watch', wrapper('n', ['--interval'], 0, true)],
|
|
76
|
+
['flock', wrapper('wE', ['--timeout', '--conflict-exit-code'], 1)],
|
|
77
|
+
['parallel', wrapper('jnNSaIPlLdTsC', ['--jobs', '--max-args', '--max-replace-args', '--sshlogin', '--arg-file', '--delimiter', '--colsep'], 0, true)],
|
|
78
|
+
['unbuffer', wrapper()],
|
|
79
|
+
['systemd-run', wrapper('puEMH', ['--property', '--unit', '--description', '--slice', '--setenv', '--machine', '--host', '--uid', '--gid', '--working-directory', '--nice', '--service-type'])],
|
|
63
80
|
['xargs', wrapper('IndsEaPL', ['--max-args', '--max-procs', '--max-chars', '--delimiter', '--arg-file', '--eof', '--max-lines', '--process-slot-var'])],
|
|
64
81
|
])
|
|
65
82
|
|
|
@@ -162,6 +179,15 @@ export function splitCommand(command: string): Piece[] {
|
|
|
162
179
|
continue
|
|
163
180
|
}
|
|
164
181
|
|
|
182
|
+
// The legacy arithmetic `$[...]` is read whole, like `$((...))`.
|
|
183
|
+
if (char === '$' && command[i + 1] === '[') {
|
|
184
|
+
const close = command.indexOf(']', i)
|
|
185
|
+
|
|
186
|
+
i = close === -1 ? command.length : close
|
|
187
|
+
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
|
|
165
191
|
// A backquoted body is read whole, as its own separators split nothing of the command around it.
|
|
166
192
|
if (char === '`') {
|
|
167
193
|
i = closingBackquote(command, i)
|
|
@@ -169,6 +195,13 @@ export function splitCommand(command: string): Piece[] {
|
|
|
169
195
|
continue
|
|
170
196
|
}
|
|
171
197
|
|
|
198
|
+
// Arithmetic is read whole: its `<<` shifts, it opens no heredoc.
|
|
199
|
+
if (command.startsWith('((', char === '$' ? i + 1 : i) && (char === '$' || startsComment(command, i))) {
|
|
200
|
+
i = closingParen(command, char === '$' ? i + 1 : i)
|
|
201
|
+
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
|
|
172
205
|
if (char === '#' && startsComment(command, i)) {
|
|
173
206
|
const newline = command.indexOf('\n', i)
|
|
174
207
|
const end = newline === -1 ? command.length : newline
|
|
@@ -179,7 +212,7 @@ export function splitCommand(command: string): Piece[] {
|
|
|
179
212
|
continue
|
|
180
213
|
}
|
|
181
214
|
|
|
182
|
-
const heredoc = char === '<' && command[i + 1] === '<' && command[i + 2] !== '<' ? HEREDOC.exec(command.slice(i)) : null
|
|
215
|
+
const heredoc = char === '<' && command[i + 1] === '<' && command[i + 2] !== '<' && !LET_PIECE.test(command.slice(start, i)) ? HEREDOC.exec(command.slice(i)) : null
|
|
183
216
|
|
|
184
217
|
if (heredoc) {
|
|
185
218
|
heredocs.push({ word: heredoc[2] ?? heredoc[3] ?? heredoc[4] ?? '', stripsTabs: heredoc[1] === '-', isQuoted: heredoc[4] === undefined })
|
|
@@ -545,13 +578,29 @@ function skipOptions(tokens: string[], spec: WrapperSpec): void {
|
|
|
545
578
|
export function parseCommand(text: string): Invocation {
|
|
546
579
|
const tokens = tokenize(text)
|
|
547
580
|
const assignments: string[] = []
|
|
581
|
+
const strings: string[] = []
|
|
548
582
|
let isWrapped = false
|
|
549
583
|
|
|
550
584
|
for (;;) {
|
|
551
585
|
const first = tokens[0] ?? ''
|
|
552
586
|
const spec = WRAPPERS.get(first.split('/').pop() ?? '')
|
|
553
587
|
|
|
554
|
-
if (
|
|
588
|
+
if (first === 'function' && tokens.length > 1) {
|
|
589
|
+
isWrapped = true
|
|
590
|
+
tokens.splice(0, 2)
|
|
591
|
+
|
|
592
|
+
if (tokens[0] === '()')
|
|
593
|
+
tokens.shift()
|
|
594
|
+
}
|
|
595
|
+
else if (FUNCTION_DEFINITION.test(first)) {
|
|
596
|
+
isWrapped = true
|
|
597
|
+
tokens.shift()
|
|
598
|
+
}
|
|
599
|
+
else if (tokens[1] === '()' && FUNCTION_NAME.test(first)) {
|
|
600
|
+
isWrapped = true
|
|
601
|
+
tokens.splice(0, 2)
|
|
602
|
+
}
|
|
603
|
+
else if (OPENERS.test(first)) {
|
|
555
604
|
isWrapped = true
|
|
556
605
|
const rest = first.replace(OPENERS, '')
|
|
557
606
|
|
|
@@ -580,6 +629,9 @@ export function parseCommand(text: string): Invocation {
|
|
|
580
629
|
isWrapped = true
|
|
581
630
|
tokens.shift()
|
|
582
631
|
skipOptions(tokens, spec)
|
|
632
|
+
|
|
633
|
+
if (spec.runsString && WHITESPACE.test(tokens[0] ?? ''))
|
|
634
|
+
strings.push(tokens[0] ?? '')
|
|
583
635
|
}
|
|
584
636
|
else {
|
|
585
637
|
break
|
|
@@ -596,7 +648,7 @@ export function parseCommand(text: string): Invocation {
|
|
|
596
648
|
|
|
597
649
|
const [program = '', ...args] = tokens
|
|
598
650
|
|
|
599
|
-
return { program: program.split('/').pop() ?? '', args, assignments, isWrapped }
|
|
651
|
+
return { program: program.split('/').pop() ?? '', args, assignments, isWrapped, strings }
|
|
600
652
|
}
|
|
601
653
|
|
|
602
654
|
// A word as a POSIX shell or zsh reads it back unchanged: left bare when it holds nothing the shell would act on (zsh expands a leading `=`), else single-quoted.
|
|
@@ -618,7 +670,8 @@ function commandStrings(tokens: readonly string[]): string[] {
|
|
|
618
670
|
if (!COMMAND_STRING_PROGRAMS.has(name))
|
|
619
671
|
continue
|
|
620
672
|
|
|
621
|
-
const
|
|
673
|
+
const isScanned = SCANNED_PROGRAMS.has(name)
|
|
674
|
+
const isPowershell = POWERSHELL.has(name)
|
|
622
675
|
let isValue = false
|
|
623
676
|
|
|
624
677
|
for (let j = i + 1; j < tokens.length; j++) {
|
|
@@ -630,25 +683,27 @@ function commandStrings(tokens: readonly string[]): string[] {
|
|
|
630
683
|
continue
|
|
631
684
|
}
|
|
632
685
|
|
|
633
|
-
if (SHELL_VALUE_OPTIONS.has(option)) {
|
|
686
|
+
if (SHELL_VALUE_OPTIONS.has(option) || (name === 'fish' && FISH_VALUE_OPTIONS.has(option))) {
|
|
634
687
|
isValue = true
|
|
635
688
|
|
|
636
689
|
continue
|
|
637
690
|
}
|
|
638
691
|
|
|
639
|
-
if (option.startsWith('--command=')) {
|
|
640
|
-
found.push(option.slice('
|
|
692
|
+
if (option.startsWith('--command=') || option.startsWith('--commands=')) {
|
|
693
|
+
found.push(option.slice(option.indexOf('=') + 1))
|
|
641
694
|
|
|
642
695
|
break
|
|
643
696
|
}
|
|
644
697
|
|
|
645
|
-
|
|
646
|
-
|
|
698
|
+
const isCommandOption = isPowershell ? POWERSHELL_COMMAND.test(option) : !option.startsWith('--') && option.startsWith('-') && option.slice(1).includes('c')
|
|
699
|
+
|
|
700
|
+
if (option === '--command' || option === '--commands' || isCommandOption) {
|
|
701
|
+
found.push(isPowershell ? tokens.slice(j + 1).join(' ') : (tokens[j + 1] ?? ''))
|
|
647
702
|
|
|
648
703
|
break
|
|
649
704
|
}
|
|
650
705
|
|
|
651
|
-
if (!option.startsWith('-') && !
|
|
706
|
+
if (!option.startsWith('-') && !isScanned)
|
|
652
707
|
break
|
|
653
708
|
}
|
|
654
709
|
}
|
|
@@ -695,8 +750,8 @@ function findExecs(args: readonly string[]): string[] {
|
|
|
695
750
|
// 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.
|
|
696
751
|
function nestedCommands(piece: Piece, previous: Piece | undefined): string[] {
|
|
697
752
|
const tokens = tokenize(piece.command)
|
|
698
|
-
const { program, args } = parseCommand(piece.command)
|
|
699
|
-
const found = commandStrings(tokens)
|
|
753
|
+
const { program, args, strings } = parseCommand(piece.command)
|
|
754
|
+
const found = [...commandStrings(tokens), ...strings]
|
|
700
755
|
|
|
701
756
|
if (program === 'eval')
|
|
702
757
|
found.push(args.join(' '))
|
package/src/size.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const SIZE = /^(\d+(?:\.\d+)?)\s*([kmgt]?)i?b?$/i
|
|
2
|
+
const POWERS: Record<string, number> = { '': 0, 'k': 1, 'm': 2, 'g': 3, 't': 4 }
|
|
3
|
+
|
|
4
|
+
// A size such as `500M`, `1.5G` or `800kb` in bytes, counting in powers of 1024; a bare number counts `bareUnit` bytes (1 for bytes, 1024 ** 2 for megabytes). Undefined when it is none.
|
|
5
|
+
export function parseSize(text: string, bareUnit = 1): number | undefined {
|
|
6
|
+
const match = SIZE.exec(text.trim())
|
|
7
|
+
|
|
8
|
+
if (match === null)
|
|
9
|
+
return undefined
|
|
10
|
+
|
|
11
|
+
const suffix = match[2]!.toLowerCase()
|
|
12
|
+
|
|
13
|
+
return Math.round(Number(match[1]) * (suffix === '' ? bareUnit : 1024 ** POWERS[suffix]!))
|
|
14
|
+
}
|
package/src/tool.ts
CHANGED
|
@@ -61,11 +61,14 @@ export async function runTool(label: string, argv: string[], { timeoutMs, cwd, o
|
|
|
61
61
|
if (ran.timedOut)
|
|
62
62
|
throw new Error(`${label}: no answer in ${formatDuration(timeoutMs ?? 0)}`)
|
|
63
63
|
|
|
64
|
-
const
|
|
64
|
+
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() }
|
|
65
65
|
const streams = { stdout: '', stderr: '' }
|
|
66
66
|
|
|
67
67
|
for (const { stream, data } of ran.chunks)
|
|
68
|
-
streams[stream] +=
|
|
68
|
+
streams[stream] += decoders[stream].decode(data, { stream: true })
|
|
69
|
+
|
|
70
|
+
streams.stdout += decoders.stdout.decode()
|
|
71
|
+
streams.stderr += decoders.stderr.decode()
|
|
69
72
|
|
|
70
73
|
return { code: ran.code, ...streams }
|
|
71
74
|
}
|