mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Local development needs to work cleanly across linked git worktrees because Paperclip itself leans on worktree-based engineering workflows > - Dev-mode asset routing, Vite watch behavior, and workspace package links are part of that day-to-day control-plane ergonomics > - The current branch had a small but coherent set of worktree/dev-tooling fixes that are independent from both the issue UI changes and the heartbeat runtime changes > - This pull request isolates those environment fixes into a standalone branch that can merge without carrying unrelated product work > - The benefit is a smoother multi-worktree developer loop with fewer stale links and less noisy dev watching ## What Changed - Serve dev public assets before the HTML shell and add a routing test that locks that behavior in. - Ignore UI test files in the Vite dev watch helper so the dev server does less unnecessary work. - Update `ensure-workspace-package-links.ts` to relink stale workspace dependencies whenever a workspace `node_modules` directory exists, instead of only inside linked-worktree detection paths. ## Verification - `pnpm vitest run server/src/__tests__/app-vite-dev-routing.test.ts ui/src/lib/vite-watch.test.ts` - `node cli/node_modules/tsx/dist/cli.mjs scripts/ensure-workspace-package-links.ts` ## Risks - The asset routing change is low risk but sits near app shell behavior, so a regression would show up as broken static assets in dev mode. - The workspace-link repair now runs in more cases, so the main risk is doing unexpected relinks when a checkout has intentionally unusual workspace symlink state. ## Model Used - OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment. Exact backend model deployment ID was not exposed in-session. Tool-assisted editing and shell execution were used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
118 lines
4.1 KiB
JavaScript
118 lines
4.1 KiB
JavaScript
#!/usr/bin/env -S node --import tsx
|
|
import fs from "node:fs/promises";
|
|
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { repoRoot } from "./dev-service-profile.ts";
|
|
|
|
type WorkspaceLinkMismatch = {
|
|
workspaceDir: string;
|
|
packageName: string;
|
|
expectedPath: string;
|
|
actualPath: string | null;
|
|
};
|
|
|
|
function readJsonFile(filePath: string): Record<string, unknown> {
|
|
return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
|
}
|
|
|
|
function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
|
|
const packagePaths = new Map<string, string>();
|
|
const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]);
|
|
|
|
function visit(dirPath: string) {
|
|
const packageJsonPath = path.join(dirPath, "package.json");
|
|
if (existsSync(packageJsonPath)) {
|
|
const packageJson = readJsonFile(packageJsonPath);
|
|
if (typeof packageJson.name === "string" && packageJson.name.length > 0) {
|
|
packagePaths.set(packageJson.name, dirPath);
|
|
}
|
|
}
|
|
|
|
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (ignoredDirNames.has(entry.name)) continue;
|
|
visit(path.join(dirPath, entry.name));
|
|
}
|
|
}
|
|
|
|
visit(path.join(rootDir, "packages"));
|
|
visit(path.join(rootDir, "server"));
|
|
visit(path.join(rootDir, "ui"));
|
|
visit(path.join(rootDir, "cli"));
|
|
|
|
return packagePaths;
|
|
}
|
|
|
|
const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot);
|
|
const workspaceDirs = Array.from(
|
|
new Set(
|
|
Array.from(workspacePackagePaths.values())
|
|
.map((packagePath) => path.relative(repoRoot, packagePath))
|
|
.filter((workspaceDir) => workspaceDir.length > 0),
|
|
),
|
|
).sort();
|
|
|
|
function findWorkspaceLinkMismatches(workspaceDir: string): WorkspaceLinkMismatch[] {
|
|
const nodeModulesDir = path.join(repoRoot, workspaceDir, "node_modules");
|
|
if (!existsSync(nodeModulesDir)) {
|
|
return [];
|
|
}
|
|
|
|
const packageJson = readJsonFile(path.join(repoRoot, workspaceDir, "package.json"));
|
|
const dependencies = {
|
|
...(packageJson.dependencies as Record<string, unknown> | undefined),
|
|
...(packageJson.devDependencies as Record<string, unknown> | undefined),
|
|
};
|
|
const mismatches: WorkspaceLinkMismatch[] = [];
|
|
|
|
for (const [packageName, version] of Object.entries(dependencies)) {
|
|
if (typeof version !== "string" || !version.startsWith("workspace:")) continue;
|
|
|
|
const expectedPath = workspacePackagePaths.get(packageName);
|
|
if (!expectedPath) continue;
|
|
|
|
const linkPath = path.join(repoRoot, workspaceDir, "node_modules", ...packageName.split("/"));
|
|
const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null;
|
|
if (actualPath === path.resolve(expectedPath)) continue;
|
|
|
|
mismatches.push({
|
|
workspaceDir,
|
|
packageName,
|
|
expectedPath: path.resolve(expectedPath),
|
|
actualPath,
|
|
});
|
|
}
|
|
|
|
return mismatches;
|
|
}
|
|
|
|
async function ensureWorkspaceLinksCurrent(workspaceDir: string) {
|
|
const mismatches = findWorkspaceLinkMismatches(workspaceDir);
|
|
if (mismatches.length === 0) return;
|
|
|
|
console.log(`[paperclip] detected stale workspace package links for ${workspaceDir}; relinking dependencies...`);
|
|
for (const mismatch of mismatches) {
|
|
console.log(
|
|
`[paperclip] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}`,
|
|
);
|
|
}
|
|
|
|
for (const mismatch of mismatches) {
|
|
const linkPath = path.join(repoRoot, mismatch.workspaceDir, "node_modules", ...mismatch.packageName.split("/"));
|
|
await fs.mkdir(path.dirname(linkPath), { recursive: true });
|
|
await fs.rm(linkPath, { recursive: true, force: true });
|
|
await fs.symlink(mismatch.expectedPath, linkPath);
|
|
}
|
|
|
|
const remainingMismatches = findWorkspaceLinkMismatches(workspaceDir);
|
|
if (remainingMismatches.length === 0) return;
|
|
|
|
throw new Error(
|
|
`Workspace relink did not repair all ${workspaceDir} package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`,
|
|
);
|
|
}
|
|
|
|
for (const workspaceDir of workspaceDirs) {
|
|
await ensureWorkspaceLinksCurrent(workspaceDir);
|
|
}
|