From ed03ae3ea820896f6f8b443dd768acc36e7fed74 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 28 May 2026 17:16:44 +0100 Subject: [PATCH] refactor(devcontainer): split workspace-deps to updateContentCommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .devcontainer/devcontainer.json | 17 ++++++--- .devcontainer/install-deps.sh | 56 +++++++++++++++++++++++++++++ .devcontainer/post-create.sh | 64 +++++++-------------------------- 3 files changed, 82 insertions(+), 55 deletions(-) create mode 100644 .devcontainer/install-deps.sh 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"