diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f2c14f..a80a9c2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: - name: Test run: | pnpm test - NODE_OPTIONS='--import=tsx/esm' pnpm run --use-node-version=18.20.3 test + pnpm run --use-node-version=22.22.2 test - name: Lint run: pnpm lint diff --git a/README.md b/README.md index a1b8f83..3b05463 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,21 @@ const parsed = typeFlag({ parsed.flags.someString // ['hello', 'world'] ``` +Acronyms are treated as a single word, so consecutive capitals stay together in the kebab-case form. + +```ts +const parsed = typeFlag({ + getID: String, + myURL: String, + getHTTPResponse: String +}) + +// $ node ./cli --get-id 1 --my-url https://example.com --get-http-response foo +parsed.flags.getID // => '1' +parsed.flags.myURL // => 'https://example.com' +parsed.flags.getHTTPResponse // => 'foo' +``` + ### Unknown Flags And Forwarding Unknown flags are returned separately instead of being mixed into `flags`. @@ -248,6 +263,8 @@ const parsed = typeFlag({}) parsed.unknownFlags // { 'some-flag': [true, '1234'] } ``` +Unknown flags are not converted to camelCase to allow for accurate error handling. + Wrapper CLIs often need to consume their own flags and pass everything else to another command. If you pass your own argv array, _type-flag_ removes parsed tokens and leaves ignored tokens behind. Ignored tokens are left in `argv` exactly as passed, so an `ignore` callback can stop parsing at a command name and preserve the full command tail for another parser. @@ -344,6 +361,20 @@ parsed.flags.env // ['TOKEN=abc'] These are the supported delimiters; arbitrary delimiter characters are not treated as value separators. +#### Error Wrapping + +When a custom type parser throws, the error is wrapped in a `TypeError` whose message identifies the flag by name. The original error is preserved on `.cause`. + +```ts +// $ node ./cli --size huge +try { + typeFlag({ size: Size }) +} catch { + // thrown TypeError message => 'Flag "--size": Invalid size: "huge"' + // .cause.message => 'Invalid size: "huge"' +} +``` + ### Boolean Negation Enable `booleanNegation` to support `--no-` prefixed flags for booleans. diff --git a/examples/count-flags.ts b/examples/count-flags.ts index 1496c99..109ae41 100644 --- a/examples/count-flags.ts +++ b/examples/count-flags.ts @@ -2,7 +2,7 @@ * This example demonstrates how to count the number of flags * * Usage: - * $ npx tsx ./examples/count-flags -vvv + * $ node --conditions=development ./examples/count-flags.ts -vvv */ import { typeFlag } from '#type-flag'; diff --git a/examples/custom-type.ts b/examples/custom-type.ts index 3513ca6..ffe0ad7 100644 --- a/examples/custom-type.ts +++ b/examples/custom-type.ts @@ -2,7 +2,7 @@ * This example demonstrates how to create a custom type * * Usage: - * $ npx tsx ./examples/count-flags --size medium + * $ node --conditions=development ./examples/custom-type.ts --size medium */ import { typeFlag } from '#type-flag'; diff --git a/examples/dot-nested.ts b/examples/dot-nested.ts index a0851b7..1a120ef 100644 --- a/examples/dot-nested.ts +++ b/examples/dot-nested.ts @@ -2,7 +2,7 @@ * This example demonstrates how a dot-nested object can be created * * Usage: - * $ npx tsx ./examples/dot-nested --env.TOKEN=123 --env.CI + * $ node --conditions=development ./examples/dot-nested.ts --env.TOKEN=123 --env.CI */ import { typeFlag } from '#type-flag'; diff --git a/examples/invert-boolean.ts b/examples/invert-boolean.ts index b8fb6c2..463dfa6 100644 --- a/examples/invert-boolean.ts +++ b/examples/invert-boolean.ts @@ -2,7 +2,7 @@ * This example demonstrates how to invert a boolean flag * * Usage: - * $ npx tsx ./examples/invert-boolean --boolean=false + * $ node --conditions=development ./examples/invert-boolean.ts --boolean=false */ import { typeFlag } from '#type-flag'; diff --git a/package.json b/package.json index d139265..355aed9 100644 --- a/package.json +++ b/package.json @@ -24,13 +24,9 @@ "skills" ], "type": "module", - "main": "./dist/index.cjs", - "module": "./dist/index.mjs", - "types": "./dist/index.d.ts", "exports": { "types": "./dist/index.d.ts", - "require": "./dist/index.cjs", - "import": "./dist/index.mjs" + "default": "./dist/index.mjs" }, "imports": { "#type-flag": { @@ -49,6 +45,9 @@ "prepare": "skills-npm", "prepack": "pnpm build && clean-pkg-json" }, + "engines": { + "node": ">=22.22.2" + }, "devDependencies": { "@types/node": "^24.10.15", "clean-pkg-json": "^1.4.2", @@ -57,7 +56,6 @@ "manten": "^2.0.0", "pkgroll": "^2.27.0", "skills-npm": "^1.0.0", - "tsx": "^4.21.0", "typescript": "^5.9.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14ef2a6..db9dcef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,9 +29,6 @@ importers: skills-npm: specifier: ^1.0.0 version: 1.0.0 - tsx: - specifier: ^4.21.0 - version: 4.21.0 typescript: specifier: ^5.9.3 version: 5.9.3 diff --git a/skills/type-flag/SKILL.md b/skills/type-flag/SKILL.md index b0f0094..b62b5d8 100644 --- a/skills/type-flag/SKILL.md +++ b/skills/type-flag/SKILL.md @@ -57,7 +57,7 @@ Type options: | `--flag value` / `--flag=value` | Long form | | `-f value` / `-f=value` | Short form (alias or single-char name) | | `-abc` | Group: each char matches an alias or single-char name independently | -| `--some-flag` | kebab-case → camelCase (`someFlag`) unless schema key is kebab | +| `--some-flag` | kebab-case → camelCase (`someFlag`) unless schema key is kebab; consecutive capitals treated as one unit (`getID` → `--get-id`, `URLParser` → `--url-parser`) | | `--flag:value` / `--flag.value` | `:` and `.` also delimit values (useful for `--define:K=V`, `--env.KEY=V`) | | `-xvalue` (concatenated) | ⚠️ Parsed as GROUP, not `x=value`. Use `-x value` or `-x=value`. | @@ -141,7 +141,7 @@ Same argv-mutation behavior as `typeFlag`. | Reserved chars in names | `\s`, `.`, `:`, `=` forbidden in flag names (they're delimiters). | | kebab schema key | If schema key is `'some-flag'`, only `--some-flag` / `--someFlag` both map to it, but output key stays kebab. | | Default functions throw | A throwing `default: () => ...` propagates. | -| Custom-type errors wrap | Parser errors include the flag name in the thrown message. | +| Custom-type errors wrap | Errors thrown by custom type parsers are wrapped in a `TypeError` with message `Flag "--": `; original error is on `.cause`. | ## Related diff --git a/src/get-flag.ts b/src/get-flag.ts index 7e2f125..8f4a8c5 100644 --- a/src/get-flag.ts +++ b/src/get-flag.ts @@ -46,7 +46,7 @@ export const getFlag = ( removeArgvs.push(valueIndex); } - results.push(applyParser(parser, implicitValue || '')); + results.push(applyParser(parser, implicitValue || '', name)); }; return ( diff --git a/src/index.ts b/src/index.ts index 9ef1ec1..8f6e815 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export { typeFlag } from './type-flag.ts'; export { getFlag } from './get-flag.ts'; +export { flagNameToKebab } from './utils.ts'; export { createPositionalArguments } from './positional-arguments.ts'; export type { TypeFlag, diff --git a/src/type-flag.ts b/src/type-flag.ts index 2249a01..ab8f7e2 100644 --- a/src/type-flag.ts +++ b/src/type-flag.ts @@ -101,7 +101,7 @@ export const typeFlag = ( } values.push( - applyParser(parser, value || ''), + applyParser(parser, value || '', name), ); }; diff --git a/src/utils.ts b/src/utils.ts index 91df8e8..d6d98b1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,8 +5,27 @@ import type { FlagSchema, } from './types.ts'; -const camelCasePattern = /\B([A-Z])/g; -const camelToKebab = (string: string) => string.replaceAll(camelCasePattern, '-$1').toLowerCase(); +/** + * Regex uses zero-width assertions to find positions for hyphen insertion: + * - (?<=[a-z])(?=[A-Z]) → after lowercase, before uppercase + * - (?<=[A-Z])(?=[A-Z][a-z]) → after uppercase, before uppercase+lowercase + */ +const kebabPattern = /(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g; + +/** + * Normalize a schema-declared flag name (e.g. `orgID`, `apiURL`, `fooBar`) + * to the kebab-case form that matches argv tokens (`--org-id`, `--api-url`, + * `--foo-bar`). Preserves acronyms as single segments. + * + * @example + * ```ts + * flagNameToKebab('orgID') // => 'org-id' + * flagNameToKebab('apiURL') // => 'api-url' + * flagNameToKebab('parseJSONData') // => 'parse-json-data' + * flagNameToKebab('fooBar') // => 'foo-bar' + * ``` + */ +export const flagNameToKebab = (name: string): string => name.replaceAll(kebabPattern, '-').toLowerCase(); const { hasOwnProperty } = Object.prototype; export const hasOwn = ( @@ -42,6 +61,7 @@ export const normalizeBoolean = ( export const applyParser = ( typeFunction: TypeFunction, value: unknown, + flagName?: string, ) => { if (typeof value === 'boolean') { return value; @@ -51,7 +71,14 @@ export const applyParser = ( return Number.NaN; } - return typeFunction(value); + try { + return typeFunction(value); + } catch (error) { + throw new TypeError( + `Flag "--${flagName}": ${error instanceof Error ? error.message : error}`, + { cause: error }, + ); + } }; const reservedCharactersPattern = /[\s.:=]/; @@ -114,7 +141,7 @@ export const createRegistry = ( setFlag(registry, flagName, flagData); - const kebabCasing = camelToKebab(flagName); + const kebabCasing = flagNameToKebab(flagName); if (flagName !== kebabCasing) { setFlag(registry, kebabCasing, flagData); } diff --git a/tests/index.ts b/tests/index.ts index 16d3068..0465ec3 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -3,4 +3,5 @@ import { describe } from 'manten'; describe('type-flag', () => { import('./specs/type-flag/index.ts'); import('./specs/get-flag/index.ts'); + import('./specs/flag-name-to-kebab/index.ts'); }); diff --git a/tests/specs/flag-name-to-kebab/index.ts b/tests/specs/flag-name-to-kebab/index.ts new file mode 100644 index 0000000..9c2fa26 --- /dev/null +++ b/tests/specs/flag-name-to-kebab/index.ts @@ -0,0 +1,43 @@ +import { test, expect } from 'manten'; +import { flagNameToKebab, typeFlag } from '#type-flag'; + +test('standard camelCase', () => { + expect(flagNameToKebab('fooBar')).toBe('foo-bar'); +}); + +test('trailing acronym', () => { + expect(flagNameToKebab('orgID')).toBe('org-id'); + expect(flagNameToKebab('apiURL')).toBe('api-url'); +}); + +test('middle acronym followed by capitalized word', () => { + expect(flagNameToKebab('someAPIKey')).toBe('some-api-key'); +}); + +test('middle acronym before capitalized word', () => { + expect(flagNameToKebab('parseJSONData')).toBe('parse-json-data'); +}); + +test('all-caps standalone', () => { + expect(flagNameToKebab('ID')).toBe('id'); +}); + +test('already lowercase', () => { + expect(flagNameToKebab('id')).toBe('id'); +}); + +test('single word', () => { + expect(flagNameToKebab('simple')).toBe('simple'); +}); + +test('parses acronym flag names from argv', () => { + const parsed = typeFlag({ + orgID: String, + apiURL: String, + }, [ + '--org-id=acme', + '--api-url=https://example.com', + ]); + expect(parsed.flags.orgID).toBe('acme'); + expect(parsed.flags.apiURL).toBe('https://example.com'); +}); diff --git a/tests/specs/type-flag/error-handling.ts b/tests/specs/type-flag/error-handling.ts index 5c78236..010934e 100644 --- a/tests/specs/type-flag/error-handling.ts +++ b/tests/specs/type-flag/error-handling.ts @@ -123,7 +123,7 @@ describe('Error handling', () => { typeFlag({ custom: ThrowingParser, }, ['--custom', 'value']); - }).toThrow('Custom parse error'); + }).toThrow('Flag "--custom": Custom parse error'); }); test('Custom parser throws on specific value', () => { @@ -139,7 +139,23 @@ describe('Error handling', () => { typeFlag({ number: StrictNumber, }, ['--number', 'not-a-number']); - }).toThrow('Invalid number: not-a-number'); + }).toThrow('Flag "--number": Invalid number: not-a-number'); + }); + + test('Wrapped error preserves original via cause', () => { + const original = new Error('original error'); + const ThrowingParser = (_value: string) => { + throw original; + }; + + try { + typeFlag({ + flag: ThrowingParser, + }, ['--flag', 'value']); + } catch (error) { + expect(error).toBeInstanceOf(TypeError); + expect((error as TypeError).cause).toBe(original); + } }); }); diff --git a/tests/specs/type-flag/parsing.ts b/tests/specs/type-flag/parsing.ts index df7c4c1..e15e2e8 100644 --- a/tests/specs/type-flag/parsing.ts +++ b/tests/specs/type-flag/parsing.ts @@ -359,6 +359,38 @@ describe('Parsing', () => { expect(argv).toStrictEqual([]); }); + test('acronyms in camelCase convert to kebab-case correctly', () => { + // getID should accept --get-id, not --get-i-d + const argv1 = ['--get-id=123']; + const parsed1 = typeFlag({ getID: String }, argv1); + expect(parsed1.flags.getID).toBe('123'); + expect(argv1).toStrictEqual([]); + + // parseURL should accept --parse-url, not --parse-u-r-l + const argv2 = ['--parse-url=https://example.com']; + const parsed2 = typeFlag({ parseURL: String }, argv2); + expect(parsed2.flags.parseURL).toBe('https://example.com'); + expect(argv2).toStrictEqual([]); + + // XMLParser should accept --xml-parser + const argv3 = ['--xml-parser=true']; + const parsed3 = typeFlag({ XMLParser: Boolean }, argv3); + expect(parsed3.flags.XMLParser).toBe(true); + expect(argv3).toStrictEqual([]); + + // getIDNumber should accept --get-id-number + const argv4 = ['--get-id-number=456']; + const parsed4 = typeFlag({ getIDNumber: Number }, argv4); + expect(parsed4.flags.getIDNumber).toBe(456); + expect(argv4).toStrictEqual([]); + + // someAPIKey should accept --some-api-key + const argv5 = ['--some-api-key=secret']; + const parsed5 = typeFlag({ someAPIKey: String }, argv5); + expect(parsed5.flags.someAPIKey).toBe('secret'); + expect(argv5).toStrictEqual([]); + }); + test('flag=', () => { const argv = ['--string=hello', '-s=bye', '--string=', '--boolean=true', '--boolean=false', '--boolean=', 'world', '--number=3.14', '--number=']; const parsed = typeFlag(