v7
  1#!/usr/bin/env node
  2
  3// Copyright 2021 Google LLC
  4//
  5// Licensed under the Apache License, Version 2.0 (the "License");
  6// you may not use this file except in compliance with the License.
  7// You may obtain a copy of the License at
  8//
  9//     https://www.apache.org/licenses/LICENSE-2.0
 10//
 11// Unless required by applicable law or agreed to in writing, software
 12// distributed under the License is distributed on an "AS IS" BASIS,
 13// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 14// See the License for the specific language governing permissions and
 15// limitations under the License.
 16
 17import fs from 'fs-extra'
 18import minimist from 'minimist'
 19import { createRequire } from 'node:module'
 20import { basename, dirname, extname, join, resolve } from 'node:path'
 21import url from 'node:url'
 22import { updateArgv } from './goods.js'
 23import { $, chalk, fetch, ProcessOutput } from './index.js'
 24import { startRepl } from './repl.js'
 25import { randomId } from './util.js'
 26import { installDeps, parseDeps } from './deps.js'
 27
 28function printUsage() {
 29  // language=txt
 30  console.log(`
 31 ${chalk.bold('zx ' + getVersion())}
 32   A tool for writing better scripts
 33
 34 ${chalk.bold('Usage')}
 35   zx [options] <script>
 36
 37 ${chalk.bold('Options')}
 38   --quiet              don't echo commands
 39   --shell=<path>       custom shell binary
 40   --prefix=<command>   prefix all commands
 41   --eval=<js>, -e      evaluate script 
 42   --install, -i        install dependencies
 43   --experimental       enable experimental features
 44   --version, -v        print current zx version
 45   --help, -h           print help
 46   --repl               start repl
 47`)
 48}
 49
 50const argv = minimist(process.argv.slice(2), {
 51  string: ['shell', 'prefix', 'eval'],
 52  boolean: ['version', 'help', 'quiet', 'install', 'repl', 'experimental'],
 53  alias: { e: 'eval', i: 'install', v: 'version', h: 'help' },
 54  stopEarly: true,
 55})
 56
 57await (async function main() {
 58  const globals = './globals.js'
 59  await import(globals)
 60  if (argv.quiet) $.verbose = false
 61  if (argv.shell) $.shell = argv.shell
 62  if (argv.prefix) $.prefix = argv.prefix
 63  if (argv.experimental) {
 64    Object.assign(global, await import('./experimental.js'))
 65  }
 66  if (argv.version) {
 67    console.log(getVersion())
 68    return
 69  }
 70  if (argv.help) {
 71    printUsage()
 72    return
 73  }
 74  if (argv.repl) {
 75    startRepl()
 76    return
 77  }
 78  if (argv.eval) {
 79    await runScript(argv.eval)
 80    return
 81  }
 82  const firstArg = argv._[0]
 83  updateArgv(argv._.slice(firstArg === undefined ? 0 : 1))
 84  if (!firstArg || firstArg === '-') {
 85    const success = await scriptFromStdin()
 86    if (!success) printUsage()
 87    return
 88  }
 89  if (/^https?:/.test(firstArg)) {
 90    await scriptFromHttp(firstArg)
 91    return
 92  }
 93  const filepath = firstArg.startsWith('file:///')
 94    ? url.fileURLToPath(firstArg)
 95    : resolve(firstArg)
 96  await importPath(filepath)
 97})().catch((err) => {
 98  if (err instanceof ProcessOutput) {
 99    console.error('Error:', err.message)
100  } else {
101    console.error(err)
102  }
103  process.exitCode = 1
104})
105
106async function runScript(script: string) {
107  const filepath = join(process.cwd(), `zx-${randomId()}.mjs`)
108  await writeAndImport(script, filepath)
109}
110
111async function scriptFromStdin() {
112  let script = ''
113  if (!process.stdin.isTTY) {
114    process.stdin.setEncoding('utf8')
115    for await (const chunk of process.stdin) {
116      script += chunk
117    }
118
119    if (script.length > 0) {
120      await runScript(script)
121      return true
122    }
123  }
124  return false
125}
126
127async function scriptFromHttp(remote: string) {
128  const res = await fetch(remote)
129  if (!res.ok) {
130    console.error(`Error: Can't get ${remote}`)
131    process.exit(1)
132  }
133  const script = await res.text()
134  const pathname = new URL(remote).pathname
135  const name = basename(pathname)
136  const ext = extname(pathname) || '.mjs'
137  const filepath = join(process.cwd(), `${name}-${randomId()}${ext}`)
138  await writeAndImport(script, filepath)
139}
140
141async function writeAndImport(
142  script: string | Buffer,
143  filepath: string,
144  origin = filepath
145) {
146  await fs.writeFile(filepath, script.toString())
147  try {
148    await importPath(filepath, origin)
149  } finally {
150    await fs.rm(filepath)
151  }
152}
153
154async function importPath(filepath: string, origin = filepath) {
155  const ext = extname(filepath)
156
157  if (ext === '') {
158    const tmpFilename = fs.existsSync(`${filepath}.mjs`)
159      ? `${basename(filepath)}-${randomId()}.mjs`
160      : `${basename(filepath)}.mjs`
161
162    return writeAndImport(
163      await fs.readFile(filepath),
164      join(dirname(filepath), tmpFilename),
165      origin
166    )
167  }
168  if (ext === '.md') {
169    return writeAndImport(
170      transformMarkdown(await fs.readFile(filepath)),
171      join(dirname(filepath), basename(filepath) + '.mjs'),
172      origin
173    )
174  }
175  if (argv.install) {
176    const deps = parseDeps(await fs.readFile(filepath))
177    await installDeps(deps, dirname(filepath))
178  }
179  const __filename = resolve(origin)
180  const __dirname = dirname(__filename)
181  const require = createRequire(origin)
182  Object.assign(global, { __filename, __dirname, require })
183  await import(url.pathToFileURL(filepath).toString())
184}
185
186function transformMarkdown(buf: Buffer) {
187  const source = buf.toString()
188  const output = []
189  let state = 'root'
190  let codeBlockEnd = ''
191  let prevLineIsEmpty = true
192  const jsCodeBlock = /^(```+|~~~+)(js|javascript)$/
193  const shCodeBlock = /^(```+|~~~+)(sh|bash)$/
194  const otherCodeBlock = /^(```+|~~~+)(.*)$/
195  for (let line of source.split('\n')) {
196    switch (state) {
197      case 'root':
198        if (/^( {4}|\t)/.test(line) && prevLineIsEmpty) {
199          output.push(line)
200          state = 'tab'
201        } else if (jsCodeBlock.test(line)) {
202          output.push('')
203          state = 'js'
204          codeBlockEnd = line.match(jsCodeBlock)![1]
205        } else if (shCodeBlock.test(line)) {
206          output.push('await $`')
207          state = 'bash'
208          codeBlockEnd = line.match(shCodeBlock)![1]
209        } else if (otherCodeBlock.test(line)) {
210          output.push('')
211          state = 'other'
212          codeBlockEnd = line.match(otherCodeBlock)![1]
213        } else {
214          prevLineIsEmpty = line === ''
215          output.push('// ' + line)
216        }
217        break
218      case 'tab':
219        if (/^( +|\t)/.test(line)) {
220          output.push(line)
221        } else if (line === '') {
222          output.push('')
223        } else {
224          output.push('// ' + line)
225          state = 'root'
226        }
227        break
228      case 'js':
229        if (line === codeBlockEnd) {
230          output.push('')
231          state = 'root'
232        } else {
233          output.push(line)
234        }
235        break
236      case 'bash':
237        if (line === codeBlockEnd) {
238          output.push('`')
239          state = 'root'
240        } else {
241          output.push(line)
242        }
243        break
244      case 'other':
245        if (line === codeBlockEnd) {
246          output.push('')
247          state = 'root'
248        } else {
249          output.push('// ' + line)
250        }
251        break
252    }
253  }
254  return output.join('\n')
255}
256
257function getVersion(): string {
258  return createRequire(import.meta.url)('../package.json').version
259}