Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down Expand Up @@ -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",
Expand All @@ -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": ""
},
Expand All @@ -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": {
Expand All @@ -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",
Expand Down
49 changes: 48 additions & 1 deletion .devcontainer/scripts/lib/base-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
171 changes: 171 additions & 0 deletions .devcontainer/scripts/lib/github-auth.sh
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions .devcontainer/scripts/post-attach.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
1 change: 1 addition & 0 deletions .devcontainer/scripts/post-create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/validate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jobs:
- '.devcontainer/.env.example'
scripts:
- '.devcontainer/scripts/**'
- '.devcontainer/config/shell/**'
compose:
- '.devcontainer/compose.yaml'
- '.devcontainer/compose/**'
Expand All @@ -44,7 +45,7 @@ jobs:
- name: Run ShellCheck
uses: ludeeus/action-shellcheck@2.0.0
with:
scandir: .devcontainer/scripts
scandir: .devcontainer
severity: warning

compose:
Expand Down
Loading