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}