#!/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"; # 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} # 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-13 # 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--bundle project a module's build output into ./bundles/ # build--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; pushd docker/devenv; if [ "$1" = "--local" ]; then echo "Build local only $DEVENV_IMGNAME:latest image"; docker build -t $DEVENV_IMGNAME:latest .; else echo "Build and push $DEVENV_IMGNAME:latest image"; setup-buildx; docker buildx build \ --platform linux/amd64,linux/arm64 \ --output type=registry \ -t $DEVENV_IMGNAME:latest .; docker pull $DEVENV_IMGNAME:latest; fi popd; } function pull-devenv { set -ex docker pull $DEVENV_IMGNAME:latest } function pull-devenv-if-not-exists { if [[ ! $(docker images $DEVENV_IMGNAME:latest -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" \ "${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 maps to for . 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") 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}" \ "SERENA_EXTERNAL_PORT=${serena}" \ "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/.* with the port-substituted # .devenv/templates/.*. 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: # /.devenv/mcp/claude-code.json loaded via --mcp-config; clean # overwrite (dedicated gitignored # file, no developer content) # /.devenv/mcp/opencode.json loaded via OPENCODE_CONFIG=; same # /.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. ws0 is the worker-bearer and must be the last one to stop; # shared infra is shut down together with ws0. Flags are mutually exclusive. # # --ws N (N>=1) stop just that workspace. Leaves ws0 and infra alone. # --ws 0 | (no flag) stop ws0 + shared infra. Refused if any 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 local non_main="" for ws in $running; do [[ "$ws" != "ws0" ]] && non_main="$non_main $ws" done if [[ -n "$non_main" ]]; then echo "stop-devenv: cannot stop ws0 while other instances are running:${non_main}." >&2 echo "Stop them first (--ws N) or use --all." >&2 return 1 fi if printf '%s\n' $running | grep -qx ws0; then stop-instance ws0 else echo "[ws0] not running." fi infra-compose down -t 2 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") 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 " 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 /.devenv/mcp/claude-code.json # opencode exec'd with OPENCODE_CONFIG=/.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.....` 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 [--ws N] [...passthrough] function start-coding-agent { local client="${1:-}" [[ $# -gt 0 ]] && shift case "$client" in ""|-h|--help) echo "Usage: $0 start-coding-agent [--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.....` 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; echo "Building image penpotapp/imagemagick:$IMAGEMAGICK_VERSION" pushd docker/imagemagick; output_option="type=registry"; platform="linux/amd64,linux/arm64"; if [ "$1" = "--local" ]; then output_option="type=docker"; platform="linux/$ARCH" fi setup-buildx; docker buildx build \ --build-arg IMAGEMAGICK_VERSION=$IMAGEMAGICK_VERSION \ --platform $platform \ --output $output_option \ -t penpotapp/imagemagick:latest \ -t penpotapp/imagemagick:$IMAGEMAGICK_VERSION .; 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:latest sudo -EH -u penpot ./scripts/$script $version echo ">> build end: $1" } function put-license-file { local target=$1; tee -a $target/LICENSE >> /dev/null <> 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"; } function build-frontend-docker-image { rsync -avr --delete ./bundles/frontend/ ./docker/images/bundle-frontend/; pushd ./docker/images; docker build \ -t penpotapp/frontend:$CURRENT_BRANCH -t penpotapp/frontend:latest \ --build-arg BUNDLE_PATH="./bundle-frontend/" \ -f Dockerfile.frontend .; popd; } function build-backend-docker-image { rsync -avr --delete ./bundles/backend/ ./docker/images/bundle-backend/; pushd ./docker/images; docker build \ -t penpotapp/backend:$CURRENT_BRANCH -t penpotapp/backend:latest \ --build-arg BUNDLE_PATH="./bundle-backend/" \ -f Dockerfile.backend .; popd; } function build-exporter-docker-image { rsync -avr --delete ./bundles/exporter/ ./docker/images/bundle-exporter/; pushd ./docker/images; docker build \ -t penpotapp/exporter:$CURRENT_BRANCH -t penpotapp/exporter:latest \ --build-arg BUNDLE_PATH="./bundle-exporter/" \ -f Dockerfile.exporter .; popd; } function build-mcp-docker-image { rsync -avr --delete ./bundles/mcp/ ./docker/images/bundle-mcp/; pushd ./docker/images; docker build \ -t penpotapp/mcp:$CURRENT_BRANCH -t penpotapp/mcp:latest \ --build-arg BUNDLE_PATH="./bundle-mcp/" \ -f Dockerfile.mcp .; popd; } function build-storybook-docker-image { rsync -avr --delete ./bundles/storybook/ ./docker/images/bundle-storybook/; pushd ./docker/images; docker build \ -t penpotapp/storybook:$CURRENT_BRANCH -t penpotapp/storybook:latest \ --build-arg BUNDLE_PATH="./bundle-storybook/" \ -f Dockerfile.storybook .; popd; } 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 lifecycle" echo "- pull-devenv Pull the devenv docker image from the registry." echo "- build-devenv [--local] Build the devenv docker image (--local skips the registry push)." 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 with the last one." echo " Options: --ws N (stop wsN, N >= 1) | (no flag) (stop ws0 + infra;" echo " refused if any wsN is still running) | --all (stop every wsN" echo " highest 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 " Refused if any wsN (N >= 1) is still running. A single --ws N (N >= 1)" echo " keeps the image since other workspaces still 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 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-storybook-bundle Build storybook bundle." echo "- build-mcp-bundle Build mcp bundle." echo "- build-docs-bundle Build docs bundle." echo "" echo "- build-docker-images Build all docker images (frontend, backend and exporter)." echo "- build-frontend-docker-image Build frontend docker images." echo "- build-backend-docker-image Build backend docker images." echo "- build-exporter-docker-image Build exporter docker images." echo "- build-mcp-docker-image Build exporter docker images." echo "- build-storybook-docker-image Build storybook docker images." 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-imagemagick-docker-image) shift; build-imagemagick-docker-image $@; ;; build-docker-images) build-frontend-docker-image build-backend-docker-image build-exporter-docker-image build-mcp-docker-image build-storybook-docker-image ;; build-frontend-docker-image) build-frontend-docker-image ;; build-backend-docker-image) build-backend-docker-image ;; build-exporter-docker-image) build-exporter-docker-image ;; build-mcp-docker-image) build-mcp-docker-image ;; build-storybook-docker-image) build-storybook-docker-image ;; *) usage ;; esac