mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
Fix worktree runtime isolation recovery
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
c74cda1851
commit
ab82e3f022
8 changed files with 715 additions and 2 deletions
357
server/src/worktree-config.ts
Normal file
357
server/src/worktree-config.ts
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { PaperclipConfig } from "@paperclipai/shared";
|
||||
import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js";
|
||||
|
||||
function nonEmpty(value: string | null | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveHomeAwarePath(value: string): string {
|
||||
return path.resolve(expandHomePrefix(value));
|
||||
}
|
||||
|
||||
function sanitizeWorktreeInstanceId(rawValue: string): string {
|
||||
const trimmed = rawValue.trim().toLowerCase();
|
||||
const normalized = trimmed
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^[-_]+|[-_]+$/g, "");
|
||||
return normalized || "worktree";
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
const value = hostname.trim().toLowerCase();
|
||||
return value === "127.0.0.1" || value === "localhost" || value === "::1";
|
||||
}
|
||||
|
||||
function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined {
|
||||
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 parseEnvFile(contents: string): Record<string, string> {
|
||||
const entries: Record<string, string> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function readEnvEntries(envPath: string): Record<string, string> {
|
||||
if (!fs.existsSync(envPath)) return {};
|
||||
return parseEnvFile(fs.readFileSync(envPath, "utf8"));
|
||||
}
|
||||
|
||||
function formatEnvEntries(entries: Record<string, string>): string {
|
||||
return [
|
||||
"# Paperclip environment variables",
|
||||
"# Generated by Paperclip worktree repair",
|
||||
...Object.entries(entries).map(([key, value]) => `${key}=${JSON.stringify(value)}`),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function isPathInside(candidatePath: string, rootPath: string): boolean {
|
||||
const candidate = path.resolve(candidatePath);
|
||||
const root = path.resolve(rootPath);
|
||||
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
||||
}
|
||||
|
||||
type WorktreeRuntimeContext = {
|
||||
configPath: string;
|
||||
envPath: string;
|
||||
worktreeName: string;
|
||||
instanceId: string;
|
||||
homeDir: string;
|
||||
instanceRoot: string;
|
||||
contextPath: string;
|
||||
embeddedPostgresDataDir: string;
|
||||
backupDir: string;
|
||||
logDir: string;
|
||||
storageDir: string;
|
||||
secretsKeyFilePath: string;
|
||||
};
|
||||
|
||||
function resolveWorktreeRuntimeContext(
|
||||
env: NodeJS.ProcessEnv,
|
||||
overrideConfigPath?: string,
|
||||
): WorktreeRuntimeContext | null {
|
||||
if (env.PAPERCLIP_IN_WORKTREE !== "true") return null;
|
||||
|
||||
const configPath = resolvePaperclipConfigPath(overrideConfigPath);
|
||||
const envPath = resolvePaperclipEnvPath(configPath);
|
||||
const worktreeRoot = path.resolve(path.dirname(configPath), "..");
|
||||
const worktreeName = nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? path.basename(worktreeRoot);
|
||||
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? sanitizeWorktreeInstanceId(worktreeName);
|
||||
const homeDir = resolveHomeAwarePath(
|
||||
nonEmpty(env.PAPERCLIP_HOME) ??
|
||||
nonEmpty(env.PAPERCLIP_WORKTREES_DIR) ??
|
||||
"~/.paperclip-worktrees",
|
||||
);
|
||||
const instanceRoot = path.resolve(homeDir, "instances", instanceId);
|
||||
|
||||
return {
|
||||
configPath,
|
||||
envPath,
|
||||
worktreeName,
|
||||
instanceId,
|
||||
homeDir,
|
||||
instanceRoot,
|
||||
contextPath: path.resolve(homeDir, "context.json"),
|
||||
embeddedPostgresDataDir: path.resolve(instanceRoot, "db"),
|
||||
backupDir: path.resolve(instanceRoot, "data", "backups"),
|
||||
logDir: path.resolve(instanceRoot, "logs"),
|
||||
storageDir: path.resolve(instanceRoot, "data", "storage"),
|
||||
secretsKeyFilePath: path.resolve(instanceRoot, "secrets", "master.key"),
|
||||
};
|
||||
}
|
||||
|
||||
function writeConfigFile(configPath: string, config: PaperclipConfig): void {
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
||||
}
|
||||
|
||||
function buildIsolatedWorktreeConfig(
|
||||
config: PaperclipConfig,
|
||||
context: WorktreeRuntimeContext,
|
||||
): PaperclipConfig {
|
||||
const nextConfig: PaperclipConfig = {
|
||||
...config,
|
||||
database: {
|
||||
...config.database,
|
||||
...(config.database.mode === "embedded-postgres"
|
||||
? {
|
||||
embeddedPostgresDataDir: context.embeddedPostgresDataDir,
|
||||
backup: {
|
||||
...config.database.backup,
|
||||
dir: context.backupDir,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
logging: {
|
||||
...config.logging,
|
||||
logDir: context.logDir,
|
||||
},
|
||||
storage: {
|
||||
...config.storage,
|
||||
localDisk: {
|
||||
...config.storage.localDisk,
|
||||
baseDir: context.storageDir,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
...config.secrets,
|
||||
localEncrypted: {
|
||||
...config.secrets.localEncrypted,
|
||||
keyFilePath: context.secretsKeyFilePath,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (config.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
|
||||
nextConfig.auth = {
|
||||
...config.auth,
|
||||
publicBaseUrl: rewriteLocalUrlPort(config.auth.publicBaseUrl, config.server.port),
|
||||
};
|
||||
}
|
||||
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
function needsWorktreeConfigRepair(
|
||||
config: PaperclipConfig,
|
||||
context: WorktreeRuntimeContext,
|
||||
): boolean {
|
||||
if (config.database.mode === "embedded-postgres") {
|
||||
if (!isPathInside(config.database.embeddedPostgresDataDir, context.instanceRoot)) {
|
||||
return true;
|
||||
}
|
||||
if (!isPathInside(config.database.backup.dir, context.instanceRoot)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPathInside(config.logging.logDir, context.instanceRoot)) {
|
||||
return true;
|
||||
}
|
||||
if (!isPathInside(config.storage.localDisk.baseDir, context.instanceRoot)) {
|
||||
return true;
|
||||
}
|
||||
if (!isPathInside(config.secrets.localEncrypted.keyFilePath, context.instanceRoot)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function applyRuntimePortSelectionToConfig(
|
||||
config: PaperclipConfig,
|
||||
input: {
|
||||
serverPort: number;
|
||||
databasePort?: number | null;
|
||||
allowServerPortWrite?: boolean;
|
||||
allowDatabasePortWrite?: boolean;
|
||||
},
|
||||
): { config: PaperclipConfig; changed: boolean } {
|
||||
let changed = false;
|
||||
let nextConfig = config;
|
||||
|
||||
if (input.allowServerPortWrite !== false && config.server.port !== input.serverPort) {
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
server: {
|
||||
...nextConfig.server,
|
||||
port: input.serverPort,
|
||||
},
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (
|
||||
input.allowDatabasePortWrite !== false &&
|
||||
nextConfig.database.mode === "embedded-postgres" &&
|
||||
typeof input.databasePort === "number" &&
|
||||
nextConfig.database.embeddedPostgresPort !== input.databasePort
|
||||
) {
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
database: {
|
||||
...nextConfig.database,
|
||||
embeddedPostgresPort: input.databasePort,
|
||||
},
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (nextConfig.auth.baseUrlMode === "explicit" && nextConfig.auth.publicBaseUrl) {
|
||||
const rewritten = rewriteLocalUrlPort(nextConfig.auth.publicBaseUrl, input.serverPort);
|
||||
if (rewritten && rewritten !== nextConfig.auth.publicBaseUrl) {
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
auth: {
|
||||
...nextConfig.auth,
|
||||
publicBaseUrl: rewritten,
|
||||
},
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { config: nextConfig, changed };
|
||||
}
|
||||
|
||||
export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): {
|
||||
repairedConfig: boolean;
|
||||
repairedEnv: boolean;
|
||||
} {
|
||||
const context = resolveWorktreeRuntimeContext(process.env);
|
||||
if (!context) {
|
||||
return { repairedConfig: false, repairedEnv: false };
|
||||
}
|
||||
|
||||
process.env.PAPERCLIP_HOME = context.homeDir;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = context.instanceId;
|
||||
process.env.PAPERCLIP_CONFIG = context.configPath;
|
||||
process.env.PAPERCLIP_CONTEXT = context.contextPath;
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = context.worktreeName;
|
||||
|
||||
let repairedConfig = false;
|
||||
if (fs.existsSync(context.configPath)) {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(context.configPath, "utf8")) as PaperclipConfig;
|
||||
if (needsWorktreeConfigRepair(parsed, context)) {
|
||||
writeConfigFile(context.configPath, buildIsolatedWorktreeConfig(parsed, context));
|
||||
repairedConfig = true;
|
||||
}
|
||||
} catch {
|
||||
// Leave invalid configs to the normal startup validation path.
|
||||
}
|
||||
}
|
||||
|
||||
const existingEnvEntries = readEnvEntries(context.envPath);
|
||||
const desiredEnvEntries: Record<string, string> = {
|
||||
...existingEnvEntries,
|
||||
PAPERCLIP_HOME: context.homeDir,
|
||||
PAPERCLIP_INSTANCE_ID: context.instanceId,
|
||||
PAPERCLIP_CONFIG: context.configPath,
|
||||
PAPERCLIP_CONTEXT: context.contextPath,
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
PAPERCLIP_WORKTREE_NAME: context.worktreeName,
|
||||
};
|
||||
|
||||
const repairedEnv = Object.entries(desiredEnvEntries).some(
|
||||
([key, value]) => existingEnvEntries[key] !== value,
|
||||
);
|
||||
|
||||
if (repairedEnv) {
|
||||
fs.mkdirSync(path.dirname(context.envPath), { recursive: true });
|
||||
fs.writeFileSync(context.envPath, formatEnvEntries(desiredEnvEntries), { mode: 0o600 });
|
||||
}
|
||||
|
||||
return { repairedConfig, repairedEnv };
|
||||
}
|
||||
|
||||
export function maybePersistWorktreeRuntimePorts(input: {
|
||||
serverPort: number;
|
||||
databasePort?: number | null;
|
||||
}): void {
|
||||
const context = resolveWorktreeRuntimeContext(process.env);
|
||||
if (!context || !fs.existsSync(context.configPath)) return;
|
||||
|
||||
let fileConfig: PaperclipConfig;
|
||||
try {
|
||||
fileConfig = JSON.parse(fs.readFileSync(context.configPath, "utf8")) as PaperclipConfig;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { config, changed } = applyRuntimePortSelectionToConfig(fileConfig, {
|
||||
serverPort: input.serverPort,
|
||||
databasePort: input.databasePort,
|
||||
allowServerPortWrite: !nonEmpty(process.env.PORT),
|
||||
allowDatabasePortWrite: !nonEmpty(process.env.DATABASE_URL),
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
writeConfigFile(context.configPath, config);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue