diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index bcb8363..a6f2766 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,7 +2,10 @@ // Uncomment optional blocks as needed. Comment out what you don't use. { "name": "Musher Dev", - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + // Ubuntu 24.04, not the floating `ubuntu` tag: that now resolves to 26.04 + // (resolute), where docker-in-docker refuses its default moby option, so the + // build fails. Move it by hand once the Features build on a newer release. + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename},type=bind,consistency=cached", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", @@ -70,15 +73,36 @@ // Allow debuggers (gdb, strace, delve) to attach to processes. "capAdd": ["SYS_PTRACE"], + // One volume per purpose per container: logins, agent sessions, shell + // history and download caches survive a rebuild. Each target's parent + // already exists in the image, so Docker creates no root-owned directory on + // the way; post-create takes ownership of the volumes themselves. "mounts": [ "source=musher-${devcontainerId}-gh-config,target=/home/vscode/.config/gh,type=volume", "source=musher-${devcontainerId}-claude-config,target=/home/vscode/.claude,type=volume", - "source=musher-${devcontainerId}-codex-config,target=/home/vscode/.codex,type=volume" - // Uncomment to persist Docker-in-Docker data across rebuilds: - // "source=musher-${devcontainerId}-dind-data,target=/var/lib/docker/volumes,type=volume" + "source=musher-${devcontainerId}-codex-config,target=/home/vscode/.codex,type=volume", + "source=musher-${devcontainerId}-shell-history,target=/home/vscode/.shell-history,type=volume", + "source=musher-${devcontainerId}-cache,target=/home/vscode/.cache,type=volume" + // Docker-in-Docker needs no line here: its Feature keeps /var/lib/docker, + // the compose services' data included, on a volume that outlives a rebuild. ], "containerEnv": { + // Claude keeps .claude.json beside its config dir, so without this it + // lands in ~/ -- outside the claude volume -- and is lost on every rebuild. + "CLAUDE_CONFIG_DIR": "/home/vscode/.claude", + + // Every Claude Code session starts at max effort. An env var because the + // settings file's effortLevel has no "max"; it also outranks /effort, so + // start a lighter session as `CLAUDE_CODE_EFFORT_LEVEL=auto claude`. + "CLAUDE_CODE_EFFORT_LEVEL": "max", + + // REPL histories join the shell's on its volume; post-create points zsh + // and bash there. + "NODE_REPL_HISTORY": "/home/vscode/.shell-history/node_repl_history", + "PYTHON_HISTORY": "/home/vscode/.shell-history/python_history", + "PSQL_HISTORY": "/home/vscode/.shell-history/psql_history", + "PYTHONUNBUFFERED": "1", "PYTHONDONTWRITEBYTECODE": "1", "UV_LINK_MODE": "copy", @@ -98,7 +122,12 @@ }, "remoteEnv": { - "PATH": "/home/vscode/.local/bin:${containerEnv:PATH}" + "PATH": "/home/vscode/.local/bin:${containerEnv:PATH}", + + // The host's GH_TOKEN, when it exports one: the first credential + // lib/github-auth.sh offers gh. Not GH_TOKEN itself, which would override + // gh's stored login on every call. + "MUSHER_HOST_GH_TOKEN": "${localEnv:GH_TOKEN}" // Uncomment if Go feature auto-sets GOROOT incorrectly: // "GOROOT": "" }, @@ -109,6 +138,9 @@ "setup": ["bash", ".devcontainer/scripts/post-create.sh"] }, "postStartCommand": ["bash", ".devcontainer/scripts/startup.sh"], + // post-attach finishes the GitHub credential once VS Code has copied the + // host's ~/.gitconfig, which happens only after post-create. + "postAttachCommand": ["bash", ".devcontainer/scripts/post-attach.sh"], "forwardPorts": [15432, 15433, 15434, 15435, 15436, 15440, 15441, 15442, 15443, 15444, 15445, 15446, 15447, 15448, 15460], "portsAttributes": { @@ -132,6 +164,10 @@ "customizations": { "vscode": { "extensions": [ + // --- AI --- + // Claude Code's editor integration; the CLI comes from post-create. + "anthropic.claude-code", + // --- Core tooling --- "redhat.vscode-yaml", "tamasfe.even-better-toml", diff --git a/.devcontainer/scripts/lib/base-setup.sh b/.devcontainer/scripts/lib/base-setup.sh index 5b3e2c6..ff83b00 100644 --- a/.devcontainer/scripts/lib/base-setup.sh +++ b/.devcontainer/scripts/lib/base-setup.sh @@ -34,7 +34,8 @@ base_setup_config_dirs() { setup_config_dirs \ "gh config:${_HOME}/.config/gh" \ "claude:${_HOME}/.claude" \ - "codex:${_HOME}/.codex" + "codex:${_HOME}/.codex" \ + "shell history:${_HOME}/.shell-history" } # --- Cache directories --- @@ -60,6 +61,50 @@ base_setup_cache_dirs() { "bun cache:${_HOME}/.cache/bun" } +# --- GitHub --- + +# Stores a GitHub token in gh when one has reached the container, and writes no +# git config: VS Code copies the host's ~/.gitconfig only after post-create. +# post-attach.sh finishes the job (lib/github-auth.sh). After the config and +# cache dirs, because gh writes to both. +# +# Outputs: +# Writes progress to stderr +base_setup_github_seed() { + # shellcheck source=github-auth.sh + source "${_LIB_DIR}/github-auth.sh" + github_auth_seed +} + +# --- Shell history --- + +# Points zsh and bash at the shell-history volume, so history survives a +# rebuild. The line goes after what each rc file already holds, because +# VS Code's shell integration sets HISTFILE before it sources ~/.zshrc and +# oh-my-zsh keeps a value already set. Not exported, so a child shell keeps +# its own file. +# +# Globals: +# _HOME — read, user home directory +# Outputs: +# Writes progress to stderr via log() +base_setup_shell_history() { + local marker="# musher devcontainer history (post-create)" + local rc line + [[ -d "${_HOME}/.shell-history" ]] || return 0 + for rc in "${_HOME}/.zshrc" "${_HOME}/.bashrc"; do + [[ -f "${rc}" ]] || continue + grep -qxF "${marker}" "${rc}" && continue + # shellcheck disable=SC2016 # expanded by the shell that reads the rc file + case "${rc}" in + *zshrc) line='HISTFILE="$HOME/.shell-history/zsh_history"' ;; + *) line='HISTFILE="$HOME/.shell-history/bash_history"; PROMPT_COMMAND="history -a${PROMPT_COMMAND:+; $PROMPT_COMMAND}"' ;; + esac + log "Keeping $(basename "${rc}") history on the shell-history volume..." + printf '\n%s\n%s\n' "${marker}" "${line}" >> "${rc}" + done +} + # --- NVM --- # Delegates to fix_nvm_permissions from common.sh. @@ -138,6 +183,8 @@ base_setup() { log "Running base setup..." base_setup_config_dirs base_setup_cache_dirs + base_setup_github_seed + base_setup_shell_history base_fix_nvm_permissions base_install_claude_code base_install_codex diff --git a/.devcontainer/scripts/lib/github-auth.sh b/.devcontainer/scripts/lib/github-auth.sh new file mode 100644 index 0000000..15698ad --- /dev/null +++ b/.devcontainer/scripts/lib/github-auth.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# github-auth.sh — Library: give the container a GitHub credential for gh and git. +# +# Canonical copy: musher-dev/development-container +# .devcontainer/scripts/lib/github-auth.sh. Repositories copy it verbatim and +# change it here first. Sourced, never executed; nothing in it fails its caller. +# +# Two entry points, because Dev Containers sets up git in two steps: +# github_auth_seed post-create. Stores a token in gh and creates no git +# config: VS Code copies the host's ~/.gitconfig only +# after post-create, and only when none exists, so a +# file written here costs the developer their identity. +# ensure_github_auth post-attach. Seeds if still needed, then points git's +# github.com credential at the container's own gh. +# +# Exposes: +# github_auth_seed, github_auth_wire, ensure_github_auth + +if [[ -z "${_MUSHER_GITHUB_AUTH_LOADED:-}" ]]; then +_MUSHER_GITHUB_AUTH_LOADED=1 + +_GH_AUTH_HOST="github.com" + +# The host's ~/.gitconfig reaches the container with these set for the host's +# own gh, naming a binary that is not here (`!/opt/homebrew/bin/gh auth +# git-credential`): gh then reports green while every push 403s. +_GH_AUTH_HELPER_KEYS=( + "credential.https://github.com.helper" + "credential.https://gist.github.com.helper" +) + +_gh_log() { + printf 'github-auth: %s\n' "$*" >&2 +} + +_gh_has() { + command -v "$1" >/dev/null 2>&1 +} + +# An env token outranks gh's stored login, and `gh auth login` refuses to +# manage one. Codespaces injects GITHUB_TOKEN, so such a container is answered. +_gh_env_token() { + [[ -n "${GH_TOKEN:-}${GITHUB_TOKEN:-}" ]] +} + +# `gh auth token` reads the stored credential locally, so an offline start is +# not mistaken for a missing login, as `gh auth status` would. +_gh_has_credential() { + gh auth token --hostname "${_GH_AUTH_HOST}" >/dev/null 2>&1 +} + +# Captured rather than piped into `grep -q`: its early exit SIGPIPEs the writer, +# which callers running under `set -o pipefail` read as a failure. +_gh_git_wired() { + local gh_bin helpers + gh_bin="$(command -v gh)" || return 1 + helpers="$(git config --global --get-all "${_GH_AUTH_HELPER_KEYS[0]}" 2>/dev/null)" || return 1 + grep -qxF "!${gh_bin} auth git-credential" <<<"${helpers}" +} + +# Removes the host-copied helpers, and a previous `gh auth setup-git`, before +# git is asked for a credential. VS Code's own `credential.helper` stays: it is +# the channel the seeding rides on. Writes only to a ~/.gitconfig that exists. +_gh_clear_url_scoped_helpers() { + local key + for key in "${_GH_AUTH_HELPER_KEYS[@]}"; do + git config --global --get-all "${key}" >/dev/null 2>&1 || continue + if git config --global --unset-all "${key}" 2>/dev/null; then + _gh_log "cleared ${key#credential.} inherited from the host." + else + _gh_log "WARNING: could not clear ${key#credential.}" + fi + done +} + +# Asks git for a credential, which VS Code's forwarded helper answers from the +# host's own store: Keychain, Credential Manager, libsecret or gh. Both prompt +# switches matter, because the editor exports GIT_ASKPASS, which outranks +# GIT_TERMINAL_PROMPT=0, and a prompt would hang the lifecycle command. +_gh_token_from_host() { + local filled + filled="$(printf 'protocol=https\nhost=%s\n\n' "${_GH_AUTH_HOST}" \ + | env -u GIT_ASKPASS -u SSH_ASKPASS GIT_TERMINAL_PROMPT=0 \ + timeout 20 git -C "${HOME}" credential fill 2>/dev/null)" || return 1 + awk '/^password=/ { print substr($0, 10); exit }' <<<"${filled}" +} + +# GitHub refuses a token without the repo and read:org scopes. stderr is dropped +# because it is the one place a credential could be echoed back. +_gh_seed_from() { + local token="$1" origin="$2" + [[ -n "${token}" ]] || return 1 + if printf '%s' "${token}" | gh auth login --hostname "${_GH_AUTH_HOST}" --with-token 2>/dev/null; then + _gh_log "seeded gh from ${origin}." + return 0 + fi + _gh_log "GitHub refused the credential from ${origin} (gh needs repo and read:org); ignoring it." + return 1 +} + +# Stores a GitHub token in gh when none is there: the host's exported GH_TOKEN +# first, then the host's git credential. The second exists only once an editor +# has attached and has written ~/.gitconfig, and asking it can open a sign-in +# window on the host, so it is tried last. +# +# Globals: +# MUSHER_HOST_GH_TOKEN — read, devcontainer.json passes ${localEnv:GH_TOKEN} +# Outputs: +# Writes progress to stderr +# Returns: +# 0 always +github_auth_seed() { + _gh_has gh || { _gh_log "gh is not installed; skipping."; return 0; } + if _gh_env_token; then + _gh_log "GH_TOKEN/GITHUB_TOKEN is set; gh reads it directly." + return 0 + fi + _gh_has_credential && return 0 + + _gh_seed_from "${MUSHER_HOST_GH_TOKEN:-}" "the host's exported GH_TOKEN" && return 0 + if [[ -f "${HOME}/.gitconfig" ]] && _gh_has git; then + _gh_clear_url_scoped_helpers + local host_token + host_token="$(_gh_token_from_host || true)" + _gh_seed_from "${host_token}" "the host's git credential helper" || true + unset host_token + fi + + _gh_has_credential && return 0 + _gh_log "no GitHub credential reached the container yet. The next editor attach tries again; or run 'gh auth login'." + return 0 +} + +# Points git's github.com credential at the container's gh. Only once VS Code +# has written ~/.gitconfig, and only once gh holds a token: `gh auth setup-git` +# starts its section with an empty `helper =`, which also drops the forwarded +# helper for github.com. +# +# Outputs: +# Writes progress to stderr +# Returns: +# 0 always +github_auth_wire() { + if ! _gh_has gh || ! _gh_has git; then + return 0 + fi + _gh_env_token && return 0 + [[ -f "${HOME}/.gitconfig" ]] || return 0 + _gh_has_credential || return 0 + _gh_git_wired && return 0 + + _gh_clear_url_scoped_helpers + if gh auth setup-git --hostname "${_GH_AUTH_HOST}" 2>/dev/null; then + _gh_log "git now resolves ${_GH_AUTH_HOST} through this container's gh." + else + _gh_log "WARNING: 'gh auth setup-git' failed; pushes may 403. Run it by hand." + fi + return 0 +} + +# Seeds gh if needed, then wires git to it. Run from post-attach. +# +# Returns: +# 0 always +ensure_github_auth() { + github_auth_seed + github_auth_wire + return 0 +} + +fi diff --git a/.devcontainer/scripts/post-attach.sh b/.devcontainer/scripts/post-attach.sh new file mode 100644 index 0000000..8286e14 --- /dev/null +++ b/.devcontainer/scripts/post-attach.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# post-attach.sh — DevContainer post-attach command hook. +# +# Runs each time an editor attaches. Only then do VS Code's forwarded +# credential helper and its copy of the host's ~/.gitconfig both exist, so the +# GitHub credential is finished here rather than in post-create. Quick once +# done, and never fails the attach (lib/github-auth.sh). +# +# Usage: Called automatically by devcontainer.json postAttachCommand. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR + +# shellcheck source=lib/github-auth.sh +source "${SCRIPT_DIR}/lib/github-auth.sh" + +# Entry point: seeds gh if still needed, then wires git to it. +# +# Arguments: +# $@ — passed through (unused, reserved for future use) +main() { + ensure_github_auth + # --- Add repo-specific attach steps below --- +} + +main "$@" diff --git a/.devcontainer/scripts/post-create.sh b/.devcontainer/scripts/post-create.sh index 71fde09..5244492 100644 --- a/.devcontainer/scripts/post-create.sh +++ b/.devcontainer/scripts/post-create.sh @@ -3,6 +3,7 @@ # # Runs once after the container is created. Sets up environment files, # invokes the base setup orchestrator, and configures shell customization. +# post-attach.sh finishes the GitHub credential once an editor attaches. # # Usage: Called automatically by devcontainer.json postCreateCommand. set -euo pipefail diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index b800979..33c796f 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -29,6 +29,7 @@ jobs: - '.devcontainer/.env.example' scripts: - '.devcontainer/scripts/**' + - '.devcontainer/config/shell/**' compose: - '.devcontainer/compose.yaml' - '.devcontainer/compose/**' @@ -44,7 +45,7 @@ jobs: - name: Run ShellCheck uses: ludeeus/action-shellcheck@2.0.0 with: - scandir: .devcontainer/scripts + scandir: .devcontainer severity: warning compose: