Repository navigation
Expand file tree
/
Copy pathmanage.sh
More file actions
executable file
·1505 lines (1329 loc) · 59 KB
/
Copy pathmanage.sh
File metadata and controls
executable file
·1505 lines (1329 loc) · 59 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/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), 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")
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" \
"${overrides[@]}" \
docker compose -p "penpotdev-${instance}" \
--env-file "$DEVENV_DEFAULTS_FILE" \
-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 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
(
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"
}
# 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 -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;;
--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] [--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 " --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
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