Commit 539130a

Anton Golub <antongolub@antongolub.com>
2025-03-02 18:22:39
feat(cli): use `--prefer-local` option to link external `node_modules (#1117)
* chore: bump to v8.4.0 * feat(cli): use `--prefer-local` option to link external `node_modules` closes #1116 * chore: minor code imprs
1 parent 778fd03
docs/cli.md
@@ -106,10 +106,10 @@ zx --shell=/bin/another/sh script.mjs
 
 ## `--prefer-local, -l`
 
-Prefer locally installed packages bins.
+Prefer locally installed packages and binaries.
 
 ```bash
-zx --shell=/bin/bash script.mjs
+zx --prefer-local=/external/node_modules/or/nm-root script.mjs
 ```
 
 ## `--prefix & --postfix`
man/zx.1
@@ -20,7 +20,7 @@ prefix all commands
 .SS --postfix=<command>
 postfix all commands
 .SS --prefer-local, -l
-prefer locally installed packages bins
+prefer locally installed packages and binaries
 .SS --eval=<js>, -e
 evaluate script
 .SS --ext=<.mjs>
src/cli.ts
@@ -63,7 +63,7 @@ export function printUsage() {
    --shell=<path>       custom shell binary
    --prefix=<command>   prefix all commands
    --postfix=<command>  postfix all commands
-   --prefer-local, -l   prefer locally installed packages bins
+   --prefer-local, -l   prefer locally installed packages and binaries
    --cwd=<path>         set current directory
    --eval=<js>, -e      evaluate script
    --ext=<.mjs>         script extension
@@ -81,8 +81,10 @@ export function printUsage() {
 
 // prettier-ignore
 export const argv: minimist.ParsedArgs = parseArgv(process.argv.slice(2), {
+  default: { ['prefer-local']: false },
+  // exclude 'prefer-local' to let minimist infer the type
   string: ['shell', 'prefix', 'postfix', 'eval', 'cwd', 'ext', 'registry', 'env'],
-  boolean: ['version', 'help', 'quiet', 'verbose', 'install', 'repl', 'experimental', 'prefer-local'],
+  boolean: ['version', 'help', 'quiet', 'verbose', 'install', 'repl', 'experimental'],
   alias: { e: 'eval', i: 'install', v: 'version', h: 'help', l: 'prefer-local', 'env-file': 'env' },
   stopEarly: true,
   parseBoolean: true,
@@ -126,19 +128,22 @@ async function runScript(
   scriptPath: string,
   tempPath: string
 ): Promise<void> {
-  const rmTemp = () => fs.rmSync(tempPath, { force: true })
+  let nm = ''
+  const rmTemp = () => {
+    fs.rmSync(tempPath, { force: true, recursive: true })
+    nm && fs.rmSync(nm, { force: true, recursive: true })
+  }
   try {
     if (tempPath) {
       scriptPath = tempPath
       await fs.writeFile(tempPath, script)
     }
-
+    const cwd = path.dirname(scriptPath)
+    if (typeof argv.preferLocal === 'string') {
+      nm = linkNodeModules(cwd, argv.preferLocal)
+    }
     if (argv.install) {
-      await installDeps(
-        parseDeps(script),
-        path.dirname(scriptPath),
-        argv.registry
-      )
+      await installDeps(parseDeps(script), cwd, argv.registry)
     }
 
     injectGlobalRequire(scriptPath)
@@ -151,6 +156,20 @@ async function runScript(
   }
 }
 
+function linkNodeModules(cwd: string, external: string): string {
+  const nm = 'node_modules'
+  const alias = path.resolve(cwd, nm)
+  const target =
+    path.basename(external) === nm
+      ? path.resolve(external)
+      : path.resolve(external, nm)
+
+  if (fs.existsSync(alias) || !fs.existsSync(target)) return ''
+
+  fs.symlinkSync(target, alias, 'junction')
+  return target
+}
+
 async function readScript() {
   const [firstArg] = argv._
   let script = ''
test/cli.test.js
@@ -186,6 +186,30 @@ describe('cli', () => {
     }
   })
 
+  test('supports --prefer-local to load modules', async () => {
+    const cwd = tmpdir()
+    const external = tmpdir()
+    await fs.outputJson(path.join(external, 'node_modules/a/package.json'), {
+      name: 'a',
+      version: '1.0.0',
+      type: 'module',
+      exports: './index.js',
+    })
+    await fs.outputFile(
+      path.join(external, 'node_modules/a/index.js'),
+      `
+export const a = 'AAA'
+`
+    )
+    const script = `
+import {a} from 'a'
+console.log(a);
+`
+    const out =
+      await $`node build/cli.js --cwd=${cwd} --prefer-local=${external} --test <<< ${script}`
+    assert.equal(out.stdout, 'AAA\n')
+  })
+
   test('scripts from https 200', async () => {
     const resp = await fs.readFile(path.resolve('test/fixtures/echo.http'))
     const port = await getPort()
.size-limit.json
@@ -9,7 +9,7 @@
   {
     "name": "zx/index",
     "path": "build/*.{js,cjs}",
-    "limit": "812 kB",
+    "limit": "812.1 kB",
     "brotli": false,
     "gzip": false
   },
@@ -30,7 +30,7 @@
   {
     "name": "all",
     "path": "build/*",
-    "limit": "850.3 kB",
+    "limit": "851 kB",
     "brotli": false,
     "gzip": false
   }