-
Notifications
You must be signed in to change notification settings - Fork 0
/
sh.ts
242 lines (193 loc) · 8.61 KB
/
sh.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
import { colors, pathUtils, streamUtils } from './deps.ts'
import { sureGetEnvVar } from './env.ts'
import { exists } from './fs.ts'
import { readStreamToFn } from './stream.ts'
const getCommandArgs = async (command: string) => {
// Always use PowerShell on windows
if (Deno.build.os === 'windows') return ['PowerShell.exe', '-Command', command]
// The default shell path is usually in the SHELL env variable
const shellFile = Deno.env.get('SHELL') || '/bin/bash'
if (!(await exists(shellFile))) throw new Error(`Cannot detect default shell`)
return [shellFile, '-c', command]
}
export interface ExecOptions {
/** Called right after the child process is created. A returned promise will be awaited */
onSetup?(params: ExecEventParams): unknown | Promise<unknown>
/** A shortcut to setting the NO_COLOR env var */
suppressColor?: boolean
/**
* Normally, only the $PATH env var is sent to the child process. If `true` the $PATH env var will not be sent to the child process.
*
* NOTE: This disables the automatic discovery of executables. You can use the `getExecFromPath` function to get the path to an executable
* before running the child process. */
suppressPath?: boolean
/** The directory that the process should run in */
cwd?: string
/** Any env variables that should be specified for the child process */
env?: Record<string, string>
/** A signal to abort this process when necessary */
signal?: AbortSignal
/**
* If `true` stout and stdin will be inherited from the component. Defaults to `true` for `sh` and `exec`, but false for `shCapture`,
* `shCaptureIncremental`, `shIgnore`, `execCapture`, `execIgnore`, and `execCaptureIncremental` */
inheritStdio?: boolean
/** If specified, content will be written to the process stdin before it is closed. If unspecified, stdin will be inherited */
input?: string
}
export interface ExecCaptureResult {
errorLines: string[]
logLines: string[]
}
export interface ExecEventParams {
process: Deno.ChildProcess
}
export interface ExecCaptureIncrementalOptions extends ExecOptions {
onLogLine?(line: string, params: ExecEventParams): unknown | Promise<unknown>
onErrorLine?(line: string, params: ExecEventParams): unknown | Promise<unknown>
}
/** Executes `command` in default shell, printing the command's output. Throws if command exits with a non-zero status */
export async function sh(command: string, options: ExecOptions = {}): Promise<void> {
return exec(await getCommandArgs(command), options)
}
/** Executes `command` in default shell. Throws if command exits with a non-zero status. */
export async function shIgnore(command: string, options: ExecOptions = {}): Promise<void> {
return execIgnore(await getCommandArgs(command), options)
}
/**
* Executes `command` in default shell. Returns `errorLines` and `logLines` containing all the lines written
* to stdout and stderr, respectively. Throws if command exits with a non-zero status. */
export async function shCapture(command: string, options: ExecOptions = {}): Promise<ExecCaptureResult> {
return execCapture(await getCommandArgs(command), options)
}
/**
* Executes `command` in default shell. Incrementally calls `options.onLogLine` and `options.onErrorLine`
* for each new line written to stdout an stderr, respectively. Throws if command exits with a
* non-zero status. */
export async function shCaptureIncremental(command: string, options: ExecCaptureIncrementalOptions = {}): Promise<void> {
return execCaptureIncremental(await getCommandArgs(command), options)
}
/**
* Executes `segments` as a child process, printing the child's output. Throws if the child exits with a non-zero status
*
* @param segments The segments to execute. The first should be the file, the rest will be passed as arguments */
export async function exec(segments: string[], options: ExecOptions = {}): Promise<void> {
await execCaptureIncremental(segments, {
inheritStdio: true,
...options,
onErrorLine(line) {
console.error(line)
},
onLogLine(line) {
console.log(line)
},
})
}
/**
* Executes `segments` as a child process. Throws if the child exits with a non-zero status.
*
* @param segments The segments to execute. The first should be the file, the rest will be passed as arguments */
export async function execIgnore(segments: string[], options: ExecOptions = {}): Promise<void> {
await execCaptureIncremental(segments, options)
}
/**
* Executes `segments` as a child process. Returns `errorLines` and `logLines` containing all the lines written
* to the child's stdout and stderr, respectively. Throws if the child exits with a non-zero status.
*
* @param segments The segments to execute. The first should be the file, the rest will be passed as arguments */
export async function execCapture(segments: string[], options: ExecOptions = {}): Promise<ExecCaptureResult> {
const errorLines: string[] = []
const logLines: string[] = []
await execCaptureIncremental(segments, {
...options,
onErrorLine(line) {
errorLines.push(line)
},
onLogLine(line) {
logLines.push(line)
},
})
return { errorLines, logLines }
}
/**
* Executes `segments` as a child process. Incrementally calls `options.onLogLine` and `options.onErrorLine`
* for each new line written to the child's stdout an stderr, respectively. Throws if the child exits with a
* non-zero status.
*
* @param segments The segments to execute. The first should be the file, the rest will be passed as arguments */
export async function execCaptureIncremental(segments: string[], options: ExecCaptureIncrementalOptions = {}): Promise<void> {
if (!segments.length) throw new Error('segments must not be empty')
const userSuppliedEnv = options.env || {}
const env: Record<string, string> = {}
if (options.suppressColor) env.NO_COLOR = '1'
else env.TERM = 'xterm-256color'
if (!options.suppressPath) env.PATH = sureGetEnvVar('PATH')
for (const key in userSuppliedEnv) env[key] = userSuppliedEnv[key]
const process = new Deno.Command(segments[0], {
args: segments.slice(1),
stderr: options.inheritStdio ? 'inherit' : 'piped',
stdout: options.inheritStdio ? 'inherit' : 'piped',
stdin: options.input ? 'piped' : 'inherit',
cwd: options.cwd,
env,
clearEnv: true,
signal: options.signal,
}).spawn()
const errorLines: string[] = []
const eventParams: ExecEventParams = { process }
if (options.onSetup) await options.onSetup(eventParams)
const logLinesStream = options.inheritStdio
? null
: process.stdout.pipeThrough(new TextDecoderStream()).pipeThrough(new streamUtils.TextLineStream())
const errorLinesStream = options.inheritStdio
? null
: process.stderr.pipeThrough(new TextDecoderStream()).pipeThrough(new streamUtils.TextLineStream())
if (options.input) {
const writer = process.stdin.getWriter()
await writer.write(new TextEncoder().encode(options.input))
await writer.close()
}
const outputPromise = logLinesStream
? readStreamToFn(logLinesStream, async (line) => {
if (options.onLogLine) await options.onLogLine(line, eventParams)
})
: Promise.resolve()
const outputLogPromise = errorLinesStream
? readStreamToFn(errorLinesStream, async (line) => {
errorLines.push(line)
if (options.onErrorLine) await options.onErrorLine(line, eventParams)
})
: Promise.resolve()
const [status] = await Promise.all([process.status, outputPromise, outputLogPromise])
if (status.success) return
const spacer = ' '
const errorHeap = errorLines.map((line) => `${spacer}${colors.gray('>')} ${line}`).join('\n')
const paddedHeap = errorLines.length
? `\n\n ${colors.gray(colors.bold('Error Output:'))}\n\n${errorHeap}\n\n`
: `\n\n${spacer}${colors.italic(colors.gray('no error output'))}\n`
throw new Error(`Command failed: ${paddedHeap}`)
}
export async function getExecFromPath(name: string): Promise<string> {
const res = await getExecutablesFromPath(name)
const path = res.get(name)?.[0]
if (!path) throw new Error(`Could not find ${name} in $PATH`)
return path
}
export async function getExecutablesFromPath(matcher: string | ((name: string) => boolean)): Promise<Map<string, string[]>> {
const path = Deno.env.get('PATH')
if (!path) throw new Error('Could not detect the $PATH env var')
const matchFn = typeof matcher === 'string' ? (name: string) => name === matcher : matcher
const directories = path.split(':')
const matches = new Map<string, string[]>()
for (const directory of directories) {
for await (const entry of Deno.readDir(directory)) {
if (entry.isDirectory) continue
if (matchFn(entry.name)) {
const exec = pathUtils.join(directory, name)
const existingMatches = matches.get(name)
if (existingMatches) existingMatches.push(exec)
else matches.set(name, [exec])
}
}
}
return matches
}