#!/usr/bin/env bash

export ORGANIZATION="penpotapp";
export DEVENV_IMGNAME="$ORGANIZATION/devenv";
export DEVENV_NETWORK="penpot_shared";
export DEVENV_DEFAULTS_FILE="docker/devenv/defaults.env";

# Tag used for the devenv image across pull/build/run/build-bundle. Defaults to
# 'latest'; override by exporting DEVENV_TAG in the shell before invoking
# manage.sh (e.g. `export DEVENV_TAG=mytest`), or per-call via
# `build-devenv --tag mytest`. Note --tag only affects that one build-devenv
# call -- for run-devenv/build/pull-devenv to pick up the same custom tag,
# DEVENV_TAG must be exported in the shell for those invocations too, since
# each `./manage.sh ...` is a separate process.
export DEVENV_TAG="${DEVENV_TAG:-latest}";

# Load instance configuration (project name, container names, ports, runtime
# config). Single source of truth for the devenv; consumed by both docker
# compose (via --env-file) and the shell logic below. Hard dependency — abort
# loudly if it's missing or unreadable.
#
# Host-shell env wins over file values: a value already set in the parent
# environment is preserved. This matches docker compose's own precedence rule
# for --env-file (so substitution-time and shell-time agree).
if [ ! -r "$DEVENV_DEFAULTS_FILE" ]; then
    echo "manage.sh: cannot read $DEVENV_DEFAULTS_FILE" >&2
    exit 1
fi
while IFS='=' read -r __key __value; do
    [[ -z "$__key" || "$__key" =~ ^[[:space:]]*# ]] && continue
    if [ -z "${!__key+x}" ]; then
        export "$__key=$__value"
    fi
done < "$DEVENV_DEFAULTS_FILE"
unset __key __value

# Source path for the workspace bind mount; consumed by docker-compose.main.yml.
# ws0 binds the live repo at $PWD; ws1+ override this in their overlay env file.
export PENPOT_SOURCE_PATH="${PENPOT_SOURCE_PATH:-$PWD}"

# Base directory under which non-main workspace clones live (one subdir per
# wsN, N>=1). Documented in defaults.env; default lives here so $HOME expands.
export PENPOT_WORKSPACES_DIR="${PENPOT_WORKSPACES_DIR:-$HOME/.penpot/penpot_workspaces}"

# Port allocation for parallel instances. Each wsN reserves a stride-wide port
# block starting at N*stride; ws0 sits at offset 0, so a per-service base port
# IS ws0's published port. To keep a single source of truth, the bases are
# derived from the ws0 values sourced from defaults.env above rather than
# duplicated here -- this makes it impossible for ws0's compose substitution and
# the ws1+ offset arithmetic to drift apart. `:?` aborts loudly if defaults.env
# is missing one. Consumed by instance-env-overrides (the values injected into
# the per-instance compose env) and print-instance-info (the startup URLs).
PENPOT_INSTANCE_PORT_STRIDE=10000
PENPOT_PORT_BASE_PUBLIC_HTTPS=${PENPOT_PUBLIC_HTTPS_PORT:?missing in defaults.env}
PENPOT_PORT_BASE_PUBLIC=${PENPOT_PUBLIC_HTTP_PORT:?missing in defaults.env}
PENPOT_PORT_BASE_MCP=${PENPOT_MCP_SERVER_PORT:?missing in defaults.env}
PENPOT_PORT_BASE_MCP_REPL=${PENPOT_MCP_REPL_PORT:?missing in defaults.env}
PENPOT_PORT_BASE_SERENA=${SERENA_EXTERNAL_PORT:?missing in defaults.env}
PENPOT_PORT_BASE_SERENA_DASHBOARD=${SERENA_DASHBOARD_EXTERNAL_PORT:?missing in defaults.env}
PENPOT_PORT_BASE_OPENCODE=${OPENCODE_EXTERNAL_PORT:?missing in defaults.env}
PENPOT_PORT_BASE_MDTS=${MDTS_EXTERNAL_PORT:?missing in defaults.env}
PENPOT_PORT_BASE_STORYBOOK=${PENPOT_STORYBOOK_PORT:?missing in defaults.env}

# Per-instance values like PENPOT_REDIS_URI are injected by
# instance-env-overrides as shell env variables (not set in this shell),
# because docker compose gives shell-env precedence over --env-file, letting
# per-instance values override the defaults.env baseline.

export CURRENT_USER_ID=$(id -u);
export CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD);

export IMAGEMAGICK_VERSION=7.1.2-27

# Safe directory to avoid ownership errors with Git
git config --global --add safe.directory /home/penpot/penpot || true

# Set default java options
export JAVA_OPTS=${JAVA_OPTS:-"-Xmx1000m -Xms50m"};

set -e

# ----------------------------------------------------------------------------
# Function map
#
# Utility helpers
#   print-current-version, setup-buildx, put-license-file
#
# Devenv image lifecycle
#   build-devenv, pull-devenv, pull-devenv-if-not-exists
#
# Devenv compose plumbing (used by every *-devenv command below)
#   ensure-devenv-network   create the external 'penpot_shared' network
#   infra-compose           wrap 'docker compose' for the shared-infra project
#   instance-compose        wrap 'docker compose' for one instance's main
#                           project, injecting that instance's overrides
#   instance-env-overrides  the per-instance KEY=VALUE overrides
#   devenv-main-container   resolve the 'main' container id via compose ps
#   devenv-main-running     true if 'main' is up
#
# Devenv bring-up commands (bring a workspace up + start background tmux)
#   run-devenv               bring one workspace up; supports --ws, --sync,
#                            --attach, --agentic (enables MCP + Serena), -e,
#                            --serena-context, git identity
#
# Devenv interactive entry points (operate on the running 'main' container)
#   attach-devenv            pure attach to the existing tmux session; fails
#                            fast if the devenv or session is missing
#   start-coding-agent       launches Claude Code or opencode against the
#                            current workspace's generated MCP config
#
# Production build pipeline
#   build                   one-shot 'docker run' that invokes a per-module
#                           build script inside the devenv image
#   build-<mod>-bundle      project a module's build output into ./bundles/
#   build-<mod>-docker-image  package a bundle into a release docker image
# ----------------------------------------------------------------------------

ARCH=$(uname -m)

if [[ "$ARCH" == "x86_64" || "$ARCH" == "amd64" || "$ARCH" == "i386" || "$ARCH" == "i686" ]]; then
    ARCH="amd64"
elif [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then
    ARCH="arm64"
else
    echo "Unknown architecture $ARCH"
    exit -1
fi


function print-current-version {
    echo -n "$(git describe --tags --match "*.*.*")";
}

function setup-buildx {
    docker run --privileged --rm tonistiigi/binfmt --install all
    docker buildx inspect penpot > /dev/null 2>&1;

    if [ $? -eq 1 ]; then
        docker buildx create --name=penpot --use
        docker buildx inspect --bootstrap > /dev/null 2>&1;
    else
        docker buildx use penpot;
        docker buildx inspect --bootstrap  > /dev/null 2>&1;
    fi
}

function build-devenv {
    set +e;

    local tag="$DEVENV_TAG"
    local do_push=false

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --tag)
                tag="$2"; shift 2;;
            --push)
                do_push=true; shift;;
            --local)
                # Kept for backward compatibility -- local-only is now the
                # default behaviour, so this flag is a no-op.
                shift;;
            *)
                echo "build-devenv: unknown argument '$1'" >&2
                return 1;;
        esac
    done

    # Propagate to the rest of this invocation (e.g. if build-devenv is
    # called from another function later). Does NOT persist to a separate
    # `./manage.sh run-devenv` call -- export DEVENV_TAG in the shell for that.
    export DEVENV_TAG="$tag"

    pushd docker/devenv;

    if [[ "$do_push" == "true" ]]; then
        echo "Build and push $DEVENV_IMGNAME:$tag image";
        setup-buildx;

        docker buildx build \
          --platform linux/amd64,linux/arm64 \
          --output type=registry \
          --provenance=mode=max --sbom=true \
          -t "$DEVENV_IMGNAME:$tag" .;

        docker pull "$DEVENV_IMGNAME:$tag";
    else
        echo "Build local only $DEVENV_IMGNAME:$tag image";
        docker build -t "$DEVENV_IMGNAME:$tag" .;
    fi

    popd;
}

function pull-devenv {
    set -ex
    docker pull "$DEVENV_IMGNAME:$DEVENV_TAG"
}

function pull-devenv-if-not-exists {
    if [[ ! $(docker images "$DEVENV_IMGNAME:$DEVENV_TAG" -q) ]]; then
        pull-devenv $@
    fi
}

function ensure-devenv-network {
    docker network inspect "$DEVENV_NETWORK" >/dev/null 2>&1 || docker network create "$DEVENV_NETWORK" >/dev/null
}

# Compose-project plumbing for the parallel-workspaces layout.
#
# - Shared infrastructure (postgres, minio, mailer, ldap, minio-setup) runs
#   under project `penpotdev-infra`.
# - Shared infrastructure (postgres, minio, mailer, ldap, valkey, minio-setup)
#   runs under project `penpotdev-infra`.
# - Each runtime instance (ws0, ws1, ...) runs only its own main container
#   under project `penpotdev-wsN`. All workspaces uniformly overlay their
#   per-instance values via instance-env-overrides injected as shell env
#   variables (no files are written).
# `env -i` strips the ambient shell before invoking docker compose, then we
# re-inject exactly what compose needs. The stripping matters because
# defaults.env is sourced into manage.sh's own shell at startup, so otherwise
# those stale values would leak into substitution. And because Docker Compose
# gives shell-env precedence over --env-file, the re-injected per-instance
# overrides cleanly override the defaults.env baseline. Re-injected: HOME/PATH
# (tooling), CURRENT_USER_ID/PENPOT_SOURCE_PATH (always per-call), and the
# instance-env-overrides block.
function infra-compose {
    env -i HOME="$HOME" PATH="$PATH" PWD="$PWD" \
        docker compose -p penpotdev-infra \
            --env-file "$DEVENV_DEFAULTS_FILE" \
            -f docker/devenv/docker-compose.infra.yml \
            "$@"
}

function instance-compose {
    local instance="$1"; shift
    local source_path
    if [[ "$instance" == "ws0" ]]; then
        source_path="$PWD"
    else
        source_path="$(workspace-path "$instance")"
    fi

    # Per-instance overrides apply to all workspaces uniformly.
    mapfile -t overrides < <(instance-env-overrides "$instance")

    env -i HOME="$HOME" PATH="$PATH" PWD="$PWD" \
        CURRENT_USER_ID="${CURRENT_USER_ID:-$(id -u)}" \
        PENPOT_SOURCE_PATH="$source_path" \
        DEVENV_TAG="$DEVENV_TAG" \
        "${overrides[@]}" \
        docker compose -p "penpotdev-${instance}" \
            --env-file "$DEVENV_DEFAULTS_FILE" \
            -f docker/devenv/docker-compose.main.yml \
            "$@"
}

# Names of currently-running parallel instances (ws0, ws1, ...).
function list-running-instances {
    docker ps --format '{{.Label "com.docker.compose.project"}}' 2>/dev/null \
        | sort -u \
        | grep -oE '^penpotdev-ws[0-9]+$' \
        | sed 's/^penpotdev-//' \
        || true
}

function devenv-main-container {
    local instance="${1:-ws0}"
    # For ws1+, skip compose if the workspace clone doesn't exist yet — the
    # instance has never been set up, so there is no container to find.
    if [[ "$instance" != "ws0" && ! -d "$(workspace-path "$instance")" ]]; then
        return 0
    fi
    instance-compose "$instance" ps -q main 2>/dev/null
}

function devenv-main-running {
    local instance="${1:-ws0}"
    local container
    container=$(devenv-main-container "$instance")
    [[ -n "$container" ]] && [[ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" = "true" ]]
}

# Bring shared infra up and block until minio-setup has provisioned the
# shared MinIO user/policy. Idempotent: a second call when everything is
# already up returns immediately.
function ensure-infra-up {
    infra-compose up -d
    local setup_container
    setup_container=$(infra-compose ps -aq minio-setup 2>/dev/null)
    if [[ -n "$setup_container" ]]; then
        docker wait "$setup_container" >/dev/null 2>&1 || true
    fi
}

# Refuse to sync workspaces if the live repo is in a fragile Git state.
# Copying a partial rebase/merge/cherry-pick into all workspaces would leave
# every instance in the same broken state.
function assert-clean-git-state {
    local fragile=""
    [ -d .git/rebase-apply ] && fragile="$fragile rebase-apply"
    [ -d .git/rebase-merge ] && fragile="$fragile rebase-merge"
    [ -f .git/MERGE_HEAD ]    && fragile="$fragile merge"
    [ -f .git/CHERRY_PICK_HEAD ] && fragile="$fragile cherry-pick"
    [ -f .git/index.lock ]    && fragile="$fragile index.lock"
    if [[ -n "$fragile" ]]; then
        echo "Live repo Git state is unsafe to copy into workspaces:$fragile" >&2
        echo "Finish or abort the in-progress operation, then retry." >&2
        return 1
    fi
}

# Absolute path of the workspace directory for a non-ws0 instance.
function workspace-path {
    local instance="$1"
    echo "${PENPOT_WORKSPACES_DIR}/${instance}"
}

# Echo the host port that <base> maps to for <instance>.
function instance-port {
    local instance="$1"
    local base="$2"
    local n=0
    [[ "$instance" =~ ^ws([0-9]+)$ ]] && n="${BASH_REMATCH[1]}"
    echo $(( base + n * PENPOT_INSTANCE_PORT_STRIDE ))
}

# Echo the per-instance Compose variable overrides for a workspace, one
# KEY=VALUE per line, for instance-compose to inject into its `env -i` line.
# Compose gives shell-env precedence over --env-file, so these override the
# defaults.env baseline. Every value is a pure function of the instance number,
# so nothing is persisted: they are recomputed on every compose invocation and
# can never drift from this logic. Called for every workspace (ws0, ws1, ...).
# All overrides are pure functions of the instance number; no per-instance
# post-processing is needed.
#
# Omitted on purpose: COMPOSE_PROJECT_NAME (set via compose's -p flag),
# PENPOT_SOURCE_PATH (injected directly by instance-compose), and
function instance-env-overrides {
    local instance="$1"
    local n=0
    [[ "$instance" =~ ^ws([0-9]+)$ ]] && n="${BASH_REMATCH[1]}"
    local public_https public mcp mcp_repl serena serena_dash
    public_https=$(instance-port "$instance" "$PENPOT_PORT_BASE_PUBLIC_HTTPS")
    public=$(instance-port "$instance" "$PENPOT_PORT_BASE_PUBLIC")
    mcp=$(instance-port "$instance" "$PENPOT_PORT_BASE_MCP")
    mcp_repl=$(instance-port "$instance" "$PENPOT_PORT_BASE_MCP_REPL")
    serena=$(instance-port "$instance" "$PENPOT_PORT_BASE_SERENA")
    serena_dash=$(instance-port "$instance" "$PENPOT_PORT_BASE_SERENA_DASHBOARD")
    opencode=$(instance-port "$instance" "$PENPOT_PORT_BASE_OPENCODE")
    mdts=$(instance-port "$instance" "$PENPOT_PORT_BASE_MDTS")
    storybook=$(instance-port "$instance" "$PENPOT_PORT_BASE_STORYBOOK")
    printf '%s\n' \
        "PENPOT_MAIN_CONTAINER_NAME=penpot-devenv-${instance}-main" \
        "PENPOT_USER_DATA_VOLUME=penpotdev_${instance}_user_data" \
        "PENPOT_PUBLIC_URI=https://localhost:${public_https}" \
        "PENPOT_REDIS_URI=redis://valkey/${n}" \
        "PENPOT_PUBLIC_HTTPS_PORT=${public_https}" \
        "PENPOT_PUBLIC_HTTP_PORT=${public}" \
        "PENPOT_MCP_SERVER_PORT=${mcp}" \
        "PENPOT_MCP_REPL_PORT=${mcp_repl}" \
        "PENPOT_STORYBOOK_PORT=${storybook}" \
        "SERENA_EXTERNAL_PORT=${serena}" \
        "OPENCODE_EXTERNAL_PORT=${opencode}" \
        "MDTS_EXTERNAL_PORT=${mdts}" \
        "SERENA_DASHBOARD_EXTERNAL_PORT=${serena_dash}" \
        "SHADOW_SERVER_URL=wss://localhost:${public_https}" \
        "PENPOT_TENANT=devenv-${instance}"
}

# Thin wrapper around .devenv/scripts/merge-mcp-config.py for the JSON clients
# (Claude Code, opencode, VS Code). The script does the actual envsubst + JSON
# deep-merge; see its docstring for the contract. ${PENPOT_MCP_PORT} /
# ${SERENA_MCP_PORT} placeholders in the template are resolved from the
# caller's environment. Any extra flags (e.g. --merge-into-existing for the VS
# Code output) are forwarded verbatim.
#
# Codex deliberately has no wrapper here: it cannot load an MCP config from an
# arbitrary file path, so instead of writing a file we inject our servers as
# `-c` overrides built at launch time -- see start-coding-agent.
function _merge-mcp-config-json {
    local shared="$1" tpl="$2" out="$3" key="$4"; shift 4
    python3 .devenv/scripts/merge-mcp-config.py \
        --format json --key "$key" "$@" \
        "$shared" "$tpl" "$out"
}

# Generate the per-workspace AI-client MCP config files by merging the
# committed .devenv/shared/<tool>.* with the port-substituted
# .devenv/templates/<tool>.*. Developers who want to override entries should
# use the client's own override mechanism: Claude Code's local scope, a
# project-level opencode.json, a VS Code user-profile entry, or a user-level
# ~/.codex/config.toml.
#
# Generated paths per tool:
#   <workspace>/.devenv/mcp/claude-code.json   loaded via --mcp-config; clean
#                                              overwrite (dedicated gitignored
#                                              file, no developer content)
#   <workspace>/.devenv/mcp/opencode.json      loaded via OPENCODE_CONFIG=; same
#   <workspace>/.vscode/mcp.json               auto-loaded by VS Code Copilot;
#                                              DEEP-MERGED into any existing file
#                                              so a developer's own entries on
#                                              ws0 survive (ws0's file IS the
#                                              live repo's; ws1+ start fresh).
#                                              Ours win on name collision.
#
# Codex is intentionally NOT generated here. It cannot load an MCP config from
# an arbitrary path, and writing the auto-discovered .codex/config.toml would
# clobber the developer's project-level Codex config on ws0. Instead its
# servers are injected as `-c` overrides at launch -- see start-coding-agent.
function write-instance-mcp-configs {
    local instance="$1"
    local workspace
    if [[ "$instance" == "ws0" ]]; then
        workspace="$PWD"
    else
        workspace=$(workspace-path "$instance")
    fi

    local src_dir="$workspace/.devenv"
    if [[ ! -d "$src_dir/shared" || ! -d "$src_dir/templates" ]]; then
        echo "[$instance] .devenv/shared or .devenv/templates missing under $workspace; skipping MCP config generation." >&2
        return 0
    fi

    local mcp_dir="$src_dir/mcp"
    mkdir -p "$mcp_dir" "$workspace/.vscode"

    PENPOT_MCP_PORT=$(instance-port "$instance" "$PENPOT_PORT_BASE_MCP")
    SERENA_MCP_PORT=$(instance-port "$instance" "$PENPOT_PORT_BASE_SERENA")
    export PENPOT_MCP_PORT SERENA_MCP_PORT

    _merge-mcp-config-json \
        "$src_dir/shared/claude-code.json" \
        "$src_dir/templates/claude-code.json" \
        "$mcp_dir/claude-code.json" \
        mcpServers
    _merge-mcp-config-json \
        "$src_dir/shared/opencode.json" \
        "$src_dir/templates/opencode.json" \
        "$mcp_dir/opencode.json" \
        mcp
    # VS Code's mcp.json is auto-discovered at a fixed path, so on ws0 it IS the
    # developer's own project file -- deep-merge into it rather than overwriting.
    # On ws1+ the path does not exist yet, so this writes it from scratch.
    _merge-mcp-config-json \
        "$src_dir/shared/vscode.json" \
        "$src_dir/templates/vscode.json" \
        "$workspace/.vscode/mcp.json" \
        servers \
        --merge-into-existing
}

# Seed (or re-seed) a workspace from the live repo, then switch it onto a
# unique branch. Two-step sync:
#   1. .git directory is rsync'd directly (so the workspace has its own
#      clone with the developer's current commits / index).
#   2. Working-tree files are enumerated by `git ls-files`, which is the
#      only authoritative source for "what files belong in the working
#      tree" (Git tracks files even when their parent directory matches
#      a gitignore pattern, e.g. .clj-kondo/config.edn). Using rsync's
#      gitignore filter directly misses those.
# Gitignored caches already in the workspace (node_modules, target, etc.)
# are left in place: no --delete on the working-tree pass.
function sync-workspace {
    local instance="$1"
    if [[ "$instance" == "ws0" ]]; then
        return 0
    fi
    assert-clean-git-state || return 1

    local workspace
    workspace=$(workspace-path "$instance")
    mkdir -p "$workspace"

    echo "[$instance] syncing workspace at $workspace ..."

    # .git directory — direct mirror, including index, refs, hooks, etc.
    rsync -a --delete "$PWD/.git/" "$workspace/.git/"

    # Working-tree files: tracked + untracked-not-ignored. git ls-files
    # speaks Git's actual semantics, including the "tracked overrides
    # gitignore" rule. --files-from feeds the path list to rsync verbatim.
    local files
    files=$(mktemp)
    git -C "$PWD" ls-files -z --cached --others --exclude-standard >"$files"
    rsync -a --files-from="$files" --from0 "$PWD/" "$workspace/"
    rm -f "$files"

    # Initial seed of frontend/resources/public/js/config.js. The file is
    # gitignored, so git ls-files would not list it, yet the agentic devenv
    # needs it (enable-mcp flag). After the first sync the workspace copy
    # belongs to the user — subsequent syncs leave it untouched.
    local cfg="frontend/resources/public/js/config.js"
    if [[ -f "$PWD/$cfg" && ! -f "$workspace/$cfg" ]]; then
        install -D "$PWD/$cfg" "$workspace/$cfg"
    fi

    (
        cd "$workspace"
        git switch -C "${instance}/${CURRENT_BRANCH}" >/dev/null
    )
}

function create-devenv {
    pull-devenv-if-not-exists $@;
    ensure-devenv-network;

    infra-compose create
    instance-compose ws0 create
}

# Stop instances. Each workspace is independent and can be stopped in any
# order. Shared infra is shut down only when no instances remain running.
# Flags are mutually exclusive.
#
#   --ws N (N>=1)  stop just that workspace. Leaves ws0 and infra alone.
#   --ws 0 | (no flag)  stop ws0; shuts down shared infra only if no ws1+ is running.
#   --all          stop every wsN highest-first, then ws0, then infra.
function stop-devenv {
    local target=""
    local all=false
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --ws)
                target="$(parse-ws-integer "$2")" || return 1; shift 2;;
            --all)
                all=true; shift;;
            *)
                echo "stop-devenv: unknown argument '$1'" >&2
                return 1;;
        esac
    done
    if [[ -n "$target" && "$all" == "true" ]]; then
        echo "stop-devenv: --ws and --all are mutually exclusive." >&2
        return 1
    fi

    local running ws
    running=$(list-running-instances)

    if [[ "$all" == "true" ]]; then
        # Highest wsN first, then ws0, then infra. ws0 stop also brings infra.
        for ws in $(printf '%s\n' $running | grep -v '^ws0$' | sed 's/^ws//' | sort -rn | sed 's/^/ws/'); do
            stop-instance "$ws"
        done
        if printf '%s\n' $running | grep -qx ws0; then
            stop-instance ws0
        fi
        infra-compose down -t 2
        return 0
    fi

    # Default target: ws0 (which also stops infra).
    [[ -z "$target" ]] && target="ws0"

    if [[ "$target" == "ws0" ]]; then
        if printf '%s\n' $running | grep -qx ws0; then
            stop-instance ws0
        else
            echo "[ws0] not running."
        fi
        # Only stop shared infra when no other instances are still running.
        local remaining=""
        for ws in $(list-running-instances); do
            [[ "$ws" != "ws0" ]] && remaining="$remaining $ws"
        done
        if [[ -z "$remaining" ]]; then
            infra-compose down -t 2
        fi
        return 0
    fi

    # --ws N (N>=1): stop just that instance, leave ws0 + infra up.
    if printf '%s\n' $running | grep -qx "$target"; then
        stop-instance "$target"
    else
        echo "[$target] not running."
    fi
}

# drop-devenv shares stop-devenv's CLI and invariants exactly; the only
# difference is that on a full teardown it also removes the devenv image
# (forcing the next bring-up to re-pull/rebuild). Single-workspace drops
# keep the image because the rest of the workspaces still depend on it.
#
#   --ws N (N >= 1)  delegate to stop-devenv; image is kept.
#   --ws 0 | (none)  delegate to stop-devenv; image is removed.
#   --all            delegate to stop-devenv; image is removed.
function drop-devenv {
    # Parse args ourselves to decide whether the image gets removed.
    # stop-devenv then re-parses the same flags and runs the actual stop.
    local target=""
    local all=false
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --ws)
                target="$(parse-ws-integer "$2")" || return 1; shift 2;;
            --all)
                all=true; shift;;
            *)
                echo "drop-devenv: unknown argument '$1'" >&2
                return 1;;
        esac
    done
    if [[ -n "$target" && "$all" == "true" ]]; then
        echo "drop-devenv: --ws and --all are mutually exclusive." >&2
        return 1
    fi

    local stop_args=()
    [[ -n "$target" ]] && stop_args+=(--ws "${target#ws}")
    [[ "$all" == "true" ]] && stop_args+=(--all)
    stop-devenv "${stop_args[@]}" || return $?

    # Image removal happens for the full-teardown paths only. A single-wsN
    # (N >= 1) drop must keep the image since ws0 and any other wsN still
    # rely on it.
    if [[ -z "$target" || "$target" == "ws0" ]] || [[ "$all" == "true" ]]; then
        echo "Clean old development image $DEVENV_IMGNAME..."
        docker images $DEVENV_IMGNAME -q | xargs --no-run-if-empty docker rmi
    fi
}

function log-devenv {
    local target="ws0"
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --ws)
                target="$(parse-ws-integer "$2")" || return 1; shift 2;;
            *)
                echo "log-devenv: unknown argument '$1'" >&2
                return 1;;
        esac
    done
    instance-compose "$target" logs -f --tail=50
}

# Strict parser for --ws values. Accepts a bare integer in the supported
# range (0..PENPOT_MAX_WS_INDEX) and returns the canonical "wsN" form.
# Anything else fails fast. The upper bound exists because the host ports
# computed for higher N overflow the 16-bit TCP port range:
#   port = base + N * PENPOT_INSTANCE_PORT_STRIDE (10000)
# With the current Serena bases (14181/14182), N = 5 still fits inside
# 65535 (64181/64182) but N = 6 overflows (74181/74182), so the cap is 5.
PENPOT_MAX_WS_INDEX=5
function parse-ws-integer {
    local raw="$1"
    if [[ ! "$raw" =~ ^[0-9]+$ ]]; then
        echo "Invalid --ws value: '$raw' (expected a non-negative integer, e.g. --ws 0, --ws 1, --ws 2)" >&2
        return 1
    fi
    if (( raw > PENPOT_MAX_WS_INDEX )); then
        echo "Invalid --ws value: '$raw' (max supported is --ws $PENPOT_MAX_WS_INDEX; higher indexes would overflow the 16-bit TCP port range)" >&2
        return 1
    fi
    echo "ws$raw"
}


# Bring a single instance up: compose up + detached tmux start. When agentic
# is true (the default) the tmux session gets MCP + Serena enabled; when false
# it is a plain non-agentic workspace (no MCP, no Serena). Workspace sync and
# env-file generation are the caller's responsibility (run-devenv handles
# them for ws1+).
function start-instance {
    local instance="$1"
    local serena_context="${2:-}"
    local git_user_name="${3:-}"
    local git_user_email="${4:-}"
    local agentic="${5:-true}"

    instance-compose "$instance" up -d main

    # Wait briefly for main to be reachable; the tmux session lives inside.
    local container deadline
    container=$(devenv-main-container "$instance")
    deadline=$(( SECONDS + 30 ))
    while ! docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -q true; do
        [[ $SECONDS -ge $deadline ]] && {
            echo "[${instance}] main container did not reach Running within 30s" >&2
            return 1
        }
        sleep 1
    done

    # Ensure /home/penpot is writable by the penpot user before touching
    # any files inside it (e.g. .gitconfig). start-tmux.sh also does this
    # but runs later asynchronously, so the container may still be root-owned
    # from a fresh volume mount at this point.
    docker exec "$container" sudo chown penpot:users /home/penpot 2>/dev/null || true

    # Seed the container's global git config from the values resolved on the
    # host so commits made inside the devenv carry a real author/committer. Empty
    # values are skipped — the host-identity warning is the caller's job.
    if [[ -n "$git_user_name" ]]; then
        docker exec "$container" sudo -u penpot git config --global user.name "$git_user_name"
    fi
    if [[ -n "$git_user_email" ]]; then
        docker exec "$container" sudo -u penpot git config --global user.email "$git_user_email"
    fi

    # Detached tmux so callers don't block on attach. Agentic mode adds the
    # MCP and Serena env vars that start-tmux.sh checks.
    local -a tmux_env=(-e PENPOT_TMUX_ATTACH=false)
    if [[ "$agentic" == "true" ]]; then
        tmux_env+=(
            -e PENPOT_FLAGS="${PENPOT_FLAGS:-} enable-mcp"
            -e SERENA_ENABLED=true
            -e SERENA_CONTEXT="$serena_context"
        )
    fi
    docker exec -d \
        "${tmux_env[@]}" \
        "$container" \
        sudo -EH -u penpot PENPOT_PLUGIN_DEV="${PENPOT_PLUGIN_DEV:-}" /home/start-tmux.sh
}

# Stop and remove one instance's containers without touching its volumes or
# its on-disk workspace directory (rule: never wipe data).
function stop-instance {
    local instance="$1"
    instance-compose "$instance" down -t 2
}

# Print per-instance URLs (Penpot UI, MCP stream endpoint, Serena, attach
# command) for one instance.
function print-instance-info {
    local instance="$1"
    local public mcp serena serena_dash
    public=$(instance-port "$instance" "$PENPOT_PORT_BASE_PUBLIC")
    public_https=$(instance-port "$instance" "$PENPOT_PORT_BASE_PUBLIC_HTTPS")
    mcp=$(instance-port "$instance" "$PENPOT_PORT_BASE_MCP")
    serena=$(instance-port "$instance" "$PENPOT_PORT_BASE_SERENA")
    opencode=$(instance-port "$instance" "$PENPOT_PORT_BASE_OPENCODE")
    serena_dash=$(instance-port "$instance" "$PENPOT_PORT_BASE_SERENA_DASHBOARD")

    # --ws takes a bare integer; ws0 is the default, so its flag is elided.
    local n="${instance#ws}"
    local ws_flag=""
    [[ "$instance" != "ws0" ]] && ws_flag=" --ws ${n}"

    echo
    echo "[$instance]"
    echo "  Penpot UI:           https://localhost:${public_https}"
    echo "  Penpot UI:           http://localhost:${public}"
    echo "  MCP stream:          http://localhost:${mcp}/mcp"
    echo "  OpenCode Server:     http://localhost:${opencode}"
    echo "  Serena MCP:          http://localhost:${serena}"
    echo "  Serena dashboard:    http://localhost:${serena_dash}"
    echo "  Attach:              ./manage.sh attach-devenv${ws_flag}"
    echo "  Coding agent:        ./manage.sh start-coding-agent claude${ws_flag}  (or: opencode|vscode|codex)"
}

# Bring a single workspace up. Without --agentic it's non-agentic (no MCP, no
# Serena); with --agentic it enables MCP + Serena for AI-driven development.
# Supports --ws for parallel workspace targets; ws0 is the default.
function run-devenv {
    local target="ws0"
    local do_sync=false
    local do_attach=false
    local agentic=false
    local serena_context="desktop-app"
    local git_user_name=""
    local git_user_email=""
    local -a extra_env_args=()

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --ws)
                target="$(parse-ws-integer "$2")" || return 1; shift 2;;
            --sync)
                do_sync=true; shift;;
            --agentic)
                agentic=true; shift;;
            --attach)
                do_attach=true; shift;;
            --serena-context)
                serena_context="$2"; shift 2;;
            --git-user-name)
                git_user_name="$2"; shift 2;;
            --git-user-email)
                git_user_email="$2"; shift 2;;
            -e)
                extra_env_args+=(-e "$2"); shift 2;;
            -e*)
                extra_env_args+=(-e "${1#-e}"); shift;;
            -h|--help)
                echo "Usage: run-devenv [--ws N] [--sync] [--attach] [--agentic] [--serena-context CTX] [--git-user-name NAME] [--git-user-email EMAIL] [-e KEY=VAL]"
                echo "  Bring a single workspace up."
                echo "  --ws N                target workspace (default: 0)."
                echo "  --sync                re-seed the wsN clone from the live repo (forbidden on ws0)."
                echo "  --attach              attach to the tmux session after startup."
                echo "  --agentic             enable MCP + Serena (AI-agent mode)."
                echo "  --serena-context CTX  context passed to Serena (default: desktop-app)."
                echo "  --git-user-name NAME  git author name inside the container (default: host git config)."
                echo "  --git-user-email EMAIL  git author email inside the container."
                echo "  -e KEY=VAL            forward env var to docker exec on attach."
                return 0;;
            *)
                echo "run-devenv: unknown argument '$1' (use --help for usage)" >&2
                return 1;;
        esac
    done

    if [[ "$target" == "ws0" && "$do_sync" == "true" ]]; then
        echo "run-devenv: --sync is not allowed on main (ws0)." >&2
        return 1
    fi

    # Pre-flight: config.js must exist for agentic mode. The file is gitignored;
    # without it the frontend never sets 'enable-mcp', so the agent can't drive
    # Penpot via MCP.
    if [[ "$agentic" == "true" ]]; then
        local cfg="frontend/resources/public/js/config.js"
        if [[ ! -f "$PWD/$cfg" ]]; then
            echo "$cfg is missing in the live repo." >&2
            echo "Create it before running with --agentic -- the file is gitignored," >&2
            echo "read directly from \$PWD on ws0 and copied into wsN only on its initial" >&2
            echo "sync. Without it the Penpot frontend will not establish the MCP" >&2
            echo "connection, so the agent cannot drive it. Minimal content:" >&2
            echo "  var penpotFlags = \"enable-mcp\";" >&2
            return 1
        fi
    fi

    # Resolve git identity from the host when flags are omitted.
    if [[ -z "$git_user_name" ]]; then
        git_user_name="$(git config user.name 2>/dev/null || true)"
    fi
    if [[ -z "$git_user_email" ]]; then
        git_user_email="$(git config user.email 2>/dev/null || true)"
    fi
    if [[ -z "$git_user_name" || -z "$git_user_email" ]]; then
        echo "[$target] warning: host git identity is incomplete (name='${git_user_name}', email='${git_user_email}')." >&2
        echo "  Commits made inside the devenv will fail until you set it via --git-user-name / --git-user-email" >&2
        echo "  or 'git config user.{name,email}' on the host." >&2
    fi

    if devenv-main-running "$target"; then
        echo "run-devenv: instance '$target' is already running." >&2
        return 1
    fi

    pull-devenv-if-not-exists
    ensure-devenv-network
    ensure-infra-up

    if [[ "$target" != "ws0" ]]; then
        local workspace
        workspace=$(workspace-path "$target")
        if [[ ! -d "$workspace" ]]; then
            echo "[$target] workspace at $workspace does not exist; performing initial sync."
            do_sync=true
        fi
        if [[ "$do_sync" == "true" ]]; then
            sync-workspace "$target"
        fi
    fi

    if [[ "$agentic" == "true" ]]; then
        write-instance-mcp-configs "$target"
    fi

    echo "Starting $target..."
    start-instance "$target" "$serena_context" "$git_user_name" "$git_user_email" "$agentic"
    print-instance-info "$target"

    if [[ "$do_attach" == "true" ]]; then
        local container
        container=$(devenv-main-container "$target")
        echo "[$target] waiting for tmux session..."
        local deadline=$(( SECONDS + 120 ))
        while ! docker exec "$container" sudo -EH -u penpot tmux has-session -t penpot 2>/dev/null; do
            [[ $SECONDS -ge $deadline ]] && {
                echo "[$target] tmux session did not appear within 120s" >&2
                return 1
            }
            sleep 2
        done
        echo "[$target] attaching to tmux session..."
        docker exec -ti \
            "${extra_env_args[@]}" \
            "$container" sudo -EH -u penpot tmux attach -t penpot
    fi
}

function attach-devenv {
    local instance="ws0"
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --ws)
                instance="$(parse-ws-integer "$2")" || return 1; shift 2;;
            *)
                echo "attach-devenv: unknown argument '$1'" >&2
                return 1;;
        esac
    done

    if ! devenv-main-running "$instance"; then
        echo "Instance '$instance' is not running." >&2
        echo "Start it first with './manage.sh run-devenv [--ws N] [--agentic]'." >&2
        return 1
    fi

    local session="penpot"
    local container
    container=$(devenv-main-container "$instance")

    if ! docker exec "$container" sudo -EH -u penpot tmux has-session -t "$session" 2>/dev/null; then
        echo "No tmux session '$session' inside instance '$instance'." >&2
        echo "The session may still be starting (the workspace's startup script runs the" >&2
        echo "project setup before creating it) or it may have been closed. Wait and retry." >&2
        return 1
    fi

    docker exec -ti "$container" sudo -EH -u penpot tmux attach -t "$session"
}

# Launch an AI coding agent against one parallel devenv workspace with the
# right MCP config wired in. The generated config enhances rather than
# replaces the developer's global client config; see .devenv/README.md for
# the precedence rules and override paths.
#
# Target selection:
#   no flag    → ws0 (the live repo at $PWD).
#   --ws N     → wsN's workspace clone at ${PENPOT_WORKSPACES_DIR}/wsN
#                (N is an integer; non-integer values are rejected).
# Before launching, the function cd's into the resolved workspace and refuses
# to start unless the target instance's 'main' container is up — the Penpot
# and Serena MCP servers only exist while the devenv is running.
#
# Per-client launch behaviour:
#   claude    exec'd with --mcp-config <workspace>/.devenv/mcp/claude-code.json
#   opencode  exec'd with OPENCODE_CONFIG=<workspace>/.devenv/mcp/opencode.json
#   vscode    'code' launched on the workspace; .vscode/mcp.json is
#             auto-discovered by GitHub Copilot
#   codex     'codex' exec'd from the workspace with our servers passed as
#             `-c mcp_servers.<name>....` overrides (built fresh from the
#             committed templates). Nothing is written to .codex/config.toml,
#             so the developer's own Codex config is left untouched.
#
# Usage: ./manage.sh start-coding-agent <claude|opencode|vscode|codex> [--ws N] [...passthrough]
function start-coding-agent {
    local client="${1:-}"
    [[ $# -gt 0 ]] && shift

    case "$client" in
        ""|-h|--help)
            echo "Usage: $0 start-coding-agent <claude|opencode|vscode|codex> [--ws N] [...passthrough]" >&2
            return 1
            ;;
        claude|opencode|vscode|codex)
            ;;
        *)
            echo "start-coding-agent: unknown client '$client' (expected one of claude, opencode, vscode, codex)." >&2
            return 1
            ;;
    esac

    local instance="ws0"
    if [[ $# -gt 0 && "$1" == "--ws" ]]; then
        instance="$(parse-ws-integer "$2")" || return 1
        shift 2
    fi

    # --ws is the default-elided flag: only emit it in suggestion strings for
    # ws1+; ws0 is the default target so 'run-devenv --agentic' is the right hint.
    local ws_flag=""
    [[ "$instance" != "ws0" ]] && ws_flag=" --ws ${instance#ws}"

    # Resolve the workspace directory for the target instance. ws0 binds
    # the live repo; ws1+ are clones under PENPOT_WORKSPACES_DIR.
    local workspace
    if [[ "$instance" == "ws0" ]]; then
        workspace="$PWD"
    else
        workspace="$(workspace-path "$instance")"
        if [[ ! -d "$workspace" ]]; then
            echo "start-coding-agent: workspace for $instance not found at $workspace." >&2
            echo "Bring '$instance' up first with './manage.sh run-devenv${ws_flag} --agentic'." >&2
            return 1
        fi
    fi

    # The MCP servers the agent talks to only exist while 'main' is up.
    # Refuse rather than launch an agent that would error on every tool call.
    if ! devenv-main-running "$instance"; then
        echo "start-coding-agent: instance '$instance' is not running." >&2
        echo "Start it first with './manage.sh run-devenv${ws_flag} --agentic'." >&2
        return 1
    fi

    # Per-client binary + config path (relative to the workspace dir so the
    # launch line in error messages and `exec` is short and stable). Codex has
    # no generated config file -- its servers are injected as `-c` flags below
    # -- so cfg_rel points at the committed template that those flags are built
    # from (present on ws0 in the repo, synced into ws1+).
    local bin cfg_rel
    case "$client" in
        claude)   bin="claude";   cfg_rel=".devenv/mcp/claude-code.json" ;;
        opencode) bin="opencode"; cfg_rel=".devenv/mcp/opencode.json" ;;
        vscode)   bin="code";     cfg_rel=".vscode/mcp.json" ;;
        codex)    bin="codex";    cfg_rel=".devenv/templates/codex.toml" ;;
    esac

    if ! command -v "$bin" >/dev/null 2>&1; then
        echo "start-coding-agent: '$bin' is not on PATH." >&2
        echo "Install it first, then retry." >&2
        return 1
    fi

    if [[ ! -f "$workspace/$cfg_rel" ]]; then
        echo "start-coding-agent: $workspace/$cfg_rel not found." >&2
        echo "Bring '$instance' up with './manage.sh run-devenv${ws_flag} --agentic'," >&2
        echo "which sets up the per-workspace MCP config." >&2
        return 1
    fi

    cd "$workspace" || return 1
    case "$client" in
        claude)
            exec claude --mcp-config "$cfg_rel" "$@"
            ;;
        opencode)
            OPENCODE_CONFIG="$cfg_rel" exec opencode "$@"
            ;;
        vscode)
            exec code "$workspace" "$@"
            ;;
        codex)
            # Build our servers as `-c mcp_servers.<name>....` overrides from the
            # committed templates (ports resolved from the instance number) and
            # pass them on the command line. Nothing is written to disk, so the
            # developer's project- or user-level .codex/config.toml is untouched.
            PENPOT_MCP_PORT=$(instance-port "$instance" "$PENPOT_PORT_BASE_MCP")
            SERENA_MCP_PORT=$(instance-port "$instance" "$PENPOT_PORT_BASE_SERENA")
            export PENPOT_MCP_PORT SERENA_MCP_PORT
            local -a codex_args=()
            local _assignment
            while IFS= read -r _assignment; do
                [[ -n "$_assignment" ]] && codex_args+=(-c "$_assignment")
            done < <(python3 .devenv/scripts/merge-mcp-config.py --format codex-args \
                        .devenv/shared/codex.toml \
                        .devenv/templates/codex.toml)
            if [[ ${#codex_args[@]} -eq 0 ]]; then
                echo "start-coding-agent: failed to build Codex MCP overrides from" >&2
                echo "  .devenv/{shared,templates}/codex.toml." >&2
                return 1
            fi
            exec codex "${codex_args[@]}" "$@"
            ;;
    esac
}

function build-imagemagick-docker-image {
    set +e;

    local custom_tag=""
    local do_push=false

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --tag)
                custom_tag="$2"; shift 2;;
            --push)
                do_push=true; shift;;
            --local)
                # Kept for backward compatibility -- local-only is now the
                # default behaviour, so this flag is a no-op.
                shift;;
            *)
                echo "build-imagemagick-docker-image: unknown argument '$1'" >&2
                return 1;;
        esac
    done

    pushd docker/imagemagick;

    # Without --tag, keep the historical behaviour of tagging both ':latest'
    # and the pinned IMAGEMAGICK_VERSION. With --tag, tag ONLY the custom
    # value -- it's meant to be an isolated/test tag, not a stand-in for latest.
    local -a tag_args=()
    if [[ -n "$custom_tag" ]]; then
        tag_args=(-t "penpotapp/imagemagick:$custom_tag")
        echo "Building image penpotapp/imagemagick:$custom_tag"
    else
        tag_args=(-t "penpotapp/imagemagick:latest" -t "penpotapp/imagemagick:$IMAGEMAGICK_VERSION")
        echo "Building image penpotapp/imagemagick:$IMAGEMAGICK_VERSION"
    fi

    local output_option="type=docker";
    local platform="linux/$ARCH";
    local -a attestation_args=();

    if [[ "$do_push" == "true" ]]; then
        output_option="type=registry";
        platform="linux/amd64,linux/arm64";
        # Attestations (SBOM/provenance) only work when pushing to a
        # registry -- the local image store doesn't support loading images
        # with attestations, so these are only added on this branch.
        attestation_args=(--provenance=mode=max --sbom=true);
        echo "Will push to registry"
    else
        echo "Local build only (pass --push to publish to the registry)"
    fi

    setup-buildx;

    docker buildx build \
      --build-arg IMAGEMAGICK_VERSION=$IMAGEMAGICK_VERSION \
      --platform $platform \
      --output $output_option \
      "${attestation_args[@]}" \
      "${tag_args[@]}" .;

    popd;
}

function build {
    echo ">> build start: $1"
    local version=$(print-current-version);
    local script=${2:-build}

    pull-devenv-if-not-exists;
    docker volume create ${PENPOT_USER_DATA_VOLUME};
    docker run -t --rm \
           --mount source=${PENPOT_USER_DATA_VOLUME},type=volume,target=/home/penpot/ \
           --mount source=`pwd`,type=bind,target=/home/penpot/penpot \
           -e EXTERNAL_UID=$CURRENT_USER_ID \
           -e BUILD_STORYBOOK=$BUILD_STORYBOOK \
           -e BUILD_WASM=$BUILD_WASM \
           -e SHADOWCLJS_EXTRA_PARAMS=$SHADOWCLJS_EXTRA_PARAMS \
           -e JAVA_OPTS="$JAVA_OPTS" \
           -w /home/penpot/penpot/$1 \
           $DEVENV_IMGNAME:$DEVENV_TAG sudo -EH -u penpot ./scripts/$script $version

    echo ">> build end: $1"
}

function put-license-file {
    local target=$1;
    tee -a $target/LICENSE  >> /dev/null <<EOF
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.

Copyright (c) KALEIDOS INC Sucursal en España SL
EOF
}

function build-frontend-bundle {
    echo ">> bundle frontend start";

    mkdir -p ./bundles
    local version=$(print-current-version);
    local bundle_dir="./bundles/frontend";

    build "frontend";

    rm -rf $bundle_dir;
    mv ./frontend/target/dist $bundle_dir;
    echo $version > $bundle_dir/version.txt;
    put-license-file $bundle_dir;
    echo ">> bundle frontend end";
}

function build-mcp-bundle {
    echo ">> bundle mcp start";

    mkdir -p ./bundles
    local version=$(print-current-version);
    local bundle_dir="./bundles/mcp";

    build "mcp";

    rm -rf $bundle_dir;
    mv ./mcp/dist $bundle_dir;
    echo $version > $bundle_dir/version.txt;
    put-license-file $bundle_dir;
    echo ">> bundle mcp end";
}


function build-backend-bundle {
    echo ">> bundle backend start";

    mkdir -p ./bundles
    local version=$(print-current-version);
    local bundle_dir="./bundles/backend";

    build "backend";

    rm -rf $bundle_dir;
    mv ./backend/target/dist $bundle_dir;
    echo $version > $bundle_dir/version.txt;
    put-license-file $bundle_dir;
    echo ">> bundle backend end";
}

function build-exporter-bundle {
    echo ">> bundle exporter start";

    mkdir -p ./bundles
    local version=$(print-current-version);
    local bundle_dir="./bundles/exporter";

    build "exporter";

    rm -rf $bundle_dir;
    mv ./exporter/target $bundle_dir;
    echo $version > $bundle_dir/version.txt
    put-license-file $bundle_dir;
    echo ">> bundle exporter end";
}

function build-storybook-bundle {
    echo ">> bundle storybook start";

    mkdir -p ./bundles
    local version=$(print-current-version);
    local bundle_dir="./bundles/storybook";

    build "frontend" "build-storybook";

    rm -rf $bundle_dir;
    mv ./frontend/storybook-static $bundle_dir;
    echo $version > $bundle_dir/version.txt;
    put-license-file $bundle_dir;
    echo ">> bundle storybook end";
}

function build-docs-bundle {
    echo ">> bundle docs start";

    mkdir -p ./bundles
    local version=$(print-current-version);
    local bundle_dir="./bundles/docs";

    build "docs";

    rm -rf $bundle_dir;
    mv ./docs/_dist $bundle_dir;
    echo $version > $bundle_dir/version.txt;
    put-license-file $bundle_dir;
    echo ">> bundle docs end";
}

# Shared helper for the release-image builds below (frontend/backend/exporter/
# mcp/storybook). Parses --tag; without it, tags with both $CURRENT_BRANCH and
# 'latest' (historical default, unchanged). With --tag, tags ONLY that custom
# value -- same convention as build-devenv/build-imagemagick-docker-image: a
# custom tag is for an isolated/test build, not a stand-in for latest.
function _build-release-docker-image {
    local image="$1" bundle_dir="$2" dockerfile="$3"; shift 3
    local custom_tag=""

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --tag)
                custom_tag="$2"; shift 2;;
            *)
                echo "build-${image}-docker-image: unknown argument '$1'" >&2
                return 1;;
        esac
    done

    local -a tag_args=()
    if [[ -n "$custom_tag" ]]; then
        tag_args=(-t "penpotapp/${image}:$custom_tag")
    else
        tag_args=(-t "penpotapp/${image}:$CURRENT_BRANCH" -t "penpotapp/${image}:latest")
    fi

    rsync -avr --delete "./bundles/${image}/" "./docker/images/${bundle_dir}/";
    pushd ./docker/images;
    docker build \
        "${tag_args[@]}" \
        --build-arg BUNDLE_PATH="./${bundle_dir}/" \
        -f "$dockerfile" .;
    popd;
}

function build-frontend-docker-image {
    _build-release-docker-image frontend bundle-frontend Dockerfile.frontend "$@"
}

function build-backend-docker-image {
    _build-release-docker-image backend bundle-backend Dockerfile.backend "$@"
}

function build-exporter-docker-image {
    _build-release-docker-image exporter bundle-exporter Dockerfile.exporter "$@"
}

function build-mcp-docker-image {
    _build-release-docker-image mcp bundle-mcp Dockerfile.mcp "$@"
}

function build-storybook-docker-image {
    _build-release-docker-image storybook bundle-storybook Dockerfile.storybook "$@"
}

function usage {
    echo "PENPOT build & release manager"
    echo "USAGE: $0 OPTION"
    echo ""
    echo "Development environment (devenv)"
    echo "--------------------------------"
    echo "The devenv runs Penpot in a Docker container and supports parallel"
    echo "'workspaces': ws0 (the live repo at \$PWD) and optional wsN (N >= 1, sibling"
    echo "clones). Use --ws N to target a specific workspace; the default is 0."
    echo "Full guide: docs/technical-guide/developer/{devenv,agentic-devenv}.md."
    echo ""
    echo "Image tag: all devenv commands use \$DEVENV_TAG (default: latest). To work"
    echo "against a custom tag end-to-end (build, then run), export it before both"
    echo "calls: 'export DEVENV_TAG=mytest; ./manage.sh build-devenv; ./manage.sh run-devenv'."
    echo "'build-devenv --tag mytest' only affects that single build call otherwise."
    echo ""
    echo "Image lifecycle"
    echo "- pull-devenv                      Pull the devenv docker image from the registry."
    echo "- build-devenv [--tag TAG] [--push]"
    echo "                                   Build the devenv docker image. Local-only by default (single-"
    echo "                                   platform, 'docker build'); pass --push to build multi-platform"
    echo "                                   (amd64+arm64) and push to the registry instead. --tag TAG overrides"
    echo "                                   the image tag (default: latest)."
    echo ""
    echo "Bring a devenv up / down"
    echo "- run-devenv                       Bring one workspace up, start its tmux session in the background,"
    echo "                                   and print the workspace's URLs. Pass --agentic to enable MCP + Serena"
    echo "                                   for AI-driven development (also regenerates per-workspace MCP configs)."
    echo "                                   Options:"
    echo "                                     --ws N                target workspace (default: 0)."
    echo "                                     --sync                re-seed the wsN clone from the live repo before"
    echo "                                                           starting (forbidden on ws0; implicit on first"
    echo "                                                           start of a wsN with no on-disk workspace yet)."
    echo "                                     --attach              attach to the tmux session after startup."
    echo "                                     --agentic             enable MCP + Serena (AI-agent mode)."
    echo "                                     --serena-context CTX  passed to Serena (default: desktop-app)."
    echo "                                     -e KEY=VAL            forwarded to 'docker exec' on attach."
    echo "                                     --git-user-name NAME / --git-user-email EMAIL"
    echo "                                                           identity wired into the container's git config"
    echo "                                                           (default: host's effective 'git config user.X',"
    echo "                                                           honouring per-repo local overrides; see"
    echo "                                                           devenv.md > 'Git identity inside the container')."
    echo "- create-devenv                    Create ws0 + shared-infra compose services without starting them."
    echo "- stop-devenv                      Stop one or more workspaces. Shared infra stops when no instances remain."
    echo "                                   Options: --ws N (stop wsN) | (no flag) (stop ws0; shared infra only"
    echo "                                            if no other ws is running) | --all (stop every wsN highest"
    echo "                                            first, then ws0 + infra)."
    echo "- drop-devenv                      Same CLI and invariants as stop-devenv (see above), plus removal of"
    echo "                                   the shared devenv image on full teardowns (no flag, --ws 0, or --all)."
    echo "                                   A single --ws N (N >= 1) keeps the image since other workspaces still"
    echo "                                   need it."
    echo "- log-devenv                       Tail a workspace's compose logs."
    echo "                                   Options: --ws N (default: 0)."
    echo ""
    echo "Work inside a running devenv"
    echo "- attach-devenv                    Attach to the tmux session inside a running workspace."
    echo "                                   Options: --ws N (default: 0)."
    echo "- start-coding-agent <client>      Launch an AI coding agent against one workspace with the right MCP"
    echo "                                   config wired in. cd's into the workspace, refuses to launch if the"
    echo "                                   instance is not running, and forwards extra args to the client."
    echo "                                   client: claude | opencode | vscode | codex"
    echo "                                   Options: --ws N (default: 0). See agentic-devenv.md and"
    echo "                                   .devenv/README.md for per-client setup and override paths."
    echo ""
    echo "- build-bundle                     Build all bundles (frontend, backend, exporter, storybook and mcp)."
    echo "- build-frontend-bundle            Build frontend bundle"
    echo "- build-backend-bundle             Build backend bundle."
    echo "- build-exporter-bundle            Build exporter bundle."
    echo "- build-mcp-bundle                 Build mcp bundle."
    echo "- build-storybook-bundle           Build storybook bundle."
    echo "- build-docs-bundle                Build docs bundle."
    echo ""
    echo "- build-docker-images [--tag TAG]  Build all docker images (frontend, backend, exporter, mcp and storybook)."
    echo "                                   Without --tag, each image is tagged with both the current git branch"
    echo "                                   and 'latest' (unchanged default). With --tag, ONLY that custom tag"
    echo "                                   is applied, to all five images."
    echo "- build-frontend-docker-image [--tag TAG]   Build frontend docker image."
    echo "- build-backend-docker-image [--tag TAG]    Build backend docker image."
    echo "- build-exporter-docker-image [--tag TAG]   Build exporter docker image."
    echo "- build-mcp-docker-image [--tag TAG]         Build mcp docker image."
    echo "- build-storybook-docker-image [--tag TAG]  Build storybook docker image."
    echo "- build-imagemagick-docker-image [--tag TAG] [--push]"
    echo "                                   Build the imagemagick docker image. Local-only by default (single-"
    echo "                                   platform); pass --push to build multi-platform (amd64+arm64) and"
    echo "                                   push to the registry instead. --tag TAG builds ONLY that tag"
    echo "                                   (default without --tag: both 'latest' and \$IMAGEMAGICK_VERSION)."
    echo ""
    echo "- version                          Show penpot's version."
}

case $1 in
    version)
        print-current-version
        ;;

    ## devenv related commands
    pull-devenv)
        pull-devenv ${@:2};
        ;;

    build-devenv)
        shift;
        build-devenv $@;
        ;;

    create-devenv)
        create-devenv ${@:2}
        ;;

    run-devenv)
        run-devenv ${@:2}
        ;;
    run-devenv-agentic)
        run-devenv --agentic ${@:2}
        ;;
    attach-devenv)
        attach-devenv ${@:2}
        ;;
    start-coding-agent)
        start-coding-agent "${@:2}"
        ;;
    stop-devenv)
        stop-devenv ${@:2}
        ;;
    drop-devenv)
        drop-devenv ${@:2}
        ;;
    log-devenv)
        log-devenv ${@:2}
        ;;

    ## production builds
    build-bundle)
        build-frontend-bundle;
        build-mcp-bundle;
        build-backend-bundle;
        build-exporter-bundle;
        build-storybook-bundle;
        ;;

    build-frontend-bundle)
        build-frontend-bundle;
        ;;

    build-mcp-bundle)
        build-mcp-bundle;
        ;;

    build-backend-bundle)
        build-backend-bundle;
        ;;

    build-exporter-bundle)
        build-exporter-bundle;
        ;;

    build-storybook-bundle)
        build-storybook-bundle;
        ;;

    build-docs-bundle)
        build-docs-bundle;
        ;;

    build-docker-images)
        build-frontend-docker-image "${@:2}"
        build-backend-docker-image "${@:2}"
        build-exporter-docker-image "${@:2}"
        build-mcp-docker-image "${@:2}"
        build-storybook-docker-image "${@:2}"
        ;;

    build-frontend-docker-image)
        build-frontend-docker-image "${@:2}"
        ;;

    build-backend-docker-image)
        build-backend-docker-image "${@:2}"
        ;;

    build-exporter-docker-image)
        build-exporter-docker-image "${@:2}"
        ;;

    build-mcp-docker-image)
        build-mcp-docker-image "${@:2}"
        ;;

    build-storybook-docker-image)
        build-storybook-docker-image "${@:2}"
        ;;

    build-imagemagick-docker-image)
        shift;
        build-imagemagick-docker-image $@;
        ;;

    *)
        usage
        ;;
esac
