Repository: google/zx (js)

Issue:
feat: provide npm registry customization
zx has internal feature to install package (see `deps.ts` and `cli.ts`), but the registry path is hardcoded. Let's make it configurable via the CLI opts too.

```
zx --install-registry='https://npm-proxy.example.com' script.mjs
```

Pull request description:
feat: add custom npm registry when installing dependencies
### Description
Add custom npm registry with composition install and registry flags.

Fixes #972

```js
zx --install --registry='https://npm-proxy.example.com' script.mjs
```
<img width="915" alt="Screenshot 2024-12-16 at 15 41 17" src="https://github.com/user-attachments/assets/ae779561-1469-444b-ac5d-550f8ede3d5a" />


- [x] Tests pass
- [ ] Appropriate changes to README are included in PR


Patch:
diff --git a/man/zx.1 b/man/zx.1
index eb30e35..5012159 100644
--- a/man/zx.1
+++ b/man/zx.1
@@ -25,6 +25,8 @@ evaluate script
 default extension
 .SS --install, -i
 install dependencies
+.SS --registry<URL>
+npm registry, defaults to https://registry.npmjs.org/
 .SS --repl
 start repl
 .SS --version, -v
diff --git a/src/cli.ts b/src/cli.ts
index 9148a53..62d4440 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -60,6 +60,7 @@ export function printUsage() {
    --eval=<js>, -e      evaluate script
    --ext=<.mjs>         default extension
    --install, -i        install dependencies
+   --registry=<URL>     npm registry, defaults to https://registry.npmjs.org/
    --version, -v        print current zx version
    --help, -h           print help
    --repl               start repl
@@ -70,7 +71,7 @@ export function printUsage() {
 }
 
 export const argv: minimist.ParsedArgs = minimist(process.argv.slice(2), {
-  string: ['shell', 'prefix', 'postfix', 'eval', 'cwd', 'ext'],
+  string: ['shell', 'prefix', 'postfix', 'eval', 'cwd', 'ext', 'registry'],
   boolean: [
     'version',
     'help',
@@ -206,8 +207,9 @@ export async function importPath(
   }
   if (argv.install) {
     const deps = parseDeps(await fs.readFile(filepath))
-    await installDeps(deps, dir)
+    await installDeps(deps, dir, argv.registry)
   }
+
   injectGlobalRequire(origin)
   // TODO: fix unanalyzable-dynamic-import to work correctly with jsr.io
   await import(url.pathToFileURL(filepath).toString())
diff --git a/src/deps.ts b/src/deps.ts
index d506713..3691bab 100644
--- a/src/deps.ts
+++ b/src/deps.ts
@@ -16,11 +16,19 @@ import { $ } from './core.js'
 import { spinner } from './goods.js'
 import { depseek } from './vendor.js'
 
+/**
+ * Install npm dependencies
+ * @param dependencies object of dependencies
+ * @param prefix  path to the directory where npm should install the dependencies
+ * @param registry custom npm registry URL when installing dependencies
+ */
 export async function installDeps(
   dependencies: Record<string, string>,
-  prefix?: string
-) {
-  const flags = prefix ? `--prefix=${prefix}` : ''
+  prefix?: string,
+  registry?: string
+): Promise<void> {
+  const prefixFlag = prefix ? `--prefix=${prefix}` : ''
+  const registryFlag = registry ? `--registry=${registry}` : ''
   const packages = Object.entries(dependencies).map(
     ([name, version]) => `${name}@${version}`
   )
@@ -28,7 +36,7 @@ export async function installDeps(
     return
   }
   await spinner(`npm i ${packages.join(' ')}`, () =>
-    $`npm install --no-save --no-audit --no-fund ${flags} ${packages}`.nothrow()
+    $`npm install --no-save --no-audit --no-fund ${registryFlag} ${prefixFlag} ${packages}`.nothrow()
   )
 }
 


Test patch:
diff --git a/test/deps.test.js b/test/deps.test.js
index 823a602..9f7511f 100644
--- a/test/deps.test.js
+++ b/test/deps.test.js
@@ -13,8 +13,8 @@
 // limitations under the License.
 
 import assert from 'node:assert'
-import { test, describe, before, beforeEach } from 'node:test'
-import { $ } from '../build/index.js'
+import { test, describe } from 'node:test'
+import { $, tmpfile, fs } from '../build/index.js'
 import { installDeps, parseDeps } from '../build/deps.js'
 
 describe('deps', () => {
@@ -27,12 +27,39 @@ describe('deps', () => {
     assert((await import('lodash-es')).pick instanceof Function)
   })
 
+  test('installDeps() loader works via JS API with custom npm registry URL', async () => {
+    await installDeps(
+      {
+        '@jsr/std__internal': '1.0.5',
+      },
+      undefined,
+      'https://npm.jsr.io'
+    )
+
+    assert((await import('@jsr/std__internal')).diff instanceof Function)
+  })
+
   test('installDeps() loader works via CLI', async () => {
     const out =
       await $`node build/cli.js --install <<< 'import _ from "lodash" /* @4.17.15 */; console.log(_.VERSION)'`
     assert.match(out.stdout, /4.17.15/)
   })
 
+  test('installDeps() loader works via CLI with custom npm registry URL', async () => {
+    const code =
+      'import { diff } from "@jsr/std__internal";console.log(diff instanceof Function)'
+    const file = tmpfile('index.mjs', code)
+
+    let out =
+      await $`node build/cli.js --i --registry=https://npm.jsr.io ${file}`
+    fs.remove(file)
+    assert.match(out.stdout, /true/)
+
+    out =
+      await $`node build/cli.js  -i --registry=https://npm.jsr.io <<< ${code}`
+    assert.match(out.stdout, /true/)
+  })
+
   test('parseDeps(): import or require', async () => {
     ;[
       [`import "foo"`, { foo: 'latest' }],
@@ -82,11 +109,11 @@ describe('deps', () => {
   require('a') // @1.0.0
   const b =require('b') /* @2.0.0 */
   const c = {
-    c:require('c') /* @3.0.0 */, 
-    d: await import('d') /* @4.0.0 */, 
+    c:require('c') /* @3.0.0 */,
+    d: await import('d') /* @4.0.0 */,
     ...require('e') /* @5.0.0 */
   }
-  const f = [...require('f') /* @6.0.0 */] 
+  const f = [...require('f') /* @6.0.0 */]
   ;require('g'); // @7.0.0
   const h = 1 *require('h') // @8.0.0
   {require('i') /* @9.0.0 */}
@@ -96,7 +123,7 @@ describe('deps', () => {
   import path from 'path'
   import foo from "foo"
   // import aaa from 'a'
-  /* import bbb from 'b' */ 
+  /* import bbb from 'b' */
   import bar from "bar" /* @1.0.0 */
   import baz from "baz" //    @^2.0
   import qux from "@qux/pkg/entry" //    @^3.0