This commit is contained in:
RocketQuack 2026-06-16 10:35:06 +08:00 committed by GitHub
commit 8bfb636e7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
134 changed files with 14850 additions and 0 deletions

66
.github/workflows/easy-docker.yml vendored Normal file
View file

@ -0,0 +1,66 @@
name: Easy Docker Tests
on:
push:
branches:
- main
paths:
- "easy-docker.sh"
- "scripts/easy-docker/**"
- "tests/easy-docker/**"
- ".github/workflows/easy-docker.yml"
pull_request:
branches:
- main
paths:
- "easy-docker.sh"
- "scripts/easy-docker/**"
- "tests/easy-docker/**"
- ".github/workflows/easy-docker.yml"
jobs:
bats:
name: Bats (${{ matrix.name }})
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- name: Ubuntu 24.04
image: ubuntu:24.04
install_cmd: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y bash ca-certificates curl gawk git gzip tar ncurses-bin
update-alternatives --set awk /usr/bin/gawk
- name: Debian 12
image: debian:12
install_cmd: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y bash ca-certificates curl gawk git gzip tar ncurses-bin
update-alternatives --set awk /usr/bin/gawk
- name: Fedora 43
image: fedora:43
install_cmd: |
dnf install -y bash ca-certificates curl gawk git gzip tar ncurses
container:
image: ${{ matrix.image }}
env:
TERM: xterm-256color
steps:
- name: Install distro dependencies
run: ${{ matrix.install_cmd }}
- name: Checkout
uses: actions/checkout@v6
- name: Install Bats
run: |
BATS_VERSION="v1.11.1"
curl -fsSL "https://github.com/bats-core/bats-core/archive/refs/tags/${BATS_VERSION}.tar.gz" -o bats-core.tar.gz
tar -xzf bats-core.tar.gz
"./bats-core-${BATS_VERSION#v}/install.sh" /usr/local
- name: Run easy-docker Bats tests
run: bats --recursive tests/easy-docker

3
.gitignore vendored
View file

@ -32,3 +32,6 @@ node_modules
# VitePress
**/.vitepress/dist
**/.vitepress/cache
# easy-docker local runtime data (contains secrets)
.easy-docker/

View file

@ -67,6 +67,24 @@ The full `frappe_docker` documentation is available in [`docs/`](docs/) and publ
- **Development workflows:** [Development](docs/05-development/01-development.md)
- **FAQ:** [Frequently Asked Questions](https://github.com/frappe/frappe_docker/wiki/Frequently-Asked-Questions)
### Easy Docker
`easy-docker` is the guided terminal workflow for creating and managing Frappe
Docker stacks from one place. It helps with stack creation, app and branch
selection, image builds, runtime actions, and supported site operations without
requiring users to assemble the Compose and Bench commands manually.
If you want a guided setup path, start with the
[Easy Docker docs](docs/10-easy-docker/index.md).
Run it from the repository root with:
```bash
bash easy-docker.sh
```
![Easy Docker main menu](docs/images/easy-docker/entry/main-menu.png)
## Prerequisites
- [Docker](https://docs.docker.com/get-docker/)

View file

@ -0,0 +1,52 @@
---
title: Easy Docker
---
# Easy Docker
`easy-docker` is the interactive setup and management workflow for this repository.
It guides common stack operations through a terminal UI so you do not have to assemble
every Docker and Bench command manually.
For the detailed guide, use the dedicated docs area under `docs/10-easy-docker/`.
This getting-started page stays short and focuses on the first steps.
Current status:
- `single-host` is the primary supported topology
- `split-services` is available for separated stack setup and Compose runtime control
- site actions currently remain part of the `single-host` workflow
- stack, site, app, backup, restart, and update flows are being expanded iteratively
The script entrypoint is:
```bash
bash ./easy-docker.sh
```
Before the wizard opens, `easy-docker` validates its startup dependencies.
Today that means:
- `gum`
- `docker`
- `docker compose`
- Docker daemon availability
- `jq`
If `gum` or `jq` is missing, `easy-docker` first tries package-manager
installation and can then fall back to a pinned GitHub binary when the setup is
interactive and fallback is not disabled. On Windows, use a real Bash
environment such as WSL or Git Bash and keep the script path in Bash syntax.
Minimal first use:
1. Start `easy-docker.sh`
2. Create a new stack
3. Choose `single-host`
4. Pick the apps and branches you want
5. Build the custom image
6. Start the stack
7. Create the first site or manage an existing one from the stack menu
Use this page as the entry point. For the full workflow reference, jump to the
dedicated `easy-docker` docs section in the root `docs` tree.

View file

@ -4,6 +4,7 @@ title: Troubleshoot
- [Fixing MariaDB issues after rebuilding the container](#fixing-mariadb-issues-after-rebuilding-the-container)
- [docker-compose does not recognize variables from `.env` file](#docker-compose-does-not-recognize-variables-from-env-file)
- [easy-docker dependency checks](#easy-docker-dependency-checks)
- [Windows Based Installation](#windows-based-installation)
- [Redo installation](#redo-installation)
@ -69,6 +70,33 @@ Note: For MariaDB 10.3 and older use `mysql.user` instead of `mysql.global_priv`
If you are using old version of `docker-compose` the .env file needs to be located in directory from where the docker-compose command is executed. There may also be difference in official `docker-compose` and the one packaged by distro. Use `--env-file=.env` if available to explicitly specify the path to file.
### easy-docker dependency checks
`easy-docker` now validates its startup dependencies before the TUI opens.
The check order is:
1. CLI options
2. `gum`
3. `docker`
4. `jq`
If `gum` or `jq` is missing, the wizard first tries package-manager
installation. If that does not work and the session is interactive, it can then
offer a pinned GitHub binary fallback unless `--no-installation-fallback` is
set.
If `jq` is still missing after those steps, startup stops with install guidance
instead of continuing into the menus.
On Windows, pay attention to which Bash runtime you are actually using:
- `bash` from PowerShell usually means WSL
- Git Bash is a separate runtime
- use Bash path syntax such as `bash ./easy-docker.sh`
Windows-native Bash setups can use either `jq` or `jq.exe` on `PATH`.
### Windows Based Installation
- Set environment variable `COMPOSE_CONVERT_WINDOWS_PATHS` e.g. `set COMPOSE_CONVERT_WINDOWS_PATHS=1`

View file

@ -0,0 +1,237 @@
---
title: Overview
---
# Overview
`easy-docker` is the guided terminal workflow for setting up and managing a
Frappe Docker stack from one place.
Instead of collecting Docker Compose, image build, and Bench commands manually,
you move through a small set of menus that guide the main lifecycle of a stack.
The interface is powered by `gum`, which is used to render the interactive
terminal menus and prompts.
All stack data created by the wizard is written into the repository-local
`.easy-docker` directory. That includes the generated stack environment files
and the stack-specific metadata used by the workflow.
Internally, the stack JSON contract is now handled through `jq` instead of
line-based `awk` parsing. This is meant to improve robustness against harmless
JSON formatting differences without changing the generated layout of
`metadata.json` or `apps.json`.
This means `easy-docker` is not a closed system. After the setup has been
created, you can still inspect the generated files, keep working with them
manually, and continue outside the wizard if that fits your workflow better.
## What It Helps With
`easy-docker` is built to make the usual stack workflow easier to follow:
- create a new stack
- choose the stack setup path
- select apps and branches
- generate the stack environment
- build the custom image
- start, stop, restart, or delete the stack
- create and manage a site
- install or uninstall apps on the site
- create backups and run site maintenance actions
## How It Feels to Use
The workflow is organized as a guided sequence.
You start with a stack, define how it should be configured, let the wizard write
the stack files, then continue into the management area for image, runtime, and
site actions.
This makes `easy-docker` useful both for first-time setup and for returning to
an existing stack later when you need to update apps, rebuild an image, restart
services, or work on the site itself.
If you have not worked with a guided Docker setup before, it helps to think of
`easy-docker` as a step-by-step assistant. It does not ask you to memorize the
Docker commands first. Instead, it asks a small number of questions, writes the
stack configuration for you, and then gives you a menu for the most common next
actions.
![Easy Docker main menu](../images/easy-docker/entry/main-menu.png)
## What It Needs
To run `easy-docker`, the environment should have:
- a working `docker` CLI
- Docker Compose v2 through `docker compose`
- a running Docker daemon
- `gum` for the interactive terminal UI
- `jq` for stack JSON processing
The startup checks happen in this order:
1. CLI option parsing
2. `gum`
3. `docker`
4. `jq`
5. TUI startup
That means `--help` exits before dependency checks, while missing dependencies
stop the workflow before the menus open.
When `gum` is already installed, the wizard uses it directly.
When `gum` is missing, `easy-docker` first tries to install it through the
system package manager. If that is not available or does not succeed, the
wizard can fall back to a pinned GitHub release and install `gum`
automatically when possible.
This means the usual setup flow is:
- check whether `gum` is already available
- try package-manager installation first
- use the verified fallback path only if needed
- continue into the wizard once the required tooling is ready
The Docker requirements are also checked on startup so the workflow stops early
with guidance instead of failing later in the middle of stack setup.
`jq` now follows the same install strategy as `gum`: `easy-docker` first checks
whether it is already available, then tries the system package manager, and can
finally offer a pinned GitHub binary fallback in interactive sessions.
Runtime resolution accepts either `jq` or `jq.exe`, which keeps Windows-native
Bash setups compatible as long as one of them is on `PATH`.
## Main Areas
### Stack Creation
The stack creation flow collects the main decisions up front and stores them in
the stack directory so the workflow can be resumed later.
This is where you define the stack identity, choose the setup path, and prepare
the generated configuration for the next steps.
A typical stack creation run moves through these prompts:
1. Name the stack and choose the Frappe version profile.
The stack name is simply the label under which `easy-docker` will remember this
setup. If you plan to run more than one setup later, choose a name that makes
the purpose obvious.
The Frappe version profile is the base version the stack should start from. If
you are unsure, pick the version you intend to use for the actual project or
the version your apps are built for.
![Stack name](../images/easy-docker/stack-creation/core/name.png)
![Frappe version profile](../images/easy-docker/stack-creation/core/frappe-version.png)
2. Choose the deployment topology and the main infrastructure options.
In this phase, the wizard asks how the stack should be structured. For most
users, this is the point where you choose the simplest practical setup and let
the wizard generate the rest of the configuration around it.
The proxy and database choices decide how traffic reaches the stack and where
the site data is stored. Even if you do not know every Docker detail yet, the
important part is that these choices describe how your stack should behave once
it is running.
![Topology selection](../images/easy-docker/stack-creation/topology/topology-menu.png)
![Proxy mode selection](../images/easy-docker/single-host/proxy-mode.png)
![Database selection](../images/easy-docker/single-host/database-engine.png)
3. Define the image naming and versioning that should be used for the stack.
This step controls the image that will later be built for your stack. You can
think of it as naming the packaged application that Docker should run.
The image name identifies the image, while the image version or tag helps you
track which build you are currently using. That becomes especially useful when
you rebuild the stack after changing app branches or updating the setup.
![Custom image naming](../images/easy-docker/stack-creation/image/image-name.png)
![Custom image version](../images/easy-docker/stack-creation/image/image-version.png)
4. Select the apps and branches that should be built into the stack image.
This is the point where you decide what should actually be included in the
stack. The app selection defines the application set, and the branch selection
defines which code line of each app should be used for the build.
For new users, the practical rule is simple: only include the apps you really
need, and choose branches that match the Frappe version profile you selected
earlier.
![App selection](../images/easy-docker/stack-creation/apps/app-selection.png)
![App version selection](../images/easy-docker/stack-creation/apps/app-version.png)
After these decisions, `easy-docker` has enough information to write the stack
files and prepare the next phase. At that point, the workflow moves from
planning the stack to actually building and running it.
### Stack Management
Once a stack exists, `easy-docker` becomes the control point for the stack:
- app selection and branch updates
- custom image build and rebuild
- Compose lifecycle actions
- site operations such as create, migrate, backup, and delete
That means the same workflow continues after setup instead of ending once the
first stack files are written.
The first management steps usually focus on preparing the image and bringing the
stack up in Docker Compose.
The build step creates the actual Docker image for the stack you just defined.
Until that image exists, there is nothing concrete for Docker Compose to start.
That is why the build action comes before the start action.
![Build image action](../images/easy-docker/stack-runtime/build-image.png)
Once the image has been built successfully, you can start the stack. This tells
Docker Compose to create the containers and launch the services that belong to
your setup.
![Start stack action](../images/easy-docker/stack-runtime/start-stack.png)
After startup, the status view helps you confirm that the stack is actually
running. This is especially useful for beginners because it gives a visible
checkpoint before moving on to site creation or later maintenance steps.
![Running stack status](../images/easy-docker/stack-runtime/running-stack.png)
In this example, the one stopped container shown in the status output is the
`configurator` container. That container is expected to finish and stop after
its setup work has completed.
From there, the workflow usually continues into site-level actions such as
creating the first site, installing apps on the site, running migrations, or
creating backups. In other words: stack creation defines the environment, and
stack management is where that environment becomes usable.
### Development Stacks
When you create a stack through the development path, newly created sites in
that stack automatically enable `developer_mode`.
This keeps the development-specific behavior attached to the stack itself, so
the workflow stays consistent when you return to manage it later.
## Entry Point
Run the wizard from the repository root:
```bash
bash ./easy-docker.sh
```

View file

@ -0,0 +1,37 @@
---
title: Workflows
---
# Workflows
The wizard follows a simple order:
1. Create a stack.
2. Choose `single-host` or `split-services`.
3. Select the apps and branches for the stack.
4. Generate the stack environment and render the Compose snapshot.
5. Build the custom image.
6. Start the stack.
7. Continue into site actions when the selected workflow supports them.
Stack actions are grouped around image and Compose lifecycle:
- `Apps` manages the stack app selection
- `Updates` handles app-branch changes and custom image tag updates
- `Site` handles site creation, backup, install, uninstall, and deletion
- `Start`, `Restart`, `Stop`, and `Delete` control the Compose lifecycle
Site app management is intentionally scoped to apps that are already part of the
stack image. The wizard does not try to install arbitrary apps that are not part
of the selected stack configuration.
Internally, the stack app contract is now handled through `jq` instead of
line-based `awk` parsing. This is intended to keep app selection and branch
update behavior the same while making the JSON processing more robust in the
background. The generated `metadata.json` and `apps.json` files are still meant
to look the same to users.
For the split-services path, see
[Split Services](./05-split-services.md). That page explains the intended flow
in simple terms and shows where the proxy, application, database, and Redis
choices fit into the setup.

View file

@ -0,0 +1,32 @@
---
title: Updates
---
# Updates
App updates are handled as an image update workflow, not as a live in-container
`git pull`.
The recommended sequence is:
1. Update the selected app branches.
2. Set a new `CUSTOM_TAG`.
3. Build the updated custom image.
4. Restart the stack.
5. Run `migrate` on the site if required by the app change.
The wizard keeps the current `frappe_branch` visible while you update apps so
you can see the base version the stack is built against.
`CUSTOM_TAG` is stored in the stack `.env` file. The Compose stack reads that
value on the next start or restart, so the tag change becomes effective once the
image has been rebuilt and the stack is restarted.
The update flow still rebuilds the image from a regenerated `apps.json`, and
`metadata.json` remains the source of truth for the stack state. The difference
is internal: stack metadata and generated app state are now processed through
`jq` instead of line-based `awk` parsing. No user-visible change in the file
layout is intended.
For now, this update flow focuses on app branch changes. A separate Frappe base
version update flow can be added later without changing the overall model.

View file

@ -0,0 +1,23 @@
---
title: Generated Compose
---
# Generated Compose
`easy-docker` can render a `compose.generated.yaml` snapshot from the stack
metadata and environment.
This file is useful when you want to inspect or reuse the resolved Compose
configuration outside the wizard, but it is not the primary runtime input for
stack start or stop.
What is important:
- the stack runtime reads the original Compose files from metadata
- the runtime also reads the stack `.env`
- `compose.generated.yaml` is a rendered snapshot, not the source of truth
- it is refreshed after a successful custom image build
That means the generated file stays aligned with the current stack state when the
image has actually been rebuilt, which is the point where manual reuse is most
likely to matter.

View file

@ -0,0 +1,215 @@
---
title: Split Services
---
# Split Services
`split-services` is the guided setup path for users who want to keep the
application part of the stack separate from the data part, with an optional
proxy layer in front.
The goal of this page is to help a first-time user understand what each step
means and what choice they are making in the wizard.
The current split-services flow focuses on:
- writing the split stack files
- rendering the generated Compose snapshot
- building the custom image
- starting, stopping, restarting, and deleting the stack
This keeps the first version easier to understand while still making the stack
layout explicit.
Think of the split-services setup as three simple parts:
- `Application Services` run the Frappe application itself
- `Data Services` provide the database and Redis services
- `Reverse Proxy` handles incoming web traffic when you want a proxy in front
This page uses the clearer names above so the setup is easier to understand the
first time you see it.
## What This Setup Is For
Split services are useful when you do not want every service in one combined
stack.
This is a good fit if you want to:
- keep the application layer separate from the data layer
- run the database and Redis independently from the app stack
- place a proxy in front only when you actually need it
- keep the setup readable so you can manage parts of it later
If you are new to Docker, you can think of this as splitting one big stack into
smaller parts that each have a clearer job.
## How The Wizard Feels
The wizard still behaves like the other `easy-docker` flows.
It asks one question at a time, explains what that choice affects, and then
writes the stack files for you. You do not need to build the final Compose
files by hand before you start.
![Split Services topology menu](../images/easy-docker/split-services/topology-menu.png)
## Step 1. Choose The Split Topology
When you select `Split services`, the wizard first explains that the stack is
being divided into separate parts instead of using one combined setup.
At this point you are not changing any service settings yet. You are only
choosing the layout of the stack.
This matters because later steps will follow the same idea. If the setup is
split, the wizard will ask about the application side, the data side, and the
proxy side separately.
## Step 2. Decide How The Data Services Should Work
The next decision is the data layer.
This is where you decide whether `easy-docker` manages the database and Redis
for you, or whether the stack should connect to services that already exist
elsewhere.
### Managed Data Services
Choose this if you want `easy-docker` to create the database and Redis
containers as part of the generated split setup.
This is usually the easier choice when you are trying split-services for the
first time.
It means:
- the wizard writes the data service configuration for you
- the stack can start with its own managed database and Redis services
- you still keep the data layer separate from the application layer
### External Data Services
Choose this if the database and Redis are already running somewhere else.
This is more advanced, but it can be useful when:
- your organization already provides shared database and Redis services
- you want the application stack to connect to existing infrastructure
- you are splitting responsibilities across different systems
If you choose this path, the wizard asks for the required connection values and
stores them in the generated stack configuration.
![Data services choice](../images/easy-docker/split-services/data-services-choice.png)
## Step 3. Choose The Database Engine
After choosing how the data layer should be handled, the wizard asks which
database engine should be used for the stack.
For most users, this is the simpler way to think about it:
- `MariaDB` is the default path and usually the easiest starting point
- `PostgreSQL` is available if that is the database you already use or want to use
If the data layer is managed by `easy-docker`, this choice decides which
database service will be created in the generated setup.
If the data layer is external, this choice still matters because it tells the
stack which kind of database it is configured to connect to.
![Database engine choice](../images/easy-docker/split-services/database-engine.png)
## Step 4. Decide How Redis Should Work
Redis is asked about separately so the setup stays explicit.
This is useful because some users want Redis managed together with the split
stack, while others already have Redis running elsewhere.
The choices are simple:
- `Managed Redis Services` means the stack includes Redis services for you
- `External Redis Services` means you provide the Redis endpoints yourself
- `No Redis Services` is the advanced option when you do not want the wizard to
configure Redis for this stack
The important beginner-friendly idea is that Redis belongs to the data side of
the setup, even though it is asked in its own step.
![Redis services choice](../images/easy-docker/split-services/redis-services.png)
## Step 5. Decide Whether A Proxy Should Be Included
The proxy layer is optional.
If you want the stack to answer HTTP or HTTPS traffic directly in front of the
application services, include a proxy.
If you do not need that yet, you can skip it and keep the setup simpler.
For a first-time user, the important idea is:
- the proxy is the front door
- the application services do the actual work
- the data services keep the site data and queue state safe
![Proxy choice](../images/easy-docker/split-services/proxy-choice.png)
## Step 6. Review The Setup Before It Is Written
Before the wizard writes the files, it shows a summary of the choices you just
made.
This summary is the last chance to stop and check whether the stack is shaped
the way you expected.
The summary makes the split very obvious:
- what runs as application services
- what runs as data services
- whether a proxy is included
- whether the data services are managed by `easy-docker` or external
![Split services summary](../images/easy-docker/split-services/summary.png)
## What Happens After Setup
Once the split stack has been written, the generated files are stored in the
same repository-local `.easy-docker` area as the other wizard data.
That means you can still inspect the generated files later and continue working
with them manually if needed.
After the files are written, the wizard returns to the stack management view for
that split-services stack. From there you can work with the stack runtime, app
selection, and update actions that are currently supported for this topology.
![Split Services manage stack actions](../images/easy-docker/split-services/manage-stack-actions.png)
For split-services, the most important practical point is that the application
side, the data side, and the proxy side remain easy to understand
individually. If you come back later to change one part, you should not have to
guess where the other parts are defined.
## A Simple Mental Model
If this is the first time you use a split setup, this is the easiest way to
think about it:
- `Application Services` are the part you interact with most often
- `Data Services` keep the database and Redis available
- `Reverse Proxy` is optional and sits in front of the application side
You do not need to understand every Docker detail before using the wizard.
You only need to know which part you want to manage separately.
## Where To Go Next
After reading this page, the next useful pages are:
- [Overview](./01-overview.md)
- [Workflows](./02-workflows.md)
- [Generated Compose](./04-generated-compose.md)

View file

@ -0,0 +1,30 @@
---
title: Easy Docker
---
# Easy Docker
`easy-docker` is the interactive setup and management workflow for this repository.
It is designed to make common Frappe Docker tasks easier from the terminal while
keeping the underlying Compose and Bench model visible.
This section documents the current behavior of the wizard:
- `single-host` is the supported production workflow today
- `split-services` is available for separated stack setup and Compose runtime control
- site actions currently remain part of the `single-host` workflow
- stack, site, app, and update actions are handled through the wizard
- the generated Compose output is available as a rendered snapshot
Before the wizard opens, `easy-docker` validates its startup dependencies. That
includes `gum`, `docker`, `docker compose`, a running Docker daemon, and `jq`.
`gum` and `jq` can both use package-manager installation and a pinned GitHub
binary fallback. `docker` still must already be present.
Start here:
- [Overview](./01-overview.md)
- [Workflows](./02-workflows.md)
- [Updates](./03-updates.md)
- [Generated Compose](./04-generated-compose.md)
- [Split Services](./05-split-services.md)

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

6
easy-docker.sh Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${ROOT_DIR}/scripts/easy-docker/main.sh" "$@"

View file

@ -0,0 +1,70 @@
# Easy-Frappe-Docker
Easy installation script for Frappe Docker for development and production
## Run
```bash
bash ./easy-docker.sh
```
Run the entrypoint from a real Bash environment.
- On Linux, use your normal shell session.
- On Windows, use WSL or Git Bash.
- If you start `bash` from PowerShell, that usually means WSL, so keep the path
in Bash form such as `bash ./easy-docker.sh`, not `bash .\easy-docker.sh`.
## Dependencies
- `gum` is used for the TUI and is installed automatically when possible
- `docker` CLI is required and checked on startup
- `docker compose` (Compose v2 command) is required and checked on startup
- `jq` is required for stack JSON handling and is checked on startup
- Docker Desktop includes Compose v2 by default; on Linux Engine-only setups you may need the `docker-compose-plugin` package
- Docker daemon must be running before the TUI starts
- Required docker commands are validated (`docker ps/exec/inspect/cp` and `docker compose config/up/down/logs/exec/pull/ps`)
- Startup validation order is: CLI options, `gum`, `docker`, then `jq`
- If package manager installation for `gum` or `jq` fails, the script can use a pinned GitHub binary fallback
- The `gum` fallback is pinned to `gum` `v0.17.0` and verifies SHA256 checksums from `scripts/easy-docker/config/gum-checksums.tsv`
- The `jq` fallback is pinned to `jq` `1.8.1` and verifies SHA256 checksums from `scripts/easy-docker/config/jq-checksums.tsv`
- `docker` still has no installation fallback path and must already be present
- Runtime `jq` resolution accepts either `jq` or `jq.exe`, so Windows-native setups with only `jq.exe` on `PATH` are supported
## JSON Handling
- `metadata.json` remains the source of truth for stack state
- `apps.json` is still generated from stack metadata and still used for the image build
- `easy-docker` now reads and writes stack JSON through `jq` instead of line-based `awk` parsing
- This is an internal robustness change only; the generated layout of `metadata.json` and `apps.json` is intended to stay the same for users
## Options
- `-h`, `--help`
- Shows usage and exits without starting the TUI
- `--no-installation-fallback`
- Disables GitHub binary fallback prompts for `gum` and `jq`
- If package manager installation fails, the script exits with manual installation guidance
## Apps Catalog
- App options in the wizard are read from:
- `scripts/easy-docker/config/apps.tsv`
- Format per line:
- `id<TAB>label<TAB>repo<TAB>default_branch<TAB>branches_csv`
- Example:
- `erpnext<TAB>ERPNext<TAB>https://github.com/frappe/erpnext<TAB>version-15<TAB>version-15,version-16,develop`
- The install selection in the wizard is limited to apps from this catalog.
- For each selected app, the wizard shows the configured branch list from this catalog and prompts branch selection.
## Frappe Version Profiles
- During new stack creation (after stack name), the wizard asks for a Frappe branch profile from:
- `scripts/easy-docker/config/frappe.tsv`
- Format per line:
- `id<TAB>label<TAB>frappe_branch`
- Example:
- `v16<TAB>Frappe v16 (version-16)<TAB>version-16`
- The selected `frappe_branch` is saved in stack `metadata.json` and used as default branch suggestion for app branch selection.
- In `metadata.json`, this value is stored top-level as:
- `"frappe_branch": "version-16"` (example)

View file

@ -0,0 +1,7 @@
# id label repo default_branch branches_csv
erpnext ERPNext https://github.com/frappe/erpnext develop develop,version-15,version-16
crm CRM https://github.com/frappe/crm develop develop,main
hrms HRMS https://github.com/frappe/hrms develop develop,version-16,version-15
lms LMS https://github.com/frappe/lms develop develop,main
helpdesk Helpdesk https://github.com/frappe/helpdesk develop develop,main
drive Drive https://github.com/frappe/drive develop develop
1 # id label repo default_branch branches_csv
2 erpnext ERPNext https://github.com/frappe/erpnext develop develop,version-15,version-16
3 crm CRM https://github.com/frappe/crm develop develop,main
4 hrms HRMS https://github.com/frappe/hrms develop develop,version-16,version-15
5 lms LMS https://github.com/frappe/lms develop develop,main
6 helpdesk Helpdesk https://github.com/frappe/helpdesk develop develop,main
7 drive Drive https://github.com/frappe/drive develop develop

View file

@ -0,0 +1,4 @@
# id label frappe_branch
v16 Frappe v16 (version-16) version-16
v15 Frappe v15 (version-15) version-15
develop Frappe develop (develop) develop
1 # id label frappe_branch
2 v16 Frappe v16 (version-16) version-16
3 v15 Frappe v15 (version-15) version-15
4 develop Frappe develop (develop) develop

View file

@ -0,0 +1,7 @@
# version asset_name sha256
0.17.0 gum_0.17.0_Darwin_arm64.tar.gz e2a4b8596efa05821d8c58d0c1afbcd7ad1699ba69c689cc3ff23a4a99c8b237
0.17.0 gum_0.17.0_Darwin_x86_64.tar.gz cd66576aeebe6cd19c771863c7e8d696e0e1d5387d1e7075666baa67c2052e53
0.17.0 gum_0.17.0_Linux_arm64.tar.gz b0b9ed95cbf7c8b7073f17b9591811f5c001e33c7cfd066ca83ce8a07c576f9c
0.17.0 gum_0.17.0_Linux_armv7.tar.gz 25711c2fbc6887cde79ed586972834121a04955968808dd688c688381ac50ab2
0.17.0 gum_0.17.0_Linux_x86_64.tar.gz 69ee169bd6387331928864e94d47ed01ef649fbfe875baed1bbf27b5377a6fdb
0.17.0 gum_0.17.0_Windows_x86_64.zip b2be80531c6babc8d4e0e6ca95773d58118a2e1582ae006aace08dbc55503072
1 # version asset_name sha256
2 0.17.0 gum_0.17.0_Darwin_arm64.tar.gz e2a4b8596efa05821d8c58d0c1afbcd7ad1699ba69c689cc3ff23a4a99c8b237
3 0.17.0 gum_0.17.0_Darwin_x86_64.tar.gz cd66576aeebe6cd19c771863c7e8d696e0e1d5387d1e7075666baa67c2052e53
4 0.17.0 gum_0.17.0_Linux_arm64.tar.gz b0b9ed95cbf7c8b7073f17b9591811f5c001e33c7cfd066ca83ce8a07c576f9c
5 0.17.0 gum_0.17.0_Linux_armv7.tar.gz 25711c2fbc6887cde79ed586972834121a04955968808dd688c688381ac50ab2
6 0.17.0 gum_0.17.0_Linux_x86_64.tar.gz 69ee169bd6387331928864e94d47ed01ef649fbfe875baed1bbf27b5377a6fdb
7 0.17.0 gum_0.17.0_Windows_x86_64.zip b2be80531c6babc8d4e0e6ca95773d58118a2e1582ae006aace08dbc55503072

View file

@ -0,0 +1,10 @@
# version asset_name sha256
1.8.1 jq-linux-amd64 020468de7539ce70ef1bceaf7cde2e8c4f2ca6c3afb84642aabc5c97d9fc2a0d
1.8.1 jq-linux64 020468de7539ce70ef1bceaf7cde2e8c4f2ca6c3afb84642aabc5c97d9fc2a0d
1.8.1 jq-linux-arm64 6bc62f25981328edd3cfcfe6fe51b073f2d7e7710d7ef7fcdac28d4e384fc3d4
1.8.1 jq-linux-armhf ac304e50cf7cd24933d83dc7d0e4f79892a71a92fb02336d4ecaffa8933760bd
1.8.1 jq-macos-amd64 e80dbe0d2a2597e3c11c404f03337b981d74b4a8504b70586c354b7697a7c27f
1.8.1 jq-osx-amd64 e80dbe0d2a2597e3c11c404f03337b981d74b4a8504b70586c354b7697a7c27f
1.8.1 jq-macos-arm64 a9fe3ea2f86dfc72f6728417521ec9067b343277152b114f4e98d8cb0e263603
1.8.1 jq-win64.exe 23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334
1.8.1 jq-windows-amd64.exe 23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334
1 # version asset_name sha256
2 1.8.1 jq-linux-amd64 020468de7539ce70ef1bceaf7cde2e8c4f2ca6c3afb84642aabc5c97d9fc2a0d
3 1.8.1 jq-linux64 020468de7539ce70ef1bceaf7cde2e8c4f2ca6c3afb84642aabc5c97d9fc2a0d
4 1.8.1 jq-linux-arm64 6bc62f25981328edd3cfcfe6fe51b073f2d7e7710d7ef7fcdac28d4e384fc3d4
5 1.8.1 jq-linux-armhf ac304e50cf7cd24933d83dc7d0e4f79892a71a92fb02336d4ecaffa8933760bd
6 1.8.1 jq-macos-amd64 e80dbe0d2a2597e3c11c404f03337b981d74b4a8504b70586c354b7697a7c27f
7 1.8.1 jq-osx-amd64 e80dbe0d2a2597e3c11c404f03337b981d74b4a8504b70586c354b7697a7c27f
8 1.8.1 jq-macos-arm64 a9fe3ea2f86dfc72f6728417521ec9067b343277152b114f4e98d8cb0e263603
9 1.8.1 jq-win64.exe 23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334
10 1.8.1 jq-windows-amd64.exe 23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334

View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
print_usage() {
cat <<'USAGE'
Usage: bash easy-docker.sh [options]
Options:
--no-installation-fallback Disable installation fallback prompt
-h, --help Show this help
USAGE
}
parse_cli_options() {
local result_var="${1}"
local disable_installation_fallback_value=0
shift
while [ "$#" -gt 0 ]; do
case "$1" in
--no-installation-fallback)
disable_installation_fallback_value=1
;;
-h | --help)
print_usage
return 2
;;
*)
echo "Unknown option: $1"
print_usage
return 1
;;
esac
shift
done
printf -v "${result_var}" "%s" "${disable_installation_fallback_value}"
return 0
}

View file

@ -0,0 +1,17 @@
#!/usr/bin/env bash
load_easy_docker_app_modules() {
local app_dir=""
app_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common.sh
source "${app_dir}/wizard/common.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/env.sh
source "${app_dir}/wizard/env.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/single_host.sh
source "${app_dir}/wizard/single_host.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/flows.sh
source "${app_dir}/wizard/flows.sh"
}
load_easy_docker_app_modules

View file

@ -0,0 +1,45 @@
#!/usr/bin/env bash
ALT_SCREEN_ACTIVE=0
CURSOR_HIDDEN=0
stdout_is_terminal() {
[ -t 1 ]
}
run_tput_quietly() {
tput "$@" 2>/dev/null
}
enter_alt_screen() {
if ! stdout_is_terminal || ! command_exists tput; then
return 0
fi
if run_tput_quietly smcup; then
ALT_SCREEN_ACTIVE=1
fi
if run_tput_quietly civis; then
CURSOR_HIDDEN=1
fi
return 0
}
leave_alt_screen() {
if command_exists tput; then
if [ "${CURSOR_HIDDEN}" = "1" ]; then
run_tput_quietly cnorm || true
fi
if [ "${ALT_SCREEN_ACTIVE}" = "1" ]; then
run_tput_quietly rmcup || true
fi
fi
CURSOR_HIDDEN=0
ALT_SCREEN_ACTIVE=0
return 0
}

View file

@ -0,0 +1,27 @@
#!/usr/bin/env bash
load_easy_docker_wizard_common_modules() {
local wizard_dir=""
wizard_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/constants.sh
source "${wizard_dir}/common/constants.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/core.sh
source "${wizard_dir}/common/core.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/helpers.sh
source "${wizard_dir}/common/helpers.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose.sh
source "${wizard_dir}/common/compose.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/frappe.sh
source "${wizard_dir}/common/frappe.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps.sh
source "${wizard_dir}/common/apps.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site.sh
source "${wizard_dir}/common/site.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/ux.sh
source "${wizard_dir}/common/ux.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/split_services.sh
source "${wizard_dir}/split_services.sh"
}
load_easy_docker_wizard_common_modules

View file

@ -0,0 +1,15 @@
#!/usr/bin/env bash
load_easy_docker_wizard_app_modules() {
local apps_dir=""
apps_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/apps"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh
source "${apps_dir}/catalog.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh
source "${apps_dir}/metadata.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh
source "${apps_dir}/persistence.sh"
}
load_easy_docker_wizard_app_modules

View file

@ -0,0 +1,490 @@
#!/usr/bin/env bash
trim_predefined_catalog_field() {
local result_var="${1}"
local value="${2}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf -v "${result_var}" "%s" "${value}"
}
is_valid_predefined_app_id() {
local value="${1}"
if [ -z "${value}" ]; then
return 1
fi
case "${value}" in
*[!a-z0-9._-]*)
return 1
;;
*)
return 0
;;
esac
}
generate_predefined_app_id_from_label() {
local result_var="${1}"
local app_label="${2}"
local generated_id=""
generated_id="$(
printf '%s' "${app_label}" |
tr '[:upper:]' '[:lower:]' |
sed -E 's/[[:space:]]+/_/g; s/[^a-z0-9._-]+/_/g; s/_+/_/g; s/^_+//; s/_+$//'
)"
if ! is_valid_predefined_app_id "${generated_id}"; then
return 1
fi
printf -v "${result_var}" "%s" "${generated_id}"
return 0
}
is_valid_predefined_app_repo() {
local value="${1}"
if [ -z "${value}" ]; then
return 1
fi
case "${value}" in
https://* | http://* | ssh://* | git://* | git@*:* | file://*)
return 0
;;
*)
return 1
;;
esac
}
is_valid_predefined_app_branch() {
local value="${1}"
if [ -z "${value}" ]; then
return 1
fi
case "${value}" in
*[!A-Za-z0-9._/-]* | .* | *..* | */ | /*)
return 1
;;
*)
return 0
;;
esac
}
csv_contains_branch() {
local csv_values="${1}"
local value="${2}"
case ",${csv_values}," in
*,"${value}",*)
return 0
;;
*)
return 1
;;
esac
}
normalize_predefined_branches_csv() {
local result_csv_var="${1}"
local branches_csv_raw="${2}"
local branch_token=""
local normalized_csv=""
local -a raw_tokens=()
IFS=',' read -r -a raw_tokens <<<"${branches_csv_raw}"
for branch_token in "${raw_tokens[@]}"; do
trim_predefined_catalog_field branch_token "${branch_token}"
if [ -z "${branch_token}" ]; then
continue
fi
if ! is_valid_predefined_app_branch "${branch_token}"; then
return 1
fi
if csv_contains_branch "${normalized_csv}" "${branch_token}"; then
continue
fi
if [ -z "${normalized_csv}" ]; then
normalized_csv="${branch_token}"
else
normalized_csv="${normalized_csv},${branch_token}"
fi
done
if [ -z "${normalized_csv}" ]; then
return 1
fi
printf -v "${result_csv_var}" "%s" "${normalized_csv}"
return 0
}
get_predefined_apps_catalog_path() {
local repo_root=""
repo_root="$(get_easy_docker_repo_root)"
printf '%s/scripts/easy-docker/config/apps.tsv\n' "${repo_root}"
}
get_predefined_apps_catalog_entries() {
local catalog_path=""
local raw_line=""
local line=""
local app_id=""
local app_label=""
local app_repo=""
local app_default_branch=""
local app_branches_csv=""
local normalized_branches_csv=""
local first_branch=""
local extra=""
local seen_ids=","
local seen_labels=","
catalog_path="$(get_predefined_apps_catalog_path)"
if [ ! -f "${catalog_path}" ]; then
return 1
fi
while IFS= read -r raw_line || [ -n "${raw_line}" ]; do
trim_predefined_catalog_field line "${raw_line}"
if [ -z "${line}" ]; then
continue
fi
case "${line}" in
\#*)
continue
;;
esac
if [[ "${line}" == *$'\t'* ]]; then
IFS=$'\t' read -r app_id app_label app_repo app_default_branch app_branches_csv extra <<<"${line}"
else
# Backward compatibility for older catalog rows.
IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv extra <<<"${line}"
fi
trim_predefined_catalog_field app_id "${app_id}"
trim_predefined_catalog_field app_label "${app_label}"
trim_predefined_catalog_field app_repo "${app_repo}"
trim_predefined_catalog_field app_default_branch "${app_default_branch}"
trim_predefined_catalog_field app_branches_csv "${app_branches_csv}"
trim_predefined_catalog_field extra "${extra}"
if [ -n "${extra}" ] || [ -z "${app_id}" ] || [ -z "${app_label}" ] || [ -z "${app_repo}" ] || [ -z "${app_branches_csv}" ]; then
return 1
fi
if ! is_valid_predefined_app_id "${app_id}"; then
return 1
fi
if ! is_valid_predefined_app_repo "${app_repo}"; then
return 1
fi
if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then
return 1
fi
if [ -z "${app_default_branch}" ]; then
first_branch="${normalized_branches_csv%%,*}"
app_default_branch="${first_branch}"
fi
if ! is_valid_predefined_app_branch "${app_default_branch}"; then
return 1
fi
if ! csv_contains_branch "${normalized_branches_csv}" "${app_default_branch}"; then
return 1
fi
case "${seen_ids}" in
*,"${app_id}",*)
return 1
;;
esac
case "${seen_labels}" in
*,"${app_label}",*)
return 1
;;
esac
seen_ids="${seen_ids}${app_id},"
seen_labels="${seen_labels}${app_label},"
printf '%s|%s|%s|%s|%s\n' "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}"
done <"${catalog_path}"
}
parse_predefined_app_catalog_entry() {
local entry="${1}"
local app_id_var="${2}"
local app_label_var="${3}"
local app_repo_var="${4}"
local app_default_branch_var="${5}"
local app_branches_csv_var="${6}"
local parsed_app_id=""
local parsed_app_label=""
local parsed_app_repo=""
local parsed_app_default_branch=""
local parsed_app_branches_csv=""
IFS='|' read -r parsed_app_id parsed_app_label parsed_app_repo parsed_app_default_branch parsed_app_branches_csv <<<"${entry}"
printf -v "${app_id_var}" "%s" "${parsed_app_id}"
printf -v "${app_label_var}" "%s" "${parsed_app_label}"
printf -v "${app_repo_var}" "%s" "${parsed_app_repo}"
printf -v "${app_default_branch_var}" "%s" "${parsed_app_default_branch}"
printf -v "${app_branches_csv_var}" "%s" "${parsed_app_branches_csv}"
}
get_predefined_app_field_by_field() {
local lookup_field="${1}"
local lookup_value="${2}"
local result_field="${3}"
local entry=""
local app_id=""
local app_label=""
local app_repo=""
local app_default_branch=""
local app_branches_csv=""
local lookup_candidate=""
local result_value=""
trim_predefined_catalog_field lookup_value "${lookup_value}"
if [ -z "${lookup_value}" ]; then
return 1
fi
while IFS= read -r entry; do
if [ -z "${entry}" ]; then
continue
fi
parse_predefined_app_catalog_entry "${entry}" app_id app_label app_repo app_default_branch app_branches_csv
case "${lookup_field}" in
id)
lookup_candidate="${app_id}"
;;
label)
lookup_candidate="${app_label}"
;;
*)
return 1
;;
esac
trim_predefined_catalog_field lookup_candidate "${lookup_candidate}"
if [ "${lookup_candidate}" != "${lookup_value}" ]; then
continue
fi
case "${result_field}" in
id)
result_value="${app_id}"
;;
label)
result_value="${app_label}"
;;
repo)
result_value="${app_repo}"
;;
default_branch)
result_value="${app_default_branch}"
;;
branches_csv)
result_value="${app_branches_csv}"
;;
*)
return 1
;;
esac
printf '%s\n' "${result_value}"
return 0
done < <(get_predefined_apps_catalog_entries)
return 1
}
get_predefined_app_id_by_label() {
local label="${1}"
get_predefined_app_field_by_field "label" "${label}" "id"
}
get_predefined_app_repo_by_id() {
local app_id_lookup="${1}"
get_predefined_app_field_by_field "id" "${app_id_lookup}" "repo"
}
get_predefined_app_label_by_id() {
local app_id_lookup="${1}"
get_predefined_app_field_by_field "id" "${app_id_lookup}" "label"
}
get_predefined_app_default_branch_by_id() {
local app_id_lookup="${1}"
get_predefined_app_field_by_field "id" "${app_id_lookup}" "default_branch"
}
get_predefined_app_branch_lines_by_id() {
local result_var="${1}"
local app_id_lookup="${2}"
local app_branches_csv=""
local branch=""
local branch_lines=""
local -a branches=()
app_branches_csv="$(get_predefined_app_field_by_field "id" "${app_id_lookup}" "branches_csv" || true)"
if [ -z "${app_branches_csv}" ]; then
return 1
fi
IFS=',' read -r -a branches <<<"${app_branches_csv}"
for branch in "${branches[@]}"; do
trim_predefined_catalog_field branch "${branch}"
if [ -z "${branch}" ]; then
continue
fi
if [ -z "${branch_lines}" ]; then
branch_lines="${branch}"
else
branch_lines="${branch_lines}"$'\n'"${branch}"
fi
done
if [ -z "${branch_lines}" ]; then
return 1
fi
printf -v "${result_var}" "%s" "${branch_lines}"
return 0
}
predefined_app_catalog_has_id() {
local app_id_lookup="${1}"
if [ -z "${app_id_lookup}" ]; then
return 1
fi
get_predefined_app_field_by_field "id" "${app_id_lookup}" "id" >/dev/null 2>&1
}
predefined_app_catalog_has_label() {
local app_label_lookup="${1}"
if [ -z "${app_label_lookup}" ]; then
return 1
fi
get_predefined_app_field_by_field "label" "${app_label_lookup}" "label" >/dev/null 2>&1
}
append_predefined_app_catalog_entry() {
local app_id="${1}"
local app_label="${2}"
local app_repo="${3}"
local app_default_branch="${4}"
local app_branches_csv="${5}"
local normalized_branches_csv=""
local first_branch=""
local catalog_path=""
local catalog_tmp_path=""
local last_char=""
if ! get_predefined_apps_catalog_entries >/dev/null 2>&1; then
return 1
fi
trim_predefined_catalog_field app_id "${app_id}"
trim_predefined_catalog_field app_label "${app_label}"
trim_predefined_catalog_field app_repo "${app_repo}"
trim_predefined_catalog_field app_default_branch "${app_default_branch}"
trim_predefined_catalog_field app_branches_csv "${app_branches_csv}"
if ! is_valid_predefined_app_id "${app_id}"; then
return 1
fi
if [ -z "${app_label}" ]; then
return 1
fi
if ! is_valid_predefined_app_repo "${app_repo}"; then
return 1
fi
if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then
return 1
fi
if [ -z "${app_default_branch}" ]; then
first_branch="${normalized_branches_csv%%,*}"
app_default_branch="${first_branch}"
fi
if ! is_valid_predefined_app_branch "${app_default_branch}"; then
return 1
fi
if ! csv_contains_branch "${normalized_branches_csv}" "${app_default_branch}"; then
return 1
fi
if predefined_app_catalog_has_id "${app_id}"; then
return 1
fi
if predefined_app_catalog_has_label "${app_label}"; then
return 1
fi
catalog_path="$(get_predefined_apps_catalog_path)"
catalog_tmp_path="${catalog_path}.tmp"
if [ ! -f "${catalog_path}" ]; then
return 1
fi
if ! cp -- "${catalog_path}" "${catalog_tmp_path}"; then
rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true
return 1
fi
if [ -s "${catalog_tmp_path}" ]; then
if command_exists tail; then
last_char="$(tail -c 1 "${catalog_tmp_path}" 2>/dev/null || true)"
if [ -n "${last_char}" ]; then
if ! printf '\n' >>"${catalog_tmp_path}"; then
rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true
return 1
fi
fi
else
if ! printf '\n' >>"${catalog_tmp_path}"; then
rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true
return 1
fi
fi
fi
if ! printf '%s\t%s\t%s\t%s\t%s\n' "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}" >>"${catalog_tmp_path}"; then
rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true
return 1
fi
if ! mv -- "${catalog_tmp_path}" "${catalog_path}"; then
rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true
return 1
fi
return 0
}

View file

@ -0,0 +1,294 @@
#!/usr/bin/env bash
persist_stack_apps_json_content() {
local stack_dir="${1}"
local apps_json_content="${2}"
local apps_json_path=""
local apps_json_tmp_path=""
apps_json_path="${stack_dir}/apps.json"
apps_json_tmp_path="${apps_json_path}.tmp"
if ! printf '%s\n' "${apps_json_content}" >"${apps_json_tmp_path}"; then
rm -f -- "${apps_json_tmp_path}" >/dev/null 2>&1 || true
return 1
fi
if ! mv -- "${apps_json_tmp_path}" "${apps_json_path}"; then
rm -f -- "${apps_json_tmp_path}" >/dev/null 2>&1 || true
return 1
fi
return 0
}
get_metadata_apps_predefined_csv() {
local metadata_path="${1}"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if ! easy_docker_require_jq; then
return 1
fi
easy_docker_run_jq -r '(.apps.predefined // []) | join(",")' "${metadata_path}"
}
get_metadata_apps_custom_lines() {
local metadata_path="${1}"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if ! easy_docker_require_jq; then
return 1
fi
easy_docker_run_jq -r '(.apps.custom // [])[]? | select(has("repo") and has("branch")) | "\(.repo)|\(.branch)"' "${metadata_path}"
}
get_metadata_apps_predefined_branch_lines() {
local metadata_path="${1}"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if ! easy_docker_require_jq; then
return 1
fi
easy_docker_run_jq -r '(.apps.predefined_branches // {}) | to_entries[]? | "\(.key)|\(.value)"' "${metadata_path}"
}
get_metadata_apps_predefined_branch_for_id() {
local metadata_path="${1}"
local app_id_lookup="${2}"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if ! easy_docker_require_jq; then
return 1
fi
# shellcheck disable=SC2016
easy_docker_run_jq -r --arg app_id "${app_id_lookup}" '.apps.predefined_branches[$app_id] // empty' "${metadata_path}"
}
build_metadata_apps_json_object() {
local result_var="${1}"
local predefined_csv="${2}"
local branch_lines="${3}"
local custom_apps_lines="${4:-}"
local app_id=""
local app_branch=""
local custom_repo=""
local custom_branch=""
local predefined_json_entries=""
local branch_json_entries=""
local custom_json_entries=""
local escaped_app_id=""
local escaped_branch=""
local escaped_repo=""
local entry_json=""
local line=""
local -a predefined_ids=()
if [ -n "${predefined_csv}" ]; then
IFS=',' read -r -a predefined_ids <<<"${predefined_csv}"
for app_id in "${predefined_ids[@]}"; do
if [ -z "${app_id}" ]; then
continue
fi
escaped_app_id="$(json_escape_string "${app_id}")"
entry_json="$(printf ' "%s"' "${escaped_app_id}")"
if [ -z "${predefined_json_entries}" ]; then
predefined_json_entries="${entry_json}"
else
predefined_json_entries="${predefined_json_entries}"$',\n'"${entry_json}"
fi
done
fi
while IFS= read -r line; do
if [ -z "${line}" ]; then
continue
fi
app_id="${line%%|*}"
app_branch="${line#*|}"
if [ -z "${app_id}" ] || [ -z "${app_branch}" ]; then
continue
fi
escaped_app_id="$(json_escape_string "${app_id}")"
escaped_branch="$(json_escape_string "${app_branch}")"
entry_json="$(printf ' "%s": "%s"' "${escaped_app_id}" "${escaped_branch}")"
if [ -z "${branch_json_entries}" ]; then
branch_json_entries="${entry_json}"
else
branch_json_entries="${branch_json_entries}"$',\n'"${entry_json}"
fi
done <<EOF
${branch_lines}
EOF
while IFS= read -r line; do
if [ -z "${line}" ]; then
continue
fi
custom_repo="${line%%|*}"
custom_branch="${line#*|}"
if [ -z "${custom_repo}" ] || [ -z "${custom_branch}" ]; then
continue
fi
escaped_repo="$(json_escape_string "${custom_repo}")"
escaped_branch="$(json_escape_string "${custom_branch}")"
entry_json="$(printf ' {\n "repo": "%s",\n "branch": "%s"\n }' "${escaped_repo}" "${escaped_branch}")"
if [ -z "${custom_json_entries}" ]; then
custom_json_entries="${entry_json}"
else
custom_json_entries="${custom_json_entries}"$',\n'"${entry_json}"
fi
done <<EOF
${custom_apps_lines}
EOF
printf -v "${result_var}" '{\n "predefined": [\n%s\n ],\n "predefined_branches": {\n%s\n },\n "custom": [\n%s\n ]\n }' "${predefined_json_entries}" "${branch_json_entries}" "${custom_json_entries}"
}
render_metadata_apps_json_object_from_metadata() {
local result_var="${1}"
local metadata_path="${2}"
local predefined_csv=""
local branch_lines=""
local custom_lines=""
local apps_json_object=""
predefined_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)"
branch_lines="$(get_metadata_apps_predefined_branch_lines "${metadata_path}" || true)"
custom_lines="$(get_metadata_apps_custom_lines "${metadata_path}" || true)"
build_metadata_apps_json_object apps_json_object "${predefined_csv}" "${branch_lines}" "${custom_lines}"
printf -v "${result_var}" "%s" "${apps_json_object}"
}
build_stack_apps_json_content_from_metadata_apps() {
local result_var="${1}"
local stack_dir="${2}"
local metadata_path=""
local preset_apps_csv=""
local custom_apps_lines=""
local predefined_branch=""
local preset_branch=""
local catalog_default_branch=""
local app=""
local line=""
local repo=""
local branch=""
local url=""
local escaped_url=""
local escaped_branch=""
local entry_json=""
local entries_json=""
local -a preset_apps=()
metadata_path="${stack_dir}/metadata.json"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if ! easy_docker_require_jq; then
return 1
fi
preset_apps_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)"
custom_apps_lines="$(get_metadata_apps_custom_lines "${metadata_path}" || true)"
preset_branch="$(get_stack_frappe_branch "${stack_dir}" || true)"
if [ -z "${preset_branch}" ]; then
preset_branch="$(get_default_frappe_branch)"
fi
if [ -n "${preset_apps_csv}" ]; then
IFS=',' read -r -a preset_apps <<<"${preset_apps_csv}"
for app in "${preset_apps[@]}"; do
url="$(get_predefined_app_repo_by_id "${app}" || true)"
if [ -z "${url}" ]; then
return 1
fi
predefined_branch="$(get_metadata_apps_predefined_branch_for_id "${metadata_path}" "${app}" || true)"
if [ -z "${predefined_branch}" ]; then
catalog_default_branch="$(get_predefined_app_default_branch_by_id "${app}" || true)"
if [ -n "${catalog_default_branch}" ]; then
predefined_branch="${catalog_default_branch}"
else
predefined_branch="${preset_branch}"
fi
fi
escaped_url="$(json_escape_string "${url}")"
escaped_branch="$(json_escape_string "${predefined_branch}")"
entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")"
if [ -z "${entries_json}" ]; then
entries_json="${entry_json}"
else
entries_json="${entries_json}"$',\n'"${entry_json}"
fi
done
fi
while IFS= read -r line; do
if [ -z "${line}" ]; then
continue
fi
repo="${line%%|*}"
branch="${line#*|}"
if [ -z "${repo}" ] || [ -z "${branch}" ]; then
continue
fi
escaped_url="$(json_escape_string "${repo}")"
escaped_branch="$(json_escape_string "${branch}")"
entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")"
if [ -z "${entries_json}" ]; then
entries_json="${entry_json}"
else
entries_json="${entries_json}"$',\n'"${entry_json}"
fi
done <<EOF
${custom_apps_lines}
EOF
if [ -z "${entries_json}" ]; then
printf -v "${result_var}" "[\n]\n"
else
printf -v "${result_var}" "[\n%s\n]\n" "${entries_json}"
fi
return 0
}
persist_stack_apps_json_from_metadata_apps() {
local stack_dir="${1}"
local apps_json_content=""
if ! build_stack_apps_json_content_from_metadata_apps apps_json_content "${stack_dir}"; then
return 1
fi
if ! persist_stack_apps_json_content "${stack_dir}" "${apps_json_content}"; then
return 1
fi
return 0
}

View file

@ -0,0 +1,193 @@
#!/usr/bin/env bash
render_stack_metadata_top_level_entry_from_json_file() {
local metadata_path="${1}"
local metadata_key="${2}"
local entry_value=""
local rendered_entry=""
local line=""
local delta=0
local depth=0
local started=0
while IFS= read -r line || [ -n "${line}" ]; do
if [ "${started}" -eq 0 ]; then
case "${line}" in
" \"${metadata_key}\":"*)
entry_value="${line# \""${metadata_key}"\": }"
entry_value="${entry_value%,}"
if [[ "${entry_value}" == \{* || "${entry_value}" == \[* ]]; then
rendered_entry=" \"${metadata_key}\": ${entry_value}"
started=1
delta="$(count_stack_metadata_json_structure_delta "${entry_value}")"
depth=$((depth + delta))
if [ "${depth}" -le 0 ]; then
printf '%s' "${rendered_entry}"
return 0
fi
else
printf ' "%s": %s' "${metadata_key}" "${entry_value}"
return 0
fi
;;
esac
continue
fi
delta="$(count_stack_metadata_json_structure_delta "${line}")"
if [ $((depth + delta)) -le 0 ]; then
rendered_entry="${rendered_entry}"$'\n'"${line%,}"
printf '%s' "${rendered_entry}"
return 0
fi
rendered_entry="${rendered_entry}"$'\n'"${line}"
depth=$((depth + delta))
done <"${metadata_path}"
return 1
}
count_stack_metadata_json_structure_delta() {
local line="${1}"
local opens=0
local closes=0
local matches=""
matches="${line//[^\{]/}"
opens=$((opens + ${#matches}))
matches="${line//[^\[]/}"
opens=$((opens + ${#matches}))
matches="${line//[^\}]/}"
closes=$((closes + ${#matches}))
matches="${line//[^\]]/}"
closes=$((closes + ${#matches}))
printf '%s\n' "$((opens - closes))"
}
build_stack_metadata_top_level_object_content() {
local result_var="${1}"
local metadata_path="${2}"
local object_key="${3}"
local object_json="${4}"
shift 4
local rendered_metadata=""
local entry_json=""
local metadata_key=""
local index=0
local total_keys=0
local -a ordered_keys=("$@")
total_keys="${#ordered_keys[@]}"
rendered_metadata="{"
if [ "${total_keys}" -gt 0 ]; then
rendered_metadata="${rendered_metadata}"$'\n'
fi
for index in "${!ordered_keys[@]}"; do
metadata_key="${ordered_keys[${index}]}"
if [ "${metadata_key}" = "${object_key}" ]; then
entry_json="$(printf ' "%s": %s' "${metadata_key}" "${object_json}")"
else
entry_json="$(render_stack_metadata_top_level_entry_from_json_file "${metadata_path}" "${metadata_key}")" || return 1
fi
rendered_metadata="${rendered_metadata}${entry_json}"
if [ "${index}" -lt $((total_keys - 1)) ]; then
rendered_metadata="${rendered_metadata},"
fi
rendered_metadata="${rendered_metadata}"$'\n'
done
rendered_metadata="${rendered_metadata}}"$'\n'
if ! printf '%s' "${rendered_metadata}" | easy_docker_run_jq -e 'type == "object"' >/dev/null 2>&1; then
return 1
fi
printf -v "${result_var}" "%s" "${rendered_metadata}"
}
persist_stack_metadata_top_level_object() {
local stack_dir="${1}"
local object_key="${2}"
local object_json="${3}"
local insert_before_key="${4:-}"
local metadata_path=""
local metadata_tmp_path=""
local metadata_content=""
local existing_key=""
local inserted=0
local -a existing_keys=()
local -a ordered_keys=()
metadata_path="${stack_dir}/metadata.json"
metadata_tmp_path="${metadata_path}.tmp"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if [ -z "${object_json}" ]; then
return 1
fi
if ! easy_docker_require_jq; then
return 1
fi
if ! easy_docker_run_jq -e 'type == "object"' "${metadata_path}" >/dev/null 2>&1; then
return 1
fi
object_json="${object_json%$'\n'}"
if ! printf '%s\n' "${object_json}" | easy_docker_run_jq -e 'type == "object"' >/dev/null 2>&1; then
return 1
fi
mapfile -t existing_keys < <(easy_docker_run_jq -r 'keys_unsorted[]' "${metadata_path}") || return 1
for existing_key in "${existing_keys[@]}"; do
if [ "${existing_key}" = "${object_key}" ]; then
continue
fi
if [ "${inserted}" -eq 0 ] && [ -n "${insert_before_key}" ] && [ "${existing_key}" = "${insert_before_key}" ]; then
ordered_keys+=("${object_key}")
inserted=1
fi
ordered_keys+=("${existing_key}")
done
if [ "${inserted}" -eq 0 ]; then
ordered_keys+=("${object_key}")
fi
build_stack_metadata_top_level_object_content metadata_content "${metadata_path}" "${object_key}" "${object_json}" "${ordered_keys[@]}" || return 1
if ! printf '%s' "${metadata_content}" >"${metadata_tmp_path}"; then
rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true
return 1
fi
if ! mv -- "${metadata_tmp_path}" "${metadata_path}"; then
rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true
return 1
fi
return 0
}
persist_stack_metadata_apps_object() {
local stack_dir="${1}"
local apps_json_object="${2}"
persist_stack_metadata_top_level_object "${stack_dir}" "apps" "${apps_json_object}" "wizard"
}
persist_stack_metadata_wizard_object() {
local stack_dir="${1}"
local wizard_json_object="${2}"
persist_stack_metadata_top_level_object "${stack_dir}" "wizard" "${wizard_json_object}"
}

View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
EASY_DOCKER_BUILD_ERROR_DETAIL=""
# shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata fails.
EASY_DOCKER_COMPOSE_ERROR_DETAIL=""
load_easy_docker_compose_modules() {
local compose_dir=""
compose_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/compose"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/render.sh
source "${compose_dir}/render.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start.sh
source "${compose_dir}/start.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/build.sh
source "${compose_dir}/build.sh"
}
load_easy_docker_compose_modules

View file

@ -0,0 +1,108 @@
#!/usr/bin/env bash
build_stack_custom_image() {
local stack_dir="${1}"
local metadata_path=""
local env_path=""
local apps_json_path=""
local custom_image=""
local custom_tag=""
local frappe_branch=""
local frappe_path="https://github.com/frappe/frappe"
local repo_root=""
local containerfile_path=""
local apps_refs_lines=""
local app_ref_line=""
local app_url=""
local app_branch=""
local git_error=""
local image_ref=""
EASY_DOCKER_BUILD_ERROR_DETAIL=""
metadata_path="${stack_dir}/metadata.json"
env_path="$(get_stack_env_path "${stack_dir}")"
apps_json_path="${stack_dir}/apps.json"
if [ ! -f "${metadata_path}" ]; then
return 11
fi
if [ ! -f "${env_path}" ]; then
return 12
fi
if ! easy_docker_require_jq; then
return 25
fi
custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)"
custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)"
frappe_branch="$(get_stack_frappe_branch "${stack_dir}" || true)"
if [ -z "${custom_image}" ]; then
return 13
fi
if [ -z "${custom_tag}" ]; then
return 14
fi
if [ -z "${frappe_branch}" ]; then
return 15
fi
# Keep apps.json aligned with current metadata app selection before build.
if ! persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then
return 16
fi
if [ ! -f "${apps_json_path}" ]; then
return 17
fi
if ! command_exists git; then
return 22
fi
apps_refs_lines="$(
easy_docker_run_jq -r '.[]? | select((.url // "") != "" and (.branch // "") != "") | "\(.url)|\(.branch)"' "${apps_json_path}"
)" || return 23
if [ -z "${apps_refs_lines}" ]; then
return 23
fi
while IFS= read -r app_ref_line; do
if [ -z "${app_ref_line}" ]; then
continue
fi
app_url="${app_ref_line%%|*}"
app_branch="${app_ref_line#*|}"
if [ -z "${app_url}" ] || [ -z "${app_branch}" ]; then
continue
fi
if git_error="$(git ls-remote --exit-code --heads "${app_url}" "${app_branch}" 2>&1)"; then
:
else
# shellcheck disable=SC2034 # Read by manage flow after build_stack_custom_image returns 24.
EASY_DOCKER_BUILD_ERROR_DETAIL="$(printf '%s@%s :: %s' "${app_url}" "${app_branch}" "${git_error}")"
return 24
fi
done <<EOF
${apps_refs_lines}
EOF
repo_root="$(get_easy_docker_repo_root)"
containerfile_path="${repo_root}/images/layered/Containerfile"
if [ ! -f "${containerfile_path}" ]; then
return 20
fi
image_ref="${custom_image}:${custom_tag}"
docker build \
-f "${containerfile_path}" \
--build-arg "FRAPPE_BRANCH=${frappe_branch}" \
--build-arg "FRAPPE_PATH=${frappe_path}" \
--secret "id=apps_json,src=${apps_json_path}" \
-t "${image_ref}" \
"${repo_root}" || return 21
return 0
}

View file

@ -0,0 +1,78 @@
#!/usr/bin/env bash
render_stack_compose_from_metadata() {
local stack_dir="${1}"
local metadata_path=""
local env_path=""
local generated_compose_path=""
local generated_compose_tmp_path=""
local compose_files_lines=""
local compose_file=""
local source_compose_path=""
local env_erpnext_version=""
local fallback_erpnext_version=""
local compose_project_name=""
local repo_root=""
local -a compose_args=()
metadata_path="${stack_dir}/metadata.json"
env_path="$(get_stack_env_path "${stack_dir}")"
compose_project_name="$(get_stack_compose_project_name "${stack_dir}")"
generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")"
generated_compose_tmp_path="${generated_compose_path}.tmp"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if [ ! -f "${env_path}" ]; then
return 1
fi
env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)"
if [ -z "${env_erpnext_version}" ]; then
fallback_erpnext_version="$(get_default_erpnext_version || true)"
fi
compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)"
if [ -z "${compose_files_lines}" ]; then
return 1
fi
repo_root="$(get_easy_docker_repo_root)"
while IFS= read -r compose_file; do
if [ -z "${compose_file}" ]; then
continue
fi
source_compose_path="${repo_root}/${compose_file}"
if [ ! -f "${source_compose_path}" ]; then
return 1
fi
compose_args+=(-f "${source_compose_path}")
done <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
return 1
fi
if [ -n "${fallback_erpnext_version}" ]; then
if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" config >"${generated_compose_tmp_path}"; then
rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true
return 1
fi
elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" config >"${generated_compose_tmp_path}"; then
rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true
return 1
fi
if ! mv -- "${generated_compose_tmp_path}" "${generated_compose_path}"; then
rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true
return 1
fi
return 0
}

View file

@ -0,0 +1,193 @@
#!/usr/bin/env bash
start_stack_with_compose_from_metadata() {
local stack_dir="${1}"
local metadata_path=""
local env_path=""
local compose_project_name=""
local fallback_erpnext_version=""
local configured_pull_policy=""
local runtime_pull_policy=""
local custom_image=""
local custom_tag=""
local image_ref=""
local image_inspect_error=""
local -a compose_args=()
# shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata fails.
EASY_DOCKER_COMPOSE_ERROR_DETAIL=""
easy_docker_compose_init_context "${stack_dir}" metadata_path env_path compose_project_name
if [ ! -f "${metadata_path}" ]; then
return 31
fi
if [ ! -f "${env_path}" ]; then
return 32
fi
if ! easy_docker_compose_require_supported_topology "${stack_dir}" 33 34; then
return $?
fi
easy_docker_compose_get_fallback_erpnext_version fallback_erpnext_version "${env_path}"
configured_pull_policy="$(get_env_file_key_value "${env_path}" "PULL_POLICY" || true)"
if [ -z "${configured_pull_policy}" ]; then
custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)"
custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)"
if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then
image_ref="${custom_image}:${custom_tag}"
if image_inspect_error="$(docker image inspect "${image_ref}" 2>&1 >/dev/null)"; then
runtime_pull_policy="if_not_present"
else
case "${image_inspect_error}" in
*"No such image"* | *"No such object"*)
# shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 38.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_ref}"
return 38
;;
*)
if [ -z "${image_inspect_error}" ]; then
image_inspect_error="docker image inspect failed for ${image_ref}"
fi
# shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 39.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_inspect_error}"
return 39
;;
esac
fi
fi
fi
if ! easy_docker_compose_collect_args compose_args "${metadata_path}" 35 36; then
return $?
fi
if [ -n "${fallback_erpnext_version}" ] && [ -n "${runtime_pull_policy}" ]; then
if ! ERPNEXT_VERSION="${fallback_erpnext_version}" PULL_POLICY="${runtime_pull_policy}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then
return 37
fi
elif [ -n "${fallback_erpnext_version}" ]; then
if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then
return 37
fi
elif [ -n "${runtime_pull_policy}" ]; then
if ! PULL_POLICY="${runtime_pull_policy}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then
return 37
fi
elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then
return 37
fi
return 0
}
stop_stack_with_compose_from_metadata() {
local stack_dir="${1}"
local metadata_path=""
local env_path=""
local compose_project_name=""
local fallback_erpnext_version=""
local -a compose_args=()
# shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata fails.
EASY_DOCKER_COMPOSE_ERROR_DETAIL=""
easy_docker_compose_init_context "${stack_dir}" metadata_path env_path compose_project_name
if [ ! -f "${metadata_path}" ]; then
return 41
fi
if [ ! -f "${env_path}" ]; then
return 42
fi
if ! easy_docker_compose_require_supported_topology "${stack_dir}" 43 44; then
return $?
fi
easy_docker_compose_get_fallback_erpnext_version fallback_erpnext_version "${env_path}"
if ! easy_docker_compose_collect_args compose_args "${metadata_path}" 45 46; then
return $?
fi
if [ -n "${fallback_erpnext_version}" ]; then
if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" stop; then
return 47
fi
elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" stop; then
return 47
fi
return 0
}
delete_stack_with_compose_from_metadata() {
local stack_dir="${1}"
local metadata_path=""
local env_path=""
local compose_project_name=""
local fallback_erpnext_version=""
local custom_image=""
local custom_tag=""
local custom_image_ref=""
local -a compose_args=()
# shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata fails.
EASY_DOCKER_COMPOSE_ERROR_DETAIL=""
easy_docker_compose_init_context "${stack_dir}" metadata_path env_path compose_project_name
if [ ! -f "${metadata_path}" ]; then
return 48
fi
if [ ! -f "${env_path}" ]; then
return 49
fi
if ! easy_docker_compose_require_supported_topology "${stack_dir}" 50 51; then
return $?
fi
easy_docker_compose_get_fallback_erpnext_version fallback_erpnext_version "${env_path}"
custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)"
custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)"
if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then
custom_image_ref="${custom_image}:${custom_tag}"
fi
if ! easy_docker_compose_collect_args compose_args "${metadata_path}" 52 53; then
return $?
fi
if [ -n "${fallback_erpnext_version}" ]; then
if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" down -v --remove-orphans --rmi local; then
return 54
fi
elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" down -v --remove-orphans --rmi local; then
return 54
fi
if [ -n "${custom_image_ref}" ]; then
if docker image inspect "${custom_image_ref}" >/dev/null 2>&1; then
if ! docker image rm "${custom_image_ref}"; then
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${custom_image_ref}"
return 55
fi
fi
fi
if ! rollback_stack_directory "${stack_dir}"; then
# shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata returns 56.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_dir}"
return 56
fi
return 0
}

View file

@ -0,0 +1,100 @@
#!/usr/bin/env bash
easy_docker_compose_init_context() {
local stack_dir="${1}"
local metadata_var="${2}"
local env_var="${3}"
local project_var="${4}"
local metadata_path=""
local env_path=""
local compose_project_name=""
metadata_path="${stack_dir}/metadata.json"
env_path="$(get_stack_env_path "${stack_dir}")"
compose_project_name="$(get_stack_compose_project_name "${stack_dir}")"
printf -v "${metadata_var}" "%s" "${metadata_path}"
printf -v "${env_var}" "%s" "${env_path}"
printf -v "${project_var}" "%s" "${compose_project_name}"
}
easy_docker_compose_get_fallback_erpnext_version() {
local result_var="${1}"
local env_path="${2}"
local env_erpnext_version=""
local fallback_erpnext_version=""
env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)"
if [ -z "${env_erpnext_version}" ]; then
fallback_erpnext_version="$(get_default_erpnext_version || true)"
fi
printf -v "${result_var}" "%s" "${fallback_erpnext_version}"
}
easy_docker_compose_require_supported_topology() {
local stack_dir="${1}"
local missing_topology_code="${2}"
local unsupported_topology_code="${3}"
local stack_topology=""
stack_topology="$(get_stack_topology "${stack_dir}" || true)"
if [ -z "${stack_topology}" ]; then
# shellcheck disable=SC2034 # Read by callers after topology resolution fails.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology"
return "${missing_topology_code}"
fi
case "${stack_topology}" in
"single-host" | "split-services")
return 0
;;
*)
# shellcheck disable=SC2034 # Read by callers after unsupported topology is returned.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}"
return "${unsupported_topology_code}"
;;
esac
}
easy_docker_compose_collect_args() {
local result_array_name="${1}"
local metadata_path="${2}"
local missing_compose_code="${3}"
local missing_file_code="${4}"
local compose_files_lines=""
local compose_file=""
local source_compose_path=""
local repo_root=""
local -n compose_args_ref="${result_array_name}"
compose_args_ref=()
compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)"
if [ -z "${compose_files_lines}" ]; then
return "${missing_compose_code}"
fi
repo_root="$(get_easy_docker_repo_root)"
while IFS= read -r compose_file; do
if [ -z "${compose_file}" ]; then
continue
fi
source_compose_path="${repo_root}/${compose_file}"
if [ ! -f "${source_compose_path}" ]; then
# shellcheck disable=SC2034 # Read by callers after compose file resolution fails.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}"
return "${missing_file_code}"
fi
compose_args_ref+=(-f "${source_compose_path}")
done <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args_ref[@]}" -eq 0 ]; then
return "${missing_compose_code}"
fi
return 0
}

View file

@ -0,0 +1,233 @@
#!/usr/bin/env bash
get_stack_compose_runtime_status_label() {
local result_var="${1}"
local stack_dir="${2}"
local metadata_path=""
local env_path=""
local stack_topology=""
local compose_files_lines=""
local compose_file=""
local source_compose_path=""
local fallback_erpnext_version=""
local container_ids_lines=""
local container_id=""
local container_status_lines=""
local container_status_line=""
local container_state=""
local container_status_text=""
local first_running_status=""
local running_status_excerpt=""
local running_status_varies=0
local compose_status=0
local total_containers_count=0
local running_containers_count=0
local exited_containers_count=0
local created_containers_count=0
local restarting_containers_count=0
local paused_containers_count=0
local dead_containers_count=0
local other_containers_count=0
local compose_project_name=""
local repo_root=""
local status_label=""
local -a compose_args=()
local -a docker_ps_args=()
easy_docker_compose_init_context "${stack_dir}" metadata_path env_path compose_project_name
if [ ! -f "${metadata_path}" ]; then
printf -v "${result_var}" "%s" "Unknown (metadata missing)"
return 0
fi
stack_topology="$(get_stack_topology "${stack_dir}" || true)"
if [ -z "${stack_topology}" ]; then
printf -v "${result_var}" "%s" "Unknown (topology missing)"
return 0
fi
case "${stack_topology}" in
"single-host" | "split-services") ;;
*)
printf -v "${result_var}" "%s" "Unsupported (${stack_topology})"
return 0
;;
esac
if [ ! -f "${env_path}" ]; then
printf -v "${result_var}" "%s" "Unknown (env missing)"
return 0
fi
easy_docker_compose_get_fallback_erpnext_version fallback_erpnext_version "${env_path}"
compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)"
if [ -z "${compose_files_lines}" ]; then
printf -v "${result_var}" "%s" "Unknown (compose files missing)"
return 0
fi
repo_root="$(get_easy_docker_repo_root)"
while IFS= read -r compose_file; do
if [ -z "${compose_file}" ]; then
continue
fi
source_compose_path="${repo_root}/${compose_file}"
if [ ! -f "${source_compose_path}" ]; then
printf -v "${result_var}" "%s" "Unknown (missing file: ${compose_file})"
return 0
fi
compose_args+=(-f "${source_compose_path}")
done <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
printf -v "${result_var}" "%s" "Unknown (compose files missing)"
return 0
fi
if [ -n "${fallback_erpnext_version}" ]; then
container_ids_lines="$(
ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/dev/null
)"
compose_status=$?
else
container_ids_lines="$(
docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/dev/null
)"
compose_status=$?
fi
if [ "${compose_status}" -ne 0 ]; then
printf -v "${result_var}" "%s" "Unknown (docker compose status failed)"
return 0
fi
if [ -z "${container_ids_lines}" ]; then
printf -v "${result_var}" "%s" "Not created"
return 0
fi
docker_ps_args=(-a --no-trunc --format "{{.ID}}|{{.State}}|{{.Status}}")
while IFS= read -r container_id; do
if [ -n "${container_id}" ]; then
docker_ps_args+=(--filter "id=${container_id}")
fi
done <<EOF
${container_ids_lines}
EOF
container_status_lines="$(docker ps "${docker_ps_args[@]}" 2>/dev/null)"
compose_status=$?
if [ "${compose_status}" -ne 0 ]; then
printf -v "${result_var}" "%s" "Unknown (docker ps status failed)"
return 0
fi
while IFS= read -r container_status_line; do
if [ -z "${container_status_line}" ]; then
continue
fi
total_containers_count=$((total_containers_count + 1))
IFS='|' read -r container_id container_state container_status_text <<EOF
${container_status_line}
EOF
case "${container_state}" in
running)
running_containers_count=$((running_containers_count + 1))
if [ -z "${first_running_status}" ]; then
first_running_status="${container_status_text}"
elif [ "${container_status_text}" != "${first_running_status}" ]; then
running_status_varies=1
fi
;;
exited)
exited_containers_count=$((exited_containers_count + 1))
;;
created)
created_containers_count=$((created_containers_count + 1))
;;
restarting)
restarting_containers_count=$((restarting_containers_count + 1))
;;
paused)
paused_containers_count=$((paused_containers_count + 1))
;;
dead)
dead_containers_count=$((dead_containers_count + 1))
;;
*)
other_containers_count=$((other_containers_count + 1))
;;
esac
done <<EOF
${container_status_lines}
EOF
if [ "${total_containers_count}" -eq 0 ]; then
printf -v "${result_var}" "%s" "Not created"
return 0
fi
if [ -n "${first_running_status}" ]; then
case "${first_running_status}" in
Up\ *)
running_status_excerpt="${first_running_status#Up }"
;;
*)
running_status_excerpt="${first_running_status}"
;;
esac
fi
if [ "${running_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Running (${running_containers_count}/${total_containers_count} containers"
if [ -n "${running_status_excerpt}" ]; then
status_label="${status_label}, up ${running_status_excerpt}"
if [ "${running_status_varies}" -eq 1 ]; then
status_label="${status_label}+"
fi
fi
status_label="${status_label})"
elif [ "${running_containers_count}" -gt 0 ]; then
status_label="Partial (${running_containers_count}/${total_containers_count} running"
if [ "${restarting_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${restarting_containers_count} restarting"
elif [ "${paused_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${paused_containers_count} paused"
elif [ "${exited_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${exited_containers_count} stopped"
elif [ "${created_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${created_containers_count} created"
elif [ "${dead_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${dead_containers_count} dead"
elif [ "${other_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${other_containers_count} other"
fi
if [ -n "${running_status_excerpt}" ]; then
status_label="${status_label}, up ${running_status_excerpt}"
if [ "${running_status_varies}" -eq 1 ]; then
status_label="${status_label}+"
fi
fi
status_label="${status_label})"
elif [ "${restarting_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Restarting (${total_containers_count} containers)"
elif [ "${paused_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Paused (${total_containers_count} containers)"
elif [ "${created_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Created (${total_containers_count} containers)"
else
status_label="Stopped (${total_containers_count} containers)"
fi
printf -v "${result_var}" "%s" "${status_label}"
return 0
}

View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
load_easy_docker_compose_lifecycle_modules() {
local lifecycle_dir=""
lifecycle_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/start"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/start.sh
source "${lifecycle_dir}/start.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/stop.sh
source "${lifecycle_dir}/stop.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/restart.sh
source "${lifecycle_dir}/restart.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/delete.sh
source "${lifecycle_dir}/delete.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/status.sh
source "${lifecycle_dir}/status.sh"
}
load_easy_docker_compose_lifecycle_modules

View file

@ -0,0 +1,132 @@
#!/usr/bin/env bash
stack_directory_contains_only_metadata() {
local stack_dir="${1}"
local metadata_path="${2}"
local remaining_entry=""
remaining_entry="$(
find "${stack_dir}" -mindepth 1 ! -path "${metadata_path}" -print -quit 2>/dev/null || true
)"
if [ -n "${remaining_entry}" ]; then
return 1
fi
return 0
}
delete_stack_with_compose_from_metadata() {
local stack_dir="${1}"
local metadata_path=""
local env_path=""
local compose_files_lines=""
local compose_file=""
local source_compose_path=""
local env_erpnext_version=""
local fallback_erpnext_version=""
local compose_project_name=""
local stack_topology=""
local repo_root=""
local custom_image=""
local custom_tag=""
local custom_image_ref=""
local -a compose_args=()
# shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata fails.
EASY_DOCKER_COMPOSE_ERROR_DETAIL=""
metadata_path="${stack_dir}/metadata.json"
env_path="$(get_stack_env_path "${stack_dir}")"
compose_project_name="$(get_stack_compose_project_name "${stack_dir}")"
if [ ! -f "${metadata_path}" ]; then
return 48
fi
if [ ! -f "${env_path}" ]; then
if stack_directory_contains_only_metadata "${stack_dir}" "${metadata_path}"; then
if ! rollback_stack_directory "${stack_dir}"; then
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_dir}"
return 56
fi
return 0
fi
return 49
fi
stack_topology="$(get_stack_topology "${stack_dir}" || true)"
if [ -z "${stack_topology}" ]; then
EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology"
return 50
fi
case "${stack_topology}" in
"single-host" | "split-services") ;;
*)
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}"
return 51
;;
esac
env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)"
if [ -z "${env_erpnext_version}" ]; then
fallback_erpnext_version="$(get_default_erpnext_version || true)"
fi
custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)"
custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)"
if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then
custom_image_ref="${custom_image}:${custom_tag}"
fi
compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)"
if [ -z "${compose_files_lines}" ]; then
return 52
fi
repo_root="$(get_easy_docker_repo_root)"
while IFS= read -r compose_file; do
if [ -z "${compose_file}" ]; then
continue
fi
source_compose_path="${repo_root}/${compose_file}"
if [ ! -f "${source_compose_path}" ]; then
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}"
return 53
fi
compose_args+=(-f "${source_compose_path}")
done <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
return 52
fi
if [ -n "${fallback_erpnext_version}" ]; then
if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" down -v --remove-orphans --rmi local; then
return 54
fi
elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" down -v --remove-orphans --rmi local; then
return 54
fi
if [ -n "${custom_image_ref}" ]; then
if docker image inspect "${custom_image_ref}" >/dev/null 2>&1; then
if ! docker image rm "${custom_image_ref}"; then
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${custom_image_ref}"
return 55
fi
fi
fi
if ! rollback_stack_directory "${stack_dir}"; then
# shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata returns 56.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_dir}"
return 56
fi
return 0
}

View file

@ -0,0 +1,74 @@
#!/usr/bin/env bash
restart_stack_with_compose_from_metadata() {
local stack_dir="${1}"
local stop_status=0
local start_status=0
# shellcheck disable=SC2034 # Read by manage flow after restart_stack_with_compose_from_metadata fails.
EASY_DOCKER_COMPOSE_ERROR_DETAIL=""
if stop_stack_with_compose_from_metadata "${stack_dir}"; then
:
else
stop_status=$?
case "${stop_status}" in
41)
return 57
;;
42)
return 58
;;
43)
return 59
;;
44)
return 60
;;
45)
return 61
;;
46)
return 62
;;
*)
return 63
;;
esac
fi
if start_stack_with_compose_from_metadata "${stack_dir}"; then
return 0
fi
start_status=$?
case "${start_status}" in
31)
return 57
;;
32)
return 58
;;
33)
return 59
;;
34)
return 60
;;
35)
return 61
;;
36)
return 62
;;
38)
return 64
;;
39)
return 65
;;
*)
return 63
;;
esac
}

View file

@ -0,0 +1,131 @@
#!/usr/bin/env bash
start_stack_with_compose_from_metadata() {
local stack_dir="${1}"
local metadata_path=""
local env_path=""
local compose_files_lines=""
local compose_file=""
local source_compose_path=""
local env_erpnext_version=""
local fallback_erpnext_version=""
local configured_pull_policy=""
local runtime_pull_policy=""
local custom_image=""
local custom_tag=""
local image_ref=""
local image_inspect_error=""
local compose_project_name=""
local stack_topology=""
local repo_root=""
local -a compose_args=()
# shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata fails.
EASY_DOCKER_COMPOSE_ERROR_DETAIL=""
metadata_path="${stack_dir}/metadata.json"
env_path="$(get_stack_env_path "${stack_dir}")"
compose_project_name="$(get_stack_compose_project_name "${stack_dir}")"
if [ ! -f "${metadata_path}" ]; then
return 31
fi
if [ ! -f "${env_path}" ]; then
return 32
fi
stack_topology="$(get_stack_topology "${stack_dir}" || true)"
if [ -z "${stack_topology}" ]; then
# shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 33.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology"
return 33
fi
case "${stack_topology}" in
"single-host" | "split-services") ;;
*)
# shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 34.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}"
return 34
;;
esac
env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)"
if [ -z "${env_erpnext_version}" ]; then
fallback_erpnext_version="$(get_default_erpnext_version || true)"
fi
configured_pull_policy="$(get_env_file_key_value "${env_path}" "PULL_POLICY" || true)"
if [ -z "${configured_pull_policy}" ]; then
custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)"
custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)"
if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then
image_ref="${custom_image}:${custom_tag}"
if image_inspect_error="$(docker image inspect "${image_ref}" 2>&1 >/dev/null)"; then
runtime_pull_policy="if_not_present"
else
case "${image_inspect_error}" in
*"No such image"* | *"No such object"*)
# shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 38.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_ref}"
return 38
;;
*)
if [ -z "${image_inspect_error}" ]; then
image_inspect_error="docker image inspect failed for ${image_ref}"
fi
# shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 39.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_inspect_error}"
return 39
;;
esac
fi
fi
fi
compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)"
if [ -z "${compose_files_lines}" ]; then
return 35
fi
repo_root="$(get_easy_docker_repo_root)"
while IFS= read -r compose_file; do
if [ -z "${compose_file}" ]; then
continue
fi
source_compose_path="${repo_root}/${compose_file}"
if [ ! -f "${source_compose_path}" ]; then
# shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 36.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}"
return 36
fi
compose_args+=(-f "${source_compose_path}")
done <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
return 35
fi
if [ -n "${fallback_erpnext_version}" ] && [ -n "${runtime_pull_policy}" ]; then
if ! ERPNEXT_VERSION="${fallback_erpnext_version}" PULL_POLICY="${runtime_pull_policy}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then
return 37
fi
elif [ -n "${fallback_erpnext_version}" ]; then
if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then
return 37
fi
elif [ -n "${runtime_pull_policy}" ]; then
if ! PULL_POLICY="${runtime_pull_policy}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then
return 37
fi
elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then
return 37
fi
return 0
}

View file

@ -0,0 +1,239 @@
#!/usr/bin/env bash
get_stack_compose_runtime_status_label() {
local result_var="${1}"
local stack_dir="${2}"
local metadata_path=""
local env_path=""
local stack_topology=""
local compose_files_lines=""
local compose_file=""
local source_compose_path=""
local env_erpnext_version=""
local fallback_erpnext_version=""
local container_ids_lines=""
local container_id=""
local container_status_lines=""
local container_status_line=""
local container_state=""
local container_status_text=""
local first_running_status=""
local running_status_excerpt=""
local running_status_varies=0
local compose_status=0
local total_containers_count=0
local running_containers_count=0
local exited_containers_count=0
local created_containers_count=0
local restarting_containers_count=0
local paused_containers_count=0
local dead_containers_count=0
local other_containers_count=0
local compose_project_name=""
local repo_root=""
local status_label=""
local -a compose_args=()
local -a docker_ps_args=()
metadata_path="${stack_dir}/metadata.json"
env_path="$(get_stack_env_path "${stack_dir}")"
compose_project_name="$(get_stack_compose_project_name "${stack_dir}")"
if [ ! -f "${metadata_path}" ]; then
printf -v "${result_var}" "%s" "Unknown (metadata missing)"
return 0
fi
stack_topology="$(get_stack_topology "${stack_dir}" || true)"
if [ -z "${stack_topology}" ]; then
printf -v "${result_var}" "%s" "Unknown (topology missing)"
return 0
fi
case "${stack_topology}" in
"single-host" | "split-services") ;;
*)
printf -v "${result_var}" "%s" "Unsupported (${stack_topology})"
return 0
;;
esac
if [ ! -f "${env_path}" ]; then
printf -v "${result_var}" "%s" "Unknown (env missing)"
return 0
fi
env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)"
if [ -z "${env_erpnext_version}" ]; then
fallback_erpnext_version="$(get_default_erpnext_version || true)"
fi
compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)"
if [ -z "${compose_files_lines}" ]; then
printf -v "${result_var}" "%s" "Unknown (compose files missing)"
return 0
fi
repo_root="$(get_easy_docker_repo_root)"
while IFS= read -r compose_file; do
if [ -z "${compose_file}" ]; then
continue
fi
source_compose_path="${repo_root}/${compose_file}"
if [ ! -f "${source_compose_path}" ]; then
printf -v "${result_var}" "%s" "Unknown (missing file: ${compose_file})"
return 0
fi
compose_args+=(-f "${source_compose_path}")
done <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
printf -v "${result_var}" "%s" "Unknown (compose files missing)"
return 0
fi
if [ -n "${fallback_erpnext_version}" ]; then
container_ids_lines="$(
ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/dev/null
)"
compose_status=$?
else
container_ids_lines="$(
docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/dev/null
)"
compose_status=$?
fi
if [ "${compose_status}" -ne 0 ]; then
printf -v "${result_var}" "%s" "Unknown (docker compose status failed)"
return 0
fi
if [ -z "${container_ids_lines}" ]; then
printf -v "${result_var}" "%s" "Not created"
return 0
fi
docker_ps_args=(-a --no-trunc --format "{{.ID}}|{{.State}}|{{.Status}}")
while IFS= read -r container_id; do
if [ -n "${container_id}" ]; then
docker_ps_args+=(--filter "id=${container_id}")
fi
done <<EOF
${container_ids_lines}
EOF
container_status_lines="$(docker ps "${docker_ps_args[@]}" 2>/dev/null)"
compose_status=$?
if [ "${compose_status}" -ne 0 ]; then
printf -v "${result_var}" "%s" "Unknown (docker ps status failed)"
return 0
fi
while IFS= read -r container_status_line; do
if [ -z "${container_status_line}" ]; then
continue
fi
total_containers_count=$((total_containers_count + 1))
IFS='|' read -r container_id container_state container_status_text <<EOF
${container_status_line}
EOF
case "${container_state}" in
running)
running_containers_count=$((running_containers_count + 1))
if [ -z "${first_running_status}" ]; then
first_running_status="${container_status_text}"
elif [ "${container_status_text}" != "${first_running_status}" ]; then
running_status_varies=1
fi
;;
exited)
exited_containers_count=$((exited_containers_count + 1))
;;
created)
created_containers_count=$((created_containers_count + 1))
;;
restarting)
restarting_containers_count=$((restarting_containers_count + 1))
;;
paused)
paused_containers_count=$((paused_containers_count + 1))
;;
dead)
dead_containers_count=$((dead_containers_count + 1))
;;
*)
other_containers_count=$((other_containers_count + 1))
;;
esac
done <<EOF
${container_status_lines}
EOF
if [ "${total_containers_count}" -eq 0 ]; then
printf -v "${result_var}" "%s" "Not created"
return 0
fi
if [ -n "${first_running_status}" ]; then
case "${first_running_status}" in
Up\ *)
running_status_excerpt="${first_running_status#Up }"
;;
*)
running_status_excerpt="${first_running_status}"
;;
esac
fi
if [ "${running_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Running (${running_containers_count}/${total_containers_count} containers"
if [ -n "${running_status_excerpt}" ]; then
status_label="${status_label}, up ${running_status_excerpt}"
if [ "${running_status_varies}" -eq 1 ]; then
status_label="${status_label}+"
fi
fi
status_label="${status_label})"
elif [ "${running_containers_count}" -gt 0 ]; then
status_label="Partial (${running_containers_count}/${total_containers_count} running"
if [ "${restarting_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${restarting_containers_count} restarting"
elif [ "${paused_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${paused_containers_count} paused"
elif [ "${exited_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${exited_containers_count} stopped"
elif [ "${created_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${created_containers_count} created"
elif [ "${dead_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${dead_containers_count} dead"
elif [ "${other_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${other_containers_count} other"
fi
if [ -n "${running_status_excerpt}" ]; then
status_label="${status_label}, up ${running_status_excerpt}"
if [ "${running_status_varies}" -eq 1 ]; then
status_label="${status_label}+"
fi
fi
status_label="${status_label})"
elif [ "${restarting_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Restarting (${total_containers_count} containers)"
elif [ "${paused_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Paused (${total_containers_count} containers)"
elif [ "${created_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Created (${total_containers_count} containers)"
else
status_label="Stopped (${total_containers_count} containers)"
fi
printf -v "${result_var}" "%s" "${status_label}"
return 0
}

View file

@ -0,0 +1,89 @@
#!/usr/bin/env bash
stop_stack_with_compose_from_metadata() {
local stack_dir="${1}"
local metadata_path=""
local env_path=""
local compose_files_lines=""
local compose_file=""
local source_compose_path=""
local env_erpnext_version=""
local fallback_erpnext_version=""
local compose_project_name=""
local stack_topology=""
local repo_root=""
local -a compose_args=()
# shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata fails.
EASY_DOCKER_COMPOSE_ERROR_DETAIL=""
metadata_path="${stack_dir}/metadata.json"
env_path="$(get_stack_env_path "${stack_dir}")"
compose_project_name="$(get_stack_compose_project_name "${stack_dir}")"
if [ ! -f "${metadata_path}" ]; then
return 41
fi
if [ ! -f "${env_path}" ]; then
return 42
fi
stack_topology="$(get_stack_topology "${stack_dir}" || true)"
if [ -z "${stack_topology}" ]; then
# shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata returns 43.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology"
return 43
fi
case "${stack_topology}" in
"single-host" | "split-services") ;;
*)
# shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata returns 44.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}"
return 44
;;
esac
env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)"
if [ -z "${env_erpnext_version}" ]; then
fallback_erpnext_version="$(get_default_erpnext_version || true)"
fi
compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)"
if [ -z "${compose_files_lines}" ]; then
return 45
fi
repo_root="$(get_easy_docker_repo_root)"
while IFS= read -r compose_file; do
if [ -z "${compose_file}" ]; then
continue
fi
source_compose_path="${repo_root}/${compose_file}"
if [ ! -f "${source_compose_path}" ]; then
# shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata returns 46.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}"
return 46
fi
compose_args+=(-f "${source_compose_path}")
done <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
return 45
fi
if [ -n "${fallback_erpnext_version}" ]; then
if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" stop; then
return 47
fi
elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" stop; then
return 47
fi
return 0
}

View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Shared flow/status constants used across sourced wizard modules.
# shellcheck disable=SC2034
readonly FLOW_CONTINUE=0
readonly FLOW_BACK_TO_MAIN=10
readonly FLOW_EXIT_APP=11
readonly FLOW_ABORT_INPUT=12
readonly FLOW_OPEN_MANAGE_STACK=13

View file

@ -0,0 +1,372 @@
#!/usr/bin/env bash
get_easy_docker_repo_root() {
local app_lib_dir=""
if [ -n "${EASY_DOCKER_REPO_ROOT_OVERRIDE:-}" ]; then
printf '%s\n' "${EASY_DOCKER_REPO_ROOT_OVERRIDE}"
return 0
fi
app_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# core.sh lives in scripts/easy-docker/lib/app/wizard/common
# so we need 6 levels up to reach repository root.
(cd "${app_lib_dir}/../../../../../.." && pwd)
}
get_easy_docker_stacks_dir() {
printf '%s/.easy-docker/stacks\n' "$(get_easy_docker_repo_root)"
}
get_current_utc_timestamp() {
date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date +"%Y-%m-%dT%H:%M:%SZ"
}
is_valid_stack_name() {
local stack_name="${1}"
if [ -z "${stack_name}" ]; then
return 1
fi
case "${stack_name}" in
*[!A-Za-z0-9._-]*)
return 1
;;
*)
return 0
;;
esac
}
create_stack_directory_with_metadata() {
local stack_dir_var="${1}"
local stack_name="${2}"
local setup_type="${3:-production}"
local frappe_branch="${4:-}"
local stacks_dir=""
local created_stack_dir=""
local metadata_path=""
local created_at=""
stacks_dir="$(get_easy_docker_stacks_dir)"
created_stack_dir="${stacks_dir}/${stack_name}"
metadata_path="${created_stack_dir}/metadata.json"
if ! mkdir -p "${stacks_dir}"; then
return 1
fi
if [ -e "${created_stack_dir}" ]; then
return 2
fi
if [ -z "${frappe_branch}" ]; then
return 1
fi
if ! mkdir -p "${created_stack_dir}"; then
return 1
fi
created_at="$(get_current_utc_timestamp)"
if ! cat >"${metadata_path}" <<EOF; then
{
"schema_version": 1,
"stack_name": "${stack_name}",
"setup_type": "${setup_type}",
"frappe_branch": "${frappe_branch}",
"created_at": "${created_at}"
}
EOF
rollback_stack_directory "${created_stack_dir}" >/dev/null 2>&1 || true
return 1
fi
printf -v "${stack_dir_var}" "%s" "${created_stack_dir}"
return 0
}
rollback_stack_directory() {
local stack_dir="${1}"
local stacks_dir=""
if [ -z "${stack_dir}" ]; then
return 1
fi
stacks_dir="$(get_easy_docker_stacks_dir)"
case "${stack_dir}" in
"${stacks_dir}"/*) ;;
*)
return 2
;;
esac
if [ ! -d "${stack_dir}" ]; then
return 0
fi
rm -rf -- "${stack_dir}"
}
get_metadata_string_field() {
local metadata_path="${1}"
local field_name="${2}"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if ! easy_docker_require_jq; then
return 1
fi
# shellcheck disable=SC2016
easy_docker_run_jq -r --arg field_name "${field_name}" '[.. | objects | .[$field_name]? | select(type == "string")][0] // empty' "${metadata_path}"
}
get_env_file_key_value() {
local env_file="${1}"
local key="${2}"
local value=""
if [ ! -f "${env_file}" ]; then
return 1
fi
value="$(
awk -v key="${key}" '
/^[[:space:]]*#/ { next }
$0 !~ /=/ { next }
{
line = $0
sub(/\r$/, "", line)
pos = index(line, "=")
if (pos == 0) {
next
}
k = substr(line, 1, pos - 1)
sub(/^[[:space:]]*export[[:space:]]+/, "", k)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", k)
if (k != key) {
next
}
v = substr(line, pos + 1)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", v)
print v
exit
}
' "${env_file}"
)"
if [ -z "${value}" ]; then
return 1
fi
case "${value}" in
\"*\")
value="${value#\"}"
value="${value%\"}"
;;
\'*\')
value="${value#\'}"
value="${value%\'}"
;;
esac
printf '%s\n' "${value}"
}
get_default_erpnext_version() {
local repo_root=""
local source_env_file=""
local value=""
if [ -n "${ERPNEXT_VERSION:-}" ]; then
printf '%s\n' "${ERPNEXT_VERSION}"
return 0
fi
repo_root="$(get_easy_docker_repo_root)"
for source_env_file in "${repo_root}/.env" "${repo_root}/example.env"; do
value="$(get_env_file_key_value "${source_env_file}" "ERPNEXT_VERSION" || true)"
if [ -n "${value}" ]; then
printf '%s\n' "${value}"
return 0
fi
done
return 1
}
get_default_frappe_branch() {
local repo_root=""
local source_env_file=""
local value=""
if [ -n "${FRAPPE_BRANCH:-}" ]; then
printf '%s\n' "${FRAPPE_BRANCH}"
return 0
fi
repo_root="$(get_easy_docker_repo_root)"
for source_env_file in "${repo_root}/.env" "${repo_root}/example.env"; do
value="$(get_env_file_key_value "${source_env_file}" "FRAPPE_BRANCH" || true)"
if [ -n "${value}" ]; then
printf '%s\n' "${value}"
return 0
fi
done
printf 'version-15\n'
return 0
}
get_stack_frappe_branch() {
local stack_dir="${1}"
local metadata_path=""
local value=""
metadata_path="${stack_dir}/metadata.json"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
value="$(get_metadata_string_field "${metadata_path}" "frappe_branch" || true)"
if [ -z "${value}" ]; then
return 1
fi
printf '%s\n' "${value}"
return 0
}
get_stack_env_path() {
local stack_dir="${1}"
local metadata_path=""
local stack_name=""
metadata_path="${stack_dir}/metadata.json"
stack_name="$(get_metadata_string_field "${metadata_path}" "stack_name" || true)"
if [ -z "${stack_name}" ]; then
stack_name="${stack_dir##*/}"
fi
printf '%s/%s.env\n' "${stack_dir}" "${stack_name}"
}
get_stack_compose_project_name() {
local stack_dir="${1}"
local metadata_path=""
local stack_name=""
local project_name=""
metadata_path="${stack_dir}/metadata.json"
stack_name="$(get_metadata_string_field "${metadata_path}" "stack_name" || true)"
if [ -z "${stack_name}" ]; then
stack_name="${stack_dir##*/}"
fi
project_name="$(
printf '%s' "${stack_name}" |
tr '[:upper:]' '[:lower:]' |
sed 's/[^a-z0-9_-]/-/g; s/--*/-/g; s/^-*//; s/-*$//'
)"
if [ -z "${project_name}" ]; then
project_name="stack"
fi
printf 'easydocker-%s\n' "${project_name}"
}
get_stack_generated_compose_path() {
local stack_dir="${1}"
printf '%s/compose.generated.yaml\n' "${stack_dir}"
}
get_stack_dir_by_name() {
local stack_name="${1}"
local stacks_dir=""
local stack_dir=""
local metadata_path=""
local candidate_name=""
stacks_dir="$(get_easy_docker_stacks_dir)"
if [ ! -d "${stacks_dir}" ]; then
return 1
fi
for stack_dir in "${stacks_dir}"/*; do
if [ ! -d "${stack_dir}" ]; then
continue
fi
metadata_path="${stack_dir}/metadata.json"
if [ ! -f "${metadata_path}" ]; then
continue
fi
candidate_name="$(get_metadata_string_field "${metadata_path}" "stack_name" || true)"
if [ "${candidate_name}" = "${stack_name}" ]; then
printf '%s\n' "${stack_dir}"
return 0
fi
done
return 1
}
get_stack_topology() {
local stack_dir="${1}"
local metadata_path=""
local topology=""
metadata_path="${stack_dir}/metadata.json"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
topology="$(get_metadata_string_field "${metadata_path}" "topology" || true)"
if [ -z "${topology}" ]; then
return 1
fi
printf '%s\n' "${topology}"
return 0
}
get_stack_setup_type() {
local stack_dir="${1}"
local metadata_path=""
local setup_type=""
metadata_path="${stack_dir}/metadata.json"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
setup_type="$(get_metadata_string_field "${metadata_path}" "setup_type" || true)"
if [ -z "${setup_type}" ]; then
return 1
fi
printf '%s\n' "${setup_type}"
return 0
}
get_metadata_compose_files_lines() {
local metadata_path="${1}"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if ! easy_docker_require_jq; then
return 1
fi
easy_docker_run_jq -r '([.. | objects | .compose_files? | select(type == "array")] | .[0] // [])[]?' "${metadata_path}"
}

View file

@ -0,0 +1,165 @@
#!/usr/bin/env bash
trim_frappe_catalog_field() {
local result_var="${1}"
local value="${2}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf -v "${result_var}" "%s" "${value}"
}
is_valid_frappe_catalog_id() {
local value="${1}"
if [ -z "${value}" ]; then
return 1
fi
case "${value}" in
*[!a-z0-9._-]*)
return 1
;;
*)
return 0
;;
esac
}
is_valid_frappe_branch_name() {
local value="${1}"
if [ -z "${value}" ]; then
return 1
fi
case "${value}" in
*[!A-Za-z0-9._/-]* | .* | *..* | */ | /*)
return 1
;;
*)
return 0
;;
esac
}
get_frappe_versions_catalog_path() {
local repo_root=""
repo_root="$(get_easy_docker_repo_root)"
printf '%s/scripts/easy-docker/config/frappe.tsv\n' "${repo_root}"
}
get_frappe_versions_catalog_entries() {
local catalog_path=""
local raw_line=""
local line=""
local version_id=""
local version_label=""
local frappe_branch=""
local extra=""
local seen_ids=","
local seen_labels=","
catalog_path="$(get_frappe_versions_catalog_path)"
if [ ! -f "${catalog_path}" ]; then
return 1
fi
while IFS= read -r raw_line || [ -n "${raw_line}" ]; do
trim_frappe_catalog_field line "${raw_line}"
if [ -z "${line}" ]; then
continue
fi
case "${line}" in
\#*)
continue
;;
esac
if [[ "${line}" == *$'\t'* ]]; then
IFS=$'\t' read -r version_id version_label frappe_branch extra <<<"${line}"
else
IFS='|' read -r version_id version_label frappe_branch extra <<<"${line}"
fi
trim_frappe_catalog_field version_id "${version_id}"
trim_frappe_catalog_field version_label "${version_label}"
trim_frappe_catalog_field frappe_branch "${frappe_branch}"
trim_frappe_catalog_field extra "${extra}"
if [ -n "${extra}" ] || [ -z "${version_id}" ] || [ -z "${version_label}" ] || [ -z "${frappe_branch}" ]; then
return 1
fi
if ! is_valid_frappe_catalog_id "${version_id}"; then
return 1
fi
if ! is_valid_frappe_branch_name "${frappe_branch}"; then
return 1
fi
case "${seen_ids}" in
*,"${version_id}",*)
return 1
;;
esac
case "${seen_labels}" in
*,"${version_label}",*)
return 1
;;
esac
seen_ids="${seen_ids}${version_id},"
seen_labels="${seen_labels}${version_label},"
printf '%s|%s|%s\n' "${version_id}" "${version_label}" "${frappe_branch}"
done <"${catalog_path}"
}
get_frappe_version_branch_by_label() {
local label_lookup="${1}"
local entry=""
local version_id=""
local version_label=""
local frappe_branch=""
while IFS= read -r entry; do
if [ -z "${entry}" ]; then
continue
fi
IFS='|' read -r version_id version_label frappe_branch <<<"${entry}"
if [ "${version_label}" = "${label_lookup}" ]; then
printf '%s\n' "${frappe_branch}"
return 0
fi
done < <(get_frappe_versions_catalog_entries)
return 1
}
get_frappe_version_label_by_branch() {
local branch_lookup="${1}"
local entry=""
local version_id=""
local version_label=""
local frappe_branch=""
while IFS= read -r entry; do
if [ -z "${entry}" ]; then
continue
fi
IFS='|' read -r version_id version_label frappe_branch <<<"${entry}"
if [ "${frappe_branch}" = "${branch_lookup}" ]; then
printf '%s\n' "${version_label}"
return 0
fi
done < <(get_frappe_versions_catalog_entries)
return 1
}

View file

@ -0,0 +1,92 @@
#!/usr/bin/env bash
json_escape_string() {
local raw_value="${1}"
raw_value="${raw_value//\\/\\\\}"
raw_value="${raw_value//\"/\\\"}"
raw_value="${raw_value//$'\n'/\\n}"
raw_value="${raw_value//$'\r'/\\r}"
raw_value="${raw_value//$'\t'/\\t}"
printf '%s' "${raw_value}"
}
build_compose_files_json_array() {
local compose_files_lines="${1}"
local line=""
local first=1
while IFS= read -r line; do
if [ -z "${line}" ]; then
continue
fi
if [ "${first}" -eq 1 ]; then
printf ' "%s"' "${line}"
first=0
else
printf ',\n "%s"' "${line}"
fi
done <<EOF
${compose_files_lines}
EOF
}
build_env_json_object() {
local env_lines="${1}"
local line=""
local key=""
local value=""
local escaped_key=""
local escaped_value=""
local first=1
printf '{'
while IFS= read -r line; do
if [ -z "${line}" ]; then
continue
fi
case "${line}" in
*=*) ;;
*)
continue
;;
esac
key="${line%%=*}"
value="${line#*=}"
escaped_key="$(json_escape_string "${key}")"
escaped_value="$(json_escape_string "${value}")"
if [ "${first}" -eq 1 ]; then
printf '\n "%s": "%s"' "${escaped_key}" "${escaped_value}"
first=0
else
printf ',\n "%s": "%s"' "${escaped_key}" "${escaped_value}"
fi
done <<EOF
${env_lines}
EOF
if [ "${first}" -eq 1 ]; then
printf '}'
else
printf '\n }'
fi
}
append_env_line() {
local existing_lines="${1}"
local key="${2}"
local value="${3}"
if [ -z "${existing_lines}" ]; then
printf '%s=%s' "${key}" "${value}"
return
fi
printf '%s\n%s=%s' "${existing_lines}" "${key}" "${value}"
}

View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
EASY_DOCKER_SITE_ERROR_DETAIL=""
EASY_DOCKER_SITE_ERROR_LOG_PATH=""
load_easy_docker_site_modules() {
local site_dir=""
site_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/site"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/metadata.sh
source "${site_dir}/metadata.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh
source "${site_dir}/bootstrap.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/backup.sh
source "${site_dir}/backup.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/apps.sh
source "${site_dir}/apps.sh"
}
load_easy_docker_site_modules

View file

@ -0,0 +1,86 @@
#!/usr/bin/env bash
load_easy_docker_site_apps_modules() {
local apps_dir=""
apps_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/apps"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/apps/lifecycle.sh
source "${apps_dir}/lifecycle.sh"
}
append_stack_installable_app_line() {
local result_var="${1}"
local existing_lines="${2:-}"
local app_name="${3:-}"
if [ -z "${app_name}" ]; then
printf -v "${result_var}" "%s" "${existing_lines}"
return 0
fi
while IFS= read -r existing_app; do
if [ "${existing_app}" = "${app_name}" ]; then
printf -v "${result_var}" "%s" "${existing_lines}"
return 0
fi
done <<EOF
${existing_lines}
EOF
if [ -z "${existing_lines}" ]; then
printf -v "${result_var}" "%s" "${app_name}"
else
printf -v "${result_var}" "%s\n%s" "${existing_lines}" "${app_name}"
fi
}
get_stack_selected_installable_apps() {
local result_var="${1}"
local stack_dir="${2}"
local metadata_path=""
local predefined_apps_csv=""
local app_name=""
local installable_app_lines=""
local deferred_erpnext=""
local ordered_app_lines=""
local -a predefined_apps=()
metadata_path="${stack_dir}/metadata.json"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
predefined_apps_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)"
if [ -z "${predefined_apps_csv}" ]; then
printf -v "${result_var}" "%s" ""
return 0
fi
IFS=',' read -r -a predefined_apps <<<"${predefined_apps_csv}"
for app_name in "${predefined_apps[@]}"; do
if [ -z "${app_name}" ] || [ "${app_name}" = "frappe" ]; then
continue
fi
if [ "${app_name}" = "erpnext" ]; then
deferred_erpnext="${app_name}"
continue
fi
append_stack_installable_app_line installable_app_lines "${installable_app_lines}" "${app_name}"
done
if [ -n "${deferred_erpnext}" ]; then
ordered_app_lines="${deferred_erpnext}"
if [ -n "${installable_app_lines}" ]; then
ordered_app_lines="${ordered_app_lines}"$'\n'"${installable_app_lines}"
fi
else
ordered_app_lines="${installable_app_lines}"
fi
printf -v "${result_var}" "%s" "${ordered_app_lines}"
return 0
}
load_easy_docker_site_apps_modules

View file

@ -0,0 +1,437 @@
#!/usr/bin/env bash
stack_site_app_lines_contain() {
local app_lines="${1:-}"
local app_name="${2:-}"
if [ -z "${app_name}" ]; then
return 1
fi
printf '%s\n' "${app_lines}" | grep -F -x -- "${app_name}" >/dev/null 2>&1
}
remove_stack_site_app_line() {
local result_var="${1}"
local existing_lines="${2:-}"
local app_name="${3:-}"
local existing_app=""
local remaining_lines=""
while IFS= read -r existing_app; do
if [ -z "${existing_app}" ] || [ "${existing_app}" = "${app_name}" ]; then
continue
fi
if [ -z "${remaining_lines}" ]; then
remaining_lines="${existing_app}"
else
remaining_lines="${remaining_lines}"$'\n'"${existing_app}"
fi
done <<EOF
${existing_lines}
EOF
printf -v "${result_var}" "%s" "${remaining_lines}"
}
get_stack_site_managed_runtime_app_lines() {
local result_var="${1}"
local stack_dir="${2}"
local site_name="${3}"
local runtime_app_lines=""
local app_name=""
local resolved_managed_app_lines=""
local runtime_status=0
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 61
fi
if runtime_app_lines="$(get_stack_site_runtime_app_names_lines "${stack_dir}" "${site_name}")"; then
:
runtime_status=0
else
runtime_status=$?
fi
if [ "${runtime_status}" -eq 54 ] || [ "${runtime_status}" -eq 52 ]; then
return "${runtime_status}"
fi
if [ -z "${runtime_app_lines}" ]; then
return 1
fi
while IFS= read -r app_name; do
if [ -z "${app_name}" ] || [ "${app_name}" = "frappe" ]; then
continue
fi
append_stack_installable_app_line resolved_managed_app_lines "${resolved_managed_app_lines}" "${app_name}"
done <<EOF
${runtime_app_lines}
EOF
printf -v "${result_var}" "%s" "${resolved_managed_app_lines}"
return 0
}
persist_stack_site_app_operation_metadata() {
local stack_dir="${1}"
local site_name="${2}"
local apps_installed_lines="${3:-}"
local last_action="${4:-manage-site-apps}"
local last_error="${5:-}"
local error_log_path="${6:-}"
local created_at=""
local updated_at=""
created_at="$(get_stack_site_created_at "${stack_dir}" || true)"
updated_at="$(get_current_utc_timestamp)"
persist_stack_site_metadata \
"${stack_dir}" \
"single-site" \
"${site_name}" \
"${apps_installed_lines}" \
"${last_action}" \
"${last_error}" \
"${error_log_path}" \
"${created_at}" \
"${updated_at}"
}
get_configured_stack_site_installable_app_lines() {
local result_var="${1}"
local stack_dir="${2}"
local site_name=""
local backend_status=0
local selected_app_lines=""
local available_app_lines=""
local installed_app_lines=""
local candidate_app=""
local resolved_installable_app_lines=""
local inspect_status=0
if ! stack_supports_single_site_management "${stack_dir}"; then
return 82
fi
site_name="$(get_stack_site_name "${stack_dir}" || true)"
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 83
fi
if stack_backend_service_is_running "${stack_dir}"; then
:
else
backend_status=$?
case "${backend_status}" in
54)
return 84
;;
52)
return 82
;;
*)
return 81
;;
esac
fi
if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then
return 84
fi
if available_app_lines="$(get_stack_runtime_available_app_lines "${stack_dir}")"; then
:
else
inspect_status=$?
case "${inspect_status}" in
54)
return 84
;;
52)
return 82
;;
*)
return 87
;;
esac
fi
if get_stack_site_managed_runtime_app_lines installed_app_lines "${stack_dir}" "${site_name}"; then
:
else
inspect_status=$?
case "${inspect_status}" in
54)
return 84
;;
52)
return 82
;;
61)
return 83
;;
*)
return 87
;;
esac
fi
while IFS= read -r candidate_app; do
if [ -z "${candidate_app}" ]; then
continue
fi
if ! stack_site_app_lines_contain "${available_app_lines}" "${candidate_app}"; then
continue
fi
if stack_site_app_lines_contain "${installed_app_lines}" "${candidate_app}"; then
continue
fi
append_stack_installable_app_line resolved_installable_app_lines "${resolved_installable_app_lines}" "${candidate_app}"
done <<EOF
${selected_app_lines}
EOF
printf -v "${result_var}" "%s" "${resolved_installable_app_lines}"
return 0
}
get_configured_stack_site_uninstallable_app_lines() {
local result_var="${1}"
local stack_dir="${2}"
local site_name=""
local backend_status=0
local resolved_installed_app_lines=""
if ! stack_supports_single_site_management "${stack_dir}"; then
return 92
fi
site_name="$(get_stack_site_name "${stack_dir}" || true)"
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 93
fi
if stack_backend_service_is_running "${stack_dir}"; then
:
else
backend_status=$?
case "${backend_status}" in
54)
return 94
;;
52)
return 92
;;
*)
return 91
;;
esac
fi
if get_stack_site_managed_runtime_app_lines resolved_installed_app_lines "${stack_dir}" "${site_name}"; then
:
else
backend_status=$?
case "${backend_status}" in
54)
return 94
;;
52)
return 92
;;
61)
return 93
;;
*)
return 97
;;
esac
fi
printf -v "${result_var}" "%s" "${resolved_installed_app_lines}"
return 0
}
install_app_on_configured_stack_site() {
local stack_dir="${1}"
local app_name="${2:-}"
local site_name=""
local installable_app_lines=""
local current_installed_app_lines=""
local updated_installed_app_lines=""
local install_command=""
local install_output=""
local command_status=0
local installable_status=0
reset_easy_docker_site_error_state
if [ -z "${app_name}" ]; then
return 86
fi
if get_configured_stack_site_installable_app_lines installable_app_lines "${stack_dir}"; then
:
else
installable_status=$?
return "${installable_status}"
fi
if [ -z "${installable_app_lines}" ]; then
return 85
fi
site_name="$(get_stack_site_name "${stack_dir}" || true)"
if ! get_stack_site_managed_runtime_app_lines current_installed_app_lines "${stack_dir}" "${site_name}"; then
current_installed_app_lines="$(get_stack_site_apps_installed_lines "${stack_dir}" || true)"
fi
if ! stack_site_app_lines_contain "${installable_app_lines}" "${app_name}"; then
return 86
fi
install_command="$(
printf "bench --site %s install-app %s" \
"$(shell_quote_site_command_arg "${site_name}")" \
"$(shell_quote_site_command_arg "${app_name}")"
)"
if run_stack_backend_bash_command_capture install_output "${stack_dir}" "${install_command}"; then
:
else
command_status=$?
EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench install-app failed for '%s'." "${app_name}")"
capture_stack_site_error_log "${stack_dir}" "site-install-app-error" "${install_output}" >/dev/null 2>&1 || true
if ! persist_stack_site_app_operation_metadata \
"${stack_dir}" \
"${site_name}" \
"${current_installed_app_lines}" \
"install-app" \
"${EASY_DOCKER_SITE_ERROR_DETAIL}" \
"${EASY_DOCKER_SITE_ERROR_LOG_PATH}"; then
return 89
fi
case "${command_status}" in
54)
return 84
;;
52)
return 82
;;
*)
return 88
;;
esac
fi
if ! get_stack_site_managed_runtime_app_lines updated_installed_app_lines "${stack_dir}" "${site_name}"; then
updated_installed_app_lines="${current_installed_app_lines}"
append_stack_installable_app_line updated_installed_app_lines "${updated_installed_app_lines}" "${app_name}"
fi
if ! persist_stack_site_app_operation_metadata \
"${stack_dir}" \
"${site_name}" \
"${updated_installed_app_lines}" \
"install-app" \
"" \
""; then
return 89
fi
return 0
}
uninstall_app_from_configured_stack_site() {
local stack_dir="${1}"
local app_name="${2:-}"
local site_name=""
local uninstallable_app_lines=""
local current_installed_app_lines=""
local updated_installed_app_lines=""
local uninstall_command=""
local uninstall_output=""
local command_status=0
local uninstallable_status=0
reset_easy_docker_site_error_state
if [ -z "${app_name}" ] || [ "${app_name}" = "frappe" ]; then
return 96
fi
if get_configured_stack_site_uninstallable_app_lines uninstallable_app_lines "${stack_dir}"; then
:
else
uninstallable_status=$?
return "${uninstallable_status}"
fi
if [ -z "${uninstallable_app_lines}" ]; then
return 95
fi
site_name="$(get_stack_site_name "${stack_dir}" || true)"
current_installed_app_lines="${uninstallable_app_lines}"
if ! stack_site_app_lines_contain "${uninstallable_app_lines}" "${app_name}"; then
return 96
fi
uninstall_command="$(
printf "bench --site %s uninstall-app %s --yes" \
"$(shell_quote_site_command_arg "${site_name}")" \
"$(shell_quote_site_command_arg "${app_name}")"
)"
if run_stack_backend_bash_command_capture uninstall_output "${stack_dir}" "${uninstall_command}"; then
:
else
command_status=$?
EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench uninstall-app failed for '%s'." "${app_name}")"
capture_stack_site_error_log "${stack_dir}" "site-uninstall-app-error" "${uninstall_output}" >/dev/null 2>&1 || true
if ! persist_stack_site_app_operation_metadata \
"${stack_dir}" \
"${site_name}" \
"${current_installed_app_lines}" \
"uninstall-app" \
"${EASY_DOCKER_SITE_ERROR_DETAIL}" \
"${EASY_DOCKER_SITE_ERROR_LOG_PATH}"; then
return 99
fi
case "${command_status}" in
54)
return 94
;;
52)
return 92
;;
*)
return 98
;;
esac
fi
if ! get_stack_site_managed_runtime_app_lines updated_installed_app_lines "${stack_dir}" "${site_name}"; then
remove_stack_site_app_line updated_installed_app_lines "${current_installed_app_lines}" "${app_name}"
fi
if ! persist_stack_site_app_operation_metadata \
"${stack_dir}" \
"${site_name}" \
"${updated_installed_app_lines}" \
"uninstall-app" \
"" \
""; then
return 99
fi
return 0
}

View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
load_easy_docker_site_backup_modules() {
local backup_dir=""
backup_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/backup"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/backup/lifecycle.sh
source "${backup_dir}/lifecycle.sh"
}
load_easy_docker_site_backup_modules

View file

@ -0,0 +1,103 @@
#!/usr/bin/env bash
backup_configured_stack_site() {
local stack_dir="${1}"
local site_name=""
local backup_command=""
local backup_output=""
local command_status=0
local created_at=""
local updated_at=""
local apps_installed_lines=""
local last_backup_at=""
local existing_last_backup_at=""
local backend_status=0
reset_easy_docker_site_error_state
if ! stack_supports_single_site_management "${stack_dir}"; then
return 72
fi
site_name="$(get_stack_site_name "${stack_dir}" || true)"
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 73
fi
if stack_backend_service_is_running "${stack_dir}"; then
:
else
backend_status=$?
case "${backend_status}" in
54)
return 74
;;
52)
return 72
;;
*)
return 71
;;
esac
fi
created_at="$(get_stack_site_created_at "${stack_dir}" || true)"
apps_installed_lines="$(get_stack_site_apps_installed_lines "${stack_dir}" || true)"
existing_last_backup_at="$(get_stack_site_last_backup_at "${stack_dir}" || true)"
backup_command="$(
printf "bench --site %s backup --with-files" \
"$(shell_quote_site_command_arg "${site_name}")"
)"
if run_stack_backend_bash_command_capture backup_output "${stack_dir}" "${backup_command}"; then
:
else
command_status=$?
EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench backup failed for '%s'." "${site_name}")"
capture_stack_site_error_log "${stack_dir}" "site-backup-error" "${backup_output}" >/dev/null 2>&1 || true
updated_at="$(get_current_utc_timestamp)"
if ! persist_stack_site_metadata \
"${stack_dir}" \
"single-site" \
"${site_name}" \
"${apps_installed_lines}" \
"backup-site" \
"${EASY_DOCKER_SITE_ERROR_DETAIL}" \
"${EASY_DOCKER_SITE_ERROR_LOG_PATH}" \
"${created_at}" \
"${updated_at}" \
"${existing_last_backup_at}"; then
return 76
fi
case "${command_status}" in
54)
return 74
;;
52)
return 72
;;
*)
return 75
;;
esac
fi
last_backup_at="$(get_current_utc_timestamp)"
updated_at="${last_backup_at}"
if ! persist_stack_site_metadata \
"${stack_dir}" \
"single-site" \
"${site_name}" \
"${apps_installed_lines}" \
"backup-site" \
"" \
"" \
"${created_at}" \
"${updated_at}" \
"${last_backup_at}"; then
return 76
fi
return 0
}

View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
load_easy_docker_site_bootstrap_modules() {
local bootstrap_dir=""
bootstrap_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/bootstrap"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/bootstrap/validation.sh
source "${bootstrap_dir}/validation.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/bootstrap/runtime.sh
source "${bootstrap_dir}/runtime.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/bootstrap/errors.sh
source "${bootstrap_dir}/errors.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/bootstrap/state.sh
source "${bootstrap_dir}/state.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh
source "${bootstrap_dir}/lifecycle.sh"
}
load_easy_docker_site_bootstrap_modules

View file

@ -0,0 +1,89 @@
#!/usr/bin/env bash
reset_easy_docker_site_error_state() {
EASY_DOCKER_SITE_ERROR_DETAIL=""
EASY_DOCKER_SITE_ERROR_LOG_PATH=""
}
build_stack_site_error_log_relative_path() {
local result_var="${1}"
local action_name="${2:-site-error}"
local raw_timestamp=""
local safe_timestamp=""
local relative_path=""
raw_timestamp="$(get_current_utc_timestamp)"
safe_timestamp="$(printf '%s' "${raw_timestamp}" | tr ':' '-')"
relative_path="$(printf 'logs/%s-%s.log' "${action_name}" "${safe_timestamp}")"
printf -v "${result_var}" "%s" "${relative_path}"
}
write_stack_site_error_log() {
local result_var="${1}"
local stack_dir="${2}"
local action_name="${3:-site-error}"
local error_output="${4:-}"
local relative_path=""
local log_dir=""
local absolute_path=""
if [ -z "${error_output}" ]; then
printf -v "${result_var}" "%s" ""
return 0
fi
build_stack_site_error_log_relative_path relative_path "${action_name}"
log_dir="${stack_dir}/logs"
absolute_path="${stack_dir}/${relative_path}"
if ! mkdir -p "${log_dir}"; then
return 1
fi
if ! printf '%s\n' "${error_output}" >"${absolute_path}"; then
return 1
fi
printf -v "${result_var}" "%s" "${relative_path}"
return 0
}
run_stack_backend_bash_command_capture() {
local result_var="${1}"
local stack_dir="${2}"
local backend_command="${3}"
local command_output=""
local command_status=0
reset_easy_docker_site_error_state
command_output="$(run_stack_backend_bash_command "${stack_dir}" "${backend_command}" 2>&1)"
command_status=$?
if [ -n "${command_output}" ]; then
printf '%s\n' "${command_output}"
fi
printf -v "${result_var}" "%s" "${command_output}"
return "${command_status}"
}
capture_stack_site_error_log() {
local stack_dir="${1}"
local action_name="${2:-site-error}"
local error_output="${3:-}"
local log_path=""
EASY_DOCKER_SITE_ERROR_LOG_PATH=""
if [ -z "${error_output}" ]; then
return 0
fi
if ! write_stack_site_error_log log_path "${stack_dir}" "${action_name}" "${error_output}"; then
EASY_DOCKER_SITE_ERROR_DETAIL="${EASY_DOCKER_SITE_ERROR_DETAIL:-Failed to write site error log.}"
return 1
fi
# shellcheck disable=SC2034 # Read by manage flow after site bootstrap failures.
EASY_DOCKER_SITE_ERROR_LOG_PATH="${log_path}"
return 0
}

View file

@ -0,0 +1,536 @@
#!/usr/bin/env bash
drop_stack_site_database() {
local stack_dir="${1}"
local site_name="${2}"
local db_password=""
local db_root_username=""
local drop_db_command=""
local drop_db_output=""
local db_drop_status=0
db_password="$(get_stack_database_root_password "${stack_dir}")"
db_root_username="$(get_stack_database_root_username "${stack_dir}" || true)"
if [ -z "${db_root_username}" ]; then
return 1
fi
drop_db_command="$(
printf "bench drop-site %s --no-backup --force --db-root-username %s --db-root-password %s" \
"$(shell_quote_site_command_arg "${site_name}")" \
"$(shell_quote_site_command_arg "${db_root_username}")" \
"$(shell_quote_site_command_arg "${db_password}")"
)"
if run_stack_backend_bash_command_capture drop_db_output "${stack_dir}" "${drop_db_command}"; then
return 0
else
db_drop_status=$?
fi
EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench drop-site failed for '%s'." "${site_name}")"
capture_stack_site_error_log "${stack_dir}" "site-delete-error" "${drop_db_output}" >/dev/null 2>&1 || true
return "${db_drop_status}"
}
remove_stack_site_directory() {
local stack_dir="${1}"
local site_name="${2}"
local remove_command=""
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 61
fi
remove_command="$(
printf "rm -rf -- sites/%s archived_sites/%s" \
"$(shell_quote_site_command_arg "${site_name}")" \
"$(shell_quote_site_command_arg "${site_name}")"
)"
if ! run_stack_backend_bash_command "${stack_dir}" "${remove_command}"; then
return 1
fi
return 0
}
cleanup_partial_stack_site() {
local stack_dir="${1}"
local site_name="${2}"
local artifact_status=0
local db_name=""
local has_site_config=1
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 61
fi
if stack_site_has_partial_artifacts "${stack_dir}" "${site_name}"; then
:
else
artifact_status=$?
case "${artifact_status}" in
61)
return 61
;;
54 | 52)
return "${artifact_status}"
;;
*)
return 0
;;
esac
fi
if stack_site_config_exists "${stack_dir}" "${site_name}"; then
:
else
artifact_status=$?
case "${artifact_status}" in
61)
return 61
;;
54 | 52)
return "${artifact_status}"
;;
*)
has_site_config=0
;;
esac
fi
if [ "${has_site_config}" -eq 1 ]; then
db_name="$(get_stack_site_database_name "${stack_dir}" "${site_name}" || true)"
if [ -z "${db_name}" ]; then
return 60
fi
fi
if [ "${has_site_config}" -eq 1 ] && ! drop_stack_site_database "${stack_dir}" "${site_name}"; then
return 60
fi
if ! remove_stack_site_directory "${stack_dir}" "${site_name}"; then
return 60
fi
if stack_site_has_partial_artifacts "${stack_dir}" "${site_name}"; then
return 60
fi
artifact_status=$?
case "${artifact_status}" in
54 | 52)
return "${artifact_status}"
;;
esac
return 0
}
delete_configured_stack_site() {
local stack_dir="${1}"
local site_name=""
local delete_status=0
if ! stack_supports_single_site_management "${stack_dir}"; then
return 52
fi
site_name="$(get_stack_site_name "${stack_dir}" || true)"
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 61
fi
if ! stack_backend_service_is_running "${stack_dir}"; then
return 51
fi
if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then
:
else
delete_status=$?
case "${delete_status}" in
54 | 52 | 61)
return "${delete_status}"
;;
*)
return 60
;;
esac
fi
if ! clear_stack_site_metadata "${stack_dir}"; then
return 58
fi
return 0
}
create_first_stack_site() {
local stack_dir="${1}"
local site_name="${2}"
local admin_password="${3}"
local create_site_command=""
local create_site_output=""
local database_id=""
local db_password=""
local db_root_username=""
local db_host="db"
local db_port="3306"
database_id="$(get_stack_database_id "${stack_dir}" || true)"
db_password="$(get_stack_database_root_password "${stack_dir}")"
db_root_username="$(get_stack_database_root_username "${stack_dir}" || true)"
if [ -z "${db_root_username}" ]; then
return 57
fi
case "${database_id}" in
mariadb)
create_site_command="$(
printf "bench new-site %s --mariadb-user-host-login-scope='%%' --admin-password %s --db-root-username %s --db-root-password %s" \
"$(shell_quote_site_command_arg "${site_name}")" \
"$(shell_quote_site_command_arg "${admin_password}")" \
"$(shell_quote_site_command_arg "${db_root_username}")" \
"$(shell_quote_site_command_arg "${db_password}")"
)"
;;
postgres)
db_port="5432"
create_site_command="$(
printf "bench new-site %s --db-type postgres --db-host %s --db-port %s --admin-password %s --db-root-username %s --db-root-password %s" \
"$(shell_quote_site_command_arg "${site_name}")" \
"$(shell_quote_site_command_arg "${db_host}")" \
"$(shell_quote_site_command_arg "${db_port}")" \
"$(shell_quote_site_command_arg "${admin_password}")" \
"$(shell_quote_site_command_arg "${db_root_username}")" \
"$(shell_quote_site_command_arg "${db_password}")"
)"
;;
*)
return 57
;;
esac
if ! run_stack_backend_bash_command_capture create_site_output "${stack_dir}" "${create_site_command}"; then
EASY_DOCKER_SITE_ERROR_DETAIL="bench new-site failed."
capture_stack_site_error_log "${stack_dir}" "site-create-error" "${create_site_output}" >/dev/null 2>&1 || true
return 55
fi
return 0
}
install_stack_apps_on_site() {
local result_var="${1}"
local stack_dir="${2}"
local site_name="${3}"
local selected_app_lines=""
local installed_app_lines=""
local app_name=""
local install_app_command=""
local install_app_output=""
local available_app_lines=""
local -a selected_apps=()
if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then
printf -v "${result_var}" "%s" ""
return 0
fi
if [ -z "${selected_app_lines}" ]; then
printf -v "${result_var}" "%s" ""
return 0
fi
available_app_lines="$(get_stack_runtime_available_app_lines "${stack_dir}" || true)"
if [ -z "${available_app_lines}" ]; then
EASY_DOCKER_SITE_ERROR_DETAIL="Could not inspect available apps in the backend image."
capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "easy-docker could not list /home/frappe/frappe-bench/apps before install-app." >/dev/null 2>&1 || true
return 63
fi
mapfile -t selected_apps <<<"${selected_app_lines}"
for app_name in "${selected_apps[@]}"; do
if [ -z "${app_name}" ]; then
continue
fi
if ! printf '%s\n' "${available_app_lines}" | grep -F -x -- "${app_name}" >/dev/null 2>&1; then
EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "Selected app '%s' is not available in the backend image." "${app_name}")"
capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "$(printf "Selected app '%s' was requested in stack metadata but is missing from /home/frappe/frappe-bench/apps.\nAvailable apps:\n%s" "${app_name}" "${available_app_lines}")" >/dev/null 2>&1 || true
if [ -n "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" ]; then
printf 'Details written to %s\n' "${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}" >&2
fi
printf -v "${result_var}" "%s" "${installed_app_lines}"
return 63
fi
install_app_command="$(
printf "bench --site %s install-app %s" \
"$(shell_quote_site_command_arg "${site_name}")" \
"$(shell_quote_site_command_arg "${app_name}")"
)"
if ! run_stack_backend_bash_command_capture install_app_output "${stack_dir}" "${install_app_command}"; then
EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench install-app failed for '%s'." "${app_name}")"
capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "${install_app_output}" >/dev/null 2>&1 || true
printf -v "${result_var}" "%s" "${installed_app_lines}"
return 56
fi
if [ -z "${installed_app_lines}" ]; then
installed_app_lines="${app_name}"
else
installed_app_lines="${installed_app_lines}"$'\n'"${app_name}"
fi
if ! persist_stack_site_metadata \
"${stack_dir}" \
"single-site" \
"${site_name}" \
"${installed_app_lines}" \
"install-apps" \
"" \
"" \
"$(get_stack_site_created_at "${stack_dir}" || true)" \
"$(get_current_utc_timestamp)"; then
return 58
fi
done
printf -v "${result_var}" "%s" "${installed_app_lines}"
return 0
}
stack_site_should_enable_developer_mode() {
local stack_dir="${1}"
local setup_type=""
setup_type="$(get_stack_setup_type "${stack_dir}" || true)"
case "${setup_type}" in
development)
return 0
;;
*)
return 1
;;
esac
}
enable_stack_site_developer_mode() {
local stack_dir="${1}"
local site_name="${2}"
local developer_mode_command=""
local developer_mode_output=""
developer_mode_command="$(
printf "bench --site %s set-config developer_mode 1 && bench --site %s clear-cache" \
"$(shell_quote_site_command_arg "${site_name}")" \
"$(shell_quote_site_command_arg "${site_name}")"
)"
if run_stack_backend_bash_command_capture developer_mode_output "${stack_dir}" "${developer_mode_command}"; then
return 0
fi
EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "Could not enable developer mode for '%s'." "${site_name}")"
capture_stack_site_error_log "${stack_dir}" "site-developer-mode-error" "${developer_mode_output}" >/dev/null 2>&1 || true
return 66
}
run_stack_site_migrate() {
local stack_dir="${1}"
local site_name="${2}"
local migrate_command=""
local migrate_output=""
migrate_command="$(
printf "bench --site %s migrate" \
"$(shell_quote_site_command_arg "${site_name}")"
)"
if ! run_stack_backend_bash_command_capture migrate_output "${stack_dir}" "${migrate_command}"; then
EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench migrate failed for '%s'." "${site_name}")"
capture_stack_site_error_log "${stack_dir}" "site-migrate-error" "${migrate_output}" >/dev/null 2>&1 || true
return 64
fi
return 0
}
bootstrap_first_stack_site() {
local stack_dir="${1}"
local site_name="${2}"
local admin_password="${3}"
local created_at=""
local updated_at=""
local installed_app_lines=""
local site_create_status=0
local developer_mode_status=0
local app_install_status=0
local site_migrate_status=0
local cleanup_status=0
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 61
fi
if ! stack_supports_single_site_management "${stack_dir}"; then
return 52
fi
if ! stack_site_bootstrap_supports_database "${stack_dir}"; then
return 57
fi
if stack_has_site_configured "${stack_dir}"; then
return 53
fi
if ! stack_backend_service_is_running "${stack_dir}"; then
return 51
fi
if ! repair_stack_site_runtime_state "${stack_dir}"; then
return $?
fi
if ! stack_database_service_is_reachable "${stack_dir}"; then
return 59
fi
created_at="$(get_current_utc_timestamp)"
updated_at="${created_at}"
if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "" "create-site" "" "" "${created_at}" "${updated_at}"; then
return 58
fi
if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then
:
else
cleanup_status=$?
case "${cleanup_status}" in
54 | 52)
return "${cleanup_status}"
;;
60)
mark_stack_site_failed "${stack_dir}" "${site_name}" "" "cleanup-partial-site" "Partial site artifacts could not be removed automatically. Manual cleanup is required." "" "" >/dev/null 2>&1 || true
return 60
;;
*)
mark_stack_site_failed "${stack_dir}" "${site_name}" "" "cleanup-partial-site" "Unexpected cleanup failure before create-site." "" "${created_at}" >/dev/null 2>&1 || true
return 60
;;
esac
fi
updated_at="${created_at}"
if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "" "create-site" "" "" "${created_at}" "${updated_at}"; then
return 58
fi
if create_first_stack_site "${stack_dir}" "${site_name}" "${admin_password}"; then
:
else
site_create_status=$?
if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then
mark_stack_site_failed "${stack_dir}" "${site_name}" "" "create-site" "bench new-site failed. Partial site data was cleaned up automatically." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true
return "${site_create_status}"
fi
cleanup_status=$?
mark_stack_site_failed "${stack_dir}" "${site_name}" "" "create-site" "bench new-site failed and partial site data could not be cleaned up automatically. Manual cleanup is required." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true
case "${cleanup_status}" in
54 | 52)
return "${cleanup_status}"
;;
*)
return 60
;;
esac
fi
if stack_site_should_enable_developer_mode "${stack_dir}"; then
if enable_stack_site_developer_mode "${stack_dir}" "${site_name}"; then
:
else
developer_mode_status=$?
if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then
mark_stack_site_failed "${stack_dir}" "${site_name}" "" "enable-developer-mode" "${EASY_DOCKER_SITE_ERROR_DETAIL:-Developer mode activation failed. Partial site data was cleaned up automatically.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true
return "${developer_mode_status}"
fi
cleanup_status=$?
mark_stack_site_failed "${stack_dir}" "${site_name}" "" "enable-developer-mode" "${EASY_DOCKER_SITE_ERROR_DETAIL:-Developer mode activation failed and partial site data could not be cleaned up automatically. Manual cleanup is required.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true
case "${cleanup_status}" in
54 | 52)
return "${cleanup_status}"
;;
*)
return 60
;;
esac
fi
fi
updated_at="$(get_current_utc_timestamp)"
if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "" "create-site" "" "" "${created_at}" "${updated_at}"; then
return 58
fi
if install_stack_apps_on_site installed_app_lines "${stack_dir}" "${site_name}"; then
:
else
app_install_status=$?
case "${app_install_status}" in
56 | 63)
if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then
mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "${EASY_DOCKER_SITE_ERROR_DETAIL:-App installation failed. Partial site data was cleaned up automatically.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true
else
cleanup_status=$?
mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "${EASY_DOCKER_SITE_ERROR_DETAIL:-App installation failed and partial site data could not be cleaned up automatically. Manual cleanup is required.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true
case "${cleanup_status}" in
54 | 52)
return "${cleanup_status}"
;;
*)
return 60
;;
esac
fi
;;
58)
return 58
;;
*)
mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "Unknown app installation failure." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true
;;
esac
return "${app_install_status}"
fi
if run_stack_site_migrate "${stack_dir}" "${site_name}"; then
:
else
site_migrate_status=$?
case "${site_migrate_status}" in
64)
mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "migrate-site" "${EASY_DOCKER_SITE_ERROR_DETAIL:-Site migration failed.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true
;;
*)
mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "migrate-site" "Unknown site migration failure." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true
;;
esac
return "${site_migrate_status}"
fi
updated_at="$(get_current_utc_timestamp)"
if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "${installed_app_lines}" "migrate-site" "" "" "${created_at}" "${updated_at}"; then
return 58
fi
return 0
}

View file

@ -0,0 +1,157 @@
#!/usr/bin/env bash
run_stack_backend_bash_command() {
local stack_dir="${1}"
local backend_command="${2}"
local wrapped_backend_command=""
local metadata_path=""
local env_path=""
local compose_files_lines=""
local compose_file=""
local source_compose_path=""
local env_erpnext_version=""
local fallback_erpnext_version=""
local compose_project_name=""
local stack_topology=""
local repo_root=""
local -a compose_args=()
metadata_path="${stack_dir}/metadata.json"
env_path="$(get_stack_env_path "${stack_dir}")"
compose_project_name="$(get_stack_compose_project_name "${stack_dir}")"
if [ ! -f "${metadata_path}" ]; then
return 54
fi
if [ ! -f "${env_path}" ]; then
return 54
fi
stack_topology="$(get_stack_topology "${stack_dir}" || true)"
if [ -z "${stack_topology}" ]; then
return 54
fi
case "${stack_topology}" in
single-host | split-services) ;;
*)
return 52
;;
esac
env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)"
if [ -z "${env_erpnext_version}" ]; then
fallback_erpnext_version="$(get_default_erpnext_version || true)"
fi
compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)"
if [ -z "${compose_files_lines}" ]; then
return 54
fi
repo_root="$(get_easy_docker_repo_root)"
while IFS= read -r compose_file; do
if [ -z "${compose_file}" ]; then
continue
fi
source_compose_path="${repo_root}/${compose_file}"
if [ ! -f "${source_compose_path}" ]; then
return 54
fi
compose_args+=(-f "${source_compose_path}")
done <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
return 54
fi
wrapped_backend_command="$(printf "cd /home/frappe/frappe-bench && %s" "${backend_command}")"
if [ -n "${fallback_erpnext_version}" ]; then
ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" exec -T backend bash -lc "${wrapped_backend_command}" </dev/null
return $?
fi
docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" exec -T backend bash -lc "${wrapped_backend_command}" </dev/null
}
stack_backend_service_is_running() {
local stack_dir="${1}"
local backend_ready_status=0
if run_stack_backend_bash_command "${stack_dir}" "true" >/dev/null 2>&1; then
return 0
fi
backend_ready_status=$?
if [ "${backend_ready_status}" -eq 54 ] || [ "${backend_ready_status}" -eq 52 ]; then
return "${backend_ready_status}"
fi
# If exec fails, the backend service is not ready for site actions yet.
return 1
}
stack_database_service_is_reachable() {
local stack_dir="${1}"
local reachability_command=""
local db_ready_status=0
IFS= read -r -d '' reachability_command <<'EOF' || true
python - <<'PY'
import json
import socket
from pathlib import Path
config_path = Path("/home/frappe/frappe-bench/sites/common_site_config.json")
with config_path.open(encoding="utf-8") as handle:
config = json.load(handle)
db_host = config.get("db_host")
db_port = int(config.get("db_port", 3306))
socket.create_connection((db_host, db_port), 5).close()
PY
EOF
if run_stack_backend_bash_command "${stack_dir}" "${reachability_command}" >/dev/null 2>&1; then
return 0
fi
db_ready_status=$?
if [ "${db_ready_status}" -eq 54 ] || [ "${db_ready_status}" -eq 52 ]; then
return "${db_ready_status}"
fi
return 1
}
get_stack_common_db_endpoint() {
local stack_dir="${1}"
local read_command=""
read_command="$(
cat <<'EOF'
python - <<'PY'
import json
from pathlib import Path
path = Path("sites/common_site_config.json")
with path.open(encoding="utf-8") as handle:
config = json.load(handle)
print(f"{config.get('db_host', '')}|{config.get('db_port', 3306)}")
PY
EOF
)"
run_stack_backend_bash_command "${stack_dir}" "${read_command}"
}
get_stack_runtime_available_app_lines() {
local stack_dir="${1}"
run_stack_backend_bash_command "${stack_dir}" "ls -1 apps"
}

View file

@ -0,0 +1,253 @@
#!/usr/bin/env bash
stack_site_exists_in_bench() {
local stack_dir="${1}"
local site_name="${2}"
local exists_command=""
local exists_status=0
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 61
fi
exists_command="$(
printf "bench list-sites | grep -F -x -- %s >/dev/null" "$(shell_quote_site_command_arg "${site_name}")"
)"
if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then
return 0
fi
exists_status=$?
if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then
return "${exists_status}"
fi
return 1
}
stack_site_directory_exists() {
local stack_dir="${1}"
local site_name="${2}"
local exists_command=""
local exists_status=0
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 61
fi
exists_command="$(
printf "test -d sites/%s" "$(shell_quote_site_command_arg "${site_name}")"
)"
if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then
return 0
fi
exists_status=$?
if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then
return "${exists_status}"
fi
return 1
}
stack_site_config_exists() {
local stack_dir="${1}"
local site_name="${2}"
local exists_command=""
local exists_status=0
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 61
fi
exists_command="$(
printf "test -f sites/%s/site_config.json" "$(shell_quote_site_command_arg "${site_name}")"
)"
if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then
return 0
fi
exists_status=$?
if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then
return "${exists_status}"
fi
return 1
}
get_stack_site_database_name() {
local stack_dir="${1}"
local site_name="${2}"
local read_command=""
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 61
fi
read_command="$(
printf "python - <<'PY'\nimport json\nfrom pathlib import Path\npath = Path('sites') / %s / 'site_config.json'\nwith path.open(encoding='utf-8') as handle:\n print(json.load(handle).get('db_name', ''))\nPY" \
"$(shell_quote_site_command_arg "${site_name}")"
)"
run_stack_backend_bash_command "${stack_dir}" "${read_command}"
}
get_stack_site_runtime_app_names_lines() {
local stack_dir="${1}"
local site_name="${2}"
local list_apps_command=""
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 61
fi
list_apps_command="$(
printf "bench --site %s list-apps | awk 'NF { print \$1 }'" \
"$(shell_quote_site_command_arg "${site_name}")"
)"
run_stack_backend_bash_command "${stack_dir}" "${list_apps_command}"
}
get_stack_site_runtime_selected_apps_lines() {
local result_var="${1}"
local stack_dir="${2}"
local site_name="${3}"
local selected_app_lines=""
local runtime_app_lines=""
local selected_app_name=""
local installed_app_lines=""
if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then
printf -v "${result_var}" "%s" ""
return 0
fi
if [ -z "${selected_app_lines}" ]; then
printf -v "${result_var}" "%s" ""
return 0
fi
runtime_app_lines="$(get_stack_site_runtime_app_names_lines "${stack_dir}" "${site_name}" || true)"
if [ -z "${runtime_app_lines}" ]; then
printf -v "${result_var}" "%s" ""
return 1
fi
while IFS= read -r selected_app_name; do
if [ -z "${selected_app_name}" ]; then
continue
fi
if ! printf '%s\n' "${runtime_app_lines}" | grep -F -x -- "${selected_app_name}" >/dev/null 2>&1; then
continue
fi
if [ -z "${installed_app_lines}" ]; then
installed_app_lines="${selected_app_name}"
else
installed_app_lines="${installed_app_lines}"$'\n'"${selected_app_name}"
fi
done <<EOF
${selected_app_lines}
EOF
printf -v "${result_var}" "%s" "${installed_app_lines}"
return 0
}
repair_stack_site_runtime_state() {
local stack_dir="${1}"
local database_id=""
local redis_id=""
local db_host=""
local db_port=""
local repair_command=""
database_id="$(get_stack_database_id "${stack_dir}" || true)"
redis_id="$(get_stack_redis_id "${stack_dir}" || true)"
case "${database_id}" in
mariadb)
db_host="db"
db_port="3306"
;;
postgres)
db_host="db"
db_port="5432"
;;
*)
return 57
;;
esac
repair_command="$(
cat <<EOF
mkdir -p sites
test -f sites/common_site_config.json || printf '{}' > sites/common_site_config.json
ls -1 apps > sites/apps.txt
bench set-config -g db_host ${db_host}
bench set-config -gp db_port ${db_port}
EOF
)"
case "${redis_id}" in
enabled)
repair_command="${repair_command}"$'\n'"bench set-config -g redis_cache redis://redis-cache:6379"
repair_command="${repair_command}"$'\n'"bench set-config -g redis_queue redis://redis-queue:6379"
repair_command="${repair_command}"$'\n'"bench set-config -g redis_socketio redis://redis-queue:6379"
;;
"" | disabled)
:
;;
*)
return 62
;;
esac
repair_command="${repair_command}"$'\n'"bench set-config -gp socketio_port 9000"
repair_command="${repair_command}"$'\n'"bench set-config -g chromium_path /usr/bin/chromium-headless-shell"
if ! run_stack_backend_bash_command "${stack_dir}" "${repair_command}"; then
return 62
fi
return 0
}
stack_site_has_partial_artifacts() {
local stack_dir="${1}"
local site_name="${2}"
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 61
fi
if stack_site_exists_in_bench "${stack_dir}" "${site_name}"; then
return 0
fi
case $? in
61)
return 61
;;
54 | 52)
return $?
;;
esac
if stack_site_directory_exists "${stack_dir}" "${site_name}"; then
return 0
fi
case $? in
61)
return 61
;;
54 | 52)
return $?
;;
esac
return 1
}

View file

@ -0,0 +1,138 @@
#!/usr/bin/env bash
is_valid_stack_site_name() {
local site_name="${1}"
if [ -z "${site_name}" ]; then
return 1
fi
case "${site_name}" in
*[!A-Za-z0-9._-]*)
return 1
;;
*)
return 0
;;
esac
}
is_safe_stack_site_cleanup_name() {
local site_name="${1}"
if ! is_valid_stack_site_name "${site_name}"; then
return 1
fi
case "${site_name}" in
"." | ".." | "/" | "")
return 1
;;
*)
return 0
;;
esac
}
shell_quote_site_command_arg() {
local raw_value="${1}"
printf "'%s'" "$(printf '%s' "${raw_value}" | sed "s/'/'\"'\"'/g")"
}
get_stack_primary_site_name_suggestion() {
local stack_dir="${1}"
local env_path=""
local site_domains=""
local primary_domain=""
env_path="$(get_stack_env_path "${stack_dir}")"
site_domains="$(get_env_file_key_value "${env_path}" "SITE_DOMAINS" || true)"
primary_domain="${site_domains%%,*}"
primary_domain="${primary_domain%% *}"
if [ -n "${primary_domain}" ]; then
printf '%s\n' "${primary_domain}"
return 0
fi
printf '%s.localhost\n' "${stack_dir##*/}"
return 0
}
get_stack_database_id() {
local stack_dir="${1}"
get_metadata_string_field "${stack_dir}/metadata.json" "database_id"
}
get_stack_redis_id() {
local stack_dir="${1}"
get_metadata_string_field "${stack_dir}/metadata.json" "redis_id"
}
get_stack_database_root_password() {
local stack_dir="${1}"
local env_path=""
local db_password=""
env_path="$(get_stack_env_path "${stack_dir}")"
db_password="$(get_env_file_key_value "${env_path}" "DB_PASSWORD" || true)"
if [ -z "${db_password}" ]; then
db_password="123"
fi
printf '%s\n' "${db_password}"
return 0
}
get_stack_database_root_username() {
local stack_dir="${1}"
local database_id=""
database_id="$(get_stack_database_id "${stack_dir}" || true)"
case "${database_id}" in
mariadb)
printf 'root\n'
return 0
;;
postgres)
printf 'postgres\n'
return 0
;;
*)
return 1
;;
esac
}
stack_site_bootstrap_supports_database() {
local stack_dir="${1}"
local database_id=""
database_id="$(get_stack_database_id "${stack_dir}" || true)"
case "${database_id}" in
mariadb | postgres)
return 0
;;
*)
return 1
;;
esac
}
stack_supports_single_site_management() {
local stack_dir="${1}"
local stack_topology=""
stack_topology="$(get_stack_topology "${stack_dir}" || true)"
case "${stack_topology}" in
single-host)
return 0
;;
*)
return 1
;;
esac
}

View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
load_easy_docker_site_metadata_modules() {
local metadata_dir=""
metadata_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/metadata"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh
source "${metadata_dir}/read.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh
source "${metadata_dir}/write.sh"
}
load_easy_docker_site_metadata_modules

View file

@ -0,0 +1,102 @@
#!/usr/bin/env bash
get_metadata_site_string_field() {
local metadata_path="${1}"
local field_name="${2}"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if ! easy_docker_require_jq; then
return 1
fi
# shellcheck disable=SC2016
easy_docker_run_jq -r --arg field_name "${field_name}" '.site[$field_name] // empty' "${metadata_path}"
}
get_metadata_site_apps_installed_lines() {
local metadata_path="${1}"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if ! easy_docker_require_jq; then
return 1
fi
easy_docker_run_jq -r '(.site.apps_installed // [])[]? | select(type == "string")' "${metadata_path}"
}
get_stack_site_name() {
local stack_dir="${1}"
get_metadata_site_string_field "${stack_dir}/metadata.json" "name"
}
get_stack_site_last_error() {
local stack_dir="${1}"
get_metadata_site_string_field "${stack_dir}/metadata.json" "last_error"
}
get_stack_site_created_at() {
local stack_dir="${1}"
get_metadata_site_string_field "${stack_dir}/metadata.json" "created_at"
}
get_stack_site_last_backup_at() {
local stack_dir="${1}"
get_metadata_site_string_field "${stack_dir}/metadata.json" "last_backup_at"
}
get_stack_site_apps_installed_lines() {
local stack_dir="${1}"
get_metadata_site_apps_installed_lines "${stack_dir}/metadata.json"
}
stack_has_site_record() {
local stack_dir="${1}"
local site_name=""
site_name="$(get_stack_site_name "${stack_dir}" || true)"
if [ -n "${site_name}" ]; then
return 0
fi
return 1
}
stack_has_site_configured() {
local stack_dir="${1}"
local site_name=""
local last_error=""
site_name="$(get_stack_site_name "${stack_dir}" || true)"
last_error="$(get_stack_site_last_error "${stack_dir}" || true)"
if [ -n "${site_name}" ] && [ -z "${last_error}" ]; then
return 0
fi
return 1
}
get_stack_site_menu_entry() {
local result_var="${1}"
local stack_dir="${2}"
local site_name=""
site_name="$(get_stack_site_name "${stack_dir}" || true)"
if [ -z "${site_name}" ]; then
return 1
fi
printf -v "${result_var}" "%s" "${site_name}"
return 0
}

View file

@ -0,0 +1,111 @@
#!/usr/bin/env bash
build_stack_site_apps_installed_json_array() {
local result_var="${1}"
local apps_installed_lines="${2:-}"
local app_name=""
local escaped_app_name=""
local entries_json=""
while IFS= read -r app_name; do
if [ -z "${app_name}" ]; then
continue
fi
escaped_app_name="$(json_escape_string "${app_name}")"
if [ -z "${entries_json}" ]; then
entries_json="$(printf ' "%s"' "${escaped_app_name}")"
else
entries_json="${entries_json}"$',\n'"$(printf ' "%s"' "${escaped_app_name}")"
fi
done <<EOF
${apps_installed_lines}
EOF
if [ -z "${entries_json}" ]; then
printf -v "${result_var}" '[\n ]'
else
printf -v "${result_var}" '[\n%s\n ]' "${entries_json}"
fi
}
build_stack_site_metadata_json_object() {
local result_var="${1}"
local site_mode="${2:-single-site}"
local site_name="${3:-}"
local apps_installed_lines="${4:-}"
local last_action="${5:-}"
local last_error="${6:-}"
local error_log_path="${7:-}"
local created_at="${8:-}"
local updated_at="${9:-}"
local last_backup_at="${10:-}"
local apps_installed_json_array=""
build_stack_site_apps_installed_json_array apps_installed_json_array "${apps_installed_lines}"
printf -v "${result_var}" '{\n "mode": "%s",\n "name": "%s",\n "apps_installed": %s,\n "last_action": "%s",\n "last_error": "%s",\n "error_log_path": "%s",\n "created_at": "%s",\n "updated_at": "%s",\n "last_backup_at": "%s"\n }' \
"$(json_escape_string "${site_mode}")" \
"$(json_escape_string "${site_name}")" \
"${apps_installed_json_array}" \
"$(json_escape_string "${last_action}")" \
"$(json_escape_string "${last_error}")" \
"$(json_escape_string "${error_log_path}")" \
"$(json_escape_string "${created_at}")" \
"$(json_escape_string "${updated_at}")" \
"$(json_escape_string "${last_backup_at}")"
}
persist_stack_site_metadata() {
local stack_dir="${1}"
local site_mode="${2:-single-site}"
local site_name="${3:-}"
local apps_installed_lines="${4:-}"
local last_action="${5:-}"
local last_error="${6:-}"
local error_log_path="${7:-}"
local created_at="${8:-}"
local updated_at="${9:-}"
local last_backup_at="${10-__KEEP_CURRENT__}"
local metadata_path=""
local site_json_object=""
metadata_path="${stack_dir}/metadata.json"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if [ "${last_backup_at}" = "__KEEP_CURRENT__" ]; then
last_backup_at="$(get_metadata_site_string_field "${metadata_path}" "last_backup_at" || true)"
fi
build_stack_site_metadata_json_object site_json_object "${site_mode}" "${site_name}" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}" "${last_backup_at}"
if ! persist_stack_metadata_top_level_object "${stack_dir}" "site" "${site_json_object}"; then
return 1
fi
return 0
}
mark_stack_site_failed() {
local stack_dir="${1}"
local site_name="${2:-}"
local apps_installed_lines="${3:-}"
local last_action="${4:-bootstrap-site}"
local last_error="${5:-Unknown site bootstrap failure}"
local error_log_path="${6:-}"
local created_at="${7:-}"
local updated_at=""
updated_at="$(get_current_utc_timestamp)"
persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}"
}
clear_stack_site_metadata() {
local stack_dir="${1}"
local updated_at=""
updated_at="$(get_current_utc_timestamp)"
persist_stack_site_metadata "${stack_dir}" "single-site" "" "" "delete-site" "" "" "" "${updated_at}" ""
}

View file

@ -0,0 +1,153 @@
#!/usr/bin/env bash
list_existing_stack_names() {
local setup_type_filter="${1:-}"
local stacks_dir=""
local stack_dir=""
local metadata_path=""
local stack_name=""
local stack_setup_type=""
stacks_dir="$(get_easy_docker_stacks_dir)"
if [ ! -d "${stacks_dir}" ]; then
return 0
fi
for stack_dir in "${stacks_dir}"/*; do
if [ ! -d "${stack_dir}" ]; then
continue
fi
metadata_path="${stack_dir}/metadata.json"
if [ ! -f "${metadata_path}" ]; then
continue
fi
stack_name="$(get_metadata_string_field "${metadata_path}" "stack_name" || true)"
if [ -z "${stack_name}" ]; then
continue
fi
stack_setup_type="$(get_metadata_string_field "${metadata_path}" "setup_type" || true)"
if [ -z "${stack_setup_type}" ]; then
stack_setup_type="production"
fi
case "${setup_type_filter}" in
"" | all) ;;
*)
if [ "${stack_setup_type}" != "${setup_type_filter}" ]; then
continue
fi
;;
esac
printf '%s\n' "${stack_name}"
done | sort
}
stack_name_in_array() {
local stack_name="${1}"
shift
local candidate=""
for candidate in "$@"; do
if [ "${candidate}" = "${stack_name}" ]; then
return 0
fi
done
return 1
}
prompt_stack_name_with_cancel() {
local result_var="${1}"
local input_value=""
local input_status=0
input_value="$(prompt_new_stack_name)"
input_status=$?
if [ "${input_status}" -ne 0 ]; then
return "${FLOW_ABORT_INPUT}"
fi
input_value="$(printf '%s' "${input_value}" | tr -d '\r\n')"
case "${input_value}" in
/cancel | /CANCEL | /Cancel)
return "${FLOW_ABORT_INPUT}"
;;
esac
printf -v "${result_var}" "%s" "${input_value}"
return "${FLOW_CONTINUE}"
}
prompt_frappe_branch_with_cancel() {
local result_var="${1}"
local stack_name="${2}"
local options_lines=""
local selection=""
local selection_status=0
local selected_label=""
local selected_branch=""
local default_branch=""
local default_label=""
local version_entry=""
local version_id=""
local version_label=""
local version_branch=""
local -a version_entries=()
mapfile -t version_entries < <(get_frappe_versions_catalog_entries || true)
for version_entry in "${version_entries[@]}"; do
IFS='|' read -r version_id version_label version_branch <<<"${version_entry}"
if [ -z "${version_id}" ] || [ -z "${version_label}" ] || [ -z "${version_branch}" ]; then
continue
fi
if [ -z "${options_lines}" ]; then
options_lines="${version_label}"
else
options_lines="$(printf '%s\n%s' "${options_lines}" "${version_label}")"
fi
done
if [ -z "${options_lines}" ]; then
show_warning_and_wait "No Frappe version profiles available in scripts/easy-docker/config/frappe.tsv." 3
return 1
fi
default_branch="$(get_default_frappe_branch || true)"
default_label="$(get_frappe_version_label_by_branch "${default_branch}" || true)"
selection="$(show_frappe_version_profile_menu "${stack_name}" "${options_lines}" "${default_label}")"
selection_status=$?
if [ "${selection_status}" -ne 0 ]; then
return "${FLOW_ABORT_INPUT}"
fi
selected_label="$(printf '%s' "${selection}" | tr -d '\r\n')"
case "${selected_label}" in
"" | "Back")
return "${FLOW_ABORT_INPUT}"
;;
esac
selected_branch="$(get_frappe_version_branch_by_label "${selected_label}" || true)"
if [ -z "${selected_branch}" ]; then
show_warning_and_wait "Could not resolve branch for selected profile: ${selected_label}" 2
return 1
fi
printf -v "${result_var}" "%s" "${selected_branch}"
return "${FLOW_CONTINUE}"
}
show_warning_and_wait() {
local message="${1}"
local seconds="${2:-1}"
show_warning_message "${message}"
sleep "${seconds}"
}

View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
load_easy_docker_wizard_env_modules() {
local wizard_dir=""
wizard_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/easy-docker/lib/app/wizard/env/validation.sh
source "${wizard_dir}/env/validation.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/env/apps.sh
source "${wizard_dir}/env/apps.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/env/update.sh
source "${wizard_dir}/env/update.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/env/collect.sh
source "${wizard_dir}/env/collect.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/env/split_services.sh
source "${wizard_dir}/env/split_services.sh"
}
load_easy_docker_wizard_env_modules

586
scripts/easy-docker/lib/app/wizard/env/apps.sh vendored Executable file
View file

@ -0,0 +1,586 @@
#!/usr/bin/env bash
csv_contains_value() {
local csv_values="${1}"
local value="${2}"
case ",${csv_values}," in
*,"${value}",*)
return 0
;;
*)
return 1
;;
esac
}
append_csv_unique() {
local result_var="${1}"
local csv_values="${2}"
local value="${3}"
local updated_csv="${csv_values}"
if [ -z "${value}" ]; then
printf -v "${result_var}" "%s" "${updated_csv}"
return 0
fi
if csv_contains_value "${updated_csv}" "${value}"; then
printf -v "${result_var}" "%s" "${updated_csv}"
return 0
fi
if [ -z "${updated_csv}" ]; then
updated_csv="${value}"
else
updated_csv="${updated_csv},${value}"
fi
printf -v "${result_var}" "%s" "${updated_csv}"
}
lines_contains_line() {
local lines="${1}"
local target_line="${2}"
local line=""
while IFS= read -r line; do
if [ -z "${line}" ]; then
continue
fi
if [ "${line}" = "${target_line}" ]; then
return 0
fi
done <<EOF
${lines}
EOF
return 1
}
append_line_unique() {
local result_var="${1}"
local lines="${2}"
local new_line="${3}"
if [ -z "${new_line}" ]; then
printf -v "${result_var}" "%s" "${lines}"
return 0
fi
if lines_contains_line "${lines}" "${new_line}"; then
printf -v "${result_var}" "%s" "${lines}"
return 0
fi
if [ -z "${lines}" ]; then
printf -v "${result_var}" "%s" "${new_line}"
else
printf -v "${result_var}" "%s\n%s" "${lines}" "${new_line}"
fi
}
build_predefined_apps_metadata_json_object() {
local result_var="${1}"
local predefined_csv="${2}"
local branch_lines="${3}"
local custom_apps_lines="${4:-}"
local app_id=""
local app_branch=""
local custom_repo=""
local custom_branch=""
local predefined_json_entries=""
local branch_json_entries=""
local custom_json_entries=""
local escaped_app_id=""
local escaped_branch=""
local escaped_repo=""
local entry_json=""
local line=""
local -a predefined_ids=()
if [ -n "${predefined_csv}" ]; then
IFS=',' read -r -a predefined_ids <<<"${predefined_csv}"
for app_id in "${predefined_ids[@]}"; do
if [ -z "${app_id}" ]; then
continue
fi
escaped_app_id="$(json_escape_string "${app_id}")"
entry_json="$(printf ' "%s"' "${escaped_app_id}")"
if [ -z "${predefined_json_entries}" ]; then
predefined_json_entries="${entry_json}"
else
predefined_json_entries="${predefined_json_entries}"$',\n'"${entry_json}"
fi
done
fi
while IFS= read -r line; do
if [ -z "${line}" ]; then
continue
fi
app_id="${line%%|*}"
app_branch="${line#*|}"
if [ -z "${app_id}" ] || [ -z "${app_branch}" ]; then
continue
fi
escaped_app_id="$(json_escape_string "${app_id}")"
escaped_branch="$(json_escape_string "${app_branch}")"
entry_json="$(printf ' "%s": "%s"' "${escaped_app_id}" "${escaped_branch}")"
if [ -z "${branch_json_entries}" ]; then
branch_json_entries="${entry_json}"
else
branch_json_entries="${branch_json_entries}"$',\n'"${entry_json}"
fi
done <<EOF
${branch_lines}
EOF
while IFS= read -r line; do
if [ -z "${line}" ]; then
continue
fi
custom_repo="${line%%|*}"
custom_branch="${line#*|}"
if [ -z "${custom_repo}" ] || [ -z "${custom_branch}" ]; then
continue
fi
escaped_repo="$(json_escape_string "${custom_repo}")"
escaped_branch="$(json_escape_string "${custom_branch}")"
entry_json="$(printf ' {\n "repo": "%s",\n "branch": "%s"\n }' "${escaped_repo}" "${escaped_branch}")"
if [ -z "${custom_json_entries}" ]; then
custom_json_entries="${entry_json}"
else
custom_json_entries="${custom_json_entries}"$',\n'"${entry_json}"
fi
done <<EOF
${custom_apps_lines}
EOF
printf -v "${result_var}" '{\n "predefined": [\n%s\n ],\n "predefined_branches": {\n%s\n },\n "custom": [\n%s\n ]\n }' "${predefined_json_entries}" "${branch_json_entries}" "${custom_json_entries}"
}
get_predefined_branch_from_lines() {
local lines="${1}"
local app_id_lookup="${2}"
local line=""
local app_id=""
local app_branch=""
while IFS= read -r line; do
if [ -z "${line}" ]; then
continue
fi
app_id="${line%%|*}"
app_branch="${line#*|}"
if [ "${app_id}" = "${app_id_lookup}" ] && [ -n "${app_branch}" ]; then
printf '%s\n' "${app_branch}"
return 0
fi
done <<EOF
${lines}
EOF
return 1
}
choose_predefined_app_branch() {
local result_var="${1}"
local stack_dir="${2}"
local app_id="${3}"
local app_label="${4}"
local repo_url="${5}"
local preferred_branch="${6:-}"
local branches_lines=""
local branch=""
local status_text=""
local selection=""
local default_hint=""
local -a branch_options=()
if ! get_predefined_app_branch_lines_by_id branches_lines "${app_id}"; then
show_warning_and_wait "No branch list configured for ${app_label} (${app_id}) in apps.tsv." 3
return 1
fi
while IFS= read -r branch; do
if [ -z "${branch}" ]; then
continue
fi
branch_options+=("${branch}")
done <<EOF
${branches_lines}
EOF
if [ "${#branch_options[@]}" -eq 0 ]; then
show_warning_and_wait "No branches available for ${app_label} (${repo_url})." 3
return 1
fi
if [ -n "${preferred_branch}" ]; then
default_hint="$(printf "Suggested default: %s" "${preferred_branch}")"
fi
render_main_screen 1 >&2
status_text="$(printf "Stack: %s\n\nSelect branch for %s (%s)\nRepo: %s\n%s" "${stack_dir##*/}" "${app_label}" "${app_id}" "${repo_url}" "${default_hint}")"
render_box_message "${status_text}" "0 2" >&2
if selection="$(
gum choose \
--height 16 \
--header "Branch selection (${app_label})" \
--cursor.foreground 63 \
--selected.foreground 45 \
"${branch_options[@]}" \
"Back to app selection"
)"; then
:
else
return 2
fi
case "${selection}" in
"Back to app selection" | "")
return 2
;;
*)
printf -v "${result_var}" "%s" "${selection}"
return 0
;;
esac
}
prompt_custom_modular_apps_data() {
local result_apps_metadata_var="${1}"
local stack_dir="${2}"
local metadata_path=""
local options_lines=""
local selected_labels_csv=""
local selection_raw=""
local selection_lines=""
local prompt_status=0
local selected_predefined_csv=""
local parsed_predefined_csv=""
local selected_label=""
local predefined_app_id=""
local predefined_app_label=""
local predefined_repo_url=""
local selected_branch=""
local preferred_branch=""
local available_branch_lines=""
local existing_branch_lines=""
local existing_custom_lines=""
local selected_branch_lines=""
local selected_app_count=0
local assembled_apps_metadata_json_object=""
local -a predefined_catalog_entries=()
local -a selected_predefined_ids=()
metadata_path="${stack_dir}/metadata.json"
if [ -f "${metadata_path}" ]; then
selected_predefined_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)"
existing_branch_lines="$(get_metadata_apps_predefined_branch_lines "${metadata_path}" || true)"
existing_custom_lines="$(get_metadata_apps_custom_lines "${metadata_path}" || true)"
fi
while true; do
options_lines=""
selected_labels_csv=""
predefined_catalog_entries=()
mapfile -t predefined_catalog_entries < <(get_predefined_apps_catalog_entries || true)
for selected_label in "${predefined_catalog_entries[@]}"; do
IFS='|' read -r predefined_app_id predefined_app_label predefined_repo_url _ _ <<<"${selected_label}"
if [ -z "${predefined_app_id}" ] || [ -z "${predefined_app_label}" ]; then
continue
fi
if [ -z "${options_lines}" ]; then
options_lines="$(printf '%s' "${predefined_app_label}")"
else
options_lines="$(printf '%s\n%s' "${options_lines}" "${predefined_app_label}")"
fi
done
if [ -n "${selected_predefined_csv}" ]; then
IFS=',' read -r -a selected_predefined_ids <<<"${selected_predefined_csv}"
for predefined_app_id in "${selected_predefined_ids[@]}"; do
if [ -z "${predefined_app_id}" ]; then
continue
fi
predefined_app_label="$(get_predefined_app_label_by_id "${predefined_app_id}" || true)"
if [ -z "${predefined_app_label}" ]; then
continue
fi
append_csv_unique selected_labels_csv "${selected_labels_csv}" "${predefined_app_label}"
done
fi
if [ -z "${options_lines}" ]; then
show_warning_and_wait "No apps available in catalog." 3
return 1
fi
if selection_raw="$(show_custom_modular_apps_multi_select "${stack_dir}" "${options_lines}" "${selected_labels_csv}")"; then
prompt_status=0
else
prompt_status=$?
fi
if [ "${prompt_status}" -ne 0 ]; then
return 2
fi
if [ -z "${selection_raw}" ]; then
show_warning_message "Select at least one app."
continue
fi
parsed_predefined_csv=""
# gum choose can return multiple values separated by newlines or commas
# depending on version/configuration. Normalize to one label per line.
selection_lines="$(printf '%s' "${selection_raw}" | tr ',' '\n')"
while IFS= read -r selected_label; do
trim_predefined_catalog_field selected_label "${selected_label}"
if [ -z "${selected_label}" ]; then
continue
fi
predefined_app_id="$(get_predefined_app_id_by_label "${selected_label}" || true)"
if [ -z "${predefined_app_id}" ]; then
continue
fi
append_csv_unique parsed_predefined_csv "${parsed_predefined_csv}" "${predefined_app_id}"
done <<EOF
${selection_lines}
EOF
selected_predefined_csv="${parsed_predefined_csv}"
if [ -z "${selected_predefined_csv}" ]; then
show_warning_message "Select at least one app."
continue
fi
selected_branch_lines=""
selected_app_count=0
IFS=',' read -r -a selected_predefined_ids <<<"${selected_predefined_csv}"
for predefined_app_id in "${selected_predefined_ids[@]}"; do
if [ -z "${predefined_app_id}" ]; then
continue
fi
predefined_app_label="$(get_predefined_app_label_by_id "${predefined_app_id}" || true)"
if [ -z "${predefined_app_label}" ]; then
predefined_app_label="${predefined_app_id}"
fi
predefined_repo_url="$(get_predefined_app_repo_by_id "${predefined_app_id}" || true)"
if [ -z "${predefined_repo_url}" ]; then
show_warning_and_wait "Missing repo URL for app '${predefined_app_id}'." 3
continue 2
fi
preferred_branch="$(get_predefined_branch_from_lines "${existing_branch_lines}" "${predefined_app_id}" || true)"
if [ -z "${preferred_branch}" ]; then
preferred_branch="$(get_stack_frappe_branch "${stack_dir}" || true)"
fi
if [ -z "${preferred_branch}" ]; then
preferred_branch="$(get_predefined_app_default_branch_by_id "${predefined_app_id}" || true)"
fi
if [ -z "${preferred_branch}" ]; then
preferred_branch="$(get_default_frappe_branch)"
fi
available_branch_lines=""
if get_predefined_app_branch_lines_by_id available_branch_lines "${predefined_app_id}"; then
if [ -n "${preferred_branch}" ] && ! lines_contains_line "${available_branch_lines}" "${preferred_branch}"; then
preferred_branch="$(get_predefined_app_default_branch_by_id "${predefined_app_id}" || true)"
fi
fi
if choose_predefined_app_branch selected_branch "${stack_dir}" "${predefined_app_id}" "${predefined_app_label}" "${predefined_repo_url}" "${preferred_branch}"; then
:
else
prompt_status=$?
if [ "${prompt_status}" -eq 2 ]; then
continue 2
fi
continue 2
fi
append_line_unique selected_branch_lines "${selected_branch_lines}" "${predefined_app_id}|${selected_branch}"
selected_app_count=$((selected_app_count + 1))
done
if [ "${selected_app_count}" -eq 0 ]; then
show_warning_message "No valid apps selected."
continue
fi
build_predefined_apps_metadata_json_object assembled_apps_metadata_json_object "${selected_predefined_csv}" "${selected_branch_lines}" "${existing_custom_lines}"
printf -v "${result_apps_metadata_var}" "%s" "${assembled_apps_metadata_json_object}"
return 0
done
}
update_stack_custom_modular_apps() {
local stack_dir="${1}"
local metadata_path=""
local apps_metadata_json_object=""
local prompt_status=0
metadata_path="${stack_dir}/metadata.json"
if [ ! -f "${metadata_path}" ]; then
return 3
fi
if prompt_custom_modular_apps_data apps_metadata_json_object "${stack_dir}"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -z "${apps_metadata_json_object}" ]; then
return 1
fi
if ! persist_stack_metadata_apps_object "${stack_dir}" "${apps_metadata_json_object}"; then
return 1
fi
if ! persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then
return 1
fi
return 0
}
prompt_selected_stack_app_branches_data() {
local result_apps_metadata_var="${1}"
local stack_dir="${2}"
local metadata_path=""
local selected_predefined_csv=""
local predefined_app_id=""
local predefined_app_label=""
local predefined_repo_url=""
local selected_branch=""
local preferred_branch=""
local available_branch_lines=""
local existing_branch_lines=""
local existing_custom_lines=""
local selected_branch_lines=""
local selected_app_count=0
local assembled_apps_metadata_json_object=""
local prompt_status=0
local -a selected_predefined_ids=()
metadata_path="${stack_dir}/metadata.json"
if [ ! -f "${metadata_path}" ]; then
return 3
fi
selected_predefined_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)"
existing_branch_lines="$(get_metadata_apps_predefined_branch_lines "${metadata_path}" || true)"
existing_custom_lines="$(get_metadata_apps_custom_lines "${metadata_path}" || true)"
if [ -z "${selected_predefined_csv}" ]; then
return 4
fi
selected_branch_lines=""
IFS=',' read -r -a selected_predefined_ids <<<"${selected_predefined_csv}"
for predefined_app_id in "${selected_predefined_ids[@]}"; do
if [ -z "${predefined_app_id}" ]; then
continue
fi
predefined_app_label="$(get_predefined_app_label_by_id "${predefined_app_id}" || true)"
if [ -z "${predefined_app_label}" ]; then
predefined_app_label="${predefined_app_id}"
fi
predefined_repo_url="$(get_predefined_app_repo_by_id "${predefined_app_id}" || true)"
if [ -z "${predefined_repo_url}" ]; then
show_warning_and_wait "Missing repo URL for app '${predefined_app_id}'." 3
return 1
fi
preferred_branch="$(get_predefined_branch_from_lines "${existing_branch_lines}" "${predefined_app_id}" || true)"
if [ -z "${preferred_branch}" ]; then
preferred_branch="$(get_stack_frappe_branch "${stack_dir}" || true)"
fi
if [ -z "${preferred_branch}" ]; then
preferred_branch="$(get_predefined_app_default_branch_by_id "${predefined_app_id}" || true)"
fi
if [ -z "${preferred_branch}" ]; then
preferred_branch="$(get_default_frappe_branch)"
fi
available_branch_lines=""
if get_predefined_app_branch_lines_by_id available_branch_lines "${predefined_app_id}"; then
if [ -n "${preferred_branch}" ] && ! lines_contains_line "${available_branch_lines}" "${preferred_branch}"; then
preferred_branch="$(get_predefined_app_default_branch_by_id "${predefined_app_id}" || true)"
fi
fi
if choose_predefined_app_branch selected_branch "${stack_dir}" "${predefined_app_id}" "${predefined_app_label}" "${predefined_repo_url}" "${preferred_branch}"; then
:
else
prompt_status=$?
if [ "${prompt_status}" -eq 2 ]; then
return 2
fi
return "${prompt_status}"
fi
append_line_unique selected_branch_lines "${selected_branch_lines}" "${predefined_app_id}|${selected_branch}"
selected_app_count=$((selected_app_count + 1))
done
if [ "${selected_app_count}" -eq 0 ]; then
return 4
fi
build_predefined_apps_metadata_json_object assembled_apps_metadata_json_object "${selected_predefined_csv}" "${selected_branch_lines}" "${existing_custom_lines}"
printf -v "${result_apps_metadata_var}" "%s" "${assembled_apps_metadata_json_object}"
return 0
}
update_stack_selected_app_branches() {
local stack_dir="${1}"
local metadata_path=""
local apps_metadata_json_object=""
local prompt_status=0
metadata_path="${stack_dir}/metadata.json"
if [ ! -f "${metadata_path}" ]; then
return 3
fi
if prompt_selected_stack_app_branches_data apps_metadata_json_object "${stack_dir}"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -z "${apps_metadata_json_object}" ]; then
return 1
fi
if ! persist_stack_metadata_apps_object "${stack_dir}" "${apps_metadata_json_object}"; then
return 1
fi
if ! persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then
return 1
fi
return 0
}

View file

@ -0,0 +1,265 @@
#!/usr/bin/env bash
collect_stack_image_and_apps_env_lines() {
local result_env_var="${1}"
local result_apps_metadata_var="${2}"
local stack_dir="${3}"
local built_env_lines=""
local custom_image_value=""
local custom_tag_value=""
local built_apps_metadata_json_object=""
local prompt_status=0
if prompt_env_value_with_validation custom_image_value "${stack_dir}" "CUSTOM_IMAGE" "Required for custom modular image mode.\nExample: ghcr.io/acme/frappe-custom\nType /back to return." "ghcr.io/acme/frappe-custom" "required" "none"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
built_env_lines="$(append_env_line "${built_env_lines}" "CUSTOM_IMAGE" "${custom_image_value}")"
if prompt_env_value_with_validation custom_tag_value "${stack_dir}" "CUSTOM_TAG" "Required for custom modular image mode.\nExample: v1.0.0\nType /back to return." "v1.0.0" "required" "none"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
built_env_lines="$(append_env_line "${built_env_lines}" "CUSTOM_TAG" "${custom_tag_value}")"
if prompt_custom_modular_apps_data built_apps_metadata_json_object "${stack_dir}"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -z "${built_apps_metadata_json_object}" ]; then
return 1
fi
printf -v "${result_env_var}" "%s" "${built_env_lines}"
printf -v "${result_apps_metadata_var}" "%s" "${built_apps_metadata_json_object}"
return 0
}
collect_single_host_env_lines() {
local result_env_var="${1}"
local result_apps_metadata_var="${2}"
local stack_dir="${3}"
local proxy_mode_id="${4}"
local database_id="${5}"
local redis_id="${6}"
local collected_single_host_env_lines=""
local collected_single_host_apps_metadata_json_object=""
local value=""
local domains_value=""
local domain_lines=""
local site_domains_value=""
local sites_rule_value=""
local nginx_proxy_hosts_value=""
local prompt_status=0
if collect_stack_image_and_apps_env_lines collected_single_host_env_lines collected_single_host_apps_metadata_json_object "${stack_dir}"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
case "${proxy_mode_id}" in
traefik-https)
if prompt_env_value_with_validation domains_value "${stack_dir}" "SITE_DOMAINS" "Required for Traefik HTTPS routing.\nUse hostnames like example.com, app.example.com, localhost, or dev.localhost.\nEnter multiple domains separated by comma or space.\nLet's Encrypt still requires a public DNS name.\nType /back to return." "erp.example.com dev.localhost" "required" "domains"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if ! parse_domains_input_to_lines domain_lines "${domains_value}"; then
show_warning_message "Could not parse SITE_DOMAINS."
return 1
fi
site_domains_value="$(domain_lines_to_csv "${domain_lines}")"
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "SITE_DOMAINS" "${site_domains_value}")"
sites_rule_value="$(domain_lines_to_sites_rule "${domain_lines}")"
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "SITES_RULE" "${sites_rule_value}")"
if prompt_env_value_with_validation value "${stack_dir}" "LETSENCRYPT_EMAIL" "Required for Let's Encrypt certificate registration.\nType /back to return." "admin@example.com" "required" "email"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "LETSENCRYPT_EMAIL" "${value}")"
if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 80.\nType /back to return." "80" "optional" "port"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "HTTP_PUBLISH_PORT" "${value}")"
fi
if prompt_env_value_with_validation value "${stack_dir}" "HTTPS_PUBLISH_PORT" "Optional. Press Enter to keep default 443.\nType /back to return." "443" "optional" "port"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "HTTPS_PUBLISH_PORT" "${value}")"
fi
;;
nginxproxy-https)
if prompt_env_value_with_validation domains_value "${stack_dir}" "SITE_DOMAINS" "Required for nginx-proxy routing.\nUse hostnames like example.com, app.example.com, localhost, or dev.localhost.\nEnter multiple domains separated by comma or space.\nLet's Encrypt still requires a public DNS name.\nType /back to return." "erp.example.com dev.localhost" "required" "domains"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if ! parse_domains_input_to_lines domain_lines "${domains_value}"; then
show_warning_message "Could not parse SITE_DOMAINS."
return 1
fi
site_domains_value="$(domain_lines_to_csv "${domain_lines}")"
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "SITE_DOMAINS" "${site_domains_value}")"
nginx_proxy_hosts_value="$(domain_lines_to_csv "${domain_lines}")"
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "NGINX_PROXY_HOSTS" "${nginx_proxy_hosts_value}")"
if prompt_env_value_with_validation value "${stack_dir}" "LETSENCRYPT_EMAIL" "Required for Let's Encrypt certificate registration.\nType /back to return." "admin@example.com" "required" "email"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "LETSENCRYPT_EMAIL" "${value}")"
if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 80.\nType /back to return." "80" "optional" "port"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "HTTP_PUBLISH_PORT" "${value}")"
fi
if prompt_env_value_with_validation value "${stack_dir}" "HTTPS_PUBLISH_PORT" "Optional. Press Enter to keep default 443.\nType /back to return." "443" "optional" "port"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "HTTPS_PUBLISH_PORT" "${value}")"
fi
;;
nginxproxy-http)
if prompt_env_value_with_validation domains_value "${stack_dir}" "SITE_DOMAINS" "Required for nginx-proxy routing.\nUse hostnames like example.com, app.example.com, localhost, or dev.localhost.\nEnter multiple domains separated by comma or space.\nType /back to return." "erp.example.com dev.localhost" "required" "domains"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if ! parse_domains_input_to_lines domain_lines "${domains_value}"; then
show_warning_message "Could not parse SITE_DOMAINS."
return 1
fi
site_domains_value="$(domain_lines_to_csv "${domain_lines}")"
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "SITE_DOMAINS" "${site_domains_value}")"
nginx_proxy_hosts_value="$(domain_lines_to_csv "${domain_lines}")"
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "NGINX_PROXY_HOSTS" "${nginx_proxy_hosts_value}")"
if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 80.\nType /back to return." "80" "optional" "port"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "HTTP_PUBLISH_PORT" "${value}")"
fi
;;
traefik-http)
if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 80.\nType /back to return." "80" "optional" "port"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "HTTP_PUBLISH_PORT" "${value}")"
fi
;;
caddy-external | no-proxy)
if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 8080 for no-proxy frontend publishing.\nType /back to return." "8080" "optional" "port"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "HTTP_PUBLISH_PORT" "${value}")"
fi
;;
*)
show_warning_and_wait "Unknown proxy mode id: ${proxy_mode_id}" 2
return 1
;;
esac
case "${database_id}" in
postgres)
if prompt_env_value_with_validation value "${stack_dir}" "DB_PASSWORD" "Required for PostgreSQL database service.\nType /back to return." "changeit" "required" "none"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "DB_PASSWORD" "${value}")"
;;
mariadb)
if prompt_env_value_with_validation value "${stack_dir}" "DB_PASSWORD" "Optional but recommended for MariaDB.\nPress Enter to use default from override.\nType /back to return." "changeit" "optional" "none"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "DB_PASSWORD" "${value}")"
fi
;;
*)
show_warning_and_wait "Unknown database id: ${database_id}" 2
return 1
;;
esac
case "${redis_id}" in
enabled)
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "REDIS_CACHE" "redis-cache:6379")"
collected_single_host_env_lines="$(append_env_line "${collected_single_host_env_lines}" "REDIS_QUEUE" "redis-queue:6379")"
;;
disabled | "")
:
;;
*)
show_warning_and_wait "Unknown Redis id: ${redis_id}" 2
return 1
;;
esac
printf -v "${result_env_var}" "%s" "${collected_single_host_env_lines}"
printf -v "${result_apps_metadata_var}" "%s" "${collected_single_host_apps_metadata_json_object}"
return 0
}

View file

@ -0,0 +1,319 @@
#!/usr/bin/env bash
collect_split_services_env_lines() {
local result_env_var="${1}"
local result_apps_metadata_var="${2}"
local stack_dir="${3}"
local proxy_mode_id="${4}"
local data_mode_id="${5}"
local database_id="${6}"
local redis_id="${7}"
local collected_split_services_env_lines=""
local collected_split_services_apps_metadata_json_object=""
local value=""
local domains_value=""
local domain_lines=""
local site_domains_value=""
local sites_rule_value=""
local nginx_proxy_hosts_value=""
local db_port=""
local prompt_status=0
if collect_stack_image_and_apps_env_lines collected_split_services_env_lines collected_split_services_apps_metadata_json_object "${stack_dir}"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
case "${proxy_mode_id}" in
traefik-https)
if prompt_env_value_with_validation domains_value "${stack_dir}" "SITE_DOMAINS" "Required for Traefik HTTPS routing.\nUse hostnames like example.com, app.example.com, localhost, or dev.localhost.\nEnter multiple domains separated by comma or space.\nLet's Encrypt still requires a public DNS name.\nType /back to return." "erp.example.com dev.localhost" "required" "domains"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if ! parse_domains_input_to_lines domain_lines "${domains_value}"; then
show_warning_message "Could not parse SITE_DOMAINS."
return 1
fi
site_domains_value="$(domain_lines_to_csv "${domain_lines}")"
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "SITE_DOMAINS" "${site_domains_value}")"
sites_rule_value="$(domain_lines_to_sites_rule "${domain_lines}")"
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "SITES_RULE" "${sites_rule_value}")"
if prompt_env_value_with_validation value "${stack_dir}" "LETSENCRYPT_EMAIL" "Required for Let's Encrypt certificate registration.\nType /back to return." "admin@example.com" "required" "email"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "LETSENCRYPT_EMAIL" "${value}")"
if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 80.\nType /back to return." "80" "optional" "port"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "HTTP_PUBLISH_PORT" "${value}")"
fi
if prompt_env_value_with_validation value "${stack_dir}" "HTTPS_PUBLISH_PORT" "Optional. Press Enter to keep default 443.\nType /back to return." "443" "optional" "port"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "HTTPS_PUBLISH_PORT" "${value}")"
fi
;;
nginxproxy-https)
if prompt_env_value_with_validation domains_value "${stack_dir}" "SITE_DOMAINS" "Required for nginx-proxy routing.\nUse hostnames like example.com, app.example.com, localhost, or dev.localhost.\nEnter multiple domains separated by comma or space.\nLet's Encrypt still requires a public DNS name.\nType /back to return." "erp.example.com dev.localhost" "required" "domains"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if ! parse_domains_input_to_lines domain_lines "${domains_value}"; then
show_warning_message "Could not parse SITE_DOMAINS."
return 1
fi
site_domains_value="$(domain_lines_to_csv "${domain_lines}")"
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "SITE_DOMAINS" "${site_domains_value}")"
nginx_proxy_hosts_value="$(domain_lines_to_csv "${domain_lines}")"
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "NGINX_PROXY_HOSTS" "${nginx_proxy_hosts_value}")"
if prompt_env_value_with_validation value "${stack_dir}" "LETSENCRYPT_EMAIL" "Required for Let's Encrypt certificate registration.\nType /back to return." "admin@example.com" "required" "email"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "LETSENCRYPT_EMAIL" "${value}")"
if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 80.\nType /back to return." "80" "optional" "port"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "HTTP_PUBLISH_PORT" "${value}")"
fi
if prompt_env_value_with_validation value "${stack_dir}" "HTTPS_PUBLISH_PORT" "Optional. Press Enter to keep default 443.\nType /back to return." "443" "optional" "port"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "HTTPS_PUBLISH_PORT" "${value}")"
fi
;;
nginxproxy-http)
if prompt_env_value_with_validation domains_value "${stack_dir}" "SITE_DOMAINS" "Required for nginx-proxy routing.\nUse hostnames like example.com, app.example.com, localhost, or dev.localhost.\nEnter multiple domains separated by comma or space.\nType /back to return." "erp.example.com dev.localhost" "required" "domains"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if ! parse_domains_input_to_lines domain_lines "${domains_value}"; then
show_warning_message "Could not parse SITE_DOMAINS."
return 1
fi
site_domains_value="$(domain_lines_to_csv "${domain_lines}")"
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "SITE_DOMAINS" "${site_domains_value}")"
nginx_proxy_hosts_value="$(domain_lines_to_csv "${domain_lines}")"
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "NGINX_PROXY_HOSTS" "${nginx_proxy_hosts_value}")"
if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 80.\nType /back to return." "80" "optional" "port"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "HTTP_PUBLISH_PORT" "${value}")"
fi
;;
traefik-http)
if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 80.\nType /back to return." "80" "optional" "port"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "HTTP_PUBLISH_PORT" "${value}")"
fi
;;
caddy-external | no-proxy)
if prompt_env_value_with_validation value "${stack_dir}" "HTTP_PUBLISH_PORT" "Optional. Press Enter to keep default 8080 for no-proxy frontend publishing.\nType /back to return." "8080" "optional" "port"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "HTTP_PUBLISH_PORT" "${value}")"
fi
;;
*)
show_warning_and_wait "Unknown proxy mode id: ${proxy_mode_id}" 2
return 1
;;
esac
case "${data_mode_id}" in
managed)
case "${database_id}" in
postgres)
if prompt_env_value_with_validation value "${stack_dir}" "DB_PASSWORD" "Required for PostgreSQL database service.\nType /back to return." "changeit" "required" "none"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "DB_PASSWORD" "${value}")"
;;
mariadb)
if prompt_env_value_with_validation value "${stack_dir}" "DB_PASSWORD" "Optional but recommended for MariaDB.\nPress Enter to use default from override.\nType /back to return." "changeit" "optional" "none"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
if [ -n "${value}" ]; then
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "DB_PASSWORD" "${value}")"
fi
;;
*)
show_warning_and_wait "Unknown database id: ${database_id}" 2
return 1
;;
esac
case "${redis_id}" in
managed)
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_CACHE" "redis-cache:6379")"
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_QUEUE" "redis-queue:6379")"
;;
external)
if prompt_env_value_with_validation value "${stack_dir}" "REDIS_CACHE" "Required for external Redis cache.\nUse host:port such as redis.example.internal:6379.\nType /back to return." "redis.example.internal:6379" "required" "hostport"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_CACHE" "${value}")"
if prompt_env_value_with_validation value "${stack_dir}" "REDIS_QUEUE" "Required for external Redis queue.\nUse host:port such as redis.example.internal:6379.\nType /back to return." "redis.example.internal:6379" "required" "hostport"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_QUEUE" "${value}")"
;;
disabled | "")
:
;;
*)
show_warning_and_wait "Unknown Redis id: ${redis_id}" 2
return 1
;;
esac
;;
external)
case "${database_id}" in
postgres)
db_port="5432"
;;
mariadb)
db_port="3306"
;;
*)
show_warning_and_wait "Unknown database id: ${database_id}" 2
return 1
;;
esac
if prompt_env_value_with_validation value "${stack_dir}" "DB_HOST" "Required for external database.\nUse a hostname or IP address.\nType /back to return." "db.example.internal" "required" "host"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "DB_HOST" "${value}")"
if prompt_env_value_with_validation value "${stack_dir}" "DB_PORT" "Required for external database.\nPress Enter to keep the default port.\nType /back to return." "${db_port}" "required" "port"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "DB_PORT" "${value}")"
if prompt_env_value_with_validation value "${stack_dir}" "DB_PASSWORD" "Required for external database access.\nType /back to return." "changeit" "required" "none"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "DB_PASSWORD" "${value}")"
case "${redis_id}" in
managed)
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_CACHE" "redis-cache:6379")"
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_QUEUE" "redis-queue:6379")"
;;
external)
if prompt_env_value_with_validation value "${stack_dir}" "REDIS_CACHE" "Required for external Redis cache.\nUse host:port such as redis.example.internal:6379.\nType /back to return." "redis.example.internal:6379" "required" "hostport"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_CACHE" "${value}")"
if prompt_env_value_with_validation value "${stack_dir}" "REDIS_QUEUE" "Required for external Redis queue.\nUse host:port such as redis.example.internal:6379.\nType /back to return." "redis.example.internal:6379" "required" "hostport"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
collected_split_services_env_lines="$(append_env_line "${collected_split_services_env_lines}" "REDIS_QUEUE" "${value}")"
;;
disabled | "")
:
;;
*)
show_warning_and_wait "Unknown Redis id: ${redis_id}" 2
return 1
;;
esac
;;
*)
show_warning_and_wait "Unknown data mode id: ${data_mode_id}" 2
return 1
;;
esac
printf -v "${result_env_var}" "%s" "${collected_split_services_env_lines}"
printf -v "${result_apps_metadata_var}" "%s" "${collected_split_services_apps_metadata_json_object}"
return 0
}

View file

@ -0,0 +1,146 @@
#!/usr/bin/env bash
is_valid_docker_image_tag() {
local value="${1}"
if [ -z "${value}" ] || [ "${#value}" -gt 128 ]; then
return 1
fi
case "${value}" in
.* | -*)
return 1
;;
*[!A-Za-z0-9_.-]*)
return 1
;;
esac
return 0
}
get_stack_custom_image_name() {
local stack_dir="${1}"
local env_path=""
env_path="$(get_stack_env_path "${stack_dir}")"
get_env_file_key_value "${env_path}" "CUSTOM_IMAGE"
}
get_stack_custom_image_tag() {
local stack_dir="${1}"
local env_path=""
env_path="$(get_stack_env_path "${stack_dir}")"
get_env_file_key_value "${env_path}" "CUSTOM_TAG"
}
get_stack_custom_image_ref() {
local stack_dir="${1}"
local custom_image=""
local custom_tag=""
custom_image="$(get_stack_custom_image_name "${stack_dir}" || true)"
custom_tag="$(get_stack_custom_image_tag "${stack_dir}" || true)"
if [ -z "${custom_image}" ] || [ -z "${custom_tag}" ]; then
return 1
fi
printf '%s:%s\n' "${custom_image}" "${custom_tag}"
}
persist_env_file_key_value() {
local env_path="${1}"
local key="${2}"
local value="${3}"
local tmp_path=""
if [ ! -f "${env_path}" ]; then
return 1
fi
tmp_path="${env_path}.tmp"
if ! awk -v key="${key}" -v value="${value}" '
BEGIN {
updated = 0
}
{
line = $0
sub(/\r$/, "", line)
if (line ~ "^[[:space:]]*(export[[:space:]]+)?" key "[[:space:]]*=") {
print key "=" value
updated = 1
next
}
print line
}
END {
if (!updated) {
print key "=" value
}
}
' "${env_path}" >"${tmp_path}"; then
rm -f -- "${tmp_path}" >/dev/null 2>&1 || true
return 1
fi
if ! mv -- "${tmp_path}" "${env_path}"; then
rm -f -- "${tmp_path}" >/dev/null 2>&1 || true
return 1
fi
return 0
}
set_stack_custom_image_tag() {
local stack_dir="${1}"
local custom_tag="${2:-}"
local env_path=""
local custom_image=""
env_path="$(get_stack_env_path "${stack_dir}")"
if [ ! -f "${env_path}" ]; then
return 31
fi
if ! is_valid_docker_image_tag "${custom_tag}"; then
return 32
fi
custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)"
if [ -z "${custom_image}" ]; then
return 33
fi
if ! persist_env_file_key_value "${env_path}" "CUSTOM_TAG" "${custom_tag}"; then
return 34
fi
return 0
}
prompt_stack_custom_image_tag_with_cancel() {
local result_var="${1}"
local stack_dir="${2}"
local current_image=""
local current_tag=""
local guidance_text=""
local custom_tag=""
local prompt_status=0
current_image="$(get_stack_custom_image_name "${stack_dir}" || true)"
current_tag="$(get_stack_custom_image_tag "${stack_dir}" || true)"
guidance_text="$(printf "Current custom image: %s\nCurrent custom tag: %s\n\nEnter the next CUSTOM_TAG for the rebuilt image.\nExample: v1.4.3 or 2026-04-02-appupdate.\nType /back to return." "${current_image:-n/a}" "${current_tag:-n/a}")"
if prompt_env_value_with_validation custom_tag "${stack_dir}" "CUSTOM_TAG" "${guidance_text}" "${current_tag}" "required" "image_tag"; then
:
else
prompt_status=$?
return "${prompt_status}"
fi
printf -v "${result_var}" "%s" "${custom_tag}"
return 0
}

View file

@ -0,0 +1,522 @@
#!/usr/bin/env bash
is_valid_email_address() {
local value="${1}"
case "${value}" in
*@*.*)
return 0
;;
*)
return 1
;;
esac
}
is_valid_port_number() {
local value="${1}"
if ! [[ "${value}" =~ ^[0-9]+$ ]]; then
return 1
fi
if [ "${value}" -lt 1 ] || [ "${value}" -gt 65535 ]; then
return 1
fi
return 0
}
is_valid_host_value() {
local value="${1}"
if [ -z "${value}" ] || [ "${#value}" -gt 253 ]; then
return 1
fi
if printf '%s' "${value}" | grep -Eq '[[:space:]/,:;?!@#]'; then
return 1
fi
case "${value}" in
.* | *. | *..*)
return 1
;;
esac
case "${value}" in
[A-Za-z0-9]*) ;;
*)
return 1
;;
esac
case "${value}" in
*[A-Za-z0-9]) ;;
*)
return 1
;;
esac
return 0
}
is_valid_host_port_value() {
local value="${1}"
local host=""
local port=""
if [ -z "${value}" ]; then
return 1
fi
case "${value}" in
*:*)
host="${value%:*}"
port="${value##*:}"
;;
*)
return 1
;;
esac
if [ -z "${host}" ] || [ -z "${port}" ]; then
return 1
fi
if ! is_valid_host_value "${host}"; then
return 1
fi
if ! is_valid_port_number "${port}"; then
return 1
fi
return 0
}
EASY_DOCKER_LAST_INVALID_DOMAIN=""
reset_domain_validation_feedback() {
EASY_DOCKER_LAST_INVALID_DOMAIN=""
}
trim_domain_token() {
local result_var="${1}"
local value="${2}"
while true; do
case "${value}" in
' '*)
value="${value# }"
;;
*' ')
value="${value% }"
;;
$'\t'*)
value="${value#$'\t'}"
;;
*$'\t')
value="${value%$'\t'}"
;;
*)
break
;;
esac
done
printf -v "${result_var}" "%s" "${value}"
return 0
}
normalize_domain_token() {
local result_var="${1}"
local raw_token="${2}"
local token="${raw_token}"
trim_domain_token token "${token}"
if [ -z "${token}" ]; then
return 1
fi
while true; do
case "${token}" in
\"*\")
token="${token#\"}"
token="${token%\"}"
;;
\'*\')
token="${token#\'}"
token="${token%\'}"
;;
\`*\`)
token="${token#\`}"
token="${token%\`}"
;;
\[*\])
token="${token#\[}"
token="${token%\]}"
;;
\(*\))
token="${token#(}"
token="${token%)}"
;;
\"*)
token="${token#\"}"
;;
\'*)
token="${token#\'}"
;;
\`*)
token="${token#\`}"
;;
\[*)
token="${token#\[}"
;;
\(*)
token="${token#(}"
;;
*)
break
;;
esac
done
while true; do
case "${token}" in
*\")
token="${token%\"}"
;;
*\')
token="${token%\'}"
;;
*\`)
token="${token%\`}"
;;
*\])
token="${token%\]}"
;;
*\))
token="${token%)}"
;;
*)
break
;;
esac
done
trim_domain_token token "${token}"
if [ -z "${token}" ]; then
return 1
fi
printf -v "${result_var}" "%s" "${token}"
return 0
}
is_valid_domain_name() {
local domain="${1}"
local normalized_domain=""
local label=""
local tld=""
local last_index=0
local -a labels=()
if ! normalize_domain_token normalized_domain "${domain}"; then
return 1
fi
case "${normalized_domain}" in
*[[:space:],:/?#!@]* | *";"* | .* | *. | *..* | *\**)
return 1
;;
esac
if [ "${normalized_domain}" = "localhost" ]; then
return 0
fi
if [ "${#normalized_domain}" -lt 5 ] || [ "${#normalized_domain}" -gt 253 ]; then
return 1
fi
local IFS='.'
read -r -a labels <<<"${normalized_domain}"
if [ "${#labels[@]}" -lt 2 ]; then
return 1
fi
for label in "${labels[@]}"; do
if [ -z "${label}" ]; then
return 1
fi
if [ "${#label}" -gt 63 ]; then
return 1
fi
case "${label}" in
[A-Za-z0-9]*) ;;
*)
return 1
;;
esac
case "${label}" in
*[A-Za-z0-9]) ;;
*)
return 1
;;
esac
case "${label}" in
*[!A-Za-z0-9-]*)
return 1
;;
esac
done
last_index=$((${#labels[@]} - 1))
tld="${labels[last_index]}"
if [ "${tld}" = "localhost" ]; then
return 0
fi
if ! [[ "${tld}" =~ ^[A-Za-z]{2,63}$ ]]; then
return 1
fi
return 0
}
parse_domains_input_to_lines() {
local result_var="${1}"
local raw_value="${2}"
local sanitized_value=""
local token=""
local normalized_token=""
local parsed_domain_lines=""
local -a tokens=()
local IFS=$' \t\n'
reset_domain_validation_feedback
sanitized_value="${raw_value//$'\r'/ }"
sanitized_value="${sanitized_value//$'\n'/ }"
sanitized_value="${sanitized_value//$'\t'/ }"
sanitized_value="${sanitized_value//,/ }"
sanitized_value="${sanitized_value//;/ }"
read -r -a tokens <<<"${sanitized_value}"
if [ "${#tokens[@]}" -eq 0 ]; then
EASY_DOCKER_LAST_INVALID_DOMAIN="${raw_value}"
return 1
fi
for token in "${tokens[@]}"; do
if ! normalize_domain_token normalized_token "${token}"; then
EASY_DOCKER_LAST_INVALID_DOMAIN="${token}"
return 1
fi
if ! is_valid_domain_name "${normalized_token}"; then
EASY_DOCKER_LAST_INVALID_DOMAIN="${normalized_token}"
return 1
fi
if [ -z "${parsed_domain_lines}" ]; then
parsed_domain_lines="${normalized_token}"
else
parsed_domain_lines="${parsed_domain_lines}"$'\n'"${normalized_token}"
fi
done
if [ -z "${parsed_domain_lines}" ]; then
return 1
fi
printf -v "${result_var}" "%s" "${parsed_domain_lines}"
return 0
}
domain_lines_to_csv() {
local domain_lines="${1}"
local domain=""
local csv_value=""
while IFS= read -r domain; do
if [ -z "${domain}" ]; then
continue
fi
if [ -z "${csv_value}" ]; then
csv_value="${domain}"
else
csv_value="${csv_value},${domain}"
fi
done <<EOF
${domain_lines}
EOF
printf '%s' "${csv_value}"
}
domain_lines_to_sites_rule() {
local domain_lines="${1}"
local domain=""
local sites_rule=""
local rule_part=""
while IFS= read -r domain; do
if [ -z "${domain}" ]; then
continue
fi
rule_part="$(printf "Host(\`%s\`)" "${domain}")"
if [ -z "${sites_rule}" ]; then
sites_rule="${rule_part}"
else
sites_rule="${sites_rule} || ${rule_part}"
fi
done <<EOF
${domain_lines}
EOF
printf '%s' "${sites_rule}"
}
is_valid_domain_list_value() {
local value="${1}"
local domain_lines=""
if ! parse_domains_input_to_lines domain_lines "${value}"; then
return 1
fi
if [ -z "${domain_lines}" ]; then
return 1
fi
return 0
}
is_valid_domains_value() {
local value="${1}"
if ! is_valid_domain_list_value "${value}"; then
return 1
fi
return 0
}
is_valid_image_tag_value() {
local value="${1}"
if ! is_valid_docker_image_tag "${value}"; then
return 1
fi
return 0
}
prompt_env_value_with_validation() {
local result_var="${1}"
local stack_dir="${2}"
local variable_name="${3}"
local guidance_text="${4}"
local placeholder="${5}"
local required_mode="${6}"
local validation_kind="${7}"
local input_value=""
local normalized_value=""
local invalid_domain_input=""
local validation_feedback=""
local prompt_status=0
local is_first_prompt=1
while true; do
input_value="$(prompt_single_host_env_value "${stack_dir}" "${variable_name}" "${guidance_text}" "${placeholder}" "${is_first_prompt}" "${validation_feedback}")"
prompt_status=$?
is_first_prompt=0
validation_feedback=""
if [ "${prompt_status}" -ne 0 ]; then
return 2
fi
normalized_value="$(printf '%s' "${input_value}" | tr -d '\r\n')"
case "${normalized_value}" in
/back | /BACK | /Back | /cancel | /CANCEL | /Cancel)
return 2
;;
esac
if [ -z "${normalized_value}" ]; then
if [ "${required_mode}" = "required" ]; then
validation_feedback="Value required for ${variable_name}."
continue
fi
printf -v "${result_var}" "%s" ""
return 0
fi
case "${validation_kind}" in
email)
if ! is_valid_email_address "${normalized_value}"; then
validation_feedback="Invalid email format for ${variable_name}."
continue
fi
;;
port)
if ! is_valid_port_number "${normalized_value}"; then
validation_feedback="Invalid port for ${variable_name}. Use 1-65535."
continue
fi
;;
host)
if ! is_valid_host_value "${normalized_value}"; then
validation_feedback="Invalid host for ${variable_name}. Use a hostname or IP address without spaces."
continue
fi
;;
hostport)
if ! is_valid_host_port_value "${normalized_value}"; then
validation_feedback="Invalid endpoint for ${variable_name}. Use host:port, for example redis.example.internal:6379."
continue
fi
;;
domains)
if ! is_valid_domains_value "${normalized_value}"; then
invalid_domain_input="${EASY_DOCKER_LAST_INVALID_DOMAIN}"
if [ -z "${invalid_domain_input}" ]; then
invalid_domain_input="${normalized_value}"
fi
validation_feedback="Domain '${invalid_domain_input}' cannot be used for ${variable_name}. Use a hostname like example.com, app.example.com, localhost, or dev.localhost."
continue
fi
;;
nginx_hosts)
if ! is_valid_domains_value "${normalized_value}"; then
validation_feedback="Invalid ${variable_name}. Use domains separated by comma or space."
continue
fi
;;
image_tag)
if ! is_valid_image_tag_value "${normalized_value}"; then
validation_feedback="Invalid image tag for ${variable_name}. Use letters, numbers, dots, dashes, or underscores."
continue
fi
;;
none | "") ;;
*)
show_warning_message "Unknown validation rule: ${validation_kind}"
return 1
;;
esac
printf -v "${result_var}" "%s" "${normalized_value}"
return 0
done
}

View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
load_easy_docker_wizard_flow_modules() {
local wizard_dir=""
wizard_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/easy-docker/lib/app/wizard/flows/single_host.sh
source "${wizard_dir}/flows/single_host.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/flows/split_services.sh
source "${wizard_dir}/flows/split_services.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/flows/manage.sh
source "${wizard_dir}/flows/manage.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/flows/navigation.sh
source "${wizard_dir}/flows/navigation.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/flows/tools.sh
source "${wizard_dir}/flows/tools.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/flows/setup.sh
source "${wizard_dir}/flows/setup.sh"
}
load_easy_docker_wizard_flow_modules

View file

@ -0,0 +1,17 @@
#!/usr/bin/env bash
load_easy_docker_manage_flow_modules() {
local manage_dir=""
manage_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/manage"
# shellcheck source=scripts/easy-docker/lib/app/wizard/flows/manage/build.sh
source "${manage_dir}/build.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/flows/manage/prompts.sh
source "${manage_dir}/prompts.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/flows/manage/site.sh
source "${manage_dir}/site.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh
source "${manage_dir}/stack.sh"
}
load_easy_docker_manage_flow_modules

View file

@ -0,0 +1,78 @@
#!/usr/bin/env bash
refresh_stack_generated_compose_with_feedback() {
local stack_dir="${1}"
local generated_compose_path=""
local render_compose_status=0
generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")"
if render_stack_compose_from_metadata "${stack_dir}"; then
show_warning_and_wait "Generated compose refreshed successfully: ${generated_compose_path}" 3
return 0
fi
render_compose_status=$?
show_warning_and_wait "The image build succeeded, but generated compose could not be refreshed (${render_compose_status}) for ${generated_compose_path}." 4
return "${render_compose_status}"
}
run_build_stack_custom_image_with_feedback() {
local stack_name="${1}"
local stack_dir="${2}"
local build_image_status=0
show_warning_message "Starting docker build for stack: ${stack_name}"
if build_stack_custom_image "${stack_dir}"; then
show_warning_and_wait "Custom image build finished successfully for stack: ${stack_name}" 3
refresh_stack_generated_compose_with_feedback "${stack_dir}" || true
return 0
else
build_image_status=$?
fi
case "${build_image_status}" in
11)
show_warning_and_wait "Custom image build failed: missing metadata.json in ${stack_dir}." 4
;;
12)
show_warning_and_wait "Custom image build failed: stack env file not found in ${stack_dir}." 4
;;
13)
show_warning_and_wait "Custom image build failed: CUSTOM_IMAGE is missing in stack env file." 4
;;
14)
show_warning_and_wait "Custom image build failed: CUSTOM_TAG is missing in stack env file." 4
;;
15)
show_warning_and_wait "Custom image build failed: frappe_branch missing in metadata.json." 4
;;
16)
show_warning_and_wait "Custom image build failed: could not generate apps.json from metadata app selection." 4
;;
17)
show_warning_and_wait "Custom image build failed: apps.json not found after generation." 4
;;
20)
show_warning_and_wait "Custom image build failed: images/layered/Containerfile not found." 4
;;
21)
show_warning_and_wait "Custom image build failed: docker build returned an error. Check the output above." 4
;;
22)
show_warning_and_wait "Custom image build failed: git is required for app branch precheck (git ls-remote)." 4
;;
23)
show_warning_and_wait "Custom image build failed: could not parse app entries from apps.json." 4
;;
24)
show_warning_and_wait "Custom image build failed: app branch precheck failed -> ${EASY_DOCKER_BUILD_ERROR_DETAIL}" 6
;;
25)
show_warning_and_wait "Custom image build failed: jq is required for stack metadata and apps.json processing." 4
;;
*)
show_warning_and_wait "Custom image build failed (${build_image_status})." 4
;;
esac
return "${build_image_status}"
}

View file

@ -0,0 +1,95 @@
#!/usr/bin/env bash
prompt_manage_stack_site_name_with_cancel() {
local result_var="${1}"
local stack_name="${2}"
local stack_dir="${3}"
local input_site_name=""
local suggestion=""
local prompt_status=0
suggestion="$(get_stack_primary_site_name_suggestion "${stack_dir}" || true)"
while true; do
input_site_name="$(prompt_stack_site_name "${stack_name}" "${suggestion}")"
prompt_status=$?
if [ "${prompt_status}" -ne 0 ]; then
return "${FLOW_ABORT_INPUT}"
fi
input_site_name="$(printf '%s' "${input_site_name}" | tr -d '\r\n')"
case "${input_site_name}" in
"")
show_warning_and_wait "Site name is required." 2
;;
/back | /Back | /BACK)
return "${FLOW_ABORT_INPUT}"
;;
*)
if ! is_valid_stack_site_name "${input_site_name}"; then
show_warning_and_wait "Site name may only contain letters, numbers, dots, dashes, and underscores." 3
continue
fi
printf -v "${result_var}" "%s" "${input_site_name}"
return "${FLOW_CONTINUE}"
;;
esac
done
}
prompt_manage_stack_site_admin_password_with_cancel() {
local result_var="${1}"
local stack_name="${2}"
local input_admin_password=""
local prompt_status=0
while true; do
input_admin_password="$(prompt_stack_site_admin_password "${stack_name}")"
prompt_status=$?
if [ "${prompt_status}" -ne 0 ]; then
return "${FLOW_ABORT_INPUT}"
fi
input_admin_password="$(printf '%s' "${input_admin_password}" | tr -d '\r\n')"
case "${input_admin_password}" in
"")
show_warning_and_wait "Administrator password is required." 2
;;
/back | /Back | /BACK)
return "${FLOW_ABORT_INPUT}"
;;
*)
printf -v "${result_var}" "%s" "${input_admin_password}"
return "${FLOW_CONTINUE}"
;;
esac
done
}
prompt_manage_stack_delete_keyword_with_cancel() {
local result_var="${1}"
local stack_name="${2}"
local delete_confirmation=""
local prompt_status=0
while true; do
delete_confirmation="$(prompt_manage_stack_delete_keyword "${stack_name}")"
prompt_status=$?
if [ "${prompt_status}" -ne 0 ]; then
return "${FLOW_ABORT_INPUT}"
fi
delete_confirmation="$(printf '%s' "${delete_confirmation}" | tr -d '\r\n')"
case "${delete_confirmation}" in
/back | /Back | /BACK | "")
return "${FLOW_ABORT_INPUT}"
;;
delete)
printf -v "${result_var}" "%s" "${delete_confirmation}"
return "${FLOW_CONTINUE}"
;;
*)
show_warning_and_wait "Type exactly delete to confirm stack removal." 3
;;
esac
done
}

View file

@ -0,0 +1,569 @@
#!/usr/bin/env bash
migrate_configured_stack_site() {
local stack_dir="${1}"
local site_name=""
local current_site_apps_lines=""
local created_at=""
local updated_at=""
local backend_status=0
reset_easy_docker_site_error_state
if ! stack_supports_single_site_management "${stack_dir}"; then
return 52
fi
site_name="$(get_stack_site_name "${stack_dir}" || true)"
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 53
fi
if stack_backend_service_is_running "${stack_dir}"; then
:
else
backend_status=$?
case "${backend_status}" in
54)
return 54
;;
52)
return 52
;;
*)
return 51
;;
esac
fi
if ! run_stack_site_migrate "${stack_dir}" "${site_name}"; then
return $?
fi
current_site_apps_lines="$(get_stack_site_apps_installed_lines "${stack_dir}" || true)"
created_at="$(get_stack_site_created_at "${stack_dir}" || true)"
updated_at="$(get_current_utc_timestamp)"
if ! persist_stack_site_metadata \
"${stack_dir}" \
"single-site" \
"${site_name}" \
"${current_site_apps_lines}" \
"migrate-site" \
"" \
"" \
"${created_at}" \
"${updated_at}"; then
return 65
fi
return 0
}
handle_manage_stack_site_flow() {
local stack_name="${1}"
local stack_dir="${2}"
local site_action=""
local site_name=""
local admin_password=""
local site_flow_status=0
local existing_site_entry=""
local existing_site_name=""
local existing_site_created_at=""
local existing_site_apps_lines=""
local existing_site_apps_csv=""
local existing_site_last_backup_at=""
local existing_site_details_action=""
local existing_site_apps_action=""
local existing_site_migrate_confirm=""
local existing_site_app_lines=""
local existing_site_app_selection=""
local existing_site_app_confirmation=""
local site_delete_confirmation=""
while true; do
existing_site_entry=""
get_stack_site_menu_entry existing_site_entry "${stack_dir}" || true
site_action="$(show_manage_stack_site_menu "${stack_name}" "${stack_dir}" "${existing_site_entry}" || true)"
case "${site_action}" in
"Create new site")
if ! prompt_manage_stack_site_name_with_cancel site_name "${stack_name}" "${stack_dir}"; then
continue
fi
if ! prompt_manage_stack_site_admin_password_with_cancel admin_password "${stack_name}"; then
continue
fi
show_warning_message "Creating the first site for stack: ${stack_name}"
if bootstrap_first_stack_site "${stack_dir}" "${site_name}" "${admin_password}"; then
show_warning_and_wait "Site created successfully, selected stack apps were installed, and bench migrate completed: ${site_name}" 3
continue
else
site_flow_status=$?
fi
case "${site_flow_status}" in
51)
show_warning_and_wait "Cannot manage site: backend service is not running yet. Start the stack first." 4
;;
52)
show_warning_and_wait "Cannot manage site for this topology yet. Only single-host stacks are supported." 4
;;
53)
show_warning_and_wait "A site is already configured for this stack. Phase 1 supports one site per stack." 4
;;
54)
show_warning_and_wait "Cannot manage site because stack metadata, env, or compose inputs are incomplete." 4
;;
55)
show_warning_and_wait "Could not create the site. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above for bench new-site details.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6
;;
56)
show_warning_and_wait "The site was created, but app installation failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6
;;
57)
show_warning_and_wait "Site bootstrap currently supports only supported single-host database stacks." 4
;;
58)
show_warning_and_wait "The site metadata could not be written to metadata.json." 4
;;
59)
show_warning_and_wait "Cannot create site: stack services are not ready yet. Wait and try again." 4
;;
60)
show_warning_and_wait "Site creation failed and automatic cleanup could not remove all partial data. Manual cleanup is required." 5
;;
61)
show_warning_and_wait "Cannot create site because the site name was empty or unsafe for cleanup operations." 4
;;
62)
show_warning_and_wait "Cannot prepare the stack for site creation because the bench runtime files could not be repaired." 4
;;
63)
show_warning_and_wait "Cannot install the selected stack apps because at least one app is missing from the backend image. ${EASY_DOCKER_SITE_ERROR_DETAIL} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 7
;;
64)
show_warning_and_wait "The site was created and apps were installed, but bench migrate failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 7
;;
66)
show_warning_and_wait "Site bootstrap failed while enabling developer mode for this development stack. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 7
;;
*)
show_warning_and_wait "Site bootstrap failed (${site_flow_status})." 4
;;
esac
;;
"Back" | "")
return "${FLOW_CONTINUE}"
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
if [ -n "${existing_site_entry}" ] && [ "${site_action}" = "${existing_site_entry}" ]; then
existing_site_name="$(get_stack_site_name "${stack_dir}" || true)"
existing_site_created_at="$(get_stack_site_created_at "${stack_dir}" || true)"
existing_site_apps_lines="$(get_stack_site_apps_installed_lines "${stack_dir}" || true)"
existing_site_last_backup_at="$(get_stack_site_last_backup_at "${stack_dir}" || true)"
if stack_backend_service_is_running "${stack_dir}" >/dev/null 2>&1; then
get_stack_site_managed_runtime_app_lines existing_site_apps_lines "${stack_dir}" "${existing_site_name}" || true
fi
if [ -n "${existing_site_apps_lines}" ]; then
existing_site_apps_csv="$(printf '%s' "${existing_site_apps_lines}" | tr '\n' ',' | sed 's/,$//')"
else
existing_site_apps_csv="None"
fi
existing_site_details_action="$(
show_manage_stack_site_details \
"${stack_name}" \
"${stack_dir}" \
"${existing_site_name}" \
"${existing_site_created_at}" \
"${existing_site_apps_csv}" \
"${existing_site_last_backup_at}" || true
)"
case "${existing_site_details_action}" in
"Manage apps on this site")
while true; do
existing_site_apps_action="$(
show_manage_stack_site_apps_menu \
"${stack_name}" \
"${stack_dir}" \
"${existing_site_name}" || true
)"
case "${existing_site_apps_action}" in
"Install app on this site")
if ! get_configured_stack_site_installable_app_lines existing_site_app_lines "${stack_dir}"; then
site_flow_status=$?
case "${site_flow_status}" in
81)
show_warning_and_wait "Cannot install app: backend service is not running yet. Start the stack first." 4
;;
82)
show_warning_and_wait "Cannot install app for this topology yet. Only single-host stacks are supported." 4
;;
83)
show_warning_and_wait "Cannot install app because no configured site was found in metadata.json." 4
;;
84)
show_warning_and_wait "Cannot install app because stack metadata, env, or compose inputs are incomplete." 4
;;
87)
show_warning_and_wait "Cannot inspect installable apps on this site right now. Check backend readiness and try again." 4
;;
*)
show_warning_and_wait "Could not prepare installable site apps (${site_flow_status})." 4
;;
esac
continue
fi
if [ -z "${existing_site_app_lines}" ]; then
show_warning_and_wait "No additional selected stack apps are available to install on this site. No further apps are currently available in this stack environment." 4
continue
fi
existing_site_app_selection="$(
show_manage_stack_site_app_selection \
"${stack_name}" \
"${stack_dir}" \
"${existing_site_name}" \
"Install app on this site" \
"${existing_site_app_lines}" || true
)"
case "${existing_site_app_selection}" in
"Back" | "")
continue
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_message "Installing app on site: ${existing_site_app_selection}"
if install_app_on_configured_stack_site "${stack_dir}" "${existing_site_app_selection}"; then
show_warning_and_wait "App installed successfully on site ${existing_site_name}: ${existing_site_app_selection}" 4
continue
fi
site_flow_status=$?
case "${site_flow_status}" in
81)
show_warning_and_wait "Cannot install app: backend service is not running yet. Start the stack first." 4
;;
82)
show_warning_and_wait "Cannot install app for this topology yet. Only single-host stacks are supported." 4
;;
83)
show_warning_and_wait "Cannot install app because no configured site was found in metadata.json." 4
;;
84)
show_warning_and_wait "Cannot install app because stack metadata, env, or compose inputs are incomplete." 4
;;
85)
show_warning_and_wait "No additional selected stack apps are available to install on this site. No further apps are currently available in this stack environment." 4
;;
86)
show_warning_and_wait "The selected app is not currently installable on this site." 4
;;
87)
show_warning_and_wait "Cannot inspect current site apps right now. Check backend readiness and try again." 4
;;
88)
show_warning_and_wait "App installation failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6
;;
89)
show_warning_and_wait "The app command finished, but site metadata could not be written to metadata.json." 5
;;
*)
show_warning_and_wait "App installation failed (${site_flow_status})." 4
;;
esac
continue
;;
esac
;;
"Uninstall app from this site")
if ! get_configured_stack_site_uninstallable_app_lines existing_site_app_lines "${stack_dir}"; then
site_flow_status=$?
case "${site_flow_status}" in
91)
show_warning_and_wait "Cannot uninstall app: backend service is not running yet. Start the stack first." 4
;;
92)
show_warning_and_wait "Cannot uninstall app for this topology yet. Only single-host stacks are supported." 4
;;
93)
show_warning_and_wait "Cannot uninstall app because no configured site was found in metadata.json." 4
;;
94)
show_warning_and_wait "Cannot uninstall app because stack metadata, env, or compose inputs are incomplete." 4
;;
97)
show_warning_and_wait "Cannot inspect uninstallable apps on this site right now. Check backend readiness and try again." 4
;;
*)
show_warning_and_wait "Could not prepare uninstallable site apps (${site_flow_status})." 4
;;
esac
continue
fi
if [ -z "${existing_site_app_lines}" ]; then
show_warning_and_wait "No installed site apps are currently available for uninstall." 4
continue
fi
existing_site_app_selection="$(
show_manage_stack_site_app_selection \
"${stack_name}" \
"${stack_dir}" \
"${existing_site_name}" \
"Uninstall app from this site" \
"${existing_site_app_lines}" || true
)"
case "${existing_site_app_selection}" in
"Back" | "")
continue
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
existing_site_app_confirmation="$(
show_manage_stack_site_app_uninstall_confirmation \
"${stack_name}" \
"${stack_dir}" \
"${existing_site_name}" \
"${existing_site_app_selection}" || true
)"
case "${existing_site_app_confirmation}" in
"No" | "")
continue
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
"Yes")
show_warning_message "Uninstalling app from site: ${existing_site_app_selection}"
if uninstall_app_from_configured_stack_site "${stack_dir}" "${existing_site_app_selection}"; then
show_warning_and_wait "App uninstalled successfully from site ${existing_site_name}: ${existing_site_app_selection}" 4
continue
fi
site_flow_status=$?
case "${site_flow_status}" in
91)
show_warning_and_wait "Cannot uninstall app: backend service is not running yet. Start the stack first." 4
;;
92)
show_warning_and_wait "Cannot uninstall app for this topology yet. Only single-host stacks are supported." 4
;;
93)
show_warning_and_wait "Cannot uninstall app because no configured site was found in metadata.json." 4
;;
94)
show_warning_and_wait "Cannot uninstall app because stack metadata, env, or compose inputs are incomplete." 4
;;
95)
show_warning_and_wait "No installed site apps are currently available for uninstall." 4
;;
96)
show_warning_and_wait "The selected app cannot be uninstalled here. frappe stays blocked, but erpnext is allowed." 4
;;
97)
show_warning_and_wait "Cannot inspect current site apps right now. Check backend readiness and try again." 4
;;
98)
show_warning_and_wait "App uninstall failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6
;;
99)
show_warning_and_wait "The app command finished, but site metadata could not be written to metadata.json." 5
;;
*)
show_warning_and_wait "App uninstall failed (${site_flow_status})." 4
;;
esac
continue
;;
*)
show_warning_and_wait "Unknown uninstall app confirmation action: ${existing_site_app_confirmation}" 2
continue
;;
esac
;;
esac
;;
"Back" | "")
break
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown site apps action: ${existing_site_apps_action}" 2
continue
;;
esac
done
continue
;;
"Migrate site now")
existing_site_migrate_confirm="$(
show_manage_stack_site_migrate_confirmation \
"${stack_name}" \
"${stack_dir}" \
"${existing_site_name}" || true
)"
case "${existing_site_migrate_confirm}" in
"Yes")
show_warning_message "Migrating site for stack: ${stack_name}"
if migrate_configured_stack_site "${stack_dir}"; then
show_warning_and_wait "Site migrated successfully: ${existing_site_name}" 3
continue
fi
site_flow_status=$?
case "${site_flow_status}" in
51)
show_warning_and_wait "Cannot migrate site: backend service is not running yet. Start the stack first." 4
;;
52)
show_warning_and_wait "Cannot migrate site for this topology yet. Only single-host stacks are supported." 4
;;
53)
show_warning_and_wait "Cannot migrate site because the configured site name was empty or unsafe for cleanup operations." 4
;;
54)
show_warning_and_wait "Cannot migrate site because stack metadata, env, or compose inputs are incomplete." 4
;;
64)
show_warning_and_wait "Site migration failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6
;;
65)
show_warning_and_wait "The migrate command finished, but the site metadata could not be written to metadata.json." 5
;;
*)
show_warning_and_wait "Site migration failed (${site_flow_status})." 4
;;
esac
continue
;;
"No" | "")
continue
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown site migrate confirmation action: ${existing_site_migrate_confirm}" 2
continue
;;
esac
;;
"Backup site now")
show_warning_message "Creating backup for site: ${existing_site_name}"
if backup_configured_stack_site "${stack_dir}"; then
existing_site_last_backup_at="$(get_stack_site_last_backup_at "${stack_dir}" || true)"
show_warning_and_wait "Site backup completed successfully: ${existing_site_name}${existing_site_last_backup_at:+ (last backup at ${existing_site_last_backup_at})}" 4
continue
fi
site_flow_status=$?
case "${site_flow_status}" in
71)
show_warning_and_wait "Cannot back up site: backend service is not running yet. Start the stack first." 4
;;
72)
show_warning_and_wait "Cannot back up site for this topology yet. Only single-host stacks are supported." 4
;;
73)
show_warning_and_wait "Cannot back up site because no configured site was found in metadata.json." 4
;;
74)
show_warning_and_wait "Cannot back up site because stack metadata, env, or compose inputs are incomplete." 4
;;
75)
show_warning_and_wait "Site backup failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6
;;
76)
show_warning_and_wait "The backup command finished, but the backup metadata could not be written to metadata.json." 5
;;
*)
show_warning_and_wait "Site backup failed (${site_flow_status})." 4
;;
esac
continue
;;
"Delete site")
site_delete_confirmation="$(
show_manage_stack_site_delete_confirmation \
"${stack_name}" \
"${stack_dir}" \
"${existing_site_name}" || true
)"
case "${site_delete_confirmation}" in
"Yes")
show_warning_message "Deleting site for stack: ${stack_name}"
if delete_configured_stack_site "${stack_dir}"; then
show_warning_and_wait "Site deleted successfully with its database: ${existing_site_name}" 3
continue
else
site_flow_status=$?
fi
case "${site_flow_status}" in
51)
show_warning_and_wait "Cannot delete site: backend service is not running yet. Start the stack first." 4
;;
52)
show_warning_and_wait "Cannot delete site for this topology yet. Only single-host stacks are supported." 4
;;
54)
show_warning_and_wait "Cannot delete site because stack metadata, env, or compose inputs are incomplete." 4
;;
58)
show_warning_and_wait "The cleared site metadata could not be written to metadata.json." 4
;;
60)
show_warning_and_wait "Site deletion could not remove all site or database data automatically. Manual cleanup is required." 5
;;
61)
show_warning_and_wait "Cannot delete site because the configured site name was empty or unsafe for cleanup operations." 4
;;
*)
show_warning_and_wait "Site deletion failed (${site_flow_status})." 4
;;
esac
continue
;;
"No" | "")
continue
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown site delete confirmation action: ${site_delete_confirmation}" 2
continue
;;
esac
;;
"Back" | "")
continue
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown site details action: ${existing_site_details_action}" 2
;;
esac
continue
fi
show_warning_and_wait "Unknown site action: ${site_action}" 2
;;
esac
done
}

View file

@ -0,0 +1,451 @@
#!/usr/bin/env bash
handle_manage_selected_stack_flow() {
local stack_name="${1}"
local stack_dir=""
local stack_action=""
local apps_action=""
local updates_action=""
local stack_metadata_path=""
local stack_apps_path=""
local stack_env_path=""
local custom_apps_update_status=0
local compose_start_status=0
local stack_runtime_status=""
local stack_frappe_branch=""
local stack_custom_image_ref=""
local stack_custom_tag=""
local custom_tag_prompt_status=0
local custom_tag_update_status=0
local missing_custom_image_action=""
local delete_stack_confirmation_action=""
local delete_stack_keyword=""
stack_dir="$(get_stack_dir_by_name "${stack_name}" || true)"
if [ -z "${stack_dir}" ]; then
show_warning_and_wait "Could not resolve stack directory for '${stack_name}'." 2
return "${FLOW_CONTINUE}"
fi
while true; do
get_stack_compose_runtime_status_label stack_runtime_status "${stack_dir}"
stack_frappe_branch="$(get_stack_frappe_branch "${stack_dir}" || true)"
stack_custom_image_ref="$(get_stack_custom_image_ref "${stack_dir}" || true)"
stack_action="$(show_manage_stack_actions_menu "${stack_name}" "${stack_dir}" "${stack_runtime_status}" || true)"
case "${stack_action}" in
"Apps")
while true; do
apps_action="$(show_manage_stack_apps_menu "${stack_name}" "${stack_dir}" "${stack_frappe_branch}" || true)"
case "${apps_action}" in
"Select apps and branches")
if update_stack_custom_modular_apps "${stack_dir}"; then
:
else
custom_apps_update_status=$?
case "${custom_apps_update_status}" in
2 | 130)
continue
;;
3)
stack_metadata_path="${stack_dir}/metadata.json"
show_warning_and_wait "Cannot update app selection because metadata is missing: ${stack_metadata_path}" 3
continue
;;
*)
show_warning_and_wait "Could not update app selection (${custom_apps_update_status}) for stack: ${stack_name}" 3
continue
;;
esac
fi
stack_apps_path="${stack_dir}/apps.json"
show_warning_and_wait "App selection updated in ${stack_dir}/metadata.json and ${stack_apps_path}." 3
;;
"Back" | "")
break
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown apps action: ${apps_action}"
;;
esac
done
;;
"Updates")
while true; do
stack_custom_image_ref="$(get_stack_custom_image_ref "${stack_dir}" || true)"
updates_action="$(show_manage_stack_updates_menu "${stack_name}" "${stack_dir}" "${stack_frappe_branch}" "${stack_custom_image_ref}" || true)"
case "${updates_action}" in
"Update selected app branches")
if update_stack_selected_app_branches "${stack_dir}"; then
stack_apps_path="${stack_dir}/apps.json"
show_warning_and_wait "Selected app branches updated in ${stack_dir}/metadata.json and ${stack_apps_path}. Set the next custom image tag, build the updated image, restart the stack, and migrate the site to apply the update." 6
else
custom_apps_update_status=$?
case "${custom_apps_update_status}" in
2 | 130)
continue
;;
3)
stack_metadata_path="${stack_dir}/metadata.json"
show_warning_and_wait "Cannot update selected app branches because metadata is missing: ${stack_metadata_path}" 3
continue
;;
4)
show_warning_and_wait "No selected stack apps were found for branch updates. Select apps for the stack first." 4
continue
;;
*)
show_warning_and_wait "Could not update selected app branches (${custom_apps_update_status}) for stack: ${stack_name}" 3
continue
;;
esac
fi
;;
"Set next custom image tag")
stack_env_path="$(get_stack_env_path "${stack_dir}")"
if [ ! -f "${stack_env_path}" ]; then
show_warning_and_wait "Cannot update CUSTOM_TAG because the stack env file is missing: ${stack_env_path}" 4
continue
fi
if [ -z "$(get_stack_custom_image_name "${stack_dir}" || true)" ]; then
show_warning_and_wait "Cannot update CUSTOM_TAG because CUSTOM_IMAGE is missing in the stack env file." 4
continue
fi
if prompt_stack_custom_image_tag_with_cancel stack_custom_tag "${stack_dir}"; then
:
else
custom_tag_prompt_status=$?
case "${custom_tag_prompt_status}" in
2 | 130)
continue
;;
*)
show_warning_and_wait "Could not collect the next custom image tag (${custom_tag_prompt_status})." 3
continue
;;
esac
fi
if set_stack_custom_image_tag "${stack_dir}" "${stack_custom_tag}"; then
stack_custom_image_ref="$(get_stack_custom_image_ref "${stack_dir}" || true)"
show_warning_message "Custom image tag updated successfully: ${stack_custom_image_ref:-${stack_custom_tag}}"
if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then
:
else
continue
fi
else
custom_tag_update_status=$?
case "${custom_tag_update_status}" in
31)
show_warning_and_wait "Cannot update CUSTOM_TAG because the stack env file is missing: ${stack_env_path}" 4
;;
32)
show_warning_and_wait "Cannot update CUSTOM_TAG because the value is not a valid Docker image tag." 4
;;
33)
show_warning_and_wait "Cannot update CUSTOM_TAG because CUSTOM_IMAGE is missing in the stack env file." 4
;;
34)
show_warning_and_wait "Could not write the updated CUSTOM_TAG to ${stack_env_path}." 4
;;
*)
show_warning_and_wait "Could not update CUSTOM_TAG (${custom_tag_update_status})." 4
;;
esac
fi
;;
"Build updated image")
if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then
:
else
continue
fi
;;
"Back" | "")
break
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown update action: ${updates_action}"
;;
esac
done
;;
"Start stack in Docker Compose")
while true; do
show_warning_message "Starting stack with docker compose: ${stack_name}"
if start_stack_with_compose_from_metadata "${stack_dir}"; then
show_warning_and_wait "Stack started successfully with docker compose: ${stack_name}" 3
break
else
compose_start_status=$?
fi
case "${compose_start_status}" in
31)
show_warning_and_wait "Cannot start stack: metadata.json is missing in ${stack_dir}." 4
break
;;
32)
show_warning_and_wait "Cannot start stack: stack env file not found in ${stack_dir}." 4
break
;;
33)
show_warning_and_wait "Cannot start stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4
break
;;
34)
show_warning_and_wait "Cannot start stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5
break
;;
35)
show_warning_and_wait "Cannot start stack: no compose files configured in metadata.json." 4
break
;;
36)
show_warning_and_wait "Cannot start stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4
break
;;
37)
show_warning_and_wait "docker compose up failed. Check the output above for details." 4
break
;;
38)
missing_custom_image_action="$(
show_missing_custom_image_start_menu "${stack_name}" "${stack_dir}" "${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" || true
)"
case "${missing_custom_image_action}" in
"Build custom image now")
if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then
continue
fi
break
;;
"Back" | "")
break
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown missing-image action: ${missing_custom_image_action}" 2
break
;;
esac
;;
39)
show_warning_and_wait "Cannot inspect custom image before start. Check Docker and try again. Details: ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5
break
;;
*)
show_warning_and_wait "Cannot start stack with docker compose (${compose_start_status})." 4
break
;;
esac
done
;;
"Restart stack in Docker Compose")
while true; do
show_warning_message "Restarting stack with docker compose: ${stack_name}"
if restart_stack_with_compose_from_metadata "${stack_dir}"; then
show_warning_and_wait "Stack restarted successfully with docker compose: ${stack_name}" 3
break
else
compose_start_status=$?
fi
case "${compose_start_status}" in
57)
show_warning_and_wait "Cannot restart stack: metadata.json is missing in ${stack_dir}." 4
break
;;
58)
show_warning_and_wait "Cannot restart stack: stack env file not found in ${stack_dir}." 4
break
;;
59)
show_warning_and_wait "Cannot restart stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4
break
;;
60)
show_warning_and_wait "Cannot restart stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5
break
;;
61)
show_warning_and_wait "Cannot restart stack: no compose files configured in metadata.json." 4
break
;;
62)
show_warning_and_wait "Cannot restart stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4
break
;;
63)
show_warning_and_wait "docker compose restart failed. Check the output above for details." 4
break
;;
64)
missing_custom_image_action="$(
show_missing_custom_image_start_menu "${stack_name}" "${stack_dir}" "${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" || true
)"
case "${missing_custom_image_action}" in
"Build custom image now")
if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then
continue
fi
break
;;
"Back" | "")
break
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown missing-image action: ${missing_custom_image_action}" 2
break
;;
esac
;;
65)
show_warning_and_wait "Cannot inspect custom image before restart. Check Docker and try again. Details: ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5
break
;;
*)
show_warning_and_wait "Cannot restart stack with docker compose (${compose_start_status})." 4
break
;;
esac
done
;;
"Stop stack in Docker Compose")
show_warning_message "Stopping stack with docker compose: ${stack_name}"
if stop_stack_with_compose_from_metadata "${stack_dir}"; then
show_warning_and_wait "Stack stopped successfully with docker compose: ${stack_name}" 3
continue
else
compose_start_status=$?
fi
case "${compose_start_status}" in
41)
show_warning_and_wait "Cannot stop stack: metadata.json is missing in ${stack_dir}." 4
;;
42)
show_warning_and_wait "Cannot stop stack: stack env file not found in ${stack_dir}." 4
;;
43)
show_warning_and_wait "Cannot stop stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4
;;
44)
show_warning_and_wait "Cannot stop stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5
;;
45)
show_warning_and_wait "Cannot stop stack: no compose files configured in metadata.json." 4
;;
46)
show_warning_and_wait "Cannot stop stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4
;;
47)
show_warning_and_wait "docker compose stop failed. Check the output above for details." 4
;;
*)
show_warning_and_wait "Cannot stop stack with docker compose (${compose_start_status})." 4
;;
esac
;;
"Delete stack")
delete_stack_confirmation_action="$(
show_manage_stack_delete_confirmation "${stack_name}" "${stack_dir}" || true
)"
case "${delete_stack_confirmation_action}" in
"Yes")
if ! prompt_manage_stack_delete_keyword_with_cancel delete_stack_keyword "${stack_name}"; then
continue
fi
if [ "${delete_stack_keyword}" != "delete" ]; then
continue
fi
show_warning_message "Deleting stack with docker compose resources: ${stack_name}"
if delete_stack_with_compose_from_metadata "${stack_dir}"; then
show_warning_and_wait "Stack deleted successfully with containers, networks, volumes, image, and stack directory: ${stack_name}" 5
return "${FLOW_CONTINUE}"
else
compose_start_status=$?
fi
case "${compose_start_status}" in
48)
show_warning_and_wait "Cannot delete stack: metadata.json is missing in ${stack_dir}." 4
;;
49)
show_warning_and_wait "Cannot delete stack: stack env file not found in ${stack_dir}." 4
;;
50)
show_warning_and_wait "Cannot delete stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4
;;
51)
show_warning_and_wait "Cannot delete stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5
;;
52)
show_warning_and_wait "Cannot delete stack: no compose files configured in metadata.json." 4
;;
53)
show_warning_and_wait "Cannot delete stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4
;;
54)
show_warning_and_wait "docker compose down failed. Check the output above for details." 4
;;
55)
show_warning_and_wait "Stack resources were removed, but the configured custom image could not be deleted -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5
;;
56)
show_warning_and_wait "Docker resources were removed, but the stack directory could not be deleted -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5
;;
*)
show_warning_and_wait "Cannot delete stack with docker compose (${compose_start_status})." 4
;;
esac
;;
"No" | "")
continue
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown delete-stack action: ${delete_stack_confirmation_action}" 2
;;
esac
;;
"Site")
if handle_manage_stack_site_flow "${stack_name}" "${stack_dir}"; then
:
else
compose_start_status=$?
case "${compose_start_status}" in
"${FLOW_EXIT_APP}")
return "${FLOW_EXIT_APP}"
;;
*)
continue
;;
esac
fi
;;
"Back" | "")
return "${FLOW_CONTINUE}"
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown stack action: ${stack_action}"
;;
esac
done
}

View file

@ -0,0 +1,131 @@
#!/usr/bin/env bash
handle_abort_wizard_flow() {
local stack_dir="${1}"
local abort_action=""
local rollback_status=0
abort_action="$(show_abort_wizard_prompt "${stack_dir}" || true)"
case "${abort_action}" in
"Rollback files and return to main menu")
if rollback_stack_directory "${stack_dir}"; then
return "${FLOW_BACK_TO_MAIN}"
fi
rollback_status=$?
if [ "${rollback_status}" -eq 2 ]; then
show_warning_and_wait "Refused rollback for unsafe path: ${stack_dir}" 2
else
show_warning_and_wait "Could not rollback stack files: ${stack_dir}" 2
fi
return "${FLOW_CONTINUE}"
;;
"Keep files and return to main menu")
return "${FLOW_BACK_TO_MAIN}"
;;
"Back to topology selection" | "")
return "${FLOW_CONTINUE}"
;;
*)
show_warning_and_wait "Unknown abort action: ${abort_action}"
return "${FLOW_CONTINUE}"
;;
esac
}
handle_stack_topology_flow() {
local stack_dir="${1}"
local topology_action=""
local abort_status=0
local single_host_status=0
local split_services_status=0
local manage_status=0
local stack_name=""
while true; do
topology_action="$(show_stack_topology_menu "${stack_dir}" || true)"
case "${topology_action}" in
"Single-host" | "Single-host (recommended)")
if handle_single_host_stack_flow "${stack_dir}"; then
single_host_status="${FLOW_CONTINUE}"
else
single_host_status=$?
fi
case "${single_host_status}" in
"${FLOW_OPEN_MANAGE_STACK}")
stack_name="${stack_dir##*/}"
if handle_manage_selected_stack_flow "${stack_name}"; then
manage_status="${FLOW_CONTINUE}"
else
manage_status=$?
fi
case "${manage_status}" in
"${FLOW_EXIT_APP}")
return "${FLOW_EXIT_APP}"
;;
*)
return "${FLOW_CONTINUE}"
;;
esac
;;
"${FLOW_EXIT_APP}")
return "${FLOW_EXIT_APP}"
;;
*) ;;
esac
;;
"Split services")
if handle_split_services_stack_flow "${stack_dir}"; then
split_services_status="${FLOW_CONTINUE}"
else
split_services_status=$?
fi
case "${split_services_status}" in
"${FLOW_OPEN_MANAGE_STACK}")
stack_name="${stack_dir##*/}"
if handle_manage_selected_stack_flow "${stack_name}"; then
manage_status="${FLOW_CONTINUE}"
else
manage_status=$?
fi
case "${manage_status}" in
"${FLOW_EXIT_APP}")
return "${FLOW_EXIT_APP}"
;;
*)
return "${FLOW_CONTINUE}"
;;
esac
;;
"${FLOW_BACK_TO_MAIN}")
return "${FLOW_BACK_TO_MAIN}"
;;
"${FLOW_EXIT_APP}")
return "${FLOW_EXIT_APP}"
;;
*) ;;
esac
;;
"Abort wizard to main menu")
handle_abort_wizard_flow "${stack_dir}"
abort_status=$?
case "${abort_status}" in
"${FLOW_BACK_TO_MAIN}")
return "${FLOW_BACK_TO_MAIN}"
;;
*) ;;
esac
;;
"")
return "${FLOW_CONTINUE}"
;;
*)
show_warning_and_wait "Unknown topology selection: ${topology_action}"
;;
esac
done
}

View file

@ -0,0 +1,312 @@
#!/usr/bin/env bash
handle_create_new_stack_flow() {
local setup_type="${1:-production}"
local stack_name=""
local frappe_branch=""
local stack_dir=""
local create_stack_status=0
local stack_input_status=0
local branch_select_status=0
local topology_status=0
case "${setup_type}" in
production | development) ;;
*)
show_warning_and_wait "Unknown setup type: ${setup_type}" 2
return "${FLOW_CONTINUE}"
;;
esac
while true; do
stack_name=""
if prompt_stack_name_with_cancel stack_name; then
:
else
stack_input_status=$?
if [ "${stack_input_status}" -eq "${FLOW_ABORT_INPUT}" ]; then
return "${FLOW_CONTINUE}"
fi
show_warning_and_wait "Input canceled."
return "${FLOW_CONTINUE}"
fi
if [ -z "${stack_name}" ]; then
return "${FLOW_CONTINUE}"
fi
if ! is_valid_stack_name "${stack_name}"; then
show_warning_and_wait "Invalid stack name. Use letters, numbers, dot, underscore, or hyphen." 2
continue
fi
frappe_branch=""
if prompt_frappe_branch_with_cancel frappe_branch "${stack_name}"; then
:
else
branch_select_status=$?
if [ "${branch_select_status}" -eq "${FLOW_ABORT_INPUT}" ]; then
continue
fi
show_warning_and_wait "Could not select Frappe branch profile." 2
return "${FLOW_CONTINUE}"
fi
stack_dir=""
if create_stack_directory_with_metadata stack_dir "${stack_name}" "${setup_type}" "${frappe_branch}"; then
handle_stack_topology_flow "${stack_dir}"
topology_status=$?
case "${topology_status}" in
"${FLOW_BACK_TO_MAIN}")
return "${FLOW_BACK_TO_MAIN}"
;;
"${FLOW_EXIT_APP}")
return "${FLOW_EXIT_APP}"
;;
*)
return "${FLOW_CONTINUE}"
;;
esac
else
create_stack_status=$?
if [ "${create_stack_status}" -eq 2 ]; then
show_warning_and_wait "Stack already exists: ${stack_name}" 2
continue
fi
show_warning_and_wait "Could not create stack directory for: ${stack_name}" 2
return "${FLOW_CONTINUE}"
fi
done
}
handle_manage_existing_stacks_flow() {
local setup_type="${1:-production}"
local manage_action=""
local selected_stack_status=0
local stack_names_raw=""
local -a stack_names=()
while true; do
stack_names_raw="$(list_existing_stack_names "${setup_type}")"
if [ -z "${stack_names_raw}" ]; then
manage_action="$(show_manage_stacks_placeholder "${setup_type}" || true)"
else
mapfile -t stack_names <<<"${stack_names_raw}"
manage_action="$(show_manage_stacks_menu "${setup_type}" "${stack_names[@]}" || true)"
fi
case "${manage_action}" in
"Back" | "")
return "${FLOW_CONTINUE}"
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
if [ -n "${stack_names_raw}" ] && stack_name_in_array "${manage_action}" "${stack_names[@]}"; then
if handle_manage_selected_stack_flow "${manage_action}"; then
selected_stack_status="${FLOW_CONTINUE}"
else
selected_stack_status=$?
fi
case "${selected_stack_status}" in
"${FLOW_BACK_TO_MAIN}")
return "${FLOW_BACK_TO_MAIN}"
;;
"${FLOW_EXIT_APP}")
return "${FLOW_EXIT_APP}"
;;
*) ;;
esac
else
show_warning_and_wait "Unknown manage-stacks action: ${manage_action}"
fi
;;
esac
done
}
handle_setup_flow() {
local setup_type="${1}"
local setup_action=""
case "${setup_type}" in
production | development) ;;
*)
show_warning_and_wait "Unknown setup type: ${setup_type}" 2
return "${FLOW_CONTINUE}"
;;
esac
while true; do
case "${setup_type}" in
production)
setup_action="$(show_production_setup_menu || true)"
;;
development)
setup_action="$(show_development_setup_menu || true)"
;;
esac
case "${setup_action}" in
"Create new stack")
if handle_create_new_stack_flow "${setup_type}"; then
:
else
case "$?" in
"${FLOW_BACK_TO_MAIN}")
return "${FLOW_BACK_TO_MAIN}"
;;
"${FLOW_EXIT_APP}")
return "${FLOW_EXIT_APP}"
;;
*) ;;
esac
fi
;;
"Manage existing stacks")
if handle_manage_existing_stacks_flow "${setup_type}"; then
:
else
case "$?" in
"${FLOW_BACK_TO_MAIN}")
return "${FLOW_BACK_TO_MAIN}"
;;
"${FLOW_EXIT_APP}")
return "${FLOW_EXIT_APP}"
;;
*) ;;
esac
fi
;;
"Back" | "")
return "${FLOW_BACK_TO_MAIN}"
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown ${setup_type} action: ${setup_action}"
;;
esac
done
}
handle_production_setup_flow() {
handle_setup_flow "production"
}
handle_development_setup_flow() {
handle_setup_flow "development"
}
handle_environment_check_flow() {
local environment_action=""
environment_action="$(show_environment_status || true)"
case "${environment_action}" in
"Back to main menu" | "")
return "${FLOW_BACK_TO_MAIN}"
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown environment action: ${environment_action}"
return "${FLOW_CONTINUE}"
;;
esac
}
run_easy_docker_app() {
local action=""
local handler_status=0
enter_alt_screen
render_main_screen 1
while true; do
action="$(show_main_menu || true)"
if [ -z "${action}" ]; then
return 0
fi
case "${action}" in
"Production Stack")
if handle_production_setup_flow; then
handler_status="${FLOW_CONTINUE}"
else
handler_status=$?
fi
case "${handler_status}" in
"${FLOW_BACK_TO_MAIN}")
render_main_screen 1
;;
"${FLOW_EXIT_APP}")
return 0
;;
*) ;;
esac
;;
"Development Stack")
if handle_development_setup_flow; then
handler_status="${FLOW_CONTINUE}"
else
handler_status=$?
fi
case "${handler_status}" in
"${FLOW_BACK_TO_MAIN}")
render_main_screen 1
;;
"${FLOW_EXIT_APP}")
return 0
;;
*) ;;
esac
;;
"Tools")
if handle_tools_flow; then
handler_status="${FLOW_CONTINUE}"
else
handler_status=$?
fi
case "${handler_status}" in
"${FLOW_BACK_TO_MAIN}")
render_main_screen 1
;;
"${FLOW_EXIT_APP}")
return 0
;;
*) ;;
esac
;;
"Environment check")
if handle_environment_check_flow; then
handler_status="${FLOW_CONTINUE}"
else
handler_status=$?
fi
case "${handler_status}" in
"${FLOW_BACK_TO_MAIN}")
render_main_screen 1
;;
"${FLOW_EXIT_APP}")
return 0
;;
*) ;;
esac
;;
"Exit")
return 0
;;
*)
show_warning_and_wait "Unknown action: ${action}"
;;
esac
done
}

View file

@ -0,0 +1,81 @@
#!/usr/bin/env bash
handle_single_host_stack_flow() {
local stack_dir="${1}"
local proxy_mode=""
local database_choice=""
local redis_choice=""
local stack_env_path=""
local stack_apps_path=""
local generated_compose_path=""
local save_selection_status=0
local render_compose_status=0
proxy_mode="$(show_single_host_proxy_menu "${stack_dir}" || true)"
case "${proxy_mode}" in
"Back to topology selection" | "")
return "${FLOW_CONTINUE}"
;;
*)
if ! get_single_host_proxy_mode_id "${proxy_mode}" >/dev/null; then
show_warning_and_wait "Unknown proxy mode: ${proxy_mode}"
return "${FLOW_CONTINUE}"
fi
;;
esac
database_choice="$(show_single_host_database_menu "${stack_dir}" || true)"
case "${database_choice}" in
"Back to topology selection" | "")
return "${FLOW_CONTINUE}"
;;
*)
if ! get_single_host_database_id "${database_choice}" >/dev/null; then
show_warning_and_wait "Unknown database choice: ${database_choice}"
return "${FLOW_CONTINUE}"
fi
;;
esac
redis_choice="$(show_single_host_redis_menu "${stack_dir}" || true)"
case "${redis_choice}" in
"Back to topology selection" | "")
return "${FLOW_CONTINUE}"
;;
*)
if ! get_single_host_redis_id "${redis_choice}" >/dev/null; then
show_warning_and_wait "Unknown Redis choice: ${redis_choice}"
return "${FLOW_CONTINUE}"
fi
;;
esac
if save_single_host_selection "${stack_dir}" "${proxy_mode}" "${database_choice}" "${redis_choice}"; then
:
else
save_selection_status=$?
if [ "${save_selection_status}" -eq 2 ] || [ "${save_selection_status}" -eq 130 ]; then
return "${FLOW_CONTINUE}"
fi
show_warning_and_wait "Could not save single-host selection for stack: ${stack_dir}" 2
return "${FLOW_CONTINUE}"
fi
if render_stack_compose_from_metadata "${stack_dir}"; then
:
else
render_compose_status=$?
stack_env_path="$(get_stack_env_path "${stack_dir}")"
stack_apps_path="${stack_dir}/apps.json"
generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")"
show_warning_and_wait "Selection saved in ${stack_dir}/metadata.json, ${stack_env_path}, and ${stack_apps_path}, but compose rendering failed (${render_compose_status}) for ${generated_compose_path}." 3
return "${FLOW_CONTINUE}"
fi
stack_env_path="$(get_stack_env_path "${stack_dir}")"
stack_apps_path="${stack_dir}/apps.json"
generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")"
show_warning_and_wait "Single-host selection saved in ${stack_dir}/metadata.json, ${stack_env_path}, and ${stack_apps_path}. Rendered compose: ${generated_compose_path}." 3
return "${FLOW_OPEN_MANAGE_STACK}"
}

View file

@ -0,0 +1,130 @@
#!/usr/bin/env bash
handle_split_services_stack_flow() {
local stack_dir="${1}"
local data_mode=""
local database_choice=""
local redis_choice=""
local proxy_mode=""
local summary_action=""
local stack_env_path=""
local stack_apps_path=""
local generated_compose_path=""
local save_selection_status=0
local render_compose_status=0
data_mode="$(show_split_services_data_mode_menu "${stack_dir}" || true)"
case "${data_mode}" in
"Back to topology selection" | "")
return "${FLOW_CONTINUE}"
;;
*)
if ! get_split_services_data_mode_id "${data_mode}" >/dev/null; then
show_warning_and_wait "Unknown data services mode: ${data_mode}"
return "${FLOW_CONTINUE}"
fi
;;
esac
database_choice="$(show_split_services_database_menu "${stack_dir}" || true)"
case "${database_choice}" in
"Back to topology selection" | "")
return "${FLOW_CONTINUE}"
;;
*)
if ! get_single_host_database_id "${database_choice}" >/dev/null; then
show_warning_and_wait "Unknown database choice: ${database_choice}"
return "${FLOW_CONTINUE}"
fi
;;
esac
redis_choice="$(show_split_services_redis_mode_menu "${stack_dir}" || true)"
case "${redis_choice}" in
"Back to topology selection" | "")
return "${FLOW_CONTINUE}"
;;
*)
if ! get_split_services_redis_id "${redis_choice}" >/dev/null; then
show_warning_and_wait "Unknown Redis choice: ${redis_choice}"
return "${FLOW_CONTINUE}"
fi
;;
esac
proxy_mode="$(show_split_services_proxy_mode_menu "${stack_dir}" || true)"
case "${proxy_mode}" in
"Back to topology selection" | "")
return "${FLOW_CONTINUE}"
;;
*)
if ! get_single_host_proxy_mode_id "${proxy_mode}" >/dev/null; then
show_warning_and_wait "Unknown reverse proxy mode: ${proxy_mode}"
return "${FLOW_CONTINUE}"
fi
;;
esac
summary_action="$(show_split_services_summary_menu "${stack_dir}" "${data_mode}" "${database_choice}" "${redis_choice}" "${proxy_mode}" || true)"
case "${summary_action}" in
"Yes, write stack files") ;;
"Back to topology selection" | "")
return "${FLOW_CONTINUE}"
;;
"Abort wizard to main menu")
handle_abort_wizard_flow "${stack_dir}"
return $?
;;
*)
show_warning_and_wait "Unknown split-services summary action: ${summary_action}"
return "${FLOW_CONTINUE}"
;;
esac
if save_split_services_selection "${stack_dir}" "${proxy_mode}" "${data_mode}" "${database_choice}" "${redis_choice}"; then
:
else
save_selection_status=$?
if [ "${save_selection_status}" -eq 2 ] || [ "${save_selection_status}" -eq 130 ]; then
return "${FLOW_CONTINUE}"
fi
case "${save_selection_status}" in
31)
show_warning_and_wait "Could not write the split-services env file for stack: ${stack_dir}" 3
;;
32)
show_warning_and_wait "Could not write the split-services wizard metadata in ${stack_dir}/metadata.json." 3
;;
33)
show_warning_and_wait "Split-services app selection is empty. Select at least one app before writing stack files." 3
;;
34)
show_warning_and_wait "Could not write the selected app metadata in ${stack_dir}/metadata.json." 3
;;
35)
show_warning_and_wait "Could not generate ${stack_dir}/apps.json from the selected split-services apps." 3
;;
*)
show_warning_and_wait "Could not save split-services selection for stack: ${stack_dir} (${save_selection_status})." 3
;;
esac
return "${FLOW_CONTINUE}"
fi
if render_stack_compose_from_metadata "${stack_dir}"; then
:
else
render_compose_status=$?
stack_env_path="$(get_stack_env_path "${stack_dir}")"
stack_apps_path="${stack_dir}/apps.json"
generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")"
show_warning_and_wait "Selection saved in ${stack_dir}/metadata.json, ${stack_env_path}, and ${stack_apps_path}, but compose rendering failed (${render_compose_status}) for ${generated_compose_path}." 3
return "${FLOW_CONTINUE}"
fi
stack_env_path="$(get_stack_env_path "${stack_dir}")"
stack_apps_path="${stack_dir}/apps.json"
generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")"
show_warning_and_wait "Split-services selection saved in ${stack_dir}/metadata.json, ${stack_env_path}, and ${stack_apps_path}. Rendered compose: ${generated_compose_path}." 3
return "${FLOW_OPEN_MANAGE_STACK}"
}

View file

@ -0,0 +1,199 @@
#!/usr/bin/env bash
prompt_tools_apps_catalog_value_with_back() {
local result_var="${1}"
local field_label="${2}"
local help_text="${3}"
local placeholder="${4:-}"
local input_value=""
local input_status=0
input_value="$(prompt_tools_apps_catalog_input "${field_label}" "${help_text}" "${placeholder}")"
input_status=$?
if [ "${input_status}" -ne 0 ]; then
return "${FLOW_ABORT_INPUT}"
fi
input_value="$(printf '%s' "${input_value}" | tr -d '\r\n')"
case "${input_value}" in
/back | /BACK | /Back)
return "${FLOW_ABORT_INPUT}"
;;
esac
printf -v "${result_var}" "%s" "${input_value}"
return "${FLOW_CONTINUE}"
}
prompt_tools_apps_default_branch_from_csv_with_back() {
local result_var="${1}"
local branches_csv="${2}"
local selection=""
local selection_status=0
local branch=""
local -a branch_options=()
IFS=',' read -r -a branch_options <<<"${branches_csv}"
if [ "${#branch_options[@]}" -eq 0 ]; then
return 1
fi
selection="$(show_tools_apps_default_branch_menu "${branch_options[@]}")"
selection_status=$?
if [ "${selection_status}" -ne 0 ]; then
return "${FLOW_ABORT_INPUT}"
fi
case "${selection}" in
"" | "Back")
return "${FLOW_ABORT_INPUT}"
;;
esac
branch="${selection}"
if ! is_valid_predefined_app_branch "${branch}"; then
return 1
fi
if ! csv_contains_branch "${branches_csv}" "${branch}"; then
return 1
fi
printf -v "${result_var}" "%s" "${branch}"
return "${FLOW_CONTINUE}"
}
run_add_app_catalog_entry_wizard() {
local app_id=""
local app_label=""
local app_repo=""
local app_branches_csv=""
local normalized_branches_csv=""
local app_default_branch=""
local input_status=0
if ! get_predefined_apps_catalog_entries >/dev/null 2>&1; then
show_warning_and_wait "Could not load scripts/easy-docker/config/apps.tsv. Check format before adding new entries." 3
return 1
fi
while true; do
if prompt_tools_apps_catalog_value_with_back \
app_label \
"App Label" \
"Display name used in the app selection list." \
"My Custom App"; then
:
else
input_status=$?
return "${input_status}"
fi
trim_predefined_catalog_field app_label "${app_label}"
if [ -z "${app_label}" ]; then
show_warning_and_wait "App label is required." 2
continue
fi
if predefined_app_catalog_has_label "${app_label}"; then
show_warning_and_wait "App label already exists in apps.tsv: ${app_label}" 2
continue
fi
if ! generate_predefined_app_id_from_label app_id "${app_label}"; then
show_warning_and_wait "Could not generate a valid app id from label. Use letters/numbers and simple separators." 2
continue
fi
if predefined_app_catalog_has_id "${app_id}"; then
show_warning_and_wait "Generated app id already exists (${app_id}). Choose a different label." 2
continue
fi
break
done
while true; do
if prompt_tools_apps_catalog_value_with_back \
app_repo \
"Repository URL" \
"Git repository URL for this app." \
"https://github.com/acme/my-custom-app"; then
:
else
input_status=$?
return "${input_status}"
fi
if ! is_valid_predefined_app_repo "${app_repo}"; then
show_warning_and_wait "Invalid repository URL. Use https/http/ssh/git formats." 2
continue
fi
break
done
while true; do
if prompt_tools_apps_catalog_value_with_back \
app_branches_csv \
"Branches (CSV)" \
"Comma-separated branches for selection. Example: version-15,version-16,develop" \
"version-15,version-16,develop"; then
:
else
input_status=$?
return "${input_status}"
fi
if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then
show_warning_and_wait "Invalid branch list. Use a comma-separated list with valid branch names." 2
continue
fi
break
done
while true; do
if prompt_tools_apps_default_branch_from_csv_with_back app_default_branch "${normalized_branches_csv}"; then
:
else
input_status=$?
if [ "${input_status}" -eq "${FLOW_ABORT_INPUT}" ]; then
return "${FLOW_ABORT_INPUT}"
fi
show_warning_and_wait "Could not select default branch from branch list." 2
return "${input_status}"
fi
break
done
if ! append_predefined_app_catalog_entry "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}"; then
show_warning_and_wait "Could not append app entry to scripts/easy-docker/config/apps.tsv." 3
return 1
fi
show_warning_and_wait "App added to apps.tsv: ${app_label} (${app_id})" 2
return 0
}
handle_tools_flow() {
local tools_action=""
while true; do
tools_action="$(show_tools_menu || true)"
case "${tools_action}" in
"Add Apps for App Selection")
run_add_app_catalog_entry_wizard || true
;;
"Back to main menu" | "")
return "${FLOW_BACK_TO_MAIN}"
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown tools action: ${tools_action}"
;;
esac
done
}

View file

@ -0,0 +1,267 @@
#!/usr/bin/env bash
get_single_host_proxy_mode_id() {
local proxy_mode="${1}"
case "${proxy_mode}" in
"Traefik (HTTP, built-in proxy)")
printf 'traefik-http\n'
;;
"Traefik (HTTPS + Let's Encrypt)")
printf 'traefik-https\n'
;;
"nginx-proxy (HTTP)")
printf 'nginxproxy-http\n'
;;
"nginx-proxy + acme-companion (HTTPS)")
printf 'nginxproxy-https\n'
;;
"Caddy (external reverse proxy)")
printf 'caddy-external\n'
;;
"No reverse proxy (direct :8080)")
printf 'no-proxy\n'
;;
*)
return 1
;;
esac
}
get_single_host_proxy_overrides() {
local proxy_mode="${1}"
case "${proxy_mode}" in
"Traefik (HTTP, built-in proxy)")
printf 'overrides/compose.proxy.yaml\n'
;;
"Traefik (HTTPS + Let's Encrypt)")
printf 'overrides/compose.https.yaml\n'
;;
"nginx-proxy (HTTP)")
printf 'overrides/compose.nginxproxy.yaml\n'
;;
"nginx-proxy + acme-companion (HTTPS)")
printf 'overrides/compose.nginxproxy.yaml\noverrides/compose.nginxproxy-ssl.yaml\n'
;;
"Caddy (external reverse proxy)" | "No reverse proxy (direct :8080)")
printf 'overrides/compose.noproxy.yaml\n'
;;
*)
return 1
;;
esac
}
get_single_host_database_id() {
local database_choice="${1}"
case "${database_choice}" in
"MariaDB (recommended)")
printf 'mariadb\n'
;;
"PostgreSQL")
printf 'postgres\n'
;;
*)
return 1
;;
esac
}
get_single_host_database_override() {
local database_choice="${1}"
case "${database_choice}" in
"MariaDB (recommended)")
printf 'overrides/compose.mariadb.yaml\n'
;;
"PostgreSQL")
printf 'overrides/compose.postgres.yaml\n'
;;
*)
return 1
;;
esac
}
get_single_host_redis_id() {
local redis_choice="${1}"
case "${redis_choice}" in
"Include Redis (recommended)")
printf 'enabled\n'
;;
"Skip Redis (experienced users only)")
printf 'disabled\n'
;;
*)
return 1
;;
esac
}
get_single_host_redis_override() {
local redis_choice="${1}"
case "${redis_choice}" in
"Include Redis (recommended)")
printf 'overrides/compose.redis.yaml\n'
;;
"Skip Redis (experienced users only)")
printf ''
;;
*)
return 1
;;
esac
}
persist_single_host_env_file() {
local stack_dir="${1}"
local env_lines="${2}"
local env_path=""
local env_tmp_path=""
local generated_at=""
env_path="$(get_stack_env_path "${stack_dir}")"
env_tmp_path="${env_path}.tmp"
generated_at="$(get_current_utc_timestamp)"
if ! {
printf '# Generated by easy-docker wizard at %s\n' "${generated_at}"
printf '# Adjust values as needed for this stack.\n'
if [ -n "${env_lines}" ]; then
printf '\n%s\n' "${env_lines}"
else
printf '\n# No additional environment variables configured by the wizard.\n'
fi
} >"${env_tmp_path}"; then
rm -f -- "${env_tmp_path}" >/dev/null 2>&1 || true
return 1
fi
if ! mv -- "${env_tmp_path}" "${env_path}"; then
rm -f -- "${env_tmp_path}" >/dev/null 2>&1 || true
return 1
fi
return 0
}
persist_single_host_selection_metadata() {
local stack_dir="${1}"
local proxy_mode_id="${2}"
local database_id="${3}"
local redis_id="${4}"
local compose_files_lines="${5}"
local env_lines="${6}"
local updated_at=""
local compose_files_json=""
local env_json_object=""
local wizard_json_object=""
updated_at="$(get_current_utc_timestamp)"
compose_files_json="$(build_compose_files_json_array "${compose_files_lines}")"
env_json_object="$(build_env_json_object "${env_lines}")"
if ! wizard_json_object="$(
cat <<EOF
{
"topology": "single-host",
"selection": {
"proxy_mode_id": "${proxy_mode_id}",
"database_id": "${database_id}",
"redis_id": "${redis_id}"
},
"env": ${env_json_object},
"compose_files": [
${compose_files_json}
],
"updated_at": "${updated_at}"
}
EOF
)"; then
return 1
fi
if ! persist_stack_metadata_wizard_object "${stack_dir}" "${wizard_json_object}"; then
return 1
fi
return 0
}
save_single_host_selection() {
local stack_dir="${1}"
local proxy_mode="${2}"
local database_choice="${3}"
local redis_choice="${4}"
local proxy_mode_id=""
local database_id=""
local redis_id=""
local database_override=""
local redis_override=""
local proxy_overrides=""
local compose_files_lines=""
local env_lines=""
local apps_metadata_json_object=""
local wizard_metadata_status=0
local apps_metadata_status=0
local collect_env_status=0
proxy_mode_id="$(get_single_host_proxy_mode_id "${proxy_mode}")" || return 1
database_id="$(get_single_host_database_id "${database_choice}")" || return 1
redis_id="$(get_single_host_redis_id "${redis_choice}")" || return 1
database_override="$(get_single_host_database_override "${database_choice}")" || return 1
redis_override="$(get_single_host_redis_override "${redis_choice}")" || return 1
proxy_overrides="$(get_single_host_proxy_overrides "${proxy_mode}")" || return 1
compose_files_lines="$(printf 'compose.yaml\n%s' "${database_override}")"
if [ -n "${redis_override}" ]; then
compose_files_lines="$(printf '%s\n%s' "${compose_files_lines}" "${redis_override}")"
fi
compose_files_lines="$(printf '%s\n%s' "${compose_files_lines}" "${proxy_overrides}")"
if collect_single_host_env_lines env_lines apps_metadata_json_object "${stack_dir}" "${proxy_mode_id}" "${database_id}" "${redis_id}"; then
:
else
collect_env_status=$?
return "${collect_env_status}"
fi
if ! persist_single_host_env_file "${stack_dir}" "${env_lines}"; then
return 1
fi
if persist_single_host_selection_metadata \
"${stack_dir}" \
"${proxy_mode_id}" \
"${database_id}" \
"${redis_id}" \
"${compose_files_lines}" \
"${env_lines}"; then
:
else
wizard_metadata_status=$?
return "${wizard_metadata_status}"
fi
if [ -z "${apps_metadata_json_object}" ]; then
return 1
fi
if persist_stack_metadata_apps_object "${stack_dir}" "${apps_metadata_json_object}"; then
:
else
apps_metadata_status=$?
return "${apps_metadata_status}"
fi
if ! persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then
return 1
fi
return 0
}

View file

@ -0,0 +1,164 @@
#!/usr/bin/env bash
get_split_services_data_mode_id() {
local data_mode="${1}"
case "${data_mode}" in
"Managed Data Services")
printf 'managed\n'
;;
"External Data Services")
printf 'external\n'
;;
*)
return 1
;;
esac
}
get_split_services_redis_id() {
local redis_choice="${1}"
case "${redis_choice}" in
"Managed Redis Services")
printf 'managed\n'
;;
"External Redis Services")
printf 'external\n'
;;
"No Redis Services")
printf 'disabled\n'
;;
*)
return 1
;;
esac
}
persist_split_services_selection_metadata() {
local stack_dir="${1}"
local proxy_mode_id="${2}"
local data_mode_id="${3}"
local database_id="${4}"
local redis_id="${5}"
local compose_files_lines="${6}"
local env_lines="${7}"
local updated_at=""
local compose_files_json=""
local env_json_object=""
local wizard_json_object=""
updated_at="$(get_current_utc_timestamp)"
compose_files_json="$(build_compose_files_json_array "${compose_files_lines}")"
env_json_object="$(build_env_json_object "${env_lines}")"
if ! wizard_json_object="$(
cat <<EOF
{
"topology": "split-services",
"selection": {
"proxy_mode_id": "${proxy_mode_id}",
"data_mode_id": "${data_mode_id}",
"database_id": "${database_id}",
"redis_id": "${redis_id}"
},
"env": ${env_json_object},
"compose_files": [
${compose_files_json}
],
"updated_at": "${updated_at}"
}
EOF
)"; then
return 1
fi
if ! persist_stack_metadata_wizard_object "${stack_dir}" "${wizard_json_object}"; then
return 1
fi
return 0
}
save_split_services_selection() {
local stack_dir="${1}"
local proxy_mode="${2}"
local data_mode="${3}"
local database_choice="${4}"
local redis_choice="${5}"
local proxy_mode_id=""
local data_mode_id=""
local database_id=""
local redis_id=""
local database_override=""
local redis_override=""
local proxy_overrides=""
local compose_files_lines=""
local env_lines=""
local apps_metadata_json_object=""
local collect_env_status=0
proxy_mode_id="$(get_single_host_proxy_mode_id "${proxy_mode}")" || return 1
data_mode_id="$(get_split_services_data_mode_id "${data_mode}")" || return 1
database_id="$(get_single_host_database_id "${database_choice}")" || return 1
redis_id="$(get_split_services_redis_id "${redis_choice}")" || return 1
if [ "${data_mode_id}" = "managed" ]; then
database_override="$(get_single_host_database_override "${database_choice}")" || return 1
fi
if [ "${redis_id}" = "managed" ]; then
redis_override="$(get_single_host_redis_override "Include Redis (recommended)")" || return 1
fi
proxy_overrides="$(get_single_host_proxy_overrides "${proxy_mode}")" || return 1
compose_files_lines="compose.yaml"
if [ -n "${database_override}" ]; then
compose_files_lines="$(printf '%s\n%s' "${compose_files_lines}" "${database_override}")"
fi
if [ -n "${redis_override}" ]; then
compose_files_lines="$(printf '%s\n%s' "${compose_files_lines}" "${redis_override}")"
fi
compose_files_lines="$(printf '%s\n%s' "${compose_files_lines}" "${proxy_overrides}")"
if collect_split_services_env_lines env_lines apps_metadata_json_object "${stack_dir}" "${proxy_mode_id}" "${data_mode_id}" "${database_id}" "${redis_id}"; then
:
else
collect_env_status=$?
return "${collect_env_status}"
fi
if ! persist_single_host_env_file "${stack_dir}" "${env_lines}"; then
return 31
fi
if persist_split_services_selection_metadata \
"${stack_dir}" \
"${proxy_mode_id}" \
"${data_mode_id}" \
"${database_id}" \
"${redis_id}" \
"${compose_files_lines}" \
"${env_lines}"; then
:
else
return 32
fi
if [ -z "${apps_metadata_json_object}" ]; then
return 33
fi
if persist_stack_metadata_apps_object "${stack_dir}" "${apps_metadata_json_object}"; then
:
else
return 34
fi
if ! persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then
return 35
fi
return 0
}

View file

@ -0,0 +1,79 @@
#!/usr/bin/env bash
docker_compose_available() {
docker compose version >/dev/null 2>&1
}
docker_daemon_running() {
docker info >/dev/null 2>&1
}
docker_supports_command() {
docker "$@" --help >/dev/null 2>&1
}
get_missing_docker_commands() {
local missing=()
local subcommand=""
for subcommand in ps exec inspect cp build; do
if ! docker_supports_command "${subcommand}"; then
missing+=("docker ${subcommand}")
fi
done
for subcommand in config up down logs exec pull ps; do
if ! docker_supports_command compose "${subcommand}"; then
missing+=("docker compose ${subcommand}")
fi
done
if [ "${#missing[@]}" -eq 0 ]; then
return 0
fi
printf '%s\n' "${missing[@]}"
return 1
}
format_missing_commands_list() {
local missing_commands="${1}"
local missing_list=""
missing_list="$(printf '%s' "${missing_commands}" | tr '\n' ',')"
missing_list="${missing_list%,}"
missing_list="${missing_list#,}"
printf '%s\n' "${missing_list}"
}
ensure_docker() {
local missing_commands=""
local missing_list=""
if ! command_exists docker; then
echo "docker is not installed."
print_docker_install_guidance
return 1
fi
if ! docker_compose_available; then
echo "docker compose (Compose v2 command) is not available."
print_docker_compose_install_guidance
return 1
fi
if ! docker_daemon_running; then
echo "docker daemon is not running."
print_docker_daemon_start_guidance
return 1
fi
if ! missing_commands="$(get_missing_docker_commands)"; then
missing_list="$(format_missing_commands_list "${missing_commands}")"
echo "Missing required docker commands: ${missing_list}"
print_docker_command_support_guidance
return 1
fi
return 0
}

View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
jq_check_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/easy-docker/lib/install/jq/load.sh
source "${jq_check_dir}/../install/jq/load.sh"

View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
command_exists() {
command -v "${1}" >/dev/null 2>&1 || command -v "${1}.exe" >/dev/null 2>&1
}
run_with_privileges() {
if command_exists sudo; then
sudo "$@"
return
fi
"$@"
}
copy_binary() {
local source_path="${1}"
local target_path="${2}"
if command_exists install; then
install -m 0755 "${source_path}" "${target_path}"
return $?
fi
cp "${source_path}" "${target_path}" && chmod +x "${target_path}"
}
copy_binary_with_privileges() {
local source_path="${1}"
local target_path="${2}"
if command_exists install; then
run_with_privileges install -m 0755 "${source_path}" "${target_path}"
return $?
fi
run_with_privileges cp "${source_path}" "${target_path}" &&
run_with_privileges chmod +x "${target_path}"
}

View file

@ -0,0 +1,26 @@
#!/usr/bin/env bash
get_easy_docker_jq_command() {
if command -v jq >/dev/null 2>&1; then
printf 'jq\n'
return 0
fi
if command -v jq.exe >/dev/null 2>&1; then
printf 'jq.exe\n'
return 0
fi
return 1
}
easy_docker_require_jq() {
get_easy_docker_jq_command >/dev/null 2>&1
}
easy_docker_run_jq() {
local jq_command=""
jq_command="$(get_easy_docker_jq_command)" || return 127
"${jq_command}" "$@"
}

View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
print_manual_gum_install_guidance() {
echo "Install gum manually: https://github.com/charmbracelet/gum#installation"
echo "If installed into ~/.local/bin, add it to PATH first."
}
print_docker_install_guidance() {
echo "Install Docker first: https://docs.docker.com/get-started/get-docker/"
}
print_docker_compose_install_guidance() {
echo "This script requires Docker Compose v2 via the 'docker compose' command."
echo "Docker Desktop includes it by default."
echo "On Linux Engine-only setups, install the Docker Compose CLI plugin package (commonly 'docker-compose-plugin')."
echo "Setup docs:"
echo "https://docs.docker.com/compose/install/"
echo "Note: this script uses 'docker compose' (Compose v2), not the old standalone 'docker-compose'."
}
print_docker_daemon_start_guidance() {
echo "Start the Docker daemon/service and retry."
echo "If you use Docker Desktop, ensure it is running."
}
print_docker_command_support_guidance() {
echo "Update Docker to a recent version and ensure Compose v2 is available as 'docker compose'."
echo "Standard 'docker' and 'docker compose' commands are required."
}
print_jq_install_guidance() {
print_manual_jq_install_guidance
}
print_manual_jq_install_guidance() {
echo "Install jq first: https://jqlang.org/download/"
echo "On Windows, you can also use: winget install jqlang.jq"
echo "Ensure the 'jq' or 'jq.exe' command is available on PATH before running easy-docker."
}

Some files were not shown because too many files have changed in this diff Show more