Files
PaperClipAI/scripts/dev-runner-install.ts
T
TonioandClaude Fable 5 9661a3050e feat(dev): auto-install dependencies when the lockfile outruns the last install
Pulling master with new dependencies left the dev server failing on
unresolvable imports (motion/react, @sentry/browser) until someone manually
ran pnpm install and cleared Vite's optimizer cache. The dev runner now
detects a pnpm-lock.yaml newer than node_modules/.modules.yaml and runs
pnpm install itself, then drops the stale Vite caches that otherwise keep
serving 504 "Outdated Optimize Dep" responses.

- boot (dev and watch modes): install before the migration preflight
- dev mode: pnpm-lock.yaml joins the watched files; auto-restart installs
  first, and skips the restart instead of exiting if the install fails so
  the running server is never orphaned
- watch mode: a lockfile watcher installs on change and nudges the server
  entry's mtime so tsx watch respawns onto the fresh node_modules
- the manifest mtime is bumped after each install so a touched-but-unchanged
  lockfile cannot re-trigger installs forever

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 11:20:01 -07:00

73 lines
2.4 KiB
TypeScript

import { existsSync, rmSync, statSync, utimesSync } from "node:fs";
import path from "node:path";
export interface StaleInstallCheck {
stale: boolean;
reason: string | null;
}
function mtimeMs(filePath: string): number | null {
try {
return statSync(filePath).mtimeMs;
} catch {
return null;
}
}
function modulesManifestPath(repoRoot: string): string {
return path.join(repoRoot, "node_modules", ".modules.yaml");
}
export function lockfilePath(repoRoot: string): string {
return path.join(repoRoot, "pnpm-lock.yaml");
}
// pnpm rewrites node_modules/.modules.yaml at the end of every install, so a
// lockfile newer than that manifest means the checkout picked up dependency
// changes (e.g. a pull) that were never installed.
export function detectStaleInstall(repoRoot: string): StaleInstallCheck {
const lockfileMtime = mtimeMs(lockfilePath(repoRoot));
if (lockfileMtime === null) {
return { stale: false, reason: null };
}
const manifestMtime = mtimeMs(modulesManifestPath(repoRoot));
if (manifestMtime === null) {
return { stale: true, reason: "dependencies have never been installed in this checkout" };
}
if (lockfileMtime > manifestMtime) {
return { stale: true, reason: "pnpm-lock.yaml changed after the last pnpm install" };
}
return { stale: false, reason: null };
}
// The manifest mtime is the staleness marker, but a no-op install may skip
// rewriting it; bump it explicitly so a touched-but-unchanged lockfile cannot
// re-trigger installs forever.
export function markInstallFresh(repoRoot: string, now = new Date()): void {
try {
utimesSync(modulesManifestPath(repoRoot), now, now);
} catch {
// Manifest missing (unusual pnpm setup): the next boot re-runs a no-op install.
}
}
export function resolveViteCachePaths(repoRoot: string): string[] {
return [
path.join(repoRoot, "ui", "node_modules", ".vite"),
path.join(repoRoot, "node_modules", ".vite"),
];
}
// Vite's optimizer keeps serving pre-bundled deps hashed against the old
// lockfile ("504 Outdated Optimize Dep") — drop the caches after an install so
// the next server boot re-bundles.
export function clearViteCaches(repoRoot: string): string[] {
const removed: string[] = [];
for (const cachePath of resolveViteCachePaths(repoRoot)) {
if (!existsSync(cachePath)) continue;
rmSync(cachePath, { recursive: true, force: true });
removed.push(cachePath);
}
return removed;
}