mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-30 01:51:20 +00:00
post-create.sh was doing two unrelated jobs: workspace dependency install (four `npm install` runs in topological order) and AI CLI credential sync. They have different lifecycle needs — deps should re-run when lockfiles change, AI sync should run once per container — but both were gated on container-create. Per Dev Container spec lifecycle, `updateContentCommand` is the right hook for workspace deps: runs at container-create AND on content changes (lockfile updates). `postCreateCommand` is right for AI CLI sync: container-create only. Move steps 3-7 (husky cleanup + four `npm install` runs) into install-deps.sh wired as `updateContentCommand`. Split the chown step too — install-deps owns workspace-side dirs (node_modules volumes, ~/.npm), post-create owns AI-side dirs (~/.claude, ~/.codex, ~/.cursor, /commandhistory, ~/.local). Each script now has one concern. post-create.sh drops from ~187 lines to 148; install-deps.sh is 56 lines new. Faster rebuilds when nothing about deps changed (the credential sync + path translation work still runs every container-create, but the npm install dance no longer does). Research backing (no other simplification applies): - Anthropic's reference devcontainer uses pure named volumes; no host-state inheritance pattern is published. - Path translation has no upstream fix (issues #21916, #10379 closed without resolution). Our Node rewrite is the workaround. - pnpm workspaces (`pnpm -r install`) would replace the four installs with one command, but that's a real refactor (touches gitnexus/scripts/build.js + 4 package.json files); deferred. - `HUSKY=0` in containerEnv would drop the `rm -rf .husky/_` hack, but would also stop pre-commit hooks from firing inside the container; deferred.
225 lines
12 KiB
JSON
225 lines
12 KiB
JSON
// Devcontainer for GitNexus. Pre-installs Claude Code, OpenAI Codex CLI,
|
|
// and Cursor CLI alongside the Node.js native build chain. Cross-platform
|
|
// across macOS, Linux, and Windows-via-WSL2 (Windows-native is unsupported
|
|
// — see .devcontainer/README.md § Windows 11 setup). Opens via the VS Code
|
|
// Dev Containers extension.
|
|
//
|
|
// First-time setup, auth flows, and troubleshooting: .devcontainer/README.md.
|
|
{
|
|
"name": "GitNexus AI CLI Devcontainer",
|
|
|
|
"build": {
|
|
"dockerfile": "Dockerfile",
|
|
"context": ".",
|
|
"args": {
|
|
"CLAUDE_CODE_VERSION": "2.1.153",
|
|
"CODEX_VERSION": "0.134.0",
|
|
"CURSOR_VERSION": "latest",
|
|
"TZ": "${localEnv:TZ:UTC}"
|
|
}
|
|
},
|
|
|
|
// Runs on the HOST (not the container) before container create. The
|
|
// single-string form is the spec-canonical shape for cross-platform
|
|
// command-property dispatch; the object form is "named parallel tasks",
|
|
// not OS dispatch. We use Node so the same command works in cmd.exe on
|
|
// Windows and bash/zsh on Linux/macOS/WSL — the script reads `os.homedir()`
|
|
// (which respects $HOME on POSIX and %USERPROFILE% on Windows) and creates
|
|
// the host-side bind mount sources idempotently. Host prerequisite: Node
|
|
// on PATH (the only host-side toolchain dependency beyond Docker Desktop
|
|
// and the VS Code Dev Containers extension).
|
|
"initializeCommand": "node .devcontainer/ensure-host-config-dirs.cjs",
|
|
|
|
"features": {
|
|
"ghcr.io/devcontainers/features/github-cli:1": {}
|
|
},
|
|
|
|
"remoteUser": "node",
|
|
"updateRemoteUserUID": true,
|
|
|
|
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
|
|
"workspaceFolder": "/workspace",
|
|
|
|
// Mount topology, by group:
|
|
//
|
|
// 1. AI CLI host config — read-only bind at /host/.<cli>. `post-create.sh`
|
|
// selectively symlinks shareable subdirs (plugins/skills/agents/memory/
|
|
// commands) into the container's named-volume config dirs, and copies
|
|
// credentials + `.claude.json` (the onboarding-state file Claude Code
|
|
// looks for at $HOME, *outside* ~/.claude/) on first run. Read-only
|
|
// eliminates write-through from container to host — a compromised npm
|
|
// dep can't drop an `agents/evil.md` into your host ~/.claude/agents/
|
|
// that the next host Claude session would auto-load. The host stays
|
|
// the source of truth for shared content; install new plugins on the
|
|
// host and rebuild the container to pick them up.
|
|
//
|
|
// 2. AI CLI container config — per-devcontainer named volumes. These are
|
|
// what CLAUDE_CONFIG_DIR / CODEX_HOME / ~/.cursor actually point at.
|
|
// Credentials live here with proper Linux perms. Container-managed
|
|
// state (sessions, history, caches, projects/, settings.json) stays
|
|
// isolated per devcontainer, so two GitNexus checkouts on the same
|
|
// host don't corrupt each other's chat history or IDE lock files.
|
|
//
|
|
// 3. Other host config — read-write or read-only bind mounts for things
|
|
// that don't have the perm-flattening / onboarding-state complexity
|
|
// Claude Code does. `~/.gitconfig` is handled separately by VS Code's
|
|
// auto-copy mechanism (not mounted).
|
|
//
|
|
// 4. Per-instance state — `${devcontainerId}`-scoped: shell history,
|
|
// npm cache. Survive rebuilds, isolated between sibling instances.
|
|
//
|
|
// 5. Per-workspace-name AND per-instance state — workspace `node_modules`
|
|
// volumes use both `${localWorkspaceFolderBasename}` (debuggable in
|
|
// `docker volume ls`) and `${devcontainerId}` (collision-free between
|
|
// sibling instances of the same repo). Keeps tree-sitter native
|
|
// binaries and onnxruntime off the workspace bind mount (Win/Mac perf).
|
|
"mounts": [
|
|
// Per-container named volumes for credentials + identity state. Each
|
|
// CLI's actual `~/.<cli>` config dir lives in a volume so credentials
|
|
// (with proper Linux 600 perms) and per-container session state stay
|
|
// isolated from the host. Login in container vs login on host =
|
|
// independent. Bind mounts BELOW these volumes override the volume's
|
|
// contents at the bound sub-paths — Docker mount precedence: more
|
|
// specific path wins.
|
|
"source=claude-config-${devcontainerId},target=/home/node/.claude,type=volume",
|
|
"source=codex-config-${devcontainerId},target=/home/node/.codex,type=volume",
|
|
"source=cursor-config-${devcontainerId},target=/home/node/.cursor,type=volume",
|
|
|
|
// Read-only host stage for post-create.sh to copy credentials +
|
|
// identity from on container-create. Kept read-only so a container
|
|
// process can't write back to host CLI state files (the write-through
|
|
// attack vector). Only the credential/identity files are READ here;
|
|
// shareable content is bind-mounted directly RW below, not staged.
|
|
"source=${localEnv:HOME}/.claude,target=/host/.claude,type=bind,readonly",
|
|
"source=${localEnv:HOME}/.codex,target=/host/.codex,type=bind,readonly",
|
|
"source=${localEnv:HOME}/.cursor,target=/host/.cursor,type=bind,readonly",
|
|
|
|
// Direct RW bind mounts for shareable subdirs + files. These OVERLAY
|
|
// the named volume at their target paths, so reads/writes from inside
|
|
// the container go straight to host. `/plugin marketplace add` in
|
|
// container = installed on host. New skill on host = visible in
|
|
// container next read. Trade-off accepted: a compromised npm dep can
|
|
// write into host plugin/skill/agent/memory/command dirs.
|
|
// Plugins are split: the SOURCE (marketplaces/ git clones + cache/
|
|
// extracted plugin files) is path-independent and bind-mounted
|
|
// bidirectionally. The REGISTRY (known_marketplaces.json,
|
|
// installed_plugins.json, plugin-catalog-cache.json) carries absolute
|
|
// OS-specific paths (`C:\Users\X\.claude\plugins\...` on Windows,
|
|
// `/Users/x/...` on macOS) so it CANNOT be shared as-is — stays in
|
|
// the named volume, generated by post-create.sh from host's versions
|
|
// with paths translated to the container's Linux paths.
|
|
"source=${localEnv:HOME}/.claude/plugins/marketplaces,target=/home/node/.claude/plugins/marketplaces,type=bind",
|
|
"source=${localEnv:HOME}/.claude/plugins/cache,target=/home/node/.claude/plugins/cache,type=bind",
|
|
"source=${localEnv:HOME}/.claude/skills,target=/home/node/.claude/skills,type=bind",
|
|
"source=${localEnv:HOME}/.claude/agents,target=/home/node/.claude/agents,type=bind",
|
|
"source=${localEnv:HOME}/.claude/memory,target=/home/node/.claude/memory,type=bind",
|
|
"source=${localEnv:HOME}/.claude/commands,target=/home/node/.claude/commands,type=bind",
|
|
"source=${localEnv:HOME}/.claude/settings.json,target=/home/node/.claude/settings.json,type=bind",
|
|
"source=${localEnv:HOME}/.claude.json,target=/home/node/.claude.json,type=bind",
|
|
"source=${localEnv:HOME}/.codex/config.toml,target=/home/node/.codex/config.toml,type=bind",
|
|
"source=${localEnv:HOME}/.codex/memories,target=/home/node/.codex/memories,type=bind",
|
|
"source=${localEnv:HOME}/.codex/skills,target=/home/node/.codex/skills,type=bind",
|
|
"source=${localEnv:HOME}/.config/git,target=/home/node/.config/git,type=bind,readonly",
|
|
"source=${localEnv:HOME}/.ssh,target=/home/node/.ssh,type=bind,readonly",
|
|
"source=${localEnv:HOME}/.config/gh,target=/home/node/.config/gh,type=bind",
|
|
"source=${localEnv:HOME}/.docker,target=/home/node/.docker,type=bind",
|
|
"source=${localEnv:HOME}/.aws,target=/home/node/.aws,type=bind,readonly",
|
|
"source=${localEnv:HOME}/.azure,target=/home/node/.azure,type=bind,readonly",
|
|
"source=commandhistory-${devcontainerId},target=/commandhistory,type=volume",
|
|
"source=npm-cache-${devcontainerId},target=/home/node/.npm,type=volume",
|
|
"source=${localWorkspaceFolderBasename}-root-node-modules-${devcontainerId},target=/workspace/node_modules,type=volume",
|
|
"source=${localWorkspaceFolderBasename}-gitnexus-node-modules-${devcontainerId},target=/workspace/gitnexus/node_modules,type=volume",
|
|
"source=${localWorkspaceFolderBasename}-gitnexus-web-node-modules-${devcontainerId},target=/workspace/gitnexus-web/node_modules,type=volume",
|
|
"source=${localWorkspaceFolderBasename}-gitnexus-shared-node-modules-${devcontainerId},target=/workspace/gitnexus-shared/node_modules,type=volume"
|
|
],
|
|
|
|
// Interactive login is the default auth path for all three CLIs;
|
|
// credentials persist in the host-bind-mounted directories (~/.claude,
|
|
// ~/.codex, ~/.cursor) declared in the mounts block above.
|
|
// API keys (ANTHROPIC_API_KEY / OPENAI_API_KEY / CURSOR_API_KEY) are NOT
|
|
// injected via containerEnv — `${localEnv:VAR}` resolves an unset host var
|
|
// to an empty string, and Cursor in particular treats `CURSOR_API_KEY=""`
|
|
// as "use this empty key" rather than "fall back to stored login", which
|
|
// would silently break `cursor-agent login`. Users who need API key auth
|
|
// should `export` the var in their container shell or carry it via their
|
|
// VS Code dotfiles repo (see .devcontainer/README.md).
|
|
// CLAUDE_CONFIG_DIR is intentionally NOT set. The Claude default is
|
|
// `$HOME/.claude` (= `/home/node/.claude`), which is exactly where the
|
|
// claude-config named volume mounts — setting the env var explicitly
|
|
// changes which file Claude reads `hasCompletedOnboarding` from: with
|
|
// the var set, Claude reads `$CLAUDE_CONFIG_DIR/.claude.json` (the
|
|
// small identity file); without it, Claude reads `$HOME/.claude.json`
|
|
// (the big onboarding-state file that actually carries
|
|
// `hasCompletedOnboarding`, MCP user-scope config, and per-project
|
|
// trust). Leaving the var unset matches host behavior and lets
|
|
// post-create.sh's host-sync of `$HOME/.claude.json` skip the wizard
|
|
// on every container-create.
|
|
//
|
|
// CODEX_HOME is kept (even though it matches the Codex default) as a
|
|
// canary: if we ever move the Codex named volume target, the env var
|
|
// makes the dependency explicit instead of silently following the
|
|
// default.
|
|
"containerEnv": {
|
|
"CODEX_HOME": "/home/node/.codex",
|
|
"DISABLE_AUTOUPDATER": "1",
|
|
"HISTFILE": "/commandhistory/.zsh_history"
|
|
},
|
|
|
|
"customizations": {
|
|
"vscode": {
|
|
"extensions": [
|
|
"anthropic.claude-code",
|
|
"dbaeumer.vscode-eslint",
|
|
"esbenp.prettier-vscode",
|
|
"eamodio.gitlens"
|
|
],
|
|
"settings": {
|
|
"editor.formatOnSave": true,
|
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
"editor.codeActionsOnSave": {
|
|
"source.fixAll.eslint": "explicit"
|
|
},
|
|
"files.eol": "\n",
|
|
"terminal.integrated.defaultProfile.linux": "zsh",
|
|
"terminal.integrated.profiles.linux": {
|
|
"bash": { "path": "bash", "icon": "terminal-bash" },
|
|
"zsh": { "path": "zsh" }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
// 4747 (gitnexus serve) must not be remapped: gitnexus-web hardcodes
|
|
// http://localhost:4747 as the default backend URL.
|
|
"forwardPorts": [5173, 4747, 4173],
|
|
"portsAttributes": {
|
|
"5173": {
|
|
"label": "Vite dev (gitnexus-web)",
|
|
"onAutoForward": "notify"
|
|
},
|
|
"4747": {
|
|
"label": "gitnexus serve HTTP API",
|
|
"onAutoForward": "notify",
|
|
"requireLocalPort": true
|
|
},
|
|
"4173": {
|
|
"label": "Static web (Vite preview)",
|
|
"onAutoForward": "silent"
|
|
}
|
|
},
|
|
|
|
// Lifecycle split (per Dev Container spec):
|
|
// - `updateContentCommand` runs on container-create AND whenever
|
|
// workspace content changes (e.g. lockfile updates). It owns
|
|
// workspace dependency installation — re-installing on every
|
|
// container-create wastes time when nothing changed, but it must
|
|
// re-run when deps shift.
|
|
// - `postCreateCommand` runs once on container-create. It owns AI CLI
|
|
// credential + identity sync from the host — work that should
|
|
// happen exactly once per container instance, not on every content
|
|
// update.
|
|
// Run both via explicit `bash` so they don't depend on the script's
|
|
// executable bit surviving the workspace bind mount.
|
|
"updateContentCommand": "bash .devcontainer/install-deps.sh",
|
|
"postCreateCommand": "bash .devcontainer/post-create.sh"
|
|
}
|