#!/usr/bin/env bash set -euo pipefail to_shell_path() { local raw="${1:-}" if [[ -z "$raw" ]]; then printf '%s\n' "" return 0 fi if command -v cygpath >/dev/null 2>&1; then cygpath -u "$raw" return 0 fi if [[ "$raw" =~ ^([A-Za-z]):[\\/](.*)$ ]]; then local drive local rest drive="$(printf '%s' "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]')" rest="${BASH_REMATCH[2]}" rest="${rest//\\//}" printf '/mnt/%s/%s\n' "$drive" "$rest" return 0 fi printf '%s\n' "$raw" } should_force_fallback_config() { case "${PAPERCLIP_WORKTREE_FORCE_FALLBACK_CONFIG:-}" in 1|true|TRUE|yes|YES) return 0 ;; *) return 1 ;; esac } base_cwd_raw="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is required}" worktree_cwd_raw="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}" base_cwd="$(to_shell_path "$base_cwd_raw")" worktree_cwd="$(to_shell_path "$worktree_cwd_raw")" paperclip_home_raw="${PAPERCLIP_HOME:-$HOME/.paperclip}" paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}" paperclip_dir="$worktree_cwd/.paperclip" paperclip_dir_raw="$worktree_cwd_raw/.paperclip" worktree_config_path_raw="$paperclip_dir_raw/config.json" worktree_env_path_raw="$paperclip_dir_raw/.env" worktree_name="${PAPERCLIP_WORKSPACE_BRANCH:-$(basename "$worktree_cwd_raw")}" if [[ ! -d "$base_cwd" ]]; then echo "Base workspace does not exist: $base_cwd_raw" >&2 exit 1 fi if [[ ! -d "$worktree_cwd" ]]; then echo "Derived worktree does not exist: $worktree_cwd_raw" >&2 exit 1 fi source_config_path_raw="${PAPERCLIP_CONFIG:-}" source_config_path_shell="$(to_shell_path "$source_config_path_raw")" if [[ -z "$source_config_path_raw" && ( -e "$base_cwd/.paperclip/config.json" || -L "$base_cwd/.paperclip/config.json" ) ]]; then source_config_path_raw="$base_cwd_raw/.paperclip/config.json" source_config_path_shell="$base_cwd/.paperclip/config.json" fi if [[ -z "$source_config_path_raw" ]]; then source_config_path_raw="$paperclip_home_raw/instances/$paperclip_instance_id/config.json" source_config_path_shell="$(to_shell_path "$source_config_path_raw")" fi source_env_path_raw="$(dirname "$source_config_path_raw")/.env" source_env_path_shell="$(dirname "$source_config_path_shell")/.env" mkdir -p "$paperclip_dir" resolve_node_command() { if [[ "$base_cwd_raw" =~ ^[A-Za-z]:[\\/] || "$worktree_cwd_raw" =~ ^[A-Za-z]:[\\/] ]]; then if command -v node.exe >/dev/null 2>&1; then printf '%s\n' "node.exe" return 0 fi fi printf '%s\n' "node" } has_penclip_script_in_manifest() { local manifest_path="${1:-}" [[ -f "$manifest_path" ]] || return 1 grep -Eq '"penclip"[[:space:]]*:' "$manifest_path" } resolved_penclip_invoker="" resolved_penclip_cwd="" resolved_penclip_node_command="" resolved_penclip_tsx_path="" resolved_penclip_entry_path="" resolve_penclip_invoker() { if [[ -n "$resolved_penclip_invoker" ]]; then return 0 fi if should_force_fallback_config; then resolved_penclip_invoker="none" return 0 fi local base_cli_tsx_path="$base_cwd/cli/node_modules/tsx/dist/cli.mjs" local base_cli_entry_path="$base_cwd/cli/src/index.ts" local node_command node_command="$(resolve_node_command)" if command -v "$node_command" >/dev/null 2>&1 && [[ -f "$base_cli_tsx_path" ]] && [[ -f "$base_cli_entry_path" ]]; then resolved_penclip_invoker="source" resolved_penclip_node_command="$node_command" resolved_penclip_tsx_path="$base_cli_tsx_path" resolved_penclip_entry_path="$base_cli_entry_path" return 0 fi local pnpm_cwd="" if has_penclip_script_in_manifest "$base_cwd/package.json"; then pnpm_cwd="$base_cwd" elif [[ "$worktree_cwd" != "$base_cwd" ]] && has_penclip_script_in_manifest "$worktree_cwd/package.json"; then pnpm_cwd="$worktree_cwd" fi if [[ -n "$pnpm_cwd" ]] && command -v pnpm >/dev/null 2>&1 && ( cd "$pnpm_cwd" && pnpm penclip --help >/dev/null 2>&1 ); then resolved_penclip_invoker="pnpm" resolved_penclip_cwd="$pnpm_cwd" return 0 fi if command -v penclip >/dev/null 2>&1; then resolved_penclip_invoker="global" return 0 fi resolved_penclip_invoker="none" } run_penclip_command() { local command_args=("$@") resolve_penclip_invoker case "$resolved_penclip_invoker" in source) "$resolved_penclip_node_command" "$resolved_penclip_tsx_path" "$resolved_penclip_entry_path" "${command_args[@]}" return $? ;; pnpm) ( cd "$resolved_penclip_cwd" && pnpm penclip "${command_args[@]}" ) return $? ;; global) penclip "${command_args[@]}" return $? ;; *) return 1 ;; esac } paperclipai_command_available() { if command -v pnpm >/dev/null 2>&1 && pnpm penclip --help >/dev/null 2>&1; then return 0 fi local base_cli_tsx_path="$base_cwd/cli/node_modules/tsx/dist/cli.mjs" local base_cli_entry_path="$base_cwd/cli/src/index.ts" if command -v node >/dev/null 2>&1 && [[ -f "$base_cli_tsx_path" ]] && [[ -f "$base_cli_entry_path" ]]; then return 0 fi if command -v penclip >/dev/null 2>&1; then return 0 fi return 1 } run_isolated_worktree_init() { run_penclip_command \ worktree \ init \ --force \ --seed-mode \ minimal \ --name \ "$worktree_name" \ --from-config \ "$source_config_path_raw" } write_fallback_worktree_config() { local node_command node_command="$(resolve_node_command)" "$node_command" \ - \ "$worktree_name" \ "$paperclip_dir_raw" \ "$source_config_path_raw" \ "$source_env_path_raw" \ "${PAPERCLIP_WORKTREES_DIR:-}" <<'EOF' const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const net = require("node:net"); function expandHomePrefix(value) { if (!value) return value; if (value === "~") return os.homedir(); if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); return value; } function nonEmpty(value) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } function sanitizeInstanceId(value) { const trimmed = String(value ?? "").trim().toLowerCase(); const normalized = trimmed .replace(/[^a-z0-9_-]+/g, "-") .replace(/-+/g, "-") .replace(/^[-_]+|[-_]+$/g, ""); return normalized || "worktree"; } function parseEnvFile(contents) { const entries = {}; for (const rawLine of contents.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); if (!match) continue; const [, key, rawValue] = match; const value = rawValue.trim(); if (!value) { entries[key] = ""; continue; } if ( (value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'")) ) { entries[key] = value.slice(1, -1); continue; } entries[key] = value.replace(/\s+#.*$/, "").trim(); } return entries; } async function findAvailablePort(preferredPort, reserved = new Set()) { const startPort = Number.isFinite(preferredPort) && preferredPort > 0 ? Math.trunc(preferredPort) : 0; if (startPort > 0) { for (let port = startPort; port < startPort + 100; port += 1) { if (reserved.has(port)) continue; const available = await new Promise((resolve) => { const server = net.createServer(); server.unref(); server.once("error", () => resolve(false)); server.listen(port, "127.0.0.1", () => { server.close(() => resolve(true)); }); }); if (available) return port; } } return await new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address === "string") { server.close(() => reject(new Error("Failed to allocate a port."))); return; } const port = address.port; server.close(() => resolve(port)); }); }); } function isLoopbackHost(hostname) { const value = hostname.trim().toLowerCase(); return value === "127.0.0.1" || value === "localhost" || value === "::1"; } function rewriteLocalUrlPort(rawUrl, port) { if (!rawUrl) return undefined; try { const parsed = new URL(rawUrl); if (!isLoopbackHost(parsed.hostname)) return rawUrl; parsed.port = String(port); return parsed.toString(); } catch { return rawUrl; } } function resolveRuntimeLikePath(value, configPath) { const expanded = expandHomePrefix(value); if (path.isAbsolute(expanded)) return expanded; return path.resolve(path.dirname(configPath), expanded); } async function main() { const [, , rawWorktreeName, rawPaperclipDir, rawSourceConfigPath, rawSourceEnvPath, rawWorktreeHome] = process.argv; const worktreeName = nonEmpty(rawWorktreeName) ?? "worktree"; const paperclipDir = nonEmpty(rawPaperclipDir); const sourceConfigPath = nonEmpty(rawSourceConfigPath); const sourceEnvPath = nonEmpty(rawSourceEnvPath); const worktreeHome = path.resolve(expandHomePrefix(nonEmpty(rawWorktreeHome) ?? "~/.paperclip-worktrees")); if (!paperclipDir) { throw new TypeError("paperclipDir is required"); } const instanceId = sanitizeInstanceId(worktreeName); const instanceRoot = path.resolve(worktreeHome, "instances", instanceId); const configPath = path.resolve(paperclipDir, "config.json"); const envPath = path.resolve(paperclipDir, ".env"); let sourceConfig = null; if (sourceConfigPath && fs.existsSync(sourceConfigPath)) { sourceConfig = JSON.parse(fs.readFileSync(sourceConfigPath, "utf8")); } const sourceEnvEntries = sourceEnvPath && fs.existsSync(sourceEnvPath) ? parseEnvFile(fs.readFileSync(sourceEnvPath, "utf8")) : {}; const preferredServerPort = Number(sourceConfig?.server?.port ?? 3101) + 1; const serverPort = await findAvailablePort(preferredServerPort); const preferredDbPort = Number(sourceConfig?.database?.embeddedPostgresPort ?? 54329) + 1; const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort])); fs.rmSync(configPath, { force: true }); fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.mkdirSync(instanceRoot, { recursive: true }); const authPublicBaseUrl = rewriteLocalUrlPort(sourceConfig?.auth?.publicBaseUrl, serverPort); const targetConfig = { $meta: { version: 1, updatedAt: new Date().toISOString(), source: "configure", }, ...(sourceConfig?.llm ? { llm: sourceConfig.llm } : {}), database: { mode: "embedded-postgres", embeddedPostgresDataDir: path.resolve(instanceRoot, "db"), embeddedPostgresPort: databasePort, backup: { enabled: sourceConfig?.database?.backup?.enabled ?? true, intervalMinutes: sourceConfig?.database?.backup?.intervalMinutes ?? 60, retentionDays: sourceConfig?.database?.backup?.retentionDays ?? 30, dir: path.resolve(instanceRoot, "data", "backups"), }, }, logging: { mode: sourceConfig?.logging?.mode ?? "file", logDir: path.resolve(instanceRoot, "logs"), }, server: { deploymentMode: sourceConfig?.server?.deploymentMode ?? "local_trusted", exposure: sourceConfig?.server?.exposure ?? "private", ...(sourceConfig?.server?.bind ? { bind: sourceConfig.server.bind } : {}), ...(sourceConfig?.server?.customBindHost ? { customBindHost: sourceConfig.server.customBindHost } : {}), host: sourceConfig?.server?.host ?? "127.0.0.1", port: serverPort, allowedHostnames: sourceConfig?.server?.allowedHostnames ?? [], serveUi: sourceConfig?.server?.serveUi ?? true, }, auth: { baseUrlMode: sourceConfig?.auth?.baseUrlMode ?? "auto", ...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}), disableSignUp: sourceConfig?.auth?.disableSignUp ?? false, }, storage: { provider: sourceConfig?.storage?.provider ?? "local_disk", localDisk: { baseDir: path.resolve(instanceRoot, "data", "storage"), }, s3: { bucket: sourceConfig?.storage?.s3?.bucket ?? "paperclip", region: sourceConfig?.storage?.s3?.region ?? "us-east-1", endpoint: sourceConfig?.storage?.s3?.endpoint, prefix: sourceConfig?.storage?.s3?.prefix ?? "", forcePathStyle: sourceConfig?.storage?.s3?.forcePathStyle ?? false, }, }, secrets: { provider: sourceConfig?.secrets?.provider ?? "local_encrypted", strictMode: sourceConfig?.secrets?.strictMode ?? false, localEncrypted: { keyFilePath: path.resolve(instanceRoot, "secrets", "master.key"), }, }, }; fs.writeFileSync(configPath, `${JSON.stringify(targetConfig, null, 2)}\n`, { mode: 0o600 }); const inlineMasterKey = nonEmpty(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY); if (inlineMasterKey) { fs.mkdirSync(path.resolve(instanceRoot, "secrets"), { recursive: true }); fs.writeFileSync(targetConfig.secrets.localEncrypted.keyFilePath, inlineMasterKey, { encoding: "utf8", mode: 0o600, }); } else { const sourceKeyFilePath = nonEmpty(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ? resolveRuntimeLikePath(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE, sourceConfigPath) : nonEmpty(sourceConfig?.secrets?.localEncrypted?.keyFilePath) ? resolveRuntimeLikePath(sourceConfig.secrets.localEncrypted.keyFilePath, sourceConfigPath) : null; if (sourceKeyFilePath && fs.existsSync(sourceKeyFilePath)) { fs.mkdirSync(path.resolve(instanceRoot, "secrets"), { recursive: true }); fs.copyFileSync(sourceKeyFilePath, targetConfig.secrets.localEncrypted.keyFilePath); fs.chmodSync(targetConfig.secrets.localEncrypted.keyFilePath, 0o600); } } const formatEnvValue = (value) => `"${String(value) .replace(/\r/g, "\\r") .replace(/\n/g, "\\n") .replace(/"/g, '\\"')}"`; const envLines = [ "PAPERCLIP_HOME=" + formatEnvValue(worktreeHome), "PAPERCLIP_INSTANCE_ID=" + formatEnvValue(instanceId), "PAPERCLIP_CONFIG=" + formatEnvValue(configPath), "PAPERCLIP_CONTEXT=" + formatEnvValue(path.resolve(worktreeHome, "context.json")), "PAPERCLIP_IN_WORKTREE=true", "PAPERCLIP_WORKTREE_NAME=" + formatEnvValue(worktreeName), ]; const agentJwtSecret = nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET); if (agentJwtSecret) { envLines.push("PAPERCLIP_AGENT_JWT_SECRET=" + formatEnvValue(agentJwtSecret)); } fs.writeFileSync(envPath, `${envLines.join("\n")}\n`, { mode: 0o600 }); } main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }); EOF } if [[ -e "$(to_shell_path "$worktree_config_path_raw")" && -e "$(to_shell_path "$worktree_env_path_raw")" ]]; then echo "Reusing existing isolated Paperclip worktree config at $worktree_config_path_raw" >&2 elif ! run_isolated_worktree_init; then resolve_penclip_invoker if [[ "$resolved_penclip_invoker" == "none" ]]; then echo "penclip CLI not available in this workspace; writing isolated fallback config without DB seeding." >&2 write_fallback_worktree_config else echo "penclip worktree init failed after CLI detection; refusing to write fallback config." >&2 exit 1 fi elif [[ ! -e "$(to_shell_path "$worktree_config_path_raw")" || ! -e "$(to_shell_path "$worktree_env_path_raw")" ]]; then echo "penclip worktree init did not materialize repo-local config; writing isolated fallback config." >&2 write_fallback_worktree_config fi disable_seeded_routines() { if should_force_fallback_config; then return 0 fi local company_id="${PAPERCLIP_COMPANY_ID:-}" if [[ -z "$company_id" ]]; then echo "PAPERCLIP_COMPANY_ID not set; skipping routine disable post-step." >&2 return 0 fi if ! run_penclip_command routines disable-all --config "$worktree_config_path_raw" --company-id "$company_id"; then echo "penclip CLI not available in this workspace; skipping routine disable post-step." >&2 fi } disable_seeded_routines list_base_node_modules_paths() { cd "$base_cwd" && find . \ -mindepth 1 \ -maxdepth 4 \ -type d \ -name node_modules \ ! -path './.git/*' \ ! -path './.paperclip/*' \ | sed 's#^\./##' } if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; then needs_install=0 while IFS= read -r relative_path; do [[ -n "$relative_path" ]] || continue target_path="$worktree_cwd/$relative_path" if [[ -L "$target_path" || ! -e "$target_path" ]]; then needs_install=1 break fi done < <(list_base_node_modules_paths) if [[ "$needs_install" -eq 1 ]]; then backup_suffix=".paperclip-backup-${BASHPID:-$$}" moved_symlink_paths=() while IFS= read -r relative_path; do [[ -n "$relative_path" ]] || continue target_path="$worktree_cwd/$relative_path" if [[ -L "$target_path" ]]; then backup_path="${target_path}${backup_suffix}" rm -rf "$backup_path" mv "$target_path" "$backup_path" moved_symlink_paths+=("$relative_path") fi done < <(list_base_node_modules_paths) restore_moved_symlinks() { local relative_path target_path backup_path [[ ${#moved_symlink_paths[@]} -gt 0 ]] || return 0 for relative_path in "${moved_symlink_paths[@]}"; do target_path="$worktree_cwd/$relative_path" backup_path="${target_path}${backup_suffix}" [[ -L "$backup_path" ]] || continue rm -rf "$target_path" mv "$backup_path" "$target_path" done } cleanup_moved_symlinks() { local relative_path target_path backup_path [[ ${#moved_symlink_paths[@]} -gt 0 ]] || return 0 for relative_path in "${moved_symlink_paths[@]}"; do target_path="$worktree_cwd/$relative_path" backup_path="${target_path}${backup_suffix}" [[ -L "$backup_path" ]] && rm "$backup_path" done } run_pnpm_install() { local stdout_path stderr_path stdout_path="$(mktemp)" stderr_path="$(mktemp)" if ( cd "$worktree_cwd" pnpm install "$@" ) >"$stdout_path" 2>"$stderr_path"; then cat "$stdout_path" cat "$stderr_path" >&2 rm -f "$stdout_path" "$stderr_path" return 0 fi local exit_code=$? cat "$stdout_path" cat "$stderr_path" >&2 if grep -q "ERR_PNPM_OUTDATED_LOCKFILE" "$stdout_path" "$stderr_path"; then rm -f "$stdout_path" "$stderr_path" return 90 fi rm -f "$stdout_path" "$stderr_path" return "$exit_code" } if run_pnpm_install --frozen-lockfile; then : else install_exit_code=$? if [[ "$install_exit_code" -eq 90 ]]; then echo "pnpm-lock.yaml is out of date in this execution workspace; retrying install without --frozen-lockfile." >&2 run_pnpm_install --no-frozen-lockfile || { restore_moved_symlinks exit 1 } else restore_moved_symlinks exit "$install_exit_code" fi fi cleanup_moved_symlinks fi exit 0 fi while IFS= read -r relative_path; do [[ -n "$relative_path" ]] || continue source_path="$base_cwd/$relative_path" target_path="$worktree_cwd/$relative_path" [[ -d "$source_path" ]] || continue [[ -e "$target_path" || -L "$target_path" ]] && continue mkdir -p "$(dirname "$target_path")" ln -s "$source_path" "$target_path" done < <( list_base_node_modules_paths )