Commit 8860b44

Anton Golub <antongolub@antongolub.com>
2024-12-21 22:13:01
feat(cli): provide `--prefer-local` option (#1015)
* feat(cli): provide `--prefer-local` option * test: extend argv test * refactor(util): simplify `snakeToCamel`
1 parent a69ddd4
man/zx.1
@@ -19,13 +19,15 @@ set the shell to use
 prefix all commands
 .SS --postfix=<command>
 postfix all commands
+.SS --prefer-local, -l
+prefer locally installed packages bins
 .SS --eval=<js>, -e
 evaluate script
 .SS --ext=<.mjs>
 default extension
 .SS --install, -i
 install dependencies
-.SS --registry<URL>
+.SS --registry=<URL>
 npm registry, defaults to https://registry.npmjs.org/
 .SS --repl
 start repl
src/cli.ts
@@ -21,10 +21,10 @@ import {
   updateArgv,
   fetch,
   chalk,
-  minimist,
   fs,
   path,
   VERSION,
+  parseArgv,
 } from './index.js'
 import { installDeps, parseDeps } from './deps.js'
 import { randomId } from './util.js'
@@ -57,6 +57,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
    --cwd=<path>         set current directory
    --eval=<js>, -e      evaluate script
    --ext=<.mjs>         default extension
@@ -71,19 +72,14 @@ export function printUsage() {
 `)
 }
 
-export const argv: minimist.ParsedArgs = minimist(process.argv.slice(2), {
+// prettier-ignore
+export const argv = parseArgv(process.argv.slice(2), {
   string: ['shell', 'prefix', 'postfix', 'eval', 'cwd', 'ext', 'registry'],
-  boolean: [
-    'version',
-    'help',
-    'quiet',
-    'verbose',
-    'install',
-    'repl',
-    'experimental',
-  ],
-  alias: { e: 'eval', i: 'install', v: 'version', h: 'help' },
+  boolean: ['version', 'help', 'quiet', 'verbose', 'install', 'repl', 'experimental', 'prefer-local'],
+  alias: { e: 'eval', i: 'install', v: 'version', h: 'help', l: 'prefer-local' },
   stopEarly: true,
+  parseBoolean: true,
+  camelCase: true,
 })
 
 export async function main() {
@@ -95,6 +91,7 @@ export async function main() {
   if (argv.shell) $.shell = argv.shell
   if (argv.prefix) $.prefix = argv.prefix
   if (argv.postfix) $.postfix = argv.postfix
+  if (argv.preferLocal) $.preferLocal = argv.preferLocal
   if (argv.version) {
     console.log(VERSION)
     return
src/core.ts
@@ -50,6 +50,7 @@ import {
   isStringLiteral,
   noop,
   once,
+  parseBool,
   parseDuration,
   preferLocalBin,
   proxyOverride,
@@ -929,7 +930,7 @@ export function resolveDefaults(
   return Object.entries(env).reduce<Options>((m, [k, v]) => {
     if (v && k.startsWith(prefix)) {
       const _k = snakeToCamel(k.slice(prefix.length))
-      const _v = { true: true, false: false }[v.toLowerCase()] ?? v
+      const _v = parseBool(v)
       if (allowed.has(_k)) (m as any)[_k] = _v
     }
     return m
src/goods.ts
@@ -15,7 +15,14 @@
 import assert from 'node:assert'
 import { createInterface } from 'node:readline'
 import { $, within, ProcessOutput } from './core.js'
-import { type Duration, isStringLiteral, parseDuration } from './util.js'
+import {
+  type Duration,
+  identity,
+  isStringLiteral,
+  parseBool,
+  parseDuration,
+  snakeToCamel,
+} from './util.js'
 import {
   chalk,
   minimist,
@@ -27,12 +34,30 @@ import {
 export { default as path } from 'node:path'
 export * as os from 'node:os'
 
-export const argv: minimist.ParsedArgs = minimist(process.argv.slice(2))
-export function updateArgv(args: string[]) {
+type ArgvOpts = minimist.Opts & { camelCase?: boolean; parseBoolean?: boolean }
+
+export const parseArgv = (
+  args: string[] = process.argv.slice(2),
+  opts: ArgvOpts = {}
+): minimist.ParsedArgs =>
+  Object.entries(minimist(args, opts)).reduce<minimist.ParsedArgs>(
+    (m, [k, v]) => {
+      const kTrans = opts.camelCase ? snakeToCamel : identity
+      const vTrans = opts.parseBoolean ? parseBool : identity
+      const [_k, _v] = k === '--' || k === '_' ? [k, v] : [kTrans(k), vTrans(v)]
+      m[_k] = _v
+      return m
+    },
+    {} as minimist.ParsedArgs
+  )
+
+export function updateArgv(args?: string[], opts?: ArgvOpts) {
   for (const k in argv) delete argv[k]
-  Object.assign(argv, minimist(args))
+  Object.assign(argv, parseArgv(args, opts))
 }
 
+export const argv: minimist.ParsedArgs = parseArgv()
+
 export function sleep(duration: Duration): Promise<void> {
   return new Promise((resolve) => {
     setTimeout(resolve, parseDuration(duration))
src/util.ts
@@ -39,6 +39,10 @@ export function tempfile(name?: string, data?: string | Buffer): string {
 
 export function noop() {}
 
+export function identity<T>(v: T): T {
+  return v
+}
+
 export function randomId() {
   return Math.random().toString(36).slice(2)
 }
@@ -282,17 +286,16 @@ export const proxyOverride = <T extends object>(
     },
   }) as T
 
-// https://stackoverflow.com/a/7888303
 export const camelToSnake = (str: string) =>
   str
     .split(/(?=[A-Z])/)
     .map((s) => s.toUpperCase())
     .join('_')
 
-// https://stackoverflow.com/a/61375162
 export const snakeToCamel = (str: string) =>
-  str
-    .toLowerCase()
-    .replace(/([-_][a-z])/g, (group) =>
-      group.toUpperCase().replace('-', '').replace('_', '')
-    )
+  str.toLowerCase().replace(/([a-z])[_-]+([a-z])/g, (_, p1, p2) => {
+    return p1 + p2.toUpperCase()
+  })
+
+export const parseBool = (v: string): boolean | string =>
+  ({ true: true, false: false })[v] ?? v
test/goods.test.js
@@ -32,15 +32,8 @@ describe('goods', () => {
     assert.match((await p).stdout, /Answer is foo/)
   })
 
-  test('globby available', async () => {
+  test('globby() works', async () => {
     assert.equal(globby, glob)
-    assert.equal(typeof globby, 'function')
-    assert.equal(typeof globby.globbySync, 'function')
-    assert.equal(typeof globby.globbyStream, 'function')
-    assert.equal(typeof globby.generateGlobTasks, 'function')
-    assert.equal(typeof globby.isDynamicPattern, 'function')
-    assert.equal(typeof globby.isGitIgnored, 'function')
-    assert.equal(typeof globby.isGitIgnoredSync, 'function')
     assert.deepEqual(await globby('*.md'), ['README.md'])
   })
 
@@ -178,4 +171,45 @@ describe('goods', () => {
       assert(out.exitCode !== 0)
     })
   })
+
+  test('parseArgv() works', () => {
+    assert.deepEqual(
+      parseArgv(
+        // prettier-ignore
+        [
+          '--foo-bar', 'baz',
+          '-a', '5',
+          '-a', '42',
+          '--aaa', 'AAA',
+          '--force',
+          './some.file',
+          '--b1', 'true',
+          '--b2', 'false',
+          '--b3',
+          '--b4', 'false',
+          '--b5', 'true',
+          '--b6', 'str'
+        ],
+        {
+          boolean: ['force', 'b3', 'b4', 'b5', 'b6'],
+          camelCase: true,
+          parseBoolean: true,
+          alias: { a: 'aaa' },
+        }
+      ),
+      {
+        a: [5, 42, 'AAA'],
+        aaa: [5, 42, 'AAA'],
+        fooBar: 'baz',
+        force: true,
+        _: ['./some.file', 'str'],
+        b1: true,
+        b2: false,
+        b3: true,
+        b4: false,
+        b5: true,
+        b6: true,
+      }
+    )
+  })
 })
test/index.test.js
@@ -39,8 +39,9 @@ import {
   quote,
   quotePowerShell,
   within,
-  argv,
   os,
+  argv,
+  parseArgv,
   updateArgv,
   globby,
   glob,
@@ -82,8 +83,9 @@ describe('index', () => {
     assert(useBash)
 
     // goods
-    assert(argv)
     assert(os)
+    assert(argv)
+    assert(parseArgv)
     assert(updateArgv)
     assert(globby)
     assert(glob)
test/util.test.js
@@ -145,5 +145,6 @@ describe('util', () => {
     assert.equal(snakeToCamel('NOTHROW'), 'nothrow')
     assert.equal(snakeToCamel('PREFER_LOCAL'), 'preferLocal')
     assert.equal(snakeToCamel('SOME_MORE_BIG_STR'), 'someMoreBigStr')
+    assert.equal(snakeToCamel('kebab-input-str'), 'kebabInputStr')
   })
 })
.size-limit.json
@@ -9,7 +9,7 @@
   {
     "name": "zx/index",
     "path": "build/*.{js,cjs}",
-    "limit": "803 kB",
+    "limit": "804 kB",
     "brotli": false,
     "gzip": false
   },