diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5721024..b518895 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,11 @@ jobs: test: runs-on: ubuntu-latest steps: - - - name: Set up Docker Compose - uses: docker/setup-compose-action@v1 + - uses: docker/setup-compose-action@v1 - uses: actions/checkout@v4 + - run: docker pull adminer + - run: docker pull postgres + - run: npm i -g @angular/cli@18 @loopback/cli@6 @nestjs/cli@11 - run: npm ci - run: npm run build - run: npm run test \ No newline at end of file diff --git a/README.md b/README.md index 8c0dd6b..1c404c0 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ The projects that can be added to a Monux monorepo also provide a lot of functio - [Handling initial database content](#handling-initial-database-content) - [How do they work?](#how-do-they-work) - [Starting prod locally](#starting-prod-locally) + - [Starting in staging environment](#starting-in-staging-environment) - [Starting in production](#starting-in-production) - [Global Management of Monorepos](#global-management-of-monorepos) - [Supported project types](#supported-project-types) @@ -140,11 +141,13 @@ Monux provides a way to safely handle type safe environment variables. A lot of The system supports two kinds of environment variables, static and calculated. ### Static environment variables -Static variables work by having a global `.env`-file which contains all the static variables of all projects in the monorepo. The cli provides a command `mx prepare`, which validates the content of the `.env`-file based on the `StaticGlobalEnvironment` schema type defined in the `global-environment.model.ts`-file. +Static variables work by having a global `.env`-file (**NOT** checked into git) and a global `.env.public`-file (checked into git), which contain all the static variables of all projects in the monorepo. The cli provides a command `mx prepare`, which validates the content of both these files based on the `StaticGlobalEnvironment` schema type defined in the `global-environment.model.ts`-file. Each project where you actually want to use these variables has its own environment file, which is generated by the `mx prepare` command as well.
How these environment files are generated depends on a `environment.model.ts`-file inside of each project. There you can define the keys of the global environment file that should be used by this specific project.
That way it is possible to only have certain variables like an contact email-address be available to a website project, while certain other variables like a db-password are not. +
+Environment variables from both files can be used inside docker when you run the `mx up` command. ### Calculated environment variables Working with static variables can sometimes be pretty tedious. This is especially true when you want to support different "modes" in which to launch your application (like we do with the different options for `mx up`).
@@ -152,7 +155,7 @@ For example, if we defined the variables "api_base_url", "website_base_url" and To solve this, Monux implements calculated environment variables. The schema type works exactly the same as with static variables. It's called `CalculatedGlobalEnvironment` and is also inside the `global-environment.model.ts`-file.
-But instead of parsing the values from the `.env`-file during the prepare step, calculated variables are created by calling a method defined in the `calculationSchemaFor`-record of the `global-environment.model.ts`-file: +But instead of parsing the values from the `.env`- and `.env.public`-file during the prepare step, calculated variables are created by calling a method defined in the `calculationSchemaFor`-record of the `global-environment.model.ts`-file: ```ts /** @@ -198,17 +201,21 @@ export type GlobalEnvironment = { ``` The model above is used to validate the environment variables we provide, so the current configuration validates: -- that both variables exist in the `.env`-file (you could change that by making them optional on the model) +- that both variables exist in the `.env`- or `.env.public`-file (you could change that by making them optional on the model) - and that they are string values (you could also change their type to number which would validate that they are numbers etc.) #### Add the actual values -To provide the actual values, we have to adjust the `.env`-file: +To provide the actual values, we have to adjust the `.env`- or `.env.public`-file: `.env` ``` api_db_password=super_secret_password +``` +`.env.public` +``` public_contact_email=public@email.address ``` + #### Define the variables locally Now that we have that, we need to define the variables that we want to use in each project where they are needed. We can do that by adjusting the respective environment.model.ts of these projects. @@ -273,7 +280,7 @@ Whenever you add a project that configures some sort of database a subfolder wit When running the `mx prepare` command, these configuration files are used to generate startup scripts for the database inside of the databases init folder (databases/nameOfDb/init/actualInitFile). -What's nice about this is that these configuration files actually only reference the environment variable names instead of real values, so things like db credentials are only ever need to be provided in the .env file, which is excluded from the git repository by default. +What's nice about this is that these configuration files actually only reference the environment variable names instead of real values, so things like the concrete db credentials only ever need to be provided in the `.env`- file, which is excluded from the git repository by default. Monux handles everything regarding mapping these variable names back to values automatically, so you don't have to worry about it at all. @@ -282,12 +289,17 @@ Often times you want to test your project under production like constraints (eg. For that, you can run `mx up` with the environment "local". +## Starting in staging environment +For staging servers Monux provides an extra environment. + +The configuration is almost the same as for production (See below), with the difference that certain services have Basic Auth setup. That way someone randomly stumbling across your projects cannot eg. see the new design of a website before it is finalized. + ## Starting in production You can start the whole monorepo with running `mx up` and then selecting "prod" as the environment. This will try to run the `mx prepare` command.
-The only info required by that command is inside the `.env`-file.
-Monux also validates the `.env`-file, so by continueously running the command you can fill it little by little and don't need to worry that you start your monorepo with invalid or missing environment variables. +The only info required by that command are inside the `.env`- and `.env.public`-files.
+Monux also validates these files, so by continueously running the command you can fill it little by little and don't need to worry that you start your monorepo with invalid or missing environment variables. ## Global Management of Monorepos The Monux cli commands `mx up`, `mx down`, `mx ls` and `mx la` can be run globally. That way you don't have to open up a certain directory just to exit some services. diff --git a/cspell.words.txt b/cspell.words.txt index 412caff..c77a8eb 100644 --- a/cspell.words.txt +++ b/cspell.words.txt @@ -37,4 +37,7 @@ dts nestjs cloudflare cldr -cldrjs \ No newline at end of file +cldrjs +htpasswd +basicauth +usersfile \ No newline at end of file diff --git a/jest.config.mjs b/jest.config.mjs index b13d51f..d32b8ad 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -8,7 +8,7 @@ const config = { '^.+.tsx?$': ['ts-jest', {}] }, setupFilesAfterEnv: ['/jest.setup.ts'], - bail: true, + bail: false, modulePathIgnorePatterns: ['tmp'], // coverage collectCoverage: true, diff --git a/package.json b/package.json index ef4fb24..7687b56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "monux-cli", - "version": "2.2.4", + "version": "2.3.0", "license": "MIT", "main": "index.js", "engines": { @@ -33,7 +33,7 @@ "start": "npm run build && cd sandbox && node ../dist/index.js", "build": "tsc", "clear": "rm -rf sandbox && mkdir sandbox && npm run start i", - "test": "jest --runInBand", + "test": "jest", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --max-warnings=0 --fix", "prepublishOnly": "npm i && npm run build" diff --git a/src/__testing__/mock/helpers/fake-array.function.ts b/src/__testing__/helpers/fake-array.function.ts similarity index 100% rename from src/__testing__/mock/helpers/fake-array.function.ts rename to src/__testing__/helpers/fake-array.function.ts diff --git a/src/__testing__/mock/helpers/fake-string-key-value.function.ts b/src/__testing__/helpers/fake-string-key-value.function.ts similarity index 87% rename from src/__testing__/mock/helpers/fake-string-key-value.function.ts rename to src/__testing__/helpers/fake-string-key-value.function.ts index ce0a5c5..935ae6b 100644 --- a/src/__testing__/mock/helpers/fake-string-key-value.function.ts +++ b/src/__testing__/helpers/fake-string-key-value.function.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'; import { fakeUniqueString } from './fake-unique-string.function'; -import { KeyValue } from '../../../types'; +import { KeyValue } from '../../types'; export function fakeStringKeyValue(): KeyValue { return { diff --git a/src/__testing__/mock/helpers/fake-string-record.function.ts b/src/__testing__/helpers/fake-string-record.function.ts similarity index 100% rename from src/__testing__/mock/helpers/fake-string-record.function.ts rename to src/__testing__/helpers/fake-string-record.function.ts diff --git a/src/__testing__/mock/helpers/fake-unique-string.function.ts b/src/__testing__/helpers/fake-unique-string.function.ts similarity index 100% rename from src/__testing__/mock/helpers/fake-unique-string.function.ts rename to src/__testing__/helpers/fake-unique-string.function.ts diff --git a/src/__testing__/mock/helpers/index.ts b/src/__testing__/helpers/index.ts similarity index 100% rename from src/__testing__/mock/helpers/index.ts rename to src/__testing__/helpers/index.ts diff --git a/src/__testing__/mock/constants.ts b/src/__testing__/mock/constants.ts index eee3779..e462223 100644 --- a/src/__testing__/mock/constants.ts +++ b/src/__testing__/mock/constants.ts @@ -1,13 +1,15 @@ /* eslint-disable jsdoc/require-jsdoc */ -import { ANGULAR_JSON_FILE_NAME, ANGULAR_ROUTES_FILE_NAME, APP_CONFIG_FILE_NAME, APPS_DIRECTORY_NAME, DEV_DOCKER_COMPOSE_FILE_NAME, PROD_DOCKER_COMPOSE_FILE_NAME, ENV_FILE_NAME, ENVIRONMENT_MODEL_TS_FILE_NAME, ENVIRONMENT_TS_FILE_NAME, ESLINT_CONFIG_FILE_NAME, GLOBAL_ENVIRONMENT_MODEL_FILE_NAME, LIBS_DIRECTORY_NAME, PACKAGE_JSON_FILE_NAME, LOCAL_DOCKER_COMPOSE_FILE_NAME, WORKSPACE_FILE_NAME, BASE_TS_CONFIG_FILE_NAME } from '../../constants'; +import { ANGULAR_JSON_FILE_NAME, ANGULAR_ROUTES_FILE_NAME, APP_CONFIG_FILE_NAME, APPS_DIRECTORY_NAME, DEV_DOCKER_COMPOSE_FILE_NAME, PROD_DOCKER_COMPOSE_FILE_NAME, ENV_FILE_NAME, ENVIRONMENT_MODEL_TS_FILE_NAME, ENVIRONMENT_TS_FILE_NAME, ESLINT_CONFIG_FILE_NAME, GLOBAL_ENVIRONMENT_MODEL_FILE_NAME, LIBS_DIRECTORY_NAME, PACKAGE_JSON_FILE_NAME, LOCAL_DOCKER_COMPOSE_FILE_NAME, WORKSPACE_FILE_NAME, BASE_TS_CONFIG_FILE_NAME, STAGE_DOCKER_COMPOSE_FILE_NAME, ENV_PUBLIC_FILE_NAME } from '../../constants'; import { OmitStrict } from '../../types'; import { getPath, Path } from '../../utilities'; -export const MAX_ADD_TIME: number = 60000; +export const MAX_ADD_TIME: number = 90000; export const MAX_GEN_CODE_TIME: number = 10000; -export const MAX_FAST_TIME: number = 300; +export const MAX_FAST_TIME: number = 1000; + +export const MAX_BARELY_NOTICEABLE_TIME: number = 500; export const MAX_INSTANT_TIME: number = 100; @@ -18,6 +20,7 @@ export type MockConstants = { readonly DOCKER_COMPOSE_YAML: Path, readonly DEV_DOCKER_COMPOSE_YAML: Path, readonly LOCAL_DOCKER_COMPOSE_YAML: Path, + readonly STAGE_DOCKER_COMPOSE_YAML: Path, readonly ANGULAR_ESLINT_CONFIG_MJS: Path, readonly ANGULAR_PACKAGE_JSON: Path, readonly ANGULAR_APP_NAME: string, @@ -36,6 +39,7 @@ export type MockConstants = { readonly ROOT_PACKAGE_JSON: Path, readonly TS_LIBRARY_NAME: string, readonly ENV: Path, + readonly ENV_PUBLIC: Path, readonly GLOBAL_ENV_MODEL: Path, readonly GITHUB_WORKFLOW_DIR: Path, readonly WORKSPACE_JSON: Path, @@ -77,6 +81,7 @@ export function getMockConstants(projectName: string): MockConstants { DOCKER_COMPOSE_YAML: getPath(PROJECT_DIR, PROD_DOCKER_COMPOSE_FILE_NAME), DEV_DOCKER_COMPOSE_YAML: getPath(PROJECT_DIR, DEV_DOCKER_COMPOSE_FILE_NAME), LOCAL_DOCKER_COMPOSE_YAML: getPath(PROJECT_DIR, LOCAL_DOCKER_COMPOSE_FILE_NAME), + STAGE_DOCKER_COMPOSE_YAML: getPath(PROJECT_DIR, STAGE_DOCKER_COMPOSE_FILE_NAME), ANGULAR_PACKAGE_JSON: getPath(ANGULAR_APP_DIR, PACKAGE_JSON_FILE_NAME), ANGULAR_ESLINT_CONFIG_MJS: getPath(ANGULAR_APP_DIR, ESLINT_CONFIG_FILE_NAME), ANGULAR_APP_NAME: ANGULAR_APP_NAME, @@ -95,6 +100,7 @@ export function getMockConstants(projectName: string): MockConstants { TS_LIBRARY_PACKAGE_JSON: getPath(TS_LIBRARY_DIR, PACKAGE_JSON_FILE_NAME), ROOT_PACKAGE_JSON: getPath(PROJECT_DIR, PACKAGE_JSON_FILE_NAME), ENV: getPath(PROJECT_DIR, ENV_FILE_NAME), + ENV_PUBLIC: getPath(PROJECT_DIR, ENV_PUBLIC_FILE_NAME), GLOBAL_ENV_MODEL: getPath(PROJECT_DIR, GLOBAL_ENVIRONMENT_MODEL_FILE_NAME), GITHUB_WORKFLOW_DIR: getPath(PROJECT_DIR, '.github', 'workflows'), WORKSPACE_JSON: getPath(PROJECT_DIR, WORKSPACE_FILE_NAME), diff --git a/src/__testing__/mock/create-admin-files.mock.ts b/src/__testing__/mock/create-admin-files.mock.ts new file mode 100644 index 0000000..4445b87 --- /dev/null +++ b/src/__testing__/mock/create-admin-files.mock.ts @@ -0,0 +1,37 @@ +import { FsUtilities } from '../../encapsulation'; +import { adminControllerContent } from '../../loopback/admin-controller.content'; +import { adminModelContent } from '../../loopback/admin-model.content'; +import { fullAdminModelContent } from '../../loopback/full-admin-model.content'; +import { newAdminModelContent } from '../../loopback/new-admin-model.content'; +import { Path, getPath } from '../../utilities'; + +// eslint-disable-next-line jsdoc/require-jsdoc +export async function createAdminFilesMock(root: string, dbName: string): Promise { + const adminModelTs: Path = getPath(root, 'src', 'models', 'admin.model.ts'); + await FsUtilities.createFile(adminModelTs, adminModelContent); + await FsUtilities.createFile( + getPath(root, 'src', 'models', 'roles.enum.ts'), + [ + 'export enum Roles {', + '\tADMIN = \'ADMIN\'', + '}' + ] + ); + await FsUtilities.createFile( + getPath(root, 'src', 'models', 'index.ts'), + [ + 'export * from \'./admin.model\';', + 'export * from \'./roles.enum\';' + ] + ); + + const adminRepositoryTs: Path = getPath(root, 'src', 'repositories', 'admin.repository.ts'); + await FsUtilities.createFile(adminRepositoryTs, []); + + const controllerPath: string = getPath(root, 'src', 'controllers'); + await FsUtilities.createFile(getPath(controllerPath, 'admin', 'admin.controller.ts'), adminControllerContent(dbName)); + await FsUtilities.updateFile(getPath(controllerPath, 'index.ts'), 'export * from \'./admin/admin.controller\';', 'append'); + + await FsUtilities.createFile(getPath(controllerPath, 'admin', 'new-admin.model.ts'), newAdminModelContent); + await FsUtilities.createFile(getPath(controllerPath, 'admin', 'full-admin.model.ts'), fullAdminModelContent); +} \ No newline at end of file diff --git a/src/__testing__/mock/create-mail-service.mock.ts b/src/__testing__/mock/create-mail-service.mock.ts new file mode 100644 index 0000000..163f03c --- /dev/null +++ b/src/__testing__/mock/create-mail-service.mock.ts @@ -0,0 +1,8 @@ +import { FsUtilities } from '../../encapsulation'; +import { Path, getPath } from '../../utilities'; + +// eslint-disable-next-line jsdoc/require-jsdoc +export async function createMailServiceMock(root: string): Promise { + const servicePath: Path = getPath(root, 'src', 'services', 'mail.service.ts'); + await FsUtilities.createFile(servicePath, []); +} \ No newline at end of file diff --git a/src/__testing__/mock/fake-calculated-env-variable.function.ts b/src/__testing__/mock/fake-calculated-env-variable.function.ts index e097d8e..24700cc 100644 --- a/src/__testing__/mock/fake-calculated-env-variable.function.ts +++ b/src/__testing__/mock/fake-calculated-env-variable.function.ts @@ -1,8 +1,8 @@ /* eslint-disable jsdoc/require-jsdoc */ import { faker } from '@faker-js/faker'; -import { fakeUniqueString } from './helpers'; import { CalculatedEnvVariable, EnvironmentVariableKey } from '../../env'; +import { fakeUniqueString } from '../helpers'; export function fakeCalculatedEnvVariable(data?: Partial): CalculatedEnvVariable { const type: 'string' | 'number' = faker.helpers.arrayElement(['string', 'number']); diff --git a/src/__testing__/mock/fake-compose-service.function.ts b/src/__testing__/mock/fake-compose-service.function.ts index 4a222d3..989f3ee 100644 --- a/src/__testing__/mock/fake-compose-service.function.ts +++ b/src/__testing__/mock/fake-compose-service.function.ts @@ -1,8 +1,8 @@ /* eslint-disable jsdoc/require-jsdoc */ import { faker } from '@faker-js/faker'; -import { fakeStringKeyValue, fakeUniqueString, fakeArray } from './helpers'; import { ComposePort, ComposeService } from '../../docker'; +import { fakeStringKeyValue, fakeUniqueString, fakeArray } from '../helpers'; function fakeComposePort(): ComposePort { return { diff --git a/src/__testing__/mock/fake-env-variable.function.ts b/src/__testing__/mock/fake-env-variable.function.ts index bfd3bcb..49cf7d0 100644 --- a/src/__testing__/mock/fake-env-variable.function.ts +++ b/src/__testing__/mock/fake-env-variable.function.ts @@ -1,8 +1,8 @@ /* eslint-disable jsdoc/require-jsdoc */ import { faker } from '@faker-js/faker'; -import { fakeUniqueString } from './helpers'; import { EnvironmentVariableKey, EnvVariable } from '../../env'; +import { fakeUniqueString } from '../helpers'; export function fakeEnvVariable(data?: Partial): EnvVariable { const type: 'string' | 'number' = faker.helpers.arrayElement(['string', 'number']); diff --git a/src/__testing__/mock/fake-update-package-json-data.function.ts b/src/__testing__/mock/fake-update-package-json-data.function.ts index 847e238..a0949bd 100644 --- a/src/__testing__/mock/fake-update-package-json-data.function.ts +++ b/src/__testing__/mock/fake-update-package-json-data.function.ts @@ -1,8 +1,8 @@ /* eslint-disable jsdoc/require-jsdoc */ import { faker } from '@faker-js/faker'; -import { fakeArray, fakeStringRecord, fakeUniqueString } from './helpers'; import { PackageJson } from '../../npm'; +import { fakeArray, fakeStringRecord, fakeUniqueString } from '../helpers'; export function fakeUpdatePackageJsonData(): Partial { return { diff --git a/src/__testing__/mock/file-mock.utilities.ts b/src/__testing__/mock/file-mock.utilities.ts index 2d20922..b55226d 100644 --- a/src/__testing__/mock/file-mock.utilities.ts +++ b/src/__testing__/mock/file-mock.utilities.ts @@ -11,11 +11,13 @@ export const defaultFilesToMock: (keyof FileMockConstants)[] = [ 'WORKSPACE_JSON', 'BASE_TS_CONFIG_JSON', 'ENV', + 'ENV_PUBLIC', 'GLOBAL_ENV_MODEL', 'ROOT_PACKAGE_JSON', 'DOCKER_COMPOSE_YAML', 'DEV_DOCKER_COMPOSE_YAML', - 'LOCAL_DOCKER_COMPOSE_YAML' + 'LOCAL_DOCKER_COMPOSE_YAML', + 'STAGE_DOCKER_COMPOSE_YAML' ] as const; export const defaultFoldersToMock: (keyof DirMockConstants)[] = [ @@ -33,6 +35,7 @@ export abstract class FileMockUtilities { DOCKER_COMPOSE_YAML: this.createEmptyFile, DEV_DOCKER_COMPOSE_YAML: this.createEmptyFile, LOCAL_DOCKER_COMPOSE_YAML: this.createEmptyFile, + STAGE_DOCKER_COMPOSE_YAML: this.createEmptyFile, ANGULAR_ESLINT_CONFIG_MJS: this.createEmptyFile, ANGULAR_PACKAGE_JSON: this.createAngularPackageJson, ANGULAR_APP_COMPONENT_TS: this.createAppComponentTsFile, @@ -46,6 +49,7 @@ export abstract class FileMockUtilities { TS_LIBRARY_PACKAGE_JSON: this.createEmptyFile, ROOT_PACKAGE_JSON: this.createRootPackageJson, ENV: this.createEnv, + ENV_PUBLIC: this.createEnvPublic, GLOBAL_ENV_MODEL: this.createGlobalEnvModel, WORKSPACE_JSON: WorkspaceUtilities.createConfig, BASE_TS_CONFIG_JSON: TsConfigUtilities.createBaseTsConfig @@ -201,6 +205,16 @@ export abstract class FileMockUtilities { } private static async createEnv(mockConstants: MockConstants): Promise { - await FsUtilities.createFile(mockConstants.ENV, ['prod_root_domain=test.com', 'is_public=false']); + await FsUtilities.createFile( + mockConstants.ENV, + ['basic_auth_user=user', 'basic_auth_password=password'] + ); + } + + private static async createEnvPublic(mockConstants: MockConstants): Promise { + await FsUtilities.createFile( + mockConstants.ENV_PUBLIC, + ['prod_root_domain=test.com', 'stage_root_domain=test-staging.com'] + ); } } \ No newline at end of file diff --git a/src/__testing__/mock/index.ts b/src/__testing__/mock/index.ts index 4e89575..67bb925 100644 --- a/src/__testing__/mock/index.ts +++ b/src/__testing__/mock/index.ts @@ -6,4 +6,6 @@ export * from './fake-update-package-json-data.function'; export * from './fake-add-nav-element-config.function'; export * from './fake-env-variable.function'; export * from './fake-calculated-env-variable.function'; -export * from './mock-inquire.function'; \ No newline at end of file +export * from './inquire.mock'; +export * from './create-mail-service.mock'; +export * from './create-admin-files.mock'; \ No newline at end of file diff --git a/src/__testing__/mock/mock-inquire.function.ts b/src/__testing__/mock/inquire.mock.ts similarity index 91% rename from src/__testing__/mock/mock-inquire.function.ts rename to src/__testing__/mock/inquire.mock.ts index ee7a51b..306db38 100644 --- a/src/__testing__/mock/mock-inquire.function.ts +++ b/src/__testing__/mock/inquire.mock.ts @@ -2,7 +2,7 @@ import { BuiltInQuestion } from 'inquirer/dist/cjs/types/types'; // eslint-disable-next-line typescript/no-explicit-any -export function mockInquire(answers: Record): (question: BuiltInQuestion) => Promise { +export function inquireMock(answers: Record): (question: BuiltInQuestion) => Promise { return (question: BuiltInQuestion) => { if (typeof question.message !== 'string') { throw new Error('Cannot mock questions with messages that are async functions.'); diff --git a/src/angular/angular-utilities.test.ts b/src/angular/angular-utilities.test.ts index fba9944..fb0b9b2 100644 --- a/src/angular/angular-utilities.test.ts +++ b/src/angular/angular-utilities.test.ts @@ -11,7 +11,7 @@ import { getPath } from '../utilities'; const mockConstants: MockConstants = getMockConstants('angular-utilities'); let npmInstallMock: jest.SpiedFunction; -let cpExecSyncMock: jest.SpiedFunction; +let cpExecSyncMock: jest.SpiedFunction; describe('AngularUtilities', () => { beforeEach(async () => { @@ -26,7 +26,7 @@ describe('AngularUtilities', () => { ] ); npmInstallMock = jest.spyOn(NpmUtilities, 'install').mockImplementation(async () => {}); - cpExecSyncMock = jest.spyOn(CPUtilities, 'execSync').mockImplementation(() => {}); + cpExecSyncMock = jest.spyOn(CPUtilities, 'exec').mockImplementation(async () => {}); }); test('addComponentImports', async () => { @@ -255,7 +255,7 @@ describe('AngularUtilities', () => { '', 'export const routes: NavRoute[] = NavUtilities.getAngularRoutes(navbarRows, footerRows, [notFoundRoute]);' ]); - }, MAX_GEN_CODE_TIME); + }, MAX_GEN_CODE_TIME * 2); test('generatePage for footer', async () => { await AngularUtilities.setupNavigation(mockConstants.ANGULAR_APP_DIR, mockConstants.ANGULAR_APP_NAME); diff --git a/src/angular/angular.utilities.ts b/src/angular/angular.utilities.ts index 3755f1e..3a6d445 100644 --- a/src/angular/angular.utilities.ts +++ b/src/angular/angular.utilities.ts @@ -513,8 +513,8 @@ export abstract class AngularUtilities { * @param command - The command to run. * @param options - Options for running the command. */ - static runCommand(directory: Path, command: AngularCliCommands, options: AngularCliOptions): void { - CPUtilities.execSync(`cd ${directory} && npx @angular/cli@${this.CLI_VERSION} ${command} ${optionsToCliString(options)}`); + static async runCommand(directory: Path, command: AngularCliCommands, options: AngularCliOptions): Promise { + await CPUtilities.exec(`cd ${directory} && npx @angular/cli@${this.CLI_VERSION} ${command} ${optionsToCliString(options)}`); } /** @@ -530,7 +530,7 @@ export abstract class AngularUtilities { navElement: AddNavElementConfig | undefined, domain: string | undefined ): Promise { - this.runCommand(root, `generate component pages/${pageName}`, { '--skip-tests': true, '--inline-style': true }); + await this.runCommand(root, `generate component pages/${pageName}`, { '--skip-tests': true, '--inline-style': true }); if (navElement) { await this.addNavElement(root, navElement); @@ -805,7 +805,7 @@ export abstract class AngularUtilities { { provide: 'HTTP_INTERCEPTORS', useClass: 'OfflineRequestInterceptor' as any, multi: true }, [{ defaultImport: false, element: 'OfflineRequestInterceptor', path: NpmPackage.NGX_PWA }] ); - this.runCommand(root, `add @angular/pwa@${this.CLI_VERSION}`, { '--skip-confirmation': true }); + await this.runCommand(root, `add @angular/pwa@${this.CLI_VERSION}`, { '--skip-confirmation': true }); await NpmUtilities.install(name, [NpmPackage.NGX_PWA]); await FsUtilities.updateFile( getPath(root, 'src', 'app', 'app.component.html'), diff --git a/src/commands/add/add-angular-library/add-angular-library-command.test.ts b/src/commands/add/add-angular-library/add-angular-library-command.test.ts index 439ea04..2f0f225 100644 --- a/src/commands/add/add-angular-library/add-angular-library-command.test.ts +++ b/src/commands/add/add-angular-library/add-angular-library-command.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; -import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, mockInquire } from '../../../__testing__'; +import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, inquireMock } from '../../../__testing__'; import { InquirerUtilities } from '../../../encapsulation'; import { AddConfiguration, AddType } from '../models'; import { AddAngularLibraryCommand } from './add-angular-library.command'; @@ -10,7 +10,7 @@ const mockConstants: MockConstants = getMockConstants('add-angular-library-comma describe('AddAngularLibraryCommand', () => { beforeEach(async () => { await FileMockUtilities.setup(mockConstants); - InquirerUtilities['inquire'] = jest.fn(mockInquire({ + InquirerUtilities['inquire'] = jest.fn(inquireMock({ scope: '@sandbox' })); }); diff --git a/src/commands/add/add-angular-library/add-angular-library.command.ts b/src/commands/add/add-angular-library/add-angular-library.command.ts index a346ae7..4be7b5e 100644 --- a/src/commands/add/add-angular-library/add-angular-library.command.ts +++ b/src/commands/add/add-angular-library/add-angular-library.command.ts @@ -91,14 +91,14 @@ export class AddAngularLibraryCommand extends BaseAddCommand { // eslint-disable-next-line no-console console.log('Creates a temporary angular workspace'); - AngularUtilities.runCommand( + await AngularUtilities.runCommand( getPath(LIBS_DIRECTORY_NAME), `new ${config.name}`, { '--no-create-application': true } ); // eslint-disable-next-line no-console console.log('Creates the base library'); - AngularUtilities.runCommand( + await AngularUtilities.runCommand( getPath(LIBS_DIRECTORY_NAME, config.name), `generate library ${config.name}`, {} @@ -106,7 +106,7 @@ export class AddAngularLibraryCommand extends BaseAddCommand { beforeEach(async () => { await FileMockUtilities.setup(mockConstants); - InquirerUtilities['inquire'] = jest.fn(mockInquire({ + InquirerUtilities['inquire'] = jest.fn(inquireMock({ port: 4200, 'sub domain': undefined, 'title suffix (eg. "| My Company")': '| Website', diff --git a/src/commands/add/add-angular-website/add-angular-website.command.ts b/src/commands/add/add-angular-website/add-angular-website.command.ts index 66398ac..58f89d9 100644 --- a/src/commands/add/add-angular-website/add-angular-website.command.ts +++ b/src/commands/add/add-angular-website/add-angular-website.command.ts @@ -100,6 +100,7 @@ export class AddAngularWebsiteCommand extends BaseAddCommand { // eslint-disable-next-line no-console console.log('Creates the base website'); - AngularUtilities.runCommand( + await AngularUtilities.runCommand( getPath(APPS_DIRECTORY_NAME), `new ${config.name}`, { '--skip-git': true, '--style': 'css', '--inline-style': true, '--ssr': true } diff --git a/src/commands/add/add-angular/add-angular-command.test.ts b/src/commands/add/add-angular/add-angular-command.test.ts index 2580415..2f6505a 100644 --- a/src/commands/add/add-angular/add-angular-command.test.ts +++ b/src/commands/add/add-angular/add-angular-command.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; import { AddAngularCommand } from './add-angular.command'; -import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, mockInquire } from '../../../__testing__'; +import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, inquireMock } from '../../../__testing__'; import { InquirerUtilities } from '../../../encapsulation'; import { AddConfiguration, AddType } from '../models'; @@ -10,7 +10,7 @@ const mockConstants: MockConstants = getMockConstants('add-angular-command'); describe('AddAngularCommand', () => { beforeEach(async () => { await FileMockUtilities.setup(mockConstants); - InquirerUtilities['inquire'] = jest.fn(mockInquire({ + InquirerUtilities['inquire'] = jest.fn(inquireMock({ port: 4200, 'sub domain': 'admin', 'title suffix (eg. "| My Company")': '| Admin', diff --git a/src/commands/add/add-angular/add-angular.command.ts b/src/commands/add/add-angular/add-angular.command.ts index 2b34caa..c06d617 100644 --- a/src/commands/add/add-angular/add-angular.command.ts +++ b/src/commands/add/add-angular/add-angular.command.ts @@ -90,6 +90,7 @@ export class AddAngularCommand extends BaseAddCommand { 4000, config.port, true, + true, config.subDomain ), AngularUtilities.updateAngularJson( @@ -213,7 +214,7 @@ export class AddAngularCommand extends BaseAddCommand { private async createProject(config: AddAngularConfiguration): Promise { console.log('Creates the base app'); - AngularUtilities.runCommand( + await AngularUtilities.runCommand( getPath(APPS_DIRECTORY_NAME), `new ${config.name}`, { '--skip-git': true, '--style': 'css', '--inline-style': true, '--ssr': true } diff --git a/src/commands/add/add-loopback/add-loopback-command.test.ts b/src/commands/add/add-loopback/add-loopback-command.test.ts index 4675046..9bde0e7 100644 --- a/src/commands/add/add-loopback/add-loopback-command.test.ts +++ b/src/commands/add/add-loopback/add-loopback-command.test.ts @@ -1,17 +1,18 @@ import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; -import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, mockInquire } from '../../../__testing__'; +import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, inquireMock, createMailServiceMock, createAdminFilesMock } from '../../../__testing__'; import { DbType } from '../../../db'; import { InquirerUtilities } from '../../../encapsulation'; import { AddConfiguration, AddType } from '../models'; import { AddLoopbackCommand } from './add-loopback.command'; +import { LoopbackUtilities } from '../../../loopback'; const mockConstants: MockConstants = getMockConstants('add-loopback-command'); describe('AddLoopbackCommand', () => { beforeEach(async () => { await FileMockUtilities.setup(mockConstants); - InquirerUtilities['inquire'] = jest.fn(mockInquire({ + InquirerUtilities['inquire'] = jest.fn(inquireMock({ port: 3000, 'sub domain': 'api', 'Email of the default user': 'test@test.com', @@ -22,6 +23,9 @@ describe('AddLoopbackCommand', () => { 'Database name': 'sandbox', 'database type': DbType.POSTGRES })); + LoopbackUtilities['createMailService'] = jest.fn(createMailServiceMock); + LoopbackUtilities['createBiometricCredentialsService'] = jest.fn(async () => {}); + LoopbackUtilities['createAdminFiles'] = jest.fn(createAdminFilesMock); }); test('should run and create new database', async () => { diff --git a/src/commands/add/add-loopback/add-loopback.command.ts b/src/commands/add/add-loopback/add-loopback.command.ts index 42b0c47..ea7ad62 100644 --- a/src/commands/add/add-loopback/add-loopback.command.ts +++ b/src/commands/add/add-loopback/add-loopback.command.ts @@ -110,6 +110,7 @@ export class AddLoopbackCommand extends BaseAddCommand 3000, config.port, true, + false, config.subDomain ), this.updateDockerFile(root, config) diff --git a/src/commands/add/add-nest/add-nest-command.test.ts b/src/commands/add/add-nest/add-nest-command.test.ts index 367e02c..2bc01ea 100644 --- a/src/commands/add/add-nest/add-nest-command.test.ts +++ b/src/commands/add/add-nest/add-nest-command.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; -import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, mockInquire } from '../../../__testing__'; +import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, inquireMock } from '../../../__testing__'; import { InquirerUtilities } from '../../../encapsulation'; import { AddConfiguration, AddType } from '../models'; import { AddNestCommand } from './add-nest.command'; @@ -11,7 +11,7 @@ const mockConstants: MockConstants = getMockConstants('add-nest-command'); describe('AddNestCommand', () => { beforeEach(async () => { await FileMockUtilities.setup(mockConstants); - InquirerUtilities['inquire'] = jest.fn(mockInquire({ + InquirerUtilities['inquire'] = jest.fn(inquireMock({ 'sub domain': 'api', port: 3000, 'Email of the default user': 'test@test.com', diff --git a/src/commands/add/add-nest/add-nest.command.ts b/src/commands/add/add-nest/add-nest.command.ts index ce985a1..221e45a 100644 --- a/src/commands/add/add-nest/add-nest.command.ts +++ b/src/commands/add/add-nest/add-nest.command.ts @@ -118,6 +118,7 @@ export class AddNestCommand extends BaseAddCommand { 3000, config.port, true, + false, config.subDomain ), this.createDockerfile(root, config), @@ -220,7 +221,7 @@ export class AddNestCommand extends BaseAddCommand { private async createProject(config: AddNestConfiguration): Promise { // eslint-disable-next-line no-console console.log('Creates the base app'); - NestUtilities.runCommand( + await NestUtilities.runCommand( getPath(APPS_DIRECTORY_NAME), `new ${config.name}`, { diff --git a/src/commands/add/add-ts-library/add-ts-library-command.test.ts b/src/commands/add/add-ts-library/add-ts-library-command.test.ts index 4eb8fa2..e75a6f7 100644 --- a/src/commands/add/add-ts-library/add-ts-library-command.test.ts +++ b/src/commands/add/add-ts-library/add-ts-library-command.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; -import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, mockInquire } from '../../../__testing__'; +import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, inquireMock } from '../../../__testing__'; import { AddConfiguration, AddType } from '../models'; import { AddTsLibraryCommand } from './add-ts-library.command'; import { InquirerUtilities } from '../../../encapsulation'; @@ -10,7 +10,7 @@ const mockConstants: MockConstants = getMockConstants('add-ts-library-command'); describe('AddTsLibraryCommand', () => { beforeEach(async () => { await FileMockUtilities.setup(mockConstants); - InquirerUtilities['inquire'] = jest.fn(mockInquire({ + InquirerUtilities['inquire'] = jest.fn(inquireMock({ scope: '@sandbox' })); }); diff --git a/src/commands/add/add-ts-library/add-ts-library.command.ts b/src/commands/add/add-ts-library/add-ts-library.command.ts index 23153a6..60bc9a4 100644 --- a/src/commands/add/add-ts-library/add-ts-library.command.ts +++ b/src/commands/add/add-ts-library/add-ts-library.command.ts @@ -77,7 +77,7 @@ export class AddTsLibraryCommand extends BaseAddCommand private async createProject(config: TsLibraryConfiguration): Promise { // eslint-disable-next-line no-console console.log('Creates the library'); - CPUtilities.execSync(`cd ${LIBS_DIRECTORY_NAME} && npm create vite@${this.VITE_VERSION} ${config.name} -- --template vanilla-ts`); + await CPUtilities.exec(`cd ${LIBS_DIRECTORY_NAME} && npm create vite@${this.VITE_VERSION} ${config.name} -- --template vanilla-ts`); const libraryPath: string = getPath(LIBS_DIRECTORY_NAME, config.name); await FsUtilities.createFile(getPath(libraryPath, 'vite.config.ts'), [ 'import { defineConfig, PluginOption } from \'vite\';', diff --git a/src/commands/add/add-wordpress/add-wordpress-command.test.ts b/src/commands/add/add-wordpress/add-wordpress-command.test.ts index 552e969..b200a0f 100644 --- a/src/commands/add/add-wordpress/add-wordpress-command.test.ts +++ b/src/commands/add/add-wordpress/add-wordpress-command.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; -import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, mockInquire } from '../../../__testing__'; +import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, inquireMock } from '../../../__testing__'; import { InquirerUtilities } from '../../../encapsulation'; import { AddConfiguration, AddType } from '../models'; import { AddWordpressCommand } from './add-wordpress.command'; @@ -11,7 +11,7 @@ const mockConstants: MockConstants = getMockConstants('add-wordpress-command'); describe('AddWordpressCommand', () => { beforeEach(async () => { await FileMockUtilities.setup(mockConstants); - InquirerUtilities['inquire'] = jest.fn(mockInquire({ + InquirerUtilities['inquire'] = jest.fn(inquireMock({ 'sub domain': 'wordpress', 'Database compose service': 'NEW', 'Compose service name': 'db', diff --git a/src/commands/add/add-wordpress/add-wordpress.command.ts b/src/commands/add/add-wordpress/add-wordpress.command.ts index cdadad1..3ccd549 100644 --- a/src/commands/add/add-wordpress/add-wordpress.command.ts +++ b/src/commands/add/add-wordpress/add-wordpress.command.ts @@ -69,6 +69,7 @@ export class AddWordpressCommand extends BaseAddCommand { * @param args - The cli args. */ protected async validate(args: string[]): Promise { - this.validateMaxLength(args); + await this.validateMaxLength(args); await this.validateInsideWorkspace(); } @@ -61,7 +61,7 @@ export abstract class BaseCommand { const config: WorkspaceConfig | undefined = await WorkspaceUtilities.getConfig(); // eslint-disable-next-line typescript/strict-boolean-expressions if (!config?.isWorkspace) { - exitWithError('This command can only be run inside a workspace'); + await exitWithError('This command can only be run inside a workspace'); } } @@ -69,12 +69,12 @@ export abstract class BaseCommand { * Validates that the provided args are not bigger than the maxLength. * @param args - The cli args. */ - protected validateMaxLength(args: string[]): void { + protected async validateMaxLength(args: string[]): Promise { if (this.maxLength == undefined) { return; } if (args.length > this.maxLength) { - exitWithError(TOO_MANY_ARGUMENTS_ERROR_MESSAGE); + await exitWithError(TOO_MANY_ARGUMENTS_ERROR_MESSAGE); } } } \ No newline at end of file diff --git a/src/commands/down/down-command.test.ts b/src/commands/down/down-command.test.ts index b5c59b8..01099eb 100644 --- a/src/commands/down/down-command.test.ts +++ b/src/commands/down/down-command.test.ts @@ -1,7 +1,7 @@ import { describe, beforeEach, jest, test, expect, afterEach } from '@jest/globals'; import { DownCommand } from './down.command'; -import { FileMockUtilities, getMockConstants, MAX_FAST_TIME, MockConstants, mockInquire } from '../../__testing__'; +import { FileMockUtilities, getMockConstants, MockConstants, inquireMock, MAX_FAST_TIME } from '../../__testing__'; import { FullyParsedDockerService, getDockerServices } from '../../docker'; import { InquirerUtilities } from '../../encapsulation'; import { UpCommand } from '../up'; @@ -21,7 +21,7 @@ describe('DownCommand', () => { ' - 8080:8080' ] }); - InquirerUtilities['inquire'] = jest.fn(mockInquire({ + InquirerUtilities['inquire'] = jest.fn(inquireMock({ env: 'dev.docker-compose.yaml' })); }); diff --git a/src/commands/down/down.command.ts b/src/commands/down/down.command.ts index 581cdde..e61c47f 100644 --- a/src/commands/down/down.command.ts +++ b/src/commands/down/down.command.ts @@ -13,14 +13,14 @@ export class DownCommand extends BaseCommand { protected override readonly insideWorkspace: boolean = true; protected override readonly maxLength: number = 2; - protected override run(input: DownConfiguration): Promise | void { + protected override async run(input: DownConfiguration): Promise { for (const filePath of input.dockerFilePaths) { - CPUtilities.execSync(`docker compose -f ${filePath} -p ${input.projectName} stop`); + await CPUtilities.exec(`docker compose -f ${filePath} -p ${input.projectName} stop`); } } protected override async validate(args: string[]): Promise { - this.validateMaxLength(args); + await this.validateMaxLength(args); if (args.length === 1) { await this.validateInsideWorkspace(); } @@ -36,7 +36,7 @@ export class DownCommand extends BaseCommand { .filter(d => d != undefined); if (!dockerFilePaths.length) { - exitWithError(`Error: Could not find any running docker services for "${projectName}"`); + await exitWithError(`Error: Could not find any running docker services for "${projectName}"`); } return { diff --git a/src/commands/generate-page/generate-page-command.test.ts b/src/commands/generate-page/generate-page-command.test.ts index d01d502..e97f8be 100644 --- a/src/commands/generate-page/generate-page-command.test.ts +++ b/src/commands/generate-page/generate-page-command.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; import { GeneratePageCommand } from './generate-page.command'; -import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MAX_GEN_CODE_TIME, MockConstants, mockInquire } from '../../__testing__'; +import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, inquireMock } from '../../__testing__'; import { InquirerUtilities } from '../../encapsulation'; import { AddAngularWebsiteCommand } from '../add/add-angular-website'; import { AddConfiguration, AddType } from '../add/models'; @@ -11,7 +11,7 @@ const mockConstants: MockConstants = getMockConstants('generate-page-command'); describe('GeneratePageCommand', () => { beforeEach(async () => { await FileMockUtilities.setup(mockConstants); - InquirerUtilities['inquire'] = jest.fn(mockInquire({ + InquirerUtilities['inquire'] = jest.fn(inquireMock({ Project: 'website', 'Page name': 'dashboard', Route: 'dashboard', @@ -31,7 +31,7 @@ describe('GeneratePageCommand', () => { const command: GeneratePageCommand = new GeneratePageCommand(); await command.start(['gp']); expect(true).toBe(true); - }, MAX_ADD_TIME + MAX_GEN_CODE_TIME); + }, MAX_ADD_TIME); afterEach(() => { jest.restoreAllMocks(); diff --git a/src/commands/init/init-command.test.ts b/src/commands/init/init-command.test.ts index fd7d8eb..dbb212e 100644 --- a/src/commands/init/init-command.test.ts +++ b/src/commands/init/init-command.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; import { InitCommand } from './init.command'; -import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, mockInquire } from '../../__testing__'; +import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, inquireMock } from '../../__testing__'; import { InquirerUtilities } from '../../encapsulation'; const mockConstants: MockConstants = getMockConstants('init-command'); @@ -9,8 +9,9 @@ const mockConstants: MockConstants = getMockConstants('init-command'); describe('InitCommand', () => { beforeEach(async () => { await FileMockUtilities.setup(mockConstants, []); - InquirerUtilities['inquire'] = jest.fn(mockInquire({ + InquirerUtilities['inquire'] = jest.fn(inquireMock({ 'prod root domain (eg. "test.com")': 'test.com', + 'stage root domain (eg. "test-staging.com")': 'test-staging.com', 'E-Mail (needed for ssl certificates)': 'user@test.com', 'Setup Github Actions?': true })); diff --git a/src/commands/init/init-configuration.model.ts b/src/commands/init/init-configuration.model.ts index 459f7ec..7c20beb 100644 --- a/src/commands/init/init-configuration.model.ts +++ b/src/commands/init/init-configuration.model.ts @@ -8,6 +8,10 @@ export type InitConfiguration = { * The root domain to use in production. */ prodRootDomain: string, + /** + * The root domain to use on stage. + */ + stageRootDomain: string, /** * The email of the user. * Is needed for lets encrypt configuration. @@ -28,6 +32,11 @@ export const initConfigQuestions: QuestionsFor = { message: 'prod root domain (eg. "test.com")', required: true }, + stageRootDomain: { + type: 'input', + message: 'stage root domain (eg. "test-staging.com")', + required: true + }, email: { type: 'input', message: 'E-Mail (needed for ssl certificates)', diff --git a/src/commands/init/init.command.ts b/src/commands/init/init.command.ts index ce65093..7cad048 100644 --- a/src/commands/init/init.command.ts +++ b/src/commands/init/init.command.ts @@ -17,7 +17,7 @@ export class InitCommand extends BaseCommand { protected override async run(config: InitConfiguration): Promise { await NpmUtilities.init('root', false); - NpmUtilities.installInRoot([ + await NpmUtilities.installInRoot([ NpmPackage.ESLINT_CONFIG_SERVICE_SOFT, NpmPackage.ESLINT, NpmPackage.TAILWIND, @@ -25,7 +25,7 @@ export class InitCommand extends BaseCommand { NpmPackage.AUTOPREFIXER ], true); - await EnvUtilities.init(config.prodRootDomain); + await EnvUtilities.init(config.prodRootDomain, config.stageRootDomain, 'user', 'password'); await Promise.all([ WorkspaceUtilities.createConfig(), @@ -40,7 +40,7 @@ export class InitCommand extends BaseCommand { this.addNpmWorkspaces() ]); - CPUtilities.execSync('git init'); + await CPUtilities.exec('git init'); if (config.setupGithubActions) { await GithubUtilities.createWorkflow({ name: 'main', @@ -69,7 +69,7 @@ export class InitCommand extends BaseCommand { await super.validate(args); const config: WorkspaceConfig | undefined = await WorkspaceUtilities.getConfig(); if (config?.isWorkspace === true) { - exitWithError('Error: The current directory is already a monorepo workspace'); + await exitWithError('Error: The current directory is already a monorepo workspace'); } } @@ -106,7 +106,7 @@ export class InitCommand extends BaseCommand { '', '// eslint-disable-next-line jsdoc/require-description', '/** @type {import(\'eslint\').Linter.Config} */', - 'export default [...configs];' + 'export default [...configs , { ignores: [\'./global-environment.model.ts\'] }];' ]); } diff --git a/src/commands/list/list-command.test.ts b/src/commands/list/list-command.test.ts index b617244..117d348 100644 --- a/src/commands/list/list-command.test.ts +++ b/src/commands/list/list-command.test.ts @@ -1,7 +1,7 @@ import { describe, beforeEach, jest, test, afterEach } from '@jest/globals'; import { ListCommand } from './list.command'; -import { FileMockUtilities, getMockConstants, MAX_FAST_TIME, MockConstants, mockInquire } from '../../__testing__'; +import { FileMockUtilities, getMockConstants, MockConstants, inquireMock, MAX_FAST_TIME } from '../../__testing__'; import { InquirerUtilities } from '../../encapsulation'; import { DownCommand } from '../down'; import { UpCommand } from '../up'; @@ -21,7 +21,7 @@ describe('ListCommand', () => { ' - 8080:8080' ] }); - InquirerUtilities['inquire'] = jest.fn(mockInquire({ + InquirerUtilities['inquire'] = jest.fn(inquireMock({ env: 'dev.docker-compose.yaml' })); }); diff --git a/src/commands/list/list.command.ts b/src/commands/list/list.command.ts index 96926ac..67bea46 100644 --- a/src/commands/list/list.command.ts +++ b/src/commands/list/list.command.ts @@ -1,11 +1,12 @@ import path from 'path'; import { CLI_BASE_COMMAND } from '../../constants'; -import { DockerLabel, FullyParsedDockerService } from '../../docker'; +import { DockerLabel, FullyParsedDockerService, isDockerComposeFileName } from '../../docker'; import { getDockerServices } from '../../docker/get-docker-services.function'; import { ChalkUtilities, CliTable, CliTableUtilities } from '../../encapsulation'; import { BaseCommand } from '../base-command.model'; import { DockerServiceStatus } from './docker-service-status.model'; +import { EnvValue, envValueForDockerComposeFileName } from '../../env'; /** * Lists the docker services grouped by their monorepo. @@ -85,31 +86,21 @@ export class ListCommand extends BaseCommand { private getEnv(labels: Record, color: 'error' | 'success' | undefined): string { const dockerFile: string | undefined = labels['com.docker.compose.project.config_files']; - let res: 'local' | 'prod' | 'dev' | '-' = '-'; if (!dockerFile) { switch (color) { case 'success': { - return ChalkUtilities.success(res); + return ChalkUtilities.success('-'); } case 'error': { - return ChalkUtilities.error(res); + return ChalkUtilities.error('-'); } case undefined: { - return res; + return '-'; } } } const fileName: string = path.basename(dockerFile); - if (fileName === 'docker-compose.yaml' || fileName === 'docker-compose.yml') { - res = 'prod'; - } - if (fileName.startsWith('dev.')) { - res = 'dev'; - } - if (fileName.startsWith('local.')) { - res = 'local'; - } - + const res: EnvValue | '-' = isDockerComposeFileName(fileName) ? envValueForDockerComposeFileName[fileName] : '-'; if (res === '-') { // eslint-disable-next-line no-console console.error(ChalkUtilities.error('Could not determine environment for the docker compose file', fileName)); diff --git a/src/commands/prepare/prepare-command.test.ts b/src/commands/prepare/prepare-command.test.ts index 698856e..0decc5f 100644 --- a/src/commands/prepare/prepare-command.test.ts +++ b/src/commands/prepare/prepare-command.test.ts @@ -1,7 +1,7 @@ import { describe, beforeEach, jest, test, afterEach, expect } from '@jest/globals'; import { PrepareCommand } from './prepare.command'; -import { FileMockUtilities, getMockConstants, MAX_INSTANT_TIME, MockConstants, mockInquire } from '../../__testing__'; +import { FileMockUtilities, getMockConstants, MAX_INSTANT_TIME, MockConstants, inquireMock } from '../../__testing__'; import { InquirerUtilities } from '../../encapsulation'; const mockConstants: MockConstants = getMockConstants('prepare-command'); @@ -9,7 +9,7 @@ const mockConstants: MockConstants = getMockConstants('prepare-command'); describe('PrepareCommand', () => { beforeEach(async () => { await FileMockUtilities.setup(mockConstants); - InquirerUtilities['inquire'] = jest.fn(mockInquire({ + InquirerUtilities['inquire'] = jest.fn(inquireMock({ env: 'dev.docker-compose.yaml' })); }); diff --git a/src/commands/prepare/prepare-config.model.ts b/src/commands/prepare/prepare-config.model.ts index 3975cd1..7f85305 100644 --- a/src/commands/prepare/prepare-config.model.ts +++ b/src/commands/prepare/prepare-config.model.ts @@ -1,5 +1,7 @@ -import { DockerComposeFileName } from '../../constants'; +import { DEV_DOCKER_COMPOSE_FILE_NAME, LOCAL_DOCKER_COMPOSE_FILE_NAME, PROD_DOCKER_COMPOSE_FILE_NAME, STAGE_DOCKER_COMPOSE_FILE_NAME } from '../../constants'; +import { DockerComposeFileName } from '../../docker'; import { QuestionsFor } from '../../encapsulation'; +import { EnvValue } from '../../env'; /** * Configuration for the prepare command. @@ -15,12 +17,12 @@ export type PrepareConfig = { rootDir: string }; -// eslint-disable-next-line jsdoc/require-jsdoc -const choices: { name: string, value: DockerComposeFileName }[] = [ - { name: 'dev', value: 'dev.docker-compose.yaml' }, - { name: 'local', value: 'local.docker-compose.yaml' }, - { name: 'prod', value: 'docker-compose.yaml' } -]; +const choices: Record = { + [EnvValue.DEV]: DEV_DOCKER_COMPOSE_FILE_NAME, + [EnvValue.LOCAL]: LOCAL_DOCKER_COMPOSE_FILE_NAME, + [EnvValue.STAGE]: STAGE_DOCKER_COMPOSE_FILE_NAME, + [EnvValue.PROD]: PROD_DOCKER_COMPOSE_FILE_NAME +}; /** * Questions for getting the environment to run in. @@ -29,6 +31,6 @@ export const prepareConfigQuestions: QuestionsFor ({ name: choice[0], value: choice[1] })) } }; \ No newline at end of file diff --git a/src/commands/prepare/prepare.command.ts b/src/commands/prepare/prepare.command.ts index 615906d..d201b32 100644 --- a/src/commands/prepare/prepare.command.ts +++ b/src/commands/prepare/prepare.command.ts @@ -1,7 +1,7 @@ /* eslint-disable jsdoc/require-jsdoc */ import { PrepareConfig, prepareConfigQuestions } from './prepare-config.model'; -import { DockerComposeFileName } from '../../constants'; import { DbUtilities } from '../../db'; +import { DockerComposeFileName } from '../../docker'; import { InquirerUtilities } from '../../encapsulation'; import { EnvUtilities, EnvValidationErrorMessage } from '../../env'; import { RobotsUtilities } from '../../robots'; @@ -30,7 +30,7 @@ export class PrepareCommand extends BaseCommand { private async buildEnv(fileName: DockerComposeFileName, rootDir: string): Promise { const validationErrors: KeyValue[] = await EnvUtilities.validate(rootDir); if (validationErrors.length) { - exitWithError( + await exitWithError( 'Error when validating the .env file:\n' + validationErrors.map(e => `\t${e.key}: ${e.value}`).join('\n') ); diff --git a/src/commands/resolve-command.function.ts b/src/commands/resolve-command.function.ts index f9c423f..e7c434a 100644 --- a/src/commands/resolve-command.function.ts +++ b/src/commands/resolve-command.function.ts @@ -7,16 +7,16 @@ import { isCommand } from './is-command.function'; * @param args - The args provided by the cli. * @returns The resolved command. */ -export function resolveCommand(args: string[]): Command | 'run' { +export async function resolveCommand(args: string[]): Promise { if (args.length < 1) { - exitWithError('Error: You need to specify a command.'); + await exitWithError('Error: You need to specify a command.'); } const command: string = args[0]; if (!isCommand(command)) { if (args.length === 1) { - exitWithError(`Error: Unknown command ${command}.`); + await exitWithError(`Error: Unknown command ${command}.`); } return 'run'; } diff --git a/src/commands/run-all/run-all-command.test.ts b/src/commands/run-all/run-all-command.test.ts index 013fbe4..4048ab6 100644 --- a/src/commands/run-all/run-all-command.test.ts +++ b/src/commands/run-all/run-all-command.test.ts @@ -1,7 +1,7 @@ import { describe, beforeEach, jest, test, afterEach, expect } from '@jest/globals'; import { RunAllCommand } from './run-all.command'; -import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MAX_GEN_CODE_TIME, MockConstants, mockInquire } from '../../__testing__'; +import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, inquireMock } from '../../__testing__'; import { InquirerUtilities } from '../../encapsulation'; import { AddTsLibraryCommand } from '../add/add-ts-library'; import { AddType } from '../add/models'; @@ -11,7 +11,7 @@ const mockConstants: MockConstants = getMockConstants('run-all-command'); describe('RunAllCommand', () => { beforeEach(async () => { await FileMockUtilities.setup(mockConstants); - InquirerUtilities['inquire'] = jest.fn(mockInquire({ + InquirerUtilities['inquire'] = jest.fn(inquireMock({ scope: '@sandbox' })); }); @@ -25,7 +25,7 @@ describe('RunAllCommand', () => { const command: RunAllCommand = new RunAllCommand(); await command.start(['ra', 'build']); expect(true).toEqual(true); - }, MAX_ADD_TIME + MAX_GEN_CODE_TIME); + }, MAX_ADD_TIME); afterEach(() => { jest.restoreAllMocks(); diff --git a/src/commands/run-all/run-all.command.ts b/src/commands/run-all/run-all.command.ts index 67722b4..433b6f8 100644 --- a/src/commands/run-all/run-all.command.ts +++ b/src/commands/run-all/run-all.command.ts @@ -17,8 +17,8 @@ type RunAllConfiguration = { */ export class RunAllCommand extends BaseCommand { - protected override run(config: RunAllConfiguration): void { - NpmUtilities.runAll(config.npmScript); + protected override async run(config: RunAllConfiguration): Promise { + await NpmUtilities.runAll(config.npmScript); } protected override resolveInput(args: string[]): RunAllConfiguration { @@ -27,7 +27,7 @@ export class RunAllCommand extends BaseCommand { protected override async validate(args: string[]): Promise { if (args.length === 1) { - exitWithError('Error: No npm script specified to run in all projects.'); + await exitWithError('Error: No npm script specified to run in all projects.'); } await this.validateInsideWorkspace(); } diff --git a/src/commands/run/run-command.test.ts b/src/commands/run/run-command.test.ts index 8244e97..650a433 100644 --- a/src/commands/run/run-command.test.ts +++ b/src/commands/run/run-command.test.ts @@ -1,7 +1,7 @@ import { describe, beforeEach, jest, test, afterEach, expect } from '@jest/globals'; import { RunCommand } from './run.command'; -import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MAX_GEN_CODE_TIME, MockConstants, mockInquire } from '../../__testing__'; +import { FileMockUtilities, getMockConstants, MAX_ADD_TIME, MockConstants, inquireMock } from '../../__testing__'; import { DbType } from '../../db'; import { InquirerUtilities } from '../../encapsulation'; import { AddNestCommand } from '../add/add-nest'; @@ -12,7 +12,7 @@ const mockConstants: MockConstants = getMockConstants('run-command'); describe('RunCommand', () => { beforeEach(async () => { await FileMockUtilities.setup(mockConstants); - InquirerUtilities['inquire'] = jest.fn(mockInquire({ + InquirerUtilities['inquire'] = jest.fn(inquireMock({ 'sub domain': 'api', port: 3000, 'Email of the default user': 'test@test.com', @@ -33,7 +33,7 @@ describe('RunCommand', () => { const command: RunCommand = new RunCommand(); await command.start(['api', 'build']); expect(true).toEqual(true); - }, MAX_ADD_TIME + MAX_GEN_CODE_TIME); + }, MAX_ADD_TIME); afterEach(() => { jest.restoreAllMocks(); diff --git a/src/commands/run/run.command.ts b/src/commands/run/run.command.ts index 21baae7..91ba35a 100644 --- a/src/commands/run/run.command.ts +++ b/src/commands/run/run.command.ts @@ -34,7 +34,7 @@ export class RunCommand extends BaseCommand { const packageJson: Dirent | undefined = (await FsUtilities.readdir(foundProject.path)).find(f => f.name === PACKAGE_JSON_FILE_NAME); if (!packageJson) { - exitWithError(`The provided project "${project}" does not contain a ${PACKAGE_JSON_FILE_NAME} file`); + return await exitWithError(`The provided project "${project}" does not contain a ${PACKAGE_JSON_FILE_NAME} file`); } if (Object.values(NativeNpmCommands).includes(args[1] as NativeNpmCommands)) { @@ -45,7 +45,7 @@ export class RunCommand extends BaseCommand { const file: PackageJson = await FsUtilities.parseFileAs(getPath(packageJson.parentPath, packageJson.name)); if (!Object.keys(file.scripts).includes(npmScript)) { - exitWithError(`The project "${project}" does not contain the provided script "${npmScript}"`); + await exitWithError(`The project "${project}" does not contain the provided script "${npmScript}"`); } } } \ No newline at end of file diff --git a/src/commands/up/up-command.test.ts b/src/commands/up/up-command.test.ts index 8f8d07a..95e01e9 100644 --- a/src/commands/up/up-command.test.ts +++ b/src/commands/up/up-command.test.ts @@ -1,6 +1,6 @@ import { describe, beforeEach, jest, test, expect, afterEach } from '@jest/globals'; -import { FileMockUtilities, getMockConstants, MAX_FAST_TIME, MockConstants, mockInquire } from '../../__testing__'; +import { FileMockUtilities, getMockConstants, MockConstants, inquireMock } from '../../__testing__'; import { FullyParsedDockerService, getDockerServices } from '../../docker'; import { InquirerUtilities } from '../../encapsulation'; import { DownCommand } from '../down'; @@ -21,7 +21,7 @@ describe('UpCommand', () => { ' - 8080:8080' ] }); - InquirerUtilities['inquire'] = jest.fn(mockInquire({ + InquirerUtilities['inquire'] = jest.fn(inquireMock({ env: 'dev.docker-compose.yaml' })); }); @@ -41,7 +41,7 @@ describe('UpCommand', () => { const runningDockerServicesAfterDown: FullyParsedDockerService[] = await getDockerServices(false); expect(runningDockerServicesAfterDown.length).toEqual(runningDockerServicesBeforeUp.length); - }, MAX_FAST_TIME * 2 /** We call up and down. */); + }, 20000 /** We call up and down. */); afterEach(() => { jest.restoreAllMocks(); diff --git a/src/commands/up/up-configuration.model.ts b/src/commands/up/up-configuration.model.ts index dd22641..dd9f0f1 100644 --- a/src/commands/up/up-configuration.model.ts +++ b/src/commands/up/up-configuration.model.ts @@ -1,4 +1,4 @@ -import { DockerComposeFileName } from '../../constants'; +import { DockerComposeFileName } from '../../docker'; /** * Configuration for the up command. diff --git a/src/commands/up/up.command.ts b/src/commands/up/up.command.ts index 9ea8563..bb063c5 100644 --- a/src/commands/up/up.command.ts +++ b/src/commands/up/up.command.ts @@ -1,13 +1,13 @@ import { dirname } from 'path'; -import { DockerComposeFileName } from '../../constants'; -import { FullyParsedDockerService, getDockerServices } from '../../docker'; +import { DockerComposeFileName, FullyParsedDockerService, getDockerServices } from '../../docker'; import { CPUtilities, InquirerUtilities } from '../../encapsulation'; import { exitWithError, getPath } from '../../utilities'; import { WorkspaceUtilities } from '../../workspace'; import { BaseCommand } from '../base-command.model'; import { PrepareCommand, prepareConfigQuestions } from '../prepare'; import { UpConfiguration } from './up-configuration.model'; +import { ENV_FILE_NAME, ENV_PUBLIC_FILE_NAME } from '../../constants'; /** * Starts monorepo services. @@ -19,11 +19,13 @@ export class UpCommand extends BaseCommand { protected override async run(input: UpConfiguration): Promise { await new PrepareCommand()['run'](input); - CPUtilities.execSync(`docker compose -f ${input.dockerFilePath ?? input.fileName} -p ${input.projectName} up --build -d`); + const dockerFile: string = input.dockerFilePath ?? input.fileName; + const options: string = `-f ${dockerFile} -p ${input.projectName} --env-file ${ENV_FILE_NAME} --env-file ${ENV_PUBLIC_FILE_NAME}`; + await CPUtilities.exec(`docker compose ${options} up --build -d`); } protected override async validate(args: string[]): Promise { - this.validateMaxLength(args); + await this.validateMaxLength(args); if (args.length === 1) { await this.validateInsideWorkspace(); } @@ -39,7 +41,7 @@ export class UpCommand extends BaseCommand { rootDir = getPath('.'); } if (rootDir === undefined) { - exitWithError(`Error: Could not find root of "${projectName}"`); + return await exitWithError(`Error: Could not find root of "${projectName}"`); } const fileName: DockerComposeFileName = (await InquirerUtilities.prompt(prepareConfigQuestions)).fileName; diff --git a/src/commands/version/version.command.ts b/src/commands/version/version.command.ts index b898053..dce8d26 100644 --- a/src/commands/version/version.command.ts +++ b/src/commands/version/version.command.ts @@ -13,7 +13,7 @@ export class VersionCommand extends BaseCommand { const packageJsonPath: Path = getPath(__dirname, '..', '..', '..', PACKAGE_JSON_FILE_NAME); const pkg: PackageJson = await FsUtilities.parseFileAs(packageJsonPath); if (!pkg.version) { - exitWithError('Could not determine the currently running version of Monux'); + return await exitWithError('Could not determine the currently running version of Monux'); } console.log(ChalkUtilities.boldUnderline('Version:')); console.log(ChalkUtilities.secondary(pkg.version)); diff --git a/src/constants.ts b/src/constants.ts index 41b6e38..8243a98 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -46,6 +46,11 @@ export const DEV_DOCKER_COMPOSE_FILE_NAME: 'dev.docker-compose.yaml' = 'dev.dock */ export const LOCAL_DOCKER_COMPOSE_FILE_NAME: 'local.docker-compose.yaml' = 'local.docker-compose.yaml'; +/** + * The name of the stage docker compose file. + */ +export const STAGE_DOCKER_COMPOSE_FILE_NAME: 'stage.docker-compose.yaml' = 'stage.docker-compose.yaml'; + /** * The name of the docker compose file. */ @@ -56,6 +61,11 @@ export const DOCKER_FILE_NAME: string = 'Dockerfile'; */ export const ENV_FILE_NAME: string = '.env'; +/** + * The name of the .env-public file. + */ +export const ENV_PUBLIC_FILE_NAME: string = '.env.public'; + /** * The name of the environment.ts file. */ @@ -151,21 +161,4 @@ export const VITE_CONFIG: string = 'vite.config.ts'; */ export const MORE_INFORMATION_MESSAGE: string = `run ${ChalkUtilities.secondary( `${CLI_BASE_COMMAND} ${Command.HELP}` -)} for more information.`; - -/** - * The possible file names of the different docker-compose files Monux provides. - */ -// eslint-disable-next-line stylistic/max-len -export type DockerComposeFileName = typeof PROD_DOCKER_COMPOSE_FILE_NAME | typeof DEV_DOCKER_COMPOSE_FILE_NAME | typeof LOCAL_DOCKER_COMPOSE_FILE_NAME; - -const dockerComposeFileNameRecord: Record = { - 'dev.docker-compose.yaml': 'dev.docker-compose.yaml', - 'docker-compose.yaml': 'docker-compose.yaml', - 'local.docker-compose.yaml': 'local.docker-compose.yaml' -}; - -/** - * All possible docker compose file names as an array. - */ -export const dockerComposeFileNames: DockerComposeFileName[] = Object.values(dockerComposeFileNameRecord); \ No newline at end of file +)} for more information.`; \ No newline at end of file diff --git a/src/db/db.utilities.ts b/src/db/db.utilities.ts index 16908ef..c262443 100644 --- a/src/db/db.utilities.ts +++ b/src/db/db.utilities.ts @@ -1,7 +1,7 @@ import { Dirent } from 'fs'; -import { DATABASES_DIRECTORY_NAME, DEV_DOCKER_COMPOSE_FILE_NAME, DockerComposeFileName, GLOBAL_ENVIRONMENT_MODEL_FILE_NAME } from '../constants'; -import { ComposeService, DockerUtilities } from '../docker'; +import { DATABASES_DIRECTORY_NAME, DEV_DOCKER_COMPOSE_FILE_NAME, GLOBAL_ENVIRONMENT_MODEL_FILE_NAME } from '../constants'; +import { ComposeService, DockerComposeFileName, DockerUtilities } from '../docker'; import { FsUtilities, InquirerUtilities, JsonUtilities, QuestionsFor } from '../encapsulation'; import { DefaultEnvKeys, EnvironmentVariableKey, EnvUtilities } from '../env'; import { generatePlaceholderPassword, getPath, Path, toKebabCase, toSnakeCase } from '../utilities'; @@ -168,19 +168,19 @@ export abstract class DbUtilities { value: password, required: true, type: 'string' - }); + }, false); await EnvUtilities.addStaticVariable({ key: DefaultEnvKeys.dbUser(baseDbConfig.dbServiceName, baseDbConfig.databaseName), value: user, required: true, type: 'string' - }); + }, false); await EnvUtilities.addStaticVariable({ key: DefaultEnvKeys.dbName(baseDbConfig.dbServiceName, baseDbConfig.databaseName), value: baseDbConfig.databaseName, required: true, type: 'string' - }); + }, false); await this.addDbInitConfig(baseDbConfig.dbServiceName, { type, nameEnvVariable: DefaultEnvKeys.dbName(baseDbConfig.dbServiceName, baseDbConfig.databaseName), @@ -284,7 +284,7 @@ export abstract class DbUtilities { } ] }; - await DockerUtilities.addServiceToCompose(serviceDefinition, 3306, 3306, false, undefined); + await DockerUtilities.addServiceToCompose(serviceDefinition, 3306, 3306, false, false, undefined); await DockerUtilities.addVolumeToComposeFiles(`${toKebabCase(dbServiceName)}-data`); await DockerUtilities.addServiceToCompose( { @@ -294,6 +294,7 @@ export abstract class DbUtilities { 3306, 3306, false, + false, undefined, DEV_DOCKER_COMPOSE_FILE_NAME ); @@ -302,25 +303,25 @@ export abstract class DbUtilities { value: password, required: true, type: 'string' - }); + }, false); await EnvUtilities.addStaticVariable({ key: DefaultEnvKeys.dbUser(dbServiceName, databaseName), value: user, required: true, type: 'string' - }); + }, false); await EnvUtilities.addStaticVariable({ key: DefaultEnvKeys.dbName(dbServiceName, databaseName), value: databaseName, required: true, type: 'string' - }); + }, false); await EnvUtilities.addStaticVariable({ key: DefaultEnvKeys.dbRootPassword(dbServiceName), value: rootPassword, required: true, type: 'string' - }); + }, false); await EnvUtilities.addCalculatedVariable({ key: DefaultEnvKeys.dbHost(dbServiceName), @@ -331,6 +332,7 @@ export abstract class DbUtilities { case 'dev.docker-compose.yaml': { return 'localhost'; } + case 'stage.docker-compose.yaml': case 'docker-compose.yaml': case 'local.docker-compose.yaml': { return 'DB_SERVICE_NAME_PLACEHOLDER'; @@ -366,7 +368,7 @@ export abstract class DbUtilities { } ] }; - await DockerUtilities.addServiceToCompose(serviceDefinition, 5432, 5432, false); + await DockerUtilities.addServiceToCompose(serviceDefinition, 5432, 5432, false, false); await DockerUtilities.addVolumeToComposeFiles(`${toKebabCase(dbServiceName)}-data`); await DockerUtilities.addServiceToCompose( { @@ -376,6 +378,7 @@ export abstract class DbUtilities { 5432, 5432, false, + false, undefined, DEV_DOCKER_COMPOSE_FILE_NAME ); @@ -384,25 +387,25 @@ export abstract class DbUtilities { value: password, required: true, type: 'string' - }); + }, false); await EnvUtilities.addStaticVariable({ key: DefaultEnvKeys.dbUser(dbServiceName, databaseName), value: user, required: true, type: 'string' - }); + }, false); await EnvUtilities.addStaticVariable({ key: DefaultEnvKeys.dbName(dbServiceName, databaseName), value: databaseName, required: true, type: 'string' - }); + }, false); await EnvUtilities.addStaticVariable({ key: DefaultEnvKeys.dbRootPassword(dbServiceName), value: rootPassword, required: true, type: 'string' - }); + }, false); await EnvUtilities.addCalculatedVariable({ key: DefaultEnvKeys.dbHost(dbServiceName), @@ -413,6 +416,7 @@ export abstract class DbUtilities { case 'dev.docker-compose.yaml': { return 'localhost'; } + case 'stage.docker-compose.yaml': case 'docker-compose.yaml': case 'local.docker-compose.yaml': { return 'DB_SERVICE_NAME_PLACEHOLDER'; diff --git a/src/docker/compose-file.model.ts b/src/docker/compose-file.model.ts index 47723a9..af1c473 100644 --- a/src/docker/compose-file.model.ts +++ b/src/docker/compose-file.model.ts @@ -56,6 +56,10 @@ export type ComposeService = { * Environment variables to be used by the service. */ environment?: ComposeServiceEnvironment, + /** + * The entrypoint to use by the service. + */ + entrypoint?: string[], /** * The labels to use on the service. */ diff --git a/src/docker/docker-compose-file-name.model.ts b/src/docker/docker-compose-file-name.model.ts new file mode 100644 index 0000000..a3024c9 --- /dev/null +++ b/src/docker/docker-compose-file-name.model.ts @@ -0,0 +1,19 @@ +import { DEV_DOCKER_COMPOSE_FILE_NAME, LOCAL_DOCKER_COMPOSE_FILE_NAME, PROD_DOCKER_COMPOSE_FILE_NAME, STAGE_DOCKER_COMPOSE_FILE_NAME } from '../constants'; + +/** + * The possible file names of the different docker-compose files Monux provides. + */ +// eslint-disable-next-line stylistic/max-len +export type DockerComposeFileName = typeof PROD_DOCKER_COMPOSE_FILE_NAME | typeof DEV_DOCKER_COMPOSE_FILE_NAME | typeof LOCAL_DOCKER_COMPOSE_FILE_NAME | typeof STAGE_DOCKER_COMPOSE_FILE_NAME; + +const dockerComposeFileNameRecord: Record = { + [DEV_DOCKER_COMPOSE_FILE_NAME]: DEV_DOCKER_COMPOSE_FILE_NAME, + [PROD_DOCKER_COMPOSE_FILE_NAME]: PROD_DOCKER_COMPOSE_FILE_NAME, + [LOCAL_DOCKER_COMPOSE_FILE_NAME]: LOCAL_DOCKER_COMPOSE_FILE_NAME, + [STAGE_DOCKER_COMPOSE_FILE_NAME]: STAGE_DOCKER_COMPOSE_FILE_NAME +}; + +/** + * All possible docker compose file names as an array. + */ +export const dockerComposeFileNames: DockerComposeFileName[] = Object.values(dockerComposeFileNameRecord); \ No newline at end of file diff --git a/src/docker/docker-traefik.utilities.ts b/src/docker/docker-traefik.utilities.ts index 990dd65..11fd979 100644 --- a/src/docker/docker-traefik.utilities.ts +++ b/src/docker/docker-traefik.utilities.ts @@ -1,6 +1,38 @@ -import { DockerComposeFileName } from '../constants'; +import { DEV_DOCKER_COMPOSE_FILE_NAME, LOCAL_DOCKER_COMPOSE_FILE_NAME, PROD_DOCKER_COMPOSE_FILE_NAME, STAGE_DOCKER_COMPOSE_FILE_NAME } from '../constants'; import { DefaultEnvKeys } from '../env'; import { toSnakeCase } from '../utilities'; +import { DockerComposeFileName } from './docker-compose-file-name.model'; + +/** + * The traefik docker socket volume. Is needed for traefik to take control of routing. + */ +export const TRAEFIK_DOCKER_SOCK_VOLUME: string = '/var/run/docker.sock:/var/run/docker.sock:ro'; + +/** + * The traefik label for enabling routing for that docker service. + */ +export const TRAEFIK_ENABLE_LABEL: string = 'traefik.enable=true'; + +/** + * The traefik label for enabling compression. + */ +export const TRAEFIK_COMPRESSION_LABEL: string = 'traefik.http.middlewares.compression.compress=true'; + +/** + * The traefik docker image, with version. + */ +export const TRAEFIK_DOCKER_IMAGE: string = 'traefik:v3.2'; + +/** + * The base traefik commands. + * Includes things such as http and https ports etc. + */ +export const TRAEFIK_BASE_DOCKER_COMMANDS: string[] = [ + '--providers.docker=true', + '--providers.docker.exposedbydefault=false', + '--entryPoints.web.address=:80', + '--entryPoints.websecure.address=:443' +]; /** * Encapsulates functionality for getting docker traefik labels. @@ -13,6 +45,7 @@ export abstract class DockerTraefikUtilities { * @param port - The port where the service runs in development. * @param composeFileName - The name of the compose file to get the traefik labels for. * @param subDomain - The sub domain of the service. + * @param useBasicAuth - ONLY VALID FOR THE STAGE DOCKER COMPOSE FILE. Whether or not to use basic auth. * @returns The traefik docker labels for the service as an array of string. * @throws When the sub domain provided is www, as this is reserved. */ @@ -20,26 +53,67 @@ export abstract class DockerTraefikUtilities { projectName: string, port: number, composeFileName: DockerComposeFileName, - subDomain: string | undefined + subDomain: string | undefined, + useBasicAuth: boolean ): string[] { if (subDomain === 'www') { throw new Error('The subdomain "www" is reserved and will be set automatically.'); } switch (composeFileName) { - case 'dev.docker-compose.yaml': { + case DEV_DOCKER_COMPOSE_FILE_NAME: { return []; } - case 'local.docker-compose.yaml': { + case LOCAL_DOCKER_COMPOSE_FILE_NAME: { return this.getTraefikLabelsForLocal(projectName, subDomain, port); } - case 'docker-compose.yaml': { + case STAGE_DOCKER_COMPOSE_FILE_NAME: { + return this.getTraefikLabelsForStage(projectName, subDomain, port, useBasicAuth); + } + case PROD_DOCKER_COMPOSE_FILE_NAME: { return this.getTraefikLabelsForProd(projectName, subDomain, port); } } } - private static getTraefikLabelsForProd(projectName: string, subDomain: string | undefined, port: number): string[] { + private static getTraefikLabelsForStage( + projectName: string, + subDomain: string | undefined, + port: number, + traefikBasicAuth: boolean + ): string[] { + let host: string = `Host(\`\${${DefaultEnvKeys.subDomain(projectName)}}.\${${DefaultEnvKeys.STAGE_ROOT_DOMAIN}}\`)`; + const labels: string[] = []; + const middlewares: string[] = ['compression']; + if (traefikBasicAuth) { + labels.push('traefik.http.middlewares.auth.basicauth.usersfile=/config/.htpasswd'); + middlewares.push('auth'); + } + if (!subDomain) { + host = `Host(\`\${${DefaultEnvKeys.STAGE_ROOT_DOMAIN}}\`) || Host(\`www.\${${DefaultEnvKeys.STAGE_ROOT_DOMAIN}}\`)`; + labels.push( + 'traefik.http.middlewares.wwwredirect.redirectregex.regex=^https://www\.(.*)', + 'traefik.http.middlewares.wwwredirect.redirectregex.replacement=https://$${1}' + ); + middlewares.push('wwwredirect'); + } + labels.push( + TRAEFIK_ENABLE_LABEL, + `traefik.http.routers.${toSnakeCase(projectName)}.rule=${host}`, + `traefik.http.routers.${toSnakeCase(projectName)}.entrypoints=web_secure`, + `traefik.http.routers.${toSnakeCase(projectName)}.tls.certresolver=ssl_resolver`, + `traefik.http.services.${toSnakeCase(projectName)}.loadbalancer.server.port=${port}`, + TRAEFIK_COMPRESSION_LABEL + ); + labels.push(`traefik.http.routers.${toSnakeCase(projectName)}.middlewares=${middlewares.join(',')}`); + return labels; + } + + private static getTraefikLabelsForProd( + projectName: string, + subDomain: string | undefined, + port: number + ): string[] { let host: string = `Host(\`\${${DefaultEnvKeys.subDomain(projectName)}}.\${${DefaultEnvKeys.PROD_ROOT_DOMAIN}}\`)`; const labels: string[] = []; const middlewares: string[] = ['compression']; @@ -52,12 +126,12 @@ export abstract class DockerTraefikUtilities { middlewares.push('wwwredirect'); } labels.push( - 'traefik.enable=true', + TRAEFIK_ENABLE_LABEL, `traefik.http.routers.${toSnakeCase(projectName)}.rule=${host}`, `traefik.http.routers.${toSnakeCase(projectName)}.entrypoints=web_secure`, `traefik.http.routers.${toSnakeCase(projectName)}.tls.certresolver=ssl_resolver`, `traefik.http.services.${toSnakeCase(projectName)}.loadbalancer.server.port=${port}`, - 'traefik.http.middlewares.compression.compress=true' + TRAEFIK_COMPRESSION_LABEL ); labels.push(`traefik.http.routers.${toSnakeCase(projectName)}.middlewares=${middlewares.join(',')}`); return labels; @@ -76,11 +150,11 @@ export abstract class DockerTraefikUtilities { middlewares.push('wwwredirect'); } labels.push( - 'traefik.enable=true', + TRAEFIK_ENABLE_LABEL, `traefik.http.routers.${toSnakeCase(projectName)}.rule=${host}`, `traefik.http.routers.${toSnakeCase(projectName)}.entrypoints=web`, `traefik.http.services.${toSnakeCase(projectName)}.loadbalancer.server.port=${port}`, - 'traefik.http.middlewares.compression.compress=true' + TRAEFIK_COMPRESSION_LABEL ); labels.push(`traefik.http.routers.${toSnakeCase(projectName)}.middlewares=${middlewares.join(',')}`); return labels; diff --git a/src/docker/docker-utilities.test.ts b/src/docker/docker-utilities.test.ts index 28fd8b0..1abbc19 100644 --- a/src/docker/docker-utilities.test.ts +++ b/src/docker/docker-utilities.test.ts @@ -6,6 +6,7 @@ import { fakeComposeService, FileMockUtilities, getMockConstants, MockConstants import { FsUtilities } from '../encapsulation'; import { ComposeDefinition, ComposeService } from './compose-file.model'; import { EnvUtilities } from '../env'; +import { TRAEFIK_COMPRESSION_LABEL, TRAEFIK_ENABLE_LABEL } from './docker-traefik.utilities'; const mockConstants: MockConstants = getMockConstants('docker-utilities'); @@ -14,7 +15,7 @@ describe('DockerUtilities', () => { await FileMockUtilities.setup(mockConstants, []); const fakeEmail: string = faker.internet.email(); - await EnvUtilities.init('test.com'); + await EnvUtilities.init('test.com', 'test-staging.com', 'user', 'password'); await DockerUtilities.createComposeFiles(fakeEmail); const initialDockerComposeContent: string[] = await FsUtilities.readFileLines(mockConstants.DOCKER_COMPOSE_YAML); @@ -28,9 +29,9 @@ describe('DockerUtilities', () => { ' - --providers.docker=true', ' - --providers.docker.exposedbydefault=false', ' - --entryPoints.web.address=:80', + ' - --entryPoints.websecure.address=:443', ' - --entrypoints.web.http.redirections.entrypoint.to=websecure', ' - --entryPoints.web.http.redirections.entrypoint.scheme=https', - ' - --entryPoints.websecure.address=:443', ' - --entrypoints.websecure.asDefault=true', ' - --certificatesresolvers.sslresolver.acme.httpchallenge=true', ' - --certificatesresolvers.sslresolver.acme.httpchallenge.entrypoint=web', @@ -47,19 +48,19 @@ describe('DockerUtilities', () => { test('createDockerCompose with prod service', async () => { const def: ComposeService = fakeComposeService(); - await DockerUtilities.addServiceToCompose(def, 4000, 4200, true, def.name); + await DockerUtilities.addServiceToCompose(def, 4000, 4200, true, true, def.name); const prodFileContent: ComposeDefinition = await DockerUtilities['yamlToComposeDefinition'](mockConstants.DOCKER_COMPOSE_YAML); const prodService: ComposeService = prodFileContent.services[1]; expect({ ...def, labels: [ ...def.labels ?? [], - 'traefik.enable=true', + TRAEFIK_ENABLE_LABEL, `traefik.http.routers.${def.name}.rule=Host(\`\${${def.name}_sub_domain}.\${prod_root_domain}\`)`, `traefik.http.routers.${def.name}.entrypoints=web_secure`, `traefik.http.routers.${def.name}.tls.certresolver=ssl_resolver`, `traefik.http.services.${def.name}.loadbalancer.server.port=4000`, - 'traefik.http.middlewares.compression.compress=true', + TRAEFIK_COMPRESSION_LABEL, `traefik.http.routers.${def.name}.middlewares=compression` ] }).toEqual(prodService); @@ -70,11 +71,11 @@ describe('DockerUtilities', () => { ...def, labels: [ ...def.labels ?? [], - 'traefik.enable=true', + TRAEFIK_ENABLE_LABEL, `traefik.http.routers.${def.name}.rule=Host(\`\${${def.name}_sub_domain}.localhost\`)`, `traefik.http.routers.${def.name}.entrypoints=web`, `traefik.http.services.${def.name}.loadbalancer.server.port=4000`, - 'traefik.http.middlewares.compression.compress=true', + TRAEFIK_COMPRESSION_LABEL, `traefik.http.routers.${def.name}.middlewares=compression` ] }).toEqual(localService); diff --git a/src/docker/docker.utilities.ts b/src/docker/docker.utilities.ts index de00238..3910351 100644 --- a/src/docker/docker.utilities.ts +++ b/src/docker/docker.utilities.ts @@ -1,48 +1,13 @@ import yaml from 'js-yaml'; -import { DEV_DOCKER_COMPOSE_FILE_NAME, PROD_DOCKER_COMPOSE_FILE_NAME, LOCAL_DOCKER_COMPOSE_FILE_NAME, DockerComposeFileName, GLOBAL_ENVIRONMENT_MODEL_FILE_NAME, dockerComposeFileNames } from '../constants'; +import { DEV_DOCKER_COMPOSE_FILE_NAME, PROD_DOCKER_COMPOSE_FILE_NAME, LOCAL_DOCKER_COMPOSE_FILE_NAME, GLOBAL_ENVIRONMENT_MODEL_FILE_NAME, STAGE_DOCKER_COMPOSE_FILE_NAME } from '../constants'; import { FsUtilities } from '../encapsulation'; import { ComposeBuild, ComposeDefinition, ComposePort, ComposeService, ComposeServiceEnvironment } from './compose-file.model'; import { DefaultEnvKeys, EnvUtilities } from '../env'; -import { OmitStrict } from '../types'; import { getPath, Path } from '../utilities'; -import { DockerTraefikUtilities } from './docker-traefik.utilities'; - -// eslint-disable-next-line jsdoc/require-jsdoc -type ParsedDockerComposeEnvironment = { [key: string]: string } | string[]; - -// eslint-disable-next-line jsdoc/require-jsdoc -type ParsedDockerComposeServiceNetwork = string[] | Record; - -// eslint-disable-next-line jsdoc/require-jsdoc -type ParsedDockerComposeService = OmitStrict & { - // eslint-disable-next-line jsdoc/require-jsdoc - volumes?: string[], - // eslint-disable-next-line jsdoc/require-jsdoc - environment?: ParsedDockerComposeEnvironment, - // eslint-disable-next-line jsdoc/require-jsdoc - networks?: ParsedDockerComposeServiceNetwork, - // eslint-disable-next-line jsdoc/require-jsdoc - ports?: string[] -}; - -// eslint-disable-next-line jsdoc/require-jsdoc -type ParsedDockerCompose = { - // eslint-disable-next-line jsdoc/require-jsdoc - version?: string, - // eslint-disable-next-line jsdoc/require-jsdoc - services?: { - [serviceName: string]: ParsedDockerComposeService - }, - // eslint-disable-next-line jsdoc/require-jsdoc - volumes?: { - [volumeName: string]: unknown - }, - // eslint-disable-next-line jsdoc/require-jsdoc - networks?: { - [networkName: string]: unknown - } -}; +import { DockerComposeFileName, dockerComposeFileNames } from './docker-compose-file-name.model'; +import { DockerTraefikUtilities, TRAEFIK_BASE_DOCKER_COMMANDS, TRAEFIK_DOCKER_IMAGE, TRAEFIK_DOCKER_SOCK_VOLUME } from './docker-traefik.utilities'; +import { ParsedDockerCompose, ParsedDockerComposeEnvironment, ParsedDockerComposeService, ParsedDockerComposeServiceNetwork } from './parsed-docker-compose.model'; /** * Utilities for docker specific code generation/manipulation. @@ -68,6 +33,10 @@ export abstract class DockerUtilities { } case LOCAL_DOCKER_COMPOSE_FILE_NAME: { await this.createLocalDockerCompose(); + return; + } + case STAGE_DOCKER_COMPOSE_FILE_NAME: { + await this.createStageDockerCompose(email); } } }) @@ -99,13 +68,45 @@ export abstract class DockerUtilities { const compose: ComposeDefinition = { services: [ { - image: 'traefik:v3.2', + image: TRAEFIK_DOCKER_IMAGE, + name: 'traefik', + command: TRAEFIK_BASE_DOCKER_COMMANDS, + ports: [ + { + internal: 80, + external: 80 + }, + { + internal: 443, + external: 443 + } + ], + volumes: [TRAEFIK_DOCKER_SOCK_VOLUME], + labels: [] + } + ], + volumes: [], + networks: [] + }; + const yaml: string[] = this.composeDefinitionToYaml(compose); + await FsUtilities.createFile(getPath(LOCAL_DOCKER_COMPOSE_FILE_NAME), yaml); + } + + private static async createStageDockerCompose(email: string): Promise { + const compose: ComposeDefinition = { + services: [ + { + image: TRAEFIK_DOCKER_IMAGE, name: 'traefik', command: [ - '--providers.docker=true', - '--providers.docker.exposedbydefault=false', - '--entryPoints.web.address=:80', - '--entryPoints.websecure.address=:443' + ...TRAEFIK_BASE_DOCKER_COMMANDS, + '--entrypoints.web.http.redirections.entrypoint.to=websecure', + '--entryPoints.web.http.redirections.entrypoint.scheme=https', + '--entrypoints.websecure.asDefault=true', + '--certificatesresolvers.sslresolver.acme.httpchallenge=true', + '--certificatesresolvers.sslresolver.acme.httpchallenge.entrypoint=web', + `--certificatesresolvers.sslresolver.acme.email=${email}`, + '--certificatesresolvers.sslresolver.acme.storage=/letsencrypt/acme.json' ], ports: [ { @@ -117,7 +118,27 @@ export abstract class DockerUtilities { external: 443 } ], - volumes: ['/var/run/docker.sock:/var/run/docker.sock:ro'], + volumes: [ + './config:/config', + './letsencrypt:/letsencrypt', + TRAEFIK_DOCKER_SOCK_VOLUME + ], + environment: [ + { + key: DefaultEnvKeys.BASIC_AUTH_USER, + value: `\${${DefaultEnvKeys.BASIC_AUTH_USER}}` + }, + { + key: DefaultEnvKeys.BASIC_AUTH_PASSWORD, + value: `\${${DefaultEnvKeys.BASIC_AUTH_PASSWORD}}` + } + ], + entrypoint: [ + '/bin/sh', + '-c', + // eslint-disable-next-line stylistic/max-len + `htpasswd -nbB "$$${DefaultEnvKeys.BASIC_AUTH_USER}" "$$${DefaultEnvKeys.BASIC_AUTH_PASSWORD}" > /config/.htpasswd && exec traefik` + ], labels: [] } ], @@ -125,22 +146,19 @@ export abstract class DockerUtilities { networks: [] }; const yaml: string[] = this.composeDefinitionToYaml(compose); - await FsUtilities.createFile(getPath(LOCAL_DOCKER_COMPOSE_FILE_NAME), yaml); + await FsUtilities.createFile(getPath(STAGE_DOCKER_COMPOSE_FILE_NAME), yaml); } private static async createProdDockerCompose(email: string): Promise { const compose: ComposeDefinition = { services: [ { - image: 'traefik:v3.2', + image: TRAEFIK_DOCKER_IMAGE, name: 'traefik', command: [ - '--providers.docker=true', - '--providers.docker.exposedbydefault=false', - '--entryPoints.web.address=:80', + ...TRAEFIK_BASE_DOCKER_COMMANDS, '--entrypoints.web.http.redirections.entrypoint.to=websecure', '--entryPoints.web.http.redirections.entrypoint.scheme=https', - '--entryPoints.websecure.address=:443', '--entrypoints.websecure.asDefault=true', '--certificatesresolvers.sslresolver.acme.httpchallenge=true', '--certificatesresolvers.sslresolver.acme.httpchallenge.entrypoint=web', @@ -159,7 +177,7 @@ export abstract class DockerUtilities { ], volumes: [ './letsencrypt:/letsencrypt', - '/var/run/docker.sock:/var/run/docker.sock:ro' + TRAEFIK_DOCKER_SOCK_VOLUME ], labels: [] } @@ -177,6 +195,7 @@ export abstract class DockerUtilities { * @param port - The port used in local and prod. * @param devPort - The port used in development. * @param addTraefik - Whether or not the service should be exposed via traefik. + * @param traefikBasicAuth - ONLY VALID FOR THE STAGE DOCKER COMPOSE FILE. Whether or not to use basic auth. * @param subDomain - The domain of the service. Optional. * Defaults to "" (which creates the file in the current directory). * @param composeFileName - The name of the compose file. @@ -187,6 +206,7 @@ export abstract class DockerUtilities { port: number, devPort: number, addTraefik: boolean, + traefikBasicAuth: boolean, subDomain?: string, composeFileName: DockerComposeFileName = PROD_DOCKER_COMPOSE_FILE_NAME ): Promise { @@ -195,7 +215,13 @@ export abstract class DockerUtilities { const labels: string[] = []; if (addTraefik) { - const traefikLabels: string[] = DockerTraefikUtilities.getTraefikLabels(service.name, port, composeFileName, subDomain); + const traefikLabels: string[] = DockerTraefikUtilities.getTraefikLabels( + service.name, + port, + composeFileName, + subDomain, + traefikBasicAuth + ); labels.push(...traefikLabels); } @@ -203,18 +229,21 @@ export abstract class DockerUtilities { await FsUtilities.updateFile(composePath, this.composeDefinitionToYaml(definition), 'replace'); if (composeFileName === PROD_DOCKER_COMPOSE_FILE_NAME) { - await this.addServiceToCompose(service, port, devPort, addTraefik, subDomain, LOCAL_DOCKER_COMPOSE_FILE_NAME); + await this.addServiceToCompose(service, port, devPort, addTraefik, false, subDomain, LOCAL_DOCKER_COMPOSE_FILE_NAME); + await this.addServiceToCompose(service, port, devPort, addTraefik, false, subDomain, STAGE_DOCKER_COMPOSE_FILE_NAME); if (!addTraefik) { return; } await EnvUtilities.addStaticVariable( - { key: DefaultEnvKeys.port(service.name), value: devPort, required: true, type: 'number' } + { key: DefaultEnvKeys.port(service.name), value: devPort, required: true, type: 'number' }, + true ); if (subDomain) { await EnvUtilities.addStaticVariable( - { key: DefaultEnvKeys.subDomain(service.name), value: subDomain, required: true, type: 'string' } + { key: DefaultEnvKeys.subDomain(service.name), value: subDomain, required: true, type: 'string' }, + true ); await EnvUtilities.addCalculatedVariable( { @@ -233,6 +262,10 @@ export abstract class DockerUtilities { case 'docker-compose.yaml': { return `https://${'SUB_DOMAIN_PLACEHOLDER'}.${'PROD_ROOT_DOMAIN_PLACEHOLDER'}`; } + // eslint-disable-next-line sonar/no-duplicate-string + case 'stage.docker-compose.yaml': { + return `https://${'SUB_DOMAIN_PLACEHOLDER'}.${'STAGE_ROOT_DOMAIN_PLACEHOLDER'}`; + } } }, required: true, @@ -251,9 +284,11 @@ export abstract class DockerUtilities { return `${'SUB_DOMAIN_PLACEHOLDER'}.localhost`; } case 'docker-compose.yaml': { - return `${'SUB_DOMAIN_PLACEHOLDER'}.${'PROD_ROOT_DOMAIN_PLACEHOLDER'}`; } + case 'stage.docker-compose.yaml': { + return `${'SUB_DOMAIN_PLACEHOLDER'}.${'STAGE_ROOT_DOMAIN_PLACEHOLDER'}`; + } } }, required: true, @@ -276,6 +311,9 @@ export abstract class DockerUtilities { case 'docker-compose.yaml': { return `https://${'PROD_ROOT_DOMAIN_PLACEHOLDER'}`; } + case 'stage.docker-compose.yaml': { + return `https://${'STAGE_ROOT_DOMAIN_PLACEHOLDER'}`; + } } }, required: true, @@ -296,6 +334,9 @@ export abstract class DockerUtilities { case 'docker-compose.yaml': { return 'PROD_ROOT_DOMAIN_PLACEHOLDER'; } + case 'stage.docker-compose.yaml': { + return 'STAGE_ROOT_DOMAIN_PLACEHOLDER'; + } } }, required: true, @@ -317,6 +358,11 @@ export abstract class DockerUtilities { '\'PROD_ROOT_DOMAIN_PLACEHOLDER\'', `env.${DefaultEnvKeys.PROD_ROOT_DOMAIN}` ); + await FsUtilities.replaceAllInFile( + environmentModelFilePath, + '\'STAGE_ROOT_DOMAIN_PLACEHOLDER\'', + `env.${DefaultEnvKeys.STAGE_ROOT_DOMAIN}` + ); } /** diff --git a/src/docker/index.ts b/src/docker/index.ts index 165a80e..1c50f5f 100644 --- a/src/docker/index.ts +++ b/src/docker/index.ts @@ -2,4 +2,6 @@ export * from './docker.utilities'; export * from './compose-file.model'; export * from './docker-labels.enum'; export * from './get-docker-services.function'; -export * from './stringified-docker-service.model'; \ No newline at end of file +export * from './stringified-docker-service.model'; +export * from './is-docker-compose-file-name.function'; +export * from './docker-compose-file-name.model'; \ No newline at end of file diff --git a/src/docker/is-docker-compose-file-name.function.ts b/src/docker/is-docker-compose-file-name.function.ts new file mode 100644 index 0000000..5db97b9 --- /dev/null +++ b/src/docker/is-docker-compose-file-name.function.ts @@ -0,0 +1,10 @@ +import { DockerComposeFileName, dockerComposeFileNames } from './docker-compose-file-name.model'; + +/** + * Checks whether or not the given string is a docker compose file name. + * @param value - The value to check. + * @returns True when the value is included in the known docker compose file names, false otherwise. + */ +export function isDockerComposeFileName(value: string): value is DockerComposeFileName { + return dockerComposeFileNames.includes(value as DockerComposeFileName); +} \ No newline at end of file diff --git a/src/docker/parsed-docker-compose.model.ts b/src/docker/parsed-docker-compose.model.ts new file mode 100644 index 0000000..acf2a1e --- /dev/null +++ b/src/docker/parsed-docker-compose.model.ts @@ -0,0 +1,38 @@ +import { OmitStrict } from '../types'; +import { ComposeService } from './compose-file.model'; + +// eslint-disable-next-line jsdoc/require-jsdoc +export type ParsedDockerComposeEnvironment = { [key: string]: string } | string[]; + +// eslint-disable-next-line jsdoc/require-jsdoc +export type ParsedDockerComposeServiceNetwork = string[] | Record; + +// eslint-disable-next-line jsdoc/require-jsdoc +export type ParsedDockerComposeService = OmitStrict & { + // eslint-disable-next-line jsdoc/require-jsdoc + volumes?: string[], + // eslint-disable-next-line jsdoc/require-jsdoc + environment?: ParsedDockerComposeEnvironment, + // eslint-disable-next-line jsdoc/require-jsdoc + networks?: ParsedDockerComposeServiceNetwork, + // eslint-disable-next-line jsdoc/require-jsdoc + ports?: string[] +}; + +// eslint-disable-next-line jsdoc/require-jsdoc +export type ParsedDockerCompose = { + // eslint-disable-next-line jsdoc/require-jsdoc + version?: string, + // eslint-disable-next-line jsdoc/require-jsdoc + services?: { + [serviceName: string]: ParsedDockerComposeService + }, + // eslint-disable-next-line jsdoc/require-jsdoc + volumes?: { + [volumeName: string]: unknown + }, + // eslint-disable-next-line jsdoc/require-jsdoc + networks?: { + [networkName: string]: unknown + } +}; \ No newline at end of file diff --git a/src/encapsulation/cp.utilities.ts b/src/encapsulation/cp.utilities.ts index 5403432..40ba3a2 100644 --- a/src/encapsulation/cp.utilities.ts +++ b/src/encapsulation/cp.utilities.ts @@ -1,7 +1,7 @@ import { execSync, ExecSyncOptions } from 'child_process'; import { ChalkUtilities } from './chalk.utilities'; -import { exitWithInterrupt, isErrorWithSignal, isExitPromptError } from '../utilities'; +import { exitGracefully, exitWithInterrupt, isErrorWithSignal, isExitPromptError } from '../utilities'; /** * Encapsulates functionality of the child_process package. @@ -20,7 +20,7 @@ export abstract class CPUtilities { * @param output - Whether or not the output of the command should be passed to the console. * @throws When there was an error during execution. */ - static execSync(command: string, output: boolean = true): void { + static async exec(command: string, output: boolean = true): Promise { const options: ExecSyncOptions = { stdio: output ? 'inherit' : undefined, killSignal: 'SIGINT', @@ -31,11 +31,11 @@ export abstract class CPUtilities { } catch (error) { if (isExitPromptError(error) || (isErrorWithSignal(error) && error.signal === 'SIGINT')) { - exitWithInterrupt(); + await exitWithInterrupt(); } // eslint-disable-next-line no-console console.error(ChalkUtilities.error(`Command failed: ${command}`)); - process.exit(0); + await exitGracefully(1); } } } \ No newline at end of file diff --git a/src/encapsulation/inquirer.utilities.ts b/src/encapsulation/inquirer.utilities.ts index 8b30e59..2d34a22 100644 --- a/src/encapsulation/inquirer.utilities.ts +++ b/src/encapsulation/inquirer.utilities.ts @@ -35,7 +35,7 @@ export abstract class InquirerUtilities { } catch (error) { if (isExitPromptError(error) || (isErrorWithSignal(error) && error.signal === 'SIGINT')) { - exitWithInterrupt(); + return await exitWithInterrupt(); } else { throw error; diff --git a/src/encapsulation/json.utilities.ts b/src/encapsulation/json.utilities.ts index c7dc39b..635b08c 100644 --- a/src/encapsulation/json.utilities.ts +++ b/src/encapsulation/json.utilities.ts @@ -20,7 +20,7 @@ export abstract class JsonUtilities { return json5.parse(value); } catch (error) { - throw new Error(`Could not parse value:\n ${this.stringify(value)}\n${error}`); + throw new Error(`Could not parse value:\n ${value}\n${error}`); } } @@ -28,9 +28,15 @@ export abstract class JsonUtilities { * Stringifies the given value into a json string. * @param value - The value to stringify. * @returns The json string, formatted with 4 spaces. + * @throws */ static stringify(value: T): string { - return JSON.stringify(value, undefined, this.indent); + try { + return JSON.stringify(value, undefined, this.indent); + } + catch (error) { + throw new Error(`Could not stringify value:\n ${value}\n${error}`); + } } /** diff --git a/src/env/default-environment-keys.ts b/src/env/default-environment-keys.ts index 116a763..977511f 100644 --- a/src/env/default-environment-keys.ts +++ b/src/env/default-environment-keys.ts @@ -6,13 +6,25 @@ import { EnvironmentVariableKey } from './environment-variable-key.model'; */ export abstract class DefaultEnvKeys { /** - * The variable that defines if eg. Robots.txt should be genereated that allow crawling. + * The variable that defines the currently used environment. */ - static readonly IS_PUBLIC: 'is_public' = 'is_public'; + static readonly ENV: 'env' = 'env'; /** - * The variable that define the root domain to use in production, like test.com. + * The variable that defines the root domain to use in production, like test.com. */ static readonly PROD_ROOT_DOMAIN: 'prod_root_domain' = 'prod_root_domain'; + /** + * The variable that defines the root domain to use in stage, like my-test-site.com. + */ + static readonly STAGE_ROOT_DOMAIN: 'stage_root_domain' = 'stage_root_domain'; + /** + * The username of the basic auth user for stage. + */ + static readonly BASIC_AUTH_USER: 'basic_auth_user' = 'basic_auth_user'; + /** + * The password of the basic auth user for stage. + */ + static readonly BASIC_AUTH_PASSWORD: 'basic_auth_password' = 'basic_auth_password'; /** * The variable that is used to generate access tokens. */ diff --git a/src/env/env-utilities.test.ts b/src/env/env-utilities.test.ts index 603b54c..e0a1579 100644 --- a/src/env/env-utilities.test.ts +++ b/src/env/env-utilities.test.ts @@ -13,30 +13,31 @@ const mockConstants: MockConstants = getMockConstants('env-utilities'); describe('EnvUtilities', () => { beforeEach(async () => { await FileMockUtilities.setup(mockConstants, []); - await EnvUtilities.init('test.com'); + await EnvUtilities.init('test.com', 'test-staging.com', 'user', 'password'); }); test('addStaticVariable', async () => { for (let i: number = 0; i < 50; i++) { await FileMockUtilities.setup(mockConstants, []); - await EnvUtilities.init('test.com'); + await EnvUtilities.init('test.com', 'test-staging.com', 'user', 'password'); const variable: EnvVariable = fakeEnvVariable(); - await EnvUtilities.addStaticVariable(variable); + await EnvUtilities.addStaticVariable(variable, true); - const lines: string[] = await FsUtilities.readFileLines(mockConstants.ENV); - expect(lines[2]).toEqual(`${variable.key}=${variable.value}`); + const lines: string[] = await FsUtilities.readFileLines(mockConstants.ENV_PUBLIC); + const firstCustomLineIndex: number = 2; + expect(lines[firstCustomLineIndex]).toEqual(`${variable.key}=${variable.value}`); const variable2: EnvVariable = fakeEnvVariable(); - await EnvUtilities.addStaticVariable(variable2); + await EnvUtilities.addStaticVariable(variable2, false); const lines2: string[] = await FsUtilities.readFileLines(mockConstants.ENV); - expect(lines2[3]).toEqual(`${variable2.key}=${variable2.value}`); + expect(lines2[firstCustomLineIndex]).toEqual(`${variable2.key}=${variable2.value}`); const globalEnvLines: string[] = await FsUtilities.readFileLines(mockConstants.GLOBAL_ENV_MODEL); - expect(globalEnvLines[7]).toEqual(` ${variable.key}${variable.required ? '' : '?'}: ${variable.type},`); - expect(globalEnvLines[8]).toEqual(` ${variable2.key}${variable2.required ? '' : '?'}: ${variable2.type}`); + expect(globalEnvLines[9]).toEqual(` ${variable.key}${variable.required ? '' : '?'}: ${variable.type},`); + expect(globalEnvLines[10]).toEqual(` ${variable2.key}${variable2.required ? '' : '?'}: ${variable2.type}`); } }); @@ -46,7 +47,8 @@ describe('EnvUtilities', () => { const subDomain: string = 'admin'; await EnvUtilities.addStaticVariable( - { key: DefaultEnvKeys.port(name), value: port, required: true, type: 'number' } + { key: DefaultEnvKeys.port(name), value: port, required: true, type: 'number' }, + true ); await EnvUtilities.addCalculatedVariable( { @@ -59,6 +61,9 @@ describe('EnvUtilities', () => { case 'local.docker-compose.yaml': { return `http://${'SUB_DOMAIN_PLACEHOLDER'}.localhost`; } + case 'stage.docker-compose.yaml': { + return `https://${'SUB_DOMAIN_PLACEHOLDER'}.${'STAGE_ROOT_DOMAIN_PLACEHOLDER'}`; + } case 'docker-compose.yaml': { return `https://${'SUB_DOMAIN_PLACEHOLDER'}.${'PROD_ROOT_DOMAIN_PLACEHOLDER'}`; } @@ -69,7 +74,8 @@ describe('EnvUtilities', () => { } ); await EnvUtilities.addStaticVariable( - { key: DefaultEnvKeys.subDomain(name), value: subDomain, required: true, type: 'string' } + { key: DefaultEnvKeys.subDomain(name), value: subDomain, required: true, type: 'string' }, + true ); await EnvUtilities.addCalculatedVariable( { @@ -82,6 +88,9 @@ describe('EnvUtilities', () => { case 'local.docker-compose.yaml': { return `${'SUB_DOMAIN_PLACEHOLDER'}.localhost`; } + case 'stage.docker-compose.yaml': { + return `${'SUB_DOMAIN_PLACEHOLDER'}.${'STAGE_ROOT_DOMAIN_PLACEHOLDER'}`; + } case 'docker-compose.yaml': { return `${'SUB_DOMAIN_PLACEHOLDER'}.${'PROD_ROOT_DOMAIN_PLACEHOLDER'}`; } @@ -96,6 +105,7 @@ describe('EnvUtilities', () => { await FsUtilities.replaceAllInFile(environmentModelFilePath, '\'PORT_PLACEHOLDER\'', `env.${DefaultEnvKeys.port(name)}`); await FsUtilities.replaceAllInFile(environmentModelFilePath, '\'SUB_DOMAIN_PLACEHOLDER\'', `env.${DefaultEnvKeys.subDomain(name)}`); await FsUtilities.replaceAllInFile(environmentModelFilePath, '\'PROD_ROOT_DOMAIN_PLACEHOLDER\'', `env.${DefaultEnvKeys.PROD_ROOT_DOMAIN}`); + await FsUtilities.replaceAllInFile(environmentModelFilePath, '\'STAGE_ROOT_DOMAIN_PLACEHOLDER\'', `env.${DefaultEnvKeys.STAGE_ROOT_DOMAIN}`); const fileLines: string[] = await FsUtilities.readFileLines(environmentModelFilePath); expect(fileLines).toEqual([ @@ -104,8 +114,10 @@ describe('EnvUtilities', () => { '* This is also used by the "mx prepare" command to validate the .env-file and create the project environment.ts files', '*/', 'type StaticGlobalEnvironment = {', - ' is_public: boolean', ' prod_root_domain: string,', + ' stage_root_domain: string,', + ' basic_auth_user: string,', + ' basic_auth_password: string,', ' test_port: number,', ' test_sub_domain: string', '};', @@ -116,13 +128,15 @@ describe('EnvUtilities', () => { '* the subdomain environment variable + baseDomain environment variable + http/https, based on the used docker compose file.', '*/', 'type CalculatedGlobalEnvironment = {', + ' env: Env,', ' test_base_url: string,', ' test_domain: string', '};', '', 'export type GlobalEnvironment = StaticGlobalEnvironment & CalculatedGlobalEnvironment;', '', - 'type DockerComposeFileName = \'docker-compose.yaml\' | \'dev.docker-compose.yaml\' | \'local.docker-compose.yaml\';', + 'type DockerComposeFileName = \'docker-compose.yaml\' | \'dev.docker-compose.yaml\' | \'local.docker-compose.yaml\' | \'stage.docker-compose.yaml\';', + 'type Env = \'dev\' | \'local\' | \'stage\' | \'prod\';', '', '/**', '* Defines how the CalculatedGlobalEnvironment values should be calculated.', @@ -133,6 +147,22 @@ describe('EnvUtilities', () => { ' keyof CalculatedGlobalEnvironment,', ' (env: StaticGlobalEnvironment, fileName: DockerComposeFileName) => CalculatedGlobalEnvironment[keyof CalculatedGlobalEnvironment]', '> = {', + ' env: (env, fileName) => {', + ' switch (fileName) {', + ' case \'dev.docker-compose.yaml\': {', + ' return \'dev\';', + ' }', + ' case \'local.docker-compose.yaml\': {', + ' return \'local\';', + ' }', + ' case \'stage.docker-compose.yaml\': {', + ' return \'stage\';', + ' }', + ' case \'docker-compose.yaml\': {', + ' return \'prod\';', + ' }', + ' }', + ' },', ' test_base_url: (env, fileName) => {', ' switch (fileName) {', ' case \'dev.docker-compose.yaml\': {', @@ -141,6 +171,9 @@ describe('EnvUtilities', () => { ' case \'local.docker-compose.yaml\': {', ' return `http://${env.test_sub_domain}.localhost`;', ' }', + ' case \'stage.docker-compose.yaml\': {', + ' return `https://${env.test_sub_domain}.${env.stage_root_domain}`;', + ' }', ' case \'docker-compose.yaml\': {', ' return `https://${env.test_sub_domain}.${env.prod_root_domain}`;', ' }', @@ -154,6 +187,9 @@ describe('EnvUtilities', () => { ' case \'local.docker-compose.yaml\': {', ' return `${env.test_sub_domain}.localhost`;', ' }', + ' case \'stage.docker-compose.yaml\': {', + ' return `${env.test_sub_domain}.${env.stage_root_domain}`;', + ' }', ' case \'docker-compose.yaml\': {', ' return `${env.test_sub_domain}.${env.prod_root_domain}`;', ' }', @@ -180,7 +216,7 @@ describe('EnvUtilities', () => { test('validate', async () => { const variable: EnvVariable = fakeEnvVariable({ required: true, type: 'number', value: 42 }); - await EnvUtilities.addStaticVariable(variable); + await EnvUtilities.addStaticVariable(variable, false); const errorMessages: KeyValue[] = await EnvUtilities.validate(getPath('.')); expect(errorMessages.length).toEqual(0); diff --git a/src/env/env-values.enum.ts b/src/env/env-values.enum.ts new file mode 100644 index 0000000..24f8481 --- /dev/null +++ b/src/env/env-values.enum.ts @@ -0,0 +1,21 @@ +import { DockerComposeFileName } from '../docker'; + +/** + * The possible environment values. + */ +export enum EnvValue { + DEV = 'dev', + LOCAL = 'local', + STAGE = 'stage', + PROD = 'prod' +} + +/** + * The environment value for the given docker compose file name. + */ +export const envValueForDockerComposeFileName: Record = { + 'docker-compose.yaml': EnvValue.PROD, + 'dev.docker-compose.yaml': EnvValue.DEV, + 'local.docker-compose.yaml': EnvValue.LOCAL, + 'stage.docker-compose.yaml': EnvValue.STAGE +}; \ No newline at end of file diff --git a/src/env/env.utilities.ts b/src/env/env.utilities.ts index 6bb51df..f495856 100644 --- a/src/env/env.utilities.ts +++ b/src/env/env.utilities.ts @@ -1,5 +1,6 @@ -import { DockerComposeFileName, ENV_FILE_NAME, ENVIRONMENT_MODEL_TS_FILE_NAME, ENVIRONMENT_TS_FILE_NAME, GLOBAL_ENVIRONMENT_MODEL_FILE_NAME } from '../constants'; +import { DEV_DOCKER_COMPOSE_FILE_NAME, ENV_FILE_NAME, ENV_PUBLIC_FILE_NAME, ENVIRONMENT_MODEL_TS_FILE_NAME, ENVIRONMENT_TS_FILE_NAME, GLOBAL_ENVIRONMENT_MODEL_FILE_NAME, LOCAL_DOCKER_COMPOSE_FILE_NAME, PROD_DOCKER_COMPOSE_FILE_NAME, STAGE_DOCKER_COMPOSE_FILE_NAME } from '../constants'; +import { DockerComposeFileName } from '../docker'; import { FileLine, FsUtilities, JsonUtilities } from '../encapsulation'; import { ParseObjectResult, TsUtilities } from '../ts'; import { KeyValue, OmitStrict } from '../types'; @@ -319,7 +320,10 @@ export abstract class EnvUtilities { failOnMissingVariable: boolean, rootDir: string ): Promise { - const lines: string[] = await FsUtilities.readFileLines(getPath(rootDir, ENV_FILE_NAME)); + const lines: string[] = [ + ...await FsUtilities.readFileLines(getPath(rootDir, ENV_FILE_NAME)), + ...await FsUtilities.readFileLines(getPath(rootDir, ENV_PUBLIC_FILE_NAME)) + ]; const staticVariableDefinitions: OmitStrict[] = await this.getVariableDefinitions( getPath(rootDir, GLOBAL_ENVIRONMENT_MODEL_FILE_NAME), 'StaticGlobalEnvironment = {' @@ -380,9 +384,12 @@ export abstract class EnvUtilities { /** * Initializes environment variables inside the monorepo. * @param prodRootDomain - The root domain used in prod. + * @param stageRootDomain - The root domain used on stage. + * @param basicAuthUser - The basic auth username. + * @param basicAuthPassword - The basic auth password. */ - static async init(prodRootDomain: string): Promise { - await this.createEnvFile(prodRootDomain); + static async init(prodRootDomain: string, stageRootDomain: string, basicAuthUser: string, basicAuthPassword: string): Promise { + await this.createEnvFiles(prodRootDomain, stageRootDomain, basicAuthUser, basicAuthPassword); await this.createGlobalEnvironmentModel(); } @@ -395,8 +402,10 @@ export abstract class EnvUtilities { '* This is also used by the "mx prepare" command to validate the .env-file and create the project environment.ts files', '*/', 'type StaticGlobalEnvironment = {', - `\t${DefaultEnvKeys.IS_PUBLIC}: boolean`, - `\t${DefaultEnvKeys.PROD_ROOT_DOMAIN}: string`, + `\t${DefaultEnvKeys.PROD_ROOT_DOMAIN}: string,`, + `\t${DefaultEnvKeys.STAGE_ROOT_DOMAIN}: string,`, + `\t${DefaultEnvKeys.BASIC_AUTH_USER}: string,`, + `\t${DefaultEnvKeys.BASIC_AUTH_PASSWORD}: string`, '};', '', '/**', @@ -405,11 +414,15 @@ export abstract class EnvUtilities { // eslint-disable-next-line stylistic/max-len '* the subdomain environment variable + baseDomain environment variable + http/https, based on the used docker compose file.', '*/', - 'type CalculatedGlobalEnvironment = {};', + 'type CalculatedGlobalEnvironment = {', + `\t${DefaultEnvKeys.ENV}: Env`, + '};', '', 'export type GlobalEnvironment = StaticGlobalEnvironment & CalculatedGlobalEnvironment;', '', - 'type DockerComposeFileName = \'docker-compose.yaml\' | \'dev.docker-compose.yaml\' | \'local.docker-compose.yaml\';', + // eslint-disable-next-line stylistic/max-len + 'type DockerComposeFileName = \'docker-compose.yaml\' | \'dev.docker-compose.yaml\' | \'local.docker-compose.yaml\' | \'stage.docker-compose.yaml\';', + 'type Env = \'dev\' | \'local\' | \'stage\' | \'prod\';', '', '/**', '* Defines how the CalculatedGlobalEnvironment values should be calculated.', @@ -420,20 +433,46 @@ export abstract class EnvUtilities { '\tkeyof CalculatedGlobalEnvironment,', // eslint-disable-next-line stylistic/max-len '\t(env: StaticGlobalEnvironment, fileName: DockerComposeFileName) => CalculatedGlobalEnvironment[keyof CalculatedGlobalEnvironment]', - '> = {};' + '> = {', + `\t${DefaultEnvKeys.ENV}: (env, fileName) => {`, + '\t\tswitch (fileName) {', + `\t\t\tcase '${DEV_DOCKER_COMPOSE_FILE_NAME}': {`, + '\t\t\t\treturn \'dev\';', + '\t\t\t}', + `\t\t\tcase '${LOCAL_DOCKER_COMPOSE_FILE_NAME}': {`, + '\t\t\t\treturn \'local\';', + '\t\t\t}', + `\t\t\tcase '${STAGE_DOCKER_COMPOSE_FILE_NAME}': {`, + '\t\t\t\treturn \'stage\';', + '\t\t\t}', + `\t\t\tcase '${PROD_DOCKER_COMPOSE_FILE_NAME}': {`, + '\t\t\t\treturn \'prod\';', + '\t\t\t}', + '\t\t}', + '\t}', + '};' ] ); } - private static async createEnvFile(prodRootDomain: string): Promise { + private static async createEnvFiles( + prodRootDomain: string, + stageRootDomain: string, + basicAuthUser: string, + basicAuthPassword: string + ): Promise { await FsUtilities.createFile(getPath(ENV_FILE_NAME), [ - `${DefaultEnvKeys.IS_PUBLIC}=false`, - `${DefaultEnvKeys.PROD_ROOT_DOMAIN}=${prodRootDomain}` + `${DefaultEnvKeys.BASIC_AUTH_USER}=${basicAuthUser}`, + `${DefaultEnvKeys.BASIC_AUTH_PASSWORD}=${basicAuthPassword}` + ]); + await FsUtilities.createFile(getPath(ENV_PUBLIC_FILE_NAME), [ + `${DefaultEnvKeys.PROD_ROOT_DOMAIN}=${prodRootDomain}`, + `${DefaultEnvKeys.STAGE_ROOT_DOMAIN}=${stageRootDomain}` ]); } /** - * Validates the .env file. + * Validates the .env files. * @param rootDir - The directory of the Monux monorepo. * @returns An array of error messages mapped to the keys that caused them. */ @@ -446,6 +485,14 @@ export abstract class EnvUtilities { } ]; } + if (!await FsUtilities.exists(getPath(rootDir, ENV_PUBLIC_FILE_NAME))) { + return [ + { + key: ENV_PUBLIC_FILE_NAME, + value: EnvValidationErrorMessage.FILE_DOES_NOT_EXIST + } + ]; + } const envValues: EnvVariable[] = await this.getStaticEnvVariables(undefined, true, rootDir); @@ -509,13 +556,23 @@ export abstract class EnvUtilities { /** * Adds a variable to the .env file. * @param variable - The variable to add. + * @param isPublic - Whether or not the variable should be public (checked into git). */ - static async addStaticVariable(variable: EnvVariable): Promise { + static async addStaticVariable(variable: EnvVariable, isPublic: boolean): Promise { const environmentFilePath: Path = getPath(ENV_FILE_NAME); + const publicEnvironmentFilePath: Path = getPath(ENV_PUBLIC_FILE_NAME); if ((await FsUtilities.readFile(environmentFilePath)).includes(`${variable.key}=`)) { throw new Error(`The variable ${variable.key} has already been set.`); } - await FsUtilities.updateFile(environmentFilePath, `${variable.key}=${variable.value ?? ''}`, 'append'); + if ((await FsUtilities.readFile(publicEnvironmentFilePath)).includes(`${variable.key}=`)) { + throw new Error(`The variable ${variable.key} has already been set.`); + } + + await FsUtilities.updateFile( + isPublic ? publicEnvironmentFilePath : environmentFilePath, + `${variable.key}=${variable.value ?? ''}`, + 'append' + ); const environmentModelFilePath: Path = getPath(GLOBAL_ENVIRONMENT_MODEL_FILE_NAME); diff --git a/src/env/environment-variable-key.model.ts b/src/env/environment-variable-key.model.ts index a1dab2d..1d74ddf 100644 --- a/src/env/environment-variable-key.model.ts +++ b/src/env/environment-variable-key.model.ts @@ -3,8 +3,10 @@ import { DefaultEnvKeys } from './default-environment-keys'; /** * Default global environment variables. */ -export type EnvironmentVariableKey = typeof DefaultEnvKeys.IS_PUBLIC +export type EnvironmentVariableKey = + | typeof DefaultEnvKeys.ENV | typeof DefaultEnvKeys.PROD_ROOT_DOMAIN + | typeof DefaultEnvKeys.STAGE_ROOT_DOMAIN | typeof DefaultEnvKeys.ACCESS_TOKEN_SECRET | typeof DefaultEnvKeys.REFRESH_TOKEN_SECRET | typeof DefaultEnvKeys.WEBSERVER_MAIL_USER diff --git a/src/env/index.ts b/src/env/index.ts index a984f4c..bd93e8f 100644 --- a/src/env/index.ts +++ b/src/env/index.ts @@ -1,3 +1,4 @@ export * from './env.utilities'; export * from './default-environment-keys'; -export * from './environment-variable-key.model'; \ No newline at end of file +export * from './environment-variable-key.model'; +export * from './env-values.enum'; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 9624d0d..86c8f5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ async function main(): Promise { FigletUtilities.displayLogo(); - const command: Command | 'run' = resolveCommand(args); + const command: Command | 'run' = await resolveCommand(args); switch (command) { case Command.H: diff --git a/src/loopback/loopback.utilities.ts b/src/loopback/loopback.utilities.ts index c1c15f2..d073638 100644 --- a/src/loopback/loopback.utilities.ts +++ b/src/loopback/loopback.utilities.ts @@ -2,7 +2,7 @@ import { AddLoopbackConfiguration } from '../commands/add/add-loopback'; import { ENVIRONMENT_MODEL_TS_FILE_NAME } from '../constants'; import { CPUtilities, FsUtilities } from '../encapsulation'; -import { DefaultEnvKeys, EnvUtilities } from '../env'; +import { DefaultEnvKeys, EnvUtilities, EnvValue } from '../env'; import { TsUtilities } from '../ts'; import { generatePlaceholderPassword, getPath, optionsToCliString, Path, toKebabCase, toPascalCase } from '../utilities'; import { LbDatabaseConfig } from './lb-database-config.model'; @@ -172,7 +172,7 @@ export abstract class LoopbackUtilities { // for the new command, extract the name command = command.split(' ')[1] as LoopbackCliCommands; } - CPUtilities.execSync(`cd ${directory} && npx @loopback/cli@${this.CLI_VERSION} ${command} ${optionsToCliString(options, ' ')}`); + await CPUtilities.exec(`cd ${directory} && npx @loopback/cli@${this.CLI_VERSION} ${command} ${optionsToCliString(options, ' ')}`); if (command.startsWith('service')) { const servicePath: Path = getPath(directory, 'src', 'services', `${toKebabCase(command.split(' ')[1])}.service.ts`); await FsUtilities.replaceInFile(servicePath, '/* inject, */', ''); @@ -337,7 +337,7 @@ export abstract class LoopbackUtilities { ' pass: environment.webserver_mail_password', ' }', ' });', - ` protected override readonly PRODUCTION: boolean = !!environment.${DefaultEnvKeys.IS_PUBLIC};`, + ` protected override readonly PRODUCTION: boolean = environment.${DefaultEnvKeys.ENV} === '${EnvValue.PROD}';`, ' protected override readonly SAVED_EMAILS_PATH: string = path.join(__dirname, \'../../../test-emails\');', // eslint-disable-next-line stylistic/max-len ` protected override readonly LOGO_HEADER_URL: string = \`\${environment.${DefaultEnvKeys.baseUrl(config.name)}}/assets/email/logo-header.png\`;`, @@ -357,30 +357,36 @@ export abstract class LoopbackUtilities { required: true, type: 'string', value: config.defaultUserEmail - }); + }, false); await EnvUtilities.addStaticVariable({ key: DefaultEnvKeys.defaultUserPassword(config.name), required: true, type: 'string', value: config.defaultUserPassword - }); + }, false); await EnvUtilities.addStaticVariable( - { key: DefaultEnvKeys.ACCESS_TOKEN_SECRET, required: true, type: 'string', value: generatePlaceholderPassword() } + { key: DefaultEnvKeys.ACCESS_TOKEN_SECRET, required: true, type: 'string', value: generatePlaceholderPassword() }, + false ); await EnvUtilities.addStaticVariable( - { key: DefaultEnvKeys.REFRESH_TOKEN_SECRET, required: true, type: 'string', value: generatePlaceholderPassword() } + { key: DefaultEnvKeys.REFRESH_TOKEN_SECRET, required: true, type: 'string', value: generatePlaceholderPassword() }, + false ); await EnvUtilities.addStaticVariable( - { key: DefaultEnvKeys.WEBSERVER_MAIL_USER, required: true, type: 'string', value: undefined } + { key: DefaultEnvKeys.WEBSERVER_MAIL_USER, required: true, type: 'string', value: undefined }, + false ); await EnvUtilities.addStaticVariable( - { key: DefaultEnvKeys.WEBSERVER_MAIL_PASSWORD, required: true, type: 'string', value: undefined } + { key: DefaultEnvKeys.WEBSERVER_MAIL_PASSWORD, required: true, type: 'string', value: undefined }, + false ); await EnvUtilities.addStaticVariable( - { key: DefaultEnvKeys.WEBSERVER_MAIL_HOST, required: true, type: 'string', value: undefined } + { key: DefaultEnvKeys.WEBSERVER_MAIL_HOST, required: true, type: 'string', value: undefined }, + false ); await EnvUtilities.addStaticVariable( - { key: DefaultEnvKeys.WEBSERVER_MAIL_PORT, required: true, type: 'number', value: undefined } + { key: DefaultEnvKeys.WEBSERVER_MAIL_PORT, required: true, type: 'number', value: undefined }, + false ); const environmentModel: Path = getPath(root, 'src', 'environment', ENVIRONMENT_MODEL_TS_FILE_NAME); @@ -412,7 +418,7 @@ export abstract class LoopbackUtilities { getPath('.') ); await EnvUtilities.addProjectVariableKey(config.name, environmentModel, DefaultEnvKeys.baseUrl(config.name), false, getPath('.')); - await EnvUtilities.addProjectVariableKey(config.name, environmentModel, DefaultEnvKeys.IS_PUBLIC, false, getPath('.')); + await EnvUtilities.addProjectVariableKey(config.name, environmentModel, DefaultEnvKeys.ENV, false, getPath('.')); await EnvUtilities.addProjectVariableKey( config.name, environmentModel, diff --git a/src/nest/nest-utilities.test.ts b/src/nest/nest-utilities.test.ts index 4d6d00d..0daf715 100644 --- a/src/nest/nest-utilities.test.ts +++ b/src/nest/nest-utilities.test.ts @@ -13,7 +13,7 @@ describe('NestUtilities', () => { }); test('run new command', async () => { - NestUtilities.runCommand(mockConstants.APPS_DIR, 'new api', { + await NestUtilities.runCommand(mockConstants.APPS_DIR, 'new api', { '--language': 'TS', '--package-manager': 'npm', '--skip-git': true, diff --git a/src/nest/nest.utilities.ts b/src/nest/nest.utilities.ts index 047eded..6603a1a 100644 --- a/src/nest/nest.utilities.ts +++ b/src/nest/nest.utilities.ts @@ -58,8 +58,8 @@ export abstract class NestUtilities { * @param command - The command to run. * @param options - Options for running the command. */ - static runCommand(directory: Path, command: NestCliCommands, options: NestCliOptions): void { - CPUtilities.execSync(`cd ${directory} && npx @nestjs/cli@${this.CLI_VERSION} ${command} ${optionsToCliString(options)}`); + static async runCommand(directory: Path, command: NestCliCommands, options: NestCliOptions): Promise { + await CPUtilities.exec(`cd ${directory} && npx @nestjs/cli@${this.CLI_VERSION} ${command} ${optionsToCliString(options)}`); } /** diff --git a/src/npm/npm.utilities.ts b/src/npm/npm.utilities.ts index 7134cfd..fe3f8e6 100644 --- a/src/npm/npm.utilities.ts +++ b/src/npm/npm.utilities.ts @@ -43,12 +43,12 @@ export abstract class NpmUtilities { static async init(config: 'root' | NpmInitConfig, output?: boolean): Promise { if (config === 'root') { const rootPackageJson: Path = getPath(PACKAGE_JSON_FILE_NAME); - CPUtilities.execSync('npm init -y', output); + await CPUtilities.exec('npm init -y', output); const oldPackageJson: PackageJson = await FsUtilities.parseFileAs(rootPackageJson); await FsUtilities.updateFile(rootPackageJson, JsonUtilities.stringify(oldPackageJson), 'replace', false); return; } - CPUtilities.execSync( + await CPUtilities.exec( `npm init -y --scope=${config.scope} -w ${config.path}`, output ); @@ -66,18 +66,18 @@ export abstract class NpmUtilities { static async run(projectName: string, commands: string, isNativeCommand: boolean): Promise { const project: WorkspaceProject = await WorkspaceUtilities.findProjectOrFail(projectName, getPath('.')); if (!isNativeCommand) { - CPUtilities.execSync(`npm run ${commands} --workspace=${project.npmWorkspaceString}`); + await CPUtilities.exec(`npm run ${commands} --workspace=${project.npmWorkspaceString}`); return; } - CPUtilities.execSync(`npm ${commands} --workspace=${project.npmWorkspaceString}`); + await CPUtilities.exec(`npm ${commands} --workspace=${project.npmWorkspaceString}`); } /** * Runs the given script inside all projects that have it. * @param npmScript - The npm script to run. */ - static runAll(npmScript: NpmScript): void { - CPUtilities.execSync(`npm run ${npmScript} --workspaces --if-present`); + static async runAll(npmScript: NpmScript): Promise { + await CPUtilities.exec(`npm run ${npmScript} --workspaces --if-present`); } /** @@ -96,9 +96,9 @@ export abstract class NpmUtilities { * @param npmPackages - The packages to install. * @param development - Whether or not the packages will be installed with -D or not. */ - static installInRoot(npmPackages: NpmPackage[], development: boolean = false): void { + static async installInRoot(npmPackages: NpmPackage[], development: boolean = false): Promise { const installCommand: string = development ? 'npm i -D' : 'npm i'; - CPUtilities.execSync(`${installCommand} ${npmPackages.join(' ')}`, true); + await CPUtilities.exec(`${installCommand} ${npmPackages.join(' ')}`, true); } /** diff --git a/src/robots/robots-utilities.test.ts b/src/robots/robots-utilities.test.ts index f3c6fad..c704fb7 100644 --- a/src/robots/robots-utilities.test.ts +++ b/src/robots/robots-utilities.test.ts @@ -5,7 +5,7 @@ import { FileMockUtilities, getMockConstants, MockConstants } from '../__testing import { RobotsUtilities } from './robots.utilities'; import { APPS_DIRECTORY_NAME, ROBOTS_FILE_NAME } from '../constants'; import { FsUtilities } from '../encapsulation'; -import { DefaultEnvKeys, EnvUtilities } from '../env'; +import { DefaultEnvKeys, EnvUtilities, EnvValue } from '../env'; import { getPath } from '../utilities'; const mockConstants: MockConstants = getMockConstants('robots-utilities'); @@ -14,13 +14,13 @@ describe('RobotsUtilities', () => { beforeEach(async () => { await FileMockUtilities.setup(mockConstants, ['ANGULAR_APP_COMPONENT_TS']); - await EnvUtilities.init('test.com'); - await EnvUtilities.addStaticVariable({ key: DefaultEnvKeys.baseUrl('angular'), required: true, type: 'string', value: 'www.test.com' }); + await EnvUtilities.init('test.com', 'test-staging.com', 'user', 'password'); + await EnvUtilities.addStaticVariable({ key: DefaultEnvKeys.baseUrl('angular'), required: true, type: 'string', value: 'www.test.com' }, false); }); test('createRobotsTxtForApp', async () => { - const isPublic: boolean = await EnvUtilities.getEnvVariable(DefaultEnvKeys.IS_PUBLIC, 'dev.docker-compose.yaml', getPath('.')); - expect(isPublic).toBe(false); + const isPublic: EnvValue = await EnvUtilities.getEnvVariable(DefaultEnvKeys.ENV, 'dev.docker-compose.yaml', getPath('.')); + expect(isPublic).toEqual(EnvValue.DEV); await RobotsUtilities.createRobotsTxtForApp( { diff --git a/src/robots/robots.utilities.ts b/src/robots/robots.utilities.ts index dea61bd..1482924 100644 --- a/src/robots/robots.utilities.ts +++ b/src/robots/robots.utilities.ts @@ -1,6 +1,7 @@ -import { DockerComposeFileName, ENV_FILE_NAME, ROBOTS_FILE_NAME, SITEMAP_FILE_NAME } from '../constants'; +import { ROBOTS_FILE_NAME, SITEMAP_FILE_NAME } from '../constants'; +import { DockerComposeFileName } from '../docker'; import { FsUtilities } from '../encapsulation'; -import { DefaultEnvKeys, EnvUtilities } from '../env'; +import { DefaultEnvKeys, EnvUtilities, EnvValue } from '../env'; import { filterAsync, getPath, Path } from '../utilities'; import { WorkspaceProject, WorkspaceUtilities } from '../workspace'; @@ -15,11 +16,6 @@ export abstract class RobotsUtilities { * @param rootDir - The directory of the Monux monorepo where the files should be created. */ static async createRobotsTxtFiles(fileName: DockerComposeFileName, rootDir: string): Promise { - const environmentFilePath: Path = getPath(rootDir, ENV_FILE_NAME); - if (!(await FsUtilities.readFile(environmentFilePath)).includes(`${DefaultEnvKeys.IS_PUBLIC}=`)) { - return; - } - // Only projects that have a sitemap file get a robots.txt file. const apps: WorkspaceProject[] = await filterAsync(await WorkspaceUtilities.getProjects('apps', rootDir), async a => { const sitemapPath: Path = getPath(a.path, 'src', SITEMAP_FILE_NAME); @@ -46,11 +42,11 @@ export abstract class RobotsUtilities { const robotsTxtPath: Path = getPath(app.path, 'src', ROBOTS_FILE_NAME); await FsUtilities.rm(robotsTxtPath); - const isPublic: boolean = await EnvUtilities.getEnvVariable(DefaultEnvKeys.IS_PUBLIC, fileName, rootDir); + const env: EnvValue = await EnvUtilities.getEnvVariable(DefaultEnvKeys.ENV, fileName, rootDir); const content: string[] = [ 'User-agent: *', - `${isPublic ? 'Allow' : 'Disallow'}: /` + `${env === EnvValue.PROD ? 'Allow' : 'Disallow'}: /` ]; const baseUrl: string = domain ? `https://${domain}` : await EnvUtilities.getEnvVariable(DefaultEnvKeys.baseUrl(app.name), fileName, rootDir); diff --git a/src/storybook/storybook.utilities.ts b/src/storybook/storybook.utilities.ts index 4c317b4..2efbfd3 100644 --- a/src/storybook/storybook.utilities.ts +++ b/src/storybook/storybook.utilities.ts @@ -12,8 +12,8 @@ export abstract class StorybookUtilities { * Sets up storybook inside the given root. * @param root - The root of the project where storybook should be setup. */ - static setup(root: Path): void { - CPUtilities.execSync( + static async setup(root: Path): Promise { + await CPUtilities.exec( `cd ${root} && npm create storybook@${this.CLI_VERSION} -- --no-dev --yes --features docs test --disable-telemetry` ); } diff --git a/src/tsconfig/tsconfig-utilities.test.ts b/src/tsconfig/tsconfig-utilities.test.ts index 4948d79..3b759a9 100644 --- a/src/tsconfig/tsconfig-utilities.test.ts +++ b/src/tsconfig/tsconfig-utilities.test.ts @@ -20,7 +20,7 @@ describe('TsConfigUtilities', () => { }); test('init', async () => { - TsConfigUtilities.init(mockConstants.TS_LIBRARY_DIR); + await TsConfigUtilities.init(mockConstants.TS_LIBRARY_DIR); const content: TsConfig = await FsUtilities.parseFileAs(getPath(mockConstants.TS_LIBRARY_DIR, TS_CONFIG_FILE_NAME)); expect(content).toEqual({ compilerOptions: { @@ -35,7 +35,7 @@ describe('TsConfigUtilities', () => { }); test('update', async () => { - TsConfigUtilities.init(mockConstants.TS_LIBRARY_DIR); + await TsConfigUtilities.init(mockConstants.TS_LIBRARY_DIR); const tsconfigPath: Path = getPath(mockConstants.TS_LIBRARY_DIR, TS_CONFIG_FILE_NAME); await TsConfigUtilities['update'](tsconfigPath, { extends: `../../${BASE_TS_CONFIG_FILE_NAME}`, diff --git a/src/tsconfig/tsconfig.utilities.ts b/src/tsconfig/tsconfig.utilities.ts index 15fa8d0..62c10f4 100644 --- a/src/tsconfig/tsconfig.utilities.ts +++ b/src/tsconfig/tsconfig.utilities.ts @@ -14,8 +14,8 @@ export abstract class TsConfigUtilities { * Initializes typescript inside the given path. * @param path - Where to initialize typescript. */ - static init(path: Path): void { - CPUtilities.execSync(`cd ${path} && npx tsc --init`); + static async init(path: Path): Promise { + await CPUtilities.exec(`cd ${path} && npx tsc --init`); } /** diff --git a/src/utilities/exit-gracefully.function.ts b/src/utilities/exit-gracefully.function.ts new file mode 100644 index 0000000..f02a41f --- /dev/null +++ b/src/utilities/exit-gracefully.function.ts @@ -0,0 +1,19 @@ +import { finished } from 'node:stream/promises'; + +/** + * Exits the process gracefully. + * @param code - The exit code. + * @returns Never, as this will close the process. + */ +export async function exitGracefully(code: number): Promise { + process.exitCode = code; + try { + await Promise.all([ + finished(process.stdout, { writable: true }), + finished(process.stderr, { writable: true }) + ]); + } + finally { + return process.exit(code); + } +} \ No newline at end of file diff --git a/src/utilities/exit-with-error.function.ts b/src/utilities/exit-with-error.function.ts index 2c8119f..246223a 100644 --- a/src/utilities/exit-with-error.function.ts +++ b/src/utilities/exit-with-error.function.ts @@ -1,15 +1,16 @@ import { MORE_INFORMATION_MESSAGE } from '../constants'; import { ChalkUtilities } from '../encapsulation'; +import { exitGracefully } from './exit-gracefully.function'; /** * Exits the cli with the given error message. * @param message - The message/reason to display when exiting. * @returns Never, as the program stops when this function finished running. */ -export function exitWithError(message: string): never { +export async function exitWithError(message: string): Promise { // eslint-disable-next-line no-console console.error(ChalkUtilities.error(message)); // eslint-disable-next-line no-console console.log(MORE_INFORMATION_MESSAGE); - return process.exit(0); + return await exitGracefully(1); } \ No newline at end of file diff --git a/src/utilities/exit-with-interrupt.function.ts b/src/utilities/exit-with-interrupt.function.ts index a27e1ad..6cfd65c 100644 --- a/src/utilities/exit-with-interrupt.function.ts +++ b/src/utilities/exit-with-interrupt.function.ts @@ -1,11 +1,12 @@ import { ChalkUtilities } from '../encapsulation'; +import { exitGracefully } from './exit-gracefully.function'; /** * Exits the cli when an interrupt occurs. * @returns Never, as the program stops when this function finished running. */ -export function exitWithInterrupt(): never { +export async function exitWithInterrupt(): Promise { // eslint-disable-next-line no-console console.log(ChalkUtilities.secondary('\nProcess interrupted (Ctrl+C)')); - return process.exit(130); + return await exitGracefully(130); } \ No newline at end of file diff --git a/src/utilities/index.ts b/src/utilities/index.ts index db9999c..598eb68 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -9,4 +9,5 @@ export * from './get-path.function'; export * from './exit-with-error.function'; export * from './exit-with-interrupt.function'; export * from './is-error-with-signal.function'; -export * from './is-exit-prompt-error.function'; \ No newline at end of file +export * from './is-exit-prompt-error.function'; +export * from './exit-gracefully.function'; \ No newline at end of file