penpot/.devenv/scripts/merge-mcp-config.py
Michael Panchenko 16dc83616a
Add the ability to launch parallel devenv instances (#9906)
* 🐳 Split devenv compose for parallel workspaces

Move shared services into an infra compose file and keep the main devenv container plus Valkey in a separate compose file driven by defaults.env. Parameterize host-side ports, container names, source path, and runtime env while keeping container-internal ports fixed for same-origin proxying.

Make tmux startup idempotent, add attach-devenv for the live instance, move shared MinIO user setup to infra startup, and let exporter scripts load backend _env.local overrides.

Co-authored-by: Codex <codex@openai.com>

* 🐳 Run parallel devenv instances against shared infra

Add support for running N parallel devenv instances under separate compose
projects sharing Postgres, MinIO, mailer, and LDAP. Each instance has its
own main container, Valkey, source checkout, tmux session, and host port
range offset by 10000 (3449 -> 13449 -> 23449, etc.).

./manage.sh run-devenv-agentic --n-instances N reconciles the running set
to exactly {ws0..ws(N-1)}: missing instances are created (workspace sync
from the live repo via git ls-files + per-instance env-file generation
under docker/devenv/instances/ + detached tmux startup), surplus instances
are stopped highest-first via compose down (never -v), already-running
instances are left untouched. ws0 binds the live repo at PWD; ws1+ are
scratch clones under ~/.penpot/penpot_workspaces/.

Backend workers (enable-backend-worker) are gated on PENPOT_BACKEND_WORKER
in backend/scripts/_env; ws1+ overlays disable them so async-task
notifications stay bound to a single Valkey Pub/Sub instance.

Compose helpers wrap docker compose with env -i so per-instance overlay
--env-file actually overrides defaults.env -- without the strip, the shell
env from sourcing defaults.env at startup would shadow the overlay (Compose
gives shell precedence over --env-file).

Other:
- Drop network aliases (- main, - redis); use container_name for
  cross-container DNS so multiple instances on the shared network don't
  fight over the same DNS name.
- Pin volume names via name: (PENPOT_*_VOLUME) so volumes survive project
  renames; ws0 keeps the pre-existing physical names (penpotdev_*).
- Remove cross-project depends_on from main.yml (postgres/minio-setup now
  live in penpotdev-infra); manage.sh ensure-infra-up docker-waits on the
  minio-setup one-shot.
- Strict arg parsing in run-devenv / run-devenv-agentic; --n-instances 0
  rejected.
- Remove unused Host-matched server block from the Caddyfile.

Memory mem:devenv/core and developer docs updated.

Co-authored-by: Codex <codex@openai.com>

*  Document and stabilise the parallel-workspace CLI; wire AI agents

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>

* ♻️ Scope the shadow devtools to the dev build

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 15:48:25 +02:00

205 lines
8.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Combine a shared MCP-server config with a port-substituted template for one
AI coding-agent client.
Invoked per workspace by manage.sh's `write-instance-mcp-configs` (JSON
clients) and by `start-coding-agent` (Codex). Each supported client ships a
`.devenv/shared/<tool>.{json,toml}` (workspace-independent entries, e.g.
Playwright) and a `.devenv/templates/<tool>.{json,toml}` (per-workspace entries
with `${PENPOT_MCP_PORT}` / `${SERENA_MCP_PORT}` placeholders). This script
combines the two for the target client.
Two output modes are supported:
json Deep-merge two JSON documents under a configurable top-level key
(`mcpServers` for Claude Code, `mcp` for opencode, `servers` for
VS Code Copilot) and write the result to <out>. Same-name
entries in the template override entries in shared. With
--merge-into-existing, any pre-existing <out> file is loaded as
the lowest-precedence layer first, so entries the developer
already had are preserved (ours win on name collision). This is
used for VS Code's auto-discovered `.vscode/mcp.json`, which on
ws0 IS the live repo's file and may hold the developer's own
servers; the Claude/opencode outputs live in a dedicated,
gitignored `.devenv/mcp/` path and are written without the flag
(a clean overwrite).
codex-args Deep-merge the two TOML chunks and print one
`dotted.key=<toml-value>` assignment per line to stdout (no
<out> file). The caller wraps each line in a `codex -c` flag.
Codex has no way to load an MCP config from an arbitrary file
path (CODEX_HOME would relocate auth/history too), so rather than
writing the auto-discovered `.codex/config.toml` we inject our
servers as ephemeral per-invocation overrides. This never
touches the developer's project- or user-level Codex config.
In both modes, `${VAR}` placeholders inside *either* chunk are resolved from
the current environment (only template chunks carry placeholders in practice,
but the substitution is uniform either way) using Python's
`os.path.expandvars`. Undefined placeholders are left as `${VAR}` literal text
-- callers (i.e. manage.sh) are responsible for exporting the variables before
invoking the script.
Usage:
merge-mcp-config.py --format json --key <key> [--merge-into-existing] \
<shared> <template> <out>
merge-mcp-config.py --format codex-args <shared> <template>
Exit codes:
0 success
2 argparse error (missing required option, bad value, unreadable input)
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import tomllib
from pathlib import Path
def merge_json(
shared_path: Path,
tpl_path: Path,
out_path: Path,
key: str,
merge_into_existing: bool,
) -> None:
"""Deep-merge JSON documents under a single top-level dict key into out.
Precedence (lowest to highest): an existing <out> file (only when
merge_into_existing is set), then shared, then the template. Entries under
`key` are merged by name, so the template wins on a name collision while
every other entry the lower layers contributed is kept. Top-level keys
other than `key` come from the existing file and shared (shared wins).
"""
shared = json.loads(shared_path.read_text())
tpl = json.loads(os.path.expandvars(tpl_path.read_text()))
base: dict = {}
if merge_into_existing and out_path.exists():
base = json.loads(out_path.read_text())
merged: dict = {**base, **shared}
merged[key] = {**base.get(key, {}), **shared.get(key, {}), **tpl.get(key, {})}
out_path.write_text(json.dumps(merged, indent=2) + "\n")
def _deep_merge(base: dict, overlay: dict) -> dict:
"""Recursively merge overlay into base; overlay wins on scalar/list keys."""
out = dict(base)
for k, v in overlay.items():
if isinstance(out.get(k), dict) and isinstance(v, dict):
out[k] = _deep_merge(out[k], v)
else:
out[k] = v
return out
def _toml_value(value: object) -> str:
"""Serialize a scalar/list as a TOML literal for a `codex -c` value.
bool is checked before int because `isinstance(True, int)` is True. Strings
are emitted as JSON strings, which are valid TOML basic strings for the
ASCII values our configs carry (commands, args, URLs). Tables never reach
here -- they are flattened into dotted keys by _flatten.
"""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return repr(value)
if isinstance(value, str):
return json.dumps(value)
if isinstance(value, list):
return "[" + ", ".join(_toml_value(v) for v in value) + "]"
raise TypeError(f"unsupported TOML value type: {type(value).__name__}")
_BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
def _key_segment(seg: str) -> str:
"""A dotted-key segment: bare if TOML-safe, else a quoted key."""
return seg if _BARE_KEY.match(seg) else json.dumps(seg)
def _flatten(obj: dict, prefix: list[str]):
"""Yield (dotted-path-segments, leaf-value) for every non-table leaf.
Lists are leaves (TOML arrays), so we do not recurse into them; nested
tables (e.g. an `env` table) are flattened into further dotted keys.
"""
for k, v in obj.items():
path = prefix + [k]
if isinstance(v, dict):
yield from _flatten(v, path)
else:
yield path, v
def emit_codex_args(shared_path: Path, tpl_path: Path) -> None:
"""Print `dotted.key=<toml-value>` lines from the merged TOML chunks."""
shared = tomllib.loads(os.path.expandvars(shared_path.read_text()))
tpl = tomllib.loads(os.path.expandvars(tpl_path.read_text()))
merged = _deep_merge(shared, tpl)
for path, value in _flatten(merged, []):
dotted = ".".join(_key_segment(s) for s in path)
sys.stdout.write(f"{dotted}={_toml_value(value)}\n")
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
description=__doc__.split("\n\n", 1)[0],
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--format",
choices=("json", "codex-args"),
required=True,
help="Output mode: 'json' writes a merged file; 'codex-args' prints -c assignments.",
)
parser.add_argument(
"--key",
help="Top-level JSON key under which MCP entries live (required for --format json).",
)
parser.add_argument(
"--merge-into-existing",
action="store_true",
help="json only: layer the merge on top of an existing <out> file, "
"preserving entries already there (ours still win on name collision).",
)
parser.add_argument("shared", type=Path, help="Path to the shared chunk.")
parser.add_argument("template", type=Path, help="Path to the port-placeholder template chunk.")
parser.add_argument(
"out",
type=Path,
nargs="?",
help="Path the merged result is written to (json only; codex-args writes stdout).",
)
args = parser.parse_args(argv)
if args.format == "json":
if not args.key:
parser.error("--key is required when --format json")
if args.out is None:
parser.error("out is required when --format json")
merge_json(args.shared, args.template, args.out, args.key, args.merge_into_existing)
else: # codex-args
if args.key:
parser.error("--key is not accepted when --format codex-args")
if args.merge_into_existing:
parser.error("--merge-into-existing is not accepted when --format codex-args")
if args.out is not None:
parser.error("out path is not accepted when --format codex-args (result goes to stdout)")
emit_codex_args(args.shared, args.template)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))