main
1// Copyright 2022 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import { Buffer } from 'node:buffer'
16import process from 'node:process'
17import { createInterface } from 'node:readline'
18import { Readable } from 'node:stream'
19import { type Mode } from 'node:fs'
20import {
21 $,
22 within,
23 ProcessOutput,
24 type ProcessPromise,
25 path,
26 os,
27 Fail,
28} from './core.ts'
29import {
30 type Duration,
31 getLast,
32 identity,
33 isStringLiteral,
34 parseBool,
35 parseDuration,
36 randomId,
37 toCamelCase,
38} from './util.ts'
39import {
40 type RequestInfo,
41 type RequestInit,
42 nodeFetch,
43 minimist,
44 fs,
45} from './vendor.ts'
46
47export { versions } from './versions.ts'
48
49export function tempdir(
50 prefix: string = `zx-${randomId()}`,
51 mode?: Mode
52): string {
53 const dirpath = path.join(os.tmpdir(), prefix)
54 fs.mkdirSync(dirpath, { recursive: true, mode })
55
56 return dirpath
57}
58
59export function tempfile(
60 name?: string,
61 data?: string | Buffer,
62 mode?: Mode
63): string {
64 const filepath = name
65 ? path.join(tempdir(), name)
66 : path.join(os.tmpdir(), `zx-${randomId()}`)
67
68 if (data === undefined) fs.closeSync(fs.openSync(filepath, 'w', mode))
69 else fs.writeFileSync(filepath, data, { mode })
70
71 return filepath
72}
73
74export { tempdir as tmpdir, tempfile as tmpfile }
75
76type ArgvOpts = minimist.Opts & { camelCase?: boolean; parseBoolean?: boolean }
77
78export const parseArgv = (
79 args: string[] = process.argv.slice(2),
80 opts: ArgvOpts = {},
81 defs: Record<string, any> = {}
82): minimist.ParsedArgs =>
83 Object.entries<string>(minimist(args, opts)).reduce<minimist.ParsedArgs>(
84 (m, [k, v]) => {
85 const kTrans = opts.camelCase ? toCamelCase : identity
86 const vTrans = opts.parseBoolean ? parseBool : identity
87 const [_k, _v] = k === '--' || k === '_' ? [k, v] : [kTrans(k), vTrans(v)]
88 m[_k] = _v
89 return m
90 },
91 defs as minimist.ParsedArgs
92 )
93
94export function updateArgv(args?: string[], opts?: ArgvOpts) {
95 for (const k in argv) delete argv[k]
96 parseArgv(args, opts, argv)
97}
98
99export const argv: minimist.ParsedArgs = parseArgv()
100
101export function sleep(duration: Duration): Promise<void> {
102 return new Promise((resolve) => {
103 setTimeout(resolve, parseDuration(duration))
104 })
105}
106
107const responseToReadable = (response: Response, rs: Readable) => {
108 const reader = response.body?.getReader()
109 if (!reader) {
110 rs.push(null)
111 return rs
112 }
113 rs._read = async () => {
114 const result = await reader.read()
115 rs.push(result.done ? null : Buffer.from(result.value))
116 }
117 return rs
118}
119
120export function fetch(
121 url: RequestInfo,
122 init?: RequestInit
123): Promise<Response> & {
124 pipe: {
125 (dest: TemplateStringsArray, ...args: any[]): ProcessPromise
126 <D>(dest: D): D
127 }
128} {
129 $.log({ kind: 'fetch', url, init, verbose: !$.quiet && $.verbose })
130 const p = nodeFetch(url, init)
131
132 return Object.assign(p, {
133 pipe(dest: any, ...args: any[]) {
134 const rs = new Readable()
135 const _dest = isStringLiteral(dest, ...args)
136 ? $({
137 halt: true,
138 signal: init?.signal as AbortSignal,
139 })(dest as TemplateStringsArray, ...args)
140 : dest
141 p.then(
142 (r) => responseToReadable(r, rs).pipe(_dest.run?.()),
143 (err) => _dest.abort?.(err)
144 )
145 return _dest
146 },
147 })
148}
149
150export function echo(...args: any[]): void
151export function echo(pieces: TemplateStringsArray, ...args: any[]) {
152 const msg = isStringLiteral(pieces, ...args)
153 ? args.map((a, i) => pieces[i] + stringify(a)).join('') + getLast(pieces)
154 : [pieces, ...args].map(stringify).join(' ')
155
156 console.log(msg)
157}
158
159function stringify(arg: any) {
160 return arg instanceof ProcessOutput ? arg.toString().trimEnd() : `${arg}`
161}
162
163export async function question(
164 query?: string,
165 {
166 choices,
167 input = process.stdin,
168 output = process.stdout,
169 }: {
170 choices?: string[]
171 input?: NodeJS.ReadStream
172 output?: NodeJS.WriteStream
173 } = {}
174): Promise<string> {
175 /* c8 ignore next 5 */
176 const completer = Array.isArray(choices)
177 ? (line: string) => {
178 const hits = choices.filter((c) => c.startsWith(line))
179 return [hits.length ? hits : choices, line]
180 }
181 : undefined
182 const rl = createInterface({
183 input,
184 output,
185 terminal: true,
186 completer,
187 })
188
189 return new Promise((resolve) =>
190 rl.question(query ?? '', (answer) => {
191 rl.close()
192 resolve(answer)
193 })
194 )
195}
196
197export async function stdin(stream: Readable = process.stdin): Promise<string> {
198 let buf = ''
199 for await (const chunk of stream.setEncoding('utf8')) {
200 buf += chunk
201 }
202 return buf
203}
204
205export async function retry<T>(count: number, callback: () => T): Promise<T>
206export async function retry<T>(
207 count: number,
208 duration: Duration | Generator<number>,
209 callback: () => T
210): Promise<T>
211export async function retry<T>(
212 count: number,
213 d: Duration | Generator<number> | (() => T),
214 cb?: () => T
215): Promise<T> {
216 if (typeof d === 'function') return retry(count, 0, d)
217 if (!cb) throw new Fail('Callback is required for retry')
218
219 const total = count
220 const gen =
221 typeof d === 'object'
222 ? d
223 : (function* (d) {
224 while (true) yield d
225 })(parseDuration(d))
226
227 let attempt = 0
228 let lastErr: unknown
229 while (count-- > 0) {
230 attempt++
231 try {
232 return await cb()
233 } catch (err) {
234 lastErr = err
235 const delay = gen.next().value
236
237 $.log({
238 kind: 'retry',
239 total,
240 attempt,
241 delay,
242 exception: err,
243 verbose: !$.quiet && $.verbose,
244 error: `FAIL Attempt: ${attempt}/${total}, next: ${delay}`, // legacy
245 })
246 if (delay > 0) await sleep(delay)
247 }
248 }
249 throw lastErr
250}
251
252export function* expBackoff(
253 max: Duration = '60s',
254 delay: Duration = '100ms'
255): Generator<number, void, unknown> {
256 const maxMs = parseDuration(max)
257 const randMs = parseDuration(delay)
258 let n = 0
259 while (true) {
260 yield Math.min(randMs * 2 ** n++, maxMs)
261 }
262}
263
264export async function spinner<T>(callback: () => T): Promise<T>
265export async function spinner<T>(title: string, callback: () => T): Promise<T>
266export async function spinner<T>(
267 title: string | (() => T),
268 callback?: () => T
269): Promise<T> {
270 if (typeof title === 'function') return spinner('', title)
271 if ($.quiet || process.env.CI) return callback!()
272
273 let i = 0
274 const stream = $.log.output || process.stderr
275 const spin = () => stream.write(` ${'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[i++ % 10]} ${title}\r`)
276 return within(async () => {
277 $.verbose = false
278 const id = setInterval(spin, 100)
279
280 try {
281 return await callback!()
282 } finally {
283 clearInterval(id as ReturnType<typeof setTimeout>)
284 stream.write(' '.repeat((process.stdout.columns || 1) - 1) + '\r')
285 }
286 })
287}