diff --git a/README.md b/README.md index 0649343..37f8d00 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ The projects that can be added to a Monux monorepo also provide a lot of functio - [Initializing a new monorepo](#initializing-a-new-monorepo) - [Adding a new project to the monorepo](#adding-a-new-project-to-the-monorepo) - [Running development services](#running-development-services) + - [Listing monorepo services](#listing-monorepo-services) - [Running npm scripts](#running-npm-scripts) - [Running npm scripts in multiple projects](#running-npm-scripts-in-multiple-projects) - [Handling environment variables](#handling-environment-variables) @@ -108,6 +109,11 @@ That section also includes a guide on how to add projects manually. Some things like databases will be added to the monorepo solely in the docker compose.
To use these during development, the cli includes the `mx up-dev` command. +## Listing monorepo services +To list all of Monux monorepos and their respective docker services we included the `mx ls` and `mx la` commands. + +Where `ls` or `list` only shows monorepos with currently running docker services, while `la` or `list-all` also shows monorepos with stopped docker services. + ## Running npm scripts To run an npm script of one of your projects you can use `mx {projectName} {npmScript}`. This works for projects in the "apps" and "libs" directories of your monorepo. @@ -140,7 +146,7 @@ But instead of parsing the values from the `.env`-file during the prepare step, /** * Defines how the CalculatedGlobalEnvironment values should be calculated. * This is used by the "mx prepare" command. -* DONT CHANGE THE NAME ("calculationSchemaFor") OR FORMATTING. Otherwise Monux might not be able to detect it. +* DON'T CHANGE THE NAME ("calculationSchemaFor") OR FORMATTING. Otherwise Monux might not be able to detect it. */ const calculationSchemaFor: Record< keyof CalculatedGlobalEnvironment, diff --git a/eslint.config.mjs b/eslint.config.mjs index cc836e8..380199a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,5 +1,6 @@ import { configs } from 'eslint-config-service-soft'; +// eslint-disable-next-line jsdoc/require-description /** @type {import('eslint').Linter.Config} */ export default [ ...configs, diff --git a/jest.config.mjs b/jest.config.mjs index 81de852..38e6a21 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -1,3 +1,5 @@ + +// eslint-disable-next-line jsdoc/require-description /** @type {import('ts-jest').JestConfigWithTsJest} **/ const config = { testEnvironment: 'node', diff --git a/package-lock.json b/package-lock.json index bc74b68..c687210 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "monux-cli", - "version": "2.1.0", + "version": "2.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "monux-cli", - "version": "2.1.0", + "version": "2.1.2", "license": "MIT", "dependencies": { "chalk": "^4.1.2", + "cli-table3": "^0.6.5", "death": "^1.1.0", "figlet": "^1.7.0", "inquirer": "^10.2.2", @@ -27,7 +28,7 @@ "@types/figlet": "^1.5.8", "@types/js-yaml": "^4.0.9", "eslint": "^9.24.0", - "eslint-config-service-soft": "^2.0.0", + "eslint-config-service-soft": "^2.0.8", "jest": "^29.7.0", "ngx-material-navigation": "^18.1.2", "ts-jest": "^29.2.5" @@ -889,6 +890,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/@cspell/cspell-bundled-dicts": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-8.18.1.tgz", @@ -4001,6 +4012,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -4888,9 +4914,9 @@ } }, "node_modules/eslint-config-service-soft": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-service-soft/-/eslint-config-service-soft-2.0.0.tgz", - "integrity": "sha512-RMLEcHs/tVCc1c0fKWcykR5UxynKEvkIRKbmizeB2GYjYccbGo8NvH3WAiUMaL86srvXREPDElJ5SryPPYMFTg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/eslint-config-service-soft/-/eslint-config-service-soft-2.0.8.tgz", + "integrity": "sha512-ZGJVWxm0h2khbatBG+kOzfVX0qaCGWIl2YHUzGLcRRYtBLJ0JdgTS5aUAnwLqgaatSjH8uMozQyvKyeSEh111w==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 35f55b3..2e50256 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "monux-cli", - "version": "2.1.0", + "version": "2.1.2", "license": "MIT", "main": "index.js", "engines": { @@ -40,6 +40,7 @@ }, "dependencies": { "chalk": "^4.1.2", + "cli-table3": "^0.6.5", "death": "^1.1.0", "figlet": "^1.7.0", "inquirer": "^10.2.2", @@ -54,7 +55,7 @@ "@types/figlet": "^1.5.8", "@types/js-yaml": "^4.0.9", "eslint": "^9.24.0", - "eslint-config-service-soft": "^2.0.0", + "eslint-config-service-soft": "^2.0.8", "jest": "^29.7.0", "ngx-material-navigation": "^18.1.2", "ts-jest": "^29.2.5" diff --git a/src/__testing__/mock/file-mock.utilities.ts b/src/__testing__/mock/file-mock.utilities.ts index 772a10d..b02a34b 100644 --- a/src/__testing__/mock/file-mock.utilities.ts +++ b/src/__testing__/mock/file-mock.utilities.ts @@ -3,6 +3,7 @@ import { MockConstants, FileMockConstants, DirMockConstants } from './constants' import { AngularJson } from '../../angular'; import { CPUtilities, FsUtilities, JsonUtilities } from '../../encapsulation'; import { EnvUtilities } from '../../env'; +import { WorkspaceUtilities } from '../../workspace'; export abstract class FileMockUtilities { @@ -38,6 +39,7 @@ export abstract class FileMockUtilities { CPUtilities['cwd'] = mockConstants.PROJECT_DIR; await this.mockFolders(mockConstants); await this.mockFiles(filesToMock, contentOverrides, mockConstants); + await WorkspaceUtilities.createConfig(); } private static async mockFolders(mockConstants: MockConstants): Promise { diff --git a/src/angular/angular.utilities.ts b/src/angular/angular.utilities.ts index 71f917a..c98322b 100644 --- a/src/angular/angular.utilities.ts +++ b/src/angular/angular.utilities.ts @@ -616,10 +616,11 @@ export abstract class AngularUtilities { * Adds a sitemap.xml and a robots.txt to a project at the given path. * @param root - The root of the angular project to add the files to. * @param projectName - The name of the project. + * @param domain - The domain of the project. Is needed to create the robots.txt file when the baseUrl environment variable has not been set yet. */ - static async addSitemapAndRobots(root: string, projectName: string): Promise { + static async addSitemapAndRobots(root: string, projectName: string, domain: string): Promise { const app: WorkspaceProject = await WorkspaceUtilities.findProjectOrFail(projectName); - await RobotsUtilities.createRobotsTxtForApp(app, 'dev.docker-compose.yaml'); + await RobotsUtilities.createRobotsTxtForApp(app, 'dev.docker-compose.yaml', domain); await FsUtilities.createFile(getPath(root, 'src', SITEMAP_FILE_NAME), [ '', '', 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 2710e38..06b0da6 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 @@ -79,35 +79,33 @@ export class AddAngularWebsiteCommand extends AddCommand = { prodRootDomain: { @@ -33,7 +34,7 @@ const initConfigQuestions: QuestionsFor = { * Runs the init cli command. */ export async function runInit(): Promise { - if (await FsUtilities.exists(WORKSPACE_FILE_NAME)) { + if (await FsUtilities.exists(getPath(WORKSPACE_FILE_NAME))) { exitWithError('Error: The current directory is already a monorepo workspace'); } diff --git a/src/commands/list/get-docker-services.function.ts b/src/commands/list/get-docker-services.function.ts new file mode 100644 index 0000000..ccc1db5 --- /dev/null +++ b/src/commands/list/get-docker-services.function.ts @@ -0,0 +1,67 @@ +import { execSync } from 'child_process'; + +import { FullyParsedDockerService, StringifiedDockerServiceWithParsedLabels, isFullyParsedDockerService, StringifiedDockerService } from './stringified-docker-service.model'; +import { WORKSPACE_FILE_NAME } from '../../constants'; +import { DockerLabel } from '../../docker'; +import { JsonUtilities } from '../../encapsulation'; +import { getPath } from '../../utilities'; +import { WorkspaceConfig, WorkspaceUtilities } from '../../workspace'; + +/** + * Gets all docker services that come from a project with an mx.workspace.json. + * @param all - Whether or not to get all docker services. + * @returns An Array of grouped docker services. + */ +export async function getDockerServices(all: boolean): Promise { + const output: string = execSync(`docker ps${all ? ' -a' : ''} --format "{{json .}}" --size=false`) + .toString() + .trim(); + + // Split the output into lines and parse each JSON line. + const services: StringifiedDockerServiceWithParsedLabels[] = await Promise.all(output + .split('\n') + .filter(line => line) + .map(async line => { + const service: StringifiedDockerService = JsonUtilities.parse(line); + const labels: Record = parseLabels(service.Labels); + const config: WorkspaceConfig | undefined = await getWorkspaceConfig(labels); + return { + ...service, + Labels: labels, + config: config + }; + })); + + return services.filter(isFullyParsedDockerService); +} + +/** + * Gets the workspace project for a docker service with the given labels. + * @param labels - The docker labels of the service to get the workspace config for. + * @returns The found workspace config or undefined. + */ +async function getWorkspaceConfig(labels: Record): Promise { + const composeProjectDir: string | undefined = labels[DockerLabel.COMPOSE_PROJECT_DIR]; + if (!composeProjectDir) { + return undefined; + } + const config: WorkspaceConfig | undefined = await WorkspaceUtilities.getConfig(getPath(composeProjectDir, WORKSPACE_FILE_NAME)); + return config; +} + +/** + * Parses the labels of a docker service. + * @param labelStr - The raw labels string value. + * @returns A record with the label as key and the label value. + */ +function parseLabels(labelStr: string): Record { + return labelStr.split(',') + .reduce>((acc, pair) => { + if (!pair) { + return acc; + } + const [key, ...value] = pair.split('='); + acc[key.trim()] = value.join('').trim(); + return acc; + }, {}); +} \ No newline at end of file diff --git a/src/commands/list/index.ts b/src/commands/list/index.ts new file mode 100644 index 0000000..3cc5c33 --- /dev/null +++ b/src/commands/list/index.ts @@ -0,0 +1 @@ +export * from './list.command'; \ No newline at end of file diff --git a/src/commands/list/list.command.ts b/src/commands/list/list.command.ts new file mode 100644 index 0000000..b7e6c99 --- /dev/null +++ b/src/commands/list/list.command.ts @@ -0,0 +1,191 @@ +import path from 'path'; + +import { getDockerServices } from './get-docker-services.function'; +import { FullyParsedDockerService } from './stringified-docker-service.model'; +import { CLI_BASE_COMMAND } from '../../constants'; +import { DockerLabel } from '../../docker'; +import { ChalkUtilities, CliTable, CliTableUtilities } from '../../encapsulation'; + +/** + * Status of a docker service. + * Consists of a label and an optional color. + */ +type DockerServiceStatus = { + /** + * The label of the status. + */ + label: string, + /** + * The color that should be used to highlight the service, eg. For errors. + */ + color: 'error' | 'success' | undefined +}; + +/** + * Runs the list command of the cli. + * This gives information about any docker services that are managed by Monux. + * @param all - Whether to list all services or only the ones currently running. + */ +export async function runList(all: boolean): Promise { + const services: FullyParsedDockerService[] = await getDockerServices(all); + if (!services.length) { + // eslint-disable-next-line no-console + console.log('No running services found.'); + if (!all) { + // eslint-disable-next-line no-console, sonar/no-nested-template-literals + console.log(`You can call ${ChalkUtilities.secondary(`${CLI_BASE_COMMAND} la`)} to also see stopped services.`); + } + // eslint-disable-next-line no-console + console.log(); + } + const grouped: Record = groupByMonorepo(services); + + for (const key in grouped) { + const services: FullyParsedDockerService[] = grouped[key]; + const data: CliTable = { + title: key, + headers: ['Name', 'Type', 'Status', 'environment'], + rows: services.map(s => { + const { label, color } = getStatus(s); + return [ + getName(s, color), + getType(s, color), + label, + getEnv(s.Labels, color) + ]; + }) + }; + + CliTableUtilities.logTable(data); + } +} + +/** + * Gets the status of a docker service. + * @param s - The service to get the status for. + * @returns Label and color of the status. + */ +function getStatus(s: FullyParsedDockerService): DockerServiceStatus { + if (s.Status.startsWith('Exited')) { + const exitCode: string = s.Status.split('Exited (')[1].split(')')[0]; + const time: string = s.Status.split(') ')[1]; + + if (exitCode === '0') { + return { label: `Stopped (${time})`, color: undefined }; + } + return { label: ChalkUtilities.error(`Crashed (${time}) with ${exitCode}`), color: 'error' }; + } + + if (s.Status.startsWith('Up')) { + const time: string = s.Status.split('Up ')[1]; + return { label: ChalkUtilities.success(`Running (${time})`), color: 'success' }; + } + + return { label: s.Status, color: undefined }; +} + +/** + * Gets the name of the given docker service. + * @param service - The docker service to get the name for. + * @param color - An optional color to highlight the row. + * @returns The service name if found, the container name otherwise. + */ +function getName(service: FullyParsedDockerService, color: 'success' | 'error' | undefined): string { + const res: string = service.Labels['com.docker.compose.service'] ?? service.Names; + switch (color) { + case 'success': { + return ChalkUtilities.success(res); + } + case 'error': { + return ChalkUtilities.error(res); + } + case undefined: { + return res; + } + } +} + +/** + * Gets the environment that the service was run in. + * @param labels - The labels of the service to get the environment from. + * @param color - An optional color to highlight the row. + * @returns 'dev', 'prod', 'local' or '-', when no environment could be determined. + */ +function 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); + } + case 'error': { + return ChalkUtilities.error(res); + } + case undefined: { + return res; + } + } + } + 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'; + } + + if (res === '-') { + // eslint-disable-next-line no-console + console.error(ChalkUtilities.error('Could not determine environment for the docker compose file', fileName)); + } + + switch (color) { + case 'success': { + return ChalkUtilities.success(res); + } + case 'error': { + return ChalkUtilities.error(res); + } + case undefined: { + return res; + } + } +} + +/** + * Groups the given docker services by the monorepo that they belong to. + * @param services - The services to group. + * @returns A record with the name of the monorepo and the docker services that belong to them. + */ +function groupByMonorepo(services: FullyParsedDockerService[]): Record { + return services.reduce>((acc, svc) => { + const repo: string = svc.config.name; + acc[repo] = acc[repo] ?? []; + acc[repo].push(svc); + return acc; + }, {}); +} + +/** + * Gets the type (image) of the service. + * @param s - The service to get the type of. + * @param color - An optional color to highlight the row. + * @returns The name of the image used by that service. + */ +function getType(s: FullyParsedDockerService, color: 'error' | 'success' | undefined): string { + switch (color) { + case 'success': { + return ChalkUtilities.success(s.Image); + } + case 'error': { + return ChalkUtilities.error(s.Image); + } + case undefined: { + return s.Image; + } + } +} \ No newline at end of file diff --git a/src/commands/list/stringified-docker-service.model.ts b/src/commands/list/stringified-docker-service.model.ts new file mode 100644 index 0000000..b069093 --- /dev/null +++ b/src/commands/list/stringified-docker-service.model.ts @@ -0,0 +1,103 @@ +import { DockerLabel } from '../../docker'; +import { WorkspaceConfig } from '../../workspace'; + +/** + * Represents a Docker container's information as returned by + * `docker ps --format '{{json .}}'`. Each property corresponds + * to a field in the Docker CLI output. + */ +export interface StringifiedDockerService { + /** + * The command that was used to start the container, + * including any arguments. This is typically a string + * representation of the command line. + */ + Command: string, + /** + * The timestamp indicating when the container was created, + * formatted as a human-readable string. + */ + CreatedAt: string, + /** + * The name of the image used to create the container, + * including the tag (e.g., 'node:latest'). + */ + Image: string, + /** + * A comma-separated string of labels assigned to the container. + * Each label is a key-value pair (e.g., 'key1=value1,key2=value2'). + */ + Labels: string, + /** + * The number of local volumes attached to the container, + * represented as a string (e.g., '0', '1'). + */ + LocalVolumes: string, + /** + * A comma-separated list of mount points used by the container, + * such as volumes or bind mounts. + */ + Mounts: string, + /** + * The name(s) assigned to the container. In most cases, + * this will be a single name, but multiple names can be + * present if the container is part of a service. + */ + Names: string, + /** + * A comma-separated list of networks the container is connected to. + */ + Networks: string, + /** + * A comma-separated list of ports exposed by the container, + * along with their mappings (e.g., '0.0.0.0:80->80/tcp'). + */ + Ports: string, + /** + * A human-readable string indicating how long the container + * has been running (e.g., '5 minutes ago'). + */ + RunningFor: string, + /** + * The current state of the container (e.g., 'running', 'exited'). + */ + State: string, + /** + * A descriptive status message for the container, including + * its state and uptime (e.g., 'Up 5 minutes'). + */ + Status: string +} + +/** + * Same as @StringifiedDockerService, but with parsed labels and an optional workspace config. + */ +export interface StringifiedDockerServiceWithParsedLabels extends Omit { + /** + * The parsed labels. + */ + Labels: Record, + /** + * The workspace config, if found. + */ + config: WorkspaceConfig | undefined +} + +/** + * A fully parsed docker service that is part of a Monux monorepo. + */ +export interface FullyParsedDockerService extends StringifiedDockerServiceWithParsedLabels { + /** + * The configuration of the monorepo. + */ + config: WorkspaceConfig +} + +/** + * Type guard that checks if the given service is fully parsed. + * @param service - The service to check. + * @returns True when there is a workspace config on the service, false otherwise. + */ +export function isFullyParsedDockerService(service: StringifiedDockerServiceWithParsedLabels): service is FullyParsedDockerService { + return service.config != undefined; +} \ No newline at end of file diff --git a/src/commands/validate-input.function.ts b/src/commands/validate-input.function.ts index 759e26e..d8f14d8 100644 --- a/src/commands/validate-input.function.ts +++ b/src/commands/validate-input.function.ts @@ -37,7 +37,11 @@ export async function validateInput(args: string[]): Promise { case Command.VERSION: case Command.V: case Command.INIT: - case Command.I: { + case Command.I: + case Command.LIST: + case Command.LS: + case Command.LIST_ALL: + case Command.LA: { validateMaxLength(args.length, 1); return; } diff --git a/src/docker/docker-labels.enum.ts b/src/docker/docker-labels.enum.ts new file mode 100644 index 0000000..b43a193 --- /dev/null +++ b/src/docker/docker-labels.enum.ts @@ -0,0 +1,8 @@ +/** + * The known labels of a docker service. + */ +export enum DockerLabel { + COMPOSE_FILE_PATH = 'com.docker.compose.project.config_files', + COMPOSE_SERVICE_NAME = 'com.docker.compose.service', + COMPOSE_PROJECT_DIR = 'com.docker.compose.project.working_dir' +} \ No newline at end of file diff --git a/src/docker/docker-traefik.utilities.ts b/src/docker/docker-traefik.utilities.ts new file mode 100644 index 0000000..990dd65 --- /dev/null +++ b/src/docker/docker-traefik.utilities.ts @@ -0,0 +1,88 @@ +import { DockerComposeFileName } from '../constants'; +import { DefaultEnvKeys } from '../env'; +import { toSnakeCase } from '../utilities'; + +/** + * Encapsulates functionality for getting docker traefik labels. + */ +export abstract class DockerTraefikUtilities { + + /** + * Gets the traefik labels for the project with the given name. + * @param projectName - The name of the project to get the labels for. + * @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. + * @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. + */ + static getTraefikLabels( + projectName: string, + port: number, + composeFileName: DockerComposeFileName, + subDomain: string | undefined + ): string[] { + if (subDomain === 'www') { + throw new Error('The subdomain "www" is reserved and will be set automatically.'); + } + + switch (composeFileName) { + case 'dev.docker-compose.yaml': { + return []; + } + case 'local.docker-compose.yaml': { + return this.getTraefikLabelsForLocal(projectName, subDomain, port); + } + case 'docker-compose.yaml': { + return this.getTraefikLabelsForProd(projectName, subDomain, port); + } + } + } + + 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']; + if (!subDomain) { + host = `Host(\`\${${DefaultEnvKeys.PROD_ROOT_DOMAIN}}\`) || Host(\`www.\${${DefaultEnvKeys.PROD_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=true', + `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' + ); + labels.push(`traefik.http.routers.${toSnakeCase(projectName)}.middlewares=${middlewares.join(',')}`); + return labels; + } + + private static getTraefikLabelsForLocal(projectName: string, subDomain: string | undefined, port: number): string[] { + let host: string = `Host(\`\${${DefaultEnvKeys.subDomain(projectName)}}.localhost\`)`; + const labels: string[] = []; + const middlewares: string[] = ['compression']; + if (!subDomain) { + host = 'Host(`localhost`) || Host(`www.localhost`)'; + labels.push( + 'traefik.http.middlewares.wwwredirect.redirectregex.regex=^http://www\.(.*)', + 'traefik.http.middlewares.wwwredirect.redirectregex.replacement=http://$${1}' + ); + middlewares.push('wwwredirect'); + } + labels.push( + 'traefik.enable=true', + `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' + ); + labels.push(`traefik.http.routers.${toSnakeCase(projectName)}.middlewares=${middlewares.join(',')}`); + return labels; + } +} \ No newline at end of file diff --git a/src/docker/docker-utilities.test.ts b/src/docker/docker-utilities.test.ts index 7ac6571..33dcf02 100644 --- a/src/docker/docker-utilities.test.ts +++ b/src/docker/docker-utilities.test.ts @@ -15,7 +15,7 @@ describe('DockerUtilities', () => { const fakeEmail: string = faker.internet.email(); await EnvUtilities.init('test.com'); - await DockerUtilities.createComposeFiles(fakeEmail, mockConstants.PROJECT_DIR); + await DockerUtilities.createComposeFiles(fakeEmail); const initialDockerComposeContent: string[] = await FsUtilities.readFileLines(mockConstants.DOCKER_COMPOSE_YAML); expect(initialDockerComposeContent).toEqual([ @@ -58,7 +58,9 @@ describe('DockerUtilities', () => { `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=4200` + `traefik.http.services.${def.name}.loadbalancer.server.port=4200`, + 'traefik.http.middlewares.compression.compress=true', + `traefik.http.routers.${def.name}.middlewares=compression` ] }).toEqual(service); @@ -71,7 +73,9 @@ describe('DockerUtilities', () => { 'traefik.enable=true', `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=4200` + `traefik.http.services.${def.name}.loadbalancer.server.port=4200`, + 'traefik.http.middlewares.compression.compress=true', + `traefik.http.routers.${def.name}.middlewares=compression` ] }).toEqual(localService); }); diff --git a/src/docker/docker.utilities.ts b/src/docker/docker.utilities.ts index da7bd27..96e39ca 100644 --- a/src/docker/docker.utilities.ts +++ b/src/docker/docker.utilities.ts @@ -5,7 +5,8 @@ import { FsUtilities } from '../encapsulation'; import { ComposeBuild, ComposeDefinition, ComposePort, ComposeService, ComposeServiceEnvironment, ComposeServiceVolume } from './compose-file.model'; import { DefaultEnvKeys, EnvUtilities } from '../env'; import { OmitStrict } from '../types'; -import { getPath, toSnakeCase } from '../utilities'; +import { getPath } from '../utilities'; +import { DockerTraefikUtilities } from './docker-traefik.utilities'; // eslint-disable-next-line jsdoc/require-jsdoc type ParsedDockerComposeEnvironment = { [key: string]: string } | string[]; @@ -48,88 +49,20 @@ type ParsedDockerCompose = { */ export abstract class DockerUtilities { - private static getTraefikLabels( - projectName: string, - port: number, - composeFileName: DockerComposeFileName, - subDomain: string | undefined - ): string[] { - if (subDomain === 'www') { - throw new Error('The subdomain "www" is reserved and will be set automatically.'); - } - - switch (composeFileName) { - // eslint-disable-next-line sonar/no-duplicate-string - case 'dev.docker-compose.yaml': { - return []; - } - // eslint-disable-next-line sonar/no-duplicate-string - case 'local.docker-compose.yaml': { - return this.getTraefikLabelsForLocal(projectName, subDomain, port); - } - // eslint-disable-next-line sonar/no-duplicate-string - case 'docker-compose.yaml': { - return this.getTraefikLabelsForProd(projectName, subDomain, port); - } - } - } - - 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[] = []; - if (!subDomain) { - host = `Host(\`\${${DefaultEnvKeys.PROD_ROOT_DOMAIN}}\`) || Host(\`www.\${${DefaultEnvKeys.PROD_ROOT_DOMAIN}}\`)`; - labels.push( - 'traefik.http.middlewares.wwwredirect.redirectregex.regex=^https://www\.(.*)', - 'traefik.http.middlewares.wwwredirect.redirectregex.replacement=https://$${1}', - `traefik.http.routers.${toSnakeCase(projectName)}.middlewares=wwwredirect` - ); - } - labels.push( - 'traefik.enable=true', - `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}` - ); - return labels; - } - - private static getTraefikLabelsForLocal(projectName: string, subDomain: string | undefined, port: number): string[] { - let host: string = `Host(\`\${${DefaultEnvKeys.subDomain(projectName)}}.localhost\`)`; - const labels: string[] = []; - if (!subDomain) { - host = 'Host(`localhost`) || Host(`www.localhost`)'; - labels.push( - 'traefik.http.middlewares.wwwredirect.redirectregex.regex=^http://www\.(.*)', - 'traefik.http.middlewares.wwwredirect.redirectregex.replacement=http://$${1}', - `traefik.http.routers.${toSnakeCase(projectName)}.middlewares=wwwredirect` - ); - } - labels.push( - 'traefik.enable=true', - `traefik.http.routers.${toSnakeCase(projectName)}.rule=${host}`, - `traefik.http.routers.${toSnakeCase(projectName)}.entrypoints=web`, - `traefik.http.services.${toSnakeCase(projectName)}.loadbalancer.server.port=${port}` - ); - return labels; - } - /** * Creates the initial docker compose files at the given path. * @param email - The email that should be used for the letsencrypt certificate. - * @param rootPath - The path of the root where to create the files. * Defaults to "" (which creates the file in the current directory). */ - static async createComposeFiles(email: string, rootPath: string = ''): Promise { + static async createComposeFiles(email: string): Promise { await Promise.all([ - this.createProdDockerCompose(email, rootPath), - this.createDevDockerCompose(rootPath), - this.createLocalDockerCompose(rootPath) + this.createProdDockerCompose(email), + this.createDevDockerCompose(), + this.createLocalDockerCompose() ]); } - private static async createDevDockerCompose(rootPath: string): Promise { + private static async createDevDockerCompose(): Promise { const compose: ComposeDefinition = { services: [ { @@ -147,10 +80,10 @@ export abstract class DockerUtilities { networks: [] }; const yaml: string[] = this.composeDefinitionToYaml(compose); - await FsUtilities.createFile(getPath(rootPath, DEV_DOCKER_COMPOSE_FILE_NAME), yaml); + await FsUtilities.createFile(getPath(DEV_DOCKER_COMPOSE_FILE_NAME), yaml); } - private static async createLocalDockerCompose(rootPath: string): Promise { + private static async createLocalDockerCompose(): Promise { const compose: ComposeDefinition = { services: [ { @@ -185,10 +118,10 @@ export abstract class DockerUtilities { networks: [] }; const yaml: string[] = this.composeDefinitionToYaml(compose); - await FsUtilities.createFile(getPath(rootPath, LOCAL_DOCKER_COMPOSE_FILE_NAME), yaml); + await FsUtilities.createFile(getPath(LOCAL_DOCKER_COMPOSE_FILE_NAME), yaml); } - private static async createProdDockerCompose(email: string, rootPath: string): Promise { + private static async createProdDockerCompose(email: string): Promise { const compose: ComposeDefinition = { services: [ { @@ -234,7 +167,7 @@ export abstract class DockerUtilities { networks: [] }; const yaml: string[] = this.composeDefinitionToYaml(compose); - await FsUtilities.createFile(getPath(rootPath, PROD_DOCKER_COMPOSE_FILE_NAME), yaml); + await FsUtilities.createFile(getPath(PROD_DOCKER_COMPOSE_FILE_NAME), yaml); } /** @@ -257,7 +190,11 @@ export abstract class DockerUtilities { const composePath: string = getPath(composeFileName); const definition: ComposeDefinition = await this.yamlToComposeDefinition(composePath); - const labels: string[] = addTraefik ? this.getTraefikLabels(service.name, port, composeFileName, subDomain) : []; + const labels: string[] = []; + if (addTraefik) { + const traefikLabels: string[] = DockerTraefikUtilities.getTraefikLabels(service.name, port, composeFileName, subDomain); + labels.push(...traefikLabels); + } definition.services.push({ ...service, labels: [...service.labels ?? [], ...labels] }); await FsUtilities.updateFile(composePath, this.composeDefinitionToYaml(definition), 'replace'); @@ -281,12 +218,15 @@ export abstract class DockerUtilities { key: DefaultEnvKeys.baseUrl(service.name), value: (env, fileName) => { switch (fileName) { + // eslint-disable-next-line sonar/no-duplicate-string case 'dev.docker-compose.yaml': { return `http://localhost:${'PORT_PLACEHOLDER'}`; } + // eslint-disable-next-line sonar/no-duplicate-string case 'local.docker-compose.yaml': { return `http://${'SUB_DOMAIN_PLACEHOLDER'}.localhost`; } + // eslint-disable-next-line sonar/no-duplicate-string case 'docker-compose.yaml': { return `https://${'SUB_DOMAIN_PLACEHOLDER'}.${'PROD_ROOT_DOMAIN_PLACEHOLDER'}`; } diff --git a/src/docker/index.ts b/src/docker/index.ts index 0000614..f1af289 100644 --- a/src/docker/index.ts +++ b/src/docker/index.ts @@ -1,2 +1,3 @@ export * from './docker.utilities'; -export * from './compose-file.model'; \ No newline at end of file +export * from './compose-file.model'; +export * from './docker-labels.enum'; \ No newline at end of file diff --git a/src/encapsulation/chalk.utilities.ts b/src/encapsulation/chalk.utilities.ts index b23d787..abe1b97 100644 --- a/src/encapsulation/chalk.utilities.ts +++ b/src/encapsulation/chalk.utilities.ts @@ -35,6 +35,15 @@ export abstract class ChalkUtilities { return chalk.red(value); } + /** + * Used to log errors in red. + * @param value - The value that should be logged as an error. + * @returns The string to log. + */ + static success(...value: string[]): string { + return chalk.green(value); + } + /** * Used to log something in bold and underlined. * @param value - The value that should be logged bold and underlined. diff --git a/src/encapsulation/cli-table.utilities.ts b/src/encapsulation/cli-table.utilities.ts new file mode 100644 index 0000000..9dd436a --- /dev/null +++ b/src/encapsulation/cli-table.utilities.ts @@ -0,0 +1,49 @@ +import CliTable3, { Table } from 'cli-table3'; + +import { ChalkUtilities } from './chalk.utilities'; + +/** + * Definition for printing a table to the console. + */ +export type CliTable = { + /** + * The title of the table. Is displayed above. + */ + title: string, + /** + * The headers of the table. + */ + headers: string[], + /** + * The rows of the table. + */ + rows: string[][] +}; + +/** + * Encapsulates functionality of the cli-table3 package. + */ +export abstract class CliTableUtilities { + /** + * Logs the given table data pretty printed to the console. + * @param data - The data of the table to print. + */ + static logTable(data: CliTable): void { + const table: Table = new CliTable3({ + head: data.headers.map(h => ChalkUtilities.secondary(h)), + colWidths: [25, 25, 40], + style: { + compact: true + } + }); + + for (const row of data.rows) { + table.push(row); + } + + // eslint-disable-next-line no-console + console.log(ChalkUtilities.boldUnderline(data.title)); + // eslint-disable-next-line no-console + console.log(table.toString()); + } +} \ No newline at end of file diff --git a/src/encapsulation/index.ts b/src/encapsulation/index.ts index ad6a24f..d81a83b 100644 --- a/src/encapsulation/index.ts +++ b/src/encapsulation/index.ts @@ -5,5 +5,6 @@ export * from './death.utilities'; export * from './fs.utilities'; export * from './json.utilities'; export * from './cp.utilities'; +export * from './cli-table.utilities'; // eslint-disable-next-line jsdoc/require-jsdoc export { CustomTsValues } from './custom-ts.resolver'; \ No newline at end of file diff --git a/src/env/env-utilities.test.ts b/src/env/env-utilities.test.ts index 856caf0..004d6aa 100644 --- a/src/env/env-utilities.test.ts +++ b/src/env/env-utilities.test.ts @@ -128,7 +128,7 @@ describe('EnvUtilities', () => { '/**', '* Defines how the CalculatedGlobalEnvironment values should be calculated.', '* This is used by the "mx prepare" command.', - '* DONT CHANGE THE NAME ("calculationSchemaFor") OR FORMATTING. Otherwise Monux might not be able to detect it.', + '* DON\'T CHANGE THE NAME ("calculationSchemaFor") OR FORMATTING. Otherwise Monux might not be able to detect it.', '*/', 'const calculationSchemaFor: Record<', ' keyof CalculatedGlobalEnvironment,', diff --git a/src/env/env.utilities.ts b/src/env/env.utilities.ts index 71b04f8..0a08613 100644 --- a/src/env/env.utilities.ts +++ b/src/env/env.utilities.ts @@ -402,7 +402,7 @@ export abstract class EnvUtilities { '/**', '* Defines how the CalculatedGlobalEnvironment values should be calculated.', '* This is used by the "mx prepare" command.', - '* DONT CHANGE THE NAME ("calculationSchemaFor") OR FORMATTING. Otherwise Monux might not be able to detect it.', + '* DON\'T CHANGE THE NAME ("calculationSchemaFor") OR FORMATTING. Otherwise Monux might not be able to detect it.', '*/', 'const calculationSchemaFor: Record<', '\tkeyof CalculatedGlobalEnvironment,', diff --git a/src/index.ts b/src/index.ts index bf52409..078af68 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { Command, isCommand, runAdd, runDown, runDownDev, runGeneratePage, runHelp, runInit, runPrepare, runRun, runRunAll, runUp, runUpDev, runUpLocal, runVersion } from './commands'; +import { Command, isCommand, runAdd, runDown, runDownDev, runGeneratePage, runHelp, runInit, runList, runPrepare, runRun, runRunAll, runUp, runUpDev, runUpLocal, runVersion } from './commands'; import { runDownLocal } from './commands/down-local'; import { validateInput } from './commands/validate-input.function'; import { DeathUtilities, FigletUtilities } from './encapsulation'; @@ -87,6 +87,16 @@ async function main(): Promise { runRunAll(args[1]); return; } + case Command.LIST: + case Command.LS: { + await runList(false); + return; + } + case Command.LIST_ALL: + case Command.LA: { + await runList(true); + return; + } } } diff --git a/src/robots/robots-utilities.test.ts b/src/robots/robots-utilities.test.ts index 70d9046..09c5da7 100644 --- a/src/robots/robots-utilities.test.ts +++ b/src/robots/robots-utilities.test.ts @@ -22,11 +22,15 @@ describe('RobotsUtilities', () => { const isPublic: boolean = await EnvUtilities.getEnvVariable(DefaultEnvKeys.IS_PUBLIC, 'dev.docker-compose.yaml'); expect(isPublic).toBe(false); - await RobotsUtilities.createRobotsTxtForApp({ - path: mockConstants.ANGULAR_APP_DIR, - name: mockConstants.ANGULAR_APP_NAME, - npmWorkspaceString: `apps/${mockConstants.ANGULAR_APP_NAME}` - }, 'dev.docker-compose.yaml'); + await RobotsUtilities.createRobotsTxtForApp( + { + path: mockConstants.ANGULAR_APP_DIR, + name: mockConstants.ANGULAR_APP_NAME, + npmWorkspaceString: `apps/${mockConstants.ANGULAR_APP_NAME}` + }, + 'dev.docker-compose.yaml', + undefined + ); const robotsTxt: string[] = await FsUtilities.readFileLines(getPath(mockConstants.ANGULAR_APP_DIR, 'src', ROBOTS_FILE_NAME)); expect(robotsTxt).toEqual([ diff --git a/src/robots/robots.utilities.ts b/src/robots/robots.utilities.ts index 825cbb8..1fdd71c 100644 --- a/src/robots/robots.utilities.ts +++ b/src/robots/robots.utilities.ts @@ -25,7 +25,7 @@ export abstract class RobotsUtilities { return await FsUtilities.exists(sitemapPath); }); await Promise.all(apps.map(async a => { - return this.createRobotsTxtForApp(a, fileName); + return this.createRobotsTxtForApp(a, fileName, undefined); })); } @@ -33,10 +33,12 @@ export abstract class RobotsUtilities { * Create a robots.txt file for the provided app. * @param app - The app to generate the robots.txt for. * @param fileName - The docker compose file get the variables for. + * @param domain - An optional domain. This is used when creating new projects, where the domain environment variable has not been set yet. */ static async createRobotsTxtForApp( app: WorkspaceProject, - fileName: DockerComposeFileName + fileName: DockerComposeFileName, + domain: string | undefined ): Promise { const robotsTxtPath: string = getPath(app.path, 'src', ROBOTS_FILE_NAME); await FsUtilities.rm(robotsTxtPath); @@ -48,7 +50,7 @@ export abstract class RobotsUtilities { `${isPublic ? 'Allow' : 'Disallow'}: /` ]; - const baseUrl: string = await EnvUtilities.getEnvVariable(DefaultEnvKeys.baseUrl(app.name), fileName); + const baseUrl: string = domain ? `https://${domain}` : await EnvUtilities.getEnvVariable(DefaultEnvKeys.baseUrl(app.name), fileName); content.push('', `Sitemap: ${baseUrl}/${SITEMAP_FILE_NAME}`); await FsUtilities.createFile(robotsTxtPath, content); diff --git a/src/workspace/workspace.utilities.ts b/src/workspace/workspace.utilities.ts index 6293192..9d25eca 100644 --- a/src/workspace/workspace.utilities.ts +++ b/src/workspace/workspace.utilities.ts @@ -1,7 +1,7 @@ import { Dirent } from 'fs'; import { APPS_DIRECTORY_NAME, LIBS_DIRECTORY_NAME, WORKSPACE_FILE_NAME } from '../constants'; -import { FsUtilities, JsonUtilities } from '../encapsulation'; +import { CPUtilities, FsUtilities, JsonUtilities } from '../encapsulation'; import { WorkspaceConfig } from './workspace-config.model'; import { getPath } from '../utilities'; @@ -32,21 +32,22 @@ export abstract class WorkspaceUtilities { * Creates a new workspace config file inside the current directory. */ static async createConfig(): Promise { - const cwd: string = process.cwd(); + const cwd: string = CPUtilities['cwd'] ?? process.cwd(); const currentDirectory: string = cwd.substring(cwd.lastIndexOf('/') + 1); const data: WorkspaceConfig = { isWorkspace: true, name: currentDirectory }; - await FsUtilities.createFile(WORKSPACE_FILE_NAME, JsonUtilities.stringify(data)); + await FsUtilities.createFile(getPath(WORKSPACE_FILE_NAME), JsonUtilities.stringify(data)); } /** * Gets the workspace configuration if there is any. + * @param workspaceFilePath - The path of the mx workspace file. Can be used when not running in the context of a workspace. * @returns The found config or undefined. */ - static async getConfig(): Promise { - if (!await FsUtilities.exists(WORKSPACE_FILE_NAME)) { + static async getConfig(workspaceFilePath: string = getPath(WORKSPACE_FILE_NAME)): Promise { + if (!await FsUtilities.exists(workspaceFilePath)) { return; } - const fileContent: string = await FsUtilities.readFile(WORKSPACE_FILE_NAME); + const fileContent: string = await FsUtilities.readFile(workspaceFilePath); return JsonUtilities.parse(fileContent); }