penpot/manage.sh
Dr. Dominik Jain e20a0d748e
✨ Add OAuth support for MCP server (#12480)
* 🎉 Add OAuth support for remote MCP connections

Add MCP-specific discovery, client registration, and approval endpoints.
Exchange single-use authorization codes with PKCE for the fixed MCP user
token, enabling MCP and creating the token on approval when needed.

Use self-contained client IDs without new database tables or stored client
records. Keep temporary authorization state in Redis/Valkey.

Accept bearer credentials while preserving query-parameter connections.
Enable OAuth through PENPOT_MCP_AUTH_URI in multi-user mode. Document the
environment variable in the README and implementation details in Serena.

Explain protocol and concurrency decisions in the Clojure code. Cover token
provisioning, OAuth state, client registration, HTTP flows, and the approval
page with tests.

AI-assisted-by: gpt-6

* 🐛 Wait for devenv initialization before starting builds

Docker reports a container as Running as soon as its main process starts,
while the entrypoint may still be preparing the development environment.
manage.sh used that status to launch tmux jobs, allowing frontend builds
to race with ownership changes to /opt/rustup and Cargo setup.

On a fresh container, rustup could fail with Permission denied before the
frontend watcher started. Nginx would later serve the existing JavaScript,
leaving source changes absent from the running app despite restarting
devenv.

Wait for nginx's local health endpoint before launching the jobs. Nginx
starts after entrypoint setup, so its response confirms that setup has
finished. Allow up to 120 seconds and report a timeout without starting
the jobs if initialization does not complete.

Validation: shell syntax and whitespace checks pass.

AI-assisted-by: gpt-6

* ♻️ Share the login modal with MCP authorization

Extract the viewer login modal and its styles into a shared auth
component, retaining the viewer wrapper and its close behavior.

Use the modal on the MCP approval page and preserve the pending request
through login. Closing it clears the saved return URL and shows a
cancellation message without further actions.

Cover return URL handling and cancellation with frontend tests.

AI-assisted-by: gpt-6

* ✨ Show client icons in MCP authorization

Preserve HTTPS logo URLs from client registration and published metadata
and show the icon beside the application name on the approval page.
Ignore unusable URLs and hide failed images without blocking approval.

Update the access description to refer to MCP connections established
from Penpot. Cover logo metadata and approval responses in backend tests.

AI-assisted-by: gpt-6

* 🐛 Follow the system theme when no preference is configured

Default to the system theme in the shared resolver used by the app and
plugins. Preserve explicit light and dark choices, including the legacy
default value that means dark.

Reuse the reactive theme hook in progress widgets and show the system
preference in settings for accounts without a configured theme.

Verify preference resolution with regression tests and browser checks.
All 1,474 frontend tests pass.

AI-assisted-by: gpt-6

* 🐛 Replace expired MCP tokens during OAuth approval

Settings can give an MCP token an expiry date. OAuth approval reused
the newest MCP token without checking it, so an expired token reached
the client and never matched a plugin connection.

Approval now treats an expired token as missing and creates a new one.
Token exchange rejects a code whose token expired after approval with
invalid_grant.

AI-assisted-by: claude-opus-5-5

* 🐛 Log unexpected errors on MCP OAuth endpoints

The OAuth routes sit outside the shared HTTP middleware, so the shared
error handler never saw them, and their own handler turned unexpected
failures into a 503 without logging anything. Failures in the session
middleware of the approval endpoints had no handler at all.

Unexpected failures now also go through the shared error handler for
its logging, while clients still get an OAuth error body. The OAuth
error handler also wraps the session middleware of the approval
endpoints.

AI-assisted-by: claude-opus-5-5

* 🔧 Add devenv script to run the MCP server with OAuth

start-mcp-devenv-oauth starts the MCP server in multi-user mode with
PENPOT_MCP_AUTH_URI derived from PENPOT_PUBLIC_URI, to test OAuth in
the devenv. Agentic tooling still uses the single-user
start-mcp-devenv.

AI-assisted-by: claude-opus-5-5

* 🐛 Fix failing test case

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-10-28 18:40:04 +01:00

1671 lines
66 KiB
Bash
Executable File

#!/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-32
# 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
# instance-test-db-name test database name for one instance
# instance-test-redis-db Valkey DB number for one instance's tests
# ensure-instance-test-database create one instance's test database
# 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, RustFS, mailer, LDAP, Valkey)
# 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), the
# optional PENPOT_OPENCODE_CONFIG_DIR (set only by run-devenv's
# --opencode-config-dir within this process), 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")
# Optional personal-opencode-config overlay: the extra -f and variable
# are only present when run-devenv --opencode-config-dir resolved a host
# directory into PENPOT_OPENCODE_CONFIG_DIR; when unset neither the file
# nor the variable is referenced, so default behaviour is unchanged.
local -a compose_files=(-f docker/devenv/docker-compose.main.yml)
local -a opencode_env=()
if [[ -n "${PENPOT_OPENCODE_CONFIG_DIR:-}" ]]; then
compose_files+=(-f docker/devenv/docker-compose.opencode.yml)
opencode_env=("PENPOT_OPENCODE_CONFIG_DIR=${PENPOT_OPENCODE_CONFIG_DIR}")
fi
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" \
"${opencode_env[@]}" \
"${overrides[@]}" \
docker compose -p "penpotdev-${instance}" \
--env-file "$DEVENV_DEFAULTS_FILE" \
"${compose_files[@]}" \
"$@"
}
# 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 services with healthchecks are healthy.
# Removing orphaned containers retires old infra services without deleting
# their named volumes.
function ensure-infra-up {
infra-compose up -d --wait --wait-timeout 60 --remove-orphans
}
# Create the backend-test database for <instance> on the shared Postgres
# if it does not exist yet. Idempotent: safe to run on every bring-up.
# Needed because postgresql_init.sql only runs when the Postgres volume is
# created for the first time, so per-instance test databases (which did not
# exist back then) would otherwise never be created on existing volumes.
# The backend applies migrations itself on first use, so an empty database
# is enough here. (Two plain SQL round-trips on purpose: psql -c does not
# reliably mix SQL with psql-only commands like \gexec.)
function ensure-instance-test-database {
local instance="$1"
local dbname
dbname=$(instance-test-db-name "$instance")
local pg_container
pg_container=$(infra-compose ps -q postgres 2>/dev/null)
if [[ -z "$pg_container" ]]; then
echo "[${instance}] postgres container not found; is shared infra up?" >&2
return 1
fi
local -a psql_base=(docker exec
-e "PGPASSWORD=${PENPOT_DATABASE_PASSWORD:-penpot}"
"$pg_container"
psql -h 127.0.0.1 -U "${PENPOT_DATABASE_USERNAME:-penpot}"
-d postgres -v ON_ERROR_STOP=1 -tA)
local exists
exists=$("${psql_base[@]}" -c "SELECT 1 FROM pg_database WHERE datname = '${dbname}'")
if [[ "$exists" == "1" ]]; then
echo "[${instance}] test database ${dbname} already exists."
return 0
fi
echo "[${instance}] creating test database ${dbname} ..."
"${psql_base[@]}" -c "CREATE DATABASE \"${dbname}\""
}
# 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 backend-test database name for <instance>. ws0 keeps the
# historical name so existing flows stay untouched; ws1+ get an isolated
# database each on the shared Postgres server. The main database stays
# shared by all instances on purpose.
function instance-test-db-name {
local instance="$1"
local n=0
[[ "$instance" =~ ^ws([0-9]+)$ ]] && n="${BASH_REMATCH[1]}"
if (( n == 0 )); then
echo "penpot_test"
else
echo "penpot_test_ws${n}"
fi
}
# Echo the Valkey DB number reserved for the backend tests of <instance>.
# Runtime backends already occupy 0..PENPOT_MAX_WS_INDEX on the shared
# Valkey, so test DBs start right after to never share a DB with a
# running backend. (Resolved at call time; PENPOT_MAX_WS_INDEX is defined
# further below but always set before any call.)
function instance-test-redis-db {
local instance="$1"
local n=0
[[ "$instance" =~ ^ws([0-9]+)$ ]] && n="${BASH_REMATCH[1]}"
echo $(( PENPOT_MAX_WS_INDEX + 1 + n ))
}
# 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")
test_db=$(instance-test-db-name "$instance")
test_redis_db=$(instance-test-redis-db "$instance")
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_TEST_DATABASE_URI=postgresql://postgres/${test_db}" \
"PENPOT_TEST_REDIS_URI=redis://valkey/${test_redis_db}" \
"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
# Initial seed of the Claude Code skills link. It is gitignored, so git
# ls-files does not list it, yet Claude Code reads skills from
# .claude/skills alone. Seeded only when absent: afterwards the
# workspace copy belongs to the user, who may keep their own. No
# CLAUDE.md is seeded: Claude Code reads AGENTS.md itself in a project
# that has none, and a CLAUDE.md here would switch that off.
if [[ ! -e "$workspace/.claude/skills" && ! -L "$workspace/.claude/skills" ]]; then
mkdir -p "$workspace/.claude"
ln -s ../.agents/skills "$workspace/.claude/skills"
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"
}
# Strict parser for --opencode-config-dir. Resolves the value to an absolute
# host directory (docker compose resolves relative bind-mount sources against
# the compose file's directory, not $PWD, so relative values would be
# misinterpreted) and verifies it exists. Echoes the absolute path; anything
# else fails fast.
function parse-opencode-config-dir {
local raw="$1"
if [[ -z "$raw" ]]; then
echo "Invalid --opencode-config-dir: value is empty." >&2
return 1
fi
raw="${raw/#\~/$HOME}"
local abs
if ! abs=$(realpath -e "$raw" 2>/dev/null) || [[ ! -d "$abs" ]]; then
echo "Invalid --opencode-config-dir: '$raw' is not an existing directory." >&2
return 1
fi
echo "$abs"
}
# 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}"
ensure-instance-test-database "$instance"
instance-compose "$instance" up -d main
# Running does not mean entrypoint setup has finished. Nginx starts only
# after ownership and Cargo setup, so wait for its local health endpoint
# before launching builds as penpot (especially on a fresh container).
local container deadline
container=$(devenv-main-container "$instance")
deadline=$(( SECONDS + 120 ))
while ! docker exec "$container" curl --fail --silent --max-time 2 \
--output /dev/null http://127.0.0.1:8082/stub_status 2>/dev/null; do
[[ $SECONDS -ge $deadline ]] && {
echo "[${instance}] main container initialization did not finish within 120s; check docker logs $container" >&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 opencode_config_dir=""
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;;
--opencode-config-dir)
opencode_config_dir="$(parse-opencode-config-dir "$2")" || return 1; 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] [--opencode-config-dir DIR] [--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 " --opencode-config-dir DIR bind-mount DIR at ~/.config/opencode inside the"
echo " container (personal agents/prompts/skills kept in a"
echo " separate repo). Applied at container creation."
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
# Scope the personal opencode config to this process only: instance-compose
# includes the overlay compose file and the variable just when it is set,
# so instances brought up without the flag mount nothing.
if [[ -n "$opencode_config_dir" ]]; then
echo "[$target] mounting personal opencode config: $opencode_config_dir -> ~/.config/opencode"
export PENPOT_OPENCODE_CONFIG_DIR="$opencode_config_dir"
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 SUBSIDIARY 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-media-processor-bundle {
echo ">> bundle media-processor start";
mkdir -p ./bundles
local version=$(print-current-version);
local bundle_dir="./bundles/media-processor";
build "media-processor";
rm -rf $bundle_dir;
mv ./media-processor/target $bundle_dir;
echo $version > $bundle_dir/version.txt;
put-license-file $bundle_dir;
echo ">> bundle media-processor 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-media-processor-docker-image {
_build-release-docker-image media-processor bundle-media-processor Dockerfile.media-processor "$@"
}
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 " --opencode-config-dir DIR"
echo " bind-mount DIR over the container's"
echo " ~/.config/opencode (personal opencode"
echo " agents/prompts/skills kept outside this"
echo " repo; applied at container creation)."
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-media-processor-bundle Build media-processor 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-media-processor-docker-image [--tag TAG] Build media-processor 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-media-processor-bundle;
build-backend-bundle;
build-exporter-bundle;
build-storybook-bundle;
;;
build-frontend-bundle)
build-frontend-bundle;
;;
build-mcp-bundle)
build-mcp-bundle;
;;
build-media-processor-bundle)
build-media-processor-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-media-processor-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-media-processor-docker-image)
build-media-processor-docker-image "${@:2}"
;;
build-storybook-docker-image)
build-storybook-docker-image "${@:2}"
;;
build-imagemagick-docker-image)
shift;
build-imagemagick-docker-image $@;
;;
*)
usage
;;
esac