From c12a6ba6665a37011bbf8168c34147c1c16d4708 Mon Sep 17 00:00:00 2001 From: Ali Date: Wed, 21 Oct 2026 16:07:34 +0000 Subject: [PATCH 1/4] feat(devcontainer): default Claude to max effort and keep more state Every Claude Code session starts at max effort: CLAUDE_CODE_EFFORT_LEVEL in containerEnv, since the settings file's effortLevel has no "max". A rebuild now keeps shell and REPL history and the download caches, on musher-${devcontainerId}-shell-history and -cache (DEVC-08), beside the gh, Claude and Codex logins it already kept. gh logs in by itself and git's github.com credential points at it, from the canonical lib/github-auth.sh that every repository copies verbatim. post-create only seeds a token (base_setup_github_seed); the git wiring runs in a new post-attach hook, because VS Code copies the host's ~/.gitconfig only after post-create and only when none exists, so writing one earlier costs the developer their name and email. lib/npm-registry.sh ships beside it for repositories that install @musher-dev/* from GitHub Packages; the template itself does not use it. Docs: what a rebuild keeps and how to reset a volume (on the host: inside, docker reaches Docker-in-Docker's daemon); the effort default; the git config copy, which is VS Code's and happens after post-create; and that stack data does survive a rebuild, in the docker-in-docker Feature's own volumes. Commented examples for a node_modules volume, Codespaces repository access and hostRequirements, and the Claude Code extension. Co-Authored-By: Claude Opus 5.5 --- .devcontainer/devcontainer.json | 57 +++++++- .devcontainer/scripts/lib/base-setup.sh | 46 +++++- .devcontainer/scripts/lib/github-auth.sh | 171 ++++++++++++++++++++++ .devcontainer/scripts/lib/npm-registry.sh | 139 ++++++++++++++++++ .devcontainer/scripts/post-attach.sh | 27 ++++ README.md | 13 +- docs/configuration.md | 101 +++++++++++-- 7 files changed, 532 insertions(+), 22 deletions(-) create mode 100644 .devcontainer/scripts/lib/github-auth.sh create mode 100644 .devcontainer/scripts/lib/npm-registry.sh create mode 100644 .devcontainer/scripts/post-attach.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 148b0dd..42aa370 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -72,15 +72,22 @@ // Allow debuggers (gdb, strace, delve) to attach to processes. "capAdd": ["SYS_PTRACE"], - // One volume per tool per container (DEVC-08): logins survive a rebuild. + // One volume per purpose per container (DEVC-08): 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" - // Product build output (e.g. Rust's target/), kept off the bind mount. - // Must sit under the declared product -- docs/layout.md → "Ecosystem Adapters": + "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 + // and /var/lib/containerd on volumes of its own, which outlive a rebuild. + // Product dependencies and build output (node_modules/, Rust's target/), + // kept off the bind mount. Must sit under the declared product -- + // docs/layout.md → "Ecosystem Adapters": + // "source=musher-${devcontainerId}-node-modules,target=/workspaces/${localWorkspaceFolderBasename}//node_modules,type=volume" // "source=musher-${devcontainerId}-target,target=/workspaces/${localWorkspaceFolderBasename}//target,type=volume" ], @@ -92,6 +99,11 @@ // "EXAMPLE_API_KEY": { "description": "...", "documentationUrl": "https://..." } // }, + // The codespace machine. `cpus` alone picks the size; a `memory` equal to a + // Docker Desktop VM's size reads as unmet locally (binary units) and warns + // on every open. + // "hostRequirements": { "cpus": 4 }, + "containerEnv": { // Auto-trust .config/mise/config.toml so `mise install` runs without a // prompt. mise finds the file itself. @@ -101,6 +113,17 @@ // 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", + // corepack resolves pnpm/yarn from package.json `packageManager` without // asking first; post-create runs `corepack enable`. "COREPACK_ENABLE_DOWNLOAD_PROMPT": "0", @@ -123,15 +146,23 @@ "remoteEnv": { // ~/.local/bin (Claude Code). The mise Feature puts mise itself and its // shims (task, uv, codex, lefthook, ...) on PATH. - "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}" }, // No initializeCommand: nothing runs on the host. post-create writes // .devcontainer/.env from env.schema.yaml inside the container. // Line endings are enforced repo-wide by .gitattributes (`* text=auto eol=lf`). + // post-attach finishes the GitHub credential once VS Code has copied the + // host's ~/.gitconfig, which happens only after post-create. "waitFor": "postCreateCommand", "postCreateCommand": ["bash", ".devcontainer/scripts/post-create.sh"], "postStartCommand": ["bash", ".devcontainer/scripts/startup.sh"], + "postAttachCommand": ["bash", ".devcontainer/scripts/post-attach.sh"], "forwardPorts": [15432, 15433, 15434, 15435, 15436, 15440, 15441, 15442, 15443, 15444, 15445, 15446, 15447, 15448, 15460], "portsAttributes": { @@ -153,8 +184,20 @@ }, "customizations": { + // A codespace's token reads only this repository. Name each other private + // repository the dev tasks read (release assets, GHCR images, GitHub + // Packages publishers); Codespaces asks once, at creation. + // "codespaces": { + // "repositories": { + // "musher-dev/": { "permissions": { "contents": "read", "packages": "read" } } + // } + // }, "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 1aab4e8..1c43b55 100644 --- a/.devcontainer/scripts/lib/base-setup.sh +++ b/.devcontainer/scripts/lib/base-setup.sh @@ -37,7 +37,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 --- @@ -63,6 +64,47 @@ 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. +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 at the end of each rc file, 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 dir="${_HOME}/.shell-history" + local marker="# musher devcontainer history (post-create)" + local rc line + [[ -d "${dir}" ]] || return 0 + for rc in "${_HOME}/.zshrc" "${_HOME}/.bashrc"; do + [[ -f "${rc}" ]] || continue + grep -qxF "${marker}" "${rc}" && continue + # shellcheck disable=SC2016 # PROMPT_COMMAND expands in the shell, not here + case "${rc}" in + *zshrc) line="HISTFILE=\"${dir}/zsh_history\"" ;; + *) line="HISTFILE=\"${dir}/bash_history\""'; PROMPT_COMMAND="history -a${PROMPT_COMMAND:+; $PROMPT_COMMAND}"' ;; + esac + log "Keeping $(basename "${rc}") history in ${dir}..." + printf '\n%s\n%s\n' "${marker}" "${line}" >> "${rc}" + done +} + # --- NVM --- # The Node feature installs Node via nvm; fix nvm's ownership so global npm @@ -254,6 +296,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_enable_corepack base_setup_path 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/lib/npm-registry.sh b/.devcontainer/scripts/lib/npm-registry.sh new file mode 100644 index 0000000..754e598 --- /dev/null +++ b/.devcontainer/scripts/lib/npm-registry.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# npm-registry.sh — Library: give bun a GitHub Packages credential for @musher-dev/*. +# +# Canonical copy: musher-dev/development-container +# .devcontainer/scripts/lib/npm-registry.sh. Repositories copy it verbatim and +# change it here first. The template itself installs no @musher-dev/* package; +# it ships this for repositories whose .npmrc routes that scope to GitHub +# Packages. Sourced, never executed. +# +# The registry wants a token even for a public package, and bun looks it up in +# the user's own .npmrc, never the repository's, so no token is ever committed. +# npm_registry_setup post-create and post-attach: never prompts, and skips +# while gh has no token with read:packages. +# npm_registry_auth `task setup:registry`: asks gh for read:packages when +# the token lacks it, then writes the line. +# +# Exposes: +# npm_registry_file, npm_registry_configured, npm_registry_setup, npm_registry_auth + +if [[ -z "${_MUSHER_NPM_REGISTRY_LOADED:-}" ]]; then +_MUSHER_NPM_REGISTRY_LOADED=1 + +_NPM_REGISTRY_HOST="npm.pkg.github.com" + +_npm_log() { + printf 'npm-registry: %s\n' "$*" >&2 +} + +# bun reads $XDG_CONFIG_HOME/.npmrc when that variable is set and $HOME/.npmrc +# otherwise, with no fallback between the two. +npm_registry_file() { + printf '%s/.npmrc\n' "${XDG_CONFIG_HOME:-${HOME}}" +} + +npm_registry_configured() { + grep -qsE "^//${_NPM_REGISTRY_HOST}/:_authToken=.+" "$(npm_registry_file)" +} + +# Whether gh's active account holds a token with a packages scope. Captured, +# not piped into `grep -q`, whose early exit SIGPIPEs gh under pipefail. +_npm_gh_reads_packages() { + local status + status="$(gh auth status --active --hostname github.com 2>&1)" || return 1 + grep -qE "'(read|write):packages'" <<<"${status}" +} + +# The value to write. With an env token (Codespaces) a reference that bun +# expands when it reads the file, so a re-issued token never goes stale; +# otherwise gh's stored token, and nothing when gh returns none. +_npm_registry_value() { + # shellcheck disable=SC2016 # the reference is written literally, for bun + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + printf '%s' '${GITHUB_TOKEN}' + elif [[ -n "${GH_TOKEN:-}" ]]; then + printf '%s' '${GH_TOKEN}' + else + local token + token="$(gh auth token --hostname github.com 2>/dev/null)" || return 1 + [[ -n "${token}" ]] || return 1 + printf '%s' "${token}" + fi +} + +# Replaces any earlier line for the host and keeps every other line. The file +# is never world-readable, and the temporary copy never outlives the call. +_npm_registry_write() { + local value="$1" file tmp + [[ -n "${value}" ]] || return 1 + file="$(npm_registry_file)" + tmp="$(mktemp)" + if [[ -f "${file}" ]]; then + grep -v "^//${_NPM_REGISTRY_HOST}/" "${file}" >"${tmp}" || true + fi + printf '//%s/:_authToken=%s\n' "${_NPM_REGISTRY_HOST}" "${value}" >>"${tmp}" + if ! install -m 600 "${tmp}" "${file}"; then + rm -f "${tmp}" + return 1 + fi + rm -f "${tmp}" +} + +# Writes the credential when it can do so without asking anything. +# +# Outputs: +# Writes progress to stderr +# Returns: +# 0 always +npm_registry_setup() { + npm_registry_configured && return 0 + local value + if [[ -z "${GITHUB_TOKEN:-}${GH_TOKEN:-}" ]]; then + if ! command -v gh >/dev/null 2>&1 || ! gh auth token --hostname github.com >/dev/null 2>&1; then + _npm_log "skipped: gh is not logged in. Run 'task setup:registry' once it is." + return 0 + fi + if ! _npm_gh_reads_packages; then + _npm_log "skipped: gh's token lacks read:packages. Run 'task setup:registry'." + return 0 + fi + fi + if ! value="$(_npm_registry_value)" || ! _npm_registry_write "${value}"; then + _npm_log "skipped: no token to write to $(npm_registry_file)." + return 0 + fi + _npm_log "bun can download @musher-dev/* from ${_NPM_REGISTRY_HOST} ($(npm_registry_file))." + return 0 +} + +# `task setup:registry`: interactive when the scope is missing, because +# `gh auth login` does not ask for read:packages by default. +# +# Outputs: +# Writes progress to stderr +# Returns: +# 0 on success, 1 when gh is missing, not logged in or refused +npm_registry_auth() { + local value + if [[ -z "${GITHUB_TOKEN:-}${GH_TOKEN:-}" ]]; then + if ! command -v gh >/dev/null 2>&1; then + _npm_log "ERROR: the GitHub CLI is required." + return 1 + fi + if ! gh auth token --hostname github.com >/dev/null 2>&1; then + _npm_log "ERROR: gh is not logged in. Run 'gh auth login', then 'task setup:registry'." + return 1 + fi + if ! _npm_gh_reads_packages; then + _npm_log "Requesting the read:packages scope (browser or device-code flow)..." + gh auth refresh --hostname github.com --scopes read:packages || return 1 + fi + fi + if ! value="$(_npm_registry_value)" || ! _npm_registry_write "${value}"; then + _npm_log "ERROR: could not write $(npm_registry_file)." + return 1 + fi + _npm_log "bun can download @musher-dev/* from ${_NPM_REGISTRY_HOST} ($(npm_registry_file))." +} + +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/README.md b/README.md index 7d72564..60acb0c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,10 @@ Docker-in-Docker, Task, and consistent VS Code settings. Comment out what you do [`.config/mise/config.toml`](.config/mise/config.toml) - Opt-in, commented out until you need them: Rust (a Feature), OpenTofu, tflint, terraform-docs, doctl, cloudflared and buf (mise pins) -- Docker-in-Docker, Git + GitHub CLI, Claude Code +- Docker-in-Docker, Git + GitHub CLI, Claude Code (starting at max effort) and its VS Code extension +- State that survives a rebuild: the gh, Claude Code and Codex logins and sessions, shell and REPL history, and the + download caches, each on a volume of its own +- A GitHub credential with no login: gh is seeded from your host, and git pushes through it - GitLens, YAML, TOML, Copilot, Go, Python, Ruff, ESLint, Docker extensions - Format on save, rulers, trailing whitespace trimming @@ -105,6 +108,14 @@ If settings aren't applying after changes, rebuild without cache: **Command Palette** → **Dev Containers: Rebuild Container Without Cache** +### Resetting persisted state + +Logins, sessions, history and caches live on `musher-${devcontainerId}-` volumes, so **Rebuild Container** +keeps them ([docs/configuration.md → Adding Dev Container Volumes](docs/configuration.md#adding-dev-container-volumes)). +To start one over, print the names from inside the container with +`awk '$5 ~ "^/home/vscode" {print $4}' /proc/self/mountinfo`, stop the container, and run `docker volume rm ` on +the **host**: inside, `docker` talks to Docker-in-Docker's own daemon. + ### Volume permission errors Named volumes may initialize with root ownership. The `ensure_writable_dir` function in `common.sh` and the diff --git a/docs/configuration.md b/docs/configuration.md index e7fcb88..02ea62c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -51,6 +51,9 @@ One-time setup step? Runs on every container start? → .devcontainer/scripts/startup.sh + +Runs every time an editor attaches? + → .devcontainer/scripts/post-attach.sh ``` ## Where Configuration Lives @@ -110,12 +113,15 @@ See [`.config/README.md`](../.config/README.md) for the per-file index. The `CON | | VS Code extensions | `devcontainer.json` → `customizations.vscode.extensions` | | | Debug launch configs | `.vscode/launch.json` (in consuming project) | | **Shell & User** | Default shell, prompt, oh-my-zsh config | `devcontainer.json` → `common-utils` feature | -| | Git config | Host `.gitconfig` (auto-forwarded by devcontainers) | +| | Git config | Host `.gitconfig`, copied in by VS Code after post-create | +| | GitHub credential for gh and git | `lib/github-auth.sh` (post-create seeds, post-attach wires) | +| | Shell and REPL history | `shell-history` volume; `HISTFILE` lines from `lib/base-setup.sh` | | **Environment** | Runtime behavior vars (`PYTHONUNBUFFERED`, etc.) | `devcontainer.json` → `containerEnv` | | | PATH extensions | `devcontainer.json` → `remoteEnv` (mise shims: the mise Feature's `containerEnv`) | | | Service credentials (dev-only) | `.devcontainer/env.schema.yaml` → written to `.env` by `repo env sync` | | | Service profiles/toggles | `COMPOSE_PROFILES` (set it with `task env:setup`) | | | Secrets (API keys, tokens) | `source: host` bindings: Codespaces secrets, or `${localEnv:...}` in `remoteEnv` | +| | GitHub Packages token (`@musher-dev/*`) | `lib/npm-registry.sh`, for a product that installs one | | **Services** | Stack orchestrator (`include:` list) | `.devcontainer/stacks/compose.yaml` | | | Infrastructure services | `.devcontainer/stacks//compose.yaml` | | | Service enable/disable | `.devcontainer/.env` → `COMPOSE_PROFILES` | @@ -130,12 +136,15 @@ See [`.config/README.md`](../.config/README.md) for the per-file index. The `CON | | DB schema init (project) | `.devcontainer/stacks/postgres/init/01-project.sql` | | | DB migrations | Project tooling (Atlas, Flyway — not in template) | | | Data persistence | Compose files → named volumes | +| | What a rebuild keeps | `devcontainer.json` → `mounts` (see Adding Dev Container Volumes) | | **Lifecycle** | One-time container setup | `.devcontainer/scripts/post-create.sh` → `lib/base-setup.sh` | | | Every-start tasks | `.devcontainer/scripts/startup.sh` | +| | Every-attach tasks | `.devcontainer/scripts/post-attach.sh` | | | Task automation (consuming project) | `Taskfile.yml` in the consuming repo | | **AI Tools** | Claude Code (native installer) | `lib/base-setup.sh` | | | Codex CLI (pinned) | `.config/mise/config.toml` | | | AI CLI config persistence | `devcontainer.json` → `mounts` (named volumes), `CLAUDE_CONFIG_DIR` | +| | Claude Code's default effort | `devcontainer.json` → `containerEnv` → `CLAUDE_CODE_EFFORT_LEVEL` | | **Security** | Container capabilities | `devcontainer.json` → `capAdd` / `securityOpt` | | | Network binding | Compose files → all ports bound to `127.0.0.1` | @@ -318,7 +327,35 @@ If a consuming project has contributors who work outside the container, adding ### Git Config -Git config is auto-forwarded from your host machine by the devcontainer CLI. No configuration needed in the template. +VS Code's Dev Containers extension copies the host's `~/.gitconfig` into the container after post-create, and only +when the container has none yet, together with a credential helper that answers from the host's own store. So nothing +in post-create may write `~/.gitconfig`: a file there by then keeps your name and email out, and `git commit` fails +with "Author identity unknown". + +### GitHub Credentials + +[`lib/github-auth.sh`](../.devcontainer/scripts/lib/github-auth.sh) gives the container's own `gh` a token and points +git's `github.com` credential at it, so `gh`, `git push` and anything that reads a private repository work without a +login: + +- **post-create** calls `github_auth_seed`, which stores a token when one has reached the container already: the + host's exported `GH_TOKEN`, passed in as `MUSHER_HOST_GH_TOKEN`. It writes no git config. +- **post-attach** calls `ensure_github_auth`. If gh still has no token it asks VS Code's forwarded helper; then it + clears the helper lines the host's `.gitconfig` brought for the host's own `gh`, which name a path that does not + exist here, and runs `gh auth setup-git`. + +A container that holds `GH_TOKEN` or `GITHUB_TOKEN` already (a codespace) is left alone. `gh` accepts a token only with +the `repo` and `read:org` scopes, which some credential managers' tokens lack; then run `gh auth login` once, and the +`gh-config` volume keeps it. The file is canonical: a repository copies it verbatim and changes it here first, and the +same goes for [`lib/npm-registry.sh`](../.devcontainer/scripts/lib/npm-registry.sh), which a product that installs +`@musher-dev/*` from GitHub Packages runs from post-create, post-attach and its `task setup:registry`. + +### Shell History + +zsh and bash keep their history on the `shell-history` volume, so it survives a rebuild. `base_setup_shell_history` +appends one `HISTFILE` line to the end of `~/.zshrc` and of `~/.bashrc`, where bash also writes after every command. +The end matters: VS Code's shell integration sets `HISTFILE` before it sources `~/.zshrc`, and oh-my-zsh keeps a value +already set. Node's, Python's and psql's REPL histories go to the same volume through `containerEnv`. --- @@ -534,9 +571,10 @@ in alphabetical order on first container creation: ### Persistence -All services use named Compose volumes (e.g., `musher-postgres-data`, scoped to the `musher-dev` project). Data -persists across container restarts but is lost on full rebuild. For migrations, use project-level tooling (Atlas, -Flyway, etc.). +All services use named Compose volumes (e.g., `musher-postgres-data`, scoped to the `musher-dev` project). They live +in Docker-in-Docker's data root, which the docker-in-docker Feature keeps on volumes of its own +(`dind-var-lib-docker-${devcontainerId}` and `dind-var-lib-containerd-${devcontainerId}`), so stack data survives a +rebuild as well as a restart. For migrations, use project-level tooling (Atlas, Flyway, etc.). ### Adding Dev Container Volumes @@ -546,6 +584,25 @@ A volume `devcontainer.json` mounts follows `musher-${devcontainerId}-` "source=musher-${devcontainerId}-my-tool,target=/home/vscode/.my-tool,type=volume" ``` +`${devcontainerId}` is stable across rebuilds and unique to the container, so a rebuild keeps the volume and no other +repository's container shares it. The template mounts these: + +| Volume (`musher-${devcontainerId}-…`) | Mounted at | Keeps | +| --- | --- | --- | +| `gh-config` | `~/.config/gh` | The `gh` login | +| `claude-config` | `~/.claude` | Claude Code's login, settings and sessions (`/resume`), with `CLAUDE_CONFIG_DIR` | +| `codex-config` | `~/.codex` | Codex's login and settings | +| `shell-history` | `~/.shell-history` | zsh, bash, Node, Python and psql history | +| `cache` | `~/.cache` | The download caches `containerEnv` points there: uv, pip, npm, bun, Go, Deno | + +Two rules keep a new volume working. Give it a target whose parent already exists in the image: Docker creates any +missing directory on the way as root, and a root-owned `~/.local` stops mise and Claude Code from installing. And add +it to `base_setup_config_dirs`, because a fresh volume arrives root-owned. + +To start a volume over, remove it on the **host** with the container stopped: inside the container, `docker` reaches +Docker-in-Docker's daemon, not the host's. `awk '$5 ~ "^/home/vscode" {print $4}' /proc/self/mountinfo`, run in the +container, prints each volume's name; then, on the host, `docker volume rm `. + --- ## Lifecycle @@ -554,10 +611,11 @@ A volume `devcontainer.json` mounts follows `musher-${devcontainerId}-` | --- | --- | --- | | `postCreateCommand` | Once, on container creation | Tool installation, permissions, lefthook hooks, writing `.env` | | `postStartCommand` | Every container start | `docker compose up`, health checks | +| `postAttachCommand` | Every time an editor attaches | Finishing the GitHub credential, once VS Code has copied `.gitconfig` | -There is no `initializeCommand`: nothing runs on the host. Both hooks run scripts in `.devcontainer/scripts/`, from -the workspace, so an edit takes effect on the next create or start with no rebuild of anything else. Repo-specific -setup goes in `post-create.sh` → `main`, below the marked line. The scripts find the workspace with `workspace_root` +There is no `initializeCommand`: nothing runs on the host. The hooks run scripts in `.devcontainer/scripts/`, from +the workspace, so an edit takes effect on the next create, start or attach with no rebuild of anything else. +Repo-specific setup goes in `post-create.sh` → `main`, below the marked line. The scripts find the workspace with `workspace_root` (git's top level, or `MUSHER_WORKSPACE`). ### Skipping Base Steps @@ -569,6 +627,8 @@ main() { log "Starting post-create setup..." base_setup_config_dirs base_setup_cache_dirs + base_setup_github_seed + base_setup_shell_history base_fix_nvm_permissions base_enable_corepack base_setup_path @@ -585,12 +645,15 @@ main() { ```text post-create.sh ← Entry point (repo-specific customization) - ├── lib/base-setup.sh ← Reusable orchestrator (mise CLIs, Claude, corepack, nvm, config/cache dirs) - │ └── lib/common.sh ← Shared utilities (log, retry, has_cmd, ensure_writable_dir) + ├── lib/base-setup.sh ← Reusable orchestrator (mise CLIs, Claude, corepack, nvm, config/cache dirs, history) + │ ├── lib/common.sh ← Shared utilities (log, retry, has_cmd, ensure_writable_dir) + │ └── lib/github-auth.sh ← github_auth_seed: a token for gh, no git config └── lib/env-load.sh ← Wired into the shell profiles: exports .env into every new shell startup.sh ← Every start: compose stacks, health checks └── lib/motd.sh ← The startup MOTD +post-attach.sh ← Every attach: ensure_github_auth (lib/github-auth.sh) verify-toolchain.sh ← CI: mise = min_version, every mise pin installed +lib/npm-registry.sh ← Not used here: GitHub Packages credential for a product that installs @musher-dev/* ``` --- @@ -599,15 +662,24 @@ verify-toolchain.sh ← CI: mise = min_version, every mise pin installed ### Installed CLIs -- **Claude Code** — native self-updating installer (`base-setup.sh`), config persisted in the `~/.claude` volume +- **Claude Code** — native self-updating installer (`base-setup.sh`), config persisted in the `~/.claude` volume, and + its VS Code extension (`anthropic.claude-code`) - **Codex CLI** — pinned in `.config/mise/config.toml`, config persisted in the `~/.codex` volume +### Effort + +Every Claude Code session starts at max effort: `containerEnv` sets `CLAUDE_CODE_EFFORT_LEVEL=max`. It is an +environment variable because the settings file's `effortLevel` accepts only `low` to `xhigh`, and quietly drops +`max`. The variable outranks `/effort` and the model picker for the whole session, subagents included, so start a +lighter one as `CLAUDE_CODE_EFFORT_LEVEL=auto claude` (the model's default) or `=high`. An organization's +`maxEffortLevel` still caps it. + ### Configuration Persistence AI CLI configs are stored in named volumes, one per tool per container (`musher-${devcontainerId}--config`, the only form `DEVC-08` accepts), mounted via `devcontainer.json` → `mounts`. This preserves authentication and -settings across container rebuilds. `CLAUDE_CONFIG_DIR` points Claude Code at its volume, so `.claude.json` survives a -rebuild too. +settings across container rebuilds. `CLAUDE_CONFIG_DIR` points Claude Code at its volume, so `.claude.json`, which +holds the account and onboarding state, survives a rebuild too, beside the session transcripts `/resume` reads. --- @@ -667,11 +739,14 @@ Taskfile.yml Task entry point (cannot move — root-only discov scripts/ post-create.sh One-time setup entry point startup.sh Every-start service launcher + post-attach.sh Every-attach GitHub credential verify-toolchain.sh Asserts mise and every mise pin (CI) lib/ base-setup.sh Reusable tool installer (mise pins, Claude, corepack, the repo CLI) common.sh Shared utilities env-load.sh Exports .env into a shell without sourcing it + github-auth.sh gh token and git wiring (canonical; repositories copy it verbatim) + npm-registry.sh GitHub Packages credential for @musher-dev/* (canonical; unused here) motd.sh Startup MOTD renderer stacks/ The services, and the orchestrator that includes them compose.yaml Stack orchestrator (`include:` + `name: musher-dev`) From ac6b4a2d4d93606c735a9dc3bba698709a9b4772 Mon Sep 17 00:00:00 2001 From: Ali Date: Wed, 21 Oct 2026 21:32:04 +0000 Subject: [PATCH 2/4] chore(devcontainer): drop the Codespaces examples Codespaces settings are out of scope for this baseline, so the commented hostRequirements and customizations.codespaces examples go. Co-Authored-By: Claude Opus 5.5 --- .devcontainer/devcontainer.json | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 42aa370..fa07712 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -99,11 +99,6 @@ // "EXAMPLE_API_KEY": { "description": "...", "documentationUrl": "https://..." } // }, - // The codespace machine. `cpus` alone picks the size; a `memory` equal to a - // Docker Desktop VM's size reads as unmet locally (binary units) and warns - // on every open. - // "hostRequirements": { "cpus": 4 }, - "containerEnv": { // Auto-trust .config/mise/config.toml so `mise install` runs without a // prompt. mise finds the file itself. @@ -184,14 +179,6 @@ }, "customizations": { - // A codespace's token reads only this repository. Name each other private - // repository the dev tasks read (release assets, GHCR images, GitHub - // Packages publishers); Codespaces asks once, at creation. - // "codespaces": { - // "repositories": { - // "musher-dev/": { "permissions": { "contents": "read", "packages": "read" } } - // } - // }, "vscode": { "extensions": [ // --- AI --- From d530686baafa1a90698d3fc4dfd40e5bdaaa4ed1 Mon Sep 17 00:00:00 2001 From: Ali Date: Wed, 21 Oct 2026 21:45:26 +0000 Subject: [PATCH 3/4] docs(devcontainer): say the mountinfo line prints a path The awk line prints each volume's mount root, .../volumes//_data, not a bare name; say so, so the reset steps read right. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- docs/configuration.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 60acb0c..022bbe8 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,9 @@ If settings aren't applying after changes, rebuild without cache: Logins, sessions, history and caches live on `musher-${devcontainerId}-` volumes, so **Rebuild Container** keeps them ([docs/configuration.md → Adding Dev Container Volumes](docs/configuration.md#adding-dev-container-volumes)). -To start one over, print the names from inside the container with -`awk '$5 ~ "^/home/vscode" {print $4}' /proc/self/mountinfo`, stop the container, and run `docker volume rm ` on -the **host**: inside, `docker` talks to Docker-in-Docker's own daemon. +To start one over, find its name inside the container: `awk '$5 ~ "^/home/vscode" {print $4}' /proc/self/mountinfo` +prints each volume's path, `…/volumes//_data`. Then stop the container and run `docker volume rm ` on the +**host**: inside, `docker` talks to Docker-in-Docker's own daemon. ### Volume permission errors diff --git a/docs/configuration.md b/docs/configuration.md index 02ea62c..d28f8cc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -601,7 +601,7 @@ it to `base_setup_config_dirs`, because a fresh volume arrives root-owned. To start a volume over, remove it on the **host** with the container stopped: inside the container, `docker` reaches Docker-in-Docker's daemon, not the host's. `awk '$5 ~ "^/home/vscode" {print $4}' /proc/self/mountinfo`, run in the -container, prints each volume's name; then, on the host, `docker volume rm `. +container, prints each volume's path, `…/volumes//_data`; then, on the host, `docker volume rm `. --- From b333c687947f01b4c06509e35d92889dc05f57d1 Mon Sep 17 00:00:00 2001 From: Ali Date: Wed, 21 Oct 2026 21:51:19 +0000 Subject: [PATCH 4/4] docs(devcontainer): fix the volume reset steps Docker refuses to remove a volume that any container still uses, even a stopped one, so the steps remove the container first. Listing the mounts with docker inspect on the host gives the names directly. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++++--- docs/configuration.md | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 022bbe8..2342cc0 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,10 @@ If settings aren't applying after changes, rebuild without cache: Logins, sessions, history and caches live on `musher-${devcontainerId}-` volumes, so **Rebuild Container** keeps them ([docs/configuration.md → Adding Dev Container Volumes](docs/configuration.md#adding-dev-container-volumes)). -To start one over, find its name inside the container: `awk '$5 ~ "^/home/vscode" {print $4}' /proc/self/mountinfo` -prints each volume's path, `…/volumes//_data`. Then stop the container and run `docker volume rm ` on the -**host**: inside, `docker` talks to Docker-in-Docker's own daemon. +To start one over, work on the **host**, because inside the container `docker` talks to Docker-in-Docker's own daemon. +List the container's volumes with `docker inspect -f '{{range .Mounts}}{{.Name}} {{end}}' ` (`docker ps -a` +finds it), remove the container (`docker rm -f `; a stopped one still holds its volumes), run +`docker volume rm `, then reopen the folder in the container. ### Volume permission errors diff --git a/docs/configuration.md b/docs/configuration.md index d28f8cc..a751522 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -599,9 +599,10 @@ Two rules keep a new volume working. Give it a target whose parent already exist missing directory on the way as root, and a root-owned `~/.local` stops mise and Claude Code from installing. And add it to `base_setup_config_dirs`, because a fresh volume arrives root-owned. -To start a volume over, remove it on the **host** with the container stopped: inside the container, `docker` reaches -Docker-in-Docker's daemon, not the host's. `awk '$5 ~ "^/home/vscode" {print $4}' /proc/self/mountinfo`, run in the -container, prints each volume's path, `…/volumes//_data`; then, on the host, `docker volume rm `. +To start a volume over, work on the **host**, because inside the container `docker` talks to Docker-in-Docker's own +daemon. List the container's volumes with `docker inspect -f '{{range .Mounts}}{{.Name}} {{end}}' ` +(`docker ps -a` finds it), remove the container (`docker rm -f `; a stopped one still holds its volumes), +run `docker volume rm `, then reopen the folder in the container. ---