GitNexus/.devcontainer/post-create.sh
Gergo Magyar ed03ae3ea8 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.
2026-05-28 17:16:44 +01:00

148 lines
5.9 KiB
Bash

#!/usr/bin/env bash
# Devcontainer postCreate driver. Runs once after the container is created
# (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
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 \
/home/node/.claude \
/home/node/.codex \
/home/node/.cursor \
/home/node/.local \
/commandhistory
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).
# The current RW-bind topology overlays sub-paths but not the parent
# symlink itself, so writes to e.g. /home/node/.claude/plugins/known_marketplaces.json
# would resolve through the stale symlink to a read-only host file and
# EROFS. Drop the symlinks; mkdir -p below recreates them as real
# directories in the named volume.
for p in plugins skills agents memory commands; do
[ -L "/home/node/.claude/$p" ] && rm "/home/node/.claude/$p"
done
for p in config.toml memories skills; do
[ -L "/home/node/.codex/$p" ] && rm "/home/node/.codex/$p"
done
mkdir -p /home/node/.claude/plugins
# Plugins, skills, agents, memory, commands, settings.json, $HOME/.claude.json,
# Codex config.toml/memories/skills are all RW bind-mounted directly from
# host in devcontainer.json — they live on host and reads/writes go
# bidirectionally. Nothing for this script to do for those.
#
# What stays per-container (in the named volume) and gets SYNCED from
# host on container-create:
# - .credentials.json (Claude OAuth tokens)
# - .claude/.claude.json (Claude identity: userID, oauthAccount,
# migration tracking — different file from $HOME/.claude.json)
# - auth.json (Codex)
# - cli-config.json (Cursor — conflates auth + settings)
#
# Sync semantics: ALWAYS overwrite from host on container-create, so a
# fresh container starts logged in as host's user (if host had creds).
# Container manages its own refresh from there until next rebuild.
# Logging out in container doesn't affect host. Per-container login is
# the design goal; bind-mounting these would make logout shared.
sync_from_host() {
local src=$1
local dst=$2
local mode=${3:-600}
if [ -f "$src" ]; then
rm -f "$dst"
cp "$src" "$dst"
chmod "$mode" "$dst"
fi
}
sync_from_host \
/host/.claude/.credentials.json /home/node/.claude/.credentials.json
sync_from_host \
/host/.claude/.claude.json /home/node/.claude/.claude.json 644
# Plugin registry path translation. Claude writes absolute OS-native paths
# into known_marketplaces.json (`installLocation`), installed_plugins.json
# (`installPath`), and plugin-catalog-cache.json — `C:\Users\X\.claude\...`
# on Windows hosts, `/Users/X/.claude/...` on macOS — so the host versions
# can't be bind-mounted into the Linux container (Claude would fail with
# `cache-miss` trying to resolve a Windows path inside Linux). Read host's
# registry files, rewrite every absolute path that ends in
# `/.claude/plugins/<rest>` to `/home/node/.claude/plugins/<rest>`, and
# write the result into the container's named volume.
mkdir -p /home/node/.claude/plugins
node <<'NODE'
const fs = require("fs");
const path = require("path");
const HOST = "/host/.claude/plugins";
const CTR = "/home/node/.claude/plugins";
// Match any absolute path (Windows `C:\Users\…\.claude\plugins\<rest>`
// or POSIX `/Users/…/.claude/plugins/<rest>` or
// `/home/…/.claude/plugins/<rest>`) and translate to the container path.
const rewrite = (s) => {
if (typeof s !== "string") return s;
return s.replace(
/^(?:[A-Za-z]:)?[\\/].*?[\\/]\.claude[\\/]plugins[\\/](.*)$/,
(_, rest) => `${CTR}/${rest.replace(/\\/g, "/")}`,
);
};
const rewriteDeep = (obj) => {
if (Array.isArray(obj)) return obj.map(rewriteDeep);
if (obj && typeof obj === "object") {
const out = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = typeof v === "string" ? rewrite(v) : rewriteDeep(v);
}
return out;
}
return obj;
};
for (const name of [
"known_marketplaces.json",
"installed_plugins.json",
"plugin-catalog-cache.json",
]) {
const src = path.join(HOST, name);
const dst = path.join(CTR, name);
if (!fs.existsSync(src) || fs.statSync(src).size === 0) continue;
let data;
try {
data = JSON.parse(fs.readFileSync(src, "utf8"));
} catch {
continue;
}
fs.writeFileSync(dst, JSON.stringify(rewriteDeep(data), null, 2));
}
NODE
# Codex auth. Hosts using OS keyring storage
# (`cli_auth_credentials_store = "keyring"`, default on macOS) have no
# auth.json on disk — the copy silently no-ops and
# `codex login --device-auth` inside the container is the path.
sync_from_host \
/host/.codex/auth.json /home/node/.codex/auth.json
# Cursor CLI — cli-config.json conflates auth + settings. Cursor has known
# upstream issues authenticating inside Docker even with correctly-copied
# config; if `cursor-agent` reports auth errors after copy, re-run
# `cursor-agent login` inside the container.
sync_from_host \
/host/.cursor/cli-config.json /home/node/.cursor/cli-config.json
echo "[post-create] done"