liquidum 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/bin/de ADDED
@@ -0,0 +1,1112 @@
1
+ #!/usr/bin/env bash
2
+ # de: development environments for this repository. Run without arguments
3
+ # for help. Repository specific settings live in .devcontainer/env.conf, this
4
+ # script is generic: it comes from the de repository, whose `de init` copies
5
+ # it into a repository as bin/de, the same everywhere. Run from the de
6
+ # repository itself (e.g. linked onto the PATH as `de`), it sets repositories
7
+ # up and hands every other command to the bin/de of the current checkout.
8
+ #
9
+ # Every checkout (main checkout or git worktree) gets its own environment: a
10
+ # Docker Compose project named after the branch, labelled with
11
+ #
12
+ # dev.env.name the environment (and compose project) name
13
+ # dev.env.kind persistent | ephemeral
14
+ # dev.env.branch the branch, empty for a detached HEAD
15
+ # dev.env.path the checkout on the host
16
+ # dev.env.gitdir the repository's common .git directory on the host
17
+ #
18
+ # Cleanup selects resources by these labels, never by name.
19
+ set -euo pipefail
20
+
21
+ # Through symlinks, e.g. ~/.local/bin/de to the de repository's bin/de
22
+ DE_ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd -P)"
23
+ # Set when running from the de repository rather than a repository's copy
24
+ DE_TEMPLATE=""
25
+ DE_DOCS="https://github.com/tdegrunt/de#readme"
26
+ # Your own tools for every environment, see env_write_host_feature
27
+ DE_CONFIG="${DE_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/de/config.json}"
28
+ # Where the folders "home" in DE_CONFIG lists are kept, shared by every environment
29
+ DE_HOME_STATE="${XDG_STATE_HOME:-$HOME/.local/state}/de/home"
30
+ # `de exec` passes a SELENIUM_URL from the caller on, see env.conf. Unset it
31
+ # so env.conf's value isn't exported to the commands this script runs.
32
+ ENV_SELENIUM_URL_GIVEN="${SELENIUM_URL:-}"
33
+ unset SELENIUM_URL
34
+ UP_COMMAND=()
35
+ PRIMARY_BRANCHES=(main master)
36
+ if [ -f "$DE_ROOT/.devcontainer/env.conf" ]; then
37
+ # shellcheck source=../.devcontainer/env.conf
38
+ source "$DE_ROOT/.devcontainer/env.conf"
39
+ elif [ -d "$DE_ROOT/template" ]; then
40
+ DE_TEMPLATE="$DE_ROOT/template"
41
+ else
42
+ printf 'error: %s has neither .devcontainer/env.conf nor template/\n' "$DE_ROOT" >&2
43
+ exit 1
44
+ fi
45
+
46
+ # The commands of a repository's bin/de, also listed by the de repository's help
47
+ usage_commands() {
48
+ cat <<'EOF'
49
+ new <branch> [folder] [--from <ref>] [--copy-data[=<services>]]
50
+ [--copy-data-from <checkout>] [--yes] [--no-trust]
51
+ Create a worktree for a branch (next to the main checkout
52
+ as <repository>-<branch> unless a folder is given) and
53
+ start its environment. New branches start from the
54
+ default branch, or from --from. --no-trust as for up.
55
+ --copy-data starts it with a copy of the volumes of the
56
+ environment it branches off (an existing branch: this
57
+ checkout's, or --copy-data-from's), of every service but
58
+ app or the listed ones, e.g. --copy-data=postgres,redis.
59
+ Those services are stopped during the copy, after
60
+ confirming, or right away with --yes.
61
+ up [--rebuild] [--no-trust]
62
+ Create or start this checkout's environment and prepare
63
+ its databases. --rebuild recreates the containers after
64
+ changing .devcontainer/, keeping the data. Trusts the
65
+ checkout's mise config on the host, unless --no-trust.
66
+ exec <command...> Run a command in this checkout's devcontainer, in the same
67
+ directory relative to the checkout.
68
+ status Show the environment's containers, health and URLs.
69
+ code [<checkout>] Open a checkout in VS Code inside its devcontainer,
70
+ starting it first if needed. <checkout> is a branch,
71
+ environment name, worktree folder name or a path in
72
+ it, default this one.
73
+ down [--destroy-persistent]
74
+ Remove an ephemeral environment with its data. A
75
+ persistent one is only stopped, unless
76
+ --destroy-persistent is given and its name confirmed.
77
+ prune [--yes] Remove ephemeral environments whose worktree or branch
78
+ is gone. --yes skips the confirmation.
79
+ EOF
80
+ }
81
+
82
+ usage() {
83
+ cat <<EOF
84
+ Usage: bin/de <command> [arguments]
85
+
86
+ Every checkout of this repository, the main one and each git worktree, gets
87
+ its own development environment: a devcontainer with its own services,
88
+ volumes and network. Environments for ${PRIMARY_BRANCHES[*]} ${PERSISTENT_BRANCHES[*]} are
89
+ persistent and keep their data, all others are ephemeral. A primary branch
90
+ (${PRIMARY_BRANCHES[*]}) gets one named $ENV_REPO, the others $ENV_REPO-<branch>.
91
+
92
+ Commands:
93
+ $(usage_commands)
94
+ help Show this help.
95
+
96
+ Examples:
97
+ bin/de new fix-login
98
+ bin/de exec bin/rails test
99
+ SELENIUM_URL=http://host.docker.internal:4444/ HEADLESS=0 bin/de exec bin/rails test:system
100
+ bin/de code main
101
+
102
+ Settings: .devcontainer/env.conf, your own tools: $DE_CONFIG,
103
+ docs: $DE_DOCS. To update bin/de, run \`de update\` from the de repository.
104
+ EOF
105
+ }
106
+
107
+ # Help when running from the de repository
108
+ usage_source() {
109
+ cat <<EOF
110
+ Usage: de <command> [arguments]
111
+
112
+ Development environments, one devcontainer per checkout. This is the de
113
+ repository's copy ($DE_ROOT): it sets repositories up, and hands every other
114
+ command to the bin/de of the checkout you're in.
115
+
116
+ Commands:
117
+ init [<repository>] [--name <name>] [--force]
118
+ Set up development environments in a repository (default
119
+ the current one). Repository specific files that already
120
+ exist there are kept, unless --force is given. --name
121
+ sets ENV_REPO, the prefix of its environment names,
122
+ default the repository's folder name: needed when two
123
+ repositories have folders of the same name.
124
+ update [<repository>] [--name <name>] [--force]
125
+ Update a repository's bin/de, and add what's new in the
126
+ setup, like init but only where it's set up already.
127
+ upgrade Update this copy of de with a git pull. Then run de
128
+ update in a repository to update its bin/de.
129
+ help Show this help.
130
+
131
+ Run by the current checkout's bin/de (see bin/de help there):
132
+ $(usage_commands)
133
+
134
+ Your own tools for every environment: $DE_CONFIG
135
+ EOF
136
+ }
137
+
138
+ # --- Output -------------------------------------------------------------
139
+
140
+ log() { printf '\033[1;34m==>\033[0m %s\n' "$*" >&2; }
141
+ warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; }
142
+ die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
143
+
144
+ # Prints a command, then runs it
145
+ run() {
146
+ printf '\033[2m+' >&2
147
+ printf ' %q' "$@" >&2
148
+ printf '\033[0m\n' >&2
149
+ "$@"
150
+ }
151
+
152
+ devcontainer_cli() {
153
+ if command -v devcontainer >/dev/null 2>&1; then
154
+ run devcontainer "$@"
155
+ else
156
+ run npx --yes "@devcontainers/cli@$DEVCONTAINER_CLI_VERSION" "$@"
157
+ fi
158
+ }
159
+
160
+ # --- Environment identity -----------------------------------------------
161
+
162
+ # Lowercases and replaces everything but [a-z0-9-] with dashes
163
+ env_sanitize() {
164
+ printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | tr -s '-' | sed 's/^-//; s/-$//'
165
+ }
166
+
167
+ env_is_primary_branch() {
168
+ local branch
169
+ for branch in "${PRIMARY_BRANCHES[@]}"; do
170
+ [ "$1" = "$branch" ] && return 0
171
+ done
172
+ return 1
173
+ }
174
+
175
+ # Primary branches are always persistent
176
+ env_is_persistent_branch() {
177
+ local branch
178
+ env_is_primary_branch "$1" && return 0
179
+ for branch in "${PERSISTENT_BRANCHES[@]}"; do
180
+ [ "$1" = "$branch" ] && return 0
181
+ done
182
+ return 1
183
+ }
184
+
185
+ # Prints the environment name for a checkout directory
186
+ env_name_for() {
187
+ local dir="$1" branch name
188
+ branch="$(git -C "$dir" symbolic-ref --short -q HEAD || true)"
189
+ if [ -n "$branch" ]; then
190
+ env_name_for_branch "$branch"
191
+ return
192
+ fi
193
+ env_name_shorten "$(env_sanitize "$ENV_REPO-detached-$(basename "$dir")")"
194
+ }
195
+
196
+ # Prints the environment name for a checkout of a branch
197
+ env_name_for_branch() {
198
+ if env_is_primary_branch "$1"; then
199
+ env_name_shorten "$(env_sanitize "$ENV_REPO")"
200
+ else
201
+ env_name_shorten "$(env_sanitize "$ENV_REPO-$1")"
202
+ fi
203
+ }
204
+
205
+ # Compose project names and OrbStack domain labels max out at 63 characters
206
+ env_name_shorten() {
207
+ local name="$1"
208
+ if [ "${#name}" -gt 63 ]; then
209
+ name="${name:0:54}-$(printf '%s' "$name" | shasum | cut -c1-8)"
210
+ fi
211
+ printf '%s' "$name"
212
+ }
213
+
214
+ # Sets the ENV_* variables describing the checkout that contains $1
215
+ env_init() {
216
+ local dir="${1:-$PWD}" common
217
+
218
+ ENV_PATH="$(cd "$(git -C "$dir" rev-parse --show-toplevel)" && pwd -P)" ||
219
+ die "$dir is not inside a git checkout"
220
+ common="$(git -C "$ENV_PATH" rev-parse --git-common-dir)"
221
+ ENV_GIT_COMMON_DIR="$(cd "$ENV_PATH" && cd "$common" && pwd -P)"
222
+ ENV_MAIN_CHECKOUT="$(dirname "$ENV_GIT_COMMON_DIR")"
223
+ ENV_BRANCH="$(git -C "$ENV_PATH" symbolic-ref --short -q HEAD || true)"
224
+ ENV_NAME="$(env_name_for "$ENV_PATH")"
225
+
226
+ if [ -n "$ENV_BRANCH" ] && env_is_persistent_branch "$ENV_BRANCH"; then
227
+ ENV_KIND=persistent
228
+ ENV_RESTART=unless-stopped
229
+ else
230
+ ENV_KIND=ephemeral
231
+ ENV_RESTART=no
232
+ fi
233
+
234
+ # Inside the container, both the checkout and the common .git directory are
235
+ # mounted at /workspaces/<host path>, so the relative paths in a worktree's
236
+ # .git file (worktree.useRelativePaths) resolve the same way.
237
+ # devcontainer.json uses the same path as its workspaceFolder.
238
+ ENV_PATH_IN_CONTAINER="/workspaces$ENV_PATH"
239
+ ENV_GIT_COMMON_DIR_IN_CONTAINER="/workspaces$ENV_GIT_COMMON_DIR"
240
+ # A shorter, stable path to the checkout, for interactive use
241
+ ENV_WORKSPACE_LINK="/workspaces/$ENV_REPO"
242
+
243
+ ENV_DOTENV="$ENV_PATH/.devcontainer/.env"
244
+ }
245
+
246
+ # Prints where a browser driven through Selenium at $1 reaches the app's test
247
+ # server: by service name from the environment's own selenium container, and
248
+ # by OrbStack domain from a browser on the Mac
249
+ env_capybara_app_host() {
250
+ case "$1" in
251
+ *//selenium:*) echo "http://app:3100" ;;
252
+ *) echo "http://app.$ENV_NAME.orb.local:3100" ;;
253
+ esac
254
+ }
255
+
256
+ # Writes .devcontainer/.env, which docker compose reads for the project name,
257
+ # labels and mounts
258
+ env_write_dotenv() {
259
+ cat > "$ENV_DOTENV" <<EOF
260
+ # Written by bin/de, don't edit
261
+ COMPOSE_PROJECT_NAME=$ENV_NAME
262
+ ENV_NAME=$ENV_NAME
263
+ ENV_KIND=$ENV_KIND
264
+ ENV_BRANCH=$ENV_BRANCH
265
+ ENV_PATH=$ENV_PATH
266
+ ENV_GIT_COMMON_DIR=$ENV_GIT_COMMON_DIR
267
+ ENV_PATH_IN_CONTAINER=$ENV_PATH_IN_CONTAINER
268
+ ENV_GIT_COMMON_DIR_IN_CONTAINER=$ENV_GIT_COMMON_DIR_IN_CONTAINER
269
+ ENV_WORKSPACE_LINK=$ENV_WORKSPACE_LINK
270
+ ENV_RESTART=$ENV_RESTART
271
+ ENV_GIT_CONFIG_DIR=$HOME/.config/git
272
+ SELENIUM_URL=$SELENIUM_URL
273
+ CAPYBARA_APP_HOST=$(env_capybara_app_host "$SELENIUM_URL")
274
+ EOF
275
+ }
276
+
277
+ # --- Docker resources ---------------------------------------------------
278
+
279
+ # Runs docker compose for this checkout's project
280
+ env_compose() {
281
+ run docker compose --project-directory "$ENV_PATH/.devcontainer" \
282
+ -f "$ENV_PATH/.devcontainer/docker-compose.yml" \
283
+ --env-file "$ENV_DOTENV" -p "$ENV_NAME" "$@"
284
+ }
285
+
286
+ # Succeeds when the app container of an environment is running
287
+ env_running() {
288
+ [ -n "$(docker container ls -q --filter "label=dev.env.name=$1" --filter label=com.docker.compose.service=app)" ]
289
+ }
290
+
291
+ # Prints the value of a label on any container, volume or network of an
292
+ # environment
293
+ env_label() {
294
+ local name="$1" label="$2" kind values
295
+ for kind in container volume network; do
296
+ values="$(docker "$kind" ls $([ "$kind" = container ] && echo -a) --filter "label=dev.env.name=$name" --format "{{.Label \"$label\"}}")"
297
+ if [ -n "$values" ]; then
298
+ head -n1 <<<"$values"
299
+ return
300
+ fi
301
+ done
302
+ }
303
+
304
+ # Prints the names of this repository's environments of a kind, one per line
305
+ env_list() {
306
+ local kind_filter="label=dev.env.kind=$1" repo_filter="label=dev.env.gitdir=$ENV_GIT_COMMON_DIR"
307
+ {
308
+ docker container ls -a --filter "$kind_filter" --filter "$repo_filter" --format '{{.Label "dev.env.name"}}'
309
+ docker volume ls --filter "$kind_filter" --filter "$repo_filter" --format '{{.Label "dev.env.name"}}'
310
+ docker network ls --filter "$kind_filter" --filter "$repo_filter" --format '{{.Label "dev.env.name"}}'
311
+ } | sort -u | sed '/^$/d'
312
+ }
313
+
314
+ # Succeeds when any resource of the environment is labelled persistent
315
+ env_is_persistent() {
316
+ [ -n "$(docker container ls -aq --filter "label=dev.env.name=$1" --filter label=dev.env.kind=persistent)$(docker volume ls -q --filter "label=dev.env.name=$1" --filter label=dev.env.kind=persistent)$(docker network ls -q --filter "label=dev.env.name=$1" --filter label=dev.env.kind=persistent)" ]
317
+ }
318
+
319
+ # Removes every container, volume and network of an environment. Resources are
320
+ # found by the dev.env.name label, plus the compose project label for the ones
321
+ # devcontainer features add without our labels (the docker-in-docker volume).
322
+ env_destroy() {
323
+ local name="$1" ids filter
324
+ for filter in "label=dev.env.name=$name" "label=com.docker.compose.project=$name"; do
325
+ ids="$(docker container ls -aq --filter "$filter")"
326
+ [ -z "$ids" ] || run docker container rm --force --volumes $ids >/dev/null
327
+ done
328
+ for filter in "label=dev.env.name=$name" "label=com.docker.compose.project=$name"; do
329
+ ids="$(docker volume ls -q --filter "$filter")"
330
+ [ -z "$ids" ] || run docker volume rm --force $ids >/dev/null
331
+ ids="$(docker network ls -q --filter "$filter")"
332
+ [ -z "$ids" ] || run docker network rm $ids >/dev/null
333
+ done
334
+ }
335
+
336
+ # Prints "<service> <health>" for every running container of an environment,
337
+ # where health is the healthcheck status, or "running" without a healthcheck
338
+ env_health() {
339
+ local ids
340
+ ids="$(docker container ls -q --filter "label=dev.env.name=$1")"
341
+ [ -z "$ids" ] && return
342
+ # shellcheck disable=SC2086
343
+ docker container inspect $ids --format '{{ index .Config.Labels "com.docker.compose.service" }} {{ if .State.Health }}{{ .State.Health.Status }}{{ else }}{{ .State.Status }}{{ end }}'
344
+ }
345
+
346
+ # Prints "<path>\t<branch>" for every checkout of the repository
347
+ checkouts() {
348
+ git -C "$ENV_PATH" worktree list --porcelain | awk '
349
+ /^worktree / { if (path != "") print path "\t" branch; path = substr($0, 10); branch = "" }
350
+ /^branch / { branch = substr($0, 8); sub("^refs/heads/", "", branch) }
351
+ END { if (path != "") print path "\t" branch }'
352
+ }
353
+
354
+ # Prints the checkout a branch, environment name, worktree folder name or a
355
+ # path in it names, nothing when none does
356
+ checkout_for() {
357
+ local path branch
358
+ while IFS=$'\t' read -r path branch; do
359
+ [ -d "$path" ] || continue
360
+ if [ "$1" = "$branch" ] || [ "$1" = "$(basename "$path")" ] || [ "$1" = "$(env_name_for "$path")" ]; then
361
+ printf '%s' "$path"
362
+ return
363
+ fi
364
+ done < <(checkouts)
365
+ # Or any folder in a checkout, e.g. the absolute path Orca's "Open in" adds
366
+ if [ -d "$1" ]; then
367
+ (cd "$(git -C "$1" rev-parse --show-toplevel)" && pwd -P) || die "$1 is not inside a git checkout"
368
+ fi
369
+ }
370
+
371
+ # Lists the checkouts with their environment names, on stderr
372
+ checkouts_print() {
373
+ local path branch
374
+ while IFS=$'\t' read -r path branch; do
375
+ [ -d "$path" ] && printf ' %-40s %-30s %s\n' "$(env_name_for "$path")" "${branch:-(detached)}" "$path" >&2
376
+ done < <(checkouts)
377
+ }
378
+
379
+ # --- Copying data -------------------------------------------------------
380
+
381
+ # Succeeds when service $1 is one of the comma separated services in $2, or
382
+ # with "all", when it's any service but the devcontainer itself (app, whose
383
+ # docker-in-docker volume is a cache)
384
+ copy_data_wants() {
385
+ if [ "$2" = all ]; then
386
+ [ "$1" != app ]
387
+ else
388
+ [[ ",$2," == *",$1,"* ]]
389
+ fi
390
+ }
391
+
392
+ # Finds the named volumes of environment $1 to copy, those of the services $2
393
+ # wants (see copy_data_wants). Sets COPY_VOLUMES ("<volume> <compose volume
394
+ # key> <image>"), and COPY_RUNNING and COPY_RUNNING_SERVICES: the running
395
+ # containers mounting them and their services.
396
+ copy_data_find() {
397
+ local src="$1" wanted="$2" id service image volume key available="" chosen="" found
398
+ COPY_VOLUMES=() COPY_RUNNING="" COPY_RUNNING_SERVICES=""
399
+ for id in $(docker container ls -aq --filter "label=com.docker.compose.project=$src"); do
400
+ service="$(docker container inspect "$id" --format '{{ index .Config.Labels "com.docker.compose.service" }}')"
401
+ image="$(docker container inspect "$id" --format '{{ .Config.Image }}')"
402
+ found=false
403
+ # Named volumes of this environment only, not anonymous or shared ones
404
+ for volume in $(docker container inspect "$id" --format '{{ range .Mounts }}{{ if eq .Type "volume" }}{{ println .Name }}{{ end }}{{ end }}'); do
405
+ [ "$(docker volume inspect "$volume" --format '{{ index .Labels "com.docker.compose.project" }}')" = "$src" ] || continue
406
+ key="$(docker volume inspect "$volume" --format '{{ index .Labels "com.docker.compose.volume" }}')"
407
+ [ -n "$key" ] || continue
408
+ found=true
409
+ copy_data_wants "$service" "$wanted" || continue
410
+ [ "$volume" = "${src}_$key" ] || die "volume $volume of $service has a fixed name, it can't be copied"
411
+ COPY_VOLUMES+=("$volume $key $image")
412
+ done
413
+ $found || continue
414
+ available="$available $service"
415
+ copy_data_wants "$service" "$wanted" || continue
416
+ chosen="$chosen $service "
417
+ if [ "$(docker container inspect "$id" --format '{{ .State.Running }}')" = true ]; then
418
+ COPY_RUNNING="$COPY_RUNNING $id"
419
+ COPY_RUNNING_SERVICES="$COPY_RUNNING_SERVICES $service"
420
+ fi
421
+ done
422
+
423
+ if [ "$wanted" != all ]; then
424
+ for service in ${wanted//,/ }; do
425
+ [[ "$chosen" == *" $service "* ]] ||
426
+ die "$src has no service $service with a named volume, these have one:${available:- none}"
427
+ done
428
+ fi
429
+ [ "${#COPY_VOLUMES[@]}" -gt 0 ] || die "$src has no data to copy, its services with a named volume:${available:- none}"
430
+ }
431
+
432
+ # Copies the COPY_VOLUMES of environment $1 into new volumes for the
433
+ # environment of branch $2 in checkout $3, labelled as compose creates them,
434
+ # so bin/de up adopts them and bin/de down removes them. The services using
435
+ # them are stopped meanwhile, so e.g. postgres's files are consistent, and
436
+ # started again afterwards, also when the copy fails.
437
+ copy_data() {
438
+ local src="$1" branch="$2" path="$3" target kind=ephemeral entry volume key image
439
+ target="$(env_name_for_branch "$branch")"
440
+ env_is_persistent_branch "$branch" && kind=persistent
441
+
442
+ if [ -n "$COPY_RUNNING" ]; then
443
+ log "Stopping$COPY_RUNNING_SERVICES of $src for the copy"
444
+ # shellcheck disable=SC2086
445
+ run docker container stop $COPY_RUNNING >/dev/null
446
+ # shellcheck disable=SC2064
447
+ trap "log 'Starting$COPY_RUNNING_SERVICES of $src again'; run docker container start $COPY_RUNNING >/dev/null" EXIT
448
+ fi
449
+
450
+ for entry in "${COPY_VOLUMES[@]}"; do
451
+ read -r volume key image <<<"$entry"
452
+ log "Copying $volume to ${target}_$key"
453
+ run docker volume create \
454
+ --label "dev.env.name=$target" --label "dev.env.kind=$kind" --label "dev.env.branch=$branch" \
455
+ --label "dev.env.path=$path" --label "dev.env.gitdir=$ENV_GIT_COMMON_DIR" \
456
+ --label "com.docker.compose.project=$target" --label "com.docker.compose.volume=$key" \
457
+ "${target}_$key" >/dev/null
458
+ run docker run --rm --user 0:0 --entrypoint "" \
459
+ -v "$volume:/from:ro" -v "${target}_$key:/to" "$image" cp -a /from/. /to/
460
+ grep -qE "^ $key:" "$path/.devcontainer/docker-compose.yml" ||
461
+ warn "$branch's docker-compose.yml has no volume $key, the copy isn't used"
462
+ done
463
+
464
+ if [ -n "$COPY_RUNNING" ]; then
465
+ trap - EXIT
466
+ log "Starting$COPY_RUNNING_SERVICES of $src again"
467
+ # shellcheck disable=SC2086
468
+ run docker container start $COPY_RUNNING >/dev/null
469
+ fi
470
+ }
471
+
472
+ # --- Commands -----------------------------------------------------------
473
+
474
+ # Writes the local feature devcontainer.json includes as ./de-host, from
475
+ # $DE_CONFIG on the host: your own editor extensions, npm packages and
476
+ # features for every environment of every repository, e.g.
477
+ #
478
+ # {
479
+ # "extensions": ["anthropic.claude-code", "openai.chatgpt"],
480
+ # "npm": ["@anthropic-ai/claude-code", "@openai/codex"],
481
+ # "features": { "ghcr.io/devcontainers/features/python:1": {} },
482
+ # "home": [".claude", ".codex"],
483
+ # "env": { "CLAUDE_CONFIG_DIR": "/home/vscode/.claude" }
484
+ # }
485
+ #
486
+ # "home" lists folders in the container's home directory that every
487
+ # environment shares, kept on the host in $DE_HOME_STATE, e.g. logins. "env"
488
+ # sets environment variables in the container.
489
+ #
490
+ # A feature, so the editor installs its extensions in the container whoever
491
+ # opens it, and its packages are part of the image. Changes apply on the next
492
+ # build, bin/de up --rebuild.
493
+ env_write_host_feature() {
494
+ local dir="$ENV_PATH/.devcontainer/de-host" config='{}' path
495
+ if [ -f "$DE_CONFIG" ]; then
496
+ command -v jq >/dev/null 2>&1 || die "reading $DE_CONFIG needs jq"
497
+ config="$(jq -e 'if type == "object" then . else error("not an object") end' "$DE_CONFIG")" ||
498
+ die "$DE_CONFIG isn't a JSON object"
499
+ fi
500
+ # Docker refuses a missing bind source
501
+ while IFS= read -r path; do
502
+ case "$path" in "" | /* | *..*) die "home in $DE_CONFIG takes paths inside the home directory, not $path" ;; esac
503
+ mkdir -p "$DE_HOME_STATE/$path"
504
+ done < <([ "$config" = '{}' ] || jq -r '.home // [] | .[]' <<<"$config")
505
+ mkdir -p "$dir"
506
+ if [ "$config" = '{}' ]; then
507
+ printf '{ "id": "de-host", "version": "1.0.0", "name": "Nothing from %s" }\n' "$DE_CONFIG" > "$dir/devcontainer-feature.json"
508
+ printf '#!/bin/sh\n# Written by bin/de, %s has nothing to install\n' "$DE_CONFIG" > "$dir/install.sh"
509
+ else
510
+ jq --arg config "$DE_CONFIG" --arg state "$DE_HOME_STATE" '{
511
+ id: "de-host",
512
+ version: "1.0.0",
513
+ name: "Your own tools from \($config), written by bin/de",
514
+ installsAfter: ["ghcr.io/devcontainers/features/node"],
515
+ customizations: { vscode: { extensions: (.extensions // []) } }
516
+ }
517
+ + (if (.features // {}) != {} then { dependsOn: .features } else {} end)
518
+ + (if (.home // []) != [] then { mounts: [.home[] | { type: "bind", source: "\($state)/\(.)", target: "/home/vscode/\(.)" }] } else {} end)
519
+ + (if (.env // {}) != {} then { containerEnv: .env } else {} end)' <<<"$config" > "$dir/devcontainer-feature.json"
520
+ {
521
+ printf '#!/bin/sh\n# Written by bin/de from %s, do not edit\nset -e\n' "$DE_CONFIG"
522
+ jq -r '.npm // [] | if length > 0 then "npm install --global --no-fund --no-audit " + (map(@sh) | join(" ")) else empty end' <<<"$config"
523
+ } > "$dir/install.sh"
524
+ fi
525
+ chmod +x "$dir/install.sh"
526
+ }
527
+
528
+ # Runs on the host before the containers start, see initializeCommand in
529
+ # devcontainer.json, so opening a checkout from an editor works too
530
+ # The Mac's mise refuses a checkout's config until it's trusted, also for
531
+ # commands this script runs there, like npx. Trust is per path, so every
532
+ # worktree needs it, the container trusts its own copy in postStart.sh.
533
+ env_trust_mise() {
534
+ local file
535
+ command -v mise >/dev/null 2>&1 || return 0
536
+ for file in mise.toml mise.local.toml .mise.toml .mise.local.toml; do
537
+ [ -f "$ENV_PATH/$file" ] || continue
538
+ log "Trusting $file on the host (--no-trust to skip)"
539
+ run mise trust --quiet "$ENV_PATH/$file"
540
+ done
541
+ }
542
+
543
+ cmd_initialize() {
544
+ # docker-compose.yml mounts it, and Docker refuses a missing bind source
545
+ mkdir -p "$HOME/.config/git"
546
+ env_write_dotenv
547
+ env_write_host_feature
548
+ log "Environment $ENV_NAME ($ENV_KIND)"
549
+ }
550
+
551
+ # Copies the setup into a repository, from the de repository. bin/de is
552
+ # generic and always updated; the template is repository
553
+ # specific, a starting point that is only copied when missing, or with --force.
554
+ # As de update, the same for a repository that's set up already.
555
+ # The repository's name (ENV_REPO, @REPO@ in the template) is --name, else
556
+ # the ENV_REPO it already has, else its main checkout's folder name.
557
+ cmd_init() {
558
+ local target="" force=false name="" rel src common repo current up_command entry
559
+ local usage_line="usage: de $command [<repository>] [--name <name>] [--force]"
560
+ while [ "$#" -gt 0 ]; do
561
+ case "$1" in
562
+ --force) force=true; shift ;;
563
+ --name) [ "$#" -ge 2 ] || die "$usage_line"; name="$2"; shift 2 ;;
564
+ --name=*) name="${1#--name=}"; shift ;;
565
+ -*) die "$usage_line" ;;
566
+ *) [ -z "$target" ] || die "$usage_line"; target="$1"; shift ;;
567
+ esac
568
+ done
569
+ if [ -n "$name" ] && [ "$(env_sanitize "$name")" != "$name" ]; then
570
+ die "--name $name: use lowercase letters, digits and dashes, e.g. $(env_sanitize "$name")"
571
+ fi
572
+ target="${target:-$PWD}"
573
+ [ -d "$target" ] || die "$target doesn't exist"
574
+ target="$(cd "$(git -C "$target" rev-parse --show-toplevel 2>/dev/null)" && pwd -P)" ||
575
+ die "$target isn't a git checkout"
576
+ [ "$target" != "$DE_ROOT" ] || die "$target is the de repository itself"
577
+ if [ "$command" = update ] && [ ! -f "$target/.devcontainer/env.conf" ]; then
578
+ die "$target isn't set up yet, run: de init"
579
+ fi
580
+
581
+ # In a subshell, env.conf is shell and sets more than ENV_REPO
582
+ current=""
583
+ if [ -f "$target/.devcontainer/env.conf" ]; then
584
+ current="$(ENV_REPO="" && source "$target/.devcontainer/env.conf" && printf '%s' "$ENV_REPO")"
585
+ fi
586
+ # Renaming would leave environments behind under the old name
587
+ if [ -n "$name" ] && [ -n "$current" ] && [ "$name" != "$current" ]; then
588
+ $force || die "$target is named $current already. To rename it, run bin/de down in each of its checkouts, then set ENV_REPO=$name in .devcontainer/env.conf (and \"name\" in devcontainer.json)"
589
+ warn "renaming $current to $name, environments created as $current are left as they are"
590
+ fi
591
+ if [ -n "$name" ]; then
592
+ repo="$name"
593
+ elif [ -n "$current" ]; then
594
+ repo="$current"
595
+ else
596
+ common="$(cd "$target" && cd "$(git rev-parse --git-common-dir)" && pwd -P)"
597
+ repo="$(env_sanitize "$(basename "$(dirname "$common")")")"
598
+ fi
599
+ if [ -d "$target/test/dummy" ] && [ -x "$target/bin/rails" ]; then
600
+ up_command="env RAILS_ENV=test bin/rails app:db:prepare"
601
+ elif [ -x "$target/bin/rails" ]; then
602
+ up_command="env RAILS_ENV=test bin/rails db:prepare"
603
+ else
604
+ up_command=""
605
+ fi
606
+ if [ "$command" = update ]; then
607
+ log "Updating development environments in $target ($repo)"
608
+ else
609
+ log "Setting up development environments in $target ($repo)"
610
+ fi
611
+
612
+ # Generic: always the same as in the de repository
613
+ if cmp -s "$DE_ROOT/bin/de" "$target/bin/de"; then
614
+ printf ' %-36s up to date\n' bin/de >&2
615
+ else
616
+ run mkdir -p "$target/bin"
617
+ run cp -p "$DE_ROOT/bin/de" "$target/bin/de"
618
+ fi
619
+ # The docs used to be copied too, they live in the de repository now
620
+ if [ -f "$target/.devcontainer/README.md" ] && grep -q '^`bin/de` manages them' "$target/.devcontainer/README.md"; then
621
+ log "Removing .devcontainer/README.md, the docs are at $DE_DOCS"
622
+ run rm "$target/.devcontainer/README.md"
623
+ fi
624
+
625
+ # Repository specific: services, image, setup steps and settings
626
+ while IFS= read -r rel; do
627
+ rel="${rel#./}"
628
+ if [ -e "$target/$rel" ] && ! $force; then
629
+ printf ' %-36s exists, kept (--force to replace)\n' "$rel" >&2
630
+ continue
631
+ fi
632
+ run mkdir -p "$(dirname "$target/$rel")"
633
+ run cp -p "$DE_TEMPLATE/$rel" "$target/$rel"
634
+ sed -i '' -e "s|@REPO@|$repo|g" -e "s|@UP_COMMAND@|$up_command|g" "$target/$rel"
635
+ done < <(cd "$DE_TEMPLATE" && find . -type f | sort)
636
+
637
+ if ! grep -q '"\./de-host"' "$target/.devcontainer/devcontainer.json"; then
638
+ warn "add \"./de-host\": {} to the features in .devcontainer/devcontainer.json, for your own tools from $DE_CONFIG"
639
+ fi
640
+
641
+ # Never commit generated files or local secrets
642
+ for entry in .devcontainer/.env .devcontainer/de-host/ mise.local.toml; do
643
+ # A file inside, as a directory entry like de-host/ only matches what's in it
644
+ git -C "$target" check-ignore -q --no-index -- "${entry%/}$([ "${entry%/}" = "$entry" ] || echo /x)" && continue
645
+ log "Adding $entry to .gitignore"
646
+ # Not onto the last line when the file doesn't end in a newline
647
+ if [ -s "$target/.gitignore" ] && [ -n "$(tail -c1 "$target/.gitignore")" ]; then
648
+ echo >> "$target/.gitignore"
649
+ fi
650
+ printf '%s\n' "$entry" >> "$target/.gitignore"
651
+ done
652
+
653
+ if [ "$command" = update ]; then
654
+ log "Done, review the changes in $target and commit them"
655
+ return
656
+ fi
657
+
658
+ cat <<EOF
659
+
660
+ Done. Review the repository specific files for $repo, then start it:
661
+
662
+ .devcontainer/env.conf ENV_REPO, PERSISTENT_BRANCHES, UP_COMMAND
663
+ .devcontainer/docker-compose.yml services, keep the x-env-labels and mounts
664
+ .devcontainer/postStart.sh installing dependencies, the dev database
665
+ .devcontainer/devcontainer.json features, keep ./de-host and the mounts
666
+ .worktreeinclude ignored files new worktrees need
667
+ orca.yaml Orca's worktree hooks
668
+
669
+ cd $target && bin/de up
670
+
671
+ Run de update there later to update its bin/de.
672
+ EOF
673
+ }
674
+
675
+ # Pulls the de repository, as install.sh does when it's already installed.
676
+ # Repositories keep their bin/de until de update runs there.
677
+ cmd_upgrade() {
678
+ local before after
679
+ [ "$#" -eq 0 ] || die "usage: de upgrade"
680
+ git -C "$DE_ROOT" rev-parse --git-dir >/dev/null 2>&1 || die "$DE_ROOT isn't a git checkout"
681
+ before="$(git -C "$DE_ROOT" rev-parse HEAD)"
682
+ log "Updating $DE_ROOT"
683
+ git -C "$DE_ROOT" pull --ff-only --quiet || die "couldn't update $DE_ROOT"
684
+ after="$(git -C "$DE_ROOT" rev-parse HEAD)"
685
+ if [ "$before" = "$after" ]; then
686
+ log "Already up to date"
687
+ return
688
+ fi
689
+ git -C "$DE_ROOT" log --oneline --no-decorate "$before..$after" >&2
690
+ log "Updated, run de update in a repository to update its bin/de"
691
+ }
692
+
693
+ cmd_new() {
694
+ local branch="" folder="" from="" file dest up_args=() existing=false
695
+ local copy="" copy_from="" yes=false source="" source_name="" target_name path checkout_branch answer
696
+ local usage_line="usage: bin/de new <branch> [folder] [--from <ref>] [--copy-data[=<services>]] [--copy-data-from <checkout>] [--yes] [--no-trust]"
697
+ while [ "$#" -gt 0 ]; do
698
+ case "$1" in
699
+ --from) [ "$#" -ge 2 ] || die "$usage_line"; from="$2"; shift 2 ;;
700
+ --copy-data) copy=all; shift ;;
701
+ --copy-data=*) copy="${1#--copy-data=}"; [ -n "$copy" ] || die "$usage_line"; shift ;;
702
+ --copy-data-from) [ "$#" -ge 2 ] || die "$usage_line"; copy_from="$2"; shift 2 ;;
703
+ --copy-data-from=*) copy_from="${1#--copy-data-from=}"; shift ;;
704
+ --yes | -y) yes=true; shift ;;
705
+ --no-trust) up_args+=(--no-trust); shift ;;
706
+ -*) die "$usage_line" ;;
707
+ *)
708
+ if [ -z "$branch" ]; then branch="$1"
709
+ elif [ -z "$folder" ]; then folder="$1"
710
+ else die "$usage_line"
711
+ fi
712
+ shift
713
+ ;;
714
+ esac
715
+ done
716
+ [ -n "$branch" ] || die "$usage_line"
717
+ git check-ref-format --branch "$branch" >/dev/null || die "$branch isn't a valid branch name"
718
+ [ -n "$copy_from" ] && [ -z "$copy" ] && copy=all
719
+
720
+ [ -n "$folder" ] || folder="$(dirname "$ENV_MAIN_CHECKOUT")/$(env_sanitize "$ENV_REPO-$branch")"
721
+ [ -e "$folder" ] && die "$folder already exists"
722
+
723
+ if git -C "$ENV_PATH" show-ref --verify --quiet "refs/heads/$branch" ||
724
+ [ -n "$(git -C "$ENV_PATH" for-each-ref --format='%(refname)' "refs/remotes/*/$branch")" ]; then
725
+ existing=true
726
+ elif [ -z "$from" ]; then
727
+ from="$(git -C "$ENV_PATH" symbolic-ref --quiet --short refs/remotes/origin/HEAD || echo HEAD)"
728
+ # Prefer the local branch when there is one, e.g. main over origin/main
729
+ git -C "$ENV_PATH" show-ref --verify --quiet "refs/heads/${from#origin/}" && from="${from#origin/}"
730
+ fi
731
+
732
+ # Everything about the copy is checked before the worktree is created.
733
+ # The data comes from the checkout of the branch the new one starts from,
734
+ # or for an existing branch, from this checkout.
735
+ if [ -n "$copy" ]; then
736
+ if [ -n "$copy_from" ]; then
737
+ source="$(checkout_for "$copy_from")"
738
+ if [ -z "$source" ]; then
739
+ warn "no checkout of $ENV_REPO matches $copy_from, these exist:"
740
+ checkouts_print
741
+ die "nothing created"
742
+ fi
743
+ elif $existing || [ "$from" = HEAD ]; then
744
+ source="$ENV_PATH"
745
+ else
746
+ while IFS=$'\t' read -r path checkout_branch; do
747
+ if [ -d "$path" ] && [ -n "$checkout_branch" ] &&
748
+ { [ "$from" = "$checkout_branch" ] || [ "$from" = "origin/$checkout_branch" ]; }; then
749
+ source="$path"
750
+ break
751
+ fi
752
+ done < <(checkouts)
753
+ [ -n "$source" ] || die "$from isn't checked out, so it has no environment to copy data from. Pick one with --copy-data-from <checkout>"
754
+ fi
755
+ source_name="$(env_name_for "$source")"
756
+ target_name="$(env_name_for_branch "$branch")"
757
+ [ "$source_name" != "$target_name" ] || die "$branch's environment is $source_name itself, nothing to copy"
758
+ [ -n "$(docker container ls -aq --filter "label=com.docker.compose.project=$source_name")" ] ||
759
+ die "$source_name has no environment to copy data from, start it with bin/de up in $source"
760
+ [ -z "$(docker volume ls -q --filter "label=com.docker.compose.project=$target_name")" ] ||
761
+ die "$target_name has volumes already, remove them first, e.g. with bin/de prune"
762
+ copy_data_find "$source_name" "$copy"
763
+
764
+ log "Copying data from $source_name ($source) to $target_name:"
765
+ printf ' %s\n' "${COPY_VOLUMES[@]%% *}" >&2
766
+ if [ -n "$COPY_RUNNING" ] && ! $yes; then
767
+ [ -t 0 ] || die "pass --yes to stop$COPY_RUNNING_SERVICES of $source_name for the copy without a terminal"
768
+ read -r -p "This stops$COPY_RUNNING_SERVICES of $source_name during the copy, then starts them again. Continue? [y/N] " answer
769
+ [ "$answer" = y ] || [ "$answer" = Y ] || die "nothing created"
770
+ fi
771
+ fi
772
+
773
+ log "Creating worktree $folder for $branch"
774
+ if $existing; then
775
+ # Creates a local branch tracking the remote one, when there's only that
776
+ run git -C "$ENV_PATH" worktree add "$folder" "$branch"
777
+ else
778
+ run git -C "$ENV_PATH" worktree add -b "$branch" "$folder" "$from"
779
+ fi
780
+ folder="$(cd "$folder" && pwd -P)"
781
+
782
+ # Branches that predate the environment setup get it from this checkout,
783
+ # with mise.toml: its SELENIUM_URL would override the environment's
784
+ if [ ! -x "$folder/bin/de" ]; then
785
+ warn "$branch has no environment setup, copying it from $ENV_PATH (uncommitted there)"
786
+ for file in "$ENV_PATH"/.devcontainer/* "$ENV_PATH/bin/de" "$ENV_PATH/mise.toml"; do
787
+ [ -e "$file" ] || continue
788
+ dest="$folder/${file#"$ENV_PATH"/}"
789
+ run mkdir -p "$(dirname "$dest")"
790
+ run cp -p "$file" "$dest"
791
+ done
792
+ fi
793
+
794
+ # A branch's older bin/de may not know --no-trust
795
+ if [ "${#up_args[@]}" -gt 0 ] && ! grep -q -- '--no-trust' "$folder/bin/de"; then
796
+ warn "$branch's bin/de doesn't support --no-trust, it won't trust mise config anyway"
797
+ up_args=()
798
+ fi
799
+ [ -z "$copy" ] || copy_data "$source_name" "$branch" "$folder"
800
+ run "$folder/bin/de" up ${up_args[@]+"${up_args[@]}"}
801
+
802
+ cat <<EOF
803
+
804
+ Worktree $folder is ready:
805
+
806
+ cd $folder
807
+ bin/de exec bin/rails test
808
+ bin/de code # open in VS Code
809
+ bin/de down # when done, then: git worktree remove $folder
810
+ EOF
811
+ }
812
+
813
+ cmd_up() {
814
+ local up_args=() trust=true arg other entry health deadline
815
+ for arg in "$@"; do
816
+ case "$arg" in
817
+ --rebuild) up_args=(--remove-existing-container) ;;
818
+ --no-trust) trust=false ;;
819
+ *) die "usage: bin/de up [--rebuild] [--no-trust]" ;;
820
+ esac
821
+ done
822
+
823
+ log "Environment $ENV_NAME ($ENV_KIND, branch ${ENV_BRANCH:-detached}) for $ENV_PATH"
824
+
825
+ # Share nothing: refuse to adopt another checkout's resources
826
+ other="$(env_label "$ENV_NAME" dev.env.path)"
827
+ if [ -n "$other" ] && [ "$other" != "$ENV_PATH" ]; then
828
+ die "$ENV_NAME already belongs to $other, remove that environment first"
829
+ fi
830
+
831
+ # Worktree .git files point to the main repository by relative path, so the
832
+ # link also resolves inside the container
833
+ if [ "$(git -C "$ENV_PATH" config --get worktree.useRelativePaths || true)" != true ]; then
834
+ log "Enabling relative worktree paths"
835
+ run git -C "$ENV_PATH" config worktree.useRelativePaths true
836
+ fi
837
+ if [ "$ENV_PATH" != "$ENV_MAIN_CHECKOUT" ]; then
838
+ run git -C "$ENV_MAIN_CHECKOUT" worktree repair --relative-paths "$ENV_PATH"
839
+
840
+ # Copy (never link) the ignored local files a worktree needs, from the
841
+ # same list other worktree tools use
842
+ if [ -f "$ENV_MAIN_CHECKOUT/.worktreeinclude" ]; then
843
+ while IFS= read -r entry || [ -n "$entry" ]; do
844
+ case "$entry" in "" | "#"*) continue ;; esac
845
+ [ -e "$ENV_MAIN_CHECKOUT/$entry" ] || continue
846
+ [ -e "$ENV_PATH/$entry" ] && continue
847
+ git -C "$ENV_MAIN_CHECKOUT" check-ignore -q -- "$entry" || continue
848
+ log "Copying $entry from the main checkout"
849
+ run mkdir -p "$(dirname "$ENV_PATH/$entry")"
850
+ run cp -pR "$ENV_MAIN_CHECKOUT/$entry" "$ENV_PATH/$entry"
851
+ done < "$ENV_MAIN_CHECKOUT/.worktreeinclude"
852
+ fi
853
+ fi
854
+
855
+ if $trust; then
856
+ env_trust_mise
857
+ fi
858
+
859
+ log "Writing $ENV_DOTENV"
860
+ env_write_dotenv
861
+ env_write_host_feature
862
+
863
+ log "Building and starting the devcontainer"
864
+ devcontainer_cli up --workspace-folder "$ENV_PATH" ${up_args[@]+"${up_args[@]}"}
865
+
866
+ log "Waiting for health checks"
867
+ deadline=$((SECONDS + 300))
868
+ while :; do
869
+ health="$(env_health "$ENV_NAME")"
870
+ if [ -n "$health" ] && ! grep -qvE ' (healthy|running)$' <<<"$health"; then
871
+ break
872
+ fi
873
+ [ "$SECONDS" -lt "$deadline" ] || die "services not healthy after 5 minutes:"$'\n'"$health"
874
+ sleep 2
875
+ done
876
+ sed 's/^/ /' <<<"$health" >&2
877
+
878
+ if [ "${#UP_COMMAND[@]}" -gt 0 ]; then
879
+ log "Running UP_COMMAND from env.conf"
880
+ (cd "$ENV_PATH" && cmd_exec "${UP_COMMAND[@]}")
881
+ fi
882
+
883
+ cmd_status
884
+ }
885
+
886
+ cmd_exec() {
887
+ local cwd dir remote_env=()
888
+ [ "$#" -gt 0 ] || die "usage: bin/de exec <command> [arguments]"
889
+ env_running "$ENV_NAME" || die "environment $ENV_NAME isn't running, start it with bin/de up"
890
+
891
+ cwd="$(pwd -P)"
892
+ case "$cwd/" in
893
+ "$ENV_PATH/"*) dir="$ENV_PATH_IN_CONTAINER${cwd#"$ENV_PATH"}" ;;
894
+ *) dir="$ENV_PATH_IN_CONTAINER" ;;
895
+ esac
896
+
897
+ if [ -n "$ENV_SELENIUM_URL_GIVEN" ]; then
898
+ log "Using Selenium at $ENV_SELENIUM_URL_GIVEN"
899
+ remote_env+=(--remote-env "SELENIUM_URL=$ENV_SELENIUM_URL_GIVEN")
900
+ remote_env+=(--remote-env "CAPYBARA_APP_HOST=$(env_capybara_app_host "$ENV_SELENIUM_URL_GIVEN")")
901
+ fi
902
+ if [ -n "${HEADLESS:-}" ]; then
903
+ remote_env+=(--remote-env "HEADLESS=$HEADLESS")
904
+ fi
905
+
906
+ # mise on PATH too: its RubyGems plugin runs `mise reshim` after a gem
907
+ # install, and without it Bundler crashes (and prints its config).
908
+ devcontainer_cli exec --workspace-folder "$ENV_PATH" ${remote_env[@]+"${remote_env[@]}"} \
909
+ bash -c 'cd "$1" && shift && PATH="$HOME/.local/bin:$PATH" exec mise exec -- "$@"' de-exec "$dir" "$@"
910
+ }
911
+
912
+ cmd_status() {
913
+ local ids id service port
914
+ [ "$#" -eq 0 ] || die "usage: bin/de status"
915
+
916
+ cat <<EOF
917
+ Environment $ENV_NAME
918
+ Kind $ENV_KIND
919
+ Branch ${ENV_BRANCH:-(detached)}
920
+ Checkout $ENV_PATH
921
+ In container $ENV_WORKSPACE_LINK
922
+
923
+ EOF
924
+
925
+ log "Containers"
926
+ run docker container ls -a --filter "label=dev.env.name=$ENV_NAME" \
927
+ --format 'table {{.Label "com.docker.compose.service"}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}'
928
+
929
+ ids="$(docker container ls -q --filter "label=dev.env.name=$ENV_NAME")"
930
+ if [ -n "$ids" ]; then
931
+ echo
932
+ log "Reachable from the Mac (OrbStack domains)"
933
+ for id in $ids; do
934
+ service="$(docker container inspect "$id" --format '{{ index .Config.Labels "com.docker.compose.service" }}')"
935
+ for port in $(docker container inspect "$id" --format '{{ range $p, $_ := .Config.ExposedPorts }}{{ $p }} {{ end }}'); do
936
+ printf ' %-10s %s.%s.orb.local:%s\n' "$service" "$service" "$ENV_NAME" "${port%/*}"
937
+ done
938
+ # Only when a host port was published, Docker picks the number
939
+ docker port "$id" | sed "s/^/ $service published /"
940
+ done
941
+ fi
942
+
943
+ echo
944
+ log "Volumes"
945
+ run docker volume ls --filter "label=com.docker.compose.project=$ENV_NAME"
946
+ echo
947
+ log "Networks"
948
+ run docker network ls --filter "label=dev.env.name=$ENV_NAME"
949
+ }
950
+
951
+ # Runs `code` (CODE to override, e.g. CODE=cursor) with the dev container URI
952
+ # itself: vscli resolves ${localWorkspaceFolder} in devcontainer.json to
953
+ # /workspaces/<folder name> instead of the host path.
954
+ cmd_code() {
955
+ local target="$ENV_PATH" name host_hex
956
+ if [ "$#" -gt 1 ]; then
957
+ die "usage: bin/de code [branch | environment | folder | path]"
958
+ elif [ "$#" -eq 1 ]; then
959
+ target="$(checkout_for "$1")"
960
+ if [ -z "$target" ]; then
961
+ warn "no checkout of $ENV_REPO matches $1, these exist:"
962
+ checkouts_print
963
+ die "create one with: bin/de new $1"
964
+ fi
965
+ fi
966
+
967
+ [ -x "$target/bin/de" ] || die "$target has no bin/de, it predates the environment setup"
968
+
969
+ name="$(env_name_for "$target")"
970
+ if ! env_running "$name"; then
971
+ log "Environment $name isn't running, starting it"
972
+ if ! run "$target/bin/de" up; then
973
+ # Still open it when only a setup step failed, the editor helps fix it
974
+ env_running "$name" || die "bin/de up failed and $name isn't running"
975
+ warn "bin/de up failed, see above, opening $name anyway"
976
+ fi
977
+ fi
978
+
979
+ log "Opening $target ($name) in VS Code"
980
+ # The authority is the host folder, hex encoded, the path is
981
+ # devcontainer.json's workspaceFolder: /workspaces/<host path>
982
+ host_hex="$(printf '%s' "$target" | xxd -p | tr -d '\n')"
983
+ run "${CODE:-code}" --folder-uri "vscode-remote://dev-container+$host_hex/workspaces$target"
984
+ }
985
+
986
+ cmd_down() {
987
+ local destroy_persistent=false arg answer
988
+ for arg in "$@"; do
989
+ case "$arg" in
990
+ --destroy-persistent) destroy_persistent=true ;;
991
+ *) die "usage: bin/de down [--destroy-persistent]" ;;
992
+ esac
993
+ done
994
+
995
+ env_write_dotenv
996
+
997
+ if [ "$ENV_KIND" = persistent ] || env_is_persistent "$ENV_NAME"; then
998
+ if ! $destroy_persistent; then
999
+ log "Stopping persistent environment $ENV_NAME, its volumes are kept"
1000
+ env_compose stop
1001
+ return
1002
+ fi
1003
+
1004
+ [ -t 0 ] || die "--destroy-persistent needs a terminal to confirm"
1005
+ warn "This deletes $ENV_NAME and all of its data:"
1006
+ docker volume ls --filter "label=com.docker.compose.project=$ENV_NAME" >&2
1007
+ read -r -p "Type the environment name to confirm: " answer
1008
+ [ "$answer" = "$ENV_NAME" ] || die "name didn't match, nothing removed"
1009
+ else
1010
+ log "Removing ephemeral environment $ENV_NAME"
1011
+ fi
1012
+
1013
+ env_compose down --volumes --remove-orphans
1014
+ env_destroy "$ENV_NAME"
1015
+ log "Removed $ENV_NAME"
1016
+ }
1017
+
1018
+ cmd_prune() {
1019
+ local yes=false arg name path branch reason answer orphans=()
1020
+ for arg in "$@"; do
1021
+ case "$arg" in
1022
+ --yes | -y) yes=true ;;
1023
+ *) die "usage: bin/de prune [--yes]" ;;
1024
+ esac
1025
+ done
1026
+
1027
+ log "Looking for ephemeral environments of $ENV_GIT_COMMON_DIR"
1028
+ for name in $(env_list ephemeral); do
1029
+ if env_is_persistent "$name"; then
1030
+ warn "$name has persistent resources, skipping"
1031
+ continue
1032
+ fi
1033
+
1034
+ path="$(env_label "$name" dev.env.path)"
1035
+ branch="$(env_label "$name" dev.env.branch)"
1036
+ reason=""
1037
+ if [ -z "$path" ] || [ ! -d "$path" ]; then
1038
+ reason="checkout ${path:-?} no longer exists"
1039
+ elif ! git -C "$path" rev-parse --git-dir >/dev/null 2>&1; then
1040
+ reason="$path is no longer a git checkout"
1041
+ elif [ -n "$branch" ] && ! git -C "$path" show-ref --verify --quiet "refs/heads/$branch"; then
1042
+ reason="branch $branch is gone"
1043
+ elif [ "$(env_name_for "$path")" != "$name" ]; then
1044
+ reason="$path is now on another branch"
1045
+ fi
1046
+
1047
+ if [ -n "$reason" ]; then
1048
+ orphans+=("$name")
1049
+ printf ' %-50s %s\n' "$name" "$reason"
1050
+ else
1051
+ printf ' %-50s in use by %s, keeping\n' "$name" "$path"
1052
+ fi
1053
+ done
1054
+
1055
+ if [ "${#orphans[@]}" -eq 0 ]; then
1056
+ log "Nothing to prune"
1057
+ return
1058
+ fi
1059
+
1060
+ echo
1061
+ log "These resources will be removed:"
1062
+ for name in "${orphans[@]}"; do
1063
+ docker container ls -a --filter "label=com.docker.compose.project=$name" --format ' container {{.Names}}'
1064
+ docker volume ls --filter "label=com.docker.compose.project=$name" --format ' volume {{.Name}}'
1065
+ docker network ls --filter "label=com.docker.compose.project=$name" --format ' network {{.Name}}'
1066
+ done
1067
+
1068
+ if ! $yes; then
1069
+ [ -t 0 ] || die "pass --yes to prune without a terminal"
1070
+ read -r -p "Remove ${#orphans[@]} environment(s)? [y/N] " answer
1071
+ [ "$answer" = y ] || [ "$answer" = Y ] || die "nothing removed"
1072
+ fi
1073
+
1074
+ for name in "${orphans[@]}"; do
1075
+ log "Removing $name"
1076
+ env_destroy "$name"
1077
+ done
1078
+ }
1079
+
1080
+ # --- Main ---------------------------------------------------------------
1081
+
1082
+ command="${1:-help}"
1083
+ [ "$#" -gt 0 ] && shift
1084
+
1085
+ if [ -n "$DE_TEMPLATE" ]; then
1086
+ case "$command" in
1087
+ help | -h | --help) usage_source; exit ;;
1088
+ init | update) cmd_init "$@"; exit ;;
1089
+ upgrade) cmd_upgrade "$@"; exit ;;
1090
+ esac
1091
+ # `code <folder>` (Orca's "Open in" runs it from /) opens the checkout that
1092
+ # folder is in, from inside it, so a checkout's older bin/de handles it too
1093
+ if [ "$command" = code ] && [ "$#" -eq 1 ] && [ -d "$1" ]; then
1094
+ cd "$1"
1095
+ shift
1096
+ fi
1097
+ # Everything else is for the checkout the current directory is in
1098
+ checkout="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git checkout"
1099
+ [ -f "$checkout/.devcontainer/env.conf" ] && [ -x "$checkout/bin/de" ] ||
1100
+ die "$checkout has no development environments, set them up with: de init"
1101
+ exec "$checkout/bin/de" "$command" "$@"
1102
+ fi
1103
+
1104
+ case "$command" in
1105
+ help | -h | --help) usage; exit ;;
1106
+ initialize | new | up | exec | status | code | down | prune) ;;
1107
+ init | update | upgrade) die "run de $command from the de repository, which bin/de comes from" ;;
1108
+ *) usage >&2; die "unknown command: $command" ;;
1109
+ esac
1110
+
1111
+ env_init "$DE_ROOT"
1112
+ "cmd_$command" "$@"