Skip to content

Commit bf53f43

Browse files
committed
fix(cli): camel-case nested flag keys
cac camel-cases only the first segment of a dotted flag, so `--deps.never-bundle` was parsed as `deps['never-bundle']` and silently ignored, along with every other nested kebab-case flag such as `--deps.always-bundle`, `--dts.emit-dts-only` or `--exports.dev-exports`. Normalize all nested keys right after parsing, keeping options whose keys are user-defined (`env`, `define`, `alias`, `entry`, `loader`, `inputOptions.moduleTypes`, `outputOptions.globals`) verbatim. close #1058
1 parent 84346b5 commit bf53f43

4 files changed

Lines changed: 103 additions & 5 deletions

File tree

docs/reference/cli.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ The mapping between CLI flags and configuration options follows these rules:
1111
- `--foo.bar` sets `foo: { bar: true }`
1212
- `--format esm --format cjs` sets `format: ['esm', 'cjs']`
1313

14-
CLI flags support both camelCase and kebab-case. For example, `--outDir` and `--out-dir` are equivalent.
14+
CLI flags support both camelCase and kebab-case. For example, `--outDir` and `--out-dir` are equivalent. Nested keys follow the same rule, so `--deps.never-bundle` and `--deps.neverBundle` are equivalent as well.
15+
16+
Options whose keys are defined by you — `--env.*`, `--define.*`, `--alias.*`, `--entry.*` and `--loader.*` — are the exception: their keys are used exactly as written.
1517

1618
This flexible pattern allows you to easily control and override configuration options directly from the command line.
1719

pnpm-workspace.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,3 @@ minimumReleaseAge: 0
100100
trustLockfile: true
101101
trustPolicy: no-downgrade
102102
trustPolicyIgnoreAfter: 10080 # 7 days
103-
virtualStoreType: global

src/cli.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { parseCLI } from './cli.ts'
3+
4+
function parse(...args: string[]): Record<string, any> {
5+
return parseCLI(['node', 'tsdown', ...args]).options
6+
}
7+
8+
describe('parseCLI', () => {
9+
it('camel-cases nested option keys', () => {
10+
expect(parse('--deps.never-bundle', 'leftpad').deps).toEqual({
11+
neverBundle: 'leftpad',
12+
})
13+
expect(parse('--deps.neverBundle', 'leftpad').deps).toEqual({
14+
neverBundle: 'leftpad',
15+
})
16+
expect(parse('--deps.dts.never-bundle', 'leftpad').deps).toEqual({
17+
dts: { neverBundle: 'leftpad' },
18+
})
19+
})
20+
21+
it('keeps user-defined keys verbatim', () => {
22+
const options = parse(
23+
'--env.my-var',
24+
'a',
25+
'--define.my-flag',
26+
'b',
27+
'--alias.my-lib',
28+
'./src/my-lib.ts',
29+
)
30+
expect(options.env).toEqual({ 'my-var': 'a' })
31+
expect(options.define).toEqual({ 'my-flag': 'b' })
32+
expect(options.alias).toEqual({ 'my-lib': './src/my-lib.ts' })
33+
})
34+
35+
it('leaves top-level flags and entries alone', () => {
36+
const { args, options } = parseCLI([
37+
'node',
38+
'tsdown',
39+
'src/index.ts',
40+
'--out-dir',
41+
'lib',
42+
])
43+
expect(args).toEqual(['src/index.ts'])
44+
expect(options.outDir).toBe('lib')
45+
})
46+
})

src/cli.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable unicorn/no-top-level-side-effects */
22
import process from 'node:process'
3-
import { cac } from 'cac'
3+
import { cac, type CAC } from 'cac'
44
import { VERSION as rolldownVersion } from 'rolldown'
55
import { x } from 'tinyexec'
66
import pkg from '../package.json' with { type: 'json' }
@@ -130,10 +130,61 @@ cli
130130
process.exitCode = exitCode
131131
})
132132

133+
/**
134+
* Options whose nested keys are user-defined values (environment variable
135+
* names, module ids, entry names, ...) instead of option names.
136+
*/
137+
const VERBATIM_KEYS: ReadonlySet<string> = new Set([
138+
'alias',
139+
'define',
140+
'entry',
141+
'env',
142+
'loader',
143+
'inputOptions.moduleTypes',
144+
'outputOptions.globals',
145+
])
146+
147+
/**
148+
* cac camel-cases only the first segment of a dotted flag, so
149+
* `--deps.never-bundle` is parsed as `deps['never-bundle']` while the config
150+
* expects `deps.neverBundle`. Camel-case the remaining segments here, leaving
151+
* the keys of {@linkcode VERBATIM_KEYS} untouched.
152+
*/
153+
function camelizeFlags(flags: Record<string, any>, path?: string): void {
154+
for (const key of Object.keys(flags)) {
155+
const camelKey = key.replaceAll(
156+
/([a-z])-([a-z])/g,
157+
(_, prev, next) => prev + next.toUpperCase(),
158+
)
159+
const value = flags[key]
160+
const childPath = path ? `${path}.${camelKey}` : camelKey
161+
162+
if (
163+
value &&
164+
typeof value === 'object' &&
165+
!Array.isArray(value) &&
166+
!VERBATIM_KEYS.has(childPath)
167+
) {
168+
camelizeFlags(value, childPath)
169+
}
170+
171+
if (camelKey !== key) {
172+
if (!(camelKey in flags)) flags[camelKey] = value
173+
delete flags[key]
174+
}
175+
}
176+
}
177+
178+
export function parseCLI(argv: string[]): ReturnType<CAC['parse']> {
179+
const parsed = cli.parse(argv, { run: false })
180+
camelizeFlags(parsed.options)
181+
return parsed
182+
}
183+
133184
export async function runCLI(): Promise<void> {
134-
cli.parse(process.argv, { run: false })
185+
const { options } = parseCLI(process.argv)
135186

136-
enableDebug(cli.options.debug)
187+
enableDebug(options.debug)
137188

138189
try {
139190
await cli.runMatchedCommand()

0 commit comments

Comments
 (0)