├── .github └── workflows │ ├── git-registry-release.yaml │ └── release.yaml ├── .gitignore ├── LICENSE ├── README.md ├── dprint.json ├── package.json ├── pnpm-lock.yaml ├── scripts └── build.ts ├── src ├── brocli-error.ts ├── command-core.ts ├── event-handler.ts ├── index.ts ├── option-builder.ts ├── util.ts └── validation-error.ts ├── tests └── main.test.ts ├── todo.md ├── tsconfig.build.json ├── tsconfig.dts.json ├── tsconfig.json └── vitest.config.ts /.github/workflows/git-registry-release.yaml: -------------------------------------------------------------------------------- 1 | name: Release to github 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | release: 7 | permissions: write-all 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | package: 12 | - brocli 13 | runs-on: ubuntu-20.04 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 18 20 | registry-url: 'https://npm.pkg.github.com' 21 | 22 | - uses: pnpm/action-setup@v3 23 | name: Install pnpm 24 | id: pnpm-install 25 | with: 26 | version: '9' 27 | run_install: false 28 | 29 | - name: Get pnpm store directory 30 | id: pnpm-cache 31 | shell: bash 32 | run: | 33 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT 34 | 35 | - uses: actions/cache@v4 36 | name: Setup pnpm cache 37 | with: 38 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 39 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 40 | restore-keys: | 41 | ${{ runner.os }}-pnpm-store- 42 | 43 | - name: Install dependencies 44 | run: pnpm install --frozen-lockfile 45 | 46 | - name: Lint 47 | run: pnpm lint 48 | 49 | - name: Build 50 | run: | 51 | pnpm build 52 | 53 | - name: Pack 54 | shell: bash 55 | env: 56 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | run: | 58 | npm run pack 59 | 60 | - name: Run @arethetypeswrong/cli 61 | run: | 62 | pnpm attw package.tgz 63 | 64 | - name: Publish 65 | shell: bash 66 | env: 67 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | run: | 69 | version="$(jq -r .version package.json)" 70 | 71 | echo "Publishing ${{ matrix.package }}@$version" 72 | npm run publish --access public 73 | 74 | echo "npm: \`+ ${{ matrix.package }}@$version\`" >> $GITHUB_STEP_SUMMARY 75 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | release: 7 | permissions: write-all 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | package: 12 | - brocli 13 | runs-on: ubuntu-20.04 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 18 20 | registry-url: 'https://registry.npmjs.org' 21 | 22 | - uses: pnpm/action-setup@v3 23 | name: Install pnpm 24 | id: pnpm-install 25 | with: 26 | version: '9' 27 | run_install: false 28 | 29 | - name: Get pnpm store directory 30 | id: pnpm-cache 31 | shell: bash 32 | run: | 33 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT 34 | 35 | - uses: actions/cache@v4 36 | name: Setup pnpm cache 37 | with: 38 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 39 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 40 | restore-keys: | 41 | ${{ runner.os }}-pnpm-store- 42 | 43 | - name: Install dependencies 44 | run: pnpm install --frozen-lockfile 45 | 46 | - name: Lint 47 | run: pnpm lint 48 | 49 | - name: Build 50 | run: | 51 | pnpm build 52 | 53 | - name: Pack 54 | shell: bash 55 | env: 56 | NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} 57 | run: | 58 | npm run pack 59 | 60 | - name: Run @arethetypeswrong/cli 61 | run: | 62 | pnpm attw package.tgz 63 | 64 | - name: Publish 65 | shell: bash 66 | env: 67 | NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} 68 | run: | 69 | version="$(jq -r .version package.json)" 70 | 71 | echo "Publishing ${{ matrix.package }}@$version" 72 | npm run publish --access public 73 | 74 | echo "npm: \`+ ${{ matrix.package }}@$version\`" >> $GITHUB_STEP_SUMMARY 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | targetExample.ts 4 | dist 5 | dist-dts 6 | package.tgz 7 | /tests/manual.ts 8 | .DS_Store 9 | todo.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Brocli 🥦 2 | Modern type-safe way of building CLIs with TypeScript or JavaScript 3 | by [Drizzle Team](https://drizzle.team) 4 | 5 | ```ts 6 | import { command, string, boolean, run } from "@drizzle-team/brocli"; 7 | 8 | const push = command({ 9 | name: "push", 10 | options: { 11 | dialect: string().enum("postgresql", "mysql", "sqlite"), 12 | databaseSchema: string().required(), 13 | databaseUrl: string().required(), 14 | strict: boolean().default(false), 15 | }, 16 | handler: (opts) => { 17 | ... 18 | }, 19 | }); 20 | 21 | run([push]); // parse shell arguments and run command 22 | ``` 23 | 24 | ### Why? 25 | Brocli is meant to solve a list of challenges we've faced while building 26 | [Drizzle ORM](https://orm.drizzle.team) CLI companion for generating and running SQL schema migrations: 27 | - [x] Explicit, straightforward and discoverable API 28 | - [x] Typed options(arguments) with built in validation 29 | - [x] Ability to reuse options(or option sets) across commands 30 | - [x] Transformer hook to decouple runtime config consumption from command business logic 31 | - [x] `--version`, `-v` as either string or callback 32 | - [x] Command hooks to run common stuff before/after command 33 | - [x] Explicit global params passthrough 34 | - [x] Testability, the most important part for us to iterate without breaking 35 | - [x] Themes, simple API to style global/command helps 36 | - [x] Docs generation API to eliminate docs drifting 37 | 38 | ### Learn by examples 39 | If you need API referece - [see here](#api-reference), this list of practical example 40 | is meant to a be a zero to hero walk through for you to learn Brocli 🚀 41 | 42 | Simple echo command with positional argument: 43 | ```ts 44 | import { run, command, positional } from "@drizzle-team/brocli"; 45 | 46 | const echo = command({ 47 | name: "echo", 48 | options: { 49 | text: positional().desc("Text to echo").default("echo"), 50 | }, 51 | handler: (opts) => { 52 | console.log(opts.text); 53 | }, 54 | }); 55 | 56 | run([echo]) 57 | ``` 58 | ```bash 59 | ~ bun run index.ts echo 60 | echo 61 | 62 | ~ bun run index.ts echo text 63 | text 64 | ``` 65 | 66 | Print version with `--version -v`: 67 | ```ts 68 | ... 69 | 70 | run([echo], { 71 | version: "1.0.0", 72 | ); 73 | ``` 74 | ```bash 75 | ~ bun run index.ts --version 76 | 1.0.0 77 | ``` 78 | 79 | Version accepts async callback for you to do any kind of io if necessary before printing cli version: 80 | ```ts 81 | import { run, command, positional } from "@drizzle-team/brocli"; 82 | 83 | const version = async () => { 84 | // you can run async here, for example fetch version of runtime-dependend library 85 | 86 | const envVersion = process.env.CLI_VERSION; 87 | console.log(chalk.gray(envVersion), "\n"); 88 | }; 89 | 90 | const echo = command({ ... }); 91 | 92 | run([echo], { 93 | version: version, 94 | ); 95 | ``` 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | # API reference 104 | [**`command`**](#command) 105 | - [`command → name`](#command-name) 106 | - [`command → desc`](#command-desc) 107 | - [`command → shortDesc`](#command-shortDesc) 108 | - [`command → aliases`](#command-aliases) 109 | - [`command → options`](#command-options) 110 | - [`command → transform`](#command-transform) 111 | - [`command → handler`](#command-handler) 112 | - [`command → help`](#command-help) 113 | - [`command → hidden`](#command-hidden) 114 | - [`command → metadata`](#command-metadata) 115 | 116 | [**`options`**](#options) 117 | - [`string`](#options-string) 118 | - [`boolean`](#options-boolean) 119 | - [`number`](#options-number) 120 | - [`enum`](#options-enum) 121 | - [`positional`](#options-positional) 122 | - [`required`](#options-required) 123 | - [`alias`](#options-alias) 124 | - [`desc`](#options-desc) 125 | - [`default`](#options-default) 126 | - [`hidden`](#options-hidden) 127 | 128 | 129 | [**`run`**](#run) 130 | - [`string`](#options-string) 131 | 132 | 133 | Brocli **`command`** declaration has: 134 | `name` - command name, will be listed in `help` 135 | `desc` - optional description, will be listed in the command `help` 136 | `shortDesc` - optional short description, will be listed in the all commands/all subcommands `help` 137 | `aliases` - command name aliases 138 | `hidden` - flag to hide command from `help` 139 | `help` - command help text or a callback to print help text with dynamically provided config 140 | `options` - typed list of shell arguments to be parsed and provided to `transform` or `handler` 141 | `transform` - optional hook, will be called before handler to modify CLI params 142 | `handler` - called with either typed `options` or `transform` params, place to run your command business logic 143 | `metadata` - optional meta information for docs generation flow 144 | 145 | `name`, `desc`, `shortDesc` and `metadata` are provided to docs generation step 146 | 147 | 148 | ```ts 149 | import { command, string, boolean } from "@drizzle-team/brocli"; 150 | 151 | 152 | 153 | const push = command({ 154 | name: "push", 155 | options: { 156 | dialect: string().enum("postgresql", "mysql", "sqlite"), 157 | databaseSchema: string().required(), 158 | databaseUrl: string().required(), 159 | strict: boolean().default(false), 160 | }, 161 | transform: (opts) => { 162 | }, 163 | handler: (opts) => { 164 | ... 165 | }, 166 | }); 167 | ``` 168 | 169 | 170 | 171 | ```ts 172 | import { command } from "@drizzle-team/brocli"; 173 | 174 | const cmd = command({ 175 | name: "cmd", 176 | options: { 177 | dialect: string().enum("postgresql", "mysql", "sqlite"), 178 | schema: string().required(), 179 | url: string().required(), 180 | }, 181 | handler: (opts) => { 182 | ... 183 | }, 184 | }); 185 | 186 | ``` 187 | 188 | ### Option builder 189 | Initial builder functions: 190 | 191 | - `string(name?: string)` - defines option as a string-type option which requires data to be passed as `--option=value` or `--option value` 192 | - `name` - name by which option is passed in cli args 193 | If not specified, defaults to key of this option 194 | :warning: - must not contain `=` character, not be in `--help`,`-h`,`--version`,`-v` and be unique per each command 195 | :speech_balloon: - will be automatically prefixed with `-` if one character long, `--` if longer 196 | If you wish to have only single hyphen as a prefix on multi character name - simply specify name with it: `string('-longname')` 197 | 198 | - `number(name?: string)` - defines option as a number-type option which requires data to be passed as `--option=value` or `--option value` 199 | - `name` - name by which option is passed in cli args 200 | If not specified, defaults to key of this option 201 | :warning: - must not contain `=` character, not be in `--help`,`-h`,`--version`,`-v` and be unique per each command 202 | :speech_balloon: - will be automatically prefixed with `-` if one character long, `--` if longer 203 | If you wish to have only single hyphen as a prefix on multi character name - simply specify name with it: `number('-longname')` 204 | 205 | - `boolean(name?: string)` - defines option as a boolean-type option which requires data to be passed as `--option` 206 | - `name` - name by which option is passed in cli args 207 | If not specified, defaults to key of this option 208 | :warning: - must not contain `=` character, not be in `--help`,`-h`,`--version`,`-v` and be unique per each command 209 | :speech_balloon: - will be automatically prefixed with `-` if one character long, `--` if longer 210 | If you wish to have only single hyphen as a prefix on multi character name - simply specify name with it: `boolean('-longname')` 211 | 212 | - `positional(displayName?: string)` - defines option as a positional-type option which requires data to be passed after a command as `command value` 213 | - `displayName` - name by which option is passed in cli args 214 | If not specified, defaults to key of this option 215 | :warning: - does not consume options and data that starts with 216 | 217 | Extensions: 218 | 219 | - `.alias(...aliases: string[])` - defines aliases for option 220 | - `aliases` - aliases by which option is passed in cli args 221 | :warning: - must not contain `=` character, not be in `--help`,`-h`,`--version`,`-v` and be unique per each command 222 | :speech_balloon: - will be automatically prefixed with `-` if one character long, `--` if longer 223 | If you wish to have only single hyphen as a prefix on multi character alias - simply specify alias with it: `.alias('-longname')` 224 | 225 | - `.desc(description: string)` - defines description for option to be displayed in `help` command 226 | 227 | - `.required()` - sets option as required, which means that application will print an error if it is not present in cli args 228 | 229 | - `.default(value: string | boolean)` - sets default value for option which will be assigned to it in case it is not present in cli args 230 | 231 | - `.hidden()` - sets option as hidden - option will be omitted from being displayed in `help` command 232 | 233 | - `.enum(values: [string, ...string[]])` - limits values of string to one of specified here 234 | - `values` - allowed enum values 235 | 236 | - `.int()` - ensures that number is an integer 237 | 238 | - `.min(value: number)` - specified minimal allowed value for numbers 239 | - `value` - minimal allowed value 240 | :warning: - does not limit defaults 241 | 242 | - `.max(value: number)` - specified maximal allowed value for numbers 243 | - `value` - maximal allowed value 244 | :warning: - does not limit defaults 245 | 246 | ### Creating handlers 247 | 248 | Normally, you can write handlers right in the `command()` function, however there might be cases where you'd want to define your handlers separately. 249 | For such cases, you'd want to infer type of `options` that will be passes inside your handler. 250 | You can do it using `TypeOf` type: 251 | 252 | ```Typescript 253 | import { string, boolean, type TypeOf } from '@drizzle-team/brocli' 254 | 255 | const commandOptions = { 256 | opt1: string(), 257 | opt2: boolean('flag').alias('f'), 258 | // And so on... 259 | } 260 | 261 | export const commandHandler = (options: TypeOf) => { 262 | // Your logic goes here... 263 | } 264 | ``` 265 | 266 | Or by using `handler(options, myHandler () => {...})` 267 | 268 | ```Typescript 269 | import { string, boolean, handler } from '@drizzle-team/brocli' 270 | 271 | const commandOptions = { 272 | opt1: string(), 273 | opt2: boolean('flag').alias('f'), 274 | // And so on... 275 | } 276 | 277 | export const commandHandler = handler(commandOptions, (options) => { 278 | // Your logic goes here... 279 | }); 280 | ``` 281 | 282 | ### Defining commands 283 | 284 | To define commands, use `command()` function: 285 | 286 | ```Typescript 287 | import { command, type Command, string, boolean, type TypeOf } from '@drizzle-team/brocli' 288 | 289 | const commandOptions = { 290 | opt1: string(), 291 | opt2: boolean('flag').alias('f'), 292 | // And so on... 293 | } 294 | 295 | const commands: Command[] = [] 296 | 297 | commands.push(command({ 298 | name: 'command', 299 | aliases: ['c', 'cmd'], 300 | desc: 'Description goes here', 301 | shortDesc: 'Short description' 302 | hidden: false, 303 | options: commandOptions, 304 | transform: (options) => { 305 | // Preprocess options here... 306 | return processedOptions 307 | }, 308 | handler: (processedOptions) => { 309 | // Your logic goes here... 310 | }, 311 | help: () => 'This command works like this: ...', 312 | subcommands: [ 313 | command( 314 | // You can define subcommands like this 315 | ) 316 | ] 317 | })); 318 | ``` 319 | 320 | Parameters: 321 | 322 | - `name` - name by which command is searched in cli args 323 | :warning: - must not start with `-` character, be equal to [`true`, `false`, `0`, `1`] (case-insensitive) and be unique per command collection 324 | 325 | - `aliases` - aliases by which command is searched in cli args 326 | :warning: - must not start with `-` character, be equal to [`true`, `false`, `0`, `1`] (case-insensitive) and be unique per command collection 327 | 328 | - `desc` - description for command to be displayed in `help` command 329 | 330 | - `shortDesc` - short description for command to be displayed in `help` command 331 | 332 | - `hidden` - sets command as hidden - if `true`, command will be omitted from being displayed in `help` command 333 | 334 | - `options` - object containing command options created using `string` and `boolean` functions 335 | 336 | - `transform` - optional function to preprocess options before they are passed to handler 337 | :warning: - type of return mutates type of handler's input 338 | 339 | - `handler` - function, which will be executed in case of successful option parse 340 | :warning: - must be present if your command doesn't have subcommands 341 | If command has subcommands but no handler, help for this command is going to be called instead of handler 342 | 343 | - `help` - function or string, which will be executed or printed when help is called for this command 344 | :warning: - if passed, takes prevalence over theme's `commandHelp` event 345 | 346 | - `subcommands` - subcommands for command 347 | :warning: - command can't have subcommands and `positional` options at the same time 348 | 349 | - `metadata` - any data that you want to attach to command to later use in docs generation step 350 | 351 | ### Running commands 352 | 353 | After defining commands, you're going to need to execute `run` function to start command execution 354 | 355 | ```Typescript 356 | import { command, type Command, run, string, boolean, type TypeOf } from '@drizzle-team/brocli' 357 | 358 | const commandOptions = { 359 | opt1: string(), 360 | opt2: boolean('flag').alias('f'), 361 | // And so on... 362 | } 363 | 364 | const commandHandler = (options: TypeOf) => { 365 | // Your logic goes here... 366 | } 367 | 368 | const commands: Command[] = [] 369 | 370 | commands.push(command({ 371 | name: 'command', 372 | aliases: ['c', 'cmd'], 373 | desc: 'Description goes here', 374 | hidden: false, 375 | options: commandOptions, 376 | handler: commandHandler, 377 | })); 378 | 379 | // And so on... 380 | 381 | run(commands, { 382 | name: 'mysoft', 383 | description: 'MySoft CLI', 384 | omitKeysOfUndefinedOptions: true, 385 | argSource: customEnvironmentArgvStorage, 386 | version: '1.0.0', 387 | help: () => { 388 | console.log('Command list:'); 389 | commands.forEach(c => console.log('This command does ... and has options ...')); 390 | }, 391 | theme: async (event) => { 392 | if (event.type === 'commandHelp') { 393 | await myCustomUniversalCommandHelp(event.command); 394 | 395 | return true; 396 | } 397 | 398 | if (event.type === 'unknownError') { 399 | console.log('Something went wrong...'); 400 | 401 | return true; 402 | } 403 | 404 | return false; 405 | }, 406 | globals: { 407 | flag: boolean('gflag').description('Global flag').default(false) 408 | }, 409 | hook: (event, command, globals) => { 410 | if(event === 'before') console.log(`Command '${command.name}' started with flag ${globals.flag}`) 411 | if(event === 'after') console.log(`Command '${command.name}' succesfully finished it's work with flag ${globals.flag}`) 412 | } 413 | }) 414 | ``` 415 | 416 | Parameters: 417 | 418 | - `name` - name that's used to invoke your application from cli. 419 | Used for themes that print usage examples, example: 420 | `app do-task --help` results in `Usage: app do-task [flags] ...` 421 | Default: `undefined` 422 | 423 | - `description` - description of your app 424 | Used for themes, example: 425 | `myapp --help` results in 426 | ``` 427 | MyApp CLI 428 | 429 | Usage: myapp [command]... 430 | ``` 431 | Default: `undefined` 432 | 433 | - `omitKeysOfUndefinedOptions` - flag that determines whether undefined options will be passed to transform\handler or not 434 | Default: `false` 435 | 436 | - `argSource` - location of array of args in your environment 437 | :warning: - first two items of this storage will be ignored as they typically contain executable and executed file paths 438 | Default: `process.argv` 439 | 440 | - `version` - string or handler used to print your app version 441 | :warning: - if passed, takes prevalence over theme's version event 442 | 443 | - `help` - string or handler used to print your app's global help 444 | :warning: - if passed, takes prevalence over theme's `globalHelp` event 445 | 446 | - `theme(event: BroCliEvent)` - function that's used to customize messages that are printed on various events 447 | Return: 448 | `true` | `Promise` if you consider event processed 449 | `false` | `Promise` to redirect event to default theme 450 | 451 | - `globals` - global options that could be processed in `hook` 452 | :warning: - positionals are not allowed in `globals` 453 | :warning: - names and aliases must not overlap with options of commands 454 | 455 | - `hook(event: EventType, command: Command, options: TypeOf)` - function that's used to execute code before and after every command's `transform` and `handler` execution 456 | 457 | ### Additional functions 458 | 459 | - `commandsInfo(commands: Command[])` - get simplified representation of your command collection 460 | Can be used to generate docs 461 | 462 | - `test(command: Command, args: string)` - test behaviour for command with specified arguments 463 | :warning: - if command has `transform`, it will get called, however `handler` won't 464 | 465 | - `getCommandNameWithParents(command: Command)` - get subcommand's name with parent command names 466 | 467 | ## CLI 468 | 469 | In `BroCLI`, command doesn't have to be the first argument, instead it may be passed in any order. 470 | To make this possible, hovewer, option that's passed right before command should have an explicit value, even if it is a flag: `--verbose true ` (does not apply to reserved flags: [ `--help` | `-h` | `--version` | `-v`]) 471 | Options are parsed in strict mode, meaning that having any unrecognized options will result in an error. 472 | -------------------------------------------------------------------------------- /dprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript": { 3 | "useTabs": true, 4 | "quoteStyle": "preferSingle", 5 | "quoteProps": "asNeeded", 6 | "arrowFunction.useParentheses": "force", 7 | "jsx.quoteStyle": "preferSingle" 8 | }, 9 | "json": { 10 | "useTabs": true 11 | }, 12 | "markdown": {}, 13 | "includes": ["**/*.{ts,tsx,js,jsx,cjs,mjs,json}"], 14 | "excludes": [ 15 | "**/node_modules", 16 | "dist", 17 | "dist-dts", 18 | "dist.new", 19 | "**/drizzle/**/meta", 20 | "**/drizzle2/**/meta", 21 | "**/*snapshot.json", 22 | "**/_journal.json", 23 | "**/tsup.config*.mjs", 24 | "**/.sst" 25 | ], 26 | "plugins": [ 27 | "https://plugins.dprint.dev/typescript-0.83.0.wasm", 28 | "https://plugins.dprint.dev/json-0.19.2.wasm", 29 | "https://plugins.dprint.dev/markdown-0.15.2.wasm" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@drizzle-team/brocli", 3 | "type": "module", 4 | "author": "Drizzle Team", 5 | "version": "0.11.0", 6 | "description": "Modern type-safe way of building CLIs", 7 | "license": "Apache-2.0", 8 | "sideEffects": false, 9 | "publishConfig": { 10 | "provenance": true 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/drizzle-team/brocli.git" 15 | }, 16 | "homepage": "https://github.com/drizzle-team/brocli", 17 | "scripts": { 18 | "build": "pnpm tsx scripts/build.ts", 19 | "b": "pnpm build", 20 | "pack": "(cd dist && npm pack --pack-destination ..) && rm -f package.tgz && mv *.tgz package.tgz", 21 | "publish": "npm publish package.tgz", 22 | "test": "vitest run && npx tsc --noEmit", 23 | "mtest": "npx tsx tests/manual.ts", 24 | "lint": "dprint check --list-different" 25 | }, 26 | "devDependencies": { 27 | "@arethetypeswrong/cli": "^0.15.3", 28 | "@originjs/vite-plugin-commonjs": "^1.0.3", 29 | "@types/clone": "^2.1.4", 30 | "@types/node": "^20.12.13", 31 | "@types/shell-quote": "^1.7.5", 32 | "clone": "^2.1.2", 33 | "dprint": "^0.46.2", 34 | "shell-quote": "^1.8.1", 35 | "tsup": "^8.1.0", 36 | "tsx": "^4.7.0", 37 | "typescript": "latest", 38 | "vite-tsconfig-paths": "^4.3.2", 39 | "vitest": "^1.6.0", 40 | "zx": "^8.1.2" 41 | }, 42 | "main": "./index.cjs", 43 | "module": "./index.js", 44 | "types": "./index.d.ts", 45 | "exports": { 46 | ".": { 47 | "import": { 48 | "types": "./index.d.ts", 49 | "default": "./index.js" 50 | }, 51 | "require": { 52 | "types": "./index.d.cjs", 53 | "default": "./index.cjs" 54 | }, 55 | "types": "./index.d.ts", 56 | "default": "./index.js" 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import 'zx/globals'; 2 | 3 | import { build } from 'tsup'; 4 | 5 | fs.removeSync('dist'); 6 | 7 | await build({ 8 | entry: ['src/index.ts'], 9 | splitting: false, 10 | sourcemap: true, 11 | dts: true, 12 | bundle: true, 13 | format: ['cjs', 'esm'], 14 | outExtension(ctx) { 15 | if (ctx.format === 'cjs') { 16 | return { 17 | dts: '.d.cts', 18 | js: '.cjs', 19 | }; 20 | } 21 | return { 22 | dts: '.d.ts', 23 | js: '.js', 24 | }; 25 | }, 26 | }); 27 | 28 | fs.copyFileSync('package.json', 'dist/package.json'); 29 | fs.copyFileSync('README.md', 'dist/README.md'); 30 | -------------------------------------------------------------------------------- /src/brocli-error.ts: -------------------------------------------------------------------------------- 1 | import type { BroCliEvent } from './event-handler'; 2 | 3 | /** 4 | * Internal error class used to bypass runCli's logging without stack trace 5 | * 6 | * Used only for malformed commands and options 7 | */ 8 | export class BroCliError extends Error { 9 | constructor(message: string | undefined, public event?: BroCliEvent) { 10 | const errPrefix = 'BroCli error: '; 11 | super(message === undefined ? message : `${errPrefix}${message}`); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/command-core.ts: -------------------------------------------------------------------------------- 1 | import clone from 'clone'; 2 | import { BroCliError } from './brocli-error'; 3 | import { defaultEventHandler, type EventHandler, eventHandlerWrapper } from './event-handler'; 4 | import { 5 | type GenericBuilderInternals, 6 | type GenericBuilderInternalsFields, 7 | type GenericBuilderInternalsLimited, 8 | type OutputType, 9 | type ProcessedBuilderConfig, 10 | type ProcessedOptions, 11 | type TypeOf, 12 | } from './option-builder'; 13 | import { executeOrLog, isInt, shellArgs } from './util'; 14 | 15 | // Type area 16 | export type CommandHandler< 17 | TOpts extends Record | undefined = 18 | | Record 19 | | undefined, 20 | > = ( 21 | options: TOpts extends Record ? TypeOf : undefined, 22 | ) => any; 23 | 24 | export type CommandInfo = { 25 | name: string; 26 | aliases?: [string, ...string[]]; 27 | desc?: string; 28 | shortDesc?: string; 29 | hidden?: boolean; 30 | options?: Record; 31 | metadata?: any; 32 | subcommands?: CommandsInfo; 33 | }; 34 | 35 | export type CommandsInfo = Record; 36 | 37 | export type EventType = 'before' | 'after'; 38 | 39 | export type BroCliConfig< 40 | TOpts extends Record | undefined = undefined, 41 | TOptsData = TOpts extends Record ? TypeOf : undefined, 42 | > = { 43 | name?: string; 44 | description?: string; 45 | argSource?: string[]; 46 | help?: string | Function; 47 | version?: string | Function; 48 | omitKeysOfUndefinedOptions?: boolean; 49 | globals?: TOpts; 50 | hook?: ( 51 | event: EventType, 52 | command: Command, 53 | options: TOptsData, 54 | ) => any; 55 | theme?: EventHandler; 56 | }; 57 | 58 | export type GenericCommandHandler = (options?: Record | undefined) => any; 59 | 60 | export type RawCommand< 61 | TOpts extends Record | undefined = 62 | | Record 63 | | undefined, 64 | TOptsData = TOpts extends Record ? TypeOf : undefined, 65 | TTransformed = TOptsData extends undefined ? undefined : TOptsData, 66 | > = { 67 | name?: string; 68 | aliases?: [string, ...string[]]; 69 | desc?: string; 70 | shortDesc?: string; 71 | hidden?: boolean; 72 | options?: TOpts; 73 | help?: string | Function; 74 | transform?: (options: TOptsData) => TTransformed; 75 | handler?: (options: Awaited) => any; 76 | subcommands?: [Command, ...Command[]]; 77 | metadata?: any; 78 | }; 79 | 80 | export type AnyRawCommand< 81 | TOpts extends Record | undefined = 82 | | Record 83 | | undefined, 84 | > = { 85 | name?: string; 86 | aliases?: [string, ...string[]]; 87 | desc?: string; 88 | shortDesc?: string; 89 | hidden?: boolean; 90 | options?: TOpts; 91 | help?: string | Function; 92 | transform?: GenericCommandHandler; 93 | handler?: GenericCommandHandler; 94 | subcommands?: [Command, ...Command[]]; 95 | metadata?: any; 96 | }; 97 | 98 | export type Command = { 99 | name: string; 100 | aliases?: [string, ...string[]]; 101 | desc?: string; 102 | shortDesc?: string; 103 | hidden?: boolean; 104 | options?: ProcessedOptions; 105 | help?: string | Function; 106 | transform?: GenericCommandHandler; 107 | handler?: GenericCommandHandler; 108 | subcommands?: [Command, ...Command[]]; 109 | parent?: Command; 110 | metadata?: any; 111 | }; 112 | 113 | export type CommandCandidate = { 114 | data: string; 115 | originalIndex: number; 116 | }; 117 | 118 | export type InnerCommandParseRes = { 119 | command: Command | undefined; 120 | args: string[]; 121 | }; 122 | 123 | export type TestResult = { 124 | type: 'handler'; 125 | options: THandlerInput; 126 | } | { 127 | type: 'help' | 'version'; 128 | } | { 129 | type: 'error'; 130 | error: unknown; 131 | }; 132 | 133 | const generatePrefix = (name: string) => name.startsWith('-') ? name : name.length > 1 ? `--${name}` : `-${name}`; 134 | 135 | const validateOptions = >( 136 | config: TOptionConfig, 137 | ): ProcessedOptions => { 138 | const cloned = clone(config); 139 | 140 | const entries: [string, GenericBuilderInternalsFields][] = []; 141 | 142 | const storedNames: [string, ...string[]][] = []; 143 | 144 | const cfgEntries = Object.entries(cloned); 145 | 146 | for (const [key, value] of cfgEntries) { 147 | const cfg = value._.config; 148 | 149 | if (cfg.name === undefined) cfg.name = key; 150 | 151 | if (cfg.type === 'positional') continue; 152 | 153 | if (cfg.name!.includes('=')) { 154 | throw new BroCliError( 155 | `Can't define option '${generatePrefix(cfg.name)}' - option names and aliases cannot contain '='!`, 156 | ); 157 | } 158 | 159 | for (const alias of cfg.aliases) { 160 | if (alias.includes('=')) { 161 | throw new BroCliError( 162 | `Can't define option '${generatePrefix(cfg.name)}' - option names and aliases cannot contain '='!`, 163 | ); 164 | } 165 | } 166 | 167 | cfg.name = generatePrefix(cfg.name); 168 | 169 | cfg.aliases = cfg.aliases.map((a) => generatePrefix(a)); 170 | } 171 | 172 | for (const [key, value] of cfgEntries) { 173 | const cfg = value._.config; 174 | 175 | if (cfg.type === 'positional') { 176 | entries.push([key, { config: cfg, $output: undefined as any }]); 177 | 178 | continue; 179 | } 180 | 181 | const reservedNames = ['--help', '-h', '--version', '-v']; 182 | 183 | const allNames = [cfg.name, ...cfg.aliases]; 184 | 185 | for (const name of allNames) { 186 | const match = reservedNames.find((n) => n === name); 187 | if (match) throw new BroCliError(`Can't define option '${cfg.name}' - name '${match}' is reserved!`); 188 | } 189 | 190 | for (const storage of storedNames) { 191 | const nameOccupier = storage.find((e) => e === cfg.name); 192 | 193 | if (!nameOccupier) continue; 194 | 195 | throw new BroCliError( 196 | `Can't define option '${cfg.name}' - name is already in use by option '${storage[0]}'!`, 197 | ); 198 | } 199 | 200 | for (const alias of cfg.aliases) { 201 | for (const storage of storedNames) { 202 | const nameOccupier = storage.find((e) => e === alias); 203 | 204 | if (!nameOccupier) continue; 205 | 206 | throw new BroCliError( 207 | `Can't define option '${cfg.name}' - alias '${alias}' is already in use by option '${storage[0]}'!`, 208 | ); 209 | } 210 | } 211 | 212 | const currentNames = [cfg.name!, ...cfg.aliases] as [string, ...string[]]; 213 | 214 | storedNames.push(currentNames); 215 | 216 | currentNames.forEach((name, idx) => { 217 | if (currentNames.findIndex((e) => e === name) === idx) return; 218 | 219 | throw new BroCliError( 220 | `Can't define option '${cfg.name}' - duplicate alias '${name}'!`, 221 | ); 222 | }); 223 | 224 | entries.push([key, { config: cfg, $output: undefined as any }]); 225 | } 226 | 227 | return Object.fromEntries(entries) as ProcessedOptions; 228 | }; 229 | 230 | const assignParent = (parent: Command, subcommands: Command[]) => 231 | subcommands.forEach((e) => { 232 | e.parent = parent; 233 | if (e.subcommands) assignParent(e, e.subcommands); 234 | }); 235 | 236 | export const command = < 237 | TOpts extends Record | undefined, 238 | TOptsData = TOpts extends Record ? TypeOf : undefined, 239 | TTransformed = TOptsData, 240 | >(command: RawCommand): Command> => { 241 | const allNames = command.aliases ? [command.name, ...command.aliases] : [command.name]; 242 | 243 | const cmd: Command = clone(command) as any; 244 | if ( 245 | ( command).subcommands && command.options 246 | && Object.values(command.options).find((opt) => opt._.config.type === 'positional') 247 | ) { 248 | throw new BroCliError( 249 | `Can't define command '${cmd.name}' - command can't have subcommands and positional args at the same time!`, 250 | ); 251 | } 252 | 253 | if (!command.handler && !command.subcommands) { 254 | throw new BroCliError( 255 | `Can't define command '${cmd.name}' - command without subcommands must have a handler present!`, 256 | ); 257 | } 258 | 259 | const processedOptions = command.options ? validateOptions(command.options) : undefined; 260 | cmd.options = processedOptions; 261 | 262 | cmd.name = cmd.name ?? cmd.aliases?.shift(); 263 | 264 | if (!cmd.name) throw new BroCliError(`Can't define command without name!`); 265 | 266 | cmd.aliases = cmd.aliases?.length ? cmd.aliases : undefined; 267 | 268 | if (cmd.name.startsWith('-')) { 269 | throw new BroCliError(`Can't define command '${cmd.name}' - command name can't start with '-'!`); 270 | } 271 | 272 | cmd.aliases?.forEach((a) => { 273 | if (a.startsWith('-')) { 274 | throw new BroCliError(`Can't define command '${cmd.name}' - command aliases can't start with '-'!`); 275 | } 276 | }); 277 | 278 | allNames.forEach((n, i) => { 279 | if (n === 'help') { 280 | throw new BroCliError( 281 | `Can't define command '${cmd.name}' - 'help' is a reserved name. If you want to redefine help message - do so in runCli's config.`, 282 | ); 283 | } 284 | 285 | const lCaseName = n?.toLowerCase(); 286 | if (lCaseName === '0' || lCaseName === '1' || lCaseName === 'true' || lCaseName === 'false') { 287 | throw new BroCliError( 288 | `Can't define command '${cmd.name}' - '${n}' is a reserved for boolean values name!`, 289 | ); 290 | } 291 | 292 | const idx = allNames.findIndex((an) => an === n); 293 | 294 | if (idx !== i) throw new BroCliError(`Can't define command '${cmd.name}' - duplicate alias '${n}'!`); 295 | }); 296 | 297 | if (cmd.subcommands) { 298 | assignParent(cmd, cmd.subcommands); 299 | } 300 | 301 | return cmd; 302 | }; 303 | 304 | const getCommandInner = ( 305 | commands: Command[], 306 | candidates: CommandCandidate[], 307 | args: string[], 308 | cliName: string | undefined, 309 | cliDescription: string | undefined, 310 | ): InnerCommandParseRes => { 311 | const { data: arg, originalIndex: index } = candidates.shift()!; 312 | 313 | const command = commands.find((c) => { 314 | const names = c.aliases ? [c.name, ...c.aliases] : [c.name]; 315 | const res = names.find((name) => name === arg); 316 | 317 | return res; 318 | }); 319 | 320 | if (!command) { 321 | return { 322 | command, 323 | args, 324 | }; 325 | } 326 | 327 | const newArgs = removeByIndex(args, index); 328 | 329 | if (!candidates.length || !command.subcommands) { 330 | return { 331 | command, 332 | args: newArgs, 333 | }; 334 | } 335 | 336 | const newCandidates = candidates.map((c) => ({ data: c.data, originalIndex: c.originalIndex - 1 })); 337 | 338 | const subcommand = getCommandInner(command.subcommands!, newCandidates, newArgs, cliName, cliDescription); 339 | 340 | if (!subcommand.command) { 341 | throw new BroCliError(undefined, { 342 | type: 'error', 343 | violation: 'unknown_subcommand_error', 344 | name: cliName, 345 | description: cliDescription, 346 | command, 347 | offender: candidates[0]!.data, 348 | }); 349 | } 350 | 351 | return subcommand; 352 | }; 353 | 354 | const getCommand = ( 355 | commands: Command[], 356 | args: string[], 357 | cliName: string | undefined, 358 | cliDescription: string | undefined, 359 | ) => { 360 | const candidates: CommandCandidate[] = []; 361 | 362 | for (let i = 0; i < args.length; ++i) { 363 | const arg = args[i]!; 364 | if (arg === '--help' || arg === '-h' || arg === '--version' || arg === '-v') { 365 | const lCaseNext = args[i + 1]?.toLowerCase(); 366 | if (lCaseNext === '0' || lCaseNext === '1' || lCaseNext === 'true' || lCaseNext === 'false') ++i; 367 | 368 | continue; 369 | } 370 | 371 | if (arg?.startsWith('-')) { 372 | if (!arg.includes('=')) ++i; 373 | 374 | continue; 375 | } 376 | 377 | candidates.push({ 378 | data: arg, 379 | originalIndex: i, 380 | }); 381 | } 382 | 383 | if (!candidates.length) { 384 | return { 385 | command: undefined, 386 | args, 387 | }; 388 | } 389 | 390 | const firstCandidate = candidates[0]!; 391 | 392 | if (firstCandidate.data === 'help') { 393 | return { 394 | command: 'help' as const, 395 | args: removeByIndex(args, firstCandidate.originalIndex), 396 | }; 397 | } 398 | 399 | const { command, args: argsRes } = getCommandInner(commands, candidates, args, cliName, cliDescription); 400 | 401 | if (!command) { 402 | throw new BroCliError(undefined, { 403 | type: 'error', 404 | violation: 'unknown_command_error', 405 | commands, 406 | name: cliName, 407 | description: cliDescription, 408 | offender: firstCandidate.data, 409 | }); 410 | } 411 | 412 | return { 413 | command, 414 | args: argsRes, 415 | }; 416 | }; 417 | 418 | const parseArg = ( 419 | command: Command, 420 | options: [string, ProcessedBuilderConfig][], 421 | positionals: [string, ProcessedBuilderConfig][], 422 | arg: string, 423 | nextArg: string | undefined, 424 | cliName: string | undefined, 425 | cliDescription: string | undefined, 426 | ) => { 427 | let data: OutputType = undefined; 428 | 429 | const argSplit = arg.split('='); 430 | const hasEq = arg.includes('='); 431 | 432 | const namePart = argSplit.shift()!; 433 | const dataPart = hasEq ? argSplit.join('=') : nextArg; 434 | let skipNext = !hasEq; 435 | 436 | if (namePart === '--help' || namePart === '-h') { 437 | return { 438 | isHelp: true, 439 | }; 440 | } 441 | 442 | if (namePart === '--version' || namePart === '-v') { 443 | return { 444 | isVersion: true, 445 | }; 446 | } 447 | 448 | if (!arg.startsWith('-')) { 449 | if (!positionals.length) return {}; 450 | 451 | const pos = positionals.shift()!; 452 | 453 | if (pos[1].enumVals && !pos[1].enumVals.find((val) => val === arg)) { 454 | throw new BroCliError(undefined, { 455 | type: 'error', 456 | name: cliName, 457 | description: cliDescription, 458 | violation: 'enum_violation', 459 | command, 460 | option: pos[1], 461 | offender: { 462 | dataPart: arg, 463 | }, 464 | }); 465 | } 466 | 467 | data = arg; 468 | 469 | return { 470 | data, 471 | skipNext: false, 472 | name: pos[0], 473 | option: pos[1], 474 | }; 475 | } 476 | 477 | const option = options.find(([optKey, opt]) => { 478 | const names = [opt.name!, ...opt.aliases]; 479 | 480 | if (opt.type === 'boolean') { 481 | const match = names.find((name) => name === namePart); 482 | if (!match) return false; 483 | 484 | let lcaseData = dataPart?.toLowerCase(); 485 | 486 | if (!hasEq && nextArg?.startsWith('-')) { 487 | data = true; 488 | skipNext = false; 489 | return true; 490 | } 491 | 492 | if (lcaseData === undefined || lcaseData === '' || lcaseData === 'true' || lcaseData === '1') { 493 | data = true; 494 | return true; 495 | } 496 | 497 | if (lcaseData === 'false' || lcaseData === '0') { 498 | data = false; 499 | return true; 500 | } 501 | 502 | if (!hasEq) { 503 | data = true; 504 | skipNext = false; 505 | return true; 506 | } 507 | 508 | throw new BroCliError(undefined, { 509 | type: 'error', 510 | name: cliName, 511 | description: cliDescription, 512 | violation: 'invalid_boolean_syntax', 513 | option: opt, 514 | command, 515 | offender: { 516 | namePart, 517 | dataPart, 518 | }, 519 | }); 520 | } else { 521 | const match = names.find((name) => name === namePart); 522 | 523 | if (!match) return false; 524 | 525 | if (opt.type === 'string') { 526 | if (!hasEq && nextArg === undefined) { 527 | throw new BroCliError(undefined, { 528 | type: 'error', 529 | name: cliName, 530 | description: cliDescription, 531 | violation: 'invalid_string_syntax', 532 | option: opt, 533 | command, 534 | offender: { 535 | namePart, 536 | dataPart, 537 | }, 538 | }); 539 | } 540 | 541 | if (opt.enumVals && !opt.enumVals.find((val) => val === dataPart)) { 542 | throw new BroCliError(undefined, { 543 | type: 'error', 544 | name: cliName, 545 | description: cliDescription, 546 | violation: 'enum_violation', 547 | option: opt, 548 | command, 549 | offender: { 550 | namePart, 551 | dataPart, 552 | }, 553 | }); 554 | } 555 | 556 | data = dataPart; 557 | 558 | return true; 559 | } 560 | 561 | if (!hasEq && nextArg === undefined) { 562 | throw new BroCliError(undefined, { 563 | type: 'error', 564 | name: cliName, 565 | description: cliDescription, 566 | violation: 'invalid_number_syntax', 567 | option: opt, 568 | command, 569 | offender: { 570 | namePart, 571 | dataPart, 572 | }, 573 | }); 574 | } 575 | 576 | const numData = Number(dataPart); 577 | 578 | if (isNaN(numData)) { 579 | throw new BroCliError(undefined, { 580 | type: 'error', 581 | name: cliName, 582 | description: cliDescription, 583 | violation: 'invalid_number_value', 584 | option: opt, 585 | command, 586 | offender: { 587 | namePart, 588 | dataPart, 589 | }, 590 | }); 591 | } 592 | 593 | if (opt.isInt && !isInt(numData)) { 594 | throw new BroCliError(undefined, { 595 | type: 'error', 596 | name: cliName, 597 | description: cliDescription, 598 | violation: 'expected_int', 599 | option: opt, 600 | command, 601 | offender: { 602 | namePart, 603 | dataPart, 604 | }, 605 | }); 606 | } 607 | 608 | if (opt.minVal !== undefined && numData < opt.minVal) { 609 | throw new BroCliError(undefined, { 610 | type: 'error', 611 | name: cliName, 612 | description: cliDescription, 613 | violation: 'below_min', 614 | option: opt, 615 | command, 616 | offender: { 617 | namePart, 618 | dataPart, 619 | }, 620 | }); 621 | } 622 | 623 | if (opt.maxVal !== undefined && numData > opt.maxVal) { 624 | throw new BroCliError(undefined, { 625 | type: 'error', 626 | name: cliName, 627 | description: cliDescription, 628 | violation: 'above_max', 629 | option: opt, 630 | command, 631 | offender: { 632 | namePart, 633 | dataPart, 634 | }, 635 | }); 636 | } 637 | 638 | data = numData; 639 | 640 | return true; 641 | } 642 | }); 643 | 644 | return { 645 | data, 646 | skipNext, 647 | name: option?.[0], 648 | option: option?.[1], 649 | }; 650 | }; 651 | 652 | const parseOptions = ( 653 | command: Command, 654 | args: string[], 655 | cliName: string | undefined, 656 | cliDescription: string | undefined, 657 | omitKeysOfUndefinedOptions?: boolean, 658 | ): Record | 'help' | 'version' | undefined => { 659 | const options = command.options; 660 | let noOpts = !options; 661 | 662 | const optEntries = Object.entries(options ?? {} as Exclude).map( 663 | (opt) => [opt[0], opt[1].config] as [string, ProcessedBuilderConfig], 664 | ); 665 | 666 | const nonPositionalEntries = optEntries.filter(([key, opt]) => opt.type !== 'positional'); 667 | const positionalEntries = optEntries.filter(([key, opt]) => opt.type === 'positional'); 668 | 669 | const result: Record = {}; 670 | 671 | const missingRequiredArr: string[][] = []; 672 | const unrecognizedArgsArr: string[] = []; 673 | 674 | for (let i = 0; i < args.length; ++i) { 675 | const arg = args[i]!; 676 | const nextArg = args[i + 1]; 677 | 678 | const { 679 | data, 680 | name, 681 | option, 682 | skipNext, 683 | isHelp, 684 | isVersion, 685 | } = parseArg(command, nonPositionalEntries, positionalEntries, arg, nextArg, cliName, cliDescription); 686 | if (!option) unrecognizedArgsArr.push(arg.split('=')[0]!); 687 | if (skipNext) ++i; 688 | 689 | if (isHelp) return 'help'; 690 | if (isVersion) return 'version'; 691 | 692 | result[name!] = data; 693 | } 694 | 695 | for (const [optKey, option] of optEntries) { 696 | const data = result[optKey] ?? option.default; 697 | 698 | if (!omitKeysOfUndefinedOptions) { 699 | result[optKey] = data; 700 | } else { 701 | if (data !== undefined) result[optKey] = data; 702 | } 703 | 704 | if (option.isRequired && result[optKey] === undefined) missingRequiredArr.push([option.name!, ...option.aliases]); 705 | } 706 | 707 | if (missingRequiredArr.length) { 708 | throw new BroCliError(undefined, { 709 | type: 'error', 710 | violation: 'missing_args_error', 711 | name: cliName, 712 | description: cliDescription, 713 | command, 714 | missing: missingRequiredArr as [string[], ...string[][]], 715 | }); 716 | } 717 | if (unrecognizedArgsArr.length) { 718 | throw new BroCliError(undefined, { 719 | type: 'error', 720 | violation: 'unrecognized_args_error', 721 | name: cliName, 722 | description: cliDescription, 723 | command, 724 | unrecognized: unrecognizedArgsArr as [string, ...string[]], 725 | }); 726 | } 727 | 728 | return noOpts ? undefined : result; 729 | }; 730 | 731 | const parseGlobals = ( 732 | command: Command, 733 | globals: ProcessedOptions> | undefined, 734 | args: string[], 735 | cliName: string | undefined, 736 | cliDescription: string | undefined, 737 | omitKeysOfUndefinedOptions?: boolean, 738 | ): Record | 'help' | 'version' | undefined => { 739 | if (!globals) return undefined; 740 | 741 | const optEntries = Object.entries(globals).map( 742 | (opt) => [opt[0], opt[1].config] as [string, ProcessedBuilderConfig], 743 | ); 744 | 745 | const result: Record = {}; 746 | const missingRequiredArr: string[][] = []; 747 | 748 | for (let i = 0; i < args.length; ++i) { 749 | const arg = args[i]!; 750 | const nextArg = args[i + 1]; 751 | 752 | const { 753 | data, 754 | name, 755 | option, 756 | skipNext, 757 | isHelp, 758 | isVersion, 759 | } = parseArg(command, optEntries, [], arg, nextArg, cliName, cliDescription); 760 | if (skipNext) ++i; 761 | 762 | if (isHelp) return 'help'; 763 | if (isVersion) return 'version'; 764 | if (!option) continue; 765 | delete args[i]; 766 | if (skipNext) delete args[i - 1]; 767 | 768 | result[name!] = data; 769 | } 770 | 771 | for (const [optKey, option] of optEntries) { 772 | const data = result[optKey] ?? option.default; 773 | 774 | if (!omitKeysOfUndefinedOptions) { 775 | result[optKey] = data; 776 | } else { 777 | if (data !== undefined) result[optKey] = data; 778 | } 779 | 780 | if (option.isRequired && result[optKey] === undefined) missingRequiredArr.push([option.name!, ...option.aliases]); 781 | } 782 | 783 | if (missingRequiredArr.length) { 784 | throw new BroCliError(undefined, { 785 | type: 'error', 786 | violation: 'missing_args_error', 787 | name: cliName, 788 | description: cliDescription, 789 | command, 790 | missing: missingRequiredArr as [string[], ...string[][]], 791 | }); 792 | } 793 | 794 | return Object.keys(result).length ? result : undefined; 795 | }; 796 | 797 | export const getCommandNameWithParents = (command: Command): string => 798 | command.parent ? `${getCommandNameWithParents(command.parent)} ${command.name}` : command.name; 799 | 800 | const validateCommands = (commands: Command[], parent?: Command) => { 801 | const storedNames: Record = {}; 802 | 803 | for (const cmd of commands) { 804 | const storageVals = Object.values(storedNames); 805 | 806 | for (const storage of storageVals) { 807 | const nameOccupier = storage.find((e) => e === cmd.name); 808 | 809 | if (!nameOccupier) continue; 810 | 811 | throw new BroCliError( 812 | `Can't define command '${getCommandNameWithParents(cmd)}': name is already in use by command '${ 813 | parent ? `${getCommandNameWithParents(parent)} ` : '' 814 | }${storage[0]}'!`, 815 | ); 816 | } 817 | 818 | if (cmd.aliases) { 819 | for (const alias of cmd.aliases) { 820 | for (const storage of storageVals) { 821 | const nameOccupier = storage.find((e) => e === alias); 822 | 823 | if (!nameOccupier) continue; 824 | 825 | throw new BroCliError( 826 | `Can't define command '${getCommandNameWithParents(cmd)}': alias '${alias}' is already in use by command '${ 827 | parent ? `${getCommandNameWithParents(parent)} ` : '' 828 | }${storage[0]}'!`, 829 | ); 830 | } 831 | } 832 | } 833 | 834 | storedNames[cmd.name] = cmd.aliases 835 | ? [cmd.name, ...cmd.aliases] 836 | : [cmd.name]; 837 | 838 | if (cmd.subcommands) cmd.subcommands = validateCommands(cmd.subcommands, cmd) as [Command, ...Command[]]; 839 | } 840 | 841 | return commands; 842 | }; 843 | 844 | const validateGlobalsInner = ( 845 | commands: Command[], 846 | globals: GenericBuilderInternalsFields[], 847 | ) => { 848 | for (const c of commands) { 849 | const { options } = c; 850 | if (!options) continue; 851 | 852 | for (const { config: opt } of Object.values(options)) { 853 | const foundNameOverlap = globals.find(({ config: g }) => g.name === opt.name); 854 | if (foundNameOverlap) { 855 | throw new BroCliError( 856 | `Global options overlap with option '${opt.name}' of command '${getCommandNameWithParents(c)}' on name`, 857 | ); 858 | } 859 | 860 | let foundAliasOverlap = opt.aliases.find((a) => globals.find(({ config: g }) => g.name === a)) 861 | ?? globals.find(({ config: g }) => opt.aliases.find((a) => a === g.name)); 862 | if (!foundAliasOverlap) { 863 | for (const { config: g } of globals) { 864 | foundAliasOverlap = g.aliases.find((gAlias) => opt.name === gAlias); 865 | 866 | if (foundAliasOverlap) break; 867 | } 868 | } 869 | if (!foundAliasOverlap) { 870 | for (const { config: g } of globals) { 871 | foundAliasOverlap = g.aliases.find((gAlias) => opt.aliases.find((a) => a === gAlias)); 872 | 873 | if (foundAliasOverlap) break; 874 | } 875 | } 876 | 877 | if (foundAliasOverlap) { 878 | throw new BroCliError( 879 | `Global options overlap with option '${opt.name}' of command '${ 880 | getCommandNameWithParents(c) 881 | }' on alias '${foundAliasOverlap}'`, 882 | ); 883 | } 884 | } 885 | 886 | if (c.subcommands) validateGlobalsInner(c.subcommands, globals); 887 | } 888 | }; 889 | 890 | const validateGlobals = ( 891 | commands: Command[], 892 | globals: ProcessedOptions> | undefined, 893 | ) => { 894 | if (!globals) return; 895 | const globalEntries = Object.values(globals); 896 | 897 | validateGlobalsInner(commands, globalEntries); 898 | }; 899 | 900 | const removeByIndex = (arr: T[], idx: number): T[] => [...arr.slice(0, idx), ...arr.slice(idx + 1, arr.length)]; 901 | 902 | /** 903 | * Runs CLI commands 904 | * 905 | * @param commands - command collection 906 | * 907 | * @param config - additional settings 908 | */ 909 | export const run = async < 910 | TOpts extends Record | undefined = undefined, 911 | >( 912 | commands: Command[], 913 | config?: BroCliConfig, 914 | ): Promise => { 915 | const eventHandler = config?.theme 916 | ? eventHandlerWrapper(config.theme) 917 | : defaultEventHandler; 918 | const argSource = config?.argSource ?? process.argv; 919 | const version = config?.version; 920 | const help = config?.help; 921 | const omitKeysOfUndefinedOptions = config?.omitKeysOfUndefinedOptions ?? false; 922 | const cliName = config?.name; 923 | const cliDescription = config?.description; 924 | const globals = config?.globals; 925 | 926 | try { 927 | const processedCmds = validateCommands(commands); 928 | const processedGlobals = globals ? validateOptions(globals) : undefined; 929 | if (processedGlobals) validateGlobals(processedCmds, processedGlobals); 930 | 931 | let args = argSource.slice(2, argSource.length); 932 | if (!args.length) { 933 | return help !== undefined ? await executeOrLog(help) : await eventHandler({ 934 | type: 'global_help', 935 | description: cliDescription, 936 | name: cliName, 937 | commands: processedCmds, 938 | globals: processedGlobals, 939 | }); 940 | } 941 | 942 | const helpIndex = args.findIndex((arg) => arg === '--help' || arg === '-h'); 943 | if ( 944 | helpIndex !== -1 && (helpIndex > 0 945 | ? args[helpIndex - 1]?.startsWith('-') && !args[helpIndex - 1]!.includes('=') ? false : true 946 | : true) 947 | ) { 948 | const command = getCommand(processedCmds, args, cliName, cliDescription).command; 949 | 950 | if (typeof command === 'object') { 951 | return command.help !== undefined ? await executeOrLog(command.help) : await eventHandler({ 952 | type: 'command_help', 953 | description: cliDescription, 954 | name: cliName, 955 | command: command, 956 | globals: processedGlobals, 957 | }); 958 | } else { 959 | return help !== undefined ? await executeOrLog(help) : await eventHandler({ 960 | type: 'global_help', 961 | description: cliDescription, 962 | name: cliName, 963 | commands: processedCmds, 964 | globals: processedGlobals, 965 | }); 966 | } 967 | } 968 | 969 | const versionIndex = args.findIndex((arg) => arg === '--version' || arg === '-v'); 970 | if (versionIndex !== -1 && (versionIndex > 0 ? args[versionIndex - 1]?.startsWith('-') ? false : true : true)) { 971 | return version !== undefined ? await executeOrLog(version) : await eventHandler({ 972 | type: 'version', 973 | name: cliName, 974 | description: cliDescription, 975 | }); 976 | } 977 | 978 | const { command, args: newArgs } = getCommand(processedCmds, args, cliName, cliDescription); 979 | 980 | if (!command) { 981 | return help !== undefined ? await executeOrLog(help) : await eventHandler({ 982 | type: 'global_help', 983 | description: cliDescription, 984 | name: cliName, 985 | commands: processedCmds, 986 | globals: processedGlobals, 987 | }); 988 | } 989 | 990 | if (command === 'help') { 991 | let helpCommand: Command | 'help' | undefined; 992 | let newestArgs: string[] = newArgs; 993 | 994 | do { 995 | const res = getCommand(processedCmds, newestArgs, cliName, cliDescription); 996 | helpCommand = res.command; 997 | newestArgs = res.args; 998 | } while (helpCommand === 'help'); 999 | 1000 | return helpCommand 1001 | ? helpCommand.help !== undefined ? await executeOrLog(helpCommand.help) : await eventHandler({ 1002 | type: 'command_help', 1003 | description: cliDescription, 1004 | name: cliName, 1005 | command: helpCommand, 1006 | globals: processedGlobals, 1007 | }) 1008 | : help !== undefined 1009 | ? await executeOrLog(help) 1010 | : await eventHandler({ 1011 | type: 'global_help', 1012 | description: cliDescription, 1013 | name: cliName, 1014 | commands: processedCmds, 1015 | globals: processedGlobals, 1016 | }); 1017 | } 1018 | 1019 | const gOptionResult = parseGlobals( 1020 | command, 1021 | processedGlobals, 1022 | newArgs, 1023 | cliName, 1024 | cliDescription, 1025 | omitKeysOfUndefinedOptions, 1026 | ); 1027 | const optionResult = gOptionResult && (gOptionResult === 'help' || gOptionResult === 'version') 1028 | ? gOptionResult 1029 | : parseOptions( 1030 | command, 1031 | globals ? newArgs.filter((a) => a !== undefined) : newArgs, 1032 | cliName, 1033 | cliDescription, 1034 | omitKeysOfUndefinedOptions, 1035 | ); 1036 | 1037 | if (optionResult === 'help' || gOptionResult === 'help') { 1038 | return command.help !== undefined ? await executeOrLog(command.help) : await eventHandler({ 1039 | type: 'command_help', 1040 | description: cliDescription, 1041 | name: cliName, 1042 | command: command, 1043 | globals: processedGlobals, 1044 | }); 1045 | } 1046 | if (optionResult === 'version' || gOptionResult === 'version') { 1047 | return version !== undefined ? await executeOrLog(version) : await eventHandler({ 1048 | type: 'version', 1049 | name: cliName, 1050 | description: cliDescription, 1051 | }); 1052 | } 1053 | 1054 | if (command.handler) { 1055 | if (config?.hook) await config.hook('before', command, gOptionResult as any); 1056 | await command.handler(command.transform ? await command.transform(optionResult) : optionResult); 1057 | if (config?.hook) await config.hook('after', command, gOptionResult as any); 1058 | return; 1059 | } else { 1060 | return command.help !== undefined ? await executeOrLog(command.help) : await eventHandler({ 1061 | type: 'command_help', 1062 | description: cliDescription, 1063 | name: cliName, 1064 | command: command, 1065 | globals: processedGlobals, 1066 | }); 1067 | } 1068 | } catch (e) { 1069 | if (e instanceof BroCliError) { 1070 | if (e.event) await eventHandler(e.event); 1071 | else { 1072 | // @ts-expect-error - option meant only for tests 1073 | if (!config?.noExit) console.error(e.message); 1074 | // @ts-expect-error - return path meant only for tests 1075 | else return e.message; 1076 | } 1077 | } else { 1078 | await eventHandler({ 1079 | type: 'error', 1080 | violation: 'unknown_error', 1081 | name: cliName, 1082 | description: cliDescription, 1083 | error: e, 1084 | }); 1085 | } 1086 | 1087 | // @ts-expect-error - option meant only for tests 1088 | if (!config?.noExit) process.exit(1); 1089 | 1090 | return; 1091 | } 1092 | }; 1093 | 1094 | export const handler = >( 1095 | options: TOpts, 1096 | handler: CommandHandler, 1097 | ) => handler; 1098 | 1099 | export const test = async ( 1100 | command: Command, 1101 | args: string, 1102 | ): Promise> => { 1103 | try { 1104 | const cliParsedArgs: string[] = shellArgs(args); 1105 | const options = parseOptions(command, cliParsedArgs, undefined, undefined); 1106 | 1107 | if (options === 'help' || options === 'version') { 1108 | return { 1109 | type: options, 1110 | }; 1111 | } 1112 | 1113 | return { 1114 | options: command.transform ? await command.transform(options) : options, 1115 | type: 'handler', 1116 | }; 1117 | } catch (e) { 1118 | return { 1119 | type: 'error', 1120 | error: e, 1121 | }; 1122 | } 1123 | }; 1124 | 1125 | export const commandsInfo = ( 1126 | commands: Command[], 1127 | ): CommandsInfo => { 1128 | const validated = validateCommands(commands); 1129 | 1130 | return Object.fromEntries(validated.map((c) => [c.name, { 1131 | name: c.name, 1132 | aliases: clone(c.aliases), 1133 | desc: c.desc, 1134 | shortDesc: c.shortDesc, 1135 | isHidden: c.hidden, 1136 | options: c.options 1137 | ? Object.fromEntries(Object.entries(c.options).map(([key, opt]) => [key, clone(opt.config)])) 1138 | : undefined, 1139 | metadata: clone(c.metadata), 1140 | subcommands: c.subcommands ? commandsInfo(c.subcommands) : undefined, 1141 | }])); 1142 | }; 1143 | -------------------------------------------------------------------------------- /src/event-handler.ts: -------------------------------------------------------------------------------- 1 | import { type Command, getCommandNameWithParents } from './command-core'; 2 | import type { 3 | BuilderConfig, 4 | GenericBuilderInternals, 5 | GenericBuilderInternalsFields, 6 | OptionType, 7 | OutputType, 8 | ProcessedBuilderConfig, 9 | ProcessedOptions, 10 | } from './option-builder'; 11 | 12 | export type CommandHelpEvent = { 13 | type: 'command_help'; 14 | name: string | undefined; 15 | description: string | undefined; 16 | command: Command; 17 | globals?: ProcessedOptions>; 18 | }; 19 | 20 | export type GlobalHelpEvent = { 21 | type: 'global_help'; 22 | description: string | undefined; 23 | name: string | undefined; 24 | commands: Command[]; 25 | globals?: ProcessedOptions>; 26 | }; 27 | 28 | export type MissingArgsEvent = { 29 | type: 'error'; 30 | violation: 'missing_args_error'; 31 | name: string | undefined; 32 | description: string | undefined; 33 | command: Command; 34 | missing: [string[], ...string[][]]; 35 | }; 36 | 37 | export type UnrecognizedArgsEvent = { 38 | type: 'error'; 39 | violation: 'unrecognized_args_error'; 40 | name: string | undefined; 41 | description: string | undefined; 42 | command: Command; 43 | unrecognized: [string, ...string[]]; 44 | }; 45 | 46 | export type UnknownCommandEvent = { 47 | type: 'error'; 48 | violation: 'unknown_command_error'; 49 | name: string | undefined; 50 | description: string | undefined; 51 | commands: Command[]; 52 | offender: string; 53 | }; 54 | 55 | export type UnknownSubcommandEvent = { 56 | type: 'error'; 57 | violation: 'unknown_subcommand_error'; 58 | name: string | undefined; 59 | description: string | undefined; 60 | command: Command; 61 | offender: string; 62 | }; 63 | 64 | export type UnknownErrorEvent = { 65 | type: 'error'; 66 | violation: 'unknown_error'; 67 | name: string | undefined; 68 | description: string | undefined; 69 | error: unknown; 70 | }; 71 | 72 | export type VersionEvent = { 73 | type: 'version'; 74 | name: string | undefined; 75 | description: string | undefined; 76 | }; 77 | 78 | export type GenericValidationViolation = 79 | | 'above_max' 80 | | 'below_min' 81 | | 'expected_int' 82 | | 'invalid_boolean_syntax' 83 | | 'invalid_string_syntax' 84 | | 'invalid_number_syntax' 85 | | 'invalid_number_value' 86 | | 'enum_violation'; 87 | 88 | export type ValidationViolation = BroCliEvent extends infer Event 89 | ? Event extends { violation: string } ? Event['violation'] : never 90 | : never; 91 | 92 | export type ValidationErrorEvent = { 93 | type: 'error'; 94 | violation: GenericValidationViolation; 95 | name: string | undefined; 96 | description: string | undefined; 97 | command: Command; 98 | option: ProcessedBuilderConfig; 99 | offender: { 100 | namePart?: string; 101 | dataPart?: string; 102 | }; 103 | }; 104 | 105 | export type BroCliEvent = 106 | | CommandHelpEvent 107 | | GlobalHelpEvent 108 | | MissingArgsEvent 109 | | UnrecognizedArgsEvent 110 | | UnknownCommandEvent 111 | | UnknownSubcommandEvent 112 | | ValidationErrorEvent 113 | | VersionEvent 114 | | UnknownErrorEvent; 115 | 116 | export type BroCliEventType = BroCliEvent['type']; 117 | 118 | const getOptionTypeText = (option: BuilderConfig) => { 119 | let result = ''; 120 | 121 | switch (option.type) { 122 | case 'boolean': 123 | result = ''; 124 | break; 125 | case 'number': { 126 | if ((option.minVal ?? option.maxVal) !== undefined) { 127 | let text = ''; 128 | 129 | if (option.isInt) text = text + `integer `; 130 | 131 | if (option.minVal !== undefined) text = text + `[${option.minVal};`; 132 | else text = text + `(∞;`; 133 | 134 | if (option.maxVal !== undefined) text = text + `${option.maxVal}]`; 135 | else text = text + `∞)`; 136 | 137 | result = text; 138 | break; 139 | } 140 | 141 | if (option.isInt) { 142 | result = 'integer'; 143 | break; 144 | } 145 | 146 | result = 'number'; 147 | break; 148 | } 149 | case 'string': { 150 | if (option.enumVals) { 151 | result = '[ ' + option.enumVals.join(' | ') + ' ]'; 152 | break; 153 | } 154 | 155 | result = 'string'; 156 | break; 157 | } 158 | case 'positional': { 159 | result = `${option.isRequired ? '<' : '['}${option.enumVals ? option.enumVals.join('|') : option.name}${ 160 | option.isRequired ? '>' : ']' 161 | }`; 162 | break; 163 | } 164 | } 165 | 166 | if (option.isRequired && option.type !== 'positional') result = '!' + result.length ? ' ' : '' + result; 167 | return result; 168 | }; 169 | 170 | /** 171 | * Return `true` if your handler processes the event 172 | * 173 | * Return `false` to process event with a built-in handler 174 | */ 175 | export type EventHandler = (event: BroCliEvent) => boolean | Promise; 176 | export const defaultEventHandler: EventHandler = async (event) => { 177 | switch (event.type) { 178 | case 'command_help': { 179 | const command = event.command; 180 | const commandName = getCommandNameWithParents(command); 181 | const cliName = event.name; 182 | const desc = command.desc ?? command.shortDesc; 183 | const subs = command.subcommands?.filter((s) => !s.hidden); 184 | const subcommands = subs && subs.length ? subs : undefined; 185 | const defaultGlobals = [ 186 | { 187 | config: { 188 | name: '--help', 189 | aliases: ['-h'], 190 | type: 'boolean' as OptionType, 191 | description: `help for ${commandName}`, 192 | default: undefined, 193 | }, 194 | $output: undefined as any as boolean, 195 | }, 196 | { 197 | config: { 198 | name: '--version', 199 | aliases: ['-v'], 200 | type: 'boolean' as OptionType, 201 | description: `version${cliName ? ` for ${cliName}` : ''}`, 202 | default: undefined, 203 | }, 204 | $output: undefined as any as boolean, 205 | }, 206 | ]; 207 | const globals: { 208 | config: ProcessedBuilderConfig; 209 | $output: OutputType; 210 | }[] = event.globals 211 | ? [...Object.values(event.globals), ...defaultGlobals] 212 | : defaultGlobals; 213 | 214 | if (desc !== undefined) { 215 | console.log(`\n${desc}`); 216 | } 217 | 218 | const opts = Object.values(command.options ?? {} as Exclude).filter((opt) => 219 | !opt.config.isHidden 220 | ); 221 | const positionals = opts.filter((opt) => opt.config.type === 'positional'); 222 | const options = [...opts.filter((opt) => opt.config.type !== 'positional'), ...globals]; 223 | 224 | console.log('\nUsage:'); 225 | if (command.handler) { 226 | console.log( 227 | ` ${cliName ? cliName + ' ' : ''}${commandName}${ 228 | positionals.length 229 | ? ' ' 230 | + positionals.map(({ config: p }) => getOptionTypeText(p)).join(' ') 231 | : '' 232 | } [flags]`, 233 | ); 234 | } else console.log(` ${cliName ? cliName + ' ' : ''}${commandName} [command]`); 235 | 236 | if (command.aliases) { 237 | console.log(`\nAliases:`); 238 | console.log(` ${[command.name, ...command.aliases].join(', ')}`); 239 | } 240 | 241 | if (subcommands) { 242 | console.log('\nAvailable Commands:'); 243 | const padding = 3; 244 | const maxLength = subcommands.reduce((p, e) => e.name.length > p ? e.name.length : p, 0); 245 | const paddedLength = maxLength + padding; 246 | const preDescPad = 2 + paddedLength; 247 | 248 | const data = subcommands.map((s) => 249 | ` ${s.name.padEnd(paddedLength)}${ 250 | (() => { 251 | const description = s.shortDesc ?? s.desc; 252 | if (!description?.length) return ''; 253 | const split = description.split('\n'); 254 | const first = split.shift()!; 255 | 256 | const final = [first, ...split.map((s) => ''.padEnd(preDescPad) + s)].join('\n'); 257 | 258 | return final; 259 | })() 260 | }` 261 | ) 262 | .join('\n'); 263 | console.log(data); 264 | } 265 | 266 | if (options.length) { 267 | const aliasLength = options.reduce((p, e) => { 268 | const currentLength = e.config.aliases.reduce((pa, a) => pa + a.length, 0) 269 | + ((e.config.aliases.length - 1) * 2) + 1; // Names + coupling symbols ", " + ending coma 270 | 271 | return currentLength > p ? currentLength : p; 272 | }, 0); 273 | const paddedAliasLength = aliasLength > 0 ? aliasLength + 1 : 0; 274 | const nameLength = options.reduce((p, e) => { 275 | const typeLen = getOptionTypeText(e.config).length; 276 | const length = typeLen > 0 ? e.config.name.length + 1 + typeLen : e.config.name.length; 277 | 278 | return length > p ? length : p; 279 | }, 0) + 3; 280 | 281 | const preDescPad = paddedAliasLength + nameLength + 2; 282 | 283 | const data = options.map(({ config: opt }) => 284 | ` ${`${opt.aliases.length ? opt.aliases.join(', ') + ',' : ''}`.padEnd(paddedAliasLength)}${ 285 | `${opt.name}${ 286 | (() => { 287 | const typeText = getOptionTypeText(opt); 288 | return typeText.length ? ' ' + typeText : ''; 289 | })() 290 | }`.padEnd(nameLength) 291 | }${ 292 | (() => { 293 | if (!opt.description?.length) { 294 | return opt.default !== undefined 295 | ? `default: ${JSON.stringify(opt.default)}` 296 | : ''; 297 | } 298 | 299 | const split = opt.description.split('\n'); 300 | const first = split.shift()!; 301 | const def = opt.default !== undefined ? ` (default: ${JSON.stringify(opt.default)})` : ''; 302 | 303 | const final = [first, ...split.map((s) => ''.padEnd(preDescPad) + s)].join('\n') + def; 304 | 305 | return final; 306 | })() 307 | }` 308 | ).join('\n'); 309 | 310 | console.log('\nFlags:'); 311 | console.log(data); 312 | } 313 | 314 | if (subcommands) { 315 | console.log( 316 | `\nUse "${ 317 | cliName ? cliName + ' ' : '' 318 | }${commandName} [command] --help" for more information about a command.\n`, 319 | ); 320 | } 321 | 322 | return true; 323 | } 324 | 325 | case 'global_help': { 326 | const cliName = event.name; 327 | const desc = event.description; 328 | const commands = event.commands.filter((c) => !c.hidden); 329 | const defaultGlobals = [ 330 | { 331 | config: { 332 | name: '--help', 333 | aliases: ['-h'], 334 | type: 'boolean' as OptionType, 335 | description: `help${cliName ? ` for ${cliName}` : ''}`, 336 | default: undefined, 337 | }, 338 | $output: undefined as any as boolean, 339 | }, 340 | { 341 | config: { 342 | name: '--version', 343 | aliases: ['-v'], 344 | type: 'boolean' as OptionType, 345 | description: `version${cliName ? ` for ${cliName}` : ''}`, 346 | default: undefined, 347 | }, 348 | $output: undefined as any as boolean, 349 | }, 350 | ]; 351 | const globals = event.globals 352 | ? [...defaultGlobals, ...Object.values(event.globals)] 353 | : defaultGlobals; 354 | 355 | if (desc !== undefined) { 356 | console.log(`${desc}\n`); 357 | } 358 | 359 | console.log('Usage:'); 360 | console.log(` ${cliName ? cliName + ' ' : ''}[command]`); 361 | 362 | if (commands.length) { 363 | console.log('\nAvailable Commands:'); 364 | const padding = 3; 365 | const maxLength = commands.reduce((p, e) => e.name.length > p ? e.name.length : p, 0); 366 | const paddedLength = maxLength + padding; 367 | 368 | const data = commands.map((c) => 369 | ` ${c.name.padEnd(paddedLength)}${ 370 | (() => { 371 | const desc = c.shortDesc ?? c.desc; 372 | 373 | if (!desc?.length) return ''; 374 | 375 | const split = desc.split('\n'); 376 | const first = split.shift()!; 377 | 378 | const final = [first, ...split.map((s) => ''.padEnd(paddedLength + 2) + s)].join('\n'); 379 | 380 | return final; 381 | })() 382 | }` 383 | ) 384 | .join('\n'); 385 | console.log(data); 386 | } else { 387 | console.log('\nNo available commands.'); 388 | } 389 | 390 | const aliasLength = globals.reduce((p, e) => { 391 | const currentLength = e.config.aliases.reduce((pa, a) => pa + a.length, 0) 392 | + ((e.config.aliases.length - 1) * 2) + 1; // Names + coupling symbols ", " + ending coma 393 | 394 | return currentLength > p ? currentLength : p; 395 | }, 0); 396 | const paddedAliasLength = aliasLength > 0 ? aliasLength + 1 : 0; 397 | const nameLength = globals.reduce((p, e) => { 398 | const typeLen = getOptionTypeText(e.config).length; 399 | const length = typeLen > 0 ? e.config.name.length + 1 + typeLen : e.config.name.length; 400 | 401 | return length > p ? length : p; 402 | }, 0) + 3; 403 | 404 | const preDescPad = paddedAliasLength + nameLength + 2; 405 | const gData = globals.map(({ config: opt }) => 406 | ` ${`${opt.aliases.length ? opt.aliases.join(', ') + ',' : ''}`.padEnd(paddedAliasLength)}${ 407 | `${opt.name}${ 408 | (() => { 409 | const typeText = getOptionTypeText(opt); 410 | return typeText.length ? ' ' + typeText : ''; 411 | })() 412 | }`.padEnd(nameLength) 413 | }${ 414 | (() => { 415 | if (!opt.description?.length) { 416 | return opt.default !== undefined 417 | ? `default: ${JSON.stringify(opt.default)}` 418 | : ''; 419 | } 420 | 421 | const split = opt.description.split('\n'); 422 | const first = split.shift()!; 423 | const def = opt.default !== undefined ? ` (default: ${JSON.stringify(opt.default)})` : ''; 424 | 425 | const final = [first, ...split.map((s) => ''.padEnd(preDescPad) + s)].join('\n') + def; 426 | 427 | return final; 428 | })() 429 | }` 430 | ).join('\n'); 431 | 432 | console.log('\nFlags:'); 433 | console.log(gData); 434 | 435 | return true; 436 | } 437 | 438 | case 'version': { 439 | return true; 440 | } 441 | 442 | case 'error': { 443 | let msg: string; 444 | 445 | switch (event.violation) { 446 | case 'above_max': { 447 | const matchedName = event.offender.namePart; 448 | const data = event.offender.dataPart; 449 | const option = event.option; 450 | 451 | const max = option.maxVal!; 452 | msg = 453 | `Invalid value: number type argument '${matchedName}' expects maximal value of ${max} as an input, got: ${data}`; 454 | 455 | break; 456 | } 457 | 458 | case 'below_min': { 459 | const matchedName = event.offender.namePart; 460 | const data = event.offender.dataPart; 461 | const option = event.option; 462 | 463 | const min = option.minVal; 464 | 465 | msg = 466 | `Invalid value: number type argument '${matchedName}' expects minimal value of ${min} as an input, got: ${data}`; 467 | 468 | break; 469 | } 470 | 471 | case 'expected_int': { 472 | const matchedName = event.offender.namePart; 473 | const data = event.offender.dataPart; 474 | 475 | msg = `Invalid value: number type argument '${matchedName}' expects an integer as an input, got: ${data}`; 476 | 477 | break; 478 | } 479 | 480 | case 'invalid_boolean_syntax': { 481 | const matchedName = event.offender.namePart; 482 | const data = event.offender.dataPart; 483 | 484 | msg = 485 | `Invalid syntax: boolean type argument '${matchedName}' must have it's value passed in the following formats: ${matchedName}= | ${matchedName} | ${matchedName}.\nAllowed values: true, false, 0, 1`; 486 | 487 | break; 488 | } 489 | 490 | case 'invalid_string_syntax': { 491 | const matchedName = event.offender.namePart; 492 | 493 | msg = 494 | `Invalid syntax: string type argument '${matchedName}' must have it's value passed in the following formats: ${matchedName}= | ${matchedName} `; 495 | 496 | break; 497 | } 498 | 499 | case 'invalid_number_syntax': { 500 | const matchedName = event.offender.namePart; 501 | 502 | msg = 503 | `Invalid syntax: number type argument '${matchedName}' must have it's value passed in the following formats: ${matchedName}= | ${matchedName} `; 504 | 505 | break; 506 | } 507 | 508 | case 'invalid_number_value': { 509 | const matchedName = event.offender.namePart; 510 | const data = event.offender.dataPart; 511 | 512 | msg = `Invalid value: number type argument '${matchedName}' expects a number as an input, got: ${data}`; 513 | break; 514 | } 515 | 516 | case 'enum_violation': { 517 | const matchedName = event.offender.namePart; 518 | const data = event.offender.dataPart; 519 | const option = event.option; 520 | 521 | const values = option.enumVals!; 522 | 523 | msg = option.type === 'positional' 524 | ? `Invalid value: value for the positional argument '${option.name}' must be either one of the following: ${ 525 | values.join(', ') 526 | }; Received: ${data}` 527 | : `Invalid value: value for the argument '${matchedName}' must be either one of the following: ${ 528 | values.join(', ') 529 | }; Received: ${data}`; 530 | 531 | break; 532 | } 533 | 534 | case 'unknown_command_error': { 535 | const msg = `Unknown command: '${event.offender}'.\nType '--help' to get help on the cli.`; 536 | 537 | console.error(msg); 538 | 539 | return true; 540 | } 541 | 542 | case 'unknown_subcommand_error': { 543 | const cName = getCommandNameWithParents(event.command); 544 | const msg = 545 | `Unknown command: ${cName} ${event.offender}.\nType '${cName} --help' to get the help on command.`; 546 | 547 | console.error(msg); 548 | 549 | return true; 550 | } 551 | 552 | case 'missing_args_error': { 553 | const { missing: missingOpts, command } = event; 554 | 555 | msg = `Command '${command.name}' is missing following required options: ${ 556 | missingOpts.map((opt) => { 557 | const name = opt.shift()!; 558 | const aliases = opt; 559 | 560 | if (aliases.length) return `${name} [${aliases.join(', ')}]`; 561 | 562 | return name; 563 | }).join(', ') 564 | }`; 565 | 566 | break; 567 | } 568 | 569 | case 'unrecognized_args_error': { 570 | const { command, unrecognized } = event; 571 | msg = `Unrecognized options for command '${command.name}': ${unrecognized.join(', ')}`; 572 | 573 | break; 574 | } 575 | 576 | case 'unknown_error': { 577 | const e = event.error; 578 | console.error(typeof e === 'object' && e !== null && 'message' in e ? e.message : e); 579 | 580 | return true; 581 | } 582 | } 583 | 584 | console.error(msg); 585 | 586 | return true; 587 | } 588 | } 589 | 590 | // @ts-expect-error 591 | return false; 592 | }; 593 | 594 | export const eventHandlerWrapper = (customEventHandler: EventHandler) => async (event: BroCliEvent) => 595 | await customEventHandler(event) ? true : await defaultEventHandler(event); 596 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { BroCliError } from './brocli-error'; 2 | export type { 3 | AnyRawCommand, 4 | BroCliConfig, 5 | Command, 6 | CommandCandidate, 7 | CommandHandler, 8 | CommandInfo, 9 | CommandsInfo, 10 | EventType, 11 | GenericCommandHandler, 12 | InnerCommandParseRes, 13 | RawCommand, 14 | TestResult, 15 | } from './command-core'; 16 | export { command, commandsInfo, getCommandNameWithParents, handler, run, test } from './command-core'; 17 | export type { 18 | BroCliEvent, 19 | BroCliEventType, 20 | CommandHelpEvent, 21 | EventHandler, 22 | GenericValidationViolation, 23 | GlobalHelpEvent, 24 | MissingArgsEvent, 25 | UnknownCommandEvent, 26 | UnknownErrorEvent, 27 | UnknownSubcommandEvent, 28 | UnrecognizedArgsEvent, 29 | ValidationErrorEvent, 30 | ValidationViolation, 31 | VersionEvent, 32 | } from './event-handler'; 33 | export type { 34 | BuilderConfig, 35 | BuilderConfigLimited, 36 | GenericBuilderInternals, 37 | GenericBuilderInternalsFields, 38 | GenericBuilderInternalsFieldsLimited, 39 | GenericBuilderInternalsLimited, 40 | OptionBuilderBase, 41 | OptionType, 42 | OutputType, 43 | ProcessedBuilderConfig, 44 | ProcessedOptions, 45 | Simplify, 46 | TypeOf, 47 | } from './option-builder'; 48 | export { boolean, number, positional, string } from './option-builder'; 49 | -------------------------------------------------------------------------------- /src/option-builder.ts: -------------------------------------------------------------------------------- 1 | import { BroCliError } from './brocli-error'; 2 | 3 | export type OptionType = 'string' | 'boolean' | 'number' | 'positional'; 4 | 5 | export type OutputType = string | boolean | number | undefined; 6 | 7 | export type BuilderConfig = { 8 | name?: string | undefined; 9 | aliases: string[]; 10 | type: TType; 11 | description?: string; 12 | default?: OutputType; 13 | isHidden?: boolean; 14 | isRequired?: boolean; 15 | isInt?: boolean; 16 | minVal?: number; 17 | maxVal?: number; 18 | enumVals?: [string, ...string[]]; 19 | }; 20 | 21 | export type ProcessedBuilderConfig = { 22 | name: string; 23 | aliases: string[]; 24 | type: OptionType; 25 | description?: string; 26 | default?: OutputType; 27 | isHidden?: boolean; 28 | isRequired?: boolean; 29 | isInt?: boolean; 30 | minVal?: number; 31 | maxVal?: number; 32 | enumVals?: [string, ...string[]]; 33 | }; 34 | 35 | export type BuilderConfigLimited = BuilderConfig & { 36 | type: Exclude; 37 | }; 38 | 39 | export class OptionBuilderBase< 40 | TBuilderConfig extends BuilderConfig = BuilderConfig, 41 | TOutput extends OutputType = string, 42 | TOmit extends string = '', 43 | TEnums extends string | undefined = undefined, 44 | > { 45 | public _: { 46 | config: TBuilderConfig; 47 | /** 48 | * Type-level only field 49 | * 50 | * Do not attempt to access at a runtime 51 | */ 52 | $output: TOutput; 53 | }; 54 | 55 | private config = (): TBuilderConfig => this._.config; 56 | 57 | constructor(config?: TBuilderConfig) { 58 | this._ = { 59 | config: config ?? { 60 | aliases: [], 61 | type: 'string', 62 | } as unknown as TBuilderConfig, 63 | $output: undefined as any as TOutput, 64 | }; 65 | } 66 | 67 | public string(name: TName): Omit< 68 | OptionBuilderBase< 69 | BuilderConfig<'string'>, 70 | string | undefined, 71 | TOmit | OptionType | 'min' | 'max' | 'int' 72 | >, 73 | TOmit | OptionType | 'min' | 'max' | 'int' 74 | >; 75 | public string(): Omit< 76 | OptionBuilderBase< 77 | BuilderConfig<'string'>, 78 | string | undefined, 79 | TOmit | OptionType | 'min' | 'max' | 'int' 80 | >, 81 | TOmit | OptionType | 'min' | 'max' | 'int' 82 | >; 83 | public string( 84 | name?: string, 85 | ) { 86 | const config = this.config(); 87 | 88 | return new OptionBuilderBase({ ...config, type: 'string', name: name }) as any; 89 | } 90 | 91 | public number(name: TName): Omit< 92 | OptionBuilderBase< 93 | BuilderConfig<'number'>, 94 | number | undefined, 95 | TOmit | OptionType | 'enum' 96 | >, 97 | TOmit | OptionType | 'enum' 98 | >; 99 | public number(): Omit< 100 | OptionBuilderBase< 101 | BuilderConfig<'number'>, 102 | string | undefined, 103 | TOmit | OptionType | 'enum' 104 | >, 105 | TOmit | OptionType | 'enum' 106 | >; 107 | public number( 108 | name?: string, 109 | ) { 110 | const config = this.config(); 111 | 112 | return new OptionBuilderBase({ ...config, type: 'number', name: name }) as any; 113 | } 114 | 115 | public boolean(name: TName): Omit< 116 | OptionBuilderBase< 117 | BuilderConfig<'boolean'>, 118 | boolean | undefined, 119 | TOmit | OptionType | 'min' | 'max' | 'enum' | 'int' 120 | >, 121 | TOmit | OptionType | 'min' | 'max' | 'enum' | 'int' 122 | >; 123 | public boolean(): Omit< 124 | OptionBuilderBase< 125 | BuilderConfig<'boolean'>, 126 | boolean | undefined, 127 | TOmit | OptionType | 'min' | 'max' | 'enum' | 'int' 128 | >, 129 | TOmit | OptionType | 'min' | 'max' | 'enum' | 'int' 130 | >; 131 | public boolean( 132 | name?: string, 133 | ) { 134 | const config = this.config(); 135 | 136 | return new OptionBuilderBase({ ...config, type: 'boolean', name: name }) as any; 137 | } 138 | 139 | public positional(displayName: TName): Omit< 140 | OptionBuilderBase< 141 | BuilderConfig<'positional'>, 142 | string | undefined, 143 | TOmit | OptionType | 'min' | 'max' | 'int' | 'alias' 144 | >, 145 | TOmit | OptionType | 'min' | 'max' | 'int' | 'alias' 146 | >; 147 | public positional(): Omit< 148 | OptionBuilderBase< 149 | BuilderConfig<'positional'>, 150 | string | undefined, 151 | TOmit | OptionType | 'min' | 'max' | 'int' | 'alias' 152 | >, 153 | TOmit | OptionType | 'min' | 'max' | 'int' | 'alias' 154 | >; 155 | public positional(displayName?: string) { 156 | const config = this.config(); 157 | 158 | return new OptionBuilderBase({ ...config, type: 'positional', name: displayName }) as any; 159 | } 160 | 161 | public alias( 162 | ...aliases: string[] 163 | ): Omit< 164 | OptionBuilderBase< 165 | TBuilderConfig, 166 | TOutput, 167 | TOmit | 'alias' 168 | >, 169 | TOmit | 'alias' 170 | > { 171 | const config = this.config(); 172 | 173 | return new OptionBuilderBase({ ...config, aliases }) as any; 174 | } 175 | 176 | public desc(description: TDescription): Omit< 177 | OptionBuilderBase< 178 | TBuilderConfig, 179 | TOutput, 180 | TOmit | 'desc' 181 | >, 182 | TOmit | 'desc' 183 | > { 184 | const config = this.config(); 185 | 186 | return new OptionBuilderBase({ ...config, description }) as any; 187 | } 188 | 189 | public hidden(): Omit< 190 | OptionBuilderBase< 191 | TBuilderConfig, 192 | TOutput, 193 | TOmit | 'hidden' 194 | >, 195 | TOmit | 'hidden' 196 | > { 197 | const config = this.config(); 198 | 199 | return new OptionBuilderBase({ ...config, isHidden: true }) as any; 200 | } 201 | 202 | public required(): Omit< 203 | OptionBuilderBase< 204 | TBuilderConfig, 205 | Exclude, 206 | TOmit | 'required' | 'default' 207 | >, 208 | TOmit | 'required' | 'default' 209 | > { 210 | const config = this.config(); 211 | 212 | return new OptionBuilderBase({ ...config, isRequired: true }) as any; 213 | } 214 | 215 | public default : TEnums>(value: TDefVal): Omit< 216 | OptionBuilderBase< 217 | TBuilderConfig, 218 | Exclude, 219 | TOmit | 'enum' | 'required' | 'default', 220 | TEnums 221 | >, 222 | TOmit | 'enum' | 'required' | 'default' 223 | > { 224 | const config = this.config(); 225 | 226 | const enums = config.enumVals; 227 | if (enums && !enums.find((v) => value === v)) { 228 | throw new Error( 229 | `Option enums [ ${enums.join(', ')} ] are incompatible with default value ${value}`, 230 | ); 231 | } 232 | 233 | return new OptionBuilderBase({ ...config, default: value }) as any; 234 | } 235 | 236 | public enum( 237 | ...values: TValues 238 | ): Omit< 239 | OptionBuilderBase< 240 | TBuilderConfig, 241 | TUnion | (TOutput extends undefined ? undefined : never), 242 | TOmit | 'enum', 243 | TUnion 244 | >, 245 | TOmit | 'enum' 246 | > { 247 | const config = this.config(); 248 | 249 | const defaultVal = config.default; 250 | if (defaultVal !== undefined && !values.find((v) => defaultVal === v)) { 251 | throw new Error( 252 | `Option enums [ ${values.join(', ')} ] are incompatible with default value ${defaultVal}`, 253 | ); 254 | } 255 | 256 | return new OptionBuilderBase({ ...config, enumVals: values }) as any; 257 | } 258 | 259 | public min(value: number): Omit< 260 | OptionBuilderBase< 261 | TBuilderConfig, 262 | TOutput, 263 | TOmit | 'min' 264 | >, 265 | TOmit | 'min' 266 | > { 267 | const config = this.config(); 268 | 269 | const maxVal = config.maxVal; 270 | if (maxVal !== undefined && maxVal < value) { 271 | throw new BroCliError("Unable to define option's min value to be higher than max value!"); 272 | } 273 | 274 | return new OptionBuilderBase({ ...config, minVal: value }) as any; 275 | } 276 | 277 | public max(value: number): Omit< 278 | OptionBuilderBase< 279 | TBuilderConfig, 280 | TOutput, 281 | TOmit | 'max' 282 | >, 283 | TOmit | 'max' 284 | > { 285 | const config = this.config(); 286 | 287 | const minVal = config.minVal; 288 | if (minVal !== undefined && minVal > value) { 289 | throw new BroCliError("Unable to define option's max value to be lower than min value!"); 290 | } 291 | 292 | return new OptionBuilderBase({ ...config, maxVal: value }) as any; 293 | } 294 | 295 | public int(): Omit< 296 | OptionBuilderBase< 297 | TBuilderConfig, 298 | TOutput, 299 | TOmit | 'int' 300 | >, 301 | TOmit | 'int' 302 | > { 303 | const config = this.config(); 304 | 305 | return new OptionBuilderBase({ ...config, isInt: true }) as any; 306 | } 307 | } 308 | 309 | export type GenericBuilderInternalsFields = { 310 | /** 311 | * Type-level only field 312 | * 313 | * Do not attempt to access at a runtime 314 | */ 315 | $output: OutputType; 316 | config: BuilderConfig; 317 | }; 318 | 319 | export type GenericBuilderInternals = { 320 | _: GenericBuilderInternalsFields; 321 | }; 322 | 323 | export type GenericBuilderInternalsFieldsLimited = { 324 | /** 325 | * Type-level only field 326 | * 327 | * Do not attempt to access at a runtime 328 | */ 329 | $output: OutputType; 330 | config: BuilderConfigLimited; 331 | }; 332 | 333 | export type GenericBuilderInternalsLimited = { 334 | _: GenericBuilderInternalsFieldsLimited; 335 | }; 336 | 337 | export type ProcessedOptions< 338 | TOptionConfig extends Record = Record, 339 | > = { 340 | [K in keyof TOptionConfig]: K extends string ? { 341 | config: ProcessedBuilderConfig; 342 | /** 343 | * Type-level only field 344 | * 345 | * Do not attempt to access at a runtime 346 | */ 347 | $output: TOptionConfig[K]['_']['$output']; 348 | } 349 | : never; 350 | }; 351 | 352 | export type Simplify = 353 | & { 354 | [K in keyof T]: T[K]; 355 | } 356 | & {}; 357 | 358 | export type TypeOf> = Simplify< 359 | { 360 | [K in keyof TOptions]: TOptions[K]['_']['$output']; 361 | } 362 | >; 363 | 364 | export function string( 365 | name: TName, 366 | ): Omit< 367 | OptionBuilderBase< 368 | BuilderConfig<'string'>, 369 | string | undefined, 370 | OptionType | 'min' | 'max' | 'int' 371 | >, 372 | OptionType | 'min' | 'max' | 'int' 373 | >; 374 | export function string(): Omit< 375 | OptionBuilderBase< 376 | BuilderConfig<'string'>, 377 | string | undefined, 378 | OptionType | 'min' | 'max' | 'int' 379 | >, 380 | OptionType | 'min' | 'max' | 'int' 381 | >; 382 | export function string(name?: TName) { 383 | return typeof name === 'string' ? new OptionBuilderBase().string(name) : new OptionBuilderBase().string(); 384 | } 385 | 386 | export function number( 387 | name: TName, 388 | ): Omit< 389 | OptionBuilderBase< 390 | BuilderConfig<'number'>, 391 | number | undefined, 392 | OptionType | 'enum' 393 | >, 394 | OptionType | 'enum' 395 | >; 396 | export function number(): Omit< 397 | OptionBuilderBase< 398 | BuilderConfig<'number'>, 399 | number | undefined, 400 | OptionType | 'enum' 401 | >, 402 | OptionType | 'enum' 403 | >; 404 | export function number(name?: TName) { 405 | return typeof name === 'string' ? new OptionBuilderBase().number(name) : new OptionBuilderBase().number(); 406 | } 407 | 408 | export function boolean( 409 | name: TName, 410 | ): Omit< 411 | OptionBuilderBase< 412 | BuilderConfig<'boolean'>, 413 | boolean | undefined, 414 | OptionType | 'min' | 'max' | 'int' | 'enum' 415 | >, 416 | OptionType | 'min' | 'max' | 'int' | 'enum' 417 | >; 418 | export function boolean(): Omit< 419 | OptionBuilderBase< 420 | BuilderConfig<'boolean'>, 421 | boolean | undefined, 422 | OptionType | 'min' | 'max' | 'int' | 'enum' 423 | >, 424 | OptionType | 'min' | 'max' | 'int' | 'enum' 425 | >; 426 | export function boolean(name?: TName) { 427 | return typeof name === 'string' ? new OptionBuilderBase().boolean(name) : new OptionBuilderBase().boolean(); 428 | } 429 | 430 | export function positional(displayName: TName): Omit< 431 | OptionBuilderBase< 432 | BuilderConfig<'positional'>, 433 | string | undefined, 434 | OptionType | 'min' | 'max' | 'int' | 'alias' 435 | >, 436 | OptionType | 'min' | 'max' | 'int' | 'alias' 437 | >; 438 | export function positional(): Omit< 439 | OptionBuilderBase< 440 | BuilderConfig<'positional'>, 441 | string | undefined, 442 | OptionType | 'min' | 'max' | 'int' | 'alias' 443 | >, 444 | OptionType | 'min' | 'max' | 'int' | 'alias' 445 | >; 446 | export function positional(displayName?: string) { 447 | return typeof displayName === 'string' 448 | ? new OptionBuilderBase().positional(displayName) 449 | : new OptionBuilderBase().positional(); 450 | } 451 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { parse as parseQuotes } from 'shell-quote'; 2 | 3 | export function isInt(value: number) { 4 | return value === Math.floor(value); 5 | } 6 | 7 | export const shellArgs = (str: string) => parseQuotes(str).map((e) => e.toString()); 8 | 9 | export const executeOrLog = async (target?: string | Function) => 10 | typeof target === 'string' ? console.log(target) : target ? await target() : undefined; 11 | -------------------------------------------------------------------------------- /src/validation-error.ts: -------------------------------------------------------------------------------- 1 | import type { BroCliEvent } from './event-handler'; 2 | 3 | export class BroCliValidationError extends Error { 4 | constructor(public event: BroCliEvent, message?: string) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/main.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | boolean, 3 | BroCliError, 4 | BroCliEvent, 5 | type BroCliEventType, 6 | type Command, 7 | command, 8 | type EventHandler, 9 | EventType, 10 | handler, 11 | number, 12 | positional, 13 | run, 14 | string, 15 | type TypeOf, 16 | } from '@/index'; 17 | import { shellArgs } from '@/util'; 18 | import { describe, expect, expectTypeOf, Mock, vi } from 'vitest'; 19 | 20 | const getArgs = (str: string) => [process.argv[0]!, process.argv[1]!, ...shellArgs(str)]; 21 | 22 | const eventMocks: Record> = { 23 | command_help: vi.fn(), 24 | global_help: vi.fn(), 25 | error: vi.fn(), 26 | version: vi.fn(), 27 | }; 28 | 29 | const hookMocks: Record> = { 30 | before: vi.fn(), 31 | after: vi.fn(), 32 | }; 33 | 34 | const testTheme: EventHandler = (event) => { 35 | eventMocks[event.type](event); 36 | 37 | return true; 38 | }; 39 | 40 | const handlers = { 41 | generate: vi.fn(), 42 | cFirst: vi.fn(), 43 | cSecond: vi.fn(), 44 | sub: vi.fn(), 45 | deep: vi.fn(), 46 | }; 47 | 48 | const commands: Command[] = []; 49 | 50 | const generateOps = { 51 | dialect: string().alias('-d', '-dlc').enum('pg', 'sqlite', 'mysql').desc('Database dialect [pg, mysql, sqlite]') 52 | .required(), 53 | schema: string('schema').alias('s').desc('Path to a schema file or folder'), 54 | out: string().alias('o').desc("Output folder, 'drizzle' by default"), 55 | name: string().alias('n').desc('Migration file name'), 56 | breakpoints: string('breakpoints').alias('break').desc(`Prepare SQL statements with breakpoints`), 57 | custom: string('custom').alias('cus').desc('Prepare empty migration file for custom SQL'), 58 | config: string().alias('c', 'cfg').desc('Path to a config.json file, drizzle.config.ts by default').default( 59 | './drizzle-kit.config.ts', 60 | ), 61 | flag: boolean().alias('f').desc('Example boolean field'), 62 | defFlag: boolean().alias('-def').desc('Example boolean field with default').default(true), 63 | defString: string().alias('-ds').desc('Example string field with default').default('Defaultvalue'), 64 | debug: boolean('dbg').alias('g').hidden(), 65 | num: number().alias('-nb').min(-10).max(10), 66 | int: number().alias('i').int(), 67 | pos: positional('Positional'), 68 | enpos: positional('Enum positional').enum('first', 'second', 'third'), 69 | }; 70 | 71 | const generate = command({ 72 | name: 'generate', 73 | aliases: ['g', 'gen'], 74 | desc: 'Generate drizzle migrations', 75 | shortDesc: 'Generate migrations', 76 | hidden: false, 77 | options: generateOps, 78 | handler: handlers.generate, 79 | }); 80 | 81 | commands.push(generate); 82 | 83 | const cFirstOps = { 84 | flag: boolean().alias('f', 'fl').desc('Boolean value'), 85 | string: string().alias('s', 'str').desc('String value'), 86 | sFlag: boolean('stealth').alias('j', 'hb').desc('Boolean value').hidden(), 87 | sString: string('sstring').alias('q', 'hs').desc('String value').hidden(), 88 | }; 89 | 90 | const cSubOps = { 91 | flag: boolean().alias('f', 'fl').desc('Boolean value'), 92 | string: string().alias('s', 'str').desc('String value'), 93 | pos: positional(), 94 | }; 95 | 96 | const cFirst = command({ 97 | name: 'c-first', 98 | options: cFirstOps, 99 | handler: handlers.cFirst, 100 | hidden: false, 101 | subcommands: [ 102 | command({ 103 | name: 'sub', 104 | options: cSubOps, 105 | handler: handlers.sub, 106 | }), 107 | command({ 108 | name: 'nohandler', 109 | subcommands: [command({ 110 | name: 'deep', 111 | handler: handlers.deep, 112 | })], 113 | }), 114 | ], 115 | }); 116 | 117 | commands.push(cFirst); 118 | 119 | const cSecondOps = { 120 | flag: boolean().alias('f', 'fl').desc('Boolean value'), 121 | string: string().alias('s', 'str').desc('String value'), 122 | sFlag: boolean('stealth').alias('j', 'hb').desc('Boolean value').hidden(), 123 | sString: string('sstring').alias('q', 'hs').desc('String value').hidden(), 124 | }; 125 | 126 | const cSecond = command({ 127 | name: 'c-second', 128 | options: cSecondOps, 129 | handler: handlers.cSecond, 130 | hidden: false, 131 | }); 132 | 133 | commands.push(cSecond); 134 | 135 | describe('Parsing tests', (it) => { 136 | it('Required options & defaults', async () => { 137 | await run(commands, { 138 | argSource: getArgs('generate --dialect=pg'), 139 | theme: testTheme, 140 | // @ts-expect-error 141 | noExit: true, 142 | }); 143 | 144 | expect(handlers.generate.mock.lastCall).toStrictEqual([{ 145 | dialect: 'pg', 146 | schema: undefined, 147 | out: undefined, 148 | name: undefined, 149 | breakpoints: undefined, 150 | custom: undefined, 151 | config: './drizzle-kit.config.ts', 152 | flag: undefined, 153 | defFlag: true, 154 | defString: 'Defaultvalue', 155 | debug: undefined, 156 | num: undefined, 157 | int: undefined, 158 | pos: undefined, 159 | enpos: undefined, 160 | }]); 161 | }); 162 | 163 | it('All options by name', async () => { 164 | await run( 165 | commands, 166 | { 167 | argSource: getArgs( 168 | 'generate --dialect pg --schema=./schemapath.ts --out=./outfile.ts --name="Example migration" --breakpoints=breakpoints --custom="custom value" --flag --defFlag false --dbg=true --num 5.5 --int=2 posval second', 169 | ), 170 | theme: testTheme, 171 | // @ts-expect-error 172 | noExit: true, 173 | }, 174 | ); 175 | 176 | expect(handlers.generate.mock.lastCall).toStrictEqual([{ 177 | dialect: 'pg', 178 | schema: './schemapath.ts', 179 | out: './outfile.ts', 180 | name: 'Example migration', 181 | breakpoints: 'breakpoints', 182 | custom: 'custom value', 183 | config: './drizzle-kit.config.ts', 184 | flag: true, 185 | defFlag: false, 186 | defString: 'Defaultvalue', 187 | debug: true, 188 | num: 5.5, 189 | int: 2, 190 | pos: 'posval', 191 | enpos: 'second', 192 | }]); 193 | }); 194 | 195 | it('All options by alias', async () => { 196 | await run( 197 | commands, 198 | { 199 | argSource: getArgs( 200 | 'generate -dlc pg -s=./schemapath.ts -o=./outfile.ts -n="Example migration" --break=breakpoints --cus="custom value" -f -def false -ds=Not=Default=Value -g=true -nb=5.5 -i=2 posval second', 201 | ), 202 | theme: testTheme, 203 | // @ts-expect-error 204 | noExit: true, 205 | }, 206 | ); 207 | 208 | expect(handlers.generate.mock.lastCall).toStrictEqual([{ 209 | dialect: 'pg', 210 | schema: './schemapath.ts', 211 | out: './outfile.ts', 212 | name: 'Example migration', 213 | breakpoints: 'breakpoints', 214 | custom: 'custom value', 215 | config: './drizzle-kit.config.ts', 216 | flag: true, 217 | defFlag: false, 218 | defString: 'Not=Default=Value', 219 | debug: true, 220 | num: 5.5, 221 | int: 2, 222 | pos: 'posval', 223 | enpos: 'second', 224 | }]); 225 | }); 226 | 227 | it('Missing required options', async () => { 228 | await run(commands, { 229 | argSource: getArgs('generate'), 230 | theme: testTheme, 231 | // @ts-expect-error 232 | noExit: true, 233 | }); 234 | 235 | expect(eventMocks.error.mock.lastCall).toStrictEqual([{ 236 | type: 'error', 237 | violation: 'missing_args_error', 238 | name: undefined, 239 | description: undefined, 240 | command: generate, 241 | missing: [['--dialect', '-d', '-dlc']], 242 | }] as BroCliEvent[]); 243 | }); 244 | 245 | it('Unrecognized options', async () => { 246 | await run(commands, { 247 | argSource: getArgs('generate --dialect=pg --unknown-one -m'), 248 | theme: testTheme, 249 | // @ts-expect-error 250 | noExit: true, 251 | }); 252 | 253 | expect(eventMocks.error.mock.lastCall).toStrictEqual([{ 254 | type: 'error', 255 | violation: 'unrecognized_args_error', 256 | name: undefined, 257 | description: undefined, 258 | command: generate, 259 | unrecognized: ['--unknown-one'], 260 | }] as BroCliEvent[]); 261 | }); 262 | 263 | it('Wrong type: string to boolean', async () => { 264 | await run(commands, { 265 | argSource: getArgs('generate --dialect=pg -def=somevalue'), 266 | theme: testTheme, 267 | // @ts-expect-error 268 | noExit: true, 269 | }); 270 | 271 | expect(eventMocks.error.mock.lastCall).toStrictEqual([{ 272 | type: 'error', 273 | violation: 'invalid_boolean_syntax', 274 | name: undefined, 275 | description: undefined, 276 | command: generate, 277 | option: generate.options!['defFlag']!.config, 278 | offender: { 279 | namePart: '-def', 280 | dataPart: 'somevalue', 281 | }, 282 | }] as BroCliEvent[]); 283 | }); 284 | 285 | it('Wrong type: boolean to string', async () => { 286 | await run(commands, { 287 | argSource: getArgs('generate --dialect=pg -ds'), 288 | theme: testTheme, 289 | // @ts-expect-error 290 | noExit: true, 291 | }); 292 | 293 | expect(eventMocks.error.mock.lastCall).toStrictEqual([{ 294 | type: 'error', 295 | name: undefined, 296 | description: undefined, 297 | violation: 'invalid_string_syntax', 298 | command: generate, 299 | option: generate.options!['defString']!.config, 300 | offender: { 301 | namePart: '-ds', 302 | dataPart: undefined, 303 | }, 304 | }] as BroCliEvent[]); 305 | }); 306 | 307 | it('Wrong type: string to number', async () => { 308 | await run(commands, { 309 | argSource: getArgs('generate --dialect=pg -nb string'), 310 | theme: testTheme, 311 | // @ts-expect-error 312 | noExit: true, 313 | }); 314 | 315 | expect(eventMocks.error.mock.lastCall).toStrictEqual([{ 316 | type: 'error', 317 | name: undefined, 318 | description: undefined, 319 | violation: 'invalid_number_value', 320 | command: generate, 321 | option: generate.options!['num']!.config, 322 | offender: { 323 | namePart: '-nb', 324 | dataPart: 'string', 325 | }, 326 | }] as BroCliEvent[]); 327 | }); 328 | 329 | it('Enum violation', async () => { 330 | await run(commands, { 331 | argSource: getArgs('generate --dialect=wrong'), 332 | theme: testTheme, 333 | // @ts-expect-error 334 | noExit: true, 335 | }); 336 | 337 | expect(eventMocks.error.mock.lastCall).toStrictEqual([{ 338 | type: 'error', 339 | name: undefined, 340 | description: undefined, 341 | violation: 'enum_violation', 342 | command: generate, 343 | option: generate.options!['dialect']!.config, 344 | offender: { 345 | namePart: '--dialect', 346 | dataPart: 'wrong', 347 | }, 348 | }] as BroCliEvent[]); 349 | }); 350 | 351 | it('Positional enum violation', async () => { 352 | await run(commands, { 353 | argSource: getArgs('generate --dialect=pg someval wrongval'), 354 | theme: testTheme, 355 | // @ts-expect-error 356 | noExit: true, 357 | }); 358 | 359 | expect(eventMocks.error.mock.lastCall).toStrictEqual([{ 360 | type: 'error', 361 | name: undefined, 362 | description: undefined, 363 | violation: 'enum_violation', 364 | command: generate, 365 | option: generate.options!['enpos']!.config, 366 | offender: { 367 | dataPart: 'wrongval', 368 | }, 369 | }] as BroCliEvent[]); 370 | }); 371 | 372 | it('Min value violation', async () => { 373 | await run(commands, { 374 | argSource: getArgs('generate --dialect=pg -nb -20'), 375 | theme: testTheme, 376 | // @ts-expect-error 377 | noExit: true, 378 | }); 379 | 380 | expect(eventMocks.error.mock.lastCall).toStrictEqual([{ 381 | type: 'error', 382 | name: undefined, 383 | description: undefined, 384 | violation: 'below_min', 385 | command: generate, 386 | option: generate.options!['num']!.config, 387 | offender: { 388 | namePart: '-nb', 389 | dataPart: '-20', 390 | }, 391 | }] as BroCliEvent[]); 392 | }); 393 | 394 | it('Max value violation', async () => { 395 | await run(commands, { 396 | argSource: getArgs('generate --dialect=pg -nb 20'), 397 | theme: testTheme, 398 | // @ts-expect-error 399 | noExit: true, 400 | }); 401 | 402 | expect(eventMocks.error.mock.lastCall).toStrictEqual([{ 403 | type: 'error', 404 | name: undefined, 405 | description: undefined, 406 | violation: 'above_max', 407 | command: generate, 408 | option: generate.options!['num']!.config, 409 | offender: { 410 | namePart: '-nb', 411 | dataPart: '20', 412 | }, 413 | }] as BroCliEvent[]); 414 | }); 415 | 416 | it('Int violation', async () => { 417 | await run(commands, { 418 | argSource: getArgs('generate --dialect=pg -i 20.5'), 419 | theme: testTheme, 420 | // @ts-expect-error 421 | noExit: true, 422 | }); 423 | 424 | expect(eventMocks.error.mock.lastCall).toStrictEqual([{ 425 | type: 'error', 426 | name: undefined, 427 | description: undefined, 428 | violation: 'expected_int', 429 | command: generate, 430 | option: generate.options!['int']!.config, 431 | offender: { 432 | namePart: '-i', 433 | dataPart: '20.5', 434 | }, 435 | }] as BroCliEvent[]); 436 | }); 437 | 438 | it('Positional in order', async () => { 439 | await run(commands, { 440 | argSource: getArgs('generate posval --dialect=pg'), 441 | theme: testTheme, 442 | // @ts-expect-error 443 | noExit: true, 444 | }); 445 | 446 | expect(handlers.generate.mock.lastCall).toStrictEqual([{ 447 | dialect: 'pg', 448 | schema: undefined, 449 | out: undefined, 450 | name: undefined, 451 | breakpoints: undefined, 452 | custom: undefined, 453 | config: './drizzle-kit.config.ts', 454 | flag: undefined, 455 | defFlag: true, 456 | defString: 'Defaultvalue', 457 | debug: undefined, 458 | num: undefined, 459 | int: undefined, 460 | pos: 'posval', 461 | enpos: undefined, 462 | }]); 463 | }); 464 | 465 | it('Positional after flag', async () => { 466 | await run(commands, { 467 | argSource: getArgs('generate -f true posval --dialect=pg'), 468 | theme: testTheme, 469 | // @ts-expect-error 470 | noExit: true, 471 | }); 472 | 473 | expect(handlers.generate.mock.lastCall).toStrictEqual([{ 474 | dialect: 'pg', 475 | schema: undefined, 476 | out: undefined, 477 | name: undefined, 478 | breakpoints: undefined, 479 | custom: undefined, 480 | config: './drizzle-kit.config.ts', 481 | flag: true, 482 | defFlag: true, 483 | defString: 'Defaultvalue', 484 | debug: undefined, 485 | num: undefined, 486 | int: undefined, 487 | pos: 'posval', 488 | enpos: undefined, 489 | }]); 490 | }); 491 | 492 | it('Positional after flag set by "="', async () => { 493 | await run(commands, { 494 | argSource: getArgs('generate -f=true posval --dialect=pg'), 495 | theme: testTheme, 496 | // @ts-expect-error 497 | noExit: true, 498 | }); 499 | 500 | expect(handlers.generate.mock.lastCall).toStrictEqual([{ 501 | dialect: 'pg', 502 | schema: undefined, 503 | out: undefined, 504 | name: undefined, 505 | breakpoints: undefined, 506 | custom: undefined, 507 | config: './drizzle-kit.config.ts', 508 | flag: true, 509 | defFlag: true, 510 | defString: 'Defaultvalue', 511 | debug: undefined, 512 | num: undefined, 513 | int: undefined, 514 | pos: 'posval', 515 | enpos: undefined, 516 | }]); 517 | }); 518 | 519 | it('Positional after valueless flag', async () => { 520 | await run(commands, { 521 | argSource: getArgs('generate -f posval --dialect=pg'), 522 | theme: testTheme, 523 | // @ts-expect-error 524 | noExit: true, 525 | }); 526 | 527 | expect(handlers.generate.mock.lastCall).toStrictEqual([{ 528 | dialect: 'pg', 529 | schema: undefined, 530 | out: undefined, 531 | name: undefined, 532 | breakpoints: undefined, 533 | custom: undefined, 534 | config: './drizzle-kit.config.ts', 535 | flag: true, 536 | defFlag: true, 537 | defString: 'Defaultvalue', 538 | debug: undefined, 539 | num: undefined, 540 | int: undefined, 541 | pos: 'posval', 542 | enpos: undefined, 543 | }]); 544 | }); 545 | 546 | it('Positional after string', async () => { 547 | await run(commands, { 548 | argSource: getArgs('generate --dialect pg posval'), 549 | theme: testTheme, 550 | // @ts-expect-error 551 | noExit: true, 552 | }); 553 | 554 | expect(handlers.generate.mock.lastCall).toStrictEqual([{ 555 | dialect: 'pg', 556 | schema: undefined, 557 | out: undefined, 558 | name: undefined, 559 | breakpoints: undefined, 560 | custom: undefined, 561 | config: './drizzle-kit.config.ts', 562 | flag: undefined, 563 | defFlag: true, 564 | defString: 'Defaultvalue', 565 | debug: undefined, 566 | num: undefined, 567 | int: undefined, 568 | pos: 'posval', 569 | enpos: undefined, 570 | }]); 571 | }); 572 | 573 | it('Positional after string set by "="', async () => { 574 | await run(commands, { 575 | argSource: getArgs('generate --dialect=pg posval'), 576 | theme: testTheme, 577 | // @ts-expect-error 578 | noExit: true, 579 | }); 580 | 581 | expect(handlers.generate.mock.lastCall).toStrictEqual([{ 582 | dialect: 'pg', 583 | schema: undefined, 584 | out: undefined, 585 | name: undefined, 586 | breakpoints: undefined, 587 | custom: undefined, 588 | config: './drizzle-kit.config.ts', 589 | flag: undefined, 590 | defFlag: true, 591 | defString: 'Defaultvalue', 592 | debug: undefined, 593 | num: undefined, 594 | int: undefined, 595 | pos: 'posval', 596 | enpos: undefined, 597 | }]); 598 | }); 599 | 600 | it('Get the right command, no args', async () => { 601 | await run(commands, { 602 | argSource: getArgs('c-first'), 603 | theme: testTheme, 604 | // @ts-expect-error 605 | noExit: true, 606 | }); 607 | 608 | expect(handlers.cFirst.mock.lastCall).toStrictEqual([{ 609 | flag: undefined, 610 | string: undefined, 611 | sFlag: undefined, 612 | sString: undefined, 613 | }]); 614 | }); 615 | 616 | it('Get the right command, command before args', async () => { 617 | await run(commands, { 618 | argSource: getArgs('c-second --flag --string=strval --stealth --sstring="Hidden string"'), 619 | }); 620 | 621 | expect(handlers.cSecond.mock.lastCall).toStrictEqual([{ 622 | flag: true, 623 | string: 'strval', 624 | sFlag: true, 625 | sString: 'Hidden string', 626 | }]); 627 | }); 628 | 629 | it('Get the right command, command between args', async () => { 630 | await run(commands, { 631 | argSource: getArgs('--flag --string=strval c-second --stealth --sstring="Hidden string"'), 632 | theme: testTheme, 633 | // @ts-expect-error 634 | noExit: true, 635 | }); 636 | 637 | expect(handlers.cSecond.mock.lastCall).toStrictEqual([{ 638 | flag: true, 639 | string: 'strval', 640 | sFlag: true, 641 | sString: 'Hidden string', 642 | }]); 643 | }); 644 | 645 | it('Get the right command, command after args', async () => { 646 | await run(commands, { 647 | argSource: getArgs('--flag --string=strval --stealth --sstring="Hidden string" c-second'), 648 | theme: testTheme, 649 | // @ts-expect-error 650 | noExit: true, 651 | }); 652 | 653 | expect(handlers.cSecond.mock.lastCall).toStrictEqual([{ 654 | flag: true, 655 | string: 'strval', 656 | sFlag: true, 657 | sString: 'Hidden string', 658 | }]); 659 | }); 660 | 661 | it('Unknown command', async () => { 662 | await run(commands, { 663 | argSource: getArgs('unknown --somearg=somevalue -f'), 664 | theme: testTheme, 665 | // @ts-expect-error 666 | noExit: true, 667 | }); 668 | 669 | expect(eventMocks.error.mock.lastCall).toStrictEqual([{ 670 | type: 'error', 671 | violation: 'unknown_command_error', 672 | commands, 673 | name: undefined, 674 | description: undefined, 675 | offender: 'unknown', 676 | }] as BroCliEvent[]); 677 | }); 678 | 679 | it('Get the right command, no args', async () => { 680 | await run(commands, { 681 | argSource: getArgs('c-first'), 682 | theme: testTheme, 683 | // @ts-expect-error 684 | noExit: true, 685 | }); 686 | 687 | expect(handlers.cFirst.mock.lastCall).toStrictEqual([{ 688 | flag: undefined, 689 | string: undefined, 690 | sFlag: undefined, 691 | sString: undefined, 692 | }]); 693 | }); 694 | 695 | it('Get the right subcommand, subcommand before args', async () => { 696 | await run(commands, { 697 | argSource: getArgs('c-first sub -f posval -s=str '), 698 | theme: testTheme, 699 | // @ts-expect-error 700 | noExit: true, 701 | }); 702 | 703 | expect(handlers.sub.mock.lastCall).toStrictEqual([{ 704 | flag: true, 705 | string: 'str', 706 | pos: 'posval', 707 | }]); 708 | }); 709 | 710 | it('Get the right subcommand, subcommand between args', async () => { 711 | await run(commands, { 712 | argSource: getArgs('c-first -f true sub posval2 -s=str '), 713 | theme: testTheme, 714 | // @ts-expect-error 715 | noExit: true, 716 | }); 717 | 718 | expect(handlers.sub.mock.lastCall).toStrictEqual([{ 719 | flag: true, 720 | string: 'str', 721 | pos: 'posval2', 722 | }]); 723 | }); 724 | 725 | it('Get the right subcommand, subcommand after args', async () => { 726 | await run(commands, { 727 | argSource: getArgs('c-first -f posval3 -s=str sub'), 728 | theme: testTheme, 729 | // @ts-expect-error 730 | noExit: true, 731 | }); 732 | 733 | expect(handlers.sub.mock.lastCall).toStrictEqual([{ 734 | flag: true, 735 | string: 'str', 736 | pos: 'posval3', 737 | }]); 738 | }); 739 | 740 | it('Get the right deep subcommand', async () => { 741 | await run(commands, { 742 | argSource: getArgs('c-first nohandler deep'), 743 | theme: testTheme, 744 | // @ts-expect-error 745 | noExit: true, 746 | }); 747 | 748 | expect(handlers.deep.mock.lastCall).toStrictEqual([undefined]); 749 | }); 750 | 751 | it('Positionals in subcommand', async () => { 752 | await run(commands, { 753 | argSource: getArgs('c-first -f posval3 -s=str sub'), 754 | theme: testTheme, 755 | // @ts-expect-error 756 | noExit: true, 757 | }); 758 | 759 | expect(handlers.sub.mock.lastCall).toStrictEqual([{ 760 | flag: true, 761 | string: 'str', 762 | pos: 'posval3', 763 | }]); 764 | }); 765 | 766 | it('Unknown subcommand', async () => { 767 | await run(commands, { 768 | argSource: getArgs('c-first unrecognized'), 769 | theme: testTheme, 770 | // @ts-expect-error 771 | noExit: true, 772 | }); 773 | 774 | expect(eventMocks.error.mock.lastCall).toStrictEqual([{ 775 | type: 'error', 776 | violation: 'unknown_subcommand_error', 777 | name: undefined, 778 | description: undefined, 779 | offender: 'unrecognized', 780 | command: cFirst, 781 | }] as BroCliEvent[]); 782 | }); 783 | 784 | it('Transform', async () => { 785 | const transformFn = vi.fn(); 786 | const handlerFn = vi.fn(); 787 | 788 | const cmd = command({ 789 | name: 'generate', 790 | options: generateOps, 791 | transform: async (opts) => { 792 | transformFn(opts); 793 | 794 | return 'transformed'; 795 | }, 796 | handler: handlerFn, 797 | }); 798 | 799 | await run([cmd], { 800 | argSource: getArgs('generate --dialect=pg'), 801 | theme: testTheme, 802 | // @ts-expect-error 803 | noExit: true, 804 | }); 805 | 806 | expect(transformFn.mock.lastCall).toStrictEqual([{ 807 | dialect: 'pg', 808 | schema: undefined, 809 | out: undefined, 810 | name: undefined, 811 | breakpoints: undefined, 812 | custom: undefined, 813 | config: './drizzle-kit.config.ts', 814 | flag: undefined, 815 | defFlag: true, 816 | defString: 'Defaultvalue', 817 | debug: undefined, 818 | num: undefined, 819 | int: undefined, 820 | pos: undefined, 821 | enpos: undefined, 822 | }]); 823 | 824 | expect(handlerFn.mock.lastCall).toStrictEqual(['transformed']); 825 | }); 826 | 827 | it('Omit undefined keys', async () => { 828 | await run(commands, { 829 | argSource: getArgs('generate --dialect=pg'), 830 | theme: testTheme, 831 | omitKeysOfUndefinedOptions: true, 832 | }); 833 | 834 | expect(handlers.generate.mock.lastCall).toStrictEqual([{ 835 | dialect: 'pg', 836 | config: './drizzle-kit.config.ts', 837 | defFlag: true, 838 | defString: 'Defaultvalue', 839 | }]); 840 | }); 841 | 842 | it('Global --help', async () => { 843 | await run(commands, { 844 | argSource: getArgs('--help'), 845 | theme: testTheme, 846 | // @ts-expect-error 847 | noExit: true, 848 | }); 849 | 850 | expect(eventMocks.global_help.mock.calls.length).toStrictEqual(1); 851 | expect(eventMocks.global_help.mock.lastCall).toStrictEqual([{ 852 | type: 'global_help', 853 | globals: undefined, 854 | name: undefined, 855 | description: undefined, 856 | commands: commands, 857 | }] as BroCliEvent[]); 858 | }); 859 | 860 | it('Global -h', async () => { 861 | await run(commands, { 862 | argSource: getArgs('--someothergarbage=there -h --somegarbage here'), 863 | theme: testTheme, 864 | // @ts-expect-error 865 | noExit: true, 866 | }); 867 | 868 | expect(eventMocks.global_help.mock.calls.length).toStrictEqual(2); 869 | expect(eventMocks.global_help.mock.lastCall).toStrictEqual([{ 870 | type: 'global_help', 871 | globals: undefined, 872 | name: undefined, 873 | description: undefined, 874 | commands: commands, 875 | }] as BroCliEvent[]); 876 | }); 877 | 878 | it('Command --help', async () => { 879 | await run(commands, { 880 | argSource: getArgs('generate --help'), 881 | theme: testTheme, 882 | // @ts-expect-error 883 | noExit: true, 884 | }); 885 | 886 | expect(eventMocks.command_help.mock.calls.length).toStrictEqual(1); 887 | expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ 888 | type: 'command_help', 889 | globals: undefined, 890 | name: undefined, 891 | description: undefined, 892 | command: generate, 893 | }] as BroCliEvent[]); 894 | }); 895 | 896 | it('Subcommand --help', async () => { 897 | await run(commands, { 898 | argSource: getArgs('c-first sub --help'), 899 | theme: testTheme, 900 | // @ts-expect-error 901 | noExit: true, 902 | }); 903 | 904 | expect(eventMocks.command_help.mock.calls.length).toStrictEqual(2); 905 | expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ 906 | type: 'command_help', 907 | globals: undefined, 908 | name: undefined, 909 | description: undefined, 910 | command: cFirst.subcommands![0], 911 | }] as BroCliEvent[]); 912 | }); 913 | 914 | it('Command --help, off position', async () => { 915 | await run(commands, { 916 | argSource: getArgs('generate sometrash --flag --help sometrash '), 917 | theme: testTheme, 918 | // @ts-expect-error 919 | noExit: true, 920 | }); 921 | 922 | expect(eventMocks.command_help.mock.calls.length).toStrictEqual(3); 923 | expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ 924 | type: 'command_help', 925 | globals: undefined, 926 | name: undefined, 927 | description: undefined, 928 | command: generate, 929 | }] as BroCliEvent[]); 930 | }); 931 | 932 | it('Command -h', async () => { 933 | await run(commands, { 934 | argSource: getArgs('generate -h'), 935 | theme: testTheme, 936 | // @ts-expect-error 937 | noExit: true, 938 | }); 939 | 940 | expect(eventMocks.command_help.mock.calls.length).toStrictEqual(4); 941 | expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ 942 | type: 'command_help', 943 | globals: undefined, 944 | name: undefined, 945 | description: undefined, 946 | command: generate, 947 | }] as BroCliEvent[]); 948 | }); 949 | 950 | it('Command -h, off position', async () => { 951 | await run(commands, { 952 | argSource: getArgs('generate sometrash --flag -h sometrash '), 953 | theme: testTheme, 954 | // @ts-expect-error 955 | noExit: true, 956 | }); 957 | 958 | expect(eventMocks.command_help.mock.calls.length).toStrictEqual(5); 959 | expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ 960 | type: 'command_help', 961 | globals: undefined, 962 | name: undefined, 963 | description: undefined, 964 | command: generate, 965 | }] as BroCliEvent[]); 966 | }); 967 | 968 | it('Global help', async () => { 969 | await run(commands, { 970 | argSource: getArgs('help'), 971 | theme: testTheme, 972 | // @ts-expect-error 973 | noExit: true, 974 | }); 975 | 976 | expect(eventMocks.global_help.mock.calls.length).toStrictEqual(3); 977 | expect(eventMocks.global_help.mock.lastCall).toStrictEqual([{ 978 | type: 'global_help', 979 | globals: undefined, 980 | name: undefined, 981 | description: undefined, 982 | commands: commands, 983 | }] as BroCliEvent[]); 984 | }); 985 | 986 | it('Command help', async () => { 987 | await run(commands, { 988 | argSource: getArgs('help generate'), 989 | theme: testTheme, 990 | // @ts-expect-error 991 | noExit: true, 992 | }); 993 | 994 | expect(eventMocks.command_help.mock.calls.length).toStrictEqual(6); 995 | expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ 996 | type: 'command_help', 997 | globals: undefined, 998 | name: undefined, 999 | description: undefined, 1000 | command: generate, 1001 | }] as BroCliEvent[]); 1002 | }); 1003 | 1004 | it('Subcommand help', async () => { 1005 | await run(commands, { 1006 | argSource: getArgs('help c-first sub'), 1007 | theme: testTheme, 1008 | // @ts-expect-error 1009 | noExit: true, 1010 | }); 1011 | 1012 | expect(eventMocks.command_help.mock.calls.length).toStrictEqual(7); 1013 | expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ 1014 | type: 'command_help', 1015 | globals: undefined, 1016 | name: undefined, 1017 | description: undefined, 1018 | command: cFirst.subcommands![0]!, 1019 | }] as BroCliEvent[]); 1020 | }); 1021 | 1022 | it('Handlerless subcommand help', async () => { 1023 | await run(commands, { 1024 | argSource: getArgs('help c-first nohandler'), 1025 | theme: testTheme, 1026 | // @ts-expect-error 1027 | noExit: true, 1028 | }); 1029 | 1030 | expect(eventMocks.command_help.mock.calls.length).toStrictEqual(8); 1031 | expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ 1032 | type: 'command_help', 1033 | globals: undefined, 1034 | name: undefined, 1035 | description: undefined, 1036 | command: cFirst.subcommands![1]!, 1037 | }] as BroCliEvent[]); 1038 | }); 1039 | 1040 | it('--version', async () => { 1041 | await run(commands, { 1042 | argSource: getArgs('--version'), 1043 | theme: testTheme, 1044 | }); 1045 | 1046 | expect(eventMocks.version.mock.calls.length).toStrictEqual(1); 1047 | expect(eventMocks.version.mock.lastCall).toStrictEqual([{ 1048 | type: 'version', 1049 | name: undefined, 1050 | description: undefined, 1051 | }] as BroCliEvent[]); 1052 | }); 1053 | 1054 | it('-v', async () => { 1055 | await run(commands, { 1056 | argSource: getArgs('-v'), 1057 | theme: testTheme, 1058 | }); 1059 | 1060 | expect(eventMocks.version.mock.calls.length).toStrictEqual(2); 1061 | expect(eventMocks.version.mock.lastCall).toStrictEqual([{ 1062 | type: 'version', 1063 | name: undefined, 1064 | description: undefined, 1065 | }] as BroCliEvent[]); 1066 | }); 1067 | }); 1068 | 1069 | describe('Option definition tests', (it) => { 1070 | it('Duplicate names', () => { 1071 | expect(() => { 1072 | command({ 1073 | name: 'Name', 1074 | handler: (opt) => '', 1075 | options: { 1076 | opFirst: boolean('flag').alias('f', 'fl'), 1077 | opSecond: boolean('flag').alias('-f2', 'fl2'), 1078 | }, 1079 | }); 1080 | }).toThrowError(new BroCliError(`Can't define option '--flag' - name is already in use by option '--flag'!`)); 1081 | }); 1082 | 1083 | it('Duplicate aliases', () => { 1084 | expect(() => 1085 | command({ 1086 | name: 'Name', 1087 | handler: (opt) => '', 1088 | options: { 1089 | opFirst: boolean('flag').alias('f', 'fl'), 1090 | opSecond: boolean('flag2').alias('-f', 'fl'), 1091 | }, 1092 | }) 1093 | ).toThrowError(new BroCliError(`Can't define option '--flag2' - alias '-f' is already in use by option '--flag'!`)); 1094 | }); 1095 | 1096 | it('Name repeats alias', () => { 1097 | expect(() => 1098 | command({ 1099 | name: 'Name', 1100 | handler: (opt) => '', 1101 | options: { 1102 | opFirst: boolean('flag').alias('f', 'fl'), 1103 | opSecond: boolean('fl').alias('-f2', 'fl2'), 1104 | }, 1105 | }) 1106 | ).toThrowError(new BroCliError(`Can't define option '--fl' - name is already in use by option '--flag'!`)); 1107 | }); 1108 | 1109 | it('Alias repeats name', () => { 1110 | expect(() => 1111 | command({ 1112 | name: 'Name', 1113 | handler: (opt) => '', 1114 | options: { 1115 | opFirst: boolean('flag').alias('f', 'fl'), 1116 | opSecond: boolean('flag2').alias('flag', 'fl2'), 1117 | }, 1118 | }) 1119 | ).toThrowError( 1120 | new BroCliError(`Can't define option '--flag2' - alias '--flag' is already in use by option '--flag'!`), 1121 | ); 1122 | }); 1123 | 1124 | it('Duplicate names in same option', () => { 1125 | expect(() => 1126 | command({ 1127 | name: 'Name', 1128 | handler: (opt) => '', 1129 | options: { 1130 | opFirst: boolean('flag').alias('flag', 'fl'), 1131 | opSecond: boolean('flag2').alias('-f2', 'fl2'), 1132 | }, 1133 | }) 1134 | ).toThrowError( 1135 | new BroCliError(`Can't define option '--flag' - duplicate alias '--flag'!`), 1136 | ); 1137 | }); 1138 | 1139 | it('Duplicate aliases in same option', () => { 1140 | expect(() => 1141 | command({ 1142 | name: 'Name', 1143 | handler: (opt) => '', 1144 | options: { 1145 | opFirst: boolean('flag').alias('fl', 'fl'), 1146 | opSecond: boolean('flag2').alias('-f2', 'fl2'), 1147 | }, 1148 | }) 1149 | ).toThrowError( 1150 | new BroCliError(`Can't define option '--flag' - duplicate alias '--fl'!`), 1151 | ); 1152 | }); 1153 | 1154 | it('Forbidden character in name', () => { 1155 | expect(() => 1156 | command({ 1157 | name: 'Name', 1158 | handler: (opt) => '', 1159 | options: { 1160 | opFirst: boolean('fl=ag').alias('f', 'fl'), 1161 | opSecond: boolean('flag2').alias('-f2', 'fl2'), 1162 | }, 1163 | }) 1164 | ).toThrowError(new BroCliError(`Can't define option '--fl=ag' - option names and aliases cannot contain '='!`)); 1165 | }); 1166 | 1167 | it('Forbidden character in alias', () => { 1168 | expect(() => 1169 | command({ 1170 | name: 'Name', 1171 | handler: (opt) => '', 1172 | options: { 1173 | opFirst: boolean('flag').alias('f', 'f=l'), 1174 | opSecond: boolean('flag2').alias('-f2', 'fl2'), 1175 | }, 1176 | }) 1177 | ).toThrowError(new BroCliError(`Can't define option '--flag' - option names and aliases cannot contain '='!`)); 1178 | }); 1179 | 1180 | it('Reserved names: --help', () => { 1181 | expect(() => 1182 | command({ 1183 | name: 'Name', 1184 | handler: (opt) => '', 1185 | options: { 1186 | opFirst: boolean('help').alias('f', 'fl'), 1187 | opSecond: boolean('flag2').alias('-f2', 'fl2'), 1188 | }, 1189 | }) 1190 | ).toThrowError(new BroCliError(`Can't define option '--help' - name '--help' is reserved!`)); 1191 | }); 1192 | 1193 | it('Reserved names: -h', () => { 1194 | expect(() => 1195 | command({ 1196 | name: 'Name', 1197 | handler: (opt) => '', 1198 | options: { 1199 | opFirst: boolean('flag').alias('h', 'fl'), 1200 | opSecond: boolean('flag2').alias('-f2', 'fl2'), 1201 | }, 1202 | }) 1203 | ).toThrowError(new BroCliError(`Can't define option '--flag' - name '-h' is reserved!`)); 1204 | }); 1205 | 1206 | it('Reserved names: --version', () => { 1207 | expect(() => 1208 | command({ 1209 | name: 'Name', 1210 | handler: (opt) => '', 1211 | options: { 1212 | opFirst: boolean('flag').alias('version', 'fl'), 1213 | opSecond: boolean('flag2').alias('-f2', 'fl2'), 1214 | }, 1215 | }) 1216 | ).toThrowError(new BroCliError(`Can't define option '--flag' - name '--version' is reserved!`)); 1217 | }); 1218 | 1219 | it('Reserved names: -v', () => { 1220 | expect(() => 1221 | command({ 1222 | name: 'Name', 1223 | handler: (opt) => '', 1224 | options: { 1225 | opFirst: boolean('v').alias('h', 'fl'), 1226 | opSecond: boolean('flag2').alias('-f2', 'fl2'), 1227 | }, 1228 | }) 1229 | ).toThrowError(new BroCliError(`Can't define option '-v' - name '-v' is reserved!`)); 1230 | }); 1231 | 1232 | it('Positionals ignore old names', () => { 1233 | command({ 1234 | name: 'Name', 1235 | handler: (opt) => '', 1236 | options: { 1237 | opFirst: boolean('flag').alias('f', 'fl'), 1238 | opSecond: boolean('flag2').alias('-f2', 'fl2'), 1239 | pos: positional('--flag'), 1240 | }, 1241 | }); 1242 | }); 1243 | 1244 | it('Positional names get ignored', () => { 1245 | command({ 1246 | name: 'Name', 1247 | handler: (opt) => '', 1248 | options: { 1249 | pos: positional('--flag'), 1250 | opFirst: boolean('flag').alias('f', 'fl'), 1251 | opSecond: boolean('flag2').alias('-f2', 'fl2'), 1252 | }, 1253 | }); 1254 | }); 1255 | 1256 | it('Positional ignore name restrictions', () => { 1257 | command({ 1258 | name: 'Name', 1259 | handler: (opt) => '', 1260 | options: { 1261 | pos: positional('--fl=ag--'), 1262 | }, 1263 | }); 1264 | }); 1265 | }); 1266 | 1267 | describe('Command definition tests', (it) => { 1268 | it('Duplicate names', async () => { 1269 | const cmd = command({ 1270 | name: 'c-first', 1271 | handler: () => '', 1272 | }); 1273 | 1274 | expect( 1275 | await run([...commands, cmd], { 1276 | theme: testTheme, 1277 | // @ts-expect-error 1278 | noExit: true, 1279 | }), 1280 | ).toStrictEqual("BroCli error: Can't define command 'c-first': name is already in use by command 'c-first'!"); 1281 | }); 1282 | 1283 | it('Duplicate aliases', async () => { 1284 | const cmd = command({ 1285 | name: 'c-third', 1286 | aliases: ['g'], 1287 | handler: () => '', 1288 | }); 1289 | 1290 | expect( 1291 | await run([...commands, cmd], { 1292 | theme: testTheme, 1293 | // @ts-expect-error 1294 | noExit: true, 1295 | }), 1296 | ).toStrictEqual("BroCli error: Can't define command 'c-third': alias 'g' is already in use by command 'generate'!"); 1297 | }); 1298 | 1299 | it('Name repeats alias', async () => { 1300 | const cmd = command({ 1301 | name: 'gen', 1302 | aliases: ['c4'], 1303 | handler: () => '', 1304 | }); 1305 | 1306 | expect( 1307 | await run([...commands, cmd], { 1308 | theme: testTheme, 1309 | // @ts-expect-error 1310 | noExit: true, 1311 | }), 1312 | ).toStrictEqual("BroCli error: Can't define command 'gen': name is already in use by command 'generate'!"); 1313 | }); 1314 | 1315 | it('Alias repeats name', async () => { 1316 | const cmd = command({ 1317 | name: 'c-fifth', 1318 | aliases: ['generate'], 1319 | handler: () => '', 1320 | }); 1321 | 1322 | expect( 1323 | await run([...commands, cmd], { 1324 | theme: testTheme, 1325 | // @ts-expect-error 1326 | noExit: true, 1327 | }), 1328 | ).toStrictEqual( 1329 | "BroCli error: Can't define command 'c-fifth': alias 'generate' is already in use by command 'generate'!", 1330 | ); 1331 | }); 1332 | 1333 | it('Duplicate names in same command', () => { 1334 | expect(() => 1335 | command({ 1336 | name: 'c-sixth', 1337 | aliases: ['c-sixth', 'c6'], 1338 | handler: () => '', 1339 | }) 1340 | ).toThrowError(new BroCliError(`Can't define command 'c-sixth' - duplicate alias 'c-sixth'!`)); 1341 | }); 1342 | 1343 | it('Duplicate aliases in same command', () => { 1344 | expect(() => 1345 | command({ 1346 | name: 'c-seventh', 1347 | aliases: ['c7', 'c7', 'csvn'], 1348 | handler: () => '', 1349 | }) 1350 | ).toThrowError(new BroCliError(`Can't define command 'c-seventh' - duplicate alias 'c7'!`)); 1351 | }); 1352 | 1353 | it('Forbidden character in name', () => { 1354 | expect(() => 1355 | command({ 1356 | name: '--c-eigth', 1357 | aliases: ['c8'], 1358 | handler: () => '', 1359 | }) 1360 | ).toThrowError(new BroCliError(`Can't define command '--c-eigth' - command name can't start with '-'!`)); 1361 | }); 1362 | 1363 | it('Forbidden character in alias', () => { 1364 | expect(() => 1365 | command({ 1366 | name: 'c-ninth', 1367 | aliases: ['-c9'], 1368 | handler: () => '', 1369 | }) 1370 | ).toThrowError(new BroCliError(`Can't define command 'c-ninth' - command aliases can't start with '-'!`)); 1371 | }); 1372 | 1373 | it('Forbidden name - true', () => { 1374 | expect(() => 1375 | command({ 1376 | name: 'tRue', 1377 | handler: () => '', 1378 | }) 1379 | ).toThrowError(new BroCliError(`Can't define command 'tRue' - 'tRue' is a reserved for boolean values name!`)); 1380 | }); 1381 | 1382 | it('Forbidden name - false', () => { 1383 | expect(() => 1384 | command({ 1385 | name: 'FALSE', 1386 | handler: () => '', 1387 | }) 1388 | ).toThrowError(new BroCliError(`Can't define command 'FALSE' - 'FALSE' is a reserved for boolean values name!`)); 1389 | }); 1390 | 1391 | it('Forbidden name - 1', () => { 1392 | expect(() => 1393 | command({ 1394 | name: '1', 1395 | handler: () => '', 1396 | }) 1397 | ).toThrowError(new BroCliError(`Can't define command '1' - '1' is a reserved for boolean values name!`)); 1398 | }); 1399 | 1400 | it('Forbidden name - 0', () => { 1401 | expect(() => 1402 | command({ 1403 | name: '0', 1404 | handler: () => '', 1405 | }) 1406 | ).toThrowError(new BroCliError(`Can't define command '0' - '0' is a reserved for boolean values name!`)); 1407 | }); 1408 | 1409 | it('Forbidden alias - true', () => { 1410 | expect(() => 1411 | command({ 1412 | name: 'c-ninth', 1413 | aliases: ['trUe'], 1414 | handler: () => '', 1415 | }) 1416 | ).toThrowError(new BroCliError(`Can't define command 'c-ninth' - 'trUe' is a reserved for boolean values name!`)); 1417 | }); 1418 | 1419 | it('Forbidden alias - false', () => { 1420 | expect(() => 1421 | command({ 1422 | name: 'c-ninth', 1423 | aliases: ['FalSe'], 1424 | handler: () => '', 1425 | }) 1426 | ).toThrowError(new BroCliError(`Can't define command 'c-ninth' - 'FalSe' is a reserved for boolean values name!`)); 1427 | }); 1428 | 1429 | it('Forbidden alias - 1', () => { 1430 | expect(() => 1431 | command({ 1432 | name: 'c-ninth', 1433 | aliases: ['1'], 1434 | handler: () => '', 1435 | }) 1436 | ).toThrowError(new BroCliError(`Can't define command 'c-ninth' - '1' is a reserved for boolean values name!`)); 1437 | }); 1438 | 1439 | it('Forbidden alias - 0', () => { 1440 | expect(() => 1441 | command({ 1442 | name: 'c-ninth', 1443 | aliases: ['0'], 1444 | handler: () => '', 1445 | }) 1446 | ).toThrowError(new BroCliError(`Can't define command 'c-ninth' - '0' is a reserved for boolean values name!`)); 1447 | }); 1448 | 1449 | it('Using handler function', async () => { 1450 | const opts = { 1451 | flag: boolean().alias('f', 'fl').desc('Boolean value'), 1452 | string: string().alias('s', 'str').desc('String value'), 1453 | sFlag: boolean('stealth').alias('j', 'hb').desc('Boolean value').hidden(), 1454 | sString: string('sstring').alias('q', 'hs').desc('String value').hidden(), 1455 | }; 1456 | 1457 | const localFn = vi.fn(); 1458 | 1459 | const cmd = command({ 1460 | name: 'c-tenth', 1461 | aliases: ['c10'], 1462 | options: opts, 1463 | handler: handler(opts, (options) => { 1464 | localFn(options); 1465 | }), 1466 | }); 1467 | 1468 | await run([cmd], { 1469 | argSource: getArgs('c-tenth -f -j false --string=strval'), 1470 | theme: testTheme, 1471 | // @ts-expect-error 1472 | noExit: true, 1473 | }); 1474 | 1475 | expect(localFn.mock.lastCall).toStrictEqual([{ 1476 | flag: true, 1477 | string: 'strval', 1478 | sFlag: false, 1479 | sString: undefined, 1480 | }]); 1481 | }); 1482 | 1483 | it('Optional handler with subcommands', async () => { 1484 | command({ 1485 | name: 'nohandler', 1486 | subcommands: [command({ 1487 | name: 'deep', 1488 | handler: handlers.deep, 1489 | })], 1490 | }); 1491 | }); 1492 | 1493 | it('Error on no handler without subcommands', async () => { 1494 | expect(() => 1495 | command({ 1496 | name: 'nohandler', 1497 | }) 1498 | ).toThrowError( 1499 | new BroCliError(`Can't define command 'nohandler' - command without subcommands must have a handler present!`), 1500 | ); 1501 | }); 1502 | 1503 | it('Error on positionals with subcommands', async () => { 1504 | expect(() => 1505 | command({ 1506 | name: 'nohandler', 1507 | options: { 1508 | pos: positional(), 1509 | }, 1510 | subcommands: [ 1511 | command({ 1512 | name: 'something', 1513 | handler: () => '', 1514 | }), 1515 | ], 1516 | }) 1517 | ).toThrowError( 1518 | new BroCliError( 1519 | `Can't define command 'nohandler' - command can't have subcommands and positional args at the same time!`, 1520 | ), 1521 | ); 1522 | }); 1523 | }); 1524 | 1525 | describe('Hook tests', (it) => { 1526 | let [before, handler, after] = [new Date(), new Date(), new Date()]; 1527 | 1528 | const test = command({ 1529 | name: 'test', 1530 | handler: () => handler = new Date(), 1531 | }); 1532 | 1533 | const cmdsLocal = [ 1534 | test, 1535 | ]; 1536 | 1537 | it('Execution in order', async () => { 1538 | await run(cmdsLocal, { 1539 | argSource: getArgs('test'), 1540 | theme: testTheme, 1541 | hook: async (event, command) => { 1542 | const stamp = new Date(); 1543 | if (event === 'before') { 1544 | before = stamp; 1545 | hookMocks.before(stamp, command); 1546 | } 1547 | if (event === 'after') { 1548 | after = stamp; 1549 | hookMocks.after(stamp, command); 1550 | } 1551 | }, 1552 | }); 1553 | 1554 | expect(before.getTime() <= handler.getTime() && handler.getTime() <= after.getTime()).toStrictEqual(true); 1555 | expect(hookMocks.before.mock.lastCall).toStrictEqual([ 1556 | before, 1557 | test, 1558 | ]); 1559 | expect(hookMocks.after.mock.lastCall).toStrictEqual([ 1560 | after, 1561 | test, 1562 | ]); 1563 | }); 1564 | }); 1565 | 1566 | describe(`Config styles' prevalence over themes test`, (it) => { 1567 | const ghelp = vi.fn(); 1568 | const chelp = vi.fn(); 1569 | const ver = vi.fn(); 1570 | 1571 | const cmd = command({ 1572 | name: 'test', 1573 | handler: () => '', 1574 | help: chelp, 1575 | }); 1576 | 1577 | const cmds = [cmd]; 1578 | 1579 | it('Global --help', async () => { 1580 | await run(cmds, { argSource: getArgs('--help'), help: ghelp }); 1581 | 1582 | expect(ghelp.mock.calls.length).toStrictEqual(1); 1583 | }); 1584 | 1585 | it('Global -h', async () => { 1586 | await run(cmds, { argSource: getArgs('-h'), help: ghelp }); 1587 | 1588 | expect(ghelp.mock.calls.length).toStrictEqual(2); 1589 | }); 1590 | 1591 | it('Command --help', async () => { 1592 | await run(cmds, { argSource: getArgs('test --help') }); 1593 | 1594 | expect(chelp.mock.calls.length).toStrictEqual(1); 1595 | }); 1596 | 1597 | it('Command -h', async () => { 1598 | await run(cmds, { argSource: getArgs('test -h') }); 1599 | 1600 | expect(chelp.mock.calls.length).toStrictEqual(2); 1601 | }); 1602 | 1603 | it('Global help', async () => { 1604 | await run(cmds, { argSource: getArgs('help'), help: ghelp }); 1605 | 1606 | expect(ghelp.mock.calls.length).toStrictEqual(3); 1607 | }); 1608 | 1609 | it('Command help', async () => { 1610 | await run(cmds, { argSource: getArgs('help test'), help: ghelp }); 1611 | 1612 | expect(chelp.mock.calls.length).toStrictEqual(3); 1613 | }); 1614 | 1615 | it('--version', async () => { 1616 | await run(cmds, { argSource: getArgs('--version'), version: ver }); 1617 | 1618 | expect(ver.mock.calls.length).toStrictEqual(1); 1619 | }); 1620 | 1621 | it('-v', async () => { 1622 | await run(cmds, { argSource: getArgs('-v'), version: ver }); 1623 | 1624 | expect(ver.mock.calls.length).toStrictEqual(2); 1625 | }); 1626 | }); 1627 | 1628 | describe('Test function string to args convertion tests', (it) => { 1629 | it('Empty string', async () => { 1630 | expect(shellArgs('')).toStrictEqual([]); 1631 | }); 1632 | 1633 | it('Regular format', async () => { 1634 | expect(shellArgs('cmd --flag var -s string --str=val')).toStrictEqual([ 1635 | 'cmd', 1636 | '--flag', 1637 | 'var', 1638 | '-s', 1639 | 'string', 1640 | '--str=val', 1641 | ]); 1642 | }); 1643 | 1644 | it('With quotes', async () => { 1645 | expect(shellArgs('cmd "--flag" var -s "string" --str="val"')).toStrictEqual([ 1646 | 'cmd', 1647 | '--flag', 1648 | 'var', 1649 | '-s', 1650 | 'string', 1651 | '--str=val', 1652 | ]); 1653 | }); 1654 | 1655 | it('With quotes and spaces', async () => { 1656 | expect(shellArgs('cmd "--flag" var -s "string string2" --str="val spaces"')).toStrictEqual([ 1657 | 'cmd', 1658 | '--flag', 1659 | 'var', 1660 | '-s', 1661 | 'string string2', 1662 | '--str=val spaces', 1663 | ]); 1664 | }); 1665 | 1666 | it('With quotes and spaces, multiline', async () => { 1667 | expect(shellArgs( 1668 | 'cmd "--flag" var -s "string string2" \ 1669 | --str="val \ 1670 | spaces"', 1671 | )).toStrictEqual([ 1672 | 'cmd', 1673 | '--flag', 1674 | 'var', 1675 | '-s', 1676 | 'string string2', 1677 | '--str=val spaces', 1678 | ]); 1679 | }); 1680 | 1681 | it('Empty multiline', async () => { 1682 | expect(shellArgs( 1683 | ' \ 1684 | \ 1685 | \ 1686 | ', 1687 | )).toStrictEqual([]); 1688 | }); 1689 | 1690 | it('Multiline', async () => { 1691 | expect(shellArgs( 1692 | 'cmd --flag \ 1693 | var\ 1694 | -s string --str=val', 1695 | )).toStrictEqual([ 1696 | 'cmd', 1697 | '--flag', 1698 | 'var', 1699 | '-s', 1700 | 'string', 1701 | '--str=val', 1702 | ]); 1703 | }); 1704 | }); 1705 | 1706 | describe('Globals tests', (it) => { 1707 | const globals = { 1708 | globalText: string(), 1709 | globalFlag: boolean(), 1710 | globalEnum: string().alias('-genum').enum('one', 'two', 'three'), 1711 | globalTextDef: string().default('strdef'), 1712 | globalFlagDef: boolean().default(false), 1713 | globalEnumDef: string().enum('one', 'two', 'three').default('three'), 1714 | }; 1715 | // .toThrowError(new BroCliError(`Can't define option '--flag' - name is already in use by option '--flag'!`)); 1716 | 1717 | const conflictCmds = [ 1718 | command({ 1719 | name: 'conflict', 1720 | options: { 1721 | flag: boolean(), 1722 | text: string().alias('txt'), 1723 | aliased: boolean('aliased').alias('-al'), 1724 | }, 1725 | handler: () => '', 1726 | }), 1727 | ]; 1728 | 1729 | it('Names conflict', async () => { 1730 | expect( 1731 | await run(conflictCmds, { 1732 | globals: { 1733 | flag: boolean(), 1734 | }, 1735 | // @ts-expect-error 1736 | noExit: true, 1737 | }), 1738 | ).toStrictEqual( 1739 | "BroCli error: Global options overlap with option '--flag' of command 'conflict' on name", 1740 | ); 1741 | }); 1742 | 1743 | it('Name conflicts with global alias', async () => { 1744 | expect( 1745 | await run(conflictCmds, { 1746 | globals: { 1747 | gFlag: boolean().alias('--flag'), 1748 | }, 1749 | // @ts-expect-error 1750 | noExit: true, 1751 | }), 1752 | ).toStrictEqual( 1753 | "BroCli error: Global options overlap with option '--flag' of command 'conflict' on alias '--flag'", 1754 | ); 1755 | }); 1756 | 1757 | it('Alias conflicts with global alias', async () => { 1758 | expect( 1759 | await run(conflictCmds, { 1760 | globals: { 1761 | gFlag: boolean().alias('-al'), 1762 | }, 1763 | // @ts-expect-error 1764 | noExit: true, 1765 | }), 1766 | ).toStrictEqual( 1767 | "BroCli error: Global options overlap with option '--aliased' of command 'conflict' on alias '-al'", 1768 | ); 1769 | }); 1770 | 1771 | it('Alias conflicts with global name', async () => { 1772 | expect( 1773 | await run(conflictCmds, { 1774 | globals: { 1775 | txt: string(), 1776 | }, 1777 | // @ts-expect-error 1778 | noExit: true, 1779 | }), 1780 | ).toStrictEqual( 1781 | "BroCli error: Global options overlap with option '--text' of command 'conflict' on alias '--txt'", 1782 | ); 1783 | }); 1784 | 1785 | it('Separate', async () => { 1786 | let gs: any; 1787 | 1788 | await run(commands, { 1789 | argSource: getArgs('-genum "one" c-first'), 1790 | globals, 1791 | hook(event, command, globals) { 1792 | gs = globals; 1793 | }, 1794 | // @ts-expect-error 1795 | noExit: true, 1796 | }); 1797 | 1798 | expect(gs).toStrictEqual({ 1799 | globalFlag: undefined, 1800 | globalText: undefined, 1801 | globalEnum: 'one', 1802 | globalTextDef: 'strdef', 1803 | globalFlagDef: false, 1804 | globalEnumDef: 'three', 1805 | }); 1806 | }); 1807 | 1808 | it('Mixed with command options', async () => { 1809 | let gs: any; 1810 | 1811 | await run(commands, { 1812 | argSource: getArgs('c-first --globalFlag true --globalText=str --string strval'), 1813 | globals, 1814 | hook(event, command, globals) { 1815 | gs = globals; 1816 | }, 1817 | // @ts-expect-error 1818 | noExit: true, 1819 | }); 1820 | 1821 | expect(gs).toStrictEqual({ 1822 | globalFlag: true, 1823 | globalText: 'str', 1824 | globalEnum: undefined, 1825 | globalTextDef: 'strdef', 1826 | globalFlagDef: false, 1827 | globalEnumDef: 'three', 1828 | }); 1829 | }); 1830 | }); 1831 | 1832 | describe('Type tests', (it) => { 1833 | const generateOps = { 1834 | dialect: string().alias('-d', '-dlc').desc('Database dialect [pg, mysql, sqlite]').enum('pg', 'mysql', 'sqlite') 1835 | .required(), 1836 | schema: string('schema').alias('s').desc('Path to a schema file or folder'), 1837 | out: string().alias('o').desc("Output folder, 'drizzle' by default"), 1838 | name: string().alias('n').desc('Migration file name'), 1839 | breakpoints: string('breakpoints').alias('break').desc(`Prepare SQL statements with breakpoints`), 1840 | custom: string('custom').alias('cus').desc('Prepare empty migration file for custom SQL'), 1841 | config: string().alias('c', 'cfg').desc('Path to a config.json file, drizzle.config.ts by default').default( 1842 | './drizzle-kit.config.ts', 1843 | ), 1844 | flag: boolean().alias('f').desc('Example boolean field'), 1845 | defFlag: boolean().alias('-def').desc('Example boolean field with default').default(true), 1846 | defString: string().alias('-ds').desc('Example string field with default').default('Defaultvalue'), 1847 | debug: boolean('dbg').alias('g').hidden(), 1848 | num: number('num'), 1849 | pos: positional(), 1850 | int: number('int').int(), 1851 | enpos: positional('Enum positional').enum('first', 'second', 'third'), 1852 | }; 1853 | 1854 | type ExpectedType = { 1855 | dialect: 'pg' | 'mysql' | 'sqlite'; 1856 | schema: string | undefined; 1857 | out: string | undefined; 1858 | name: string | undefined; 1859 | breakpoints: string | undefined; 1860 | custom: string | undefined; 1861 | config: string; 1862 | flag: boolean | undefined; 1863 | defFlag: boolean; 1864 | defString: string; 1865 | debug: boolean | undefined; 1866 | num: number | undefined; 1867 | pos: string | undefined; 1868 | int: number | undefined; 1869 | enpos: 'first' | 'second' | 'third' | undefined; 1870 | }; 1871 | 1872 | it('Param type inferrence test', () => { 1873 | type GenerateOptions = TypeOf; 1874 | 1875 | expectTypeOf().toEqualTypeOf(); 1876 | }); 1877 | 1878 | it("'handler' function type inferrence test", () => { 1879 | const hdl = handler(generateOps, () => ''); 1880 | 1881 | type HandlerOpts = typeof hdl extends (options: infer Options) => any ? Options : never; 1882 | 1883 | expectTypeOf().toEqualTypeOf(); 1884 | }); 1885 | 1886 | it('Transorm type mutation test', () => { 1887 | command({ 1888 | name: 'test', 1889 | options: generateOps, 1890 | transform: (opts) => { 1891 | expectTypeOf(opts).toEqualTypeOf(); 1892 | return 'transformed' as const; 1893 | }, 1894 | handler: (opts) => { 1895 | expectTypeOf(opts).toEqualTypeOf<'transformed'>(); 1896 | }, 1897 | }); 1898 | }); 1899 | 1900 | it('Async transorm type mutation test', () => { 1901 | command({ 1902 | name: 'test', 1903 | options: generateOps, 1904 | transform: async (opts) => { 1905 | expectTypeOf(opts).toEqualTypeOf(); 1906 | return 'transformed' as const; 1907 | }, 1908 | handler: (opts) => { 1909 | expectTypeOf(opts).toEqualTypeOf<'transformed'>(); 1910 | }, 1911 | }); 1912 | }); 1913 | 1914 | it('Globals type inferrence', () => { 1915 | const commands = [command({ 1916 | name: 'test', 1917 | handler: (opts) => '', 1918 | })]; 1919 | 1920 | type ExpectedType = { 1921 | gBool: boolean; 1922 | gText: string; 1923 | gTextNoDef: string | undefined; 1924 | gTextRequired: string; 1925 | gEnum: 'variant_one' | 'variant_two' | undefined; 1926 | }; 1927 | 1928 | run(commands, { 1929 | globals: { 1930 | gBool: boolean().alias('-gb').default(false), 1931 | gText: string().alias('-gt').default('text'), 1932 | gTextNoDef: string().alias('-gtnd'), 1933 | gTextRequired: string().alias('-gtr').required(), 1934 | gEnum: string().enum('variant_one', 'variant_two'), 1935 | }, 1936 | hook(event, command, globals) { 1937 | expectTypeOf(globals).toEqualTypeOf(); 1938 | }, 1939 | theme: testTheme, 1940 | }); 1941 | }); 1942 | }); 1943 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | # Backlog 2 | 3 | - exclusive\inclusive comparison for min\max 4 | - .length() 5 | - Make TypeOf work on single element 6 | - Array positionals -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.dts.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "composite": false, 5 | "rootDir": "src", 6 | "outDir": "dist-dts", 7 | "declaration": true, 8 | "noEmit": false, 9 | "emitDeclarationOnly": true, 10 | "incremental": false 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "isolatedModules": true, 5 | "composite": false, 6 | "target": "esnext", 7 | "module": "esnext", 8 | "moduleResolution": "bundler", 9 | "lib": ["es2020", "es2018", "es2017", "es7", "es6", "es5"], 10 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 11 | "declarationMap": false, 12 | "sourceMap": false, 13 | "allowJs": true, 14 | "incremental": false, 15 | "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 16 | "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 17 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 18 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 19 | "strict": true, /* Enable all strict type-checking options. */ 20 | "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 21 | "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 22 | "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 23 | "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 24 | "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 25 | "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 26 | "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 27 | "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 28 | "exactOptionalPropertyTypes": false, /* Interpret optional property types as written, rather than adding 'undefined'. */ 29 | "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 30 | "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 31 | "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 32 | "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 33 | "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 34 | "allowUnusedLabels": false, /* Disable error reporting for unused labels. */ 35 | "allowUnreachableCode": false, /* Disable error reporting for unreachable code. */ 36 | "skipLibCheck": true, /* Skip type checking all .d.ts files. */ 37 | "noErrorTruncation": true, /* Disable truncating types in error messages. */ 38 | "checkJs": true, 39 | "allowImportingTsExtensions": true, 40 | "baseUrl": ".", 41 | "paths": { 42 | "@/*": ["src/*"] 43 | }, 44 | "outDir": "dist" 45 | }, 46 | "exclude": ["/**/node_modules/**/*", "**/dist"], 47 | "include": ["src/**/*", "drizzle.config.ts", "tests/**/*"] 48 | } 49 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { viteCommonjs } from '@originjs/vite-plugin-commonjs'; 2 | import tsconfigPaths from 'vite-tsconfig-paths'; 3 | import { defineConfig } from 'vitest/config'; 4 | 5 | export default defineConfig({ 6 | test: { 7 | include: ['tests/**/*.test.ts'], 8 | isolate: true, 9 | typecheck: { 10 | tsconfig: 'tsconfig.json', 11 | }, 12 | testTimeout: 100000, 13 | hookTimeout: 100000, 14 | }, 15 | plugins: [viteCommonjs(), tsconfigPaths()], 16 | }); 17 | --------------------------------------------------------------------------------