├── libs ├── .gitkeep └── models │ ├── .babelrc │ ├── .eslintrc │ ├── README.md │ ├── tsconfig.lib.json │ ├── tsconfig.json │ ├── src │ ├── lib │ │ ├── todo.entity.ts │ │ └── root.entity.ts │ └── index.ts │ ├── jest.config.js │ ├── tsconfig.spec.json │ └── project.json ├── .npmrc ├── apps ├── database │ ├── migrations │ │ └── .gitkeep │ ├── postgres │ │ ├── init.sql │ │ ├── Dockerfile │ │ └── docker-compose.yml │ ├── mongo │ │ ├── mongo-entrypoint │ │ │ ├── seed-data.js │ │ │ └── init-users.sh │ │ └── docker-compose.yml │ └── orm-config.ts ├── webapp │ ├── src │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── favicon.ico │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── styles.scss │ │ ├── services │ │ │ └── http.ts │ │ ├── polyfills.ts │ │ ├── index.html │ │ ├── app │ │ │ ├── app.styles.tsx │ │ │ └── app.tsx │ │ ├── redux │ │ │ ├── api.ts │ │ │ ├── endpoints │ │ │ │ ├── generated-cache-keys.ts │ │ │ │ └── todos-endpoints.ts │ │ │ ├── middlewares │ │ │ │ └── error-middleware.ts │ │ │ ├── thunks-slice │ │ │ │ └── snackbar-thunks-slice.ts │ │ │ └── store.ts │ │ ├── pages │ │ │ └── todos-page │ │ │ │ ├── todos-page.styles.tsx │ │ │ │ └── todos-page.tsx │ │ ├── main.tsx │ │ ├── loading-icon-button │ │ │ └── loading-icon-button.tsx │ │ ├── hooks │ │ │ └── use-form-validator.ts │ │ └── components │ │ │ └── global │ │ │ └── snackbar-listener │ │ │ └── snackbar-listener.tsx │ ├── .babelrc │ ├── Dockerfile │ ├── babel-jest.config.json │ ├── .eslintrc │ ├── tsconfig.json │ ├── jest.config.js │ ├── tsconfig.app.json │ ├── tsconfig.spec.json │ ├── .browserslistrc │ ├── default.conf │ └── project.json ├── api │ ├── .eslintrc │ ├── src │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── assets │ │ │ ├── fixtures │ │ │ │ └── todos.yml │ │ │ └── index.html │ │ ├── endpoints │ │ │ ├── health │ │ │ │ ├── health.module.ts │ │ │ │ ├── health.controller.ts │ │ │ │ └── health.e2e.spec.ts │ │ │ └── todos │ │ │ │ ├── todos.module.ts │ │ │ │ ├── todos.controller.ts │ │ │ │ └── todos.e2e.spec.ts │ │ ├── config │ │ │ ├── configuration.test.ts │ │ │ └── configuration.ts │ │ ├── services │ │ │ ├── todos-service.module.ts │ │ │ └── todos.service.ts │ │ ├── app │ │ │ └── app.module.ts │ │ ├── utils │ │ │ ├── utils.ts │ │ │ └── test.ts │ │ └── main.ts │ ├── nest-cli.json │ ├── Dockerfile │ ├── tsconfig.json │ ├── tsconfig.spec.json │ ├── tsconfig.app.json │ ├── jest.config.js │ ├── webpack.config.ts │ └── project.json ├── cli │ ├── src │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── open-api-config.ts │ │ ├── utils.ts │ │ ├── main.ts │ │ └── commands │ │ │ ├── enforce-valid-imports-api.ts │ │ │ ├── enforce-file-folder-naming-convention.ts │ │ │ ├── generate-cache-key-file.ts │ │ │ ├── rename-project.ts │ │ │ └── generate-entity-index-file.ts │ ├── tsconfig.json │ ├── tsconfig.spec.json │ ├── tsconfig.app.json │ ├── jest.config.js │ ├── .eslintrc.json │ └── project.json └── webapp-e2e │ ├── src │ ├── fixtures │ │ └── example.json │ ├── support │ │ ├── index.ts │ │ └── commands.ts │ ├── plugins │ │ └── index.js │ └── integration │ │ └── app.spec.ts │ ├── tsconfig.json │ ├── tsconfig.e2e.json │ ├── .eslintrc │ ├── cypress.json │ └── project.json ├── babel.config.json ├── commitlint.config.js ├── .prettierignore ├── jest.preset.js ├── readme-assets ├── redoc.png ├── todo-demo.gif ├── use-template.png └── project-appropriation-success.png ├── tools ├── getting-started │ ├── src │ │ ├── cli.tsx │ │ ├── error.tsx │ │ ├── is-valid-icon.tsx │ │ ├── label-value-input.tsx │ │ ├── validate-dependencies.tsx │ │ ├── run-scripts.tsx │ │ └── ui.tsx │ ├── tsconfig.json │ └── package.json └── tsconfig.tools.json ├── CONTRIBUTING.md ├── .vscode └── launch.json ├── workspace.json ├── .prettierrc ├── renovate.json ├── .do ├── remap-api-url.ts ├── remap-redoc.ts └── app.yaml ├── jest.config.js ├── .editorconfig ├── .github ├── workflows │ ├── automerge.yml │ ├── replace-webapp-api-url-prod-build.mjs │ ├── wait-for-review-app-url-to-be-ready.mjs │ ├── delete-review-app.yml │ ├── build-docker-api.yml │ ├── build-docker-webapp.yml │ ├── build-test-release.yml │ ├── delete-digitalocean-review-app-resources.mjs │ ├── create-review-app.yml │ └── create-digitalocean-review-app-resources.mjs ├── pull_request_template.md └── action-setup-ci │ └── action.yml ├── .dockerignore ├── .gitignore ├── tsconfig.base.json ├── .eslintrc.json ├── LICENSE ├── docker-compose.yml ├── nx.json ├── .eslintrc ├── package.json ├── cli ├── main.js └── main.js.map └── README.md /libs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /apps/database/migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/webapp/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/database/postgres/init.sql: -------------------------------------------------------------------------------- 1 | create database "stator"; 2 | -------------------------------------------------------------------------------- /libs/models/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@nrwl/web/babel"] 3 | } 4 | -------------------------------------------------------------------------------- /apps/database/postgres/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:13 2 | 3 | EXPOSE 5432 4 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [], 3 | "babelrcRoots": ["*"] 4 | } 5 | -------------------------------------------------------------------------------- /apps/webapp/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@nrwl/react/babel"], 3 | "plugins": [] 4 | } 5 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ["@commitlint/config-conventional"] } 2 | -------------------------------------------------------------------------------- /apps/api/.eslintrc: -------------------------------------------------------------------------------- 1 | { "extends": "../../.eslintrc", "rules": {}, "ignorePatterns": ["!**/*"] } 2 | -------------------------------------------------------------------------------- /libs/models/.eslintrc: -------------------------------------------------------------------------------- 1 | { "extends": "../../.eslintrc", "rules": {}, "ignorePatterns": ["!**/*"] } 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | 3 | /dist 4 | /coverage 5 | -------------------------------------------------------------------------------- /apps/cli/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | } 4 | -------------------------------------------------------------------------------- /apps/cli/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | } 4 | -------------------------------------------------------------------------------- /jest.preset.js: -------------------------------------------------------------------------------- 1 | const nxPreset = require("@nrwl/jest/preset") 2 | 3 | module.exports = { ...nxPreset } 4 | -------------------------------------------------------------------------------- /readme-assets/redoc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chocolat-chaud-io/stator/HEAD/readme-assets/redoc.png -------------------------------------------------------------------------------- /apps/webapp/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chocolat-chaud-io/stator/HEAD/apps/webapp/src/favicon.ico -------------------------------------------------------------------------------- /readme-assets/todo-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chocolat-chaud-io/stator/HEAD/readme-assets/todo-demo.gif -------------------------------------------------------------------------------- /readme-assets/use-template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chocolat-chaud-io/stator/HEAD/readme-assets/use-template.png -------------------------------------------------------------------------------- /apps/webapp-e2e/src/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io" 4 | } 5 | -------------------------------------------------------------------------------- /apps/api/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | apiUrl: process.env.API_URL, 4 | } 5 | -------------------------------------------------------------------------------- /apps/api/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | apiUrl: "http://127.0.0.1:3333", 4 | } 5 | -------------------------------------------------------------------------------- /apps/webapp/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | apiUrl: `${process.env.API_URL}`, 4 | } 5 | -------------------------------------------------------------------------------- /readme-assets/project-appropriation-success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chocolat-chaud-io/stator/HEAD/readme-assets/project-appropriation-success.png -------------------------------------------------------------------------------- /apps/webapp/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | html { 3 | background-color: aquamarine; 4 | } 5 | -------------------------------------------------------------------------------- /apps/api/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src", 4 | "compilerOptions": { 5 | "plugins": ["@nestjs/swagger"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tools/getting-started/src/cli.tsx: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { render } from "ink" 4 | import React from "react" 5 | 6 | import Ui from "./ui" 7 | 8 | render() 9 | -------------------------------------------------------------------------------- /apps/api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14.18-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY dist/apps/api ./ 6 | 7 | EXPOSE 3333 8 | RUN npm i --production 9 | CMD ["node", "main.js"] 10 | -------------------------------------------------------------------------------- /apps/api/src/assets/fixtures/todos.yml: -------------------------------------------------------------------------------- 1 | entity: Todo 2 | items: 3 | todo1: 4 | id: 1 5 | text: test-name-0 6 | 7 | todo2: 8 | id: 2 9 | text: test-name-1 10 | -------------------------------------------------------------------------------- /apps/webapp/src/services/http.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | 3 | export const http = (url: string) => 4 | axios.create({ 5 | baseURL: url, 6 | timeout: 10000, 7 | }) 8 | -------------------------------------------------------------------------------- /apps/webapp/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.21.5 2 | 3 | COPY apps/webapp/default.conf /etc/nginx/nginx.conf 4 | 5 | WORKDIR /usr/share/nginx/html 6 | COPY dist/apps/webapp . 7 | 8 | EXPOSE 80 9 | -------------------------------------------------------------------------------- /libs/models/README.md: -------------------------------------------------------------------------------- 1 | # models 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test models` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /apps/webapp-e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.e2e.json" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | **Working on your first Pull Request?** You can learn how from this *free* series [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github) -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug api", 6 | "type": "node", 7 | "request": "attach", 8 | "port": 7777 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /workspace.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "projects": { 4 | "api": "apps/api", 5 | "cli": "apps/cli", 6 | "models": "libs/models", 7 | "webapp": "apps/webapp", 8 | "webapp-e2e": "apps/webapp-e2e" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /libs/models/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [] 6 | }, 7 | "exclude": ["**/*.spec.ts", "**/*.test.ts"], 8 | "include": ["**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/webapp/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Polyfill stable language features. These imports will be optimized by `@babel/preset-env`. 3 | * 4 | * See: https://github.com/zloirock/core-js#babel 5 | */ 6 | import "core-js/stable" 7 | import "regenerator-runtime/runtime" 8 | -------------------------------------------------------------------------------- /tools/getting-started/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "jsx": "react", 6 | "esModuleInterop": true, 7 | "outDir": "dist" 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "quoteProps": "as-needed", 4 | "arrowParens": "avoid", 5 | "tabWidth": 2, 6 | "trailingComma": "es5", 7 | "semi": false, 8 | "jsxSingleQuote": false, 9 | "bracketSameLine": false, 10 | "printWidth": 140 11 | } 12 | -------------------------------------------------------------------------------- /apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.app.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /apps/api/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.app.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /apps/cli/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": ["**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /libs/models/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "packageRules": [ 4 | { 5 | "updateTypes": ["minor", "patch", "pin", "digest"], 6 | "automerge": true 7 | } 8 | ], 9 | "prConcurrentLimit": 1, 10 | "lockFileMaintenance": { "enabled": true } 11 | } 12 | -------------------------------------------------------------------------------- /apps/webapp/babel-jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ], 11 | "@babel/preset-typescript", 12 | "@babel/preset-react" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /apps/webapp-e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "outDir": "../../dist/out-tsc", 6 | "allowJs": true, 7 | "types": ["cypress", "node"] 8 | }, 9 | "include": ["src/**/*.ts", "src/**/*.js"] 10 | } 11 | -------------------------------------------------------------------------------- /tools/tsconfig.tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/out-tsc/tools", 5 | "rootDir": ".", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": ["node"] 9 | }, 10 | "include": ["**/*.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /apps/webapp-e2e/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": {}, 3 | "extends": ["plugin:cypress/recommended", "../../.eslintrc"], 4 | "overrides": [ 5 | { "files": ["src/plugins/index.js"], "rules": { "@typescript-eslint/no-var-requires": "off", "no-undef": "off" } } 6 | ], 7 | "ignorePatterns": ["!**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/webapp/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // When building for production, this file is replaced with `environment.prod.ts`. 3 | 4 | export const environment = { 5 | production: false, 6 | apiUrl: "http://127.0.0.1:3333/api", 7 | } 8 | -------------------------------------------------------------------------------- /libs/models/src/lib/todo.entity.ts: -------------------------------------------------------------------------------- 1 | import { MinLength } from "class-validator" 2 | import { Column, Entity } from "typeorm" 3 | 4 | import { RootEntity } from "./root.entity" 5 | 6 | @Entity() 7 | export class Todo extends RootEntity { 8 | @Column() 9 | @MinLength(5, { always: true }) 10 | text: string 11 | } 12 | -------------------------------------------------------------------------------- /.do/remap-api-url.ts: -------------------------------------------------------------------------------- 1 | const replace = require("replace-in-file") 2 | 3 | try { 4 | const result = replace.sync({ 5 | files: "apps/webapp/src/environments/environment.prod.ts", 6 | from: "${process.env.API_URL}", 7 | to: "do", 8 | }) 9 | console.log(result) 10 | } catch (e) { 11 | console.error(e) 12 | } 13 | -------------------------------------------------------------------------------- /.do/remap-redoc.ts: -------------------------------------------------------------------------------- 1 | const replace = require("replace-in-file") 2 | 3 | try { 4 | const result = replace.sync({ 5 | files: "apps/api/src/assets/index.html", 6 | from: "/documentation/json", 7 | to: "/do/documentation/json", 8 | }) 9 | console.log(result) 10 | } catch (e) { 11 | console.error(e) 12 | } 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ["**/+(*.)+(spec).+(ts|js)?(x)"], 3 | transform: { 4 | "^.+\\.(ts|js|html)$": "ts-jest", 5 | }, 6 | resolver: "@nrwl/jest/plugins/resolver", 7 | moduleFileExtensions: ["ts", "js", "html"], 8 | coverageReporters: ["lcov", "json"], 9 | testTimeout: 10000, 10 | } 11 | -------------------------------------------------------------------------------- /apps/api/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": ["node"], 6 | "emitDecoratorMetadata": true, 7 | "target": "es2015" 8 | }, 9 | "exclude": ["**/*.spec.ts", "**/*.test.ts"], 10 | "include": ["**/*.ts", "**/*.html"] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | max_line_length = 140 11 | 12 | [*.md] 13 | max_line_length = off 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /tools/getting-started/src/error.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "ink" 2 | import React from "react" 3 | 4 | interface Props { 5 | errorMessage: string 6 | } 7 | 8 | const Error: React.FC = props => { 9 | return {!!props.errorMessage && {props.errorMessage}} 10 | } 11 | 12 | export default Error 13 | -------------------------------------------------------------------------------- /apps/webapp/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-empty-function": "off", 4 | "react-hooks/exhaustive-deps": "off", 5 | "@typescript-eslint/no-empty-interface": "off", 6 | "@typescript-eslint/no-empty-function": "off" 7 | }, 8 | "extends": ["plugin:@nrwl/nx/react", "../../.eslintrc"], 9 | "ignorePatterns": ["!**/*"] 10 | } 11 | -------------------------------------------------------------------------------- /libs/models/src/lib/root.entity.ts: -------------------------------------------------------------------------------- 1 | import { CreateDateColumn, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm" 2 | 3 | export class RootEntity { 4 | @PrimaryGeneratedColumn() 5 | id?: number 6 | 7 | @CreateDateColumn({ type: "timestamp" }) 8 | createdAt?: Date 9 | 10 | @UpdateDateColumn({ type: "timestamp" }) 11 | updatedAt?: Date 12 | } 13 | -------------------------------------------------------------------------------- /apps/webapp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "allowJs": true 6 | }, 7 | "files": [], 8 | "include": [], 9 | "references": [ 10 | { 11 | "path": "./tsconfig.app.json" 12 | }, 13 | { 14 | "path": "./tsconfig.spec.json" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /apps/cli/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "importHelpers": true, 7 | "module": "commonjs", 8 | "rootDir": "src", 9 | "target": "es2019" 10 | }, 11 | "exclude": ["**/*.spec.ts", "**/*.test.ts"], 12 | "include": ["**/*.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /libs/models/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file was automatically generated by generate-entity-index.js file 3 | * You can disable the automatic generation by removing the prepare section of the workspace.json file under api section 4 | */ 5 | 6 | // root.entity.ts 7 | export * from "./lib/root.entity" 8 | 9 | // todo.entity.ts 10 | export * from "./lib/todo.entity" 11 | -------------------------------------------------------------------------------- /apps/api/src/endpoints/health/health.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common" 2 | import { HttpModule } from "@nestjs/axios"; 3 | import { TerminusModule } from "@nestjs/terminus" 4 | 5 | import { HealthController } from "./health.controller" 6 | 7 | @Module({ 8 | imports: [TerminusModule, HttpModule], 9 | controllers: [HealthController], 10 | }) 11 | export class HealthModule {} 12 | -------------------------------------------------------------------------------- /apps/webapp/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Webapp 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /libs/models/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: "models", 3 | preset: "../../jest.config.js", 4 | globals: { 5 | "ts-jest": { 6 | tsconfig: "/tsconfig.spec.json", 7 | }, 8 | }, 9 | transform: { 10 | "^.+\\.[tj]sx?$": "ts-jest", 11 | }, 12 | moduleFileExtensions: ["ts", "tsx", "js", "jsx"], 13 | coverageDirectory: "../../coverage/libs/models", 14 | } 15 | -------------------------------------------------------------------------------- /apps/api/src/config/configuration.test.ts: -------------------------------------------------------------------------------- 1 | import { configuration } from "./configuration" 2 | 3 | export const configurationTest = (databaseName: string) => { 4 | const baseConfig = configuration() 5 | 6 | return { 7 | ...baseConfig, 8 | test: true, 9 | database: { 10 | ...baseConfig.database, 11 | name: databaseName, 12 | keepConnectionAlive: false, 13 | }, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/cli/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: "cli", 3 | preset: "../../jest.preset.js", 4 | globals: { 5 | "ts-jest": { 6 | tsconfig: "/tsconfig.spec.json", 7 | }, 8 | }, 9 | testEnvironment: "node", 10 | transform: { 11 | "^.+\\.[tj]s$": "ts-jest", 12 | }, 13 | moduleFileExtensions: ["ts", "js", "html"], 14 | coverageDirectory: "../../coverage/apps/cli", 15 | } 16 | -------------------------------------------------------------------------------- /apps/webapp/src/app/app.styles.tsx: -------------------------------------------------------------------------------- 1 | import { Theme } from "@mui/material" 2 | import { makeStyles } from "@mui/styles" 3 | 4 | export const useAppStyles = makeStyles((theme: Theme) => ({ 5 | app: { 6 | fontFamily: "sans-serif", 7 | minWidth: 300, 8 | maxWidth: 600, 9 | margin: "50px auto", 10 | }, 11 | cardContainer: { 12 | display: "grid", 13 | gridGap: theme.spacing(2), 14 | }, 15 | })) 16 | -------------------------------------------------------------------------------- /apps/webapp/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: "webapp", 3 | preset: "../../jest.config.js", 4 | transform: { 5 | "^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "@nrwl/react/plugins/jest", 6 | "^.+\\.[tj]sx?$": ["babel-jest", { cwd: __dirname, configFile: "./babel-jest.config.json" }], 7 | }, 8 | moduleFileExtensions: ["ts", "tsx", "js", "jsx"], 9 | coverageDirectory: "../../coverage/apps/webapp", 10 | } 11 | -------------------------------------------------------------------------------- /apps/webapp/src/redux/api.ts: -------------------------------------------------------------------------------- 1 | import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react" 2 | 3 | import { environment } from "../environments/environment" 4 | 5 | export const apiTagTypes = { todo: "todo" } 6 | 7 | export const api = createApi({ 8 | baseQuery: fetchBaseQuery({ baseUrl: environment.apiUrl.replace("/api", "") }), 9 | tagTypes: Object.values(apiTagTypes), 10 | endpoints: () => ({}), 11 | }) 12 | -------------------------------------------------------------------------------- /apps/api/src/services/todos-service.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common" 2 | import { TypeOrmModule } from "@nestjs/typeorm" 3 | import { Todo } from "@stator/models" 4 | 5 | import { TodosService } from "./todos.service" 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([Todo])], 9 | providers: [TodosService], 10 | exports: [TodosService], 11 | controllers: [], 12 | }) 13 | export class TodosServiceModule {} 14 | -------------------------------------------------------------------------------- /apps/webapp/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": ["node"] 6 | }, 7 | "files": ["../../node_modules/@nrwl/react/typings/cssmodule.d.ts", "../../node_modules/@nrwl/react/typings/image.d.ts"], 8 | "exclude": ["**/*.spec.ts", "**/*.test.ts", "**/*.spec.tsx", "**/*.test.tsx"], 9 | "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/cli/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": { 8 | "no-irregular-whitespace": "off" 9 | } 10 | }, 11 | { 12 | "files": ["*.ts", "*.tsx"], 13 | "rules": {} 14 | }, 15 | { 16 | "files": ["*.js", "*.jsx"], 17 | "rules": {} 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /libs/models/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.spec.ts", 10 | "**/*.test.ts", 11 | "**/*.spec.tsx", 12 | "**/*.test.tsx", 13 | "**/*.spec.js", 14 | "**/*.test.js", 15 | "**/*.spec.jsx", 16 | "**/*.test.jsx", 17 | "**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /apps/api/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: "api", 3 | preset: "../../jest.config.js", 4 | globals: { 5 | "ts-jest": { 6 | tsconfig: "/tsconfig.spec.json", 7 | }, 8 | }, 9 | transform: { 10 | "^.+\\.[tj]s$": "ts-jest", 11 | }, 12 | moduleFileExtensions: ["ts", "js", "html"], 13 | coverageDirectory: "../../coverage/apps/api", 14 | coveragePathIgnorePatterns: ["./src/config/"], 15 | testEnvironment: "node", 16 | } 17 | -------------------------------------------------------------------------------- /apps/api/src/services/todos.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common" 2 | import { InjectRepository } from "@nestjs/typeorm" 3 | import { TypeOrmCrudService } from "@nestjsx/crud-typeorm" 4 | import { Todo } from "@stator/models" 5 | import { Repository } from "typeorm" 6 | 7 | @Injectable() 8 | export class TodosService extends TypeOrmCrudService { 9 | constructor(@InjectRepository(Todo) repository: Repository) { 10 | super(repository) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/webapp-e2e/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "fileServerFolder": ".", 3 | "fixturesFolder": "./src/fixtures", 4 | "integrationFolder": "./src/integration", 5 | "modifyObstructiveCode": false, 6 | "pluginsFile": "./src/plugins/index", 7 | "supportFile": "./src/support/index.ts", 8 | "video": true, 9 | "videosFolder": "../../dist/cypress/apps/webapp-e2e/videos", 10 | "screenshotsFolder": "../../dist/cypress/apps/webapp-e2e/screenshots", 11 | "chromeWebSecurity": false 12 | } 13 | -------------------------------------------------------------------------------- /apps/database/mongo/mongo-entrypoint/seed-data.js: -------------------------------------------------------------------------------- 1 | print("===============JAVASCRIPT===============") 2 | print("Count of rows in stator collection: " + db.stator.count()) 3 | 4 | db.stator.insert({ message: "Testing data is preserved on docker-compose down and docker-compose-up" }) 5 | 6 | print("===============AFTER JS INSERT==========") 7 | print("Count of rows in stator collection: " + db.stator.count()) 8 | 9 | data = db.stator.find() 10 | while (data.hasNext()) { 11 | printjson(data.next()) 12 | } 13 | -------------------------------------------------------------------------------- /apps/webapp/src/app/app.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import React from "react" 3 | 4 | import { SnackbarListener } from "../components/global/snackbar-listener/snackbar-listener" 5 | import { TodosPage } from "../pages/todos-page/todos-page" 6 | import { useAppStyles } from "./app.styles" 7 | 8 | export const App = () => { 9 | const classes = useAppStyles() 10 | 11 | return ( 12 |
13 | 14 | 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /apps/cli/src/open-api-config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigFile } from "@rtk-query/codegen-openapi" 2 | 3 | const reduxPath = "../../apps/webapp/src/redux" 4 | const config: ConfigFile = { 5 | schemaFile: "http://localhost:3333/documentation/json", 6 | apiFile: `${reduxPath}/stator-api.ts`, 7 | apiImport: "statorApi", 8 | outputFiles: { 9 | [`${reduxPath}/endpoints/todos-endpoints.ts`]: { exportName: "todosApi", filterEndpoints: /todo/i }, 10 | }, 11 | filterEndpoints: [/todo/i], 12 | exportName: "statorApi", 13 | hooks: true, 14 | } 15 | 16 | export default config 17 | -------------------------------------------------------------------------------- /apps/webapp/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.spec.ts", 10 | "**/*.test.ts", 11 | "**/*.spec.tsx", 12 | "**/*.test.tsx", 13 | "**/*.spec.js", 14 | "**/*.test.js", 15 | "**/*.spec.jsx", 16 | "**/*.test.jsx", 17 | "**/*.d.ts" 18 | ], 19 | "files": ["../../node_modules/@nrwl/react/typings/cssmodule.d.ts", "../../node_modules/@nrwl/react/typings/image.d.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /apps/database/mongo/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | services: 3 | database: 4 | image: mongo:4.4.1-bionic 5 | container_name: stator-mongo 6 | ports: 7 | - 27017:27017 8 | environment: 9 | - MONGO_INITDB_DATABASE=stator 10 | - MONGO_INITDB_ROOT_USERNAME=stator 11 | - MONGO_INITDB_ROOT_PASSWORD=secret 12 | volumes: 13 | # seeding scripts 14 | - ./mongo-entrypoint:/docker-entrypoint-initdb.d 15 | # named volumes 16 | - mongodb:/data/db 17 | - mongoconfig:/data/configdb 18 | 19 | volumes: 20 | mongodb: 21 | mongoconfig: 22 | -------------------------------------------------------------------------------- /apps/api/src/endpoints/todos/todos.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common" 2 | import { TypeOrmModule } from "@nestjs/typeorm" 3 | import { Todo } from "@stator/models" 4 | 5 | import { TodosServiceModule } from "../../services/todos-service.module" 6 | import { TodosService } from "../../services/todos.service" 7 | import { TodosController } from "./todos.controller" 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([Todo]), TodosServiceModule], 11 | providers: [TodosService], 12 | exports: [TodosService], 13 | controllers: [TodosController], 14 | }) 15 | export class TodosModule {} 16 | -------------------------------------------------------------------------------- /apps/database/mongo/mongo-entrypoint/init-users.sh: -------------------------------------------------------------------------------- 1 | if [ "$MONGO_INITDB_ROOT_USERNAME" ] && [ "$MONGO_INITDB_ROOT_PASSWORD" ]; then 2 | "${mongo[@]}" "$MONGO_INITDB_DATABASE" <<-EOJS 3 | db.createUser({ 4 | user: $(_js_escape "$MONGO_INITDB_ROOT_USERNAME"), 5 | pwd: $(_js_escape "$MONGO_INITDB_ROOT_PASSWORD"), 6 | roles: [ "readWrite", "dbAdmin" ] 7 | }) 8 | EOJS 9 | fi 10 | 11 | echo ====================================================== 12 | echo created "$MONGO_INITDB_ROOT_USERNAME" in database "$MONGO_INITDB_DATABASE" 13 | echo ====================================================== 14 | -------------------------------------------------------------------------------- /apps/cli/src/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import path from "path" 3 | 4 | export const walk = async function* (dir: string, ignoredPaths: Array, walkedFolderNames: string[] = []) { 5 | for await (const directoryEntry of await fs.promises.opendir(dir)) { 6 | const entryPath = path.join(dir, directoryEntry.name) 7 | if (directoryEntry.isDirectory() && !ignoredPaths.includes(directoryEntry.name)) { 8 | walkedFolderNames.push(entryPath) 9 | yield* walk(entryPath, ignoredPaths, walkedFolderNames) 10 | } else if (directoryEntry.isFile()) { 11 | yield entryPath 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tools/getting-started/src/is-valid-icon.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text } from "ink" 2 | import React from "react" 3 | 4 | interface Props { 5 | isValid?: boolean 6 | } 7 | 8 | const IsValidIcon: React.FC = props => { 9 | return ( 10 | <> 11 | 12 | {props.isValid === true && ( 13 | 14 | ✓ 15 | 16 | )} 17 | {props.isValid === false && ( 18 | 19 | ✗ 20 | 21 | )} 22 | 23 | 24 | ) 25 | } 26 | 27 | export default IsValidIcon 28 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: automerge 2 | on: 3 | pull_request: 4 | types: 5 | - labeled 6 | - unlabeled 7 | - synchronize 8 | - opened 9 | - edited 10 | - ready_for_review 11 | - reopened 12 | - unlocked 13 | pull_request_review: 14 | types: 15 | - submitted 16 | check_suite: 17 | types: 18 | - completed 19 | status: {} 20 | jobs: 21 | automerge: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: automerge 25 | uses: "pascalgn/automerge-action@v0.14.3" 26 | env: 27 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 28 | -------------------------------------------------------------------------------- /apps/webapp/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by: 2 | # 1. autoprefixer to adjust CSS to support the below specified browsers 3 | # 2. babel preset-env to adjust included polyfills 4 | # 5 | # For additional information regarding the format and rule options, please see: 6 | # https://github.com/browserslist/browserslist#queries 7 | # 8 | # If you need to support different browsers in production, you may tweak the list below. 9 | 10 | last 1 Chrome version 11 | last 1 Firefox version 12 | last 2 Edge major versions 13 | last 2 Safari major version 14 | last 2 iOS major versions 15 | Firefox ESR 16 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /apps/webapp/src/redux/endpoints/generated-cache-keys.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file was automatically generated by tools/generators/generate-cache-file.js file 3 | */ 4 | 5 | import { todosApi } from "./todos-endpoints" 6 | 7 | export const addTodosCacheKeys = () => 8 | todosApi.enhanceEndpoints({ 9 | endpoints: { 10 | getManyTodos: { providesTags: ["todos"] }, 11 | createOneTodo: { invalidatesTags: ["todos"] }, 12 | updateOneTodo: { invalidatesTags: ["todos"] }, 13 | deleteOneTodo: { invalidatesTags: ["todos"] }, 14 | }, 15 | }) 16 | export const addGeneratedCacheKeys = () => { 17 | addTodosCacheKeys() 18 | } 19 | -------------------------------------------------------------------------------- /apps/database/postgres/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | services: 3 | database: 4 | image: postgres:13 5 | container_name: stator-postgres 6 | ports: 7 | - 5433:5432 8 | restart: always 9 | command: 10 | - postgres 11 | - -c 12 | - listen_addresses=* 13 | environment: 14 | POSTGRES_DB: stator 15 | POSTGRES_HOST_AUTH_METHOD: "trust" # Not recommended, only for demo purposes 16 | volumes: 17 | # seeding 18 | - ./init.sql:/docker-entrypoint-initdb.d/init.sql 19 | # named volume 20 | - stator-data:/var/lib/postgresql/stator/data 21 | 22 | volumes: 23 | stator-data: 24 | -------------------------------------------------------------------------------- /.github/workflows/replace-webapp-api-url-prod-build.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zx 2 | 3 | const webappMainFileName = `./dist/apps/webapp/${(await $`ls dist/apps/webapp | grep main | grep -v LICENSE`).stdout.replace("\n", "")}`; 4 | fs.readFile(webappMainFileName, "utf8", function(error, data) { 5 | if (error) { 6 | console.error(error); 7 | process.exit(1); 8 | } 9 | const result = data.replace(/apiUrl:`[a-zA-Z`{}\/:",\d_\-$.]+`/g, `apiUrl:"${process.env.DROPLET_URL}"`); 10 | 11 | fs.writeFile(webappMainFileName, result, "utf8", function(error) { 12 | if (error) { 13 | console.error(error); 14 | process.exit(1); 15 | } 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /tmp 5 | /out-tsc 6 | 7 | # dependencies 8 | /node_modules 9 | 10 | # IDEs and editors 11 | /.idea 12 | .project 13 | .classpath 14 | .c9/ 15 | *.launch 16 | .settings/ 17 | *.sublime-workspace 18 | 19 | # IDE - VSCode 20 | .vscode/* 21 | !.vscode/settings.json 22 | !.vscode/tasks.json 23 | !.vscode/launch.json 24 | !.vscode/extensions.json 25 | 26 | # misc 27 | /.sass-cache 28 | /connect.lock 29 | /coverage 30 | /libpeerconnection.log 31 | npm-debug.log 32 | yarn-error.log 33 | testem.log 34 | /typings 35 | .cache 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | -------------------------------------------------------------------------------- /apps/webapp-e2e/src/support/index.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands" 18 | -------------------------------------------------------------------------------- /apps/webapp/src/redux/middlewares/error-middleware.ts: -------------------------------------------------------------------------------- 1 | import { Middleware, MiddlewareAPI } from "@reduxjs/toolkit" 2 | 3 | import { AppDispatch } from "../store" 4 | import { snackbarThunks } from "../thunks-slice/snackbar-thunks-slice" 5 | 6 | export const errorMiddleware = (): Middleware => { 7 | return (store: MiddlewareAPI) => next => (action: { type: string; payload?: { data: { message: string } } }) => { 8 | const { dispatch } = store 9 | const errorMessage = action.payload?.data?.message 10 | 11 | if (errorMessage) { 12 | dispatch(snackbarThunks.display({ message: errorMessage, severity: "error" })) 13 | } 14 | 15 | return next(action) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/api/src/endpoints/health/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from "@nestjs/common" 2 | import { ApiOperation, ApiTags } from "@nestjs/swagger" 3 | import { HealthCheck, HealthCheckService, HttpHealthIndicator } from "@nestjs/terminus"; 4 | 5 | import { environment } from "../../environments/environment" 6 | 7 | @ApiTags("health") 8 | @Controller("health") 9 | export class HealthController { 10 | constructor(private health: HealthCheckService, private dns: HttpHealthIndicator) {} 11 | 12 | @Get() 13 | @HealthCheck() 14 | @ApiOperation({ summary: "Health Check" }) 15 | check() { 16 | return this.health.check([() => this.dns.pingCheck("api", environment.apiUrl)]) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/api/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common" 2 | import { ServeStaticModule } from "@nestjs/serve-static" 3 | 4 | import { configuration } from "../config/configuration" 5 | import { HealthModule } from "../endpoints/health/health.module" 6 | import { TodosModule } from "../endpoints/todos/todos.module" 7 | import { getRootModuleImports } from "../utils/utils" 8 | 9 | @Module({ 10 | imports: [ 11 | ...getRootModuleImports(configuration), 12 | ServeStaticModule.forRoot({ 13 | rootPath: `${__dirname}/assets`, 14 | exclude: ["/api*"], 15 | }), 16 | HealthModule, 17 | TodosModule, 18 | ], 19 | controllers: [], 20 | providers: [], 21 | }) 22 | export class AppModule {} 23 | -------------------------------------------------------------------------------- /libs/models/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/models", 3 | "sourceRoot": "libs/models/src", 4 | "projectType": "library", 5 | "generators": {}, 6 | "targets": { 7 | "linter": { 8 | "executor": "@nrwl/linter:eslint", 9 | "options": { 10 | "linter": "eslint", 11 | "tsConfig": ["libs/models/tsconfig.lib.json", "libs/models/tsconfig.spec.json"], 12 | "exclude": ["**/node_modules/**", "!libs/models/**/*"] 13 | } 14 | }, 15 | "test": { 16 | "executor": "@nrwl/jest:jest", 17 | "options": { 18 | "jestConfig": "libs/models/jest.config.js", 19 | "passWithNoTests": true 20 | }, 21 | "outputs": ["coverage/libs/models"] 22 | } 23 | }, 24 | "tags": [] 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/wait-for-review-app-url-to-be-ready.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zx 2 | 3 | async function waitForReviewAppUrlToBeReady(currentTry = 0) { 4 | const maxTries = 30; 5 | let response = {}; 6 | try { 7 | response = await fetch(process.env.DROPLET_URL); 8 | } catch (_) { 9 | // ignored 10 | } 11 | if (!response.ok) { 12 | const waitSeconds = 5; 13 | console.log(`Application not ready, waiting ${waitSeconds} seconds`); 14 | await sleep(waitSeconds * 1000); 15 | if (currentTry >= maxTries) { 16 | console.error("Could not talk to review app in time"); 17 | process.exit(1); 18 | } 19 | await waitForReviewAppUrlToBeReady(currentTry + 1); 20 | } 21 | } 22 | 23 | await waitForReviewAppUrlToBeReady(); 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | **/dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | **/node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | .cache 37 | entity-index-hash.txt 38 | cli/3rdpartylicenses.txt 39 | 40 | # System Files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /apps/api/src/assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ReDoc 5 | 6 | 7 | 8 | 9 | 10 | 11 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /apps/webapp/src/pages/todos-page/todos-page.styles.tsx: -------------------------------------------------------------------------------- 1 | import { Theme } from "@mui/material" 2 | import { makeStyles } from "@mui/styles" 3 | 4 | export const useTodosPageStyles = makeStyles((theme: Theme) => ({ 5 | addTodoContainer: { 6 | display: "grid", 7 | gridTemplateColumns: "1fr auto", 8 | gridGap: theme.spacing(2), 9 | alignItems: "center", 10 | paddingLeft: theme.spacing(4), 11 | paddingRight: theme.spacing(4), 12 | }, 13 | cardContent: { 14 | display: "grid", 15 | }, 16 | getLoadingProgress: { 17 | justifySelf: "center", 18 | }, 19 | listItemSecondaryAction: { 20 | display: "grid", 21 | gridTemplateColumns: "1fr 1fr", 22 | }, 23 | updateTextField: { 24 | marginRight: theme.spacing(9), 25 | }, 26 | })) 27 | -------------------------------------------------------------------------------- /apps/database/orm-config.ts: -------------------------------------------------------------------------------- 1 | import { PostgresConnectionOptions } from "typeorm/driver/postgres/PostgresConnectionOptions" 2 | 3 | const ormConfig: PostgresConnectionOptions = { 4 | type: "postgres", 5 | host: process.env.DATABASE_HOST || "localhost", 6 | port: parseInt(process.env.DATABASE_PORT, 10) || 5433, 7 | username: process.env.DATABASE_USERNAME || "postgres", 8 | password: process.env.DATABASE_PASSWORD || "postgres", 9 | database: process.env.DATABASE_NAME || "stator", 10 | synchronize: !!(process.env.DATABASE_SYNCHRONIZE ?? true), 11 | entities: [__dirname + "/../../libs/**/*.entity{.ts,.js}"], 12 | migrations: [__dirname + "/migrations/*.ts"], 13 | cli: { 14 | migrationsDir: "apps/database/migrations", 15 | }, 16 | } 17 | 18 | module.exports = ormConfig 19 | -------------------------------------------------------------------------------- /tools/getting-started/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "getting-started", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "bin": "dist/cli.js", 6 | "engines": { 7 | "node": ">=14.13.1 <15.0.0" 8 | }, 9 | "scripts": { 10 | "build": "cross-env node ../../node_modules/typescript/bin/tsc", 11 | "start": "npm run build && node dist/cli.js", 12 | "pretest": "npm run build", 13 | "test": "xo && ava" 14 | }, 15 | "files": [ 16 | "dist/cli.js" 17 | ], 18 | "ava": { 19 | "typescript": { 20 | "extensions": [ 21 | "tsx" 22 | ], 23 | "rewritePaths": { 24 | "src/": "dist/" 25 | } 26 | } 27 | }, 28 | "xo": { 29 | "extends": "xo-react", 30 | "rules": { 31 | "react/prop-types": "off" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "importHelpers": true, 11 | "target": "es2017", 12 | "module": "commonjs", 13 | "typeRoots": ["node_modules/@types"], 14 | "lib": ["es2017", "es2020", "dom"], 15 | "skipLibCheck": true, 16 | "skipDefaultLibCheck": true, 17 | "noImplicitUseStrict": true, 18 | "baseUrl": ".", 19 | "paths": { 20 | "@stator/models": ["libs/models/src/index.ts"] 21 | }, 22 | "allowSyntheticDefaultImports": true, 23 | "esModuleInterop": true 24 | }, 25 | "exclude": ["stator/node_modules", "tmp"] 26 | } 27 | -------------------------------------------------------------------------------- /apps/webapp/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider, createTheme } from "@mui/material" 2 | import React from "react" 3 | import ReactDOM from "react-dom" 4 | import { Provider } from "react-redux" 5 | import { BrowserRouter } from "react-router-dom" 6 | 7 | import { App } from "./app/app" 8 | import { store } from "./redux/store" 9 | 10 | const theme = createTheme({ 11 | components: { 12 | MuiCardContent: { 13 | styleOverrides: { 14 | root: { 15 | paddingTop: 24, 16 | }, 17 | }, 18 | }, 19 | }, 20 | }) 21 | 22 | ReactDOM.render( 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | , 32 | document.getElementById("root") 33 | ) 34 | -------------------------------------------------------------------------------- /apps/webapp-e2e/src/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | const { preprocessTypescript } = require("@nrwl/cypress/plugins/preprocessor") 15 | 16 | module.exports = (on, config) => { 17 | // `on` is used to hook into various events Cypress emits 18 | // `config` is the resolved Cypress config 19 | // Preprocess Typescript file using Nx helper 20 | } 21 | -------------------------------------------------------------------------------- /apps/webapp-e2e/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "apps/webapp-e2e", 3 | "sourceRoot": "apps/webapp-e2e/src", 4 | "projectType": "application", 5 | "targets": { 6 | "e2e": { 7 | "executor": "@nrwl/cypress:cypress", 8 | "options": { 9 | "cypressConfig": "apps/webapp-e2e/cypress.json", 10 | "tsConfig": "apps/webapp-e2e/tsconfig.e2e.json", 11 | "devServerTarget": "webapp:serve" 12 | }, 13 | "configurations": { 14 | "production": { 15 | "devServerTarget": "webapp:serve:production" 16 | } 17 | } 18 | }, 19 | "lint": { 20 | "executor": "@nrwl/linter:eslint", 21 | "options": { 22 | "lintFilePatterns": ["apps/webapp-e2e/**/*.{ts,tsx,js,jsx}"] 23 | }, 24 | "outputs": ["{options.outputFile}"] 25 | } 26 | }, 27 | "tags": [], 28 | "implicitDependencies": ["webapp"] 29 | } 30 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["@nrwl/nx"], 5 | "overrides": [ 6 | { 7 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 8 | "rules": { 9 | "@nrwl/nx/enforce-module-boundaries": [ 10 | "error", 11 | { 12 | "enforceBuildableLibDependency": true, 13 | "allow": [], 14 | "depConstraints": [ 15 | { 16 | "sourceTag": "*", 17 | "onlyDependOnLibsWithTags": ["*"] 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | }, 24 | { 25 | "files": ["*.ts", "*.tsx"], 26 | "extends": ["plugin:@nrwl/nx/typescript"], 27 | "rules": {} 28 | }, 29 | { 30 | "files": ["*.js", "*.jsx"], 31 | "extends": ["plugin:@nrwl/nx/javascript"], 32 | "rules": {} 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /apps/webapp/src/redux/thunks-slice/snackbar-thunks-slice.ts: -------------------------------------------------------------------------------- 1 | import { AlertColor } from "@mui/material" 2 | import { PayloadAction, Slice, createSlice } from "@reduxjs/toolkit" 3 | 4 | export interface SnackbarState { 5 | snackbar: { 6 | message?: string 7 | severity?: AlertColor 8 | } 9 | } 10 | 11 | export const snackbarThunksSlice: Slice = createSlice({ 12 | name: "snackbar", 13 | initialState: { snackbar: {} } as SnackbarState, 14 | reducers: { 15 | display(state: SnackbarState, action: PayloadAction) { 16 | state.snackbar.message = action.payload.message 17 | state.snackbar.severity = action.payload.severity 18 | }, 19 | clear(state: SnackbarState) { 20 | state.snackbar.message = "" 21 | }, 22 | }, 23 | }) 24 | 25 | export const snackbarThunks = { 26 | display: snackbarThunksSlice.actions.display, 27 | clear: snackbarThunksSlice.actions.clear, 28 | } 29 | -------------------------------------------------------------------------------- /apps/cli/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Builtins, Cli } from "clipanion" 2 | 3 | import { EnforceFileFolderNamingConvention } from "./commands/enforce-file-folder-naming-convention" 4 | import { EnforceValidImportsApi } from "./commands/enforce-valid-imports-api" 5 | import { GenerateCacheKeyFile } from "./commands/generate-cache-key-file" 6 | import { GenerateEntityIndexFile } from "./commands/generate-entity-index-file" 7 | import { RenameProject } from "./commands/rename-project" 8 | 9 | const [, , ...args] = process.argv 10 | 11 | const cli = new Cli({ 12 | binaryLabel: `stator-cli`, 13 | binaryName: `npm run stator-cli`, 14 | binaryVersion: `1.0.0`, 15 | }) 16 | 17 | cli.register(RenameProject) 18 | cli.register(GenerateCacheKeyFile) 19 | cli.register(GenerateEntityIndexFile) 20 | cli.register(EnforceValidImportsApi) 21 | cli.register(EnforceFileFolderNamingConvention) 22 | cli.register(Builtins.HelpCommand) 23 | cli.runExit(args).catch(console.error) 24 | -------------------------------------------------------------------------------- /apps/webapp/default.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | server { 9 | listen 80; 10 | server_name localhost; 11 | 12 | root /usr/share/nginx/html; 13 | index index.html index.htm; 14 | include /etc/nginx/mime.types; 15 | 16 | gzip on; 17 | gzip_min_length 1000; 18 | gzip_proxied expired no-cache no-store private auth; 19 | gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript; 20 | 21 | location / { 22 | try_files $uri $uri/ @proxy; 23 | } 24 | 25 | location @proxy { 26 | proxy_pass http://api-full-stack:3333; 27 | proxy_http_version 1.1; 28 | proxy_set_header Upgrade $http_upgrade; 29 | proxy_set_header Connection 'upgrade'; 30 | proxy_set_header Host $host; 31 | proxy_cache_bypass $http_upgrade; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/delete-review-app.yml: -------------------------------------------------------------------------------- 1 | name: Delete review app 2 | 3 | on: 4 | pull_request: 5 | types: [closed, locked] 6 | 7 | jobs: 8 | delete-review-app: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Setup CI 13 | uses: ./.github/action-setup-ci 14 | with: 15 | github_token: ${{ github.token }} 16 | nx_cloud_token: ${{ secrets.NX_CLOUD_TOKEN }} 17 | 18 | - name: Install doctl 19 | uses: digitalocean/action-doctl@v2 20 | with: 21 | token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 22 | 23 | - name: Delete review app resources 24 | run: node node_modules/zx/zx.mjs ./.github/workflows/delete-digitalocean-review-app-resources.mjs 25 | env: 26 | PR_NUMBER: ${{ github.event.number }} 27 | REPO_NAME: ${{ github.event.pull_request.head.repo.full_name }} 28 | DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 29 | -------------------------------------------------------------------------------- /apps/api/src/endpoints/health/health.e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import supertest from "supertest" 2 | 3 | import { TestingHelper } from "../../utils/test" 4 | import { HealthModule } from "./health.module" 5 | 6 | describe("Health", () => { 7 | let testingHelper: TestingHelper 8 | 9 | beforeAll(async () => { 10 | testingHelper = await new TestingHelper().initializeModuleAndApp("health", [HealthModule]) 11 | }) 12 | 13 | afterAll(async () => { 14 | await testingHelper.shutdownServer() 15 | }) 16 | 17 | describe("GET /health", () => { 18 | it("should return status 200", async () => { 19 | await supertest 20 | .agent(testingHelper.app.getHttpServer()) 21 | .get("/health") 22 | .set("Accept", "application/json") 23 | .expect("Content-Type", /json/) 24 | .expect(200) 25 | .expect({ 26 | status: "ok", 27 | info: { 28 | api: { status: "up" }, 29 | }, 30 | error: {}, 31 | details: { 32 | api: { status: "up" }, 33 | }, 34 | }) 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /tools/getting-started/src/label-value-input.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text } from "ink" 2 | import TextInput from "ink-text-input" 3 | import React from "react" 4 | 5 | import Error from "./error" 6 | import IsValidIcon from "./is-valid-icon" 7 | import { InputValue } from "./ui" 8 | 9 | interface Props { 10 | label: string 11 | placeholder?: string 12 | inputValue: InputValue 13 | onChange: (value: string) => void 14 | onSubmit: (value: string) => void 15 | } 16 | 17 | const LabelValueInput: React.FC = props => { 18 | return ( 19 | <> 20 | 21 | {props.label}: 22 | props.onChange(value)} 26 | onSubmit={props.onSubmit} 27 | focus={!props.inputValue.isValid} 28 | /> 29 | 30 | 31 | 32 | 33 | 34 | ) 35 | } 36 | 37 | export default LabelValueInput 38 | -------------------------------------------------------------------------------- /apps/api/src/endpoints/todos/todos.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from "@nestjs/common" 2 | import { ApiOperation, ApiTags } from "@nestjs/swagger" 3 | import { Crud, CrudController } from "@nestjsx/crud" 4 | import { Todo } from "@stator/models" 5 | 6 | import { TodosService } from "../../services/todos.service" 7 | 8 | @ApiTags("todos") 9 | @Controller("todos") 10 | @Crud({ 11 | model: { type: Todo }, 12 | routes: { 13 | exclude: ["createManyBase", "getOneBase", "replaceOneBase"], 14 | getManyBase: { decorators: [ApiOperation({ operationId: "getManyTodos", summary: "Retrieve multiple Todos" })] }, 15 | createOneBase: { decorators: [ApiOperation({ operationId: "createOneTodo", summary: "Create one Todo" })] }, 16 | updateOneBase: { decorators: [ApiOperation({ operationId: "updateOneTodo", summary: "Update a single todo" })] }, 17 | deleteOneBase: { decorators: [ApiOperation({ operationId: "deleteOneTodo", summary: "Delete a single todo" })] }, 18 | }, 19 | }) 20 | export class TodosController implements CrudController { 21 | constructor(public service: TodosService) {} 22 | } 23 | -------------------------------------------------------------------------------- /apps/webapp/src/loading-icon-button/loading-icon-button.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress, IconButton, Theme } from "@mui/material" 2 | import { makeStyles } from "@mui/styles" 3 | import clsx from "clsx" 4 | import React, { ComponentType } from "react" 5 | import { FC } from "react" 6 | 7 | const useStyles = makeStyles((theme: Theme) => ({ 8 | root: { 9 | margin: theme.spacing(1), 10 | position: "relative", 11 | }, 12 | progress: { 13 | position: "absolute", 14 | top: 2, 15 | left: 2, 16 | zIndex: 1, 17 | }, 18 | })) 19 | 20 | interface Props { 21 | loading: boolean 22 | Icon: ComponentType 23 | onClick: () => void 24 | className?: string 25 | } 26 | 27 | export const LoadingIconButton: FC = props => { 28 | const classes = useStyles() 29 | 30 | return ( 31 |
32 | 33 | 34 | 35 | {props.loading && } 36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /apps/webapp/src/redux/store.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers, configureStore } from "@reduxjs/toolkit" 2 | import { setupListeners } from "@reduxjs/toolkit/query" 3 | 4 | import { addGeneratedCacheKeys } from "./endpoints/generated-cache-keys" 5 | import { errorMiddleware } from "./middlewares/error-middleware" 6 | import { api } from "./api" 7 | import { snackbarThunksSlice } from "./thunks-slice/snackbar-thunks-slice" 8 | 9 | addGeneratedCacheKeys() 10 | 11 | export const rootReducer = combineReducers({ 12 | snackbarReducer: snackbarThunksSlice.reducer, 13 | [api.reducerPath]: api.reducer, 14 | }) 15 | 16 | export type RootState = ReturnType 17 | 18 | export const isSuccess = (response: { type: string }) => !!response?.type?.includes("fulfilled") 19 | 20 | export const store = configureStore({ 21 | reducer: rootReducer, 22 | middleware: getDefaultMiddleware => 23 | getDefaultMiddleware({ 24 | serializableCheck: false, 25 | }) 26 | .concat(errorMiddleware()) 27 | .concat(api.middleware), 28 | }) 29 | setupListeners(store.dispatch) 30 | 31 | export type AppDispatch = typeof store.dispatch 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Yann Thibodeau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/api/src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule, ConfigService } from "@nestjs/config" 2 | import { ConfigFactory } from "@nestjs/config/dist/interfaces" 3 | import { TypeOrmModule } from "@nestjs/typeorm" 4 | import { WinstonModule, utilities as nestWinstonModuleUtilities } from "nest-winston" 5 | import winston from "winston" 6 | 7 | import { getOrmConfigFn } from "../config/configuration" 8 | import { environment } from "../environments/environment" 9 | 10 | export const getWinstonConsoleFormat = () => 11 | environment.production 12 | ? winston.format.json() 13 | : winston.format.combine( 14 | winston.format.timestamp(), 15 | winston.format.ms(), 16 | nestWinstonModuleUtilities.format.nestLike("MyApp", { prettyPrint: true }) 17 | ) 18 | 19 | export const getRootModuleImports = (configuration: ConfigFactory) => [ 20 | ConfigModule.forRoot({ isGlobal: true, load: [configuration] }), 21 | TypeOrmModule.forRootAsync({ 22 | imports: [ConfigModule], 23 | inject: [ConfigService], 24 | useFactory: getOrmConfigFn, 25 | }), 26 | WinstonModule.forRoot({ transports: [new winston.transports.Console({ format: getWinstonConsoleFormat() })] }), 27 | ] 28 | -------------------------------------------------------------------------------- /apps/webapp-e2e/src/support/commands.ts: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | 11 | // eslint-disable-next-line @typescript-eslint/no-namespace 12 | declare namespace Cypress { 13 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 14 | interface Chainable { 15 | login(email: string, password: string): void 16 | } 17 | } 18 | // 19 | // -- This is a parent command -- 20 | Cypress.Commands.add("login", (email, password) => { 21 | console.log("Custom command example: Login", email, password) 22 | }) 23 | // 24 | // -- This is a child command -- 25 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 26 | // 27 | // 28 | // -- This is a dual command -- 29 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 30 | // 31 | // 32 | // -- This will overwrite an existing command -- 33 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 34 | -------------------------------------------------------------------------------- /apps/webapp-e2e/src/integration/app.spec.ts: -------------------------------------------------------------------------------- 1 | describe("webapp", () => { 2 | const newTodoText = "Brand new todo" 3 | const todoTextSelector = "[data-testid='todo-text'] span" 4 | 5 | beforeEach(() => { 6 | cy.visit("/") 7 | 8 | cy.intercept("**/todos**").as("getTodos") 9 | 10 | cy.wait("@getTodos").then(({ response }) => { 11 | if (response.body.length > 0) { 12 | cy.get(".delete-icon-button").click({ multiple: true }) 13 | } 14 | }) 15 | }) 16 | 17 | const createTodo = () => { 18 | cy.get("#create-text-field").type(`${newTodoText}{enter}`) 19 | } 20 | 21 | it("should create a todo", () => { 22 | createTodo() 23 | expect(cy.get(todoTextSelector).contains(newTodoText)).to.exist 24 | }) 25 | 26 | it("should update a todo", () => { 27 | const updatedTodoText = "Update todo text" 28 | 29 | createTodo() 30 | 31 | cy.get(".edit-icon-button").click() 32 | 33 | cy.get("[data-testid='edit-text-field']").type(`${updatedTodoText}{enter}`) 34 | 35 | expect(cy.get(todoTextSelector).contains(updatedTodoText)).to.exist 36 | }) 37 | 38 | it("should delete a todo", () => { 39 | createTodo() 40 | 41 | cy.get(".delete-icon-button").click() 42 | 43 | cy.get(todoTextSelector).should("not.exist") 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | services: 3 | database: 4 | container_name: stator-postgres-full-stack 5 | image: postgres:latest 6 | build: 7 | context: . 8 | dockerfile: ./apps/database/postgres/Dockerfile 9 | restart: always 10 | command: 11 | - postgres 12 | - -c 13 | - listen_addresses=* 14 | environment: 15 | POSTGRES_DB: stator 16 | POSTGRES_HOST_AUTH_METHOD: "trust" # Not recommended, only for demo purposes 17 | volumes: 18 | # seeding 19 | - ./apps/database/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql 20 | # named volume 21 | - stator-data:/var/lib/postgresql/stator/data 22 | webapp: 23 | container_name: webapp-full-stack 24 | build: 25 | context: . 26 | dockerfile: ./apps/webapp/Dockerfile 27 | ports: 28 | - "${DROPLET_PORT}:80" 29 | api: 30 | container_name: api-full-stack 31 | environment: 32 | - DATABASE_PORT=5432 33 | - DATABASE_HOST=stator-postgres-full-stack 34 | build: 35 | context: . 36 | dockerfile: ./apps/api/Dockerfile 37 | ports: 38 | - "3333:3333" 39 | nginx: 40 | container_name: load-balancer-health-check 41 | image: nginx:latest 42 | ports: 43 | - "80:80" 44 | 45 | volumes: 46 | stator-data: 47 | -------------------------------------------------------------------------------- /.github/workflows/build-docker-api.yml: -------------------------------------------------------------------------------- 1 | name: Build a docker image for the API 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [14.13.1] 14 | 15 | steps: 16 | - name: Cancel previous runs 17 | uses: styfle/cancel-workflow-action@0.5.0 18 | with: 19 | access_token: ${{ github.token }} 20 | 21 | - name: Checkout 22 | uses: actions/checkout@v2 23 | 24 | - name: Inject Nx Cloud token 25 | shell: bash 26 | env: 27 | nx_cloud_token: ${{ secrets.NX_CLOUD_TOKEN }} 28 | run: 29 | sed -i "s/nx_cloud_token/$nx_cloud_token/" $GITHUB_WORKSPACE/nx.json 30 | 31 | - name: Install npm packages 32 | run: npm ci 33 | 34 | - name: Build api 35 | run: nx build api 36 | 37 | - name: Push to GitHub Packages 38 | uses: docker/build-push-action@v1 39 | with: 40 | username: ${{ github.actor }} 41 | password: ${{ secrets.GITHUB_TOKEN }} 42 | dockerfile: dockerfiles/api/Dockerfile 43 | registry: docker.pkg.github.com 44 | repository: chocolat-chaud-io/stator/stator-api 45 | tag_with_ref: true 46 | tag_with_sha: true 47 | -------------------------------------------------------------------------------- /.github/workflows/build-docker-webapp.yml: -------------------------------------------------------------------------------- 1 | name: Build a docker image for the web application 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [14.13.1] 14 | 15 | steps: 16 | - name: Cancel previous runs 17 | uses: styfle/cancel-workflow-action@0.5.0 18 | with: 19 | access_token: ${{ github.token }} 20 | 21 | - name: Checkout 22 | uses: actions/checkout@v2 23 | 24 | - name: Inject Nx Cloud token 25 | shell: bash 26 | env: 27 | nx_cloud_token: ${{ secrets.NX_CLOUD_TOKEN }} 28 | run: 29 | sed -i "s/nx_cloud_token/$nx_cloud_token/" $GITHUB_WORKSPACE/nx.json 30 | 31 | - name: Install npm packages 32 | run: npm ci 33 | 34 | - name: Build webapp 35 | run: nx build webapp 36 | 37 | - name: Push to GitHub Packages 38 | uses: docker/build-push-action@v1 39 | with: 40 | username: ${{ github.actor }} 41 | password: ${{ secrets.GITHUB_TOKEN }} 42 | registry: docker.pkg.github.com 43 | dockerfile: dockerfiles/webapp/Dockerfile 44 | repository: chocolat-chaud-io/stator/stator-webapp 45 | tag_with_ref: true 46 | tag_with_sha: true 47 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmScope": "stator", 3 | "affected": { 4 | "defaultBase": "master" 5 | }, 6 | "implicitDependencies": { 7 | "workspace.json": "*", 8 | "package.json": { 9 | "dependencies": "*", 10 | "devDependencies": "*" 11 | }, 12 | "tsconfig.base.json": "*", 13 | "tslint.json": "*", 14 | "nx.json": "*" 15 | }, 16 | "tasksRunnerOptions": { 17 | "default": { 18 | "runner": "@nrwl/nx-cloud", 19 | "options": { 20 | "accessToken": "nx_cloud_token", 21 | "cacheableOperations": ["build", "lint", "e2e", "test"], 22 | "cacheDirectory": ".cache/nx", 23 | "canTrackAnalytics": false, 24 | "showUsageWarnings": true, 25 | "parallel": 3 26 | } 27 | } 28 | }, 29 | "targetDependencies": { 30 | "build": [ 31 | { 32 | "target": "build", 33 | "projects": "dependencies" 34 | } 35 | ] 36 | }, 37 | "cli": { 38 | "defaultCollection": "@nrwl/nest" 39 | }, 40 | "defaultProject": "api", 41 | "generators": { 42 | "@nrwl/react": { 43 | "application": { 44 | "style": "scss", 45 | "linter": "eslint", 46 | "babel": true 47 | }, 48 | "component": { 49 | "style": "scss" 50 | }, 51 | "library": { 52 | "style": "scss", 53 | "linter": "eslint" 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.do/app.yaml: -------------------------------------------------------------------------------- 1 | name: stator 2 | 3 | static_sites: 4 | - name: webapp 5 | environment_slug: html 6 | github: 7 | repo: chocolat-chaud-io/stator 8 | branch: master 9 | deploy_on_push: true 10 | build_command: npm run remap-api-url && npm run build webapp -- --prod 11 | output_dir: /dist/apps/webapp 12 | routes: 13 | - path: / 14 | 15 | services: 16 | - name: api 17 | environment_slug: node-js 18 | github: 19 | repo: chocolat-chaud-io/stator 20 | branch: master 21 | deploy_on_push: true 22 | build_command: npm run remap-redoc && npm run build api -- --prod 23 | run_command: node dist/apps/api/main.js 24 | http_port: 3333 25 | envs: 26 | - key: API_URL 27 | value: ${_self.PUBLIC_URL} 28 | - key: DATABASE_HOST 29 | value: ${database.HOSTNAME} 30 | - key: DATABASE_PORT 31 | value: ${database.PORT} 32 | - key: DATABASE_NAME 33 | value: ${database.DATABASE} 34 | - key: DATABASE_USERNAME 35 | value: ${database.USERNAME} 36 | - key: DATABASE_PASSWORD 37 | value: ${database.PASSWORD} 38 | - key: DATABASE_CA_CERT 39 | value: ${database.CA_CERT} 40 | routes: 41 | - path: /do 42 | health_check: 43 | http_path: /do/api/health 44 | cors: 45 | allow_origins: 46 | - regex: .* 47 | 48 | databases: 49 | - name: database 50 | engine: PG 51 | -------------------------------------------------------------------------------- /apps/cli/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "apps/cli", 3 | "sourceRoot": "apps/cli/src", 4 | "projectType": "application", 5 | "targets": { 6 | "build": { 7 | "executor": "@nrwl/node:webpack", 8 | "outputs": ["{options.outputPath}"], 9 | "options": { 10 | "outputPath": "cli", 11 | "main": "apps/cli/src/main.ts", 12 | "tsConfig": "apps/cli/tsconfig.app.json" 13 | }, 14 | "configurations": { 15 | "production": { 16 | "optimization": true, 17 | "extractLicenses": true, 18 | "inspect": false, 19 | "fileReplacements": [ 20 | { 21 | "replace": "apps/cli/src/environments/environment.ts", 22 | "with": "apps/cli/src/environments/environment.prod.ts" 23 | } 24 | ] 25 | } 26 | } 27 | }, 28 | "serve": { 29 | "executor": "@nrwl/node:node", 30 | "options": { 31 | "buildTarget": "cli:build" 32 | } 33 | }, 34 | "lint": { 35 | "executor": "@nrwl/linter:eslint", 36 | "options": { 37 | "lintFilePatterns": ["apps/cli/**/*.ts"] 38 | }, 39 | "outputs": ["{options.outputFile}"] 40 | }, 41 | "test": { 42 | "executor": "@nrwl/jest:jest", 43 | "outputs": ["coverage/apps/cli"], 44 | "options": { 45 | "jestConfig": "apps/cli/jest.config.js", 46 | "passWithNoTests": true 47 | } 48 | } 49 | }, 50 | "tags": [] 51 | } 52 | -------------------------------------------------------------------------------- /apps/api/src/config/configuration.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from "@nestjs/config" 2 | import { TypeOrmModuleOptions } from "@nestjs/typeorm/dist/interfaces/typeorm-options.interface" 3 | import { Todo } from "@stator/models" 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-var-requires 6 | const ormConfig = require("../../../database/orm-config") 7 | 8 | export const configuration = () => ({ 9 | port: parseInt(process.env.PORT, 10) || 3333, 10 | address: process.env.ADDRESS || "0.0.0.0", 11 | test: process.env.TEST === "true", 12 | 13 | database: { 14 | ...ormConfig, 15 | certificateAuthority: process.env.DATABASE_CA_CERT, 16 | keepConnectionAlive: false, 17 | retryAttempts: 3, 18 | }, 19 | }) 20 | 21 | export const getOrmConfigFn = async (configService: ConfigService): Promise => ({ 22 | type: "postgres", 23 | host: configService.get("database.host"), 24 | port: configService.get("database.port"), 25 | database: configService.get("database.database"), 26 | username: configService.get("database.username"), 27 | password: configService.get("database.password"), 28 | synchronize: configService.get("database.synchronize"), 29 | keepConnectionAlive: configService.get("database.keepConnectionAlive"), 30 | ssl: configService.get("database.certificateAuthority") ? { ca: configService.get("database.certificateAuthority") } : false, 31 | entities: [Todo], 32 | logging: ["error"], 33 | retryAttempts: configService.get("database.retryAttempts"), 34 | }) 35 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | closes #issue_number 2 | 3 | ### Pull request creator checklist 4 | 5 | - [ ] Add an issue closing statement in the PR description. E.g: `closes #123` 6 | - [ ] Ensure you respect all the requirements of the issue you completed. 7 | - [ ] `conditional` UI/UX improvements: Try to think how you would like the feature to have been build if you get into the role of the end user. E.g: when you click a button, a modal opens up, personally as a user when I press escape I would like the modal to be closed. 8 | - [ ] Review your code as if you were reviewing someone else's code. Don't hesitate to refactor everywhere you think your code could be cleaner. 9 | - [ ] Test thoroughly every new feature you added and try to think if your changes could have affected some other flows. E.g: you modified a generic component used in other areas; if you think it can cause issues, please test these other areas.- [ ] Squash all the commits into a single one (if you make any PR fixes create separate commits). 10 | - [ ] Tag a reviewer. 11 | - [ ] Move the issue to review in progress once you've completed the checklist. 12 | 13 | ### Pull request reviewer checklist 14 | 15 | - [ ] Ensure all the requirements of the issue have been implemented. 16 | - [ ] Identify potential bugs and code improvements. 17 | - [ ] `conditional` Identify potential UI/UX improvements. 18 | - [ ] Test thoroughly every new feature that were added and try to think if any changes could have affected some other flows. E.g: you modified a generic component used in other areas; if you think it can cause issues, please test these other areas. 19 | -------------------------------------------------------------------------------- /apps/webapp/src/hooks/use-form-validator.ts: -------------------------------------------------------------------------------- 1 | import { ClassType, transformAndValidate } from "class-transformer-validator" 2 | import { ValidationError } from "class-validator" 3 | import { useState } from "react" 4 | 5 | // Extending object is required for this generic type T 6 | // eslint-disable-next-line @typescript-eslint/ban-types 7 | export function useFormValidator(classType: ClassType) { 8 | const [errors, setErrors] = useState([]) 9 | const [isValid, setIsValid] = useState(false) 10 | 11 | /** 12 | * @param entity The entity object (doesn't need to be a class instance) 13 | */ 14 | const validateForm = async (entity: T) => { 15 | try { 16 | await transformAndValidate(classType, entity) 17 | setErrors([]) 18 | setIsValid(true) 19 | 20 | return true 21 | } catch (errors) { 22 | setErrors(errors) 23 | setIsValid(false) 24 | 25 | return false 26 | } 27 | } 28 | 29 | /** 30 | * 31 | * @param property The property of the object as a string 32 | */ 33 | const getPropertyErrors = (property: keyof T) => { 34 | const propertyError = errors.find(error => error.property === property) 35 | const constraints = propertyError?.constraints || {} 36 | 37 | return (Object.values(constraints) || []).join("\n") 38 | } 39 | 40 | const errorsByProperty = errors.reduce( 41 | (container, error) => ({ 42 | ...container, 43 | [error.property]: getPropertyErrors(error.property), 44 | }), 45 | {} 46 | ) 47 | 48 | return { errors, errorsByProperty, validateForm, getPropertyErrors, isValid } 49 | } 50 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["@nrwl/nx"], 5 | "overrides": [ 6 | { 7 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 8 | "extends": ["plugin:react/recommended"], 9 | "settings": { 10 | "react": { 11 | "version": "detect" 12 | } 13 | }, 14 | "parserOptions": { 15 | "ecmaFeatures": { 16 | "jsx": true 17 | }, 18 | "project": "./tsconfig.*?.json" 19 | }, 20 | "plugins": ["react"], 21 | "rules": { 22 | "@typescript-eslint/no-inferrable-types": "off", 23 | "@nrwl/nx/enforce-module-boundaries": [ 24 | "error", 25 | { 26 | "enforceBuildableLibDependency": true, 27 | "allow": [], 28 | "depConstraints": [ 29 | { 30 | "sourceTag": "*", 31 | "onlyDependOnLibsWithTags": ["*"] 32 | } 33 | ] 34 | } 35 | ], 36 | "react/prefer-stateless-function": "error", 37 | "react/prop-types": "off", 38 | "id-length": ["error", { "exceptions": ["i", "_", "s"] }], 39 | "react/jsx-no-useless-fragment": "off", 40 | "newline-before-return": "warn" 41 | } 42 | }, 43 | { 44 | "files": ["*.ts", "*.tsx"], 45 | "extends": ["plugin:@nrwl/nx/typescript"], 46 | "parserOptions": { 47 | "project": "./tsconfig.*?.json" 48 | }, 49 | "rules": {} 50 | }, 51 | { 52 | "files": ["*.js", "*.jsx"], 53 | "extends": ["plugin:@nrwl/nx/javascript"], 54 | "rules": {} 55 | } 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /.github/action-setup-ci/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup CI 2 | 3 | inputs: 4 | github_token: 5 | description: "Github private access token" 6 | required: true 7 | nx_cloud_token: 8 | description: "NX cloud token" 9 | required: true 10 | 11 | runs: 12 | using: "composite" 13 | steps: 14 | - name: Cancel previous runs 15 | uses: styfle/cancel-workflow-action@0.5.0 16 | with: 17 | access_token: ${{ inputs.github_token }} 18 | 19 | - name: Increase watcher limit 20 | shell: bash 21 | run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p 22 | 23 | - name: Checkout 24 | uses: actions/checkout@v2 25 | with: 26 | fetch-depth: 0 27 | 28 | - name: Derive appropriate SHAs for base and head for `nx affected` commands 29 | uses: nrwl/nx-set-shas@v2 30 | with: 31 | main-branch-name: master 32 | 33 | - name: Inject Nx Cloud token 34 | shell: bash 35 | env: 36 | nx_cloud_token: ${{ inputs.NX_CLOUD_TOKEN }} 37 | run: sed -i "s/nx_cloud_token/$nx_cloud_token/" $GITHUB_WORKSPACE/nx.json 38 | 39 | - name: Setup node.js 40 | uses: actions/setup-node@v2 41 | with: 42 | node-version: 14.18.0 43 | 44 | - name: Cache dependencies 45 | id: cache 46 | uses: actions/cache@v2 47 | with: 48 | path: | 49 | ./node_modules 50 | /home/runner/.cache/Cypress 51 | key: modules-${{ hashFiles('package-lock.json') }} 52 | 53 | - name: Install npm packages 54 | if: steps.cache.outputs.cache-hit != 'true' 55 | shell: bash 56 | run: npm i 57 | -------------------------------------------------------------------------------- /apps/api/webpack.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const path = require("path") 3 | const webpack = require("webpack") 4 | const ts = require("typescript") 5 | 6 | /** 7 | * Extend the default Webpack configuration from nx / ng. 8 | * this webpack.config is used w/ node:build builder 9 | * see angular.json greenroom-rest-api 10 | */ 11 | module.exports = config => { 12 | addSwagger(config) 13 | 14 | config.plugins = [ 15 | ...(config.plugins || []), 16 | new webpack.ProvidePlugin({ 17 | openapi: "@nestjs/swagger", 18 | }), 19 | ] 20 | 21 | return config 22 | } 23 | 24 | /** 25 | * Adds nestjs swagger plugin 26 | * 27 | * nestjs swagger: https://docs.nestjs.com/recipes/swagger#plugin 28 | * ts-loader: https://github.com/Igorbek/typescript-plugin-styled-components#ts-loader 29 | * getCustomTransformers: https://github.com/TypeStrong/ts-loader#getcustomtransformers 30 | * 31 | * Someone else has done this, see: 32 | * https://github.com/nrwl/nx/issues/2147 33 | */ 34 | const addSwagger = config => { 35 | const rule = config.module.rules.find(rule => rule.loader.includes("ts-loader")) 36 | if (!rule) throw new Error("no ts-loader rule found") 37 | 38 | rule.options = { 39 | ...rule.options, 40 | getCustomTransformers: () => { 41 | const program = ts.createProgram([path.join(__dirname, "src/main.ts")], {}) 42 | 43 | return { 44 | before: [ 45 | require("@nestjs/swagger/plugin").before( 46 | { 47 | classValidatorShim: true, 48 | }, 49 | program 50 | ), 51 | ], 52 | } 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tools/getting-started/src/validate-dependencies.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text } from "ink" 2 | import Link from "ink-link" 3 | import React, { useEffect } from "react" 4 | 5 | import IsValidIcon from "./is-valid-icon" 6 | 7 | const exec = require("child_process").exec 8 | 9 | interface Props { 10 | isDockerInstalled?: boolean 11 | onDockerIsInstalledChange: (isInstalled: boolean) => void 12 | } 13 | 14 | const ValidateDependencies: React.FC = props => { 15 | useEffect(() => { 16 | exec("docker-compose --version", (error: Error | null, stdout: string) => { 17 | const validationTexts = ["docker-compose version", "docker compose version"] 18 | const isDockerValid = !!validationTexts.find(text => stdout.toString().toLocaleLowerCase().includes(text)) 19 | props.onDockerIsInstalledChange(!error && isDockerValid) 20 | }) 21 | }, []) 22 | 23 | return ( 24 | <> 25 | Validating required dependencies 26 | 27 | 28 | docker-compose: 29 | 30 | {props.isDockerInstalled === false && ( 31 | 32 | 33 | {" "} 34 | Install docker-compose 35 | 36 | 37 | )} 38 | 39 | 40 | {/* Always true because we are ensuring it is installed with the package.json */} 41 | nodejs: 42 | 43 | 44 | 45 | ) 46 | } 47 | 48 | export default ValidateDependencies 49 | -------------------------------------------------------------------------------- /apps/cli/src/commands/enforce-valid-imports-api.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import path from "path" 3 | 4 | import { Command } from "clipanion" 5 | 6 | import { walk } from "../utils" 7 | 8 | export class EnforceValidImportsApi extends Command { 9 | static paths = [["enforce-valid-imports-api"]] 10 | 11 | static usage = Command.Usage({ 12 | category: "enforcers", 13 | description: 14 | "This script will make sure that your imports are valid in the API. This is used to avoid import errors than can be hard to spot.", 15 | examples: [["A basic example", "npm run stator-cli enforce-valid-imports-api"]], 16 | }) 17 | 18 | async execute(): Promise { 19 | const invalidImportRegex = /import .*stator\/[a-zA-Z]+\//gm 20 | const fileContainingInvalidImports = [] 21 | 22 | async function validateEntryName(entry) { 23 | const fileContent = await fs.promises.readFile(entry, { encoding: "utf-8" }) 24 | const match = fileContent.match(invalidImportRegex) 25 | if (match) { 26 | fileContainingInvalidImports.push(entry) 27 | } 28 | } 29 | 30 | for await (const entry of walk(path.join(__dirname, "../apps/api/src"), [])) { 31 | await validateEntryName(entry) 32 | } 33 | 34 | if (fileContainingInvalidImports.length > 0) { 35 | const errorMessage = `${fileContainingInvalidImports.length} file(s) have invalid imports. They should NOT look like this: "@stator/models/something/entity"` 36 | 37 | console.error(errorMessage) 38 | console.error(fileContainingInvalidImports) 39 | 40 | process.exit(1) 41 | } 42 | 43 | console.info("Congratulations, all your imports in api are valid!") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /apps/webapp/src/components/global/snackbar-listener/snackbar-listener.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, Snackbar } from "@mui/material" 2 | import { SnackbarCloseReason } from "@mui/material/Snackbar/Snackbar" 3 | import React, { useEffect, useState } from "react" 4 | import { useDispatch, useSelector } from "react-redux" 5 | 6 | import { AppDispatch, RootState } from "../../../redux/store" 7 | import { SnackbarState, snackbarThunks } from "../../../redux/thunks-slice/snackbar-thunks-slice" 8 | 9 | interface Props {} 10 | 11 | export const SnackbarListener: React.FC = () => { 12 | const dispatch = useDispatch() 13 | const snackbarState = useSelector((state: RootState) => state.snackbarReducer) 14 | const [isOpened, setIsOpened] = useState(!!snackbarState.snackbar?.message) 15 | 16 | useEffect(() => { 17 | setIsOpened(!!snackbarState.snackbar?.message) 18 | }, [snackbarState.snackbar?.message]) 19 | 20 | const onErrorAlertClose = (_: React.SyntheticEvent, reason?: SnackbarCloseReason) => { 21 | if (reason !== "clickaway") { 22 | setIsOpened(false) 23 | dispatch(snackbarThunks.clear(null)) 24 | } 25 | } 26 | 27 | if (!snackbarState.snackbar?.message) { 28 | return null 29 | } 30 | 31 | return ( 32 | 39 | 40 | {snackbarState.snackbar?.message} 41 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/build-test-release.yml: -------------------------------------------------------------------------------- 1 | name: stator CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | build-test-release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: ./.github/action-setup-ci 15 | with: 16 | github_token: ${{ github.token }} 17 | nx_cloud_token: ${{ secrets.NX_CLOUD_TOKEN }} 18 | 19 | - name: Setup postgres container 20 | run: docker-compose -f $GITHUB_WORKSPACE/apps/database/postgres/docker-compose.yml up -d 21 | 22 | - name: Start api 23 | run: npm run typeorm -- migration:run && npm start api & 24 | env: 25 | TEST: true 26 | 27 | - name: Enforce naming conventions 28 | run: npm run lint:file-folder-convention 29 | 30 | - name: Lint affected files 31 | run: npm run affected:lint 32 | 33 | - name: Build affected apps 34 | run: npm run affected:build 35 | 36 | - name: Test affected apps 37 | run: npm run affected:test -- --code-coverage 38 | 39 | - name: Test affected apps e2e 40 | run: npm run affected:e2e 41 | 42 | - name: Codecov 43 | uses: codecov/codecov-action@v2 44 | with: 45 | token: ${{ secrets.CODECOV_TOKEN }} 46 | directory: ./coverage/apps/api/ 47 | fail_ci_if_error: false 48 | 49 | - name: Archive code coverage results 50 | uses: actions/upload-artifact@v2 51 | with: 52 | name: code-coverage-report 53 | path: ./coverage 54 | 55 | - name: Release 56 | env: 57 | GITHUB_TOKEN: ${{ github.token }} 58 | run: npx semantic-release 59 | -------------------------------------------------------------------------------- /apps/cli/src/commands/enforce-file-folder-naming-convention.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | 3 | import { Command } from "clipanion" 4 | 5 | import { walk } from "../utils" 6 | 7 | export class EnforceFileFolderNamingConvention extends Command { 8 | static paths = [["enforce-file-folder-naming-convention"]] 9 | 10 | static usage = Command.Usage({ 11 | category: "enforcers", 12 | description: "This script will make sure that your folders and file use kebab-case.", 13 | examples: [["A basic example", "npm run stator-cli generate-cache-key-file"]], 14 | }) 15 | 16 | async execute(): Promise { 17 | const ignoredPaths = [ 18 | "node_modules", 19 | "dist", 20 | ".git", 21 | ".idea", 22 | ".gitkeep", 23 | ".eslintrc", 24 | ".cache", 25 | "README", 26 | "LICENSE", 27 | "CONTRIBUTING", 28 | "dockerfiles", 29 | "Dockerfile", 30 | ] 31 | const capitalLetterRegex = /[A-Z]/gm 32 | const errorPathPaths = [] 33 | 34 | function validateEntryName(entry) { 35 | const entryName = path.basename(entry).replace(/\.[^/.]+$/, "") 36 | if (entryName.length > 0 && !ignoredPaths.includes(entryName) && entryName.match(capitalLetterRegex)) { 37 | errorPathPaths.push(entry) 38 | } 39 | } 40 | 41 | const folderNames = [] 42 | for await (const entry of walk(path.join(__dirname, ".."), ignoredPaths, folderNames)) { 43 | validateEntryName(entry) 44 | } 45 | 46 | for (const folderName of folderNames) { 47 | validateEntryName(folderName) 48 | } 49 | 50 | if (errorPathPaths.length > 0) { 51 | const errorMessage = `${errorPathPaths.length} files/directories do not respect the kebab-case convention enforced.` 52 | 53 | console.error(errorMessage) 54 | console.error(errorPathPaths) 55 | 56 | process.exit(1) 57 | } 58 | 59 | console.info("Congratulations, all your files and directories are properly named!") 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /apps/api/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "apps/api", 3 | "sourceRoot": "apps/api/src", 4 | "projectType": "application", 5 | "prefix": "api", 6 | "generators": {}, 7 | "targets": { 8 | "build": { 9 | "executor": "@nrwl/node:webpack", 10 | "options": { 11 | "outputPath": "dist/apps/api", 12 | "main": "apps/api/src/main.ts", 13 | "tsConfig": "apps/api/tsconfig.app.json", 14 | "assets": ["apps/api/src/assets"], 15 | "generatePackageJson": true, 16 | "webpackConfig": "apps/api/webpack.config.ts", 17 | "showCircularDependencies": false, 18 | "maxWorkers": 2, 19 | "memoryLimit": 1024 20 | }, 21 | "configurations": { 22 | "production": { 23 | "optimization": true, 24 | "extractLicenses": true, 25 | "generatePackageJson": true, 26 | "inspect": false, 27 | "fileReplacements": [ 28 | { 29 | "replace": "apps/api/src/environments/environment.ts", 30 | "with": "apps/api/src/environments/environment.prod.ts" 31 | } 32 | ] 33 | } 34 | }, 35 | "outputs": ["{options.outputPath}"] 36 | }, 37 | "serve": { 38 | "executor": "@nrwl/node:node", 39 | "options": { 40 | "buildTarget": "api:build" 41 | } 42 | }, 43 | "debug": { 44 | "executor": "@nrwl/node:node", 45 | "options": { 46 | "buildTarget": "api:build", 47 | "inspect": true, 48 | "port": 7777 49 | } 50 | }, 51 | "lint": { 52 | "executor": "@nrwl/linter:eslint", 53 | "options": { 54 | "lintFilePatterns": ["apps/api/**/*.{ts,tsx,js,jsx}"] 55 | }, 56 | "outputs": ["{options.outputFile}"] 57 | }, 58 | "test": { 59 | "executor": "@nrwl/jest:jest", 60 | "options": { 61 | "jestConfig": "apps/api/jest.config.js", 62 | "passWithNoTests": true 63 | }, 64 | "outputs": ["coverage/apps/api"] 65 | } 66 | }, 67 | "tags": [] 68 | } 69 | -------------------------------------------------------------------------------- /apps/api/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from "@nestjs/common" 2 | import { ConfigService } from "@nestjs/config" 3 | import { NestFactory } from "@nestjs/core" 4 | import { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify" 5 | import { DocumentBuilder, SwaggerDocumentOptions, SwaggerModule } from "@nestjs/swagger" 6 | import { WinstonModule } from "nest-winston" 7 | import winston from "winston" 8 | 9 | import { AppModule } from "./app/app.module" 10 | import { getWinstonConsoleFormat } from "./utils/utils" 11 | 12 | function configureSwagger(app: NestFastifyApplication) { 13 | const swaggerConfig = new DocumentBuilder().setTitle("Stator").setDescription("The stator API description").setVersion("1.0").build() 14 | const swaggerOptions: SwaggerDocumentOptions = { 15 | // This basically has no effect because of this bug: https://github.com/nestjsx/crud/issues/759 16 | operationIdFactory: (controllerKey, methodKey) => { 17 | const entityName = `${methodKey.includes("Many") ? "" : "s"}Controller` 18 | 19 | return methodKey.includes("Base") 20 | ? `${methodKey.replace("Base", controllerKey.replace(entityName, ""))}` 21 | : `${methodKey}${entityName}` 22 | }, 23 | } 24 | 25 | const document = SwaggerModule.createDocument(app, swaggerConfig, swaggerOptions) 26 | SwaggerModule.setup("documentation", app, document) 27 | } 28 | 29 | async function bootstrap() { 30 | const app = await NestFactory.create(AppModule, new FastifyAdapter({ logger: true }), { 31 | logger: WinstonModule.createLogger({ transports: [new winston.transports.Console({ format: getWinstonConsoleFormat() })] }), 32 | }) 33 | app.useGlobalPipes(new ValidationPipe({ transform: true })) 34 | app.enableShutdownHooks() 35 | app.enableCors({ origin: "*" }) 36 | app.setGlobalPrefix("api") 37 | 38 | configureSwagger(app) 39 | 40 | const configService = app.get(ConfigService) 41 | app.listen(configService.get("port"), configService.get("address")).catch(error => console.error(error)) 42 | } 43 | 44 | bootstrap().catch(console.error) 45 | -------------------------------------------------------------------------------- /apps/api/src/endpoints/todos/todos.e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import { Todo } from "@stator/models" 2 | import * as supertest from "supertest" 3 | import { Repository } from "typeorm" 4 | 5 | import { TestingHelper } from "../../utils/test" 6 | import { TodosModule } from "./todos.module" 7 | 8 | describe("Todos", () => { 9 | let testingHelper: TestingHelper 10 | let repository: Repository 11 | 12 | beforeAll(async () => { 13 | testingHelper = await new TestingHelper().initializeModuleAndApp("todos", [TodosModule]) 14 | 15 | repository = testingHelper.module.get("TodoRepository") 16 | }) 17 | 18 | beforeEach(() => testingHelper.reloadFixtures()) 19 | afterAll(() => testingHelper.shutdownServer()) 20 | 21 | describe("GET /todos", () => { 22 | it("should return an array of todos", async () => { 23 | const { body } = await supertest 24 | .agent(testingHelper.app.getHttpServer()) 25 | .get("/todos") 26 | .set("Accept", "application/json") 27 | .expect("Content-Type", /json/) 28 | .expect(200) 29 | 30 | expect(body).toMatchObject([ 31 | { id: expect.any(Number), text: "test-name-0" }, 32 | { id: expect.any(Number), text: "test-name-1" }, 33 | ]) 34 | }) 35 | 36 | it("should create one todo", async () => { 37 | const todo = { text: "test-name-0" } 38 | 39 | const { body } = await supertest 40 | .agent(testingHelper.app.getHttpServer()) 41 | .post("/todos") 42 | .send(todo) 43 | .set("Accept", "application/json") 44 | .expect("Content-Type", /json/) 45 | .expect(201) 46 | 47 | expect(body).toMatchObject({ id: expect.any(Number), text: "test-name-0" }) 48 | }) 49 | 50 | it("should update the name of a todo", async () => { 51 | const { body } = await supertest 52 | .agent(testingHelper.app.getHttpServer()) 53 | .patch(`/todos/1`) 54 | .send({ text: "updated-name" }) 55 | .set("Accept", "application/json") 56 | .expect("Content-Type", /json/) 57 | .expect(200) 58 | 59 | expect(body).toMatchObject({ id: 1, text: "updated-name" }) 60 | }) 61 | 62 | it("should delete one todo", async () => { 63 | await supertest.agent(testingHelper.app.getHttpServer()).delete(`/todos/1`).set("Accept", "application/json").expect(200) 64 | const missingTodo = await repository.findOne({ id: 1 }) 65 | 66 | expect(missingTodo).toBe(undefined) 67 | }) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /apps/webapp/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "apps/webapp", 3 | "sourceRoot": "apps/webapp/src", 4 | "projectType": "application", 5 | "generators": {}, 6 | "targets": { 7 | "build": { 8 | "executor": "@nrwl/web:webpack", 9 | "options": { 10 | "outputPath": "dist/apps/webapp", 11 | "index": "apps/webapp/src/index.html", 12 | "main": "apps/webapp/src/main.tsx", 13 | "polyfills": "apps/webapp/src/polyfills.ts", 14 | "tsConfig": "apps/webapp/tsconfig.app.json", 15 | "assets": ["apps/webapp/src/favicon.ico", "apps/webapp/src/assets"], 16 | "styles": ["apps/webapp/src/styles.scss"], 17 | "scripts": [], 18 | "webpackConfig": "@nrwl/react/plugins/webpack", 19 | "showCircularDependencies": false, 20 | "maxWorkers": 2, 21 | "memoryLimit": 1024 22 | }, 23 | "configurations": { 24 | "production": { 25 | "fileReplacements": [ 26 | { 27 | "replace": "apps/webapp/src/environments/environment.ts", 28 | "with": "apps/webapp/src/environments/environment.prod.ts" 29 | } 30 | ], 31 | "optimization": true, 32 | "outputHashing": "all", 33 | "sourceMap": false, 34 | "extractCss": true, 35 | "namedChunks": false, 36 | "extractLicenses": true, 37 | "vendorChunk": false, 38 | "budgets": [ 39 | { 40 | "type": "initial", 41 | "maximumWarning": "2mb", 42 | "maximumError": "5mb" 43 | } 44 | ] 45 | } 46 | }, 47 | "outputs": ["{options.outputPath}"] 48 | }, 49 | "serve": { 50 | "executor": "@nrwl/web:dev-server", 51 | "options": { 52 | "buildTarget": "webapp:build" 53 | }, 54 | "configurations": { 55 | "production": { 56 | "buildTarget": "webapp:build:production" 57 | } 58 | } 59 | }, 60 | "lint": { 61 | "executor": "@nrwl/linter:eslint", 62 | "options": { 63 | "lintFilePatterns": ["apps/webapp/**/*.{ts,tsx,js,jsx}"] 64 | }, 65 | "outputs": ["{options.outputFile}"] 66 | }, 67 | "test": { 68 | "executor": "@nrwl/jest:jest", 69 | "options": { 70 | "jestConfig": "apps/webapp/jest.config.js", 71 | "passWithNoTests": true 72 | }, 73 | "outputs": ["coverage/apps/webapp"] 74 | } 75 | }, 76 | "tags": [] 77 | } 78 | -------------------------------------------------------------------------------- /apps/cli/src/commands/generate-cache-key-file.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import path from "path" 3 | 4 | import { Command } from "clipanion" 5 | import camelCase from "lodash/camelCase" 6 | import capitalize from "lodash/capitalize" 7 | import kebabCase from "lodash/kebabCase" 8 | 9 | import { walk } from "../utils" 10 | 11 | export class GenerateCacheKeyFile extends Command { 12 | static paths = [["generate-cache-key-file"]] 13 | 14 | static usage = Command.Usage({ 15 | category: "generators", 16 | description: "This script will generate the required cache key files for your redux webapp.", 17 | examples: [["A basic example", "npm run stator-cli generate-cache-key-file"]], 18 | }) 19 | 20 | async execute(): Promise { 21 | const endpointsPath = path.join(__dirname, "../apps/webapp/src/redux/endpoints") 22 | const importStatements = [] 23 | const cacheKeys = [] 24 | let cacheFileContent = `/** 25 | * This file was automatically generated by tools/generators/generate-cache-file.js file 26 | */ 27 | 28 | IMPORT_STATEMENTS 29 | 30 | ` 31 | 32 | for await (const pathName of walk(endpointsPath, [])) { 33 | const isEndpointsFile = fs.lstatSync(pathName).isFile() && pathName.includes("-endpoints") 34 | if (isEndpointsFile) { 35 | const cacheKey = camelCase(path.basename(pathName, ".ts").replace("-endpoints", "")) 36 | cacheKeys.push(cacheKey) 37 | const endpointsSelectorRegex = /build => \(({[\s\S]+overrideExisting: false,\s+})/m 38 | const endpointsObjectString = fs.readFileSync(pathName, { encoding: "utf8" }).match(endpointsSelectorRegex)[1] 39 | const endpointSelectorRegex = /([a-z-A-Z]+): build.[qm]/gm 40 | const endpointNames = [...endpointsObjectString.matchAll(endpointSelectorRegex)].map(entries => [entries[1]]).flat() 41 | 42 | if (endpointNames.length > 0) { 43 | importStatements.push(`import { ${cacheKey}Api } from "./${kebabCase(cacheKey)}-endpoints"`) 44 | cacheFileContent += `export const add${capitalize(cacheKey)}CacheKeys = () => 45 | ${cacheKey}Api.enhanceEndpoints({ 46 | endpoints: { 47 | ${endpointNames 48 | .map(endpointName => { 49 | const tagPropertyKey = endpointName.includes("get") ? "providesTags" : "invalidatesTags" 50 | return ` ${endpointName}: { ${tagPropertyKey}: ["${cacheKey}"] },` 51 | }) 52 | .join("\n")} 53 | }, 54 | })\n` 55 | } 56 | } 57 | } 58 | 59 | cacheFileContent = cacheFileContent.replace("IMPORT_STATEMENTS", importStatements.map(importStatement => importStatement).join("\n")) 60 | cacheFileContent += `export const addGeneratedCacheKeys = () => { 61 | ${cacheKeys.map(cacheKey => `add${capitalize(cacheKey)}CacheKeys()`).join("\n")} 62 | }\n` 63 | 64 | fs.writeFileSync(`${endpointsPath}/generated-cache-keys.ts`, cacheFileContent, { encoding: "utf8" }) 65 | console.info(`Generated ${endpointsPath}/generated-cache-keys.ts`) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /apps/cli/src/commands/rename-project.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import path from "path" 3 | 4 | import { Command, Option } from "clipanion" 5 | import { camelCase, kebabCase } from "lodash" 6 | 7 | import { walk } from "../utils" 8 | 9 | export class RenameProject extends Command { 10 | static paths = [["rename-project"]] 11 | organization = Option.String("--organization", { required: true }) 12 | project = Option.String("--project", { required: true }) 13 | 14 | static usage = Command.Usage({ 15 | category: "getting-started", 16 | description: "This script will rename all occurrences of stator and chocolat-chaud with your own names.", 17 | examples: [["A basic example", "npm run stator-cli rename-project --organization chocolat-chaud-io --project stator"]], 18 | }) 19 | 20 | async execute(): Promise { 21 | await this.renameProject() 22 | } 23 | 24 | async renameProject() { 25 | try { 26 | const organizationRegex = /^[a-zA-Z-\d_]+$/gim 27 | if (!organizationRegex.test(this.organization)) { 28 | console.error("The organization name must respect this regex /^[a-zA-Z-\\d_]+$/gmi") 29 | process.exit(1) 30 | } 31 | 32 | const projectRegex = /^[a-zA-Z-\d_]+$/gim 33 | if (!projectRegex.test(this.project)) { 34 | console.error("The project name must respect this regex /^[a-zA-Z-\\d_]+$/gmi") 35 | process.exit(1) 36 | } 37 | const databaseName = this.project.replace(/-/g, "_") 38 | const databaseFiles = ["docker-compose.yml", "seed-data.js", "init.sql", "test.ts", "orm-config.ts"] 39 | 40 | const camelCaseProjectName = camelCase(this.project) 41 | 42 | const ignoredFolders = ["node_modules", "dist", ".git", ".idea", ".cache"] 43 | for await (const entry of walk(path.join(__dirname, "../"), ignoredFolders)) { 44 | const entryStat = await fs.promises.lstat(entry) 45 | if (entryStat.isFile()) { 46 | const fileContent = await fs.promises.readFile(entry, "utf-8") 47 | if (fileContent) { 48 | const isDatabaseFile = databaseFiles.some(databaseFile => entry.includes(databaseFile)) 49 | const replacedFileContent = fileContent 50 | .replace(/chocolat-chaud-io/gim, this.organization) 51 | .replace(/stator/gim, isDatabaseFile ? databaseName : camelCaseProjectName) 52 | await fs.promises.writeFile(entry, replacedFileContent, "utf-8") 53 | } 54 | } 55 | } 56 | 57 | console.info(`This is now YOUR project provided generously by: 58 | 59 | ███████ ████████  █████  ████████  ██████  ██████  60 | ██         ██    ██   ██    ██    ██    ██ ██   ██  61 | ███████  ██  ███████  ██  ██  ██ ██████   62 |      ██  ██  ██   ██  ██  ██  ██ ██   ██  63 | ███████  ██  ██  ██  ██   ██████  ██  ██  64 |                                          65 | `) 66 | } catch (error) { 67 | console.error(error as Error) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.github/workflows/delete-digitalocean-review-app-resources.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zx 2 | 3 | const projectName = process.env.REPO_NAME.split("/")[1]; 4 | const dropletName = `pr-${process.env.PR_NUMBER}`; 5 | const firewallName = `${projectName}-review-apps`; 6 | const loadBalancerName = `${projectName}-review-apps`; 7 | const digitaloceanBaseUrl = "https://api.digitalocean.com/v2"; 8 | const headers = { Authorization: `Bearer ${process.env.DIGITALOCEAN_ACCESS_TOKEN}` }; 9 | 10 | const dropletPort = (await nothrow($`doctl compute droplet get ${dropletName}`)).stdout.split("port_")[1].split(",")[0]; 11 | await deleteFirewallRuleIfExists(); 12 | await deleteLoadBalancerRuleIfExists(); 13 | if (dropletPort) { 14 | await $`doctl compute droplet delete ${dropletName} --force`; 15 | } 16 | 17 | async function deleteFirewallRuleIfExists() { 18 | const firewallsResponse = await fetch(`${digitaloceanBaseUrl}/firewalls`, { headers }); 19 | await checkResponseValidity(firewallsResponse, "Could not fetch firewall list:"); 20 | 21 | const { firewalls: firewalls } = JSON.parse(await firewallsResponse.text()); 22 | const firewall = firewalls.find(firewall => firewall.name === firewallName); 23 | const firewallRule = firewall?.inbound_rules?.find(rule => rule.ports === dropletPort); 24 | 25 | if (firewallRule) { 26 | const response = await fetch(`${digitaloceanBaseUrl}/firewalls/${firewall.id}`, { 27 | method: "PUT", 28 | headers, 29 | body: JSON.stringify({ 30 | ...firewall, 31 | inbound_rules: firewall.inbound_rules.filter(rule => rule.ports !== dropletPort) 32 | }) 33 | }); 34 | await checkResponseValidity(response, "Could not remove firewall rule:"); 35 | } 36 | } 37 | 38 | async function deleteLoadBalancerRuleIfExists() { 39 | const loadBalancerResponse = await fetch(`${digitaloceanBaseUrl}/load_balancers`, { headers }); 40 | await checkResponseValidity(loadBalancerResponse, "Could not fetch load balancer list:"); 41 | 42 | const { load_balancers } = JSON.parse(await loadBalancerResponse.text()); 43 | const loadBalancer = load_balancers.find(loadBalancer => loadBalancer.name === loadBalancerName); 44 | const loadBalancerRule = loadBalancer?.forwarding_rules?.find(rule => rule.entry_port.toString() === dropletPort); 45 | 46 | if (loadBalancerRule) { 47 | delete loadBalancer.droplet_ids 48 | const response = await fetch(`${digitaloceanBaseUrl}/load_balancers/${loadBalancer.id}`, { 49 | method: "PUT", 50 | headers, 51 | body: JSON.stringify({ 52 | ...loadBalancer, 53 | region: "nyc1", 54 | forwarding_rules: loadBalancer.forwarding_rules.filter(rule => rule.entry_port.toString() !== dropletPort) 55 | }) 56 | }); 57 | await checkResponseValidity(response, "Could not remove load balancer rule:"); 58 | } 59 | } 60 | 61 | async function checkResponseValidity(response, message) { 62 | if (!response.ok) { 63 | console.error(message); 64 | console.error(await response.text()); 65 | process.exit(1); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /apps/cli/src/commands/generate-entity-index-file.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { walk } from "../utils"; 4 | import { Command } from "clipanion"; 5 | 6 | export class GenerateEntityIndexFile extends Command { 7 | static paths = [["generate-entity-index-file"]] 8 | 9 | static usage = Command.Usage({ 10 | category: "generators", 11 | description: "This script will generate index file for the model library.", 12 | examples: [["A basic example", "npm run stator-cli generate-entity-index-file"]], 13 | }) 14 | 15 | async execute(): Promise { 16 | const entityIndexLockFilePath = path.join(__dirname, "entity-index-hash.txt") 17 | const indexFilePath = path.join(__dirname, "../libs/models/src/index.ts") 18 | const filePathsByFolder = {} 19 | 20 | for await (const entry of walk(path.join(__dirname, "../libs/models/src/lib"), [])) { 21 | const folder = entry.split("lib/")[1].split("/")[0] 22 | 23 | if (!filePathsByFolder[folder]) { 24 | filePathsByFolder[folder] = [] 25 | } 26 | filePathsByFolder[folder].push(entry) 27 | } 28 | 29 | let indexFileContent = `/** 30 | * This file was automatically generated by generate-entity-index.js file 31 | * You can disable the automatic generation by removing the prepare section of the workspace.json file under api section 32 | */\n\n` 33 | 34 | const sortedFolders = Object.entries(filePathsByFolder) 35 | .sort() 36 | .reduce((container, [key, value]) => ({ ...container, [key]: value }), {}) 37 | for (const [folder, filePaths] of Object.entries(sortedFolders)) { 38 | indexFileContent += `// ${folder}\n` 39 | indexFileContent += getExportLinesFromFilePaths(filePaths) 40 | indexFileContent += "\n" 41 | } 42 | 43 | const entityIndexLockFileExists = fs.existsSync(entityIndexLockFilePath) 44 | const existingEntityHash = parseInt( 45 | entityIndexLockFileExists ? await fs.promises.readFile(entityIndexLockFilePath, { encoding: "utf8" }) : "" 46 | ) 47 | const currentHash = hashCode(indexFileContent) 48 | if (existingEntityHash !== currentHash) { 49 | await fs.promises.writeFile(entityIndexLockFilePath, currentHash.toString(), { encoding: "utf8" }) 50 | await fs.promises.writeFile(indexFilePath, indexFileContent, { encoding: "utf8" }) 51 | 52 | console.info("Generated index file for shared entity library") 53 | } 54 | } 55 | } 56 | 57 | function hashCode(str) { 58 | let hash = 0 59 | let i 60 | let chr 61 | 62 | for (i = 0; i < str.length; i++) { 63 | chr = str.charCodeAt(i) 64 | hash = (hash << 5) - hash + chr 65 | hash |= 0 // Convert to 32bit integer 66 | } 67 | return hash 68 | } 69 | 70 | function getExportLinesFromFilePaths(filePaths) { 71 | return filePaths 72 | .sort() 73 | .map(filePath => { 74 | const relevantFilePath = filePath.split("src/")[1].replace(".ts", "") 75 | 76 | return `export * from "./${relevantFilePath}"\n` 77 | }) 78 | .join("") 79 | } 80 | -------------------------------------------------------------------------------- /apps/api/src/utils/test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import path from "path" 3 | 4 | import { ModuleMetadata } from "@nestjs/common/interfaces/modules/module-metadata.interface" 5 | import { Provider } from "@nestjs/common/interfaces/modules/provider.interface" 6 | import { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify" 7 | import { Test, TestingModule } from "@nestjs/testing" 8 | import { Connection, createConnection } from "typeorm" 9 | import { Builder, Loader, Parser, Resolver, fixturesIterator } from "typeorm-fixtures-cli/dist" 10 | import { ConnectionOptions } from "typeorm/connection/ConnectionOptions" 11 | 12 | import { configurationTest } from "../config/configuration.test" 13 | import { getRootModuleImports } from "./utils" 14 | 15 | export class TestingHelper { 16 | module: TestingModule 17 | app: NestFastifyApplication 18 | 19 | async initializeModuleAndApp(testName: string, importedModules: ModuleMetadata["imports"], providers: Provider[] = undefined) { 20 | const databaseName = `stator_test_${testName}` 21 | const configuration = configurationTest.bind(this, databaseName) 22 | 23 | const connectionOptions: ConnectionOptions = { ...configuration().database } 24 | const connection = await createConnection(connectionOptions) 25 | await this.createDatabaseIfNotExist(connection, databaseName) 26 | 27 | this.module = await Test.createTestingModule({ 28 | imports: [...getRootModuleImports(configuration), ...importedModules], 29 | providers: providers, 30 | }).compile() 31 | 32 | this.app = this.module.createNestApplication(new FastifyAdapter()) 33 | 34 | await this.app.init() 35 | await this.app.getHttpAdapter().getInstance().ready() 36 | 37 | return this 38 | } 39 | 40 | async reloadFixtures() { 41 | const connection = await this.app.get(Connection) 42 | await connection.synchronize(true) 43 | 44 | const loader = new Loader() 45 | 46 | loader.load(path.resolve(this.getFixturePath())) 47 | 48 | const fixtures = fixturesIterator(new Resolver().resolve(loader.fixtureConfigs)) 49 | const builder = new Builder(connection, new Parser()) 50 | 51 | for (const fixture of fixtures) { 52 | const entity = await builder.build(fixture) 53 | await connection.getRepository(entity.constructor.name).save(entity) 54 | } 55 | } 56 | 57 | async shutdownServer() { 58 | await this.app.close() 59 | } 60 | 61 | private getFixturePath() { 62 | const possibleFixturePaths = ["./apps/api/src/assets/fixtures", "./src/assets/fixtures", "./assets/fixtures"] 63 | for (const possibleFixturePath of possibleFixturePaths) { 64 | if (fs.existsSync(possibleFixturePath)) { 65 | return possibleFixturePath 66 | } 67 | } 68 | } 69 | 70 | private async createDatabaseIfNotExist(connection: Connection, databaseName: string) { 71 | await connection.query(`CREATE EXTENSION IF NOT EXISTS dblink; 72 | DO $$ 73 | BEGIN 74 | PERFORM dblink_exec('', 'CREATE DATABASE ${databaseName}'); 75 | EXCEPTION WHEN duplicate_database THEN RAISE NOTICE '%, skipping', SQLERRM USING ERRCODE = SQLSTATE; 76 | END 77 | $$;`) 78 | await connection.close() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /apps/webapp/src/redux/endpoints/todos-endpoints.ts: -------------------------------------------------------------------------------- 1 | import { api } from "../api" 2 | const injectedRtkApi = api.injectEndpoints({ 3 | endpoints: build => ({ 4 | getManyTodos: build.query({ 5 | query: queryArg => ({ 6 | url: `/api/todos`, 7 | params: { 8 | fields: queryArg.fields, 9 | s: queryArg.s, 10 | filter: queryArg.filter, 11 | or: queryArg.or, 12 | sort: queryArg.sort, 13 | join: queryArg.join, 14 | limit: queryArg.limit, 15 | offset: queryArg.offset, 16 | page: queryArg.page, 17 | cache: queryArg.cache, 18 | }, 19 | }), 20 | }), 21 | createOneTodo: build.mutation({ 22 | query: queryArg => ({ url: `/api/todos`, method: "POST", body: queryArg.todo }), 23 | }), 24 | updateOneTodo: build.mutation({ 25 | query: queryArg => ({ url: `/api/todos/${queryArg.id}`, method: "PATCH", body: queryArg.todo }), 26 | }), 27 | deleteOneTodo: build.mutation({ 28 | query: queryArg => ({ url: `/api/todos/${queryArg.id}`, method: "DELETE" }), 29 | }), 30 | }), 31 | overrideExisting: false, 32 | }) 33 | export { injectedRtkApi as todosApi } 34 | export type GetManyTodosApiResponse = /** status 200 Get many base response */ GetManyTodoResponseDto | Todo[] 35 | export type GetManyTodosApiArg = { 36 | /** Selects resource fields. Docs */ 37 | fields?: string[] 38 | /** Adds search condition. Docs */ 39 | s?: string 40 | /** Adds filter condition. Docs */ 41 | filter?: string[] 42 | /** Adds OR condition. Docs */ 43 | or?: string[] 44 | /** Adds sort by field. Docs */ 45 | sort?: string[] 46 | /** Adds relational resources. Docs */ 47 | join?: string[] 48 | /** Limit amount of resources. Docs */ 49 | limit?: number 50 | /** Offset amount of resources. Docs */ 51 | offset?: number 52 | /** Page portion of resources. Docs */ 53 | page?: number 54 | /** Reset cache (if was enabled). Docs */ 55 | cache?: number 56 | } 57 | export type CreateOneTodoApiResponse = /** status 201 Get create one base response */ Todo 58 | export type CreateOneTodoApiArg = { 59 | todo: Todo 60 | } 61 | export type UpdateOneTodoApiResponse = /** status 200 Response */ Todo 62 | export type UpdateOneTodoApiArg = { 63 | id: number 64 | todo: Todo 65 | } 66 | export type DeleteOneTodoApiResponse = unknown 67 | export type DeleteOneTodoApiArg = { 68 | id: number 69 | } 70 | export type Todo = { 71 | text: string 72 | id?: number 73 | createdAt?: string 74 | updatedAt?: string 75 | } 76 | export type GetManyTodoResponseDto = { 77 | data: Todo[] 78 | count: number 79 | total: number 80 | page: number 81 | pageCount: number 82 | } 83 | export const { useGetManyTodosQuery, useCreateOneTodoMutation, useUpdateOneTodoMutation, useDeleteOneTodoMutation } = injectedRtkApi 84 | -------------------------------------------------------------------------------- /.github/workflows/create-review-app.yml: -------------------------------------------------------------------------------- 1 | name: Create review app 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | create-review-app: 8 | if: ${{ !contains(github.event.pull_request.title, 'chore(deps)') }} 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | 13 | - name: Setup CI 14 | uses: ./.github/action-setup-ci 15 | with: 16 | github_token: ${{ github.token }} 17 | nx_cloud_token: ${{ secrets.NX_CLOUD_TOKEN }} 18 | 19 | - name: Install doctl 20 | uses: digitalocean/action-doctl@v2 21 | with: 22 | token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 23 | 24 | - name: Inject slug/short variables 25 | uses: rlespinasse/github-slug-action@v4 26 | 27 | - name: Create DigitalOcean resources 28 | run: node node_modules/zx/zx.mjs ./.github/workflows/create-digitalocean-review-app-resources.mjs 29 | env: 30 | PR_NUMBER: ${{ github.event.number }} 31 | DOMAIN_NAME: ${{ secrets.DOMAIN_NAME }} 32 | REPO_NAME: ${{ github.event.pull_request.head.repo.full_name }} 33 | DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 34 | 35 | - name: Build affected apps 36 | run: npm run affected:build -- --prod 37 | 38 | - name: Replace apiUrl in webapp 39 | run: node node_modules/zx/zx.mjs ./.github/workflows/replace-webapp-api-url-prod-build.mjs 40 | env: 41 | DROPLET_URL: ${{ env.DROPLET_URL }} 42 | 43 | - name: Create SSH key files 44 | run: | 45 | echo "$DIGITALOCEAN_PRIVATE_SSH_KEY" > id_rsa 46 | echo "$DIGITALOCEAN_PUBLIC_SSH_KEY" > id_rsa.pub 47 | env: 48 | DIGITALOCEAN_PRIVATE_SSH_KEY: ${{ secrets.DIGITALOCEAN_SSH_KEY }} 49 | DIGITALOCEAN_PUBLIC_SSH_KEY: ${{ secrets.DIGITALOCEAN_SSH_KEY_PUBLIC }} 50 | 51 | - name: Copy SSH keys 52 | uses: appleboy/scp-action@master 53 | with: 54 | host: ${{ env.DROPLET_HOST }} 55 | username: root 56 | key: ${{ secrets.DIGITALOCEAN_SSH_KEY }} 57 | timeout: 5m 58 | source: "id_rsa,id_rsa.pub" 59 | target: "~/.ssh" 60 | overwrite: true 61 | 62 | - name: Copy dist folder 63 | uses: garygrossgarten/github-action-scp@release 64 | with: 65 | local: dist 66 | remote: dist 67 | rmRemote: true 68 | host: ${{ env.DROPLET_HOST }} 69 | username: root 70 | privateKey: ${{ secrets.DIGITALOCEAN_SSH_KEY }} 71 | 72 | - name: Execute remote command 73 | uses: appleboy/ssh-action@master 74 | with: 75 | host: ${{ env.DROPLET_HOST }} 76 | username: root 77 | key: ${{ secrets.DIGITALOCEAN_SSH_KEY }} 78 | timeout: 15m 79 | script: | 80 | eval `ssh-agent -s` 81 | ssh-add ~/.ssh/id_rsa 82 | cd ~ 83 | ssh-keyscan github.com >> ~/.ssh/known_hosts 84 | rm -rf stator 85 | git clone git@github.com:chocolat-chaud-io/stator.git 86 | cd stator 87 | git checkout ${{ env.GITHUB_HEAD_REF_SLUG }} 88 | echo "DROPLET_PORT=${{ env.DROPLET_PORT }}" > .env 89 | docker-compose config 90 | cp ../dist ./dist -ar 91 | docker-compose up --build -d 92 | 93 | - name: Wait for review app to be ready 94 | run: node node_modules/zx/zx.mjs ./.github/workflows/wait-for-review-app-url-to-be-ready.mjs 95 | env: 96 | DROPLET_URL: ${{ env.DROPLET_URL }} 97 | 98 | - name: Find deployment comment 99 | uses: peter-evans/find-comment@v1 100 | id: pull-request-comment 101 | with: 102 | issue-number: ${{ github.event.number }} 103 | body-includes: Your review app has been 104 | 105 | - name: Delete deployment comment if exists 106 | if: ${{ !!steps.pull-request-comment.outputs.comment-id }} 107 | uses: jungwinter/comment@v1 108 | with: 109 | type: delete 110 | comment_id: ${{ steps.pull-request-comment.outputs.comment-id }} 111 | token: ${{ github.token }} 112 | 113 | - name: Create deployment comment 114 | uses: jungwinter/comment@v1 115 | with: 116 | type: create 117 | issue_number: ${{ github.event.number }} 118 | token: ${{ github.token }} 119 | body: ":rocket: Your review app has been [deployed](${{ env.DROPLET_URL }})" 120 | -------------------------------------------------------------------------------- /tools/getting-started/src/run-scripts.tsx: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | 3 | import { Box, Text, useApp } from "ink" 4 | import Spinner from "ink-spinner" 5 | import React, { useEffect, useState } from "react" 6 | 7 | import Error from "./error" 8 | import IsValidIcon from "./is-valid-icon" 9 | 10 | const util = require("util") 11 | const exec = util.promisify(require("child_process").exec) 12 | 13 | interface Props { 14 | projectName: string 15 | organizationName: string 16 | digitalOceanToken: string 17 | } 18 | 19 | const projectRootPath = path.join(__dirname, "../../../") 20 | 21 | const RunScripts: React.FC = props => { 22 | const { exit } = useApp() 23 | 24 | const [isRenamingProject, setIsRenamingProject] = useState(false) 25 | const [isRenamingProjectValid, setIsRenamingProjectValid] = useState(false) 26 | const [renameProjectOutput, setRenameProjectOutput] = useState("") 27 | 28 | const [isDeployingApplication, setIsDeployingApplication] = useState(false) 29 | const [isApplicationValid, setIsApplicationValid] = useState(false) 30 | 31 | const [errorMessage, setErrorMessage] = useState("") 32 | 33 | useEffect(() => { 34 | const asyncFn = async () => { 35 | setIsRenamingProject(true) 36 | 37 | const { stdout } = await exec( 38 | `npm --prefix ${projectRootPath} run rename-project -- --organization ${props.organizationName} --project ${props.projectName}` 39 | ) 40 | 41 | const validationText = "This is now YOUR project" 42 | const isValid = stdout.includes(validationText) 43 | if (!isValid) { 44 | setErrorMessage(stdout) 45 | } 46 | 47 | setIsRenamingProject(false) 48 | setIsRenamingProjectValid(isValid) 49 | setRenameProjectOutput(`${validationText}${stdout.split(validationText)[1]}`.trim()) 50 | } 51 | asyncFn() 52 | .then() 53 | .catch(err => { 54 | setErrorMessage(err.message) 55 | exit() 56 | }) 57 | }, []) 58 | 59 | useEffect(() => { 60 | if (renameProjectOutput) { 61 | if (props.digitalOceanToken) { 62 | const asyncFn = async () => { 63 | setIsDeployingApplication(true) 64 | 65 | const { stdout } = await exec(`doctl apps create --spec ${projectRootPath}.do/app.yaml`) 66 | 67 | const isValid = stdout.includes(props.projectName) 68 | if (!isValid) { 69 | setErrorMessage(stdout) 70 | } 71 | 72 | setIsDeployingApplication(false) 73 | setIsApplicationValid(isValid) 74 | } 75 | asyncFn() 76 | .then() 77 | .catch(err => { 78 | setErrorMessage(err.message) 79 | exit() 80 | }) 81 | } else { 82 | setIsApplicationValid(true) 83 | } 84 | } 85 | }, [renameProjectOutput]) 86 | 87 | useEffect(() => { 88 | if (isApplicationValid) { 89 | exit() 90 | } 91 | }, [isApplicationValid]) 92 | 93 | return ( 94 | <> 95 | 96 | 97 | 98 | 99 | {isRenamingProject && ( 100 | <> 101 | 102 | {" "} 103 | 104 | 105 | )} 106 | Renaming project 107 | 108 | 109 | 110 | 111 | {!!props.digitalOceanToken && ( 112 | 113 | 114 | {isDeployingApplication && ( 115 | <> 116 | 117 | {" "} 118 | 119 | 120 | )} 121 | Deploying application on DigitalOcean 122 | 123 | 124 | 125 | )} 126 | 127 | {isRenamingProjectValid && isApplicationValid && ( 128 | <> 129 | 130 | Start your stack: 131 | 132 | 133 | 134 | database:{" "} 135 | 136 | npm run postgres 137 | 138 | 139 | 140 | create database:{" "} 141 | 142 | npm run db:create 143 | 144 | 145 | 146 | api:{" "} 147 | 148 | npm start api 149 | 150 | 151 | 152 | webapp:{" "} 153 | 154 | npm start webapp 155 | 156 | 157 | 158 | )} 159 | 160 | 161 | {!!renameProjectOutput && isApplicationValid && {renameProjectOutput}} 162 | 163 | 164 | ) 165 | } 166 | 167 | export default RunScripts 168 | -------------------------------------------------------------------------------- /apps/webapp/src/pages/todos-page/todos-page.tsx: -------------------------------------------------------------------------------- 1 | import { Add, Delete, Done, Edit } from "@mui/icons-material" 2 | import { Card, CardContent, CircularProgress, List, ListItem, ListItemSecondaryAction, ListItemText, TextField } from "@mui/material" 3 | import { Todo } from "@stator/models" 4 | import React, { ChangeEvent, useEffect, useState } from "react" 5 | 6 | import { LoadingIconButton } from "../../loading-icon-button/loading-icon-button" 7 | import { 8 | useCreateOneTodoMutation, 9 | useDeleteOneTodoMutation, 10 | useGetManyTodosQuery, 11 | useUpdateOneTodoMutation, 12 | } from "../../redux/endpoints/todos-endpoints" 13 | import { useTodosPageStyles } from "./todos-page.styles" 14 | 15 | interface Props {} 16 | 17 | export const TodosPage: React.FC = () => { 18 | const classes = useTodosPageStyles() 19 | const { data, isLoading: isGetAllTodosLoading } = useGetManyTodosQuery({ sort: ["id,DESC"] }) 20 | const todos = (data as unknown as Todo[]) || [] 21 | const [createTodo, { isLoading: isCreatingTodo }] = useCreateOneTodoMutation() 22 | const [updateTodo, { isLoading: isUpdatingTodo }] = useUpdateOneTodoMutation() 23 | const [deleteTodo, { isLoading: isDeletingTodo }] = useDeleteOneTodoMutation() 24 | const [selectedTodo, setSelectedTodo] = useState() 25 | const [todoCreateText, setTodoCreateText] = useState("") 26 | const [todoEditTextMap, setTodoEditTextMap] = useState(new Map()) 27 | const [todoEditIdMap, setTodoEditIdMap] = useState(new Map()) 28 | 29 | useEffect(() => { 30 | setTodoEditTextMap(todos.reduce((container, todo) => ({ ...container, [todo.id]: todo.text }), new Map())) 31 | }, [todos]) 32 | 33 | const onTodoCreateChange = (event: ChangeEvent) => setTodoCreateText(event.target.value) 34 | 35 | const onTodoUpdateChange = (todo: Todo) => (event: ChangeEvent) => { 36 | return setTodoEditTextMap({ ...todoEditTextMap, [todo.id]: event.target.value }) 37 | } 38 | 39 | const onTodoCreate = async () => { 40 | const response = await createTodo({ todo: { text: todoCreateText } }) 41 | if ("data" in response) { 42 | setTodoCreateText("") 43 | } 44 | } 45 | 46 | const onTodoEditClick = async (todo: Todo) => { 47 | setSelectedTodo(todo) 48 | if (todoEditIdMap[todo.id]) { 49 | await updateTodo({ id: todo.id, todo: { text: todoEditTextMap[todo.id] } }) 50 | setTodoEditIdMap(todoEditIdMap => ({ 51 | ...todoEditIdMap, 52 | [todo.id]: false, 53 | })) 54 | } else { 55 | setTodoEditIdMap(todoEditIdMap => ({ 56 | ...todoEditIdMap, 57 | [todo.id]: true, 58 | })) 59 | } 60 | } 61 | 62 | const onTodoCreateKeyPress = () => async (event: React.KeyboardEvent) => { 63 | if (event.key === "Enter") { 64 | await onTodoCreate() 65 | } 66 | } 67 | 68 | const onTodoUpdateKeyPress = (todo: Todo) => async (event: React.KeyboardEvent) => { 69 | if (event.key === "Enter") { 70 | await onTodoEditClick(todo) 71 | } 72 | } 73 | 74 | return ( 75 | <> 76 | 77 | 78 | {!isGetAllTodosLoading && ( 79 | <> 80 | 87 | 88 | 89 | )} 90 | 91 | 92 | 93 | 94 | {isGetAllTodosLoading ? ( 95 | 96 | ) : ( 97 | 98 | {todos.map(todo => ( 99 | 100 | {todoEditIdMap[todo.id] && ( 101 | 110 | )} 111 | {!todoEditIdMap[todo.id] && } 112 | 113 | onTodoEditClick(todo)} 118 | /> 119 | { 124 | setSelectedTodo(todo) 125 | deleteTodo({ id: todo.id }) 126 | }} 127 | /> 128 | 129 | 130 | ))} 131 | 132 | )} 133 | 134 | 135 | 136 | ) 137 | } 138 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stator", 3 | "private": true, 4 | "license": "MIT", 5 | "scripts": { 6 | "affected": "nx affected", 7 | "affected:apps": "nx affected:apps", 8 | "affected:build": "nx affected:build", 9 | "affected:dep-graph": "nx affected:dep-graph", 10 | "affected:e2e": "nx affected:e2e", 11 | "affected:libs": "nx affected:libs", 12 | "affected:lint": "nx affected:lint", 13 | "affected:test": "nx affected:test", 14 | "build": "nx build", 15 | "commit": "cz", 16 | "dep-graph": "nx dep-graph", 17 | "docker:build:api": "docker build -t stator-api -f dockerfiles/api/Dockerfile .", 18 | "docker:build:webapp": "docker build -t stator-webapp -f dockerfiles/webapp/Dockerfile .", 19 | "e2e": "nx e2e", 20 | "format": "nx format:write", 21 | "format:check": "nx format:check", 22 | "format:prettier": "prettier --write \"./(apps|libs|tools)/**/*.(js|ts|tsx|json|yml)\"", 23 | "format:write": "nx format:write", 24 | "get-started": "npm i && npm run start --prefix tools/getting-started", 25 | "help": "nx help", 26 | "lint": "npm run affected:lint", 27 | "lint:file-folder-convention": "node cli/main.js enforce-file-folder-naming-convention", 28 | "lint:api-imports": "node cli/main.js enforce-valid-imports-api", 29 | "mongo": "run-script-os", 30 | "mongo:default": "sudo docker-compose -f ./apps/database/mongo/docker-compose.yml up", 31 | "mongo:win32": "docker-compose -f ./apps/database/mongo/docker-compose.yml up", 32 | "nx": "nx", 33 | "postgres": "run-script-os", 34 | "postgres:default": "sudo docker-compose -f ./apps/database/postgres/docker-compose.yml up", 35 | "postgres:win32": "docker-compose -f ./apps/database/postgres/docker-compose.yml up", 36 | "remap-api-url": "ts-node ./.do/remap-api-url.ts", 37 | "remap-redoc": "ts-node ./.do/remap-redoc.ts", 38 | "rename-project": "node cli/main.js rename-project", 39 | "start": "nx serve", 40 | "test": "nx test", 41 | "typeorm": "ts-node --project tsconfig.base.json ./node_modules/typeorm/cli.js --config apps/database/orm-config.ts", 42 | "db:create": "ts-node --project tsconfig.base.json ./node_modules/typeorm-extension/dist/cli/index.js db:create --config apps/database/orm-config.ts", 43 | "db:drop": "ts-node --project tsconfig.base.json ./node_modules/typeorm-extension/dist/cli/index.js db:drop --config apps/database/orm-config.ts", 44 | "update": "nx migrate latest", 45 | "workspace-schematic": "nx workspace-schematic", 46 | "workspace-generator": "nx workspace-generator", 47 | "generate-api-redux": "node node_modules/@rtk-query/codegen-openapi/lib/bin/cli.js apps/cli/src/open-api-config.ts && node cli/main.js generate-cache-key-file", 48 | "cli": "node cli/main.js", 49 | "stator-cli": "node cli/main.js" 50 | }, 51 | "husky": { 52 | "hooks": { 53 | "post-checkout": "branch-naming-check '^\\d+(-[a-z]+)+|master$'", 54 | "pre-commit": "branch-naming-check '^\\d+(-[a-z]+)+|master$' && npm run lint:file-folder-convention && npm run lint:api-imports", 55 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 56 | } 57 | }, 58 | "config": { 59 | "commitizen": { 60 | "path": "./node_modules/cz-conventional-changelog" 61 | } 62 | }, 63 | "dependencies": { 64 | "@emotion/react": "11.7.1", 65 | "@emotion/styled": "11.6.0", 66 | "@mui/icons-material": "5.2.5", 67 | "@mui/material": "5.2.7", 68 | "@mui/styles": "5.2.3", 69 | "@nestjs/axios": "0.0.4", 70 | "@nestjs/cli": "8.1.6", 71 | "@nestjs/common": "8.2.4", 72 | "@nestjs/config": "1.1.6", 73 | "@nestjs/core": "8.2.4", 74 | "@nestjs/platform-express": "8.2.4", 75 | "@nestjs/platform-fastify": "8.2.4", 76 | "@nestjs/serve-static": "2.2.2", 77 | "@nestjs/swagger": "5.1.5", 78 | "@nestjs/terminus": "8.0.3", 79 | "@nestjs/typeorm": "8.0.2", 80 | "@nestjsx/crud": "5.0.0-alpha.3", 81 | "@nestjsx/crud-request": "5.0.0-alpha.3", 82 | "@nestjsx/crud-typeorm": "5.0.0-alpha.3", 83 | "@nrwl/nx-cloud": "13.2.2", 84 | "@reduxjs/toolkit": "1.7.1", 85 | "axios": "0.24.0", 86 | "class-transformer": "0.5.1", 87 | "class-transformer-validator": "0.9.1", 88 | "class-validator": "0.13.2", 89 | "clsx": "1.1.1", 90 | "document-register-element": "1.14.10", 91 | "extra-watch-webpack-plugin": "1.0.3", 92 | "fastify-swagger": "4.13.0", 93 | "lodash": "4.17.21", 94 | "mongodb": "4.2.2", 95 | "nest-winston": "1.6.2", 96 | "nx": "13.9.6", 97 | "pg": "8.7.1", 98 | "react": "17.0.2", 99 | "react-dom": "17.0.2", 100 | "react-redux": "7.2.6", 101 | "react-router-dom": "6.2.1", 102 | "redoc": "2.0.0-rc.44", 103 | "rxjs": "7.5.1", 104 | "tslib": "2.0.0", 105 | "typeorm": "0.2.41", 106 | "winston": "3.3.3" 107 | }, 108 | "devDependencies": { 109 | "@ava/typescript": "3.0.1", 110 | "@babel/core": "7.16.7", 111 | "@babel/preset-env": "7.16.7", 112 | "@babel/preset-react": "7.16.7", 113 | "@babel/preset-typescript": "7.16.7", 114 | "@commitlint/cli": "16.0.1", 115 | "@commitlint/config-conventional": "16.0.0", 116 | "@innocells/branch-naming-check": "1.0.0", 117 | "@nestjs/schematics": "8.0.5", 118 | "@nestjs/testing": "8.2.4", 119 | "@nrwl/cli": "13.9.6", 120 | "@nrwl/cypress": "13.9.6", 121 | "@nrwl/eslint-plugin-nx": "13.9.6", 122 | "@nrwl/jest": "13.9.6", 123 | "@nrwl/linter": "13.9.6", 124 | "@nrwl/nest": "13.9.6", 125 | "@nrwl/node": "13.9.6", 126 | "@nrwl/react": "13.9.6", 127 | "@nrwl/web": "13.9.6", 128 | "@nrwl/workspace": "13.9.6", 129 | "@rtk-query/codegen-openapi": "1.0.0-alpha.1", 130 | "@semantic-release/changelog": "6.0.1", 131 | "@semantic-release/git": "10.0.1", 132 | "@sindresorhus/tsconfig": "2.0.0", 133 | "@testing-library/react": "12.1.2", 134 | "@types/ink-divider": "2.0.2", 135 | "@types/jest": "26.0.24", 136 | "@types/lodash": "4.14.190", 137 | "@types/node": "17.0.7", 138 | "@types/react": "17.0.38", 139 | "@types/react-dom": "17.0.11", 140 | "@types/react-router-dom": "5.3.2", 141 | "@types/supertest": "2.0.11", 142 | "@types/winston": "2.4.4", 143 | "@typescript-eslint/eslint-plugin": "5.8.1", 144 | "@typescript-eslint/parser": "5.8.1", 145 | "babel-jest": "26.6.1", 146 | "chalk": "5.0.0", 147 | "clipanion": "3.2.0-rc.4", 148 | "commitizen": "4.2.4", 149 | "cross-env": "7.0.3", 150 | "cypress": "9.5.3", 151 | "cz-conventional-changelog": "3.3.0", 152 | "dotenv": "10.0.0", 153 | "eslint": "8.28.0", 154 | "eslint-config-prettier": "8.3.0", 155 | "eslint-plugin-cypress": "2.12.1", 156 | "eslint-plugin-import": "2.25.4", 157 | "eslint-plugin-jsx-a11y": "6.5.1", 158 | "eslint-plugin-react": "7.28.0", 159 | "eslint-plugin-react-hooks": "4.3.0", 160 | "hook-shell-script-webpack-plugin": "0.1.4", 161 | "husky": "7.0.4", 162 | "import-sort-style-module": "6.0.0", 163 | "ink": "3.2.0", 164 | "ink-divider": "3.0.0", 165 | "ink-link": "2.0.0", 166 | "ink-spinner": "4.0.3", 167 | "ink-testing-library": "2.1.0", 168 | "ink-text-input": "4.0.2", 169 | "ink-use-stdout-dimensions": "1.0.5", 170 | "jest": "26.6.1", 171 | "prettier": "2.5.1", 172 | "prettier-plugin-import-sort": "0.0.7", 173 | "replace-in-file": "6.3.2", 174 | "run-script-os": "1.1.6", 175 | "semantic-release": "18.0.1", 176 | "supertest": "6.1.6", 177 | "ts-jest": "26.4.2", 178 | "ts-node": "10.4.0", 179 | "tslint": "6.1.3", 180 | "typeorm-extension": "1.0.2", 181 | "typeorm-fixtures-cli": "1.9.2", 182 | "typescript": "4.5.4", 183 | "zx": "4.2.0" 184 | }, 185 | "engines": { 186 | "node": ">=14.13.1 <15.0.0" 187 | }, 188 | "importSort": { 189 | ".js, .jsx, .ts, .tsx": { 190 | "style": "module", 191 | "parser": "typescript" 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /.github/workflows/create-digitalocean-review-app-resources.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zx 2 | 3 | import { sleep } from "zx"; 4 | 5 | const projectName = process.env.REPO_NAME.split("/")[1]; 6 | const firewallName = `${projectName}-review-apps`; 7 | const loadBalancerName = `${projectName}-review-apps`; 8 | const certificateName = `${projectName}-review-apps`; 9 | const dropletName = `pr-${process.env.PR_NUMBER}`; 10 | const dropletReviewAppTag = "review-app"; 11 | const subDomainName = `review-apps.${process.env.DOMAIN_NAME}`; 12 | const digitaloceanBaseUrl = "https://api.digitalocean.com/v2"; 13 | const headers = { Authorization: `Bearer ${process.env.DIGITALOCEAN_ACCESS_TOKEN}` }; 14 | 15 | validateSecrets(); 16 | const { dropletPort } = await getOrCreateDroplet(); 17 | const loadBalancer = await createOrGetLoadBalancer(); 18 | const reviewAppInboundRule = { protocol: "tcp", ports: dropletPort, sources: { load_balancer_uids: [loadBalancer.id] } }; 19 | await createDomainIfDoesntExist(loadBalancer); 20 | const certificateId = await getOrCreateCertificate(); 21 | await createDropletLoadBalancerForwardingRuleIfDoesntExist(loadBalancer, certificateId); 22 | const firewall = await getOrCreateFirewall(loadBalancer); 23 | await createFirewallRuleIfDoesntExist(firewall); 24 | 25 | export async function getLoadBalancer(tryUntilReady = false) { 26 | const loadBalancerResponse = await fetch(`${digitaloceanBaseUrl}/load_balancers`, { headers }); 27 | await checkResponseValidity(loadBalancerResponse, "Could not fetch load balancer list:"); 28 | 29 | const { load_balancers } = JSON.parse(await loadBalancerResponse.text()); 30 | const loadBalancer = load_balancers.find(loadBalancer => loadBalancer.name === loadBalancerName); 31 | if (!loadBalancer?.ip && tryUntilReady) { 32 | console.log("Waiting 10 seconds for load balancer to be available"); 33 | await sleep(10000); 34 | 35 | return await getLoadBalancer(tryUntilReady); 36 | } 37 | 38 | return loadBalancer ? loadBalancer : null; 39 | } 40 | 41 | async function createOrGetLoadBalancer() { 42 | let loadBalancer = await getLoadBalancer(); 43 | if (!loadBalancer) { 44 | const response = await fetch(`${digitaloceanBaseUrl}/load_balancers`, { 45 | method: "POST", 46 | headers, 47 | body: JSON.stringify({ 48 | name: loadBalancerName, 49 | tag: "review-app", 50 | region: "nyc1", 51 | health_check: { 52 | protocol: "http", 53 | port: 80, 54 | path: "/", 55 | check_interval_seconds: 10, 56 | response_timeout_seconds: 5, 57 | unhealthy_threshold: 5, 58 | healthy_threshold: 2 59 | }, 60 | forwarding_rules: [{ 61 | entry_protocol: "http", 62 | entry_port: 80, 63 | target_protocol: "http", 64 | target_port: 80 65 | }] 66 | }) 67 | }); 68 | await checkResponseValidity(response, "Could not create load balancer:"); 69 | 70 | console.log("Sleeping 30 seconds to ensure load balancer has an IP assigned"); 71 | await sleep(30000); 72 | loadBalancer = await getLoadBalancer(true); 73 | } 74 | return loadBalancer; 75 | } 76 | 77 | async function createDomainIfDoesntExist(loadBalancer) { 78 | const domainResponse = await fetch(`${digitaloceanBaseUrl}/domains/${process.env.DOMAIN_NAME}`, { headers }); 79 | if (domainResponse.status === 404) { 80 | await $`doctl compute domain create ${process.env.DOMAIN_NAME}`; 81 | await $`doctl compute domain records create ${process.env.DOMAIN_NAME} --record-type A --record-name review-apps --record-data ${loadBalancer.ip}`; 82 | } 83 | } 84 | 85 | async function getCertificateId(tryUntilReady = false) { 86 | const loadBalancerOutput = await $`doctl compute certificate list --format "ID, Name"`.pipe(nothrow($`grep ${certificateName}`)); 87 | if (!loadBalancerOutput.stdout && tryUntilReady) { 88 | console.log("Waiting 1 minute for certificate to be ready"); 89 | await sleep(60 * 1000); 90 | 91 | return await getCertificateId(tryUntilReady); 92 | } 93 | 94 | return !loadBalancerOutput.stdout ? null : loadBalancerOutput.stdout.split(" ")[0]; 95 | } 96 | 97 | async function getOrCreateCertificate() { 98 | let certificateId = await getCertificateId(); 99 | if (!certificateId) { 100 | await $`doctl compute certificate create --type lets_encrypt --name ${certificateName} --dns-names ${subDomainName}`; 101 | certificateId = await getCertificateId(true); 102 | } 103 | return certificateId; 104 | } 105 | 106 | async function createDropletLoadBalancerForwardingRuleIfDoesntExist(loadBalancer, certificateId) { 107 | const hasExistingForwardingRule = (await $`doctl compute load-balancer list`.pipe(nothrow($`grep port:${dropletPort}`))).stdout; 108 | if (!hasExistingForwardingRule) { 109 | await $`doctl compute load-balancer add-forwarding-rules ${loadBalancer.id} --forwarding-rules entry_protocol:https,entry_port:${dropletPort},target_protocol:http,target_port:${dropletPort},certificate_id:${certificateId},tls_passthrough:false`; 110 | } 111 | } 112 | 113 | async function getFirewallId() { 114 | const firewallOutput = await $`doctl compute firewall list --format "ID, Name"`.pipe(nothrow($`grep ${firewallName}`)); 115 | return !firewallOutput.stdout ? null : firewallOutput.stdout.split(" ")[0]; 116 | } 117 | 118 | async function getOrCreateFirewall(loadBalancer) { 119 | let firewallId = await getFirewallId(); 120 | if (!firewallId) { 121 | const response = await fetch(`${digitaloceanBaseUrl}/firewalls`, { 122 | method: "POST", 123 | headers, 124 | body: JSON.stringify({ 125 | name: firewallName, 126 | tags: [dropletReviewAppTag], 127 | inbound_rules: [ 128 | { protocol: "tcp", ports: 22, sources: { addresses: ["0.0.0.0/0", "::/0"] } }, 129 | { protocol: "tcp", ports: 80, sources: { load_balancer_uids: [loadBalancer.id] } }, 130 | reviewAppInboundRule 131 | ], 132 | outbound_rules: [ 133 | { protocol: "tcp", ports: 0, destinations: { addresses: ["0.0.0.0/0", "::/0"] } }, 134 | { protocol: "udp", ports: 0, destinations: { addresses: ["0.0.0.0/0", "::/0"] } }, 135 | { protocol: "icmp", ports: 0, destinations: { addresses: ["0.0.0.0/0", "::/0"] } } 136 | ] 137 | }) 138 | }); 139 | await checkResponseValidity(response, "Could not create firewall:"); 140 | firewallId = await getFirewallId(); 141 | } 142 | 143 | return await getFirewall(firewallId); 144 | } 145 | 146 | async function getFirewall(firewallId) { 147 | const firewallResponse = await fetch(`${digitaloceanBaseUrl}/firewalls/${firewallId}`, { 148 | method: "GET", 149 | headers: { 150 | Authorization: `Bearer ${process.env.DIGITALOCEAN_ACCESS_TOKEN}` 151 | } 152 | }); 153 | await checkResponseValidity(firewallResponse, "Could not fetch firewall:"); 154 | 155 | return JSON.parse(await firewallResponse.text()).firewall; 156 | } 157 | 158 | async function createFirewallRuleIfDoesntExist(firewall) { 159 | const hasExistingFirewallRule = firewall.inbound_rules.find(rule => rule.ports === dropletPort); 160 | if (!hasExistingFirewallRule) { 161 | const response = await fetch(`${digitaloceanBaseUrl}/firewalls/${firewall.id}/rules`, { 162 | method: "POST", 163 | headers: { 164 | Authorization: `Bearer ${process.env.DIGITALOCEAN_ACCESS_TOKEN}` 165 | }, 166 | body: JSON.stringify({ 167 | inbound_rules: [reviewAppInboundRule] 168 | }) 169 | }); 170 | await checkResponseValidity(response, "Could not create firewall rule:"); 171 | } 172 | } 173 | 174 | async function checkResponseValidity(response, message) { 175 | if (!response.ok) { 176 | console.error(message); 177 | console.error(await response.text()); 178 | process.exit(1); 179 | } 180 | } 181 | 182 | async function getDropletHost() { 183 | try { 184 | const { stdout: dropletIp } = await $`doctl compute droplet get ${dropletName} --format "Public IPv4"`.pipe(nothrow($`grep -v Public`)); 185 | return dropletIp.replace("\n", ""); 186 | } catch (error) { 187 | const hasLegitError = error.stderr && !error.stderr.includes("could not be found"); 188 | if (hasLegitError) { 189 | console.error(error); 190 | process.exit(error.exitCode); 191 | } else { 192 | return null; 193 | } 194 | } 195 | } 196 | 197 | async function getFreeDropletPort(hasExistingDroplet) { 198 | if (hasExistingDroplet) { 199 | const dropletInfo = (await $`doctl compute droplet get ${dropletName}`).toString(); 200 | return /port_\d+/m.exec(dropletInfo)[0].split("_")[1]; 201 | } 202 | 203 | const port = parseInt(Math.random() * (65534 - 1024) + 1024); 204 | const hasExistingPort = (await $`doctl compute droplet list`.pipe(nothrow($`grep port_${port}`))).stdout; 205 | if (hasExistingPort) { 206 | return await getFreeDropletPort(); 207 | } 208 | 209 | return port; 210 | } 211 | 212 | async function getOrCreateDroplet() { 213 | let dropletHost = await getDropletHost(); 214 | const dropletPort = await getFreeDropletPort(!!dropletHost); 215 | await $`echo "DROPLET_PORT=${dropletPort}" >> $GITHUB_ENV`; 216 | if (!dropletHost) { 217 | await $`doctl compute droplet create ${dropletName} --image docker-20-04 --size s-1vcpu-1gb --region nyc1 --ssh-keys 32900988 --tag-names review-app,port_${dropletPort} --enable-private-networking --wait`; 218 | console.log("Waiting 10 seconds to ensure the droplet is really ready because of this bug: https://github.com/digitalocean/doctl/issues/1003"); 219 | await sleep(10000); 220 | dropletHost = await getDropletHost(); 221 | } 222 | 223 | await $`echo "DROPLET_HOST=${dropletHost}" >> $GITHUB_ENV`; 224 | await $`echo "DROPLET_URL=https://${subDomainName}:${dropletPort}" >> $GITHUB_ENV`; 225 | 226 | return { dropletHost, dropletPort }; 227 | } 228 | 229 | function validateSecrets() { 230 | if (!process.env.DOMAIN_NAME) { 231 | console.error("You need to add your domain name as a secret with the following key 'DOMAIN_NAME'"); 232 | process.exit(1); 233 | } 234 | 235 | if (!process.env.DIGITALOCEAN_ACCESS_TOKEN) { 236 | console.error("You need to add your DigitalOcean access token as a secret with the following key 'DIGITALOCEAN_ACCESS_TOKEN'"); 237 | process.exit(1); 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /tools/getting-started/src/ui.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text, useStdin } from "ink" 2 | import Divider from "ink-divider" 3 | import Link from "ink-link" 4 | import useStdoutDimensions from "ink-use-stdout-dimensions" 5 | import React, { FC, useEffect, useState } from "react" 6 | 7 | import LabelValueInput from "./label-value-input" 8 | import RunScripts from "./run-scripts" 9 | import ValidateDependencies from "./validate-dependencies" 10 | 11 | export interface InputValue { 12 | value: string 13 | errorMessage: string 14 | isValid?: boolean 15 | } 16 | const initialInputValue: InputValue = { value: "", errorMessage: "" } 17 | 18 | const cloneInputValueWithoutError = (inputValue: InputValue) => ({ 19 | ...inputValue, 20 | errorMessage: "", 21 | isValid: undefined, 22 | }) 23 | const cloneInputValueWithError = (inputValue: InputValue, errorMessage: string) => ({ 24 | ...inputValue, 25 | errorMessage, 26 | isValid: false, 27 | }) 28 | 29 | const Ui: FC = () => { 30 | const { setRawMode } = useStdin() 31 | const [stdoutWidth] = useStdoutDimensions() 32 | const dividerWidth = stdoutWidth - 1 33 | 34 | const [isDockerInstalled, setIsDockerInstalled] = useState() 35 | 36 | const [organizationName, setOrganizationName] = useState(initialInputValue) 37 | const [projectName, setProjectName] = useState(initialInputValue) 38 | const [codecovToken, setCodecovToken] = useState(initialInputValue) 39 | const [nxCloudToken, setNxCloudToken] = useState(initialInputValue) 40 | const [digitalOceanToken, setDigitalOceanToken] = useState(initialInputValue) 41 | 42 | useEffect(() => { 43 | setRawMode(true) 44 | }, []) 45 | 46 | const onOrganizationNameSubmit = () => { 47 | setOrganizationName(value => cloneInputValueWithoutError(value)) 48 | 49 | const organizationNameRegex = /^[a-zA-Z-\d_]+$/ 50 | if (!organizationNameRegex.test(organizationName.value.trim())) { 51 | return setOrganizationName(value => 52 | cloneInputValueWithError(value, "The organization name can only contain letters, numbers and '-'") 53 | ) 54 | } 55 | 56 | setOrganizationName(value => ({ ...value, isValid: true })) 57 | } 58 | 59 | const onProjectNameSubmit = () => { 60 | setProjectName(value => cloneInputValueWithoutError(value)) 61 | 62 | const projectNameRegex = /^[a-zA-Z-\d_]+$/ 63 | if (!projectNameRegex.test(projectName.value.trim())) { 64 | return setProjectName(value => cloneInputValueWithError(value, "The project name can only contain letters, numbers and '-'")) 65 | } 66 | 67 | setProjectName(value => ({ ...value, isValid: true })) 68 | } 69 | 70 | const onCodecovSubmit = () => { 71 | setCodecovToken(value => cloneInputValueWithoutError(value)) 72 | 73 | const tokenRegex = /^[a-zA-Z\d-]{30,40}$/ 74 | if (codecovToken.value !== "" && !tokenRegex.test(codecovToken.value.trim())) { 75 | return setCodecovToken(value => cloneInputValueWithError(value, "The token you provided doesn't respect the Codecov token format")) 76 | } 77 | 78 | setCodecovToken(value => ({ ...value, isValid: true })) 79 | } 80 | 81 | const onNxCloudTokenSubmit = () => { 82 | setNxCloudToken(value => cloneInputValueWithoutError(value)) 83 | 84 | const tokenRegex = /^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/ 85 | if (nxCloudToken.value !== "" && !tokenRegex.test(nxCloudToken.value.trim())) { 86 | return setNxCloudToken(value => cloneInputValueWithError(value, "The token you provided doesn't respect the NX cloud token format")) 87 | } 88 | 89 | setNxCloudToken(value => ({ ...value, isValid: true })) 90 | } 91 | 92 | const onDigitalOceanTokenSubmit = () => { 93 | setDigitalOceanToken(value => cloneInputValueWithoutError(value)) 94 | 95 | const tokenRegex = /^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/ 96 | if (digitalOceanToken.value !== "" && !tokenRegex.test(digitalOceanToken.value.trim())) { 97 | return setDigitalOceanToken(value => 98 | cloneInputValueWithError(value, "The token you provided doesn't respect the DigitalOcean token format") 99 | ) 100 | } 101 | 102 | setDigitalOceanToken(value => ({ ...value, isValid: true })) 103 | } 104 | 105 | return ( 106 | <> 107 | 108 | 109 | {isDockerInstalled && ( 110 | <> 111 | setOrganizationName({ ...organizationName, value })} 115 | onSubmit={onOrganizationNameSubmit} 116 | /> 117 | 118 | {organizationName.isValid && ( 119 | setProjectName({ ...projectName, value })} 123 | onSubmit={onProjectNameSubmit} 124 | /> 125 | )} 126 | 127 | {projectName.isValid && ( 128 | <> 129 | 130 | 131 | 132 | 133 | 134 | 1. Log in on the Codecov website 135 | 136 | 137 | 2. Link your repository 138 | 139 | 3. Copy the token from the page shown after successfully linking your repository 140 | 4. Go to your Github repository → Click Settings → Click Secrets → Click New secret 141 | 5. Secret name is CODECOV_TOKEN and it's value is the accessToken we previously copied and click Add secret 142 | 6. Paste the token previously copied in the input below 143 | 144 | 145 | 146 | setCodecovToken({ ...codecovToken, value })} 151 | onSubmit={onCodecovSubmit} 152 | /> 153 | 154 | )} 155 | 156 | {codecovToken.isValid && ( 157 | <> 158 | 159 | 160 | 161 | 162 | 163 | 1. Navigate to NX website 164 | 165 | 2. Log in / Register 166 | 3. Click "Set up a workspace" 167 | 4. Click "No, I'm not using @nrwl/nx-cloud" 168 | 5. Copy the command provided and run it locally 169 | If you are using Windows, you will need to run the following command for the previous step to work. 170 | 171 | npm install -g nx 172 | 173 | 174 | 6. Copy the accessToken that was generated in nx.json (make sure you don't lose it as it is needed for the following steps) 175 | 176 | 7. [PUBLIC REPOSITORY ONLY] Replace the token in nx.json with nx_cloud_token 177 | 8. Paste the token on the NX website 178 | 9. Complete the set up 179 | 10. Go to your Github repository → Click Settings → Click Secrets → Click New secret 180 | 181 | 11. Secret name is NX_CLOUD_TOKEN and it's value is the accessToken we previously copied and click Add secret 182 | 183 | 12. Paste the accessToken previously copied in the input below 184 | 185 | 186 | 187 | setNxCloudToken({ ...nxCloudToken, value })} 192 | onSubmit={onNxCloudTokenSubmit} 193 | /> 194 | 195 | )} 196 | 197 | {nxCloudToken.isValid && ( 198 | <> 199 | 200 | 201 | 202 | 203 | 204 | 1. Navigate to Digital Ocean website [sponsored link] 205 | 206 | 2. Log in or create an account 207 | 208 | 3. Install doctl 209 | 210 | 4. Validate that your are properly authenticated by running "doctl account get" 211 | If successful, you will see your email 212 | 5. Generate a new SSH key by running "ssh-keygen -t rsa -b 4096 -f ~/.ssh/digitalocean-ci" 213 | This SSH key will be used to create review apps 214 | 215 | 6. Let's add the new key to DigitalOcean by running "public_key=$(cat ~/.ssh/digitalocean-ci.pub); doctl compute ssh-key 216 | create github-ci --public-key $public_key" 217 | 218 | {`7. Copy your private key to your clipboard by running "xclip -sel c < ~/.ssh/digitalocean-ci"`} 219 | 8. Go to your Github repository → Click Settings → Click Secrets → Click New secret 220 | 221 | 9. Secret name is DIGITALOCEAN_SSH_KEY and it's value is the private SSH key we just copied and click Add secret 222 | 223 | {`10. Copy your public key to your clipboard by running "xclip -sel c < ~/.ssh/digitalocean-ci.pub"`} 224 | 225 | 11. Secret name is DIGITALOCEAN_SSH_KEY_PUBLIC and it's value is the public SSH key we just copied and click Add secret 226 | 227 | 228 | 12. Make DigitalOcean your DNS record manager 229 | 230 | Follow the instruction by clicking the above link 231 | 232 | 13. Generate a new API token 233 | 234 | 14. Copy the API token 235 | 15. Go to your Github repository → Click Settings → Click Secrets → Click New secret 236 | 237 | 16. Secret name is DIGITALOCEAN_ACCESS_TOKEN and it's value is the API token we previously copied and click Add secret 238 | 239 | 17. Paste the API token you generated in the input below 240 | 241 | 242 | 243 | setDigitalOceanToken({ ...digitalOceanToken, value })} 248 | onSubmit={onDigitalOceanTokenSubmit} 249 | /> 250 | 251 | )} 252 | 253 | {digitalOceanToken.isValid && ( 254 | 259 | )} 260 | 261 | )} 262 | 263 | ) 264 | } 265 | 266 | module.exports = Ui 267 | export default Ui 268 | -------------------------------------------------------------------------------- /cli/main.js: -------------------------------------------------------------------------------- 1 | (()=>{var __webpack_modules__={857:(__unused_webpack_module,exports,__webpack_require__)=>{Object.defineProperty(exports,"__esModule",{value:!0}),exports.EnforceFileFolderNamingConvention=void 0;const path_1=(0,__webpack_require__(752).__importDefault)(__webpack_require__(17)),clipanion_1=__webpack_require__(638),utils_1=__webpack_require__(733);class EnforceFileFolderNamingConvention extends clipanion_1.Command{async execute(){const ignoredPaths=["node_modules","dist",".git",".idea",".gitkeep",".eslintrc",".cache","README","LICENSE","CONTRIBUTING","dockerfiles","Dockerfile"],capitalLetterRegex=/[A-Z]/gm,errorPathPaths=[];function validateEntryName(entry){const entryName=path_1.default.basename(entry).replace(/\.[^/.]+$/,"");entryName.length>0&&!ignoredPaths.includes(entryName)&&entryName.match(capitalLetterRegex)&&errorPathPaths.push(entry)}const folderNames=[];for await(const entry of(0,utils_1.walk)(path_1.default.join(__dirname,".."),ignoredPaths,folderNames))validateEntryName(entry);for(const folderName of folderNames)validateEntryName(folderName);if(errorPathPaths.length>0){const errorMessage=`${errorPathPaths.length} files/directories do not respect the kebab-case convention enforced.`;console.error(errorMessage),console.error(errorPathPaths),process.exit(1)}console.info("Congratulations, all your files and directories are properly named!")}}exports.EnforceFileFolderNamingConvention=EnforceFileFolderNamingConvention,EnforceFileFolderNamingConvention.paths=[["enforce-file-folder-naming-convention"]],EnforceFileFolderNamingConvention.usage=clipanion_1.Command.Usage({category:"enforcers",description:"This script will make sure that your folders and file use kebab-case.",examples:[["A basic example","npm run stator-cli generate-cache-key-file"]]})},351:(__unused_webpack_module,exports,__webpack_require__)=>{Object.defineProperty(exports,"__esModule",{value:!0}),exports.EnforceValidImportsApi=void 0;const tslib_1=__webpack_require__(752),fs_1=(0,tslib_1.__importDefault)(__webpack_require__(147)),path_1=(0,tslib_1.__importDefault)(__webpack_require__(17)),clipanion_1=__webpack_require__(638),utils_1=__webpack_require__(733);class EnforceValidImportsApi extends clipanion_1.Command{async execute(){const invalidImportRegex=/import .*stator\/[a-zA-Z]+\//gm,fileContainingInvalidImports=[];async function validateEntryName(entry){(await fs_1.default.promises.readFile(entry,{encoding:"utf-8"})).match(invalidImportRegex)&&fileContainingInvalidImports.push(entry)}for await(const entry of(0,utils_1.walk)(path_1.default.join(__dirname,"../apps/api/src"),[]))await validateEntryName(entry);if(fileContainingInvalidImports.length>0){const errorMessage=`${fileContainingInvalidImports.length} file(s) have invalid imports. They should NOT look like this: "@stator/models/something/entity"`;console.error(errorMessage),console.error(fileContainingInvalidImports),process.exit(1)}console.info("Congratulations, all your imports in api are valid!")}}exports.EnforceValidImportsApi=EnforceValidImportsApi,EnforceValidImportsApi.paths=[["enforce-valid-imports-api"]],EnforceValidImportsApi.usage=clipanion_1.Command.Usage({category:"enforcers",description:"This script will make sure that your imports are valid in the API. This is used to avoid import errors than can be hard to spot.",examples:[["A basic example","npm run stator-cli enforce-valid-imports-api"]]})},744:(__unused_webpack_module,exports,__webpack_require__)=>{Object.defineProperty(exports,"__esModule",{value:!0}),exports.GenerateCacheKeyFile=void 0;const tslib_1=__webpack_require__(752),fs_1=(0,tslib_1.__importDefault)(__webpack_require__(147)),path_1=(0,tslib_1.__importDefault)(__webpack_require__(17)),clipanion_1=__webpack_require__(638),camelCase_1=(0,tslib_1.__importDefault)(__webpack_require__(897)),capitalize_1=(0,tslib_1.__importDefault)(__webpack_require__(969)),kebabCase_1=(0,tslib_1.__importDefault)(__webpack_require__(546)),utils_1=__webpack_require__(733);class GenerateCacheKeyFile extends clipanion_1.Command{async execute(){const endpointsPath=path_1.default.join(__dirname,"../apps/webapp/src/redux/endpoints"),importStatements=[],cacheKeys=[];let cacheFileContent="/**\n * This file was automatically generated by tools/generators/generate-cache-file.js file\n */\n\nIMPORT_STATEMENTS\n\n";for await(const pathName of(0,utils_1.walk)(endpointsPath,[])){if(fs_1.default.lstatSync(pathName).isFile()&&pathName.includes("-endpoints")){const cacheKey=(0,camelCase_1.default)(path_1.default.basename(pathName,".ts").replace("-endpoints",""));cacheKeys.push(cacheKey);const endpointsSelectorRegex=/build => \(({[\s\S]+overrideExisting: false,\s+})/m,endpointSelectorRegex=/([a-z-A-Z]+): build.[qm]/gm,endpointNames=[...fs_1.default.readFileSync(pathName,{encoding:"utf8"}).match(endpointsSelectorRegex)[1].matchAll(endpointSelectorRegex)].map((entries=>[entries[1]])).flat();endpointNames.length>0&&(importStatements.push(`import { ${cacheKey}Api } from "./${(0,kebabCase_1.default)(cacheKey)}-endpoints"`),cacheFileContent+=`export const add${(0,capitalize_1.default)(cacheKey)}CacheKeys = () =>\n ${cacheKey}Api.enhanceEndpoints({\n endpoints: {\n${endpointNames.map((endpointName=>{const tagPropertyKey=endpointName.includes("get")?"providesTags":"invalidatesTags";return` ${endpointName}: { ${tagPropertyKey}: ["${cacheKey}"] },`})).join("\n")}\n },\n })\n`)}}cacheFileContent=cacheFileContent.replace("IMPORT_STATEMENTS",importStatements.map((importStatement=>importStatement)).join("\n")),cacheFileContent+=`export const addGeneratedCacheKeys = () => {\n ${cacheKeys.map((cacheKey=>`add${(0,capitalize_1.default)(cacheKey)}CacheKeys()`)).join("\n")}\n}\n`,fs_1.default.writeFileSync(`${endpointsPath}/generated-cache-keys.ts`,cacheFileContent,{encoding:"utf8"}),console.info(`Generated ${endpointsPath}/generated-cache-keys.ts`)}}exports.GenerateCacheKeyFile=GenerateCacheKeyFile,GenerateCacheKeyFile.paths=[["generate-cache-key-file"]],GenerateCacheKeyFile.usage=clipanion_1.Command.Usage({category:"generators",description:"This script will generate the required cache key files for your redux webapp.",examples:[["A basic example","npm run stator-cli generate-cache-key-file"]]})},999:(__unused_webpack_module,exports,__webpack_require__)=>{Object.defineProperty(exports,"__esModule",{value:!0}),exports.GenerateEntityIndexFile=void 0;const tslib_1=__webpack_require__(752),fs_1=(0,tslib_1.__importDefault)(__webpack_require__(147)),path_1=(0,tslib_1.__importDefault)(__webpack_require__(17)),utils_1=__webpack_require__(733),clipanion_1=__webpack_require__(638);class GenerateEntityIndexFile extends clipanion_1.Command{async execute(){const entityIndexLockFilePath=path_1.default.join(__dirname,"entity-index-hash.txt"),indexFilePath=path_1.default.join(__dirname,"../libs/models/src/index.ts"),filePathsByFolder={};for await(const entry of(0,utils_1.walk)(path_1.default.join(__dirname,"../libs/models/src/lib"),[])){const folder=entry.split("lib/")[1].split("/")[0];filePathsByFolder[folder]||(filePathsByFolder[folder]=[]),filePathsByFolder[folder].push(entry)}let indexFileContent="/**\n * This file was automatically generated by generate-entity-index.js file\n * You can disable the automatic generation by removing the prepare section of the workspace.json file under api section\n */\n\n";const sortedFolders=Object.entries(filePathsByFolder).sort().reduce(((container,[key,value])=>({...container,[key]:value})),{});for(const[folder,filePaths]of Object.entries(sortedFolders))indexFileContent+=`// ${folder}\n`,indexFileContent+=getExportLinesFromFilePaths(filePaths),indexFileContent+="\n";const entityIndexLockFileExists=fs_1.default.existsSync(entityIndexLockFilePath),existingEntityHash=parseInt(entityIndexLockFileExists?await fs_1.default.promises.readFile(entityIndexLockFilePath,{encoding:"utf8"}):""),currentHash=function(str){let i,chr,hash=0;for(i=0;i`export * from "./${filePath.split("src/")[1].replace(".ts","")}"\n`)).join("")}exports.GenerateEntityIndexFile=GenerateEntityIndexFile,GenerateEntityIndexFile.paths=[["generate-entity-index-file"]],GenerateEntityIndexFile.usage=clipanion_1.Command.Usage({category:"generators",description:"This script will generate index file for the model library.",examples:[["A basic example","npm run stator-cli generate-entity-index-file"]]})},28:(__unused_webpack_module,exports,__webpack_require__)=>{Object.defineProperty(exports,"__esModule",{value:!0}),exports.RenameProject=void 0;const tslib_1=__webpack_require__(752),fs_1=(0,tslib_1.__importDefault)(__webpack_require__(147)),path_1=(0,tslib_1.__importDefault)(__webpack_require__(17)),clipanion_1=__webpack_require__(638),lodash_1=__webpack_require__(517),utils_1=__webpack_require__(733);class RenameProject extends clipanion_1.Command{constructor(){super(...arguments),this.organization=clipanion_1.Option.String("--organization",{required:!0}),this.project=clipanion_1.Option.String("--project",{required:!0})}async execute(){await this.renameProject()}async renameProject(){try{/^[a-zA-Z-\d_]+$/gim.test(this.organization)||(console.error("The organization name must respect this regex /^[a-zA-Z-\\d_]+$/gmi"),process.exit(1));/^[a-zA-Z-\d_]+$/gim.test(this.project)||(console.error("The project name must respect this regex /^[a-zA-Z-\\d_]+$/gmi"),process.exit(1));const databaseName=this.project.replace(/-/g,"_"),databaseFiles=["docker-compose.yml","seed-data.js","init.sql","test.ts","orm-config.ts"],camelCaseProjectName=(0,lodash_1.camelCase)(this.project),ignoredFolders=["node_modules","dist",".git",".idea",".cache"];for await(const entry of(0,utils_1.walk)(path_1.default.join(__dirname,"../"),ignoredFolders)){if((await fs_1.default.promises.lstat(entry)).isFile()){const fileContent=await fs_1.default.promises.readFile(entry,"utf-8");if(fileContent){const isDatabaseFile=databaseFiles.some((databaseFile=>entry.includes(databaseFile))),replacedFileContent=fileContent.replace(/chocolat-chaud-io/gim,this.organization).replace(/stator/gim,isDatabaseFile?databaseName:camelCaseProjectName);await fs_1.default.promises.writeFile(entry,replacedFileContent,"utf-8")}}}console.info("This is now YOUR project provided generously by:\n\n███████ ████████  █████  ████████  ██████  ██████ \n██         ██    ██   ██    ██    ██    ██ ██   ██ \n███████  ██  ███████  ██  ██  ██ ██████  \n     ██  ██  ██   ██  ██  ██  ██ ██   ██ \n███████  ██  ██  ██  ██   ██████  ██  ██ \n                                        \n ")}catch(error){console.error(error)}}}exports.RenameProject=RenameProject,RenameProject.paths=[["rename-project"]],RenameProject.usage=clipanion_1.Command.Usage({category:"getting-started",description:"This script will rename all occurrences of stator and chocolat-chaud with your own names.",examples:[["A basic example","npm run stator-cli rename-project --organization chocolat-chaud-io --project stator"]]})},733:(__unused_webpack_module,exports,__webpack_require__)=>{Object.defineProperty(exports,"__esModule",{value:!0}),exports.walk=void 0;const tslib_1=__webpack_require__(752),fs_1=(0,tslib_1.__importDefault)(__webpack_require__(147)),path_1=(0,tslib_1.__importDefault)(__webpack_require__(17));exports.walk=async function*(dir,ignoredPaths,walkedFolderNames=[]){for await(const directoryEntry of await fs_1.default.promises.opendir(dir)){const entryPath=path_1.default.join(dir,directoryEntry.name);directoryEntry.isDirectory()&&!ignoredPaths.includes(directoryEntry.name)?(walkedFolderNames.push(entryPath),yield*(0,exports.walk)(entryPath,ignoredPaths,walkedFolderNames)):directoryEntry.isFile()&&(yield entryPath)}}},638:module=>{"use strict";module.exports=require("clipanion")},517:module=>{"use strict";module.exports=require("lodash")},897:module=>{"use strict";module.exports=require("lodash/camelCase")},969:module=>{"use strict";module.exports=require("lodash/capitalize")},546:module=>{"use strict";module.exports=require("lodash/kebabCase")},752:module=>{"use strict";module.exports=require("tslib")},147:module=>{"use strict";module.exports=require("fs")},17:module=>{"use strict";module.exports=require("path")}},__webpack_module_cache__={};function __webpack_require__(moduleId){var cachedModule=__webpack_module_cache__[moduleId];if(void 0!==cachedModule)return cachedModule.exports;var module=__webpack_module_cache__[moduleId]={exports:{}};return __webpack_modules__[moduleId](module,module.exports,__webpack_require__),module.exports}var __webpack_exports__={};(()=>{var exports=__webpack_exports__;Object.defineProperty(exports,"__esModule",{value:!0});const clipanion_1=__webpack_require__(638),enforce_file_folder_naming_convention_1=__webpack_require__(857),enforce_valid_imports_api_1=__webpack_require__(351),generate_cache_key_file_1=__webpack_require__(744),generate_entity_index_file_1=__webpack_require__(999),rename_project_1=__webpack_require__(28),[,,...args]=process.argv,cli=new clipanion_1.Cli({binaryLabel:"stator-cli",binaryName:"npm run stator-cli",binaryVersion:"1.0.0"});cli.register(rename_project_1.RenameProject),cli.register(generate_cache_key_file_1.GenerateCacheKeyFile),cli.register(generate_entity_index_file_1.GenerateEntityIndexFile),cli.register(enforce_valid_imports_api_1.EnforceValidImportsApi),cli.register(enforce_file_folder_naming_convention_1.EnforceFileFolderNamingConvention),cli.register(clipanion_1.Builtins.HelpCommand),cli.runExit(args).catch(console.error)})();var __webpack_export_target__=exports;for(var i in __webpack_exports__)__webpack_export_target__[i]=__webpack_exports__[i];__webpack_exports__.__esModule&&Object.defineProperty(__webpack_export_target__,"__esModule",{value:!0})})(); 2 | //# sourceMappingURL=main.js.map -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Stator

3 |
4 |
5 | Stator, your go-to template for the perfect stack. 6 |
7 |
8 | 9 | 44 | 45 |
46 | 47 | ## 🚀 Quick Start 48 | 49 | The interactive CLI will guide you to easily setup your project. 50 | 51 | ``` 52 | npm run get-started 53 | ``` 54 | 55 |
56 | 57 | ## 📋 Table of Contents 58 | 59 | - [About the Project](#-about-the-project) 60 | - [Demo Application](#-demo-application) 61 | - [Technical Stack](#technical-stack) 62 | - [Getting Started](#-getting-started) 63 | - [Prerequisites](#prerequisites) 64 | - [Copy the template](#copy-the-template) 65 | - [Make it yours](#make-it-yours) 66 | - [Run the application](#run-the-application) 67 | - [Continuous Integration](#continuous-integration) 68 | - [Deployment](#deployment) 69 | - [Digital Ocean App Platform](#digital-ocean-app-platform) 70 | - [Kubernetes](#kubernetes) 71 | - [Implementation](#%EF%B8%8F-implementation) 72 | - [Database](#database) 73 | - [Postgres](#postgres) 74 | - [Mongo](#mongo-not-recommended) 75 | - [Data seeding](#data-seeding) 76 | - [Backend](#backend) 77 | - [Frontend](#frontend) 78 | - [General](#general) 79 | 80 |
81 | 82 | ## 📚 About the Project 83 | 84 | Have you ever started a new project by yourself?
85 | If so, you probably know that it is tedious to set up all the necessary tools.
86 | Just like you, the part I enjoy the most is coding, not boilerplate. 87 | 88 | Say hi to stator, a full-stack [TypeScript](https://github.com/microsoft/TypeScript) template that enforces conventions, handles releases, automates deployments and much more! 89 | 90 | If you want more details about how this idea was implemented, I recommend reading the [series of blog articles](https://yann510.hashnode.dev/creating-the-modern-developer-stack-template-part-1-ckfl56axy02e85ds18pa26a6z) I wrote on the topic. 91 | 92 |
93 | 94 | ## 🦄 [Demo Application](https://www.stator.dev) 95 | 96 | This template includes a demo **todo application** that serves as an example of sound patterns. 97 | Of course, you won't be creating a todo application for your project, but you can use this as an example of useful patterns and learn how to use the technologies presented in this project. 98 | 99 | ![demo application](readme-assets/todo-demo.gif) 100 | 101 | ### Technical Stack 102 | 103 | For a detailed list of all those technologies, you can read this [blog article](https://yann510.hashnode.dev/stator-a-full-stack-template-releases-deployments-enforced-conventions-ckhmnyhr903us9ms1b20lgi3b). 104 | 105 | | Deployment | Database | Backend | Frontend | Testing | Conventions | 106 | | -------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | 107 | | [DigitalOcean App Platform](https://www.digitalocean.com/products/app-platform/) | [Postgres](https://github.com/postgres/postgres) | [Nest](https://github.com/nestjs/nest) | [React](https://github.com/facebook/react) | [jest](https://github.com/facebook/jest) | [commitlint](https://github.com/conventional-changelog/commitlint) | 108 | | [semantic-release](https://github.com/semantic-release/semantic-release) | [Mongo](https://github.com/mongodb/mongo) | [Fastify](https://github.com/fastify/fastify) | [React Router](https://github.com/ReactTraining/react-router) | [cypress](https://github.com/cypress-io/cypress) | [eslint](https://github.com/eslint/eslint) | 109 | | [docker-compose](https://github.com/docker/compose) | [TypeORM](https://github.com/typeorm/typeorm) | [Swagger](https://github.com/nestjs/swagger) | [Redux](https://github.com/reduxjs/redux) | | [prettier](https://github.com/prettier/prettier) | 110 | | | [NestJs CRUD](https://github.com/nestjsx/crud) | [ReDoc](https://github.com/Redocly/redoc) | [Redux Toolkit](https://github.com/reduxjs/redux-toolkit) | | | 111 | | | | | [Material UI](https://github.com/mui-org/material-ui) | | | 112 | 113 |
114 | 115 | ## 💥 Getting Started 116 | 117 | ### Prerequisites 118 | 119 | - [Docker Compose](https://docs.docker.com/compose/install/) 120 | - [node.js](https://nodejs.org/en/download/) v14.x 121 | 122 | ### Copy the template 123 | 124 | This repository is a repository template, which means you can use the `Use this template` button at the top to create your project based on this. 125 | 126 | ![use template button](readme-assets/use-template.png) 127 | 128 | \*Note: If you have an existing repository, this will require more work. I would recommend using the `use template button` and migrating your current code to the newly created projects. 129 | 130 | ### Make it yours 131 | 132 | You will now want to make this project yours by replacing all organization and project naming occurrences with your own names. 133 | Thankfully, we have a script just for that: 134 | 135 | ``` 136 | npm run rename-project -- --organization {YOUR_ORGANIZATION_NAME} --project {YOUR_PROJECT_NAME} 137 | ``` 138 | 139 | \*Note: I highly recommend that the project name is the same as your git repository. 140 | 141 | On completion, you will see the following message: 142 | 143 | ![project appropriation success](readme-assets/project-appropriation-success.png) 144 | 145 | ### Run the application 146 | 147 | First, install the dependencies: 148 | 149 | ``` 150 | npm i 151 | ``` 152 | 153 | Then, run the whole stack: 154 | 155 | ``` 156 | npm run postgres 157 | ``` 158 | 159 | ``` 160 | npm start api 161 | ``` 162 | 163 | ``` 164 | npm start webapp 165 | ``` 166 | 167 | Finally, why not test it: 168 | 169 | ``` 170 | npm test api && npm run e2e webapp-e2e 171 | ``` 172 | 173 | For a full list of available commands, consult the `package.json`. 174 | 175 | ### Continuous Integration 176 | 177 | This templates integrates Github Actions for its Continuous Integration. The existing workflows are under `.github/workflows`. 178 | Currently, the CI will ensure all your apps work properly, by building and testing. 179 | For your pull requests, it will create a review application which will basically host your whole stack on a VM. 180 | Once everything is ready a new comment will be added to your pull request with the deployment URL. 181 | When the PR is closed, your review app will be destroyed as it's purpose will have been served. 182 | It's sacrifice will be for the greater good and also your wallet. 183 | To have the CI working, you must: 184 | 185 | 1. (Optional) If you want review apps to work, you should follow the instruction provided by the `get-started` CLI. 186 | 2. (Optional) Link your repository with [Codecov](https://github.com/apps/codecov) by inserting your `CODECOV_TOKEN` in github secrets. 187 | 3. (Optional) Insert your [Nx Cloud](https://nx.app/) access token in github secrets under `NX_CLOUD_TOKEN`. This enables for caching and faster build times. 188 | 189 | ### Deployment 190 | 191 | The application can be deployed in two different ways, depending on your objectives. 192 | 193 | #### Digital Ocean App Platform 194 | 195 | For a simple and fast deployment, the new [App Platform](https://www.digitalocean.com/docs/app-platform/) from Digital Ocean makes it easy to work with monorepos. For our todo app, the config file lies under `.do/app.yaml`. There, you can change the configuration of the different apps being deployed. [The spec can be found here.](https://www.digitalocean.com/docs/app-platform/references/app-specification-reference/) 196 | 197 | To deploy this full stack application yourself, follow the steps below: 198 | 199 | 1. Create an account on [Digital Ocean Cloud](https://m.do.co/c/67f72eccb557) (this is a sponsored link) and enable Github access 200 | 1. Install [doctl CLI](https://www.digitalocean.com/docs/apis-clis/doctl/how-to/install/) 201 | 1. Run `doctl apps create --spec .do/app.yaml` 202 | 1. View the build, logs, and deployment url [here](https://cloud.digitalocean.com/apps) 203 | 204 | Once done, your app will be hooked to master branch commits as defined in the spec. Therefore, on merge, the application will update. To update the spec of the application, first get the application id with `doctl apps list`, then simply run `doctl apps update --spec .do/app.yaml`. 205 | 206 |
207 | 208 | ## ⚙️ Implementation 209 | 210 | ### Database 211 | 212 | #### Postgres 213 | 214 | There are 2 databases available, postgres and mongo. 215 | To ensure your developers don't get into any trouble while installing those, they are already pre-configured with `docker-compose.yml` files. 216 | 217 | **By default, the project uses postgres.** 218 | If this is what you want, you're good to go; everything will work out of the box. 219 | 220 | #### Migrations 221 | 222 | By default, the automatic synchronization is activated between your models and the database. 223 | This means that making changes on your models will be automatically reflected on your database schemas. 224 | If you would like to control your migrations manually, you can do so by setting `synchronize` to false in `orm-config.ts` file. 225 | 226 | Generate migration from your modified schemas: 227 | 228 | ``` 229 | npm run typeorm -- migration:generate -n {MIGRATION_NAME} 230 | ``` 231 | This will check the difference between models for your defined entities and your database schemas. 232 | If it finds changes, it will generate the appropriate migration scripts. 233 | 234 | Run all pending migrations: 235 | 236 | ``` 237 | npm run typeorm -- migration:run 238 | ``` 239 | 240 | To get all the information on migrations, consult [typeorm documentation](https://github.com/typeorm/typeorm/blob/master/docs/migrations.md). 241 | 242 | #### Mongo [NOT RECOMMENDED] 243 | 244 | If you would like to use mongodb, even though it is absolutely not recommended because it currently doesn't work well with [typeorm](https://github.com/typeorm/typeorm), you can still do that by updating the connection info under `./apps/api/src/config/configuration.ts`. 245 | You simply need to replace `type: "postgres"` with `type: "mongo"`. 246 | Make sure you run the mongo container using the command: `npm run mongo`. 247 | 248 | #### Data seeding 249 | 250 | If you want your database to be pre-populated with that, it is very easy to do so. 251 | For postgres add your `sql` statements to `apps/database/postgres/init.sql` file. 252 | For mongo add your mongo statements to `apps/database/mongo/mongo-entrypoint/seed-data.js` file. 253 | 254 | ### Backend 255 | 256 | We are using cutting edge technologies to ensure that you get the best development experience one could hope for. 257 | To communicate with the database, we make use of the great [typeorm](https://github.com/typeorm/typeorm). 258 | We use the code-first approach, which means defining your models will also represent your tables in your database. 259 | Here is an example: 260 | 261 | ```typescript 262 | import { Column, Entity } from "typeorm" 263 | import { RootEntity } from "./root.entity" 264 | import { MinLength } from "class-validator" 265 | 266 | @Entity() 267 | export class Todo extends RootEntity { 268 | @Column() 269 | @MinLength(5, { always: true }) 270 | text: string 271 | } 272 | ``` 273 | 274 | To serve your API requests, we make use of [nest](https://github.com/nestjs/nest) alongside with [fastify](https://github.com/fastify/fastify) to ensure blazing fast [performance](https://github.com/fastify/fastify#benchmarks). 275 | 276 | To reduce the boilerplate commonly found around creating a new entity, we are using the [nestjsx/crud](https://github.com/nestjsx/crud) plugin that will generate all necessary routes for CRUD operations. 277 | 278 | Here is an example from our todo app: 279 | 280 | ```typescript 281 | import { Controller } from "@nestjs/common" 282 | import { Crud, CrudController } from "@nestjsx/crud" 283 | import { Todo } from "@stator/models" 284 | 285 | import { TodosService } from "./todos.service" 286 | 287 | @Crud({ model: { type: Todo } }) 288 | @Controller("todos") 289 | export class TodosController implements CrudController { 290 | constructor(public service: TodosService) {} 291 | } 292 | ``` 293 | 294 | Of course, you're probably wondering if this actually works. 295 | To convince you, we have implemented integration tests that perform real requests using [supertest](https://github.com/visionmedia/supertest). 296 | 297 | **Can I view the generated endpoints?** Well, of course, you can! 298 | 299 | We now have generated [swagger documentation](https://github.com/fastify/fastify-swagger) that is viewable with the beautiful [redoc](https://github.com/Redocly/redoc). 300 | 301 | Once you navigate to [localhost:3333](http://localhost:3333), you will see this: 302 | 303 | ![redoc](readme-assets/redoc.png) 304 | 305 | ### Frontend 306 | 307 | For our webapp, we're using the very popular [react](https://github.com/facebook/react) alongside [redux-toolkit](https://github.com/reduxjs/redux-toolkit) and [react-router](https://github.com/ReactTraining/react-router). 308 | We highly recommend that you use [function components](https://reactjs.org/docs/components-and-props.html) as demonstrated in the example. 309 | 310 | To further reduce the boilerplate necessary you can generate hooks based on your API swagger by running `npm run generate-api-redux`. 311 | When you add new entities to your API, you should also add them in the output file property of the `tools/generators/open-api-config.ts` file. 312 | If you would like to avoid this, you can generate a single file by removing both properties [`outputFiles`, `filterEndpoints`] 313 | 314 | This script will generate the required [RTK Query](https://redux-toolkit.js.org/rtk-query/overview) code and caching keys so your data remains up to date while performing CRUD operations. 315 | 316 | For a complete example of CRUD operations, consult the `apps/webapp/src/pages/todos-page.tsx` file. 317 | 318 | In our example, we are using [material-ui](https://github.com/mui-org/material-ui), but you could replace that with any other framework. 319 | 320 | We also use [axios](https://github.com/axios/axios) to simplify our requests handling as it works very well with TypeScript. 321 | 322 | ### General 323 | 324 | We strongly believe that typing helps create a more robust program; thus, we use [TypeScript](https://github.com/microsoft/TypeScript). 325 | 326 | To facilitate and optimize the usage of the monorepo, we make use of [NX](https://github.com/nrwl/nx). 327 | 328 | [eslint](https://github.com/eslint/eslint) enforces excellent standards, and [prettier](https://github.com/prettier/prettier) helps you apply them. 329 | 330 | Commit messages must abide to those [guidelines](https://www.conventionalcommits.org/en/v1.0.0/). If you need help following them, simply run `npm run commit` and you will be prompted with an interactive menu. 331 | 332 | File and directory names are enforced by the custom-made `enforce-file-folder-naming-convention.ts`. 333 | 334 | Branch names are enforced before you even commit to ensure everyone adopts the same standard: `{issue-number}-{branch-work-title-kebab-case}`. 335 | 336 | For end-to-end testing, we use the notorious [cypress](https://github.com/cypress-io/cypress). 337 | 338 | We also have a pre-built CI toolkit for you that will build and run the tests. 339 | -------------------------------------------------------------------------------- /cli/main.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"main.js","mappings":"yMAAA,U,yBAAA,0CAEA,qCAEA,iCAEA,MAAaA,0CAA0C,YAAAC,QASrDC,gBACE,MAAMC,aAAe,CACnB,eACA,OACA,OACA,QACA,WACA,YACA,SACA,SACA,UACA,eACA,cACA,cAEIC,mBAAqB,UACrBC,eAAiB,GAEvB,SAASC,kBAAkBC,OACzB,MAAMC,UAAY,eAAKC,SAASF,OAAOG,QAAQ,YAAa,IACxDF,UAAUG,OAAS,IAAMR,aAAaS,SAASJ,YAAcA,UAAUK,MAAMT,qBAC/EC,eAAeS,KAAKP,OAIxB,MAAMQ,YAAc,GACpB,UAAW,MAAMR,SAAS,UAAAS,MAAK,eAAKC,KAAKC,UAAW,MAAOf,aAAcY,aACvET,kBAAkBC,OAGpB,IAAK,MAAMY,cAAcJ,YACvBT,kBAAkBa,YAGpB,GAAId,eAAeM,OAAS,EAAG,CAC7B,MAAMS,aAAe,GAAGf,eAAeM,8EAEvCU,QAAQC,MAAMF,cACdC,QAAQC,MAAMjB,gBAEdkB,QAAQC,KAAK,GAGfH,QAAQI,KAAK,wEApDjB,4EACS,kCAAAC,MAAQ,CAAC,CAAC,0CAEV,kCAAAC,MAAQ,YAAA1B,QAAQ2B,MAAM,CAC3BC,SAAU,YACVC,YAAa,wEACbC,SAAU,CAAC,CAAC,kBAAmB,kD,gMCZnC,2DACA,4DAEA,qCAEA,iCAEA,MAAaC,+BAA+B,YAAA/B,QAU1CC,gBACE,MAAM+B,mBAAqB,iCACrBC,6BAA+B,GAErChC,eAAeI,kBAAkBC,cACL,aAAG4B,SAASC,SAAS7B,MAAO,CAAE8B,SAAU,WACxCxB,MAAMoB,qBAE9BC,6BAA6BpB,KAAKP,OAItC,UAAW,MAAMA,SAAS,UAAAS,MAAK,eAAKC,KAAKC,UAAW,mBAAoB,UAChEZ,kBAAkBC,OAG1B,GAAI2B,6BAA6BvB,OAAS,EAAG,CAC3C,MAAMS,aAAe,GAAGc,6BAA6BvB,yGAErDU,QAAQC,MAAMF,cACdC,QAAQC,MAAMY,8BAEdX,QAAQC,KAAK,GAGfH,QAAQI,KAAK,wDAnCjB,sDACS,uBAAAC,MAAQ,CAAC,CAAC,8BAEV,uBAAAC,MAAQ,YAAA1B,QAAQ2B,MAAM,CAC3BC,SAAU,YACVC,YACE,mIACFC,SAAU,CAAC,CAAC,kBAAmB,oD,8LCdnC,2DACA,4DAEA,qCACA,kEACA,mEACA,kEAEA,iCAEA,MAAaO,6BAA6B,YAAArC,QASxCC,gBACE,MAAMqC,cAAgB,eAAKtB,KAAKC,UAAW,sCACrCsB,iBAAmB,GACnBC,UAAY,GAClB,IAAIC,iBAAmB,8HAQvB,UAAW,MAAMC,YAAY,UAAA3B,MAAKuB,cAAe,IAAK,CAEpD,GADwB,aAAGK,UAAUD,UAAUE,UAAYF,SAAS/B,SAAS,cACxD,CACnB,MAAMkC,UAAW,uBAAU,eAAKrC,SAASkC,SAAU,OAAOjC,QAAQ,aAAc,KAChF+B,UAAU3B,KAAKgC,UACf,MAAMC,uBAAyB,qDAEzBC,sBAAwB,6BACxBC,cAAgB,IAFQ,aAAGC,aAAaP,SAAU,CAAEN,SAAU,SAAUxB,MAAMkC,wBAAwB,GAE5DI,SAASH,wBAAwBI,KAAIC,SAAW,CAACA,QAAQ,MAAKC,OAE1GL,cAActC,OAAS,IACzB6B,iBAAiB1B,KAAK,YAAYgC,0BAAyB,uBAAUA,wBACrEJ,kBAAoB,oBAAmB,wBAAWI,iCACxDA,qDAEFG,cACCG,KAAIG,eACH,MAAMC,eAAiBD,aAAa3C,SAAS,OAAS,eAAiB,kBACvE,MAAO,SAAS2C,mBAAmBC,qBAAqBV,mBAEzD7B,KAAK,0BAOJyB,iBAAmBA,iBAAiBhC,QAAQ,oBAAqB8B,iBAAiBY,KAAIK,iBAAmBA,kBAAiBxC,KAAK,OAC/HyB,kBAAoB,mDACpBD,UAAUW,KAAIN,UAAY,OAAM,wBAAWA,yBAAwB7B,KAAK,aAGxE,aAAGyC,cAAc,GAAGnB,wCAAyCG,iBAAkB,CAAEL,SAAU,SAC3FhB,QAAQI,KAAK,aAAac,0CAtD9B,kDACS,qBAAAb,MAAQ,CAAC,CAAC,4BAEV,qBAAAC,MAAQ,YAAA1B,QAAQ2B,MAAM,CAC3BC,SAAU,aACVC,YAAa,gFACbC,SAAU,CAAC,CAAC,kBAAmB,kD,iMChBnC,2DACA,4DACA,iCACA,qCAEA,MAAa4B,gCAAgC,YAAA1D,QAS3CC,gBACE,MAAM0D,wBAA0B,eAAK3C,KAAKC,UAAW,yBAC/C2C,cAAgB,eAAK5C,KAAKC,UAAW,+BACrC4C,kBAAoB,GAE1B,UAAW,MAAMvD,SAAS,UAAAS,MAAK,eAAKC,KAAKC,UAAW,0BAA2B,IAAK,CAClF,MAAM6C,OAASxD,MAAMyD,MAAM,QAAQ,GAAGA,MAAM,KAAK,GAE5CF,kBAAkBC,UACrBD,kBAAkBC,QAAU,IAE9BD,kBAAkBC,QAAQjD,KAAKP,OAGjC,IAAI0D,iBAAmB,oNAKvB,MAAMC,cAAgBC,OAAOd,QAAQS,mBAClCM,OACAC,QAAO,CAACC,WAAYC,IAAKC,UAAW,IAAMF,UAAW,CAACC,KAAMC,SAAU,IACzE,IAAK,MAAOT,OAAQU,aAAcN,OAAOd,QAAQa,eAC/CD,kBAAoB,MAAMF,WAC1BE,kBAAoBS,4BAA4BD,WAChDR,kBAAoB,KAGtB,MAAMU,0BAA4B,aAAGC,WAAWhB,yBAC1CiB,mBAAqBC,SACzBH,gCAAkC,aAAGxC,SAASC,SAASwB,wBAAyB,CAAEvB,SAAU,SAAY,IAEpG0C,YAUV,SAAkBC,KAChB,IACIC,EACAC,IAFAC,KAAO,EAIX,IAAKF,EAAI,EAAGA,EAAID,IAAIrE,OAAQsE,IAC1BC,IAAMF,IAAII,WAAWH,GACrBE,MAAQA,MAAQ,GAAKA,KAAOD,IAC5BC,MAAQ,EAEV,OAAOA,KApBeE,CAASpB,kBACzBY,qBAAuBE,oBACnB,aAAG5C,SAASmD,UAAU1B,wBAAyBmB,YAAYQ,WAAY,CAAElD,SAAU,eACnF,aAAGF,SAASmD,UAAUzB,cAAeI,iBAAkB,CAAE5B,SAAU,SAEzEhB,QAAQI,KAAK,oDAkBnB,SAASiD,4BAA4BD,WACnC,OAAOA,UACJL,OACAhB,KAAIoC,UAGI,oBAFkBA,SAASxB,MAAM,QAAQ,GAAGtD,QAAQ,MAAO,WAInEO,KAAK,IAxEV,wDACS,wBAAAS,MAAQ,CAAC,CAAC,+BAEV,wBAAAC,MAAQ,YAAA1B,QAAQ2B,MAAM,CAC3BC,SAAU,aACVC,YAAa,8DACbC,SAAU,CAAC,CAAC,kBAAmB,qD,sLCXnC,2DACA,4DAEA,qCACA,kCAEA,iCAEA,MAAa0D,sBAAsB,YAAAxF,QAAnC,c,oBAEE,KAAAyF,aAAe,YAAAC,OAAOC,OAAO,iBAAkB,CAAEC,UAAU,IAC3D,KAAAC,QAAU,YAAAH,OAAOC,OAAO,YAAa,CAAEC,UAAU,IAQjD3F,sBACQ6F,KAAKC,gBAGb9F,sBACE,IAC4B,qBACH+F,KAAKF,KAAKL,gBAC/BrE,QAAQC,MAAM,uEACdC,QAAQC,KAAK,IAGM,qBACHyE,KAAKF,KAAKD,WAC1BzE,QAAQC,MAAM,kEACdC,QAAQC,KAAK,IAEf,MAAM0E,aAAeH,KAAKD,QAAQpF,QAAQ,KAAM,KAC1CyF,cAAgB,CAAC,qBAAsB,eAAgB,WAAY,UAAW,iBAE9EC,sBAAuB,WAAAC,WAAUN,KAAKD,SAEtCQ,eAAiB,CAAC,eAAgB,OAAQ,OAAQ,QAAS,UACjE,UAAW,MAAM/F,SAAS,UAAAS,MAAK,eAAKC,KAAKC,UAAW,OAAQoF,gBAAiB,CAE3E,UADwB,aAAGnE,SAASoE,MAAMhG,QAC5BsC,SAAU,CACtB,MAAM2D,kBAAoB,aAAGrE,SAASC,SAAS7B,MAAO,SACtD,GAAIiG,YAAa,CACf,MAAMC,eAAiBN,cAAcO,MAAKC,cAAgBpG,MAAMK,SAAS+F,gBACnEC,oBAAsBJ,YACzB9F,QAAQ,uBAAwBqF,KAAKL,cACrChF,QAAQ,YAAa+F,eAAiBP,aAAeE,4BAClD,aAAGjE,SAASmD,UAAU/E,MAAOqG,oBAAqB,WAK9DvF,QAAQI,KAAK,yXASb,MAAOH,OACPD,QAAQC,MAAMA,SA1DpB,oCACS,cAAAI,MAAQ,CAAC,CAAC,mBAIV,cAAAC,MAAQ,YAAA1B,QAAQ2B,MAAM,CAC3BC,SAAU,kBACVC,YAAa,4FACbC,SAAU,CAAC,CAAC,kBAAmB,2F,8KChBnC,2DACA,4DAEa,QAAAf,KAAOd,gBAAiB2G,IAAa1G,aAA6B2G,kBAA8B,IAC3G,UAAW,MAAMC,wBAAwB,aAAG5E,SAAS6E,QAAQH,KAAM,CACjE,MAAMI,UAAY,eAAKhG,KAAK4F,IAAKE,eAAeG,MAC5CH,eAAeI,gBAAkBhH,aAAaS,SAASmG,eAAeG,OACxEJ,kBAAkBhG,KAAKmG,kBAChB,UAAAjG,MAAKiG,UAAW9G,aAAc2G,oBAC5BC,eAAelE,iBAClBoE,c,0BCVZG,OAAOC,QAAUC,QAAQ,c,0BCAzBF,OAAOC,QAAUC,QAAQ,W,0BCAzBF,OAAOC,QAAUC,QAAQ,qB,0BCAzBF,OAAOC,QAAUC,QAAQ,sB,0BCAzBF,OAAOC,QAAUC,QAAQ,qB,0BCAzBF,OAAOC,QAAUC,QAAQ,U,0BCAzBF,OAAOC,QAAUC,QAAQ,O,yBCAzBF,OAAOC,QAAUC,QAAQ,UCCrBC,yBAA2B,GAG/B,SAASC,oBAAoBC,UAE5B,IAAIC,aAAeH,yBAAyBE,UAC5C,QAAqBE,IAAjBD,aACH,OAAOA,aAAaL,QAGrB,IAAID,OAASG,yBAAyBE,UAAY,CAGjDJ,QAAS,IAOV,OAHAO,oBAAoBH,UAAUL,OAAQA,OAAOC,QAASG,qBAG/CJ,OAAOC,Q,wHCrBf,2CAEA,iEACA,qDACA,mDACA,sDACA,0CAEO,CAAE,IAAKQ,MAAQtG,QAAQuG,KAExBC,IAAM,IAAI,YAAAC,IAAI,CAClBC,YAAa,aACbC,WAAY,qBACZC,cAAe,UAGjBJ,IAAIK,SAAS,iBAAA3C,eACbsC,IAAIK,SAAS,0BAAA9F,sBACbyF,IAAIK,SAAS,6BAAAzE,yBACboE,IAAIK,SAAS,4BAAApG,wBACb+F,IAAIK,SAAS,wCAAApI,mCACb+H,IAAIK,SAAS,YAAAC,SAASC,aACtBP,IAAIQ,QAAQV,MAAMW,MAAMnH,QAAQC,Q","sources":["webpack://stator/./apps/cli/src/commands/enforce-file-folder-naming-convention.ts","webpack://stator/./apps/cli/src/commands/enforce-valid-imports-api.ts","webpack://stator/./apps/cli/src/commands/generate-cache-key-file.ts","webpack://stator/./apps/cli/src/commands/generate-entity-index-file.ts","webpack://stator/./apps/cli/src/commands/rename-project.ts","webpack://stator/./apps/cli/src/utils.ts","webpack://stator/external commonjs \"clipanion\"","webpack://stator/external commonjs \"lodash\"","webpack://stator/external commonjs \"lodash/camelCase\"","webpack://stator/external commonjs \"lodash/capitalize\"","webpack://stator/external commonjs \"lodash/kebabCase\"","webpack://stator/external commonjs \"tslib\"","webpack://stator/external node-commonjs \"fs\"","webpack://stator/external node-commonjs \"path\"","webpack://stator/webpack/bootstrap","webpack://stator/./apps/cli/src/main.ts"],"sourcesContent":["import path from \"path\"\n\nimport { Command } from \"clipanion\"\n\nimport { walk } from \"../utils\"\n\nexport class EnforceFileFolderNamingConvention extends Command {\n static paths = [[\"enforce-file-folder-naming-convention\"]]\n\n static usage = Command.Usage({\n category: \"enforcers\",\n description: \"This script will make sure that your folders and file use kebab-case.\",\n examples: [[\"A basic example\", \"npm run stator-cli generate-cache-key-file\"]],\n })\n\n async execute(): Promise {\n const ignoredPaths = [\n \"node_modules\",\n \"dist\",\n \".git\",\n \".idea\",\n \".gitkeep\",\n \".eslintrc\",\n \".cache\",\n \"README\",\n \"LICENSE\",\n \"CONTRIBUTING\",\n \"dockerfiles\",\n \"Dockerfile\",\n ]\n const capitalLetterRegex = /[A-Z]/gm\n const errorPathPaths = []\n\n function validateEntryName(entry) {\n const entryName = path.basename(entry).replace(/\\.[^/.]+$/, \"\")\n if (entryName.length > 0 && !ignoredPaths.includes(entryName) && entryName.match(capitalLetterRegex)) {\n errorPathPaths.push(entry)\n }\n }\n\n const folderNames = []\n for await (const entry of walk(path.join(__dirname, \"..\"), ignoredPaths, folderNames)) {\n validateEntryName(entry)\n }\n\n for (const folderName of folderNames) {\n validateEntryName(folderName)\n }\n\n if (errorPathPaths.length > 0) {\n const errorMessage = `${errorPathPaths.length} files/directories do not respect the kebab-case convention enforced.`\n\n console.error(errorMessage)\n console.error(errorPathPaths)\n\n process.exit(1)\n }\n\n console.info(\"Congratulations, all your files and directories are properly named!\")\n }\n}\n","import fs from \"fs\"\nimport path from \"path\"\n\nimport { Command } from \"clipanion\"\n\nimport { walk } from \"../utils\"\n\nexport class EnforceValidImportsApi extends Command {\n static paths = [[\"enforce-valid-imports-api\"]]\n\n static usage = Command.Usage({\n category: \"enforcers\",\n description:\n \"This script will make sure that your imports are valid in the API. This is used to avoid import errors than can be hard to spot.\",\n examples: [[\"A basic example\", \"npm run stator-cli enforce-valid-imports-api\"]],\n })\n\n async execute(): Promise {\n const invalidImportRegex = /import .*stator\\/[a-zA-Z]+\\//gm\n const fileContainingInvalidImports = []\n\n async function validateEntryName(entry) {\n const fileContent = await fs.promises.readFile(entry, { encoding: \"utf-8\" })\n const match = fileContent.match(invalidImportRegex)\n if (match) {\n fileContainingInvalidImports.push(entry)\n }\n }\n\n for await (const entry of walk(path.join(__dirname, \"../apps/api/src\"), [])) {\n await validateEntryName(entry)\n }\n\n if (fileContainingInvalidImports.length > 0) {\n const errorMessage = `${fileContainingInvalidImports.length} file(s) have invalid imports. They should NOT look like this: \"@stator/models/something/entity\"`\n\n console.error(errorMessage)\n console.error(fileContainingInvalidImports)\n\n process.exit(1)\n }\n\n console.info(\"Congratulations, all your imports in api are valid!\")\n }\n}\n","import fs from \"fs\"\nimport path from \"path\"\n\nimport { Command } from \"clipanion\"\nimport camelCase from \"lodash/camelCase\"\nimport capitalize from \"lodash/capitalize\"\nimport kebabCase from \"lodash/kebabCase\"\n\nimport { walk } from \"../utils\"\n\nexport class GenerateCacheKeyFile extends Command {\n static paths = [[\"generate-cache-key-file\"]]\n\n static usage = Command.Usage({\n category: \"generators\",\n description: \"This script will generate the required cache key files for your redux webapp.\",\n examples: [[\"A basic example\", \"npm run stator-cli generate-cache-key-file\"]],\n })\n\n async execute(): Promise {\n const endpointsPath = path.join(__dirname, \"../apps/webapp/src/redux/endpoints\")\n const importStatements = []\n const cacheKeys = []\n let cacheFileContent = `/**\n * This file was automatically generated by tools/generators/generate-cache-file.js file\n */\n\nIMPORT_STATEMENTS\n\n`\n\n for await (const pathName of walk(endpointsPath, [])) {\n const isEndpointsFile = fs.lstatSync(pathName).isFile() && pathName.includes(\"-endpoints\")\n if (isEndpointsFile) {\n const cacheKey = camelCase(path.basename(pathName, \".ts\").replace(\"-endpoints\", \"\"))\n cacheKeys.push(cacheKey)\n const endpointsSelectorRegex = /build => \\(({[\\s\\S]+overrideExisting: false,\\s+})/m\n const endpointsObjectString = fs.readFileSync(pathName, { encoding: \"utf8\" }).match(endpointsSelectorRegex)[1]\n const endpointSelectorRegex = /([a-z-A-Z]+): build.[qm]/gm\n const endpointNames = [...endpointsObjectString.matchAll(endpointSelectorRegex)].map(entries => [entries[1]]).flat()\n\n if (endpointNames.length > 0) {\n importStatements.push(`import { ${cacheKey}Api } from \"./${kebabCase(cacheKey)}-endpoints\"`)\n cacheFileContent += `export const add${capitalize(cacheKey)}CacheKeys = () =>\n ${cacheKey}Api.enhanceEndpoints({\n endpoints: {\n${endpointNames\n .map(endpointName => {\n const tagPropertyKey = endpointName.includes(\"get\") ? \"providesTags\" : \"invalidatesTags\"\n return ` ${endpointName}: { ${tagPropertyKey}: [\"${cacheKey}\"] },`\n })\n .join(\"\\n\")}\n },\n })\\n`\n }\n }\n }\n\n cacheFileContent = cacheFileContent.replace(\"IMPORT_STATEMENTS\", importStatements.map(importStatement => importStatement).join(\"\\n\"))\n cacheFileContent += `export const addGeneratedCacheKeys = () => {\n ${cacheKeys.map(cacheKey => `add${capitalize(cacheKey)}CacheKeys()`).join(\"\\n\")}\n}\\n`\n\n fs.writeFileSync(`${endpointsPath}/generated-cache-keys.ts`, cacheFileContent, { encoding: \"utf8\" })\n console.info(`Generated ${endpointsPath}/generated-cache-keys.ts`)\n }\n}\n","import fs from \"fs\";\nimport path from \"path\";\nimport { walk } from \"../utils\";\nimport { Command } from \"clipanion\";\n\nexport class GenerateEntityIndexFile extends Command {\n static paths = [[\"generate-entity-index-file\"]]\n\n static usage = Command.Usage({\n category: \"generators\",\n description: \"This script will generate index file for the model library.\",\n examples: [[\"A basic example\", \"npm run stator-cli generate-entity-index-file\"]],\n })\n\n async execute(): Promise {\n const entityIndexLockFilePath = path.join(__dirname, \"entity-index-hash.txt\")\n const indexFilePath = path.join(__dirname, \"../libs/models/src/index.ts\")\n const filePathsByFolder = {}\n\n for await (const entry of walk(path.join(__dirname, \"../libs/models/src/lib\"), [])) {\n const folder = entry.split(\"lib/\")[1].split(\"/\")[0]\n\n if (!filePathsByFolder[folder]) {\n filePathsByFolder[folder] = []\n }\n filePathsByFolder[folder].push(entry)\n }\n\n let indexFileContent = `/**\n * This file was automatically generated by generate-entity-index.js file\n * You can disable the automatic generation by removing the prepare section of the workspace.json file under api section\n */\\n\\n`\n\n const sortedFolders = Object.entries(filePathsByFolder)\n .sort()\n .reduce((container, [key, value]) => ({ ...container, [key]: value }), {})\n for (const [folder, filePaths] of Object.entries(sortedFolders)) {\n indexFileContent += `// ${folder}\\n`\n indexFileContent += getExportLinesFromFilePaths(filePaths)\n indexFileContent += \"\\n\"\n }\n\n const entityIndexLockFileExists = fs.existsSync(entityIndexLockFilePath)\n const existingEntityHash = parseInt(\n entityIndexLockFileExists ? await fs.promises.readFile(entityIndexLockFilePath, { encoding: \"utf8\" }) : \"\"\n )\n const currentHash = hashCode(indexFileContent)\n if (existingEntityHash !== currentHash) {\n await fs.promises.writeFile(entityIndexLockFilePath, currentHash.toString(), { encoding: \"utf8\" })\n await fs.promises.writeFile(indexFilePath, indexFileContent, { encoding: \"utf8\" })\n\n console.info(\"Generated index file for shared entity library\")\n }\n }\n}\n\nfunction hashCode(str) {\n let hash = 0\n let i\n let chr\n\n for (i = 0; i < str.length; i++) {\n chr = str.charCodeAt(i)\n hash = (hash << 5) - hash + chr\n hash |= 0 // Convert to 32bit integer\n }\n return hash\n}\n\nfunction getExportLinesFromFilePaths(filePaths) {\n return filePaths\n .sort()\n .map(filePath => {\n const relevantFilePath = filePath.split(\"src/\")[1].replace(\".ts\", \"\")\n\n return `export * from \"./${relevantFilePath}\"\\n`\n })\n .join(\"\")\n}\n","import fs from \"fs\"\nimport path from \"path\"\n\nimport { Command, Option } from \"clipanion\"\nimport { camelCase, kebabCase } from \"lodash\"\n\nimport { walk } from \"../utils\"\n\nexport class RenameProject extends Command {\n static paths = [[\"rename-project\"]]\n organization = Option.String(\"--organization\", { required: true })\n project = Option.String(\"--project\", { required: true })\n\n static usage = Command.Usage({\n category: \"getting-started\",\n description: \"This script will rename all occurrences of stator and chocolat-chaud with your own names.\",\n examples: [[\"A basic example\", \"npm run stator-cli rename-project --organization chocolat-chaud-io --project stator\"]],\n })\n\n async execute(): Promise {\n await this.renameProject()\n }\n\n async renameProject() {\n try {\n const organizationRegex = /^[a-zA-Z-\\d_]+$/gim\n if (!organizationRegex.test(this.organization)) {\n console.error(\"The organization name must respect this regex /^[a-zA-Z-\\\\d_]+$/gmi\")\n process.exit(1)\n }\n\n const projectRegex = /^[a-zA-Z-\\d_]+$/gim\n if (!projectRegex.test(this.project)) {\n console.error(\"The project name must respect this regex /^[a-zA-Z-\\\\d_]+$/gmi\")\n process.exit(1)\n }\n const databaseName = this.project.replace(/-/g, \"_\")\n const databaseFiles = [\"docker-compose.yml\", \"seed-data.js\", \"init.sql\", \"test.ts\", \"orm-config.ts\"]\n\n const camelCaseProjectName = camelCase(this.project)\n\n const ignoredFolders = [\"node_modules\", \"dist\", \".git\", \".idea\", \".cache\"]\n for await (const entry of walk(path.join(__dirname, \"../\"), ignoredFolders)) {\n const entryStat = await fs.promises.lstat(entry)\n if (entryStat.isFile()) {\n const fileContent = await fs.promises.readFile(entry, \"utf-8\")\n if (fileContent) {\n const isDatabaseFile = databaseFiles.some(databaseFile => entry.includes(databaseFile))\n const replacedFileContent = fileContent\n .replace(/chocolat-chaud-io/gim, this.organization)\n .replace(/stator/gim, isDatabaseFile ? databaseName : camelCaseProjectName)\n await fs.promises.writeFile(entry, replacedFileContent, \"utf-8\")\n }\n }\n }\n\n console.info(`This is now YOUR project provided generously by:\n\n███████ ████████  █████  ████████  ██████  ██████ \n██         ██    ██   ██    ██    ██    ██ ██   ██ \n███████  ██  ███████  ██  ██  ██ ██████  \n     ██  ██  ██   ██  ██  ██  ██ ██   ██ \n███████  ██  ██  ██  ██   ██████  ██  ██ \n                                        \n `)\n } catch (error) {\n console.error(error as Error)\n }\n }\n}\n","import fs from \"fs\"\nimport path from \"path\"\n\nexport const walk = async function* (dir: string, ignoredPaths: Array, walkedFolderNames: string[] = []) {\n for await (const directoryEntry of await fs.promises.opendir(dir)) {\n const entryPath = path.join(dir, directoryEntry.name)\n if (directoryEntry.isDirectory() && !ignoredPaths.includes(directoryEntry.name)) {\n walkedFolderNames.push(entryPath)\n yield* walk(entryPath, ignoredPaths, walkedFolderNames)\n } else if (directoryEntry.isFile()) {\n yield entryPath\n }\n }\n}\n","module.exports = require(\"clipanion\");","module.exports = require(\"lodash\");","module.exports = require(\"lodash/camelCase\");","module.exports = require(\"lodash/capitalize\");","module.exports = require(\"lodash/kebabCase\");","module.exports = require(\"tslib\");","module.exports = require(\"fs\");","module.exports = require(\"path\");","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","import { Builtins, Cli } from \"clipanion\"\n\nimport { EnforceFileFolderNamingConvention } from \"./commands/enforce-file-folder-naming-convention\"\nimport { EnforceValidImportsApi } from \"./commands/enforce-valid-imports-api\"\nimport { GenerateCacheKeyFile } from \"./commands/generate-cache-key-file\"\nimport { GenerateEntityIndexFile } from \"./commands/generate-entity-index-file\"\nimport { RenameProject } from \"./commands/rename-project\"\n\nconst [, , ...args] = process.argv\n\nconst cli = new Cli({\n binaryLabel: `stator-cli`,\n binaryName: `npm run stator-cli`,\n binaryVersion: `1.0.0`,\n})\n\ncli.register(RenameProject)\ncli.register(GenerateCacheKeyFile)\ncli.register(GenerateEntityIndexFile)\ncli.register(EnforceValidImportsApi)\ncli.register(EnforceFileFolderNamingConvention)\ncli.register(Builtins.HelpCommand)\ncli.runExit(args).catch(console.error)\n"],"names":["EnforceFileFolderNamingConvention","Command","async","ignoredPaths","capitalLetterRegex","errorPathPaths","validateEntryName","entry","entryName","basename","replace","length","includes","match","push","folderNames","walk","join","__dirname","folderName","errorMessage","console","error","process","exit","info","paths","usage","Usage","category","description","examples","EnforceValidImportsApi","invalidImportRegex","fileContainingInvalidImports","promises","readFile","encoding","GenerateCacheKeyFile","endpointsPath","importStatements","cacheKeys","cacheFileContent","pathName","lstatSync","isFile","cacheKey","endpointsSelectorRegex","endpointSelectorRegex","endpointNames","readFileSync","matchAll","map","entries","flat","endpointName","tagPropertyKey","importStatement","writeFileSync","GenerateEntityIndexFile","entityIndexLockFilePath","indexFilePath","filePathsByFolder","folder","split","indexFileContent","sortedFolders","Object","sort","reduce","container","key","value","filePaths","getExportLinesFromFilePaths","entityIndexLockFileExists","existsSync","existingEntityHash","parseInt","currentHash","str","i","chr","hash","charCodeAt","hashCode","writeFile","toString","filePath","RenameProject","organization","Option","String","required","project","this","renameProject","test","databaseName","databaseFiles","camelCaseProjectName","camelCase","ignoredFolders","lstat","fileContent","isDatabaseFile","some","databaseFile","replacedFileContent","dir","walkedFolderNames","directoryEntry","opendir","entryPath","name","isDirectory","module","exports","require","__webpack_module_cache__","__webpack_require__","moduleId","cachedModule","undefined","__webpack_modules__","args","argv","cli","Cli","binaryLabel","binaryName","binaryVersion","register","Builtins","HelpCommand","runExit","catch"],"sourceRoot":""} --------------------------------------------------------------------------------