2026-03-10 14:55:35 -05:00
import {
chmodSync ,
copyFileSync ,
existsSync ,
mkdirSync ,
readdirSync ,
readFileSync ,
readlinkSync ,
rmSync ,
statSync ,
symlinkSync ,
writeFileSync ,
} from "node:fs" ;
2026-03-10 10:08:13 -05:00
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" ;
2026-03-20 15:02:24 -05:00
import { and , eq , inArray , sql } from "drizzle-orm" ;
2026-03-10 10:08:13 -05:00
import {
2026-03-10 10:08:58 -05:00
applyPendingMigrations ,
2026-03-20 15:02:24 -05:00
agents ,
companies ,
2026-03-10 13:50:29 -05:00
createDb ,
2026-03-10 10:08:13 -05:00
ensurePostgresDatabase ,
formatDatabaseBackupResult ,
2026-03-20 15:02:24 -05:00
goals ,
heartbeatRuns ,
issueComments ,
issueDocuments ,
issues ,
2026-03-10 13:50:29 -05:00
projectWorkspaces ,
2026-03-20 15:02:24 -05:00
projects ,
2026-03-10 10:08:13 -05:00
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" ;
2026-03-10 12:57:53 -05:00
import { resolveRuntimeLikePath } from "../utils/path-resolver.js" ;
2026-03-10 10:08:13 -05:00
import {
buildWorktreeConfig ,
buildWorktreeEnvEntries ,
DEFAULT_WORKTREE_HOME ,
formatShellExports ,
2026-03-13 11:12:43 -05:00
generateWorktreeColor ,
2026-03-10 07:41:01 -05:00
isWorktreeSeedMode ,
2026-03-10 10:08:13 -05:00
resolveSuggestedWorktreeName ,
2026-03-10 07:41:01 -05:00
resolveWorktreeSeedPlan ,
2026-03-10 10:08:13 -05:00
resolveWorktreeLocalPaths ,
sanitizeWorktreeInstanceId ,
2026-03-10 07:41:01 -05:00
type WorktreeSeedMode ,
2026-03-10 10:08:13 -05:00
type WorktreeLocalPaths ,
} from "./worktree-lib.js" ;
2026-03-20 15:02:24 -05:00
import {
buildWorktreeMergePlan ,
parseWorktreeMergeScopes ,
type PlannedCommentInsert ,
type PlannedIssueInsert ,
} from "./worktree-merge-history-lib.js" ;
2026-03-10 10:08:13 -05:00
type WorktreeInitOptions = {
name? : string ;
instance? : string ;
home? : string ;
fromConfig? : string ;
fromDataDir? : string ;
fromInstance? : string ;
2026-03-13 14:24:06 -05:00
sourceConfigPathOverride? : string ;
2026-03-10 10:08:13 -05:00
serverPort? : number ;
dbPort? : number ;
seed? : boolean ;
2026-03-10 07:41:01 -05:00
seedMode? : string ;
2026-03-10 10:08:13 -05:00
force? : boolean ;
} ;
2026-03-11 09:15:27 -05:00
type WorktreeMakeOptions = WorktreeInitOptions & {
startPoint? : string ;
} ;
2026-03-10 16:52:26 -05:00
2026-03-10 10:08:13 -05:00
type WorktreeEnvOptions = {
config? : string ;
json? : boolean ;
} ;
2026-03-20 15:02:24 -05:00
type WorktreeMergeHistoryOptions = {
company? : string ;
scope? : string ;
apply? : boolean ;
dry? : boolean ;
yes? : boolean ;
} ;
2026-03-10 10:08:13 -05:00
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 ;
2026-03-13 09:25:04 -05:00
initdbFlags? : string [ ] ;
2026-03-10 10:08:13 -05:00
onLog ? : ( message : unknown ) = > void ;
onError ? : ( message : unknown ) = > void ;
} ) = > EmbeddedPostgresInstance ;
type EmbeddedPostgresHandle = {
port : number ;
startedByThisProcess : boolean ;
stop : ( ) = > Promise < void > ;
} ;
2026-03-10 13:50:29 -05:00
type GitWorkspaceInfo = {
root : string ;
commonDir : string ;
2026-03-10 14:55:35 -05:00
gitDir : string ;
hooksPath : string ;
} ;
type CopiedGitHooksResult = {
sourceHooksPath : string ;
targetHooksPath : string ;
copied : boolean ;
2026-03-10 13:50:29 -05:00
} ;
type SeedWorktreeDatabaseResult = {
backupSummary : string ;
reboundWorkspaces : Array < {
name : string ;
fromCwd : string ;
toCwd : string ;
} > ;
} ;
2026-03-10 10:08:13 -05:00
function nonEmpty ( value : string | null | undefined ) : string | null {
return typeof value === "string" && value . trim ( ) . length > 0 ? value . trim ( ) : null ;
}
2026-03-11 16:38:31 -05:00
function isCurrentSourceConfigPath ( sourceConfigPath : string ) : boolean {
const currentConfigPath = process . env . PAPERCLIP_CONFIG ;
if ( ! currentConfigPath || currentConfigPath . trim ( ) . length === 0 ) {
return false ;
}
return path . resolve ( currentConfigPath ) === path . resolve ( sourceConfigPath ) ;
}
2026-03-13 07:24:39 -05:00
const WORKTREE_NAME_PREFIX = "paperclip-" ;
2026-03-10 16:52:26 -05:00
function resolveWorktreeMakeName ( name : string ) : string {
const value = nonEmpty ( name ) ;
if ( ! value ) {
throw new Error ( "Worktree name is required." ) ;
}
if ( ! /^[A-Za-z0-9._-]+$/ . test ( value ) ) {
throw new Error (
"Worktree name must contain only letters, numbers, dots, underscores, or dashes." ,
) ;
}
2026-03-13 07:24:39 -05:00
return value . startsWith ( WORKTREE_NAME_PREFIX ) ? value : ` ${ WORKTREE_NAME_PREFIX } ${ value } ` ;
}
function resolveWorktreeHome ( explicit? : string ) : string {
return explicit ? ? process . env . PAPERCLIP_WORKTREES_DIR ? ? DEFAULT_WORKTREE_HOME ;
}
function resolveWorktreeStartPoint ( explicit? : string ) : string | undefined {
return explicit ? ? nonEmpty ( process . env . PAPERCLIP_WORKTREE_START_POINT ) ? ? undefined ;
2026-03-10 16:52:26 -05:00
}
export function resolveWorktreeMakeTargetPath ( name : string ) : string {
return path . resolve ( os . homedir ( ) , resolveWorktreeMakeName ( name ) ) ;
}
function extractExecSyncErrorMessage ( error : unknown ) : string | null {
if ( ! error || typeof error !== "object" ) {
return error instanceof Error ? error.message : null ;
}
const stderr = "stderr" in error ? error.stderr : null ;
if ( typeof stderr === "string" ) {
return nonEmpty ( stderr ) ;
}
if ( stderr instanceof Buffer ) {
return nonEmpty ( stderr . toString ( "utf8" ) ) ;
}
return error instanceof Error ? nonEmpty ( error . message ) : null ;
}
function localBranchExists ( cwd : string , branchName : string ) : boolean {
try {
execFileSync ( "git" , [ "show-ref" , "--verify" , "--quiet" , ` refs/heads/ ${ branchName } ` ] , {
cwd ,
stdio : "ignore" ,
} ) ;
return true ;
} catch {
return false ;
}
}
export function resolveGitWorktreeAddArgs ( input : {
branchName : string ;
targetPath : string ;
branchExists : boolean ;
2026-03-11 09:15:27 -05:00
startPoint? : string ;
2026-03-10 16:52:26 -05:00
} ) : string [ ] {
2026-03-11 09:15:27 -05:00
if ( input . branchExists && ! input . startPoint ) {
2026-03-10 16:52:26 -05:00
return [ "worktree" , "add" , input . targetPath , input . branchName ] ;
}
2026-03-11 09:15:27 -05:00
const commitish = input . startPoint ? ? "HEAD" ;
return [ "worktree" , "add" , "-b" , input . branchName , input . targetPath , commitish ] ;
2026-03-10 16:52:26 -05:00
}
2026-03-10 10:08:13 -05:00
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 ;
}
}
2026-03-10 13:50:29 -05:00
function detectGitWorkspaceInfo ( cwd : string ) : GitWorkspaceInfo | null {
try {
const root = execFileSync ( "git" , [ "rev-parse" , "--show-toplevel" ] , {
cwd ,
encoding : "utf8" ,
stdio : [ "ignore" , "pipe" , "ignore" ] ,
} ) . trim ( ) ;
const commonDirRaw = execFileSync ( "git" , [ "rev-parse" , "--git-common-dir" ] , {
cwd : root ,
encoding : "utf8" ,
stdio : [ "ignore" , "pipe" , "ignore" ] ,
} ) . trim ( ) ;
2026-03-10 14:55:35 -05:00
const gitDirRaw = execFileSync ( "git" , [ "rev-parse" , "--git-dir" ] , {
cwd : root ,
encoding : "utf8" ,
stdio : [ "ignore" , "pipe" , "ignore" ] ,
} ) . trim ( ) ;
const hooksPathRaw = execFileSync ( "git" , [ "rev-parse" , "--git-path" , "hooks" ] , {
cwd : root ,
encoding : "utf8" ,
stdio : [ "ignore" , "pipe" , "ignore" ] ,
} ) . trim ( ) ;
2026-03-10 13:50:29 -05:00
return {
root : path.resolve ( root ) ,
commonDir : path.resolve ( root , commonDirRaw ) ,
2026-03-10 14:55:35 -05:00
gitDir : path.resolve ( root , gitDirRaw ) ,
hooksPath : path.resolve ( root , hooksPathRaw ) ,
2026-03-10 13:50:29 -05:00
} ;
} catch {
return null ;
}
}
2026-03-10 14:55:35 -05:00
function copyDirectoryContents ( sourceDir : string , targetDir : string ) : boolean {
if ( ! existsSync ( sourceDir ) ) return false ;
const entries = readdirSync ( sourceDir , { withFileTypes : true } ) ;
if ( entries . length === 0 ) return false ;
mkdirSync ( targetDir , { recursive : true } ) ;
let copied = false ;
for ( const entry of entries ) {
const sourcePath = path . resolve ( sourceDir , entry . name ) ;
const targetPath = path . resolve ( targetDir , entry . name ) ;
if ( entry . isDirectory ( ) ) {
mkdirSync ( targetPath , { recursive : true } ) ;
copyDirectoryContents ( sourcePath , targetPath ) ;
copied = true ;
continue ;
}
if ( entry . isSymbolicLink ( ) ) {
rmSync ( targetPath , { recursive : true , force : true } ) ;
symlinkSync ( readlinkSync ( sourcePath ) , targetPath ) ;
copied = true ;
continue ;
}
copyFileSync ( sourcePath , targetPath ) ;
try {
chmodSync ( targetPath , statSync ( sourcePath ) . mode & 0 o777 ) ;
} catch {
// best effort
}
copied = true ;
}
return copied ;
}
export function copyGitHooksToWorktreeGitDir ( cwd : string ) : CopiedGitHooksResult | null {
const workspace = detectGitWorkspaceInfo ( cwd ) ;
if ( ! workspace ) return null ;
const sourceHooksPath = workspace . hooksPath ;
const targetHooksPath = path . resolve ( workspace . gitDir , "hooks" ) ;
if ( sourceHooksPath === targetHooksPath ) {
return {
sourceHooksPath ,
targetHooksPath ,
copied : false ,
} ;
}
return {
sourceHooksPath ,
targetHooksPath ,
copied : copyDirectoryContents ( sourceHooksPath , targetHooksPath ) ,
} ;
}
2026-03-10 13:50:29 -05:00
export function rebindWorkspaceCwd ( input : {
sourceRepoRoot : string ;
targetRepoRoot : string ;
workspaceCwd : string ;
} ) : string | null {
const sourceRepoRoot = path . resolve ( input . sourceRepoRoot ) ;
const targetRepoRoot = path . resolve ( input . targetRepoRoot ) ;
const workspaceCwd = path . resolve ( input . workspaceCwd ) ;
const relative = path . relative ( sourceRepoRoot , workspaceCwd ) ;
if ( ! relative || relative === "" ) {
return targetRepoRoot ;
}
if ( relative . startsWith ( ".." ) || path . isAbsolute ( relative ) ) {
return null ;
}
return path . resolve ( targetRepoRoot , relative ) ;
}
async function rebindSeededProjectWorkspaces ( input : {
targetConnectionString : string ;
currentCwd : string ;
} ) : Promise < SeedWorktreeDatabaseResult [ "reboundWorkspaces" ] > {
const targetRepo = detectGitWorkspaceInfo ( input . currentCwd ) ;
if ( ! targetRepo ) return [ ] ;
const db = createDb ( input . targetConnectionString ) ;
const closableDb = db as typeof db & {
$client ? : { end ? : ( opts ? : { timeout? : number } ) = > Promise < void > } ;
} ;
try {
const rows = await db
. select ( {
id : projectWorkspaces.id ,
name : projectWorkspaces.name ,
cwd : projectWorkspaces.cwd ,
} )
. from ( projectWorkspaces ) ;
const rebound : SeedWorktreeDatabaseResult [ "reboundWorkspaces" ] = [ ] ;
for ( const row of rows ) {
const workspaceCwd = nonEmpty ( row . cwd ) ;
if ( ! workspaceCwd ) continue ;
const sourceRepo = detectGitWorkspaceInfo ( workspaceCwd ) ;
if ( ! sourceRepo ) continue ;
if ( sourceRepo . commonDir !== targetRepo . commonDir ) continue ;
const reboundCwd = rebindWorkspaceCwd ( {
sourceRepoRoot : sourceRepo.root ,
targetRepoRoot : targetRepo.root ,
workspaceCwd ,
} ) ;
if ( ! reboundCwd ) continue ;
const normalizedCurrent = path . resolve ( workspaceCwd ) ;
if ( reboundCwd === normalizedCurrent ) continue ;
if ( ! existsSync ( reboundCwd ) ) continue ;
await db
. update ( projectWorkspaces )
. set ( {
cwd : reboundCwd ,
updatedAt : new Date ( ) ,
} )
. where ( eq ( projectWorkspaces . id , row . id ) ) ;
rebound . push ( {
name : row.name ,
fromCwd : normalizedCurrent ,
toCwd : reboundCwd ,
} ) ;
}
return rebound ;
} finally {
await closableDb . $client ? . end ? . ( { timeout : 5 } ) . catch ( ( ) = > undefined ) ;
}
}
2026-03-13 14:24:06 -05:00
export function resolveSourceConfigPath ( opts : WorktreeInitOptions ) : string {
if ( opts . sourceConfigPathOverride ) return path . resolve ( opts . sourceConfigPathOverride ) ;
2026-03-10 10:08:13 -05:00
if ( opts . fromConfig ) return path . resolve ( opts . fromConfig ) ;
2026-03-13 14:24:06 -05:00
if ( ! opts . fromDataDir && ! opts . fromInstance ) {
return resolveConfigPath ( ) ;
}
2026-03-10 10:08:13 -05:00
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 ` ;
}
2026-03-10 12:57:53 -05:00
export function copySeededSecretsKey ( input : {
sourceConfigPath : string ;
sourceConfig : PaperclipConfig ;
sourceEnvEntries : Record < string , string > ;
targetKeyFilePath : string ;
} ) : void {
if ( input . sourceConfig . secrets . provider !== "local_encrypted" ) {
return ;
}
mkdirSync ( path . dirname ( input . targetKeyFilePath ) , { recursive : true } ) ;
2026-03-11 16:38:31 -05:00
const allowProcessEnvFallback = isCurrentSourceConfigPath ( input . sourceConfigPath ) ;
2026-03-10 12:57:53 -05:00
const sourceInlineMasterKey =
nonEmpty ( input . sourceEnvEntries . PAPERCLIP_SECRETS_MASTER_KEY ) ? ?
2026-03-11 16:38:31 -05:00
( allowProcessEnvFallback ? nonEmpty ( process . env . PAPERCLIP_SECRETS_MASTER_KEY ) : null ) ;
2026-03-10 12:57:53 -05:00
if ( sourceInlineMasterKey ) {
writeFileSync ( input . targetKeyFilePath , sourceInlineMasterKey , {
encoding : "utf8" ,
mode : 0o600 ,
} ) ;
try {
chmodSync ( input . targetKeyFilePath , 0 o600 ) ;
} catch {
// best effort
}
return ;
}
const sourceKeyFileOverride =
nonEmpty ( input . sourceEnvEntries . PAPERCLIP_SECRETS_MASTER_KEY_FILE ) ? ?
2026-03-11 16:38:31 -05:00
( allowProcessEnvFallback ? nonEmpty ( process . env . PAPERCLIP_SECRETS_MASTER_KEY_FILE ) : null ) ;
2026-03-10 12:57:53 -05:00
const sourceConfiguredKeyPath = sourceKeyFileOverride ? ? input . sourceConfig . secrets . localEncrypted . keyFilePath ;
const sourceKeyFilePath = resolveRuntimeLikePath ( sourceConfiguredKeyPath , input . sourceConfigPath ) ;
if ( ! existsSync ( sourceKeyFilePath ) ) {
throw new Error (
` Cannot seed worktree database because source local_encrypted secrets key was not found at ${ sourceKeyFilePath } . ` ,
) ;
}
copyFileSync ( sourceKeyFilePath , input . targetKeyFilePath ) ;
try {
chmodSync ( input . targetKeyFilePath , 0 o600 ) ;
} catch {
// best effort
}
}
2026-03-10 10:08:13 -05:00
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 ,
2026-03-11 21:59:02 +01:00
initdbFlags : [ "--encoding=UTF8" , "--locale=C" ] ,
2026-03-10 07:41:01 -05:00
onLog : ( ) = > { } ,
onError : ( ) = > { } ,
2026-03-10 10:08:13 -05:00
} ) ;
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 ;
2026-03-10 07:41:01 -05:00
seedMode : WorktreeSeedMode ;
2026-03-10 13:50:29 -05:00
} ) : Promise < SeedWorktreeDatabaseResult > {
2026-03-10 07:41:01 -05:00
const seedPlan = resolveWorktreeSeedPlan ( input . seedMode ) ;
2026-03-10 10:08:13 -05:00
const sourceEnvFile = resolvePaperclipEnvFile ( input . sourceConfigPath ) ;
const sourceEnvEntries = readPaperclipEnvEntries ( sourceEnvFile ) ;
2026-03-10 12:57:53 -05:00
copySeededSecretsKey ( {
sourceConfigPath : input.sourceConfigPath ,
sourceConfig : input.sourceConfig ,
sourceEnvEntries ,
targetKeyFilePath : input.targetPaths.secretsKeyFilePath ,
} ) ;
2026-03-10 10:08:13 -05:00
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 ,
2026-03-10 07:41:01 -05:00
excludeTables : seedPlan.excludedTables ,
nullifyColumns : seedPlan.nullifyColumns ,
2026-03-10 10:08:13 -05:00
} ) ;
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 ,
} ) ;
2026-03-10 10:08:58 -05:00
await applyPendingMigrations ( targetConnectionString ) ;
2026-03-10 13:50:29 -05:00
const reboundWorkspaces = await rebindSeededProjectWorkspaces ( {
targetConnectionString ,
currentCwd : input.targetPaths.cwd ,
} ) ;
2026-03-10 10:08:13 -05:00
2026-03-10 13:50:29 -05:00
return {
backupSummary : formatDatabaseBackupResult ( backup ) ,
reboundWorkspaces ,
} ;
2026-03-10 10:08:13 -05:00
} finally {
if ( targetHandle ? . startedByThisProcess ) {
await targetHandle . stop ( ) ;
}
if ( sourceHandle ? . startedByThisProcess ) {
await sourceHandle . stop ( ) ;
}
}
}
2026-03-10 16:52:26 -05:00
async function runWorktreeInit ( opts : WorktreeInitOptions ) : Promise < void > {
2026-03-10 10:08:13 -05:00
const cwd = process . cwd ( ) ;
2026-03-13 11:12:43 -05:00
const worktreeName = resolveSuggestedWorktreeName (
2026-03-10 10:08:13 -05:00
cwd ,
opts . name ? ? detectGitBranchName ( cwd ) ? ? undefined ,
) ;
2026-03-10 07:41:01 -05:00
const seedMode = opts . seedMode ? ? "minimal" ;
if ( ! isWorktreeSeedMode ( seedMode ) ) {
throw new Error ( ` Unsupported seed mode " ${ seedMode } ". Expected one of: minimal, full. ` ) ;
}
2026-03-13 11:12:43 -05:00
const instanceId = sanitizeWorktreeInstanceId ( opts . instance ? ? worktreeName ) ;
2026-03-10 10:08:13 -05:00
const paths = resolveWorktreeLocalPaths ( {
cwd ,
2026-03-13 07:24:39 -05:00
homeDir : resolveWorktreeHome ( opts . home ) ,
2026-03-10 10:08:13 -05:00
instanceId ,
} ) ;
2026-03-13 11:12:43 -05:00
const branding = {
name : worktreeName ,
color : generateWorktreeColor ( ) ,
} ;
2026-03-10 10:08:13 -05:00
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 ) ;
2026-03-11 16:38:16 -05:00
const sourceEnvEntries = readPaperclipEnvEntries ( resolvePaperclipEnvFile ( sourceConfigPath ) ) ;
const existingAgentJwtSecret =
nonEmpty ( sourceEnvEntries . PAPERCLIP_AGENT_JWT_SECRET ) ? ?
nonEmpty ( process . env . PAPERCLIP_AGENT_JWT_SECRET ) ;
mergePaperclipEnvEntries (
{
2026-03-13 11:12:43 -05:00
. . . buildWorktreeEnvEntries ( paths , branding ) ,
2026-03-11 16:38:16 -05:00
. . . ( existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET : existingAgentJwtSecret } : { } ) ,
} ,
paths . envPath ,
) ;
2026-03-10 10:08:13 -05:00
ensureAgentJwtSecret ( paths . configPath ) ;
loadPaperclipEnvFile ( paths . configPath ) ;
2026-03-10 14:55:35 -05:00
const copiedGitHooks = copyGitHooksToWorktreeGitDir ( cwd ) ;
2026-03-10 10:08:13 -05:00
let seedSummary : string | null = null ;
2026-03-10 13:50:29 -05:00
let reboundWorkspaceSummary : SeedWorktreeDatabaseResult [ "reboundWorkspaces" ] = [ ] ;
2026-03-10 10:08:13 -05:00
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 ( ) ;
2026-03-10 07:41:01 -05:00
spinner . start ( ` Seeding isolated worktree database from source instance ( ${ seedMode } )... ` ) ;
2026-03-10 10:08:13 -05:00
try {
2026-03-10 13:50:29 -05:00
const seeded = await seedWorktreeDatabase ( {
2026-03-10 10:08:13 -05:00
sourceConfigPath ,
sourceConfig ,
targetConfig ,
targetPaths : paths ,
instanceId ,
2026-03-10 07:41:01 -05:00
seedMode ,
2026-03-10 10:08:13 -05:00
} ) ;
2026-03-10 13:50:29 -05:00
seedSummary = seeded . backupSummary ;
reboundWorkspaceSummary = seeded . reboundWorkspaces ;
2026-03-10 07:41:01 -05:00
spinner . stop ( ` Seeded isolated worktree database ( ${ seedMode } ). ` ) ;
2026-03-10 10:08:13 -05:00
} 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 } ` ) ) ;
2026-03-13 11:12:43 -05:00
p . log . message ( pc . dim ( ` Worktree badge: ${ branding . name } ( ${ branding . color } ) ` ) ) ;
2026-03-10 10:08:13 -05:00
p . log . message ( pc . dim ( ` Server port: ${ serverPort } | DB port: ${ databasePort } ` ) ) ;
2026-03-10 14:55:35 -05:00
if ( copiedGitHooks ? . copied ) {
p . log . message (
pc . dim ( ` Mirrored git hooks: ${ copiedGitHooks . sourceHooksPath } -> ${ copiedGitHooks . targetHooksPath } ` ) ,
) ;
}
2026-03-10 10:08:13 -05:00
if ( seedSummary ) {
2026-03-10 07:41:01 -05:00
p . log . message ( pc . dim ( ` Seed mode: ${ seedMode } ` ) ) ;
2026-03-10 10:08:13 -05:00
p . log . message ( pc . dim ( ` Seed snapshot: ${ seedSummary } ` ) ) ;
2026-03-10 13:50:29 -05:00
for ( const rebound of reboundWorkspaceSummary ) {
p . log . message (
pc . dim ( ` Rebound workspace ${ rebound . name } : ${ rebound . fromCwd } -> ${ rebound . toCwd } ` ) ,
) ;
}
2026-03-10 10:08:13 -05:00
}
p . outro (
pc . green (
` Worktree ready. Run Paperclip inside this repo and the CLI/server will use ${ paths . instanceId } automatically. ` ,
) ,
) ;
}
2026-03-10 16:52:26 -05:00
export async function worktreeInitCommand ( opts : WorktreeInitOptions ) : Promise < void > {
printPaperclipCliBanner ( ) ;
p . intro ( pc . bgCyan ( pc . black ( " paperclipai worktree init " ) ) ) ;
await runWorktreeInit ( opts ) ;
}
export async function worktreeMakeCommand ( nameArg : string , opts : WorktreeMakeOptions ) : Promise < void > {
printPaperclipCliBanner ( ) ;
p . intro ( pc . bgCyan ( pc . black ( " paperclipai worktree:make " ) ) ) ;
const name = resolveWorktreeMakeName ( nameArg ) ;
2026-03-13 07:24:39 -05:00
const startPoint = resolveWorktreeStartPoint ( opts . startPoint ) ;
2026-03-10 16:52:26 -05:00
const sourceCwd = process . cwd ( ) ;
2026-03-13 14:24:06 -05:00
const sourceConfigPath = resolveSourceConfigPath ( opts ) ;
2026-03-10 16:52:26 -05:00
const targetPath = resolveWorktreeMakeTargetPath ( name ) ;
if ( existsSync ( targetPath ) ) {
throw new Error ( ` Target path already exists: ${ targetPath } ` ) ;
}
mkdirSync ( path . dirname ( targetPath ) , { recursive : true } ) ;
2026-03-13 07:24:39 -05:00
if ( startPoint ) {
const [ remote ] = startPoint . split ( "/" , 1 ) ;
2026-03-11 09:15:27 -05:00
try {
execFileSync ( "git" , [ "fetch" , remote ] , {
cwd : sourceCwd ,
stdio : [ "ignore" , "pipe" , "pipe" ] ,
} ) ;
} catch ( error ) {
throw new Error (
` Failed to fetch from remote " ${ remote } ": ${ extractExecSyncErrorMessage ( error ) ? ? String ( error ) } ` ,
) ;
}
}
2026-03-10 16:52:26 -05:00
const worktreeArgs = resolveGitWorktreeAddArgs ( {
branchName : name ,
targetPath ,
2026-03-13 07:24:39 -05:00
branchExists : ! startPoint && localBranchExists ( sourceCwd , name ) ,
startPoint ,
2026-03-10 16:52:26 -05:00
} ) ;
const spinner = p . spinner ( ) ;
spinner . start ( ` Creating git worktree at ${ targetPath } ... ` ) ;
try {
execFileSync ( "git" , worktreeArgs , {
cwd : sourceCwd ,
stdio : [ "ignore" , "pipe" , "pipe" ] ,
} ) ;
spinner . stop ( ` Created git worktree at ${ targetPath } . ` ) ;
} catch ( error ) {
spinner . stop ( pc . red ( "Failed to create git worktree." ) ) ;
throw new Error ( extractExecSyncErrorMessage ( error ) ? ? String ( error ) ) ;
}
2026-03-11 09:31:41 -05:00
const installSpinner = p . spinner ( ) ;
installSpinner . start ( "Installing dependencies..." ) ;
try {
execFileSync ( "pnpm" , [ "install" ] , {
cwd : targetPath ,
stdio : [ "ignore" , "pipe" , "pipe" ] ,
} ) ;
installSpinner . stop ( "Installed dependencies." ) ;
} catch ( error ) {
installSpinner . stop ( pc . yellow ( "Failed to install dependencies (continuing anyway)." ) ) ;
p . log . warning ( extractExecSyncErrorMessage ( error ) ? ? String ( error ) ) ;
}
2026-03-10 16:52:26 -05:00
const originalCwd = process . cwd ( ) ;
try {
process . chdir ( targetPath ) ;
await runWorktreeInit ( {
. . . opts ,
name ,
2026-03-13 14:24:06 -05:00
sourceConfigPathOverride : sourceConfigPath ,
2026-03-10 16:52:26 -05:00
} ) ;
} catch ( error ) {
throw error ;
} finally {
process . chdir ( originalCwd ) ;
}
}
2026-03-13 07:24:39 -05:00
type WorktreeCleanupOptions = {
instance? : string ;
home? : string ;
force? : boolean ;
} ;
type GitWorktreeListEntry = {
worktree : string ;
branch : string | null ;
bare : boolean ;
detached : boolean ;
} ;
function parseGitWorktreeList ( cwd : string ) : GitWorktreeListEntry [ ] {
const raw = execFileSync ( "git" , [ "worktree" , "list" , "--porcelain" ] , {
cwd ,
encoding : "utf8" ,
stdio : [ "ignore" , "pipe" , "pipe" ] ,
} ) ;
const entries : GitWorktreeListEntry [ ] = [ ] ;
let current : Partial < GitWorktreeListEntry > = { } ;
for ( const line of raw . split ( "\n" ) ) {
if ( line . startsWith ( "worktree " ) ) {
current = { worktree : line.slice ( "worktree " . length ) } ;
} else if ( line . startsWith ( "branch " ) ) {
current . branch = line . slice ( "branch " . length ) ;
} else if ( line === "bare" ) {
current . bare = true ;
} else if ( line === "detached" ) {
current . detached = true ;
} else if ( line === "" && current . worktree ) {
entries . push ( {
worktree : current.worktree ,
branch : current.branch ? ? null ,
bare : current.bare ? ? false ,
detached : current.detached ? ? false ,
} ) ;
current = { } ;
}
}
if ( current . worktree ) {
entries . push ( {
worktree : current.worktree ,
branch : current.branch ? ? null ,
bare : current.bare ? ? false ,
detached : current.detached ? ? false ,
} ) ;
}
return entries ;
}
function branchHasUniqueCommits ( cwd : string , branchName : string ) : boolean {
try {
const output = execFileSync (
"git" ,
[ "log" , "--oneline" , branchName , "--not" , "--remotes" , "--exclude" , ` refs/heads/ ${ branchName } ` , "--branches" ] ,
{ cwd , encoding : "utf8" , stdio : [ "ignore" , "pipe" , "pipe" ] } ,
) . trim ( ) ;
return output . length > 0 ;
} catch {
return false ;
}
}
function branchExistsOnAnyRemote ( cwd : string , branchName : string ) : boolean {
try {
const output = execFileSync (
"git" ,
[ "branch" , "-r" , "--list" , ` */ ${ branchName } ` ] ,
{ cwd , encoding : "utf8" , stdio : [ "ignore" , "pipe" , "pipe" ] } ,
) . trim ( ) ;
return output . length > 0 ;
} catch {
return false ;
}
}
function worktreePathHasUncommittedChanges ( worktreePath : string ) : boolean {
try {
const output = execFileSync (
"git" ,
[ "status" , "--porcelain" ] ,
{ cwd : worktreePath , encoding : "utf8" , stdio : [ "ignore" , "pipe" , "pipe" ] } ,
) . trim ( ) ;
return output . length > 0 ;
} catch {
return false ;
}
}
export async function worktreeCleanupCommand ( nameArg : string , opts : WorktreeCleanupOptions ) : Promise < void > {
printPaperclipCliBanner ( ) ;
p . intro ( pc . bgCyan ( pc . black ( " paperclipai worktree:cleanup " ) ) ) ;
const name = resolveWorktreeMakeName ( nameArg ) ;
const sourceCwd = process . cwd ( ) ;
const targetPath = resolveWorktreeMakeTargetPath ( name ) ;
const instanceId = sanitizeWorktreeInstanceId ( opts . instance ? ? name ) ;
const homeDir = path . resolve ( expandHomePrefix ( resolveWorktreeHome ( opts . home ) ) ) ;
const instanceRoot = path . resolve ( homeDir , "instances" , instanceId ) ;
// ── 1. Assess current state ──────────────────────────────────────────
const hasBranch = localBranchExists ( sourceCwd , name ) ;
const hasTargetDir = existsSync ( targetPath ) ;
const hasInstanceData = existsSync ( instanceRoot ) ;
const worktrees = parseGitWorktreeList ( sourceCwd ) ;
const linkedWorktree = worktrees . find (
( wt ) = > wt . branch === ` refs/heads/ ${ name } ` || path . resolve ( wt . worktree ) === path . resolve ( targetPath ) ,
) ;
if ( ! hasBranch && ! hasTargetDir && ! hasInstanceData && ! linkedWorktree ) {
p . log . info ( "Nothing to clean up — no branch, worktree directory, or instance data found." ) ;
p . outro ( pc . green ( "Already clean." ) ) ;
return ;
}
// ── 2. Safety checks ────────────────────────────────────────────────
const problems : string [ ] = [ ] ;
if ( hasBranch && branchHasUniqueCommits ( sourceCwd , name ) ) {
const onRemote = branchExistsOnAnyRemote ( sourceCwd , name ) ;
if ( onRemote ) {
p . log . info (
` Branch " ${ name } " has unique local commits, but the branch also exists on a remote — safe to delete locally. ` ,
) ;
} else {
problems . push (
` Branch " ${ name } " has commits not found on any other branch or remote. ` +
` Deleting it will lose work. Push it first, or use --force. ` ,
) ;
}
}
if ( hasTargetDir && worktreePathHasUncommittedChanges ( targetPath ) ) {
problems . push (
` Worktree directory ${ targetPath } has uncommitted changes. Commit or stash first, or use --force. ` ,
) ;
}
if ( problems . length > 0 && ! opts . force ) {
for ( const problem of problems ) {
p . log . error ( problem ) ;
}
throw new Error ( "Safety checks failed. Resolve the issues above or re-run with --force." ) ;
}
if ( problems . length > 0 && opts . force ) {
for ( const problem of problems ) {
p . log . warning ( ` Overridden by --force: ${ problem } ` ) ;
}
}
// ── 3. Clean up (idempotent steps) ──────────────────────────────────
// 3a. Remove the git worktree registration
if ( linkedWorktree ) {
const worktreeDirExists = existsSync ( linkedWorktree . worktree ) ;
const spinner = p . spinner ( ) ;
if ( worktreeDirExists ) {
spinner . start ( ` Removing git worktree at ${ linkedWorktree . worktree } ... ` ) ;
try {
const removeArgs = [ "worktree" , "remove" , linkedWorktree . worktree ] ;
if ( opts . force ) removeArgs . push ( "--force" ) ;
execFileSync ( "git" , removeArgs , {
cwd : sourceCwd ,
stdio : [ "ignore" , "pipe" , "pipe" ] ,
} ) ;
spinner . stop ( ` Removed git worktree at ${ linkedWorktree . worktree } . ` ) ;
} catch ( error ) {
spinner . stop ( pc . yellow ( ` Could not remove worktree cleanly, will prune instead. ` ) ) ;
p . log . warning ( extractExecSyncErrorMessage ( error ) ? ? String ( error ) ) ;
}
} else {
spinner . start ( "Pruning stale worktree entry..." ) ;
execFileSync ( "git" , [ "worktree" , "prune" ] , {
cwd : sourceCwd ,
stdio : [ "ignore" , "pipe" , "pipe" ] ,
} ) ;
spinner . stop ( "Pruned stale worktree entry." ) ;
}
} else {
// Even without a linked worktree, prune to clean up any orphaned entries
execFileSync ( "git" , [ "worktree" , "prune" ] , {
cwd : sourceCwd ,
stdio : [ "ignore" , "pipe" , "pipe" ] ,
} ) ;
}
// 3b. Remove the worktree directory if it still exists (e.g. partial creation)
if ( existsSync ( targetPath ) ) {
const spinner = p . spinner ( ) ;
spinner . start ( ` Removing worktree directory ${ targetPath } ... ` ) ;
rmSync ( targetPath , { recursive : true , force : true } ) ;
spinner . stop ( ` Removed worktree directory ${ targetPath } . ` ) ;
}
// 3c. Delete the local branch (now safe — worktree is gone)
if ( localBranchExists ( sourceCwd , name ) ) {
const spinner = p . spinner ( ) ;
spinner . start ( ` Deleting local branch " ${ name } "... ` ) ;
try {
const deleteFlag = opts . force ? "-D" : "-d" ;
execFileSync ( "git" , [ "branch" , deleteFlag , name ] , {
cwd : sourceCwd ,
stdio : [ "ignore" , "pipe" , "pipe" ] ,
} ) ;
spinner . stop ( ` Deleted local branch " ${ name } ". ` ) ;
} catch ( error ) {
spinner . stop ( pc . yellow ( ` Could not delete branch " ${ name } ". ` ) ) ;
p . log . warning ( extractExecSyncErrorMessage ( error ) ? ? String ( error ) ) ;
}
}
// 3d. Remove instance data
if ( existsSync ( instanceRoot ) ) {
const spinner = p . spinner ( ) ;
spinner . start ( ` Removing instance data at ${ instanceRoot } ... ` ) ;
rmSync ( instanceRoot , { recursive : true , force : true } ) ;
spinner . stop ( ` Removed instance data at ${ instanceRoot } . ` ) ;
}
p . outro ( pc . green ( "Cleanup complete." ) ) ;
}
2026-03-10 10:08:13 -05:00
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 ) ) ;
}
2026-03-20 15:02:24 -05:00
type ClosableDb = ReturnType < typeof createDb > & {
$client ? : { end ? : ( opts ? : { timeout? : number } ) = > Promise < void > } ;
} ;
type OpenDbHandle = {
db : ClosableDb ;
stop : ( ) = > Promise < void > ;
} ;
type ResolvedMergeCompany = {
id : string ;
name : string ;
issuePrefix : string ;
} ;
function requirePathArgument ( name : string , value : string | undefined ) : string {
const trimmed = nonEmpty ( value ) ;
if ( ! trimmed ) {
throw new Error ( ` ${ name } is required. ` ) ;
}
return path . resolve ( trimmed ) ;
}
async function closeDb ( db : ClosableDb ) : Promise < void > {
await db . $client ? . end ? . ( { timeout : 5 } ) . catch ( ( ) = > undefined ) ;
}
async function openConfiguredDb ( configPath : string ) : Promise < OpenDbHandle > {
const config = readConfig ( configPath ) ;
if ( ! config ) {
throw new Error ( ` Config not found at ${ configPath } . ` ) ;
}
const envEntries = readPaperclipEnvEntries ( resolvePaperclipEnvFile ( configPath ) ) ;
let embeddedHandle : EmbeddedPostgresHandle | null = null ;
try {
if ( config . database . mode === "embedded-postgres" ) {
embeddedHandle = await ensureEmbeddedPostgres (
config . database . embeddedPostgresDataDir ,
config . database . embeddedPostgresPort ,
) ;
}
const connectionString = resolveSourceConnectionString ( config , envEntries , embeddedHandle ? . port ) ;
const db = createDb ( connectionString ) as ClosableDb ;
return {
db ,
stop : async ( ) = > {
await closeDb ( db ) ;
if ( embeddedHandle ? . startedByThisProcess ) {
await embeddedHandle . stop ( ) ;
}
} ,
} ;
} catch ( error ) {
if ( embeddedHandle ? . startedByThisProcess ) {
await embeddedHandle . stop ( ) . catch ( ( ) = > undefined ) ;
}
throw error ;
}
}
async function resolveMergeCompany ( input : {
sourceDb : ClosableDb ;
targetDb : ClosableDb ;
selector? : string ;
} ) : Promise < ResolvedMergeCompany > {
const [ sourceCompanies , targetCompanies ] = await Promise . all ( [
input . sourceDb
. select ( {
id : companies.id ,
name : companies.name ,
issuePrefix : companies.issuePrefix ,
} )
. from ( companies ) ,
input . targetDb
. select ( {
id : companies.id ,
name : companies.name ,
issuePrefix : companies.issuePrefix ,
} )
. from ( companies ) ,
] ) ;
const targetById = new Map ( targetCompanies . map ( ( company ) = > [ company . id , company ] ) ) ;
const shared = sourceCompanies . filter ( ( company ) = > targetById . has ( company . id ) ) ;
const selector = nonEmpty ( input . selector ) ;
if ( selector ) {
const matched = shared . find (
( company ) = > company . id === selector || company . issuePrefix . toLowerCase ( ) === selector . toLowerCase ( ) ,
) ;
if ( ! matched ) {
throw new Error ( ` Could not resolve company " ${ selector } " in both source and target databases. ` ) ;
}
return matched ;
}
if ( shared . length === 1 ) {
return shared [ 0 ] ;
}
if ( shared . length === 0 ) {
throw new Error ( "Source and target databases do not share a company id. Pass --company explicitly once both sides match." ) ;
}
const options = shared
. map ( ( company ) = > ` ${ company . issuePrefix } ( ${ company . name } ) ` )
. join ( ", " ) ;
throw new Error ( ` Multiple shared companies found. Re-run with --company <id-or-prefix>. Options: ${ options } ` ) ;
}
function renderMergePlan ( plan : Awaited < ReturnType < typeof collectMergePlan > > [ "plan" ] , extras : {
sourcePath : string ;
unsupportedRunCount : number ;
unsupportedDocumentCount : number ;
} ) : string {
const lines = [
` Mode: preview ` ,
` Source: ${ extras . sourcePath } ` ,
` Company: ${ plan . companyName } ( ${ plan . issuePrefix } ) ` ,
"" ,
"Issues" ,
` - insert: ${ plan . counts . issuesToInsert } ` ,
` - already present: ${ plan . counts . issuesExisting } ` ,
` - shared/imported issues with drift: ${ plan . counts . issueDrift } ` ,
] ;
const issueInserts = plan . issuePlans . filter ( ( item ) : item is PlannedIssueInsert = > item . action === "insert" ) ;
if ( issueInserts . length > 0 ) {
lines . push ( "" ) ;
lines . push ( "Planned issue imports" ) ;
for ( const issue of issueInserts ) {
2026-03-20 15:13:35 -05:00
const projectNote =
issue . projectResolution === "mapped" && issue . mappedProjectName
? ` project-> ${ issue . mappedProjectName } `
: "" ;
2026-03-20 15:02:24 -05:00
const adjustments = issue . adjustments . length > 0 ? ` [ ${ issue . adjustments . join ( ", " ) } ] ` : "" ;
lines . push (
2026-03-20 15:13:35 -05:00
` - ${ issue . source . identifier ? ? issue . source . id } -> ${ issue . previewIdentifier } ( ${ issue . targetStatus } ${ projectNote } ) ${ adjustments } ` ,
2026-03-20 15:02:24 -05:00
) ;
}
}
if ( plan . scopes . includes ( "comments" ) ) {
lines . push ( "" ) ;
lines . push ( "Comments" ) ;
lines . push ( ` - insert: ${ plan . counts . commentsToInsert } ` ) ;
lines . push ( ` - already present: ${ plan . counts . commentsExisting } ` ) ;
lines . push ( ` - skipped (missing parent): ${ plan . counts . commentsMissingParent } ` ) ;
}
lines . push ( "" ) ;
lines . push ( "Adjustments" ) ;
lines . push ( ` - cleared assignee agents: ${ plan . adjustments . clear_assignee_agent } ` ) ;
lines . push ( ` - cleared projects: ${ plan . adjustments . clear_project } ` ) ;
lines . push ( ` - cleared project workspaces: ${ plan . adjustments . clear_project_workspace } ` ) ;
lines . push ( ` - cleared goals: ${ plan . adjustments . clear_goal } ` ) ;
lines . push ( ` - cleared comment author agents: ${ plan . adjustments . clear_author_agent } ` ) ;
lines . push ( ` - coerced in_progress to todo: ${ plan . adjustments . coerce_in_progress_to_todo } ` ) ;
lines . push ( "" ) ;
lines . push ( "Not imported in this phase" ) ;
lines . push ( ` - heartbeat runs: ${ extras . unsupportedRunCount } ` ) ;
lines . push ( ` - issue documents: ${ extras . unsupportedDocumentCount } ` ) ;
lines . push ( "" ) ;
lines . push ( "Identifiers shown above are provisional preview values. `--apply` reserves fresh issue numbers at write time." ) ;
return lines . join ( "\n" ) ;
}
async function collectMergePlan ( input : {
sourceDb : ClosableDb ;
targetDb : ClosableDb ;
company : ResolvedMergeCompany ;
scopes : ReturnType < typeof parseWorktreeMergeScopes > ;
2026-03-20 15:13:35 -05:00
projectIdOverrides? : Record < string , string | null | undefined > ;
2026-03-20 15:02:24 -05:00
} ) {
const companyId = input . company . id ;
2026-03-20 15:13:35 -05:00
const [ targetCompanyRow , sourceIssuesRows , targetIssuesRows , sourceCommentsRows , targetCommentsRows , sourceProjectsRows , targetProjectsRows , targetAgentsRows , targetProjectWorkspaceRows , targetGoalsRows , runCountRows , documentCountRows ] = await Promise . all ( [
2026-03-20 15:02:24 -05:00
input . targetDb
. select ( {
issueCounter : companies.issueCounter ,
} )
. from ( companies )
. where ( eq ( companies . id , companyId ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ,
input . sourceDb
. select ( )
. from ( issues )
. where ( eq ( issues . companyId , companyId ) ) ,
input . targetDb
. select ( )
. from ( issues )
. where ( eq ( issues . companyId , companyId ) ) ,
input . scopes . includes ( "comments" )
? input . sourceDb
. select ( )
. from ( issueComments )
. where ( eq ( issueComments . companyId , companyId ) )
: Promise . resolve ( [ ] ) ,
input . scopes . includes ( "comments" )
? input . targetDb
. select ( )
. from ( issueComments )
. where ( eq ( issueComments . companyId , companyId ) )
: Promise . resolve ( [ ] ) ,
2026-03-20 15:13:35 -05:00
input . sourceDb
2026-03-20 15:02:24 -05:00
. select ( )
2026-03-20 15:13:35 -05:00
. from ( projects )
. where ( eq ( projects . companyId , companyId ) ) ,
2026-03-20 15:02:24 -05:00
input . targetDb
. select ( )
. from ( projects )
. where ( eq ( projects . companyId , companyId ) ) ,
2026-03-20 15:13:35 -05:00
input . targetDb
. select ( )
. from ( agents )
. where ( eq ( agents . companyId , companyId ) ) ,
2026-03-20 15:02:24 -05:00
input . targetDb
. select ( )
. from ( projectWorkspaces )
. where ( eq ( projectWorkspaces . companyId , companyId ) ) ,
input . targetDb
. select ( )
. from ( goals )
. where ( eq ( goals . companyId , companyId ) ) ,
input . sourceDb
. select ( { count : sql < number > ` count(*)::int ` } )
. from ( heartbeatRuns )
. where ( eq ( heartbeatRuns . companyId , companyId ) ) ,
input . sourceDb
. select ( { count : sql < number > ` count(*)::int ` } )
. from ( issueDocuments )
. innerJoin ( issues , eq ( issueDocuments . issueId , issues . id ) )
. where ( eq ( issues . companyId , companyId ) ) ,
] ) ;
if ( ! targetCompanyRow ) {
throw new Error ( ` Target company ${ companyId } was not found. ` ) ;
}
const plan = buildWorktreeMergePlan ( {
companyId ,
companyName : input.company.name ,
issuePrefix : input.company.issuePrefix ,
previewIssueCounterStart : targetCompanyRow.issueCounter ,
scopes : input.scopes ,
sourceIssues : sourceIssuesRows ,
targetIssues : targetIssuesRows ,
sourceComments : sourceCommentsRows ,
targetComments : targetCommentsRows ,
targetAgents : targetAgentsRows ,
targetProjects : targetProjectsRows ,
targetProjectWorkspaces : targetProjectWorkspaceRows ,
targetGoals : targetGoalsRows ,
2026-03-20 15:13:35 -05:00
projectIdOverrides : input.projectIdOverrides ,
2026-03-20 15:02:24 -05:00
} ) ;
return {
plan ,
2026-03-20 15:13:35 -05:00
sourceProjects : sourceProjectsRows ,
targetProjects : targetProjectsRows ,
2026-03-20 15:02:24 -05:00
unsupportedRunCount : runCountRows [ 0 ] ? . count ? ? 0 ,
unsupportedDocumentCount : documentCountRows [ 0 ] ? . count ? ? 0 ,
} ;
}
2026-03-20 15:13:35 -05:00
async function promptForProjectMappings ( input : {
plan : Awaited < ReturnType < typeof collectMergePlan > > [ "plan" ] ;
sourceProjects : Awaited < ReturnType < typeof collectMergePlan > > [ "sourceProjects" ] ;
targetProjects : Awaited < ReturnType < typeof collectMergePlan > > [ "targetProjects" ] ;
} ) : Promise < Record < string , string | null > > {
const missingProjectIds = [
. . . new Set (
input . plan . issuePlans
. filter ( ( plan ) : plan is PlannedIssueInsert = > plan . action === "insert" )
. filter ( ( plan ) = > ! ! plan . source . projectId && plan . projectResolution === "cleared" )
. map ( ( plan ) = > plan . source . projectId as string ) ,
) ,
] ;
if ( missingProjectIds . length === 0 || input . targetProjects . length === 0 ) {
return { } ;
}
const sourceProjectsById = new Map ( input . sourceProjects . map ( ( project ) = > [ project . id , project ] ) ) ;
const targetChoices = [ . . . input . targetProjects ]
. sort ( ( left , right ) = > left . name . localeCompare ( right . name ) )
. map ( ( project ) = > ( {
value : project.id ,
label : project.name ,
hint : project.status ,
} ) ) ;
const mappings : Record < string , string | null > = { } ;
for ( const sourceProjectId of missingProjectIds ) {
const sourceProject = sourceProjectsById . get ( sourceProjectId ) ;
if ( ! sourceProject ) continue ;
const nameMatch = input . targetProjects . find (
( project ) = > project . name . trim ( ) . toLowerCase ( ) === sourceProject . name . trim ( ) . toLowerCase ( ) ,
) ;
const selection = await p . select < string | null > ( {
message : ` Project " ${ sourceProject . name } " is missing in target. How should ${ input . plan . issuePrefix } imports handle it? ` ,
options : [
. . . ( nameMatch
? [ {
value : nameMatch.id ,
label : ` Map to ${ nameMatch . name } ` ,
hint : "Recommended: exact name match" ,
} ]
: [ ] ) ,
{
value : null ,
label : "Leave unset" ,
hint : "Keep imported issues without a project" ,
} ,
. . . targetChoices . filter ( ( choice ) = > choice . value !== nameMatch ? . id ) ,
] ,
initialValue : nameMatch?.id ? ? null ,
} ) ;
if ( p . isCancel ( selection ) ) {
throw new Error ( "Project mapping cancelled." ) ;
}
mappings [ sourceProjectId ] = selection ;
}
return mappings ;
}
2026-03-20 15:02:24 -05:00
async function applyMergePlan ( input : {
targetDb : ClosableDb ;
company : ResolvedMergeCompany ;
plan : Awaited < ReturnType < typeof collectMergePlan > > [ "plan" ] ;
} ) {
const companyId = input . company . id ;
return await input . targetDb . transaction ( async ( tx ) = > {
const issueCandidates = input . plan . issuePlans . filter (
( plan ) : plan is PlannedIssueInsert = > plan . action === "insert" ,
) ;
const issueCandidateIds = issueCandidates . map ( ( issue ) = > issue . source . id ) ;
const existingIssueIds = issueCandidateIds . length > 0
? new Set (
( await tx
. select ( { id : issues.id } )
. from ( issues )
. where ( inArray ( issues . id , issueCandidateIds ) ) )
. map ( ( row ) = > row . id ) ,
)
: new Set < string > ( ) ;
const issueInserts = issueCandidates . filter ( ( issue ) = > ! existingIssueIds . has ( issue . source . id ) ) ;
let nextIssueNumber = 0 ;
if ( issueInserts . length > 0 ) {
const [ companyRow ] = await tx
. update ( companies )
. set ( { issueCounter : sql ` ${ companies . issueCounter } + ${ issueInserts . length } ` } )
. where ( eq ( companies . id , companyId ) )
. returning ( { issueCounter : companies.issueCounter } ) ;
nextIssueNumber = companyRow . issueCounter - issueInserts . length + 1 ;
}
const insertedIssueIdentifiers = new Map < string , string > ( ) ;
for ( const issue of issueInserts ) {
const issueNumber = nextIssueNumber ;
nextIssueNumber += 1 ;
const identifier = ` ${ input . company . issuePrefix } - ${ issueNumber } ` ;
insertedIssueIdentifiers . set ( issue . source . id , identifier ) ;
await tx . insert ( issues ) . values ( {
id : issue.source.id ,
companyId ,
projectId : issue.targetProjectId ,
projectWorkspaceId : issue.targetProjectWorkspaceId ,
goalId : issue.targetGoalId ,
parentId : issue.source.parentId ,
title : issue.source.title ,
description : issue.source.description ,
status : issue.targetStatus ,
priority : issue.source.priority ,
assigneeAgentId : issue.targetAssigneeAgentId ,
assigneeUserId : issue.source.assigneeUserId ,
checkoutRunId : null ,
executionRunId : null ,
executionAgentNameKey : null ,
executionLockedAt : null ,
createdByAgentId : issue.targetCreatedByAgentId ,
createdByUserId : issue.source.createdByUserId ,
issueNumber ,
identifier ,
requestDepth : issue.source.requestDepth ,
billingCode : issue.source.billingCode ,
assigneeAdapterOverrides : issue.targetAssigneeAgentId ? issue.source.assigneeAdapterOverrides : null ,
executionWorkspaceId : null ,
executionWorkspacePreference : null ,
executionWorkspaceSettings : null ,
startedAt : issue.source.startedAt ,
completedAt : issue.source.completedAt ,
cancelledAt : issue.source.cancelledAt ,
hiddenAt : issue.source.hiddenAt ,
createdAt : issue.source.createdAt ,
updatedAt : issue.source.updatedAt ,
} ) ;
}
const commentCandidates = input . plan . commentPlans . filter (
( plan ) : plan is PlannedCommentInsert = > plan . action === "insert" ,
) ;
const commentCandidateIds = commentCandidates . map ( ( comment ) = > comment . source . id ) ;
const existingCommentIds = commentCandidateIds . length > 0
? new Set (
( await tx
. select ( { id : issueComments.id } )
. from ( issueComments )
. where ( inArray ( issueComments . id , commentCandidateIds ) ) )
. map ( ( row ) = > row . id ) ,
)
: new Set < string > ( ) ;
for ( const comment of commentCandidates ) {
if ( existingCommentIds . has ( comment . source . id ) ) continue ;
const parentExists = await tx
. select ( { id : issues.id } )
. from ( issues )
. where ( and ( eq ( issues . id , comment . source . issueId ) , eq ( issues . companyId , companyId ) ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
if ( ! parentExists ) continue ;
await tx . insert ( issueComments ) . values ( {
id : comment.source.id ,
companyId ,
issueId : comment.source.issueId ,
authorAgentId : comment.targetAuthorAgentId ,
authorUserId : comment.source.authorUserId ,
body : comment.source.body ,
createdAt : comment.source.createdAt ,
updatedAt : comment.source.updatedAt ,
} ) ;
}
return {
insertedIssues : issueInserts.length ,
insertedComments : commentCandidates.filter ( ( comment ) = > ! existingCommentIds . has ( comment . source . id ) ) . length ,
insertedIssueIdentifiers ,
} ;
} ) ;
}
export async function worktreeMergeHistoryCommand ( sourceArg : string , opts : WorktreeMergeHistoryOptions ) : Promise < void > {
if ( opts . apply && opts . dry ) {
throw new Error ( "Use either --apply or --dry, not both." ) ;
}
const sourceRoot = requirePathArgument ( "Source worktree path" , sourceArg ) ;
const sourceConfigPath = path . resolve ( sourceRoot , ".paperclip" , "config.json" ) ;
if ( ! existsSync ( sourceConfigPath ) ) {
throw new Error ( ` Source worktree config not found at ${ sourceConfigPath } . ` ) ;
}
const targetConfigPath = resolveConfigPath ( ) ;
if ( path . resolve ( sourceConfigPath ) === path . resolve ( targetConfigPath ) ) {
throw new Error ( "Source and target Paperclip configs are the same. Point --source at a different worktree." ) ;
}
const scopes = parseWorktreeMergeScopes ( opts . scope ) ;
const sourceHandle = await openConfiguredDb ( sourceConfigPath ) ;
const targetHandle = await openConfiguredDb ( targetConfigPath ) ;
try {
const company = await resolveMergeCompany ( {
sourceDb : sourceHandle.db ,
targetDb : targetHandle.db ,
selector : opts.company ,
} ) ;
2026-03-20 15:13:35 -05:00
let collected = await collectMergePlan ( {
2026-03-20 15:02:24 -05:00
sourceDb : sourceHandle.db ,
targetDb : targetHandle.db ,
company ,
scopes ,
} ) ;
2026-03-20 15:13:35 -05:00
if ( ! opts . yes ) {
const projectIdOverrides = await promptForProjectMappings ( {
plan : collected.plan ,
sourceProjects : collected.sourceProjects ,
targetProjects : collected.targetProjects ,
} ) ;
if ( Object . keys ( projectIdOverrides ) . length > 0 ) {
collected = await collectMergePlan ( {
sourceDb : sourceHandle.db ,
targetDb : targetHandle.db ,
company ,
scopes ,
projectIdOverrides ,
} ) ;
}
}
2026-03-20 15:02:24 -05:00
console . log ( renderMergePlan ( collected . plan , {
sourcePath : sourceRoot ,
unsupportedRunCount : collected.unsupportedRunCount ,
unsupportedDocumentCount : collected.unsupportedDocumentCount ,
} ) ) ;
if ( ! opts . apply ) {
return ;
}
const confirmed = opts . yes
? true
: await p . confirm ( {
message : ` Import ${ collected . plan . counts . issuesToInsert } issues and ${ collected . plan . counts . commentsToInsert } comments from ${ path . basename ( sourceRoot ) } ? ` ,
initialValue : false ,
} ) ;
if ( p . isCancel ( confirmed ) || ! confirmed ) {
p . log . warn ( "Import cancelled." ) ;
return ;
}
const applied = await applyMergePlan ( {
targetDb : targetHandle.db ,
company ,
plan : collected.plan ,
} ) ;
p . outro (
pc . green (
` Imported ${ applied . insertedIssues } issues and ${ applied . insertedComments } comments into ${ company . issuePrefix } . ` ,
) ,
) ;
} finally {
await targetHandle . stop ( ) ;
await sourceHandle . stop ( ) ;
}
}
2026-03-10 10:08:13 -05:00
export function registerWorktreeCommands ( program : Command ) : void {
const worktree = program . command ( "worktree" ) . description ( "Worktree-local Paperclip instance helpers" ) ;
2026-03-10 16:52:26 -05:00
program
. command ( "worktree:make" )
. description ( "Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it" )
2026-03-13 07:24:39 -05:00
. argument ( "<name>" , "Worktree name — auto-prefixed with paperclip- if needed (created at ~/paperclip-NAME)" )
. option ( "--start-point <ref>" , "Remote ref to base the new branch on (env: PAPERCLIP_WORKTREE_START_POINT)" )
2026-03-10 16:52:26 -05:00
. option ( "--instance <id>" , "Explicit isolated instance id" )
2026-03-13 07:24:39 -05:00
. option ( "--home <path>" , ` Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${ DEFAULT_WORKTREE_HOME } ) ` )
2026-03-10 16:52:26 -05:00
. 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 ( "--seed-mode <mode>" , "Seed profile: minimal or full (default: minimal)" , "minimal" )
. option ( "--no-seed" , "Skip database seeding from the source instance" )
. option ( "--force" , "Replace existing repo-local config and isolated instance data" , false )
. action ( worktreeMakeCommand ) ;
2026-03-10 10:08:13 -05:00
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" )
2026-03-13 07:24:39 -05:00
. option ( "--home <path>" , ` Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${ DEFAULT_WORKTREE_HOME } ) ` )
2026-03-10 10:08:13 -05:00
. 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 ) )
2026-03-10 07:41:01 -05:00
. option ( "--seed-mode <mode>" , "Seed profile: minimal or full (default: minimal)" , "minimal" )
2026-03-10 10:08:13 -05:00
. 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 ) ;
2026-03-13 07:24:39 -05:00
2026-03-20 15:02:24 -05:00
program
. command ( "worktree:merge-history" )
. description ( "Preview or import issue/comment history from another worktree into the current instance" )
. argument ( "<source>" , "Path to the source worktree root" )
. option ( "--company <id-or-prefix>" , "Company id or issue prefix to import" )
. option ( "--scope <items>" , "Comma-separated scopes to import (issues, comments)" , "issues,comments" )
. option ( "--apply" , "Apply the import after previewing the plan" , false )
. option ( "--dry" , "Preview only and do not import anything" , false )
. option ( "--yes" , "Skip the interactive confirmation prompt when applying" , false )
. action ( worktreeMergeHistoryCommand ) ;
2026-03-13 07:24:39 -05:00
program
. command ( "worktree:cleanup" )
. description ( "Safely remove a worktree, its branch, and its isolated instance data" )
. argument ( "<name>" , "Worktree name — auto-prefixed with paperclip- if needed" )
. option ( "--instance <id>" , "Explicit instance id (if different from the worktree name)" )
. option ( "--home <path>" , ` Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${ DEFAULT_WORKTREE_HOME } ) ` )
. option ( "--force" , "Bypass safety checks (uncommitted changes, unique commits)" , false )
. action ( worktreeCleanupCommand ) ;
2026-03-10 10:08:13 -05:00
}