From 0dfc8982368904860a1f021e57b048e2b75e50c8 Mon Sep 17 00:00:00 2001 From: Martin Helmich Date: Fri, 10 Apr 2026 14:37:27 +0200 Subject: [PATCH 1/4] feat(stack): warn about container deletion during stack deploy Add warning and confirmation prompt when deploying a stack would delete existing containers that are not in the new stack definition. Users are now notified which containers will be removed and asked to confirm in interactive mode. A --force flag allows skipping the confirmation. Co-Authored-By: Claude Opus 4.5 --- README.md | 1 + docs/cronjob.md | 28 ++++++++++++++++++++++ docs/stack.md | 8 +++++-- src/commands/stack/deploy.tsx | 44 ++++++++++++++++++++++++++++++++++- 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bbc129377..f20888451 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ USAGE * [`mw backup`](docs/backup.md) - Manage backups of your projects * [`mw container`](docs/container.md) - Manage containers * [`mw context`](docs/context.md) - Save certain environment parameters for later use +* [`mw contributor`](docs/contributor.md) - Commands for mStudio marketplace contributors * [`mw conversation`](docs/conversation.md) - Manage your support cases * [`mw cronjob`](docs/cronjob.md) - Manage cronjobs of your projects * [`mw database`](docs/database.md) - Manage databases (like MySQL and Redis) in your projects diff --git a/docs/cronjob.md b/docs/cronjob.md index 29d87509b..52e8947fa 100644 --- a/docs/cronjob.md +++ b/docs/cronjob.md @@ -6,6 +6,7 @@ Manage cronjobs of your projects * [`mw cronjob create`](#mw-cronjob-create) * [`mw cronjob delete CRONJOB-ID`](#mw-cronjob-delete-cronjob-id) * [`mw cronjob execute CRONJOB-ID`](#mw-cronjob-execute-cronjob-id) +* [`mw cronjob execution abort CRONJOB-ID EXECUTION-ID`](#mw-cronjob-execution-abort-cronjob-id-execution-id) * [`mw cronjob execution get CRONJOB-ID EXECUTION-ID`](#mw-cronjob-execution-get-cronjob-id-execution-id) * [`mw cronjob execution list`](#mw-cronjob-execution-list) * [`mw cronjob execution logs CRONJOB-ID EXECUTION-ID`](#mw-cronjob-execution-logs-cronjob-id-execution-id) @@ -152,6 +153,33 @@ FLAG DESCRIPTIONS ``` +## `mw cronjob execution abort CRONJOB-ID EXECUTION-ID` + +Abort a running cron job execution. + +``` +USAGE + $ mw cronjob execution abort CRONJOB-ID EXECUTION-ID [--token ] [-q] + +ARGUMENTS + CRONJOB-ID ID of the cronjob the execution belongs to + EXECUTION-ID ID of the cron job execution to abort + +FLAGS + -q, --quiet suppress process output and only display a machine-readable summary + +AUTHENTICATION FLAGS + --token= API token to use for authentication (overrides environment and config file). NOTE: watch out that + tokens passed via this flag might be logged in your shell history. + +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. +``` + + ## `mw cronjob execution get CRONJOB-ID EXECUTION-ID` Get a cron job execution. diff --git a/docs/stack.md b/docs/stack.md index f8f4b1fe0..b6c8ea8ef 100644 --- a/docs/stack.md +++ b/docs/stack.md @@ -51,10 +51,12 @@ Deploys a docker-compose compatible file to a mittwald container stack ``` USAGE - $ mw stack deploy [--token ] [-s ] [-q] [-c | --from-template ] [--env-file ] + $ mw stack deploy [--token ] [-s ] [-q] [-c | --from-template ] [--env-file + ] [-f] FLAGS -c, --compose-file= [default: ./docker-compose.yml] path to a compose file, or "-" to read from stdin + -f, --force do not ask for confirmation when containers will be deleted -q, --quiet suppress process output and only display a machine-readable summary -s, --stack-id= ID of a stack; this flag is optional if a default stack is set in the context --env-file= [default: ./.env] alternative path to file with environment variables @@ -249,10 +251,12 @@ Deploys a docker-compose compatible file to a mittwald container stack ``` USAGE - $ mw stack up [--token ] [-s ] [-q] [-c | --from-template ] [--env-file ] + $ mw stack up [--token ] [-s ] [-q] [-c | --from-template ] [--env-file + ] [-f] FLAGS -c, --compose-file= [default: ./docker-compose.yml] path to a compose file, or "-" to read from stdin + -f, --force do not ask for confirmation when containers will be deleted -q, --quiet suppress process output and only display a machine-readable summary -s, --stack-id= ID of a stack; this flag is optional if a default stack is set in the context --env-file= [default: ./.env] alternative path to file with environment variables diff --git a/src/commands/stack/deploy.tsx b/src/commands/stack/deploy.tsx index f82267319..a4dc8c322 100644 --- a/src/commands/stack/deploy.tsx +++ b/src/commands/stack/deploy.tsx @@ -1,7 +1,7 @@ import { ExecRenderBaseCommand } from "../../lib/basecommands/ExecRenderBaseCommand.js"; import { stackFlags, withStackId } from "../../lib/resources/stack/flags.js"; import { ReactNode } from "react"; -import { Flags } from "@oclif/core"; +import { Flags, ux } from "@oclif/core"; import { makeProcessRenderer, processFlags, @@ -65,8 +65,26 @@ This flag is mutually exclusive with --compose-file.`, summary: "alternative path to file with environment variables", default: "./.env", }), + force: Flags.boolean({ + char: "f", + summary: "do not ask for confirmation when containers will be deleted", + }), }; + private findServicesToDelete( + existingStack: ContainerStackResponse, + newStackDefinition: RawStackInput, + ): string[] { + const existingServiceNames = (existingStack.services ?? []).map( + (s) => s.serviceName, + ); + const newServiceNames = Object.keys(newStackDefinition.services ?? {}); + + return existingServiceNames.filter( + (name) => !newServiceNames.includes(name), + ); + } + private async loadStackDefinition( source: { template: string } | { composeFile: string }, envFile: string, @@ -146,6 +164,30 @@ This flag is mutually exclusive with --compose-file.`, enrichStackDefinition(stackDefinition), ); + // Check for containers that will be deleted + const servicesToDelete = this.findServicesToDelete( + existingStack, + stackDefinition, + ); + + if (servicesToDelete.length > 0) { + r.addInfo( + `The following containers will be deleted: ${servicesToDelete.join(", ")}`, + ); + + if (!this.flags.force) { + const confirmed = await r.addConfirmation( + "do you want to continue and delete these containers?", + ); + if (!confirmed) { + r.addInfo("deployment cancelled by user"); + await r.complete(<>); + ux.exit(1); + return result; + } + } + } + const declaredStack = await r.runStep("deploying stack", async () => { const resp = await this.apiClient.container.declareStack({ stackId, From cfd7dfed3d20d0a27dfe21e8220ebc2b98345711 Mon Sep 17 00:00:00 2001 From: Martin Helmich Date: Fri, 10 Apr 2026 14:39:10 +0200 Subject: [PATCH 2/4] refactor(stack): extract methods from deploy exec function Break down the exec function into smaller, focused methods: - getExistingStack: retrieve current stack state - confirmDeletion: handle deletion confirmation flow - deployStack: deploy the stack definition - recreateServices: recreate services requiring recreation Co-Authored-By: Claude Opus 4.5 --- src/commands/stack/deploy.tsx | 152 +++++++++++++++++++++------------- 1 file changed, 96 insertions(+), 56 deletions(-) diff --git a/src/commands/stack/deploy.tsx b/src/commands/stack/deploy.tsx index a4dc8c322..7b7175a30 100644 --- a/src/commands/stack/deploy.tsx +++ b/src/commands/stack/deploy.tsx @@ -85,6 +85,87 @@ This flag is mutually exclusive with --compose-file.`, ); } + private async getExistingStack( + stackId: string, + renderer: ReturnType, + ): Promise { + return renderer.runStep("retrieving current stack state", async () => { + const resp = await this.apiClient.container.getStack({ stackId }); + assertStatus(resp, 200); + return resp.data; + }); + } + + private async confirmDeletion( + servicesToDelete: string[], + renderer: ReturnType, + ): Promise { + if (servicesToDelete.length === 0) { + return true; + } + + renderer.addInfo( + `the following containers will be deleted: ${servicesToDelete.join(", ")}`, + ); + + if (this.flags.force) { + return true; + } + + const confirmed = await renderer.addConfirmation( + "do you want to continue and delete these containers?", + ); + + if (!confirmed) { + renderer.addInfo("deployment cancelled by user"); + await renderer.complete(<>); + ux.exit(1); + } + + return confirmed; + } + + private async deployStack( + stackId: string, + stackDefinition: RawStackInput, + renderer: ReturnType, + ): Promise { + return renderer.runStep("deploying stack", async () => { + const resp = await this.apiClient.container.declareStack({ + stackId, + data: stackDefinition as StackRequest, + }); + assertStatus(resp, 200); + return resp.data; + }); + } + + private async recreateServices( + stackId: string, + declaredStack: ContainerStackResponse, + renderer: ReturnType, + ): Promise { + const restartedServices: string[] = []; + + for (const service of declaredStack.services ?? []) { + if (service.requiresRecreate) { + await renderer.runStep( + `recreating service ${service.serviceName}`, + async () => { + const resp = await this.apiClient.container.recreateService({ + stackId, + serviceId: service.id, + }); + assertSuccess(resp); + restartedServices.push(service.serviceName); + }, + ); + } + } + + return restartedServices; + } + private async loadStackDefinition( source: { template: string } | { composeFile: string }, envFile: string, @@ -140,20 +221,13 @@ This flag is mutually exclusive with --compose-file.`, } = this.flags; const r = makeProcessRenderer(this.flags, "Deploying container stack"); - const existingStack = await r.runStep( - "retrieving current stack state", - async () => { - const resp = await this.apiClient.container.getStack({ stackId }); - assertStatus(resp, 200); - - return resp.data; - }, - ); - - const result: DeployResult = { restartedServices: [] }; + const existingStack = await this.getExistingStack(stackId, r); + const stackSource = fromTemplate + ? { template: fromTemplate } + : { composeFile }; let stackDefinition = await this.loadStackDefinition( - fromTemplate ? { template: fromTemplate } : { composeFile }, + stackSource, envFile, existingStack, r, @@ -164,57 +238,23 @@ This flag is mutually exclusive with --compose-file.`, enrichStackDefinition(stackDefinition), ); - // Check for containers that will be deleted const servicesToDelete = this.findServicesToDelete( existingStack, stackDefinition, ); - - if (servicesToDelete.length > 0) { - r.addInfo( - `The following containers will be deleted: ${servicesToDelete.join(", ")}`, - ); - - if (!this.flags.force) { - const confirmed = await r.addConfirmation( - "do you want to continue and delete these containers?", - ); - if (!confirmed) { - r.addInfo("deployment cancelled by user"); - await r.complete(<>); - ux.exit(1); - return result; - } - } + const confirmed = await this.confirmDeletion(servicesToDelete, r); + if (!confirmed) { + return { restartedServices: [] }; } - const declaredStack = await r.runStep("deploying stack", async () => { - const resp = await this.apiClient.container.declareStack({ - stackId, - data: stackDefinition as StackRequest, - }); - - assertStatus(resp, 200); - return resp.data; - }); - - for (const service of declaredStack.services ?? []) { - if (service.requiresRecreate) { - await r.runStep( - `recreating service ${service.serviceName}`, - async () => { - const resp = await this.apiClient.container.recreateService({ - stackId, - serviceId: service.id, - }); - assertSuccess(resp); - result.restartedServices.push(service.serviceName); - }, - ); - } - } + const declaredStack = await this.deployStack(stackId, stackDefinition, r); + const restartedServices = await this.recreateServices( + stackId, + declaredStack, + r, + ); - return result; + return { restartedServices }; } protected render({ restartedServices }: DeployResult): ReactNode { From 5111c1d27fbf8c3d5ed2b3ef357c1468dcf7c267 Mon Sep 17 00:00:00 2001 From: martin-helmich <2538958+martin-helmich@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:53:53 +0000 Subject: [PATCH 3/4] chore: re-generate README --- README.md | 1 - docs/cronjob.md | 28 ---------------------------- 2 files changed, 29 deletions(-) diff --git a/README.md b/README.md index f20888451..bbc129377 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,6 @@ USAGE * [`mw backup`](docs/backup.md) - Manage backups of your projects * [`mw container`](docs/container.md) - Manage containers * [`mw context`](docs/context.md) - Save certain environment parameters for later use -* [`mw contributor`](docs/contributor.md) - Commands for mStudio marketplace contributors * [`mw conversation`](docs/conversation.md) - Manage your support cases * [`mw cronjob`](docs/cronjob.md) - Manage cronjobs of your projects * [`mw database`](docs/database.md) - Manage databases (like MySQL and Redis) in your projects diff --git a/docs/cronjob.md b/docs/cronjob.md index 52e8947fa..29d87509b 100644 --- a/docs/cronjob.md +++ b/docs/cronjob.md @@ -6,7 +6,6 @@ Manage cronjobs of your projects * [`mw cronjob create`](#mw-cronjob-create) * [`mw cronjob delete CRONJOB-ID`](#mw-cronjob-delete-cronjob-id) * [`mw cronjob execute CRONJOB-ID`](#mw-cronjob-execute-cronjob-id) -* [`mw cronjob execution abort CRONJOB-ID EXECUTION-ID`](#mw-cronjob-execution-abort-cronjob-id-execution-id) * [`mw cronjob execution get CRONJOB-ID EXECUTION-ID`](#mw-cronjob-execution-get-cronjob-id-execution-id) * [`mw cronjob execution list`](#mw-cronjob-execution-list) * [`mw cronjob execution logs CRONJOB-ID EXECUTION-ID`](#mw-cronjob-execution-logs-cronjob-id-execution-id) @@ -153,33 +152,6 @@ FLAG DESCRIPTIONS ``` -## `mw cronjob execution abort CRONJOB-ID EXECUTION-ID` - -Abort a running cron job execution. - -``` -USAGE - $ mw cronjob execution abort CRONJOB-ID EXECUTION-ID [--token ] [-q] - -ARGUMENTS - CRONJOB-ID ID of the cronjob the execution belongs to - EXECUTION-ID ID of the cron job execution to abort - -FLAGS - -q, --quiet suppress process output and only display a machine-readable summary - -AUTHENTICATION FLAGS - --token= API token to use for authentication (overrides environment and config file). NOTE: watch out that - tokens passed via this flag might be logged in your shell history. - -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. -``` - - ## `mw cronjob execution get CRONJOB-ID EXECUTION-ID` Get a cron job execution. From c0248c8bf373eeaf5460049a4c824ad7cb7e296c Mon Sep 17 00:00:00 2001 From: Martin Helmich Date: Mon, 13 Apr 2026 10:56:24 +0200 Subject: [PATCH 4/4] chore: ux fine-tuning --- src/commands/stack/deploy.tsx | 29 ++++++++++--------- .../process/components/ProcessStateIcon.tsx | 2 +- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/commands/stack/deploy.tsx b/src/commands/stack/deploy.tsx index 7b7175a30..00b87aced 100644 --- a/src/commands/stack/deploy.tsx +++ b/src/commands/stack/deploy.tsx @@ -19,13 +19,13 @@ import { import { sanitizeStackDefinition } from "../../lib/resources/stack/sanitize.js"; import { enrichStackDefinition } from "../../lib/resources/stack/enrich.js"; import { Success } from "../../rendering/react/components/Success.js"; -import { Value } from "../../rendering/react/components/Value.js"; import { loadStackFromTemplate } from "../../lib/resources/stack/template-loader.js"; import { parseEnvironmentVariablesFromStr } from "../../lib/util/parser.js"; import { RawStackInput } from "../../lib/resources/stack/types.js"; interface DeployResult { restartedServices: string[]; + deletedServices: string[]; } type StackRequest = @@ -117,8 +117,7 @@ This flag is mutually exclusive with --compose-file.`, ); if (!confirmed) { - renderer.addInfo("deployment cancelled by user"); - await renderer.complete(<>); + await renderer.error("deployment cancelled by user"); ux.exit(1); } @@ -244,7 +243,7 @@ This flag is mutually exclusive with --compose-file.`, ); const confirmed = await this.confirmDeletion(servicesToDelete, r); if (!confirmed) { - return { restartedServices: [] }; + return { restartedServices: [], deletedServices: [] }; } const declaredStack = await this.deployStack(stackId, stackDefinition, r); @@ -254,20 +253,22 @@ This flag is mutually exclusive with --compose-file.`, r, ); - return { restartedServices }; + return { restartedServices, deletedServices: servicesToDelete }; } - protected render({ restartedServices }: DeployResult): ReactNode { - if (restartedServices.length === 0) { - return ( - Deployment successful. No services were restarted. - ); - } - + protected render({ + restartedServices, + deletedServices, + }: DeployResult): ReactNode { return ( - Deployment successful. The following services were restarted:{" "} - {restartedServices.join(", ")} + Deployment successful.{" "} + {restartedServices.length > 0 + ? `The following services were restarted: ${restartedServices.join(", ")}` + : "No services were restarted."}{" "} + {deletedServices.length > 0 + ? `The following services were deleted: ${deletedServices.join(", ")}` + : "No services were deleted."} ); } diff --git a/src/rendering/process/components/ProcessStateIcon.tsx b/src/rendering/process/components/ProcessStateIcon.tsx index 988f94846..a8bd71591 100644 --- a/src/rendering/process/components/ProcessStateIcon.tsx +++ b/src/rendering/process/components/ProcessStateIcon.tsx @@ -10,7 +10,7 @@ export const ProcessStateIcon: React.FC<{ step: ProcessStep }> = ({ step }) => { step.type === "input" || step.type === "select" ) { - return ; + return ; } else if (step.phase === "completed") { return ; } else if (step.phase === "aborted") {