mensa 0.9.5 → 0.9.6

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,868 @@
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
+ usage() {
47
+ cat <<EOF
48
+ Usage: bin/de <command> [arguments]
49
+
50
+ Every checkout of this repository, the main one and each git worktree, gets
51
+ its own development environment: a devcontainer with its own services,
52
+ volumes and network. Environments for ${PRIMARY_BRANCHES[*]} ${PERSISTENT_BRANCHES[*]} are
53
+ persistent and keep their data, all others are ephemeral. A primary branch
54
+ (${PRIMARY_BRANCHES[*]}) gets one named $ENV_REPO, the others $ENV_REPO-<branch>.
55
+
56
+ Commands:
57
+ new <branch> [folder] [--from <ref>] [--no-trust]
58
+ Create a worktree for a branch (next to the main checkout
59
+ as $ENV_REPO-<branch> unless a folder is given) and start
60
+ its environment. New branches start from the default
61
+ branch, or from --from. --no-trust as for up.
62
+ up [--rebuild] [--no-trust]
63
+ Create or start this checkout's environment and prepare
64
+ its databases. --rebuild recreates the containers after
65
+ changing .devcontainer/, keeping the data. Trusts the
66
+ checkout's mise config on the host, unless --no-trust.
67
+ exec <command...> Run a command in this checkout's devcontainer, in the same
68
+ directory relative to the checkout.
69
+ status Show the environment's containers, health and URLs.
70
+ code [<checkout>] Open a checkout in VS Code inside its devcontainer,
71
+ starting it first if needed. <checkout> is a branch,
72
+ environment name, worktree folder name or a path in
73
+ it, default this one.
74
+ down [--destroy-persistent]
75
+ Remove an ephemeral environment with its data. A
76
+ persistent one is only stopped, unless
77
+ --destroy-persistent is given and its name confirmed.
78
+ prune [--yes] Remove ephemeral environments whose worktree or branch
79
+ is gone. --yes skips the confirmation.
80
+ help Show this help.
81
+
82
+ Examples:
83
+ bin/de new fix-login
84
+ bin/de exec bin/rails test
85
+ SELENIUM_URL=http://host.docker.internal:4444/ HEADLESS=0 bin/de exec bin/rails test:system
86
+ bin/de code main
87
+
88
+ Settings: .devcontainer/env.conf, your own tools: $DE_CONFIG,
89
+ docs: $DE_DOCS. To update bin/de, run \`de init\` from the de repository.
90
+ EOF
91
+ }
92
+
93
+ # Help when running from the de repository
94
+ usage_source() {
95
+ cat <<EOF
96
+ Usage: de <command> [arguments]
97
+
98
+ Development environments, one devcontainer per checkout. This is the de
99
+ repository's copy ($DE_ROOT): it sets repositories up, and hands every other
100
+ command to the bin/de of the checkout you're in.
101
+
102
+ Commands:
103
+ init [<repository>] [--force]
104
+ Set up development environments in a repository (default
105
+ the current one), or update its bin/de. Repository
106
+ specific files that already exist there are kept, unless
107
+ --force is given.
108
+ help Show this help.
109
+ <command> Any bin/de command, run by the current checkout's bin/de,
110
+ e.g. de up, de exec bin/rails test. See bin/de help there.
111
+
112
+ Your own tools for every environment: $DE_CONFIG
113
+ EOF
114
+ }
115
+
116
+ # --- Output -------------------------------------------------------------
117
+
118
+ log() { printf '\033[1;34m==>\033[0m %s\n' "$*" >&2; }
119
+ warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; }
120
+ die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
121
+
122
+ # Prints a command, then runs it
123
+ run() {
124
+ printf '\033[2m+' >&2
125
+ printf ' %q' "$@" >&2
126
+ printf '\033[0m\n' >&2
127
+ "$@"
128
+ }
129
+
130
+ devcontainer_cli() {
131
+ if command -v devcontainer >/dev/null 2>&1; then
132
+ run devcontainer "$@"
133
+ else
134
+ run npx --yes "@devcontainers/cli@$DEVCONTAINER_CLI_VERSION" "$@"
135
+ fi
136
+ }
137
+
138
+ # --- Environment identity -----------------------------------------------
139
+
140
+ # Lowercases and replaces everything but [a-z0-9-] with dashes
141
+ env_sanitize() {
142
+ printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | tr -s '-' | sed 's/^-//; s/-$//'
143
+ }
144
+
145
+ env_is_primary_branch() {
146
+ local branch
147
+ for branch in "${PRIMARY_BRANCHES[@]}"; do
148
+ [ "$1" = "$branch" ] && return 0
149
+ done
150
+ return 1
151
+ }
152
+
153
+ # Primary branches are always persistent
154
+ env_is_persistent_branch() {
155
+ local branch
156
+ env_is_primary_branch "$1" && return 0
157
+ for branch in "${PERSISTENT_BRANCHES[@]}"; do
158
+ [ "$1" = "$branch" ] && return 0
159
+ done
160
+ return 1
161
+ }
162
+
163
+ # Prints the environment name for a checkout directory
164
+ env_name_for() {
165
+ local dir="$1" branch name
166
+ branch="$(git -C "$dir" symbolic-ref --short -q HEAD || true)"
167
+ if [ -n "$branch" ] && env_is_primary_branch "$branch"; then
168
+ name="$(env_sanitize "$ENV_REPO")"
169
+ elif [ -n "$branch" ]; then
170
+ name="$(env_sanitize "$ENV_REPO-$branch")"
171
+ else
172
+ name="$(env_sanitize "$ENV_REPO-detached-$(basename "$dir")")"
173
+ fi
174
+ # Compose project names and OrbStack domain labels max out at 63 characters
175
+ if [ "${#name}" -gt 63 ]; then
176
+ name="${name:0:54}-$(printf '%s' "$name" | shasum | cut -c1-8)"
177
+ fi
178
+ printf '%s' "$name"
179
+ }
180
+
181
+ # Sets the ENV_* variables describing the checkout that contains $1
182
+ env_init() {
183
+ local dir="${1:-$PWD}" common
184
+
185
+ ENV_PATH="$(cd "$(git -C "$dir" rev-parse --show-toplevel)" && pwd -P)" ||
186
+ die "$dir is not inside a git checkout"
187
+ common="$(git -C "$ENV_PATH" rev-parse --git-common-dir)"
188
+ ENV_GIT_COMMON_DIR="$(cd "$ENV_PATH" && cd "$common" && pwd -P)"
189
+ ENV_MAIN_CHECKOUT="$(dirname "$ENV_GIT_COMMON_DIR")"
190
+ ENV_BRANCH="$(git -C "$ENV_PATH" symbolic-ref --short -q HEAD || true)"
191
+ ENV_NAME="$(env_name_for "$ENV_PATH")"
192
+
193
+ if [ -n "$ENV_BRANCH" ] && env_is_persistent_branch "$ENV_BRANCH"; then
194
+ ENV_KIND=persistent
195
+ ENV_RESTART=unless-stopped
196
+ else
197
+ ENV_KIND=ephemeral
198
+ ENV_RESTART=no
199
+ fi
200
+
201
+ # Inside the container, both the checkout and the common .git directory are
202
+ # mounted at /workspaces/<host path>, so the relative paths in a worktree's
203
+ # .git file (worktree.useRelativePaths) resolve the same way.
204
+ # devcontainer.json uses the same path as its workspaceFolder.
205
+ ENV_PATH_IN_CONTAINER="/workspaces$ENV_PATH"
206
+ ENV_GIT_COMMON_DIR_IN_CONTAINER="/workspaces$ENV_GIT_COMMON_DIR"
207
+ # A shorter, stable path to the checkout, for interactive use
208
+ ENV_WORKSPACE_LINK="/workspaces/$ENV_REPO"
209
+
210
+ ENV_DOTENV="$ENV_PATH/.devcontainer/.env"
211
+ }
212
+
213
+ # Prints where a browser driven through Selenium at $1 reaches the app's test
214
+ # server: by service name from the environment's own selenium container, and
215
+ # by OrbStack domain from a browser on the Mac
216
+ env_capybara_app_host() {
217
+ case "$1" in
218
+ *//selenium:*) echo "http://app:3100" ;;
219
+ *) echo "http://app.$ENV_NAME.orb.local:3100" ;;
220
+ esac
221
+ }
222
+
223
+ # Writes .devcontainer/.env, which docker compose reads for the project name,
224
+ # labels and mounts
225
+ env_write_dotenv() {
226
+ cat > "$ENV_DOTENV" <<EOF
227
+ # Written by bin/de, don't edit
228
+ COMPOSE_PROJECT_NAME=$ENV_NAME
229
+ ENV_NAME=$ENV_NAME
230
+ ENV_KIND=$ENV_KIND
231
+ ENV_BRANCH=$ENV_BRANCH
232
+ ENV_PATH=$ENV_PATH
233
+ ENV_GIT_COMMON_DIR=$ENV_GIT_COMMON_DIR
234
+ ENV_PATH_IN_CONTAINER=$ENV_PATH_IN_CONTAINER
235
+ ENV_GIT_COMMON_DIR_IN_CONTAINER=$ENV_GIT_COMMON_DIR_IN_CONTAINER
236
+ ENV_WORKSPACE_LINK=$ENV_WORKSPACE_LINK
237
+ ENV_RESTART=$ENV_RESTART
238
+ ENV_GIT_CONFIG_DIR=$HOME/.config/git
239
+ SELENIUM_URL=$SELENIUM_URL
240
+ CAPYBARA_APP_HOST=$(env_capybara_app_host "$SELENIUM_URL")
241
+ EOF
242
+ }
243
+
244
+ # --- Docker resources ---------------------------------------------------
245
+
246
+ # Runs docker compose for this checkout's project
247
+ env_compose() {
248
+ run docker compose --project-directory "$ENV_PATH/.devcontainer" \
249
+ -f "$ENV_PATH/.devcontainer/docker-compose.yml" \
250
+ --env-file "$ENV_DOTENV" -p "$ENV_NAME" "$@"
251
+ }
252
+
253
+ # Succeeds when the app container of an environment is running
254
+ env_running() {
255
+ [ -n "$(docker container ls -q --filter "label=dev.env.name=$1" --filter label=com.docker.compose.service=app)" ]
256
+ }
257
+
258
+ # Prints the value of a label on any container, volume or network of an
259
+ # environment
260
+ env_label() {
261
+ local name="$1" label="$2" kind values
262
+ for kind in container volume network; do
263
+ values="$(docker "$kind" ls $([ "$kind" = container ] && echo -a) --filter "label=dev.env.name=$name" --format "{{.Label \"$label\"}}")"
264
+ if [ -n "$values" ]; then
265
+ head -n1 <<<"$values"
266
+ return
267
+ fi
268
+ done
269
+ }
270
+
271
+ # Prints the names of this repository's environments of a kind, one per line
272
+ env_list() {
273
+ local kind_filter="label=dev.env.kind=$1" repo_filter="label=dev.env.gitdir=$ENV_GIT_COMMON_DIR"
274
+ {
275
+ docker container ls -a --filter "$kind_filter" --filter "$repo_filter" --format '{{.Label "dev.env.name"}}'
276
+ docker volume ls --filter "$kind_filter" --filter "$repo_filter" --format '{{.Label "dev.env.name"}}'
277
+ docker network ls --filter "$kind_filter" --filter "$repo_filter" --format '{{.Label "dev.env.name"}}'
278
+ } | sort -u | sed '/^$/d'
279
+ }
280
+
281
+ # Succeeds when any resource of the environment is labelled persistent
282
+ env_is_persistent() {
283
+ [ -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)" ]
284
+ }
285
+
286
+ # Removes every container, volume and network of an environment. Resources are
287
+ # found by the dev.env.name label, plus the compose project label for the ones
288
+ # devcontainer features add without our labels (the docker-in-docker volume).
289
+ env_destroy() {
290
+ local name="$1" ids filter
291
+ for filter in "label=dev.env.name=$name" "label=com.docker.compose.project=$name"; do
292
+ ids="$(docker container ls -aq --filter "$filter")"
293
+ [ -z "$ids" ] || run docker container rm --force --volumes $ids >/dev/null
294
+ done
295
+ for filter in "label=dev.env.name=$name" "label=com.docker.compose.project=$name"; do
296
+ ids="$(docker volume ls -q --filter "$filter")"
297
+ [ -z "$ids" ] || run docker volume rm --force $ids >/dev/null
298
+ ids="$(docker network ls -q --filter "$filter")"
299
+ [ -z "$ids" ] || run docker network rm $ids >/dev/null
300
+ done
301
+ }
302
+
303
+ # Prints "<service> <health>" for every running container of an environment,
304
+ # where health is the healthcheck status, or "running" without a healthcheck
305
+ env_health() {
306
+ local ids
307
+ ids="$(docker container ls -q --filter "label=dev.env.name=$1")"
308
+ [ -z "$ids" ] && return
309
+ # shellcheck disable=SC2086
310
+ docker container inspect $ids --format '{{ index .Config.Labels "com.docker.compose.service" }} {{ if .State.Health }}{{ .State.Health.Status }}{{ else }}{{ .State.Status }}{{ end }}'
311
+ }
312
+
313
+ # Prints "<path>\t<branch>" for every checkout of the repository
314
+ checkouts() {
315
+ git -C "$ENV_PATH" worktree list --porcelain | awk '
316
+ /^worktree / { if (path != "") print path "\t" branch; path = substr($0, 10); branch = "" }
317
+ /^branch / { branch = substr($0, 8); sub("^refs/heads/", "", branch) }
318
+ END { if (path != "") print path "\t" branch }'
319
+ }
320
+
321
+ # --- Commands -----------------------------------------------------------
322
+
323
+ # Writes the local feature devcontainer.json includes as ./de-host, from
324
+ # $DE_CONFIG on the host: your own editor extensions, npm packages and
325
+ # features for every environment of every repository, e.g.
326
+ #
327
+ # {
328
+ # "extensions": ["anthropic.claude-code", "openai.chatgpt"],
329
+ # "npm": ["@anthropic-ai/claude-code", "@openai/codex"],
330
+ # "features": { "ghcr.io/devcontainers/features/python:1": {} },
331
+ # "home": [".claude", ".codex"],
332
+ # "env": { "CLAUDE_CONFIG_DIR": "/home/vscode/.claude" }
333
+ # }
334
+ #
335
+ # "home" lists folders in the container's home directory that every
336
+ # environment shares, kept on the host in $DE_HOME_STATE, e.g. logins. "env"
337
+ # sets environment variables in the container.
338
+ #
339
+ # A feature, so the editor installs its extensions in the container whoever
340
+ # opens it, and its packages are part of the image. Changes apply on the next
341
+ # build, bin/de up --rebuild.
342
+ env_write_host_feature() {
343
+ local dir="$ENV_PATH/.devcontainer/de-host" config='{}' path
344
+ if [ -f "$DE_CONFIG" ]; then
345
+ command -v jq >/dev/null 2>&1 || die "reading $DE_CONFIG needs jq"
346
+ config="$(jq -e 'if type == "object" then . else error("not an object") end' "$DE_CONFIG")" ||
347
+ die "$DE_CONFIG isn't a JSON object"
348
+ fi
349
+ # Docker refuses a missing bind source
350
+ while IFS= read -r path; do
351
+ case "$path" in "" | /* | *..*) die "home in $DE_CONFIG takes paths inside the home directory, not $path" ;; esac
352
+ mkdir -p "$DE_HOME_STATE/$path"
353
+ done < <([ "$config" = '{}' ] || jq -r '.home // [] | .[]' <<<"$config")
354
+ mkdir -p "$dir"
355
+ if [ "$config" = '{}' ]; then
356
+ printf '{ "id": "de-host", "version": "1.0.0", "name": "Nothing from %s" }\n' "$DE_CONFIG" > "$dir/devcontainer-feature.json"
357
+ printf '#!/bin/sh\n# Written by bin/de, %s has nothing to install\n' "$DE_CONFIG" > "$dir/install.sh"
358
+ else
359
+ jq --arg config "$DE_CONFIG" --arg state "$DE_HOME_STATE" '{
360
+ id: "de-host",
361
+ version: "1.0.0",
362
+ name: "Your own tools from \($config), written by bin/de",
363
+ installsAfter: ["ghcr.io/devcontainers/features/node"],
364
+ customizations: { vscode: { extensions: (.extensions // []) } }
365
+ }
366
+ + (if (.features // {}) != {} then { dependsOn: .features } else {} end)
367
+ + (if (.home // []) != [] then { mounts: [.home[] | { type: "bind", source: "\($state)/\(.)", target: "/home/vscode/\(.)" }] } else {} end)
368
+ + (if (.env // {}) != {} then { containerEnv: .env } else {} end)' <<<"$config" > "$dir/devcontainer-feature.json"
369
+ {
370
+ printf '#!/bin/sh\n# Written by bin/de from %s, do not edit\nset -e\n' "$DE_CONFIG"
371
+ jq -r '.npm // [] | if length > 0 then "npm install --global --no-fund --no-audit " + (map(@sh) | join(" ")) else empty end' <<<"$config"
372
+ } > "$dir/install.sh"
373
+ fi
374
+ chmod +x "$dir/install.sh"
375
+ }
376
+
377
+ # Runs on the host before the containers start, see initializeCommand in
378
+ # devcontainer.json, so opening a checkout from an editor works too
379
+ # The Mac's mise refuses a checkout's config until it's trusted, also for
380
+ # commands this script runs there, like npx. Trust is per path, so every
381
+ # worktree needs it, the container trusts its own copy in postStart.sh.
382
+ env_trust_mise() {
383
+ local file
384
+ command -v mise >/dev/null 2>&1 || return 0
385
+ for file in mise.toml mise.local.toml .mise.toml .mise.local.toml; do
386
+ [ -f "$ENV_PATH/$file" ] || continue
387
+ log "Trusting $file on the host (--no-trust to skip)"
388
+ run mise trust --quiet "$ENV_PATH/$file"
389
+ done
390
+ }
391
+
392
+ cmd_initialize() {
393
+ # docker-compose.yml mounts it, and Docker refuses a missing bind source
394
+ mkdir -p "$HOME/.config/git"
395
+ env_write_dotenv
396
+ env_write_host_feature
397
+ log "Environment $ENV_NAME ($ENV_KIND)"
398
+ }
399
+
400
+ # Copies the setup into a repository, from the de repository. bin/de is
401
+ # generic and always updated; the template is repository
402
+ # specific, a starting point that is only copied when missing, or with --force.
403
+ cmd_init() {
404
+ local target="" force=false arg rel src common repo up_command entry
405
+ local usage_line="usage: de init [<repository>] [--force]"
406
+ for arg in "$@"; do
407
+ case "$arg" in
408
+ --force) force=true ;;
409
+ -*) die "$usage_line" ;;
410
+ *) [ -z "$target" ] || die "$usage_line"; target="$arg" ;;
411
+ esac
412
+ done
413
+ target="${target:-$PWD}"
414
+ [ -d "$target" ] || die "$target doesn't exist"
415
+ target="$(cd "$(git -C "$target" rev-parse --show-toplevel 2>/dev/null)" && pwd -P)" ||
416
+ die "$target isn't a git checkout"
417
+ [ "$target" != "$DE_ROOT" ] || die "$target is the de repository itself"
418
+
419
+ common="$(cd "$target" && cd "$(git rev-parse --git-common-dir)" && pwd -P)"
420
+ repo="$(env_sanitize "$(basename "$(dirname "$common")")")"
421
+ if [ -d "$target/test/dummy" ] && [ -x "$target/bin/rails" ]; then
422
+ up_command="env RAILS_ENV=test bin/rails app:db:prepare"
423
+ elif [ -x "$target/bin/rails" ]; then
424
+ up_command="env RAILS_ENV=test bin/rails db:prepare"
425
+ else
426
+ up_command=""
427
+ fi
428
+ log "Setting up development environments in $target ($repo)"
429
+
430
+ # Generic: always the same as in the de repository
431
+ if cmp -s "$DE_ROOT/bin/de" "$target/bin/de"; then
432
+ printf ' %-36s up to date\n' bin/de >&2
433
+ else
434
+ run mkdir -p "$target/bin"
435
+ run cp -p "$DE_ROOT/bin/de" "$target/bin/de"
436
+ fi
437
+ # The docs used to be copied too, they live in the de repository now
438
+ if [ -f "$target/.devcontainer/README.md" ] && grep -q '^`bin/de` manages them' "$target/.devcontainer/README.md"; then
439
+ log "Removing .devcontainer/README.md, the docs are at $DE_DOCS"
440
+ run rm "$target/.devcontainer/README.md"
441
+ fi
442
+
443
+ # Repository specific: services, image, setup steps and settings
444
+ while IFS= read -r rel; do
445
+ rel="${rel#./}"
446
+ if [ -e "$target/$rel" ] && ! $force; then
447
+ printf ' %-36s exists, kept (--force to replace)\n' "$rel" >&2
448
+ continue
449
+ fi
450
+ run mkdir -p "$(dirname "$target/$rel")"
451
+ run cp -p "$DE_TEMPLATE/$rel" "$target/$rel"
452
+ sed -i '' -e "s|@REPO@|$repo|g" -e "s|@UP_COMMAND@|$up_command|g" "$target/$rel"
453
+ done < <(cd "$DE_TEMPLATE" && find . -type f | sort)
454
+
455
+ if ! grep -q '"\./de-host"' "$target/.devcontainer/devcontainer.json"; then
456
+ warn "add \"./de-host\": {} to the features in .devcontainer/devcontainer.json, for your own tools from $DE_CONFIG"
457
+ fi
458
+
459
+ # Never commit generated files or local secrets
460
+ for entry in .devcontainer/.env .devcontainer/de-host/ mise.local.toml; do
461
+ # A file inside, as a directory entry like de-host/ only matches what's in it
462
+ git -C "$target" check-ignore -q --no-index -- "${entry%/}$([ "${entry%/}" = "$entry" ] || echo /x)" && continue
463
+ log "Adding $entry to .gitignore"
464
+ # Not onto the last line when the file doesn't end in a newline
465
+ if [ -s "$target/.gitignore" ] && [ -n "$(tail -c1 "$target/.gitignore")" ]; then
466
+ echo >> "$target/.gitignore"
467
+ fi
468
+ printf '%s\n' "$entry" >> "$target/.gitignore"
469
+ done
470
+
471
+ cat <<EOF
472
+
473
+ Done. Review the repository specific files for $repo, then start it:
474
+
475
+ .devcontainer/env.conf ENV_REPO, PERSISTENT_BRANCHES, UP_COMMAND
476
+ .devcontainer/docker-compose.yml services, keep the x-env-labels and mounts
477
+ .devcontainer/postStart.sh installing dependencies, the dev database
478
+ .devcontainer/devcontainer.json features, keep ./de-host and the mounts
479
+ .worktreeinclude ignored files new worktrees need
480
+ orca.yaml Orca's worktree hooks
481
+
482
+ cd $target && bin/de up
483
+
484
+ Run de init there again later to update its bin/de.
485
+ EOF
486
+ }
487
+
488
+ cmd_new() {
489
+ local branch="" folder="" from="" file dest up_args=()
490
+ local usage_line="usage: bin/de new <branch> [folder] [--from <ref>] [--no-trust]"
491
+ while [ "$#" -gt 0 ]; do
492
+ case "$1" in
493
+ --from) [ "$#" -ge 2 ] || die "$usage_line"; from="$2"; shift 2 ;;
494
+ --no-trust) up_args+=(--no-trust); shift ;;
495
+ -*) die "$usage_line" ;;
496
+ *)
497
+ if [ -z "$branch" ]; then branch="$1"
498
+ elif [ -z "$folder" ]; then folder="$1"
499
+ else die "$usage_line"
500
+ fi
501
+ shift
502
+ ;;
503
+ esac
504
+ done
505
+ [ -n "$branch" ] || die "$usage_line"
506
+ git check-ref-format --branch "$branch" >/dev/null || die "$branch isn't a valid branch name"
507
+
508
+ [ -n "$folder" ] || folder="$(dirname "$ENV_MAIN_CHECKOUT")/$(env_sanitize "$ENV_REPO-$branch")"
509
+ [ -e "$folder" ] && die "$folder already exists"
510
+
511
+ log "Creating worktree $folder for $branch"
512
+ if git -C "$ENV_PATH" show-ref --verify --quiet "refs/heads/$branch"; then
513
+ run git -C "$ENV_PATH" worktree add "$folder" "$branch"
514
+ elif [ -n "$(git -C "$ENV_PATH" for-each-ref --format='%(refname)' "refs/remotes/*/$branch")" ]; then
515
+ # Creates a local branch tracking the remote one
516
+ run git -C "$ENV_PATH" worktree add "$folder" "$branch"
517
+ else
518
+ if [ -z "$from" ]; then
519
+ from="$(git -C "$ENV_PATH" symbolic-ref --quiet --short refs/remotes/origin/HEAD || echo HEAD)"
520
+ # Prefer the local branch when there is one, e.g. main over origin/main
521
+ git -C "$ENV_PATH" show-ref --verify --quiet "refs/heads/${from#origin/}" && from="${from#origin/}"
522
+ fi
523
+ run git -C "$ENV_PATH" worktree add -b "$branch" "$folder" "$from"
524
+ fi
525
+ folder="$(cd "$folder" && pwd -P)"
526
+
527
+ # Branches that predate the environment setup get it from this checkout,
528
+ # with mise.toml: its SELENIUM_URL would override the environment's
529
+ if [ ! -x "$folder/bin/de" ]; then
530
+ warn "$branch has no environment setup, copying it from $ENV_PATH (uncommitted there)"
531
+ for file in "$ENV_PATH"/.devcontainer/* "$ENV_PATH/bin/de" "$ENV_PATH/mise.toml"; do
532
+ [ -e "$file" ] || continue
533
+ dest="$folder/${file#"$ENV_PATH"/}"
534
+ run mkdir -p "$(dirname "$dest")"
535
+ run cp -p "$file" "$dest"
536
+ done
537
+ fi
538
+
539
+ # A branch's older bin/de may not know --no-trust
540
+ if [ "${#up_args[@]}" -gt 0 ] && ! grep -q -- '--no-trust' "$folder/bin/de"; then
541
+ warn "$branch's bin/de doesn't support --no-trust, it won't trust mise config anyway"
542
+ up_args=()
543
+ fi
544
+ run "$folder/bin/de" up ${up_args[@]+"${up_args[@]}"}
545
+
546
+ cat <<EOF
547
+
548
+ Worktree $folder is ready:
549
+
550
+ cd $folder
551
+ bin/de exec bin/rails test
552
+ bin/de code # open in VS Code
553
+ bin/de down # when done, then: git worktree remove $folder
554
+ EOF
555
+ }
556
+
557
+ cmd_up() {
558
+ local up_args=() trust=true arg other entry health deadline
559
+ for arg in "$@"; do
560
+ case "$arg" in
561
+ --rebuild) up_args=(--remove-existing-container) ;;
562
+ --no-trust) trust=false ;;
563
+ *) die "usage: bin/de up [--rebuild] [--no-trust]" ;;
564
+ esac
565
+ done
566
+
567
+ log "Environment $ENV_NAME ($ENV_KIND, branch ${ENV_BRANCH:-detached}) for $ENV_PATH"
568
+
569
+ # Share nothing: refuse to adopt another checkout's resources
570
+ other="$(env_label "$ENV_NAME" dev.env.path)"
571
+ if [ -n "$other" ] && [ "$other" != "$ENV_PATH" ]; then
572
+ die "$ENV_NAME already belongs to $other, remove that environment first"
573
+ fi
574
+
575
+ # Worktree .git files point to the main repository by relative path, so the
576
+ # link also resolves inside the container
577
+ if [ "$(git -C "$ENV_PATH" config --get worktree.useRelativePaths || true)" != true ]; then
578
+ log "Enabling relative worktree paths"
579
+ run git -C "$ENV_PATH" config worktree.useRelativePaths true
580
+ fi
581
+ if [ "$ENV_PATH" != "$ENV_MAIN_CHECKOUT" ]; then
582
+ run git -C "$ENV_MAIN_CHECKOUT" worktree repair --relative-paths "$ENV_PATH"
583
+
584
+ # Copy (never link) the ignored local files a worktree needs, from the
585
+ # same list other worktree tools use
586
+ if [ -f "$ENV_MAIN_CHECKOUT/.worktreeinclude" ]; then
587
+ while IFS= read -r entry || [ -n "$entry" ]; do
588
+ case "$entry" in "" | "#"*) continue ;; esac
589
+ [ -e "$ENV_MAIN_CHECKOUT/$entry" ] || continue
590
+ [ -e "$ENV_PATH/$entry" ] && continue
591
+ git -C "$ENV_MAIN_CHECKOUT" check-ignore -q -- "$entry" || continue
592
+ log "Copying $entry from the main checkout"
593
+ run mkdir -p "$(dirname "$ENV_PATH/$entry")"
594
+ run cp -pR "$ENV_MAIN_CHECKOUT/$entry" "$ENV_PATH/$entry"
595
+ done < "$ENV_MAIN_CHECKOUT/.worktreeinclude"
596
+ fi
597
+ fi
598
+
599
+ if $trust; then
600
+ env_trust_mise
601
+ fi
602
+
603
+ log "Writing $ENV_DOTENV"
604
+ env_write_dotenv
605
+ env_write_host_feature
606
+
607
+ log "Building and starting the devcontainer"
608
+ devcontainer_cli up --workspace-folder "$ENV_PATH" ${up_args[@]+"${up_args[@]}"}
609
+
610
+ log "Waiting for health checks"
611
+ deadline=$((SECONDS + 300))
612
+ while :; do
613
+ health="$(env_health "$ENV_NAME")"
614
+ if [ -n "$health" ] && ! grep -qvE ' (healthy|running)$' <<<"$health"; then
615
+ break
616
+ fi
617
+ [ "$SECONDS" -lt "$deadline" ] || die "services not healthy after 5 minutes:"$'\n'"$health"
618
+ sleep 2
619
+ done
620
+ sed 's/^/ /' <<<"$health" >&2
621
+
622
+ if [ "${#UP_COMMAND[@]}" -gt 0 ]; then
623
+ log "Running UP_COMMAND from env.conf"
624
+ (cd "$ENV_PATH" && cmd_exec "${UP_COMMAND[@]}")
625
+ fi
626
+
627
+ cmd_status
628
+ }
629
+
630
+ cmd_exec() {
631
+ local cwd dir remote_env=()
632
+ [ "$#" -gt 0 ] || die "usage: bin/de exec <command> [arguments]"
633
+ env_running "$ENV_NAME" || die "environment $ENV_NAME isn't running, start it with bin/de up"
634
+
635
+ cwd="$(pwd -P)"
636
+ case "$cwd/" in
637
+ "$ENV_PATH/"*) dir="$ENV_PATH_IN_CONTAINER${cwd#"$ENV_PATH"}" ;;
638
+ *) dir="$ENV_PATH_IN_CONTAINER" ;;
639
+ esac
640
+
641
+ if [ -n "$ENV_SELENIUM_URL_GIVEN" ]; then
642
+ log "Using Selenium at $ENV_SELENIUM_URL_GIVEN"
643
+ remote_env+=(--remote-env "SELENIUM_URL=$ENV_SELENIUM_URL_GIVEN")
644
+ remote_env+=(--remote-env "CAPYBARA_APP_HOST=$(env_capybara_app_host "$ENV_SELENIUM_URL_GIVEN")")
645
+ fi
646
+ if [ -n "${HEADLESS:-}" ]; then
647
+ remote_env+=(--remote-env "HEADLESS=$HEADLESS")
648
+ fi
649
+
650
+ devcontainer_cli exec --workspace-folder "$ENV_PATH" ${remote_env[@]+"${remote_env[@]}"} \
651
+ bash -c 'cd "$1" && shift && exec "$HOME/.local/bin/mise" exec -- "$@"' de-exec "$dir" "$@"
652
+ }
653
+
654
+ cmd_status() {
655
+ local ids id service port
656
+ [ "$#" -eq 0 ] || die "usage: bin/de status"
657
+
658
+ cat <<EOF
659
+ Environment $ENV_NAME
660
+ Kind $ENV_KIND
661
+ Branch ${ENV_BRANCH:-(detached)}
662
+ Checkout $ENV_PATH
663
+ In container $ENV_WORKSPACE_LINK
664
+
665
+ EOF
666
+
667
+ log "Containers"
668
+ run docker container ls -a --filter "label=dev.env.name=$ENV_NAME" \
669
+ --format 'table {{.Label "com.docker.compose.service"}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}'
670
+
671
+ ids="$(docker container ls -q --filter "label=dev.env.name=$ENV_NAME")"
672
+ if [ -n "$ids" ]; then
673
+ echo
674
+ log "Reachable from the Mac (OrbStack domains)"
675
+ for id in $ids; do
676
+ service="$(docker container inspect "$id" --format '{{ index .Config.Labels "com.docker.compose.service" }}')"
677
+ for port in $(docker container inspect "$id" --format '{{ range $p, $_ := .Config.ExposedPorts }}{{ $p }} {{ end }}'); do
678
+ printf ' %-10s %s.%s.orb.local:%s\n' "$service" "$service" "$ENV_NAME" "${port%/*}"
679
+ done
680
+ # Only when a host port was published, Docker picks the number
681
+ docker port "$id" | sed "s/^/ $service published /"
682
+ done
683
+ fi
684
+
685
+ echo
686
+ log "Volumes"
687
+ run docker volume ls --filter "label=com.docker.compose.project=$ENV_NAME"
688
+ echo
689
+ log "Networks"
690
+ run docker network ls --filter "label=dev.env.name=$ENV_NAME"
691
+ }
692
+
693
+ # Runs `code` (CODE to override, e.g. CODE=cursor) with the dev container URI
694
+ # itself: vscli resolves ${localWorkspaceFolder} in devcontainer.json to
695
+ # /workspaces/<folder name> instead of the host path.
696
+ cmd_code() {
697
+ local target="$ENV_PATH" path branch name host_hex
698
+ if [ "$#" -gt 1 ]; then
699
+ die "usage: bin/de code [branch | environment | folder | path]"
700
+ elif [ "$#" -eq 1 ]; then
701
+ target=""
702
+ while IFS=$'\t' read -r path branch; do
703
+ [ -d "$path" ] || continue
704
+ if [ "$1" = "$branch" ] || [ "$1" = "$(basename "$path")" ] || [ "$1" = "$(env_name_for "$path")" ]; then
705
+ target="$path"
706
+ break
707
+ fi
708
+ done < <(checkouts)
709
+ # Or any folder in a checkout, e.g. the absolute path Orca's "Open in" adds
710
+ if [ -z "$target" ] && [ -d "$1" ]; then
711
+ target="$(cd "$(git -C "$1" rev-parse --show-toplevel)" && pwd -P)" ||
712
+ die "$1 is not inside a git checkout"
713
+ fi
714
+
715
+ if [ -z "$target" ]; then
716
+ warn "no checkout of $ENV_REPO matches $1, these exist:"
717
+ while IFS=$'\t' read -r path branch; do
718
+ [ -d "$path" ] && printf ' %-40s %-30s %s\n' "$(env_name_for "$path")" "${branch:-(detached)}" "$path" >&2
719
+ done < <(checkouts)
720
+ die "create one with: bin/de new $1"
721
+ fi
722
+ fi
723
+
724
+ [ -x "$target/bin/de" ] || die "$target has no bin/de, it predates the environment setup"
725
+
726
+ name="$(env_name_for "$target")"
727
+ if ! env_running "$name"; then
728
+ log "Environment $name isn't running, starting it"
729
+ if ! run "$target/bin/de" up; then
730
+ # Still open it when only a setup step failed, the editor helps fix it
731
+ env_running "$name" || die "bin/de up failed and $name isn't running"
732
+ warn "bin/de up failed, see above, opening $name anyway"
733
+ fi
734
+ fi
735
+
736
+ log "Opening $target ($name) in VS Code"
737
+ # The authority is the host folder, hex encoded, the path is
738
+ # devcontainer.json's workspaceFolder: /workspaces/<host path>
739
+ host_hex="$(printf '%s' "$target" | xxd -p | tr -d '\n')"
740
+ run "${CODE:-code}" --folder-uri "vscode-remote://dev-container+$host_hex/workspaces$target"
741
+ }
742
+
743
+ cmd_down() {
744
+ local destroy_persistent=false arg answer
745
+ for arg in "$@"; do
746
+ case "$arg" in
747
+ --destroy-persistent) destroy_persistent=true ;;
748
+ *) die "usage: bin/de down [--destroy-persistent]" ;;
749
+ esac
750
+ done
751
+
752
+ env_write_dotenv
753
+
754
+ if [ "$ENV_KIND" = persistent ] || env_is_persistent "$ENV_NAME"; then
755
+ if ! $destroy_persistent; then
756
+ log "Stopping persistent environment $ENV_NAME, its volumes are kept"
757
+ env_compose stop
758
+ return
759
+ fi
760
+
761
+ [ -t 0 ] || die "--destroy-persistent needs a terminal to confirm"
762
+ warn "This deletes $ENV_NAME and all of its data:"
763
+ docker volume ls --filter "label=com.docker.compose.project=$ENV_NAME" >&2
764
+ read -r -p "Type the environment name to confirm: " answer
765
+ [ "$answer" = "$ENV_NAME" ] || die "name didn't match, nothing removed"
766
+ else
767
+ log "Removing ephemeral environment $ENV_NAME"
768
+ fi
769
+
770
+ env_compose down --volumes --remove-orphans
771
+ env_destroy "$ENV_NAME"
772
+ log "Removed $ENV_NAME"
773
+ }
774
+
775
+ cmd_prune() {
776
+ local yes=false arg name path branch reason answer orphans=()
777
+ for arg in "$@"; do
778
+ case "$arg" in
779
+ --yes | -y) yes=true ;;
780
+ *) die "usage: bin/de prune [--yes]" ;;
781
+ esac
782
+ done
783
+
784
+ log "Looking for ephemeral environments of $ENV_GIT_COMMON_DIR"
785
+ for name in $(env_list ephemeral); do
786
+ if env_is_persistent "$name"; then
787
+ warn "$name has persistent resources, skipping"
788
+ continue
789
+ fi
790
+
791
+ path="$(env_label "$name" dev.env.path)"
792
+ branch="$(env_label "$name" dev.env.branch)"
793
+ reason=""
794
+ if [ -z "$path" ] || [ ! -d "$path" ]; then
795
+ reason="checkout ${path:-?} no longer exists"
796
+ elif ! git -C "$path" rev-parse --git-dir >/dev/null 2>&1; then
797
+ reason="$path is no longer a git checkout"
798
+ elif [ -n "$branch" ] && ! git -C "$path" show-ref --verify --quiet "refs/heads/$branch"; then
799
+ reason="branch $branch is gone"
800
+ elif [ "$(env_name_for "$path")" != "$name" ]; then
801
+ reason="$path is now on another branch"
802
+ fi
803
+
804
+ if [ -n "$reason" ]; then
805
+ orphans+=("$name")
806
+ printf ' %-50s %s\n' "$name" "$reason"
807
+ else
808
+ printf ' %-50s in use by %s, keeping\n' "$name" "$path"
809
+ fi
810
+ done
811
+
812
+ if [ "${#orphans[@]}" -eq 0 ]; then
813
+ log "Nothing to prune"
814
+ return
815
+ fi
816
+
817
+ echo
818
+ log "These resources will be removed:"
819
+ for name in "${orphans[@]}"; do
820
+ docker container ls -a --filter "label=com.docker.compose.project=$name" --format ' container {{.Names}}'
821
+ docker volume ls --filter "label=com.docker.compose.project=$name" --format ' volume {{.Name}}'
822
+ docker network ls --filter "label=com.docker.compose.project=$name" --format ' network {{.Name}}'
823
+ done
824
+
825
+ if ! $yes; then
826
+ [ -t 0 ] || die "pass --yes to prune without a terminal"
827
+ read -r -p "Remove ${#orphans[@]} environment(s)? [y/N] " answer
828
+ [ "$answer" = y ] || [ "$answer" = Y ] || die "nothing removed"
829
+ fi
830
+
831
+ for name in "${orphans[@]}"; do
832
+ log "Removing $name"
833
+ env_destroy "$name"
834
+ done
835
+ }
836
+
837
+ # --- Main ---------------------------------------------------------------
838
+
839
+ command="${1:-help}"
840
+ [ "$#" -gt 0 ] && shift
841
+
842
+ if [ -n "$DE_TEMPLATE" ]; then
843
+ case "$command" in
844
+ help | -h | --help) usage_source; exit ;;
845
+ init) cmd_init "$@"; exit ;;
846
+ esac
847
+ # `code <folder>` (Orca's "Open in" runs it from /) opens the checkout that
848
+ # folder is in, from inside it, so a checkout's older bin/de handles it too
849
+ if [ "$command" = code ] && [ "$#" -eq 1 ] && [ -d "$1" ]; then
850
+ cd "$1"
851
+ shift
852
+ fi
853
+ # Everything else is for the checkout the current directory is in
854
+ checkout="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git checkout"
855
+ [ -f "$checkout/.devcontainer/env.conf" ] && [ -x "$checkout/bin/de" ] ||
856
+ die "$checkout has no development environments, set them up with: de init"
857
+ exec "$checkout/bin/de" "$command" "$@"
858
+ fi
859
+
860
+ case "$command" in
861
+ help | -h | --help) usage; exit ;;
862
+ initialize | new | up | exec | status | code | down | prune) ;;
863
+ init) die "run de init from the de repository, which bin/de comes from" ;;
864
+ *) usage >&2; die "unknown command: $command" ;;
865
+ esac
866
+
867
+ env_init "$DE_ROOT"
868
+ "cmd_$command" "$@"