Skip to content

Instantly share code, notes, and snippets.

@troykelly
Created March 11, 2026 00:04
Show Gist options
  • Select an option

  • Save troykelly/82f552a21eee06761563ed942651a36e to your computer and use it in GitHub Desktop.

Select an option

Save troykelly/82f552a21eee06761563ed942651a36e to your computer and use it in GitHub Desktop.
Troy's Container Post Create Script
#!/usr/bin/env bash
# Unified devcontainer post-create script
# Idempotent, atomic, architecture-aware
# Each step is independent — failures are logged but never abort the script.
set -uo pipefail
###############################################################################
# CRLF self-heal — Windows git checkout can inject \r into shell scripts
###############################################################################
if head -1 "$0" | grep -q $'\r'; then
sed -i 's/\r$//' "$0"
exec bash "$0" "$@"
fi
###############################################################################
# Platform detection
###############################################################################
RAW_ARCH="$(uname -m)"
case "$RAW_ARCH" in
x86_64|amd64) ARCH="x86_64"; IS_ARM64=false ;;
aarch64|arm64) ARCH="aarch64"; IS_ARM64=true ;;
*)
echo "FATAL: Unsupported architecture: $RAW_ARCH"
exit 1
;;
esac
OS="$(uname -s)" # Linux expected inside devcontainer
# Resolve workspace root — prefer $CONTAINER_WORKSPACE_FOLDER, fall back to /workspaces/*
if [ -n "${CONTAINER_WORKSPACE_FOLDER:-}" ]; then
WORKSPACE_DIR="$CONTAINER_WORKSPACE_FOLDER"
elif [ -d /workspaces ]; then
WORKSPACE_DIR="$(find /workspaces -mindepth 1 -maxdepth 1 -type d | head -1)"
else
WORKSPACE_DIR="$(pwd)"
fi
# Determine the non-root user running inside the container
DEV_USER="${_REMOTE_USER:-${USER:-vscode}}"
DEV_HOME="$(eval echo "~${DEV_USER}")"
echo "=== Unified post-create ==="
echo " Arch: $ARCH (arm64=$IS_ARM64)"
echo " OS: $OS"
echo " Workspace: $WORKSPACE_DIR"
echo " User: $DEV_USER ($DEV_HOME)"
echo ""
###############################################################################
# Step runner — tracks pass/fail per step, never aborts
###############################################################################
declare -a STEP_RESULTS=()
run_step() {
local name="$1"
shift
echo "--- [$name] ---"
if "$@"; then
STEP_RESULTS+=("✅ $name")
else
STEP_RESULTS+=("❌ $name")
echo "⚠️ $name failed (non-fatal, continuing)"
fi
echo ""
}
###############################################################################
# 1. Node.js / NVM
###############################################################################
setup_node() {
export NVM_DIR="${DEV_HOME}/.nvm"
# Source nvm from common locations
for candidate in \
"${NVM_DIR}/nvm.sh" \
/usr/local/share/nvm/nvm.sh \
/usr/share/nvm/nvm.sh; do
if [ -s "$candidate" ]; then
# shellcheck source=/dev/null
. "$candidate"
break
fi
done
if ! command -v nvm &>/dev/null; then
echo "nvm not found — skipping Node.js setup"
return 1
fi
if [ -f "${WORKSPACE_DIR}/.nvmrc" ]; then
nvm install
else
echo "No .nvmrc found — installing current node"
nvm install node
fi
echo "Node $(node --version) / npm $(npm --version)"
}
###############################################################################
# 2. pnpm
###############################################################################
setup_pnpm() {
export PNPM_HOME="${DEV_HOME}/.local/share/pnpm"
export PATH="${PNPM_HOME}:${PATH}"
# Ensure directories exist with correct ownership
for dir in "${DEV_HOME}/.local" "${DEV_HOME}/.local/share" "$PNPM_HOME"; do
if [ ! -d "$dir" ]; then
sudo mkdir -p "$dir"
fi
sudo chown "$DEV_USER" "$dir"
done
if ! command -v pnpm &>/dev/null; then
echo "Installing pnpm..."
curl -fsSL https://get.pnpm.io/install.sh | sh -
fi
echo "pnpm $(pnpm --version)"
}
###############################################################################
# 3. Install project dependencies
###############################################################################
install_deps() {
if [ ! -f "${WORKSPACE_DIR}/package.json" ]; then
echo "No package.json — skipping"
return 0
fi
cd "$WORKSPACE_DIR" || return 1
# Fix node_modules ownership if Docker created it as root
if [ -d node_modules ] && [ "$(stat -c '%U' node_modules 2>/dev/null || stat -f '%Su' node_modules 2>/dev/null)" = "root" ]; then
echo "Fixing node_modules ownership (root → $DEV_USER)..."
sudo chown -R "$DEV_USER" node_modules
fi
pnpm install --frozen-lockfile || pnpm install
}
###############################################################################
# 4. System Chromium + no-sandbox wrapper
###############################################################################
install_chromium() {
if [ "$OS" != "Linux" ]; then
echo "Not Linux — skipping system Chromium"
return 0
fi
sudo apt-get update -qq
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq chromium >/dev/null 2>&1 \
|| sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq chromium-browser >/dev/null 2>&1
# Verify we got a binary
local chromium_bin=""
for candidate in /usr/bin/chromium /usr/bin/chromium-browser; do
if [ -x "$candidate" ]; then
chromium_bin="$candidate"
break
fi
done
if [ -z "$chromium_bin" ]; then
echo "Chromium binary not found after install"
return 1
fi
# Create wrapper at /opt/google/chrome/chrome so tools that probe for
# "google-chrome" or "chrome" find it automatically. --no-sandbox is
# required inside container PID namespaces.
sudo mkdir -p /opt/google/chrome
sudo tee /opt/google/chrome/chrome >/dev/null <<WRAPPER
#!/bin/sh
exec ${chromium_bin} --no-sandbox "\$@"
WRAPPER
sudo chmod +x /opt/google/chrome/chrome
echo "Chromium wrapper installed → /opt/google/chrome/chrome"
}
###############################################################################
# 5. Playwright browsers
###############################################################################
install_playwright() {
if [ "$OS" != "Linux" ]; then
echo "Not Linux — skipping Playwright"
return 0
fi
# Install ALL browsers (chromium, firefox, webkit) plus OS-level shared
# libraries (libgtk, libnss, fonts, etc.) via --with-deps.
# Both Playwright and its deps are architecture-aware (amd64/arm64).
npx --yes playwright install --with-deps
echo "Playwright browsers installed"
}
###############################################################################
# 6. Puppeteer browsers + arm64 fallback
###############################################################################
install_puppeteer() {
if [ "$OS" != "Linux" ]; then
echo "Not Linux — skipping Puppeteer"
return 0
fi
# Install the exact Chrome version that the installed puppeteer expects.
# Playwright's --with-deps (above) already installed the system libraries.
npx --yes puppeteer browsers install chrome || true
npx --yes puppeteer browsers install chrome-headless-shell || true
# arm64 Linux: Chrome for Testing has no native build.
# Fall back to Playwright's Chromium via PUPPETEER_EXECUTABLE_PATH.
if $IS_ARM64; then
local pw_chrome=""
pw_chrome="$(find "${DEV_HOME}/.cache/ms-playwright" \
-name 'chrome' -path '*/chromium-*/chrome-linux*/chrome' \
-type f 2>/dev/null | head -1)"
if [ -n "$pw_chrome" ]; then
echo "arm64: Chrome for Testing unavailable — using Playwright Chromium"
echo " → $pw_chrome"
export PUPPETEER_EXECUTABLE_PATH="$pw_chrome"
# Persisted to shell rc in setup_shell step
else
echo "⚠️ arm64: No Playwright Chromium found for Puppeteer fallback"
fi
fi
}
###############################################################################
# 7. Claude Code CLI
###############################################################################
install_claude() {
if command -v claude &>/dev/null; then
echo "Claude Code already installed: $(claude --version 2>/dev/null || echo 'unknown')"
return 0
fi
echo "Installing Claude Code CLI..."
curl -fsSL https://claude.ai/install.sh | bash
# Ensure it's on PATH for the rest of this script
export PATH="${DEV_HOME}/.claude/bin:${DEV_HOME}/.local/bin:${PATH}"
if command -v claude &>/dev/null; then
echo "Claude Code installed: $(claude --version 2>/dev/null || echo 'ok')"
else
echo "Claude Code install did not place binary on PATH"
return 1
fi
}
###############################################################################
# 8. Claude Code plugins
###############################################################################
install_plugins() {
if ! command -v claude &>/dev/null; then
echo "claude not found — skipping plugins"
return 1
fi
# Add official marketplace
claude plugin marketplace add anthropics/claude-plugins-official || true
# Install plugins from the official marketplace
local plugins=(
circleback
claude-code-setup
claude-md-management
code-review
code-simplifier
commit-commands
feature-dev
frontend-design
github
hookify
linear
playground
playwright
pr-review-toolkit
pyright-lsp
ralph-loop
security-guidance
sentry
stripe
superpowers
typescript-lsp
)
local failed=0
for plugin in "${plugins[@]}"; do
if ! claude plugin install "${plugin}@claude-plugins-official"; then
((failed++))
fi
done
echo "Installed $((${#plugins[@]} - failed))/${#plugins[@]} plugins"
}
###############################################################################
# 9. Codex CLI (architecture-aware binary)
###############################################################################
install_codex() {
if command -v codex &>/dev/null; then
echo "Codex already installed: $(codex --version 2>/dev/null || echo 'unknown')"
return 0
fi
if [ "$OS" != "Linux" ]; then
echo "Codex binary install only supported on Linux — skipping"
return 0
fi
local codex_arch=""
case "$ARCH" in
x86_64) codex_arch="x86_64-unknown-linux-musl" ;;
aarch64) codex_arch="aarch64-unknown-linux-musl" ;;
*) echo "Unsupported arch for Codex: $ARCH"; return 1 ;;
esac
echo "Fetching latest Codex release for $codex_arch..."
local latest_tag=""
latest_tag="$(curl -fsSL --retry 3 --retry-delay 2 \
"https://api.github.com/repos/openai/codex/releases" \
| jq -r '[.[] | select(.prerelease==false)][0].tag_name' 2>/dev/null)"
if [ -z "$latest_tag" ] || [ "$latest_tag" = "null" ]; then
echo "Could not determine latest Codex version"
return 1
fi
# Asset names are codex-{arch}.tar.gz (no version in filename)
local download_url="https://github.com/openai/codex/releases/download/${latest_tag}/codex-${codex_arch}.tar.gz"
local _tmpdir=""
_tmpdir="$(mktemp -d)"
echo "Downloading $download_url"
if ! curl -fsSL --retry 3 --retry-delay 2 "$download_url" -o "${_tmpdir}/codex.tar.gz"; then
rm -rf "$_tmpdir"
echo "Download failed"
return 1
fi
tar -xzf "${_tmpdir}/codex.tar.gz" -C "$_tmpdir"
# The archive contains a single binary named codex-{arch}
local codex_bin="${_tmpdir}/codex-${codex_arch}"
if [ ! -f "$codex_bin" ]; then
rm -rf "$_tmpdir"
echo "Codex binary not found in archive (expected codex-${codex_arch})"
return 1
fi
sudo install -m 755 "$codex_bin" /usr/local/bin/codex
rm -rf "$_tmpdir"
echo "Codex $(codex --version 2>/dev/null || echo "$latest_tag") installed"
}
###############################################################################
# 10. MCP server configuration
###############################################################################
configure_mcp() {
if ! command -v claude &>/dev/null; then
echo "claude not found — skipping MCP config"
return 1
fi
# MCP servers are registered in the user's ~/.claude.json via jq
# (direct JSON manipulation avoids relying on claude CLI being on PATH)
local user_claude_json="${DEV_HOME}/.claude.json"
if [ ! -f "$user_claude_json" ]; then
echo '{}' > "$user_claude_json"
fi
# Codex MCP — runs codex as an MCP server via stdio
if command -v codex &>/dev/null; then
local tmp_mcp=""
tmp_mcp="$(mktemp)"
jq '.mcpServers.codex = {"type":"stdio","command":"codex","args":["mcp-server"],"env":{}}' \
"$user_claude_json" > "$tmp_mcp" && mv "$tmp_mcp" "$user_claude_json"
echo "Codex MCP server registered"
fi
# GlitchTip MCP — error tracking with project filtering (CleverMobi/glitchtip-mcp)
# Conditional on GLITCHTIP_AUTH_TOKEN in env (passed via --env-file in devcontainer.json)
if [ -n "${GLITCHTIP_AUTH_TOKEN:-}" ]; then
local glitchtip_org="${GLITCHTIP_ORGANIZATION:-aperim}"
local glitchtip_url="${GLITCHTIP_API_URL:-https://glitchtip.sy3.aperim.net}"
local tmp_mcp=""
tmp_mcp="$(mktemp)"
jq --arg token "$GLITCHTIP_AUTH_TOKEN" \
--arg url "$glitchtip_url" \
--arg org "$glitchtip_org" \
'.mcpServers.glitchtip = {
"type":"stdio",
"command":"npx",
"args":["-y","github:CleverMobi/glitchtip-mcp"],
"env":{
"GLITCHTIP_API_TOKEN":$token,
"GLITCHTIP_API_ENDPOINT":$url,
"GLITCHTIP_ORGANIZATION_SLUG":$org
}
}' "$user_claude_json" > "$tmp_mcp" && mv "$tmp_mcp" "$user_claude_json"
# Also register with Codex if available
if command -v codex &>/dev/null; then
codex mcp add glitchtip \
--env "GLITCHTIP_API_TOKEN=${GLITCHTIP_AUTH_TOKEN}" \
--env "GLITCHTIP_API_ENDPOINT=${glitchtip_url}" \
--env "GLITCHTIP_ORGANIZATION_SLUG=${glitchtip_org}" \
-- npx -y github:CleverMobi/glitchtip-mcp || true
fi
echo "GlitchTip MCP registered (org=$glitchtip_org, endpoint=${glitchtip_url})"
else
echo "GLITCHTIP_AUTH_TOKEN not set — skipping GlitchTip MCP"
fi
# Ensure correct ownership
chown "$DEV_USER" "$user_claude_json" 2>/dev/null || true
}
###############################################################################
# 11. Claude Code settings (merged via jq)
###############################################################################
configure_claude() {
local settings_file="${DEV_HOME}/.claude/settings.json"
mkdir -p "${DEV_HOME}/.claude"
# Build the desired settings object
local desired='{
"permissions": {
"defaultMode": "bypassPermissions"
},
"apiKeyHelper": "/bin/sh -c '\''echo $ANTHROPIC_API_KEY'\''"
}'
if [ -f "$settings_file" ]; then
local tmp=""
tmp="$(mktemp)"
jq -s '.[0] * .[1]' "$settings_file" <(echo "$desired") > "$tmp" 2>/dev/null \
&& mv "$tmp" "$settings_file"
else
printf '%s\n' "$desired" | jq '.' > "$settings_file"
fi
echo "Claude settings updated: apiKeyHelper + bypassPermissions"
# User preferences (onboarding, theme)
local prefs_file="${DEV_HOME}/.claude.json"
local prefs='{
"hasCompletedOnboarding": true,
"hasAcknowledgedCostThreshold": true,
"theme": "dark"
}'
if [ -f "$prefs_file" ]; then
local tmp=""
tmp="$(mktemp)"
jq -s '.[0] * .[1]' "$prefs_file" <(echo "$prefs") > "$tmp" 2>/dev/null \
&& mv "$tmp" "$prefs_file"
else
printf '%s\n' "$prefs" | jq '.' > "$prefs_file"
fi
echo "Claude preferences updated: onboarding + dark theme"
}
###############################################################################
# 12. Codex CLI configuration
###############################################################################
configure_codex() {
if ! command -v codex &>/dev/null; then
echo "codex not found — skipping config"
return 0
fi
local codex_dir="${DEV_HOME}/.codex"
mkdir -p "$codex_dir"
cat > "${codex_dir}/config.toml" <<'TOML'
# Devcontainer: no interactive terminal for approval prompts
approval_policy = "never"
[sandbox]
type = "danger-full-access"
TOML
echo "Codex config written"
}
###############################################################################
# 13. Ed25519 signing key (for audit logs)
###############################################################################
generate_keys() {
local key_dir="${WORKSPACE_DIR}"
local key_file="${key_dir}/signing_key.pem"
if [ -f "$key_file" ]; then
echo "Signing key already exists — skipping"
return 0
fi
if ! command -v openssl &>/dev/null; then
echo "openssl not found — skipping key generation"
return 1
fi
openssl genpkey -algorithm Ed25519 -out "$key_file" 2>/dev/null
openssl pkey -in "$key_file" -pubout -out "${key_dir}/signing_key.pub" 2>/dev/null
chmod 600 "$key_file"
echo "Ed25519 signing key generated"
}
###############################################################################
# 14. .env from .env.example
###############################################################################
setup_env() {
local env_file="${WORKSPACE_DIR}/.env"
local env_example="${WORKSPACE_DIR}/.env.example"
if [ -f "$env_file" ]; then
echo ".env already exists — skipping"
return 0
fi
if [ ! -f "$env_example" ]; then
echo "No .env.example found — skipping"
return 0
fi
cp "$env_example" "$env_file"
# Generate secrets for common placeholder patterns
if command -v openssl &>/dev/null; then
local jwt_secret=""
jwt_secret="$(openssl rand -base64 48)"
local encryption_key=""
encryption_key="$(openssl rand -base64 32)"
# Replace placeholder values (YOUR_SECRET_HERE, CHANGE_ME, <generate>, etc.)
sed -i "s|JWT_SECRET=.*|JWT_SECRET=${jwt_secret}|" "$env_file" 2>/dev/null || true
sed -i "s|ENCRYPTION_KEY=.*|ENCRYPTION_KEY=${encryption_key}|" "$env_file" 2>/dev/null || true
fi
# Rewrite common hostname placeholders for devcontainer networking
sed -i 's/localhost:5432/db:5432/g' "$env_file" 2>/dev/null || true
sed -i 's/localhost:6379/redis:6379/g' "$env_file" 2>/dev/null || true
sed -i 's/localhost:1025/mailpit:1025/g' "$env_file" 2>/dev/null || true
echo ".env created from .env.example"
}
###############################################################################
# 15. Git safe directory
###############################################################################
setup_git() {
if command -v git &>/dev/null; then
git config --global --add safe.directory "$WORKSPACE_DIR" 2>/dev/null || true
echo "Git safe directory: $WORKSPACE_DIR"
fi
}
###############################################################################
# 16. Shell customizations (zsh + bash)
###############################################################################
setup_shell() {
local marker="# >>> unified-devcontainer-config >>>"
local end_marker="# <<< unified-devcontainer-config <<<"
# Build the shell block
local shell_block=""
shell_block="$(cat <<'SHELL_BLOCK'
# >>> unified-devcontainer-config >>>
# nvm
export NVM_DIR="${HOME}/.nvm"
for _nvm_candidate in \
"${NVM_DIR}/nvm.sh" \
/usr/local/share/nvm/nvm.sh \
/usr/share/nvm/nvm.sh; do
if [ -s "$_nvm_candidate" ]; then
. "$_nvm_candidate"
break
fi
done
unset _nvm_candidate
# pnpm
export PNPM_HOME="${HOME}/.local/share/pnpm"
case ":${PATH}:" in
*":${PNPM_HOME}:"*) ;;
*) export PATH="${PNPM_HOME}:${PATH}" ;;
esac
# Claude Code
case ":${PATH}:" in
*":${HOME}/.claude/bin:"*) ;;
*) export PATH="${HOME}/.claude/bin:${PATH}" ;;
esac
case ":${PATH}:" in
*":${HOME}/.local/bin:"*) ;;
*) export PATH="${HOME}/.local/bin:${PATH}" ;;
esac
# GitHub token for MCP servers
if command -v gh &> /dev/null && gh auth status &> /dev/null 2>&1; then
export GITHUB_TOKEN=$(gh auth token 2>/dev/null)
fi
# Container sandbox — bypass interactive prompts
alias claude="claude --dangerously-skip-permissions"
alias codex="codex --dangerously-bypass-approvals-and-sandbox"
SHELL_BLOCK
)"
# Add workspace alias — needs the actual path baked in
shell_block+="
# Workspace
alias ws=\"cd ${WORKSPACE_DIR}\"
"
# arm64 Puppeteer fallback
if $IS_ARM64 && [ -n "${PUPPETEER_EXECUTABLE_PATH:-}" ]; then
shell_block+="
# arm64: Chrome for Testing has no native build — use Playwright Chromium
export PUPPETEER_EXECUTABLE_PATH=\"${PUPPETEER_EXECUTABLE_PATH}\"
"
fi
# Auto-switch node version on directory change (.nvmrc)
shell_block+='
# Auto-switch node version from .nvmrc
if [ -n "$ZSH_VERSION" ]; then
autoload -U add-zsh-hook
load-nvmrc() {
local nvmrc_path="$(nvm_find_nvmrc 2>/dev/null)"
if [ -n "$nvmrc_path" ]; then
local nvmrc_node_version=$(nvm version "$(cat "${nvmrc_path}")")
if [ "$nvmrc_node_version" = "N/A" ]; then
nvm install
elif [ "$nvmrc_node_version" != "$(nvm version)" ]; then
nvm use
fi
fi
}
add-zsh-hook chpwd load-nvmrc
load-nvmrc
fi
'
shell_block+="
$end_marker
"
# Apply to both zshrc and bashrc, idempotently
for rc_file in "${DEV_HOME}/.zshrc" "${DEV_HOME}/.bashrc"; do
if [ ! -f "$rc_file" ]; then
touch "$rc_file"
fi
if grep -qF "$marker" "$rc_file" 2>/dev/null; then
# Remove old block and replace
local tmp=""
tmp="$(mktemp)"
sed "/$marker/,/$end_marker/d" "$rc_file" > "$tmp"
printf '%s\n' "$shell_block" >> "$tmp"
mv "$tmp" "$rc_file"
echo "Updated shell block in $(basename "$rc_file")"
else
printf '%s\n' "$shell_block" >> "$rc_file"
echo "Added shell block to $(basename "$rc_file")"
fi
done
}
###############################################################################
# Run all steps
###############################################################################
cd "$WORKSPACE_DIR" || true
run_step "Node.js / NVM" setup_node
run_step "pnpm" setup_pnpm
run_step "Project dependencies" install_deps
run_step "System Chromium" install_chromium
run_step "Playwright browsers" install_playwright
run_step "Puppeteer browsers" install_puppeteer
run_step "Claude Code CLI" install_claude
run_step "Claude plugins" install_plugins
run_step "Codex CLI" install_codex
run_step "MCP servers" configure_mcp
run_step "Claude settings" configure_claude
run_step "Codex config" configure_codex
run_step "Signing keys" generate_keys
run_step ".env setup" setup_env
run_step "Git config" setup_git
run_step "Shell customizations" setup_shell
###############################################################################
# Invoke post-start if it exists (for first-run convenience)
###############################################################################
POST_START_SCRIPT="${WORKSPACE_DIR}/.devcontainer/post-start.sh"
if [ -f "$POST_START_SCRIPT" ]; then
echo ""
echo "=== Running post-start.sh ==="
bash "$POST_START_SCRIPT" || echo "⚠️ post-start.sh returned non-zero"
fi
###############################################################################
# Summary
###############################################################################
echo ""
echo "========================================="
echo " Post-create complete"
echo "========================================="
for result in "${STEP_RESULTS[@]}"; do
echo " $result"
done
echo ""
echo " Workspace: $WORKSPACE_DIR"
echo " Arch: $ARCH"
if command -v node &>/dev/null; then
echo " Node: $(node --version 2>/dev/null)"
fi
if command -v pnpm &>/dev/null; then
echo " pnpm: $(pnpm --version 2>/dev/null)"
fi
if command -v claude &>/dev/null; then
echo " Claude: $(claude --version 2>/dev/null || echo 'installed')"
fi
if command -v codex &>/dev/null; then
echo " Codex: $(codex --version 2>/dev/null || echo 'installed')"
fi
echo "========================================="
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment