diff --git a/README.md b/README.md index 359766d5..ce116229 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,8 @@ USAGE * [`mw database redis list`](#mw-database-redis-list) * [`mw database redis shell DATABASE-ID`](#mw-database-redis-shell-database-id) * [`mw database redis versions`](#mw-database-redis-versions) +* [`mw ddev init [INSTALLATION-ID]`](#mw-ddev-init-installation-id) +* [`mw ddev render-config [INSTALLATION-ID]`](#mw-ddev-render-config-installation-id) * [`mw domain dnszone get DNSZONE-ID`](#mw-domain-dnszone-get-dnszone-id) * [`mw domain dnszone list`](#mw-domain-dnszone-list) * [`mw domain dnszone update DNSZONE-ID RECORD-SET`](#mw-domain-dnszone-update-dnszone-id-record-set) @@ -2698,6 +2700,95 @@ FLAG DESCRIPTIONS to persistently set a default project for all commands that accept this flag. ``` +## `mw ddev init [INSTALLATION-ID]` + +Initialize a new ddev project in the current directory. + +``` +USAGE + $ mw ddev init [INSTALLATION-ID] [-q] [--override-type ] [--project-name ] + [--override-mittwald-plugin ] + +ARGUMENTS + INSTALLATION-ID ID or short ID of an app installation; this argument is optional if a default app installation is set + in the context + +FLAGS + -q, --quiet suppress process output and only display a machine-readable summary. + --override-type= [default: auto] Override the type of the generated DDEV configuration + --project-name= DDEV project name + +DEVELOPMENT FLAGS + --override-mittwald-plugin= [default: mittwald/ddev] override the mittwald plugin + +DESCRIPTION + Initialize a new ddev project in the current directory. + + This command initializes a new ddev configuration for an existing app installation in the current directory. + + More precisely, this command will do the following: + + 1. Create a new ddev configuration file in the .ddev directory, appropriate for the reference app installation + 2. Initialize a new ddev project with the given configuration + 3. Install the official mittwald DDEV addon + 4. Add SSH credentials to the DDEV project + + This command can be run repeatedly to update the DDEV configuration of the project. + + Please note that this command requires DDEV to be installed on your system. + +FLAG DESCRIPTIONS + -q, --quiet suppress process output and only display a machine-readable summary. + + This flag controls if you want to see the process output or only a summary. When using mw non-interactively (e.g. in + scripts), you can use this flag to easily get the IDs of created resources for further processing. + + --override-mittwald-plugin= override the mittwald plugin + + This flag allows you to override the mittwald plugin that should be installed by default; this is useful for testing + purposes + + --override-type= Override the type of the generated DDEV configuration + + The type of the generated DDEV configuration; this can be any of the documented DDEV project types, or 'auto' (which + is also the default) for automatic discovery. + + See https://ddev.readthedocs.io/en/latest/users/configuration/config/#type for more information + + --project-name= DDEV project name + + The name of the DDEV project +``` + +## `mw ddev render-config [INSTALLATION-ID]` + +Generate a DDEV configuration YAML file for the current app. + +``` +USAGE + $ mw ddev render-config [INSTALLATION-ID] [--override-type ] + +ARGUMENTS + INSTALLATION-ID ID or short ID of an app installation; this argument is optional if a default app installation is set + in the context + +FLAGS + --override-type= [default: auto] Override the type of the generated DDEV configuration + +DESCRIPTION + Generate a DDEV configuration YAML file for the current app. + + This command initializes a new ddev configuration in the current directory. + +FLAG DESCRIPTIONS + --override-type= Override the type of the generated DDEV configuration + + The type of the generated DDEV configuration; this can be any of the documented DDEV project types, or 'auto' (which + is also the default) for automatic discovery. + + See https://ddev.readthedocs.io/en/latest/users/configuration/config/#type for more information +``` + ## `mw domain dnszone get DNSZONE-ID` gets a specific zone diff --git a/package.json b/package.json index bfd1bbf3..0176c11b 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "devDependencies": { "@types/chalk": "^2.2.0", "@types/copyfiles": "^2.4.1", - "@types/js-yaml": "^4.0.5", + "@types/js-yaml": "^4.0.9", "@types/marked-terminal": "^3.1.3", "@types/node": "^20.3.3", "@types/node-notifier": "^8.0.2", @@ -154,6 +154,9 @@ "database": { "description": "Manage databases (like MySQL and Redis) in your projects" }, + "ddev": { + "description": "Integrate your mittwald projects with DDEV" + }, "domain": { "description": "Manage domains, virtual hosts and DNS settings in your projects" }, diff --git a/src/commands/app/download.tsx b/src/commands/app/download.tsx index c1d7517a..080e2288 100644 --- a/src/commands/app/download.tsx +++ b/src/commands/app/download.tsx @@ -7,9 +7,9 @@ import { import { Flags } from "@oclif/core"; import { Success } from "../../rendering/react/components/Success.js"; import { ReactNode } from "react"; -import { spawn } from "child_process"; import { hasBinary } from "../../lib/hasbin.js"; import { getSSHConnectionForAppInstallation } from "../../lib/ssh/appinstall.js"; +import { spawnInProcess } from "../../rendering/process/process_exec.js"; import { sshConnectionFlags } from "../../lib/ssh/flags.js"; export class Download extends ExecRenderBaseCommand { @@ -64,10 +64,6 @@ export class Download extends ExecRenderBaseCommand { } }); - const downloadStep = p.addStep( - "downloading app installation" + (dryRun ? " (dry-run)" : ""), - ); - const rsyncOpts = [ "--archive", "--recursive", @@ -82,38 +78,23 @@ export class Download extends ExecRenderBaseCommand { rsyncOpts.push("--delete"); } - const child = spawn( + await spawnInProcess( + p, + "downloading app installation" + (dryRun ? " (dry-run)" : ""), "rsync", [...rsyncOpts, `${user}@${host}:${directory}/`, target], - { - shell: false, - }, - ); - - child.stdout.on("data", (chunk) => - downloadStep.appendOutput(chunk.toString()), - ); - child.stderr.on("data", (chunk) => - downloadStep.appendOutput(chunk.toString()), ); - child.on("exit", (code) => { - if (code === 0) { - downloadStep.complete(); - } else { - downloadStep.error(new Error(`rsync exited with code ${code}`)); - } - }); - - await downloadStep.wait(); if (dryRun) { - p.complete( + await p.complete( App would (probably) have successfully been downloaded. 🙂 , ); } else { - p.complete(App successfully downloaded; have fun! 🚀); + await p.complete( + App successfully downloaded; have fun! 🚀, + ); } } diff --git a/src/commands/app/install/drupal.tsx b/src/commands/app/install/drupal.tsx index c022563a..19fa8ed0 100644 --- a/src/commands/app/install/drupal.tsx +++ b/src/commands/app/install/drupal.tsx @@ -5,7 +5,7 @@ import { AppInstaller, } from "../../../lib/app/Installer.js"; -const installer = new AppInstaller( +export const drupalInstaller = new AppInstaller( "3d8a261a-3d6f-4e09-b68c-bfe90aece514", "Drupal", [ @@ -23,14 +23,19 @@ export default class InstallDrupal extends ExecRenderBaseCommand< typeof InstallDrupal, AppInstallationResult > { - static description = installer.description; - static flags = installer.flags; + static description = drupalInstaller.description; + static flags = drupalInstaller.flags; protected async exec(): Promise<{ appInstallationId: string }> { - return installer.exec(this.apiClient, this.args, this.flags, this.config); + return drupalInstaller.exec( + this.apiClient, + this.args, + this.flags, + this.config, + ); } protected render(result: AppInstallationResult): React.ReactNode { - return installer.render(result, this.flags); + return drupalInstaller.render(result, this.flags); } } diff --git a/src/commands/app/install/shopware6.tsx b/src/commands/app/install/shopware6.tsx index 9bafa4a3..d2c43b4c 100644 --- a/src/commands/app/install/shopware6.tsx +++ b/src/commands/app/install/shopware6.tsx @@ -5,7 +5,7 @@ import { AppInstaller, } from "../../../lib/app/Installer.js"; -const installer = new AppInstaller( +export const shopware6Installer = new AppInstaller( "12d54d05-7e55-4cf3-90c4-093516e0eaf8", "Shopware 6", [ @@ -28,14 +28,19 @@ export default class InstallShopware6 extends ExecRenderBaseCommand< typeof InstallShopware6, AppInstallationResult > { - static description = installer.description; - static flags = installer.flags; + static description = shopware6Installer.description; + static flags = shopware6Installer.flags; protected async exec(): Promise<{ appInstallationId: string }> { - return installer.exec(this.apiClient, this.args, this.flags, this.config); + return shopware6Installer.exec( + this.apiClient, + this.args, + this.flags, + this.config, + ); } protected render(result: AppInstallationResult): React.ReactNode { - return installer.render(result, this.flags); + return shopware6Installer.render(result, this.flags); } } diff --git a/src/commands/app/install/typo3.tsx b/src/commands/app/install/typo3.tsx index 7b8605f2..6a2337ec 100644 --- a/src/commands/app/install/typo3.tsx +++ b/src/commands/app/install/typo3.tsx @@ -5,7 +5,7 @@ import { AppInstaller, } from "../../../lib/app/Installer.js"; -const installer = new AppInstaller( +export const typo3Installer = new AppInstaller( "352971cc-b96a-4a26-8651-b08d7c8a7357", "TYPO3", [ @@ -24,14 +24,19 @@ export default class InstallTYPO3 extends ExecRenderBaseCommand< typeof InstallTYPO3, AppInstallationResult > { - static description = installer.description; - static flags = installer.flags; + static description = typo3Installer.description; + static flags = typo3Installer.flags; protected async exec(): Promise<{ appInstallationId: string }> { - return installer.exec(this.apiClient, this.args, this.flags, this.config); + return typo3Installer.exec( + this.apiClient, + this.args, + this.flags, + this.config, + ); } protected render(result: AppInstallationResult): React.ReactNode { - return installer.render(result, this.flags); + return typo3Installer.render(result, this.flags); } } diff --git a/src/commands/app/install/wordpress.tsx b/src/commands/app/install/wordpress.tsx index dfe218a6..8b77155b 100644 --- a/src/commands/app/install/wordpress.tsx +++ b/src/commands/app/install/wordpress.tsx @@ -5,7 +5,7 @@ import { AppInstaller, } from "../../../lib/app/Installer.js"; -const installer = new AppInstaller( +export const wordpressInstaller = new AppInstaller( "da3aa3ae-4b6b-4398-a4a8-ee8def827876", "WordPress", [ @@ -23,14 +23,19 @@ export default class InstallWordPress extends ExecRenderBaseCommand< typeof InstallWordPress, AppInstallationResult > { - static description = installer.description; - static flags = installer.flags; + static description = wordpressInstaller.description; + static flags = wordpressInstaller.flags; protected async exec(): Promise<{ appInstallationId: string }> { - return installer.exec(this.apiClient, this.args, this.flags, this.config); + return wordpressInstaller.exec( + this.apiClient, + this.args, + this.flags, + this.config, + ); } protected render(result: AppInstallationResult): React.ReactNode { - return installer.render(result, this.flags); + return wordpressInstaller.render(result, this.flags); } } diff --git a/src/commands/context/get.tsx b/src/commands/context/get.tsx index 347f36af..56d517b7 100644 --- a/src/commands/context/get.tsx +++ b/src/commands/context/get.tsx @@ -20,23 +20,46 @@ const ContextSourceValue: FC<{ source: ContextValueSource }> = ({ source }) => { switch (source.type) { case "user": return ( - - user configuration, in{" "} - - + ); case "terraform": return ( - - terraform state file, in{" "} - - + + ); + case "ddev": + return ( + ); default: - return unknown; + return ; } }; +const ContextSourceKnownValue: FC<{ + name: string; + source: ContextValueSource; + relative?: boolean; +}> = ({ name, source, relative }) => { + return ( + + {name}, in{" "} + + + ); +}; + +const ContextSourceUnknown: FC = () => { + return unknown; +}; + const ContextSource: FC<{ source: ContextValueSource }> = ({ source }) => { return ( @@ -51,6 +74,7 @@ const GetContext: FC<{ ctx: Context }> = ({ ctx }) => { const values: Record = {}; let hasTerraformSource = false; + let hasDDEVSource = false; for (const key of [ "project-id", @@ -69,6 +93,7 @@ const GetContext: FC<{ ctx: Context }> = ({ ctx }) => { hasTerraformSource = hasTerraformSource || value.source.type === "terraform"; + hasDDEVSource = hasDDEVSource || value.source.type === "ddev"; } else { rows[`--${key}`] = ; } @@ -79,33 +104,45 @@ const GetContext: FC<{ ctx: Context }> = ({ ctx }) => { } return ( - <> - - - - - {hasTerraformSource && ( - - You are in a directory that contains a terraform state file; some of - the context values were read from there. - - )} - - Use the mw context set command to set one of the values - listed above. - + + + - + {hasTerraformSource && } + {hasDDEVSource && } + + ); }; +const TerraformHint: FC = () => ( + + You are in a directory that contains a terraform state file; some of the + context values were read from there. + +); + +const DDEVHint: FC = () => ( + + You are in a directory that contains a DDEV project; some of the context + values were read from there. + +); + +const ContextSetHint: FC = () => ( + + Use the mw context set command to set one of the values + listed above. + +); + export class Get extends RenderBaseCommand { static summary = "Print an overview of currently set context parameters"; static description = Set.description; static flags = { ...RenderBaseCommand.buildFlags() }; protected render(): ReactNode { - const ctx = new Context(this.config); + const ctx = new Context(this.apiClient, this.config); return ; } } diff --git a/src/commands/context/reset.ts b/src/commands/context/reset.ts index 991f45d8..1b0a32b5 100644 --- a/src/commands/context/reset.ts +++ b/src/commands/context/reset.ts @@ -7,6 +7,6 @@ export class Reset extends BaseCommand { "This command resets any values for common parameters that you've previously set with 'context set'."; public async run(): Promise { - await new Context(this.config).reset(); + await new Context(this.apiClient, this.config).reset(); } } diff --git a/src/commands/context/set.ts b/src/commands/context/set.ts index 535dfede..d2e48d6d 100644 --- a/src/commands/context/set.ts +++ b/src/commands/context/set.ts @@ -30,7 +30,7 @@ export class Set extends BaseCommand { public async run(): Promise { const { flags } = await this.parse(Set); - const ctx = new Context(this.config); + const ctx = new Context(this.apiClient, this.config); if (flags["project-id"]) { const projectId = await normalizeProjectId( diff --git a/src/commands/ddev/init.tsx b/src/commands/ddev/init.tsx new file mode 100644 index 00000000..af5b7a90 --- /dev/null +++ b/src/commands/ddev/init.tsx @@ -0,0 +1,170 @@ +import { ExecRenderBaseCommand } from "../../rendering/react/ExecRenderBaseCommand.js"; +import { appInstallationArgs } from "../../lib/app/flags.js"; +import React from "react"; +import { + makeProcessRenderer, + processFlags, +} from "../../rendering/process/process_flags.js"; +import { mkdir, writeFile } from "fs/promises"; +import path from "path"; +import { DDEVConfigBuilder } from "../../lib/ddev/config_builder.js"; +import { spawnInProcess } from "../../rendering/process/process_exec.js"; +import { Flags } from "@oclif/core"; +import { DDEVInitSuccess } from "../../rendering/react/components/DDEV/DDEVInitSuccess.js"; +import { DDEVConfig, ddevConfigToFlags } from "../../lib/ddev/config.js"; +import { hasBinary } from "../../lib/hasbin.js"; +import { ProcessRenderer } from "../../rendering/process/process.js"; +import { renderDDEVConfig } from "../../lib/ddev/config_render.js"; +import { loadDDEVConfig } from "../../lib/ddev/config_loader.js"; +import { Value } from "../../rendering/react/components/Value.js"; +import { ddevFlags } from "../../lib/ddev/flags.js"; + +export class Init extends ExecRenderBaseCommand { + static summary = "Initialize a new ddev project in the current directory."; + static description = + "This command initializes a new ddev configuration for an existing app installation in the current directory.\n" + + "\n" + + "More precisely, this command will do the following:\n\n" + + " 1. Create a new ddev configuration file in the .ddev directory, appropriate for the reference app installation\n" + + " 2. Initialize a new ddev project with the given configuration\n" + + " 3. Install the official mittwald DDEV addon\n" + + " 4. Add SSH credentials to the DDEV project\n" + + "\n" + + "This command can be run repeatedly to update the DDEV configuration of the project.\n" + + "\n" + + "Please note that this command requires DDEV to be installed on your system."; + + static flags = { + ...processFlags, + ...ddevFlags, + "project-name": Flags.string({ + summary: "DDEV project name", + description: "The name of the DDEV project", + required: false, + default: undefined, + }), + "override-mittwald-plugin": Flags.string({ + summary: "override the mittwald plugin", + helpGroup: "Development", + description: + "This flag allows you to override the mittwald plugin that should be installed by default; this is useful for testing purposes", + default: "mittwald/ddev", + }), + }; + static args = { + ...appInstallationArgs, + }; + + protected async exec(): Promise { + const appInstallationId = await this.withAppInstallationId(Init); + const r = makeProcessRenderer(this.flags, "Initializing DDEV project"); + + await assertDDEVIsInstalled(r); + + const config = await this.writeMittwaldConfiguration(r, appInstallationId); + const projectName = await this.determineProjectName(r); + + await this.initializeDDEVProject(r, config, projectName); + await this.installMittwaldPlugin(r); + await this.addSSHCredentials(r); + + await r.complete(); + } + + protected render(): React.ReactNode { + return undefined; + } + + private async addSSHCredentials(r: ProcessRenderer) { + await spawnInProcess(r, "adding SSH credentials to DDEV", "ddev", [ + "auth", + "ssh", + ]); + } + + private async installMittwaldPlugin(r: ProcessRenderer) { + const { "override-mittwald-plugin": mittwaldPlugin } = this.flags; + await spawnInProcess(r, "installing mittwald plugin", "ddev", [ + "get", + mittwaldPlugin, + ]); + } + + private async initializeDDEVProject( + r: ProcessRenderer, + config: Partial, + projectName: string, + ): Promise { + await spawnInProcess(r, "initializing DDEV project", "ddev", [ + "config", + "--project-name", + projectName, + ...ddevConfigToFlags(config), + ]); + } + + private async determineProjectName(r: ProcessRenderer): Promise { + const { "project-name": projectName } = this.flags; + if (projectName) { + return projectName; + } + + const existing = await loadDDEVConfig(); + if (existing?.name) { + r.addInfo(); + return existing.name; + } + + return await r.addInput("Enter the project name", false); + } + + private async writeMittwaldConfiguration( + r: ProcessRenderer, + appInstallationId: string, + ) { + return await r.runStep( + "creating mittwald-specific DDEV configuration", + async () => { + const builder = new DDEVConfigBuilder(this.apiClient); + const config = await builder.build( + appInstallationId, + this.flags["override-type"], + ); + const configFile = path.join(".ddev", "config.mittwald.yaml"); + + await writeContentsToFile( + configFile, + renderDDEVConfig(appInstallationId, config), + ); + + return config; + }, + ); + } +} + +async function assertDDEVIsInstalled(r: ProcessRenderer): Promise { + await r.runStep("check if DDEV is installed", async () => { + if (!(await hasBinary("ddev"))) { + throw new Error("this command requires DDEV to be installed"); + } + }); +} + +async function writeContentsToFile( + filename: string, + data: string, +): Promise { + const dirname = path.dirname(filename); + + await mkdir(dirname, { recursive: true }); + await writeFile(filename, data); +} + +function InfoUsingExistingName({ name }: { name: string }) { + return ( + <> + using existing project name: {name} + + ); +} diff --git a/src/commands/ddev/render-config.ts b/src/commands/ddev/render-config.ts new file mode 100644 index 00000000..62605047 --- /dev/null +++ b/src/commands/ddev/render-config.ts @@ -0,0 +1,33 @@ +import { appInstallationArgs } from "../../lib/app/flags.js"; +import { ExtendedBaseCommand } from "../../ExtendedBaseCommand.js"; +import { DDEVConfigBuilder } from "../../lib/ddev/config_builder.js"; +import { renderDDEVConfig } from "../../lib/ddev/config_render.js"; +import { ddevFlags } from "../../lib/ddev/flags.js"; + +export class RenderConfig extends ExtendedBaseCommand { + static summary = + "Generate a DDEV configuration YAML file for the current app."; + static description = + "This command initializes a new ddev configuration in the current directory."; + + static flags = { + ...ddevFlags, + }; + + static args = { + ...appInstallationArgs, + }; + + public async run(): Promise { + const appInstallationId = await this.withAppInstallationId(RenderConfig); + const projectType = this.flags["override-type"]; + + const ddevConfigBuilder = new DDEVConfigBuilder(this.apiClient); + const ddevConfig = await ddevConfigBuilder.build( + appInstallationId, + projectType, + ); + + this.log(renderDDEVConfig(appInstallationId, ddevConfig)); + } +} diff --git a/src/commands/project/create.tsx b/src/commands/project/create.tsx index 9df4b99c..8a991b84 100644 --- a/src/commands/project/create.tsx +++ b/src/commands/project/create.tsx @@ -80,7 +80,9 @@ export default class Create extends ExecRenderBaseCommand< if (flags["update-context"]) { await process.runStep("updating CLI context", async () => { - await new Context(this.config).setProjectId(result.data.id); + await new Context(this.apiClient, this.config).setProjectId( + result.data.id, + ); }); } diff --git a/src/lib/context.ts b/src/lib/context.ts index 4870183c..7fb8031c 100644 --- a/src/lib/context.ts +++ b/src/lib/context.ts @@ -1,6 +1,8 @@ import { Config } from "@oclif/core"; import { TerraformContextProvider } from "./context_terraform.js"; import { UserContextProvider } from "./context_user.js"; +import { DDEVContextProvider } from "./context_ddev.js"; +import { MittwaldAPIV2Client } from "@mittwald/api-client"; export type ContextNames = | "project" @@ -35,10 +37,11 @@ export class Context { public readonly providers: ContextProvider[]; - public constructor(config: Config) { + public constructor(apiClient: MittwaldAPIV2Client, config: Config) { this.providers = [ new UserContextProvider(config), new TerraformContextProvider(), + new DDEVContextProvider(apiClient), ]; this.contextData = this.initializeContextData(); } diff --git a/src/lib/context_ddev.ts b/src/lib/context_ddev.ts new file mode 100644 index 00000000..84a01a08 --- /dev/null +++ b/src/lib/context_ddev.ts @@ -0,0 +1,105 @@ +import { ContextMap, ContextProvider, ContextValueSource } from "./context.js"; +import { cwd } from "process"; +import path from "path"; +import fs from "fs/promises"; +import yaml from "js-yaml"; +import { DDEVConfig } from "./ddev/config.js"; +import { assertStatus, MittwaldAPIV2Client } from "@mittwald/api-client"; +import { pathExists } from "./fsutil.js"; + +/** + * DDEVContextProvider is a ContextProvider that reads context overrides from + * local DDEV configuration files; it looks for a .ddev directory in the current + * working directory or any of its parent directories and reads any + * configuration yaml files from it. + */ +export class DDEVContextProvider implements ContextProvider { + name = "ddev"; + + private apiClient: MittwaldAPIV2Client; + + public constructor(apiClient: MittwaldAPIV2Client) { + this.apiClient = apiClient; + } + + async getOverrides(): Promise { + const ddevConfigDir = await this.findDDEVConfigDir(); + if (!ddevConfigDir) { + return {}; + } + + const configs = [ + "config.yaml", + ...(await findDDEVConfigFiles(ddevConfigDir)), + ]; + + let overrides: ContextMap = {}; + + for (const config of configs) { + const configPath = path.join(ddevConfigDir, config); + const contents = await fs.readFile(configPath, "utf-8"); + const parsed = yaml.load(contents) as Partial; + const source = { type: "ddev", identifier: configPath }; + + for (const env of parsed.web_environment ?? []) { + const [name, valueInput] = env.split("=", 2); + if (name === "MITTWALD_APP_INSTALLATION_ID") { + overrides = { + ...overrides, + ...(await this.fillOverridesFromAppInstallationId( + source, + valueInput, + )), + }; + } + } + } + + return overrides; + } + + private async fillOverridesFromAppInstallationId( + source: ContextValueSource, + appInstallationId: string, + ): Promise { + const response = await this.apiClient.app.getAppinstallation({ + appInstallationId, + }); + assertStatus(response, 200); + + const out: ContextMap = { + "installation-id": { value: response.data.id, source }, + }; + + if (response.data.projectId) { + out["project-id"] = { + value: response.data.projectId, + source, + }; + } + + return out; + } + + /** + * Find the .ddev directory in the current working directory or any of its + * parent directories. + */ + private async findDDEVConfigDir(): Promise { + let currentDir = cwd(); + while (currentDir !== "/") { + const ddevDir = path.join(currentDir, ".ddev", "config.yaml"); + if (await pathExists(ddevDir)) { + return path.dirname(ddevDir); + } + + currentDir = path.dirname(currentDir); + } + return undefined; + } +} + +async function findDDEVConfigFiles(dir: string): Promise { + const configFilePattern = /^config\.*\.ya?ml$/; + return (await fs.readdir(dir)).filter((e) => configFilePattern.test(e)); +} diff --git a/src/lib/context_flags.ts b/src/lib/context_flags.ts index 53fe8b7a..04260141 100644 --- a/src/lib/context_flags.ts +++ b/src/lib/context_flags.ts @@ -164,7 +164,9 @@ export function makeFlagSet( return normalize(apiClient, idInput); } - const idFromContext = await new Context(cfg).getContextValue(flagName); + const idFromContext = await new Context(apiClient, cfg).getContextValue( + flagName, + ); if (idFromContext) { return idFromContext.value; } diff --git a/src/lib/context_terraform.ts b/src/lib/context_terraform.ts index 93e31e23..be511588 100644 --- a/src/lib/context_terraform.ts +++ b/src/lib/context_terraform.ts @@ -2,6 +2,7 @@ import fs from "fs/promises"; import { cwd } from "process"; import path from "path"; import { ContextMap, ContextProvider } from "./context.js"; +import { pathExists } from "./fsutil.js"; interface TerraformInstance { attributes: Record; @@ -74,16 +75,10 @@ export class TerraformContextProvider implements ContextProvider { let currentDir = cwd(); while (currentDir !== "/") { const stateFile = path.join(currentDir, "terraform.tfstate"); - try { - await fs.stat(stateFile); + if (await pathExists(stateFile)) { return stateFile; - } catch (e) { - if (e instanceof Error && "code" in e && e.code === "ENOENT") { - currentDir = path.dirname(currentDir); - continue; - } - throw e; } + currentDir = path.dirname(currentDir); } return undefined; } diff --git a/src/lib/ddev/config.ts b/src/lib/ddev/config.ts new file mode 100644 index 00000000..3f09e861 --- /dev/null +++ b/src/lib/ddev/config.ts @@ -0,0 +1,62 @@ +/** + * This type defines a subset of the DDEV configuration that is relevant for the + * DDEV init-config command. See the [full reference][ddev-config] for a full + * reference. + * + * [ddev-config]: https://ddev.readthedocs.io/en/latest/users/configuration/config/ + */ +export interface DDEVConfig { + name: string; + type: string; + override_config: boolean; + webserver_type: string; + php_version: string; + nodejs_version: string; + web_environment: string[]; + docroot: string; + database: DDEVDatabaseConfig; +} + +export interface DDEVDatabaseConfig { + type: string; + version: string; +} + +/** + * Convert a DDEV configuration to a list of command-line flags that can be used + * for the "ddev config" command. + * + * @param config + */ +export function ddevConfigToFlags(config: Partial): string[] { + const flags: string[] = []; + + if (config.type) { + flags.push("--project-type", config.type); + } + + if (config.webserver_type) { + flags.push("--webserver-type", config.webserver_type); + } + + if (config.php_version) { + flags.push("--php-version", config.php_version); + } + + if (config.docroot) { + flags.push("--docroot", config.docroot); + } + + if (config.database) { + flags.push( + "--database", + `${config.database.type}:${config.database.version}`, + ); + } + + for (const env of config.web_environment || []) { + flags.push("--web-environment-add", env); + } + + return flags; +} diff --git a/src/lib/ddev/config_builder.ts b/src/lib/ddev/config_builder.ts new file mode 100644 index 00000000..f5849435 --- /dev/null +++ b/src/lib/ddev/config_builder.ts @@ -0,0 +1,208 @@ +import { assertStatus, MittwaldAPIV2Client } from "@mittwald/api-client"; +import { DDEVConfig, DDEVDatabaseConfig } from "./config.js"; +import { typo3Installer } from "../../commands/app/install/typo3.js"; +import { wordpressInstaller } from "../../commands/app/install/wordpress.js"; +import { shopware6Installer } from "../../commands/app/install/shopware6.js"; +import { drupalInstaller } from "../../commands/app/install/drupal.js"; + +import type { MittwaldAPIV2 } from "@mittwald/api-client"; + +type AppInstallation = MittwaldAPIV2.Components.Schemas.AppAppInstallation; +type AppVersion = MittwaldAPIV2.Components.Schemas.AppAppVersion; +type LinkedDatabase = MittwaldAPIV2.Components.Schemas.AppLinkedDatabase; +type SystemSoftware = MittwaldAPIV2.Components.Schemas.AppSystemSoftware; +type SystemSoftwareVersion = + MittwaldAPIV2.Components.Schemas.AppSystemSoftwareVersion; +type AppInstallationWithDocRoot = AppInstallation & { + customDocumentRoot: string; +}; + +type SystemSoftwareVersions = Record; + +export class DDEVConfigBuilder { + private apiClient: MittwaldAPIV2Client; + + public constructor(apiClient: MittwaldAPIV2Client) { + this.apiClient = apiClient; + } + + public async build( + appInstallationId: string, + type: string, + ): Promise> { + const appInstallation = await this.getAppInstallation(appInstallationId); + const systemSoftwares = + await this.buildSystemSoftwareVersionMap(appInstallation); + + return { + override_config: true, + type: await this.determineProjectType(appInstallation, type), + webserver_type: "apache-fpm", + php_version: this.determinePHPVersion(systemSoftwares), + database: await this.determineDatabaseVersion(appInstallation), + docroot: await this.determineDocumentRoot(appInstallation), + web_environment: [ + `MITTWALD_APP_INSTALLATION_ID=${appInstallation.shortId}`, + ], + }; + } + + private async determineDocumentRoot(inst: AppInstallation): Promise { + const appVersion = await this.getAppVersion( + inst.appId, + inst.appVersion.desired, + ); + + if (appVersion.docRootUserEditable && hasCustomDocumentRoot(inst)) { + return stripLeadingSlash(inst.customDocumentRoot); + } + + return stripLeadingSlash(appVersion.docRoot); + } + + private async determineProjectType( + inst: AppInstallation, + type: string, + ): Promise { + if (type !== "auto") { + return type; + } + + switch (inst.appId) { + case typo3Installer.appId: + return "typo3"; + case wordpressInstaller.appId: + return "wordpress"; + case shopware6Installer.appId: + return "shopware6"; + case drupalInstaller.appId: { + const version = await this.getAppVersion( + inst.appId, + inst.appVersion.desired, + ); + + const [major] = version.externalVersion.split("."); + return `drupal${major}`; + } + default: + throw new Error( + "Automatic project type detection failed. Please specify the project type manually by setting the `--override-type` flag.", + ); + } + } + + private async determineDatabaseVersion( + inst: AppInstallation, + ): Promise { + const isPrimary = (db: LinkedDatabase) => db.purpose === "primary"; + const primary = (inst.linkedDatabases || []).find(isPrimary); + + if (primary?.kind === "mysql") { + const r = await this.apiClient.database.getMysqlDatabase({ + mysqlDatabaseId: primary.databaseId, + }); + assertStatus(r, 200); + + return { + type: "mysql", + version: r.data.version, + }; + } + + return undefined; + } + + private determinePHPVersion( + systemSoftwareVersions: SystemSoftwareVersions, + ): string | undefined { + if (!("php" in systemSoftwareVersions)) { + return undefined; + } + + const version = systemSoftwareVersions["php"]; + return stripPatchLevelVersion(version); + } + + private async buildSystemSoftwareVersionMap( + inst: AppInstallation, + ): Promise { + const versionMap: SystemSoftwareVersions = {}; + + for (const { + systemSoftwareId, + systemSoftwareVersion, + } of inst.systemSoftware || []) { + const { name } = await this.getSystemSoftware(systemSoftwareId); + const { externalVersion } = await this.getSystemSoftwareVersion( + systemSoftwareId, + systemSoftwareVersion.desired, + ); + + versionMap[name] = externalVersion; + } + + return versionMap; + } + + private async getSystemSoftware( + systemSoftwareId: string, + ): Promise { + const systemSoftwareResponse = await this.apiClient.app.getSystemsoftware({ + systemSoftwareId, + }); + assertStatus(systemSoftwareResponse, 200); + return systemSoftwareResponse.data; + } + + private async getSystemSoftwareVersion( + systemSoftwareId: string, + systemSoftwareVersionId: string, + ): Promise { + const r = await this.apiClient.app.getSystemsoftwareversion({ + systemSoftwareId, + systemSoftwareVersionId, + }); + + assertStatus(r, 200); + return r.data; + } + + private async getAppVersion( + appId: string, + appVersionId: string, + ): Promise { + const r = await this.apiClient.app.getAppversion({ + appId, + appVersionId, + }); + + assertStatus(r, 200); + return r.data; + } + + private async getAppInstallation( + appInstallationId: string, + ): Promise { + const r = await this.apiClient.app.getAppinstallation({ + appInstallationId, + }); + + assertStatus(r, 200); + return r.data; + } +} + +function hasCustomDocumentRoot( + inst: AppInstallation, +): inst is AppInstallationWithDocRoot { + return inst.customDocumentRoot !== undefined; +} + +function stripLeadingSlash(input: string): string { + return input.replace(/^\//, ""); +} + +function stripPatchLevelVersion(version: string): string { + const [major, minor] = version.split("."); + return `${major}.${minor}`; +} diff --git a/src/lib/ddev/config_loader.ts b/src/lib/ddev/config_loader.ts new file mode 100644 index 00000000..73654758 --- /dev/null +++ b/src/lib/ddev/config_loader.ts @@ -0,0 +1,18 @@ +import { DDEVConfig } from "./config.js"; +import { readFile } from "fs/promises"; +import { pathExists } from "../fsutil.js"; +import { load } from "js-yaml"; +import { cwd } from "process"; +import path from "path"; + +export async function loadDDEVConfig( + baseDir: string = cwd(), +): Promise | undefined> { + const configPath = path.join(baseDir, ".ddev", "config.yaml"); + if (!(await pathExists(configPath))) { + return undefined; + } + + const existing = await readFile(".ddev/config.yaml", "utf-8"); + return load(existing) as Partial; +} diff --git a/src/lib/ddev/config_render.ts b/src/lib/ddev/config_render.ts new file mode 100644 index 00000000..0195c470 --- /dev/null +++ b/src/lib/ddev/config_render.ts @@ -0,0 +1,11 @@ +import { DDEVConfig } from "./config.js"; +import yaml from "js-yaml"; + +export function renderDDEVConfig( + appInstallationId: string, + cfg: Partial, +): string { + return `# DDEV configuration for mittwald app installation '${appInstallationId}' +# generated by 'mw ddev init-config' +${yaml.dump(cfg)}`; +} diff --git a/src/lib/ddev/flags.ts b/src/lib/ddev/flags.ts new file mode 100644 index 00000000..30113477 --- /dev/null +++ b/src/lib/ddev/flags.ts @@ -0,0 +1,12 @@ +import { Flags } from "@oclif/core"; + +export const ddevFlags = { + "override-type": Flags.string({ + summary: "Override the type of the generated DDEV configuration", + default: "auto", + description: + "The type of the generated DDEV configuration; this can be any of the documented DDEV project types, or 'auto' (which is also the default) for automatic discovery." + + "\n\n" + + "See https://ddev.readthedocs.io/en/latest/users/configuration/config/#type for more information", + }), +}; diff --git a/src/lib/fsutil.ts b/src/lib/fsutil.ts new file mode 100644 index 00000000..630d4c9a --- /dev/null +++ b/src/lib/fsutil.ts @@ -0,0 +1,17 @@ +import fs from "fs/promises"; + +export function isNotFound(e: unknown): boolean { + return e instanceof Error && "code" in e && e.code === "ENOENT"; +} + +export async function pathExists(path: string): Promise { + try { + await fs.stat(path); + return true; + } catch (e) { + if (isNotFound(e)) { + return false; + } + throw e; + } +} diff --git a/src/lib/project/flags.ts b/src/lib/project/flags.ts index 73154906..cbdf3696 100644 --- a/src/lib/project/flags.ts +++ b/src/lib/project/flags.ts @@ -111,7 +111,9 @@ export function makeProjectFlagSet( return normalize(apiClient, projectId, idInput); } - const idFromContext = await new Context(cfg).getContextValue(flagName); + const idFromContext = await new Context(apiClient, cfg).getContextValue( + flagName, + ); if (idFromContext) { return idFromContext.value; } diff --git a/src/lib/ssh/exec.ts b/src/lib/ssh/exec.ts index d4d590cb..0ab26aa1 100644 --- a/src/lib/ssh/exec.ts +++ b/src/lib/ssh/exec.ts @@ -1,8 +1,9 @@ -import cp from "child_process"; +import cp, { ChildProcessByStdio } from "child_process"; import { MittwaldAPIV2Client } from "@mittwald/api-client"; import { SSHConnectionData } from "./types.js"; import { getSSHConnectionForAppInstallation } from "./appinstall.js"; import { getSSHConnectionForProject } from "./project.js"; +import { Readable, Writable } from "stream"; export type RunTarget = { appInstallationId: string } | { projectId: string }; @@ -15,7 +16,8 @@ export async function executeViaSSH( sshUser: string | undefined, target: RunTarget, command: RunCommand, - output: NodeJS.WritableStream, + output: NodeJS.WritableStream | null, + input: NodeJS.ReadableStream | null = null, ): Promise { const { user, host } = await connectionDataForTarget(client, target, sshUser); const sshCommandArgs = @@ -24,12 +26,19 @@ export async function executeViaSSH( : [command.command, ...command.args]; const sshArgs = ["-l", user, "-T", host, ...sshCommandArgs]; const ssh = cp.spawn("ssh", sshArgs, { - stdio: ["ignore", "pipe", "pipe"], - }); + stdio: [input ? "pipe" : "ignore", output ? "pipe" : "ignore", "pipe"], + }) as ChildProcessByStdio; let err = ""; - ssh.stdout.pipe(output); + if (input && ssh.stdin) { + input.pipe(ssh.stdin); + } + + if (output && ssh.stdout) { + ssh.stdout.pipe(output); + } + ssh.stderr.on("data", (data) => { err += data.toString(); }); @@ -44,7 +53,7 @@ export async function executeViaSSH( } }; - if (output === process.stdout) { + if (output === process.stdout || output === null) { resolve(); } else { output.end(resolve); diff --git a/src/rendering/process/components/ProcessInput.tsx b/src/rendering/process/components/ProcessInput.tsx index a02a0d6f..99ef2290 100644 --- a/src/rendering/process/components/ProcessInput.tsx +++ b/src/rendering/process/components/ProcessInput.tsx @@ -25,12 +25,14 @@ export const ProcessInput: React.FC<{ {step.title}: {isRawModeSupported ? ( - + + + ) : ( )} diff --git a/src/rendering/process/process.tsx b/src/rendering/process/process.tsx index fb33474f..e4789a44 100644 --- a/src/rendering/process/process.tsx +++ b/src/rendering/process/process.tsx @@ -83,6 +83,9 @@ export class RunnableHandler { } public appendOutput(o: string) { + if (this.processStep.output === undefined) { + this.processStep.output = ""; + } this.processStep.output += o; this.listener(); } diff --git a/src/rendering/process/process_exec.ts b/src/rendering/process/process_exec.ts new file mode 100644 index 00000000..d71cfe12 --- /dev/null +++ b/src/rendering/process/process_exec.ts @@ -0,0 +1,39 @@ +import { spawn } from "child_process"; +import { ProcessRenderer } from "./process.js"; +import { ReactNode } from "react"; + +export async function spawnInProcess( + r: ProcessRenderer, + title: ReactNode, + cmd: string, + args: string[], +): Promise { + const step = r.addStep(title); + + const child = spawn(cmd, args, { + shell: false, + stdio: ["inherit", "pipe", "pipe"], + }); + + const appendOutput = (chunk: unknown) => { + if (typeof chunk === "string") { + step.appendOutput(chunk); + } + + if (Buffer.isBuffer(chunk)) { + step.appendOutput(chunk.toString()); + } + }; + + child.stdout.on("data", appendOutput); + child.stderr.on("data", appendOutput); + child.on("exit", (code) => { + if (code === 0) { + step.complete(); + } else { + step.error(new Error(`${cmd} exited with code ${code}`)); + } + }); + + await step.wait(); +} diff --git a/src/rendering/react/components/DDEV/DDEVInitSuccess.tsx b/src/rendering/react/components/DDEV/DDEVInitSuccess.tsx new file mode 100644 index 00000000..d2b431b8 --- /dev/null +++ b/src/rendering/react/components/DDEV/DDEVInitSuccess.tsx @@ -0,0 +1,67 @@ +import { defaultSuccessColor, Success } from "../Success.js"; +import { Box, Text } from "ink"; +import React, { ReactNode } from "react"; + +function DDEVCommand({ + command, + text, + dangerous, +}: { + command: string[]; + text: ReactNode; + dangerous?: boolean; +}) { + return ( + <> + + {command.join(" ")} + {dangerous ? ( + + {" "} + (DANGEROUS!) + + ) : undefined} + + + {text} + + + ); +} + +export function DDEVInitSuccess() { + return ( + + + + DDEV project successfully initialized! 🚀 + + + + + You can now use the following commands to get started with your DDEV + project: + + + + + + + + You can also use the mw ddev init command + repeatedly to continuously update your DDEV configuration. + + + + ); +} diff --git a/src/rendering/react/components/Success.tsx b/src/rendering/react/components/Success.tsx index 6a4720cb..25996277 100644 --- a/src/rendering/react/components/Success.tsx +++ b/src/rendering/react/components/Success.tsx @@ -1,15 +1,30 @@ -import React, { FC } from "react"; +import React, { FC, PropsWithChildren } from "react"; import { Box, Text } from "ink"; interface Props { title?: string; - children?: React.ReactNode; color?: string; width?: number; + innerText?: boolean; } -export const Success: FC = (props) => { - const { title = "Success", color = "#00B785", width = 80 } = props; +export const defaultSuccessColor = "#00B785"; + +export const Success: FC> = (props) => { + const { + title = "Success", + color = defaultSuccessColor, + width = 80, + innerText = true, + } = props; + + const inner = innerText ? ( + + {props.children} + + ) : ( + props.children + ); return ( = (props) => { {title.toUpperCase()} - - {props.children} - + {inner} ); }; diff --git a/yarn.lock b/yarn.lock index 7f71a482..61855e91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1502,7 +1502,7 @@ __metadata: "@oclif/plugin-warn-if-update-available": ^3.0.2 "@types/chalk": ^2.2.0 "@types/copyfiles": ^2.4.1 - "@types/js-yaml": ^4.0.5 + "@types/js-yaml": ^4.0.9 "@types/marked-terminal": ^3.1.3 "@types/node": ^20.3.3 "@types/node-notifier": ^8.0.2 @@ -2817,7 +2817,7 @@ __metadata: languageName: node linkType: hard -"@types/js-yaml@npm:^4.0.5": +"@types/js-yaml@npm:^4.0.9": version: 4.0.9 resolution: "@types/js-yaml@npm:4.0.9" checksum: e5e5e49b5789a29fdb1f7d204f82de11cb9e8f6cb24ab064c616da5d6e1b3ccfbf95aa5d1498a9fbd3b9e745564e69b4a20b6c530b5a8bbb2d4eb830cda9bc69