diff --git a/daemon/api.go b/daemon/api.go index bd38e8b..961edeb 100644 --- a/daemon/api.go +++ b/daemon/api.go @@ -84,6 +84,7 @@ func (s *Server) Routes(webdist string) http.Handler { api.HandleFunc("GET /api/spawn/status", s.handleSpawnStatus) api.HandleFunc("GET /api/repos", s.handleRepoImages) api.HandleFunc("POST /api/repos/", s.handlePrepareRepo) + api.HandleFunc("POST /api/pi-config/resync", s.handlePiConfigResync) // repo paths contain "/" (group/project), which a single {repo} wildcard // segment cannot match — subtree routes with manual path parsing instead. api.HandleFunc("PUT /api/repos/", s.handleSetRepoImage) diff --git a/daemon/pi-config.go b/daemon/pi-config.go new file mode 100644 index 0000000..bf1a87d --- /dev/null +++ b/daemon/pi-config.go @@ -0,0 +1,126 @@ +package main + +// pi-config.go — re-sync the user's pi config (dotfiles, mounted read-only) +// into the image build context and rebuild the worker + ops images, so +// spawned agents pick up config changes without a full deploy. + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "sync" + "time" +) + +const ( + envPiDotfilesMount string = "LVMH_PI_DOTFILES" // default /pi-dotfiles + piSyncScript string = "/app/build/deploy/rsync-pi-agent.sh" + piSyncTimeout time.Duration = 10 * time.Minute +) + +// piSyncState serializes resyncs; at most one runs at a time. +var piSyncState = struct { + mu sync.Mutex + running bool +}{} + +func (s *Server) handlePiConfigResync(w http.ResponseWriter, r *http.Request) { + piSyncState.mu.Lock() + if piSyncState.running { + piSyncState.mu.Unlock() + writeError(w, http.StatusConflict, "pi config sync already running") + return + } + piSyncState.running = true + piSyncState.mu.Unlock() + defer func() { + piSyncState.mu.Lock() + piSyncState.running = false + piSyncState.mu.Unlock() + }() + + src := envOr(envPiDotfilesMount, "/pi-dotfiles") + if _, err := os.Stat(filepath.Join(src, "settings.json")); err != nil { + writeError(w, http.StatusBadRequest, "no pi settings at "+src) + return + } + scriptPath := os.Getenv("LVMH_TEST_SYNC_SCRIPT") + if scriptPath == "" { + scriptPath = piSyncScript + } + if _, err := os.Stat(scriptPath); err != nil { + writeError(w, http.StatusInternalServerError, "sync script missing: "+scriptPath) + return + } + + // The script writes into the repo checkout (docker/pi-agent) and we then + // rebuild both images from that context. Build context is mounted :ro in + // compose — remount rw or run against a writable copy? The build context + // bind is :ro; run the script with DEST inside the shared lvmh-data volume + // instead, then build from a tar we assemble there. + bakeDir := os.Getenv("LVMH_PI_BAKE_DIR") + if bakeDir == "" { + bakeDir = "/data/pi-bake" + } + script := exec.CommandContext(r.Context(), "bash", scriptPath) + script.Env = append(os.Environ(), + "LVMH_PI_AGENT_DIR="+src, + "LVMH_PI_BAKE_DIR="+bakeDir, + ) + out, err := script.CombinedOutput() + if err != nil { + log.Printf("pi-config: sync failed: %v\n%s", err, out) + writeError(w, http.StatusInternalServerError, "sync failed: "+err.Error()) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), piSyncTimeout) + defer cancel() + bakeCtx := filepath.Join(os.TempDir(), "lvmh-pi-bake-ctx") + if bc := os.Getenv("LVMH_TEST_BUILD_CTX"); bc != "" { + bakeCtx = bc + } + if err := os.RemoveAll(bakeCtx); err != nil { + writeError(w, http.StatusInternalServerError, "clear bake context: "+err.Error()) + return + } + buildRepo := "/app/build" + if br := os.Getenv("LVMH_TEST_BUILD_REPO"); br != "" { + buildRepo = br + } + if err := copyTree(buildRepo, bakeCtx); err != nil { + writeError(w, http.StatusInternalServerError, "prep build context: "+err.Error()) + return + } + if err := os.RemoveAll(filepath.Join(bakeCtx, "docker", "pi-agent")); err != nil { + writeError(w, http.StatusInternalServerError, "clear stale pi-agent: "+err.Error()) + return + } + if err := os.Rename(bakeDir, filepath.Join(bakeCtx, "docker", "pi-agent")); err != nil { + writeError(w, http.StatusInternalServerError, "move bake dir: "+err.Error()) + return + } + + if err := s.spawn.buildImage(ctx, bakeCtx, filepath.Join(bakeCtx, "docker", "worker.Dockerfile"), imageRefWorker); err != nil { + writeError(w, http.StatusInternalServerError, "worker rebuild: "+err.Error()) + return + } + if err := s.spawn.buildImage(ctx, bakeCtx, filepath.Join(bakeCtx, "docker", "control.Dockerfile"), opsImageRef); err != nil { + writeError(w, http.StatusInternalServerError, "ops rebuild: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// copyTree duplicates a directory tree for a one-shot build context. +func copyTree(src, dst string) error { + out, err := exec.Command("cp", "-a", src, dst).CombinedOutput() + if err != nil { + return fmt.Errorf("cp %s -> %s: %s", src, dst, out) + } + return nil +} diff --git a/daemon/pi_config_test.go b/daemon/pi_config_test.go new file mode 100644 index 0000000..fc714fa --- /dev/null +++ b/daemon/pi_config_test.go @@ -0,0 +1,308 @@ +package main + +// pi_config_test.go — resync endpoint wiring: auth, conflict-while-running, +// missing dotfiles mount, missing script. + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestAPIPiConfigResync(t *testing.T) { + ts, _ := newTestServer(t) + auth := testToken + + if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", "", ""); code != http.StatusUnauthorized { + t.Fatalf("resync without token = %d, want 401", code) + } + + // script missing in test env (path is /app/build/...): 500 before any work + t.Setenv(envPiDotfilesMount, t.TempDir()) + code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", auth, "") + if code != http.StatusInternalServerError && code != http.StatusBadRequest { + t.Fatalf("resync with no settings/script = %d %s", code, body) + } +} + +func TestPiSyncStateMutex(t *testing.T) { + piSyncState.mu.Lock() + if piSyncState.running { + piSyncState.mu.Unlock() + t.Fatal("running must start false") + } + piSyncState.running = true + piSyncState.mu.Unlock() + // emulate handler guard + piSyncState.mu.Lock() + again := piSyncState.running + piSyncState.mu.Unlock() + if !again { + t.Fatal("guard must observe running") + } + piSyncState.mu.Lock() + piSyncState.running = false + piSyncState.mu.Unlock() +} + +func TestCopyTreeSmoke(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + "/out" + if err := os.WriteFile(src+"/f.txt", []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := copyTree(src, dst); err != nil { + t.Fatalf("copyTree: %v", err) + } + if _, err := os.Stat(dst + "/f.txt"); err != nil { + t.Fatalf("copied file missing: %v", err) + } +} + +func TestPiConfigResyncHappyPathStub(t *testing.T) { + // full resync against a stub script + fake docker + writable dirs + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + ts := f.server(t) + t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String()) + + root := t.TempDir() + src := filepath.Join(root, "dotfiles") + bake := filepath.Join(root, "bake") + repo := filepath.Join(root, "repo") + for _, d := range []string{src, bake, filepath.Join(repo, "docker")} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(src, "settings.json"), []byte(`{"packages":[]}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "docker", "worker.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "docker", "control.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { + t.Fatal(err) + } + script := filepath.Join(root, "sync.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\nmkdir -p \"$LVMH_PI_BAKE_DIR\"\ncp \"$LVMH_PI_AGENT_DIR/settings.json\" \"$LVMH_PI_BAKE_DIR/\"\n"), 0o755); err != nil { + t.Fatal(err) + } + + t.Setenv(envPiDotfilesMount, src) + t.Setenv("LVMH_PI_BAKE_DIR", bake) + daemonToken = testToken + store := openTestStore(t) + hub := NewHub(store) + sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example") + if err != nil { + t.Fatal(err) + } + srv := &Server{store: store, hub: hub, spawn: sp, gitlab: NewGitLab(store, "https://gitlab.example")} + api := httptest.NewServer(srv.Routes("")) + t.Cleanup(api.Close) + + // point the handler at the stub script + repo layout + t.Setenv("LVMH_TEST_SYNC_SCRIPT", script) + t.Setenv("LVMH_TEST_BUILD_CTX", filepath.Join(root, "bake-ctx")) + t.Setenv("LVMH_TEST_BUILD_REPO", repo) + if code, body := apiReq(t, http.MethodPost, api.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusOK { + t.Fatalf("resync = %d %s", code, body) + } + if !f.hasCall(http.MethodPost, "/build") { + t.Fatal("image builds not issued") + } +} + +func TestPiConfigResyncConflictWhileRunning(t *testing.T) { + ts, _ := newTestServer(t) + piSyncState.mu.Lock() + piSyncState.running = true + piSyncState.mu.Unlock() + t.Cleanup(func() { + piSyncState.mu.Lock() + piSyncState.running = false + piSyncState.mu.Unlock() + }) + if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusConflict { + t.Fatalf("resync while running = %d %s, want 409", code, body) + } +} + +func TestCopyTreeError(t *testing.T) { + if err := copyTree(t.TempDir()+"/nope", t.TempDir()+"/dst"); err == nil { + t.Fatal("copyTree of missing src must fail") + } +} + +func TestPiConfigResyncFailurePaths(t *testing.T) { + ts, _ := newTestServer(t) + + // 1: dotfiles mount without settings.json -> 400 + empty := t.TempDir() + t.Setenv(envPiDotfilesMount, empty) + t.Setenv("LVMH_TEST_SYNC_SCRIPT", "/nonexistent-sync.sh") + if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusBadRequest { + t.Fatalf("no settings = %d %s, want 400", code, body) + } + + // 2: settings present, script missing -> 500 + if err := os.WriteFile(filepath.Join(empty, "settings.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusInternalServerError { + t.Fatalf("missing script = %d %s, want 500", code, body) + } + + // 3: script fails -> 500 sync failed + root := t.TempDir() + src := filepath.Join(root, "src") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "settings.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + failScript := filepath.Join(root, "fail.sh") + if err := os.WriteFile(failScript, []byte("#!/bin/sh\nexit 3\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv(envPiDotfilesMount, src) + t.Setenv("LVMH_TEST_SYNC_SCRIPT", failScript) + if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusInternalServerError { + t.Fatalf("failing script = %d %s, want 500", code, body) + } +} + +func TestPiConfigResyncBuildFailure(t *testing.T) { + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + f.failBuildHTTP = true + ts := f.server(t) + t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String()) + + root := t.TempDir() + src := filepath.Join(root, "src") + repo := filepath.Join(root, "repo") + bake := filepath.Join(root, "bake") + for _, d := range []string{src, bake, filepath.Join(repo, "docker")} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(src, "settings.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "docker", "worker.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "docker", "control.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { + t.Fatal(err) + } + script := filepath.Join(root, "ok.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\nmkdir -p \"$LVMH_PI_BAKE_DIR\"\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv(envPiDotfilesMount, src) + t.Setenv("LVMH_TEST_SYNC_SCRIPT", script) + t.Setenv("LVMH_PI_BAKE_DIR", bake) + t.Setenv("LVMH_TEST_BUILD_REPO", repo) + t.Setenv("LVMH_TEST_BUILD_CTX", filepath.Join(root, "ctx")) + + daemonToken = testToken + store := openTestStore(t) + hub := NewHub(store) + sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example") + if err != nil { + t.Fatal(err) + } + srv := &Server{store: store, hub: hub, spawn: sp, gitlab: NewGitLab(store, "https://gitlab.example")} + api := httptest.NewServer(srv.Routes("")) + t.Cleanup(api.Close) + + if code, body := apiReq(t, http.MethodPost, api.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusInternalServerError { + t.Fatalf("build failure = %d %s, want 500", code, body) + } +} + +func mustWrite(t *testing.T, path, content string, mode os.FileMode) string { + t.Helper() + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } + return path +} + +func TestPiConfigResyncContextFailureArms(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "src") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "settings.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + okScript := filepath.Join(root, "ok.sh") + if err := os.WriteFile(okScript, []byte("#!/bin/sh\nmkdir -p \"$LVMH_PI_BAKE_DIR\"\n"), 0o755); err != nil { + t.Fatal(err) + } + // repo missing control.Dockerfile only → copyTree ok, rename ok, worker ok (scratch), ops dockerfile missing → buildImage fails + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + ts := f.server(t) + t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String()) + daemonToken = testToken + store := openTestStore(t) + hub := NewHub(store) + sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example") + if err != nil { + t.Fatal(err) + } + srv := &Server{store: store, hub: hub, spawn: sp, gitlab: NewGitLab(store, "https://gitlab.example")} + api := httptest.NewServer(srv.Routes("")) + t.Cleanup(api.Close) + + repo := filepath.Join(root, "repo") + bake := filepath.Join(root, "bake") + for _, d := range []string{bake, filepath.Join(repo, "docker")} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(repo, "docker", "worker.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "docker", "control.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil { + t.Fatal(err) + } + // rename arm: bake dir absent → os.Rename fails + t.Setenv("LVMH_TEST_SYNC_SCRIPT", mustWrite(t, filepath.Join(root, "noop.sh"), "#!/bin/sh\nexit 0\n", 0o755)) + t.Setenv(envPiDotfilesMount, src) + t.Setenv("LVMH_PI_BAKE_DIR", filepath.Join(root, "no-such-bake")) + t.Setenv("LVMH_TEST_BUILD_REPO", repo) + t.Setenv("LVMH_TEST_BUILD_CTX", filepath.Join(root, "ctx")) + + if code, body := apiReq(t, http.MethodPost, api.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusInternalServerError { + t.Fatalf("ops missing = %d %s, want 500", code, body) + } +} + +func TestPiConfigDefaultScriptPathBranch(t *testing.T) { + ts, _ := newTestServer(t) + root := t.TempDir() + src := filepath.Join(root, "src") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "settings.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(envPiDotfilesMount, src) + t.Setenv("LVMH_TEST_SYNC_SCRIPT", "") // default branch → missing real script path → 500 + if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/pi-config/resync", testToken, ""); code != http.StatusInternalServerError { + t.Fatalf("default script missing = %d %s, want 500", code, body) + } +} diff --git a/deploy/rsync-pi-agent.sh b/deploy/rsync-pi-agent.sh index d946af0..3d2904c 100755 --- a/deploy/rsync-pi-agent.sh +++ b/deploy/rsync-pi-agent.sh @@ -4,7 +4,7 @@ set -euo pipefail SRC="${LVMH_PI_AGENT_DIR:-$HOME/.dotfiles/pi/agent}" -DEST="$(dirname "$0")/../docker/pi-agent" +DEST="${LVMH_PI_BAKE_DIR:-$(dirname "$0")/../docker/pi-agent}" # DEST must exist even without dotfiles: the worker Dockerfile COPYs it # unconditionally (a missing dir would fail the build). @@ -31,20 +31,41 @@ rsync -a --delete \ --exclude '.pi/' \ "$SRC/" "$DEST/" -# Container sessions cannot run git: package postinstalls (e.g. husky) — keep -# registry (npm:) packages only in the baked settings.json. -python3 - "$DEST/settings.json" <<'PY' -import json, sys -p = sys.argv[1] -with open(p) as f: - s = json.load(f) -pkgs = s.get("packages") or [] -kept = [x for x in pkgs if x.startswith("npm:")] -if pkgs != kept: - s["packages"] = kept - with open(p, "w") as f: - json.dump(s, f, indent=2) - f.write("\n") - print(f"rsync-pi-agent: dropped {len(pkgs) - len(kept)} git: packages from baked settings") +# Pre-seed the git: package cache: clone each pinned git: package into +# docker/pi-agent/git/... where pi's package manager installs them, with +# lifecycle scripts ignored (postinstalls like husky crash headless). +# Keeps subagents/todo/async tooling present with no runtime network need. +# Requires network access to github.com at bake time. +python3 - "$DEST" <<'PY' +import json, re, shutil, subprocess, sys +from pathlib import Path + +dest = Path(sys.argv[1]) +settings = json.loads((dest / "settings.json").read_text()) +git_root = dest / "git" +if git_root.exists(): + shutil.rmtree(git_root) + +for pkg in settings.get("packages") or []: + if not pkg.startswith("git:"): + continue + rest = pkg[4:] + m = re.match(r"^(.+)@([0-9a-f]{7,40})$", rest) + repo, ref = (m.group(1), m.group(2)) if m else (rest, None) + target = git_root / repo + target.parent.mkdir(parents=True, exist_ok=True) + url = f"https://{repo}" if not repo.startswith("https://") else repo + print(f"rsync-pi-agent: cloning {repo}@{ref or 'HEAD'}") + subprocess.run(["git", "clone", "--quiet", url, str(target)], check=True) + if ref: + subprocess.run(["git", "checkout", "--quiet", ref], cwd=target, check=True) + shutil.rmtree(target / ".git", ignore_errors=True) + if (target / "package.json").exists(): + npm = "/usr/bin/npm" if Path("/usr/bin/npm").exists() else "npm" + subprocess.run( + [npm, "install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], + cwd=target, check=True, capture_output=True, + ) PY -echo "rsync-pi-agent: synced $(find "$DEST" -type f | wc -l) files from $SRC" + +echo "rsync-pi-agent: synced $(find "$DEST" -type f | wc -l) files from $SRC (git: packages preserved)" diff --git a/docker-compose.yml b/docker-compose.yml index 6a0455b..894a950 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,9 @@ services: - .:/app/build:ro # repo checkout (rsynced by deploy.sh) # daemon rebuilds lvmh-worker from /app/build/docker/worker.Dockerfile - ./web/dist:/app/web-dist:ro # web UI served from repo checkout + - ${LVMH_PI_AGENT_DIR:-/home/raph/.dotfiles/pi/agent}:/pi-dotfiles:ro + # read-only user pi config; POST /api/pi-config/resync bakes it into + # fresh worker+ops images networks: lvmh-net: aliases: [lvmh] diff --git a/docker/control.Dockerfile b/docker/control.Dockerfile index 0b0e258..7e0bb49 100644 --- a/docker/control.Dockerfile +++ b/docker/control.Dockerfile @@ -12,6 +12,11 @@ RUN apt-get update \ RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent +# Same npm shim as the worker image (see worker.Dockerfile). +RUN mv /usr/local/bin/npm /usr/local/bin/npm-real +COPY docker/npm-shim /usr/local/bin/npm +RUN chmod +x /usr/local/bin/npm + # Same pi config as workers (dotfiles, filtered; git: packages stripped). COPY docker/pi-agent/ /root/.pi/agent/ COPY plugin/lvmh-agent.ts /root/.pi/agent/extensions/lvmh-agent.ts diff --git a/docker/npm-shim b/docker/npm-shim new file mode 100644 index 0000000..5ecc0ed --- /dev/null +++ b/docker/npm-shim @@ -0,0 +1,16 @@ +#!/bin/sh +# npm shim for headless pi containers: skip lifecycle scripts during package +# installs. pi installs git: packages by running `npm install --omit=dev` in +# each clone; some upstream packages (e.g. husky in rpiv-mono) have +# postinstall scripts that assume a dev machine and fail/crash headless +# installs. --ignore-scripts makes installs safe; runtime code (pure TS/JS +# extensions) does not need lifecycle scripts. Everything else passes through +# to the real npm transparently. +case "$1" in + install|i) + exec /usr/bin/npm-real "$@" --ignore-scripts + ;; + *) + exec /usr/bin/npm-real "$@" + ;; +esac diff --git a/docker/worker.Dockerfile b/docker/worker.Dockerfile index cfd4a22..d8811e2 100644 --- a/docker/worker.Dockerfile +++ b/docker/worker.Dockerfile @@ -13,6 +13,14 @@ RUN GOBIN=/usr/local/bin go install golang.org/x/tools/gopls@latest || true RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent +# npm shim: pi installs git: packages with `npm install`; postinstall +# scripts (husky etc.) crash headless installs. Route installs through +# --ignore-scripts so ALL dotfiles packages (pi-subagents, todo tooling, +# async agents, every extension) survive the bake. +RUN mv /usr/local/bin/npm /usr/local/bin/npm-real +COPY docker/npm-shim /usr/local/bin/npm +RUN chmod +x /usr/local/bin/npm + # User's pi config from dotfiles (settings, skills, agents, extensions, # APPEND_SYSTEM.md) — synced by deploy.sh from ~/.dotfiles/pi/agent (filtered: # no auth.json/sessions/cache/npm). If docker/pi-agent/ is absent this layer