├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.js ├── package-lock.json ├── package.json ├── src ├── bin │ └── nodecg.ts ├── commands │ ├── defaultconfig.ts │ ├── index.ts │ ├── install.ts │ ├── schema-types.ts │ ├── setup.ts │ ├── start.ts │ └── uninstall.ts ├── index.ts └── lib │ ├── fetch-tags.ts │ ├── install-bundle-deps.ts │ ├── sample │ ├── npm-release.json │ └── npm-release.ts │ └── util.ts ├── test ├── commands │ ├── defaultconfig.spec.ts │ ├── install.spec.ts │ ├── schema-types.spec.ts │ ├── setup.spec.ts │ ├── tmp-dir.ts │ └── uninstall.spec.ts ├── fixtures │ └── bundles │ │ ├── config-schema │ │ ├── configschema.json │ │ └── package.json │ │ ├── schema-types │ │ ├── package.json │ │ └── schemas │ │ │ └── example.json │ │ └── uninstall-test │ │ └── package.json └── mocks │ └── program.ts ├── tsconfig.build.json ├── tsconfig.json └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [{package.json,*.yml}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # Don't remove trailing whitespace from Markdown 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | strategy: 14 | matrix: 15 | node-version: 16 | - "18" 17 | - "20" 18 | - "22" 19 | os: 20 | - ubuntu-latest 21 | - windows-latest 22 | runs-on: ${{ matrix.os }} 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: npm 29 | - run: npm i -g bower 30 | - run: npm ci 31 | - run: npm run static 32 | - run: npm test 33 | - run: npm run build 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | packages: write 12 | 13 | jobs: 14 | release-please: 15 | runs-on: ubuntu-latest 16 | outputs: 17 | release_created: ${{ steps.release-please.outputs.release_created }} 18 | release_name: ${{ steps.release-please.outputs.name }} 19 | steps: 20 | - uses: googleapis/release-please-action@v4 21 | id: release-please 22 | with: 23 | release-type: node 24 | 25 | publish: 26 | runs-on: ubuntu-latest 27 | needs: release-please 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: actions/setup-node@v4 31 | with: 32 | node-version: "22" 33 | registry-url: "https://registry.npmjs.org" 34 | cache: npm 35 | - run: npm ci 36 | - run: npm run build 37 | 38 | - if: ${{ needs.release-please.outputs.release_created }} 39 | run: npm publish --access public 40 | env: 41 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 42 | 43 | - if: ${{ !needs.release-please.outputs.release_created }} 44 | run: | 45 | npm version 0.0.0-canary.${{ github.sha }} --no-git-tag-version 46 | npm publish --tag canary 47 | env: 48 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | tsconfig.vitest-temp.json 3 | 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional stylelint cache 62 | .stylelintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variable files 80 | .env 81 | .env.development.local 82 | .env.test.local 83 | .env.production.local 84 | .env.local 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | .parcel-cache 89 | 90 | # Next.js build output 91 | .next 92 | out 93 | 94 | # Nuxt.js build / generate output 95 | .nuxt 96 | dist 97 | 98 | # Gatsby files 99 | .cache/ 100 | # Comment in the public line in if your project uses Gatsby and not Next.js 101 | # https://nextjs.org/blog/next-9-1#public-directory-support 102 | # public 103 | 104 | # vuepress build output 105 | .vuepress/dist 106 | 107 | # vuepress v2.x temp and cache directory 108 | .temp 109 | .cache 110 | 111 | # Docusaurus cache and generated files 112 | .docusaurus 113 | 114 | # Serverless directories 115 | .serverless/ 116 | 117 | # FuseBox cache 118 | .fusebox/ 119 | 120 | # DynamoDB Local files 121 | .dynamodb/ 122 | 123 | # TernJS port file 124 | .tern-port 125 | 126 | # Stores VSCode versions used for testing VSCode extensions 127 | .vscode-test 128 | 129 | # yarn v2 130 | .yarn/cache 131 | .yarn/unplugged 132 | .yarn/build-state.yml 133 | .yarn/install-state.gz 134 | .pnp.* 135 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /test/fixtures 2 | CHANGELOG.md 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [9.0.2](https://github.com/nodecg/nodecg-cli/compare/v9.0.1...v9.0.2) (2025-01-05) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * windows npm install ([#132](https://github.com/nodecg/nodecg-cli/issues/132)) ([d042394](https://github.com/nodecg/nodecg-cli/commit/d0423942b11bfced0c5d94a44f909cac92d53eb3)) 11 | 12 | ## [9.0.1](https://github.com/nodecg/nodecg-cli/compare/v9.0.0...v9.0.1) (2025-01-03) 13 | 14 | 15 | ### Bug Fixes 16 | 17 | * release CI bug ([4bb377a](https://github.com/nodecg/nodecg-cli/commit/4bb377a7540f4774edb75643d9694c21338de3a2)) 18 | 19 | ## [9.0.0](https://github.com/nodecg/nodecg-cli/compare/v8.7.0...v9.0.0) (2025-01-01) 20 | 21 | 22 | ### ⚠ BREAKING CHANGES 23 | 24 | * **setup:** Drop support for nodecg 0.x.x and 1.x.x. 25 | * **schema-types:** schema-types command no longer outputs index file 26 | * `defaultconfig` command now uses ajv to generate default value. A config schema that are not object will throw an error. A config schema with top level default will now throw an error. 27 | 28 | ### Features 29 | 30 | * deprecated package ([#128](https://github.com/nodecg/nodecg-cli/issues/128)) ([1726529](https://github.com/nodecg/nodecg-cli/commit/17265294e94cb93a0b88d19e31135cba6441ca12)) 31 | * use ajv for defaultconfig command ([#117](https://github.com/nodecg/nodecg-cli/issues/117)) ([6f2c19d](https://github.com/nodecg/nodecg-cli/commit/6f2c19d8a7a99f9ca90c5cfff5cd636f5804333b)) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * **defaultconfig:** correct styling of log output ([#120](https://github.com/nodecg/nodecg-cli/issues/120)) ([7cf54f3](https://github.com/nodecg/nodecg-cli/commit/7cf54f3efeae36ad8008777e321128f5970ab939)) 37 | * **schema-types:** don't output index file ([#119](https://github.com/nodecg/nodecg-cli/issues/119)) ([4ca2931](https://github.com/nodecg/nodecg-cli/commit/4ca29311b5220fdc53357c54dff41a3f1dc20686)) 38 | 39 | 40 | ### Code Refactoring 41 | 42 | * **setup:** remove support for nodecg less than 2.0.0 ([#124](https://github.com/nodecg/nodecg-cli/issues/124)) ([4536527](https://github.com/nodecg/nodecg-cli/commit/4536527088ccfb320cb6989a00bc9bf04b3a0266)) 43 | 44 | ## [8.7.0](https://github.com/nodecg/nodecg-cli/compare/v8.6.8...v8.7.0) (2024-12-26) 45 | 46 | 47 | ### Features 48 | 49 | * update commander and inquirer ([#109](https://github.com/nodecg/nodecg-cli/issues/109)) ([579b79e](https://github.com/nodecg/nodecg-cli/commit/579b79ed255875e76cb06b453a54f150f6f76172)) 50 | * update node version to 18/20/22 ([#98](https://github.com/nodecg/nodecg-cli/issues/98)) ([9990349](https://github.com/nodecg/nodecg-cli/commit/999034977350695e4c09fbf12446e900743f81db)) 51 | * use esm and update packages ([#108](https://github.com/nodecg/nodecg-cli/issues/108)) ([058be35](https://github.com/nodecg/nodecg-cli/commit/058be35204e48ee989161b96f7cc36b7b5eeb904)) 52 | 53 | 54 | ### Bug Fixes 55 | 56 | * remove __dirname ([#113](https://github.com/nodecg/nodecg-cli/issues/113)) ([8c9a033](https://github.com/nodecg/nodecg-cli/commit/8c9a033dce5630f6ef0edf5515ffdc8f1f673f6d)) 57 | * remove node-fetch ([#102](https://github.com/nodecg/nodecg-cli/issues/102)) ([50a6323](https://github.com/nodecg/nodecg-cli/commit/50a632382fba9a05f087042d78dde09ae3c96997)) 58 | 59 | ## [8.6.8](https://github.com/nodecg/nodecg-cli/compare/v8.6.7...v8.6.8) (2023-06-20) 60 | 61 | ### Bug Fixes 62 | 63 | - try prepending "v" to the checkout tag when installing a bundle ([58daa03](https://github.com/nodecg/nodecg-cli/commit/58daa0336319624f5ec783806d0e8a00f4aefb24)) 64 | 65 | ## [8.6.7](https://github.com/nodecg/nodecg-cli/compare/v8.6.6...v8.6.7) (2023-06-20) 66 | 67 | ### Bug Fixes 68 | 69 | - better semver parsing ([61a0e8c](https://github.com/nodecg/nodecg-cli/commit/61a0e8cdc704bff5c081beffd3f30d76dbf59cbb)) 70 | 71 | ## [8.6.6](https://github.com/nodecg/nodecg-cli/compare/v8.6.5...v8.6.6) (2023-06-11) 72 | 73 | ### Bug Fixes 74 | 75 | - force build to try to fix release-please ([e2ae645](https://github.com/nodecg/nodecg-cli/commit/e2ae6451be408d821d3211fea82ed1a95cf6db89)) 76 | 77 | ## [8.6.5](https://github.com/nodecg/nodecg-cli/compare/v8.6.4...v8.6.5) (2023-06-11) 78 | 79 | ### Bug Fixes 80 | 81 | - don't return a promise from the compile method, it isn't used ([2903e50](https://github.com/nodecg/nodecg-cli/commit/2903e5016a9daa410f3972b7d94873fd9f41adee)) 82 | - prevent eslint and typescript from being overly worried about replicant schemas ([3d2dd82](https://github.com/nodecg/nodecg-cli/commit/3d2dd82ea642e5a6e596ff2191577f0dd8424f42)) 83 | 84 | ## [6.1.0](https://github.com/nodecg/nodecg-cli/compare/v6.0.4-alpha.2...v6.1.0) (2019-08-06) 85 | 86 | ### Features 87 | 88 | - make installation of Bower deps optional ([#66](https://github.com/nodecg/nodecg-cli/issues/66)) ([2e16c1b](https://github.com/nodecg/nodecg-cli/commit/2e16c1b)) 89 | 90 | ## [6.0.4-alpha.2](https://github.com/nodecg/nodecg-cli/compare/v6.0.4-alpha.1...v6.0.4-alpha.2) (2019-01-27) 91 | 92 | ### Bug Fixes 93 | 94 | - **modules:** use commonjs export for dynamic modules ([523a6a6](https://github.com/nodecg/nodecg-cli/commit/523a6a6)) 95 | 96 | 97 | 98 | ## [6.0.4-alpha.1](https://github.com/nodecg/nodecg-cli/compare/v6.0.4-alpha.0...v6.0.4-alpha.1) (2019-01-27) 99 | 100 | ### Bug Fixes 101 | 102 | - correct require path to package.json ([035e71e](https://github.com/nodecg/nodecg-cli/commit/035e71e)) 103 | 104 | 105 | 106 | ## [6.0.4-alpha.0](https://github.com/nodecg/nodecg-cli/compare/v6.0.3...v6.0.4-alpha.0) (2019-01-27) 107 | 108 | 109 | 110 | ## [6.0.3](https://github.com/nodecg/nodecg-cli/compare/v6.0.2...v6.0.3) (2018-12-03) 111 | 112 | ### Bug Fixes 113 | 114 | - **package:** make fs-extra a prod dep, not a devDep ([76ab59d](https://github.com/nodecg/nodecg-cli/commit/76ab59d)) 115 | 116 | 117 | 118 | ## [6.0.2](https://github.com/nodecg/nodecg-cli/compare/v6.0.1...v6.0.2) (2018-12-03) 119 | 120 | 121 | 122 | ## [6.0.1](https://github.com/nodecg/nodecg-cli/compare/v6.0.0...v6.0.1) (2018-12-03) 123 | 124 | 125 | 126 | # [6.0.0](https://github.com/nodecg/nodecg-cli/compare/v5.0.1...v6.0.0) (2018-12-03) 127 | 128 | ### Bug Fixes 129 | 130 | - **package:** update chalk to version 2.0.0 ([#44](https://github.com/nodecg/nodecg-cli/issues/44)) ([b19ddc1](https://github.com/nodecg/nodecg-cli/commit/b19ddc1)) 131 | - **package:** update inquirer to version 4.0.0 ([#52](https://github.com/nodecg/nodecg-cli/issues/52)) ([ef4560f](https://github.com/nodecg/nodecg-cli/commit/ef4560f)) 132 | 133 | ### Code Refactoring 134 | 135 | - port to ES6 ([b373fff](https://github.com/nodecg/nodecg-cli/commit/b373fff)) 136 | 137 | ### Features 138 | 139 | - add schema-types command ([#62](https://github.com/nodecg/nodecg-cli/issues/62)) ([237d734](https://github.com/nodecg/nodecg-cli/commit/237d734)) 140 | 141 | ### BREAKING CHANGES 142 | 143 | - drop support for Node 6 144 | - requires Node 6+ 145 | 146 | 147 | 148 | ## [5.0.1](https://github.com/nodecg/nodecg-cli/compare/v5.0.0...v5.0.1) (2016-03-06) 149 | 150 | ### Bug Fixes 151 | 152 | - **setup:** run `git fetch` before attempting to check out an updated release ([aa352c2](https://github.com/nodecg/nodecg-cli/commit/aa352c2)) 153 | 154 | 155 | 156 | # [5.0.0](https://github.com/nodecg/nodecg-cli/compare/v4.1.0...v5.0.0) (2016-03-02) 157 | 158 | ### Bug Fixes 159 | 160 | - **install:** I'm the worst, go back to using httpsUrl ([501264d](https://github.com/nodecg/nodecg-cli/commit/501264d)) 161 | - **install:** use gitUrl instead of httpsUrl ([8c483a3](https://github.com/nodecg/nodecg-cli/commit/8c483a3)) 162 | - **install:** use ssh instead of giturl ([7c25f0f](https://github.com/nodecg/nodecg-cli/commit/7c25f0f)) 163 | - **update:** fix error when installing bundle deps ([20ccda4](https://github.com/nodecg/nodecg-cli/commit/20ccda4)) 164 | 165 | ### Code Refactoring 166 | 167 | - **install:** use system bower ([1109d82](https://github.com/nodecg/nodecg-cli/commit/1109d82)) 168 | 169 | ### Features 170 | 171 | - **install:** install command now respects semver ranges, if supplied ([3be0c6a](https://github.com/nodecg/nodecg-cli/commit/3be0c6a)) 172 | - **setup:** add `-k` alias for `--skip-dependencies` ([fcd841a](https://github.com/nodecg/nodecg-cli/commit/fcd841a)) 173 | - **update:** remove update command while its functionality is re-evaluated ([52fbe07](https://github.com/nodecg/nodecg-cli/commit/52fbe07)) 174 | 175 | ### BREAKING CHANGES 176 | 177 | - update: remove update command 178 | - install: requires bower to be globally installed 179 | 180 | 181 | 182 | # [4.1.0](https://github.com/nodecg/nodecg-cli/compare/v4.0.0...v4.1.0) (2016-02-18) 183 | 184 | ### Features 185 | 186 | - **command:** Add defaultconfig command ([e247110](https://github.com/nodecg/nodecg-cli/commit/e247110)) 187 | 188 | 189 | 190 | # [4.0.0](https://github.com/nodecg/nodecg-cli/compare/v3.0.1...v4.0.0) (2016-02-07) 191 | 192 | 193 | 194 | ## [3.0.1](https://github.com/nodecg/nodecg-cli/compare/v3.0.0...v3.0.1) (2016-02-02) 195 | 196 | 197 | 198 | # [3.0.0](https://github.com/nodecg/nodecg-cli/compare/v2.2.4...v3.0.0) (2016-02-02) 199 | 200 | 201 | 202 | ## [2.2.4](https://github.com/nodecg/nodecg-cli/compare/v2.2.3...v2.2.4) (2015-04-23) 203 | 204 | 205 | 206 | ## [2.2.3](https://github.com/nodecg/nodecg-cli/compare/v2.2.1...v2.2.3) (2015-04-10) 207 | 208 | 209 | 210 | ## [2.2.1](https://github.com/nodecg/nodecg-cli/compare/v2.2.0...v2.2.1) (2015-02-20) 211 | 212 | 213 | 214 | # [2.2.0](https://github.com/nodecg/nodecg-cli/compare/v2.1.1...v2.2.0) (2015-02-20) 215 | 216 | 217 | 218 | ## [2.1.1](https://github.com/nodecg/nodecg-cli/compare/v2.1.0...v2.1.1) (2015-02-19) 219 | 220 | 221 | 222 | ## [2.0.1](https://github.com/nodecg/nodecg-cli/compare/v2.0.0...v2.0.1) (2015-02-18) 223 | 224 | 225 | 226 | # [2.0.0](https://github.com/nodecg/nodecg-cli/compare/v1.0.3...v2.0.0) (2015-02-18) 227 | 228 | 229 | 230 | ## [1.0.3](https://github.com/nodecg/nodecg-cli/compare/v1.0.1...v1.0.3) (2015-02-14) 231 | 232 | 233 | 234 | ## [1.0.1](https://github.com/nodecg/nodecg-cli/compare/v1.0.0...v1.0.1) (2015-01-24) 235 | 236 | 237 | 238 | # [1.0.0](https://github.com/nodecg/nodecg-cli/compare/v0.0.2...v1.0.0) (2015-01-18) 239 | 240 | 241 | 242 | ## [0.0.2](https://github.com/nodecg/nodecg-cli/compare/v0.0.1...v0.0.2) (2015-01-17) 243 | 244 | 245 | 246 | ## 0.0.1 (2015-01-16) 247 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 The NodeCG Project (https://nodecg.dev) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nodecg-cli [![CI](https://github.com/nodecg/nodecg-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/nodecg/nodecg-cli/actions/workflows/ci.yml) 2 | 3 | [NodeCG](https://github.com/nodecg/nodecg)'s command line interface. 4 | 5 | ## Deprecation Notice 6 | 7 | `nodecg-cli` has been migrated to [`nodecg`](github.com/nodecg/nodecg). This repository is now deprecated and will no longer be maintained. Please uninstall `nodecg-cli` and install `nodecg` instead. 8 | 9 | ```sh 10 | npm un -g nodecg-cli 11 | npm i -g nodecg 12 | ``` 13 | 14 | The `nodecg` includes the CLI from v2.4.0, which is equivalent to `nodecg-cli@9.0.1`. 15 | 16 | ## Compatibility 17 | 18 | - `nodecg-cli` version earlier than 8.6.1 is not compatible with NodeCG 2.x.x. 19 | - `nodecg-cli` version 9.0.0 and later are not compatible with NodeCG 0.x.x and 1.x.x. 20 | 21 | | NodeCG | nodecg-cli | 22 | | ------ | ---------- | 23 | | 0.x.x | < 9.0.0 | 24 | | 1.x.x | < 9.0.0 | 25 | | 2.x.x | >= 8.6.1 | 26 | 27 | ## Installation 28 | 29 | First, make sure you have [git](http://git-scm.com/) installed, and that it is in your PATH. 30 | 31 | Once those are installed, you may install nodecg-cli via npm: 32 | 33 | ```sh 34 | npm install -g nodecg-cli 35 | ``` 36 | 37 | Installing `nodecg-cli` does not install `NodeCG`. 38 | To install an instance of `NodeCG`, use the `setup` command in an empty directory: 39 | 40 | ```sh 41 | mkdir nodecg 42 | cd nodecg 43 | nodecg setup 44 | ``` 45 | 46 | ## Usage 47 | 48 | - `nodecg setup [version] [--update]`, install a new instance of NodeCG. `version` is a semver range. 49 | If `version` is not supplied, the latest release will be installed. 50 | Enable `--update` flag to install over an existing copy of NodeCG. 51 | - `nodecg start`, start the NodeCG instance in this directory path 52 | - `nodecg install [repo] [--dev]`, install a bundle by cloning a git repo. 53 | Can be a GitHub owner/repo pair (`supportclass/lfg-sublistener`) or https git url (`https://github.com/SupportClass/lfg-sublistener.git`). 54 | If run in a bundle directory with no arguments, installs that bundle's dependencies. 55 | Enable `--dev` flag to install the bundle's `devDependencies`. 56 | - `nodecg uninstall `, uninstall a bundle 57 | - `nodecg defaultconfig`, If a bundle has a `configschema.json` present in its root, this command will create a default 58 | config file at `nodecg/cfg/:bundleName.json` with defaults based on that schema. 59 | - `nodecg schema-types [dir]`, Generate d.ts TypeScript typedef files from Replicant schemas and configschema.json (if present) 60 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from "@eslint/js"; 4 | import tseslint from "typescript-eslint"; 5 | import prettier from "eslint-config-prettier"; 6 | import simpleImportSort from "eslint-plugin-simple-import-sort"; 7 | 8 | export default tseslint.config( 9 | eslint.configs.recommended, 10 | tseslint.configs.strictTypeChecked, 11 | tseslint.configs.stylisticTypeChecked, 12 | { 13 | languageOptions: { 14 | parserOptions: { 15 | projectService: true, 16 | tsconfigRootDir: import.meta.dirname, 17 | }, 18 | }, 19 | }, 20 | { 21 | rules: { 22 | "@typescript-eslint/no-explicit-any": "off", 23 | "@typescript-eslint/no-unused-vars": "off", 24 | "@typescript-eslint/no-unsafe-return": "off", 25 | "@typescript-eslint/no-unsafe-argument": "off", 26 | "@typescript-eslint/no-unsafe-assignment": "off", 27 | "@typescript-eslint/no-unsafe-member-access": "off", 28 | "@typescript-eslint/no-unsafe-call": "off", 29 | "@typescript-eslint/restrict-template-expressions": [ 30 | "error", 31 | { allowNumber: true }, 32 | ], 33 | }, 34 | }, 35 | { 36 | plugins: { 37 | "simple-import-sort": simpleImportSort, 38 | }, 39 | rules: { 40 | "simple-import-sort/imports": "error", 41 | "simple-import-sort/exports": "error", 42 | }, 43 | }, 44 | { 45 | files: ["test/**/*.ts"], 46 | rules: { 47 | "@typescript-eslint/no-non-null-assertion": "off", 48 | }, 49 | }, 50 | prettier, 51 | { 52 | ignores: [ 53 | "node_modules", 54 | "coverage", 55 | "dist", 56 | "test/fixtures", 57 | "eslint.config.js", 58 | ], 59 | }, 60 | ); 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodecg-cli", 3 | "version": "9.0.2", 4 | "description": "The NodeCG command line interface.", 5 | "bugs": { 6 | "url": "http://github.com/nodecg/nodecg-cli/issues" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "git://github.com/nodecg/nodecg-cli.git" 11 | }, 12 | "license": "MIT", 13 | "type": "module", 14 | "bin": { 15 | "nodecg": "dist/bin/nodecg.js" 16 | }, 17 | "files": [ 18 | "AUTHORS", 19 | "LICENSE", 20 | "README.md", 21 | "dist" 22 | ], 23 | "contributors": [ 24 | { 25 | "name": "Alex Van Camp", 26 | "email": "email@alexvan.camp", 27 | "url": "https://alexvan.camp/" 28 | }, 29 | { 30 | "name": "Matthew McNamara", 31 | "email": "matt@mattmcn.com", 32 | "url": "http://mattmcn.com/" 33 | }, 34 | { 35 | "name": "Keiichiro Amemiya", 36 | "email": "kei@hoishin.dev" 37 | } 38 | ], 39 | "scripts": { 40 | "build": "del-cli dist && tsc --build tsconfig.build.json", 41 | "dev": "tsc --build tsconfig.build.json --watch", 42 | "format": "prettier --write \"**/*.ts\"", 43 | "static": "run-s static:*", 44 | "static:prettier": "prettier --check \"**/*.ts\"", 45 | "static:eslint": "eslint --cache", 46 | "fix": "run-s fix:*", 47 | "fix:prettier": "prettier --write \"**/*.ts\"", 48 | "fix:eslint": "eslint --fix", 49 | "test": "vitest" 50 | }, 51 | "prettier": {}, 52 | "dependencies": { 53 | "@inquirer/prompts": "^7.2.1", 54 | "ajv": "^8.17.1", 55 | "chalk": "^5.4.1", 56 | "commander": "^12.1.0", 57 | "hosted-git-info": "^8.0.2", 58 | "json-schema-to-typescript": "^15.0.3", 59 | "npm-package-arg": "^12.0.1", 60 | "semver": "^7.6.3", 61 | "tar": "^7.4.3", 62 | "update-notifier": "^7.3.1" 63 | }, 64 | "devDependencies": { 65 | "@eslint/js": "^9.17.0", 66 | "@types/hosted-git-info": "^3.0.5", 67 | "@types/node": "18", 68 | "@types/npm-package-arg": "^6.1.4", 69 | "@types/semver": "^7.5.8", 70 | "@types/update-notifier": "^6.0.8", 71 | "del-cli": "^6.0.0", 72 | "eslint": "^9.17.0", 73 | "eslint-config-prettier": "^9.1.0", 74 | "eslint-plugin-simple-import-sort": "^12.1.1", 75 | "npm-run-all2": "^7.0.2", 76 | "prettier": "^3.4.2", 77 | "type-fest": "^4.31.0", 78 | "typescript": "~5.7.2", 79 | "typescript-eslint": "^8.18.2", 80 | "vitest": "^2.1.8" 81 | }, 82 | "engines": { 83 | "node": "^18.17.0 || ^20.9.0 || ^22.11.0" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/bin/nodecg.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { execSync } from "node:child_process"; 4 | 5 | import chalk from "chalk"; 6 | import updateNotifier from "update-notifier"; 7 | 8 | import packageJson from "../../package.json" with { type: "json" }; 9 | 10 | console.warn( 11 | "`nodecg-cli` package is deprecated. Please uninstall `nodecg-cli` and install `nodecg` instead.", 12 | ); 13 | 14 | updateNotifier({ pkg: packageJson }).notify(); 15 | 16 | try { 17 | execSync("git --version"); 18 | } catch { 19 | console.error( 20 | `nodecg-cli requires that ${chalk.cyan("git")} be available in your PATH.`, 21 | ); 22 | process.exit(1); 23 | } 24 | 25 | await import("../index.js"); 26 | -------------------------------------------------------------------------------- /src/commands/defaultconfig.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | 4 | import { Ajv, type JSONSchemaType } from "ajv"; 5 | import chalk from "chalk"; 6 | import { Command } from "commander"; 7 | 8 | import { getNodeCGPath, isBundleFolder } from "../lib/util.js"; 9 | 10 | const ajv = new Ajv({ useDefaults: true, strict: true }); 11 | 12 | export function defaultconfigCommand(program: Command) { 13 | program 14 | .command("defaultconfig [bundle]") 15 | .description("Generate default config from configschema.json") 16 | .action(action); 17 | } 18 | 19 | function action(bundleName?: string) { 20 | const cwd = process.cwd(); 21 | const nodecgPath = getNodeCGPath(); 22 | 23 | if (!bundleName) { 24 | if (isBundleFolder(cwd)) { 25 | bundleName = bundleName ?? path.basename(cwd); 26 | } else { 27 | console.error( 28 | `${chalk.red("Error:")} No bundle found in the current directory!`, 29 | ); 30 | return; 31 | } 32 | } 33 | 34 | const bundlePath = path.join(nodecgPath, "bundles/", bundleName); 35 | const schemaPath = path.join( 36 | nodecgPath, 37 | "bundles/", 38 | bundleName, 39 | "/configschema.json", 40 | ); 41 | const cfgPath = path.join(nodecgPath, "cfg/"); 42 | 43 | if (!fs.existsSync(bundlePath)) { 44 | console.error(`${chalk.red("Error:")} Bundle ${bundleName} does not exist`); 45 | return; 46 | } 47 | 48 | if (!fs.existsSync(schemaPath)) { 49 | console.error( 50 | `${chalk.red("Error:")} Bundle ${bundleName} does not have a configschema.json`, 51 | ); 52 | return; 53 | } 54 | 55 | if (!fs.existsSync(cfgPath)) { 56 | fs.mkdirSync(cfgPath); 57 | } 58 | 59 | const schema: JSONSchemaType = JSON.parse( 60 | fs.readFileSync(schemaPath, "utf8"), 61 | ); 62 | const configPath = path.join(nodecgPath, "cfg/", `${bundleName}.json`); 63 | if (fs.existsSync(configPath)) { 64 | console.error( 65 | `${chalk.red("Error:")} Bundle ${bundleName} already has a config file`, 66 | ); 67 | } else { 68 | try { 69 | const validate = ajv.compile(schema); 70 | const data = {}; 71 | validate(data); 72 | 73 | fs.writeFileSync(configPath, JSON.stringify(data, null, 2)); 74 | console.log( 75 | `${chalk.green("Success:")} Created ${chalk.bold(bundleName)}'s default config from schema\n`, 76 | ); 77 | } catch (error) { 78 | console.error(chalk.red("Error:"), error); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from "commander"; 2 | 3 | import { defaultconfigCommand } from "./defaultconfig.js"; 4 | import { installCommand } from "./install.js"; 5 | import { schemaTypesCommand } from "./schema-types.js"; 6 | import { setupCommand } from "./setup.js"; 7 | import { startCommand } from "./start.js"; 8 | import { uninstallCommand } from "./uninstall.js"; 9 | 10 | export function setupCommands(program: Command) { 11 | defaultconfigCommand(program); 12 | installCommand(program); 13 | schemaTypesCommand(program); 14 | setupCommand(program); 15 | startCommand(program); 16 | uninstallCommand(program); 17 | } 18 | -------------------------------------------------------------------------------- /src/commands/install.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "node:child_process"; 2 | import fs from "node:fs"; 3 | import os from "node:os"; 4 | import path from "node:path"; 5 | 6 | import chalk from "chalk"; 7 | import { Command } from "commander"; 8 | import HostedGitInfo from "hosted-git-info"; 9 | import npa from "npm-package-arg"; 10 | import semver, { SemVer } from "semver"; 11 | 12 | import { fetchTags } from "../lib/fetch-tags.js"; 13 | import { installBundleDeps } from "../lib/install-bundle-deps.js"; 14 | import { getNodeCGPath } from "../lib/util.js"; 15 | 16 | export function installCommand(program: Command) { 17 | program 18 | .command("install [repo]") 19 | .description( 20 | "Install a bundle by cloning a git repo. Can be a GitHub owner/repo pair or a git url." + 21 | "\n\t\t If run in a bundle directory with no arguments, installs that bundle's dependencies.", 22 | ) 23 | .option("-d, --dev", "install development npm & bower dependencies") 24 | .action(action); 25 | } 26 | 27 | function action(repo: string, options: { dev: boolean }) { 28 | const dev = options.dev || false; 29 | 30 | // If no args are supplied, assume the user is intending to operate on the bundle in the current dir 31 | if (!repo) { 32 | installBundleDeps(process.cwd(), dev); 33 | return; 34 | } 35 | 36 | let range = ""; 37 | if (repo.indexOf("#") > 0) { 38 | const repoParts = repo.split("#"); 39 | range = repoParts[1] ?? ""; 40 | repo = repoParts[0] ?? ""; 41 | } 42 | 43 | const nodecgPath = getNodeCGPath(); 44 | const parsed = npa(repo); 45 | if (!parsed.hosted) { 46 | console.error( 47 | "Please enter a valid git repository URL or GitHub username/repo pair.", 48 | ); 49 | return; 50 | } 51 | 52 | const hostedInfo = parsed.hosted as unknown as HostedGitInfo; 53 | const repoUrl = hostedInfo.https(); 54 | if (!repoUrl) { 55 | console.error( 56 | "Please enter a valid git repository URL or GitHub username/repo pair.", 57 | ); 58 | return; 59 | } 60 | 61 | // Check that `bundles` exists 62 | const bundlesPath = path.join(nodecgPath, "bundles"); 63 | /* istanbul ignore next: Simple directory creation, not necessary to test */ 64 | if (!fs.existsSync(bundlesPath)) { 65 | fs.mkdirSync(bundlesPath); 66 | } 67 | 68 | // Extract repo name from git url 69 | const temp = repoUrl.split("/").pop() ?? ""; 70 | const bundleName = temp.slice(0, temp.length - 4); 71 | const bundlePath = path.join(nodecgPath, "bundles/", bundleName); 72 | 73 | // Figure out what version to checkout 74 | process.stdout.write(`Fetching ${bundleName} release list... `); 75 | let tags; 76 | let target; 77 | try { 78 | tags = fetchTags(repoUrl); 79 | target = semver.maxSatisfying( 80 | tags 81 | .map((tag) => semver.coerce(tag)) 82 | .filter((coercedTag): coercedTag is SemVer => Boolean(coercedTag)), 83 | range, 84 | ); 85 | process.stdout.write(chalk.green("done!") + os.EOL); 86 | } catch (e: any) { 87 | /* istanbul ignore next */ 88 | process.stdout.write(chalk.red("failed!") + os.EOL); 89 | /* istanbul ignore next */ 90 | console.error(e.stack); 91 | /* istanbul ignore next */ 92 | return; 93 | } 94 | 95 | // Clone from github 96 | process.stdout.write(`Installing ${bundleName}... `); 97 | try { 98 | execSync(`git clone ${repoUrl} "${bundlePath}"`, { 99 | stdio: ["pipe", "pipe", "pipe"], 100 | }); 101 | process.stdout.write(chalk.green("done!") + os.EOL); 102 | } catch (e: any) { 103 | /* istanbul ignore next */ 104 | process.stdout.write(chalk.red("failed!") + os.EOL); 105 | /* istanbul ignore next */ 106 | console.error(e.stack); 107 | /* istanbul ignore next */ 108 | return; 109 | } 110 | 111 | // If a bundle has no git tags, target will be null. 112 | if (target) { 113 | process.stdout.write(`Checking out version ${target.version}... `); 114 | try { 115 | // First try the target as-is. 116 | execSync(`git checkout ${target.version}`, { 117 | cwd: bundlePath, 118 | stdio: ["pipe", "pipe", "pipe"], 119 | }); 120 | process.stdout.write(chalk.green("done!") + os.EOL); 121 | } catch (_) { 122 | try { 123 | // Next try prepending `v` to the target, which may have been stripped by `semver.coerce`. 124 | execSync(`git checkout v${target.version}`, { 125 | cwd: bundlePath, 126 | stdio: ["pipe", "pipe", "pipe"], 127 | }); 128 | process.stdout.write(chalk.green("done!") + os.EOL); 129 | } catch (e: any) { 130 | /* istanbul ignore next */ 131 | process.stdout.write(chalk.red("failed!") + os.EOL); 132 | /* istanbul ignore next */ 133 | console.error(e.stack); 134 | /* istanbul ignore next */ 135 | return; 136 | } 137 | } 138 | } 139 | 140 | // After installing the bundle, install its npm dependencies 141 | installBundleDeps(bundlePath, dev); 142 | } 143 | -------------------------------------------------------------------------------- /src/commands/schema-types.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | 4 | import chalk from "chalk"; 5 | import { Command } from "commander"; 6 | import { compileFromFile } from "json-schema-to-typescript"; 7 | 8 | export function schemaTypesCommand(program: Command) { 9 | program 10 | .command("schema-types [dir]") 11 | .option( 12 | "-o, --out-dir [path]", 13 | "Where to put the generated d.ts files", 14 | "src/types/schemas", 15 | ) 16 | .option( 17 | "--no-config-schema", 18 | "Don't generate a typedef from configschema.json", 19 | ) 20 | .description( 21 | "Generate d.ts TypeScript typedef files from Replicant schemas and configschema.json (if present)", 22 | ) 23 | .action(action); 24 | } 25 | 26 | function action(inDir: string, cmd: { outDir: string; configSchema: boolean }) { 27 | const processCwd = process.cwd(); 28 | const schemasDir = path.resolve(processCwd, inDir || "schemas"); 29 | if (!fs.existsSync(schemasDir)) { 30 | console.error(`${chalk.red("Error:")} Input directory does not exist`); 31 | return; 32 | } 33 | 34 | const outDir = path.resolve(processCwd, cmd.outDir); 35 | if (!fs.existsSync(outDir)) { 36 | fs.mkdirSync(outDir, { recursive: true }); 37 | } 38 | 39 | const configSchemaPath = path.join(processCwd, "configschema.json"); 40 | const schemas = fs.readdirSync(schemasDir).filter((f) => f.endsWith(".json")); 41 | 42 | const style = { 43 | singleQuote: true, 44 | useTabs: true, 45 | }; 46 | 47 | const compilePromises: Promise[] = []; 48 | const compile = (input: string, output: string, cwd = processCwd) => { 49 | const promise = compileFromFile(input, { 50 | cwd, 51 | declareExternallyReferenced: true, 52 | enableConstEnums: true, 53 | style, 54 | }) 55 | .then((ts) => 56 | fs.promises.writeFile(output, "/* prettier-ignore */\n" + ts), 57 | ) 58 | .then(() => { 59 | console.log(output); 60 | }) 61 | .catch((err: unknown) => { 62 | console.error(err); 63 | }); 64 | compilePromises.push(promise); 65 | }; 66 | 67 | if (fs.existsSync(configSchemaPath) && cmd.configSchema) { 68 | compile(configSchemaPath, path.resolve(outDir, "configschema.d.ts")); 69 | } 70 | 71 | for (const schema of schemas) { 72 | compile( 73 | path.resolve(schemasDir, schema), 74 | path.resolve(outDir, schema.replace(/\.json$/i, ".d.ts")), 75 | schemasDir, 76 | ); 77 | } 78 | 79 | return Promise.all(compilePromises).then(() => { 80 | (process.emit as any)("schema-types-done"); 81 | }); 82 | } 83 | -------------------------------------------------------------------------------- /src/commands/setup.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "node:child_process"; 2 | import fs from "node:fs"; 3 | import os from "node:os"; 4 | import stream from "node:stream/promises"; 5 | 6 | import { confirm } from "@inquirer/prompts"; 7 | import chalk from "chalk"; 8 | import { Command } from "commander"; 9 | import semver from "semver"; 10 | import * as tar from "tar"; 11 | 12 | import { fetchTags } from "../lib/fetch-tags.js"; 13 | import type { NpmRelease } from "../lib/sample/npm-release.js"; 14 | import { getCurrentNodeCGVersion, pathContainsNodeCG } from "../lib/util.js"; 15 | 16 | const NODECG_GIT_URL = "https://github.com/nodecg/nodecg.git"; 17 | 18 | export function setupCommand(program: Command) { 19 | program 20 | .command("setup [version]") 21 | .option("-u, --update", "Update the local NodeCG installation") 22 | .option( 23 | "-k, --skip-dependencies", 24 | "Skip installing npm & bower dependencies", 25 | ) 26 | .description("Install a new NodeCG instance") 27 | .action(decideActionVersion); 28 | } 29 | 30 | async function decideActionVersion( 31 | version: string, 32 | options: { update: boolean; skipDependencies: boolean }, 33 | ) { 34 | // If NodeCG is already installed but the `-u` flag was not supplied, display an error and return. 35 | let isUpdate = false; 36 | 37 | // If NodeCG exists in the cwd, but the `-u` flag was not supplied, display an error and return. 38 | // If it was supplied, fetch the latest tags and set the `isUpdate` flag to true for later use. 39 | // Else, if this is a clean, empty directory, then we need to clone a fresh copy of NodeCG into the cwd. 40 | if (pathContainsNodeCG(process.cwd())) { 41 | if (!options.update) { 42 | console.error("NodeCG is already installed in this directory."); 43 | console.error( 44 | `Use ${chalk.cyan("nodecg setup [version] -u")} if you want update your existing install.`, 45 | ); 46 | return; 47 | } 48 | 49 | isUpdate = true; 50 | } 51 | 52 | if (version) { 53 | process.stdout.write( 54 | `Finding latest release that satisfies semver range ${chalk.magenta(version)}... `, 55 | ); 56 | } else if (isUpdate) { 57 | process.stdout.write("Checking against local install for updates... "); 58 | } else { 59 | process.stdout.write("Finding latest release... "); 60 | } 61 | 62 | let tags; 63 | try { 64 | tags = fetchTags(NODECG_GIT_URL); 65 | } catch (error) { 66 | process.stdout.write(chalk.red("failed!") + os.EOL); 67 | console.error(error instanceof Error ? error.message : error); 68 | return; 69 | } 70 | 71 | let target: string; 72 | 73 | // If a version (or semver range) was supplied, find the latest release that satisfies the range. 74 | // Else, make the target the newest version. 75 | if (version) { 76 | const maxSatisfying = semver.maxSatisfying(tags, version); 77 | if (!maxSatisfying) { 78 | process.stdout.write(chalk.red("failed!") + os.EOL); 79 | console.error( 80 | `No releases match the supplied semver range (${chalk.magenta(version)})`, 81 | ); 82 | return; 83 | } 84 | 85 | target = maxSatisfying; 86 | } else { 87 | target = semver.maxSatisfying(tags, "") ?? ""; 88 | } 89 | 90 | process.stdout.write(chalk.green("done!") + os.EOL); 91 | 92 | let current: string | undefined; 93 | let downgrade = false; 94 | 95 | if (isUpdate) { 96 | current = getCurrentNodeCGVersion(); 97 | 98 | if (semver.eq(target, current)) { 99 | console.log( 100 | `The target version (${chalk.magenta(target)}) is equal to the current version (${chalk.magenta(current)}). No action will be taken.`, 101 | ); 102 | return; 103 | } 104 | 105 | if (semver.lt(target, current)) { 106 | console.log( 107 | `${chalk.red("WARNING:")} The target version (${chalk.magenta(target)}) is older than the current version (${chalk.magenta(current)})`, 108 | ); 109 | 110 | const answer = await confirm({ 111 | message: "Are you sure you wish to continue?", 112 | }); 113 | 114 | if (!answer) { 115 | console.log("Setup cancelled."); 116 | return; 117 | } 118 | 119 | downgrade = true; 120 | } 121 | } 122 | 123 | if (semver.lt(target, "v2.0.0")) { 124 | console.error( 125 | "nodecg-cli does not support NodeCG versions older than v2.0.0.", 126 | ); 127 | return; 128 | } 129 | 130 | await installNodecg(current, target, isUpdate); 131 | 132 | // Install NodeCG's dependencies 133 | // This operation takes a very long time, so we don't test it. 134 | if (!options.skipDependencies) { 135 | installDependencies(); 136 | } 137 | 138 | if (isUpdate) { 139 | const verb = downgrade ? "downgraded" : "upgraded"; 140 | console.log(`NodeCG ${verb} to ${chalk.magenta(target)}`); 141 | } else { 142 | console.log(`NodeCG (${target}) installed to ${process.cwd()}`); 143 | } 144 | } 145 | 146 | async function installNodecg( 147 | current: string | undefined, 148 | target: string, 149 | isUpdate: boolean, 150 | ) { 151 | if (isUpdate) { 152 | const deletingDirectories = [".git", "build", "scripts", "schemas"]; 153 | await Promise.all( 154 | deletingDirectories.map((dir) => 155 | fs.promises.rm(dir, { recursive: true, force: true }), 156 | ), 157 | ); 158 | } 159 | 160 | process.stdout.write(`Downloading ${target} from npm... `); 161 | 162 | const targetVersion = semver.coerce(target)?.version; 163 | if (!targetVersion) { 164 | throw new Error(`Failed to determine target NodeCG version`); 165 | } 166 | const releaseResponse = await fetch( 167 | `http://registry.npmjs.org/nodecg/${targetVersion}`, 168 | ); 169 | if (!releaseResponse.ok) { 170 | throw new Error( 171 | `Failed to fetch NodeCG release information from npm, status code ${releaseResponse.status}`, 172 | ); 173 | } 174 | const release = (await releaseResponse.json()) as NpmRelease; 175 | 176 | process.stdout.write(chalk.green("done!") + os.EOL); 177 | 178 | if (current) { 179 | const verb = semver.lt(target, current) ? "Downgrading" : "Upgrading"; 180 | process.stdout.write( 181 | `${verb} from ${chalk.magenta(current)} to ${chalk.magenta(target)}... `, 182 | ); 183 | } 184 | 185 | const tarballResponse = await fetch(release.dist.tarball); 186 | if (!tarballResponse.ok || !tarballResponse.body) { 187 | throw new Error( 188 | `Failed to fetch release tarball from ${release.dist.tarball}, status code ${tarballResponse.status}`, 189 | ); 190 | } 191 | await stream.pipeline(tarballResponse.body, tar.x({ strip: 1 })); 192 | } 193 | 194 | function installDependencies() { 195 | try { 196 | process.stdout.write("Installing production npm dependencies... "); 197 | execSync("npm install --production"); 198 | 199 | process.stdout.write(chalk.green("done!") + os.EOL); 200 | } catch (e: any) { 201 | process.stdout.write(chalk.red("failed!") + os.EOL); 202 | console.error(e); 203 | return; 204 | } 205 | 206 | if (fs.existsSync("./bower.json")) { 207 | process.stdout.write("Installing production bower dependencies... "); 208 | try { 209 | execSync("bower install --production", { 210 | stdio: ["pipe", "pipe", "pipe"], 211 | }); 212 | process.stdout.write(chalk.green("done!") + os.EOL); 213 | } catch (e: any) { 214 | process.stdout.write(chalk.red("failed!") + os.EOL); 215 | console.error(e.stack); 216 | } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/commands/start.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "node:child_process"; 2 | 3 | import { Command } from "commander"; 4 | 5 | import { pathContainsNodeCG } from "../lib/util.js"; 6 | 7 | export function startCommand(program: Command) { 8 | program 9 | .command("start") 10 | .option("-d, --disable-source-maps", "Disable source map support") 11 | .description("Start NodeCG") 12 | .action((options: { disableSourceMaps: boolean }) => { 13 | // Check if nodecg is already installed 14 | if (pathContainsNodeCG(process.cwd())) { 15 | if (options.disableSourceMaps) { 16 | execSync("node index.js", { stdio: "inherit" }); 17 | } else { 18 | execSync("node --enable-source-maps index.js", { stdio: "inherit" }); 19 | } 20 | } else { 21 | console.warn("No NodeCG installation found in this folder."); 22 | } 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /src/commands/uninstall.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import os from "node:os"; 3 | import path from "node:path"; 4 | 5 | import { confirm } from "@inquirer/prompts"; 6 | import chalk from "chalk"; 7 | import { Command } from "commander"; 8 | 9 | import { getNodeCGPath } from "../lib/util.js"; 10 | 11 | export function uninstallCommand(program: Command) { 12 | program 13 | .command("uninstall ") 14 | .description("Uninstalls a bundle.") 15 | .option("-f, --force", "ignore warnings") 16 | .action(action); 17 | } 18 | 19 | function action(bundleName: string, options: { force: boolean }) { 20 | const nodecgPath = getNodeCGPath(); 21 | const bundlePath = path.join(nodecgPath, "bundles/", bundleName); 22 | 23 | if (!fs.existsSync(bundlePath)) { 24 | console.error( 25 | `Cannot uninstall ${chalk.magenta(bundleName)}: bundle is not installed.`, 26 | ); 27 | return; 28 | } 29 | 30 | /* istanbul ignore if: deleteBundle() is tested in the else path */ 31 | if (options.force) { 32 | deleteBundle(bundleName, bundlePath); 33 | } else { 34 | void confirm({ 35 | message: `Are you sure you wish to uninstall ${chalk.magenta(bundleName)}?`, 36 | }).then((answer) => { 37 | if (answer) { 38 | deleteBundle(bundleName, bundlePath); 39 | } 40 | }); 41 | } 42 | } 43 | 44 | function deleteBundle(name: string, path: string) { 45 | if (!fs.existsSync(path)) { 46 | console.log("Nothing to uninstall."); 47 | return; 48 | } 49 | 50 | process.stdout.write(`Uninstalling ${chalk.magenta(name)}... `); 51 | try { 52 | fs.rmSync(path, { recursive: true, force: true }); 53 | } catch (e: any) { 54 | /* istanbul ignore next */ 55 | process.stdout.write(chalk.red("failed!") + os.EOL); 56 | /* istanbul ignore next */ 57 | console.error(e.stack); 58 | /* istanbul ignore next */ 59 | return; 60 | } 61 | 62 | process.stdout.write(chalk.green("done!") + os.EOL); 63 | } 64 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | process.title = "nodecg"; 2 | 3 | import { Command } from "commander"; 4 | 5 | import packageJson from "../package.json" with { type: "json" }; 6 | import { setupCommands } from "./commands/index.js"; 7 | 8 | const program = new Command("nodecg"); 9 | 10 | // Initialise CLI 11 | program.version(packageJson.version).usage(" [options]"); 12 | 13 | // Initialise commands 14 | setupCommands(program); 15 | 16 | // Handle unknown commands 17 | program.on("*", () => { 18 | console.log("Unknown command:", program.args.join(" ")); 19 | program.help(); 20 | }); 21 | 22 | // Print help if no commands were given 23 | if (!process.argv.slice(2).length) { 24 | program.help(); 25 | } 26 | 27 | // Process commands 28 | program.parse(process.argv); 29 | -------------------------------------------------------------------------------- /src/lib/fetch-tags.ts: -------------------------------------------------------------------------------- 1 | import { spawnSync } from "node:child_process"; 2 | 3 | export function fetchTags(repoUrl: string) { 4 | return spawnSync("git", ["ls-remote", "--refs", "--tags", repoUrl]) 5 | .stdout.toString("utf-8") 6 | .trim() 7 | .split("\n") 8 | .map((rawTag) => rawTag.split("refs/tags/").at(-1)) 9 | .filter((t) => typeof t === "string"); 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/install-bundle-deps.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "node:child_process"; 2 | import fs from "node:fs"; 3 | import os from "node:os"; 4 | import path from "node:path"; 5 | 6 | import chalk from "chalk"; 7 | 8 | import { isBundleFolder } from "./util.js"; 9 | 10 | /** 11 | * Installs npm and bower dependencies for the NodeCG bundle present at the given path. 12 | * @param bundlePath - The path of the NodeCG bundle to install dependencies for. 13 | * @param installDev - Whether to install devDependencies. 14 | */ 15 | export function installBundleDeps(bundlePath: string, installDev = false) { 16 | if (!isBundleFolder(bundlePath)) { 17 | console.error( 18 | `${chalk.red("Error:")} There doesn't seem to be a valid NodeCG bundle in this folder:\n\t${chalk.magenta(bundlePath)}`, 19 | ); 20 | process.exit(1); 21 | } 22 | 23 | let cmdline; 24 | 25 | const cachedCwd = process.cwd(); 26 | if (fs.existsSync(path.join(bundlePath, "package.json"))) { 27 | process.chdir(bundlePath); 28 | let cmdline: string; 29 | if (fs.existsSync(path.join(bundlePath, "yarn.lock"))) { 30 | cmdline = installDev ? "yarn" : "yarn --production"; 31 | process.stdout.write( 32 | `Installling npm dependencies with yarn (dev: ${installDev})... `, 33 | ); 34 | } else { 35 | cmdline = installDev ? "npm install" : "npm install --production"; 36 | process.stdout.write( 37 | `Installing npm dependencies (dev: ${installDev})... `, 38 | ); 39 | } 40 | 41 | try { 42 | execSync(cmdline, { 43 | cwd: bundlePath, 44 | stdio: ["pipe", "pipe", "pipe"], 45 | }); 46 | process.stdout.write(chalk.green("done!") + os.EOL); 47 | } catch (e: any) { 48 | /* istanbul ignore next */ 49 | process.stdout.write(chalk.red("failed!") + os.EOL); 50 | /* istanbul ignore next */ 51 | console.error(e.stack); 52 | /* istanbul ignore next */ 53 | return; 54 | } 55 | 56 | process.chdir(cachedCwd); 57 | } 58 | 59 | if (fs.existsSync(path.join(bundlePath, "bower.json"))) { 60 | cmdline = installDev ? "bower install" : "bower install --production"; 61 | process.stdout.write( 62 | `Installing bower dependencies (dev: ${installDev})... `, 63 | ); 64 | try { 65 | execSync(cmdline, { 66 | cwd: bundlePath, 67 | stdio: ["pipe", "pipe", "pipe"], 68 | }); 69 | process.stdout.write(chalk.green("done!") + os.EOL); 70 | } catch (e: any) { 71 | /* istanbul ignore next */ 72 | process.stdout.write(chalk.red("failed!") + os.EOL); 73 | /* istanbul ignore next */ 74 | console.error(e.stack); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/lib/sample/npm-release.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodecg-cli", 3 | "version": "8.7.0", 4 | "license": "MIT", 5 | "_id": "nodecg-cli@8.7.0", 6 | "maintainers": [ 7 | { "name": "mattmcnam", "email": "matt@mattmcn.com" }, 8 | { "name": "hoishin", "email": "hoishinxii@gmail.com" } 9 | ], 10 | "contributors": [ 11 | { 12 | "url": "https://alexvan.camp/", 13 | "name": "Alex Van Camp", 14 | "email": "email@alexvan.camp" 15 | }, 16 | { 17 | "url": "http://mattmcn.com/", 18 | "name": "Matthew McNamara", 19 | "email": "matt@mattmcn.com" 20 | }, 21 | { "name": "Keiichiro Amemiya", "email": "kei@hoishin.dev" } 22 | ], 23 | "homepage": "https://github.com/nodecg/nodecg-cli#readme", 24 | "bugs": { "url": "http://github.com/nodecg/nodecg-cli/issues" }, 25 | "bin": { "nodecg": "dist/bin/nodecg.js" }, 26 | "dist": { 27 | "shasum": "bd59db2d98c2077bc03623cb9be59ff45fb511a2", 28 | "tarball": "https://registry.npmjs.org/nodecg-cli/-/nodecg-cli-8.7.0.tgz", 29 | "fileCount": 28, 30 | "integrity": "sha512-RcoE8PhtivBz1dtKDmgBh3MTozckwymcPr9UC1oiagYMlDLoTvLC1Bqssef5h+rNNK/aBeVB33leySFEQWNbjQ==", 31 | "signatures": [ 32 | { 33 | "sig": "MEUCIQDuXO/x48us+Bt7A9SskHcLDaAmDFhEDCgbMcTfolZHUQIgNLF0GtmhOF5COKdZue+HwYIRAeO8KbScnZeE/U6PQe4=", 34 | "keyid": "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA" 35 | } 36 | ], 37 | "unpackedSize": 155193 38 | }, 39 | "type": "module", 40 | "engines": { "node": "^18.17.0 || ^20.9.0 || ^22.11.0" }, 41 | "gitHead": "15d3992ec6d6eeb874d9c20473a63435689acbc9", 42 | "scripts": { 43 | "dev": "tsc --build tsconfig.build.json --watch", 44 | "fix": "run-s fix:*", 45 | "test": "vitest", 46 | "build": "del-cli dist && tsc --build tsconfig.build.json", 47 | "format": "prettier --write \"**/*.ts\"", 48 | "static": "run-s static:*", 49 | "fix:eslint": "eslint --fix", 50 | "fix:prettier": "prettier --write \"**/*.ts\"", 51 | "static:eslint": "eslint --cache", 52 | "static:prettier": "prettier --check \"**/*.ts\"" 53 | }, 54 | "_npmUser": { "name": "hoishin", "email": "hoishinxii@gmail.com" }, 55 | "prettier": {}, 56 | "repository": { 57 | "url": "git://github.com/nodecg/nodecg-cli.git", 58 | "type": "git" 59 | }, 60 | "_npmVersion": "10.9.0", 61 | "description": "The NodeCG command line interface.", 62 | "directories": {}, 63 | "_nodeVersion": "22.12.0", 64 | "dependencies": { 65 | "tar": "^7.4.3", 66 | "chalk": "^5.4.1", 67 | "semver": "^7.6.3", 68 | "inquirer": "^12.3.0", 69 | "commander": "^12.1.0", 70 | "hosted-git-info": "^8.0.2", 71 | "npm-package-arg": "^12.0.1", 72 | "json-schema-defaults": "0.4.0", 73 | "json-schema-to-typescript": "^15.0.3" 74 | }, 75 | "_hasShrinkwrap": false, 76 | "devDependencies": { 77 | "eslint": "^9.17.0", 78 | "vitest": "^2.1.8", 79 | "del-cli": "^6.0.0", 80 | "prettier": "^3.4.2", 81 | "type-fest": "^4.31.0", 82 | "@eslint/js": "^9.17.0", 83 | "typescript": "~5.7.2", 84 | "@types/node": "18", 85 | "npm-run-all2": "^7.0.2", 86 | "@types/semver": "^7.5.8", 87 | "typescript-eslint": "^8.18.2", 88 | "@vitest/coverage-v8": "^2.1.8", 89 | "@types/hosted-git-info": "^3.0.5", 90 | "@types/npm-package-arg": "^6.1.4", 91 | "eslint-config-prettier": "^9.1.0", 92 | "eslint-plugin-simple-import-sort": "^12.1.1" 93 | }, 94 | "_npmOperationalInternal": { 95 | "tmp": "tmp/nodecg-cli_8.7.0_1735252303987_0.756945223884677", 96 | "host": "s3://npm-registry-packages-npm-production" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/lib/sample/npm-release.ts: -------------------------------------------------------------------------------- 1 | import npmRelease from "./npm-release.json" with { type: "json" }; 2 | 3 | export type NpmRelease = typeof npmRelease; 4 | -------------------------------------------------------------------------------- /src/lib/util.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | 4 | /** 5 | * Checks if the given directory contains a NodeCG installation. 6 | * @param pathToCheck 7 | */ 8 | export function pathContainsNodeCG(pathToCheck: string): boolean { 9 | const pjsonPath = path.join(pathToCheck, "package.json"); 10 | try { 11 | const pjson = JSON.parse(fs.readFileSync(pjsonPath, "utf-8")); 12 | return pjson.name.toLowerCase() === "nodecg"; 13 | } catch { 14 | return false; 15 | } 16 | } 17 | 18 | /** 19 | * Gets the nearest NodeCG installation folder. First looks in process.cwd(), then looks 20 | * in every parent folder until reaching the root. Throws an error if no NodeCG installation 21 | * could be found. 22 | */ 23 | export function getNodeCGPath() { 24 | let curr = process.cwd(); 25 | do { 26 | if (pathContainsNodeCG(curr)) { 27 | return curr; 28 | } 29 | 30 | const nextCurr = path.resolve(curr, ".."); 31 | if (nextCurr === curr) { 32 | throw new Error( 33 | "NodeCG installation could not be found in this directory or any parent directory.", 34 | ); 35 | } 36 | 37 | curr = nextCurr; 38 | } while (fs.lstatSync(curr).isDirectory()); 39 | 40 | throw new Error( 41 | "NodeCG installation could not be found in this directory or any parent directory.", 42 | ); 43 | } 44 | 45 | /** 46 | * Checks if the given directory is a NodeCG bundle. 47 | */ 48 | export function isBundleFolder(pathToCheck: string) { 49 | const pjsonPath = path.join(pathToCheck, "package.json"); 50 | if (fs.existsSync(pjsonPath)) { 51 | const pjson = JSON.parse(fs.readFileSync(pjsonPath, "utf8")); 52 | return typeof pjson.nodecg === "object"; 53 | } 54 | 55 | return false; 56 | } 57 | 58 | /** 59 | * Gets the currently-installed NodeCG version string, in the format "vX.Y.Z" 60 | */ 61 | export function getCurrentNodeCGVersion(): string { 62 | const nodecgPath = getNodeCGPath(); 63 | return JSON.parse(fs.readFileSync(`${nodecgPath}/package.json`, "utf8")) 64 | .version; 65 | } 66 | -------------------------------------------------------------------------------- /test/commands/defaultconfig.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | 5 | import { Command } from "commander"; 6 | import { beforeEach, describe, expect, it, vi } from "vitest"; 7 | 8 | import { defaultconfigCommand } from "../../src/commands/defaultconfig.js"; 9 | import { createMockProgram, MockCommand } from "../mocks/program.js"; 10 | import { setupTmpDir } from "./tmp-dir.js"; 11 | 12 | const dirname = path.dirname(fileURLToPath(import.meta.url)); 13 | 14 | let program: MockCommand; 15 | 16 | beforeEach(() => { 17 | // Set up environment. 18 | const tempFolder = setupTmpDir(); 19 | process.chdir(tempFolder); 20 | fs.writeFileSync("package.json", JSON.stringify({ name: "nodecg" })); 21 | 22 | // Copy fixtures. 23 | fs.cpSync(path.resolve(dirname, "../fixtures/"), "./", { recursive: true }); 24 | 25 | // Build program. 26 | program = createMockProgram(); 27 | defaultconfigCommand(program as unknown as Command); 28 | }); 29 | 30 | describe("when run with a bundle argument", () => { 31 | it("should successfully create a bundle config file when bundle has configschema.json", async () => { 32 | await program.runWith("defaultconfig config-schema"); 33 | const config = JSON.parse( 34 | fs.readFileSync("./cfg/config-schema.json", { encoding: "utf8" }), 35 | ); 36 | expect(config.username).toBe("user"); 37 | expect(config.value).toBe(5); 38 | expect(config.nodefault).toBeUndefined(); 39 | }); 40 | 41 | it("should print an error when the target bundle does not have a configschema.json", async () => { 42 | const spy = vi.spyOn(console, "error"); 43 | fs.mkdirSync( 44 | path.resolve(process.cwd(), "./bundles/missing-schema-bundle"), 45 | { recursive: true }, 46 | ); 47 | await program.runWith("defaultconfig missing-schema-bundle"); 48 | expect(spy.mock.calls[0]).toMatchInlineSnapshot( 49 | ` 50 | [ 51 | "Error: Bundle missing-schema-bundle does not have a configschema.json", 52 | ] 53 | `, 54 | ); 55 | spy.mockRestore(); 56 | }); 57 | 58 | it("should print an error when the target bundle does not exist", async () => { 59 | const spy = vi.spyOn(console, "error"); 60 | await program.runWith("defaultconfig not-installed"); 61 | expect(spy.mock.calls[0]).toMatchInlineSnapshot( 62 | ` 63 | [ 64 | "Error: Bundle not-installed does not exist", 65 | ] 66 | `, 67 | ); 68 | spy.mockRestore(); 69 | }); 70 | 71 | it("should print an error when the target bundle already has a config", async () => { 72 | const spy = vi.spyOn(console, "error"); 73 | fs.mkdirSync("./cfg"); 74 | fs.writeFileSync( 75 | "./cfg/config-schema.json", 76 | JSON.stringify({ fake: "data" }), 77 | ); 78 | await program.runWith("defaultconfig config-schema"); 79 | expect(spy.mock.calls[0]).toMatchInlineSnapshot( 80 | ` 81 | [ 82 | "Error: Bundle config-schema already has a config file", 83 | ] 84 | `, 85 | ); 86 | spy.mockRestore(); 87 | }); 88 | }); 89 | 90 | describe("when run with no arguments", () => { 91 | it("should successfully create a bundle config file when run from inside bundle directory", async () => { 92 | process.chdir("./bundles/config-schema"); 93 | await program.runWith("defaultconfig"); 94 | expect(fs.existsSync("../../cfg/config-schema.json")).toBe(true); 95 | }); 96 | 97 | it("should print an error when in a folder with no package.json", async () => { 98 | fs.mkdirSync(path.resolve(process.cwd(), "./bundles/not-a-bundle"), { 99 | recursive: true, 100 | }); 101 | process.chdir("./bundles/not-a-bundle"); 102 | 103 | const spy = vi.spyOn(console, "error"); 104 | await program.runWith("defaultconfig"); 105 | expect(spy.mock.calls[0]).toMatchInlineSnapshot( 106 | ` 107 | [ 108 | "Error: No bundle found in the current directory!", 109 | ] 110 | `, 111 | ); 112 | spy.mockRestore(); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /test/commands/install.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | 3 | import { Command } from "commander"; 4 | import semver from "semver"; 5 | import { beforeEach, expect, it, vi } from "vitest"; 6 | 7 | import { installCommand } from "../../src/commands/install.js"; 8 | import { createMockProgram, MockCommand } from "../mocks/program.js"; 9 | import { setupTmpDir } from "./tmp-dir.js"; 10 | 11 | let program: MockCommand; 12 | const tempFolder = setupTmpDir(); 13 | process.chdir(tempFolder); 14 | fs.writeFileSync("package.json", JSON.stringify({ name: "nodecg" })); 15 | 16 | beforeEach(() => { 17 | program = createMockProgram(); 18 | installCommand(program as unknown as Command); 19 | }); 20 | 21 | it("should install a bundle and its dependencies", async () => { 22 | await program.runWith("install supportclass/lfg-streamtip"); 23 | expect(fs.existsSync("./bundles/lfg-streamtip/package.json")).toBe(true); 24 | expect( 25 | fs.readdirSync("./bundles/lfg-streamtip/node_modules").length, 26 | ).toBeGreaterThan(0); 27 | expect( 28 | fs.readdirSync("./bundles/lfg-streamtip/bower_components").length, 29 | ).toBeGreaterThan(0); 30 | }); 31 | 32 | it("should install a version that satisfies a provided semver range", async () => { 33 | await program.runWith("install supportclass/lfg-nucleus#^1.1.0"); 34 | expect(fs.existsSync("./bundles/lfg-nucleus/package.json")).toBe(true); 35 | 36 | const pjson = JSON.parse( 37 | fs.readFileSync("./bundles/lfg-nucleus/package.json", { 38 | encoding: "utf8", 39 | }), 40 | ); 41 | expect(semver.satisfies(pjson.version, "^1.1.0")).toBe(true); 42 | }); 43 | 44 | it("should install bower & npm dependencies when run with no arguments in a bundle directory", async () => { 45 | fs.rmSync("./bundles/lfg-streamtip/node_modules", { 46 | recursive: true, 47 | force: true, 48 | }); 49 | fs.rmSync("./bundles/lfg-streamtip/bower_components", { 50 | recursive: true, 51 | force: true, 52 | }); 53 | 54 | process.chdir("./bundles/lfg-streamtip"); 55 | await program.runWith("install"); 56 | expect(fs.readdirSync("./node_modules").length).toBeGreaterThan(0); 57 | expect(fs.readdirSync("./bower_components").length).toBeGreaterThan(0); 58 | }); 59 | 60 | it("should print an error when no valid git repo is provided", async () => { 61 | const spy = vi.spyOn(console, "error"); 62 | await program.runWith("install 123"); 63 | expect(spy).toBeCalledWith( 64 | "Please enter a valid git repository URL or GitHub username/repo pair.", 65 | ); 66 | spy.mockRestore(); 67 | }); 68 | -------------------------------------------------------------------------------- /test/commands/schema-types.spec.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "node:events"; 2 | import fs from "node:fs"; 3 | import path from "node:path"; 4 | import { fileURLToPath } from "node:url"; 5 | 6 | import { beforeEach, expect, it, vi } from "vitest"; 7 | 8 | import { schemaTypesCommand } from "../../src/commands/schema-types.js"; 9 | import { createMockProgram, MockCommand } from "../mocks/program.js"; 10 | import { setupTmpDir } from "./tmp-dir.js"; 11 | 12 | const dirname = path.dirname(fileURLToPath(import.meta.url)); 13 | 14 | let program: MockCommand; 15 | 16 | beforeEach(() => { 17 | // Set up environment. 18 | const tempFolder = setupTmpDir(); 19 | process.chdir(tempFolder); 20 | fs.writeFileSync("package.json", JSON.stringify({ name: "nodecg" })); 21 | 22 | // Copy fixtures. 23 | fs.cpSync(path.resolve(dirname, "../fixtures/"), "./", { recursive: true }); 24 | 25 | // Build program. 26 | program = createMockProgram(); 27 | schemaTypesCommand(program as any); 28 | }); 29 | 30 | it("should successfully create d.ts files from the replicant schemas and create an index.d.ts file", async () => { 31 | process.chdir("bundles/schema-types"); 32 | 33 | /* 34 | * Commander has no return values for command invocations. 35 | * This means that if your command returns a promise (or is otherwise async), 36 | * there is no way to get a reference to that promise to await it. 37 | * The command is just invoked by a dispatched event, with no 38 | * way to access the return value of your command's action. 39 | * 40 | * This makes testing async actions very challenging. 41 | * 42 | * Our current solution is to hack custom events onto the process global. 43 | * It's gross, but whatever. It works for now. 44 | */ 45 | await Promise.all([ 46 | program.runWith("schema-types"), 47 | waitForEvent(process, "schema-types-done"), 48 | ]); 49 | 50 | const outputPath = "./src/types/schemas/example.d.ts"; 51 | expect(fs.existsSync(outputPath)).toBe(true); 52 | 53 | expect(fs.readFileSync(outputPath, "utf8")).toMatchInlineSnapshot(` 54 | "/* prettier-ignore */ 55 | /* eslint-disable */ 56 | /** 57 | * This file was automatically generated by json-schema-to-typescript. 58 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 59 | * and run json-schema-to-typescript to regenerate this file. 60 | */ 61 | 62 | export interface Example { 63 | ip: string; 64 | port: number; 65 | password: string; 66 | status: 'connected' | 'connecting' | 'disconnected' | 'error'; 67 | } 68 | " 69 | `); 70 | }); 71 | 72 | it("should print an error when the target bundle does not have a schemas dir", async () => { 73 | process.chdir("bundles/uninstall-test"); 74 | const spy = vi.spyOn(console, "error"); 75 | await program.runWith("schema-types"); 76 | expect(spy.mock.calls[0]).toMatchInlineSnapshot( 77 | ` 78 | [ 79 | "Error: Input directory does not exist", 80 | ] 81 | `, 82 | ); 83 | spy.mockRestore(); 84 | }); 85 | 86 | it("should successfully compile the config schema", async () => { 87 | process.chdir("bundles/config-schema"); 88 | fs.mkdirSync("empty-dir"); 89 | 90 | await Promise.all([ 91 | program.runWith("schema-types empty-dir"), 92 | waitForEvent(process, "schema-types-done"), 93 | ]); 94 | 95 | const outputPath = "./src/types/schemas/configschema.d.ts"; 96 | expect(fs.existsSync(outputPath)).toBe(true); 97 | 98 | expect(fs.readFileSync(outputPath, "utf8")).toMatchInlineSnapshot(` 99 | "/* prettier-ignore */ 100 | /* eslint-disable */ 101 | /** 102 | * This file was automatically generated by json-schema-to-typescript. 103 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 104 | * and run json-schema-to-typescript to regenerate this file. 105 | */ 106 | 107 | export interface Configschema { 108 | username?: string; 109 | value?: number; 110 | nodefault?: string; 111 | [k: string]: unknown; 112 | } 113 | " 114 | `); 115 | }); 116 | 117 | async function waitForEvent(emitter: EventEmitter, eventName: string) { 118 | return new Promise((resolve) => { 119 | emitter.on(eventName, () => { 120 | resolve(); 121 | }); 122 | }); 123 | } 124 | -------------------------------------------------------------------------------- /test/commands/setup.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | 3 | import { Command } from "commander"; 4 | import type { PackageJson } from "type-fest"; 5 | import { beforeEach, expect, test, vi } from "vitest"; 6 | 7 | import { setupCommand } from "../../src/commands/setup.js"; 8 | import { createMockProgram, MockCommand } from "../mocks/program.js"; 9 | import { setupTmpDir } from "./tmp-dir.js"; 10 | 11 | vi.mock("@inquirer/prompts", () => ({ confirm: () => Promise.resolve(true) })); 12 | 13 | let program: MockCommand; 14 | let currentDir = setupTmpDir(); 15 | const chdir = (keepCurrentDir = false) => { 16 | if (!keepCurrentDir) { 17 | currentDir = setupTmpDir(); 18 | } 19 | 20 | process.chdir(currentDir); 21 | }; 22 | 23 | const readPackageJson = (): PackageJson => { 24 | return JSON.parse(fs.readFileSync("./package.json", { encoding: "utf8" })); 25 | }; 26 | 27 | beforeEach(() => { 28 | chdir(true); 29 | program = createMockProgram(); 30 | setupCommand(program as unknown as Command); 31 | }); 32 | 33 | test("should install the latest NodeCG when no version is specified", async () => { 34 | chdir(); 35 | await program.runWith("setup --skip-dependencies"); 36 | expect(readPackageJson().name).toBe("nodecg"); 37 | }); 38 | 39 | test("should install v2 NodeCG when specified", async () => { 40 | chdir(); 41 | await program.runWith("setup 2.0.0 --skip-dependencies"); 42 | expect(readPackageJson().name).toBe("nodecg"); 43 | expect(readPackageJson().version).toBe("2.0.0"); 44 | 45 | await program.runWith("setup 2.1.0 -u --skip-dependencies"); 46 | expect(readPackageJson().version).toBe("2.1.0"); 47 | 48 | await program.runWith("setup 2.0.0 -u --skip-dependencies"); 49 | expect(readPackageJson().version).toBe("2.0.0"); 50 | }); 51 | 52 | test("install NodeCG with dependencies", async () => { 53 | chdir(); 54 | await program.runWith("setup 2.4.0"); 55 | expect(readPackageJson().name).toBe("nodecg"); 56 | expect(readPackageJson().version).toBe("2.4.0"); 57 | expect(fs.readdirSync(".")).toContain("node_modules"); 58 | }); 59 | 60 | test("should throw when trying to install v1 NodeCG", async () => { 61 | chdir(); 62 | const consoleError = vi.spyOn(console, "error"); 63 | await program.runWith("setup 1.9.0 -u --skip-dependencies"); 64 | expect(consoleError.mock.calls[0]).toMatchInlineSnapshot(` 65 | [ 66 | "nodecg-cli does not support NodeCG versions older than v2.0.0.", 67 | ] 68 | `); 69 | }); 70 | 71 | test("should print an error when the target version is the same as current", async () => { 72 | chdir(); 73 | const spy = vi.spyOn(console, "log"); 74 | await program.runWith("setup 2.1.0 --skip-dependencies"); 75 | await program.runWith("setup 2.1.0 -u --skip-dependencies"); 76 | expect(spy.mock.calls[1]).toMatchInlineSnapshot(` 77 | [ 78 | "The target version (v2.1.0) is equal to the current version (2.1.0). No action will be taken.", 79 | ] 80 | `); 81 | spy.mockRestore(); 82 | }); 83 | 84 | test("should print an error when the target version doesn't exist", async () => { 85 | chdir(); 86 | const spy = vi.spyOn(console, "error"); 87 | await program.runWith("setup 999.999.999 --skip-dependencies"); 88 | expect(spy.mock.calls[0]).toMatchInlineSnapshot(` 89 | [ 90 | "No releases match the supplied semver range (999.999.999)", 91 | ] 92 | `); 93 | spy.mockRestore(); 94 | }); 95 | 96 | test("should print an error and exit, when nodecg is already installed in the current directory ", async () => { 97 | chdir(); 98 | const spy = vi.spyOn(console, "error"); 99 | await program.runWith("setup 2.0.0 --skip-dependencies"); 100 | await program.runWith("setup 2.0.0 --skip-dependencies"); 101 | expect(spy).toBeCalledWith("NodeCG is already installed in this directory."); 102 | spy.mockRestore(); 103 | }); 104 | -------------------------------------------------------------------------------- /test/commands/tmp-dir.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from "node:crypto"; 2 | import { mkdirSync } from "node:fs"; 3 | import { tmpdir } from "node:os"; 4 | import path from "node:path"; 5 | 6 | export function setupTmpDir() { 7 | const dir = path.join(tmpdir(), randomUUID()); 8 | mkdirSync(dir); 9 | return dir; 10 | } 11 | -------------------------------------------------------------------------------- /test/commands/uninstall.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | 5 | import { Command } from "commander"; 6 | import { beforeEach, expect, it, vi } from "vitest"; 7 | 8 | import { uninstallCommand } from "../../src/commands/uninstall.js"; 9 | import { createMockProgram, MockCommand } from "../mocks/program.js"; 10 | import { setupTmpDir } from "./tmp-dir.js"; 11 | 12 | vi.mock("@inquirer/prompts", () => ({ confirm: () => Promise.resolve(true) })); 13 | 14 | const dirname = path.dirname(fileURLToPath(import.meta.url)); 15 | 16 | let program: MockCommand; 17 | 18 | beforeEach(() => { 19 | // Set up environment. 20 | const tempFolder = setupTmpDir(); 21 | process.chdir(tempFolder); 22 | fs.writeFileSync("package.json", JSON.stringify({ name: "nodecg" })); 23 | 24 | // Copy fixtures. 25 | fs.cpSync(path.resolve(dirname, "../fixtures/"), "./", { recursive: true }); 26 | 27 | // Build program. 28 | program = createMockProgram(); 29 | uninstallCommand(program as unknown as Command); 30 | }); 31 | 32 | it("should delete the bundle's folder after prompting for confirmation", async () => { 33 | await program.runWith("uninstall uninstall-test"); 34 | expect(fs.existsSync("./bundles/uninstall-test")).toBe(false); 35 | }); 36 | 37 | it("should print an error when the target bundle is not installed", async () => { 38 | const spy = vi.spyOn(console, "error"); 39 | await program.runWith("uninstall not-installed"); 40 | expect(spy.mock.calls[0]).toMatchInlineSnapshot(` 41 | [ 42 | "Cannot uninstall not-installed: bundle is not installed.", 43 | ] 44 | `); 45 | spy.mockRestore(); 46 | }); 47 | -------------------------------------------------------------------------------- /test/fixtures/bundles/config-schema/configschema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "username": { 5 | "type": "string", 6 | "default": "user" 7 | }, 8 | "value": { 9 | "type": "integer", 10 | "default": 5 11 | }, 12 | "nodefault": { 13 | "type": "string" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/fixtures/bundles/config-schema/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "config-schema", 3 | "nodecg": { 4 | "compatibleRange": "^1.3.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/bundles/schema-types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "schema-types", 3 | "nodecg": { 4 | "compatibleRange": "^1.3.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/bundles/schema-types/schemas/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "additionalProperties": false, 4 | "properties": { 5 | "ip": { 6 | "type": "string", 7 | "default": "localhost" 8 | }, 9 | "port": { 10 | "type": "number", 11 | "default": 4444 12 | }, 13 | "password": { 14 | "type": "string", 15 | "default": "" 16 | }, 17 | "status": { 18 | "type": "string", 19 | "enum": ["connected", "connecting", "disconnected", "error"], 20 | "default": "disconnected" 21 | } 22 | }, 23 | "required": [ 24 | "ip", 25 | "port", 26 | "password", 27 | "status" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /test/fixtures/bundles/uninstall-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uninstall-test", 3 | "nodecg": { 4 | "compatibleRange": "^1.3.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/mocks/program.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { vi } from "vitest"; 3 | 4 | export class MockCommand extends Command { 5 | log() { 6 | // To be mocked later 7 | } 8 | 9 | request(opts: any) { 10 | throw new Error(`Unexpected request: ${JSON.stringify(opts, null, 2)}`); 11 | } 12 | 13 | runWith(argString: string) { 14 | return this.parseAsync(["node", "./", ...argString.split(" ")]); 15 | } 16 | } 17 | 18 | export const createMockProgram = () => { 19 | const program = new MockCommand(); 20 | 21 | vi.spyOn(program, "log").mockImplementation(() => void 0); 22 | 23 | return program; 24 | }; 25 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "sourceMap": true, 6 | "rootDir": "./src" 7 | }, 8 | "include": ["./src/**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | 5 | "target": "ES2020", 6 | "lib": ["ES2020"], 7 | 8 | "module": "NodeNext", 9 | "types": ["node"], 10 | 11 | "isolatedModules": true, 12 | "verbatimModuleSyntax": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "resolveJsonModule": true, 15 | 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noImplicitOverride": true, 23 | "noPropertyAccessFromIndexSignature": true, 24 | 25 | "skipLibCheck": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | testTimeout: 30_000, 6 | }, 7 | }); 8 | --------------------------------------------------------------------------------