paperclip / scripts /provision-worktree.sh
cjovs's picture
Deploy Paperclip CN to Hugging Face Space
96e86e5
#!/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
)