Add worktree init CLI for isolated development instances
This commit is contained in:
384
cli/src/commands/worktree.ts
Normal file
384
cli/src/commands/worktree.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
ensurePostgresDatabase,
|
||||
formatDatabaseBackupResult,
|
||||
runDatabaseBackup,
|
||||
runDatabaseRestore,
|
||||
} from "@paperclipai/db";
|
||||
import type { Command } from "commander";
|
||||
import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
|
||||
import { expandHomePrefix } from "../config/home.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
|
||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||
import {
|
||||
buildWorktreeConfig,
|
||||
buildWorktreeEnvEntries,
|
||||
DEFAULT_WORKTREE_HOME,
|
||||
formatShellExports,
|
||||
resolveSuggestedWorktreeName,
|
||||
resolveWorktreeLocalPaths,
|
||||
sanitizeWorktreeInstanceId,
|
||||
type WorktreeLocalPaths,
|
||||
} from "./worktree-lib.js";
|
||||
|
||||
type WorktreeInitOptions = {
|
||||
name?: string;
|
||||
instance?: string;
|
||||
home?: string;
|
||||
fromConfig?: string;
|
||||
fromDataDir?: string;
|
||||
fromInstance?: string;
|
||||
serverPort?: number;
|
||||
dbPort?: number;
|
||||
seed?: boolean;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
type WorktreeEnvOptions = {
|
||||
config?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
type EmbeddedPostgresHandle = {
|
||||
port: number;
|
||||
startedByThisProcess: boolean;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
function nonEmpty(value: string | null | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readPidFilePort(postmasterPidFile: string): number | null {
|
||||
if (!existsSync(postmasterPidFile)) return null;
|
||||
try {
|
||||
const lines = readFileSync(postmasterPidFile, "utf8").split("\n");
|
||||
const port = Number(lines[3]?.trim());
|
||||
return Number.isInteger(port) && port > 0 ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
|
||||
if (!existsSync(postmasterPidFile)) return null;
|
||||
try {
|
||||
const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim());
|
||||
if (!Number.isInteger(pid) || pid <= 0) return null;
|
||||
process.kill(pid, 0);
|
||||
return pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function isPortAvailable(port: number): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findAvailablePort(preferredPort: number, reserved = new Set<number>()): Promise<number> {
|
||||
let port = Math.max(1, Math.trunc(preferredPort));
|
||||
while (reserved.has(port) || !(await isPortAvailable(port))) {
|
||||
port += 1;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function detectGitBranchName(cwd: string): string | null {
|
||||
try {
|
||||
const value = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
return nonEmpty(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
|
||||
if (opts.fromConfig) return path.resolve(opts.fromConfig);
|
||||
const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip"));
|
||||
const sourceInstanceId = sanitizeWorktreeInstanceId(opts.fromInstance ?? "default");
|
||||
return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json");
|
||||
}
|
||||
|
||||
function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record<string, string>, portOverride?: number): string {
|
||||
if (config.database.mode === "postgres") {
|
||||
const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString);
|
||||
if (!connectionString) {
|
||||
throw new Error(
|
||||
"Source instance uses postgres mode but has no connection string in config or adjacent .env.",
|
||||
);
|
||||
}
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
const port = portOverride ?? config.database.embeddedPostgresPort;
|
||||
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
}
|
||||
|
||||
async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise<EmbeddedPostgresHandle> {
|
||||
const moduleName = "embedded-postgres";
|
||||
let EmbeddedPostgres: EmbeddedPostgresCtor;
|
||||
try {
|
||||
const mod = await import(moduleName);
|
||||
EmbeddedPostgres = mod.default as EmbeddedPostgresCtor;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
|
||||
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
||||
if (runningPid) {
|
||||
return {
|
||||
port: readPidFilePort(postmasterPidFile) ?? preferredPort,
|
||||
startedByThisProcess: false,
|
||||
stop: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const port = await findAvailablePort(preferredPort);
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
});
|
||||
|
||||
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
|
||||
await instance.initialise();
|
||||
}
|
||||
if (existsSync(postmasterPidFile)) {
|
||||
rmSync(postmasterPidFile, { force: true });
|
||||
}
|
||||
await instance.start();
|
||||
|
||||
return {
|
||||
port,
|
||||
startedByThisProcess: true,
|
||||
stop: async () => {
|
||||
await instance.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedWorktreeDatabase(input: {
|
||||
sourceConfigPath: string;
|
||||
sourceConfig: PaperclipConfig;
|
||||
targetConfig: PaperclipConfig;
|
||||
targetPaths: WorktreeLocalPaths;
|
||||
instanceId: string;
|
||||
}): Promise<string> {
|
||||
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
|
||||
const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile);
|
||||
let sourceHandle: EmbeddedPostgresHandle | null = null;
|
||||
let targetHandle: EmbeddedPostgresHandle | null = null;
|
||||
|
||||
try {
|
||||
if (input.sourceConfig.database.mode === "embedded-postgres") {
|
||||
sourceHandle = await ensureEmbeddedPostgres(
|
||||
input.sourceConfig.database.embeddedPostgresDataDir,
|
||||
input.sourceConfig.database.embeddedPostgresPort,
|
||||
);
|
||||
}
|
||||
const sourceConnectionString = resolveSourceConnectionString(
|
||||
input.sourceConfig,
|
||||
sourceEnvEntries,
|
||||
sourceHandle?.port,
|
||||
);
|
||||
const backup = await runDatabaseBackup({
|
||||
connectionString: sourceConnectionString,
|
||||
backupDir: path.resolve(input.targetPaths.backupDir, "seed"),
|
||||
retentionDays: 7,
|
||||
filenamePrefix: `${input.instanceId}-seed`,
|
||||
includeMigrationJournal: true,
|
||||
});
|
||||
|
||||
targetHandle = await ensureEmbeddedPostgres(
|
||||
input.targetConfig.database.embeddedPostgresDataDir,
|
||||
input.targetConfig.database.embeddedPostgresPort,
|
||||
);
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const targetConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/paperclip`;
|
||||
await runDatabaseRestore({
|
||||
connectionString: targetConnectionString,
|
||||
backupFile: backup.backupFile,
|
||||
});
|
||||
|
||||
return formatDatabaseBackupResult(backup);
|
||||
} finally {
|
||||
if (targetHandle?.startedByThisProcess) {
|
||||
await targetHandle.stop();
|
||||
}
|
||||
if (sourceHandle?.startedByThisProcess) {
|
||||
await sourceHandle.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
|
||||
|
||||
const cwd = process.cwd();
|
||||
const name = resolveSuggestedWorktreeName(
|
||||
cwd,
|
||||
opts.name ?? detectGitBranchName(cwd) ?? undefined,
|
||||
);
|
||||
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name);
|
||||
const paths = resolveWorktreeLocalPaths({
|
||||
cwd,
|
||||
homeDir: opts.home ?? DEFAULT_WORKTREE_HOME,
|
||||
instanceId,
|
||||
});
|
||||
const sourceConfigPath = resolveSourceConfigPath(opts);
|
||||
const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null;
|
||||
|
||||
if ((existsSync(paths.configPath) || existsSync(paths.instanceRoot)) && !opts.force) {
|
||||
throw new Error(
|
||||
`Worktree config already exists at ${paths.configPath} or instance data exists at ${paths.instanceRoot}. Re-run with --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.force) {
|
||||
rmSync(paths.repoConfigDir, { recursive: true, force: true });
|
||||
rmSync(paths.instanceRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1);
|
||||
const serverPort = await findAvailablePort(preferredServerPort);
|
||||
const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1);
|
||||
const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort]));
|
||||
const targetConfig = buildWorktreeConfig({
|
||||
sourceConfig,
|
||||
paths,
|
||||
serverPort,
|
||||
databasePort,
|
||||
});
|
||||
|
||||
writeConfig(targetConfig, paths.configPath);
|
||||
mergePaperclipEnvEntries(buildWorktreeEnvEntries(paths), paths.envPath);
|
||||
ensureAgentJwtSecret(paths.configPath);
|
||||
loadPaperclipEnvFile(paths.configPath);
|
||||
|
||||
let seedSummary: string | null = null;
|
||||
if (opts.seed !== false) {
|
||||
if (!sourceConfig) {
|
||||
throw new Error(
|
||||
`Cannot seed worktree database because source config was not found at ${sourceConfigPath}. Use --no-seed or provide --from-config.`,
|
||||
);
|
||||
}
|
||||
const spinner = p.spinner();
|
||||
spinner.start("Seeding isolated worktree database from source instance...");
|
||||
try {
|
||||
seedSummary = await seedWorktreeDatabase({
|
||||
sourceConfigPath,
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
targetPaths: paths,
|
||||
instanceId,
|
||||
});
|
||||
spinner.stop("Seeded isolated worktree database.");
|
||||
} catch (error) {
|
||||
spinner.stop(pc.red("Failed to seed worktree database."));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
p.log.message(pc.dim(`Repo config: ${paths.configPath}`));
|
||||
p.log.message(pc.dim(`Repo env: ${paths.envPath}`));
|
||||
p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`));
|
||||
p.log.message(pc.dim(`Instance: ${paths.instanceId}`));
|
||||
p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`));
|
||||
if (seedSummary) {
|
||||
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
|
||||
}
|
||||
p.outro(
|
||||
pc.green(
|
||||
`Worktree ready. Run Paperclip inside this repo and the CLI/server will use ${paths.instanceId} automatically.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void> {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const envPath = resolvePaperclipEnvFile(configPath);
|
||||
const envEntries = readPaperclipEnvEntries(envPath);
|
||||
const out = {
|
||||
PAPERCLIP_CONFIG: configPath,
|
||||
...(envEntries.PAPERCLIP_HOME ? { PAPERCLIP_HOME: envEntries.PAPERCLIP_HOME } : {}),
|
||||
...(envEntries.PAPERCLIP_INSTANCE_ID ? { PAPERCLIP_INSTANCE_ID: envEntries.PAPERCLIP_INSTANCE_ID } : {}),
|
||||
...(envEntries.PAPERCLIP_CONTEXT ? { PAPERCLIP_CONTEXT: envEntries.PAPERCLIP_CONTEXT } : {}),
|
||||
...envEntries,
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(formatShellExports(out));
|
||||
}
|
||||
|
||||
export function registerWorktreeCommands(program: Command): void {
|
||||
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
|
||||
|
||||
worktree
|
||||
.command("init")
|
||||
.description("Create repo-local config/env and an isolated instance for this worktree")
|
||||
.option("--name <name>", "Display name used to derive the instance id")
|
||||
.option("--instance <id>", "Explicit isolated instance id")
|
||||
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
|
||||
.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", "default")
|
||||
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
|
||||
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
|
||||
.option("--no-seed", "Skip database seeding from the source instance")
|
||||
.option("--force", "Replace existing repo-local config and isolated instance data", false)
|
||||
.action(worktreeInitCommand);
|
||||
|
||||
worktree
|
||||
.command("env")
|
||||
.description("Print shell exports for the current worktree-local Paperclip instance")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("--json", "Print JSON instead of shell exports")
|
||||
.action(worktreeEnvCommand);
|
||||
}
|
||||
Reference in New Issue
Block a user