diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 933182544..5d308a169 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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" } diff --git a/.devcontainer/install-deps.sh b/.devcontainer/install-deps.sh new file mode 100644 index 000000000..86c1cc836 --- /dev/null +++ b/.devcontainer/install-deps.sh @@ -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" diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index c571ce63a..892e1ffbe 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -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"