Commit e8ac8fc

Anton Medvedev <anton@medv.io>
2022-06-01 23:15:57
Add repl
1 parent ab7a345
src/cli.ts
@@ -20,6 +20,7 @@ import { tmpdir } from 'node:os'
 import { basename, dirname, extname, join, resolve } from 'node:path'
 import url from 'node:url'
 import { $, argv, fetch, ProcessOutput, chalk } from './index.js'
+import { startRepl } from './repl.js'
 import { randomId } from './util.js'
 import './globals.js'
 
@@ -35,16 +36,32 @@ await (async function main() {
     Object.assign(global, await import('./experimental.js'))
   }
   try {
-    if (['--version', '-v', '-V'].includes(process.argv[2])) {
-      console.log(createRequire(import.meta.url)('../package.json').version)
+    if (
+      process.argv.length == 3 &&
+      ['--version', '-v', '-V'].includes(process.argv[2])
+    ) {
+      console.log(getVersion())
+      return (process.exitCode = 0)
+    }
+    if (
+      process.argv.length == 3 &&
+      ['--help', '-h'].includes(process.argv[2])
+    ) {
+      printUsage()
+      return (process.exitCode = 0)
+    }
+    if (
+      process.argv.length == 3 &&
+      ['--interactive', '-i'].includes(process.argv[2])
+    ) {
+      startRepl()
       return (process.exitCode = 0)
     }
     let firstArg = process.argv.slice(2).find((a) => !a.startsWith('--'))
     if (typeof firstArg === 'undefined' || firstArg === '-') {
       let ok = await scriptFromStdin()
       if (!ok) {
-        printUsage()
-        return (process.exitCode = 2)
+        startRepl()
       }
     } else if (
       firstArg.startsWith('http://') ||
@@ -211,18 +228,25 @@ function transformMarkdown(buf: Buffer) {
   return output.join('\n')
 }
 
+function getVersion(): string {
+  return createRequire(import.meta.url)('../package.json').version
+}
+
 function printUsage() {
   console.log(`
- ${chalk.bgGreenBright.black(' ZX ')}
+ ${chalk.bold('zx ' + getVersion())}
+   A tool for writing better scripts
 
- Usage:
+ ${chalk.bold('Usage')}
    zx [options] <script>
 
- Options:
-   --quiet            : don't echo commands
-   --shell=<path>     : custom shell binary
-   --prefix=<command> : prefix all commands
-   --experimental     : enable new api proposals
-   --version, -v      : print current zx version
+ ${chalk.bold('Options')}
+   --quiet             don't echo commands
+   --shell=<path>      custom shell binary
+   --prefix=<command>  prefix all commands
+   --interactive, -i   start repl
+   --experimental      enable new api proposals
+   --version, -v       print current zx version
+   --help, -h          print help
 `)
 }
src/core.ts
@@ -37,6 +37,35 @@ type Options = {
 
 const storage = new AsyncLocalStorage<Options>()
 
+function init() {
+  storage.enterWith({
+    verbose: true,
+    cwd: process.cwd(),
+    env: process.env,
+    shell: true,
+    prefix: '',
+    quote,
+    spawn,
+  })
+  if (process.env.ZX_VERBOSE) $.verbose = process.env.ZX_VERBOSE == 'true'
+  try {
+    $.shell = which.sync('bash')
+    $.prefix = 'set -euo pipefail;'
+  } catch (err) {
+    // ¯\_(ツ)_/¯
+  }
+}
+
+function getStore() {
+  let context = storage.getStore()
+  if (!context) {
+    init()
+    context = storage.getStore()
+    assert(context)
+  }
+  return context
+}
+
 export const $ = new Proxy<Shell & Options>(
   function (pieces, ...args) {
     let from = new Error().stack!.split(/^\s*at\s/m)[2].trim()
@@ -60,37 +89,15 @@ export const $ = new Proxy<Shell & Options>(
   } as Shell & Options,
   {
     set(_, key, value) {
-      let context = storage.getStore()
-      assert(context)
-      Reflect.set(context, key, value)
+      Reflect.set(getStore(), key, value)
       return true
     },
     get(_, key) {
-      let context = storage.getStore()
-      assert(context)
-      return Reflect.get(context, key)
+      return Reflect.get(getStore(), 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> {
@@ -330,7 +337,5 @@ export function quiet(promise: ProcessPromise) {
 }
 
 export function within<R>(callback: (...args: any) => R): R {
-  let context = storage.getStore()
-  assert(context)
-  return storage.run({ ...context }, callback)
+  return storage.run({ ...getStore() }, callback)
 }
src/globals.ts
@@ -1,3 +1,17 @@
+// 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 _ from './index.js'
 
 Object.assign(global, _)
src/repl.ts
@@ -0,0 +1,36 @@
+// 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 repl from 'node:repl'
+import path from 'node:path'
+import os from 'node:os'
+import { inspect } from 'node:util'
+import chalk from 'chalk'
+import { ProcessOutput } from './core.js'
+
+export function startRepl() {
+  process.env.ZX_VERBOSE = 'false'
+  const r = repl.start({
+    prompt: chalk.greenBright.bold('❯ '),
+    useGlobal: true,
+    preview: false,
+    writer(output: any) {
+      if (output instanceof ProcessOutput) {
+        return output.toString().replace(/\n$/, '')
+      }
+      return inspect(output, { colors: true })
+    },
+  })
+  r.setupHistory(path.join(os.homedir(), '.zx_repl_history'), () => {})
+}
test/cli.test.js
@@ -18,17 +18,34 @@ import '../build/globals.js'
 
 $.verbose = false
 
-test('supports `-v` flag / prints version', async () => {
+test('prints version', async () => {
   assert.match((await $`node build/cli.js -v`).toString(), /\d+.\d+.\d+/)
 })
 
 test('prints help', async () => {
-  let p = nothrow($`node build/cli.js`)
+  let p = $`node build/cli.js -h`
   p.stdin.end()
   let help = await p
   assert.match(help.stdout, 'zx')
 })
 
+test('starts repl', async () => {
+  let p = $`node build/cli.js`
+  p.stdin.end()
+  let out = await p
+  assert.match(out.stdout, '❯')
+})
+
+test('starts repl with -i', async () => {
+  let p = $`node build/cli.js -i`
+  p.stdin.write('await $`echo f"o"o`\n')
+  p.stdin.write('"b"+"ar"\n')
+  p.stdin.end()
+  let out = await p
+  assert.match(out.stdout, 'foo')
+  assert.match(out.stdout, 'bar')
+})
+
 test('supports `--experimental` flag', async () => {
   await $`echo 'echo("test")' | node build/cli.js --experimental`
 })