@kaynooo/cli-kit 0.0.0-stage → 0.7.3
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/LICENSE +21 -0
- package/README.md +59 -2
- package/package.json +36 -4
- package/src/args.ts +189 -0
- package/src/chart.ts +61 -0
- package/src/cli.ts +234 -0
- package/src/config.ts +59 -0
- package/src/format.ts +163 -0
- package/src/index.ts +8 -0
- package/src/process.ts +120 -0
- package/src/progress.ts +211 -0
- package/src/prompt.ts +331 -0
- package/src/shell.ts +373 -0
- package/src/style.ts +73 -0
- package/src/test.ts +42 -0
- package/src/text.ts +21 -0
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import type { Colors } from './style'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import { fail } from './args'
|
|
4
|
+
import { truncate } from './format'
|
|
5
|
+
import { paint } from './style'
|
|
6
|
+
|
|
7
|
+
// What the terminal sends for a key: a whole escape sequence, such as an arrow's or Home's, which no prompt but the arrows reads, or one character, a lone Escape included.
|
|
8
|
+
// eslint-disable-next-line no-control-regex -- escape sequences are what it splits
|
|
9
|
+
const KEY_SEQUENCES = /\x1B\[[0-9;]*[A-Z~]|\x1BO[A-Z]|[\s\S]/giu
|
|
10
|
+
// eslint-disable-next-line no-control-regex -- control characters are what it leaves out
|
|
11
|
+
const CONTROL = /^[\x00-\x1F\x7F]/
|
|
12
|
+
const WORDS = /\s+/
|
|
13
|
+
// Choices drawn at once at most, the rest scrolled to.
|
|
14
|
+
const MAX_ROWS = 10
|
|
15
|
+
|
|
16
|
+
type Key = 'up' | 'down' | 'enter' | 'cancel' | 'erase' | { text: string }
|
|
17
|
+
type Outcome = 'enter' | 'cancel'
|
|
18
|
+
type State = 'asking' | Outcome
|
|
19
|
+
|
|
20
|
+
const KEYS: Record<string, Key> = { '\x1B[A': 'up', '\x1BOA': 'up', '\x1B[B': 'down', '\x1BOB': 'down', '\r': 'enter', '\n': 'enter', '\x03': 'cancel', '\x1B': 'cancel', '\x7F': 'erase', '\b': 'erase' }
|
|
21
|
+
|
|
22
|
+
export interface Choice<T> {
|
|
23
|
+
value: T
|
|
24
|
+
label: string
|
|
25
|
+
hint: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The ends a prompt reads keys from and draws on, the process's own by default.
|
|
29
|
+
export interface Terminal {
|
|
30
|
+
stdin: Pick<NodeJS.ReadStream, 'setRawMode' | 'resume' | 'pause' | 'on' | 'off'> & { isTTY?: boolean }
|
|
31
|
+
stderr: Pick<NodeJS.WriteStream, 'write' | 'columns' | 'rows'> & { isTTY?: boolean }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PromptSettings {
|
|
35
|
+
// Shown under the error a prompt fails with off a terminal, such as `pass --yes`.
|
|
36
|
+
hint?: string
|
|
37
|
+
terminal?: Terminal
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The keys a chunk of terminal input holds, in order: the ones prompts read, and printable text.
|
|
41
|
+
function readKeys(input: string): Key[] {
|
|
42
|
+
return (input.match(KEY_SEQUENCES) ?? []).flatMap(sequence => KEYS[sequence] ?? (CONTROL.test(sequence) ? [] : [{ text: sequence }]))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SelectSettings extends PromptSettings {
|
|
46
|
+
// The index of the choice the cursor starts on.
|
|
47
|
+
initial?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// One choice among several, typing filtering them, the arrows moving and Enter picking; undefined once Escape or Ctrl+C cancels.
|
|
51
|
+
export async function select<T>(question: string, choices: Choice<T>[], { initial = 0, ...settings }: SelectSettings = {}): Promise<T | undefined> {
|
|
52
|
+
const list = new List(choices, initial)
|
|
53
|
+
const picked = await ask(question, settings, (colors, state, rows) => state === 'asking'
|
|
54
|
+
? [asking(colors, question, list.query, 'type to filter, enter picks'), ...list.rows(colors, rows, () => '')]
|
|
55
|
+
: [answer(colors, question, state, list.current()?.label ?? '')], (key) => {
|
|
56
|
+
if (key === 'enter')
|
|
57
|
+
return list.current() === undefined ? undefined : 'enter'
|
|
58
|
+
|
|
59
|
+
if (key === 'cancel')
|
|
60
|
+
return 'cancel'
|
|
61
|
+
|
|
62
|
+
list.press(key)
|
|
63
|
+
|
|
64
|
+
return undefined
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return picked ? list.current()?.value : undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ChecklistSettings<T> extends PromptSettings {
|
|
71
|
+
initial?: ReadonlySet<T>
|
|
72
|
+
// What checking or unchecking `value` leaves checked, which may change other choices along; flips `value` alone by default.
|
|
73
|
+
toggle?: (checked: ReadonlySet<T>, value: T) => ReadonlySet<T>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function flip<T>(checked: ReadonlySet<T>, value: T): ReadonlySet<T> {
|
|
77
|
+
const next = new Set(checked)
|
|
78
|
+
|
|
79
|
+
if (!next.delete(value))
|
|
80
|
+
next.add(value)
|
|
81
|
+
|
|
82
|
+
return next
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Several choices, typing filtering them, Space checking or unchecking the one under the cursor and Enter accepting what is checked; undefined once cancelled.
|
|
86
|
+
export async function checklist<T>(question: string, choices: Choice<T>[], { initial = new Set(), toggle = flip, ...settings }: ChecklistSettings<T> = {}): Promise<ReadonlySet<T> | undefined> {
|
|
87
|
+
const list = new List(choices, 0)
|
|
88
|
+
let checked = initial
|
|
89
|
+
const accepted = await ask(question, settings, (colors, state, rows) => state === 'asking'
|
|
90
|
+
? [asking(colors, question, list.query, 'type to filter, space toggles, enter accepts'), ...list.rows(colors, rows, choice => checked.has(choice.value) ? `${colors.cyan('◉')} ` : `${colors.dim('◯')} `)]
|
|
91
|
+
: [answer(colors, question, state, choices.filter(choice => checked.has(choice.value)).map(choice => choice.label).join(', ') || 'none')], (key) => {
|
|
92
|
+
if (key === 'enter' || key === 'cancel')
|
|
93
|
+
return key
|
|
94
|
+
|
|
95
|
+
const current = list.current()
|
|
96
|
+
|
|
97
|
+
if (typeof key === 'object' && key.text === ' ') {
|
|
98
|
+
if (current !== undefined)
|
|
99
|
+
checked = toggle(checked, current.value)
|
|
100
|
+
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
list.press(key)
|
|
105
|
+
|
|
106
|
+
return undefined
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
return accepted ? checked : undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ConfirmSettings extends PromptSettings {
|
|
113
|
+
// What Enter answers; without one, Enter waits for y or n.
|
|
114
|
+
default?: boolean
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A yes or no, y or n answering it and Enter taking the default; undefined once cancelled.
|
|
118
|
+
export async function confirm(question: string, { default: fallback, ...settings }: ConfirmSettings = {}): Promise<boolean | undefined> {
|
|
119
|
+
let answered = fallback
|
|
120
|
+
const choices = { true: 'Y/n', false: 'y/N', undefined: 'y/n' }[String(fallback) as 'true' | 'false' | 'undefined']
|
|
121
|
+
const accepted = await ask(question, settings, (colors, state) => [state === 'asking' ? asking(colors, question, '', choices) : answer(colors, question, state, answered ? 'yes' : 'no')], (key) => {
|
|
122
|
+
if (key === 'cancel')
|
|
123
|
+
return 'cancel'
|
|
124
|
+
|
|
125
|
+
if (key === 'enter')
|
|
126
|
+
return answered === undefined ? undefined : 'enter'
|
|
127
|
+
|
|
128
|
+
const letter = typeof key === 'object' ? key.text.toLowerCase() : ''
|
|
129
|
+
|
|
130
|
+
if (letter !== 'y' && letter !== 'n')
|
|
131
|
+
return undefined
|
|
132
|
+
|
|
133
|
+
answered = letter === 'y'
|
|
134
|
+
|
|
135
|
+
return 'enter'
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
return accepted ? answered : undefined
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface InputPromptSettings extends PromptSettings {
|
|
142
|
+
// What an empty answer stands for, shown dimmed until something is typed.
|
|
143
|
+
default?: string
|
|
144
|
+
// Draws each character typed as `*`, for a password or a token.
|
|
145
|
+
mask?: boolean
|
|
146
|
+
// What is wrong with the answer, shown under it until Enter is pressed on one it takes; undefined takes it.
|
|
147
|
+
validate?: (value: string) => string | undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A line of text, Enter taking it once `validate` does; undefined once cancelled.
|
|
151
|
+
export async function input(question: string, { default: fallback, mask = false, validate, ...settings }: InputPromptSettings = {}): Promise<string | undefined> {
|
|
152
|
+
let value = ''
|
|
153
|
+
let invalid: string | undefined
|
|
154
|
+
const taken = () => value === '' ? fallback ?? '' : value
|
|
155
|
+
const shown = () => mask ? '*'.repeat([...taken()].length) : taken()
|
|
156
|
+
const accepted = await ask(question, settings, (colors, state, { columns }) => {
|
|
157
|
+
if (state !== 'asking')
|
|
158
|
+
return [answer(colors, question, state, shown())]
|
|
159
|
+
|
|
160
|
+
// Its end alone where it would wrap: `? `, the question, a space, the caret, and the last column kept free.
|
|
161
|
+
const characters = [...shown()]
|
|
162
|
+
const room = Math.max(columns - question.length - 5, 1)
|
|
163
|
+
const tail = characters.length > room ? `…${characters.slice(1 - room).join('')}` : shown()
|
|
164
|
+
const typed = value === '' ? colors.dim(tail) : tail
|
|
165
|
+
|
|
166
|
+
return [`${colors.cyan('?')} ${question} ${typed}${colors.dim('█')}`, ...invalid === undefined ? [] : [` ${colors.red(invalid)}`]]
|
|
167
|
+
}, (key) => {
|
|
168
|
+
if (key === 'cancel')
|
|
169
|
+
return 'cancel'
|
|
170
|
+
|
|
171
|
+
invalid = undefined
|
|
172
|
+
|
|
173
|
+
if (key === 'enter') {
|
|
174
|
+
invalid = validate?.(taken())
|
|
175
|
+
|
|
176
|
+
return invalid === undefined ? 'enter' : undefined
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (key === 'erase')
|
|
180
|
+
value = [...value].slice(0, -1).join('')
|
|
181
|
+
else if (typeof key === 'object')
|
|
182
|
+
value += key.text
|
|
183
|
+
|
|
184
|
+
return undefined
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
return accepted ? taken() : undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The choices a filter leaves, the cursor on one of them and the window of rows drawn around it.
|
|
191
|
+
class List<T> {
|
|
192
|
+
query = ''
|
|
193
|
+
private matches: Choice<T>[]
|
|
194
|
+
private cursor: number
|
|
195
|
+
private top = 0
|
|
196
|
+
private readonly width: number
|
|
197
|
+
|
|
198
|
+
constructor(private readonly choices: Choice<T>[], initial: number) {
|
|
199
|
+
this.matches = choices
|
|
200
|
+
this.cursor = Math.min(Math.max(initial, 0), choices.length - 1)
|
|
201
|
+
this.width = Math.max(0, ...choices.map(choice => choice.label.length))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
current(): Choice<T> | undefined {
|
|
205
|
+
return this.matches[this.cursor]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
press(key: Key): void {
|
|
209
|
+
if (key === 'up' || key === 'down') {
|
|
210
|
+
if (this.matches.length > 0)
|
|
211
|
+
this.cursor = (this.cursor + (key === 'up' ? this.matches.length - 1 : 1)) % this.matches.length
|
|
212
|
+
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (key === 'erase')
|
|
217
|
+
this.filter([...this.query].slice(0, -1).join(''))
|
|
218
|
+
else if (typeof key === 'object')
|
|
219
|
+
this.filter(this.query + key.text)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Every word typed is in the label or the hint, case ignored; the cursor stays on its choice while it still matches, else goes to the first.
|
|
223
|
+
private filter(query: string): void {
|
|
224
|
+
const words = query.toLowerCase().split(WORDS).filter(word => word !== '')
|
|
225
|
+
const current = this.current()
|
|
226
|
+
|
|
227
|
+
this.query = query
|
|
228
|
+
this.matches = this.choices.filter(({ label, hint }) => words.every(word => `${label} ${hint}`.toLowerCase().includes(word)))
|
|
229
|
+
this.cursor = current === undefined ? 0 : Math.max(this.matches.indexOf(current), 0)
|
|
230
|
+
this.top = 0
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// A line per choice in the window, each cut to the terminal's width so none wraps, which would throw off the redraw, and a dimmed count of those scrolled past.
|
|
234
|
+
rows(colors: Colors, { columns, rows }: { columns: number, rows: number }, marker: (choice: Choice<T>) => string): string[] {
|
|
235
|
+
if (this.matches.length === 0)
|
|
236
|
+
return [colors.dim(' no match')]
|
|
237
|
+
|
|
238
|
+
const height = Math.max(1, Math.min(MAX_ROWS, rows - 3, this.matches.length))
|
|
239
|
+
|
|
240
|
+
this.top = Math.min(Math.max(this.top, this.cursor - height + 1), this.cursor)
|
|
241
|
+
|
|
242
|
+
const shown = this.matches.slice(this.top, this.top + height).map((choice, index) => {
|
|
243
|
+
const here = this.top + index === this.cursor
|
|
244
|
+
const mark = marker(choice)
|
|
245
|
+
const head = `${here ? '❯' : ' '} ${mark}`
|
|
246
|
+
const label = choice.label.padEnd(this.width)
|
|
247
|
+
// What is left of the line for the hint: the pointer and a space, a mark of two visible characters (`◉ `) when there is one, the label, two spaces, and the last column kept free.
|
|
248
|
+
const room = columns - 5 - (mark === '' ? 0 : 2) - this.width
|
|
249
|
+
|
|
250
|
+
return `${here ? colors.cyan(head) : head}${here ? colors.cyan(label) : label} ${colors.dim(truncate(choice.hint, Math.max(room, 0)))}`
|
|
251
|
+
})
|
|
252
|
+
const below = this.matches.length - this.top - height
|
|
253
|
+
|
|
254
|
+
return [...this.top > 0 ? [colors.dim(` … ${this.top} more`)] : [], ...shown, ...below > 0 ? [colors.dim(` … ${below} more`)] : []]
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The question line while asking: what was typed after it, else a dimmed note of the keys.
|
|
259
|
+
function asking(colors: Colors, question: string, typed: string, keys: string): string {
|
|
260
|
+
return `${colors.cyan('?')} ${question} ${typed === '' ? colors.dim(`(${keys})`) : typed}`
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// The line a prompt leaves once answered: what was picked, or that it was cancelled.
|
|
264
|
+
function answer(colors: Colors, question: string, state: Outcome, picked: string): string {
|
|
265
|
+
return state === 'enter' ? `${colors.green('✓')} ${question} ${colors.cyan(picked)}` : `${colors.red('✗')} ${question} ${colors.dim('cancelled')}`
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
type Render = (colors: Colors, state: State, size: { columns: number, rows: number }) => string[]
|
|
269
|
+
|
|
270
|
+
// Draws a prompt on stderr and redraws it after each key stdin sends raw, until `press` answers `enter` or `cancel`; true once entered. Off a terminal, nobody could answer, so it fails at once.
|
|
271
|
+
async function ask(question: string, { hint, terminal = process }: PromptSettings, render: Render, press: (key: Key) => Outcome | undefined): Promise<boolean> {
|
|
272
|
+
const { stdin, stderr } = terminal
|
|
273
|
+
|
|
274
|
+
if (stdin.isTTY !== true || stderr.isTTY !== true)
|
|
275
|
+
fail(`cannot ask "${question}" off a terminal`, 2, hint)
|
|
276
|
+
|
|
277
|
+
const colors = paint(stderr)
|
|
278
|
+
let drawn = 0
|
|
279
|
+
let outcome: Outcome | undefined
|
|
280
|
+
const draw = (state: State) => {
|
|
281
|
+
// A terminal that does not say its size, as a pseudo-terminal may, answers 0.
|
|
282
|
+
const frame = render(colors, state, { columns: stderr.columns || 80, rows: stderr.rows || 24 })
|
|
283
|
+
|
|
284
|
+
stderr.write(`${drawn > 1 ? `\x1B[${drawn - 1}A` : ''}\r\x1B[0J${frame.join('\n')}`)
|
|
285
|
+
drawn = frame.length
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
stdin.setRawMode(true)
|
|
290
|
+
stdin.resume()
|
|
291
|
+
stderr.write('\x1B[?25l')
|
|
292
|
+
draw('asking')
|
|
293
|
+
|
|
294
|
+
return await new Promise<boolean>((resolve) => {
|
|
295
|
+
function onData(data: Buffer) {
|
|
296
|
+
for (const key of readKeys(data.toString())) {
|
|
297
|
+
const pressed = press(key)
|
|
298
|
+
|
|
299
|
+
if (pressed !== undefined) {
|
|
300
|
+
settle(pressed)
|
|
301
|
+
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
draw('asking')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// A stdin closing before an answer, as a terminal hung up does, cancels instead of leaving the prompt waiting forever.
|
|
310
|
+
function onEnd() {
|
|
311
|
+
settle('cancel')
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function settle(settled: Outcome) {
|
|
315
|
+
outcome = settled
|
|
316
|
+
stdin.off('data', onData)
|
|
317
|
+
stdin.off('end', onEnd)
|
|
318
|
+
resolve(settled === 'enter')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
stdin.on('data', onData)
|
|
322
|
+
stdin.on('end', onEnd)
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
draw(outcome ?? 'cancel')
|
|
327
|
+
stderr.write('\n\x1B[?25h')
|
|
328
|
+
stdin.setRawMode(false)
|
|
329
|
+
stdin.pause()
|
|
330
|
+
}
|
|
331
|
+
}
|
package/src/shell.ts
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
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 }
|
|
3
|
+
export interface Invocation { program: string, args: string[], assignments: string[], isWrapped: boolean }
|
|
4
|
+
|
|
5
|
+
const SEPARATORS = new Set(['\n', ';', '|'])
|
|
6
|
+
const PLAIN_BREAKERS = new Set(['<', '>', '(', ')', '&', '|'])
|
|
7
|
+
const WHITESPACE = /\s/
|
|
8
|
+
const ASSIGNMENT = /^\w+=/
|
|
9
|
+
const TRAILING_SLASHES = /\/+$/
|
|
10
|
+
const LEADING_TILDE = /^~(?=\/|$)/
|
|
11
|
+
const LEADING_TABS = /^\t+/
|
|
12
|
+
const OPENERS = /^[({]+/
|
|
13
|
+
const CLOSERS = /[)}]+$/
|
|
14
|
+
const HEREDOC = /^<<(-?)[ \t]*(?:'([^']*)'|"([^"]*)"|([^\s;&|<>()-][^\s;&|<>()]*))/
|
|
15
|
+
// Programs that run the command following them, which is the one that counts.
|
|
16
|
+
const WRAPPERS = new Set(['command', 'env', 'sudo', 'exec', 'nohup', 'time'])
|
|
17
|
+
|
|
18
|
+
// A lone `&` sends the command before it to the background, unless it belongs to a redirection such as `2>&1` or `&>`.
|
|
19
|
+
function separatorWidth(command: string, i: number): number {
|
|
20
|
+
const pair = command.slice(i, i + 2)
|
|
21
|
+
|
|
22
|
+
if (pair === '&&' || pair === '||')
|
|
23
|
+
return 2
|
|
24
|
+
|
|
25
|
+
const char = command[i] ?? ''
|
|
26
|
+
|
|
27
|
+
if (char === '&')
|
|
28
|
+
return command[i - 1] === '>' || command[i - 1] === '<' || command[i + 1] === '>' ? 0 : 1
|
|
29
|
+
|
|
30
|
+
return SEPARATORS.has(char) ? 1 : 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Where the heredocs opened on a line end: from the newline closing that line to the end of the last delimiter line.
|
|
34
|
+
function heredocEnd(command: string, newline: number, words: readonly { word: string, stripsTabs: boolean }[]): number {
|
|
35
|
+
let lineStart = newline + 1
|
|
36
|
+
|
|
37
|
+
for (const { word, stripsTabs } of words) {
|
|
38
|
+
while (lineStart <= command.length) {
|
|
39
|
+
const next = command.indexOf('\n', lineStart)
|
|
40
|
+
const lineEnd = next === -1 ? command.length : next
|
|
41
|
+
const line = command.slice(lineStart, lineEnd)
|
|
42
|
+
|
|
43
|
+
lineStart = lineEnd + 1
|
|
44
|
+
|
|
45
|
+
if ((stripsTabs ? line.replace(LEADING_TABS, '') : line) === word)
|
|
46
|
+
break
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return Math.min(lineStart - 1, command.length)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function withoutRanges(command: string, start: number, end: number, ranges: readonly [number, number][]): string {
|
|
54
|
+
let text = ''
|
|
55
|
+
let at = start
|
|
56
|
+
|
|
57
|
+
for (const [from, to] of ranges) {
|
|
58
|
+
if (to <= start || from >= end)
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
text += command.slice(at, Math.max(at, from))
|
|
62
|
+
at = Math.max(at, to)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return text + command.slice(at, end)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 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.
|
|
69
|
+
export function splitCommand(command: string): Piece[] {
|
|
70
|
+
const bounds: [number, number, string][] = []
|
|
71
|
+
const bodies: [number, number, boolean][] = []
|
|
72
|
+
let heredocs: { word: string, stripsTabs: boolean, isQuoted: boolean }[] = []
|
|
73
|
+
let start = 0
|
|
74
|
+
let quote: string | null = null
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < command.length; i++) {
|
|
77
|
+
const char = command[i] ?? ''
|
|
78
|
+
|
|
79
|
+
if (char === '\\' && quote !== '\'') {
|
|
80
|
+
i++
|
|
81
|
+
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (quote !== null) {
|
|
86
|
+
if (char === quote)
|
|
87
|
+
quote = null
|
|
88
|
+
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (char === '\'' || char === '"') {
|
|
93
|
+
quote = char
|
|
94
|
+
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const heredoc = char === '<' && command[i + 1] === '<' && command[i + 2] !== '<' ? HEREDOC.exec(command.slice(i)) : null
|
|
99
|
+
|
|
100
|
+
if (heredoc) {
|
|
101
|
+
heredocs.push({ word: heredoc[2] ?? heredoc[3] ?? heredoc[4] ?? '', stripsTabs: heredoc[1] === '-', isQuoted: heredoc[4] === undefined })
|
|
102
|
+
i += heredoc[0].length - 1
|
|
103
|
+
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (char === '\n' && heredocs.length > 0) {
|
|
108
|
+
const end = heredocEnd(command, i, heredocs)
|
|
109
|
+
|
|
110
|
+
bodies.push([i, end, heredocs.some(each => !each.isQuoted)])
|
|
111
|
+
heredocs = []
|
|
112
|
+
i = end - 1
|
|
113
|
+
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const width = separatorWidth(command, i)
|
|
118
|
+
|
|
119
|
+
if (width === 0)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
bounds.push([start, i, command.slice(i, i + width)])
|
|
123
|
+
i += width - 1
|
|
124
|
+
start = i + 1
|
|
125
|
+
}
|
|
126
|
+
bounds.push([start, command.length, ''])
|
|
127
|
+
|
|
128
|
+
const ranges = bodies.map(([from, to]): [number, number] => [from, to])
|
|
129
|
+
|
|
130
|
+
return bounds.map(([from, to, separator]) => ({
|
|
131
|
+
text: command.slice(from, to),
|
|
132
|
+
start: from,
|
|
133
|
+
end: to,
|
|
134
|
+
command: withoutRanges(command, from, to, ranges),
|
|
135
|
+
separator,
|
|
136
|
+
expanded: bodies.filter(([body, , expands]) => expands && body >= from && body < to).map(([body, bodyEnd]) => command.slice(body, bodyEnd)).join(''),
|
|
137
|
+
}))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// True when the text holds no redirect, substitution, subshell or group outside single quotes, and closes every quote it opens.
|
|
141
|
+
export function isPlain(text: string): boolean {
|
|
142
|
+
let quote: string | null = null
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < text.length; i++) {
|
|
145
|
+
const char = text[i] ?? ''
|
|
146
|
+
const isSubstitution = char === '`' || (char === '$' && text[i + 1] === '(')
|
|
147
|
+
|
|
148
|
+
if (char === '\\' && quote !== '\'')
|
|
149
|
+
i++
|
|
150
|
+
else if (quote !== null && char === quote)
|
|
151
|
+
quote = null
|
|
152
|
+
else if (quote === null && (char === '"' || char === '\''))
|
|
153
|
+
quote = char
|
|
154
|
+
else if (quote !== '\'' && (isSubstitution || (quote === null && PLAIN_BREAKERS.has(char))))
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return quote === null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The index of the `)` closing the `(` at `open`, past nested parentheses and quotes; the text's end when it never closes.
|
|
162
|
+
function closingParen(text: string, open: number): number {
|
|
163
|
+
let depth = 0
|
|
164
|
+
let quote: string | null = null
|
|
165
|
+
|
|
166
|
+
for (let i = open; i < text.length; i++) {
|
|
167
|
+
const char = text[i] ?? ''
|
|
168
|
+
|
|
169
|
+
if (char === '\\' && quote !== '\'') {
|
|
170
|
+
i++
|
|
171
|
+
}
|
|
172
|
+
else if (quote !== null) {
|
|
173
|
+
if (char === quote)
|
|
174
|
+
quote = null
|
|
175
|
+
}
|
|
176
|
+
else if (char === '\'' || char === '"') {
|
|
177
|
+
quote = char
|
|
178
|
+
}
|
|
179
|
+
else if (char === '(') {
|
|
180
|
+
depth++
|
|
181
|
+
}
|
|
182
|
+
else if (char === ')' && --depth === 0) {
|
|
183
|
+
return i
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return text.length
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The index of the backquote closing the one at `open`, past escaped characters; the text's end when it never closes.
|
|
191
|
+
function closingBackquote(text: string, open: number): number {
|
|
192
|
+
for (let i = open + 1; i < text.length; i++) {
|
|
193
|
+
if (text[i] === '\\')
|
|
194
|
+
i++
|
|
195
|
+
else if (text[i] === '`')
|
|
196
|
+
return i
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return text.length
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The bodies of the outermost `$(...)` and backquotes; quotes only count where `isQuoting`, as a heredoc body holds them as plain text.
|
|
203
|
+
function bodies(text: string, isQuoting: boolean): string[] {
|
|
204
|
+
const found: string[] = []
|
|
205
|
+
let quote: string | null = null
|
|
206
|
+
|
|
207
|
+
for (let i = 0; i < text.length; i++) {
|
|
208
|
+
const char = text[i] ?? ''
|
|
209
|
+
|
|
210
|
+
if (quote === '\'') {
|
|
211
|
+
if (char === '\'')
|
|
212
|
+
quote = null
|
|
213
|
+
}
|
|
214
|
+
else if (char === '\\') {
|
|
215
|
+
i++
|
|
216
|
+
}
|
|
217
|
+
else if (char === '$' && text[i + 1] === '(') {
|
|
218
|
+
const end = closingParen(text, i + 1)
|
|
219
|
+
|
|
220
|
+
found.push(text.slice(i + 2, end))
|
|
221
|
+
i = end
|
|
222
|
+
}
|
|
223
|
+
else if (char === '`') {
|
|
224
|
+
const end = closingBackquote(text, i)
|
|
225
|
+
|
|
226
|
+
found.push(text.slice(i + 1, end))
|
|
227
|
+
i = end
|
|
228
|
+
}
|
|
229
|
+
else if (isQuoting && char === '"') {
|
|
230
|
+
quote = quote === null ? char : null
|
|
231
|
+
}
|
|
232
|
+
else if (isQuoting && char === '\'' && quote === null) {
|
|
233
|
+
quote = char
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return found
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 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.
|
|
241
|
+
export function substitutions(command: string): string[] {
|
|
242
|
+
return splitCommand(command).flatMap(piece => [...bodies(piece.command, true), ...bodies(piece.expanded, false)])
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 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.
|
|
246
|
+
export interface Word { text: string, isProse: boolean }
|
|
247
|
+
|
|
248
|
+
// The words of a piece, quotes and line continuations removed.
|
|
249
|
+
export function words(text: string): Word[] {
|
|
250
|
+
const found: Word[] = []
|
|
251
|
+
let current = ''
|
|
252
|
+
let isStarted = false
|
|
253
|
+
let isProse = false
|
|
254
|
+
let quote: string | null = null
|
|
255
|
+
|
|
256
|
+
for (let i = 0; i < text.length; i++) {
|
|
257
|
+
const char = text[i] ?? ''
|
|
258
|
+
const isContinuation = char === '\\' && quote !== '\'' && text[i + 1] === '\n'
|
|
259
|
+
|
|
260
|
+
if (isContinuation)
|
|
261
|
+
i++
|
|
262
|
+
|
|
263
|
+
if (quote === null && (isContinuation || WHITESPACE.test(char))) {
|
|
264
|
+
if (isStarted)
|
|
265
|
+
found.push({ text: current, isProse })
|
|
266
|
+
|
|
267
|
+
current = ''
|
|
268
|
+
isStarted = false
|
|
269
|
+
isProse = false
|
|
270
|
+
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (isContinuation)
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
isStarted = true
|
|
278
|
+
|
|
279
|
+
if (char === '\\' && quote !== '\'')
|
|
280
|
+
current += text[++i] ?? ''
|
|
281
|
+
else if (quote !== null && char === quote)
|
|
282
|
+
quote = null
|
|
283
|
+
else if (quote === null && (char === '"' || char === '\''))
|
|
284
|
+
quote = char
|
|
285
|
+
else
|
|
286
|
+
current += char
|
|
287
|
+
|
|
288
|
+
isProse ||= quote !== null && WHITESPACE.test(char)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (isStarted)
|
|
292
|
+
found.push({ text: current, isProse })
|
|
293
|
+
|
|
294
|
+
return found
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The words of a piece as the shell would pass them, quotes and line continuations removed.
|
|
298
|
+
export function tokenize(text: string): string[] {
|
|
299
|
+
return words(text).map(word => word.text)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The program a piece runs, by its base name, past any subshell opener, variable assignment or wrapper such as `env` or `sudo`.
|
|
303
|
+
export function parseCommand(text: string): Invocation {
|
|
304
|
+
const tokens = tokenize(text)
|
|
305
|
+
const assignments: string[] = []
|
|
306
|
+
let isWrapped = false
|
|
307
|
+
|
|
308
|
+
for (;;) {
|
|
309
|
+
const first = tokens[0] ?? ''
|
|
310
|
+
|
|
311
|
+
if (OPENERS.test(first)) {
|
|
312
|
+
isWrapped = true
|
|
313
|
+
const rest = first.replace(OPENERS, '')
|
|
314
|
+
|
|
315
|
+
if (rest === '')
|
|
316
|
+
tokens.shift()
|
|
317
|
+
else tokens[0] = rest
|
|
318
|
+
}
|
|
319
|
+
else if (ASSIGNMENT.test(first)) {
|
|
320
|
+
assignments.push(tokens.shift() ?? '')
|
|
321
|
+
}
|
|
322
|
+
else if (WRAPPERS.has(first)) {
|
|
323
|
+
isWrapped = true
|
|
324
|
+
tokens.shift()
|
|
325
|
+
|
|
326
|
+
while (tokens[0]?.startsWith('-')) tokens.shift()
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
break
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// the closing of a subshell or a group, `(git stash)` or `{ cd x && npm i; }`, ends the last word
|
|
333
|
+
const last = (tokens.at(-1) ?? '').replace(CLOSERS, '')
|
|
334
|
+
|
|
335
|
+
if (tokens.length > 0)
|
|
336
|
+
tokens.splice(-1, 1, ...(last === '' ? [] : [last]))
|
|
337
|
+
|
|
338
|
+
const [program = '', ...args] = tokens
|
|
339
|
+
|
|
340
|
+
return { program: program.split('/').pop() ?? '', args, assignments, isWrapped }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Swaps each piece's text for its replacement, keeping the separators between them.
|
|
344
|
+
export function replacePieces(command: string, replacements: ReadonlyMap<Piece, string>): string {
|
|
345
|
+
let result = command
|
|
346
|
+
|
|
347
|
+
for (const [piece, text] of [...replacements].sort(([a], [b]) => b.start - a.start)) {
|
|
348
|
+
result = result.slice(0, piece.start) + text + result.slice(piece.end)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return result
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function parentDir(path: string): string {
|
|
355
|
+
const cut = path.replace(TRAILING_SLASHES, '').lastIndexOf('/')
|
|
356
|
+
|
|
357
|
+
return cut <= 0 ? '/' : path.slice(0, cut)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Resolves a shell path argument against a directory, `~` included, `.` and `..` folded.
|
|
361
|
+
export function resolvePath(base: string, arg: string, home: string): string {
|
|
362
|
+
const expanded = arg.replace(LEADING_TILDE, home)
|
|
363
|
+
const parts: string[] = []
|
|
364
|
+
|
|
365
|
+
for (const part of (expanded.startsWith('/') ? expanded : `${base}/${expanded}`).split('/')) {
|
|
366
|
+
if (part === '..')
|
|
367
|
+
parts.pop()
|
|
368
|
+
else if (part !== '' && part !== '.')
|
|
369
|
+
parts.push(part)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return `/${parts.join('/')}`
|
|
373
|
+
}
|