refactor(devcontainer): split workspace-deps to updateContentCommand

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.
This commit is contained in:
Gergo Magyar 2026-05-28 17:16:44 +01:00
parent 6bcc2fe207
commit ed03ae3ea8
3 changed files with 82 additions and 55 deletions

View file

@ -208,9 +208,18 @@
}
},
// Driver script with labeled steps lives at .devcontainer/post-create.sh
// so each step's success/failure is visible in the log without parsing
// an &&-chain. Run via `bash` explicitly so the script doesn't depend
// on its executable bit surviving the workspace bind mount.
// 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"
}

View file

@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Devcontainer updateContentCommand — runs on container-create AND whenever
# workspace content changes (lockfile updates etc., per the Dev Container
# spec). Handles workspace dependency installation only; AI CLI state sync
# lives in post-create.sh which runs once after this.
#
# Why split out: `updateContentCommand` re-runs on content updates, while
# `postCreateCommand` runs only on container-create. Putting `npm install`
# here means a rebuild after pulling new dependencies refreshes them
# without re-running the AI CLI credential/path-translation work each time.
set -euo pipefail
cd /workspace
echo "[install-deps] 1/4: chown workspace node_modules + npm cache mount points"
# Named volumes (workspace/*/node_modules, ~/.npm) created at first mount
# inherit ownership from the image's pre-realignment UID. After
# `updateRemoteUserUID: true` shifts the `node` user, the volumes end up
# owned by the stale UID — npm install can't write. Re-chown
# post-realignment; idempotent on subsequent runs.
sudo chown -R node:node \
/workspace/node_modules \
/workspace/gitnexus/node_modules \
/workspace/gitnexus-web/node_modules \
/workspace/gitnexus-shared/node_modules \
/home/node/.npm
echo "[install-deps] 2/4: clear stale .husky/_ runtime cache"
# Docker Desktop's Windows bind-mount permission translation refuses to
# let the new container's `node` user overwrite a `.husky/_/h` left by a
# prior container with a different effective UID. `.husky/_` is
# gitignored runtime cache; husky regenerates it during the root
# `npm install`. Upstream husky has no fix for this UID-clash case.
rm -rf .husky/_
echo "[install-deps] 3/4: npm install at root, then gitnexus-shared (build required)"
# Install order: root first (lint-staged + husky + prettier), then
# gitnexus-shared (build needed BEFORE gitnexus-web or gitnexus install
# because both consume it via `file:../gitnexus-shared`).
npm install
cd /workspace/gitnexus-shared
npm install
npm run build
echo "[install-deps] 4/4: npm install gitnexus-web, then gitnexus"
# gitnexus-web before gitnexus: gitnexus's `prepare` script runs
# scripts/build.js, which compiles gitnexus-web when the directory is
# present. In the devcontainer the full workspace is bind-mounted, so
# gitnexus-web/ is present at gitnexus install time (not the case in the
# production Dockerfiles, which COPY selectively).
cd /workspace/gitnexus-web
npm install
cd /workspace/gitnexus
npm install
echo "[install-deps] done"

View file

@ -1,35 +1,27 @@
#!/usr/bin/env bash
# Devcontainer postCreate driver. Runs once after the container is created
# (per devcontainer.json `postCreateCommand`). Each labeled step is its own
# command, so a failure log line names the step that failed instead of an
# opaque `&&`-chain index.
# (per devcontainer.json `postCreateCommand`). Workspace dependency
# installation lives in install-deps.sh (`updateContentCommand`) which
# runs BEFORE this script — see the spec lifecycle. This script only
# handles AI CLI credential + identity sync from the host.
set -euo pipefail
cd /workspace
echo "[post-create] 1/7: chown workspace node_modules + named-volume mount points"
# `updateRemoteUserUID: true` realigns the `node` user's UID/GID at runtime
# on Linux hosts (no-op on Mac/Windows where Docker Desktop translates UIDs
# via its VM layer). The Dockerfile chown at build time targets the original
# UID; empty named volumes created at first mount inherit that ownership and
# end up owned by the stale UID after realignment. Re-chown here, post-
# realignment, so npm install can write to ~/.npm, the AI CLIs can write
# to their config dirs, and zsh history writes to /commandhistory succeed
# on hosts with non-1000 UIDs.
echo "[post-create] 1/2: chown AI CLI named-volume mount points"
# Named volumes (~/.claude, ~/.codex, ~/.cursor, /commandhistory,
# ~/.local) inherit ownership from the image's pre-realignment UID at
# first mount. After `updateRemoteUserUID: true` shifts the `node` user,
# these end up owned by the stale UID — writes inside the volume fail.
# install-deps.sh handles the workspace-side chown; this script handles
# the AI CLI side so each lifecycle hook owns its own concern.
sudo chown -R node:node \
/workspace/node_modules \
/workspace/gitnexus/node_modules \
/workspace/gitnexus-web/node_modules \
/workspace/gitnexus-shared/node_modules \
/home/node/.npm \
/home/node/.local \
/home/node/.claude \
/home/node/.codex \
/home/node/.cursor \
/home/node/.local \
/commandhistory
echo "[post-create] 2/7: sync AI CLI credentials + identity from host"
echo "[post-create] 2/2: sync AI CLI credentials + identity from host"
# Defensive cleanup for users upgrading from an earlier devcontainer
# design (Option B) where these paths were symlinks into the read-only
# host stage (e.g. /home/node/.claude/plugins -> /host/.claude/plugins).
@ -153,34 +145,4 @@ sync_from_host \
sync_from_host \
/host/.cursor/cli-config.json /home/node/.cursor/cli-config.json
echo "[post-create] 3/7: clear stale .husky/_ runtime cache"
# Docker Desktop's Windows bind-mount permission translation refuses to let
# the new container's `node` user overwrite a `.husky/_/h` left by a prior
# container with a different effective UID. `.husky/_` is gitignored runtime
# cache; husky regenerates it during npm install.
rm -rf .husky/_
echo "[post-create] 4/7: npm install at root (husky + lint-staged + prettier + eslint)"
npm install
echo "[post-create] 5/7: npm install + build gitnexus-shared"
# gitnexus and gitnexus-web both consume gitnexus-shared via
# file:../gitnexus-shared, so it must be built before either installs.
cd /workspace/gitnexus-shared
npm install
npm run build
echo "[post-create] 6/7: npm install gitnexus-web"
# Must install BEFORE gitnexus: gitnexus's `prepare` script runs
# scripts/build.js, which compiles gitnexus-web when the directory is
# present. In the devcontainer the full workspace is bind-mounted, so
# gitnexus-web/ is present at gitnexus install time even though it
# wouldn't be in the production Dockerfiles (which COPY selectively).
cd /workspace/gitnexus-web
npm install
echo "[post-create] 7/7: npm install gitnexus (triggers prepare -> scripts/build.js)"
cd /workspace/gitnexus
npm install
echo "[post-create] done"