diff --git a/.eslintignore b/.eslintignore index 849ddff..62cb519 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ dist/ +bin/ diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 83139c8..64dec25 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,20 +1,28 @@ module.exports = { + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "eslint-config-prettier", + "prettier", + ], parser: "@typescript-eslint/parser", parserOptions: { project: ["./tsconfig.json"], }, - plugins: ["@typescript-eslint/eslint-plugin"], - extends: ["plugin:@typescript-eslint/recommended"], + plugins: ["@typescript-eslint"], root: true, env: { node: true, + jest: true, }, ignorePatterns: [ "bin/*", "node_modules/*", "expressots.config.ts", "commitlint.config.ts", - "vite.config.ts", + "vitest.config.ts", + ".eslintrc.cjs", + "coverage/*", ], rules: { "@typescript-eslint/interface-name-prefix": "off", @@ -26,5 +34,6 @@ module.exports = { "no-trailing-spaces": ["error", { skipBlankLines: true }], "no-multi-spaces": ["error", { ignoreEOLComments: true }], "no-multi-spaces": "off", + "no-async-promise-executor": "off", }, }; diff --git a/.github/PULL_REQUEST_TEMPLATE b/.github/PULL_REQUEST_TEMPLATE deleted file mode 100644 index 2d87b0a..0000000 --- a/.github/PULL_REQUEST_TEMPLATE +++ /dev/null @@ -1,48 +0,0 @@ -# Pull Request Guidelines - -Our guidelines for submitting a pull request. - -## Before submitting a Pull Request, please make sure you have verified the following: - -- A good commit message should be two things: meaningful and concise. It should not contain every single detail, describing each changed lineβ€”we can see all the changes in Gitβ€”but, at the same time, it should say enough to avoid ambiguity. -- We use Microverse's commit message convention -- The convention stablish that a commit message has to be in the present tense, imperative and lowercase. -- Example: `fix typo in README.md` -- [ ] The commit message follows our guidelines: -- [ ] Tests for the changes have been added (for bug fixes / features) -- [ ] Docs have been added / updated (for bug fixes / features) - -## PR Type - -What kind of change does this PR introduce? - - - -- [ ] Bugfix -- [ ] Feature -- [ ] Code style update (formatting, local variables) -- [ ] Refactoring (no functional changes, no api changes) -- [ ] Build related changes -- [ ] CI related changes -- [ ] Other... Please describe: - -## What is the current behavior? - -Please describe the current behavior that you are modifying, or link to a relevant issue. - -Issue Number: N/A - -## What is the new behavior? - -Describe the new behavior or link to a relevant issue. - -## Does this PR introduce a breaking change? - -- [ ] Yes -- [ ] No - -If this PR contains a breaking change, please describe the impact and migration path for existing applications below. - -## Other information - -Any other information that is important to this PR. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d3d0cf9 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,50 @@ +# Pull Request Guidelines + +Our guidelines for submitting a pull request. + +## Before submitting your PR, please verify the following: + +- [ ] The commit message follows our guidelines: + + > Make sure you prefix your commit message with the type of change you are making. Your commit message should look like this: `type: description of the change`. + > + > - Fixing a bug : `fix: description of the change`. + > - Adding a new feature : `feat: description of the change`. + +See the options for the different types of changes you can make in the `package.json` file of your project. + +**Leave the options below unchecked if they are not applicable to your Pull Request.** + +- [ ] Tests for the changes have been added (for bug fixes / features) +- [ ] Docs have been added / updated (for bug fixes / features) + +## PR Type + +What kind of change does this PR introduce? + + + +- [ ] Bugfix +- [ ] Feature +- [ ] Code style update (formatting, local variables) +- [ ] Refactoring (no functional changes, no api changes) +- [ ] Build related changes +- [ ] CI related changes +- [ ] Documentation content changes +- [ ] Test +- [ ] Other... Please describe: + +## Does this PR introduce a breaking change? + +- [ ] Yes +- [ ] No + +If `yes` Please describe the impact and mitigation strategy for existing applications. + +## What was changed? + +Please describe what you have changed in this PR. + +## Other information + +Any other information that is important to this PR. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..903db5c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,51 @@ +name: Build and Test + +on: + push: + branches: + - main + pull_request: + branches: ["main"] + pull_request_target: + types: [opened, synchronize, reopened] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Use Node.js + uses: actions/setup-node@v2 + with: + node-version: "18.18.0" + + - name: Install Dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Unit Tests + working-directory: ./ + run: npm run test + env: + CI: true + + - name: Run Code Coverage + working-directory: ./ + run: npm run coverage + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4.0.1 + with: + working-directory: . + token: ${{ secrets.CODECOV_TOKEN }} + + #- name: Set up .npmrc + # run: echo "//npm.pkg.github.com/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc + + - name: Install dependencies + run: npm ci diff --git a/.github/workflows/codesee-arch-diagram.yml b/.github/workflows/codesee-arch-diagram.yml deleted file mode 100644 index 806d41d..0000000 --- a/.github/workflows/codesee-arch-diagram.yml +++ /dev/null @@ -1,23 +0,0 @@ -# This workflow was added by CodeSee. Learn more at https://codesee.io/ -# This is v2.0 of this workflow file -on: - push: - branches: - - main - pull_request_target: - types: [opened, synchronize, reopened] - -name: CodeSee - -permissions: read-all - -jobs: - codesee: - runs-on: ubuntu-latest - continue-on-error: true - name: Analyze the repo with CodeSee - steps: - - uses: Codesee-io/codesee-action@v2 - with: - codesee-token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }} - codesee-url: https://app.codesee.io diff --git a/.gitignore b/.gitignore index ee4679c..7ef4f15 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,6 @@ expressots.config.ts *.tgz -*.container.ts \ No newline at end of file +*.container.ts + +coverage/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index df36bac..0b14051 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,23 +1,21 @@ { - "cSpell.ignoreWords": [ - "Expresso" - ], - "workbench.colorCustomizations": { - // "activityBar.foreground": "#38e715", - // "activityBarBadge.background": "#248d0f", - // "activityBarBadge.foreground": "#fcfcfc", - // "sideBar.border": "#38e715", - // "sideBarTitle.foreground": "#38e715", - // "sideBarSectionHeader.border": "#38e715", - // "editorGroupHeader.border": "#38e715", - // "editorGroupHeader.tabsBorder": "#38e715", - // "tab.border": "#38e715", - // "tab.activeBorderTop": "#38e715", - // "panel.border": "#38e715", - // "statusBar.border": "#38e715", - // "statusBar.foreground": "#38e715" - }, - "editor.rulers": [ - 120 - ], -} \ No newline at end of file + "cSpell.ignoreWords": ["Expresso"], + "workbench.colorCustomizations": { + // "activityBar.foreground": "#38e715", + // "activityBarBadge.background": "#248d0f", + // "activityBarBadge.foreground": "#fcfcfc", + // "sideBar.border": "#38e715", + // "sideBarTitle.foreground": "#38e715", + // "sideBarSectionHeader.border": "#38e715", + // "editorGroupHeader.border": "#38e715", + // "editorGroupHeader.tabsBorder": "#38e715", + // "tab.border": "#38e715", + // "tab.activeBorderTop": "#38e715", + // "panel.border": "#38e715", + // "statusBar.border": "#38e715", + // "statusBar.foreground": "#38e715" + }, + "editor.rulers": [120], + "cSpell.words": ["nonopinionated", "usecase"], + "typescript.tsdk": "node_modules\\typescript\\lib" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 436b249..a3e90f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,150 @@ +## [1.8.2](https://github.com/expressots/expressots-cli/compare/1.8.1...1.8.2) (2024-07-04) + + +### Code Refactoring + +* remove inversify binding decorators [@provide](https://github.com/provide) ([a566490](https://github.com/expressots/expressots-cli/commit/a566490edcc2c3d1249470dd879a4884ba4b9c63)) + +## [1.8.1](https://github.com/expressots/expressots-cli/compare/1.8.0...1.8.1) (2024-06-12) + + +### Bug Fixes + +* add node version restriction ([9cf3416](https://github.com/expressots/expressots-cli/commit/9cf3416a8379d35487f9224751c84c2cab8b8ae4)) +* adjust engine version ([5834ab5](https://github.com/expressots/expressots-cli/commit/5834ab5c099305f436bd196a3c1967fc6bef9826)) +* remove test lib cli ([1d1772b](https://github.com/expressots/expressots-cli/commit/1d1772bf9fb888e7d87875280a71bc0bfdb8b82a)) +* update codecov plugin version ([a1f6ded](https://github.com/expressots/expressots-cli/commit/a1f6ded7161744be8459d7ad0e842a1b17657c6e)) +* update engine on ci/cd ([8ebc317](https://github.com/expressots/expressots-cli/commit/8ebc31724bd487d1aaa148385056ba438bd63fda)) + +## [1.8.0](https://github.com/expressots/expressots-cli/compare/1.7.1...1.8.0) (2024-04-29) + + +### Features + +* add code coverage ([034f078](https://github.com/expressots/expressots-cli/commit/034f078c4bc6d33193600e176f6ca25bc6b175f6)) +* add external provider scaffold ([8ed033b](https://github.com/expressots/expressots-cli/commit/8ed033b3a3d64febd03b93fbca1cd571d6a46d39)) +* add vitest configuration ([c1e4521](https://github.com/expressots/expressots-cli/commit/c1e45217bd098fe70fe6545997bdc18af46648e8)) +* rename .env.example to .env during project creation ([94bc93d](https://github.com/expressots/expressots-cli/commit/94bc93d450887e4e3cee35b22a17b2229e26c3a2)) +* update readme shields ([241b83b](https://github.com/expressots/expressots-cli/commit/241b83bc4191d8f9b8837d4a2d8ef9ccb58c7f77)) + + +### Bug Fixes + +* add coverage folder to eslint ignore pattern ([61c4df1](https://github.com/expressots/expressots-cli/commit/61c4df1cc23e4a6266c47c8c1ed1be7f06f1cdd2)) +* add coverage to gitignore ([53b9cfd](https://github.com/expressots/expressots-cli/commit/53b9cfd384623e078e5fee91710514f9a3bd4b56)) +* adjust package version dependencies ([067170b](https://github.com/expressots/expressots-cli/commit/067170bf2a01fb449624979b89366f2d6a0902ec)) +* adjust sheild for npm & build ([416595f](https://github.com/expressots/expressots-cli/commit/416595ffe7be630d058444d994abd617b49d0db2)) +* npm package installation progress message ([93f9c83](https://github.com/expressots/expressots-cli/commit/93f9c8394a65f2008c408f1fa9fd5e163100951a)) +* remove codesee workflow ([afc492b](https://github.com/expressots/expressots-cli/commit/afc492b735d819a1e58c32c8f6bb78055c73532a)) + + +### Code Refactoring + +* update index help and await print ([17dda21](https://github.com/expressots/expressots-cli/commit/17dda210e7643f95a9e0903d2ae584080dd6de1b)) + + +### Continuous Integrations + +* update package dependencies, lint issues ([54f81b7](https://github.com/expressots/expressots-cli/commit/54f81b752cc5c1a22d2f1386bf9ffc78d9608902)) +* update pull request workflow ([9695b00](https://github.com/expressots/expressots-cli/commit/9695b00ddb798a2cc574ded560789e1703e8a582)) + +## [1.7.1](https://github.com/expressots/expressots-cli/compare/1.7.0...1.7.1) (2024-04-11) + + +### Bug Fixes + +* update nonop controller and usecase template ([1a84a3a](https://github.com/expressots/expressots-cli/commit/1a84a3a3c52df1a67077f7380a3b7c19c7ad26c9)) + +## [1.7.0](https://github.com/expressots/expressots-cli/compare/1.6.0...1.7.0) (2024-03-29) + + +### Features + +* add expressotsconfig scaffoldName schematics changeable by user ([964804f](https://github.com/expressots/expressots-cli/commit/964804f05d2d234f2ab87cf02ac158a583d2d102)) +* add Nested path validation ([0a105bb](https://github.com/expressots/expressots-cli/commit/0a105bb6abacc2a9b62f948df304ab34f5fb7f19)) +* add path command style to opinionated services ([3dea624](https://github.com/expressots/expressots-cli/commit/3dea624160a689ee82e75d4171752c98d5506b16)) +* add single and sugar path validation ([b5738e5](https://github.com/expressots/expressots-cli/commit/b5738e50cfc6d26a068bffa2dd7e9e342e4f0c76)) +* fixed nested resource gen e add fn comments ([3a2c26c](https://github.com/expressots/expressots-cli/commit/3a2c26ce46b3491db41b327ba3377e422e3fd91b)) +* resource list panel ([249cc73](https://github.com/expressots/expressots-cli/commit/249cc73fd5251274e2742aada1a68b4c457c4f8e)) + + +### Bug Fixes + +* adjust linter ([8ef1714](https://github.com/expressots/expressots-cli/commit/8ef1714ed6f03a5b7d7ebd960597069ab8fa05ab)) + + +### Code Refactoring + +* add controller service to module ([1b7b94a](https://github.com/expressots/expressots-cli/commit/1b7b94a04671b3c0c06ca42d3015cf00ce00da60)) +* add generate module service scaffold ([f634c13](https://github.com/expressots/expressots-cli/commit/f634c132d7d2fe4ded5116ce594b75980eb320e6)) +* add module to container ([c2768fb](https://github.com/expressots/expressots-cli/commit/c2768fb652c901080602650c45ebd662f17f8d2e)) +* adjust form console msg on error in existing project ([44f17f1](https://github.com/expressots/expressots-cli/commit/44f17f1d053adead04ba607920499b1b5408af5b)) +* adjust opinionated path module insertion ([e9b3822](https://github.com/expressots/expressots-cli/commit/e9b382235f90f118786b2a9e270a229015cf1297)) +* adjust templates ([fda129f](https://github.com/expressots/expressots-cli/commit/fda129f3c931711859eae12ec245653c09f37af7)) +* create nonop & op command file ([0c63f67](https://github.com/expressots/expressots-cli/commit/0c63f67a7e3da729dc6cceff40a89f6aa3adb7cf)) +* redo all nonop generator resources ([249c1bd](https://github.com/expressots/expressots-cli/commit/249c1bdde2ad9187f05f85e43f25bd1b92f3c162)) +* remove the cli version from info cmd ([4056a5e](https://github.com/expressots/expressots-cli/commit/4056a5e0d2e85a43ff44ce8da571c0af1acd0785)) +* restructure all generate scaffold methods ([cdddaa1](https://github.com/expressots/expressots-cli/commit/cdddaa1a29466d93369d1052ae9db65e0dc8b4c8)) + +## [1.6.0](https://github.com/expressots/expressots-cli/compare/1.5.0...1.6.0) (2024-03-22) + + +### Features + +* add expressots custom project command ([d85f4a5](https://github.com/expressots/expressots-cli/commit/d85f4a5600c89026ea7eac48bf52f492805f6e6e)) +* add middleware scaffold ([5ffcac1](https://github.com/expressots/expressots-cli/commit/5ffcac103bf163c577c89142c2290b51277def18)) +* adjust templates and module creation ([56aaea6](https://github.com/expressots/expressots-cli/commit/56aaea6fb872232bad2133b6d75bbde25441d1b1)) +* cmds add for op and nonop templates ([53b450f](https://github.com/expressots/expressots-cli/commit/53b450f2b034df294744727a7c73e68a3488a42f)) +* improve dev command performance and rm nonused pkgs ([ecc693d](https://github.com/expressots/expressots-cli/commit/ecc693d0b9f8425b1d95b82d0f461c5c1c4b350b)) + + +### Bug Fixes + +* **cli.ts:** fix the order of choices in the "template" option to match the order in the form ([52d783f](https://github.com/expressots/expressots-cli/commit/52d783f9640cc9ef4ec18dc769297c34e040d9d5)) + + +### Code Refactoring + +* adjust op and nop templates, proj confirm msg ([d2bbc2e](https://github.com/expressots/expressots-cli/commit/d2bbc2e8fcfeebcb064d8c2278bfa1ad02464cc4)) +* adjust sponsor message spacing ([a1ff88d](https://github.com/expressots/expressots-cli/commit/a1ff88df86940f3a63baca9c11a5fb83a89a46a8)) +* improve new cmd cli performance ([1782206](https://github.com/expressots/expressots-cli/commit/1782206b3991269598a071c18dd8b406946d0755)) + +## [1.5.0](https://github.com/expressots/expressots-cli/compare/1.4.0...1.5.0) (2023-10-21) + + +### Features + +* add base repository and interface from prisma provider template ([d643830](https://github.com/expressots/expressots-cli/commit/d643830216d5d88196b70dbe2e4e2df19c9d1e39)) +* add base repository and interface from prisma provider template ([a42c9bb](https://github.com/expressots/expressots-cli/commit/a42c9bb5a2c1cadee064b9f4ede853caf72f4800)) +* add base repository templatess ([3390346](https://github.com/expressots/expressots-cli/commit/339034618046ff43af1b585c6fcb26cba13ae3a1)) +* add base repository templatess ([bc42738](https://github.com/expressots/expressots-cli/commit/bc42738ca9f0f40505ec42ed970f13f88dcd48e9)) +* add better log messages during installation ([8d1766e](https://github.com/expressots/expressots-cli/commit/8d1766e44abd8f30eb83bd40f4c45cf791e4741e)) +* add prisma provider configuration expressots.config file ([0b656f3](https://github.com/expressots/expressots-cli/commit/0b656f3bd7fa4966929d71248d45b8928f3ff608)) +* add prisma provider configuration expressots.config file ([674f7df](https://github.com/expressots/expressots-cli/commit/674f7dfe69d867cbb4f86dbd1e25a0c8a246ec7e)) +* add process to install database driver when opt-in ([80e848e](https://github.com/expressots/expressots-cli/commit/80e848e17f838f20500ab211d8e68f871d4462cd)) +* add process to install database driver when opt-in ([ebe6fed](https://github.com/expressots/expressots-cli/commit/ebe6fed0b7dbff2297eee5087e144b4f31152c59)) +* add script pck.json codegen ([7ffd673](https://github.com/expressots/expressots-cli/commit/7ffd673407be59c5e824a0c5df3c1a9adb15940e)) +* change base repository and interface to fix DI and typescript errors ([96fb098](https://github.com/expressots/expressots-cli/commit/96fb098640c0ce0290462359489256b04cab3341)) +* change base repository and interface to fix DI and typescript errors ([475e6b2](https://github.com/expressots/expressots-cli/commit/475e6b20e5e762fbead4c83bc14f628dea3c1129)) + + +### Bug Fixes + +* add new text for CLI add command ([83eef1c](https://github.com/expressots/expressots-cli/commit/83eef1ccf93f8a09280a2dd6711fc00145d136bf)) +* add new text for CLI add command ([68e7b1b](https://github.com/expressots/expressots-cli/commit/68e7b1b25b0158e5abfc8d2be23e28bce8dc7572)) +* add prisma provider download cmd ([0093412](https://github.com/expressots/expressots-cli/commit/0093412f9b2d746c7e5e2779cbd222bf694cd10c)) +* add prisma question to override install ([54933ca](https://github.com/expressots/expressots-cli/commit/54933caf5b9774ed7c5409506cebdd087922c820)) +* base repository template prisma dependency ([8943e6a](https://github.com/expressots/expressots-cli/commit/8943e6a9b4d4af8098907c69272b7404fadf315d)) +* CLI documentation link ([62f403b](https://github.com/expressots/expressots-cli/commit/62f403b493b7a101151978082e913237e07c89ed)) +* copy prisma base repository templates ([9277285](https://github.com/expressots/expressots-cli/commit/927728535835a3f380310f97f042384d80027ce4)) +* linter fix ([5cf14fa](https://github.com/expressots/expressots-cli/commit/5cf14fa1f3a36670fc25fcf26db0fe9ffcc91682)) +* replace providers object when it already exists in expressots config file ([eea0c6f](https://github.com/expressots/expressots-cli/commit/eea0c6fe7738dd6e6d767214bfbd570b2ff6129f)) +* replace providers object when it already exists in expressots config file ([2e3b425](https://github.com/expressots/expressots-cli/commit/2e3b425409190680722fb32f6717731e4edf0752)) +* using yarn and pnpm to install dependencies ([08cc02d](https://github.com/expressots/expressots-cli/commit/08cc02d11a3704b74d8530bdbd6bb2f74813f01c)) +* using yarn and pnpm to install dependencies ([ccff34d](https://github.com/expressots/expressots-cli/commit/ccff34d3f7d33a903c41aadc586e7de025b3e942)) + ## [1.4.0](https://github.com/expressots/expressots-cli/compare/1.3.4...1.4.0) (2023-09-27) diff --git a/README.md b/README.md index 864f4de..7d728c5 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ +[![Codecov][codecov-shield]][codecov-url] +[![NPM][npm-shield]][npm-url] +![Build][build-shield] [![Contributors][contributors-shield]][contributors-url] [![Forks][forks-shield]][forks-url] [![Stargazers][stars-shield]][stars-url] @@ -95,6 +98,11 @@ Distributed under the MIT License. See [`LICENSE.txt`](https://github.com/expres +[codecov-url]: https://codecov.io/gh/expressots/expressots-cli +[codecov-shield]: https://img.shields.io/codecov/c/gh/expressots/expressots-cli/main?style=for-the-badge&logo=codecov&labelColor=FB9AD1 +[npm-url]: https://www.npmjs.com/package/@expressots/expressots-cli +[npm-shield]: https://img.shields.io/npm/v/%40expressots%2Fcli?style=for-the-badge&logo=npm&color=9B3922 +[build-shield]: https://img.shields.io/github/actions/workflow/status/expressots/expressots-cli/build.yml?branch=main&style=for-the-badge&logo=github [contributors-shield]: https://img.shields.io/github/contributors/expressots/expressots-cli?style=for-the-badge [contributors-url]: https://github.com/expressots/expressots-cli/graphs/contributors [forks-shield]: https://img.shields.io/github/forks/expressots/expressots-cli?style=for-the-badge diff --git a/expressots.config.ts b/expressots.config.ts index 4d77fb1..0fbbcfe 100644 --- a/expressots.config.ts +++ b/expressots.config.ts @@ -1,9 +1,18 @@ import { ExpressoConfig, Pattern } from "./src/types"; const config: ExpressoConfig = { - sourceRoot: "src", - scaffoldPattern: Pattern.KEBAB_CASE, - opinionated: false + sourceRoot: "src", + scaffoldPattern: Pattern.KEBAB_CASE, + opinionated: true, + scaffoldSchematics: { + entity: "model", + provider: "adapter", + controller: "controller", + usecase: "operation", + dto: "payload", + module: "group", + middleware: "exjs", + }, }; export default config; diff --git a/package.json b/package.json index fe84112..f1caf6c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@expressots/cli", - "version": "1.4.0", + "version": "1.8.2", "description": "Expressots CLI - modern, fast, lightweight nodejs web framework (@cli)", "author": "Richard Zampieri", "license": "MIT", @@ -11,7 +11,7 @@ "expressots": "bin/cli.js" }, "engines": { - "node": ">=18.10.0" + "node": ">=18.18.0" }, "funding": { "type": "github", @@ -36,52 +36,60 @@ "start": "node ./bin/cli.js", "start:dev": "tsnd ./src/cli.ts", "build": "npm run clean && tsc -p tsconfig.json && yarn cp:templates && chmod +x ./bin/cli.js", - "cp:templates": "cp -r ./src/generate/templates ./bin/generate/templates", + "cp:templates": "cp -r ./src/generate/templates ./bin/generate/templates && cp -r ./src/providers/prisma/templates ./bin/providers/prisma/templates", "clean": "rimraf ./bin", + "prepublish": "npm run build && npm pack", + "publish": "npm publish --tag latest", "format": "prettier --write \"./src/**/*.ts\" --cache", "lint": "eslint \"./src/**/*.ts\"", "lint:fix": "eslint \"./src/**/*.ts\" --fix", "release": "release-it", - "test": "vitest run", + "test": "vitest run --reporter default", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "coverage": "vitest run --coverage" }, "dependencies": { "@expressots/boost-ts": "1.1.1", "chalk-animation": "2.0.3", - "cli-progress": "3.11.2", + "cli-progress": "3.12.0", + "cli-table3": "0.6.4", "degit": "2.8.4", - "glob": "10.2.6", - "inquirer": "8.0.0", + "glob": "10.4.1", + "inquirer": "8.2.6", "mustache": "4.2.0", + "semver": "7.6.2", "ts-node": "10.9.1", - "yargs": "17.6.2" + "yargs": "17.7.2" }, "devDependencies": { - "@commitlint/cli": "^17.7.1", - "@commitlint/config-conventional": "^17.7.0", - "@release-it/conventional-changelog": "^7.0.1", - "@types/chalk-animation": "^1.6.1", - "@types/cli-progress": "^3.11.0", - "@types/degit": "^2.8.3", - "@types/inquirer": "^9.0.3", - "@types/mustache": "^4.2.2", - "@types/node": "^18.11.19", - "@types/yargs": "^17.0.22", - "@typescript-eslint/eslint-plugin": "^5.53.0", - "@typescript-eslint/parser": "^5.53.0", - "chalk": "^4.1.2", - "eslint": "^8.34.0", - "eslint-config-prettier": "^8.6.0", - "eslint-plugin-prettier": "^4.2.1", - "husky": "^8.0.3", - "prettier": "^2.8.4", - "release-it": "^16.1.5", - "rimraf": "^4.1.2", - "ts-node-dev": "^2.0.0", - "typescript": "^4.9.5", - "vite": "^4.4.9", - "vitest": "^0.34.4" + "@codecov/vite-plugin": "^0.0.1-beta.9", + "@commitlint/cli": "19.2.1", + "@commitlint/config-conventional": "19.1.0", + "@release-it/conventional-changelog": "7.0.2", + "@types/chalk-animation": "1.6.1", + "@types/cli-progress": "3.11.0", + "@types/degit": "2.8.3", + "@types/inquirer": "9.0.3", + "@types/mustache": "4.2.2", + "@types/node": "20.12.7", + "@types/yargs": "17.0.22", + "@typescript-eslint/eslint-plugin": "7.6.0", + "@typescript-eslint/parser": "7.6.0", + "@vitest/coverage-v8": "1.4.0", + "chalk": "4.1.2", + "eslint": "8.57.0", + "eslint-config-prettier": "9.1.0", + "husky": "9.0.11", + "prettier": "3.2.5", + "reflect-metadata": "0.2.2", + "release-it": "16.3.0", + "rimraf": "5.0.5", + "shx": "0.3.4", + "ts-node-dev": "2.0.0", + "typescript": "5.2.2", + "vite": "5.2.8", + "vite-tsconfig-paths": "4.3.2", + "vitest": "1.4.0" }, "release-it": { "git": { diff --git a/src/@types/config.ts b/src/@types/config.ts index 170f48c..9d788c4 100644 --- a/src/@types/config.ts +++ b/src/@types/config.ts @@ -5,6 +5,15 @@ export const enum Pattern { CAMEL_CASE = "camelCase", } +interface IProviders { + prisma?: { + schemaName: string; + schemaPath: string; + entitiesPath: string; + entityNamePattern: string; + }; +} + /** * The configuration object for the Expresso CLI. * @@ -18,4 +27,14 @@ export interface ExpressoConfig { scaffoldPattern: Pattern; sourceRoot: string; opinionated: boolean; + providers?: IProviders; + scaffoldSchematics?: { + entity?: string; + controller?: string; + usecase?: string; + dto?: string; + module?: string; + provider?: string; + middleware?: string; + }; } diff --git a/src/cli.ts b/src/cli.ts index 8015556..8873c20 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,19 +2,23 @@ import yargs from "yargs"; import { hideBin } from "yargs/helpers"; +import { runCommandModule } from "./commands/project.commands"; import { generateProject } from "./generate"; +import { helpCommand } from "./help/cli"; import { infoProject } from "./info"; import { createProject } from "./new"; - -export const CLI_VERSION = "1.3.4"; +import { generateProviders } from "./providers"; console.log(`\n[🐎 Expressots]\n`); yargs(hideBin(process.argv)) .scriptName("expressots") + .command(runCommandModule) .command(createProject()) + .command(generateProviders()) .command(generateProject()) .command(infoProject()) + .command(helpCommand()) .example("$0 new expressots-demo", "Create interactively") .example("$0 new expressots-demo -d ./", "Create interactively with path") .example("$0 new expressots-demo -p yarn -t opinionated", "Create silently") diff --git a/src/commands/__tests__/project.commands.spec.ts b/src/commands/__tests__/project.commands.spec.ts new file mode 100644 index 0000000..e331cb5 --- /dev/null +++ b/src/commands/__tests__/project.commands.spec.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from "vitest"; + +describe("project.commands", () => { + it("should return a list of commands", () => { + expect(true).toBe(true); + }); +}); diff --git a/src/commands/project.commands.ts b/src/commands/project.commands.ts new file mode 100644 index 0000000..8a10486 --- /dev/null +++ b/src/commands/project.commands.ts @@ -0,0 +1,146 @@ +import { spawn } from "child_process"; +import { promises as fs } from "fs"; +import path from "path"; +import { Argv, CommandModule } from "yargs"; +import Compiler from "../utils/compiler"; + +/** + * Load the configuration from the compiler + * @param compiler The compiler to load the configuration from + * @returns The configuration + */ + +const opinionatedConfig: Array = [ + "--transpile-only", + "-r", + "dotenv/config", + "-r", + "tsconfig-paths/register", + "./src/main.ts", +]; + +const nonOpinionatedConfig: Array = [ + "--transpile-only", + "-r", + "dotenv/config", + "./src/main.ts", +]; + +/** + * Helper function to execute a command + * @param command The command to execute + * @param args The arguments to pass to the command + * @param cwd The current working directory to execute the command in + * @returns A promise that resolves when the command completes successfully + */ +function execCmd( + command: string, + args: Array, + cwd: string = process.cwd(), +): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { + stdio: "inherit", + shell: true, + cwd, + }); + + proc.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Command failed with code ${code}`)); + } + }); + }); +} + +// Helper to delete the dist directory +const cleanDist = async (): Promise => { + await fs.rm("./dist", { recursive: true, force: true }); +}; + +// Helper to compile TypeScript +const compileTypescript = async () => { + await execCmd("npx", ["tsc", "-p", "tsconfig.build.json"]); +}; + +// Helper to copy files +const copyFiles = async () => { + const { opinionated } = await Compiler.loadConfig(); + let filesToCopy: Array = []; + if (opinionated) { + filesToCopy = [ + "./register-path.js", + "tsconfig.build.json", + "package.json", + ]; + } else { + filesToCopy = ["tsconfig.json", "package.json"]; + } + filesToCopy.forEach((file) => { + fs.copyFile(file, path.join("./dist", path.basename(file))); + }); +}; + +// eslint-disable-next-line @typescript-eslint/ban-types +export const runCommandModule: CommandModule<{}, { command: string }> = { + command: "run ", + describe: "Runs a specified command (dev, build, prod)", + builder: (yargs: Argv) => { + return yargs.positional("command", { + describe: "The command to run", + type: "string", + choices: ["dev", "build", "prod"], + }); + }, + handler: async (argv) => { + const { command } = argv; + // Now call your original runCommand function with the command + // Ensure runCommand is properly defined to handle these commands + await runCommand({ command }); + }, +}; + +const runCommand = async ({ command }: { command: string }): Promise => { + const { opinionated } = await Compiler.loadConfig(); + try { + switch (command) { + case "dev": + // Use execSync or spawn to run ts-node-dev programmatically + execCmd( + "tsnd", + opinionated ? opinionatedConfig : nonOpinionatedConfig, + ); + break; + case "build": + await cleanDist(); + await compileTypescript(); + await copyFiles(); + break; + case "prod": { + let config: Array = []; + if (opinionated) { + config = [ + "-r", + "dotenv/config", + "-r", + "./dist/register-path.js", + "./dist/src/main.js", + ]; + } else { + config = ["-r", "dotenv/config", "./dist/main.js"]; + } + // Ensure environment variables are set + execCmd("node", config); + break; + } + default: + console.log(`Unknown command: ${command}`); + } + } catch (error) { + console.error("Error executing command:", error); + } +}; + +export { runCommand }; diff --git a/src/generate/cli.ts b/src/generate/cli.ts index 79455de..4bfc81c 100644 --- a/src/generate/cli.ts +++ b/src/generate/cli.ts @@ -18,8 +18,10 @@ const coerceSchematicAliases = (arg: string) => { return "provider"; case "e": return "entity"; - case "m": + case "mo": return "module"; + case "mi": + return "middleware"; default: return arg; } @@ -27,7 +29,7 @@ const coerceSchematicAliases = (arg: string) => { const generateProject = (): CommandModule => { return { - command: "generate [schematic] [path]", + command: "generate [schematic] [path] [method]", describe: "Scaffold a new resource", aliases: ["g"], builder: (yargs: Argv): Argv => { @@ -40,6 +42,7 @@ const generateProject = (): CommandModule => { "provider", "entity", "module", + "middleware", ] as const, describe: "The schematic to generate", type: "string", diff --git a/src/generate/form.ts b/src/generate/form.ts index 099f99a..8691332 100644 --- a/src/generate/form.ts +++ b/src/generate/form.ts @@ -1,473 +1,52 @@ -import * as nodePath from "path"; -import { mkdirSync, readFileSync } from "node:fs"; -import { render } from "mustache"; -import { writeFileSync, existsSync } from "fs"; -import chalk from "chalk"; -import { - anyCaseToCamelCase, - anyCaseToKebabCase, - anyCaseToPascalCase, - anyCaseToLowerCase, -} from "@expressots/boost-ts"; import Compiler from "../utils/compiler"; -import { Pattern } from "../types"; -import { addControllerToModule } from "../utils/add-controller-to-module"; -import { verifyIfFileExists } from "../utils/verify-file-exists"; -import { addModuleToContainer } from "../utils/add-module-to-container"; -import { printError } from "../utils/cli-ui"; - -function getFileNameWithoutExtension(filePath: string) { - return filePath.split(".")[0]; -} - +import { checkPathStyle } from "./utils/command-utils"; +import { nonOpinionatedProcess } from "./utils/nonopininated-cmd"; +import { opinionatedProcess } from "./utils/opinionated-cmd"; + +/** + * Create a template props + * @param schematic + * @param path + * @param method + */ type CreateTemplateProps = { schematic: string; path: string; method: string; }; +/** + * Create a template based on the schematic + * @param schematic - the schematic to create + * @param path - the path to create the schematic + * @param method - the http method + * @returns the file created + */ export const createTemplate = async ({ schematic, path: target, method, }: CreateTemplateProps) => { - const { opinionated, sourceRoot } = await Compiler.loadConfig(); - - if (sourceRoot === "") { - printError( - "You must specify a source root in your expressots.config.ts", - "sourceRoot", - ); - process.exit(1); - } - - let folderMatch = ""; - - if (opinionated) { - folderMatch = schematicFolder(schematic); - } else { - folderMatch = ""; - } - - const { path, file, className, moduleName, modulePath } = await splitTarget( - { target, schematic }, - ); - - const usecaseDir = `${sourceRoot}/${folderMatch}`; - - await verifyIfFileExists(`${usecaseDir}/${path}/${file}`); - - mkdirSync(`${usecaseDir}/${path}`, { recursive: true }); - - if (schematic !== "service") { - // add to guarantee that the routing will always be the last part of the path - let routeSchema = ""; - - if ( - target.includes("/") || - target.includes("\\") || - target.includes("//") - ) { - routeSchema = path.split("/").pop(); - } else { - routeSchema = path.replace(/\/$/, ""); - } - - let templateBasedSchematic = schematic; - if (schematic === "module") { - templateBasedSchematic = "module-default"; - } - - writeTemplate({ - outputPath: `${usecaseDir}/${path}/${file}`, - template: { - path: `./templates/${templateBasedSchematic}.tpl`, - data: { - className, - moduleName: className, - route: routeSchema, - construct: anyCaseToKebabCase(className), - method: getHttpMethod(method), - }, - }, - }); - } else { - for await (const resource of ["controller-service", "usecase", "dto"]) { - const currentSchematic = resource.replace( - "controller-service", - "controller", - ); - - const schematicFile = file.replace( - `controller.ts`, - `${currentSchematic}.ts`, - ); - - console.log( - " ", - chalk.greenBright(`[${currentSchematic}]`.padEnd(14)), - chalk.bold.white(`${schematicFile} created! βœ”οΈ`), - ); - - let templateBasedMethod = ""; - if (method) { - if ( - resource === "controller-service" || - resource === "controller" - ) { - if (method === "get") - templateBasedMethod = `./templates/${resource}.tpl`; - else - templateBasedMethod = `./templates/${resource}-${method}.tpl`; - } else { - templateBasedMethod = `./templates/${resource}.tpl`; - } - - if (resource === "usecase") { - templateBasedMethod = `./templates/${resource}-op.tpl`; - } - - if (resource === "usecase") { - if (method === "get") - templateBasedMethod = `./templates/${resource}.tpl`; - if (method === "post") - templateBasedMethod = `./templates/${resource}-${method}.tpl`; - } - } else { - templateBasedMethod = `./templates/${resource}.tpl`; - } - - // add to guarantee that the routing will always be the last part of the path - let routeSchema = ""; - - if ( - target.includes("/") || - target.includes("\\") || - target.includes("//") - ) { - routeSchema = path.split("/").pop(); - } else { - routeSchema = path.replace(/\/$/, ""); - } - - writeTemplate({ - outputPath: `${usecaseDir}/${path}/${schematicFile}`, - template: { - path: templateBasedMethod, - data: { - className, - fileName: getFileNameWithoutExtension(file), - useCase: anyCaseToCamelCase(className), - route: routeSchema, //path.replace(/\/$/, ''), - construct: anyCaseToKebabCase(className), - method: getHttpMethod(method), - }, - }, - }); - } - } - - // Module generation - if (["controller", "service"].includes(schematic)) { - let moduleExist = false; - let moduleOutPath = ""; - - if ( - target.includes("/") || - target.includes("\\") || - target.includes("//") - ) { - if (modulePath === "") { - moduleExist = existsSync( - `${usecaseDir}/${moduleName}.module.ts`, - ); - moduleOutPath = `${usecaseDir}/${moduleName}.module.ts`; - } else { - moduleExist = existsSync( - `${usecaseDir}/${modulePath}/${moduleName}.module.ts`, - ); - moduleOutPath = `${usecaseDir}/${modulePath}/${moduleName}.module.ts`; - } - } else { - moduleExist = existsSync( - `${usecaseDir}/${moduleName}/${moduleName}.module.ts`, - ); - if (modulePath === "") { - moduleExist = existsSync( - `${usecaseDir}/${moduleName}.module.ts`, - ); - moduleOutPath = `${usecaseDir}/${moduleName}.module.ts`; - } else { - moduleExist = existsSync( - `${usecaseDir}/${moduleName}/${moduleName}.module.ts`, - ); - moduleOutPath = `${usecaseDir}/${moduleName}/${moduleName}.module.ts`; - } - } - - let controllerPath = "./"; - const pathCount = path.split("/").length; - - if (path === "") { - controllerPath += `${file.slice(0, file.lastIndexOf("."))}`; - } else if (pathCount === 1) { - controllerPath += `${path}/${file.slice(0, file.lastIndexOf("."))}`; - } else if (pathCount === 2) { - controllerPath += `${path.split("/")[1]}/${file.slice( - 0, - file.lastIndexOf("."), - )}`; - } else { - const segments: string[] = path - .split("/") - .filter((segment) => segment !== ""); - controllerPath += `${segments[segments.length - 1]}/${file.slice( - 0, - file.lastIndexOf("."), - )}`; - } - - if (moduleExist) { - if ( - target.includes("/") || - target.includes("\\") || - target.includes("//") - ) { - await addControllerToModule( - `${usecaseDir}/${modulePath}/${moduleName}.module.ts`, - `${className}Controller`, - controllerPath, - ); - } else { - if (modulePath === "") { - await addControllerToModule( - `${usecaseDir}/${moduleName}.module.ts`, - `${className}Controller`, - controllerPath, - ); - } else { - await addControllerToModule( - `${usecaseDir}/${moduleName}/${moduleName}.module.ts`, - `${className}Controller`, - controllerPath, - ); - } - } - } else { - writeTemplate({ - outputPath: moduleOutPath, - template: { - path: `./templates/module.tpl`, - data: { - moduleName: - moduleName[0].toUpperCase() + moduleName.slice(1), - className, - path: controllerPath, - }, - }, - }); - - console.log( - " ", - chalk.greenBright(`[module]`.padEnd(14)), - chalk.bold.white(`${moduleName}.module created! βœ”οΈ`), - ); - - if ( - target.includes("/") || - target.includes("\\") || - target.includes("//") - ) { - await addModuleToContainer(moduleName, modulePath, path); - } else { - await addModuleToContainer(moduleName, moduleName, path); - } - } - } - - if (schematic === "service") { - console.log( - " ", - chalk.greenBright(`[${schematic}]`.padEnd(14)), - chalk.bold.yellow(`${file.split(".")[0]} created! βœ”οΈ`), + const config = await Compiler.loadConfig(); + const pathStyle = checkPathStyle(target); + + let returnFile = ""; + if (config.opinionated) { + returnFile = await opinionatedProcess( + schematic, + target, + method, + config, + pathStyle, ); } else { - console.log( - " ", - chalk.greenBright(`[${schematic}]`.padEnd(14)), - chalk.bold.white(`${file.split(".")[0]} ${schematic} created! βœ”οΈ`), + returnFile = await nonOpinionatedProcess( + schematic, + target, + method, + config, ); } - return file; -}; - -const splitTarget = async ({ - target, - schematic, -}: { - target: string; - schematic: string; -}): Promise<{ - path: string; - file: string; - className: string; - moduleName: string; - modulePath: string; -}> => { - const pathContent: string[] = target - .split("/") - .filter((item) => item !== ""); - const endsWithSlash: boolean = target.endsWith("/"); - let path = ""; - let fileName = ""; - let module = ""; - let modulePath = ""; - - if ( - target.includes("/") || - target.includes("\\") || - target.includes("//") - ) { - //pathContent = target.split("/").filter((item) => item !== ""); - if (schematic === "service") schematic = "controller"; - if ( - schematic === "service" || - (schematic === "controller" && pathContent.length > 4) - ) { - printError("Max path depth is 4.", pathContent.join("/")); - process.exit(1); - } - - if (endsWithSlash) { - fileName = pathContent[pathContent.length - 1]; - path = pathContent.join("/"); - module = - pathContent.length == 1 - ? pathContent[pathContent.length - 1] - : pathContent[pathContent.length - 2]; - modulePath = pathContent.slice(0, -1).join("/"); - } else { - fileName = pathContent[pathContent.length - 1]; - path = pathContent.slice(0, -1).join("/"); - module = - pathContent.length == 2 - ? pathContent[pathContent.length - 2] - : pathContent[pathContent.length - 3]; - modulePath = pathContent.slice(0, -2).join("/"); - } - - return { - path, - file: `${await getNameWithScaffoldPattern( - fileName, - )}.${schematic}.ts`, - className: anyCaseToPascalCase(fileName), - moduleName: module, - modulePath, - }; - } else { - if (schematic === "service") schematic = "controller"; - // 1. Extract the name (first part of the target) - const [name, ...remainingPath] = target.split("/"); - // 2. Check if the name is camelCase or kebab-case - const camelCaseRegex = /[A-Z]/; - const kebabCaseRegex = /[_\-\s]+/; - const isCamelCase = camelCaseRegex.test(name); - const isKebabCase = kebabCaseRegex.test(name); - if (isCamelCase || isKebabCase) { - const [wordName, ...path] = name - ?.split(isCamelCase ? /(?=[A-Z])/ : kebabCaseRegex) - .map((word) => word.toLowerCase()); - - return { - path: `${wordName}/${pathEdgeCase(path)}${pathEdgeCase( - remainingPath, - )}`, - file: `${await getNameWithScaffoldPattern( - name, - )}.${schematic}.ts`, - className: anyCaseToPascalCase(name), - moduleName: wordName, - modulePath: pathContent[0].split("-")[1], - }; - } - - // 3. Return the base case - return { - path: "", - file: `${await getNameWithScaffoldPattern(name)}.${schematic}.ts`, - className: anyCaseToPascalCase(name), - moduleName: name, - modulePath: "", - }; - } -}; - -const getHttpMethod = (method: string): string => { - switch (method) { - case "put": - return "Put"; - case "post": - return "Post"; - case "patch": - return "Patch"; - case "delete": - return "Delete"; - default: - return "Get"; - } -}; - -const writeTemplate = ({ - outputPath, - template: { path, data }, -}: { - outputPath: string; - template: { - path: string; - data: Record; - }; -}) => { - writeFileSync( - outputPath, - render(readFileSync(nodePath.join(__dirname, path), "utf8"), data), - ); -}; - -const schematicFolder = (schematic: string): string | undefined => { - switch (schematic) { - case "usecase": - return "useCases"; - case "controller": - return "useCases"; - case "dto": - return "useCases"; - case "service": - return "useCases"; - case "provider": - return "providers"; - case "entity": - return "entities"; - } - - return undefined; -}; - -const getNameWithScaffoldPattern = async (name: string) => { - const configObject = await Compiler.loadConfig(); - - switch (configObject.scaffoldPattern) { - case Pattern.LOWER_CASE: - return anyCaseToLowerCase(name); - case Pattern.KEBAB_CASE: - return anyCaseToKebabCase(name); - case Pattern.PASCAL_CASE: - return anyCaseToPascalCase(name); - case Pattern.CAMEL_CASE: - return anyCaseToCamelCase(name); - } -}; -const pathEdgeCase = (path: string[]): string => { - return `${path.join("/")}${path.length > 0 ? "/" : ""}`; + return returnFile; }; diff --git a/src/generate/templates/dto-op.tpl b/src/generate/templates/dto-op.tpl deleted file mode 100644 index 767e15d..0000000 --- a/src/generate/templates/dto-op.tpl +++ /dev/null @@ -1,7 +0,0 @@ -export interface I{{className}}RequestDTO { - id: string; -} - -export interface I{{className}}ResponseDTO { } - - diff --git a/src/generate/templates/module-default.tpl b/src/generate/templates/module-default.tpl deleted file mode 100644 index e512824..0000000 --- a/src/generate/templates/module-default.tpl +++ /dev/null @@ -1,3 +0,0 @@ -import { CreateModule } from "@expressots/core"; - -export const {{moduleName}}Module = CreateModule([]); diff --git a/src/generate/templates/module.tpl b/src/generate/templates/module.tpl deleted file mode 100644 index 627650f..0000000 --- a/src/generate/templates/module.tpl +++ /dev/null @@ -1,4 +0,0 @@ -import { CreateModule } from "@expressots/core"; -import { {{className}}Controller } from "{{{path}}}"; - -export const {{moduleName}}Module = CreateModule([{{className}}Controller]); diff --git a/src/generate/templates/nonopinionated/controller.tpl b/src/generate/templates/nonopinionated/controller.tpl new file mode 100644 index 0000000..c30cc55 --- /dev/null +++ b/src/generate/templates/nonopinionated/controller.tpl @@ -0,0 +1,4 @@ +import { controller } from "@expressots/adapter-express"; + +@controller("/{{{route}}}") +export class {{className}}{{schematic}} {} diff --git a/src/generate/templates/nonopinionated/dto.tpl b/src/generate/templates/nonopinionated/dto.tpl new file mode 100644 index 0000000..5fe9346 --- /dev/null +++ b/src/generate/templates/nonopinionated/dto.tpl @@ -0,0 +1,3 @@ +export interface I{{className}}Request{{schematic}} {} + +export interface I{{className}}Response{{schematic}} {} diff --git a/src/generate/templates/nonopinionated/entity.tpl b/src/generate/templates/nonopinionated/entity.tpl new file mode 100644 index 0000000..7460c4b --- /dev/null +++ b/src/generate/templates/nonopinionated/entity.tpl @@ -0,0 +1,4 @@ +import { provide } from "@expressots/core"; + +@provide({{className}}{{schematic}}) +export class {{className}}{{schematic}} {} diff --git a/src/generate/templates/nonopinionated/middleware.tpl b/src/generate/templates/nonopinionated/middleware.tpl new file mode 100644 index 0000000..b521706 --- /dev/null +++ b/src/generate/templates/nonopinionated/middleware.tpl @@ -0,0 +1,9 @@ +import { ExpressoMiddleware, provide } from "@expressots/core"; +import { NextFunction, Request, Response } from "express"; + +@provide({{className}}{{schematic}}) +export class {{className}}{{schematic}} extends ExpressoMiddleware { + use(req: Request, res: Response, next: NextFunction): void | Promise { + throw new Error("Method not implemented."); + } +} \ No newline at end of file diff --git a/src/generate/templates/nonopinionated/module.tpl b/src/generate/templates/nonopinionated/module.tpl new file mode 100644 index 0000000..beef305 --- /dev/null +++ b/src/generate/templates/nonopinionated/module.tpl @@ -0,0 +1,4 @@ +import { ContainerModule } from "inversify"; +import { CreateModule } from "@expressots/core"; + +export const {{moduleName}}{{schematic}}: ContainerModule = CreateModule([]); diff --git a/src/generate/templates/nonopinionated/provider.tpl b/src/generate/templates/nonopinionated/provider.tpl new file mode 100644 index 0000000..2c50221 --- /dev/null +++ b/src/generate/templates/nonopinionated/provider.tpl @@ -0,0 +1,4 @@ +import { provide } from "@expressots/core"; + +@provide({{className}}{{schematic}}) +export class {{className}}{{schematic}} {} \ No newline at end of file diff --git a/src/generate/templates/nonopinionated/usecase.tpl b/src/generate/templates/nonopinionated/usecase.tpl new file mode 100644 index 0000000..7460c4b --- /dev/null +++ b/src/generate/templates/nonopinionated/usecase.tpl @@ -0,0 +1,4 @@ +import { provide } from "@expressots/core"; + +@provide({{className}}{{schematic}}) +export class {{className}}{{schematic}} {} diff --git a/src/generate/templates/controller-service-delete.tpl b/src/generate/templates/opinionated/controller-service-delete.tpl similarity index 85% rename from src/generate/templates/controller-service-delete.tpl rename to src/generate/templates/opinionated/controller-service-delete.tpl index e4c9206..7d6c0e2 100644 --- a/src/generate/templates/controller-service-delete.tpl +++ b/src/generate/templates/opinionated/controller-service-delete.tpl @@ -1,5 +1,5 @@ import { BaseController, StatusCode } from "@expressots/core"; -import { controller, {{method}}, param, response } from "@expressots/adapter-express"; +import { controller, Delete, param, response } from "@expressots/adapter-express"; import { Response } from "express"; import { {{className}}UseCase } from "./{{fileName}}.usecase"; import { I{{className}}RequestDTO, I{{className}}ResponseDTO } from "./{{fileName}}.dto"; @@ -10,7 +10,7 @@ export class {{className}}Controller extends BaseController { super(); } - @{{method}}("/:id") + @Delete("/:id") execute(@param("id") id: string, @response() res: Response): I{{className}}ResponseDTO { return this.callUseCase( this.{{useCase}}UseCase.execute(id), diff --git a/src/generate/templates/controller-service.tpl b/src/generate/templates/opinionated/controller-service-get.tpl similarity index 85% rename from src/generate/templates/controller-service.tpl rename to src/generate/templates/opinionated/controller-service-get.tpl index 13790b2..ed8ad1f 100644 --- a/src/generate/templates/controller-service.tpl +++ b/src/generate/templates/opinionated/controller-service-get.tpl @@ -1,5 +1,5 @@ import { BaseController, StatusCode } from "@expressots/core"; -import { controller, {{method}}, response } from "@expressots/adapter-express"; +import { controller, Get, response } from "@expressots/adapter-express"; import { Response } from "express"; import { {{className}}UseCase } from "./{{fileName}}.usecase"; import { I{{className}}ResponseDTO } from "./{{fileName}}.dto"; @@ -10,7 +10,7 @@ export class {{className}}Controller extends BaseController { super(); } - @{{method}}("/") + @Get("/") execute(@response() res: Response): I{{className}}ResponseDTO { return this.callUseCase( this.{{useCase}}UseCase.execute(), diff --git a/src/generate/templates/controller-service-patch.tpl b/src/generate/templates/opinionated/controller-service-patch.tpl similarity index 86% rename from src/generate/templates/controller-service-patch.tpl rename to src/generate/templates/opinionated/controller-service-patch.tpl index d689f08..c7dd160 100644 --- a/src/generate/templates/controller-service-patch.tpl +++ b/src/generate/templates/opinionated/controller-service-patch.tpl @@ -1,5 +1,5 @@ import { BaseController, StatusCode } from "@expressots/core"; -import { controller, {{method}}, body, param, response } from "@expressots/adapter-express"; +import { controller, Patch, body, param, response } from "@expressots/adapter-express"; import { Response } from "express"; import { {{className}}UseCase } from "./{{fileName}}.usecase"; import { I{{className}}RequestDTO, I{{className}}ResponseDTO } from "./{{fileName}}.dto"; @@ -10,7 +10,7 @@ export class {{className}}Controller extends BaseController { super(); } - @{{method}}("/") + @Patch("/") execute( @body() payload: I{{className}}RequestDTO, @response() res: Response, diff --git a/src/generate/templates/controller-service-post.tpl b/src/generate/templates/opinionated/controller-service-post.tpl similarity index 86% rename from src/generate/templates/controller-service-post.tpl rename to src/generate/templates/opinionated/controller-service-post.tpl index 39d54e1..ac8481d 100644 --- a/src/generate/templates/controller-service-post.tpl +++ b/src/generate/templates/opinionated/controller-service-post.tpl @@ -1,5 +1,5 @@ import { BaseController, StatusCode } from "@expressots/core"; -import { controller, {{method}}, body, response } from "@expressots/adapter-express"; +import { controller, Post, body, response } from "@expressots/adapter-express"; import { Response } from "express"; import { {{className}}UseCase } from "./{{fileName}}.usecase"; import { I{{className}}RequestDTO, I{{className}}ResponseDTO } from "./{{fileName}}.dto"; @@ -10,7 +10,7 @@ export class {{className}}Controller extends BaseController { super(); } - @{{method}}("/") + @Post("/") execute(@body() payload: I{{className}}RequestDTO, @response() res: Response): I{{className}}ResponseDTO { return this.callUseCase( this.{{useCase}}UseCase.execute(payload), diff --git a/src/generate/templates/controller-service-put.tpl b/src/generate/templates/opinionated/controller-service-put.tpl similarity index 86% rename from src/generate/templates/controller-service-put.tpl rename to src/generate/templates/opinionated/controller-service-put.tpl index d689f08..806c782 100644 --- a/src/generate/templates/controller-service-put.tpl +++ b/src/generate/templates/opinionated/controller-service-put.tpl @@ -1,5 +1,5 @@ import { BaseController, StatusCode } from "@expressots/core"; -import { controller, {{method}}, body, param, response } from "@expressots/adapter-express"; +import { controller, Put, body, param, response } from "@expressots/adapter-express"; import { Response } from "express"; import { {{className}}UseCase } from "./{{fileName}}.usecase"; import { I{{className}}RequestDTO, I{{className}}ResponseDTO } from "./{{fileName}}.dto"; @@ -10,7 +10,7 @@ export class {{className}}Controller extends BaseController { super(); } - @{{method}}("/") + @Put("/") execute( @body() payload: I{{className}}RequestDTO, @response() res: Response, diff --git a/src/generate/templates/controller.tpl b/src/generate/templates/opinionated/controller-service.tpl similarity index 100% rename from src/generate/templates/controller.tpl rename to src/generate/templates/opinionated/controller-service.tpl diff --git a/src/generate/templates/dto.tpl b/src/generate/templates/opinionated/dto.tpl similarity index 100% rename from src/generate/templates/dto.tpl rename to src/generate/templates/opinionated/dto.tpl diff --git a/src/generate/templates/entity.tpl b/src/generate/templates/opinionated/entity.tpl similarity index 52% rename from src/generate/templates/entity.tpl rename to src/generate/templates/opinionated/entity.tpl index 2c6121e..1647b70 100644 --- a/src/generate/templates/entity.tpl +++ b/src/generate/templates/opinionated/entity.tpl @@ -1,8 +1,8 @@ -import { provide } from "inversify-binding-decorators"; +import { provide } from "@expressots/core"; import { randomUUID } from "node:crypto"; -@provide({{className}}) -export class {{className}} { +@provide({{className}}Entity) +export class {{className}}Entity { id: string; constructor() { diff --git a/src/generate/templates/opinionated/middleware.tpl b/src/generate/templates/opinionated/middleware.tpl new file mode 100644 index 0000000..829fe47 --- /dev/null +++ b/src/generate/templates/opinionated/middleware.tpl @@ -0,0 +1,9 @@ +import { ExpressoMiddleware, provide } from "@expressots/core"; +import { NextFunction, Request, Response } from "express"; + +@provide({{className}}Middleware) +export class {{className}}Middleware extends ExpressoMiddleware { + use(req: Request, res: Response, next: NextFunction): void | Promise { + throw new Error("Method not implemented."); + } +} \ No newline at end of file diff --git a/src/generate/templates/opinionated/module-service.tpl b/src/generate/templates/opinionated/module-service.tpl new file mode 100644 index 0000000..9ef58e7 --- /dev/null +++ b/src/generate/templates/opinionated/module-service.tpl @@ -0,0 +1,5 @@ +import { ContainerModule } from "inversify"; +import { CreateModule } from "@expressots/core"; +import { {{className}}Controller } from "{{{path}}}"; + +export const {{moduleName}}Module: ContainerModule = CreateModule([{{className}}Controller]); diff --git a/src/generate/templates/opinionated/module.tpl b/src/generate/templates/opinionated/module.tpl new file mode 100644 index 0000000..45df72d --- /dev/null +++ b/src/generate/templates/opinionated/module.tpl @@ -0,0 +1,4 @@ +import { ContainerModule } from "inversify"; +import { CreateModule } from "@expressots/core"; + +export const {{moduleName}}Module: ContainerModule = CreateModule([]); diff --git a/src/generate/templates/provider.tpl b/src/generate/templates/opinionated/provider.tpl similarity index 55% rename from src/generate/templates/provider.tpl rename to src/generate/templates/opinionated/provider.tpl index b27ed01..5965b08 100644 --- a/src/generate/templates/provider.tpl +++ b/src/generate/templates/opinionated/provider.tpl @@ -1,4 +1,4 @@ -import { provide } from "inversify-binding-decorators"; +import { provide } from "@expressots/core"; @provide({{className}}Provider) export class {{className}}Provider {} \ No newline at end of file diff --git a/src/generate/templates/opinionated/usecase-service-delete.tpl b/src/generate/templates/opinionated/usecase-service-delete.tpl new file mode 100644 index 0000000..bf05cf1 --- /dev/null +++ b/src/generate/templates/opinionated/usecase-service-delete.tpl @@ -0,0 +1,8 @@ +import { provide } from "@expressots/core"; + +@provide({{className}}UseCase) +export class {{className}}UseCase { + execute(id: string) { + return "Use Case"; + } +} diff --git a/src/generate/templates/usecase-op.tpl b/src/generate/templates/opinionated/usecase-service.tpl similarity index 82% rename from src/generate/templates/usecase-op.tpl rename to src/generate/templates/opinionated/usecase-service.tpl index 4806d7e..9769d89 100644 --- a/src/generate/templates/usecase-op.tpl +++ b/src/generate/templates/opinionated/usecase-service.tpl @@ -1,4 +1,4 @@ -import { provide } from "inversify-binding-decorators"; +import { provide } from "@expressots/core"; import { I{{className}}RequestDTO, I{{className}}ResponseDTO } from "./{{fileName}}.dto"; @provide({{className}}UseCase) diff --git a/src/generate/templates/usecase.tpl b/src/generate/templates/opinionated/usecase.tpl similarity index 68% rename from src/generate/templates/usecase.tpl rename to src/generate/templates/opinionated/usecase.tpl index 738d40d..473270f 100644 --- a/src/generate/templates/usecase.tpl +++ b/src/generate/templates/opinionated/usecase.tpl @@ -1,4 +1,4 @@ -import { provide } from "inversify-binding-decorators"; +import { provide } from "@expressots/core"; @provide({{className}}UseCase) export class {{className}}UseCase { diff --git a/src/generate/templates/usecase-post.tpl b/src/generate/templates/usecase-post.tpl deleted file mode 100644 index 5c391e8..0000000 --- a/src/generate/templates/usecase-post.tpl +++ /dev/null @@ -1,9 +0,0 @@ -import { provide } from "inversify-binding-decorators"; -import { I{{className}}RequestDTO, I{{className}}ResponseDTO } from "./{{fileName}}.dto"; - -@provide({{className}}UseCase) -export class {{className}}UseCase { - execute(payload: I{{className}}RequestDTO): I{{className}}ResponseDTO { - return "Use Case"; - } -} \ No newline at end of file diff --git a/src/generate/utils/command-utils.ts b/src/generate/utils/command-utils.ts new file mode 100644 index 0000000..9dd1dae --- /dev/null +++ b/src/generate/utils/command-utils.ts @@ -0,0 +1,393 @@ +import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import * as nodePath from "node:path"; +import { render } from "mustache"; +import { + anyCaseToCamelCase, + anyCaseToKebabCase, + anyCaseToPascalCase, + anyCaseToLowerCase, +} from "@expressots/boost-ts"; + +import { printError } from "../../utils/cli-ui"; +import { verifyIfFileExists } from "../../utils/verify-file-exists"; +import Compiler from "../../utils/compiler"; +import { ExpressoConfig, Pattern } from "../../types"; + +export const enum PathStyle { + None = "none", + Single = "single", + Nested = "nested", + Sugar = "sugar", +} + +/** + * File preparation + * @param schematic + * @param target + * @param method + * @param opinionated + * @param sourceRoot + * @returns the file output + */ +export type FilePreparation = { + schematic: string; + target: string; + method: string; + expressoConfig: ExpressoConfig; +}; + +/** + * File output + * @param path + * @param file + * @param className + * @param moduleName + * @param modulePath + * @param outputPath + * @param folderToScaffold + */ +export type FileOutput = { + path: string; + file: string; + className: string; + moduleName: string; + modulePath: string; + outputPath: string; + folderToScaffold: string; + fileName: string; + schematic: string; +}; + +/** + * Create a template based on the schematic + * @param fp + * @returns the file created + */ +export async function validateAndPrepareFile(fp: FilePreparation) { + const { sourceRoot, scaffoldSchematics, opinionated } = fp.expressoConfig; + if (sourceRoot === "") { + printError( + "You must specify a source root in your expressots.config.ts", + "sourceRoot", + ); + process.exit(1); + } + + if (opinionated) { + const folderSchematic = schematicFolder(fp.schematic); + + const folderToScaffold = `${sourceRoot}/${folderSchematic}`; + const { path, file, className, moduleName, modulePath } = + await splitTarget({ + target: fp.target, + schematic: fp.schematic, + }); + + const outputPath = `${folderToScaffold}/${path}/${file}`; + await verifyIfFileExists(outputPath, fp.schematic); + mkdirSync(`${folderToScaffold}/${path}`, { recursive: true }); + + return { + path, + file, + className, + moduleName, + modulePath, + outputPath, + folderToScaffold, + fileName: getFileNameWithoutExtension(file), + schematic: fp.schematic, + }; + } + + const folderSchematic = ""; + + const folderToScaffold = `${sourceRoot}/${folderSchematic}`; + const { path, file, className, moduleName, modulePath } = await splitTarget( + { + target: fp.target, + schematic: fp.schematic, + }, + ); + + const fileBaseSchema = + scaffoldSchematics?.[fp.schematic as keyof typeof scaffoldSchematics]; + const validateFileSchema = + fileBaseSchema !== undefined + ? file.replace(fp.schematic, fileBaseSchema) + : file; + + const outputPath = `${folderToScaffold}/${path}/${validateFileSchema}`; + await verifyIfFileExists(outputPath, fp.schematic); + mkdirSync(`${folderToScaffold}/${path}`, { recursive: true }); + + return { + path, + file, + className, + moduleName, + modulePath, + outputPath, + folderToScaffold, + fileName: getFileNameWithoutExtension(file), + schematic: fileBaseSchema !== undefined ? fileBaseSchema : fp.schematic, + }; +} + +/** + * Get the file name without the extension + * @param filePath + * @returns the file name + */ +export function getFileNameWithoutExtension(filePath: string) { + return filePath.split(".")[0]; +} + +/** + * Split the target into path, file, class name, module name and module path + * @param target + * @param schematic + * @returns the split target + */ +export const splitTarget = async ({ + target, + schematic, +}: { + target: string; + schematic: string; +}): Promise<{ + path: string; + file: string; + className: string; + moduleName: string; + modulePath: string; +}> => { + const pathContent: string[] = target + .split("/") + .filter((item) => item !== ""); + const endsWithSlash: boolean = target.endsWith("/"); + let path = ""; + let fileName = ""; + let module = ""; + let modulePath = ""; + + if ( + target.includes("/") || + target.includes("\\") || + target.includes("//") + ) { + if (schematic === "service") schematic = "controller"; + if ( + schematic === "service" || + (schematic === "controller" && pathContent.length > 4) + ) { + printError("Max path depth is 4.", pathContent.join("/")); + process.exit(1); + } + + if (endsWithSlash) { + fileName = pathContent[pathContent.length - 1]; + path = pathContent.join("/"); + module = + pathContent.length == 1 + ? pathContent[pathContent.length - 1] + : pathContent[pathContent.length - 2]; + modulePath = pathContent.slice(0, -1).join("/"); + } else { + fileName = pathContent[pathContent.length - 1]; + path = pathContent.slice(0, -1).join("/"); + module = + pathContent.length == 2 + ? pathContent[pathContent.length - 2] + : pathContent[pathContent.length - 3]; + modulePath = pathContent.slice(0, -2).join("/"); + } + + return { + path, + file: `${await getNameWithScaffoldPattern( + fileName, + )}.${schematic}.ts`, + className: anyCaseToPascalCase(fileName), + moduleName: module, + modulePath, + }; + } else { + if (schematic === "service") schematic = "controller"; + // 1. Extract the name (first part of the target) + const [name, ...remainingPath] = target.split("/"); + // 2. Check if the name is camelCase or kebab-case + const camelCaseRegex = /[A-Z]/; + const kebabCaseRegex = /[_\-\s]+/; + const isCamelCase = camelCaseRegex.test(name); + const isKebabCase = kebabCaseRegex.test(name); + if (isCamelCase || isKebabCase) { + const [wordName, ...path] = name + ? name + .split(isCamelCase ? /(?=[A-Z])/ : kebabCaseRegex) + .map((word) => word.toLowerCase()) + : []; + + return { + path: `${wordName}/${pathEdgeCase(path)}${pathEdgeCase( + remainingPath, + )}`, + file: `${await getNameWithScaffoldPattern( + name, + )}.${schematic}.ts`, + className: anyCaseToPascalCase(name), + moduleName: wordName, + modulePath: pathContent[0].split("-")[1], + }; + } + + // 3. Return the base case + return { + path: "", + file: `${await getNameWithScaffoldPattern(name)}.${schematic}.ts`, + className: anyCaseToPascalCase(name), + moduleName: name, + modulePath: "", + }; + } +}; + +/** + * Write the template based on the http method + * @param method - the http method + * @returns decorator - the decorator to be used + */ +export const getHttpMethod = (method: string): string => { + switch (method) { + case "put": + return "Put"; + case "post": + return "Post"; + case "patch": + return "Patch"; + case "delete": + return "Delete"; + default: + return "Get"; + } +}; + +/** + * Write the template based on the schematics + * @param outputPath - the output path + * @param template - the template to be used + * @returns void + */ +export const writeTemplate = ({ + outputPath, + template: { path, data }, +}: { + outputPath: string; + template: { + path: string; + data: Record; + }; +}) => { + writeFileSync( + outputPath, + render(readFileSync(nodePath.join(__dirname, path), "utf8"), data), + ); +}; + +/** + * Returns the folder where the schematic should be placed + * @param schematic + */ +export const schematicFolder = (schematic: string): string | undefined => { + switch (schematic) { + case "usecase": + return "useCases"; + case "controller": + return "useCases"; + case "dto": + return "useCases"; + case "service": + return "useCases"; + case "provider": + return "providers"; + case "entity": + return "entities"; + case "middleware": + return "providers/middlewares"; + case "module": + return "useCases"; + } + + return undefined; +}; + +/** + * Get the name with the scaffold pattern + * @param name + * @returns the name in the scaffold pattern + */ +export const getNameWithScaffoldPattern = async (name: string) => { + const configObject = await Compiler.loadConfig(); + + switch (configObject.scaffoldPattern) { + case Pattern.LOWER_CASE: + return anyCaseToLowerCase(name); + case Pattern.KEBAB_CASE: + return anyCaseToKebabCase(name); + case Pattern.PASCAL_CASE: + return anyCaseToPascalCase(name); + case Pattern.CAMEL_CASE: + return anyCaseToCamelCase(name); + } +}; + +/** + * Get the path edge case + * @param path + * @returns the path edge case from the last element of the path + */ +const pathEdgeCase = (path: string[]): string => { + return `${path.join("/")}${path.length > 0 ? "/" : ""}`; +}; + +/** + * Extract the first word from a file and convert it to the scaffold pattern + * @param file + * @returns the first word in the scaffold pattern + */ +export async function extractFirstWord(file: string) { + const f = file.split(".")[0]; + + const regex = /(?:-|(?<=[a-z])(?=[A-Z]))/; + const firstWord = f.split(regex)[0]; + + const config = await Compiler.loadConfig(); + switch (config.scaffoldPattern) { + case Pattern.LOWER_CASE: + return anyCaseToLowerCase(firstWord); + case Pattern.KEBAB_CASE: + return anyCaseToKebabCase(firstWord); + case Pattern.PASCAL_CASE: + return anyCaseToPascalCase(firstWord); + case Pattern.CAMEL_CASE: + return anyCaseToCamelCase(firstWord); + } +} + +/** + * Check if the path is a nested path, a single path or a sugar path + * @param path + * @returns the path style + */ +export const checkPathStyle = (path: string): PathStyle => { + const singleOrNestedPathRegex = /\/|\\/; + const sugarPathRegex = /^\w+-\w+$/; + + if (singleOrNestedPathRegex.test(path)) { + return PathStyle.Nested; + } else if (sugarPathRegex.test(path)) { + return PathStyle.Sugar; + } else { + return PathStyle.Single; + } +}; diff --git a/src/generate/utils/nonopininated-cmd.ts b/src/generate/utils/nonopininated-cmd.ts new file mode 100644 index 0000000..2a3f44b --- /dev/null +++ b/src/generate/utils/nonopininated-cmd.ts @@ -0,0 +1,389 @@ +import { + anyCaseToCamelCase, + anyCaseToKebabCase, + anyCaseToPascalCase, +} from "@expressots/boost-ts"; +import { ExpressoConfig } from "../../@types"; + +import { printGenerateSuccess } from "../../utils/cli-ui"; +import { + FileOutput, + getFileNameWithoutExtension, + getHttpMethod, + validateAndPrepareFile, + writeTemplate, +} from "./command-utils"; + +/** + * Process the non-opinionated command + * @param schematic - The schematic + * @param target - The target + * @param method - The method + * @param expressoConfig - The expresso config + */ +export async function nonOpinionatedProcess( + schematic: string, + target: string, + method: string, + expressoConfig: ExpressoConfig, +): Promise { + let f: FileOutput = await validateAndPrepareFile({ + schematic, + target, + method, + expressoConfig, + }); + switch (schematic) { + case "service": + f = await validateAndPrepareFile({ + schematic: "controller", + target, + method, + expressoConfig, + }); + await generateController( + f.outputPath, + f.className, + f.path, + method, + f.file, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + + f = await validateAndPrepareFile({ + schematic: "usecase", + target, + method, + expressoConfig, + }); + await generateUseCase( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.fileName, + f.schematic, + "../templates/nonopinionated/usecase.tpl", + ); + await printGenerateSuccess(f.schematic, f.file); + + f = await validateAndPrepareFile({ + schematic: "dto", + target, + method, + expressoConfig, + }); + await generateDTO( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + + f = await validateAndPrepareFile({ + schematic: "module", + target, + method, + expressoConfig, + }); + await generateModule( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + case "usecase": + await generateUseCase( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.fileName, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + case "controller": + await generateController( + f.outputPath, + f.className, + f.path, + method, + f.file, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + case "dto": + await generateDTO( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + case "provider": + await generateProvider( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + case "entity": + await generateEntity( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + case "middleware": + await generateMiddleware( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + case "module": + await generateModule( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + } + + return f.file; +} + +/* Generate Resource */ +/** + * Generate a use case + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + * @param template - The template + */ +async function generateUseCase( + outputPath: string, + className: string, + moduleName: string, + path: string, + fileName: string, + schematic: string, + template?: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: template + ? template + : "../templates/nonopinionated/usecase.tpl", + data: { + className, + moduleName, + path, + fileName, + schematic: anyCaseToPascalCase(schematic), + }, + }, + }); +} + +/** + * Generate a controller + * @param outputPath - The output path + * @param className - The class name + * @param path - The path + * @param method - The method + * @param file - The file + */ +async function generateController( + outputPath: string, + className: string, + path: string, + method: string, + file: string, + schematic: string, +): Promise { + const templateBasedMethod = "../templates/nonopinionated/controller.tpl"; + writeTemplate({ + outputPath, + template: { + path: templateBasedMethod, + data: { + className, + fileName: getFileNameWithoutExtension(file), + useCase: anyCaseToCamelCase(className), + route: className + ? className.toLowerCase() + : path.replace(/\/$/, ""), + construct: anyCaseToKebabCase(className), + method: getHttpMethod(method), + schematic: anyCaseToPascalCase(schematic), + }, + }, + }); +} + +/** + * Generate a DTO + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateDTO( + outputPath: string, + className: string, + moduleName: string, + path: string, + schematic: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/nonopinionated/dto.tpl", + data: { + className, + moduleName, + path, + schematic: anyCaseToPascalCase(schematic), + }, + }, + }); +} + +/** + * Generate a provider + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateProvider( + outputPath: string, + className: string, + moduleName: string, + path: string, + schematic: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/nonopinionated/provider.tpl", + data: { + className, + moduleName, + path, + schematic: anyCaseToPascalCase(schematic), + }, + }, + }); +} + +/** + * Generate an entity + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateEntity( + outputPath: string, + className: string, + moduleName: string, + path: string, + schematic: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/nonopinionated/entity.tpl", + data: { + className, + moduleName, + path, + schematic: anyCaseToPascalCase(schematic), + }, + }, + }); +} + +/** + * Generate a middleware + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateMiddleware( + outputPath: string, + className: string, + moduleName: string, + path: string, + schematic: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/nonopinionated/middleware.tpl", + data: { + className, + moduleName, + path, + schematic: anyCaseToPascalCase(schematic), + }, + }, + }); +} + +/** + * Generate a module + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateModule( + outputPath: string, + className: string, + moduleName: string, + path: string, + schematic: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/nonopinionated/module.tpl", + data: { + className, + moduleName: className + ? anyCaseToPascalCase(className) + : anyCaseToPascalCase(moduleName), + path, + schematic: anyCaseToPascalCase(schematic), + }, + }, + }); +} diff --git a/src/generate/utils/opinionated-cmd.ts b/src/generate/utils/opinionated-cmd.ts new file mode 100644 index 0000000..3c48006 --- /dev/null +++ b/src/generate/utils/opinionated-cmd.ts @@ -0,0 +1,677 @@ +import { + anyCaseToCamelCase, + anyCaseToKebabCase, + anyCaseToPascalCase, +} from "@expressots/boost-ts"; +import * as nodePath from "node:path"; +import fs from "fs"; +import { printGenerateSuccess } from "../../utils/cli-ui"; +import { + extractFirstWord, + FileOutput, + getFileNameWithoutExtension, + getHttpMethod, + PathStyle, + validateAndPrepareFile, + writeTemplate, +} from "./command-utils"; +import { addControllerToModule } from "../../utils/add-controller-to-module"; +import { + addModuleToContainer, + addModuleToContainerNestedPath, +} from "../../utils/add-module-to-container"; +import { ExpressoConfig } from "../../@types"; + +/** + * Process commands for opinionated service scaffolding + * @param schematic - Resource to scaffold + * @param target - Target path + * @param method - HTTP method + * @param expressoConfig - Expresso configuration [expressots.config.ts] + * @param pathStyle - Path command style [sugar, nested, single] + * @returns + */ +export async function opinionatedProcess( + schematic: string, + target: string, + method: string, + expressoConfig: ExpressoConfig, + pathStyle: string, +): Promise { + const f: FileOutput = await validateAndPrepareFile({ + schematic, + target, + method, + expressoConfig, + }); + switch (schematic) { + case "service": { + await generateControllerService( + f.outputPath, + f.className, + f.path, + method, + f.file, + ); + + const u = await validateAndPrepareFile({ + schematic: "usecase", + target, + method, + expressoConfig, + }); + await generateUseCaseService( + u.outputPath, + u.className, + method, + u.moduleName, + u.path, + u.fileName, + ); + + const d = await validateAndPrepareFile({ + schematic: "dto", + target, + method, + expressoConfig, + }); + await generateDTO(d.outputPath, d.className, d.moduleName, d.path); + + const m = await validateAndPrepareFile({ + schematic: "module", + target, + method, + expressoConfig, + }); + + if (pathStyle === PathStyle.Sugar) { + await generateModuleServiceSugarPath( + f.outputPath, + m.className, + m.moduleName, + m.path, + m.file, + m.folderToScaffold, + ); + } else if (pathStyle === PathStyle.Nested) { + await generateModuleServiceNestedPath( + f.outputPath, + m.className, + m.path, + m.folderToScaffold, + ); + } else if (pathStyle === PathStyle.Single) { + await generateModuleServiceSinglePath( + f.outputPath, + m.className, + m.moduleName, + m.path, + m.file, + m.folderToScaffold, + ); + } + + await printGenerateSuccess("controller", f.file); + await printGenerateSuccess("usecase", f.file); + await printGenerateSuccess("dto", f.file); + await printGenerateSuccess("module", f.file); + break; + } + case "usecase": + await generateUseCase( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.fileName, + ); + await printGenerateSuccess(schematic, f.file); + break; + case "controller": + await generateController( + f.outputPath, + f.className, + f.path, + method, + f.file, + ); + await printGenerateSuccess(schematic, f.file); + break; + case "dto": + await generateDTO(f.outputPath, f.className, f.moduleName, f.path); + await printGenerateSuccess(schematic, f.file); + break; + case "provider": + await generateProvider( + f.outputPath, + f.className, + f.moduleName, + f.path, + ); + await printGenerateSuccess(schematic, f.file); + break; + case "entity": + await generateEntity( + f.outputPath, + f.className, + f.moduleName, + f.path, + ); + await printGenerateSuccess(schematic, f.file); + break; + case "middleware": + await generateMiddleware( + f.outputPath, + f.className, + f.moduleName, + f.path, + ); + await printGenerateSuccess(schematic, f.file); + break; + case "module": + await generateModule( + f.outputPath, + f.className, + f.moduleName, + f.path, + ); + await printGenerateSuccess(schematic, f.file); + break; + } + + return f.file; +} + +/* Generate Resource */ + +/** + * Generate a controller service + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + * @param method - The method + * @param file - The file + */ +async function generateControllerService( + outputPath: string, + className: string, + path: string, + method: string, + file: string, +): Promise { + let templateBasedMethod = ""; + + switch (method) { + case "put": + templateBasedMethod = + "../templates/opinionated/controller-service-put.tpl"; + break; + case "patch": + templateBasedMethod = + "../templates/opinionated/controller-service-patch.tpl"; + break; + case "post": + templateBasedMethod = + "../templates/opinionated/controller-service-post.tpl"; + break; + case "delete": + templateBasedMethod = + "../templates/opinionated/controller-service-delete.tpl"; + break; + default: + templateBasedMethod = + "../templates/opinionated/controller-service-get.tpl"; + break; + } + + writeTemplate({ + outputPath, + template: { + path: templateBasedMethod, + data: { + className, + fileName: getFileNameWithoutExtension(file), + useCase: anyCaseToCamelCase(className), + route: path.replace(/\/$/, ""), + construct: anyCaseToKebabCase(className), + method: getHttpMethod(method), + }, + }, + }); +} + +/** + * Generate a use case + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + * @param template - The template + */ +async function generateUseCaseService( + outputPath: string, + className: string, + method: string, + moduleName: string, + path: string, + fileName: string, +): Promise { + let templateBasedMethod = ""; + + switch (method) { + case "put": + templateBasedMethod = + "../templates/opinionated/usecase-service.tpl"; + break; + case "patch": + templateBasedMethod = + "../templates/opinionated/usecase-service.tpl"; + break; + case "post": + templateBasedMethod = + "../templates/opinionated/usecase-service.tpl"; + break; + case "delete": + templateBasedMethod = + "../templates/opinionated/usecase-service-delete.tpl"; + break; + default: + templateBasedMethod = "../templates/opinionated/usecase.tpl"; + break; + } + writeTemplate({ + outputPath, + template: { + path: templateBasedMethod, + data: { + className, + moduleName, + path, + fileName, + }, + }, + }); +} + +/** + * Generate a use case + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + * @param fileName - The file name + */ +async function generateUseCase( + outputPath: string, + className: string, + moduleName: string, + path: string, + fileName: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/opinionated/usecase.tpl", + data: { + className, + moduleName, + path, + fileName, + }, + }, + }); +} + +/** + * Generate a controller + * @param outputPath - The output path + * @param className - The class name + * @param path - The path + * @param method - The method + * @param file - The file + */ +async function generateController( + outputPath: string, + className: string, + path: string, + method: string, + file: string, +): Promise { + const templateBasedMethod = + "../templates/opinionated/controller-service.tpl"; + + writeTemplate({ + outputPath, + template: { + path: templateBasedMethod, + data: { + className, + fileName: getFileNameWithoutExtension(file), + useCase: anyCaseToCamelCase(className), + route: path.replace(/\/$/, ""), + construct: anyCaseToKebabCase(className), + method: getHttpMethod(method), + }, + }, + }); +} + +/** + * Generate a DTO + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateDTO( + outputPath: string, + className: string, + moduleName: string, + path: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/opinionated/dto.tpl", + data: { + className, + moduleName, + path, + }, + }, + }); +} + +/** + * Generate a provider + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateProvider( + outputPath: string, + className: string, + moduleName: string, + path: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/opinionated/provider.tpl", + data: { + className, + moduleName, + path, + }, + }, + }); +} + +/** + * Generate an entity + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateEntity( + outputPath: string, + className: string, + moduleName: string, + path: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/opinionated/entity.tpl", + data: { + className, + moduleName, + path, + }, + }, + }); +} + +/** + * Generate a middleware + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateMiddleware( + outputPath: string, + className: string, + moduleName: string, + path: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/opinionated/middleware.tpl", + data: { + className, + moduleName, + path, + }, + }, + }); +} + +/** + * Generate a module for service scaffolding with sugar path + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateModuleServiceSugarPath( + outputPathController: string, + className: string, + moduleName: string, + path: string, + file: string, + folderToScaffold: string, +): Promise { + const newModuleFile = await extractFirstWord(file); + const newModulePath = nodePath + .join(folderToScaffold, path, "..") + .normalize(); + const newModuleName = `${newModuleFile}.module.ts`; + const newModuleOutputPath = `${newModulePath}/${newModuleName}`.replace( + "\\", + "/", + ); + + const controllerToModule = nodePath + .relative(newModuleOutputPath, outputPathController) + .normalize() + .replace(/\.ts$/, "") + .replace(/\\/g, "/") + .replace(/\.\./g, "."); + + const controllerFullPath = nodePath + .join(folderToScaffold, path, "..", newModuleName) + .normalize(); + + if (fs.existsSync(newModuleOutputPath)) { + await addControllerToModule( + controllerFullPath, + `${className}Controller`, + controllerToModule, + ); + return; + } + + writeTemplate({ + outputPath: newModuleOutputPath, + template: { + path: "../templates/opinionated/module-service.tpl", + data: { + className, + moduleName: anyCaseToPascalCase(moduleName), + path: controllerToModule, + }, + }, + }); + + await addModuleToContainer( + anyCaseToPascalCase(moduleName), + `${moduleName}/${file.replace(".ts", "")}`, + path, + ); +} + +/** + * Generate a module for service scaffolding with single path + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateModuleServiceSinglePath( + outputPathController: string, + className: string, + moduleName: string, + path: string, + file: string, + folderToScaffold: string, +): Promise { + const newModuleFile = await extractFirstWord(file); + const newModulePath = nodePath.join(folderToScaffold, path).normalize(); + const newModuleName = `${newModuleFile}.module.ts`; + const newModuleOutputPath = `${newModulePath}/${newModuleName}`.replace( + "\\", + "/", + ); + + const controllerToModule = nodePath + .relative(newModuleOutputPath, outputPathController) + .normalize() + .replace(/\.ts$/, "") + .replace(/\\/g, "/") + .replace(/\.\./g, "."); + + const controllerFullPath = nodePath + .join(folderToScaffold, path, "..", newModuleName) + .normalize(); + + if (fs.existsSync(newModuleOutputPath)) { + await addControllerToModule( + controllerFullPath, + `${className}Controller`, + controllerToModule, + ); + return; + } + + writeTemplate({ + outputPath: newModuleOutputPath, + template: { + path: "../templates/opinionated/module-service.tpl", + data: { + className, + moduleName: anyCaseToPascalCase(moduleName), + path: controllerToModule, + }, + }, + }); + + await addModuleToContainer( + anyCaseToPascalCase(moduleName), + `${moduleName}/${file.replace(".ts", "")}`, + path, + ); +} + +/** + * Generate a module for service scaffolding with nested path + * @param outputPathController + * @param className + * @param path + * @param folderToScaffold + * @returns + */ +async function generateModuleServiceNestedPath( + outputPathController: string, + className: string, + path: string, + folderToScaffold: string, +): Promise { + const moduleFileName = nodePath.basename(path, "/"); + const newModulePath = nodePath + .join(folderToScaffold, path, "..") + .normalize(); + + const newModuleName = `${moduleFileName}.module.ts`; + const newModuleOutputPath = `${newModulePath}/${newModuleName}`.replace( + "\\", + "/", + ); + + const controllerToModule = nodePath + .relative(newModuleOutputPath, outputPathController) + .normalize() + .replace(/\.ts$/, "") + .replace(/\\/g, "/") + .replace(/\.\./g, "."); + + const controllerFullPath = nodePath + .join(folderToScaffold, path, "..", newModuleName) + .normalize(); + + if (fs.existsSync(newModuleOutputPath)) { + await addControllerToModule( + controllerFullPath, + `${className}Controller`, + controllerToModule, + ); + return; + } + + writeTemplate({ + outputPath: newModuleOutputPath, + template: { + path: "../templates/opinionated/module-service.tpl", + data: { + className, + moduleName: anyCaseToPascalCase(moduleFileName), + path: controllerToModule, + }, + }, + }); + + await addModuleToContainerNestedPath( + anyCaseToPascalCase(moduleFileName), + path, + ); +} + +/** + * Generate a module + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateModule( + outputPath: string, + className: string, + moduleName: string, + path: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/opinionated/module.tpl", + data: { + className, + moduleName: anyCaseToPascalCase(moduleName), + path, + }, + }, + }); +} diff --git a/src/help/cli.ts b/src/help/cli.ts new file mode 100644 index 0000000..489ce58 --- /dev/null +++ b/src/help/cli.ts @@ -0,0 +1,18 @@ +import { CommandModule } from "yargs"; +import { helpForm } from "./form"; + +// eslint-disable-next-line @typescript-eslint/ban-types +type CommandModuleArgs = {}; + +const helpCommand = (): CommandModule => { + return { + command: "resources", + describe: "Resource list", + aliases: ["r"], + handler: async () => { + await helpForm(); + }, + }; +}; + +export { helpCommand }; diff --git a/src/help/form.ts b/src/help/form.ts new file mode 100644 index 0000000..1c15272 --- /dev/null +++ b/src/help/form.ts @@ -0,0 +1,47 @@ +import chalk from "chalk"; +import CliTable3 from "cli-table3"; + +const helpForm = async (): Promise => { + const table = new CliTable3({ + head: [ + chalk.green("Name"), + chalk.green("Alias"), + chalk.green("Description"), + ], + colWidths: [15, 15, 60], + }); + + table.push( + ["new project", "new", "Generate a new project"], + ["info", "i", "Provides project information"], + ["resources", "r", "Displays cli commands and resources"], + ["help", "h", "Show command help"], + [ + "service", + "g s", + "Generate a service [controller, usecase, dto, module]", + ], + ["controller", "g c", "Generate a controller"], + ["usecase", "g u", "Generate a usecase"], + ["dto", "g d", "Generate a dto"], + ["entity", "g e", "Generate an entity"], + ["provider", "g p", "Generate a provider"], + ["provider external", "a provider", "Generate an external provider"], + ["module", "g mo", "Generate a module"], + ["middleware", "g mi", "Generate a middleware"], + ); + console.log( + chalk.bold.white("ExpressoTS:", `${chalk.green("Resources List")}`), + ); + console.log(chalk.whiteBright(table.toString())); + console.log( + chalk.bold.white( + `πŸ“ More info: ${chalk.green( + "https://doc.expresso-ts.com/docs/category/cli", + )}`, + ), + ); + console.log("\n"); +}; + +export { helpForm }; diff --git a/src/help/index.ts b/src/help/index.ts new file mode 100644 index 0000000..c1d55cf --- /dev/null +++ b/src/help/index.ts @@ -0,0 +1 @@ +export * from "./cli"; diff --git a/src/index.ts b/src/index.ts index 1391039..814fd72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,5 @@ export * from "./types"; export * from "./generate"; export * from "./utils"; export * from "./new"; +export * from "./help"; +export * from "./providers"; diff --git a/src/info/form.ts b/src/info/form.ts index 321417d..43016bc 100644 --- a/src/info/form.ts +++ b/src/info/form.ts @@ -2,7 +2,6 @@ import chalk from "chalk"; import path from "path"; import fs from "fs"; import os from "os"; -import { CLI_VERSION } from "../cli"; import { printError } from "../utils/cli-ui"; function getInfosFromPackage() { @@ -16,10 +15,10 @@ function getInfosFromPackage() { const packageJson = JSON.parse(fileContents); console.log(chalk.green("ExpressoTS Project:")); - console.log(chalk.bold(`\tName: ${packageJson.name}`)); - console.log(chalk.bold(`\tDescription: ${packageJson.description}`)); - console.log(chalk.bold(`\tVersion: ${packageJson.version}`)); - console.log(chalk.bold(`\tAuthor: ${packageJson.author}`)); + console.log(chalk.white(`\tName: ${packageJson.name}`)); + console.log(chalk.white(`\tDescription: ${packageJson.description}`)); + console.log(chalk.white(`\tVersion: ${packageJson.version}`)); + console.log(chalk.white(`\tAuthor: ${packageJson.author}`)); } catch (error) { printError( "No project information available.", @@ -28,15 +27,12 @@ function getInfosFromPackage() { } } -const infoForm = async (): Promise => { - console.log(chalk.green("System informations:")); - console.log(chalk.bold(`\tOS Version: ${os.version()}`)); - console.log(chalk.bold(`\tNodeJS version: ${process.version}`)); - - console.log(chalk.green("CLI Version:")); - console.log(chalk.bold(`\tCurrent version: v${CLI_VERSION}`)); - +const infoForm = (): void => { getInfosFromPackage(); + + console.log(chalk.green("System information:")); + console.log(chalk.white(`\tOS Version: ${os.version()}`)); + console.log(chalk.white(`\tNodeJS version: ${process.version}`)); }; export { infoForm }; diff --git a/src/new/cli.ts b/src/new/cli.ts index 1bf5702..7b02655 100644 --- a/src/new/cli.ts +++ b/src/new/cli.ts @@ -1,53 +1,68 @@ import { Argv, CommandModule } from "yargs"; import { projectForm } from "./form"; +import semver from "semver"; -// eslint-disable-next-line @typescript-eslint/ban-types -type CommandModuleArgs = {}; +type CommandModuleArgs = object; -const createProject = (): CommandModule => { - const packageManagers: Array = ["npm", "yarn", "pnpm"]; +const packageManagers: Array = [ + "npm", + "yarn", + "pnpm", + ...(process.platform !== "win32" ? ["bun"] : []), +]; + +const commandOptions = (yargs: Argv): Argv => { + return yargs + .positional("project-name", { + describe: "The name of the project", + type: "string", + }) + .option("template", { + describe: "The project template to use", + type: "string", + choices: ["opinionated", "non-opinionated"], + alias: "t", + }) + .option("package-manager", { + describe: "The package manager to use", + type: "string", + choices: packageManagers, + alias: "p", + }) + .option("directory", { + describe: "The directory for new project", + type: "string", + alias: "d", + }) + .implies("package-manager", "template") + .implies("template", "package-manager"); +}; + +const checkNodeVersion = (): void => { + const minVersion = "18.0.0"; + const maxVersion = "20.7.0"; + const currentVersion = process.version; - if (process.platform !== "win32") { - packageManagers.push("bun"); + if (!semver.satisfies(currentVersion, `>=${minVersion} <=${maxVersion}`)) { + console.error( + `Node.js version ${currentVersion} is not supported. Please use a version between ${minVersion} and ${maxVersion}.`, + ); + process.exit(1); } +}; +const createProject = (): CommandModule => { return { command: "new [package-manager] [template] [directory]", describe: "Create a new project", - builder: (yargs: Argv): Argv => { - yargs - .positional("project-name", { - describe: "The name of the project", - type: "string", - }) - .option("template", { - describe: "The project template to use", - type: "string", - choices: ["non-opinionated", "opinionated"], - alias: "t", - }) - .option("package-manager", { - describe: "The package manager to use", - type: "string", - choices: packageManagers, - alias: "p", - }) - .option("directory", { - describe: "The directory for new project", - type: "string", - alias: "d", - }) - .implies("package-manager", "template") - .implies("template", "package-manager"); - - return yargs; - }, + builder: commandOptions, handler: async ({ projectName, packageManager, template, directory, }) => { + checkNodeVersion(); return await projectForm(projectName, [ packageManager, template, diff --git a/src/new/form.ts b/src/new/form.ts index 8ba925a..ee829c7 100644 --- a/src/new/form.ts +++ b/src/new/form.ts @@ -1,5 +1,5 @@ import chalk from "chalk"; -import { execSync, spawn } from "child_process"; +import { execSync, spawn } from "node:child_process"; import { Presets, SingleBar } from "cli-progress"; import degit from "degit"; import inquirer from "inquirer"; @@ -27,10 +27,26 @@ async function packageManagerInstall({ cwd: directory, }); - installProcess.stdout.on("data", (data: Buffer) => { - progressBar.increment(5, { - doing: `${data.toString().trim()}`, - }); + installProcess.on("error", (error) => { + reject(new Error(`Failed to start subprocess: ${error.message}`)); + }); + + installProcess.stdout?.on("data", (data: Buffer) => { + const output = data.toString().trim(); + + const npmProgressMatch = output.match( + /\[(\d+)\/(\d+)\] (?:npm )?([\w\s]+)\.{3}/, + ); + + if (npmProgressMatch) { + const [, current, total, task] = npmProgressMatch; + const progress = Math.round( + (parseInt(current) / parseInt(total)) * 100, + ); + progressBar.update(progress, { doing: task }); + } else { + progressBar.increment(5, { doing: output }); + } }); installProcess.on("close", (code) => { @@ -79,9 +95,25 @@ function changePackageName({ fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); } +function renameEnvFile(directory: string): void { + try { + const envExamplePath = path.join(directory, ".env.example"); + const envPath = path.join(directory, ".env"); + + if (!fs.existsSync(envExamplePath)) { + throw new Error(`File not found: ${envExamplePath}`); + } + + fs.renameSync(envExamplePath, envPath); + } catch (error: any) { + printError("Error renaming .env.example file", ".env.example to .env"); + process.exit(1); + } +} + enum Template { - "non-opinionated" = "Non-Opinionated :: A simple ExpressoTS project.", - opinionated = "Opinionated :: A complete ExpressoTS project with an opinionated structure and features.", + "non-opinionated" = "Non-Opinionated :: Allows users to choose where to scaffold resources, offering flexible project organization.", + opinionated = "Opinionated :: Automatically scaffolds resources into a preset project structure. (Recommended)", } const enum PackageManager { @@ -137,8 +169,10 @@ const projectForm = async (projectName: string, args: ProjectFormArgs): Promise< name: "template", message: "Select a template", choices: [ - "Non-Opinionated :: A simple ExpressoTS project.", - "Opinionated :: A complete ExpressoTS project with an opinionated structure and features.", + `Opinionated :: Automatically scaffolds resources into a preset project structure. (${chalk.yellow( + "Recommended", + )})`, + "Non-Opinionated :: Allows users to choose where to scaffold resources, offering flexible project organization.", ], }, { @@ -185,7 +219,7 @@ const projectForm = async (projectName: string, args: ProjectFormArgs): Promise< "| {percentage}% || {doing}", hideCursor: true, }, - Presets.shades_classic, + Presets.rect, ); progressBar.start(100, 0, { @@ -201,6 +235,7 @@ const projectForm = async (projectName: string, args: ProjectFormArgs): Promise< await emitter.clone(answer.name); } catch (err: any) { + console.log("\n"); printError( "Project already exists or Folder is not empty", answer.name, @@ -225,13 +260,15 @@ const projectForm = async (projectName: string, args: ProjectFormArgs): Promise< name: projectName, }); + renameEnvFile(answer.name); + progressBar.update(100); progressBar.stop(); console.log("\n"); console.log( - "🐎 Project ", + "🐎 Project", chalk.green(answer.name), "created successfully!", ); @@ -269,6 +306,7 @@ const projectForm = async (projectName: string, args: ProjectFormArgs): Promise< ), ), ); + console.log("\n"); } }; diff --git a/src/providers/cli.ts b/src/providers/cli.ts new file mode 100644 index 0000000..329b7f7 --- /dev/null +++ b/src/providers/cli.ts @@ -0,0 +1,46 @@ +import { Argv, CommandModule } from "yargs"; +import { externalProvider } from "./external/external.provider"; +import { prismaProvider } from "./prisma/prisma.provider"; + +// eslint-disable-next-line @typescript-eslint/ban-types +type CommandModuleArgs = {}; + +const generateProviders = (): CommandModule => { + return { + command: "add [library-version] [provider-version]", + describe: "Scaffold a new provider", + aliases: ["a"], + builder: (yargs: Argv): Argv => { + yargs + .positional("provider", { + choices: ["prisma", "provider"] as const, + describe: "The provider to add to the project", + type: "string", + alias: "p", + }) + .option("library-version", { + describe: "The library version to install", + type: "string", + default: "latest", + alias: "v", + }) + .option("provider-version", { + describe: "The version of the provider to install", + type: "string", + default: "latest", + alias: "vp", + }); + + return yargs; + }, + handler: async ({ provider, libraryVersion, providerVersion }) => { + if (provider === "prisma") { + await prismaProvider(libraryVersion, providerVersion); + } else if (provider === "provider") { + await externalProvider(); + } + }, + }; +}; + +export { generateProviders }; diff --git a/src/providers/external/external.provider.ts b/src/providers/external/external.provider.ts new file mode 100644 index 0000000..c8af189 --- /dev/null +++ b/src/providers/external/external.provider.ts @@ -0,0 +1,68 @@ +import chalk from "chalk"; +import degit from "degit"; +import inquirer from "inquirer"; +import { centerText } from "../../utils/center-text"; +import { printError } from "../../utils/cli-ui"; + +async function printInfo(providerName: string): Promise { + console.log("\n"); + console.log( + "🐎 Provider", + chalk.green(providerName), + "created successfully!", + ); + console.log("πŸ€™ Run the following commands to start the provider:\n"); + + console.log(chalk.bold.gray(`$ cd ${providerName}`)); + + console.log("\n"); + console.log(chalk.bold.green(centerText("Happy coding!"))); + console.log( + chalk.bold.gray( + centerText("Please consider donating to support the project.\n"), + ), + ); + console.log( + chalk.bold.white( + centerText("πŸ’– Sponsor: https://github.com/sponsors/expressots"), + ), + ); + console.log("\n"); +} + +interface IExternalProvider { + providerName: string; +} + +const externalProvider = async (): Promise => { + return new Promise(async (resolve, reject) => { + const providerInfo = await inquirer.prompt([ + { + type: "input", + name: "providerName", + message: "Type the name of your provider:", + default: "expressots-provider", + transformer: (input: string) => { + return chalk.yellow(chalk.bold(input)); + }, + }, + ]); + + try { + const emitter = degit(`expressots/expressots-provider-template`); + await emitter.clone(providerInfo.providerName); + await printInfo(providerInfo.providerName); + + resolve(); + } catch (err: any) { + console.log("\n"); + printError( + "Project already exists or Folder is not empty", + "generate-external-provider", + ); + reject(err); + } + }); +}; + +export { externalProvider }; diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..c1d55cf --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1 @@ +export * from "./cli"; diff --git a/src/providers/prisma/prisma.provider.ts b/src/providers/prisma/prisma.provider.ts new file mode 100644 index 0000000..ecb5220 --- /dev/null +++ b/src/providers/prisma/prisma.provider.ts @@ -0,0 +1,399 @@ +import chalk from "chalk"; +import inquirer, { PromptModule } from "inquirer"; +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import { printError } from "../../utils/cli-ui"; +import path from "node:path"; +import { exit } from "node:process"; +import Compiler from "../../utils/compiler"; +import { hasFolder } from "../../utils/find-folder"; + +const prismaProvider = async ( + version: string, + providerVersion: string, +): Promise => { + const choices = [ + { name: "CockroachDB", value: "cockroachdb" }, + { name: "Microsoft SQL Server", value: "sqlserver" }, + { name: "MongoDB", value: "mongodb" }, + { name: "MySQL", value: "mysql" }, + { name: "PostgreSQL", value: "postgresql" }, + { name: "SQLite", value: "sqlite" }, + ]; + + const drivers = { + PostgreSQL: "pg", + MySQL: "mysql2", + SQLite: "sqlite3", + "Microsoft SQL Server": "mssql", + MongoDB: "mongodb", + CockroachDB: "pg", + } as { [key: string]: string }; + + const answerPt1 = await inquirer.prompt([ + { + type: "input", + name: "prismaClientVersion", + message: "Type the prisma client version (default=latest):", + default: "latest", + transformer: (input: string) => { + return chalk.yellow(chalk.bold(input)); + }, + }, + { + type: "input", + name: "schemaName", + message: "Type the schema name (default=schema):", + default: "schema", + transformer: (input: string) => { + return chalk.yellow(chalk.bold(input)); + }, + }, + { + type: "input", + name: "schemaPath", + message: + "Where do you want to save your prisma schema (default=./):", + default: ".", + transformer: (input: string) => { + return chalk.yellow(chalk.bold(input)); + }, + }, + { + type: "list", + name: "databaseName", + message: "Select your database:", + choices: choices.map((choice) => choice.name), + }, + ]); + + const answerPt2 = await inquirer.prompt([ + { + type: "confirm", + name: "installDriver", + message: `Do you want to install the latest recommended database driver for ${answerPt1.databaseName}?`, + default: true, + }, + { + type: "confirm", + name: "baseRepository", + message: + "Do you want to add BaseRepository Pattern in this project?\nthis will replace the existing BaseRepository and BaseRespositoryInterface if it exists.", + default: true, + }, + { + type: "confirm", + name: "confirm", + message: "Do you want to add prisma provider in this project?", + default: true, + }, + ]); + + const answer = { ...answerPt1, ...answerPt2 }; + + if (answer.confirm) { + // Find which package manager the user has used to install the desired prisma version + const packageManager = fs.existsSync( + "package-lock.json" || "yarn.lock" || "pnpm-lock.yaml", + ) + ? "npm" + : fs.existsSync("yarn.lock") + ? "yarn" + : fs.existsSync("pnpm-lock.yaml") + ? "pnpm" + : null; + + if (packageManager) { + // Install prisma in the project + console.log(`Installing prisma with ${packageManager}...`); + await execProcess({ + commandArg: packageManager, + args: ["add", `prisma@${providerVersion}`, "-D"], + directory: process.cwd(), + }); + + // Install Prisma Client + console.log(`Installing Prisma Client with ${packageManager}...`); + await execProcess({ + commandArg: packageManager, + args: ["add", `@prisma/client@${answer.prismaClientVersion}`], + directory: process.cwd(), + }); + + if (answer.installDriver) { + // Install database driver + console.log( + `Installing the latest recommended database driver for ${ + answer.databaseName + }: ${drivers[answer.databaseName]} ...`, + ); + await execProcess({ + commandArg: packageManager, + args: ["add", drivers[answer.databaseName]], + directory: process.cwd(), + }); + } + + // Install @expressots/prisma in the project + console.log( + `Installing @expressots/prisma with ${packageManager}...`, + ); + await execProcess({ + commandArg: packageManager, + args: ["add", `@expressots/prisma@${version}`], + directory: process.cwd(), + }); + } else { + printError( + `Could not find a package manager installed in this project.\nPlease install prisma and @expressots/prisma manually.`, + "prisma", + ); + process.exit(1); + } + + // Map choices to find the corresponding value + answer.databaseName = choices.find( + (choice) => choice.name === answer.databaseName, + )?.value; + + // Init prisma + console.log(`Initializing prisma...`); + + const prismaFolder = hasFolder(process.cwd(), ["node_modules", ".git"]); + + if (prismaFolder.found) { + const prismaEnquirer = await inquirer.prompt([ + { + type: "confirm", + name: "confirm", + message: + "Prisma is already initialized. Do you want to override it?", + default: true, + }, + ]); + + if (prismaEnquirer?.confirm === false) { + console.log(chalk.red("Prisma init aborted!")); + return; + } + + fs.rmSync(prismaFolder.path, { + recursive: true, + force: true, + }); + } + + await execProcess({ + commandArg: "npx", + args: [ + "prisma", + "init", + "--datasource-provider", + answer.databaseName, + ], + directory: process.cwd(), + }); + + const oldFileName = path.join(process.cwd(), "/prisma/schema.prisma"); + const newFileName = path.join( + process.cwd(), + `/prisma/${answer.schemaName}.prisma`, + ); + fs.renameSync(oldFileName, newFileName); + + // Move the folder to the destination + const schemaPath = path.join( + process.cwd(), + `${answer.schemaPath}/prisma/${answer.schemaName}.prisma`, + ); + if (!fs.existsSync(schemaPath)) { + fs.mkdirSync( + path.join(process.cwd(), `${answer.schemaPath}/prisma`), + { recursive: true }, + ); + } + fs.renameSync(newFileName, schemaPath); + + // Remove the source folder + if (newFileName !== schemaPath) { + fs.rmSync(path.join(process.cwd(), "/prisma"), { recursive: true }); + } + + // Add prisma to package.json + prismaPackage(answer); + + if (answer.baseRepository) { + const { opinionated, sourceRoot } = await Compiler.loadConfig(); + let folderMatch = ""; + if (opinionated) { + folderMatch = "repositories"; + } else { + folderMatch = ""; + } + const repositoryDir = `${sourceRoot}/${folderMatch}`; + + console.log(`Generating BaseRepository Pattern...`); + + const baseRepositoryInterfaceTplPath = path.join( + __dirname, + "./templates/base-repository.interface.tpl", + ); + const baseRepositoryTplPath = path.join( + __dirname, + "./templates/base-repository.tpl", + ); + + const baseRepositoryInterfaceTemplate = fs.readFileSync( + baseRepositoryInterfaceTplPath, + "utf8", + ); + const baseRepositoryTemplate = fs.readFileSync( + baseRepositoryTplPath, + "utf8", + ); + fs.writeFileSync( + path.join(repositoryDir, "base-repository.interface.ts"), + baseRepositoryInterfaceTemplate, + ); + + fs.writeFileSync( + path.join(repositoryDir, "base-repository.ts"), + baseRepositoryTemplate, + ); + } + + // Install @expressots/prisma in the project + console.log(`Mapping configurations to expressots.config...`); + await addProviderConfigInExpressotsConfig( + answer.schemaName, + `${answer.schemaPath}/prisma`, + ); + + console.log("Now configure your database connection in the project."); + console.log(chalk.green("\nπŸ‘ Prisma provider added successfully!")); + } else { + console.log(chalk.red("Prisma provider not added!")); + } +}; + +async function execProcess({ + commandArg, + args, + directory, +}: { + commandArg: string; + args: string[]; + directory: string; +}) { + return new Promise((resolve, reject) => { + const isWindows: boolean = process.platform === "win32"; + const command: string = isWindows ? `${commandArg}.cmd` : commandArg; + + const installProcess = spawn(command, args, { + cwd: directory, + }); + + console.log(chalk.bold.blue(`Executing: ${command} ${args.join(" ")}`)); + console.log(chalk.yellow("---------------------------------------")); + + installProcess.stdout.on("data", (data) => { + console.log(chalk.green(data.toString().trim())); // Display regular messages in green + }); + + installProcess.stderr.on("data", (data) => { + console.error(chalk.red(data.toString().trim())); // Display error messages in red + }); + + installProcess.on("close", (code) => { + if (code === 0) { + console.log( + chalk.bold.green("---------------------------------------"), + ); + console.log(chalk.bold.green("Installation Done!")); + resolve("Installation Done!"); + } else { + console.error( + chalk.bold.red("---------------------------------------"), + ); + console.error( + chalk.bold.red( + `Command ${command} ${args.join( + " ", + )} exited with code ${code}`, + ), + ); + reject( + new Error( + `Command ${command} ${args.join( + " ", + )} exited with code ${code}`, + ), + ); + exit(1); + } + }); + }); +} + +function prismaPackage(answer: any): void { + // Get the absolute path of the input directory parameter + const absDirPath = path.resolve(process.cwd()); + + // Load the package.json file + const packageJsonPath = path.join(absDirPath, "package.json"); + const fileContents = fs.readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(fileContents); + + // Add the Prisma configuration to the package.json + packageJson.prisma = { + schema: `${answer.schemaPath}/prisma/${answer.schemaName}.prisma`, + }; + + // Add the Prisma script to the package.json + packageJson.scripts = { + ...packageJson.scripts, + prisma: "npx @expressots/prisma codegen", + }; + + // Save the package.json file + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); +} + +async function addProviderConfigInExpressotsConfig( + schemaName: string, + schemaPath: string, +): Promise { + const absDirPath = path.resolve(process.cwd()); + + const expressotsConfigPath = path.join(absDirPath, "expressots.config.ts"); + + let fileContents = fs.readFileSync(expressotsConfigPath, "utf-8"); + + const config = await Compiler.loadConfig(); + + const providersObject = `opinionated: ${config.opinionated},\n\tproviders: { + prisma: { + schemaName: "${schemaName}", + schemaPath: "${schemaPath}", + entitiesPath: "entities", + entityNamePattern: "entity", + }, + },`; + + if (config.providers) { + // delete the providers object until the last closing curly brace + fileContents = fileContents.replace( + /\n([\s]*?)providers: {([\s\S]*?)};/, + "\n};", + ); + } + + const newFileContents = fileContents.replace( + /opinionated: (.*)/, + providersObject, + ); + + fs.writeFileSync(expressotsConfigPath, newFileContents); +} + +export { prismaProvider }; diff --git a/src/providers/prisma/templates/base-repository.interface.tpl b/src/providers/prisma/templates/base-repository.interface.tpl new file mode 100644 index 0000000..5c1a02b --- /dev/null +++ b/src/providers/prisma/templates/base-repository.interface.tpl @@ -0,0 +1,57 @@ +import { Prisma, PrismaClient } from "@prisma/client"; +import { + CreateInput, + ModelsOf, + DeleteWhere, + Select, + PrismaAction, +} from "@expressots/prisma"; + +interface IBaseRepository> { + aggregate: (args: PrismaAction) => Promise; + count: (args: PrismaAction) => Promise; + create: ( + data: + | CreateInput["data"] + | { + data: CreateInput["data"]; + select?: Select["select"]; + }, + ) => Promise; + delete: ( + where: DeleteWhere["where"], + response?: Select["select"], + ) => Promise; + deleteMany: ( + args?: PrismaAction, + ) => Promise; + findFirst: ( + args: PrismaAction, + ) => Promise; + findFirstOrThrow: ( + args?: PrismaAction, + ) => Promise; + findMany: ( + args: PrismaAction, + ) => Promise; + findUnique: ( + args: PrismaAction, + ) => Promise; + findUniqueOrThrow: ( + args?: PrismaAction, + ) => Promise; + groupBy: ( + args: PrismaAction, + ) => Promise; + update: ( + args: PrismaAction, + ) => Promise; + updateMany: ( + args: PrismaAction, + ) => Promise; + upsert: ( + args: PrismaAction, + ) => Promise; +} + +export { IBaseRepository }; diff --git a/src/providers/prisma/templates/base-repository.tpl b/src/providers/prisma/templates/base-repository.tpl new file mode 100644 index 0000000..fe821bb --- /dev/null +++ b/src/providers/prisma/templates/base-repository.tpl @@ -0,0 +1,146 @@ +import { PrismaClient, Prisma } from "@prisma/client"; +import { + CreateInput, + ModelsOf, + DeleteWhere, + Select, + PrismaAction, +} from "@expressots/prisma"; +import { provide } from "inversify-binding-decorators"; +import { IBaseRepository } from "./base-repository.interface"; + +@provide(BaseRepository) +class BaseRepository> + implements IBaseRepository +{ + protected prismaModel: any; + protected prismaClient: PrismaClient; + constructor(modelName: keyof PrismaClient) { + this.prismaClient = new PrismaClient(); + this.prismaModel = this.prismaClient[modelName]; + } + + async aggregate(args: PrismaAction): Promise { + return await this.prismaModel.aggregate(args); + } + + async count(args: PrismaAction): Promise { + return await this.prismaModel.count(args); + } + + async create( + data: + | CreateInput["data"] + | { + data: CreateInput["data"]; + select?: Select["select"]; + }, + ): Promise { + if (!data) { + throw new Error("Data cannot be null or undefined"); + } + + if (typeof data === "object" && "data" in data) { + return await this.prismaModel.create(data); + } + + return await this.prismaModel.create({ data }); + } + + async delete( + where: DeleteWhere["where"], + select?: Select["select"], + ): Promise { + if (!where) { + throw new Error("Data cannot be null or undefined"); + } + + const obj = await this.prismaModel.delete({ where }); + + if (select) { + const entries = Object.entries(select); + + const hasTrueField = entries.some(([, value]) => value); + + if (hasTrueField) { + const result: any = {}; + for (const [key, value] of entries) { + if (value) { + result[key] = obj[key as keyof typeof obj]; + } + } + return result as ModelName; + } else { + for (const [key, value] of entries) { + if (!value) { + delete obj[key as keyof typeof obj]; + } + } + } + } + + return obj; + } + + async deleteMany( + args?: PrismaAction, + ): Promise { + return await this.prismaModel.deleteMany(args); + } + + async findFirst( + args?: PrismaAction, + ): Promise { + return await this.prismaModel.findFirst(args); + } + + async findFirstOrThrow( + args?: PrismaAction, + ): Promise { + return await this.prismaModel.findFirstOrThrow(args); + } + + async findMany( + args: PrismaAction, + ): Promise { + return await this.prismaModel.findMany(args); + } + + async findUnique( + args: PrismaAction, + ): Promise { + return this.prismaModel.findUnique(args); + } + + async findUniqueOrThrow( + args?: PrismaAction, + ): Promise { + return await this.prismaModel.findUniqueOrThrow(args); + } + + async groupBy( + args: PrismaAction, + ): Promise { + return await this.prismaModel.groupBy(args); + } + + async update( + args: PrismaAction, + ): Promise { + return await this.prismaModel.update(args); + } + + async updateMany( + args: PrismaAction, + ): Promise { + return await this.prismaModel.updateMany(args); + } + + async upsert( + args: PrismaAction, + ): Promise { + return await this.prismaModel.upsert(args); + } +} + +export { BaseRepository }; diff --git a/src/utils/add-controller-to-module.ts b/src/utils/add-controller-to-module.ts index dce6560..362078b 100644 --- a/src/utils/add-controller-to-module.ts +++ b/src/utils/add-controller-to-module.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; -async function addControllerToModule( +export async function addControllerToModule( filePath: string, controllerName: string, controllerPath: string, @@ -54,5 +54,3 @@ async function addControllerToModule( await fs.promises.writeFile(filePath, newFileContent, "utf8"); } - -export { addControllerToModule }; diff --git a/src/utils/add-module-to-container.ts b/src/utils/add-module-to-container.ts index f913eea..0700757 100644 --- a/src/utils/add-module-to-container.ts +++ b/src/utils/add-module-to-container.ts @@ -90,12 +90,12 @@ async function addModuleToContainer( if (!modulePathRegex.test(modulePath)) { if (path.split("/").length > 1) { - newImport = `import { ${moduleName}Module } from "${usecaseDir}${modulePath}/${name}.module";`; + newImport = `import { ${moduleName}Module } from "${usecaseDir}${name.toLowerCase()}/${name.toLowerCase()}.module";`; } else { - newImport = `import { ${moduleName}Module } from "${usecaseDir}${name}.module";`; + newImport = `import { ${moduleName}Module } from "${usecaseDir}${name.toLowerCase()}.module";`; } } else { - newImport = `import { ${moduleName}Module } from "${usecaseDir}${name}/${name}.module";`; + newImport = `import { ${moduleName}Module } from "${usecaseDir}${name}/${name.toLowerCase()}.module";`; } if ( @@ -127,4 +127,52 @@ async function addModuleToContainer( await fs.promises.writeFile(containerData.path, newFileContent, "utf8"); } -export { addModuleToContainer }; +async function addModuleToContainerNestedPath(name: string, path?: string) { + const containerData: AppContainerType = await validateAppContainer(); + + const moduleName = (name[0].toUpperCase() + name.slice(1)).trimStart(); + const { opinionated } = await Compiler.loadConfig(); + + let usecaseDir: string; + if (opinionated) { + usecaseDir = `@useCases/`; + } else { + usecaseDir = `./`; + } + + if (path.endsWith("/")) { + path = path.slice(0, -1); + } + + const newImport = `import { ${moduleName}Module } from "${usecaseDir}${path}.module";`; + + if ( + containerData.imports.includes(newImport) && + containerData.modules.includes(`${moduleName}Module`) + ) { + return; + } + + containerData.imports.push(newImport); + containerData.modules.push(`${moduleName}Module`); + + const newModule = containerData.modules.join(", "); + const newModuleDeclaration = `.create([${newModule}]`; + + const newFileContent = [ + ...containerData.imports, + ...containerData.notImports, + ] + .join("\n") + .replace(containerData.regex, newModuleDeclaration); + + console.log( + " ", + chalk.greenBright(`[container]`.padEnd(14)), + chalk.bold.white(`${moduleName}Module added to ${APP_CONTAINER}! βœ”οΈ`), + ); + + await fs.promises.writeFile(containerData.path, newFileContent, "utf8"); +} + +export { addModuleToContainer, addModuleToContainerNestedPath }; diff --git a/src/utils/cli-ui.ts b/src/utils/cli-ui.ts index 163bfd8..e5bb64a 100644 --- a/src/utils/cli-ui.ts +++ b/src/utils/cli-ui.ts @@ -2,6 +2,22 @@ import chalk from "chalk"; export function printError(message: string, component: string): void { console.error( - chalk.red(`\n\n😞 ${message}:`, chalk.white(`[${component}]`)), + chalk.red(`${message}:`, chalk.bold(chalk.white(`[${component}] ❌`))), + ); +} + +export async function printGenerateError(schematic: string, file: string) { + console.error( + " ", + chalk.redBright(`[${schematic}]`.padEnd(14)), + chalk.bold.white(`${file.split(".")[0]} not created! ❌`), + ); +} + +export async function printGenerateSuccess(schematic: string, file: string) { + console.log( + " ", + chalk.greenBright(`[${schematic}]`.padEnd(14)), + chalk.bold.white(`${file.split(".")[0]} created! βœ”οΈ`), ); } diff --git a/src/utils/find-folder.ts b/src/utils/find-folder.ts new file mode 100644 index 0000000..fc9612c --- /dev/null +++ b/src/utils/find-folder.ts @@ -0,0 +1,38 @@ +import fs from "fs"; +import path from "path"; + +function hasFolder( + rootDir: string, + ignoreList: Array = [], +): { found: boolean; path: string | null } { + function searchDir(directory: string): string | null { + const files = fs.readdirSync(directory); + + for (const file of files) { + if (ignoreList.includes(file)) continue; // Skip if the file/folder is in the ignore list + + const filePath = path.join(directory, file); + const stats = fs.statSync(filePath); + + if (stats.isDirectory()) { + if (file === "prisma") { + return filePath; // Return full path of the 'prisma' folder + } else { + const result = searchDir(filePath); // Search inside other directories recursively + if (result) { + return result; + } + } + } + } + return null; // 'prisma' folder was not found in this directory + } + + const foundPath = searchDir(rootDir); + return { + found: !!foundPath, + path: foundPath, + }; +} + +export { hasFolder }; diff --git a/src/utils/verify-file-exists.ts b/src/utils/verify-file-exists.ts index 453c01d..97b66f5 100644 --- a/src/utils/verify-file-exists.ts +++ b/src/utils/verify-file-exists.ts @@ -1,24 +1,24 @@ import inquirer from "inquirer"; import fs from "node:fs"; -import { printError } from "./cli-ui"; +import { printError, printGenerateError } from "./cli-ui"; -async function verifyIfFileExists(path: string) { +async function verifyIfFileExists(path: string, schematic?: string) { const fileExists = fs.existsSync(path); + const fileName = path.split("/").pop(); if (fileExists) { const answer = await inquirer.prompt([ { type: "confirm", name: "confirm", - message: - "File with this path already exists. Do you want to create it anyway?", + message: `File [${fileName}] exists. Overwrite?`, default: true, }, ]); - - const fileName = path.split("/").pop(); if (!answer.confirm) { - printError("File not created!", fileName); + schematic + ? printGenerateError(schematic, fileName) + : printError("File not created!", fileName); process.exit(1); } } diff --git a/tsconfig.json b/tsconfig.json index 2b02467..91d0322 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,8 +6,8 @@ "esModuleInterop": true, "moduleResolution": "node", "resolveJsonModule": false, - "target": "ES2017", - "lib": ["ES2017"], + "target": "ES2021", + "lib": ["ES2021"], "outDir": "bin", "sourceMap": false, "strictNullChecks": false, @@ -15,8 +15,8 @@ "skipLibCheck": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, - "types": ["node", "vitest/globals"] + "types": ["node", "reflect-metadata", "vitest/globals"] }, - "include": ["./src"], - "exclude": ["node_modules", "test/**/*.spec.ts"] + "include": ["./src/**/*.ts"], + "exclude": ["node_modules", "test/**/*.spec.ts", "./bin/**/*"] } diff --git a/vitest.config.ts b/vitest.config.ts index 16f581d..aa2776a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,14 +1,39 @@ import { defineConfig } from "vitest/config"; +import { codecovVitePlugin } from "@codecov/vite-plugin"; +import tsconfigPaths from "vite-tsconfig-paths"; /** * @see {@link https://vitejs.dev/config/} * @see {@link https://vitest.dev/config/} */ export default defineConfig({ + plugins: [ + tsconfigPaths(), + codecovVitePlugin({ + enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined, + bundleName: "expresso-ts-cli-coverage", + uploadToken: process.env.CODECOV_TOKEN, + }), + ], test: { globals: true, + environment: "node", + setupFiles: ["reflect-metadata"], + exclude: ["**/node_modules/**", "**/benchmark/**", "**/bin/**"], coverage: { all: true, + include: ["./src/**"], + exclude: ["**/node_modules/**", "**/bin/**", "**/index.ts/**"], + thresholds: { + global: { + statements: 85, + branches: 85, + functions: 85, + lines: 85, + }, + }, + reporter: ["text", "html", "json"], + provider: "v8", }, // ref: https://vitest.dev/config/#testtimeout testTimeout: 10000,