mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
chore(dev): preflight workspace links and simplify worktree helpers
This commit is contained in:
parent
b1e9215375
commit
8e88577371
5 changed files with 83 additions and 188 deletions
|
|
@ -573,6 +573,7 @@ describe("worktree helpers", () => {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true });
|
fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true });
|
||||||
fs.mkdirSync(path.dirname(sourcePaths.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(repoRoot, { recursive: true });
|
||||||
fs.mkdirSync(sourceRoot, { 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(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8");
|
||||||
fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8");
|
fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8");
|
||||||
|
fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8");
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
currentPaths.envPath,
|
currentPaths.envPath,
|
||||||
[
|
[
|
||||||
|
|
@ -606,7 +608,6 @@ describe("worktree helpers", () => {
|
||||||
|
|
||||||
await worktreeReseedCommand({
|
await worktreeReseedCommand({
|
||||||
fromConfig: sourcePaths.configPath,
|
fromConfig: sourcePaths.configPath,
|
||||||
seed: false,
|
|
||||||
yes: true,
|
yes: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -628,7 +629,7 @@ describe("worktree helpers", () => {
|
||||||
}
|
}
|
||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
}, 20_000);
|
||||||
|
|
||||||
it("restores the current worktree config and instance data if reseed fails", async () => {
|
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-"));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-"));
|
||||||
|
|
|
||||||
|
|
@ -98,22 +98,6 @@ type WorktreeMakeOptions = WorktreeInitOptions & {
|
||||||
startPoint?: string;
|
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 = {
|
type WorktreeEnvOptions = {
|
||||||
config?: string;
|
config?: string;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
|
|
@ -964,6 +948,8 @@ async function seedWorktreeDatabase(input: {
|
||||||
input.sourceConfig.database.embeddedPostgresDataDir,
|
input.sourceConfig.database.embeddedPostgresDataDir,
|
||||||
input.sourceConfig.database.embeddedPostgresPort,
|
input.sourceConfig.database.embeddedPostgresPort,
|
||||||
);
|
);
|
||||||
|
const sourceAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${sourceHandle.port}/postgres`;
|
||||||
|
await ensurePostgresDatabase(sourceAdminConnectionString, "paperclip");
|
||||||
}
|
}
|
||||||
const sourceConnectionString = resolveSourceConnectionString(
|
const sourceConnectionString = resolveSourceConnectionString(
|
||||||
input.sourceConfig,
|
input.sourceConfig,
|
||||||
|
|
@ -1138,160 +1124,6 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
||||||
await runWorktreeInit(opts);
|
await runWorktreeInit(opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasExplicitSourceSelection(opts: {
|
|
||||||
fromConfig?: string;
|
|
||||||
fromDataDir?: string;
|
|
||||||
fromInstance?: string;
|
|
||||||
sourceConfigPathOverride?: string;
|
|
||||||
}): boolean {
|
|
||||||
return Boolean(
|
|
||||||
nonEmpty(opts.fromConfig)
|
|
||||||
|| nonEmpty(opts.fromDataDir)
|
|
||||||
|| nonEmpty(opts.fromInstance)
|
|
||||||
|| nonEmpty(opts.sourceConfigPathOverride),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveCurrentWorktreeReseedState(opts: { home?: string } = {}) {
|
|
||||||
const currentConfigPath = resolveConfigPath();
|
|
||||||
if (!existsSync(currentConfigPath)) {
|
|
||||||
throw new Error(
|
|
||||||
"Current directory does not have a Paperclip worktree config. Run `paperclipai worktree init` here first.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const currentConfig = readConfig(currentConfigPath);
|
|
||||||
if (!currentConfig) {
|
|
||||||
throw new Error(`Could not read current worktree config at ${currentConfigPath}.`);
|
|
||||||
}
|
|
||||||
if (currentConfig.database.mode !== "embedded-postgres") {
|
|
||||||
throw new Error("Worktree reseed only supports embedded-postgres worktree instances.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(currentConfigPath));
|
|
||||||
const instanceRoot = path.dirname(currentConfig.database.embeddedPostgresDataDir);
|
|
||||||
const derivedHomeDir = path.dirname(path.dirname(instanceRoot));
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentConfigPath: path.resolve(currentConfigPath),
|
|
||||||
instanceId:
|
|
||||||
nonEmpty(currentEnvEntries.PAPERCLIP_INSTANCE_ID)
|
|
||||||
?? nonEmpty(path.basename(instanceRoot))
|
|
||||||
?? sanitizeWorktreeInstanceId(path.basename(process.cwd())),
|
|
||||||
homeDir: path.resolve(expandHomePrefix(opts.home ?? currentEnvEntries.PAPERCLIP_HOME ?? derivedHomeDir)),
|
|
||||||
serverPort: currentConfig.server.port,
|
|
||||||
dbPort: currentConfig.database.embeddedPostgresPort,
|
|
||||||
worktreeName: nonEmpty(currentEnvEntries.PAPERCLIP_WORKTREE_NAME) ?? undefined,
|
|
||||||
worktreeColor: nonEmpty(currentEnvEntries.PAPERCLIP_WORKTREE_COLOR) ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function snapshotDirectory(sourcePath: string, targetPath: string): Promise<string | null> {
|
|
||||||
if (!existsSync(sourcePath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
await fsPromises.cp(sourcePath, targetPath, { recursive: true });
|
|
||||||
return targetPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function snapshotWorktreeReseedState(target: {
|
|
||||||
repoConfigDir: string;
|
|
||||||
instanceRoot: string;
|
|
||||||
}): Promise<WorktreeReseedBackup> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
await restoreDirectoryBackup(backup.repoConfigDirBackup, target.repoConfigDir);
|
|
||||||
await restoreDirectoryBackup(backup.instanceRootBackup, target.instanceRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
|
|
||||||
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<void> {
|
export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> {
|
||||||
printPaperclipCliBanner();
|
printPaperclipCliBanner();
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
|
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")
|
.option("--json", "Print JSON instead of shell exports")
|
||||||
.action(worktreeEnvCommand);
|
.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 <path>", "Source config.json to seed from")
|
|
||||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
|
||||||
.option("--from-instance <id>", "Source instance id when deriving the source config")
|
|
||||||
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
|
|
||||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
|
||||||
.option("--yes", "Skip the destructive confirmation prompt", false)
|
|
||||||
.action(worktreeReseedCommand);
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("worktree:list")
|
.command("worktree:list")
|
||||||
.description("List git worktrees visible from this repo and whether they look like Paperclip worktrees")
|
.description("List git worktrees visible from this repo and whether they look like Paperclip worktrees")
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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": "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: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",
|
"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:stop": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts stop",
|
||||||
"dev:server": "pnpm --filter @paperclipai/server dev",
|
"dev:server": "pnpm --filter @paperclipai/server dev",
|
||||||
"dev:ui": "pnpm --filter @paperclipai/ui dev",
|
"dev:ui": "pnpm --filter @paperclipai/ui dev",
|
||||||
"build": "pnpm -r build",
|
"build": "pnpm run preflight:workspace-links && pnpm -r build",
|
||||||
"typecheck": "pnpm -r typecheck",
|
"typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck",
|
||||||
"test": "vitest",
|
"test": "pnpm run preflight:workspace-links && vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "pnpm run preflight:workspace-links && vitest run",
|
||||||
"db:generate": "pnpm --filter @paperclipai/db generate",
|
"db:generate": "pnpm --filter @paperclipai/db generate",
|
||||||
"db:migrate": "pnpm --filter @paperclipai/db migrate",
|
"db:migrate": "pnpm --filter @paperclipai/db migrate",
|
||||||
"secrets:migrate-inline-env": "tsx scripts/migrate-inline-env-secrets.ts",
|
"secrets:migrate-inline-env": "tsx scripts/migrate-inline-env-secrets.ts",
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,13 @@ function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot);
|
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[] {
|
function findWorkspaceLinkMismatches(workspaceDir: string): WorkspaceLinkMismatch[] {
|
||||||
const packageJson = readJsonFile(path.join(repoRoot, workspaceDir, "package.json"));
|
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);
|
await ensureWorkspaceLinksCurrent(workspaceDir);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
scripts/kill-agent-browsers.sh
Executable file
65
scripts/kill-agent-browsers.sh
Executable file
|
|
@ -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."
|
||||||
Loading…
Add table
Add a link
Reference in a new issue