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}