Commit 8c2aea8

Anton Medvedev <anton@medv.io>
2022-05-29 17:38:02
Migrate to TypeScript
1 parent 9012894
src/context.mjs → src/context.ts
@@ -14,20 +14,20 @@
 
 import { AsyncLocalStorage } from 'node:async_hooks'
 
-let root
+let root: any
 
-const storage = new AsyncLocalStorage()
+const storage = new AsyncLocalStorage<any>()
 
 export function getCtx() {
   return storage.getStore()
 }
-export function setRootCtx(ctx) {
+export function setRootCtx(ctx: any) {
   storage.enterWith(ctx)
   root = ctx
 }
 export function getRootCtx() {
   return root
 }
-export function runInCtx(ctx, cb) {
+export function runInCtx(ctx: any, cb: any) {
   return storage.run(ctx, cb)
 }
src/core.mjs → src/core.ts
@@ -12,24 +12,44 @@
 // 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 { Readable, Writable } from 'node:stream'
 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 { chalk, which } from './goods.js'
+import { getCtx, runInCtx, 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 $(...args) {
+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: formatCmd(...args),
-    __from: new Error().stack.split(/^\s*at\s/m)[2].trim(),
+    cmd,
+    __from: new Error().stack!.split(/^\s*at\s/m)[2].trim(),
     resolve,
     reject,
   }
@@ -53,29 +73,50 @@ try {
   $.prefix = 'set -euo pipefail;'
 } catch (e) {}
 
-export class ProcessPromise extends Promise {
-  child = undefined
+type Options = {
+  nothrow: boolean
+  verbose: boolean
+  cmd: string
+  cwd: string
+  env: NodeJS.ProcessEnv
+  prefix: string
+  shell: string
+  maxBuffer: number
+  __from: string
+  resolve: any
+  reject: any
+}
+
+export class ProcessPromise extends Promise<ProcessOutput> {
+  child?: ChildProcessByStdio<Writable, Readable, Readable>
   _resolved = false
   _inheritStdin = true
   _piped = false
-  _prerun = undefined
-  _postrun = undefined
+  _prerun: any = undefined
+  _postrun: any = undefined
+  ctx?: Options
 
   get stdin() {
     this._inheritStdin = false
     this._run()
+    if (!this.child)
+      throw new Error('Access to stdin without creation a subprocess.')
     return this.child.stdin
   }
 
   get stdout() {
     this._inheritStdin = false
     this._run()
+    if (!this.child)
+      throw new Error('Access to stdout without creation a subprocess.')
     return this.child.stdout
   }
 
   get stderr() {
     this._inheritStdin = false
     this._run()
+    if (!this.child)
+      throw new Error('Access to stderr without creation a subprocess.')
     return this.child.stderr
   }
 
@@ -86,11 +127,11 @@ export class ProcessPromise extends Promise {
     )
   }
 
-  pipe(dest) {
+  pipe(dest: Writable | ProcessPromise | string) {
     if (typeof dest === 'string') {
       throw new Error('The pipe() method does not take strings. Forgot $?')
     }
-    if (this._resolved === true) {
+    if (this._resolved) {
       throw new Error(
         "The pipe() method shouldn't be called after promise is already resolved!"
       )
@@ -99,7 +140,13 @@ export class ProcessPromise extends Promise {
     if (dest instanceof ProcessPromise) {
       dest._inheritStdin = false
       dest._prerun = this._run.bind(this)
-      dest._postrun = () => this.stdout.pipe(dest.child.stdin)
+      dest._postrun = () => {
+        if (!dest.child)
+          throw new Error(
+            'Access to stdin of pipe destination without creation a subprocess.'
+          )
+        this.stdout.pipe(dest.child.stdin)
+      }
       return dest
     } else {
       this._postrun = () => this.stdout.pipe(dest)
@@ -109,10 +156,13 @@ export class ProcessPromise extends Promise {
 
   async kill(signal = 'SIGTERM') {
     this.catch((_) => _)
+    if (!this.child)
+      throw new Error('Trying to kill child process without creating one.')
+    if (!this.child.pid) throw new Error('Child process pid is undefined.')
     let children = await psTree(this.child.pid)
     for (const p of children) {
       try {
-        process.kill(p.PID, signal)
+        process.kill(+p.PID, signal)
       } catch (e) {}
     }
     try {
@@ -136,18 +186,23 @@ export class ProcessPromise extends Promise {
         __from,
         resolve,
         reject,
-      } = this.ctx
+      } = this.ctx!
 
       printCmd(cmd)
 
-      let child = spawn(prefix + cmd, {
+      let options: SpawnOptionsWithStdioTuple<any, StdioPipe, StdioPipe> = {
         cwd,
         shell: typeof shell === 'string' ? shell : true,
         stdio: [this._inheritStdin ? 'inherit' : 'pipe', 'pipe', 'pipe'],
         windowsHide: true,
-        maxBuffer,
+        // 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 = `${stderr || '\n'}    at ${__from}`
@@ -172,12 +227,12 @@ export class ProcessPromise extends Promise {
       let stdout = '',
         stderr = '',
         combined = ''
-      let onStdout = (data) => {
+      let onStdout = (data: any) => {
         printStd(data)
         stdout += data
         combined += data
       }
-      let onStderr = (data) => {
+      let onStderr = (data: any) => {
         printStd(null, data)
         stderr += data
         combined += data
@@ -191,13 +246,27 @@ export class ProcessPromise extends Promise {
 }
 
 export class ProcessOutput extends Error {
-  #code = null
-  #signal = null
+  #code: number | null = null
+  #signal: NodeJS.Signals | null = null
   #stdout = ''
   #stderr = ''
   #combined = ''
 
-  constructor({ code, signal, stdout, stderr, combined, message }) {
+  constructor({
+    code,
+    signal,
+    stdout,
+    stderr,
+    combined,
+    message,
+  }: {
+    code: number | null
+    signal: NodeJS.Signals | null
+    stdout: string
+    stderr: string
+    combined: string
+    message: string
+  }) {
     super(message)
     this.#code = code
     this.#signal = signal
@@ -227,7 +296,8 @@ export class ProcessOutput extends Error {
   }
 
   [inspect.custom]() {
-    let stringify = (s, c) => (s.length === 0 ? "''" : c(inspect(s)))
+    let stringify = (s: string, c: ChalkInstance) =>
+      s.length === 0 ? "''" : c(inspect(s))
     return `ProcessOutput {
   stdout: ${stringify(this.stdout, chalk.green)},
   stderr: ${stringify(this.stderr, chalk.red)},
@@ -241,7 +311,7 @@ export class ProcessOutput extends Error {
   }
 }
 
-function exitCodeInfo(exitCode) {
+function exitCodeInfo(exitCode: number | null): string | undefined {
   return {
     2: 'Misuse of shell builtins',
     126: 'Invoked command cannot execute',
@@ -275,5 +345,5 @@ function exitCodeInfo(exitCode) {
     155: 'Profiling timer expired',
     157: 'Pollable event',
     159: 'Bad syscall',
-  }[exitCode]
+  }[exitCode || -1]
 }
src/experimental.mjs → src/experimental.ts
@@ -12,17 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import { ProcessOutput, $ } from './core.mjs'
-import { sleep } from './goods.mjs'
-import { isString } from './util.mjs'
-import { getCtx, runInCtx } from './context.mjs'
+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.
 export function retry(count = 5, delay = 0) {
-  return async function (cmd, ...args) {
+  return async function (cmd: TemplateStringsArray, ...args: any[]) {
     while (count-- > 0)
       try {
         return await $(cmd, ...args)
@@ -30,12 +30,13 @@ export function retry(count = 5, delay = 0) {
         if (count === 0) throw p
         if (delay) await sleep(delay)
       }
+    return
   }
 }
 
 // Runs and sets a timeout for a cmd
-export function withTimeout(timeout, signal) {
-  return async function (cmd, ...args) {
+export function withTimeout(timeout: number, signal: string) {
+  return async function (cmd: TemplateStringsArray, ...args: any[]) {
     let p = $(cmd, ...args)
     if (!timeout) return p
 
@@ -46,7 +47,7 @@ export function withTimeout(timeout, signal) {
 }
 
 // A console.log() alternative which can take ProcessOutput.
-export function echo(pieces, ...args) {
+export function echo(pieces: TemplateStringsArray, ...args: any[]) {
   let msg
   let lastIdx = pieces.length - 1
   if (
@@ -62,7 +63,7 @@ export function echo(pieces, ...args) {
   console.log(msg)
 }
 
-function stringify(arg) {
+function stringify(arg: ProcessOutput | any) {
   if (arg instanceof ProcessOutput) {
     return arg.toString().replace(/\n$/, '')
   }
src/globals.mjs
@@ -1,3 +0,0 @@
-import { registerGlobals } from './index.mjs'
-
-registerGlobals()
src/globals.ts
@@ -0,0 +1,3 @@
+import { registerGlobals } from './index.js'
+
+registerGlobals()
src/goods.mjs → src/goods.ts
@@ -15,9 +15,9 @@
 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'
+import nodeFetch, { RequestInfo, RequestInit } from 'node-fetch'
+import { getCtx, getRootCtx } from './context.js'
+import { colorize } from './print.js'
 
 export { default as chalk } from 'chalk'
 export { default as fs } from 'fs-extra'
@@ -29,13 +29,17 @@ 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 globby = Object.assign(function globby(
+  patterns: string | readonly string[],
+  options?: globbyModule.Options
+) {
+  return globbyModule.globby(patterns, options)
+},
+globbyModule)
 
 export const glob = globby
 
-export async function fetch(url, init) {
+export async function fetch(url: RequestInfo, init?: RequestInit) {
   if (getCtx().verbose) {
     if (typeof init !== 'undefined') {
       console.log('$', colorize(`fetch ${url}`), init)
@@ -46,7 +50,7 @@ export async function fetch(url, init) {
   return nodeFetch(url, init)
 }
 
-export function cd(path) {
+export function cd(path: string) {
   if (getCtx().verbose) console.log('$', colorize(`cd ${path}`))
   process.chdir(path)
   getRootCtx().cwd = getCtx().cwd = process.cwd()
src/guards.mjs → src/guards.ts
@@ -12,9 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import { getCtx } from './context.mjs'
+import { getCtx } from './context.js'
+import { ProcessPromise } from './core.js'
 
-export function quote(arg) {
+export function quote(arg: string) {
   if (/^[a-z0-9/_.-]+$/i.test(arg) || arg === '') {
     return arg
   }
@@ -33,24 +34,7 @@ export function quote(arg) {
   )
 }
 
-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) {
+export function substitute(arg: ProcessPromise | any) {
   if (arg?.stdout) {
     return arg.stdout.replace(/\n$/, '')
   }
src/hooks.mjs → src/hooks.ts
@@ -12,12 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-export function nothrow(promise) {
-  promise.ctx.nothrow = true
+import { ProcessPromise } from './core.js'
+
+export function nothrow(promise: ProcessPromise) {
+  promise.ctx!.nothrow = true
   return promise
 }
 
-export function quiet(promise) {
-  promise.ctx.verbose = false
+export function quiet(promise: ProcessPromise) {
+  promise.ctx!.verbose = false
   return promise
 }
src/index.d.ts
@@ -1,151 +0,0 @@
-// 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.
-
-declare module 'zx' {
-  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 * as _yaml from 'yaml'
-  import _fetch from 'node-fetch'
-  import { ParsedArgs } from 'minimist'
-  import _which from 'which'
-
-  export interface ZxTemplate {
-    (
-      pieces: TemplateStringsArray,
-      ...args: any[]
-    ): ProcessPromise<ProcessOutput>
-  }
-
-  interface $ extends ZxTemplate {
-    verbose: boolean
-    shell: string
-    prefix: string
-    quote: (input: string) => string
-    spawn: typeof spawn
-    maxBuffer?: number | undefined
-  }
-
-  export interface ProcessPromise<T> extends Promise<T> {
-    child: ChildProcess
-    readonly stdin: Writable
-    readonly stdout: Readable
-    readonly stderr: Readable
-    readonly exitCode: Promise<number>
-
-    pipe(
-      dest: ProcessPromise<ProcessOutput> | Writable
-    ): ProcessPromise<ProcessOutput>
-
-    kill(signal?: string | number): Promise<void>
-  }
-
-  export class ProcessOutput {
-    readonly exitCode: number | null
-    readonly signal: NodeJS.Signals | null
-    readonly stdout: string
-    readonly stderr: string
-
-    toString(): string
-  }
-
-  export type QuestionOptions = { choices: string[] }
-
-  type cd = (path: string) => void
-  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>
-
-  export const $: $
-  export const argv: ParsedArgs
-  export const cd: cd
-  export const chalk: ChalkInstance
-  export const fetch: typeof _fetch
-  export const YAML: typeof _yaml
-  export const fs: typeof _fs
-  export const glob: typeof _globby.globby & typeof _globby
-  export const globby: typeof _globby.globby & typeof _globby
-  export const nothrow: nothrow
-  export const os: typeof _os
-  export const path: typeof _path
-  export const question: question
-  export const sleep: sleep
-  export const quiet: quiet
-  export const which: typeof _which
-}
-
-declare module 'zx/globals' {
-  import {
-    $,
-    argv as _argv,
-    cd,
-    chalk as _chalk,
-    fetch as _fetch,
-    fs as _fs,
-    globby as _globby,
-    nothrow,
-    os as _os,
-    path as _path,
-    question,
-    sleep,
-    which as _which,
-  } from 'zx'
-
-  global {
-    var $: $
-    var argv: typeof _argv
-    var cd: cd
-    var chalk: typeof _chalk
-    // @ts-ignore
-    var fetch: typeof _fetch
-    var fs: typeof _fs
-    var globby: typeof _globby.globby & typeof _globby
-    var glob: typeof _globby.globby & typeof _globby
-    var nothrow: nothrow
-    var os: typeof _os
-    var path: typeof _path
-    var question: question
-    var sleep: sleep
-    var which: typeof _which
-  }
-}
-
-declare module 'zx/experimental' {
-  import { ZxTemplate } from 'zx'
-
-  interface Echo {
-    (pieces: TemplateStringsArray, ...args: any[]): void
-    (...args: any[]): void
-  }
-  export const echo: Echo
-
-  export const retry: (count?: number, delay?: number) => ZxTemplate
-
-  export const withTimeout: (
-    delay?: number,
-    signal?: string | number
-  ) => ZxTemplate
-
-  type StopSpinner = () => void
-  export function startSpinner(title: string): StopSpinner
-}
src/index.mjs → src/index.ts
@@ -25,10 +25,10 @@ import {
   which,
   YAML,
   os,
-} from './goods.mjs'
-import { nothrow, quiet } from './hooks.mjs'
-import { question } from './question.mjs'
-import { $, ProcessPromise, ProcessOutput } from './core.mjs'
+} from './goods.js'
+import { nothrow, quiet } from './hooks.js'
+import { question } from './question.js'
+import { $, ProcessPromise, ProcessOutput } from './core.js'
 
 export {
   $,
src/print.mjs → src/print.ts
@@ -12,10 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import { getCtx } from './context.mjs'
-import { chalk } from './goods.mjs'
+import { getCtx } from './context.js'
+import { chalk } from './goods.js'
 
-export function printCmd(cmd) {
+export function printCmd(cmd: string) {
   if (!getCtx()?.verbose) return
   if (/\n/.test(cmd)) {
     console.log(
@@ -29,13 +29,13 @@ export function printCmd(cmd) {
   }
 }
 
-export function printStd(data, err) {
+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) {
+export function colorize(cmd: string) {
   return cmd.replace(/^[\w_.-]+(\s|$)/, (substr) => {
     return chalk.greenBright(substr)
   })
src/question.mjs → src/question.ts
@@ -14,10 +14,10 @@
 
 import { createInterface } from 'node:readline'
 
-export async function question(query, options) {
+export async function question(query: string, options: { choices: string[] }) {
   let completer = undefined
   if (Array.isArray(options?.choices)) {
-    completer = function completer(line) {
+    completer = function completer(line: string) {
       const completions = options.choices
       const hits = completions.filter((c) => c.startsWith(line))
       return [hits.length ? hits : completions, line]
src/util.mjs → src/util.ts
@@ -12,10 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-export const randomId = function () {
+export function randomId() {
   return Math.random().toString(36).slice(2)
 }
 
-export function isString(obj) {
+export function isString(obj: any) {
   return typeof obj === 'string'
 }
test/experimental.test.mjs → test/experimental.test.js
@@ -12,8 +12,13 @@
 // 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 '../build/experimental.js'
+import { assert, testFactory } from './test-utils.js'
 import chalk from 'chalk'
 
 const test = testFactory('experimental', import.meta)
test/full.test.mjs → test/full.test.js
@@ -12,6 +12,6 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import './zx.test.mjs'
-import './index.test.mjs'
-import './experimental.test.mjs'
+import './zx.test.js'
+import './index.test.js'
+import './experimental.test.js'
test/index.test.mjs → test/index.test.js
@@ -17,9 +17,9 @@ import chalk from 'chalk'
 import { Writable } from 'node:stream'
 import { Socket } from 'node:net'
 
-import { assert, testFactory } from './test-utils.mjs'
-import { ProcessPromise } from '../src/index.mjs'
-import { getCtx, runInCtx } from '../src/context.mjs'
+import { assert, testFactory } from './test-utils.js'
+import { ProcessPromise } from '../build/index.js'
+import { getCtx, runInCtx } from '../build/context.js'
 
 const test = testFactory('index', import.meta)
 
@@ -82,7 +82,7 @@ test('The toString() is called on arguments', async () => {
 
 test('Can use array as an argument', async () => {
   try {
-    let files = ['./zx.mjs', './test/index.test.mjs']
+    let files = ['./zx.js', './test/index.test.js']
     await $`tar czf archive ${files}`
   } finally {
     await $`rm archive`
@@ -248,7 +248,7 @@ test('Executes a script from $PATH', async () => {
 
   const toPOSIXPath = (_path) => _path.split(path.sep).join(path.posix.sep)
 
-  const zxPath = path.resolve('./zx.mjs')
+  const zxPath = path.resolve('./zx.js')
   const zxLocation = isWindows ? toPOSIXPath(zxPath) : zxPath
   const scriptCode = `#!/usr/bin/env ${zxLocation}\nconsole.log('The script from path runs.')`
 
test/test-utils.mjs → test/test-utils.js
File renamed without changes
test/zx.test.mjs → test/zx.test.js
@@ -12,19 +12,19 @@
 // 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.js'
 
 const test = testFactory('zx', import.meta)
 
 test('supports `-v` flag / prints version', async () => {
-  let v = (await $`node zx.mjs -v`).toString().trim()
+  let v = (await $`node zx.js -v`).toString().trim()
   assert.equal(v, require('../package.json').version)
 })
 
 test('prints help', async () => {
   let help
   try {
-    await $`node zx.mjs`
+    await $`node zx.js`
   } catch (err) {
     help = err.toString().trim()
   }
@@ -32,37 +32,37 @@ test('prints help', async () => {
 })
 
 test('supports `--experimental` flag', async () => {
-  await $`echo 'echo("test")' | node zx.mjs --experimental`
+  await $`echo 'echo("test")' | node zx.js --experimental`
 })
 
 test('supports `--quiet` flag / Quiet mode is working', async () => {
-  let p = await $`node zx.mjs --quiet docs/markdown.md`
+  let p = await $`node zx.js --quiet docs/markdown.md`
   assert(!p.stdout.includes('whoami'))
 })
 
 test('supports `--shell` flag ', async () => {
   let shell = $.shell
-  let p = await $`node zx.mjs --shell=${shell} <<< '$\`echo \${$.shell}\`'`
+  let p = await $`node zx.js --shell=${shell} <<< '$\`echo \${$.shell}\`'`
   assert(p.stdout.includes(shell))
 })
 
 test('supports `--prefix` flag ', async () => {
   let prefix = 'set -e;'
-  let p = await $`node zx.mjs --prefix=${prefix} <<< '$\`echo \${$.prefix}\`'`
+  let p = await $`node zx.js --prefix=${prefix} <<< '$\`echo \${$.prefix}\`'`
   assert(p.stdout.includes(prefix))
 })
 
 test('Eval script from https ref', async () => {
   let script = path.resolve('test/fixtures/echo.http')
   let server = quiet($`while true; do cat ${script} | nc -l 8080; done`)
-  let p = await quiet($`node zx.mjs http://127.0.0.1:8080/echo.mjs`)
+  let p = await quiet($`node zx.js http://127.0.0.1:8080/echo.mjs`)
 
   assert(p.stdout.includes('test'))
   server.kill()
 
   let err
   try {
-    await quiet($`node zx.mjs http://127.0.0.1:8081/echo.mjs`)
+    await quiet($`node zx.js http://127.0.0.1:8081/echo.mjs`)
   } catch (e) {
     err = e
   }
@@ -70,7 +70,7 @@ test('Eval script from https ref', async () => {
 })
 
 test('Scripts with no extension', async () => {
-  await $`node zx.mjs test/fixtures/no-extension`
+  await $`node zx.js 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./
@@ -78,9 +78,9 @@ test('Scripts with no extension', async () => {
 })
 
 test('The require() is working from stdin', async () => {
-  await $`node zx.mjs <<< 'require("./package.json").name'`
+  await $`node zx.js <<< 'require("./package.json").name'`
 })
 
 test('Markdown scripts are working', async () => {
-  await $`node zx.mjs docs/markdown.md`
+  await $`node zx.js docs/markdown.md`
 })
.gitignore
@@ -1,4 +1,5 @@
 /node_modules/
+/build/
+/coverage/
 package-lock.json
 yarn.lock
-coverage
.prettierignore
@@ -1,5 +1,6 @@
 /node_modules/
+/build/
+/coverage/
 package-lock.json
 yarn.lock
-coverage
 *.md
package.json
@@ -2,26 +2,28 @@
   "name": "zx",
   "version": "6.1.0",
   "description": "A tool for writing better scripts.",
-  "main": "src/index.mjs",
+  "type": "module",
+  "main": "build/index.js",
   "types": "src/index.d.ts",
   "exports": {
-    ".": "./src/index.mjs",
-    "./globals": "./src/globals.mjs",
-    "./experimental": "./src/experimental.mjs",
-    "./cli": "./zx.mjs",
-    "./core": "./src/core.mjs",
+    ".": "./build/index.js",
+    "./globals": "./build/globals.js",
+    "./experimental": "./build/experimental.js",
+    "./cli": "./zx.js",
+    "./core": "./build/core.js",
     "./package.json": "./package.json"
   },
   "bin": {
-    "zx": "zx.mjs"
+    "zx": "zx.js"
   },
   "engines": {
     "node": ">= 16.0.0"
   },
   "scripts": {
     "fmt": "prettier --write .",
-    "test": "npm run test:unit",
-    "test:unit": "node zx.mjs test/full.test.mjs",
+    "build": "tsc",
+    "test": "tsc && npm run test:unit",
+    "test:unit": "node zx.js test/full.test.js",
     "test:cov": "c8 --reporter=html npm run test:unit",
     "test:zx": "npm run test zx",
     "test:index": "npm run test index"
@@ -30,6 +32,7 @@
     "@types/fs-extra": "^9.0.13",
     "@types/minimist": "^1.2.2",
     "@types/node": "^17.0",
+    "@types/ps-tree": "^1.1.2",
     "@types/which": "^2.0.1",
     "chalk": "^5.0.1",
     "fs-extra": "^10.1.0",
@@ -42,7 +45,8 @@
   },
   "devDependencies": {
     "c8": "^7.11.2",
-    "prettier": "^2.6.2"
+    "prettier": "^2.6.2",
+    "typescript": "^4.8.0-dev.20220529"
   },
   "publishConfig": {
     "registry": "https://wombat-dressing-room.appspot.com"
tsconfig.json
@@ -0,0 +1,13 @@
+{
+  "compilerOptions": {
+    "target": "ES2021",
+    "lib": ["ES2021"],
+    "module": "nodenext",
+    "strict": true,
+    "noImplicitReturns": true,
+    "noFallthroughCasesInSwitch": true,
+    "outDir": "./build",
+    "declaration": true
+  },
+  "include": ["./src/**/*"]
+}
zx.mjs → zx.js
@@ -20,8 +20,14 @@ import { tmpdir } from 'node:os'
 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'
+import {
+  $,
+  argv,
+  fetch,
+  ProcessOutput,
+  registerGlobals,
+} from './build/index.js'
+import { randomId } from './build/util.js'
 
 await (async function main() {
   registerGlobals()
@@ -33,7 +39,7 @@ await (async function main() {
     $.prefix = argv.prefix
   }
   if (argv.experimental) {
-    Object.assign(global, await import('./src/experimental.mjs'))
+    Object.assign(global, await import('./build/experimental.js'))
   }
   try {
     if (['--version', '-v', '-V'].includes(process.argv[2])) {