diff --git a/README.md b/README.md index efd2eedc58..9dcc110f0d 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,10 @@ outputs. Or use a CLI argument `--quiet` to set `$.verbose = false`. +### `$.env` + +Specifies env map. Defaults to `process.env`. + ## Polyfills ### `__filename` & `__dirname` @@ -434,6 +438,28 @@ import {withTimeout} from 'zx/experimental' await withTimeout(100, 'SIGTERM')`sleep 9999` ``` +### `getCtx()` and `runInCtx()` + +[async_hooks](https://nodejs.org/api/async_hooks.html) methods to manipulate bound context. +This object is used by zx inners, so it has a significant impact on the call mechanics. Please use this carefully and wisely. + +```js +import {getCtx, runInCtx} from 'zx/experimental' + +runInCtx({ ...getCtx() }, async () => { + await sleep(10) + cd('/foo') + // $.cwd refers to /foo + // getCtx().cwd === $.cwd +}) + +runInCtx({ ...getCtx() }, async () => { + await sleep(20) + // $.cwd refers to /foo + // but getCtx().cwd !== $.cwd +}) +``` + ## FAQ ### Passing env variables diff --git a/package.json b/package.json index 53e0cbb16e..7f7bcf7a74 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "./globals": "./src/globals.mjs", "./experimental": "./src/experimental.mjs", "./cli": "./zx.mjs", + "./core": "./src/core.mjs", "./package.json": "./package.json" }, "bin": { @@ -18,8 +19,10 @@ "node": ">= 16.0.0" }, "scripts": { - "test:cov": "c8 --reporter=html npm run test", - "test": "node zx.mjs test/full.test.mjs", + "lint": "prettier --single-quote --no-semi --write src test", + "test": "npm run lint && npm run test:unit", + "test:unit": "node zx.mjs test/full.test.mjs", + "test:cov": "c8 --reporter=html npm run test:unit", "test:zx": "npm run test zx", "test:index": "npm run test index" }, @@ -38,7 +41,8 @@ "yaml": "^2.0.1" }, "devDependencies": { - "c8": "^7.11.2" + "c8": "^7.11.2", + "prettier": "^2.6.2" }, "publishConfig": { "registry": "https://wombat-dressing-room.appspot.com" diff --git a/src/context.mjs b/src/context.mjs new file mode 100644 index 0000000000..38098f8832 --- /dev/null +++ b/src/context.mjs @@ -0,0 +1,33 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AsyncLocalStorage } from 'node:async_hooks' + +let root + +const storage = new AsyncLocalStorage() + +export function getCtx() { + return storage.getStore() +} +export function setRootCtx(ctx) { + storage.enterWith(ctx) + root = ctx +} +export function getRootCtx() { + return root +} +export function runInCtx(ctx, cb) { + return storage.run(ctx, cb) +} diff --git a/src/core.mjs b/src/core.mjs new file mode 100644 index 0000000000..fd415d2009 --- /dev/null +++ b/src/core.mjs @@ -0,0 +1,280 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { inspect, promisify } from 'node:util' +import { spawn } from 'node:child_process' +import { chalk, which } from './goods.mjs' +import { getCtx, runInCtx, setRootCtx } from './context.mjs' +import { printStd, printCmd } from './print.mjs' +import { formatCmd, quote } from './guards.mjs' +import psTreeModule from 'ps-tree' + +const psTree = promisify(psTreeModule) + +export function $(...args) { + let resolve, reject + let promise = new ProcessPromise((...args) => ([resolve, reject] = args)) + + promise.ctx = { + ...getCtx(), + cmd: formatCmd(...args), + __from: new Error().stack.split(/^\s*at\s/m)[2].trim(), + resolve, + reject, + } + + setImmediate(() => promise._run()) // Make sure all subprocesses are started, if not explicitly by await or then(). + + return promise +} + +setRootCtx($) + +$.cwd = process.cwd() +$.env = process.env +$.quote = quote +$.spawn = spawn +$.verbose = true +$.maxBuffer = 200 * 1024 * 1024 /* 200 MiB*/ +$.prefix = '' // Bash not found, no prefix. +try { + $.shell = which.sync('bash') + $.prefix = 'set -euo pipefail;' +} catch (e) {} + +export class ProcessPromise extends Promise { + child = undefined + _resolved = false + _inheritStdin = true + _piped = false + _prerun = undefined + _postrun = undefined + + get stdin() { + this._inheritStdin = false + this._run() + return this.child.stdin + } + + get stdout() { + this._inheritStdin = false + this._run() + return this.child.stdout + } + + get stderr() { + this._inheritStdin = false + this._run() + return this.child.stderr + } + + get exitCode() { + return this.then( + (p) => p.exitCode, + (p) => p.exitCode + ) + } + + pipe(dest) { + if (typeof dest === 'string') { + throw new Error('The pipe() method does not take strings. Forgot $?') + } + if (this._resolved === true) { + throw new Error( + "The pipe() method shouldn't be called after promise is already resolved!" + ) + } + this._piped = true + if (dest instanceof ProcessPromise) { + dest._inheritStdin = false + dest._prerun = this._run.bind(this) + dest._postrun = () => this.stdout.pipe(dest.child.stdin) + return dest + } else { + this._postrun = () => this.stdout.pipe(dest) + return this + } + } + + async kill(signal = 'SIGTERM') { + this.catch((_) => _) + let children = await psTree(this.child.pid) + for (const p of children) { + try { + process.kill(p.PID, signal) + } catch (e) {} + } + try { + process.kill(this.child.pid, signal) + } catch (e) {} + } + + _run() { + if (this.child) return // The _run() called from two places: then() and setTimeout(). + if (this._prerun) this._prerun() // In case $1.pipe($2), the $2 returned, and on $2._run() invoke $1._run(). + + const ctx = this.ctx + runInCtx(ctx, () => { + const { + nothrow, + cmd, + cwd, + env, + prefix, + shell, + maxBuffer, + __from, + resolve, + reject, + } = ctx + + printCmd(cmd) + + let child = spawn(prefix + cmd, { + cwd, + shell: typeof shell === 'string' ? shell : true, + stdio: [this._inheritStdin ? 'inherit' : 'pipe', 'pipe', 'pipe'], + windowsHide: true, + maxBuffer, + env, + }) + + child.on('close', (code, signal) => { + let message = `${stderr || '\n'} at ${__from}` + message += `\n exit code: ${code}${ + exitCodeInfo(code) ? ' (' + exitCodeInfo(code) + ')' : '' + }` + if (signal !== null) { + message += `\n signal: ${signal}` + } + let output = new ProcessOutput({ + code, + signal, + stdout, + stderr, + combined, + message, + }) + ;(code === 0 || nothrow ? resolve : reject)(output) + this._resolved = true + }) + + let stdout = '', + stderr = '', + combined = '' + let onStdout = (data) => { + printStd(data) + stdout += data + combined += data + } + let onStderr = (data) => { + printStd(null, data) + stderr += data + combined += data + } + if (!this._piped) child.stdout.on('data', onStdout) // If process is piped, don't collect or print output. + child.stderr.on('data', onStderr) // Stderr should be printed regardless of piping. + this.child = child + if (this._postrun) this._postrun() // In case $1.pipe($2), after both subprocesses are running, we can pipe $1.stdout to $2.stdin. + }) + } +} + +export class ProcessOutput extends Error { + #code = null + #signal = null + #stdout = '' + #stderr = '' + #combined = '' + + constructor({ code, signal, stdout, stderr, combined, message }) { + super(message) + this.#code = code + this.#signal = signal + this.#stdout = stdout + this.#stderr = stderr + this.#combined = combined + } + + toString() { + return this.#combined + } + + get stdout() { + return this.#stdout + } + + get stderr() { + return this.#stderr + } + + get exitCode() { + return this.#code + } + + get signal() { + return this.#signal + } + + [inspect.custom]() { + let stringify = (s, c) => (s.length === 0 ? "''" : c(inspect(s))) + return `ProcessOutput { + stdout: ${stringify(this.stdout, chalk.green)}, + stderr: ${stringify(this.stderr, chalk.red)}, + signal: ${inspect(this.signal)}, + exitCode: ${(this.exitCode === 0 ? chalk.green : chalk.red)(this.exitCode)}${ + exitCodeInfo(this.exitCode) + ? chalk.grey(' (' + exitCodeInfo(this.exitCode) + ')') + : '' + } +}` + } +} + +function exitCodeInfo(exitCode) { + return { + 2: 'Misuse of shell builtins', + 126: 'Invoked command cannot execute', + 127: 'Command not found', + 128: 'Invalid exit argument', + 129: 'Hangup', + 130: 'Interrupt', + 131: 'Quit and dump core', + 132: 'Illegal instruction', + 133: 'Trace/breakpoint trap', + 134: 'Process aborted', + 135: 'Bus error: "access to undefined portion of memory object"', + 136: 'Floating point exception: "erroneous arithmetic operation"', + 137: 'Kill (terminate immediately)', + 138: 'User-defined 1', + 139: 'Segmentation violation', + 140: 'User-defined 2', + 141: 'Write to pipe with no one reading', + 142: 'Signal raised by alarm', + 143: 'Termination (request to terminate)', + 145: 'Child process terminated, stopped (or continued*)', + 146: 'Continue if stopped', + 147: 'Stop executing temporarily', + 148: 'Terminal stop signal', + 149: 'Background process attempting to read from tty ("in")', + 150: 'Background process attempting to write to tty ("out")', + 151: 'Urgent data available on socket', + 152: 'CPU time limit exceeded', + 153: 'File size limit exceeded', + 154: 'Signal raised by timer counting virtual time: "virtual timer expired"', + 155: 'Profiling timer expired', + 157: 'Pollable event', + 159: 'Bad syscall', + }[exitCode] +} diff --git a/src/experimental.mjs b/src/experimental.mjs index 8d755b0412..29218e686b 100644 --- a/src/experimental.mjs +++ b/src/experimental.mjs @@ -12,45 +12,56 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {ProcessOutput, sleep, $} from './index.mjs' +import { ProcessOutput, $ } from './core.mjs' +import { sleep } from './goods.mjs' +import { isString } from './util.mjs' +import { getCtx, runInCtx } from './context.mjs' + +export { getCtx, runInCtx } // Retries a command a few times. Will return after the first // successful attempt, or will throw after specifies attempts count. -export const retry = (count = 5, delay = 0) => async (cmd, ...args) => { - while (count --> 0) try { - return await $(cmd, ...args) - } catch (p) { - if (count === 0) throw p - if (delay) await sleep(delay) +export function retry(count = 5, delay = 0) { + return async function (cmd, ...args) { + while (count-- > 0) + try { + return await $(cmd, ...args) + } catch (p) { + if (count === 0) throw p + if (delay) await sleep(delay) + } } } // Runs and sets a timeout for a cmd -export const withTimeout = (timeout, signal) => async (cmd, ...args) => { - let p = $(cmd, ...args) - if (!timeout) return p +export function withTimeout(timeout, signal) { + return async function (cmd, ...args) { + let p = $(cmd, ...args) + if (!timeout) return p - let timer = setTimeout(() => p.kill(signal), timeout) + let timer = setTimeout(() => p.kill(signal), timeout) - return p.finally(() => clearTimeout(timer)) + return p.finally(() => clearTimeout(timer)) + } } // A console.log() alternative which can take ProcessOutput. export function echo(pieces, ...args) { let msg let lastIdx = pieces.length - 1 - if (Array.isArray(pieces) && pieces.every(isString) && lastIdx === args.length) { - msg = args.map((a, i) => pieces[i] + stringify(a)).join('') + pieces[lastIdx] + if ( + Array.isArray(pieces) && + pieces.every(isString) && + lastIdx === args.length + ) { + msg = + args.map((a, i) => pieces[i] + stringify(a)).join('') + pieces[lastIdx] } else { msg = [pieces, ...args].map(stringify).join(' ') } console.log(msg) } -function isString(obj) { - return typeof obj === 'string' -} - function stringify(arg) { if (arg instanceof ProcessOutput) { return arg.toString().replace(/\n$/, '') @@ -60,6 +71,10 @@ function stringify(arg) { // Starts a simple CLI spinner, and returns stop() func. export function startSpinner(title = '') { - let i = 0, spin = () => process.stdout.write(` ${'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[i++ % 10]} ${title}\r`) - return (id => () => clearInterval(id))(setInterval(spin, 100)) + let i = 0, + spin = () => process.stdout.write(` ${'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[i++ % 10]} ${title}\r`) + return ( + (id) => () => + clearInterval(id) + )(setInterval(spin, 100)) } diff --git a/src/globals.mjs b/src/globals.mjs index 7dfc55513e..0cf0d8a86c 100644 --- a/src/globals.mjs +++ b/src/globals.mjs @@ -1,3 +1,3 @@ -import {registerGlobals} from './index.mjs' +import { registerGlobals } from './index.mjs' registerGlobals() diff --git a/src/goods.mjs b/src/goods.mjs new file mode 100644 index 0000000000..2d507ca0bb --- /dev/null +++ b/src/goods.mjs @@ -0,0 +1,53 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as globbyModule from 'globby' +import minimist from 'minimist' +import { setTimeout as sleep } from 'node:timers/promises' +import nodeFetch from 'node-fetch' +import { getCtx, getRootCtx } from './context.mjs' +import { colorize } from './print.mjs' + +export { default as chalk } from 'chalk' +export { default as fs } from 'fs-extra' +export { default as which } from 'which' +export { default as YAML } from 'yaml' +export { default as path } from 'node:path' +export { default as os } from 'node:os' +export { sleep } + +export const argv = minimist(process.argv.slice(2)) + +export const globby = Object.assign(function globby(...args) { + return globbyModule.globby(...args) +}, globbyModule) + +export const glob = globby + +export async function fetch(url, init) { + if (getCtx().verbose) { + if (typeof init !== 'undefined') { + console.log('$', colorize(`fetch ${url}`), init) + } else { + console.log('$', colorize(`fetch ${url}`)) + } + } + return nodeFetch(url, init) +} + +export function cd(path) { + if (getCtx().verbose) console.log('$', colorize(`cd ${path}`)) + process.chdir(path) + getRootCtx().cwd = getCtx().cwd = process.cwd() +} diff --git a/src/guards.mjs b/src/guards.mjs new file mode 100644 index 0000000000..9e7016c634 --- /dev/null +++ b/src/guards.mjs @@ -0,0 +1,58 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { getCtx } from './context.mjs' + +export function quote(arg) { + if (/^[a-z0-9/_.-]+$/i.test(arg) || arg === '') { + return arg + } + return ( + `$'` + + arg + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\f/g, '\\f') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + .replace(/\v/g, '\\v') + .replace(/\0/g, '\\0') + + `'` + ) +} + +export function formatCmd(pieces, ...args) { + let cmd = pieces[0], + i = 0 + let quote = getCtx().quote + while (i < args.length) { + let s + if (Array.isArray(args[i])) { + s = args[i].map((x) => quote(substitute(x))).join(' ') + } else { + s = quote(substitute(args[i])) + } + cmd += s + pieces[++i] + } + + return cmd +} + +function substitute(arg) { + if (arg?.stdout) { + return arg.stdout.replace(/\n$/, '') + } + return `${arg}` +} diff --git a/src/hooks.mjs b/src/hooks.mjs new file mode 100644 index 0000000000..e9ca21e65d --- /dev/null +++ b/src/hooks.mjs @@ -0,0 +1,23 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export function nothrow(promise) { + promise.ctx.nothrow = true + return promise +} + +export function quiet(promise) { + promise.ctx.verbose = false + return promise +} diff --git a/src/index.d.ts b/src/index.d.ts index aaaeba7584..f0805bf047 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,11 +1,11 @@ // Copyright 2021 Google LLC -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // https://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -13,20 +13,23 @@ // limitations under the License. declare module 'zx' { - import {ChildProcess, spawn} from 'node:child_process' - import {Readable, Writable} from 'node:stream' + import { ChildProcess, spawn } from 'node:child_process' + import { Readable, Writable } from 'node:stream' import * as _fs from 'fs-extra' import * as _globby from 'globby' import * as _os from 'node:os' import * as _path from 'node:path' - import {ChalkInstance} from 'chalk' + import { ChalkInstance } from 'chalk' import * as _yaml from 'yaml' import _fetch from 'node-fetch' - import {ParsedArgs} from 'minimist' + import { ParsedArgs } from 'minimist' import * as _which from 'which' export interface ZxTemplate { - (pieces: TemplateStringsArray, ...args: any[]): ProcessPromise + ( + pieces: TemplateStringsArray, + ...args: any[] + ): ProcessPromise } interface $ extends ZxTemplate { @@ -45,7 +48,9 @@ declare module 'zx' { readonly stderr: Readable readonly exitCode: Promise - pipe(dest: ProcessPromise | Writable): ProcessPromise + pipe( + dest: ProcessPromise | Writable + ): ProcessPromise kill(signal?: string | number): Promise } @@ -62,10 +67,14 @@ declare module 'zx' { export type QuestionOptions = { choices: string[] } type cd = (path: string) => void - type nothrow = (p: ProcessPromise) => ProcessPromise + type nothrow = ( + p: ProcessPromise + ) => ProcessPromise type question = (query?: string, options?: QuestionOptions) => Promise type sleep = (ms: number) => Promise - type quiet = (p: ProcessPromise) => ProcessPromise + type quiet = ( + p: ProcessPromise + ) => ProcessPromise export const $: $ export const argv: ParsedArgs @@ -122,7 +131,7 @@ declare module 'zx/globals' { } declare module 'zx/experimental' { - import {ZxTemplate} from 'zx' + import { ZxTemplate } from 'zx' interface Echo { (pieces: TemplateStringsArray, ...args: any[]): void @@ -132,9 +141,11 @@ declare module 'zx/experimental' { export const retry: (count?: number, delay?: number) => ZxTemplate - export const withTimeout: (delay?: number, signal?: string | number) => ZxTemplate + export const withTimeout: ( + delay?: number, + signal?: string | number + ) => ZxTemplate type StopSpinner = () => void export function startSpinner(title: string): StopSpinner } - diff --git a/src/index.mjs b/src/index.mjs index e291f812b2..f1f060e7d1 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -1,39 +1,55 @@ -// Copyright 2021 Google LLC -// +// Copyright 2022 Google LLC +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // https://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import fs from 'fs-extra' -import * as globbyModule from 'globby' -import os from 'node:os' -import path from 'node:path' -import {promisify, inspect} from 'node:util' -import {spawn} from 'node:child_process' -import {createInterface} from 'node:readline' -import {default as nodeFetch} from 'node-fetch' -import which from 'which' -import chalk from 'chalk' -import YAML from 'yaml' -import minimist from 'minimist' -import psTreeModule from 'ps-tree' - -export {chalk, fs, os, path, YAML, which} -export const sleep = promisify(setTimeout) -export const argv = minimist(process.argv.slice(2)) -export const globby = Object.assign(function globby(...args) { - return globbyModule.globby(...args) -}, globbyModule) -export const glob = globby -const psTree = promisify(psTreeModule) +import { + argv, + cd, + chalk, + fetch, + fs, + glob, + globby, + path, + sleep, + which, + YAML, + os, +} from './goods.mjs' +import { nothrow, quiet } from './hooks.mjs' +import { question } from './question.mjs' +import { $, ProcessPromise, ProcessOutput } from './core.mjs' + +export { + $, + ProcessPromise, + ProcessOutput, + argv, + cd, + chalk, + fetch, + fs, + glob, + globby, + nothrow, + os, + path, + question, + quiet, + sleep, + which, + YAML, +} export function registerGlobals() { Object.assign(global, { @@ -46,347 +62,12 @@ export function registerGlobals() { glob, globby, nothrow, - quiet, os, path, question, + quiet, sleep, - YAML, which, + YAML, }) } - -export function $(pieces, ...args) { - let { - verbose, - shell, - prefix, - spawn, - maxBuffer = 200 * 1024 * 1024 /* 200 MiB*/ - } = $ - let __from = (new Error().stack.split(/^\s*at\s/m)[2]).trim() - let cwd = process.cwd() - - let cmd = pieces[0], i = 0 - while (i < args.length) { - let s - if (Array.isArray(args[i])) { - s = args[i].map(x => $.quote(substitute(x))).join(' ') - } else { - s = $.quote(substitute(args[i])) - } - cmd += s + pieces[++i] - } - - let resolve, reject - let promise = new ProcessPromise((...args) => [resolve, reject] = args) - - promise._run = () => { - if (promise.child) return // The _run() called from two places: then() and setTimeout(). - if (promise._prerun) promise._prerun() // In case $1.pipe($2), the $2 returned, and on $2._run() invoke $1._run(). - if (verbose && !promise._quiet) { - printCmd(cmd) - } - - let child = spawn(prefix + cmd, { - cwd, - shell: typeof shell === 'string' ? shell : true, - stdio: [promise._inheritStdin ? 'inherit' : 'pipe', 'pipe', 'pipe'], - windowsHide: true, - maxBuffer, - }) - - child.on('close', (code, signal) => { - let message = `${stderr || '\n'} at ${__from}` - message += `\n exit code: ${code}${exitCodeInfo(code) ? ' (' + exitCodeInfo(code) + ')' : ''}` - if (signal !== null) { - message += `\n signal: ${signal}` - } - let output = new ProcessOutput({ - code, - signal, - stdout, - stderr, - combined, - message, - }); - (code === 0 || promise._nothrow ? resolve : reject)(output) - promise._resolved = true - }) - - let stdout = '', stderr = '', combined = '' - let onStdout = data => { - if (verbose && !promise._quiet) process.stdout.write(data) - stdout += data - combined += data - } - let onStderr = data => { - if (verbose && !promise._quiet) process.stderr.write(data) - stderr += data - combined += data - } - if (!promise._piped) child.stdout.on('data', onStdout) // If process is piped, don't collect or print output. - child.stderr.on('data', onStderr) // Stderr should be printed regardless of piping. - promise.child = child - if (promise._postrun) promise._postrun() // In case $1.pipe($2), after both subprocesses are running, we can pipe $1.stdout to $2.stdin. - } - setTimeout(promise._run, 0) // Make sure all subprocesses are started, if not explicitly by await or then(). - return promise -} - -$.quote = quote -$.spawn = spawn -$.verbose = true -$.prefix = '' // Bash not found, no prefix. -try { - $.shell = which.sync('bash') - $.prefix = 'set -euo pipefail;' -} catch (e) { -} - -export function cd(path) { - if ($.verbose) console.log('$', colorize(`cd ${path}`)) - process.chdir(path) -} - -export async function question(query, options) { - let completer = undefined - if (Array.isArray(options?.choices)) { - completer = function completer(line) { - const completions = options.choices - const hits = completions.filter((c) => c.startsWith(line)) - return [hits.length ? hits : completions, line] - } - } - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - terminal: true, - completer, - }) - - return new Promise((resolve) => rl.question(query ?? '', (answer) => { - rl.close() - resolve(answer) - })) -} - -export async function fetch(url, init) { - if ($.verbose) { - if (typeof init !== 'undefined') { - console.log('$', colorize(`fetch ${url}`), init) - } else { - console.log('$', colorize(`fetch ${url}`)) - } - } - return nodeFetch(url, init) -} - -export function nothrow(promise) { - promise._nothrow = true - return promise -} - -export function quiet(promise) { - promise._quiet = true - return promise -} - -export class ProcessPromise extends Promise { - child = undefined - _nothrow = false - _quiet = false - _resolved = false - _inheritStdin = true - _piped = false - _prerun = undefined - _postrun = undefined - - get stdin() { - this._inheritStdin = false - this._run() - return this.child.stdin - } - - get stdout() { - this._inheritStdin = false - this._run() - return this.child.stdout - } - - get stderr() { - this._inheritStdin = false - this._run() - return this.child.stderr - } - - get exitCode() { - return this - .then(p => p.exitCode) - .catch(p => p.exitCode) - } - - then(onfulfilled, onrejected) { - if (this._run) this._run() - return super.then(onfulfilled, onrejected) - } - - pipe(dest) { - if (typeof dest === 'string') { - throw new Error('The pipe() method does not take strings. Forgot $?') - } - if (this._resolved === true) { - throw new Error('The pipe() method shouldn\'t be called after promise is already resolved!') - } - this._piped = true - if (dest instanceof ProcessPromise) { - dest._inheritStdin = false - dest._prerun = this._run - dest._postrun = () => this.stdout.pipe(dest.child.stdin) - return dest - } else { - this._postrun = () => this.stdout.pipe(dest) - return this - } - } - - async kill(signal = 'SIGTERM') { - this.catch(_ => _) - let children = await psTree(this.child.pid) - for (const p of children) { - try { - process.kill(p.PID, signal) - } catch (e) { - } - } - try { - process.kill(this.child.pid, signal) - } catch (e) { - } - } -} - -export class ProcessOutput extends Error { - #code = null - #signal = null - #stdout = '' - #stderr = '' - #combined = '' - - constructor({code, signal, stdout, stderr, combined, message}) { - super(message) - this.#code = code - this.#signal = signal - this.#stdout = stdout - this.#stderr = stderr - this.#combined = combined - } - - toString() { - return this.#combined - } - - get stdout() { - return this.#stdout - } - - get stderr() { - return this.#stderr - } - - get exitCode() { - return this.#code - } - - get signal() { - return this.#signal - } - - [inspect.custom]() { - let stringify = (s, c) => s.length === 0 ? '\'\'' : c(inspect(s)) - return `ProcessOutput { - stdout: ${stringify(this.stdout, chalk.green)}, - stderr: ${stringify(this.stderr, chalk.red)}, - signal: ${inspect(this.signal)}, - exitCode: ${(this.exitCode === 0 ? chalk.green : chalk.red)(this.exitCode)}${(exitCodeInfo(this.exitCode) ? chalk.grey(' (' + exitCodeInfo(this.exitCode) + ')') : '')} -}` - } -} - -function printCmd(cmd) { - if (/\n/.test(cmd)) { - console.log(cmd - .split('\n') - .map((line, i) => (i === 0 ? '$' : '>') + ' ' + colorize(line)) - .join('\n')) - } else { - console.log('$', colorize(cmd)) - } -} - -function colorize(cmd) { - return cmd.replace(/^[\w_.-]+(\s|$)/, substr => { - return chalk.greenBright(substr) - }) -} - -function substitute(arg) { - if (arg instanceof ProcessOutput) { - return arg.stdout.replace(/\n$/, '') - } - return `${arg}` -} - -function quote(arg) { - if (/^[a-z0-9/_.-]+$/i.test(arg) || arg === '') { - return arg - } - return `$'` - + arg - .replace(/\\/g, '\\\\') - .replace(/'/g, '\\\'') - .replace(/\f/g, '\\f') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/\t/g, '\\t') - .replace(/\v/g, '\\v') - .replace(/\0/g, '\\0') - + `'` -} - -function exitCodeInfo(exitCode) { - return { - 2: 'Misuse of shell builtins', - 126: 'Invoked command cannot execute', - 127: 'Command not found', - 128: 'Invalid exit argument', - 129: 'Hangup', - 130: 'Interrupt', - 131: 'Quit and dump core', - 132: 'Illegal instruction', - 133: 'Trace/breakpoint trap', - 134: 'Process aborted', - 135: 'Bus error: "access to undefined portion of memory object"', - 136: 'Floating point exception: "erroneous arithmetic operation"', - 137: 'Kill (terminate immediately)', - 138: 'User-defined 1', - 139: 'Segmentation violation', - 140: 'User-defined 2', - 141: 'Write to pipe with no one reading', - 142: 'Signal raised by alarm', - 143: 'Termination (request to terminate)', - 145: 'Child process terminated, stopped (or continued*)', - 146: 'Continue if stopped', - 147: 'Stop executing temporarily', - 148: 'Terminal stop signal', - 149: 'Background process attempting to read from tty ("in")', - 150: 'Background process attempting to write to tty ("out")', - 151: 'Urgent data available on socket', - 152: 'CPU time limit exceeded', - 153: 'File size limit exceeded', - 154: 'Signal raised by timer counting virtual time: "virtual timer expired"', - 155: 'Profiling timer expired', - 157: 'Pollable event', - 159: 'Bad syscall', - }[exitCode] -} diff --git a/src/print.mjs b/src/print.mjs new file mode 100644 index 0000000000..1ffec0d91e --- /dev/null +++ b/src/print.mjs @@ -0,0 +1,42 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { getCtx } from './context.mjs' +import { chalk } from './goods.mjs' + +export function printCmd(cmd) { + if (!getCtx()?.verbose) return + if (/\n/.test(cmd)) { + console.log( + cmd + .split('\n') + .map((line, i) => (i === 0 ? '$' : '>') + ' ' + colorize(line)) + .join('\n') + ) + } else { + console.log('$', colorize(cmd)) + } +} + +export function printStd(data, err) { + if (!getCtx()?.verbose) return + if (data) process.stdout.write(data) + if (err) process.stderr.write(err) +} + +export function colorize(cmd) { + return cmd.replace(/^[\w_.-]+(\s|$)/, (substr) => { + return chalk.greenBright(substr) + }) +} diff --git a/src/question.mjs b/src/question.mjs new file mode 100644 index 0000000000..f7b1df9f87 --- /dev/null +++ b/src/question.mjs @@ -0,0 +1,39 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createInterface } from 'node:readline' + +export async function question(query, options) { + let completer = undefined + if (Array.isArray(options?.choices)) { + completer = function completer(line) { + const completions = options.choices + const hits = completions.filter((c) => c.startsWith(line)) + return [hits.length ? hits : completions, line] + } + } + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + completer, + }) + + return new Promise((resolve) => + rl.question(query ?? '', (answer) => { + rl.close() + resolve(answer) + }) + ) +} diff --git a/src/util.mjs b/src/util.mjs new file mode 100644 index 0000000000..45750232b4 --- /dev/null +++ b/src/util.mjs @@ -0,0 +1,21 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export const randomId = function () { + return Math.random().toString(36).slice(2) +} + +export function isString(obj) { + return typeof obj === 'string' +} diff --git a/test/experimental.test.mjs b/test/experimental.test.mjs index 8262ceb6ef..2c684ddec4 100644 --- a/test/experimental.test.mjs +++ b/test/experimental.test.mjs @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {echo, retry, startSpinner, withTimeout} from '../src/experimental.mjs' -import {assert, testFactory} from './test-utils.mjs' +import { echo, retry, startSpinner, withTimeout } from '../src/experimental.mjs' +import { assert, testFactory } from './test-utils.mjs' import chalk from 'chalk' const test = testFactory('experimental', import.meta) @@ -49,7 +49,11 @@ test('withTimeout works', async () => { test('echo works', async () => { echo(chalk.red('foo'), chalk.green('bar'), chalk.bold('baz')) echo`${chalk.red('foo')} ${chalk.green('bar')} ${chalk.bold('baz')}` - echo(await $`echo ${chalk.red('foo')}`, await $`echo ${chalk.green('bar')}`, await $`echo ${chalk.bold('baz')}`) + echo( + await $`echo ${chalk.red('foo')}`, + await $`echo ${chalk.green('bar')}`, + await $`echo ${chalk.bold('baz')}` + ) }) test('spinner works', async () => { diff --git a/test/fixtures/interactive.mjs b/test/fixtures/interactive.mjs index 138b2f62b4..a9484b3247 100755 --- a/test/fixtures/interactive.mjs +++ b/test/fixtures/interactive.mjs @@ -1,13 +1,13 @@ #!/usr/bin/env zx // Copyright 2021 Google LLC -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // https://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/test/index.test.mjs b/test/index.test.mjs index 207f6085d2..54015adada 100755 --- a/test/index.test.mjs +++ b/test/index.test.mjs @@ -1,23 +1,25 @@ // Copyright 2021 Google LLC -// +// // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at -// +// // https://www.apache.org/licenses/LICENSE-2.0 -// +// // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import {inspect} from 'util' +import { inspect } from 'node:util' import chalk from 'chalk' -import {Writable} from 'stream' -import {Socket} from 'net' +import { Writable } from 'node:stream' +import { Socket } from 'node:net' -import {assert, testFactory} from './test-utils.mjs' +import { assert, testFactory } from './test-utils.mjs' +import { ProcessPromise } from '../src/index.mjs' +import { getCtx, runInCtx } from '../src/context.mjs' const test = testFactory('index', import.meta) @@ -99,19 +101,17 @@ test('Quiet mode is working', async () => { }) test('Pipes are working', async () => { - let {stdout} = await $`echo "hello"` + let { stdout } = await $`echo "hello"` .pipe($`awk '{print $1" world"}'`) .pipe($`tr '[a-z]' '[A-Z]'`) assert(stdout === 'HELLO WORLD\n') try { - await $`echo foo` - .pipe(fs.createWriteStream('/tmp/output.txt')) + await $`echo foo`.pipe(fs.createWriteStream('/tmp/output.txt')) assert((await fs.readFile('/tmp/output.txt')).toString() === 'foo\n') let r = $`cat` - fs.createReadStream('/tmp/output.txt') - .pipe(r.stdin) + fs.createReadStream('/tmp/output.txt').pipe(r.stdin) assert((await r).stdout === 'foo\n') } finally { await fs.rm('/tmp/output.txt') @@ -119,7 +119,7 @@ test('Pipes are working', async () => { }) test('question', async () => { - let p = question('foo or bar? ', {choices: ['foo', 'bar']}) + let p = question('foo or bar? ', { choices: ['foo', 'bar'] }) setImmediate(() => { process.stdin.emit('data', 'fo') @@ -136,7 +136,7 @@ test('ProcessPromise', async () => { write: function (chunk, encoding, next) { contents += chunk.toString() next() - } + }, }) let p = $`echo 'test'`.pipe(stream) await p @@ -150,7 +150,29 @@ test('ProcessPromise', async () => { } catch (p) { err = p } - assert.equal(err.message, 'The pipe() method does not take strings. Forgot $?') + assert.equal( + err.message, + 'The pipe() method does not take strings. Forgot $?' + ) +}) + +test('ProcessPromise: inherits native Promise', async () => { + const p1 = $`echo 1` + const p2 = p1.then((v) => v) + const p3 = p2.then((v) => v) + const p4 = p3.catch((v) => v) + const p5 = p1.finally((v) => v) + + assert.ok(p1 instanceof Promise) + assert.ok(p1 instanceof ProcessPromise) + assert.ok(p2 instanceof ProcessPromise) + assert.ok(p3 instanceof ProcessPromise) + assert.ok(p4 instanceof ProcessPromise) + assert.ok(p5 instanceof ProcessPromise) + assert.ok(p1 !== p2) + assert.ok(p2 !== p3) + assert.ok(p3 !== p4) + assert.ok(p5 !== p1) }) test('ProcessOutput thrown as error', async () => { @@ -166,12 +188,16 @@ test('ProcessOutput thrown as error', async () => { }) test('The pipe() throws if already resolved', async () => { - let out, p = $`echo "Hello"` + let out, + p = $`echo "Hello"` await p try { out = await p.pipe($`less`) } catch (err) { - assert.equal(err.message, `The pipe() method shouldn't be called after promise is already resolved!`) + assert.equal( + err.message, + `The pipe() method shouldn't be called after promise is already resolved!` + ) } if (out) { assert.fail('Expected failure!') @@ -179,12 +205,12 @@ test('The pipe() throws if already resolved', async () => { }) test('ProcessOutput::exitCode do not throw', async () => { - assert(await $`grep qwerty README.md`.exitCode !== 0) - assert(await $`[[ -f ${__filename} ]]`.exitCode === 0) + assert((await $`grep qwerty README.md`.exitCode) !== 0) + assert((await $`[[ -f ${__filename} ]]`.exitCode) === 0) }) test('The nothrow() do not throw', async () => { - let {exitCode} = await nothrow($`exit 42`) + let { exitCode } = await nothrow($`exit 42`) assert(exitCode === 42) }) @@ -202,14 +228,14 @@ test('globby available', async () => { assert(await globby('test/fixtures/*'), [ 'test/fixtures/interactive.mjs', 'test/fixtures/no-extension', - 'test/fixtures/no-extension.mjs' + 'test/fixtures/no-extension.mjs', ]) }) test('fetch', async () => { assert( await fetch('https://example.com'), - await fetch('https://example.com', {method: 'GET'}) + await fetch('https://example.com', { method: 'GET' }) ) }) @@ -220,16 +246,16 @@ test('Executes a script from $PATH', async () => { const envPathSeparator = isWindows ? ';' : ':' process.env.PATH += envPathSeparator + path.resolve('/tmp/') - const toPOSIXPath = (_path) => - _path.split(path.sep).join(path.posix.sep) + const toPOSIXPath = (_path) => _path.split(path.sep).join(path.posix.sep) const zxPath = path.resolve('./zx.mjs') const zxLocation = isWindows ? toPOSIXPath(zxPath) : zxPath const scriptCode = `#!/usr/bin/env ${zxLocation}\nconsole.log('The script from path runs.')` try { - await $`echo ${scriptCode}` - .pipe(fs.createWriteStream('/tmp/script-from-path', {mode: 0o744})) + await $`echo ${scriptCode}`.pipe( + fs.createWriteStream('/tmp/script-from-path', { mode: 0o744 }) + ) await $`script-from-path` } finally { process.env.PATH = oldPath @@ -239,21 +265,67 @@ test('Executes a script from $PATH', async () => { test('The cd() works with relative paths', async () => { let cwd = process.cwd() + assert.equal($.cwd, cwd) try { fs.mkdirpSync('/tmp/zx-cd-test/one/two') cd('/tmp/zx-cd-test/one/two') let p1 = $`pwd` + assert.ok($.cwd.endsWith('/two')) + assert.ok(process.cwd().endsWith('/two')) + cd('..') let p2 = $`pwd` + assert.ok($.cwd.endsWith('/one')) + assert.ok(process.cwd().endsWith('/one')) + cd('..') let p3 = $`pwd` + assert.ok(process.cwd().endsWith('/zx-cd-test')) + assert.ok($.cwd.endsWith('/tmp/zx-cd-test')) - let results = (await Promise.all([p1, p2, p3])) - .map(p => path.basename(p.stdout.trim())) + let results = (await Promise.all([p1, p2, p3])).map((p) => + path.basename(p.stdout.trim()) + ) assert.deepEqual(results, ['two', 'one', 'zx-cd-test']) + } catch (e) { + assert(!e, e) + } finally { + fs.rmSync('/tmp/zx-cd-test', { recursive: true }) + cd(cwd) + assert.equal($.cwd, cwd) + } +}) + +test('cd() does not affect parallel contexts', async () => { + let cwd = process.cwd() + let resolve, reject + let promise = new ProcessPromise((...args) => ([resolve, reject] = args)) + + try { + fs.mkdirpSync('/tmp/zx-cd-parallel') + runInCtx({ ...getCtx() }, async () => { + assert.equal($.cwd, cwd) + await sleep(10) + cd('/tmp/zx-cd-parallel') + assert.ok(getCtx().cwd.endsWith('/zx-cd-parallel')) + assert.ok($.cwd.endsWith('/zx-cd-parallel')) + }) + + runInCtx({ ...getCtx() }, async () => { + assert.equal($.cwd, cwd) + assert.equal(getCtx().cwd, cwd) + await sleep(20) + assert.equal(getCtx().cwd, cwd) + assert.ok($.cwd.endsWith('/zx-cd-parallel')) + resolve() + }) + + await promise + } catch (e) { + assert(!e, e) } finally { - fs.rmSync('/tmp/zx-cd-test', {recursive: true}) + fs.rmSync('/tmp/zx-cd-parallel', { recursive: true }) cd(cwd) } }) @@ -279,7 +351,7 @@ test('The signal is passed with kill() method', async () => { }) test('YAML works', async () => { - assert.deepEqual(YAML.parse(YAML.stringify({foo: 'bar'})), {foo: 'bar'}) + assert.deepEqual(YAML.parse(YAML.stringify({ foo: 'bar' })), { foo: 'bar' }) console.log(chalk.greenBright('YAML works')) }) diff --git a/test/test-utils.mjs b/test/test-utils.mjs index 114a625801..d0fb6e9ee4 100644 --- a/test/test-utils.mjs +++ b/test/test-utils.mjs @@ -13,11 +13,11 @@ // limitations under the License. import chalk from 'chalk' -import {fileURLToPath} from 'node:url' -import {relative} from 'node:path' -import {sleep} from '../src/index.mjs' +import { fileURLToPath } from 'node:url' +import { relative } from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' -export {strict as assert} from 'assert' +export { strict as assert } from 'assert' let queued = 0 let passed = 0 @@ -29,7 +29,7 @@ let focused = 0 const singleThread = (fn) => { let p = Promise.resolve() return async function (...args) { - return (p = p.catch(_ => _).then(() => fn.call(this, ...args))) + return (p = p.catch((_) => _).then(() => fn.call(this, ...args))) } } @@ -42,12 +42,17 @@ const log = (name, group, err, file = '') => { console.log(err) console.log(file) } - console.log('\n' + chalk[err ? 'bgRedBright' : 'bgGreenBright'].black(`${chalk.inverse(' ' + group + ' ')} ${name} `)) + console.log( + '\n' + + chalk[err ? 'bgRedBright' : 'bgGreenBright'].black( + `${chalk.inverse(' ' + group + ' ')} ${name} ` + ) + ) } export const test = async function (name, cb, ms, focus, skip) { const filter = RegExp(process.argv[3] || '.') - const {group, meta} = this + const { group, meta } = this const file = meta ? relative(process.cwd(), fileURLToPath(meta.url)) : '' if (filter.test(name) || filter.test(group) || filter.test(file)) { @@ -84,21 +89,25 @@ export const skip = async function (name, cb, ms) { return test.call(this, name, cb, ms, false, true) } -export const testFactory = (group, meta) => Object.assign( - test.bind({group, meta}), { +export const testFactory = (group, meta) => + Object.assign(test.bind({ group, meta }), { test, skip, only, group, - meta + meta, }) export const printTestDigest = () => { - console.log('\n' + - chalk.black.bgYellowBright(` zx version is ${require('../package.json').version} `) + '\n' + - chalk.greenBright(` 🍺 tests passed: ${passed} `) + - (skipped ? chalk.yellowBright(`\n 🚧 skipped: ${skipped} `) : '') + - (failed ? chalk.redBright(`\n ❌ failed: ${failed} `) : '') + console.log( + '\n' + + chalk.black.bgYellowBright( + ` zx version is ${require('../package.json').version} ` + ) + + '\n' + + chalk.greenBright(` 🍺 tests passed: ${passed} `) + + (skipped ? chalk.yellowBright(`\n 🚧 skipped: ${skipped} `) : '') + + (failed ? chalk.redBright(`\n ❌ failed: ${failed} `) : '') ) failed && process.exit(1) } diff --git a/test/zx.test.mjs b/test/zx.test.mjs index f56c4b4f8f..cf297fb31a 100644 --- a/test/zx.test.mjs +++ b/test/zx.test.mjs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {assert, testFactory} from './test-utils.mjs' +import { assert, testFactory } from './test-utils.mjs' const test = testFactory('zx', import.meta) @@ -71,7 +71,10 @@ test('Eval script from https ref', async () => { test('Scripts with no extension', async () => { await $`node zx.mjs test/fixtures/no-extension` - assert.match((await fs.readFile('test/fixtures/no-extension.mjs')).toString(), /Test file to verify no-extension didn't overwrite similarly name .mjs file./) + assert.match( + (await fs.readFile('test/fixtures/no-extension.mjs')).toString(), + /Test file to verify no-extension didn't overwrite similarly name .mjs file./ + ) }) test('The require() is working from stdin', async () => { diff --git a/zx.mjs b/zx.mjs index ca6b55d8b8..5f5cac98ef 100755 --- a/zx.mjs +++ b/zx.mjs @@ -21,6 +21,7 @@ import {basename, dirname, extname, join, resolve} from 'node:path' import url from 'node:url' import {$, argv, fetch, ProcessOutput, registerGlobals} from './src/index.mjs' +import {randomId} from './src/util.mjs' await async function main() { registerGlobals() @@ -80,7 +81,7 @@ async function scriptFromStdin() { if (script.length > 0) { let filepath = join( tmpdir(), - Math.random().toString(36).substr(2) + '.mjs' + randomId() + '.mjs' ) await fs.mkdtemp(filepath) await writeAndImport(script, filepath, join(process.cwd(), 'stdin.mjs')) @@ -115,7 +116,7 @@ async function importPath(filepath, origin = filepath) { if (ext === '') { let tmpFilename = fs.existsSync(`${filepath}.mjs`) ? - `${basename(filepath)}-${Math.random().toString(36).substr(2)}.mjs` : + `${basename(filepath)}-${randomId()}.mjs` : `${basename(filepath)}.mjs` return await writeAndImport(