Commit 8eb0493

Anton Medvedev <anton@medv.io>
2022-05-31 02:04:39
Refactor context
1 parent 2e45a01
src/context.ts
@@ -1,33 +0,0 @@
-// 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: any
-
-const storage = new AsyncLocalStorage<any>()
-
-export function getCtx() {
-  return storage.getStore()
-}
-export function setRootCtx(ctx: any) {
-  storage.enterWith(ctx)
-  root = ctx
-}
-export function getRootCtx() {
-  return root
-}
-export function runInCtx(ctx: any, cb: any) {
-  return storage.run(ctx, cb)
-}
src/core.ts
@@ -12,91 +12,189 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import { ChalkInstance } from 'chalk'
 import {
   ChildProcessByStdio,
   SpawnOptionsWithStdioTuple,
   StdioPipe,
 } from 'child_process'
+import { AsyncLocalStorage } from 'node:async_hooks'
 import { Readable, Writable } from 'node:stream'
 import { inspect, promisify } from 'node:util'
 import { spawn } from 'node:child_process'
-
+import assert from 'node:assert'
+import { ChalkInstance } from 'chalk'
 import { chalk, which } from './goods.js'
-import { runInCtx, getCtx, setRootCtx } from './context.js'
-import { printStd, printCmd } from './print.js'
-import { quote, substitute } from './guards.js'
-
-import psTreeModule from 'ps-tree'
-
-const psTree = promisify(psTreeModule)
-
-export function $(pieces: TemplateStringsArray, ...args: any[]) {
-  let resolve, reject
-  let promise = new ProcessPromise((...args) => ([resolve, reject] = 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: any) => quote(substitute(x))).join(' ')
-    } else {
-      s = quote(substitute(args[i]))
-    }
-    cmd += s + pieces[++i]
-  }
-
-  promise.ctx = {
-    ...getCtx(),
-    cmd,
-    __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($)
+import { printCmd } from './print.js'
+import { noop, quote, substitute, psTree, exitCodeInfo } from './util.js'
 
-$.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) {}
+type Shell = (pieces: TemplateStringsArray, ...args: any[]) => ProcessPromise
 
 type Options = {
-  nothrow: boolean
   verbose: boolean
-  cmd: string
   cwd: string
   env: NodeJS.ProcessEnv
+  shell: string | boolean
   prefix: string
-  shell: string
-  maxBuffer: number
-  __from: string
-  resolve: any
-  reject: any
+  quote: typeof quote
+  spawn: typeof spawn
 }
 
+const storage = new AsyncLocalStorage<Options>()
+
+export const $ = new Proxy<Shell & Options>(
+  function (pieces, ...args) {
+    let from = new Error().stack!.split(/^\s*at\s/m)[2].trim()
+    let resolve: Resolve, reject: Resolve
+    let promise = new ProcessPromise((...args) => ([resolve, reject] = args))
+    let cmd = pieces[0],
+      i = 0
+    while (i < args.length) {
+      let s
+      if (Array.isArray(args[i])) {
+        s = args[i].map((x: any) => $.quote(substitute(x))).join(' ')
+      } else {
+        s = $.quote(substitute(args[i]))
+      }
+      cmd += s + pieces[++i]
+    }
+    promise._bind(cmd, $.cwd, from, resolve!, reject!)
+    // Make sure all subprocesses are started, if not explicitly by await or then().
+    setImmediate(() => promise._run())
+    return promise
+  } as Shell & Options,
+  {
+    set(_, key, value) {
+      let context = storage.getStore()
+      assert(context)
+      Reflect.set(context, key, value)
+      return true
+    },
+    get(_, key) {
+      let context = storage.getStore()
+      assert(context)
+      return Reflect.get(context, key)
+    },
+  }
+)
+
+void (function init() {
+  storage.enterWith({
+    verbose: true,
+    cwd: process.cwd(),
+    env: process.env,
+    shell: true,
+    prefix: '',
+    quote,
+    spawn,
+  })
+  try {
+    $.shell = which.sync('bash')
+    $.prefix = 'set -euo pipefail;'
+  } catch (err) {
+    // ¯\_(ツ)_/¯
+  }
+})()
+
+type Resolve = (out: ProcessOutput) => void
+
 export class ProcessPromise extends Promise<ProcessOutput> {
   child?: ChildProcessByStdio<Writable, Readable, Readable>
-  _resolved = false
+  private _command = ''
+  private _cwd = ''
+  private _from = ''
+  private _resolve: Resolve = noop
+  private _reject: Resolve = noop
+  _nothrow = false
+  _quiet = false
+  private _resolved = false
   _inheritStdin = true
   _piped = false
-  _prerun: any = undefined
-  _postrun: any = undefined
-  ctx?: Options
+  _prerun = noop
+  _postrun = noop
+
+  _bind(
+    cmd: string,
+    cwd: string,
+    from: string,
+    resolve: Resolve,
+    reject: Resolve
+  ) {
+    this._command = cmd
+    this._cwd = cwd
+    this._from = from
+    this._resolve = resolve
+    this._reject = reject
+  }
+
+  _run() {
+    if (this.child) return // The _run() called from two places: then() and setTimeout().
+    this._prerun() // In case $1.pipe($2), the $2 returned, and on $2._run() invoke $1._run().
+
+    let context = storage.getStore()
+    assert(context)
+    storage.run(context, () => {
+      if ($.verbose && !this._quiet) {
+        printCmd(this._command)
+      }
+
+      let options: SpawnOptionsWithStdioTuple<any, StdioPipe, StdioPipe> = {
+        cwd: this._cwd,
+        shell: typeof $.shell === 'string' ? $.shell : true,
+        stdio: [this._inheritStdin ? 'inherit' : 'pipe', 'pipe', 'pipe'],
+        windowsHide: true,
+        env: $.env,
+      }
+      let child: ChildProcessByStdio<Writable, Readable, Readable> = spawn(
+        $.prefix + this._command,
+        options
+      )
+
+      child.on('close', (code, signal) => {
+        let message = `exit code: ${code}`
+        if (code != 0 || signal != null) {
+          message = `${stderr || '\n'}    at ${this._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,
+        })
+        if (code === 0 || this._nothrow) {
+          this._resolve(output)
+        } else {
+          this._reject(output)
+        }
+        this._resolved = true
+      })
+
+      let stdout = '',
+        stderr = '',
+        combined = ''
+      let onStdout = (data: any) => {
+        if ($.verbose && !this._quiet) process.stdout.write(data)
+        stdout += data
+        combined += data
+      }
+      let onStderr = (data: any) => {
+        if ($.verbose && !this._quiet) process.stderr.write(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
+      this._postrun() // In case $1.pipe($2), after both subprocesses are running, we can pipe $1.stdout to $2.stdin.
+    })
+  }
 
   get stdin() {
     this._inheritStdin = false
@@ -171,91 +269,14 @@ export class ProcessPromise extends Promise<ProcessOutput> {
       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().
-
-    runInCtx(this.ctx, () => {
-      const {
-        nothrow,
-        cmd,
-        cwd,
-        env,
-        prefix,
-        shell,
-        maxBuffer,
-        __from,
-        resolve,
-        reject,
-      } = this.ctx!
-
-      printCmd(cmd)
-
-      let options: SpawnOptionsWithStdioTuple<any, StdioPipe, StdioPipe> = {
-        cwd,
-        shell: typeof shell === 'string' ? shell : true,
-        stdio: [this._inheritStdin ? 'inherit' : 'pipe', 'pipe', 'pipe'],
-        windowsHide: true,
-        // TODO: Surprise: maxBuffer have no effect for spawn.
-        // maxBuffer,
-        env,
-      }
-      let child: ChildProcessByStdio<Writable, Readable, Readable> = spawn(
-        prefix + cmd,
-        options
-      )
-
-      child.on('close', (code, signal) => {
-        let message = `exit code: ${code}`
-        if (code != 0 || signal != null) {
-          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: any) => {
-        printStd(data)
-        stdout += data
-        combined += data
-      }
-      let onStderr = (data: any) => {
-        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: number | null = null
-  #signal: NodeJS.Signals | null = null
-  #stdout = ''
-  #stderr = ''
-  #combined = ''
+  readonly #code: number | null
+  readonly #signal: NodeJS.Signals | null
+  readonly #stdout: string
+  readonly #stderr: string
+  readonly #combined: string
 
   constructor({
     code,
@@ -316,39 +337,18 @@ export class ProcessOutput extends Error {
   }
 }
 
-function exitCodeInfo(exitCode: number | null): string | undefined {
-  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 || -1]
+export function nothrow(promise: ProcessPromise) {
+  promise._nothrow = true
+  return promise
+}
+
+export function quiet(promise: ProcessPromise) {
+  promise._quiet = true
+  return promise
+}
+
+export function within(callback: () => void) {
+  let context = storage.getStore()
+  assert(context)
+  storage.run({ ...context }, callback)
 }
src/experimental.ts
@@ -15,9 +15,6 @@
 import { ProcessOutput, $ } from './core.js'
 import { sleep } from './goods.js'
 import { isString } from './util.js'
-import { getCtx, runInCtx } from './context.js'
-
-export { getCtx, runInCtx }
 
 // Retries a command a few times. Will return after the first
 // successful attempt, or will throw after specifies attempts count.
src/globals.ts
@@ -17,5 +17,6 @@ declare global {
   var quiet: typeof _.quiet
   var sleep: typeof _.sleep
   var which: typeof _.which
+  var within: typeof _.within
   var YAML: typeof _.YAML
 }
src/goods.ts
@@ -16,8 +16,7 @@ import * as globbyModule from 'globby'
 import minimist from 'minimist'
 import { setTimeout as sleep } from 'node:timers/promises'
 import nodeFetch, { RequestInfo, RequestInit } from 'node-fetch'
-import { getCtx, getRootCtx } from './context.js'
-import { colorize } from './print.js'
+import { colorize } from './util.js'
 
 export { default as chalk } from 'chalk'
 export { default as fs } from 'fs-extra'
@@ -40,7 +39,7 @@ globbyModule)
 export const glob = globby
 
 export async function fetch(url: RequestInfo, init?: RequestInit) {
-  if (getCtx().verbose) {
+  if ($.verbose) {
     if (typeof init !== 'undefined') {
       console.log('$', colorize(`fetch ${url}`), init)
     } else {
@@ -50,8 +49,7 @@ export async function fetch(url: RequestInfo, init?: RequestInit) {
   return nodeFetch(url, init)
 }
 
-export function cd(path: string) {
-  if (getCtx().verbose) console.log('$', colorize(`cd ${path}`))
-  process.chdir(path)
-  getRootCtx().cwd = getCtx().cwd = process.cwd()
+export function cd(dir: string) {
+  if ($.verbose) console.log('$', colorize(`cd ${dir}`))
+  $.cwd = path.resolve($.cwd, dir)
 }
src/guards.ts
@@ -1,42 +0,0 @@
-// 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.js'
-import { ProcessPromise } from './core.js'
-
-export function quote(arg: string) {
-  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 substitute(arg: ProcessPromise | any) {
-  if (arg?.stdout) {
-    return arg.stdout.replace(/\n$/, '')
-  }
-  return `${arg}`
-}
src/hooks.ts
@@ -1,25 +0,0 @@
-// 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 { ProcessPromise } from './core.js'
-
-export function nothrow(promise: ProcessPromise) {
-  promise.ctx!.nothrow = true
-  return promise
-}
-
-export function quiet(promise: ProcessPromise) {
-  promise.ctx!.verbose = false
-  return promise
-}
src/index.ts
@@ -26,9 +26,15 @@ import {
   YAML,
   os,
 } from './goods.js'
-import { nothrow, quiet } from './hooks.js'
 import { question } from './question.js'
-import { $, ProcessPromise, ProcessOutput } from './core.js'
+import {
+  $,
+  ProcessPromise,
+  ProcessOutput,
+  nothrow,
+  quiet,
+  within,
+} from './core.js'
 
 export {
   $,
@@ -48,5 +54,6 @@ export {
   quiet,
   sleep,
   which,
+  within,
   YAML,
 }
src/print.ts
@@ -12,11 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import { getCtx } from './context.js'
-import { chalk } from './goods.js'
+import { colorize } from './util.js'
 
 export function printCmd(cmd: string) {
-  if (!getCtx()?.verbose) return
   if (/\n/.test(cmd)) {
     console.log(
       cmd
@@ -28,15 +26,3 @@ export function printCmd(cmd: string) {
     console.log('$', colorize(cmd))
   }
 }
-
-export function printStd(data: any, err?: any) {
-  if (!getCtx()?.verbose) return
-  if (data) process.stdout.write(data)
-  if (err) process.stderr.write(err)
-}
-
-export function colorize(cmd: string) {
-  return cmd.replace(/^[\w_.-]+(\s|$)/, (substr) => {
-    return chalk.greenBright(substr)
-  })
-}
src/util.ts
@@ -12,6 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import chalk from 'chalk'
+import { promisify } from 'node:util'
+import psTreeModule from 'ps-tree'
+import { ProcessPromise } from './core.js'
+
+export const psTree = promisify(psTreeModule)
+
+export function noop() {}
+
 export function randomId() {
   return Math.random().toString(36).slice(2)
 }
@@ -19,3 +28,72 @@ export function randomId() {
 export function isString(obj: any) {
   return typeof obj === 'string'
 }
+
+export function quote(arg: string) {
+  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 substitute(arg: ProcessPromise | any) {
+  if (arg?.stdout) {
+    return arg.stdout.replace(/\n$/, '')
+  }
+  return `${arg}`
+}
+
+export function colorize(cmd: string) {
+  return cmd.replace(/^[\w_.-]+(\s|$)/, (substr) => {
+    return chalk.greenBright(substr)
+  })
+}
+
+export function exitCodeInfo(exitCode: number | null): string | undefined {
+  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 || -1]
+}
test/fixtures/markdown.md
@@ -25,6 +25,7 @@ VAR=$(echo hello)
 echo "$VAR"
 ```
 
+    // ignore
     console.log('world')
 
 Other code blocks are ignored:
test/experimental.test.js
@@ -15,7 +15,6 @@
 import { test } from 'uvu'
 import * as assert from 'uvu/assert'
 import '../build/globals.js'
-
 import {
   echo,
   retry,
test/index.test.js
@@ -15,12 +15,12 @@
 import { test } from 'uvu'
 import * as assert from 'uvu/assert'
 import { inspect } from 'node:util'
-import chalk from 'chalk'
 import { Writable } from 'node:stream'
 import { Socket } from 'node:net'
 import '../build/globals.js'
 import { ProcessPromise } from '../build/index.js'
-import {getCtx, runInCtx} from '../build/experimental.js'
+
+$.verbose = false
 
 test('only stdout is used during command substitution', async () => {
   let hello = await $`echo Error >&2; echo Hello`
@@ -45,7 +45,6 @@ test('arguments are quoted', async () => {
 })
 
 test('undefined and empty string correctly quoted', async () => {
-  $.verbose = true
   assert.is((await $`echo -n ${undefined}`).toString(), 'undefined')
   assert.is((await $`echo -n ${''}`).toString(), '')
 })
@@ -66,7 +65,6 @@ test('pipefail is on', async () => {
   try {
     p = await $`cat /dev/not_found | sort`
   } catch (e) {
-    console.log('Caught an exception -> ok')
     p = e
   }
   assert.is.not(p.exitCode, 0)
@@ -214,8 +212,6 @@ test('globby available', async () => {
   assert.is(typeof globby.isDynamicPattern, 'function')
   assert.is(typeof globby.isGitIgnored, 'function')
   assert.is(typeof globby.isGitIgnoredSync, 'function')
-  console.log(chalk.greenBright('globby available'))
-
   assert.equal(await globby('*.md'), ['README.md'])
 })
 
@@ -258,18 +254,16 @@ test('cd() works with relative paths', async () => {
     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'))
+    assert.match($.cwd, '/two')
+    assert.equal(process.cwd(), cwd)
 
     cd('..')
     let p2 = $`pwd`
-    assert.ok($.cwd.endsWith('/one'))
-    assert.ok(process.cwd().endsWith('/one'))
+    assert.match($.cwd, '/one')
 
     cd('..')
     let p3 = $`pwd`
-    assert.ok(process.cwd().endsWith('/zx-cd-test'))
-    assert.ok($.cwd.endsWith('/tmp/zx-cd-test'))
+    assert.match($.cwd, '/tmp/zx-cd-test')
 
     let results = (await Promise.all([p1, p2, p3])).map((p) =>
       path.basename(p.stdout.trim())
@@ -285,27 +279,24 @@ test('cd() works with relative paths', async () => {
   }
 })
 
-test('cd() does not affect parallel contexts', async () => {
+test('cd() does affect parallel contexts', async () => {
   let cwd = process.cwd()
   let resolve, reject
-  let promise = new ProcessPromise((...args) => ([resolve, reject] = args))
+  let promise = new Promise((...args) => ([resolve, reject] = args))
 
   try {
     fs.mkdirpSync('/tmp/zx-cd-parallel')
-    runInCtx({ ...getCtx() }, async () => {
+    within(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 () => {
+    within(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'))
+      assert.ok(!$.cwd.endsWith('/zx-cd-parallel'))
       resolve()
     })
 
@@ -346,4 +337,49 @@ test('which available', async () => {
   assert.is(which.sync('npm'), await which('npm'))
 })
 
+test('within() works', async () => {
+  let resolve, reject
+  let promise = new Promise((...args) => ([resolve, reject] = args))
+
+  function yes() {
+    assert.equal($.verbose, true)
+    resolve()
+  }
+
+  $.verbose = false
+  assert.equal($.verbose, false)
+
+  within(() => {
+    $.verbose = true
+  })
+  assert.equal($.verbose, false)
+
+  within(async () => {
+    $.verbose = true
+    setTimeout(yes, 10)
+  })
+  assert.equal($.verbose, false)
+
+  await promise
+})
+
+test('within() restores previous cwd', async () => {
+  let resolve, reject
+  let promise = new Promise((...args) => ([resolve, reject] = args))
+
+  let pwd = await $`pwd`
+
+  within(async () => {
+    $.verbose = false
+    cd('/tmp')
+    setTimeout(async () => {
+      assert.match((await $`pwd`).stdout, '/tmp')
+      resolve()
+    }, 1000)
+  })
+
+  assert.equal((await $`pwd`).stdout, pwd.stdout)
+  await promise
+})
+
 test.run()
README.md
@@ -225,6 +225,7 @@ if ((await nothrow($`[[ -d path ]]`)).exitCode == 0) {
   ...
 }
 ```
+
 ### `quiet()`
 
 Changes behavior of `$` to disable verbose output.
@@ -240,6 +241,34 @@ await quiet($`grep something from-file`)
 // Command and output will not be displayed.
 ```
 
+### `within()`
+
+Create a new async context.
+
+```ts
+function within(callback): void
+```
+
+Usage:
+
+```js
+$.verbose = true
+await $`pwd` // => /home/path
+
+within(async () => {
+  $.verbose = false
+  cd('/tmp')
+  
+  setTimeout(async () => {
+    await $`pwd` // => /tmp
+    assert($.verbose == false)
+  }, 1000)
+})
+
+await $`pwd` // => /home/path
+assert($.verbose == true)
+```
+
 ## Packages
 
 Following packages are available without importing inside scripts.
@@ -438,28 +467,6 @@ 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
@@ -592,31 +599,6 @@ jobs:
         EOF
 ```
 
-### Customizing
-
-You can build your very own custom zx version that best suits your needs.
-```ts
-// index.js
-import {$} from 'zx'
-
-$.quote = () => {}
-$.extraFn = async () => {
-    await $`ping example.com`
-    await $`npm whoami`
-}
-
-// cli.js
-#!/usr/bin/env node
-
-import './index.js'
-import 'zx/cli'
-
-// script.mjs
-await $.extraFn()
-
-// zx-custom script.mjs
-```
-
 ## License
 
 [Apache-2.0](LICENSE)