[READ-ONLY] Mirror of https://github.com/bombshell-dev/args. <1kB CLI flag parser
args args-parser cli command-line command-line-parser node
5

Configure Feed

Select the types of activity you want to include in your feed.

ref(schema): refactor to use schema-based args

Nate Moore (May 12, 2026, 11:33 PM EDT) c94f8100 3bc34eb6

+1385 -476
+48
.changeset/async-parse-standard-schema.md
··· 1 + --- 2 + "@bomb.sh/args": major 3 + --- 4 + 5 + `parse()` is now async and returns `Promise<T>`. Update call sites with `await`. 6 + 7 + `ParseOptions` no longer accepts `boolean`, `string`, `array`, or `default` options. Use your schema library's built-in type coercion and `.default()` instead. 8 + 9 + **Before:** 10 + 11 + ```ts 12 + const args = parse(argv, { 13 + boolean: ["verbose"], 14 + string: ["output"], 15 + default: { port: 3000 }, 16 + alias: { v: "verbose" }, 17 + }); 18 + ``` 19 + 20 + **After (with Zod):** 21 + 22 + ```ts 23 + const args = await parse(argv, { 24 + schema: z.object({ 25 + verbose: z.boolean().default(false), 26 + output: z.string(), 27 + port: z.coerce.number().default(3000), 28 + }), 29 + alias: { v: "verbose" }, 30 + }); 31 + ``` 32 + 33 + **After (built-in primitives, no Zod):** 34 + 35 + ```ts 36 + import { object, boolean, string, number } from "@bomb.sh/args/schema"; 37 + 38 + const args = await parse(argv, { 39 + schema: object({ verbose: boolean(), output: string(), port: number() }), 40 + alias: { v: "verbose" }, 41 + }); 42 + ``` 43 + 44 + **After (sync, no schema):** 45 + 46 + ```ts 47 + const args = parseSync(argv, { alias: { v: "verbose" } }); 48 + ```
+19
.changeset/camelcase-kebab-normalization.md
··· 1 + --- 2 + "@bomb.sh/args": minor 3 + --- 4 + 5 + `parse()` now automatically resolves camelCase ↔ kebab-case flag variants when using an `object()` schema. No aliases required — `--moduleTypes` and `--module-types` both map to a `moduleTypes` field. 6 + 7 + ```ts 8 + const args = await parse(["--module-types", "esm"], { 9 + schema: object({ moduleTypes: string() }), 10 + }); 11 + // args.moduleTypes → "esm" 12 + 13 + const args2 = await parse(["--moduleTypes", "esm"], { 14 + schema: object({ moduleTypes: string() }), 15 + }); 16 + // args2.moduleTypes → "esm" 17 + ``` 18 + 19 + This eliminates the need for manual workarounds like `--moduleTypes` → `--module-types` normalization.
+11
.changeset/parse-sync-env-terminator.md
··· 1 + --- 2 + "@bomb.sh/args": minor 3 + --- 4 + 5 + New features alongside the Standard Schema rewrite: 6 + 7 + - **`parseSync(argv)`** — synchronous parsing with no schema validation, returns `RawArgs`. 8 + - **`ParseError`** — thrown by `parse()` on schema validation failure. Carries a typed `issues: ReadonlyArray<StandardSchemaV1.Issue>` array. 9 + - **`env` option** — `env: { prefix: 'APP' }` auto-injects `APP_*` environment variables as flag defaults before schema validation. Argv takes precedence. 10 + - **`--` terminator** — all arguments after `--` are collected into `_` verbatim, not parsed as flags. 11 + - **Typed positionals** — define `_` in your schema for full type safety (`z.tuple([z.string()])` for fixed-length, `z.array(z.string())` for variadic).
+5
.changeset/remove-json-file-schemas.md
··· 1 + --- 2 + "@bomb.sh/args": major 3 + --- 4 + 5 + Removed `json()` and `file()` from `@bomb.sh/args/schema`.
+26
.changeset/schema-metadata-methods.md
··· 1 + --- 2 + "@bomb.sh/args": minor 3 + --- 4 + 5 + Add `.alias()` and `.docs()` chainable methods to all built-in schema primitives. 6 + 7 + `.alias(...names)` registers per-field aliases that are automatically extracted by `parse()` when using an `object()` schema — no manual `alias` map required. 8 + 9 + `.docs(description)` attaches a description string to the schema for help text generation and tooling. 10 + 11 + ```ts 12 + import { object, number, boolean } from "@bomb.sh/args/schema"; 13 + 14 + const args = await parse(process.argv.slice(2), { 15 + schema: object({ 16 + port: number().alias("p").docs("Port to listen on"), 17 + verbose: boolean().alias("v").docs("Enable verbose output"), 18 + }), 19 + }); 20 + // parse(["-p", "3000"]) → { port: 3000 } 21 + // parse(["-v"]) → { verbose: true } 22 + ``` 23 + 24 + Manual `opts.alias` entries take precedence over per-field aliases on conflict. 25 + 26 + Also exports `ArgsSchema`, `SchemaMeta`, and `ObjectSchema` types for consumers building on top of the built-in primitives. `object()` now exposes a `shape` property for introspection.
+20
.changeset/schema-subpath.md
··· 1 + --- 2 + "@bomb.sh/args": minor 3 + --- 4 + 5 + Add `@bomb.sh/args/schema` subpath with built-in Standard Schema primitives requiring no external dependencies: `string`, `number`, `boolean`, `array`, `object`. 6 + 7 + These work with `parse()` directly or compose with Zod, Valibot, Arktype, and any other `@standard-schema/spec`-compliant library. 8 + 9 + ```ts 10 + import { parse } from "@bomb.sh/args"; 11 + import { object, number, boolean, string, array } from "@bomb.sh/args/schema"; 12 + 13 + const args = await parse(process.argv.slice(2), { 14 + schema: object({ 15 + port: number(), 16 + verbose: boolean(), 17 + tags: array(string()), 18 + }), 19 + }); 20 + ```
+1 -1
CHANGELOG.md
··· 1 - # @bomb.sh/args (fka `ultraflag`) 1 + # @bomb.sh/args 2 2 3 3 ## 0.3.1 4 4
+246 -18
README.md
··· 1 1 # `@bomb.sh/args` 2 2 3 - A <1kB library for parsing CLI flags. Inspired by Deno's `std/cli` [`parseArgs`](https://github.com/denoland/std/blob/main/cli/parse_args.ts) module. 3 + A <1kB library for parsing CLI flags with first-class schema validation. 4 4 5 5 ### Features 6 6 ··· 10 10 11 11 🏃 very fast (beats [`node:util`](https://nodejs.org/api/util.html#utilparseargsconfig)) 12 12 13 - 🔏 strongly typed 13 + 🔏 strongly typed via [`@standard-schema/spec`](https://standardschema.dev/) 14 + 15 + 🧩 works with any schema library (Zod, Valibot, Arktype, or built-ins) 16 + 17 + --- 14 18 15 - ### Usage 19 + ## Usage 16 20 17 - Basic usage does not require any configuration. 21 + ### Basic (no schema) 18 22 19 - ```js 20 - import { parse } from "@bomb.sh/args"; 23 + ```ts 24 + import { parseSync } from "@bomb.sh/args"; 21 25 22 26 // my-cli build --bundle -rf --a value --b=value --c 1 23 - const argv = process.argv.slice(2); 24 - const args = parse(argv); 25 - 26 - console.log(args); 27 + const args = parseSync(process.argv.slice(2)); 27 28 // { _: ['build'], bundle: true, r: true, f: true, a: "value", b: "value", c: 1 } 28 29 ``` 29 30 30 - Parsing can be configured to ensure arguments are coerced to specific types, which enhances type safety. 31 + ### With schema (async) 31 32 32 - ```js 33 - const args = parse(argv, { 34 - default: { a: 1, b: 2, c: "value" }, 35 - alias: { h: "help" }, 36 - boolean: ["foo", "bar"], 37 - string: ["baz", "qux"], 38 - array: ["input"], 33 + Pass any [`@standard-schema/spec`](https://standardschema.dev/)-compliant schema to `parse()` for validation and strong type inference. Works with [Zod](https://zod.dev), [Valibot](https://valibot.dev), [Arktype](https://arktype.io), and more. 34 + 35 + ```ts 36 + import { parse } from "@bomb.sh/args"; 37 + import { z } from "zod"; 38 + 39 + const args = await parse(process.argv.slice(2), { 40 + schema: z.object({ 41 + port: z.coerce.number().default(3000), 42 + verbose: z.boolean().default(false), 43 + }), 44 + alias: { v: "verbose" }, 45 + }); 46 + 47 + // args.port → number 48 + // args.verbose → boolean 49 + ``` 50 + 51 + ### Built-in schema primitives 52 + 53 + No Zod? No problem. Import from `@bomb.sh/args/schema` for zero-dependency validation. 54 + 55 + ```ts 56 + import { parse } from "@bomb.sh/args"; 57 + import { object, number, boolean, string, array } from "@bomb.sh/args/schema"; 58 + 59 + const args = await parse(process.argv.slice(2), { 60 + schema: object({ 61 + port: number(), 62 + verbose: boolean(), 63 + tags: array(string()), 64 + }), 39 65 }); 40 66 ``` 67 + 68 + ### Per-field aliases and docs 69 + 70 + Built-in primitives support chainable `.alias()` and `.docs()` methods. Aliases registered on fields are automatically wired into `parse()` — no manual `alias` map needed. 71 + 72 + ```ts 73 + import { parse } from "@bomb.sh/args"; 74 + import { object, number, boolean, string } from "@bomb.sh/args/schema"; 75 + 76 + const args = await parse(process.argv.slice(2), { 77 + schema: object({ 78 + port: number().alias("p").docs("Port to listen on"), 79 + verbose: boolean().alias("v").docs("Enable verbose output"), 80 + }), 81 + }); 82 + 83 + // parse(["-p", "3000"]) → { port: 3000 } 84 + // parse(["-v"]) → { verbose: true } 85 + ``` 86 + 87 + ### camelCase ↔ kebab-case auto-normalization 88 + 89 + `parse()` automatically resolves camelCase ↔ kebab-case flag variants when using an `object()` schema. No aliases required — `--moduleTypes` and `--module-types` both resolve to the same field. 90 + 91 + ```ts 92 + const args = await parse(["--module-types", "esm"], { 93 + schema: object({ moduleTypes: string() }), 94 + }); 95 + // args.moduleTypes → "esm" 96 + 97 + const args2 = await parse(["--moduleTypes", "esm"], { 98 + schema: object({ moduleTypes: string() }), 99 + }); 100 + // args2.moduleTypes → "esm" 101 + ``` 102 + 103 + ### Boolean negation 104 + 105 + Flags prefixed with `--no-` are automatically treated as `false` for the base flag name. 106 + 107 + ```ts 108 + // my-cli --no-verbose 109 + // args.verbose → false 110 + ``` 111 + 112 + ### Nested flags 113 + 114 + Dotted flag names create nested output objects. 115 + 116 + ```ts 117 + // my-cli --output.dir dist --output.format esm 118 + // args.output → { dir: "dist", format: "esm" } 119 + ``` 120 + 121 + ### `=` syntax 122 + 123 + Both `--flag value` and `--flag=value` forms are supported. 124 + 125 + ```ts 126 + // my-cli --port=3000 127 + // my-cli --port 3000 128 + // Both → args.port → 3000 129 + ``` 130 + 131 + --- 132 + 133 + ## ParseOptions 134 + 135 + ```ts 136 + interface ParseOptions { 137 + /** Standard Schema-compliant schema. Drives return type and validation. */ 138 + schema?: StandardSchemaV1; 139 + 140 + /** 141 + * Map short flags to long names. When using built-in schema primitives, 142 + * prefer per-field .alias() instead. Manual aliases take precedence on conflict. 143 + */ 144 + alias?: Record<string, string>; 145 + 146 + /** 147 + * Auto-inject environment variables as flag defaults before validation. 148 + * --foo-bar maps to PREFIX_FOO_BAR (uppercased, hyphens → underscores). 149 + */ 150 + env?: false | { prefix: string }; 151 + } 152 + ``` 153 + 154 + --- 155 + 156 + ## Positional arguments 157 + 158 + Positionals land in `_` on the raw parsed object. Define `_` in your schema for full type safety. 159 + 160 + ```ts 161 + import { z } from "zod"; 162 + 163 + const args = await parse(process.argv.slice(2), { 164 + schema: z.object({ 165 + _: z.tuple([z.string()]), // first positional is a required string 166 + port: z.coerce.number().default(3000), 167 + }), 168 + }); 169 + 170 + // args._[0] → string (typed!) 171 + ``` 172 + 173 + Use `z.array()` for variadic positionals: 174 + 175 + ```ts 176 + schema: z.object({ 177 + _: z.array(z.string()), 178 + // ... 179 + }) 180 + ``` 181 + 182 + ### `--` terminator 183 + 184 + Everything after `--` is collected into `_` verbatim, not parsed as flags. 185 + 186 + ``` 187 + my-cli --verbose -- --not-a-flag file.txt 188 + ``` 189 + 190 + ```ts 191 + // args.verbose → true 192 + // args._ → ["--not-a-flag", "file.txt"] 193 + ``` 194 + 195 + --- 196 + 197 + ## Environment variable fallback 198 + 199 + When `env: { prefix }` is set, any `PREFIX_FLAG_NAME` env var is injected as a default for missing flags before schema validation. 200 + 201 + ```ts 202 + // APP_PORT=9000 my-cli 203 + const args = await parse([], { 204 + schema: z.object({ port: z.coerce.number() }), 205 + env: { prefix: "APP" }, 206 + }); 207 + // args.port → 9000 208 + ``` 209 + 210 + Argv always takes precedence over env vars. 211 + 212 + --- 213 + 214 + ## Error handling 215 + 216 + ```ts 217 + import { parse, ParseError } from "@bomb.sh/args"; 218 + 219 + try { 220 + const args = await parse(argv, { schema }); 221 + } catch (e) { 222 + if (e instanceof ParseError) { 223 + for (const issue of e.issues) { 224 + console.error(issue.message); 225 + } 226 + } 227 + } 228 + ``` 229 + 230 + --- 231 + 232 + ## `@bomb.sh/args/schema` 233 + 234 + Built-in schema primitives that implement `@standard-schema/spec`. Composable with other schema libraries or usable standalone. 235 + 236 + ### Primitives 237 + 238 + ```ts 239 + import { string, number, boolean, array, object } from "@bomb.sh/args/schema"; 240 + ``` 241 + 242 + | Primitive | Input | Output | 243 + |-----------|-------|--------| 244 + | `string()` | `string \| number \| boolean` | `string` | 245 + | `number()` | `number \| string` (coerces numeric strings) | `number` | 246 + | `boolean()` | `boolean \| "true" \| "false" \| "1" \| "0"` | `boolean` | 247 + | `array(item?)` | `unknown[]` | `T[]` | 248 + | `object(shape)` | `object` | `{ [K]: OutputType }` | 249 + 250 + All primitives support `.docs(description)` and `.alias(...names)` for metadata and per-field alias registration. 251 + 252 + --- 253 + 254 + ## `parseSync()` 255 + 256 + Synchronous parsing with no schema validation. Returns `RawArgs`. 257 + 258 + ```ts 259 + import { parseSync } from "@bomb.sh/args"; 260 + 261 + const args = parseSync(process.argv.slice(2), { 262 + alias: { v: "verbose" }, 263 + env: { prefix: "APP" }, 264 + }); 265 + // args.verbose → string | number | boolean | undefined 266 + ``` 267 + 268 + --- 41 269 42 270 ## Benchmarks 43 271
+11 -2
package.json
··· 20 20 "types": "./dist/index.d.ts", 21 21 "import": "./dist/index.js" 22 22 }, 23 + "./schema": { 24 + "types": "./dist/schema.d.ts", 25 + "import": "./dist/schema.js" 26 + }, 23 27 "./package.json": "./package.json" 24 28 }, 25 29 "scripts": { ··· 27 31 "build": "bsh build", 28 32 "format": "bsh format", 29 33 "lint": "bsh lint", 30 - "test": "vitest run" 34 + "test": "bsh test" 31 35 }, 32 36 "devDependencies": { 33 37 "@bomb.sh/tools": "^0.0.5", 34 38 "@changesets/cli": "^2.29.8", 39 + "@types/node": "^18.19.130", 35 40 "benchmark": "^2.1.4", 36 41 "chalk": "^5.6.2", 37 42 "esbuild": "^0.27.3", ··· 42 47 "pretty-bytes": "^6.1.1", 43 48 "typescript": "^4.9.5", 44 49 "vitest": "^4.0.18", 45 - "yargs-parser": "^21.1.1" 50 + "yargs-parser": "^21.1.1", 51 + "zod": "^4.3.6" 46 52 }, 47 53 "publishConfig": { 48 54 "access": "public", ··· 67 73 "version": "10.7.0", 68 74 "onFail": "error" 69 75 } 76 + }, 77 + "dependencies": { 78 + "@standard-schema/spec": "^1.1.0" 70 79 } 71 80 }
+43 -11
pnpm-lock.yaml
··· 7 7 importers: 8 8 9 9 .: 10 + dependencies: 11 + '@standard-schema/spec': 12 + specifier: ^1.1.0 13 + version: 1.1.0 10 14 devDependencies: 11 15 '@bomb.sh/tools': 12 16 specifier: ^0.0.5 13 17 version: 0.0.5 14 18 '@changesets/cli': 15 19 specifier: ^2.29.8 16 - version: 2.29.8 20 + version: 2.29.8(@types/node@18.19.130) 21 + '@types/node': 22 + specifier: ^18.19.130 23 + version: 18.19.130 17 24 benchmark: 18 25 specifier: ^2.1.4 19 26 version: 2.1.4 ··· 43 50 version: 4.9.5 44 51 vitest: 45 52 specifier: ^4.0.18 46 - version: 4.0.18 53 + version: 4.0.18(@types/node@18.19.130) 47 54 yargs-parser: 48 55 specifier: ^21.1.1 49 56 version: 21.1.1 57 + zod: 58 + specifier: ^4.3.6 59 + version: 4.3.6 50 60 51 61 packages: 52 62 ··· 651 661 '@types/node@12.20.55': 652 662 resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} 653 663 664 + '@types/node@18.19.130': 665 + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} 666 + 654 667 '@vitest/expect@4.0.18': 655 668 resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} 656 669 ··· 1095 1108 engines: {node: '>=4.2.0'} 1096 1109 hasBin: true 1097 1110 1111 + undici-types@5.26.5: 1112 + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} 1113 + 1098 1114 universalify@0.1.2: 1099 1115 resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} 1100 1116 engines: {node: '>= 4.0.0'} ··· 1187 1203 resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} 1188 1204 engines: {node: '>=12'} 1189 1205 1206 + zod@4.3.6: 1207 + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} 1208 + 1190 1209 snapshots: 1191 1210 1192 1211 '@babel/runtime@7.28.6': {} ··· 1265 1284 dependencies: 1266 1285 '@changesets/types': 6.1.0 1267 1286 1268 - '@changesets/cli@2.29.8': 1287 + '@changesets/cli@2.29.8(@types/node@18.19.130)': 1269 1288 dependencies: 1270 1289 '@changesets/apply-release-plan': 7.0.14 1271 1290 '@changesets/assemble-release-plan': 6.0.9 ··· 1281 1300 '@changesets/should-skip-package': 0.1.2 1282 1301 '@changesets/types': 6.1.0 1283 1302 '@changesets/write': 0.4.0 1284 - '@inquirer/external-editor': 1.0.3 1303 + '@inquirer/external-editor': 1.0.3(@types/node@18.19.130) 1285 1304 '@manypkg/get-packages': 1.1.3 1286 1305 ansi-colors: 4.1.3 1287 1306 ci-info: 3.9.0 ··· 1536 1555 '@esbuild/win32-x64@0.27.3': 1537 1556 optional: true 1538 1557 1539 - '@inquirer/external-editor@1.0.3': 1558 + '@inquirer/external-editor@1.0.3(@types/node@18.19.130)': 1540 1559 dependencies: 1541 1560 chardet: 2.1.1 1542 1561 iconv-lite: 0.7.2 1562 + optionalDependencies: 1563 + '@types/node': 18.19.130 1543 1564 1544 1565 '@jridgewell/sourcemap-codec@1.5.5': {} 1545 1566 ··· 1659 1680 1660 1681 '@types/node@12.20.55': {} 1661 1682 1683 + '@types/node@18.19.130': 1684 + dependencies: 1685 + undici-types: 5.26.5 1686 + 1662 1687 '@vitest/expect@4.0.18': 1663 1688 dependencies: 1664 1689 '@standard-schema/spec': 1.1.0 ··· 1668 1693 chai: 6.2.2 1669 1694 tinyrainbow: 3.0.3 1670 1695 1671 - '@vitest/mocker@4.0.18(vite@7.3.1)': 1696 + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@18.19.130))': 1672 1697 dependencies: 1673 1698 '@vitest/spy': 4.0.18 1674 1699 estree-walker: 3.0.3 1675 1700 magic-string: 0.30.21 1676 1701 optionalDependencies: 1677 - vite: 7.3.1 1702 + vite: 7.3.1(@types/node@18.19.130) 1678 1703 1679 1704 '@vitest/pretty-format@4.0.18': 1680 1705 dependencies: ··· 2103 2128 2104 2129 typescript@4.9.5: {} 2105 2130 2131 + undici-types@5.26.5: {} 2132 + 2106 2133 universalify@0.1.2: {} 2107 2134 2108 - vite@7.3.1: 2135 + vite@7.3.1(@types/node@18.19.130): 2109 2136 dependencies: 2110 2137 esbuild: 0.27.3 2111 2138 fdir: 6.5.0(picomatch@4.0.3) ··· 2114 2141 rollup: 4.59.0 2115 2142 tinyglobby: 0.2.15 2116 2143 optionalDependencies: 2144 + '@types/node': 18.19.130 2117 2145 fsevents: 2.3.3 2118 2146 2119 - vitest@4.0.18: 2147 + vitest@4.0.18(@types/node@18.19.130): 2120 2148 dependencies: 2121 2149 '@vitest/expect': 4.0.18 2122 - '@vitest/mocker': 4.0.18(vite@7.3.1) 2150 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@18.19.130)) 2123 2151 '@vitest/pretty-format': 4.0.18 2124 2152 '@vitest/runner': 4.0.18 2125 2153 '@vitest/snapshot': 4.0.18 ··· 2136 2164 tinyexec: 1.0.2 2137 2165 tinyglobby: 0.2.15 2138 2166 tinyrainbow: 3.0.3 2139 - vite: 7.3.1 2167 + vite: 7.3.1(@types/node@18.19.130) 2140 2168 why-is-node-running: 2.3.0 2169 + optionalDependencies: 2170 + '@types/node': 18.19.130 2141 2171 transitivePeerDependencies: 2142 2172 - jiti 2143 2173 - less ··· 2161 2191 stackback: 0.0.2 2162 2192 2163 2193 yargs-parser@21.1.1: {} 2194 + 2195 + zod@4.3.6: {}
+164
spec.md
··· 1 + # Arg Parser — Design Specification 2 + 3 + ## Overview 4 + 5 + A minimal, standalone TypeScript library for parsing CLI arguments with full type safety. Decoupled from any routing or framework layer. Accepts a spec string, a StandardSchema V1 flag schema, and an optional alias map. Returns a fully typed, validated context object. 6 + 7 + --- 8 + 9 + ## Package API 10 + 11 + ### `parse(argv, options)` 12 + 13 + ```ts 14 + function parse<Spec extends string, Schema extends StandardSchemaV1>( 15 + argv: string[], 16 + options: { 17 + spec: Spec; 18 + schema: Schema; 19 + alias?: Record< 20 + string, 21 + keyof Schema["~types"]["input"] | ExtractSpecNames<Spec> 22 + >; 23 + }, 24 + ): Promise<ParseResult<Spec, Schema>>; 25 + ``` 26 + 27 + The return type is a `Promise` because schema validation (via StandardSchema V1) may be async. 28 + 29 + --- 30 + 31 + ## Spec String 32 + 33 + The `spec` string describes the full command signature using standard CLI notation, inspired by docopt and POSIX conventions. It is intended to be human-readable and suitable for direct use in help text. 34 + 35 + ### Notation 36 + 37 + | Syntax | Meaning | Output type (no schema override) | 38 + | ----------- | ----------------------------- | -------------------------------- | 39 + | `bare` | Subcommand or literal segment | Not included in output | 40 + | `<name>` | Required positional | `string` | 41 + | `[name]` | Optional positional | `string \| undefined` | 42 + | `[...name]` | Variadic positional | `string[]` | 43 + 44 + ### Examples 45 + 46 + ``` 47 + 'deploy <env> [region] [...targets]' 48 + 'git commit <message>' 49 + 'build' 50 + ``` 51 + 52 + ### Rules 53 + 54 + - Bare words are treated as subcommand names and are not included in the parsed output. 55 + - Required positionals (`<name>`) must precede optional positionals (`[name]`). 56 + - The variadic positional (`[...name]`) must be the terminal segment if present. Only one variadic positional is allowed per spec. 57 + - Spec names are extracted at the type level to produce `ExtractSpecNames<Spec>`. 58 + 59 + --- 60 + 61 + ## Schema 62 + 63 + The `schema` option accepts any StandardSchema V1-compatible schema (e.g. Zod, Valibot, ArkType). It serves two purposes: 64 + 65 + 1. **Flag definitions** — any key in the schema that does not appear in the spec is treated as a named flag. 66 + 2. **Positional type overrides** — any key in the schema that matches a positional name in the spec overrides the default `string` type for that positional. 67 + 68 + ### Positional Type Resolution 69 + 70 + For each name extracted from the spec, the output type is resolved as follows: 71 + 72 + | Spec form | No schema entry | Schema entry `T` | Schema entry `T[]` | 73 + | ----------- | --------------------- | ---------------- | ------------------ | 74 + | `<name>` | `string` | `T` | `T[]` | 75 + | `[name]` | `string \| undefined` | `T \| undefined` | `T[] \| undefined` | 76 + | `[...name]` | `string[]` | `T[]` | `T[]` ← unwrapped | 77 + 78 + **Variadicity is always declared in the spec, never in the schema.** If a variadic spec entry (`[...name]`) maps to a schema entry of type `T[]`, the array is silently unwrapped to avoid `T[][]`. There is no CLI use case for a nested array output type. 79 + 80 + A schema entry typed as `T[]` on a non-variadic positional is left as-is — the user is expected to handle their own parsing of the raw string value (e.g. comma-separated input). 81 + 82 + --- 83 + 84 + ## Aliases 85 + 86 + The `alias` option maps short or alternate names to their canonical targets. Alias targets must be either a key of the schema's input type or a positional name extracted from the spec. This constraint is enforced at the type level. 87 + 88 + ```ts 89 + alias: { 90 + e: 'env', // positional alias 91 + f: 'force', // flag alias 92 + r: 'region' // optional positional alias 93 + } 94 + ``` 95 + 96 + **Alias resolution happens before validation.** By the time the schema is called, all aliases have been resolved to their canonical names. The output object is always keyed by canonical names only — aliases are invisible in the output type. 97 + 98 + --- 99 + 100 + ## Argument Parsing Rules 101 + 102 + ### Positional filling 103 + 104 + Positionals are filled left-to-right from non-flag tokens in argv. A positional may also be passed by name (e.g. `--env prod`), in which case the named form takes precedence over positional filling. If both a named and positional form are provided for the same argument, the named form wins. 105 + 106 + ### Variadic collection 107 + 108 + A variadic positional (`[...name]`) collects all remaining non-flag tokens after prior positionals are filled. When passed by name, it uses space-separated values (`--targets a b c`), stopping at the next flag token. 109 + 110 + ### Flag parsing 111 + 112 + Flags are parsed from tokens starting with `--` (long form) or `-` (short form, alias only). Boolean flags are `true` when present, `false` when absent. Value flags consume the next token or use `=` syntax (`--tag=v1.0`). 113 + 114 + ### Precedence summary 115 + 116 + 1. Named form (`--name value`) wins over positional form for the same argument. 117 + 2. Aliases resolve to canonical names before any other processing. 118 + 3. Subcommand bare words are matched and consumed before positional filling begins. 119 + 120 + --- 121 + 122 + ## Output Type 123 + 124 + The resolved output type is the intersection of spec-inferred positionals and the schema output type: 125 + 126 + ```ts 127 + type ParseResult< 128 + Spec extends string, 129 + Schema extends StandardSchemaV1, 130 + > = InferSpecOutput<Spec, Schema> & Schema["~types"]["output"]; 131 + ``` 132 + 133 + Where `InferSpecOutput` maps each spec positional to its resolved type (per the resolution table above), and `Schema['~types']['output']` contributes all flag types. Since the schema may also contain keys that match positional names (for type overrides), positional types in the intersection take their resolved form — the schema's contribution for that key is subsumed. 134 + 135 + --- 136 + 137 + ## Validation 138 + 139 + Parsing and validation are two distinct phases: 140 + 141 + 1. **Parse** — tokenise argv, resolve aliases, fill positionals, collect flags. Produces a raw unvalidated record. 142 + 2. **Validate** — pass the raw record through the StandardSchema. Returns `Promise<output>` to accommodate async validators. 143 + 144 + If validation fails, `parse()` rejects with the schema's validation error. The library does not catch or transform schema errors — they propagate as-is. 145 + 146 + --- 147 + 148 + ## Constraints and Invariants 149 + 150 + - A positional name and a flag schema key must not collide. If `spec` contains `<env>` and `schema` also defines `env` as a flag (rather than a type override), this is ambiguous. The spec takes ownership of the name; the schema entry is treated as a type override, not a flag. 151 + - Variadic positionals must be terminal in the spec. A non-terminal variadic is a runtime error thrown before parsing begins. 152 + - Optional positionals must all be terminal and contiguous. A required positional may not follow an optional one. 153 + - Only one variadic positional is permitted per spec. 154 + - Alias targets that do not resolve to a known spec name or schema key produce a type error. 155 + 156 + --- 157 + 158 + ## Subpath: `cli-parser/schema` 159 + 160 + A companion subpath exposes helpers for CLI-specific schema constructs: 161 + 162 + - `path()` — a schema for filesystem paths, with coercion and existence validation hooks. 163 + 164 + These helpers are thin wrappers over StandardSchema-compatible schemas and are designed to be passed directly as values in the `schema` option.
+136 -85
src/index.ts
··· 1 1 import type { 2 - Aliases, 3 - Args, 4 - BooleanType, 5 - Collectable, 2 + ArgsSchema, 3 + EnvOptions, 6 4 NestedMapping, 7 5 ParseOptions, 8 - StringType, 9 - Values, 6 + ParseResult, 7 + RawArgs, 8 + RawValue, 9 + SchemaMeta, 10 + StandardSchemaV1, 10 11 } from "./types.js"; 11 - export { ParseOptions, Args } from "./types.js"; 12 + export { ParseError, ParseOptions, RawArgs, StandardSchemaV1, ArgsSchema, SchemaMeta } from "./types.js"; 13 + import { ParseError } from "./types.js"; 14 + import type { ObjectSchema } from "./schema.js"; 12 15 13 16 const BOOL_RE = /^(true|false)$/; 14 17 const QUOTED_RE = /^('|").*\1$/; 15 18 16 - const set = (obj: NestedMapping, key: string, value: any, type?: string) => { 19 + const set = (obj: NestedMapping, key: string, value: unknown, collect?: boolean) => { 17 20 if (key.includes(".")) { 18 21 const parts = key.split("."); 19 22 for (let i = 0; i < parts.length - 1; i++) { 20 23 const k = parts[i]; 21 - const tmp = {}; 24 + const tmp: NestedMapping = {}; 22 25 set(obj, k, tmp); 23 26 obj = tmp; 24 27 } 25 28 key = parts[parts.length - 1]; 26 29 } 27 - if (type === "array" && obj[key] !== undefined) { 30 + if (collect && obj[key] !== undefined) { 28 31 if (Array.isArray(obj[key])) { 29 - (obj[key] as any[]).push(value); 32 + (obj[key] as unknown[]).push(value); 30 33 } else { 31 34 obj[key] = [obj[key], value]; 32 35 } 33 36 } else { 34 - obj[key] = type === "array" ? [value] : value; 37 + obj[key] = collect ? [value] : value; 35 38 } 36 39 }; 37 40 38 - const type = ( 39 - key: string, 40 - opts: Record<"boolean" | "string" | "array", string[]>, 41 - ): "boolean" | "string" | "array" | undefined => { 42 - if (opts.array && opts.array.length > 0 && opts.array.includes(key)) 43 - return "array"; 44 - if (opts.string && opts.string.length > 0 && opts.string.includes(key)) 45 - return "string"; 46 - if (opts.boolean && opts.boolean.length > 0 && opts.boolean.includes(key)) 47 - return "boolean"; 48 - return; 49 - }; 50 - 51 - const defaultValue = (type?: "boolean" | "string" | "array") => { 52 - if (type === "string") return ""; 53 - if (type === "array") return []; 54 - return true; 55 - }; 56 - 57 - const coerce = (value?: string, type?: "string" | "boolean" | "array") => { 58 - if (type === "string") return value; 59 - if (type === "boolean") return value === undefined ? true : value === "true"; 60 - 61 - if (!value) return value; 41 + const coerce = (value?: string): RawValue | undefined => { 42 + if (value === undefined) return undefined; 62 43 if (value.length > 3 && BOOL_RE.test(value)) return value === "true"; 63 44 if (value.length > 2 && QUOTED_RE.test(value)) return value.slice(1, -1); 64 45 if ((value[0] === "." && /\d/.test(value[1])) || /\d/.test(value[0])) ··· 66 47 return value; 67 48 }; 68 49 69 - export function parse< 70 - TArgs extends Values< 71 - TBooleans, 72 - TStrings, 73 - TCollectable, 74 - undefined, 75 - TDefaults, 76 - TAliases 77 - >, 78 - TBooleans extends BooleanType = undefined, 79 - TStrings extends StringType = undefined, 80 - TCollectable extends Collectable = undefined, 81 - TDefaults extends Record<string, unknown> | undefined = undefined, 82 - TAliases extends Aliases<TAliasArgNames, TAliasNames> | undefined = undefined, 83 - TAliasArgNames extends string = string, 84 - TAliasNames extends string = string, 85 - >( 50 + function applyEnv(raw: RawArgs, { prefix }: EnvOptions): void { 51 + const p = prefix.toUpperCase() + "_"; 52 + for (const [envKey, envVal] of Object.entries(process.env)) { 53 + if (!envKey.startsWith(p) || envVal === undefined) continue; 54 + const flag = envKey.slice(p.length).toLowerCase().replace(/_/g, "-"); 55 + if (!(flag in raw)) { 56 + const coerced = coerce(envVal); 57 + if (coerced !== undefined) raw[flag] = coerced; 58 + } 59 + } 60 + } 61 + 62 + /** Convert camelCase to kebab-case: "moduleTypes" → "module-types" */ 63 + function toKebab(str: string): string { 64 + return str.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); 65 + } 66 + 67 + /** Convert kebab-case to camelCase: "module-types" → "moduleTypes" */ 68 + function toCamel(str: string): string { 69 + return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); 70 + } 71 + 72 + /** 73 + * Extract aliases from a schema's shape (if it's an ObjectSchema). 74 + * For each field: 75 + * - Per-field `.alias()` names → { aliasName: fieldName } 76 + * - camelCase field names → auto-add kebab-case alias: { "module-types": "moduleTypes" } 77 + * - kebab-case field names → auto-add camelCase alias: { "moduleTypes": "module-types" } 78 + */ 79 + function extractAliases(schema: StandardSchemaV1): Record<string, string> { 80 + const result: Record<string, string> = {}; 81 + if (!("shape" in schema) || typeof (schema as any).shape !== "object" || (schema as any).shape === null) { 82 + return result; 83 + } 84 + const shape = (schema as ObjectSchema<Record<string, StandardSchemaV1>>).shape; 85 + for (const [fieldName, fieldSchema] of Object.entries(shape)) { 86 + // Per-field aliases from .alias() 87 + if ("~meta" in fieldSchema) { 88 + const meta = (fieldSchema as ArgsSchema)["~meta"] as SchemaMeta; 89 + if (meta.aliases) { 90 + for (const a of meta.aliases) { 91 + result[a] = fieldName; 92 + } 93 + } 94 + } 95 + // Auto camelCase↔kebab-case 96 + if (/[A-Z]/.test(fieldName)) { 97 + // camelCase field → add kebab alias 98 + const kebab = toKebab(fieldName); 99 + if (!(kebab in result)) result[kebab] = fieldName; 100 + } else if (fieldName.includes("-")) { 101 + // kebab-case field → add camelCase alias 102 + const camel = toCamel(fieldName); 103 + if (!(camel in result)) result[camel] = fieldName; 104 + } 105 + } 106 + return result; 107 + } 108 + 109 + function parseRaw( 86 110 argv: string[], 87 - { 88 - default: defaults, 89 - alias: aliases, 90 - ...types 91 - }: ParseOptions<TBooleans, TStrings, TCollectable, TDefaults, TAliases> = {}, 92 - ): Args<TArgs> { 93 - const obj = { ...defaults, _: [] } as unknown as Args<TArgs>; 111 + aliases?: Record<string, string>, 112 + ): RawArgs { 113 + const obj: RawArgs = { _: [] }; 94 114 if (argv.length === 0) return obj; 95 115 96 116 for (let i = 0; i < argv.length; i++) { 97 117 const curr = argv[i]; 98 118 const next = argv[i + 1]; 99 119 100 - let t: "string" | "boolean" | "array" | undefined; 120 + // -- terminator: everything after is a raw positional 121 + if (curr === "--") { 122 + for (let j = i + 1; j < argv.length; j++) { 123 + const v = coerce(argv[j]); 124 + obj._.push(v !== undefined ? v : argv[j] as RawValue); 125 + } 126 + break; 127 + } 128 + 101 129 let key = ""; 102 130 let value: string | undefined; 103 131 104 132 if (curr.length > 1 && curr[0] === "-") { 105 133 if (curr[1] !== "-" && curr.length > 2 && !curr.includes("=")) { 134 + // Short combined flags: -abc or -a.b (dotted short) 106 135 if (curr.includes(".")) { 107 136 key = curr.slice(1, 2); 108 137 value = curr.slice(2); 109 138 } else { 139 + // Expand all but last as boolean flags 110 140 const keys = curr.slice(1, -1); 111 - for (let key of keys) { 112 - if ( 113 - aliases && 114 - (aliases as Record<string, any>)[key] !== undefined 115 - ) { 116 - key = aliases[key as keyof typeof aliases] as string; 117 - } 118 - set(obj, key, defaultValue(t), t); 141 + for (let k of keys) { 142 + if (aliases?.[k] !== undefined) k = aliases[k]; 143 + set(obj, k, true); 119 144 } 120 145 key = curr.slice(-1); 121 146 if (next && next[0] !== "-") { ··· 124 149 } 125 150 } 126 151 } else if (!curr.includes("=") && next && next[0] !== "-") { 152 + // --flag value 127 153 key = curr.replace(/^-{1,2}/, ""); 128 - t = type(key, types as any); 129 - // treat boolean as flag without parameter 130 - if (t === "boolean") { 131 - value = "true"; 132 - } else { 133 - value = next; 134 - i++; 135 - } 154 + value = next; 155 + i++; 136 156 } else { 157 + // --flag or --flag=value 137 158 const eq = curr.indexOf("="); 138 159 if (eq === -1) { 139 160 key = curr.replace(/^-{1,2}/, ""); ··· 141 162 key = curr.slice(0, eq).replace(/^-{1,2}/, ""); 142 163 value = curr.slice(eq + 1); 143 164 } 144 - t = type(key, types as any); 145 165 } 146 166 147 - if ((!t || t === "boolean") && key.length > 3 && key.startsWith("no-")) { 167 + if (key.length > 3 && key.startsWith("no-")) { 148 168 set(obj, key.slice(3), false); 149 169 } else { 150 - if (aliases && (aliases as Record<string, any>)[key] !== undefined) { 151 - key = aliases[key as keyof typeof aliases] as string; 152 - } 153 - set(obj, key, coerce(value, t) ?? defaultValue(t), t); 170 + if (aliases?.[key] !== undefined) key = aliases[key]; 171 + set(obj, key, coerce(value) ?? true); 154 172 } 155 173 } else if (curr) { 156 - (obj as any)._.push(coerce(curr)); 157 - continue; 174 + const v = coerce(curr); 175 + obj._.push(v !== undefined ? v : curr as RawValue); 158 176 } 159 177 } 160 178 161 179 return obj; 162 180 } 181 + 182 + export async function parse< 183 + TSchema extends StandardSchemaV1 | undefined = undefined, 184 + TAliases extends Record<string, string> | undefined = undefined, 185 + >( 186 + argv: string[], 187 + opts?: ParseOptions<TSchema, TAliases>, 188 + ): Promise<ParseResult<TSchema>> { 189 + const schemaAliases = opts?.schema ? extractAliases(opts.schema) : {}; 190 + const mergedAliases = { ...schemaAliases, ...opts?.alias }; 191 + const hasAliases = Object.keys(mergedAliases).length > 0; 192 + 193 + const raw = parseRaw(argv, hasAliases ? mergedAliases : undefined); 194 + if (opts?.env) applyEnv(raw, opts.env); 195 + 196 + if (!opts?.schema) return raw as ParseResult<TSchema>; 197 + 198 + let result = opts.schema["~standard"].validate(raw); 199 + if (result instanceof Promise) result = await result; 200 + if (result.issues) { 201 + throw new ParseError(result.issues); 202 + } 203 + return result.value as ParseResult<TSchema>; 204 + } 205 + 206 + export function parseSync( 207 + argv: string[], 208 + opts?: Omit<ParseOptions<undefined, Record<string, string>>, "schema">, 209 + ): RawArgs { 210 + const raw = parseRaw(argv, opts?.alias); 211 + if (opts?.env) applyEnv(raw, opts.env); 212 + return raw; 213 + }
+149
src/schema.ts
··· 1 + import type { StandardSchemaV1 } from "@standard-schema/spec"; 2 + import type { ArgsSchema, SchemaMeta } from "./types.js"; 3 + 4 + type Issue = StandardSchemaV1.Issue; 5 + type Result<T> = StandardSchemaV1.Result<T>; 6 + 7 + function ok<T>(value: T): Result<T> { 8 + return { value }; 9 + } 10 + 11 + function fail(message: string, path?: PropertyKey[]): Result<never> { 12 + const issue: Issue = path ? { message, path } : { message }; 13 + return { issues: [issue] }; 14 + } 15 + 16 + function makeSchema<I, O>( 17 + validate: (value: I) => Result<O> | Promise<Result<O>>, 18 + ): ArgsSchema<I, O> { 19 + const schema = { 20 + "~standard": { 21 + version: 1, 22 + vendor: "@bomb.sh/args", 23 + validate: validate as (value: unknown) => Result<O> | Promise<Result<O>>, 24 + }, 25 + "~meta": {} as SchemaMeta, 26 + docs(description: string) { 27 + this["~meta"].docs = description; 28 + return this; 29 + }, 30 + alias(...names: string[]) { 31 + this["~meta"].aliases = [...(this["~meta"].aliases ?? []), ...names]; 32 + return this; 33 + }, 34 + } as ArgsSchema<I, O>; 35 + return schema; 36 + } 37 + 38 + export function string(): ArgsSchema<unknown, string> { 39 + return makeSchema((value) => { 40 + if (typeof value === "string") return ok(value); 41 + if (typeof value === "number" || typeof value === "boolean") 42 + return ok(String(value)); 43 + return fail(`Expected string, received ${typeof value}`); 44 + }); 45 + } 46 + 47 + export function number(): ArgsSchema<unknown, number> { 48 + return makeSchema((value) => { 49 + if (typeof value === "number") return ok(value); 50 + if (typeof value === "string") { 51 + const n = Number(value); 52 + if (!Number.isNaN(n)) return ok(n); 53 + } 54 + return fail(`Expected number, received ${typeof value}`); 55 + }); 56 + } 57 + 58 + export function boolean(): ArgsSchema<unknown, boolean> { 59 + return makeSchema((value) => { 60 + if (typeof value === "boolean") return ok(value); 61 + if (value === "true" || value === "1" || value === 1) return ok(true); 62 + if (value === "false" || value === "0" || value === 0) return ok(false); 63 + return fail(`Expected boolean, received ${typeof value}`); 64 + }); 65 + } 66 + 67 + export function array<T = unknown>( 68 + item?: StandardSchemaV1<unknown, T>, 69 + ): ArgsSchema<unknown, T[]> { 70 + return makeSchema(async (value) => { 71 + if (!Array.isArray(value)) 72 + return fail(`Expected array, received ${typeof value}`); 73 + if (!item) return ok(value as T[]); 74 + 75 + const out: T[] = []; 76 + const issues: Issue[] = []; 77 + for (let i = 0; i < value.length; i++) { 78 + let r = item["~standard"].validate(value[i]); 79 + if (r instanceof Promise) r = await r; 80 + if (r.issues) { 81 + for (const issue of r.issues) { 82 + issues.push({ 83 + message: issue.message, 84 + path: [i, ...(issue.path ?? [])], 85 + }); 86 + } 87 + } else { 88 + out.push(r.value); 89 + } 90 + } 91 + return issues.length > 0 ? { issues } : ok(out); 92 + }); 93 + } 94 + 95 + type ShapeOutput<T extends Record<string, StandardSchemaV1>> = { 96 + [K in keyof T]: StandardSchemaV1.InferOutput<T[K]>; 97 + }; 98 + 99 + export interface ObjectSchema<T extends Record<string, StandardSchemaV1>> 100 + extends ArgsSchema<unknown, ShapeOutput<T>> { 101 + readonly shape: T; 102 + } 103 + 104 + export function object<T extends Record<string, StandardSchemaV1>>( 105 + shape: T, 106 + ): ObjectSchema<T> { 107 + const validate = async (value: unknown): Promise<Result<ShapeOutput<T>>> => { 108 + if (typeof value !== "object" || value === null || Array.isArray(value)) 109 + return fail(`Expected object, received ${typeof value}`); 110 + 111 + const input = value as Record<string, unknown>; 112 + const out: Record<string, unknown> = {}; 113 + const issues: Issue[] = []; 114 + 115 + for (const [key, schema] of Object.entries(shape)) { 116 + let r = schema["~standard"].validate(input[key]); 117 + if (r instanceof Promise) r = await r; 118 + if (r.issues) { 119 + for (const issue of r.issues) { 120 + issues.push({ 121 + message: issue.message, 122 + path: [key, ...(issue.path ?? [])], 123 + }); 124 + } 125 + } else { 126 + out[key] = r.value; 127 + } 128 + } 129 + return issues.length > 0 ? { issues } : ok(out as ShapeOutput<T>); 130 + }; 131 + 132 + return { 133 + "~standard": { 134 + version: 1, 135 + vendor: "@bomb.sh/args", 136 + validate, 137 + }, 138 + "~meta": {} as SchemaMeta, 139 + shape, 140 + docs(description: string) { 141 + this["~meta"].docs = description; 142 + return this; 143 + }, 144 + alias(...names: string[]) { 145 + this["~meta"].aliases = [...(this["~meta"].aliases ?? []), ...names]; 146 + return this; 147 + }, 148 + } as ObjectSchema<T>; 149 + }
+42 -269
src/types.ts
··· 1 - /** Combines recursively all intersection types and returns a new single type. */ 2 - type Id<TRecord> = TRecord extends Record<string, unknown> 3 - ? TRecord extends infer InferredRecord 4 - ? { [Key in keyof InferredRecord]: Id<InferredRecord[Key]> } 5 - : never 6 - : TRecord; 7 - 8 - /** Converts a union type `A | B | C` into an intersection type `A & B & C`. */ 9 - type UnionToIntersection<TValue> = ( 10 - TValue extends unknown 11 - ? (args: TValue) => unknown 12 - : never 13 - ) extends (args: infer R) => unknown 14 - ? R extends Record<string, unknown> 15 - ? R 16 - : never 17 - : never; 18 - 19 - export type BooleanType = boolean | string | undefined; 20 - export type StringType = string | undefined; 21 - export type ArgType = StringType | BooleanType; 22 - 23 - export type Collectable = string | undefined; 24 - export type Negatable = string | undefined; 25 - 26 - type UseTypes< 27 - TBooleans extends BooleanType, 28 - TStrings extends StringType, 29 - TCollectable extends Collectable, 30 - > = undefined extends (false extends TBooleans ? undefined : TBooleans) & 31 - TCollectable & 32 - TStrings 33 - ? false 34 - : true; 35 - 36 - /** 37 - * Creates a record with all available flags with the corresponding type and 38 - * default type. 39 - */ 40 - export type Values< 41 - TBooleans extends BooleanType, 42 - TStrings extends StringType, 43 - TCollectable extends Collectable, 44 - TNegatable extends Negatable, 45 - TDefault extends Record<string, unknown> | undefined, 46 - TAliases extends Aliases | undefined, 47 - > = UseTypes<TBooleans, TStrings, TCollectable> extends true 48 - ? Record<string, unknown> & 49 - AddAliases< 50 - SpreadDefaults< 51 - CollectValues<TStrings, string, TCollectable, TNegatable> & 52 - RecursiveRequired<CollectValues<TBooleans, boolean, TCollectable>> & 53 - CollectUnknownValues<TBooleans, TStrings, TCollectable, TNegatable>, 54 - DedotRecord<TDefault> 55 - >, 56 - TAliases 57 - > 58 - : // deno-lint-ignore no-explicit-any 59 - Record<string, any>; 1 + import type { StandardSchemaV1 } from "@standard-schema/spec"; 2 + export type { StandardSchemaV1 }; 60 3 61 - export type Aliases< 62 - TArgNames = string, 63 - TAliasNames extends string = string, 64 - > = Partial< 65 - Record<Extract<TArgNames, string>, TAliasNames | ReadonlyArray<TAliasNames>> 66 - >; 67 - 68 - type AddAliases<TArgs, TAliases extends Aliases | undefined> = { 69 - [TArgName in keyof TArgs as AliasNames<TArgName, TAliases>]: TArgs[TArgName]; 70 - }; 71 - 72 - type AliasNames< 73 - TArgName, 74 - TAliases extends Aliases | undefined, 75 - > = TArgName extends keyof TAliases 76 - ? string extends TAliases[TArgName] 77 - ? TArgName 78 - : TAliases[TArgName] extends string 79 - ? TArgName | TAliases[TArgName] 80 - : TAliases[TArgName] extends Array<string> 81 - ? TArgName | TAliases[TArgName][number] 82 - : TArgName 83 - : TArgName; 4 + export interface SchemaMeta { 5 + docs?: string; 6 + aliases?: string[]; 7 + } 84 8 85 - /** 86 - * Spreads all default values of Record `TDefaults` into Record `TArgs` 87 - * and makes default values required. 88 - * 89 - * **Example:** 90 - * `SpreadValues<{ foo?: boolean, bar?: number }, { foo: number }>` 91 - * 92 - * **Result:** `{ foo: boolean | number, bar?: number }` 93 - */ 94 - type SpreadDefaults<TArgs, TDefaults> = TDefaults extends undefined 95 - ? TArgs 96 - : TArgs extends Record<string, unknown> 97 - ? Omit<TArgs, keyof TDefaults> & { 98 - [Default in keyof TDefaults]: Default extends keyof TArgs 99 - ? 100 - | (TArgs[Default] & TDefaults[Default]) 101 - | TDefaults[Default] extends Record<string, unknown> 102 - ? NonNullable<SpreadDefaults<TArgs[Default], TDefaults[Default]>> 103 - : TDefaults[Default] | NonNullable<TArgs[Default]> 104 - : unknown; 105 - } 106 - : never; 9 + export interface ArgsSchema<I = unknown, O = I> extends StandardSchemaV1<I, O> { 10 + readonly "~meta": SchemaMeta; 11 + docs(description: string): this; 12 + alias(...names: string[]): this; 13 + } 107 14 108 - /** 109 - * Defines the Record for the `default` option to add 110 - * auto-suggestion support for IDE's. 111 - */ 112 - type Defaults<TBooleans extends BooleanType, TStrings extends StringType> = Id< 113 - UnionToIntersection< 114 - Record<string, unknown> & 115 - // Dedotted auto suggestions: { foo: { bar: unknown } } 116 - MapTypes<TStrings, unknown> & 117 - MapTypes<TBooleans, unknown> & 118 - // Flat auto suggestions: { "foo.bar": unknown } 119 - MapDefaults<TBooleans> & 120 - MapDefaults<TStrings> 121 - > 122 - >; 15 + export type RawValue = string | number | boolean; 123 16 124 - type MapDefaults<TArgNames extends ArgType> = Partial< 125 - Record<TArgNames extends string ? TArgNames : string, unknown> 126 - >; 17 + export interface RawArgs { 18 + _: RawValue[]; 19 + [key: string]: RawValue | RawValue[] | RawArgs; 20 + } 127 21 128 - type RecursiveRequired<TRecord> = TRecord extends Record<string, unknown> 129 - ? { 130 - [Key in keyof TRecord]-?: RecursiveRequired<TRecord[Key]>; 131 - } 132 - : TRecord; 22 + export interface EnvOptions { 23 + prefix: string; 24 + } 133 25 134 - /** Same as `MapTypes` but also supports collectable options. */ 135 - type CollectValues< 136 - TArgNames extends ArgType, 137 - TType, 138 - TCollectable extends Collectable, 139 - TNegatable extends Negatable = undefined, 140 - > = UnionToIntersection< 141 - Extract<TArgNames, TCollectable> extends string 142 - ? (Exclude<TArgNames, TCollectable> extends never 143 - ? Record<never, never> 144 - : MapTypes<Exclude<TArgNames, TCollectable>, TType, TNegatable>) & 145 - (Extract<TArgNames, TCollectable> extends never 146 - ? Record<never, never> 147 - : RecursiveRequired< 148 - MapTypes< 149 - Extract<TArgNames, TCollectable>, 150 - Array<TType>, 151 - TNegatable 152 - > 153 - >) 154 - : MapTypes<TArgNames, TType, TNegatable> 155 - >; 156 - 157 - /** Same as `Record` but also supports dotted and negatable options. */ 158 - type MapTypes< 159 - TArgNames extends ArgType, 160 - TType, 161 - TNegatable extends Negatable = undefined, 162 - > = undefined extends TArgNames 163 - ? Record<never, never> 164 - : TArgNames extends `${infer Name}.${infer Rest}` 165 - ? { 166 - [Key in Name]?: MapTypes< 167 - Rest, 168 - TType, 169 - TNegatable extends `${Name}.${infer Negate}` ? Negate : undefined 170 - >; 171 - } 172 - : TArgNames extends string 173 - ? Partial< 174 - Record< 175 - TArgNames, 176 - TNegatable extends TArgNames ? TType | false : TType 177 - > 178 - > 179 - : Record<never, never>; 180 - 181 - type CollectUnknownValues< 182 - TBooleans extends BooleanType, 183 - TStrings extends StringType, 184 - TCollectable extends Collectable, 185 - TNegatable extends Negatable, 186 - > = UnionToIntersection< 187 - TCollectable extends TBooleans & TStrings 188 - ? Record<never, never> 189 - : DedotRecord< 190 - // Unknown collectable & non-negatable args. 191 - Record< 192 - Exclude< 193 - Extract<Exclude<TCollectable, TNegatable>, string>, 194 - Extract<TStrings | TBooleans, string> 195 - >, 196 - Array<unknown> 197 - > & 198 - // Unknown collectable & negatable args. 199 - Record< 200 - Exclude< 201 - Extract<Extract<TCollectable, TNegatable>, string>, 202 - Extract<TStrings | TBooleans, string> 203 - >, 204 - Array<unknown> | false 205 - > 206 - > 207 - >; 208 - 209 - /** Converts `{ "foo.bar.baz": unknown }` into `{ foo: { bar: { baz: unknown } } }`. */ 210 - type DedotRecord<TRecord> = Record<string, unknown> extends TRecord 211 - ? TRecord 212 - : TRecord extends Record<string, unknown> 213 - ? UnionToIntersection< 214 - ValueOf<{ 215 - [Key in keyof TRecord]: Key extends string 216 - ? Dedot<Key, TRecord[Key]> 217 - : never; 218 - }> 219 - > 220 - : TRecord; 221 - 222 - type Dedot< 223 - TKey extends string, 224 - TValue, 225 - > = TKey extends `${infer Name}.${infer Rest}` 226 - ? { [Key in Name]: Dedot<Rest, TValue> } 227 - : { [Key in TKey]: TValue }; 228 - 229 - type ValueOf<TValue> = TValue[keyof TValue]; 230 - 231 - /** The value returned from `parse`. */ 232 - export type Args< 233 - // deno-lint-ignore no-explicit-any 234 - TArgs extends Record<string, unknown> = Record<string, any>, 235 - > = Id< 236 - TArgs & { 237 - /** Contains all the arguments that didn't have an option associated with 238 - * them. */ 239 - _: Array<string | number | boolean>; 240 - } 241 - >; 242 - 243 - /** The options for the `parse` call. */ 244 26 export interface ParseOptions< 245 - TBooleans extends BooleanType = BooleanType, 246 - TStrings extends StringType = StringType, 247 - TCollectable extends Collectable = Collectable, 248 - TDefault extends Record<string, unknown> | undefined = 249 - | Record<string, unknown> 250 - | undefined, 251 - TAliases extends Aliases | undefined = Aliases | undefined, 27 + TSchema extends StandardSchemaV1 | undefined = undefined, 28 + TAliases extends Record<string, string> | undefined = undefined, 252 29 > { 253 - /** 254 - * An object mapping string names to strings or arrays of string argument 255 - * names to use as aliases. 256 - */ 30 + /** Standard Schema-compliant schema. Output type drives `parse()` return type. */ 31 + schema?: TSchema; 32 + /** Map short flags to long names. */ 257 33 alias?: TAliases; 258 - 259 34 /** 260 - * A boolean, string or array of strings to always treat as booleans. If 261 - * `true` will treat all double hyphenated arguments without equal signs as 262 - * `boolean` (e.g. affects `--foo`, not `-f` or `--foo=bar`). 263 - * All `boolean` arguments will be set to `false` by default. 264 - */ 265 - boolean?: TBooleans | ReadonlyArray<Extract<TBooleans, string>>; 266 - 267 - /** An object mapping string argument names to default values. */ 268 - default?: TDefault & Defaults<TBooleans, TStrings>; 269 - 270 - /** A string or array of strings argument names to always treat as strings. */ 271 - string?: TStrings | ReadonlyArray<Extract<TStrings, string>>; 272 - 273 - /** 274 - * A string or array of strings argument names to always treat as arrays. 275 - * Array options can be used multiple times. All values will be 276 - * collected into one array. If a non-array option is used multiple 277 - * times, the last value is used. 278 - * All Collectable arguments will be set to `[]` by default. 35 + * When set, auto-defaults flags from environment variables before schema 36 + * validation. Flag `--foo-bar` maps to `PREFIX_FOO_BAR` (uppercased, 37 + * hyphens replaced with underscores). 279 38 */ 280 - array?: TCollectable | ReadonlyArray<Extract<TCollectable, string>>; 39 + env?: false | EnvOptions; 281 40 } 41 + 42 + export type ParseResult<TSchema extends StandardSchemaV1 | undefined> = 43 + TSchema extends StandardSchemaV1 44 + ? StandardSchemaV1.InferOutput<TSchema> 45 + : RawArgs; 282 46 283 47 export interface NestedMapping { 284 48 [key: string]: NestedMapping | unknown; 285 49 } 50 + 51 + export class ParseError extends Error { 52 + readonly issues: ReadonlyArray<StandardSchemaV1.Issue>; 53 + constructor(issues: ReadonlyArray<StandardSchemaV1.Issue>) { 54 + super(issues.map((i) => i.message).join("\n")); 55 + this.name = "ParseError"; 56 + this.issues = issues; 57 + } 58 + }
+27 -90
test/flags.test.ts
··· 1 - import { describe, expect, it, test } from "vitest"; 2 - import { parse } from "../src"; 1 + import { describe, expect, it } from "vitest"; 2 + import { parseSync } from "../src"; 3 3 4 4 describe("flags", () => { 5 5 it("a b c", () => { 6 6 const input = ["a", "b", "c"]; 7 7 const output = { _: ["a", "b", "c"] }; 8 - expect(parse(input)).toEqual(output); 8 + expect(parseSync(input)).toEqual(output); 9 9 }); 10 10 11 11 it("-a -b -c", () => { 12 12 const input = ["-a", "-b", "-c"]; 13 13 const output = { _: [], a: true, b: true, c: true }; 14 - expect(parse(input)).toEqual(output); 14 + expect(parseSync(input)).toEqual(output); 15 15 }); 16 16 17 17 it("-a 1 -b 2 -c 3 -d -e", () => { 18 18 const input = ["-a", "1", "-b", "2", "-c", "3", "-d", "-e"]; 19 19 const output = { _: [], a: 1, b: 2, c: 3, d: true, e: true }; 20 - expect(parse(input)).toEqual(output); 20 + expect(parseSync(input)).toEqual(output); 21 21 }); 22 22 23 23 it("-a=1 -b 2 -c=3 -d -e", () => { 24 24 const input = ["-a", "1", "-b", "2", "-c", "3", "-d", "-e"]; 25 25 const output = { _: [], a: 1, b: 2, c: 3, d: true, e: true }; 26 - expect(parse(input)).toEqual(output); 26 + expect(parseSync(input)).toEqual(output); 27 27 }); 28 28 29 29 it(`-a aaa bbb -b ccc ddd -c 3 -d -e`, () => { ··· 47 47 d: true, 48 48 e: true, 49 49 }; 50 - expect(parse(input)).toEqual(output); 50 + expect(parseSync(input)).toEqual(output); 51 51 }); 52 52 53 53 it(`-a "aaa bbb" -b "ccc ddd" -c 3 -d -e`, () => { ··· 60 60 d: true, 61 61 e: true, 62 62 }; 63 - expect(parse(input)).toEqual(output); 63 + expect(parseSync(input)).toEqual(output); 64 64 }); 65 65 66 66 it("comprehensive", () => { ··· 95 95 name: "meowmers", 96 96 _: ["bare"], 97 97 }; 98 - expect(parse(input)).toEqual(output); 98 + expect(parseSync(input)).toEqual(output); 99 99 }); 100 100 }); 101 101 ··· 110 110 d: true, 111 111 e: true, 112 112 }; 113 - expect(parse(input)).toEqual(output); 113 + expect(parseSync(input)).toEqual(output); 114 114 }); 115 115 116 116 it("--a.a1 1 --b.b1 2 --c.c1 3 -d -e", () => { ··· 123 123 d: true, 124 124 e: true, 125 125 }; 126 - expect(parse(input)).toEqual(output); 126 + expect(parseSync(input)).toEqual(output); 127 127 }); 128 128 129 129 it("--a.a1.a2 1 --b.b1.b2 2 --c.c1.c2 3 -d -e", () => { ··· 145 145 d: true, 146 146 e: true, 147 147 }; 148 - expect(parse(input)).toEqual(output); 148 + expect(parseSync(input)).toEqual(output); 149 149 }); 150 150 }); 151 151 ··· 157 157 bundle: false, 158 158 watch: true, 159 159 }; 160 - expect(parse(input)).toEqual(output); 161 - }); 162 - 163 - it("ignores strings", () => { 164 - const input = ["--no-bundle", "--watch"]; 165 - const opts = { 166 - string: ['no-bundle'] 167 - } 168 - const output = { 169 - _: [], 170 - 'no-bundle': '', 171 - watch: true, 172 - }; 173 - expect(parse(input, opts)).toEqual(output); 174 - }); 175 - 176 - it("ignores arrays", () => { 177 - const input = ["--no-bundle", '1', "--watch"]; 178 - const opts = { 179 - array: ['no-bundle'] 180 - } 181 - const output = { 182 - _: [], 183 - 'no-bundle': [1], 184 - watch: true, 185 - }; 186 - expect(parse(input, opts)).toEqual(output); 160 + expect(parseSync(input)).toEqual(output); 187 161 }); 188 162 }); 189 163 ··· 194 168 _: [], 195 169 help: true 196 170 }; 197 - const result = parse(input, { alias: { h: 'help' } }); 171 + const result = parseSync(input, { alias: { h: 'help' } }); 198 172 expect(result).toEqual(output); 199 173 }); 200 - 201 - it("ignores strings", () => { 202 - const input = ["--no-bundle", "--watch"]; 203 - const opts = { 204 - string: ['no-bundle'] 205 - } 206 - const output = { 207 - _: [], 208 - 'no-bundle': '', 209 - watch: true, 210 - }; 211 - expect(parse(input, opts)).toEqual(output); 212 - }); 213 - 214 - it("ignores arrays", () => { 215 - const input = ["--no-bundle", '1', "--watch"]; 216 - const opts = { 217 - array: ['no-bundle'] 218 - } 219 - const output = { 220 - _: [], 221 - 'no-bundle': [1], 222 - watch: true, 223 - }; 224 - expect(parse(input, opts)).toEqual(output); 225 - }); 226 174 }); 227 175 228 176 describe("special cases", () => { ··· 231 179 const output = { 232 180 _: ['-'], 233 181 }; 234 - const result = parse(input); 182 + const result = parseSync(input); 235 183 expect(result).toEqual(output); 236 184 }); 237 185 238 - it("just a hyphen", () => { 239 - const input = ["-"]; 186 + it("-- terminator: remaining args become positionals", () => { 187 + const input = ["--verbose", "--", "--not-a-flag", "also-not"]; 240 188 const output = { 241 - _: ['-'], 189 + _: ["--not-a-flag", "also-not"], 190 + verbose: true, 242 191 }; 243 - const result = parse(input); 244 - expect(result).toEqual(output); 192 + expect(parseSync(input)).toEqual(output); 245 193 }); 246 194 247 - it("string after boolean should be treated as positional", () => { 248 - const input = ["--get", "http://my-url.com"]; 249 - const opts = { 250 - boolean: ['get'] 251 - } 195 + it("-- terminator: mixed positionals", () => { 196 + const input = ["cmd", "--", "file.txt"]; 252 197 const output = { 253 - "_": ["http://my-url.com"], 254 - "get": true, 198 + _: ["cmd", "file.txt"], 255 199 }; 256 - const result = parse(input, opts); 257 - expect(result).toEqual(output); 200 + expect(parseSync(input)).toEqual(output); 258 201 }); 259 202 }); 260 203 261 204 describe("boolean flags", () => { 262 205 it("should handle long-form boolean flags correctly", () => { 263 206 const input = ["--add"]; 264 - const opts = { 265 - boolean: ['add'] 266 - }; 267 207 const output = { _: [], add: true }; 268 - expect(parse(input, opts)).toEqual(output); 208 + expect(parseSync(input)).toEqual(output); 269 209 }); 270 210 271 211 it("should handle alias boolean flags correctly", () => { 272 212 const input = ["-a"]; 273 - const opts = { 274 - boolean: ['add'], 275 - alias: { a: 'add' } 276 - }; 213 + const result = parseSync(input, { alias: { a: "add" } }); 277 214 const output = { _: [], add: true }; 278 - expect(parse(input, opts)).toEqual(output); 215 + expect(result).toEqual(output); 279 216 }); 280 217 });
+258
test/schema-helpers.test.ts
··· 1 + import { describe, expect, expectTypeOf, it } from "vitest"; 2 + import { parse } from "../src"; 3 + import { array, boolean, number, object, string } from "../src/schema"; 4 + 5 + describe("string()", () => { 6 + it("passes strings through", async () => { 7 + const s = string(); 8 + const r = s["~standard"].validate("hello"); 9 + expect(r).toEqual({ value: "hello" }); 10 + }); 11 + 12 + it("coerces numbers to string", async () => { 13 + const r = string()["~standard"].validate(42); 14 + expect(r).toEqual({ value: "42" }); 15 + }); 16 + 17 + it("coerces booleans to string", async () => { 18 + const r = string()["~standard"].validate(true); 19 + expect(r).toEqual({ value: "true" }); 20 + }); 21 + 22 + it("fails for objects", async () => { 23 + const r = string()["~standard"].validate({}); 24 + expect(r).toHaveProperty("issues"); 25 + }); 26 + }); 27 + 28 + describe("number()", () => { 29 + it("passes numbers through", async () => { 30 + const r = number()["~standard"].validate(42); 31 + expect(r).toEqual({ value: 42 }); 32 + }); 33 + 34 + it("coerces numeric strings", async () => { 35 + const r = number()["~standard"].validate("3.14"); 36 + expect(r).toEqual({ value: 3.14 }); 37 + }); 38 + 39 + it("fails for non-numeric strings", async () => { 40 + const r = number()["~standard"].validate("abc"); 41 + expect(r).toHaveProperty("issues"); 42 + }); 43 + 44 + it("fails for booleans", async () => { 45 + const r = number()["~standard"].validate(true); 46 + expect(r).toHaveProperty("issues"); 47 + }); 48 + }); 49 + 50 + describe("boolean()", () => { 51 + it("passes booleans through", async () => { 52 + expect(boolean()["~standard"].validate(true)).toEqual({ value: true }); 53 + expect(boolean()["~standard"].validate(false)).toEqual({ value: false }); 54 + }); 55 + 56 + it("coerces 'true' string", async () => { 57 + expect(boolean()["~standard"].validate("true")).toEqual({ value: true }); 58 + }); 59 + 60 + it("coerces 'false' string", async () => { 61 + expect(boolean()["~standard"].validate("false")).toEqual({ value: false }); 62 + }); 63 + 64 + it("fails for arbitrary strings", async () => { 65 + const r = boolean()["~standard"].validate("yes"); 66 + expect(r).toHaveProperty("issues"); 67 + }); 68 + }); 69 + 70 + describe("array()", () => { 71 + it("passes arrays through", async () => { 72 + let r = array()["~standard"].validate([1, 2, 3]); 73 + if (r instanceof Promise) r = await r; 74 + expect(r).toEqual({ value: [1, 2, 3] }); 75 + }); 76 + 77 + it("fails for non-arrays", async () => { 78 + let r = array()["~standard"].validate("not an array"); 79 + if (r instanceof Promise) r = await r; 80 + expect(r).toHaveProperty("issues"); 81 + }); 82 + 83 + it("validates items with item schema", async () => { 84 + let r = array(number())["~standard"].validate([1, 2, 3]); 85 + if (r instanceof Promise) r = await r; 86 + expect(r).toEqual({ value: [1, 2, 3] }); 87 + }); 88 + 89 + it("fails when item schema fails", async () => { 90 + let r = array(number())["~standard"].validate([1, "abc", 3]); 91 + if (r instanceof Promise) r = await r; 92 + expect(r).toHaveProperty("issues"); 93 + }); 94 + 95 + it("includes path in item errors", async () => { 96 + let r = array(number())["~standard"].validate(["bad"]); 97 + if (r instanceof Promise) r = await r; 98 + if ("issues" in r) { 99 + expect(r.issues[0].path).toContain(0); 100 + } 101 + }); 102 + }); 103 + 104 + describe("object()", () => { 105 + it("validates object shape", async () => { 106 + const schema = object({ name: string(), age: number() }); 107 + let r = schema["~standard"].validate({ name: "Nate", age: 30 }); 108 + if (r instanceof Promise) r = await r; 109 + expect(r).toEqual({ value: { name: "Nate", age: 30 } }); 110 + }); 111 + 112 + it("fails for non-objects", async () => { 113 + let r = object({ name: string() })["~standard"].validate("not an object"); 114 + if (r instanceof Promise) r = await r; 115 + expect(r).toHaveProperty("issues"); 116 + }); 117 + 118 + it("includes key in nested errors", async () => { 119 + const schema = object({ port: number() }); 120 + let r = schema["~standard"].validate({ port: "bad" }); 121 + if (r instanceof Promise) r = await r; 122 + if ("issues" in r) { 123 + expect(r.issues[0].path).toContain("port"); 124 + } 125 + }); 126 + 127 + it("composes as parse() schema (no Zod needed)", async () => { 128 + const result = await parse(["--port", "8080", "--name", "cli"], { 129 + schema: object({ 130 + port: number(), 131 + name: string(), 132 + _: array(), 133 + }), 134 + }); 135 + expect(result.port).toBe(8080); 136 + expect(result.name).toBe("cli"); 137 + }); 138 + 139 + it("exposes shape for introspection", () => { 140 + const shape = { port: number(), name: string() }; 141 + const schema = object(shape); 142 + expect(schema.shape).toBe(shape); 143 + }); 144 + }); 145 + 146 + describe(".docs()", () => { 147 + it("stores description in ~meta", () => { 148 + const s = string().docs("A string flag"); 149 + expect(s["~meta"].docs).toBe("A string flag"); 150 + }); 151 + 152 + it("overwrites on second call", () => { 153 + const s = string().docs("first").docs("second"); 154 + expect(s["~meta"].docs).toBe("second"); 155 + }); 156 + 157 + it("validation still works after chaining", () => { 158 + const r = string().docs("desc")["~standard"].validate("hello"); 159 + expect(r).toEqual({ value: "hello" }); 160 + }); 161 + 162 + it("works on all primitives", () => { 163 + expect(number().docs("n")["~meta"].docs).toBe("n"); 164 + expect(boolean().docs("b")["~meta"].docs).toBe("b"); 165 + expect(array().docs("a")["~meta"].docs).toBe("a"); 166 + expect(object({}).docs("o")["~meta"].docs).toBe("o"); 167 + }); 168 + }); 169 + 170 + describe(".alias()", () => { 171 + it("stores alias in ~meta", () => { 172 + const s = string().alias("t"); 173 + expect(s["~meta"].aliases).toEqual(["t"]); 174 + }); 175 + 176 + it("accepts multiple names at once", () => { 177 + const s = string().alias("t", "timeout"); 178 + expect(s["~meta"].aliases).toEqual(["t", "timeout"]); 179 + }); 180 + 181 + it("accumulates across multiple calls", () => { 182 + const s = string().alias("t").alias("timeout"); 183 + expect(s["~meta"].aliases).toEqual(["t", "timeout"]); 184 + }); 185 + 186 + it("validation still works after chaining", () => { 187 + const r = string().alias("t")["~standard"].validate("hello"); 188 + expect(r).toEqual({ value: "hello" }); 189 + }); 190 + 191 + it("works on all primitives", () => { 192 + expect(number().alias("n")["~meta"].aliases).toEqual(["n"]); 193 + expect(boolean().alias("b")["~meta"].aliases).toEqual(["b"]); 194 + expect(array().alias("a")["~meta"].aliases).toEqual(["a"]); 195 + expect(object({}).alias("o")["~meta"].aliases).toEqual(["o"]); 196 + }); 197 + }); 198 + 199 + describe("parse() alias auto-extraction", () => { 200 + it("resolves per-field .alias() short flags", async () => { 201 + const result = await parse(["-t", "5000"], { 202 + schema: object({ 203 + timeout: number().alias("t"), 204 + _: array(), 205 + }), 206 + }); 207 + expect(result.timeout).toBe(5000); 208 + }); 209 + 210 + it("auto-resolves kebab-case flag to camelCase field", async () => { 211 + const result = await parse(["--module-types", "esm"], { 212 + schema: object({ 213 + moduleTypes: string(), 214 + _: array(), 215 + }), 216 + }); 217 + expect(result.moduleTypes).toBe("esm"); 218 + }); 219 + 220 + it("auto-resolves camelCase flag to camelCase field", async () => { 221 + const result = await parse(["--moduleTypes", "esm"], { 222 + schema: object({ 223 + moduleTypes: string(), 224 + _: array(), 225 + }), 226 + }); 227 + expect(result.moduleTypes).toBe("esm"); 228 + }); 229 + 230 + it("auto-resolves camelCase flag to kebab-case field", async () => { 231 + const result = await parse(["--moduleTypes", "esm"], { 232 + schema: object({ 233 + "module-types": string(), 234 + _: array(), 235 + }), 236 + }); 237 + expect(result["module-types"]).toBe("esm"); 238 + }); 239 + 240 + it("manual opts.alias overrides per-field alias on conflict", async () => { 241 + const result = await parse(["-t", "999"], { 242 + schema: object({ 243 + threads: number(), 244 + _: array(), 245 + }), 246 + alias: { t: "threads" }, 247 + }); 248 + // manual alias wins: -t → threads 249 + expect(result.threads).toBe(999); 250 + }); 251 + 252 + it("schemas without per-field aliases work unchanged", async () => { 253 + const result = await parse(["--port", "3000"], { 254 + schema: object({ port: number(), _: array() }), 255 + }); 256 + expect(result.port).toBe(3000); 257 + }); 258 + });
+179
test/schema.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, expectTypeOf, it } from "vitest"; 2 + import { z } from "zod"; 3 + import { parse, ParseError } from "../src"; 4 + 5 + describe("parse() with schema", () => { 6 + it("validates and returns typed output", async () => { 7 + const result = await parse(["--port", "3000", "--verbose"], { 8 + schema: z.object({ 9 + port: z.coerce.number(), 10 + verbose: z.boolean().optional(), 11 + _: z.array(z.unknown()).optional(), 12 + }), 13 + }); 14 + 15 + expect(result.port).toBe(3000); 16 + expect(result.verbose).toBe(true); 17 + expectTypeOf(result.port).toBeNumber(); 18 + expectTypeOf(result.verbose).toEqualTypeOf<boolean | undefined>(); 19 + }); 20 + 21 + it("infers output type from schema", async () => { 22 + const result = await parse(["--name", "world"], { 23 + schema: z.object({ 24 + name: z.string(), 25 + _: z.array(z.unknown()).optional(), 26 + }), 27 + }); 28 + 29 + expectTypeOf(result).toEqualTypeOf<{ name: string; _?: unknown[] | undefined }>(); 30 + }); 31 + 32 + it("returns RawArgs when no schema provided", async () => { 33 + const result = await parse(["--foo", "bar"]); 34 + expect(result.foo).toBe("bar"); 35 + expect(result._).toEqual([]); 36 + expectTypeOf(result._).toEqualTypeOf<(string | number | boolean)[]>(); 37 + }); 38 + 39 + it("throws ParseError on validation failure", async () => { 40 + await expect( 41 + parse(["--port", "notanumber"], { 42 + schema: z.object({ port: z.number() }), 43 + }) 44 + ).rejects.toThrow(ParseError); 45 + }); 46 + 47 + it("ParseError carries issues array", async () => { 48 + try { 49 + await parse(["--port", "bad"], { 50 + schema: z.object({ port: z.number() }), 51 + }); 52 + } catch (e) { 53 + expect(e).toBeInstanceOf(ParseError); 54 + expect((e as ParseError).issues).toBeDefined(); 55 + expect((e as ParseError).issues.length).toBeGreaterThan(0); 56 + } 57 + }); 58 + 59 + it("works with async schema validation", async () => { 60 + const asyncSchema = { 61 + "~standard": { 62 + version: 1 as const, 63 + vendor: "test", 64 + validate: async (value: unknown) => { 65 + await new Promise((r) => setTimeout(r, 0)); 66 + const v = value as { port: string }; 67 + return { value: { port: Number(v.port) } }; 68 + }, 69 + }, 70 + }; 71 + 72 + const result = await parse(["--port", "8080"], { schema: asyncSchema }); 73 + expect(result.port).toBe(8080); 74 + }); 75 + 76 + it("handles default values via schema", async () => { 77 + const result = await parse([], { 78 + schema: z.object({ 79 + port: z.coerce.number().default(3000), 80 + _: z.array(z.unknown()).default([]), 81 + }), 82 + }); 83 + expect(result.port).toBe(3000); 84 + }); 85 + 86 + it("supports aliases", async () => { 87 + const result = await parse(["-v"], { 88 + schema: z.object({ 89 + verbose: z.boolean().optional(), 90 + _: z.array(z.unknown()).optional(), 91 + }), 92 + alias: { v: "verbose" }, 93 + }); 94 + expect(result.verbose).toBe(true); 95 + }); 96 + }); 97 + 98 + describe("positionals via _ in schema", () => { 99 + it("z.tuple for typed positionals", async () => { 100 + const result = await parse(["build", "--port", "3000"], { 101 + schema: z.object({ 102 + _: z.tuple([z.string()]), 103 + port: z.coerce.number().optional(), 104 + }), 105 + }); 106 + 107 + expect(result._[0]).toBe("build"); 108 + expect(result.port).toBe(3000); 109 + expectTypeOf(result._).toEqualTypeOf<[string]>(); 110 + }); 111 + 112 + it("z.array for variadic positionals", async () => { 113 + const result = await parse(["a", "b", "c"], { 114 + schema: z.object({ 115 + _: z.array(z.string()), 116 + }), 117 + }); 118 + expect(result._).toEqual(["a", "b", "c"]); 119 + }); 120 + 121 + it("-- terminator: passthrough args land in _", async () => { 122 + const result = await parse(["cmd", "--", "--not-a-flag"], { 123 + schema: z.object({ 124 + _: z.array(z.string()), 125 + }), 126 + }); 127 + expect(result._).toEqual(["cmd", "--not-a-flag"]); 128 + }); 129 + }); 130 + 131 + describe("env fallback", () => { 132 + const originalEnv = { ...process.env }; 133 + 134 + beforeEach(() => { 135 + process.env.APP_PORT = "9000"; 136 + process.env.APP_VERBOSE = "true"; 137 + }); 138 + 139 + afterEach(() => { 140 + // Restore original env 141 + for (const key of Object.keys(process.env)) { 142 + if (!(key in originalEnv)) delete process.env[key]; 143 + } 144 + Object.assign(process.env, originalEnv); 145 + }); 146 + 147 + it("injects env vars for missing flags", async () => { 148 + const result = await parse([], { 149 + schema: z.object({ 150 + port: z.coerce.number(), 151 + verbose: z.coerce.boolean(), 152 + _: z.array(z.unknown()).default([]), 153 + }), 154 + env: { prefix: "APP" }, 155 + }); 156 + expect(result.port).toBe(9000); 157 + expect(result.verbose).toBe(true); 158 + }); 159 + 160 + it("argv takes precedence over env", async () => { 161 + const result = await parse(["--port", "4000"], { 162 + schema: z.object({ 163 + port: z.coerce.number(), 164 + _: z.array(z.unknown()).default([]), 165 + }), 166 + env: { prefix: "APP" }, 167 + }); 168 + expect(result.port).toBe(4000); 169 + }); 170 + 171 + it("env works with parseSync", () => { 172 + const result = parseSync([], { env: { prefix: "APP" } }); 173 + expect(result.port).toBe(9000); 174 + expect(result.verbose).toBe(true); // coerce() converts "true" → boolean 175 + }); 176 + }); 177 + 178 + // Keep parseSync import for env test 179 + import { parseSync } from "../src";