mirror of
https://github.com/penpot/penpot.git
synced 2026-09-24 04:46:14 +00:00
* ♻️ Keep agent content on standard paths, drop client shims Agent guidance stays where the ecosystem reads it: AGENTS.md at the root and the skills in .agents/skills. Codex scans .agents/skills and follows symlinks, opencode lists it among its discovery locations, and Cursor, Zed, Warp, Cline and Amp use the same project path. Claude Code reads neither, so it needed CLAUDE.md and .claude/skills. Committing those two paths is not a neutral default. Both are gitignored, git treats ignored files as expendable, and a checkout that needs the path deletes whatever a contributor keeps there without a warning. Neither sparse-checkout nor skip-worktree offers a way out, because both make the next pull abort. The two links are now created by the contributor, once per clone, and documented in .agents/README.md. AGENTS.local.md and .agents/local/ give every client one personal layer that composes on top of the shared one, instead of one file per client inside the repository. sync-workspace seeds the two links for ws1 and above, the way it already seeds the gitignored config.js, so the devenv workflow does not change. AI-assisted-by: claude-opus-5 Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro> * ♻️ Lean on Claude Code reading AGENTS.md, drop the pointer Claude Code 2.1.277 reads AGENTS.md through the built-in agents-md plugin, whose default mode loads it in a project that has no instruction file of its own. Deleting the committed CLAUDE.md is what turns that on here, so the pointer a Claude user had to create is no longer part of the setup, and only the .claude/skills link remains: the plugin covers instruction files, and skills still load from that path alone. The instruction that replaces the pointer is a removal. A CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md anywhere from the root down to the working directory hands the project back to CLAUDE.md and hides this repository's AGENTS.md, while .claude/rules/*.md and the person's own ~/.claude/CLAUDE.md do not count and stay usable for personal steering. CLAUDE.local.md joins the ignore list, and a hard rule in AGENTS.md forbids all three. The docs name the version, say the fallback has not reached Bedrock, Vertex or Foundry, and say the stable auto-update channel runs about a week behind the default one, so the symlink is documented as the stopgap for those cases and as something to drop afterwards. sync-workspace seeds the skills link alone for the same reason. AI-assisted-by: claude-opus-5 Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro> * 📎 Remove skills directory mention on AGENTS.md Signed-off-by: Andrey Antukh <niwi@niwi.nz> --------- Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro> Co-authored-by: Andrey Antukh <niwi@niwi.nz>
1570 lines
62 KiB
Bash
Executable File
1570 lines
62 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-27
|
|
|
|
# Safe directory to avoid ownership errors with Git
|
|
git config --global --add safe.directory /home/penpot/penpot || true
|
|
|
|
# Set default java options
|
|
export JAVA_OPTS=${JAVA_OPTS:-"-Xmx1000m -Xms50m"};
|
|
|
|
set -e
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Function map
|
|
#
|
|
# Utility helpers
|
|
# print-current-version, setup-buildx, put-license-file
|
|
#
|
|
# Devenv image lifecycle
|
|
# build-devenv, pull-devenv, pull-devenv-if-not-exists
|
|
#
|
|
# Devenv compose plumbing (used by every *-devenv command below)
|
|
# ensure-devenv-network create the external 'penpot_shared' network
|
|
# infra-compose wrap 'docker compose' for the shared-infra project
|
|
# instance-compose wrap 'docker compose' for one instance's main
|
|
# project, injecting that instance's overrides
|
|
# instance-env-overrides the per-instance KEY=VALUE overrides
|
|
# devenv-main-container resolve the 'main' container id via compose ps
|
|
# devenv-main-running true if 'main' is up
|
|
#
|
|
# Devenv bring-up commands (bring a workspace up + start background tmux)
|
|
# run-devenv bring one workspace up; supports --ws, --sync,
|
|
# --attach, --agentic (enables MCP + Serena), -e,
|
|
# --serena-context, git identity
|
|
#
|
|
# Devenv interactive entry points (operate on the running 'main' container)
|
|
# attach-devenv pure attach to the existing tmux session; fails
|
|
# fast if the devenv or session is missing
|
|
# start-coding-agent launches Claude Code or opencode against the
|
|
# current workspace's generated MCP config
|
|
#
|
|
# Production build pipeline
|
|
# build one-shot 'docker run' that invokes a per-module
|
|
# build script inside the devenv image
|
|
# build-<mod>-bundle project a module's build output into ./bundles/
|
|
# build-<mod>-docker-image package a bundle into a release docker image
|
|
# ----------------------------------------------------------------------------
|
|
|
|
ARCH=$(uname -m)
|
|
|
|
if [[ "$ARCH" == "x86_64" || "$ARCH" == "amd64" || "$ARCH" == "i386" || "$ARCH" == "i686" ]]; then
|
|
ARCH="amd64"
|
|
elif [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then
|
|
ARCH="arm64"
|
|
else
|
|
echo "Unknown architecture $ARCH"
|
|
exit -1
|
|
fi
|
|
|
|
|
|
function print-current-version {
|
|
echo -n "$(git describe --tags --match "*.*.*")";
|
|
}
|
|
|
|
function setup-buildx {
|
|
docker run --privileged --rm tonistiigi/binfmt --install all
|
|
docker buildx inspect penpot > /dev/null 2>&1;
|
|
|
|
if [ $? -eq 1 ]; then
|
|
docker buildx create --name=penpot --use
|
|
docker buildx inspect --bootstrap > /dev/null 2>&1;
|
|
else
|
|
docker buildx use penpot;
|
|
docker buildx inspect --bootstrap > /dev/null 2>&1;
|
|
fi
|
|
}
|
|
|
|
function build-devenv {
|
|
set +e;
|
|
|
|
local tag="$DEVENV_TAG"
|
|
local do_push=false
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--tag)
|
|
tag="$2"; shift 2;;
|
|
--push)
|
|
do_push=true; shift;;
|
|
--local)
|
|
# Kept for backward compatibility -- local-only is now the
|
|
# default behaviour, so this flag is a no-op.
|
|
shift;;
|
|
*)
|
|
echo "build-devenv: unknown argument '$1'" >&2
|
|
return 1;;
|
|
esac
|
|
done
|
|
|
|
# Propagate to the rest of this invocation (e.g. if build-devenv is
|
|
# called from another function later). Does NOT persist to a separate
|
|
# `./manage.sh run-devenv` call -- export DEVENV_TAG in the shell for that.
|
|
export DEVENV_TAG="$tag"
|
|
|
|
pushd docker/devenv;
|
|
|
|
if [[ "$do_push" == "true" ]]; then
|
|
echo "Build and push $DEVENV_IMGNAME:$tag image";
|
|
setup-buildx;
|
|
|
|
docker buildx build \
|
|
--platform linux/amd64,linux/arm64 \
|
|
--output type=registry \
|
|
--provenance=mode=max --sbom=true \
|
|
-t "$DEVENV_IMGNAME:$tag" .;
|
|
|
|
docker pull "$DEVENV_IMGNAME:$tag";
|
|
else
|
|
echo "Build local only $DEVENV_IMGNAME:$tag image";
|
|
docker build -t "$DEVENV_IMGNAME:$tag" .;
|
|
fi
|
|
|
|
popd;
|
|
}
|
|
|
|
function pull-devenv {
|
|
set -ex
|
|
docker pull "$DEVENV_IMGNAME:$DEVENV_TAG"
|
|
}
|
|
|
|
function pull-devenv-if-not-exists {
|
|
if [[ ! $(docker images "$DEVENV_IMGNAME:$DEVENV_TAG" -q) ]]; then
|
|
pull-devenv $@
|
|
fi
|
|
}
|
|
|
|
function ensure-devenv-network {
|
|
docker network inspect "$DEVENV_NETWORK" >/dev/null 2>&1 || docker network create "$DEVENV_NETWORK" >/dev/null
|
|
}
|
|
|
|
# Compose-project plumbing for the parallel-workspaces layout.
|
|
#
|
|
# - Shared infrastructure (postgres, minio, mailer, ldap, minio-setup) runs
|
|
# under project `penpotdev-infra`.
|
|
# - Shared infrastructure (postgres, minio, mailer, ldap, valkey, minio-setup)
|
|
# runs under project `penpotdev-infra`.
|
|
# - Each runtime instance (ws0, ws1, ...) runs only its own main container
|
|
# under project `penpotdev-wsN`. All workspaces uniformly overlay their
|
|
# per-instance values via instance-env-overrides injected as shell env
|
|
# variables (no files are written).
|
|
# `env -i` strips the ambient shell before invoking docker compose, then we
|
|
# re-inject exactly what compose needs. The stripping matters because
|
|
# defaults.env is sourced into manage.sh's own shell at startup, so otherwise
|
|
# those stale values would leak into substitution. And because Docker Compose
|
|
# gives shell-env precedence over --env-file, the re-injected per-instance
|
|
# overrides cleanly override the defaults.env baseline. Re-injected: HOME/PATH
|
|
# (tooling), CURRENT_USER_ID/PENPOT_SOURCE_PATH (always per-call), 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 minio-setup has provisioned the
|
|
# shared MinIO user/policy. Idempotent: a second call when everything is
|
|
# already up returns immediately.
|
|
function ensure-infra-up {
|
|
infra-compose up -d
|
|
local setup_container
|
|
setup_container=$(infra-compose ps -aq minio-setup 2>/dev/null)
|
|
if [[ -n "$setup_container" ]]; then
|
|
docker wait "$setup_container" >/dev/null 2>&1 || true
|
|
fi
|
|
}
|
|
|
|
# Refuse to sync workspaces if the live repo is in a fragile Git state.
|
|
# Copying a partial rebase/merge/cherry-pick into all workspaces would leave
|
|
# every instance in the same broken state.
|
|
function assert-clean-git-state {
|
|
local fragile=""
|
|
[ -d .git/rebase-apply ] && fragile="$fragile rebase-apply"
|
|
[ -d .git/rebase-merge ] && fragile="$fragile rebase-merge"
|
|
[ -f .git/MERGE_HEAD ] && fragile="$fragile merge"
|
|
[ -f .git/CHERRY_PICK_HEAD ] && fragile="$fragile cherry-pick"
|
|
[ -f .git/index.lock ] && fragile="$fragile index.lock"
|
|
if [[ -n "$fragile" ]]; then
|
|
echo "Live repo Git state is unsafe to copy into workspaces:$fragile" >&2
|
|
echo "Finish or abort the in-progress operation, then retry." >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Absolute path of the workspace directory for a non-ws0 instance.
|
|
function workspace-path {
|
|
local instance="$1"
|
|
echo "${PENPOT_WORKSPACES_DIR}/${instance}"
|
|
}
|
|
|
|
# Echo the host port that <base> maps to for <instance>.
|
|
function instance-port {
|
|
local instance="$1"
|
|
local base="$2"
|
|
local n=0
|
|
[[ "$instance" =~ ^ws([0-9]+)$ ]] && n="${BASH_REMATCH[1]}"
|
|
echo $(( base + n * PENPOT_INSTANCE_PORT_STRIDE ))
|
|
}
|
|
|
|
# Echo the per-instance Compose variable overrides for a workspace, one
|
|
# KEY=VALUE per line, for instance-compose to inject into its `env -i` line.
|
|
# Compose gives shell-env precedence over --env-file, so these override the
|
|
# defaults.env baseline. Every value is a pure function of the instance number,
|
|
# so nothing is persisted: they are recomputed on every compose invocation and
|
|
# can never drift from this logic. Called for every workspace (ws0, ws1, ...).
|
|
# All overrides are pure functions of the instance number; no per-instance
|
|
# post-processing is needed.
|
|
#
|
|
# Omitted on purpose: COMPOSE_PROJECT_NAME (set via compose's -p flag),
|
|
# PENPOT_SOURCE_PATH (injected directly by instance-compose), and
|
|
function instance-env-overrides {
|
|
local instance="$1"
|
|
local n=0
|
|
[[ "$instance" =~ ^ws([0-9]+)$ ]] && n="${BASH_REMATCH[1]}"
|
|
local public_https public mcp mcp_repl serena serena_dash
|
|
public_https=$(instance-port "$instance" "$PENPOT_PORT_BASE_PUBLIC_HTTPS")
|
|
public=$(instance-port "$instance" "$PENPOT_PORT_BASE_PUBLIC")
|
|
mcp=$(instance-port "$instance" "$PENPOT_PORT_BASE_MCP")
|
|
mcp_repl=$(instance-port "$instance" "$PENPOT_PORT_BASE_MCP_REPL")
|
|
serena=$(instance-port "$instance" "$PENPOT_PORT_BASE_SERENA")
|
|
serena_dash=$(instance-port "$instance" "$PENPOT_PORT_BASE_SERENA_DASHBOARD")
|
|
opencode=$(instance-port "$instance" "$PENPOT_PORT_BASE_OPENCODE")
|
|
mdts=$(instance-port "$instance" "$PENPOT_PORT_BASE_MDTS")
|
|
storybook=$(instance-port "$instance" "$PENPOT_PORT_BASE_STORYBOOK")
|
|
printf '%s\n' \
|
|
"PENPOT_MAIN_CONTAINER_NAME=penpot-devenv-${instance}-main" \
|
|
"PENPOT_USER_DATA_VOLUME=penpotdev_${instance}_user_data" \
|
|
"PENPOT_PUBLIC_URI=https://localhost:${public_https}" \
|
|
"PENPOT_REDIS_URI=redis://valkey/${n}" \
|
|
"PENPOT_PUBLIC_HTTPS_PORT=${public_https}" \
|
|
"PENPOT_PUBLIC_HTTP_PORT=${public}" \
|
|
"PENPOT_MCP_SERVER_PORT=${mcp}" \
|
|
"PENPOT_MCP_REPL_PORT=${mcp_repl}" \
|
|
"PENPOT_STORYBOOK_PORT=${storybook}" \
|
|
"SERENA_EXTERNAL_PORT=${serena}" \
|
|
"OPENCODE_EXTERNAL_PORT=${opencode}" \
|
|
"MDTS_EXTERNAL_PORT=${mdts}" \
|
|
"SERENA_DASHBOARD_EXTERNAL_PORT=${serena_dash}" \
|
|
"SHADOW_SERVER_URL=wss://localhost:${public_https}" \
|
|
"PENPOT_TENANT=devenv-${instance}"
|
|
}
|
|
|
|
# Thin wrapper around .devenv/scripts/merge-mcp-config.py for the JSON clients
|
|
# (Claude Code, opencode, VS Code). The script does the actual envsubst + JSON
|
|
# deep-merge; see its docstring for the contract. ${PENPOT_MCP_PORT} /
|
|
# ${SERENA_MCP_PORT} placeholders in the template are resolved from the
|
|
# caller's environment. Any extra flags (e.g. --merge-into-existing for the VS
|
|
# Code output) are forwarded verbatim.
|
|
#
|
|
# Codex deliberately has no wrapper here: it cannot load an MCP config from an
|
|
# arbitrary file path, so instead of writing a file we inject our servers as
|
|
# `-c` overrides built at launch time -- see start-coding-agent.
|
|
function _merge-mcp-config-json {
|
|
local shared="$1" tpl="$2" out="$3" key="$4"; shift 4
|
|
python3 .devenv/scripts/merge-mcp-config.py \
|
|
--format json --key "$key" "$@" \
|
|
"$shared" "$tpl" "$out"
|
|
}
|
|
|
|
# Generate the per-workspace AI-client MCP config files by merging the
|
|
# committed .devenv/shared/<tool>.* with the port-substituted
|
|
# .devenv/templates/<tool>.*. Developers who want to override entries should
|
|
# use the client's own override mechanism: Claude Code's local scope, a
|
|
# project-level opencode.json, a VS Code user-profile entry, or a user-level
|
|
# ~/.codex/config.toml.
|
|
#
|
|
# Generated paths per tool:
|
|
# <workspace>/.devenv/mcp/claude-code.json loaded via --mcp-config; clean
|
|
# overwrite (dedicated gitignored
|
|
# file, no developer content)
|
|
# <workspace>/.devenv/mcp/opencode.json loaded via OPENCODE_CONFIG=; same
|
|
# <workspace>/.vscode/mcp.json auto-loaded by VS Code Copilot;
|
|
# DEEP-MERGED into any existing file
|
|
# so a developer's own entries on
|
|
# ws0 survive (ws0's file IS the
|
|
# live repo's; ws1+ start fresh).
|
|
# Ours win on name collision.
|
|
#
|
|
# Codex is intentionally NOT generated here. It cannot load an MCP config from
|
|
# an arbitrary path, and writing the auto-discovered .codex/config.toml would
|
|
# clobber the developer's project-level Codex config on ws0. Instead its
|
|
# servers are injected as `-c` overrides at launch -- see start-coding-agent.
|
|
function write-instance-mcp-configs {
|
|
local instance="$1"
|
|
local workspace
|
|
if [[ "$instance" == "ws0" ]]; then
|
|
workspace="$PWD"
|
|
else
|
|
workspace=$(workspace-path "$instance")
|
|
fi
|
|
|
|
local src_dir="$workspace/.devenv"
|
|
if [[ ! -d "$src_dir/shared" || ! -d "$src_dir/templates" ]]; then
|
|
echo "[$instance] .devenv/shared or .devenv/templates missing under $workspace; skipping MCP config generation." >&2
|
|
return 0
|
|
fi
|
|
|
|
local mcp_dir="$src_dir/mcp"
|
|
mkdir -p "$mcp_dir" "$workspace/.vscode"
|
|
|
|
PENPOT_MCP_PORT=$(instance-port "$instance" "$PENPOT_PORT_BASE_MCP")
|
|
SERENA_MCP_PORT=$(instance-port "$instance" "$PENPOT_PORT_BASE_SERENA")
|
|
export PENPOT_MCP_PORT SERENA_MCP_PORT
|
|
|
|
_merge-mcp-config-json \
|
|
"$src_dir/shared/claude-code.json" \
|
|
"$src_dir/templates/claude-code.json" \
|
|
"$mcp_dir/claude-code.json" \
|
|
mcpServers
|
|
_merge-mcp-config-json \
|
|
"$src_dir/shared/opencode.json" \
|
|
"$src_dir/templates/opencode.json" \
|
|
"$mcp_dir/opencode.json" \
|
|
mcp
|
|
# VS Code's mcp.json is auto-discovered at a fixed path, so on ws0 it IS the
|
|
# developer's own project file -- deep-merge into it rather than overwriting.
|
|
# On ws1+ the path does not exist yet, so this writes it from scratch.
|
|
_merge-mcp-config-json \
|
|
"$src_dir/shared/vscode.json" \
|
|
"$src_dir/templates/vscode.json" \
|
|
"$workspace/.vscode/mcp.json" \
|
|
servers \
|
|
--merge-into-existing
|
|
}
|
|
|
|
# Seed (or re-seed) a workspace from the live repo, then switch it onto a
|
|
# unique branch. Two-step sync:
|
|
# 1. .git directory is rsync'd directly (so the workspace has its own
|
|
# clone with the developer's current commits / index).
|
|
# 2. Working-tree files are enumerated by `git ls-files`, which is the
|
|
# only authoritative source for "what files belong in the working
|
|
# tree" (Git tracks files even when their parent directory matches
|
|
# a gitignore pattern, e.g. .clj-kondo/config.edn). Using rsync's
|
|
# gitignore filter directly misses those.
|
|
# Gitignored caches already in the workspace (node_modules, target, etc.)
|
|
# are left in place: no --delete on the working-tree pass.
|
|
function sync-workspace {
|
|
local instance="$1"
|
|
if [[ "$instance" == "ws0" ]]; then
|
|
return 0
|
|
fi
|
|
assert-clean-git-state || return 1
|
|
|
|
local workspace
|
|
workspace=$(workspace-path "$instance")
|
|
mkdir -p "$workspace"
|
|
|
|
echo "[$instance] syncing workspace at $workspace ..."
|
|
|
|
# .git directory — direct mirror, including index, refs, hooks, etc.
|
|
rsync -a --delete "$PWD/.git/" "$workspace/.git/"
|
|
|
|
# Working-tree files: tracked + untracked-not-ignored. git ls-files
|
|
# speaks Git's actual semantics, including the "tracked overrides
|
|
# gitignore" rule. --files-from feeds the path list to rsync verbatim.
|
|
local files
|
|
files=$(mktemp)
|
|
git -C "$PWD" ls-files -z --cached --others --exclude-standard >"$files"
|
|
rsync -a --files-from="$files" --from0 "$PWD/" "$workspace/"
|
|
rm -f "$files"
|
|
|
|
# Initial seed of frontend/resources/public/js/config.js. The file is
|
|
# gitignored, so git ls-files would not list it, yet the agentic devenv
|
|
# needs it (enable-mcp flag). After the first sync the workspace copy
|
|
# belongs to the user — subsequent syncs leave it untouched.
|
|
local cfg="frontend/resources/public/js/config.js"
|
|
if [[ -f "$PWD/$cfg" && ! -f "$workspace/$cfg" ]]; then
|
|
install -D "$PWD/$cfg" "$workspace/$cfg"
|
|
fi
|
|
|
|
# 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}"
|
|
|
|
instance-compose "$instance" up -d main
|
|
|
|
# Wait briefly for main to be reachable; the tmux session lives inside.
|
|
local container deadline
|
|
container=$(devenv-main-container "$instance")
|
|
deadline=$(( SECONDS + 30 ))
|
|
while ! docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -q true; do
|
|
[[ $SECONDS -ge $deadline ]] && {
|
|
echo "[${instance}] main container did not reach Running within 30s" >&2
|
|
return 1
|
|
}
|
|
sleep 1
|
|
done
|
|
|
|
# Ensure /home/penpot is writable by the penpot user before touching
|
|
# any files inside it (e.g. .gitconfig). start-tmux.sh also does this
|
|
# but runs later asynchronously, so the container may still be root-owned
|
|
# from a fresh volume mount at this point.
|
|
docker exec "$container" sudo chown penpot:users /home/penpot 2>/dev/null || true
|
|
|
|
# Seed the container's global git config from the values resolved on the
|
|
# host so commits made inside the devenv carry a real author/committer. Empty
|
|
# values are skipped — the host-identity warning is the caller's job.
|
|
if [[ -n "$git_user_name" ]]; then
|
|
docker exec "$container" sudo -u penpot git config --global user.name "$git_user_name"
|
|
fi
|
|
if [[ -n "$git_user_email" ]]; then
|
|
docker exec "$container" sudo -u penpot git config --global user.email "$git_user_email"
|
|
fi
|
|
|
|
# Detached tmux so callers don't block on attach. Agentic mode adds the
|
|
# MCP and Serena env vars that start-tmux.sh checks.
|
|
local -a tmux_env=(-e PENPOT_TMUX_ATTACH=false)
|
|
if [[ "$agentic" == "true" ]]; then
|
|
tmux_env+=(
|
|
-e PENPOT_FLAGS="${PENPOT_FLAGS:-} enable-mcp"
|
|
-e SERENA_ENABLED=true
|
|
-e SERENA_CONTEXT="$serena_context"
|
|
)
|
|
fi
|
|
docker exec -d \
|
|
"${tmux_env[@]}" \
|
|
"$container" \
|
|
sudo -EH -u penpot PENPOT_PLUGIN_DEV="${PENPOT_PLUGIN_DEV:-}" /home/start-tmux.sh
|
|
}
|
|
|
|
# Stop and remove one instance's containers without touching its volumes or
|
|
# its on-disk workspace directory (rule: never wipe data).
|
|
function stop-instance {
|
|
local instance="$1"
|
|
instance-compose "$instance" down -t 2
|
|
}
|
|
|
|
# Print per-instance URLs (Penpot UI, MCP stream endpoint, Serena, attach
|
|
# command) for one instance.
|
|
function print-instance-info {
|
|
local instance="$1"
|
|
local public mcp serena serena_dash
|
|
public=$(instance-port "$instance" "$PENPOT_PORT_BASE_PUBLIC")
|
|
public_https=$(instance-port "$instance" "$PENPOT_PORT_BASE_PUBLIC_HTTPS")
|
|
mcp=$(instance-port "$instance" "$PENPOT_PORT_BASE_MCP")
|
|
serena=$(instance-port "$instance" "$PENPOT_PORT_BASE_SERENA")
|
|
opencode=$(instance-port "$instance" "$PENPOT_PORT_BASE_OPENCODE")
|
|
serena_dash=$(instance-port "$instance" "$PENPOT_PORT_BASE_SERENA_DASHBOARD")
|
|
|
|
# --ws takes a bare integer; ws0 is the default, so its flag is elided.
|
|
local n="${instance#ws}"
|
|
local ws_flag=""
|
|
[[ "$instance" != "ws0" ]] && ws_flag=" --ws ${n}"
|
|
|
|
echo
|
|
echo "[$instance]"
|
|
echo " Penpot UI: https://localhost:${public_https}"
|
|
echo " Penpot UI: http://localhost:${public}"
|
|
echo " MCP stream: http://localhost:${mcp}/mcp"
|
|
echo " OpenCode Server: http://localhost:${opencode}"
|
|
echo " Serena MCP: http://localhost:${serena}"
|
|
echo " Serena dashboard: http://localhost:${serena_dash}"
|
|
echo " Attach: ./manage.sh attach-devenv${ws_flag}"
|
|
echo " Coding agent: ./manage.sh start-coding-agent claude${ws_flag} (or: opencode|vscode|codex)"
|
|
}
|
|
|
|
# Bring a single workspace up. Without --agentic it's non-agentic (no MCP, no
|
|
# Serena); with --agentic it enables MCP + Serena for AI-driven development.
|
|
# Supports --ws for parallel workspace targets; ws0 is the default.
|
|
function run-devenv {
|
|
local target="ws0"
|
|
local do_sync=false
|
|
local do_attach=false
|
|
local agentic=false
|
|
local serena_context="desktop-app"
|
|
local git_user_name=""
|
|
local git_user_email=""
|
|
local 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 INC Sucursal en España SL
|
|
EOF
|
|
}
|
|
|
|
function build-frontend-bundle {
|
|
echo ">> bundle frontend start";
|
|
|
|
mkdir -p ./bundles
|
|
local version=$(print-current-version);
|
|
local bundle_dir="./bundles/frontend";
|
|
|
|
build "frontend";
|
|
|
|
rm -rf $bundle_dir;
|
|
mv ./frontend/target/dist $bundle_dir;
|
|
echo $version > $bundle_dir/version.txt;
|
|
put-license-file $bundle_dir;
|
|
echo ">> bundle frontend end";
|
|
}
|
|
|
|
function build-mcp-bundle {
|
|
echo ">> bundle mcp start";
|
|
|
|
mkdir -p ./bundles
|
|
local version=$(print-current-version);
|
|
local bundle_dir="./bundles/mcp";
|
|
|
|
build "mcp";
|
|
|
|
rm -rf $bundle_dir;
|
|
mv ./mcp/dist $bundle_dir;
|
|
echo $version > $bundle_dir/version.txt;
|
|
put-license-file $bundle_dir;
|
|
echo ">> bundle mcp end";
|
|
}
|
|
|
|
|
|
function build-backend-bundle {
|
|
echo ">> bundle backend start";
|
|
|
|
mkdir -p ./bundles
|
|
local version=$(print-current-version);
|
|
local bundle_dir="./bundles/backend";
|
|
|
|
build "backend";
|
|
|
|
rm -rf $bundle_dir;
|
|
mv ./backend/target/dist $bundle_dir;
|
|
echo $version > $bundle_dir/version.txt;
|
|
put-license-file $bundle_dir;
|
|
echo ">> bundle backend end";
|
|
}
|
|
|
|
function build-exporter-bundle {
|
|
echo ">> bundle exporter start";
|
|
|
|
mkdir -p ./bundles
|
|
local version=$(print-current-version);
|
|
local bundle_dir="./bundles/exporter";
|
|
|
|
build "exporter";
|
|
|
|
rm -rf $bundle_dir;
|
|
mv ./exporter/target $bundle_dir;
|
|
echo $version > $bundle_dir/version.txt
|
|
put-license-file $bundle_dir;
|
|
echo ">> bundle exporter end";
|
|
}
|
|
|
|
function build-storybook-bundle {
|
|
echo ">> bundle storybook start";
|
|
|
|
mkdir -p ./bundles
|
|
local version=$(print-current-version);
|
|
local bundle_dir="./bundles/storybook";
|
|
|
|
build "frontend" "build-storybook";
|
|
|
|
rm -rf $bundle_dir;
|
|
mv ./frontend/storybook-static $bundle_dir;
|
|
echo $version > $bundle_dir/version.txt;
|
|
put-license-file $bundle_dir;
|
|
echo ">> bundle storybook end";
|
|
}
|
|
|
|
function build-docs-bundle {
|
|
echo ">> bundle docs start";
|
|
|
|
mkdir -p ./bundles
|
|
local version=$(print-current-version);
|
|
local bundle_dir="./bundles/docs";
|
|
|
|
build "docs";
|
|
|
|
rm -rf $bundle_dir;
|
|
mv ./docs/_dist $bundle_dir;
|
|
echo $version > $bundle_dir/version.txt;
|
|
put-license-file $bundle_dir;
|
|
echo ">> bundle docs end";
|
|
}
|
|
|
|
# Shared helper for the release-image builds below (frontend/backend/exporter/
|
|
# mcp/storybook). Parses --tag; without it, tags with both $CURRENT_BRANCH and
|
|
# 'latest' (historical default, unchanged). With --tag, tags ONLY that custom
|
|
# value -- same convention as build-devenv/build-imagemagick-docker-image: a
|
|
# custom tag is for an isolated/test build, not a stand-in for latest.
|
|
function _build-release-docker-image {
|
|
local image="$1" bundle_dir="$2" dockerfile="$3"; shift 3
|
|
local custom_tag=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--tag)
|
|
custom_tag="$2"; shift 2;;
|
|
*)
|
|
echo "build-${image}-docker-image: unknown argument '$1'" >&2
|
|
return 1;;
|
|
esac
|
|
done
|
|
|
|
local -a tag_args=()
|
|
if [[ -n "$custom_tag" ]]; then
|
|
tag_args=(-t "penpotapp/${image}:$custom_tag")
|
|
else
|
|
tag_args=(-t "penpotapp/${image}:$CURRENT_BRANCH" -t "penpotapp/${image}:latest")
|
|
fi
|
|
|
|
rsync -avr --delete "./bundles/${image}/" "./docker/images/${bundle_dir}/";
|
|
pushd ./docker/images;
|
|
docker build \
|
|
"${tag_args[@]}" \
|
|
--build-arg BUNDLE_PATH="./${bundle_dir}/" \
|
|
-f "$dockerfile" .;
|
|
popd;
|
|
}
|
|
|
|
function build-frontend-docker-image {
|
|
_build-release-docker-image frontend bundle-frontend Dockerfile.frontend "$@"
|
|
}
|
|
|
|
function build-backend-docker-image {
|
|
_build-release-docker-image backend bundle-backend Dockerfile.backend "$@"
|
|
}
|
|
|
|
function build-exporter-docker-image {
|
|
_build-release-docker-image exporter bundle-exporter Dockerfile.exporter "$@"
|
|
}
|
|
|
|
function build-mcp-docker-image {
|
|
_build-release-docker-image mcp bundle-mcp Dockerfile.mcp "$@"
|
|
}
|
|
|
|
function build-storybook-docker-image {
|
|
_build-release-docker-image storybook bundle-storybook Dockerfile.storybook "$@"
|
|
}
|
|
|
|
function usage {
|
|
echo "PENPOT build & release manager"
|
|
echo "USAGE: $0 OPTION"
|
|
echo ""
|
|
echo "Development environment (devenv)"
|
|
echo "--------------------------------"
|
|
echo "The devenv runs Penpot in a Docker container and supports parallel"
|
|
echo "'workspaces': ws0 (the live repo at \$PWD) and optional wsN (N >= 1, sibling"
|
|
echo "clones). Use --ws N to target a specific workspace; the default is 0."
|
|
echo "Full guide: docs/technical-guide/developer/{devenv,agentic-devenv}.md."
|
|
echo ""
|
|
echo "Image tag: all devenv commands use \$DEVENV_TAG (default: latest). To work"
|
|
echo "against a custom tag end-to-end (build, then run), export it before both"
|
|
echo "calls: 'export DEVENV_TAG=mytest; ./manage.sh build-devenv; ./manage.sh run-devenv'."
|
|
echo "'build-devenv --tag mytest' only affects that single build call otherwise."
|
|
echo ""
|
|
echo "Image lifecycle"
|
|
echo "- pull-devenv Pull the devenv docker image from the registry."
|
|
echo "- build-devenv [--tag TAG] [--push]"
|
|
echo " Build the devenv docker image. Local-only by default (single-"
|
|
echo " platform, 'docker build'); pass --push to build multi-platform"
|
|
echo " (amd64+arm64) and push to the registry instead. --tag TAG overrides"
|
|
echo " the image tag (default: latest)."
|
|
echo ""
|
|
echo "Bring a devenv up / down"
|
|
echo "- run-devenv Bring one workspace up, start its tmux session in the background,"
|
|
echo " and print the workspace's URLs. Pass --agentic to enable MCP + Serena"
|
|
echo " for AI-driven development (also regenerates per-workspace MCP configs)."
|
|
echo " Options:"
|
|
echo " --ws N target workspace (default: 0)."
|
|
echo " --sync re-seed the wsN clone from the live repo before"
|
|
echo " starting (forbidden on ws0; implicit on first"
|
|
echo " start of a wsN with no on-disk workspace yet)."
|
|
echo " --attach attach to the tmux session after startup."
|
|
echo " --agentic enable MCP + Serena (AI-agent mode)."
|
|
echo " --serena-context CTX passed to Serena (default: desktop-app)."
|
|
echo " --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-storybook-bundle Build storybook bundle."
|
|
echo "- build-docs-bundle Build docs bundle."
|
|
echo ""
|
|
echo "- build-docker-images [--tag TAG] Build all docker images (frontend, backend, exporter, mcp and storybook)."
|
|
echo " Without --tag, each image is tagged with both the current git branch"
|
|
echo " and 'latest' (unchanged default). With --tag, ONLY that custom tag"
|
|
echo " is applied, to all five images."
|
|
echo "- build-frontend-docker-image [--tag TAG] Build frontend docker image."
|
|
echo "- build-backend-docker-image [--tag TAG] Build backend docker image."
|
|
echo "- build-exporter-docker-image [--tag TAG] Build exporter docker image."
|
|
echo "- build-mcp-docker-image [--tag TAG] Build mcp docker image."
|
|
echo "- build-storybook-docker-image [--tag TAG] Build storybook docker image."
|
|
echo "- build-imagemagick-docker-image [--tag TAG] [--push]"
|
|
echo " Build the imagemagick docker image. Local-only by default (single-"
|
|
echo " platform); pass --push to build multi-platform (amd64+arm64) and"
|
|
echo " push to the registry instead. --tag TAG builds ONLY that tag"
|
|
echo " (default without --tag: both 'latest' and \$IMAGEMAGICK_VERSION)."
|
|
echo ""
|
|
echo "- version Show penpot's version."
|
|
}
|
|
|
|
case $1 in
|
|
version)
|
|
print-current-version
|
|
;;
|
|
|
|
## devenv related commands
|
|
pull-devenv)
|
|
pull-devenv ${@:2};
|
|
;;
|
|
|
|
build-devenv)
|
|
shift;
|
|
build-devenv $@;
|
|
;;
|
|
|
|
create-devenv)
|
|
create-devenv ${@:2}
|
|
;;
|
|
|
|
run-devenv)
|
|
run-devenv ${@:2}
|
|
;;
|
|
run-devenv-agentic)
|
|
run-devenv --agentic ${@:2}
|
|
;;
|
|
attach-devenv)
|
|
attach-devenv ${@:2}
|
|
;;
|
|
start-coding-agent)
|
|
start-coding-agent "${@:2}"
|
|
;;
|
|
stop-devenv)
|
|
stop-devenv ${@:2}
|
|
;;
|
|
drop-devenv)
|
|
drop-devenv ${@:2}
|
|
;;
|
|
log-devenv)
|
|
log-devenv ${@:2}
|
|
;;
|
|
|
|
## production builds
|
|
build-bundle)
|
|
build-frontend-bundle;
|
|
build-mcp-bundle;
|
|
build-backend-bundle;
|
|
build-exporter-bundle;
|
|
build-storybook-bundle;
|
|
;;
|
|
|
|
build-frontend-bundle)
|
|
build-frontend-bundle;
|
|
;;
|
|
|
|
build-mcp-bundle)
|
|
build-mcp-bundle;
|
|
;;
|
|
|
|
build-backend-bundle)
|
|
build-backend-bundle;
|
|
;;
|
|
|
|
build-exporter-bundle)
|
|
build-exporter-bundle;
|
|
;;
|
|
|
|
build-storybook-bundle)
|
|
build-storybook-bundle;
|
|
;;
|
|
|
|
build-docs-bundle)
|
|
build-docs-bundle;
|
|
;;
|
|
|
|
build-docker-images)
|
|
build-frontend-docker-image "${@:2}"
|
|
build-backend-docker-image "${@:2}"
|
|
build-exporter-docker-image "${@:2}"
|
|
build-mcp-docker-image "${@:2}"
|
|
build-storybook-docker-image "${@:2}"
|
|
;;
|
|
|
|
build-frontend-docker-image)
|
|
build-frontend-docker-image "${@:2}"
|
|
;;
|
|
|
|
build-backend-docker-image)
|
|
build-backend-docker-image "${@:2}"
|
|
;;
|
|
|
|
build-exporter-docker-image)
|
|
build-exporter-docker-image "${@:2}"
|
|
;;
|
|
|
|
build-mcp-docker-image)
|
|
build-mcp-docker-image "${@:2}"
|
|
;;
|
|
|
|
build-storybook-docker-image)
|
|
build-storybook-docker-image "${@:2}"
|
|
;;
|
|
|
|
build-imagemagick-docker-image)
|
|
shift;
|
|
build-imagemagick-docker-image $@;
|
|
;;
|
|
|
|
*)
|
|
usage
|
|
;;
|
|
esac
|