UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

125 lines 5.68 kB
import { join, relative } from 'node:path'; import { nodeFs } from '../node-fs.js'; import { excludeFromGit } from '../git-exclude.js'; import { nodeGitRunner } from '../project.js'; import { FRAMEWORK_DIR } from './agent-store.js'; /** * Give a fresh worktree a dependency tree (#736). `node_modules` is gitignored, so * `git worktree add` hands the agent an empty one and every command in it fails. * * Three ways to fix that: copy the tree (correct, but gigabytes per agent), install * into each worktree (correct, but real latency on every start), or symlink the * parent checkout's trees in (instant, no extra disk, one store shared by N runs). * We symlink. The one case it is wrong for is an agent that changes the lockfile — * that needs its own install regardless, and the agent runs the install itself. * * Directory symlinks are what make this work in a pnpm workspace: linking * `packages/foo/node_modules` as a whole means the `.pnpm` symlinks inside it * still resolve against their real location in the parent checkout. */ /** The dependency directory mirrored into a worktree. */ const NODE_MODULES = 'node_modules'; /** How deep below the repo root a `node_modules` is looked for (root = 0). Covers a * pnpm/npm workspace's `packages/<pkg>/node_modules` without walking the world. */ const MAX_DEPTH = 2; /** Directory names never descended into while scanning for dependency trees. */ const SKIP = new Set([NODE_MODULES, '.git', FRAMEWORK_DIR, 'dist', 'build', 'coverage']); /** The `node:fs/promises` implementation of {@link LinkFs}. */ export function nodeLinkFs() { return { async readdir(path) { const { readdir } = await import('node:fs/promises'); return readdir(path).catch(() => []); }, async isDirectory(path) { const { stat } = await import('node:fs/promises'); return stat(path).then(s => s.isDirectory(), () => false); }, async entryExists(path) { const { lstat } = await import('node:fs/promises'); return lstat(path).then(() => true, () => false); }, async mkdir(path) { const { mkdir } = await import('node:fs/promises'); await mkdir(path, { recursive: true }); }, async symlinkDir(target, path) { const { symlink } = await import('node:fs/promises'); // 'junction' is the only directory-link type Windows grants without elevation; // it is ignored on POSIX. await symlink(target, path, process.platform === 'win32' ? 'junction' : 'dir'); }, }; } /** * Every `node_modules` directory in `repo`, as repo-relative paths, down to * {@link MAX_DEPTH}. Sorted, so the linking order (and any log of it) is stable. */ export async function findDependencyDirs(repo, fs = nodeLinkFs()) { const found = []; const walk = async (dir, depth) => { if (await fs.isDirectory(join(dir, NODE_MODULES))) found.push(relative(repo, join(dir, NODE_MODULES))); if (depth >= MAX_DEPTH) return; for (const name of await fs.readdir(dir)) { if (name.startsWith('.') || SKIP.has(name)) continue; const child = join(dir, name); if (await fs.isDirectory(child)) await walk(child, depth + 1); } }; await walk(repo, 0); return found.sort(); } /** * Symlink `repo`'s dependency trees into `worktree` at the same relative paths. * Returns the paths linked. Best-effort throughout: a worktree with no deps is a * worse run, not a failed one, so a link that cannot be made is skipped rather * than thrown. An existing entry is left alone (the agent may have installed already). */ export async function linkDependencies(repo, worktree, fs = nodeLinkFs()) { const linked = []; for (const rel of await findDependencyDirs(repo, fs)) { const target = join(repo, rel); const link = join(worktree, rel); try { if (await fs.entryExists(link)) continue; const parent = join(link, '..'); if (!(await fs.isDirectory(parent))) await fs.mkdir(parent); await fs.symlinkDir(target, link); linked.push(rel); } catch { // Raced, or a filesystem that refuses the link: the agent still starts. } } return linked; } /** The exclude rule that covers a linked tree. Deliberately slash-free: see below. */ const EXCLUDE_RULE = NODE_MODULES; /** * Make git ignore the dependency links (#738). A repo's `.gitignore` says `node_modules/`, and * a trailing slash matches a *directory* only — the links {@link linkDependencies} makes are * symlinks, so they are not covered, and they show up as untracked in every agent's worktree. * That is not cosmetic: the agent runs `git add -A`, so the agent would commit dangling absolute * symlinks into its branch and onto the PR. * * A slash-free `node_modules` in the repo-level exclude ({@link excludeFromGit}) covers the * symlink form in every worktree, and the main checkout is unaffected in practice: its * `node_modules` is a real directory, already ignored by the same name. * * Best-effort: an agent whose links are merely untracked is still an agent. */ export async function excludeDependencyLinks(repo, fs = nodeFs(), agent = nodeGitRunner()) { try { await excludeFromGit(repo, EXCLUDE_RULE, fs, agent); } catch { // Not a repo, or an unwritable git dir: the links just stay visible to git status. } } //# sourceMappingURL=worktree-deps.js.map