← all scripts

scripts/setup-dev-alias.sh

271 of 271 statements covered (100.00%).

coveredmissednever traced by Bash (not counted)A line ending in … continues the statement above it and shares its fate.

1#!/usr/bin/env bash
2#
3# setup-dev-alias.sh — install a `gco` shell function that runs the GCO CLI
4# inside the dev container against your current working directory.
5#
6# Why a function instead of a bare alias? A function forwards arguments and
7# pipes correctly, attaches a TTY only when one is present (so it also works
8# in scripts and CI), and bakes in the correct container socket for the
9# runtime you actually have — which a copy-pasted `docker run ...` alias does
10# not. The block is written between marker lines, so re-running this script
11# updates it in place instead of appending duplicates.
12#
13# By default it also builds (or refreshes) the dev image from Dockerfile.dev
14# with the detected runtime before installing the function, so a single run
15# takes a fresh clone all the way to a working `gco`. Re-running always rebuilds
16# (cached layers make that cheap), which transparently replaces a stale local
17# image. Pass --no-build to skip the build when you manage the image yourself.
18#
1978set -euo pipefail
20
2178MARKER_BEGIN="# >>> gco >>>"
2278MARKER_END="# <<< gco <<<"
2378IMAGE="gco-dev"
2478FORCED_RUNTIME=""
2578RC_FILE=""
2678PRINT_ONLY=0
2778NO_BUILD=0
2878AWS_WRITABLE=0
2978UNINSTALL=0
30
31# Host environment variables forwarded into the container in bare `-e NAME`
32# form. Every runtime (Docker, Finch/nerdctl, and Podman) passes a variable only
33# when it is set in the caller's environment, so an unset variable never becomes
34# an empty override. The AWS entries preserve the host credential/Region chain;
35# the GCO_AUTOPILOT entries preserve engine, model, and generated-config choices.
36#
37# Values naming files outside ~/.aws are forwarded but require a matching mount.
38# Likewise, GCO_AUTOPILOT_CONFIG_DIR must be a writable path as seen *inside*
39# the container (normally under /root/.gco or /workspace), not an arbitrary host
40# path. Filesystem-bearing plugin paths are intentionally not forwarded.
4178FORWARDED_ENV_VARS=(
42 AWS_PROFILE
43 AWS_DEFAULT_PROFILE
44 AWS_REGION
45 AWS_DEFAULT_REGION
46 AWS_ACCESS_KEY_ID
47 AWS_SECRET_ACCESS_KEY
48 AWS_SESSION_TOKEN
49 AWS_CREDENTIAL_EXPIRATION
50 AWS_ROLE_ARN
51 AWS_ROLE_SESSION_NAME
52 AWS_WEB_IDENTITY_TOKEN_FILE
53 AWS_CONFIG_FILE
54 AWS_SHARED_CREDENTIALS_FILE
55 AWS_CA_BUNDLE
56 AWS_ENDPOINT_URL
57 AWS_USE_FIPS_ENDPOINT
58 AWS_USE_DUALSTACK_ENDPOINT
59 AWS_RETRY_MODE
60 AWS_MAX_ATTEMPTS
61 AWS_EC2_METADATA_DISABLED
62 GCO_DEFAULT_REGION
63 GCO_AUTOPILOT_ENGINE
64 GCO_AUTOPILOT_MODEL
65 GCO_AUTOPILOT_CODEX_MODEL
66 GCO_AUTOPILOT_OPENCODE_MODEL
67 GCO_AUTOPILOT_SMALL_FAST_MODEL
68 GCO_AUTOPILOT_CONFIG_DIR
69)
70
71# The dev image is built from Dockerfile.dev at the repository root. Resolve it
72# from this script's own location so the build works from any working directory.
73312SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
74234REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
7578DOCKERFILE="$REPO_ROOT/Dockerfile.dev"
76
77505log() { printf '%s\n' "$*"; }
785warn() { printf 'warning: %s\n' "$*" >&2; }
7922die() { printf 'error: %s\n' "$*" >&2; exit 1; }
80
81# Values supplied through --runtime, --image, CDK_DOCKER, or
82# XDG_RUNTIME_DIR are persisted into a shell profile and therefore cross a
83# second shell-parsing boundary. Emit simple image/runtime names unchanged for
84# readability, but POSIX-single-quote anything containing shell syntax. The
85# sed replacement turns each embedded apostrophe into the safe '\'' sequence.
86shell_quote() {
8766 printf "'"
88132 printf '%s' "$1" | sed "s/'/'\\\\''/g"
8966 printf "'"
90}
91
92shell_word() {
93140 case "$1" in
942 ""|*[!A-Za-z0-9_./:@+-]*) shell_quote "$1" ;;
95138 *) printf '%s' "$1" ;;
96 esac
97}
98
99require_single_line() {
100153 local label="$1" value="$2"
101232 case "$value" in
1022 *$'\n'*|*$'\r'*) die "$label must not contain line breaks" ;;
103 esac
104}
105
106usage() {
1071 cat <<'EOF'
108setup-dev-alias.sh — install a `gco` shell function for the dev container.
109
110Usage: scripts/setup-dev-alias.sh [options]
111
112 -p, --print Print the shell function to stdout and exit (no build, no writes).
113 -r, --runtime NAME Force a runtime (docker|finch|podman) vs auto-detecting.
114 --rc PATH Target this rc file instead of the one inferred from $SHELL.
115 --image NAME Dev image to build and run (default: gco-dev).
116 --no-build Skip building the dev image; assume it already exists.
117 --aws-writable Mount ~/.aws read-write so `aws sso login` can run in
118 the container and cache its token for the host (default:
119 read-only).
120 --uninstall Remove the managed block from the rc file and exit.
121 -h, --help Show this help and exit.
122
123By default the script builds (or refreshes) the dev image from Dockerfile.dev
124with the detected runtime, then installs the `gco` function. Detection prefers
125docker, then finch, then podman (the first whose daemon answers `<rt> info`).
126GCO_CONTAINER_RUNTIME or CDK_DOCKER override detection.
127
128AWS credentials: the function mounts ~/.aws and forwards the standard AWS
129environment variables (AWS_PROFILE, AWS_REGION, static keys, session tokens,
130role/web-identity settings, endpoint and retry overrides) only when the calling
131shell has them set. So `AWS_PROFILE=prod gco status`, an exported SSO or
132assume-role session, static keys, and a plain ~/.aws/config all work the same
133way inside the container as they do on the host.
134
135Autopilot controls are also forwarded by name: GCO_AUTOPILOT_ENGINE,
136GCO_AUTOPILOT_MODEL, GCO_AUTOPILOT_CODEX_MODEL, GCO_AUTOPILOT_OPENCODE_MODEL,
137GCO_AUTOPILOT_SMALL_FAST_MODEL, and GCO_AUTOPILOT_CONFIG_DIR. Config-directory
138values must name a writable path as seen inside the container.
139EOF
1401}
1411
142264while [ "$#" -gt 0 ]; do
143187 case "$1" in
14480 -p|--print) PRINT_ONLY=1; shift ;;
145201 -r|--runtime) [ "$#" -ge 2 ] || die "--runtime needs a value"; FORCED_RUNTIME="$2"; shift 2 ;;
1461 --runtime=*) FORCED_RUNTIME="${1#*=}"; shift ;;
147111 --rc) [ "$#" -ge 2 ] || die "--rc needs a value"; RC_FILE="$2"; shift 2 ;;
1481 --rc=*) RC_FILE="${1#*=}"; shift ;;
14957 --image) [ "$#" -ge 2 ] || die "--image needs a value"; IMAGE="$2"; shift 2 ;;
1501 --image=*) IMAGE="${1#*=}"; shift ;;
15138 --no-build) NO_BUILD=1; shift ;;
1522 --aws-writable) AWS_WRITABLE=1; shift ;;
1536 --uninstall) UNINSTALL=1; shift ;;
1542 -h|--help) usage; exit 0 ;;
1551 *) die "unknown option: $1 (try --help)" ;;
1561 esac
1571done
1581
1591# Detection mirrors cli/_container_runtime.py: only accept a runtime whose
1601# daemon actually answers `<rt> info`.
1611runtime_responds() {
16286 command -v "$1" >/dev/null 2>&1 || return 1
16376 "$1" info >/dev/null 2>&1
1641}
1651
1661detect_runtime() {
1675 local rt
16812 for rt in docker finch podman; do
16912 if runtime_responds "$rt"; then
1703 printf '%s\n' "$rt"
1713 return 0
1721 fi
1731 done
1742 return 1
1751}
1761
1771resolve_runtime() {
17874 local override=""
17974 if [ -n "$FORCED_RUNTIME" ]; then
18067 override="$FORCED_RUNTIME"
1817 elif [ -n "${GCO_CONTAINER_RUNTIME:-}" ]; then
1821 override="$GCO_CONTAINER_RUNTIME"
1836 elif [ -n "${CDK_DOCKER:-}" ]; then
1841 override="$CDK_DOCKER"
1851 fi
18674 if [ -n "$override" ]; then
18774 runtime_responds "$override" || warn "runtime '$override' is not answering '$override info' yet; using it anyway"
18869 printf '%s\n' "$override"
18969 return 0
1901 fi
1915 detect_runtime
1921}
1931
1941socket_args_for() {
19572 local mount=""
19672 case "$1" in
19755 docker) mount="/var/run/docker.sock:/var/run/docker.sock" ;;
1989 podman) mount="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/podman/podman.sock:/var/run/docker.sock" ;;
1998 *) return 0 ;;
2001 esac
201128 printf -- '-v %s ' "$(shell_quote "$mount")"
2021}
2031
2041socket_desc_for() {
20524 case "$1" in
20623 docker) printf '%s' "host Docker socket -> /var/run/docker.sock" ;;
2071 podman) printf '%s' "Podman socket (${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/podman/podman.sock) -> /var/run/docker.sock" ;;
2081 *) printf '%s' "none ($1 has no host socket to share)" ;;
2091 esac
2101}
2111
2121choose_rc_file() {
21328 if [ -n "$RC_FILE" ]; then
21427 printf '%s\n' "$RC_FILE"
21527 return
2161 fi
2173 case "$(basename "${SHELL:-sh}")" in
2181 zsh) printf '%s\n' "$HOME/.zshrc" ;;
2191 bash) printf '%s\n' "$HOME/.bashrc" ;;
2201 # fish cannot parse POSIX function syntax, so writing this block into a
2211 # fish config would produce a file fish errors on at every new shell —
2221 # and writing it to ~/.profile (which fish does not read) would look
2231 # like a successful install that silently never provides `gco`. Fail
2241 # loudly with the two real options instead.
2251 fish) die "fish shell cannot source this POSIX function.
226Either run the CLI through a POSIX shell:
227 bash -lc 'gco --help'
228or add a fish wrapper of your own using the printed command as the body:
229 scripts/setup-dev-alias.sh --print
230Pass --rc PATH to install into a specific file anyway." ;;
2311 *) printf '%s\n' "$HOME/.profile" ;;
2321 esac
2331}
2341
2351# Remove the managed block, leaving any surrounding rc content untouched.
2361remove_block() {
2373 local rc="$1" tmp
2383 [ -f "$rc" ] || { log "Nothing to remove: $rc does not exist."; return 0; }
2393 if ! grep -qF "$MARKER_BEGIN" "$rc"; then
2401 log "Nothing to remove: no gco block found in $rc."
2411 return 0
2421 fi
2434 tmp="$(mktemp)"
2442 awk -v b="$MARKER_BEGIN" -v e="$MARKER_END" '
245 $0 == b { skip = 1 }
246 skip != 1 { print }
247 $0 == e { skip = 0 }
248 ' "$rc" > "$tmp"
2492 mv "$tmp" "$rc"
2502 log "Removed the 'gco' function block from $rc."
2512 log "Open a new shell (or unset it with: unset -f gco) to finish."
2521}
2531
2541# SELinux-enforcing hosts (Fedora, RHEL, CentOS Stream and friends) deny a
2551# container access to every bind mount unless the mount carries a relabel
2561# option. Without it `gco` starts and then fails on "permission denied" for
2571# /workspace and ~/.aws, which reads like a GCO bug rather than a host policy.
2581# `z` (lowercase) applies a shared label so several containers — and the host —
2591# keep access; `Z` would relabel exclusively and break other consumers of
2601# ~/.aws. Non-SELinux hosts get no suffix, keeping their command lines clean.
2611mount_suffix_for_host() {
26271 if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled 2>/dev/null; then
2631 printf ',z'
2641 fi
2651}
2661
2671# Emit one bare `-e NAME` flag per forwarded host variable.
2681forwarded_env_args() {
26970 local name
2701890 for name in "${FORWARDED_ENV_VARS[@]}"; do
2711890 printf -- '-e %s ' "$name"
2721 done
2731}
2741
2751emit_block() {
27670 local rt="$1" socket="$2" image="$3" forwarded_env="$4" mount_opts="$5" rt_word image_word
277140 rt_word="$(shell_word "$rt")"
278140 image_word="$(shell_word "$image")"
2791 # Four persistence mounts make every `gco autopilot` engine (and anything
2801 # else that keeps state under ~/.gco) survive the --rm container lifecycle:
2811 # gco-dev-tools -> /root/.npm-global named volume; pinned
2821 # Claude Code, Codex, and
2831 # OpenCode lazy installs
2841 # ~/.claude -> /root/.claude host dir; CLAUDE_CONFIG_DIR
2851 # keeps Claude onboarding
2861 # and transcripts
2871 # ~/.gco -> /root/.gco host dir; GCO CLI state
2881 # plus the generated MCP/
2891 # Codex/OpenCode configs and
2901 # isolated Codex sessions
2911 # gco-dev-opencode -> /root/.local/share/opencode named volume; OpenCode's
2921 # session database, so
2931 # --continue/--resume work
2941 # across container runs
2951 # OpenCode state is a named volume rather than a host mount on purpose: the
2961 # host's own ~/.local/share/opencode may belong to a different OpenCode
2971 # release, and sharing one SQLite database across versions is not safe.
2981 # The host dirs are pre-created so a root-owned mount point is never
2991 # created on Linux hosts.
30070 cat <<EOF
301$MARKER_BEGIN
302# Run the \`gco\` CLI inside the GCO dev container, against \$PWD.
303# Managed by scripts/setup-dev-alias.sh — re-run that script to regenerate
304# after switching container runtimes or image names.
305# ~/.aws is created (empty is fine) so hosts that authenticate purely through
306# environment variables, an OIDC/web-identity file, or instance metadata still
307# get a valid mount source instead of the runtime materialising a root-owned
308# directory on the host. Forwarded host variables use bare \`-e NAME\`, so
309# each is passed only when the calling shell actually has it set.
310gco() {
311 mkdir -p "\$HOME/.aws" "\$HOME/.claude" "\$HOME/.gco"
312 if [ -t 0 ] && [ -t 1 ]; then
313 $rt_word run --rm -it -v "\$HOME/.aws:/root/.aws:${mount_opts}" -v "\$HOME/.claude:/root/.claude" -v "\$HOME/.gco:/root/.gco" -v gco-dev-tools:/root/.npm-global -v gco-dev-opencode:/root/.local/share/opencode -e CLAUDE_CONFIG_DIR=/root/.claude ${forwarded_env}-v "\$PWD:/workspace" ${socket}-w /workspace $image_word gco "\$@"
314 else
315 $rt_word run --rm -i -v "\$HOME/.aws:/root/.aws:${mount_opts}" -v "\$HOME/.claude:/root/.claude" -v "\$HOME/.gco:/root/.gco" -v gco-dev-tools:/root/.npm-global -v gco-dev-opencode:/root/.local/share/opencode -e CLAUDE_CONFIG_DIR=/root/.claude ${forwarded_env}-v "\$PWD:/workspace" ${socket}-w /workspace $image_word gco "\$@"
316 fi
317}
318$MARKER_END
319EOF
320}
321
322# Number of times to try the image build, and the backoff between tries.
323# The build's first act is resolving the Dockerfile.dev base image from Docker
324# Hub, which is a public registry the build does not control: a single DNS or
325# TCP timeout there ("failed to resolve source metadata ... i/o timeout") fails
326# an otherwise healthy build. Retrying makes that transient class self-healing
327# while a genuine build error still fails on the last attempt with its own
328# output. Override the count to 1 to disable retries.
32977BUILD_ATTEMPTS="${GCO_DEV_IMAGE_BUILD_ATTEMPTS:-3}"
33077BUILD_RETRY_DELAY="${GCO_DEV_IMAGE_BUILD_RETRY_DELAY:-15}"
331
332build_image() {
33314 local rt="$1"
33414 [ -f "$DOCKERFILE" ] || die "cannot build '$IMAGE': $DOCKERFILE not found."
33514 log "Building the '$IMAGE' image from Dockerfile.dev with $rt ..."
33614 log "(the first build can take a few minutes; re-runs reuse cached layers and just refresh what changed)"
337
33814 local attempt=1
33924 while true; do
34024 if "$rt" build -f "$DOCKERFILE" -t "$IMAGE" "$REPO_ROOT"; then
3418 break
342 fi
34316 if [ "$attempt" -ge "$BUILD_ATTEMPTS" ]; then
3446 die "$rt failed to build '$IMAGE' from $DOCKERFILE."
345 fi
34610 log "build attempt $attempt/$BUILD_ATTEMPTS failed; retrying in ${BUILD_RETRY_DELAY}s ..."
34710 log "(usually a transient registry/network error pulling the base image)"
34810 sleep "$BUILD_RETRY_DELAY"
34910 attempt=$((attempt + 1))
350 done
351
3528 log "Image '$IMAGE' is ready."
3538 log ""
354}
355
356install_block() {
35724 local rc="$1" block="$2" tmp
35848 tmp="$(mktemp)"
35924 if [ -f "$rc" ]; then
3605 awk -v b="$MARKER_BEGIN" -v e="$MARKER_END" '
361 $0 == b { skip = 1 }
362 skip != 1 { print }
363 $0 == e { skip = 0 }
364 ' "$rc" > "$tmp"
3658 if [ -s "$tmp" ]; then printf '\n' >> "$tmp"; fi
366 fi
36724 printf '%s\n' "$block" >> "$tmp"
36824 mv "$tmp" "$rc"
369}
370
371# Uninstall is pure rc-file surgery: it must work on a machine whose container
372# runtime is gone or broken, so it runs before any runtime detection or build.
37377if [ "$UNINSTALL" -eq 1 ]; then
3746 remove_block "$(choose_rc_file)"
3753 exit 0
376fi
377
378150runtime="$(resolve_runtime || true)"
37976[ -n "$runtime" ] || die "no container runtime found. Install Docker, Finch, or Podman and start it, then re-run (or force one with --runtime NAME)."
38072require_single_line "container runtime" "$runtime"
38172if [ "$runtime" = "podman" ]; then
3829 require_single_line "XDG_RUNTIME_DIR" "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
383fi
384
385144socket="$(socket_args_for "$runtime")"
386
387# Podman does not resolve a bare, locally-built image name: an image built with
388# `podman build -t gco-dev` is stored as `localhost/gco-dev`, but `podman run
389# gco-dev` treats the unqualified name as remote and searches the configured
390# registries (docker.io, quay.io, ...) instead of local storage. Prefix
391# `localhost/` so the emitted `podman run` finds the image you built locally.
392# Names that already carry a registry/namespace (contain a `/`) are untouched,
393# and docker/finch — which do resolve bare local names — keep the plain name.
39472image_ref="$IMAGE"
39572if [ "$runtime" = "podman" ]; then
3969 case "$IMAGE" in
3971 */*) : ;;
3988 *) image_ref="localhost/$IMAGE" ;;
399 esac
400fi
40172require_single_line "image name" "$image_ref"
402
403140mount_suffix="$(mount_suffix_for_host)"
40470if [ "$AWS_WRITABLE" -eq 1 ]; then
405 # Writable ~/.aws lets `aws sso login` / `aws configure` run INSIDE the
406 # container and cache their tokens where the host can reuse them. Opt-in:
407 # the read-only default keeps a container that runs third-party tooling
408 # from rewriting the operator's credential files.
4091 aws_mount_opts="rw${mount_suffix}"
410else
41169 aws_mount_opts="ro${mount_suffix}"
412fi
413
414210block="$(emit_block "$runtime" "$socket" "$image_ref" "$(forwarded_env_args)" "$aws_mount_opts")"
415
41670if [ "$PRINT_ONLY" -eq 1 ]; then
41739 printf '%s\n' "$block"
41839 exit 0
419fi
420
421# Build (or refresh) the dev image before wiring up the function, so a single
422# run takes a fresh clone all the way to a working `gco`. Always rebuilding also
423# means a stale local image is transparently replaced. Skipped with --no-build.
42431if [ "$NO_BUILD" -eq 1 ]; then
42517 log "Skipping the image build (--no-build); assuming '$image_ref' already exists."
42617 log ""
427else
42814 build_image "$runtime"
429fi
430
43150rc="$(choose_rc_file)"
43224install_block "$rc" "$block"
433
43424log "Installed the 'gco' dev-container function."
43524log ""
43624log " Container runtime : $runtime"
43748log " Socket mount : $(socket_desc_for "$runtime")"
43824log " Dev image : $image_ref"
43924log " Shell profile : $rc"
44072log " AWS credentials : ~/.aws mounted $([ "$AWS_WRITABLE" -eq 1 ] && printf 'read-write' || printf 'read-only') + AWS_PROFILE/AWS_REGION/keys/session"
44124log " forwarded from your shell when set"
44224log " Autopilot env : engine/model/config controls forwarded when set"
44324if [ -n "$mount_suffix" ]; then
4441 log " SELinux : enforcing host detected; bind mounts carry the ',z' shared label"
445fi
44624log ""
44724if [ "$AWS_WRITABLE" -eq 0 ]; then
44824 log "Note: ~/.aws is mounted read-only, so an SSO/session token that expires must be"
44924 log "refreshed on the host ('aws sso login'); re-run with --aws-writable to allow the"
45024 log "container to refresh and cache it instead."
451fi
45224if [ -n "${AWS_CONFIG_FILE:-}${AWS_SHARED_CREDENTIALS_FILE:-}${AWS_WEB_IDENTITY_TOKEN_FILE:-}" ]; then
4531 log ""
4541 log "Note: you have an AWS file-path variable set. It is forwarded, but the file is"
4551 log "only readable in the container when it lives under ~/.aws (the mounted path)."
4561 log "Copy or symlink it under ~/.aws, or add your own -v mount to the function."
457fi
45824log ""
45924log "Activate it in this shell: source \"$rc\""
46024log "Then try: gco --help"
46124if [ -z "$socket" ]; then
4621 log ""
4631 log "Note: $runtime runs containers inside a VM and exposes no host daemon socket the"
4641 log "container can reach (a bind-mounted socket connects to nothing across the VM"
4651 log "boundary), so the function omits the socket mount. Everyday commands — jobs,"
4661 log "status, costs, inference, and non-build stacks operations — work as-is."
4671 log ""
4681 log "Build-heavy commands ('gco stacks deploy-all', image builds) need a container"
4691 log "daemon at CDK synth time, so run them on the host with $runtime as the builder:"
4701 log ""
4711 log " CDK_DOCKER=$runtime gco stacks deploy-all -y"
4721 log ""
4731 log "Run that against a host install of the GCO CLI whose deps match the lockfile"
4741 log "(e.g. a project virtualenv), so the pinned aws-cdk-lib / cdk-nag are used."
475fi