From 8e885773713265af2d3f6eb70a6c95a71c52b313 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 9 Apr 2026 06:12:29 -0500 Subject: [PATCH] chore(dev): preflight workspace links and simplify worktree helpers --- cli/src/__tests__/worktree.test.ts | 5 +- cli/src/commands/worktree.ts | 183 +--------------------- package.json | 9 +- scripts/ensure-workspace-package-links.ts | 9 +- scripts/kill-agent-browsers.sh | 65 ++++++++ 5 files changed, 83 insertions(+), 188 deletions(-) create mode 100755 scripts/kill-agent-browsers.sh diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 2089c032..3245da05 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -573,6 +573,7 @@ describe("worktree helpers", () => { try { fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true }); fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true }); + fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { recursive: true }); fs.mkdirSync(repoRoot, { recursive: true }); fs.mkdirSync(sourceRoot, { recursive: true }); @@ -590,6 +591,7 @@ describe("worktree helpers", () => { }); fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8"); fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8"); + fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8"); fs.writeFileSync( currentPaths.envPath, [ @@ -606,7 +608,6 @@ describe("worktree helpers", () => { await worktreeReseedCommand({ fromConfig: sourcePaths.configPath, - seed: false, yes: true, }); @@ -628,7 +629,7 @@ describe("worktree helpers", () => { } fs.rmSync(tempRoot, { recursive: true, force: true }); } - }); + }, 20_000); it("restores the current worktree config and instance data if reseed fails", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-")); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 23eafe11..963ae5e8 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -98,22 +98,6 @@ type WorktreeMakeOptions = WorktreeInitOptions & { startPoint?: string; }; -type WorktreeReseedOptions = { - fromConfig?: string; - fromDataDir?: string; - fromInstance?: string; - home?: string; - seedMode?: string; - yes?: boolean; - seed?: boolean; -}; - -type WorktreeReseedBackup = { - tempRoot: string; - repoConfigDirBackup: string | null; - instanceRootBackup: string | null; -}; - type WorktreeEnvOptions = { config?: string; json?: boolean; @@ -964,6 +948,8 @@ async function seedWorktreeDatabase(input: { input.sourceConfig.database.embeddedPostgresDataDir, input.sourceConfig.database.embeddedPostgresPort, ); + const sourceAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${sourceHandle.port}/postgres`; + await ensurePostgresDatabase(sourceAdminConnectionString, "paperclip"); } const sourceConnectionString = resolveSourceConnectionString( input.sourceConfig, @@ -1138,160 +1124,6 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise { - if (!existsSync(sourcePath)) { - return null; - } - await fsPromises.cp(sourcePath, targetPath, { recursive: true }); - return targetPath; -} - -async function snapshotWorktreeReseedState(target: { - repoConfigDir: string; - instanceRoot: string; -}): Promise { - const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-reseed-backup-")); - return { - tempRoot, - repoConfigDirBackup: await snapshotDirectory( - target.repoConfigDir, - path.resolve(tempRoot, "repo-config"), - ), - instanceRootBackup: await snapshotDirectory( - target.instanceRoot, - path.resolve(tempRoot, "instance-root"), - ), - }; -} - -async function restoreDirectoryBackup(backupPath: string | null, targetPath: string): Promise { - rmSync(targetPath, { recursive: true, force: true }); - if (!backupPath) { - return; - } - await fsPromises.cp(backupPath, targetPath, { recursive: true }); -} - -async function restoreWorktreeReseedState( - backup: WorktreeReseedBackup, - target: { repoConfigDir: string; instanceRoot: string }, -): Promise { - await restoreDirectoryBackup(backup.repoConfigDirBackup, target.repoConfigDir); - await restoreDirectoryBackup(backup.instanceRootBackup, target.instanceRoot); -} - -export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise { - printPaperclipCliBanner(); - p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed "))); - - if (!hasExplicitSourceSelection(opts)) { - throw new Error( - "Reseed requires an explicit source. Pass --from-config or --from-instance (optionally with --from-data-dir).", - ); - } - - const target = resolveCurrentWorktreeReseedState({ home: opts.home }); - const sourceConfigPath = resolveSourceConfigPath(opts); - if (path.resolve(sourceConfigPath) === target.currentConfigPath) { - throw new Error( - "Source and target Paperclip configs are the same. Pass a different source instance/config when reseeding.", - ); - } - - const seedMode = opts.seedMode ?? "minimal"; - if (!isWorktreeSeedMode(seedMode)) { - throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`); - } - - const confirmed = opts.yes - ? true - : await p.confirm({ - message: `Reseed the current worktree instance (${target.instanceId}) from ${sourceConfigPath}? This overwrites only the current worktree Paperclip instance data.`, - initialValue: false, - }); - if (p.isCancel(confirmed) || !confirmed) { - p.log.warn("Reseed cancelled."); - return; - } - - const targetPaths = resolveWorktreeLocalPaths({ - cwd: process.cwd(), - homeDir: target.homeDir, - instanceId: target.instanceId, - }); - const backup = await snapshotWorktreeReseedState(targetPaths); - - try { - await runWorktreeInit({ - name: target.worktreeName, - color: target.worktreeColor, - instance: target.instanceId, - home: target.homeDir, - fromConfig: opts.fromConfig, - fromDataDir: opts.fromDataDir, - fromInstance: opts.fromInstance, - sourceConfigPathOverride: sourceConfigPath, - serverPort: target.serverPort, - dbPort: target.dbPort, - seed: opts.seed ?? true, - seedMode, - force: true, - }); - } catch (error) { - await restoreWorktreeReseedState(backup, targetPaths); - throw error; - } finally { - rmSync(backup.tempRoot, { recursive: true, force: true }); - } -} - export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise { printPaperclipCliBanner(); p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make "))); @@ -2968,17 +2800,6 @@ export function registerWorktreeCommands(program: Command): void { .option("--json", "Print JSON instead of shell exports") .action(worktreeEnvCommand); - worktree - .command("reseed") - .description("Replace the current worktree instance with a fresh seed while preserving this worktree's ports and instance id") - .option("--from-config ", "Source config.json to seed from") - .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") - .option("--from-instance ", "Source instance id when deriving the source config") - .option("--home ", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) - .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") - .option("--yes", "Skip the destructive confirmation prompt", false) - .action(worktreeReseedCommand); - program .command("worktree:list") .description("List git worktrees visible from this repo and whether they look like Paperclip worktrees") diff --git a/package.json b/package.json index 58ffa103..7697e1d8 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "preflight:workspace-links": "pnpm exec tsx scripts/ensure-workspace-package-links.ts", "dev": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch", "dev:watch": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch", "dev:once": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts dev", @@ -10,10 +11,10 @@ "dev:stop": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts stop", "dev:server": "pnpm --filter @paperclipai/server dev", "dev:ui": "pnpm --filter @paperclipai/ui dev", - "build": "pnpm -r build", - "typecheck": "pnpm -r typecheck", - "test": "vitest", - "test:run": "vitest run", + "build": "pnpm run preflight:workspace-links && pnpm -r build", + "typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck", + "test": "pnpm run preflight:workspace-links && vitest", + "test:run": "pnpm run preflight:workspace-links && vitest run", "db:generate": "pnpm --filter @paperclipai/db generate", "db:migrate": "pnpm --filter @paperclipai/db migrate", "secrets:migrate-inline-env": "tsx scripts/migrate-inline-env-secrets.ts", diff --git a/scripts/ensure-workspace-package-links.ts b/scripts/ensure-workspace-package-links.ts index 8ff86b71..d8be419c 100644 --- a/scripts/ensure-workspace-package-links.ts +++ b/scripts/ensure-workspace-package-links.ts @@ -44,6 +44,13 @@ function discoverWorkspacePackagePaths(rootDir: string): Map { } 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 packageJson = readJsonFile(path.join(repoRoot, workspaceDir, "package.json")); @@ -100,6 +107,6 @@ async function ensureWorkspaceLinksCurrent(workspaceDir: string) { ); } -for (const workspaceDir of ["server", "ui"]) { +for (const workspaceDir of workspaceDirs) { await ensureWorkspaceLinksCurrent(workspaceDir); } diff --git a/scripts/kill-agent-browsers.sh b/scripts/kill-agent-browsers.sh new file mode 100755 index 00000000..c89fa96e --- /dev/null +++ b/scripts/kill-agent-browsers.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# +# Kill all "Google Chrome for Testing" processes (agent headless browsers). +# +# Usage: +# scripts/kill-agent-browsers.sh # kill all +# scripts/kill-agent-browsers.sh --dry # preview what would be killed +# + +set -euo pipefail + +DRY_RUN=false +if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then + DRY_RUN=true +fi + +pids=() +lines=() + +while IFS= read -r line; do + [[ -z "$line" ]] && continue + pid=$(echo "$line" | awk '{print $2}') + pids+=("$pid") + lines+=("$line") +done < <(ps aux | grep 'Google Chrome for Testing' | grep -v grep || true) + +if [[ ${#pids[@]} -eq 0 ]]; then + echo "No Google Chrome for Testing processes found." + exit 0 +fi + +echo "Found ${#pids[@]} Google Chrome for Testing process(es):" +echo "" + +for i in "${!pids[@]}"; do + line="${lines[$i]}" + pid=$(echo "$line" | awk '{print $2}') + start=$(echo "$line" | awk '{print $9}') + cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}') + cmd=$(echo "$cmd" | sed "s|$HOME/||g") + printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd" +done + +echo "" + +if [[ "$DRY_RUN" == true ]]; then + echo "Dry run — re-run without --dry to kill these processes." + exit 0 +fi + +echo "Sending SIGTERM..." +for pid in "${pids[@]}"; do + kill -TERM "$pid" 2>/dev/null && echo " signaled $pid" || echo " $pid already gone" +done + +sleep 2 + +for pid in "${pids[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + echo " $pid still alive, sending SIGKILL..." + kill -KILL "$pid" 2>/dev/null || true + fi +done + +echo "Done."