Commit a741743

Anton Golub <antongolub@antongolub.com>
2022-05-26 22:32:33
refactor: introduce internal bound context (#397)
1 parent 6ba2345
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)
+}
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]
+}
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))
 }
src/globals.mjs
@@ -1,3 +1,3 @@
-import {registerGlobals} from './index.mjs'
+import { registerGlobals } from './index.mjs'
 
 registerGlobals()
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()
+}
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}`
+}
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
+}
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<ProcessOutput>
+    (
+      pieces: TemplateStringsArray,
+      ...args: any[]
+    ): ProcessPromise<ProcessOutput>
   }
 
   interface $ extends ZxTemplate {
@@ -45,7 +48,9 @@ declare module 'zx' {
     readonly stderr: Readable
     readonly exitCode: Promise<number>
 
-    pipe(dest: ProcessPromise<ProcessOutput> | Writable): ProcessPromise<ProcessOutput>
+    pipe(
+      dest: ProcessPromise<ProcessOutput> | Writable
+    ): ProcessPromise<ProcessOutput>
 
     kill(signal?: string | number): Promise<void>
   }
@@ -62,10 +67,14 @@ declare module 'zx' {
   export type QuestionOptions = { choices: string[] }
 
   type cd = (path: string) => void
-  type nothrow = (p: ProcessPromise<ProcessOutput>) => ProcessPromise<ProcessOutput>
+  type nothrow = (
+    p: ProcessPromise<ProcessOutput>
+  ) => ProcessPromise<ProcessOutput>
   type question = (query?: string, options?: QuestionOptions) => Promise<string>
   type sleep = (ms: number) => Promise<void>
-  type quiet = (p: ProcessPromise<ProcessOutput>) => ProcessPromise<ProcessOutput>
+  type quiet = (
+    p: ProcessPromise<ProcessOutput>
+  ) => ProcessPromise<ProcessOutput>
 
   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
 }
-
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]
-}
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)
+  })
+}
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)
+    })
+  )
+}
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'
+}
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.
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 () => {
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'))
 })
 
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)
 }
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 () => {
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"
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
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(