mirror of
https://github.com/penpot/penpot.git
synced 2026-06-17 04:42:03 +00:00
Improve parallel-workspaces developer CLI,
and add an opt-in layer that lets four AI
coding agents (Claude Code, opencode, VS Code Copilot, OpenAI Codex CLI)
drive a specific workspace through a single launcher command.
Parallel-workspace semantics
----------------------------
each run-devenv-agentic call brings up one wsN;
--ws N (integer; default 0) targets a specific workspace and auto-starts
ws0 first when N>=1 so the worker invariant holds. --sync is forbidden on
ws0 and re-seeds the workspace from the live repo for ws1+. Stop semantics
mirror the start invariant -- ws0 is the last to stop, shared infra stops
with it, --all walks every instance highest-first. The worker policy
section explains why workers run only on ws0 (Postgres FOR UPDATE
SKIP LOCKED is safe across many workers but the cron dedup primitive is
best-effort, and :telemetry / :audit-log-archive are not idempotent).
Per-instance Valkey Pub/Sub isolation, msgbus topology, and the
"async task notifications miss ws1+ tabs" caveat are stated explicitly.
The mem:prod-infra/core memory captures the same external-services and
task-queue / Pub-Sub topology in agent-readable form, and
mem:backend/core and mem:critical-info now cross-link it so backend work
surfaces the horizontal-scaling constraints from the start.
AI coding agent integration
---------------------------
New top-level .devenv/ directory holds committed templates
(templates/{claude-code,opencode,vscode}.json and templates/codex.toml,
each with \${PENPOT_MCP_PORT} and \${SERENA_MCP_PORT} placeholders) plus
committed shared entries (matching shared/* files for Playwright, the
only workspace-independent server we ship today).
./manage.sh start-coding-agent <claude|opencode|vscode|codex> [--ws N]
launches the chosen client against one workspace. It cd's into the
target's directory (the live repo for ws0; workspace-path "wsN" for ws1+)
and refuses to launch unless (a) the binary is on PATH, (b) the
workspace directory exists for ws1+, and (c) the instance is up
(devenv-main-running) -- the MCP servers only exist while the devenv is
running. The agentic-devenv guide is restructured around this Quick
start path, with a per-client table and a Manual configuration fallback
for clients we don't cover.
Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1345 lines
47 KiB
Bash
Executable File
1345 lines
47 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";
|
|
|
|
# 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; per-service base ports sit at well-known offsets
|
|
# inside ws0's block. Single source of truth for both write-instance-env (the
|
|
# values baked into the per-instance compose env file) and print-instance-info
|
|
# (the URLs printed at startup).
|
|
PENPOT_INSTANCE_PORT_STRIDE=10000
|
|
PENPOT_PORT_BASE_PUBLIC=3449
|
|
PENPOT_PORT_BASE_MCP=4401
|
|
PENPOT_PORT_BASE_MCP_REPL=4403
|
|
PENPOT_PORT_BASE_SERENA=14181
|
|
PENPOT_PORT_BASE_SERENA_DASHBOARD=14182
|
|
|
|
# Per-instance values like PENPOT_REDIS_URI must live in each instance's env
|
|
# file (not in this shell), because docker compose's --env-file mechanism
|
|
# lets a per-instance overlay override the baseline while the shell env
|
|
# would otherwise shadow both for every project.
|
|
|
|
export CURRENT_USER_ID=$(id -u);
|
|
export CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD);
|
|
|
|
export IMAGEMAGICK_VERSION=7.1.2-13
|
|
|
|
# Safe directory to avoid ownership errors with Git
|
|
git config --global --add safe.directory /home/penpot/penpot || true
|
|
|
|
# Set default java options
|
|
export JAVA_OPTS=${JAVA_OPTS:-"-Xmx1000m -Xms50m"};
|
|
|
|
set -e
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Function map
|
|
#
|
|
# Utility helpers
|
|
# print-current-version, setup-buildx, put-license-file
|
|
#
|
|
# Devenv image lifecycle
|
|
# build-devenv, pull-devenv, pull-devenv-if-not-exists
|
|
#
|
|
# Devenv compose plumbing (used by every *-devenv command below)
|
|
# ensure-devenv-network create the external 'penpot_shared' network
|
|
# devenv-compose wrap 'docker compose' with --env-file + both files
|
|
# devenv-main-container resolve the 'main' container id via compose ps
|
|
# devenv-main-running true if 'main' is up
|
|
#
|
|
# Devenv lifecycle (operate on the whole compose project)
|
|
# start-devenv, create-devenv, stop-devenv, drop-devenv, log-devenv
|
|
#
|
|
# Devenv interactive entry points (all operate on the running 'main' container)
|
|
# run-devenv-tmux starts 'main' if needed and execs start-tmux.sh
|
|
# interactively (this is what 'run-devenv' resolves to)
|
|
# run-devenv-agentic same as run-devenv-tmux but enables MCP + Serena;
|
|
# also regenerates .devenv/mcp/<tool>.json for the
|
|
# target workspace (via write-instance-mcp-configs)
|
|
# 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
|
|
# run-devenv-shell starts 'main' if needed and execs a bash shell
|
|
# run-devenv-isolated-shell one-shot 'docker run' (NOT compose) against the
|
|
# project user_data volume and the current PWD; used
|
|
# for ad-hoc operations that should not touch a
|
|
# running devenv
|
|
#
|
|
# 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;
|
|
|
|
pushd docker/devenv;
|
|
|
|
if [ "$1" = "--local" ]; then
|
|
echo "Build local only $DEVENV_IMGNAME:latest image";
|
|
docker build -t $DEVENV_IMGNAME:latest .;
|
|
else
|
|
echo "Build and push $DEVENV_IMGNAME:latest image";
|
|
setup-buildx;
|
|
|
|
docker buildx build \
|
|
--platform linux/amd64,linux/arm64 \
|
|
--output type=registry \
|
|
-t $DEVENV_IMGNAME:latest .;
|
|
|
|
docker pull $DEVENV_IMGNAME:latest;
|
|
fi
|
|
|
|
popd;
|
|
}
|
|
|
|
function pull-devenv {
|
|
set -ex
|
|
docker pull $DEVENV_IMGNAME:latest
|
|
}
|
|
|
|
function pull-devenv-if-not-exists {
|
|
if [[ ! $(docker images $DEVENV_IMGNAME:latest -q) ]]; then
|
|
pull-devenv $@
|
|
fi
|
|
}
|
|
|
|
function ensure-devenv-network {
|
|
docker network inspect "$DEVENV_NETWORK" >/dev/null 2>&1 || docker network create "$DEVENV_NETWORK" >/dev/null
|
|
}
|
|
|
|
# Compose-project plumbing for the parallel-workspaces layout.
|
|
#
|
|
# - Shared infrastructure (postgres, minio, mailer, ldap, minio-setup) runs
|
|
# under project `penpotdev-infra`.
|
|
# - Each runtime instance (ws0, ws1, ...) runs its own main + valkey under
|
|
# project `penpotdev-wsN`. ws0 uses only `defaults.env`; ws1+ additionally
|
|
# layer a generated overlay file under `docker/devenv/instances/`.
|
|
# `env -i` strips the shell env before invoking docker compose so the
|
|
# per-instance overlay --env-file actually overrides defaults.env. Without
|
|
# stripping, the shell would still hold whatever values defaults.env was
|
|
# sourced into at startup (PENPOT_MAIN_CONTAINER_NAME, etc.), and Docker
|
|
# Compose's substitution gives the shell precedence over --env-file.
|
|
# Only the values that genuinely need to be per-call (HOME/PATH for tooling,
|
|
# CURRENT_USER_ID/PENPOT_SOURCE_PATH for the compose substitution) are
|
|
# re-exported.
|
|
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 env_files
|
|
env_files=(--env-file "$DEVENV_DEFAULTS_FILE")
|
|
if [[ "$instance" == "ws0" ]]; then
|
|
source_path="$PWD"
|
|
else
|
|
source_path="$(workspace-path "$instance")"
|
|
env_files+=(--env-file "docker/devenv/instances/${instance}.env")
|
|
fi
|
|
env -i HOME="$HOME" PATH="$PATH" PWD="$PWD" \
|
|
CURRENT_USER_ID="${CURRENT_USER_ID:-$(id -u)}" \
|
|
PENPOT_SOURCE_PATH="$source_path" \
|
|
docker compose -p "penpotdev-${instance}" \
|
|
"${env_files[@]}" \
|
|
-f docker/devenv/docker-compose.main.yml \
|
|
"$@"
|
|
}
|
|
|
|
# Names of currently-running parallel instances (ws0, ws1, ...).
|
|
function list-running-instances {
|
|
docker ps --format '{{.Label "com.docker.compose.project"}}' 2>/dev/null \
|
|
| sort -u \
|
|
| grep -oE '^penpotdev-ws[0-9]+$' \
|
|
| sed 's/^penpotdev-//' \
|
|
|| true
|
|
}
|
|
|
|
function devenv-main-container {
|
|
local instance="${1:-ws0}"
|
|
# For ws1+, skip compose if the env-file hasn't been generated yet — the
|
|
# instance has never been brought up so there is no container to find.
|
|
if [[ "$instance" != "ws0" && ! -f "docker/devenv/instances/${instance}.env" ]]; then
|
|
return 0
|
|
fi
|
|
instance-compose "$instance" ps -q main 2>/dev/null
|
|
}
|
|
|
|
function devenv-main-running {
|
|
local instance="${1:-ws0}"
|
|
local container
|
|
container=$(devenv-main-container "$instance")
|
|
[[ -n "$container" ]] && [[ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)" = "true" ]]
|
|
}
|
|
|
|
# Bring shared infra up and block until minio-setup has provisioned the
|
|
# shared MinIO user/policy. Idempotent: a second call when everything is
|
|
# already up returns immediately.
|
|
function ensure-infra-up {
|
|
infra-compose up -d
|
|
local setup_container
|
|
setup_container=$(infra-compose ps -aq minio-setup 2>/dev/null)
|
|
if [[ -n "$setup_container" ]]; then
|
|
docker wait "$setup_container" >/dev/null 2>&1 || true
|
|
fi
|
|
}
|
|
|
|
# Refuse to sync workspaces if the live repo is in a fragile Git state.
|
|
# Copying a partial rebase/merge/cherry-pick into all workspaces would leave
|
|
# every instance in the same broken state.
|
|
function assert-clean-git-state {
|
|
local fragile=""
|
|
[ -d .git/rebase-apply ] && fragile="$fragile rebase-apply"
|
|
[ -d .git/rebase-merge ] && fragile="$fragile rebase-merge"
|
|
[ -f .git/MERGE_HEAD ] && fragile="$fragile merge"
|
|
[ -f .git/CHERRY_PICK_HEAD ] && fragile="$fragile cherry-pick"
|
|
[ -f .git/index.lock ] && fragile="$fragile index.lock"
|
|
if [[ -n "$fragile" ]]; then
|
|
echo "Live repo Git state is unsafe to copy into workspaces:$fragile" >&2
|
|
echo "Finish or abort the in-progress operation, then retry." >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Absolute path of the workspace directory for a non-ws0 instance.
|
|
function workspace-path {
|
|
local instance="$1"
|
|
echo "${PENPOT_WORKSPACES_DIR}/${instance}"
|
|
}
|
|
|
|
# Echo the host port that <base> maps to for <instance>.
|
|
function instance-port {
|
|
local instance="$1"
|
|
local base="$2"
|
|
local n=0
|
|
[[ "$instance" =~ ^ws([0-9]+)$ ]] && n="${BASH_REMATCH[1]}"
|
|
echo $(( base + n * PENPOT_INSTANCE_PORT_STRIDE ))
|
|
}
|
|
|
|
# Generate (or refresh) the per-instance Compose env-file overlay. Idempotent;
|
|
# safe to call on every reconciler pass.
|
|
function write-instance-env {
|
|
local instance="$1"
|
|
if [[ "$instance" == "ws0" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ ! "$instance" =~ ^ws([0-9]+)$ ]]; then
|
|
echo "write-instance-env: invalid instance '$instance'" >&2
|
|
return 1
|
|
fi
|
|
|
|
local file="docker/devenv/instances/${instance}.env"
|
|
mkdir -p docker/devenv/instances
|
|
local workspace
|
|
workspace=$(workspace-path "$instance")
|
|
local public mcp mcp_repl serena serena_dash
|
|
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")
|
|
cat >"$file" <<EOF
|
|
# Auto-generated by manage.sh for instance '$instance'.
|
|
# Edits are overwritten on the next reconciler pass.
|
|
|
|
COMPOSE_PROJECT_NAME=penpotdev-${instance}
|
|
PENPOT_MAIN_CONTAINER_NAME=penpot-devenv-${instance}-main
|
|
PENPOT_VALKEY_CONTAINER_NAME=penpot-devenv-${instance}-valkey
|
|
PENPOT_VALKEY_HOSTNAME=penpot-devenv-${instance}-valkey
|
|
PENPOT_USER_DATA_VOLUME=penpotdev_${instance}_user_data
|
|
PENPOT_VALKEY_DATA_VOLUME=penpotdev_${instance}_valkey_data
|
|
|
|
PENPOT_PUBLIC_URI=https://localhost:${public}
|
|
PENPOT_REDIS_URI=redis://penpot-devenv-${instance}-valkey/0
|
|
PENPOT_TMUX_SESSION=penpot
|
|
|
|
PENPOT_PUBLIC_HTTP_PORT=${public}
|
|
PENPOT_MCP_SERVER_PORT=${mcp}
|
|
PENPOT_MCP_REPL_PORT=${mcp_repl}
|
|
SERENA_EXTERNAL_PORT=${serena}
|
|
SERENA_DASHBOARD_EXTERNAL_PORT=${serena_dash}
|
|
|
|
# Background workers run only on ws0 to keep async-task notifications bound
|
|
# to a single Valkey Pub/Sub. See mem:devenv/core.
|
|
PENPOT_BACKEND_WORKER=false
|
|
|
|
# Workspace bind mount (computed in manage.sh too, but recorded here for
|
|
# clarity when inspecting the env file).
|
|
PENPOT_SOURCE_PATH=${workspace}
|
|
EOF
|
|
}
|
|
|
|
# Thin wrappers around .devenv/scripts/merge-mcp-config.py. The script does
|
|
# the actual envsubst + JSON deep-merge or TOML concat; see its docstring for
|
|
# the contract. ${PENPOT_MCP_PORT} / ${SERENA_MCP_PORT} placeholders in the
|
|
# template are resolved from the caller's environment.
|
|
function _merge-mcp-config-json {
|
|
local shared="$1" tpl="$2" out="$3" key="$4"
|
|
python3 .devenv/scripts/merge-mcp-config.py \
|
|
--format json --key "$key" \
|
|
"$shared" "$tpl" "$out"
|
|
}
|
|
|
|
function _merge-mcp-config-toml {
|
|
local shared="$1" tpl="$2" out="$3"
|
|
python3 .devenv/scripts/merge-mcp-config.py \
|
|
--format toml \
|
|
"$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>.*. Idempotent; overwrites on every call (the
|
|
# generated files are gitignored and not meant for hand-editing — 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 personal
|
|
# .vscode/mcp.json entry, or a user-level ~/.codex/config.toml).
|
|
#
|
|
# Generated paths per tool:
|
|
# <workspace>/.devenv/mcp/claude-code.json loaded via --mcp-config
|
|
# <workspace>/.devenv/mcp/opencode.json loaded via OPENCODE_CONFIG=
|
|
# <workspace>/.vscode/mcp.json auto-loaded by VS Code Copilot
|
|
# <workspace>/.codex/config.toml auto-loaded by Codex CLI
|
|
# (project must be "trusted")
|
|
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" "$workspace/.codex"
|
|
|
|
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
|
|
_merge-mcp-config-json \
|
|
"$src_dir/shared/vscode.json" \
|
|
"$src_dir/templates/vscode.json" \
|
|
"$workspace/.vscode/mcp.json" \
|
|
servers
|
|
_merge-mcp-config-toml \
|
|
"$src_dir/shared/codex.toml" \
|
|
"$src_dir/templates/codex.toml" \
|
|
"$workspace/.codex/config.toml"
|
|
}
|
|
|
|
# Seed (or re-seed) a workspace from the live repo, then switch it onto a
|
|
# unique branch. Two-step sync:
|
|
# 1. .git directory is rsync'd directly (so the workspace has its own
|
|
# clone with the developer's current commits / index).
|
|
# 2. Working-tree files are enumerated by `git ls-files`, which is the
|
|
# only authoritative source for "what files belong in the working
|
|
# tree" (Git tracks files even when their parent directory matches
|
|
# a gitignore pattern, e.g. .clj-kondo/config.edn). Using rsync's
|
|
# gitignore filter directly misses those.
|
|
# Gitignored caches already in the workspace (node_modules, target, etc.)
|
|
# are left in place: no --delete on the working-tree pass.
|
|
function sync-workspace {
|
|
local instance="$1"
|
|
if [[ "$instance" == "ws0" ]]; then
|
|
return 0
|
|
fi
|
|
assert-clean-git-state || return 1
|
|
|
|
local workspace
|
|
workspace=$(workspace-path "$instance")
|
|
mkdir -p "$workspace"
|
|
|
|
echo "[$instance] syncing workspace at $workspace ..."
|
|
|
|
# .git directory — direct mirror, including index, refs, hooks, etc.
|
|
rsync -a --delete "$PWD/.git/" "$workspace/.git/"
|
|
|
|
# Working-tree files: tracked + untracked-not-ignored. git ls-files
|
|
# speaks Git's actual semantics, including the "tracked overrides
|
|
# gitignore" rule. --files-from feeds the path list to rsync verbatim.
|
|
local files
|
|
files=$(mktemp)
|
|
git -C "$PWD" ls-files -z --cached --others --exclude-standard >"$files"
|
|
rsync -a --files-from="$files" --from0 "$PWD/" "$workspace/"
|
|
rm -f "$files"
|
|
|
|
# Initial seed of frontend/resources/public/js/config.js. The file is
|
|
# gitignored, so git ls-files would not list it, yet the agentic devenv
|
|
# needs it (enable-mcp flag). After the first sync the workspace copy
|
|
# belongs to the user — subsequent syncs leave it untouched.
|
|
local cfg="frontend/resources/public/js/config.js"
|
|
if [[ -f "$PWD/$cfg" && ! -f "$workspace/$cfg" ]]; then
|
|
install -D "$PWD/$cfg" "$workspace/$cfg"
|
|
fi
|
|
|
|
(
|
|
cd "$workspace"
|
|
git switch -C "${instance}/${CURRENT_BRANCH}" >/dev/null
|
|
)
|
|
}
|
|
|
|
function start-devenv {
|
|
pull-devenv-if-not-exists $@;
|
|
ensure-devenv-network;
|
|
|
|
ensure-infra-up
|
|
instance-compose ws0 up -d
|
|
}
|
|
|
|
function create-devenv {
|
|
pull-devenv-if-not-exists $@;
|
|
ensure-devenv-network;
|
|
|
|
infra-compose create
|
|
instance-compose ws0 create
|
|
}
|
|
|
|
# Stop instances. ws0 is the worker-bearer and must be the last one to stop;
|
|
# shared infra is shut down together with ws0. Flags are mutually exclusive.
|
|
#
|
|
# --ws N (N>=1) stop just that workspace. Leaves ws0 and infra alone.
|
|
# --ws 0 | (no flag) stop ws0 + shared infra. Refused if any ws1+ is running.
|
|
# --all stop every wsN highest-first, then ws0, then infra.
|
|
function stop-devenv {
|
|
local target=""
|
|
local all=false
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--ws)
|
|
target="$(parse-ws-integer "$2")" || return 1; shift 2;;
|
|
--all)
|
|
all=true; shift;;
|
|
*)
|
|
echo "stop-devenv: unknown argument '$1'" >&2
|
|
return 1;;
|
|
esac
|
|
done
|
|
if [[ -n "$target" && "$all" == "true" ]]; then
|
|
echo "stop-devenv: --ws and --all are mutually exclusive." >&2
|
|
return 1
|
|
fi
|
|
|
|
local running ws
|
|
running=$(list-running-instances)
|
|
|
|
if [[ "$all" == "true" ]]; then
|
|
# Highest wsN first, then ws0, then infra. ws0 stop also brings infra.
|
|
for ws in $(printf '%s\n' $running | grep -v '^ws0$' | sed 's/^ws//' | sort -rn | sed 's/^/ws/'); do
|
|
stop-instance "$ws"
|
|
done
|
|
if printf '%s\n' $running | grep -qx ws0; then
|
|
stop-instance ws0
|
|
fi
|
|
infra-compose down -t 2
|
|
return 0
|
|
fi
|
|
|
|
# Default target: ws0 (which also stops infra).
|
|
[[ -z "$target" ]] && target="ws0"
|
|
|
|
if [[ "$target" == "ws0" ]]; then
|
|
local non_main=""
|
|
for ws in $running; do
|
|
[[ "$ws" != "ws0" ]] && non_main="$non_main $ws"
|
|
done
|
|
if [[ -n "$non_main" ]]; then
|
|
echo "stop-devenv: cannot stop ws0 while other instances are running:${non_main}." >&2
|
|
echo "Stop them first (--ws N) or use --all." >&2
|
|
return 1
|
|
fi
|
|
if printf '%s\n' $running | grep -qx ws0; then
|
|
stop-instance ws0
|
|
else
|
|
echo "[ws0] not running."
|
|
fi
|
|
infra-compose down -t 2
|
|
return 0
|
|
fi
|
|
|
|
# --ws N (N>=1): stop just that instance, leave ws0 + infra up.
|
|
if printf '%s\n' $running | grep -qx "$target"; then
|
|
stop-instance "$target"
|
|
else
|
|
echo "[$target] not running."
|
|
fi
|
|
}
|
|
|
|
function drop-devenv {
|
|
local ws
|
|
for ws in $(list-running-instances); do
|
|
# Never -v: data preservation rule.
|
|
instance-compose "$ws" down -t 2
|
|
done
|
|
infra-compose down -t 2
|
|
|
|
echo "Clean old development image $DEVENV_IMGNAME..."
|
|
docker images $DEVENV_IMGNAME -q | xargs --no-run-if-empty docker rmi
|
|
}
|
|
|
|
function log-devenv {
|
|
# Tail ws0 by default; for multi-instance dev, attach explicitly per project.
|
|
instance-compose ws0 logs -f --tail=50
|
|
}
|
|
|
|
function run-devenv-tmux {
|
|
local extra_env_args=()
|
|
local instance="ws0"
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--instance)
|
|
instance="$(normalize-instance "$2")"; shift 2;;
|
|
-e)
|
|
extra_env_args+=(-e "$2"); shift 2;;
|
|
-e*)
|
|
extra_env_args+=(-e "${1#-e}"); shift;;
|
|
*)
|
|
echo "run-devenv: unknown argument '$1'" >&2
|
|
return 1;;
|
|
esac
|
|
done
|
|
|
|
if ! devenv-main-running "$instance"; then
|
|
if [[ "$instance" == "ws0" ]]; then
|
|
start-devenv
|
|
echo "Waiting for containers fully start (5s)..."
|
|
sleep 5
|
|
else
|
|
echo "Instance '$instance' is not running; bring it up first with './manage.sh run-devenv-agentic --n-instances N'." >&2
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
local container
|
|
container=$(devenv-main-container "$instance")
|
|
docker exec -ti \
|
|
"${extra_env_args[@]}" \
|
|
"$container" sudo -EH -u penpot PENPOT_PLUGIN_DEV=$PENPOT_PLUGIN_DEV /home/start-tmux.sh
|
|
}
|
|
|
|
# Normalize an instance specifier ("main", "0", "ws0", "1", "ws3", ...) to "wsN".
|
|
function normalize-instance {
|
|
local raw="$1"
|
|
if [[ "$raw" == "main" ]]; then
|
|
echo "ws0"
|
|
elif [[ "$raw" =~ ^ws[0-9]+$ ]]; then
|
|
echo "$raw"
|
|
elif [[ "$raw" =~ ^[0-9]+$ ]]; then
|
|
echo "ws$raw"
|
|
else
|
|
echo "Invalid instance value: '$raw' (expected main|0|ws0|1|ws1|...)" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Strict parser for --ws values. Accepts a bare integer in the supported
|
|
# range (0..PENPOT_MAX_WS_INDEX) and returns the canonical "wsN" form.
|
|
# Anything else fails fast. The upper bound exists because the host ports
|
|
# computed for higher N overflow the 16-bit TCP port range:
|
|
# port = base + N * PENPOT_INSTANCE_PORT_STRIDE (10000)
|
|
# With the current Serena bases (14181/14182), N = 5 still fits inside
|
|
# 65535 (64181/64182) but N = 6 overflows (74181/74182), so the cap is 5.
|
|
PENPOT_MAX_WS_INDEX=5
|
|
function parse-ws-integer {
|
|
local raw="$1"
|
|
if [[ ! "$raw" =~ ^[0-9]+$ ]]; then
|
|
echo "Invalid --ws value: '$raw' (expected a non-negative integer, e.g. --ws 0, --ws 1, --ws 2)" >&2
|
|
return 1
|
|
fi
|
|
if (( raw > PENPOT_MAX_WS_INDEX )); then
|
|
echo "Invalid --ws value: '$raw' (max supported is --ws $PENPOT_MAX_WS_INDEX; higher indexes would overflow the 16-bit TCP port range)" >&2
|
|
return 1
|
|
fi
|
|
echo "ws$raw"
|
|
}
|
|
|
|
|
|
# Bring a single agentic instance up: compose up + detached tmux start with
|
|
# MCP and Serena enabled. Workspace sync and env-file generation are the
|
|
# caller's responsibility (run-devenv-agentic handles them for ws1+).
|
|
function start-instance {
|
|
local instance="$1"
|
|
local serena_context="$2"
|
|
|
|
instance-compose "$instance" up -d --no-deps main redis
|
|
|
|
# Wait briefly for main to be reachable; the tmux session lives inside.
|
|
local container deadline
|
|
container=$(devenv-main-container "$instance")
|
|
deadline=$(( SECONDS + 30 ))
|
|
while ! docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -q true; do
|
|
[[ $SECONDS -ge $deadline ]] && {
|
|
echo "[${instance}] main container did not reach Running within 30s" >&2
|
|
return 1
|
|
}
|
|
sleep 1
|
|
done
|
|
|
|
# Detached tmux so callers don't block on attach. Agentic mode is the only
|
|
# mode here, so MCP and Serena are always on.
|
|
docker exec -d \
|
|
-e PENPOT_TMUX_ATTACH=false \
|
|
-e PENPOT_FLAGS="${PENPOT_FLAGS:-} enable-mcp" \
|
|
-e SERENA_ENABLED=true \
|
|
-e SERENA_CONTEXT="$serena_context" \
|
|
"$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")
|
|
mcp=$(instance-port "$instance" "$PENPOT_PORT_BASE_MCP")
|
|
serena=$(instance-port "$instance" "$PENPOT_PORT_BASE_SERENA")
|
|
serena_dash=$(instance-port "$instance" "$PENPOT_PORT_BASE_SERENA_DASHBOARD")
|
|
|
|
# --ws takes a bare integer; ws0 is the default, so its flag is elided.
|
|
local n="${instance#ws}"
|
|
local ws_flag=""
|
|
[[ "$instance" != "ws0" ]] && ws_flag=" --ws ${n}"
|
|
|
|
echo
|
|
echo "[$instance]"
|
|
echo " Penpot UI: https://localhost:${public}"
|
|
echo " MCP stream: http://localhost:${mcp}/mcp"
|
|
echo " Serena MCP: http://localhost:${serena}"
|
|
echo " Serena dashboard: http://localhost:${serena_dash}"
|
|
echo " Attach: ./manage.sh attach-devenv${ws_flag}"
|
|
echo " Coding agent: ./manage.sh start-coding-agent claude${ws_flag} (or: opencode|vscode|codex)"
|
|
}
|
|
|
|
# Bring up a single agentic instance (always with MCP + Serena).
|
|
#
|
|
# --ws N target instance (non-negative integer). Default: 0 (ws0).
|
|
# --sync re-seed the workspace from the live repo. Forbidden on main;
|
|
# ws1+ also sync implicitly the first time when their workspace
|
|
# directory does not exist yet.
|
|
# --serena-context CTX passed to Serena (default: desktop-app).
|
|
#
|
|
# ws0 is the worker-bearer and must be running whenever any ws1+ is up. When
|
|
# starting ws1+, ws0 is brought up first automatically if it is not already
|
|
# running. Errors out if the requested target itself is already running.
|
|
function run-devenv-agentic {
|
|
local target="ws0"
|
|
local do_sync=false
|
|
local serena_context="desktop-app"
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--ws)
|
|
target="$(parse-ws-integer "$2")" || return 1; shift 2;;
|
|
--sync)
|
|
do_sync=true; shift;;
|
|
--serena-context)
|
|
serena_context="$2"; shift 2;;
|
|
*)
|
|
echo "run-devenv-agentic: unknown argument '$1'" >&2
|
|
return 1;;
|
|
esac
|
|
done
|
|
|
|
if [[ "$target" == "ws0" && "$do_sync" == "true" ]]; then
|
|
echo "run-devenv-agentic: --sync is not allowed on main (ws0)." >&2
|
|
return 1
|
|
fi
|
|
|
|
if devenv-main-running "$target"; then
|
|
echo "run-devenv-agentic: instance '$target' is already running." >&2
|
|
return 1
|
|
fi
|
|
|
|
pull-devenv-if-not-exists
|
|
ensure-devenv-network
|
|
ensure-infra-up
|
|
|
|
# ws0 invariant: must be up whenever any ws1+ runs. Bring it up first if a
|
|
# non-main instance is being requested and ws0 is not yet running.
|
|
if [[ "$target" != "ws0" ]] && ! devenv-main-running "ws0"; then
|
|
echo "[ws0] not running; starting it first (workers run only on ws0)."
|
|
echo "Starting ws0..."
|
|
write-instance-mcp-configs "ws0"
|
|
start-instance "ws0" "$serena_context"
|
|
print-instance-info "ws0"
|
|
fi
|
|
|
|
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
|
|
write-instance-env "$target"
|
|
fi
|
|
|
|
echo "Starting $target..."
|
|
write-instance-mcp-configs "$target"
|
|
start-instance "$target" "$serena_context"
|
|
print-instance-info "$target"
|
|
}
|
|
|
|
function run-devenv-shell {
|
|
local instance="ws0"
|
|
local positional=()
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--instance)
|
|
instance="$(normalize-instance "$2")"; shift 2;;
|
|
*)
|
|
positional+=("$1"); shift;;
|
|
esac
|
|
done
|
|
|
|
if ! devenv-main-running "$instance"; then
|
|
if [[ "$instance" == "ws0" ]]; then
|
|
start-devenv
|
|
else
|
|
echo "Instance '$instance' is not running." >&2
|
|
return 1
|
|
fi
|
|
fi
|
|
local container
|
|
container=$(devenv-main-container "$instance")
|
|
docker exec -ti \
|
|
-e JAVA_OPTS="$JAVA_OPTS" \
|
|
-e EXTERNAL_UID=$CURRENT_USER_ID \
|
|
"$container" sudo -EH -u penpot "${positional[@]}"
|
|
}
|
|
|
|
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-agentic [--ws N]'." >&2
|
|
return 1
|
|
fi
|
|
|
|
local session="${PENPOT_TMUX_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 "Start it with './manage.sh run-devenv-agentic [--ws N]'." >&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; .codex/config.toml is
|
|
# auto-discovered (the project must be marked trusted in Codex
|
|
# on first run)
|
|
#
|
|
# 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-agentic${ws_flag}'." >&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-agentic${ws_flag}'." >&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).
|
|
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=".codex/config.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-agentic${ws_flag}'," >&2
|
|
echo "which (re)generates 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)
|
|
exec codex "$@"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
function run-devenv-isolated-shell {
|
|
docker volume create ${PENPOT_USER_DATA_VOLUME};
|
|
docker run -ti --rm \
|
|
--mount source=${PENPOT_USER_DATA_VOLUME},type=volume,target=/home/penpot/ \
|
|
--mount source=`pwd`,type=bind,target=/home/penpot/penpot \
|
|
-e EXTERNAL_UID=$CURRENT_USER_ID \
|
|
-e BUILD_STORYBOOK=$BUILD_STORYBOOK \
|
|
-e BUILD_WASM=$BUILD_WASM \
|
|
-e SHADOWCLJS_EXTRA_PARAMS=$SHADOWCLJS_EXTRA_PARAMS \
|
|
-e JAVA_OPTS="$JAVA_OPTS" \
|
|
-w /home/penpot/penpot/$1 \
|
|
$DEVENV_IMGNAME:latest sudo -EH -u penpot $@
|
|
}
|
|
|
|
function build-imagemagick-docker-image {
|
|
set +e;
|
|
echo "Building image penpotapp/imagemagick:$IMAGEMAGICK_VERSION"
|
|
|
|
pushd docker/imagemagick;
|
|
|
|
output_option="type=registry";
|
|
platform="linux/amd64,linux/arm64";
|
|
|
|
if [ "$1" = "--local" ]; then
|
|
output_option="type=docker";
|
|
platform="linux/$ARCH"
|
|
fi
|
|
|
|
setup-buildx;
|
|
|
|
docker buildx build \
|
|
--build-arg IMAGEMAGICK_VERSION=$IMAGEMAGICK_VERSION \
|
|
--platform $platform \
|
|
--output $output_option \
|
|
-t penpotapp/imagemagick:latest \
|
|
-t penpotapp/imagemagick:$IMAGEMAGICK_VERSION .;
|
|
|
|
popd;
|
|
}
|
|
|
|
function build {
|
|
echo ">> build start: $1"
|
|
local version=$(print-current-version);
|
|
local script=${2:-build}
|
|
|
|
pull-devenv-if-not-exists;
|
|
docker volume create ${PENPOT_USER_DATA_VOLUME};
|
|
docker run -t --rm \
|
|
--mount source=${PENPOT_USER_DATA_VOLUME},type=volume,target=/home/penpot/ \
|
|
--mount source=`pwd`,type=bind,target=/home/penpot/penpot \
|
|
-e EXTERNAL_UID=$CURRENT_USER_ID \
|
|
-e BUILD_STORYBOOK=$BUILD_STORYBOOK \
|
|
-e BUILD_WASM=$BUILD_WASM \
|
|
-e SHADOWCLJS_EXTRA_PARAMS=$SHADOWCLJS_EXTRA_PARAMS \
|
|
-e JAVA_OPTS="$JAVA_OPTS" \
|
|
-w /home/penpot/penpot/$1 \
|
|
$DEVENV_IMGNAME:latest sudo -EH -u penpot ./scripts/$script $version
|
|
|
|
echo ">> build end: $1"
|
|
}
|
|
|
|
function put-license-file {
|
|
local target=$1;
|
|
tee -a $target/LICENSE >> /dev/null <<EOF
|
|
This Source Code Form is subject to the terms of the Mozilla Public
|
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
|
|
Copyright (c) KALEIDOS INC Sucursal en España SL
|
|
EOF
|
|
}
|
|
|
|
function build-frontend-bundle {
|
|
echo ">> bundle frontend start";
|
|
|
|
mkdir -p ./bundles
|
|
local version=$(print-current-version);
|
|
local bundle_dir="./bundles/frontend";
|
|
|
|
build "frontend";
|
|
|
|
rm -rf $bundle_dir;
|
|
mv ./frontend/target/dist $bundle_dir;
|
|
echo $version > $bundle_dir/version.txt;
|
|
put-license-file $bundle_dir;
|
|
echo ">> bundle frontend end";
|
|
}
|
|
|
|
function build-mcp-bundle {
|
|
echo ">> bundle mcp start";
|
|
|
|
mkdir -p ./bundles
|
|
local version=$(print-current-version);
|
|
local bundle_dir="./bundles/mcp";
|
|
|
|
build "mcp";
|
|
|
|
rm -rf $bundle_dir;
|
|
mv ./mcp/dist $bundle_dir;
|
|
echo $version > $bundle_dir/version.txt;
|
|
put-license-file $bundle_dir;
|
|
echo ">> bundle mcp end";
|
|
}
|
|
|
|
|
|
function build-backend-bundle {
|
|
echo ">> bundle backend start";
|
|
|
|
mkdir -p ./bundles
|
|
local version=$(print-current-version);
|
|
local bundle_dir="./bundles/backend";
|
|
|
|
build "backend";
|
|
|
|
rm -rf $bundle_dir;
|
|
mv ./backend/target/dist $bundle_dir;
|
|
echo $version > $bundle_dir/version.txt;
|
|
put-license-file $bundle_dir;
|
|
echo ">> bundle backend end";
|
|
}
|
|
|
|
function build-exporter-bundle {
|
|
echo ">> bundle exporter start";
|
|
|
|
mkdir -p ./bundles
|
|
local version=$(print-current-version);
|
|
local bundle_dir="./bundles/exporter";
|
|
|
|
build "exporter";
|
|
|
|
rm -rf $bundle_dir;
|
|
mv ./exporter/target $bundle_dir;
|
|
echo $version > $bundle_dir/version.txt
|
|
put-license-file $bundle_dir;
|
|
echo ">> bundle exporter end";
|
|
}
|
|
|
|
function build-storybook-bundle {
|
|
echo ">> bundle storybook start";
|
|
|
|
mkdir -p ./bundles
|
|
local version=$(print-current-version);
|
|
local bundle_dir="./bundles/storybook";
|
|
|
|
build "frontend" "build-storybook";
|
|
|
|
rm -rf $bundle_dir;
|
|
mv ./frontend/storybook-static $bundle_dir;
|
|
echo $version > $bundle_dir/version.txt;
|
|
put-license-file $bundle_dir;
|
|
echo ">> bundle storybook end";
|
|
}
|
|
|
|
function build-docs-bundle {
|
|
echo ">> bundle docs start";
|
|
|
|
mkdir -p ./bundles
|
|
local version=$(print-current-version);
|
|
local bundle_dir="./bundles/docs";
|
|
|
|
build "docs";
|
|
|
|
rm -rf $bundle_dir;
|
|
mv ./docs/_dist $bundle_dir;
|
|
echo $version > $bundle_dir/version.txt;
|
|
put-license-file $bundle_dir;
|
|
echo ">> bundle docs end";
|
|
}
|
|
|
|
function build-frontend-docker-image {
|
|
rsync -avr --delete ./bundles/frontend/ ./docker/images/bundle-frontend/;
|
|
pushd ./docker/images;
|
|
docker build \
|
|
-t penpotapp/frontend:$CURRENT_BRANCH -t penpotapp/frontend:latest \
|
|
--build-arg BUNDLE_PATH="./bundle-frontend/" \
|
|
-f Dockerfile.frontend .;
|
|
popd;
|
|
}
|
|
|
|
function build-backend-docker-image {
|
|
rsync -avr --delete ./bundles/backend/ ./docker/images/bundle-backend/;
|
|
pushd ./docker/images;
|
|
docker build \
|
|
-t penpotapp/backend:$CURRENT_BRANCH -t penpotapp/backend:latest \
|
|
--build-arg BUNDLE_PATH="./bundle-backend/" \
|
|
-f Dockerfile.backend .;
|
|
popd;
|
|
}
|
|
|
|
function build-exporter-docker-image {
|
|
rsync -avr --delete ./bundles/exporter/ ./docker/images/bundle-exporter/;
|
|
pushd ./docker/images;
|
|
docker build \
|
|
-t penpotapp/exporter:$CURRENT_BRANCH -t penpotapp/exporter:latest \
|
|
--build-arg BUNDLE_PATH="./bundle-exporter/" \
|
|
-f Dockerfile.exporter .;
|
|
popd;
|
|
}
|
|
|
|
function build-mcp-docker-image {
|
|
rsync -avr --delete ./bundles/mcp/ ./docker/images/bundle-mcp/;
|
|
pushd ./docker/images;
|
|
docker build \
|
|
-t penpotapp/mcp:$CURRENT_BRANCH -t penpotapp/mcp:latest \
|
|
--build-arg BUNDLE_PATH="./bundle-mcp/" \
|
|
-f Dockerfile.mcp .;
|
|
popd;
|
|
}
|
|
|
|
function build-storybook-docker-image {
|
|
rsync -avr --delete ./bundles/storybook/ ./docker/images/bundle-storybook/;
|
|
pushd ./docker/images;
|
|
docker build \
|
|
-t penpotapp/storybook:$CURRENT_BRANCH -t penpotapp/storybook:latest \
|
|
--build-arg BUNDLE_PATH="./bundle-storybook/" \
|
|
-f Dockerfile.storybook .;
|
|
popd;
|
|
}
|
|
|
|
function usage {
|
|
echo "PENPOT build & release manager"
|
|
echo "USAGE: $0 OPTION"
|
|
echo "Options:"
|
|
echo "- pull-devenv Pulls docker development oriented image"
|
|
echo "- build-devenv Build docker development oriented image"
|
|
echo "- build-devenv --local Build a local docker development oriented image"
|
|
echo "- create-devenv Create the development oriented docker compose service."
|
|
echo "- start-devenv Start the development oriented docker compose service."
|
|
echo "- stop-devenv Stops the development oriented docker compose service."
|
|
echo "- drop-devenv Remove the development oriented docker compose containers, volumes and clean images."
|
|
echo "- run-devenv Brings ws0 up and attaches to its tmux session (no MCP, no Serena)."
|
|
echo " Optional --instance <wsN> targets a different instance."
|
|
echo " Optional -e flags are forwarded to 'docker exec' (e.g. -e MY_VAR=value)."
|
|
echo "- run-devenv-agentic Brings one agentic instance (MCP + Serena) up. Errors out if it is already running."
|
|
echo " Auto-starts ws0 first when --ws N (N>=1) is requested (workers run only on ws0)."
|
|
echo " Options: --ws N (default: 0; non-negative integer only),"
|
|
echo " --sync (re-seed workspace from live repo; ws1+ only),"
|
|
echo " --serena-context CONTEXT (default: desktop-app)"
|
|
echo "- attach-devenv Attaches to the tmux session inside a running instance."
|
|
echo " Options: --ws N (default: 0; non-negative integer only)"
|
|
echo "- start-coding-agent <client> Launches an AI coding agent against one workspace with the right MCP config wired in."
|
|
echo " client: claude | opencode | vscode | codex"
|
|
echo " Options: --ws N (default: 0; non-negative integer only)."
|
|
echo " cd's into the target workspace and refuses to launch if the instance is not running."
|
|
echo " Extra args after --ws (or after the client name) are forwarded to the underlying client."
|
|
echo " See docs/technical-guide/developer/agentic-devenv.md and .devenv/README.md"
|
|
echo " for per-client setup, override paths, and the Codex 'trusted project' caveat."
|
|
echo "- run-devenv-shell Opens a bash shell inside a running instance."
|
|
echo " Options: --instance 0|wsN|N (default: 0)"
|
|
echo "- stop-devenv Stops instances. ws0 must be the last to stop; shared infra stops with ws0."
|
|
echo " Options: --ws N (stop one ws1+) | --ws 0 | (none) (stop ws0 + infra) | --all"
|
|
echo "- isolated-shell Starts a bash shell in a new devenv container."
|
|
echo "- log-devenv Show logs of the running devenv docker compose service."
|
|
echo ""
|
|
echo "- build-bundle Build all bundles (frontend, backend, exporter, storybook and mcp)."
|
|
echo "- build-frontend-bundle Build frontend bundle"
|
|
echo "- build-backend-bundle Build backend bundle."
|
|
echo "- build-exporter-bundle Build exporter bundle."
|
|
echo "- build-storybook-bundle Build storybook bundle."
|
|
echo "- build-mcp-bundle Build mcp bundle."
|
|
echo "- build-docs-bundle Build docs bundle."
|
|
echo ""
|
|
echo "- build-docker-images Build all docker images (frontend, backend and exporter)."
|
|
echo "- build-frontend-docker-image Build frontend docker images."
|
|
echo "- build-backend-docker-image Build backend docker images."
|
|
echo "- build-exporter-docker-image Build exporter docker images."
|
|
echo "- build-mcp-docker-image Build exporter docker images."
|
|
echo "- build-storybook-docker-image Build storybook docker images."
|
|
echo ""
|
|
echo "- version Show penpot's version."
|
|
}
|
|
|
|
case $1 in
|
|
version)
|
|
print-current-version
|
|
;;
|
|
|
|
## devenv related commands
|
|
pull-devenv)
|
|
pull-devenv ${@:2};
|
|
;;
|
|
|
|
build-devenv)
|
|
shift;
|
|
build-devenv $@;
|
|
;;
|
|
|
|
create-devenv)
|
|
create-devenv ${@:2}
|
|
;;
|
|
|
|
start-devenv)
|
|
start-devenv ${@:2}
|
|
;;
|
|
run-devenv)
|
|
run-devenv-tmux ${@:2}
|
|
;;
|
|
run-devenv-agentic)
|
|
run-devenv-agentic ${@:2}
|
|
;;
|
|
attach-devenv)
|
|
attach-devenv ${@:2}
|
|
;;
|
|
start-coding-agent)
|
|
start-coding-agent "${@:2}"
|
|
;;
|
|
run-devenv-shell)
|
|
run-devenv-shell ${@:2}
|
|
;;
|
|
|
|
isolated-shell)
|
|
run-devenv-isolated-shell ${@:2}
|
|
;;
|
|
|
|
stop-devenv)
|
|
stop-devenv ${@:2}
|
|
;;
|
|
drop-devenv)
|
|
drop-devenv ${@:2}
|
|
;;
|
|
log-devenv)
|
|
log-devenv ${@:2}
|
|
;;
|
|
|
|
## production builds
|
|
build-bundle)
|
|
build-frontend-bundle;
|
|
build-mcp-bundle;
|
|
build-backend-bundle;
|
|
build-exporter-bundle;
|
|
build-storybook-bundle;
|
|
;;
|
|
|
|
build-frontend-bundle)
|
|
build-frontend-bundle;
|
|
;;
|
|
|
|
build-mcp-bundle)
|
|
build-mcp-bundle;
|
|
;;
|
|
|
|
build-backend-bundle)
|
|
build-backend-bundle;
|
|
;;
|
|
|
|
build-exporter-bundle)
|
|
build-exporter-bundle;
|
|
;;
|
|
|
|
build-storybook-bundle)
|
|
build-storybook-bundle;
|
|
;;
|
|
|
|
build-docs-bundle)
|
|
build-docs-bundle;
|
|
;;
|
|
|
|
build-imagemagick-docker-image)
|
|
shift;
|
|
build-imagemagick-docker-image $@;
|
|
;;
|
|
|
|
build-docker-images)
|
|
build-frontend-docker-image
|
|
build-backend-docker-image
|
|
build-exporter-docker-image
|
|
build-mcp-docker-image
|
|
build-storybook-docker-image
|
|
;;
|
|
|
|
build-frontend-docker-image)
|
|
build-frontend-docker-image
|
|
;;
|
|
|
|
build-backend-docker-image)
|
|
build-backend-docker-image
|
|
;;
|
|
|
|
build-exporter-docker-image)
|
|
build-exporter-docker-image
|
|
;;
|
|
|
|
build-mcp-docker-image)
|
|
build-mcp-docker-image
|
|
;;
|
|
|
|
build-storybook-docker-image)
|
|
build-storybook-docker-image
|
|
;;
|
|
|
|
*)
|
|
usage
|
|
;;
|
|
esac
|