mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
* 🎉 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>
1671 lines
66 KiB
Bash
Executable File
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
|