main
  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 url from 'node:url'
 18import process from 'node:process'
 19import {
 20  $,
 21  ProcessOutput,
 22  parseArgv,
 23  updateArgv,
 24  resolveDefaults,
 25  chalk,
 26  dotenv,
 27  fetch,
 28  fs,
 29  path,
 30  stdin,
 31  VERSION,
 32  Fail,
 33} from './index.ts'
 34import { installDeps, parseDeps } from './deps.ts'
 35import { startRepl } from './repl.ts'
 36import { randomId } from './util.ts'
 37import { transformMarkdown } from './md.ts'
 38import { createRequire, type minimist } from './vendor.ts'
 39
 40export { transformMarkdown } from './md.ts'
 41
 42const EXT = '.mjs'
 43const EXT_RE = /^\.[mc]?[jt]sx?$/
 44
 45// prettier-ignore
 46export const argv: minimist.ParsedArgs = parseArgv(process.argv.slice(2), {
 47  default: resolveDefaults({ ['prefer-local']: false } as any, 'ZX_', process.env, new Set(['env', 'install', 'registry'])),
 48  // exclude 'prefer-local' to let minimist infer the type
 49  string: ['shell', 'prefix', 'postfix', 'eval', 'cwd', 'ext', 'registry', 'env'],
 50  boolean: ['version', 'help', 'quiet', 'verbose', 'install', 'repl', 'experimental'],
 51  alias: { e: 'eval', i: 'install', v: 'version', h: 'help', l: 'prefer-local', 'env-file': 'env' },
 52  stopEarly: true,
 53  parseBoolean: true,
 54  camelCase: true,
 55})
 56
 57isMain() &&
 58  main().catch((err) => {
 59    if (err instanceof ProcessOutput) {
 60      console.error('Error:', err.message)
 61    } else {
 62      console.error(err)
 63    }
 64    process.exitCode = 1
 65  })
 66
 67export function printUsage() {
 68  // language=txt
 69  console.log(`
 70 ${chalk.bold('zx ' + VERSION)}
 71   A tool for writing better scripts
 72
 73 ${chalk.bold('Usage')}
 74   zx [options] <script>
 75
 76 ${chalk.bold('Options')}
 77   --quiet              suppress any outputs
 78   --verbose            enable verbose mode
 79   --shell=<path>       custom shell binary
 80   --prefix=<command>   prefix all commands
 81   --postfix=<command>  postfix all commands
 82   --prefer-local, -l   prefer locally installed packages and binaries
 83   --cwd=<path>         set current directory
 84   --eval=<js>, -e      evaluate script
 85   --ext=<.mjs>         script extension
 86   --install, -i        install dependencies
 87   --registry=<URL>     npm registry, defaults to https://registry.npmjs.org/
 88   --version, -v        print current zx version
 89   --help, -h           print help
 90   --repl               start repl
 91   --env=<path>         path to env file
 92   --experimental       enables experimental features (deprecated)
 93
 94 ${chalk.italic('Full documentation:')} ${chalk.underline(Fail.DOCS_URL)}
 95`)
 96}
 97
 98export async function main(): Promise<void> {
 99  if (argv.version) {
100    console.log(VERSION)
101    return
102  }
103  if (argv.help) {
104    printUsage()
105    return
106  }
107  if (argv.cwd) $.cwd = argv.cwd
108  if (argv.env) {
109    const envfile = path.resolve($.cwd ?? process.cwd(), argv.env)
110    dotenv.config(envfile)
111    resolveDefaults()
112  }
113  if (argv.verbose) $.verbose = true
114  if (argv.quiet) $.quiet = true
115  if (argv.shell) $.shell = argv.shell
116  if (argv.prefix) $.prefix = argv.prefix
117  if (argv.postfix) $.postfix = argv.postfix
118  if (argv.preferLocal) $.preferLocal = argv.preferLocal
119
120  await import('zx/globals')
121  if (argv.repl) {
122    await startRepl()
123    return
124  }
125  argv.ext = normalizeExt(argv.ext)
126
127  const { script, scriptPath, tempPath } = await readScript()
128  await runScript(script, scriptPath, tempPath)
129}
130
131const rmrf = (p: string) => {
132  if (!p) return
133
134  lstat(p)?.isSymbolicLink()
135    ? fs.unlinkSync(p)
136    : fs.rmSync(p, { force: true, recursive: true })
137}
138async function runScript(
139  script: string,
140  scriptPath: string,
141  tempPath: string
142): Promise<void> {
143  let nmLink = ''
144  const rmTemp = () => {
145    rmrf(tempPath)
146    rmrf(nmLink)
147  }
148  try {
149    if (tempPath) {
150      scriptPath = tempPath
151      await fs.writeFile(tempPath, script)
152    }
153    const cwd = path.dirname(scriptPath)
154    if (typeof argv.preferLocal === 'string') {
155      nmLink = linkNodeModules(cwd, argv.preferLocal)
156    }
157    if (argv.install) {
158      await installDeps(parseDeps(script), cwd, argv.registry)
159    }
160
161    injectGlobalRequire(scriptPath)
162    process.once('exit', rmTemp)
163
164    // TODO: fix unanalyzable-dynamic-import to work correctly with jsr.io
165    await import(url.pathToFileURL(scriptPath).toString())
166  } finally {
167    rmTemp()
168  }
169}
170
171function linkNodeModules(cwd: string, external: string): string {
172  const nm = 'node_modules'
173  const alias = path.resolve(cwd, nm)
174  const target =
175    path.basename(external) === nm
176      ? path.resolve(external)
177      : path.resolve(external, nm)
178  const aliasStat = lstat(alias)
179  const targetStat = lstat(target)
180
181  if (!targetStat?.isDirectory())
182    throw new Fail(
183      `Can't link node_modules: ${target} doesn't exist or is not a directory`
184    )
185  if (aliasStat?.isDirectory() && alias !== target)
186    throw new Fail(`Can't link node_modules: ${alias} already exists`)
187  if (aliasStat) return ''
188
189  fs.symlinkSync(target, alias, 'junction')
190  return alias
191}
192
193function lstat(p: string) {
194  try {
195    return fs.lstatSync(p)
196  } catch {}
197}
198
199async function readScript() {
200  const [firstArg] = argv._
201  let script = ''
202  let scriptPath = ''
203  let tempPath = ''
204  let argSlice = 1
205
206  if (argv.eval) {
207    argSlice = 0
208    script = argv.eval
209    tempPath = getFilepath($.cwd, 'zx', argv.ext)
210  } else if (!firstArg || firstArg === '-') {
211    script = await readScriptFromStdin()
212    tempPath = getFilepath($.cwd, 'zx', argv.ext)
213    if (script.length === 0) {
214      printUsage()
215      process.exitCode = 1
216      throw new Fail('No script provided')
217    }
218  } else if (/^https?:/.test(firstArg)) {
219    const { name, ext = argv.ext } = path.parse(new URL(firstArg).pathname)
220    script = await readScriptFromHttp(firstArg)
221    tempPath = getFilepath($.cwd, name, ext)
222  } else {
223    script = await fs.readFile(firstArg, 'utf8')
224    scriptPath = firstArg.startsWith('file:')
225      ? url.fileURLToPath(firstArg)
226      : path.resolve(firstArg)
227  }
228
229  const { ext, base, dir } = path.parse(tempPath || scriptPath)
230  if (ext === '' || (argv.ext && !EXT_RE.test(ext))) {
231    tempPath = getFilepath(dir, base)
232  }
233  if (ext === '.md') {
234    script = transformMarkdown(script)
235    tempPath = getFilepath(dir, base)
236  }
237  if (argSlice) updateArgv(argv._.slice(argSlice))
238
239  return { script, scriptPath, tempPath }
240}
241
242async function readScriptFromStdin(): Promise<string> {
243  return process.stdin.isTTY ? '' : stdin()
244}
245
246async function readScriptFromHttp(remote: string): Promise<string> {
247  const res = await fetch(remote)
248  if (!res.ok) {
249    console.error(`Error: Can't get ${remote}`)
250    process.exit(1)
251  }
252  return res.text()
253}
254
255export function injectGlobalRequire(origin: string): void {
256  const __filename = path.resolve(origin)
257  const __dirname = path.dirname(__filename)
258  const require = createRequire(origin)
259  Object.assign(globalThis, { __filename, __dirname, require })
260}
261
262export function isMain(
263  metaurl: string = import.meta.url,
264  scriptpath: string = process.argv[1]
265): boolean {
266  if (metaurl.startsWith('file:')) {
267    const modulePath = url.fileURLToPath(metaurl).replace(/\.\w+$/, '')
268    const mainPath = fs.realpathSync(scriptpath).replace(/\.\w+$/, '')
269    return mainPath === modulePath
270  }
271
272  return false
273}
274
275export function normalizeExt(ext?: string): string | undefined {
276  return ext ? path.parse(`foo.${ext}`).ext : ext
277}
278
279// prettier-ignore
280function getFilepath(cwd = '.', name = 'zx', _ext?: string): string {
281  const ext = _ext || argv.ext || EXT
282  return [
283    name + ext,
284    name + '-' + randomId() + ext,
285  ]
286    .map(f => path.resolve(process.cwd(), cwd, f))
287    .find(f => !fs.existsSync(f))!
288}