← all scripts

scripts/setup-dev-alias.sh

261 of 261 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#
1977set -euo pipefail
20
2177MARKER_BEGIN="# >>> gco >>>"
2277MARKER_END="# <<< gco <<<"
2377IMAGE="gco-dev"
2477FORCED_RUNTIME=""
2577RC_FILE=""
2677PRINT_ONLY=0
2777NO_BUILD=0
2877AWS_WRITABLE=0
2977UNINSTALL=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.
4177FORWARDED_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_SMALL_FAST_MODEL
67 GCO_AUTOPILOT_CONFIG_DIR
68)
69
70# The dev image is built from Dockerfile.dev at the repository root. Resolve it
71# from this script's own location so the build works from any working directory.
72308SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
73231REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
7477DOCKERFILE="$REPO_ROOT/Dockerfile.dev"
75
76505log() { printf '%s\n' "$*"; }
775warn() { printf 'warning: %s\n' "$*" >&2; }
7822die() { printf 'error: %s\n' "$*" >&2; exit 1; }
79
80# Values supplied through --runtime, --image, CDK_DOCKER, or
81# XDG_RUNTIME_DIR are persisted into a shell profile and therefore cross a
82# second shell-parsing boundary. Emit simple image/runtime names unchanged for
83# readability, but POSIX-single-quote anything containing shell syntax. The
84# sed replacement turns each embedded apostrophe into the safe '\'' sequence.
85shell_quote() {
8665 printf "'"
87130 printf '%s' "$1" | sed "s/'/'\\\\''/g"
8865 printf "'"
89}
90
91shell_word() {
92138 case "$1" in
932 ""|*[!A-Za-z0-9_./:@+-]*) shell_quote "$1" ;;
94136 *) printf '%s' "$1" ;;
95 esac
96}
97
98require_single_line() {
99151 local label="$1" value="$2"
100229 case "$value" in
1012 *$'\n'*|*$'\r'*) die "$label must not contain line breaks" ;;
102 esac
103}
104
105usage() {
1061 cat <<'EOF'
107setup-dev-alias.sh — install a `gco` shell function for the dev container.
108
109Usage: scripts/setup-dev-alias.sh [options]
110
111 -p, --print Print the shell function to stdout and exit (no build, no writes).
112 -r, --runtime NAME Force a runtime (docker|finch|podman) vs auto-detecting.
113 --rc PATH Target this rc file instead of the one inferred from $SHELL.
114 --image NAME Dev image to build and run (default: gco-dev).
115 --no-build Skip building the dev image; assume it already exists.
116 --aws-writable Mount ~/.aws read-write so `aws sso login` can run in
117 the container and cache its token for the host (default:
118 read-only).
119 --uninstall Remove the managed block from the rc file and exit.
120 -h, --help Show this help and exit.
121
122By default the script builds (or refreshes) the dev image from Dockerfile.dev
123with the detected runtime, then installs the `gco` function. Detection prefers
124docker, then finch, then podman (the first whose daemon answers `<rt> info`).
125GCO_CONTAINER_RUNTIME or CDK_DOCKER override detection.
126
127AWS credentials: the function mounts ~/.aws and forwards the standard AWS
128environment variables (AWS_PROFILE, AWS_REGION, static keys, session tokens,
129role/web-identity settings, endpoint and retry overrides) only when the calling
130shell has them set. So `AWS_PROFILE=prod gco status`, an exported SSO or
131assume-role session, static keys, and a plain ~/.aws/config all work the same
132way inside the container as they do on the host.
133
134Autopilot controls are also forwarded by name: GCO_AUTOPILOT_ENGINE,
135GCO_AUTOPILOT_MODEL, GCO_AUTOPILOT_CODEX_MODEL,
136GCO_AUTOPILOT_SMALL_FAST_MODEL, and GCO_AUTOPILOT_CONFIG_DIR. Config-directory
137values must name a writable path as seen inside the container.
138EOF
1391}
1401
141261while [ "$#" -gt 0 ]; do
142185 case "$1" in
14378 -p|--print) PRINT_ONLY=1; shift ;;
144198 -r|--runtime) [ "$#" -ge 2 ] || die "--runtime needs a value"; FORCED_RUNTIME="$2"; shift 2 ;;
1451 --runtime=*) FORCED_RUNTIME="${1#*=}"; shift ;;
146111 --rc) [ "$#" -ge 2 ] || die "--rc needs a value"; RC_FILE="$2"; shift 2 ;;
1471 --rc=*) RC_FILE="${1#*=}"; shift ;;
14857 --image) [ "$#" -ge 2 ] || die "--image needs a value"; IMAGE="$2"; shift 2 ;;
1491 --image=*) IMAGE="${1#*=}"; shift ;;
15038 --no-build) NO_BUILD=1; shift ;;
1512 --aws-writable) AWS_WRITABLE=1; shift ;;
1526 --uninstall) UNINSTALL=1; shift ;;
1532 -h|--help) usage; exit 0 ;;
1541 *) die "unknown option: $1 (try --help)" ;;
1551 esac
1561done
1571
1581# Detection mirrors cli/_container_runtime.py: only accept a runtime whose
1591# daemon actually answers `<rt> info`.
1601runtime_responds() {
16185 command -v "$1" >/dev/null 2>&1 || return 1
16275 "$1" info >/dev/null 2>&1
1631}
1641
1651detect_runtime() {
1665 local rt
16712 for rt in docker finch podman; do
16812 if runtime_responds "$rt"; then
1693 printf '%s\n' "$rt"
1703 return 0
1711 fi
1721 done
1732 return 1
1741}
1751
1761resolve_runtime() {
17773 local override=""
17873 if [ -n "$FORCED_RUNTIME" ]; then
17966 override="$FORCED_RUNTIME"
1807 elif [ -n "${GCO_CONTAINER_RUNTIME:-}" ]; then
1811 override="$GCO_CONTAINER_RUNTIME"
1826 elif [ -n "${CDK_DOCKER:-}" ]; then
1831 override="$CDK_DOCKER"
1841 fi
18573 if [ -n "$override" ]; then
18673 runtime_responds "$override" || warn "runtime '$override' is not answering '$override info' yet; using it anyway"
18768 printf '%s\n' "$override"
18868 return 0
1891 fi
1905 detect_runtime
1911}
1921
1931socket_args_for() {
19471 local mount=""
19571 case "$1" in
19654 docker) mount="/var/run/docker.sock:/var/run/docker.sock" ;;
1979 podman) mount="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/podman/podman.sock:/var/run/docker.sock" ;;
1988 *) return 0 ;;
1991 esac
200126 printf -- '-v %s ' "$(shell_quote "$mount")"
2011}
2021
2031socket_desc_for() {
20424 case "$1" in
20523 docker) printf '%s' "host Docker socket -> /var/run/docker.sock" ;;
2061 podman) printf '%s' "Podman socket (${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/podman/podman.sock) -> /var/run/docker.sock" ;;
2071 *) printf '%s' "none ($1 has no host socket to share)" ;;
2081 esac
2091}
2101
2111choose_rc_file() {
21228 if [ -n "$RC_FILE" ]; then
21327 printf '%s\n' "$RC_FILE"
21427 return
2151 fi
2163 case "$(basename "${SHELL:-sh}")" in
2171 zsh) printf '%s\n' "$HOME/.zshrc" ;;
2181 bash) printf '%s\n' "$HOME/.bashrc" ;;
2191 # fish cannot parse POSIX function syntax, so writing this block into a
2201 # fish config would produce a file fish errors on at every new shell —
2211 # and writing it to ~/.profile (which fish does not read) would look
2221 # like a successful install that silently never provides `gco`. Fail
2231 # loudly with the two real options instead.
2241 fish) die "fish shell cannot source this POSIX function.
225Either run the CLI through a POSIX shell:
226 bash -lc 'gco --help'
227or add a fish wrapper of your own using the printed command as the body:
228 scripts/setup-dev-alias.sh --print
229Pass --rc PATH to install into a specific file anyway." ;;
2301 *) printf '%s\n' "$HOME/.profile" ;;
2311 esac
2321}
2331
2341# Remove the managed block, leaving any surrounding rc content untouched.
2351remove_block() {
2363 local rc="$1" tmp
2373 [ -f "$rc" ] || { log "Nothing to remove: $rc does not exist."; return 0; }
2383 if ! grep -qF "$MARKER_BEGIN" "$rc"; then
2391 log "Nothing to remove: no gco block found in $rc."
2401 return 0
2411 fi
2424 tmp="$(mktemp)"
2432 awk -v b="$MARKER_BEGIN" -v e="$MARKER_END" '
244 $0 == b { skip = 1 }
245 skip != 1 { print }
246 $0 == e { skip = 0 }
247 ' "$rc" > "$tmp"
2482 mv "$tmp" "$rc"
2492 log "Removed the 'gco' function block from $rc."
2502 log "Open a new shell (or unset it with: unset -f gco) to finish."
2511}
2521
2531# SELinux-enforcing hosts (Fedora, RHEL, CentOS Stream and friends) deny a
2541# container access to every bind mount unless the mount carries a relabel
2551# option. Without it `gco` starts and then fails on "permission denied" for
2561# /workspace and ~/.aws, which reads like a GCO bug rather than a host policy.
2571# `z` (lowercase) applies a shared label so several containers — and the host —
2581# keep access; `Z` would relabel exclusively and break other consumers of
2591# ~/.aws. Non-SELinux hosts get no suffix, keeping their command lines clean.
2601mount_suffix_for_host() {
26170 if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled 2>/dev/null; then
2621 printf ',z'
2631 fi
2641}
2651
2661# Emit one bare `-e NAME` flag per forwarded host variable.
2671forwarded_env_args() {
26869 local name
2691794 for name in "${FORWARDED_ENV_VARS[@]}"; do
2701794 printf -- '-e %s ' "$name"
2711 done
2721}
2731
2741emit_block() {
27569 local rt="$1" socket="$2" image="$3" forwarded_env="$4" mount_opts="$5" rt_word image_word
276138 rt_word="$(shell_word "$rt")"
277138 image_word="$(shell_word "$image")"
2781 # Three persistence mounts make both `gco autopilot` engines (and anything
2791 # else that keeps state under ~/.gco) survive the --rm container lifecycle:
2801 # gco-dev-tools -> /root/.npm-global named volume; pinned Claude Code
2811 # and Codex lazy installs persist
2821 # ~/.claude -> /root/.claude host dir; CLAUDE_CONFIG_DIR keeps
2831 # Claude onboarding and transcripts
2841 # ~/.gco -> /root/.gco host dir; GCO CLI state plus the
2851 # generated MCP/Codex configs and
2861 # isolated Codex session state
2871 # The host dirs are pre-created so a root-owned mount point is never
2881 # created on Linux hosts.
28969 cat <<EOF
290$MARKER_BEGIN
291# Run the \`gco\` CLI inside the GCO dev container, against \$PWD.
292# Managed by scripts/setup-dev-alias.sh — re-run that script to regenerate
293# after switching container runtimes or image names.
294# ~/.aws is created (empty is fine) so hosts that authenticate purely through
295# environment variables, an OIDC/web-identity file, or instance metadata still
296# get a valid mount source instead of the runtime materialising a root-owned
297# directory on the host. Forwarded host variables use bare \`-e NAME\`, so
298# each is passed only when the calling shell actually has it set.
299gco() {
300 mkdir -p "\$HOME/.aws" "\$HOME/.claude" "\$HOME/.gco"
301 if [ -t 0 ] && [ -t 1 ]; then
302 $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 -e CLAUDE_CONFIG_DIR=/root/.claude ${forwarded_env}-v "\$PWD:/workspace" ${socket}-w /workspace $image_word gco "\$@"
303 else
304 $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 -e CLAUDE_CONFIG_DIR=/root/.claude ${forwarded_env}-v "\$PWD:/workspace" ${socket}-w /workspace $image_word gco "\$@"
305 fi
306}
307$MARKER_END
308EOF
309}
310
311# Number of times to try the image build, and the backoff between tries.
312# The build's first act is resolving the Dockerfile.dev base image from Docker
313# Hub, which is a public registry the build does not control: a single DNS or
314# TCP timeout there ("failed to resolve source metadata ... i/o timeout") fails
315# an otherwise healthy build. Retrying makes that transient class self-healing
316# while a genuine build error still fails on the last attempt with its own
317# output. Override the count to 1 to disable retries.
31876BUILD_ATTEMPTS="${GCO_DEV_IMAGE_BUILD_ATTEMPTS:-3}"
31976BUILD_RETRY_DELAY="${GCO_DEV_IMAGE_BUILD_RETRY_DELAY:-15}"
320
321build_image() {
32214 local rt="$1"
32314 [ -f "$DOCKERFILE" ] || die "cannot build '$IMAGE': $DOCKERFILE not found."
32414 log "Building the '$IMAGE' image from Dockerfile.dev with $rt ..."
32514 log "(the first build can take a few minutes; re-runs reuse cached layers and just refresh what changed)"
326
32714 local attempt=1
32824 while true; do
32924 if "$rt" build -f "$DOCKERFILE" -t "$IMAGE" "$REPO_ROOT"; then
3308 break
331 fi
33216 if [ "$attempt" -ge "$BUILD_ATTEMPTS" ]; then
3336 die "$rt failed to build '$IMAGE' from $DOCKERFILE."
334 fi
33510 log "build attempt $attempt/$BUILD_ATTEMPTS failed; retrying in ${BUILD_RETRY_DELAY}s ..."
33610 log "(usually a transient registry/network error pulling the base image)"
33710 sleep "$BUILD_RETRY_DELAY"
33810 attempt=$((attempt + 1))
339 done
340
3418 log "Image '$IMAGE' is ready."
3428 log ""
343}
344
345install_block() {
34624 local rc="$1" block="$2" tmp
34748 tmp="$(mktemp)"
34824 if [ -f "$rc" ]; then
3495 awk -v b="$MARKER_BEGIN" -v e="$MARKER_END" '
350 $0 == b { skip = 1 }
351 skip != 1 { print }
352 $0 == e { skip = 0 }
353 ' "$rc" > "$tmp"
3548 if [ -s "$tmp" ]; then printf '\n' >> "$tmp"; fi
355 fi
35624 printf '%s\n' "$block" >> "$tmp"
35724 mv "$tmp" "$rc"
358}
359
360# Uninstall is pure rc-file surgery: it must work on a machine whose container
361# runtime is gone or broken, so it runs before any runtime detection or build.
36276if [ "$UNINSTALL" -eq 1 ]; then
3636 remove_block "$(choose_rc_file)"
3643 exit 0
365fi
366
367148runtime="$(resolve_runtime || true)"
36875[ -n "$runtime" ] || die "no container runtime found. Install Docker, Finch, or Podman and start it, then re-run (or force one with --runtime NAME)."
36971require_single_line "container runtime" "$runtime"
37071if [ "$runtime" = "podman" ]; then
3719 require_single_line "XDG_RUNTIME_DIR" "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
372fi
373
374142socket="$(socket_args_for "$runtime")"
375
376# Podman does not resolve a bare, locally-built image name: an image built with
377# `podman build -t gco-dev` is stored as `localhost/gco-dev`, but `podman run
378# gco-dev` treats the unqualified name as remote and searches the configured
379# registries (docker.io, quay.io, ...) instead of local storage. Prefix
380# `localhost/` so the emitted `podman run` finds the image you built locally.
381# Names that already carry a registry/namespace (contain a `/`) are untouched,
382# and docker/finch — which do resolve bare local names — keep the plain name.
38371image_ref="$IMAGE"
38471if [ "$runtime" = "podman" ]; then
3859 case "$IMAGE" in
3861 */*) : ;;
3878 *) image_ref="localhost/$IMAGE" ;;
388 esac
389fi
39071require_single_line "image name" "$image_ref"
391
392138mount_suffix="$(mount_suffix_for_host)"
39369if [ "$AWS_WRITABLE" -eq 1 ]; then
394 # Writable ~/.aws lets `aws sso login` / `aws configure` run INSIDE the
395 # container and cache their tokens where the host can reuse them. Opt-in:
396 # the read-only default keeps a container that runs third-party tooling
397 # from rewriting the operator's credential files.
3981 aws_mount_opts="rw${mount_suffix}"
399else
40068 aws_mount_opts="ro${mount_suffix}"
401fi
402
403207block="$(emit_block "$runtime" "$socket" "$image_ref" "$(forwarded_env_args)" "$aws_mount_opts")"
404
40569if [ "$PRINT_ONLY" -eq 1 ]; then
40638 printf '%s\n' "$block"
40738 exit 0
408fi
409
410# Build (or refresh) the dev image before wiring up the function, so a single
411# run takes a fresh clone all the way to a working `gco`. Always rebuilding also
412# means a stale local image is transparently replaced. Skipped with --no-build.
41331if [ "$NO_BUILD" -eq 1 ]; then
41417 log "Skipping the image build (--no-build); assuming '$image_ref' already exists."
41517 log ""
416else
41714 build_image "$runtime"
418fi
419
42050rc="$(choose_rc_file)"
42124install_block "$rc" "$block"
422
42324log "Installed the 'gco' dev-container function."
42424log ""
42524log " Container runtime : $runtime"
42648log " Socket mount : $(socket_desc_for "$runtime")"
42724log " Dev image : $image_ref"
42824log " Shell profile : $rc"
42972log " AWS credentials : ~/.aws mounted $([ "$AWS_WRITABLE" -eq 1 ] && printf 'read-write' || printf 'read-only') + AWS_PROFILE/AWS_REGION/keys/session"
43024log " forwarded from your shell when set"
43124log " Autopilot env : engine/model/config controls forwarded when set"
43224if [ -n "$mount_suffix" ]; then
4331 log " SELinux : enforcing host detected; bind mounts carry the ',z' shared label"
434fi
43524log ""
43624if [ "$AWS_WRITABLE" -eq 0 ]; then
43724 log "Note: ~/.aws is mounted read-only, so an SSO/session token that expires must be"
43824 log "refreshed on the host ('aws sso login'); re-run with --aws-writable to allow the"
43924 log "container to refresh and cache it instead."
440fi
44124if [ -n "${AWS_CONFIG_FILE:-}${AWS_SHARED_CREDENTIALS_FILE:-}${AWS_WEB_IDENTITY_TOKEN_FILE:-}" ]; then
4421 log ""
4431 log "Note: you have an AWS file-path variable set. It is forwarded, but the file is"
4441 log "only readable in the container when it lives under ~/.aws (the mounted path)."
4451 log "Copy or symlink it under ~/.aws, or add your own -v mount to the function."
446fi
44724log ""
44824log "Activate it in this shell: source \"$rc\""
44924log "Then try: gco --help"
45024if [ -z "$socket" ]; then
4511 log ""
4521 log "Note: $runtime runs containers inside a VM and exposes no host daemon socket the"
4531 log "container can reach (a bind-mounted socket connects to nothing across the VM"
4541 log "boundary), so the function omits the socket mount. Everyday commands — jobs,"
4551 log "status, costs, inference, and non-build stacks operations — work as-is."
4561 log ""
4571 log "Build-heavy commands ('gco stacks deploy-all', image builds) need a container"
4581 log "daemon at CDK synth time, so run them on the host with $runtime as the builder:"
4591 log ""
4601 log " CDK_DOCKER=$runtime gco stacks deploy-all -y"
4611 log ""
4621 log "Run that against a host install of the GCO CLI whose deps match the lockfile"
4631 log "(e.g. a project virtualenv), so the pinned aws-cdk-lib / cdk-nag are used."
464fi