mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The CLI and server share a JSON configuration contract for local installations and worktrees. > - Existing config writes removed extension keys because Zod stripped unknown object properties. > - Invalid config files could also be replaced with defaults before an operator preserved the original bytes. > - Configuration updates must preserve operator edits and must not rewrite files when the effective value is unchanged. > - This pull request adds extension-preserving merges, guarded invalid-config repair, atomic writes, and focused regression tests. > - The benefit is safe setup and configuration reruns without data loss or unnecessary mtime changes. ## Linked Issues or Issue Description **What happened?** Known-field updates through the CLI or server removed unknown top-level and nested config keys. Non-interactive configure and onboard paths could replace a present but invalid config with defaults. **Expected behavior** Writers preserve extension keys, skip semantic no-op writes, and require explicit interactive confirmation before an invalid config is replaced. Repair preserves an exact collision-safe backup first. **Steps to reproduce** 1. Add an unknown top-level key and an unknown nested provider key to `config.json`. 2. Update a known field through the CLI or worktree config writer. 3. Observe that the extension keys are removed on the base branch. 4. Write invalid JSON and run configure or onboard without an interactive terminal. 5. Observe that the original file can be replaced without a durable invalid-file backup on the base branch. **Paperclip version or commit** `master` at the pull request base commit. ## What Changed - Accept unknown properties at each extensible config object boundary while keeping every known field validated. - Merge known-field updates into the parsed source config and preserve only unknown extension data. - Warn about near-match key names without deleting or changing them. - Skip writes when the effective config is unchanged, which keeps file mtimes stable. - Write config changes through a temporary file, file sync, rename, and directory sync. - Distinguish a missing config from an invalid config in configure and onboard. - Back up invalid bytes as `config.json.invalid-N` and verify the source still matches that backup before repair. - Require interactive repair confirmation and reject non-interactive replacement with an actionable message. - Document the config preservation and repair behavior. ## Verification - `pnpm exec vitest run packages/shared/src/config-schema.test.ts cli/src/__tests__/config-store.test.ts cli/src/__tests__/configure-repair.test.ts cli/src/__tests__/configure.test.ts cli/src/__tests__/onboard.test.ts server/src/__tests__/config-file.test.ts server/src/__tests__/worktree-config.test.ts` - `pnpm -r typecheck` - `AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= VITEST_MAX_WORKERS=1 pnpm test:run` - `pnpm build` - Confirm all pull request checks are green on the latest commit. - Confirm Greptile reports 5/5 with no unresolved comments. ## Risks - Passthrough keeps misspelled keys. Near-match warnings make this visible without destructive cleanup. - Merge behavior must distinguish unknown extension keys from optional known keys. Schema-aware regression tests cover preservation and known-key deletion. - Repair must not overwrite bytes that changed after backup. The writer compares the current source with the selected backup before atomic replacement. - The change does not alter database schema, company scoping, or activity logging. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 model family. The exact deployment model ID and context window are not exposed. Agentic reasoning, tool use, and code execution were enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1096 lines
43 KiB
TypeScript
1096 lines
43 KiB
TypeScript
import fsSync from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
applyRuntimePortSelectionToConfig,
|
|
maybePersistWorktreeRuntimePorts,
|
|
maybeRepairLegacyWorktreeConfigAndEnvFiles,
|
|
} from "../worktree-config.js";
|
|
|
|
const ORIGINAL_ENV = { ...process.env };
|
|
const ORIGINAL_CWD = process.cwd();
|
|
|
|
// The ambient shell can carry real PAPERCLIP_* settings (agent shells export
|
|
// PAPERCLIP_CONFIG pointing at the live default instance). Repair helpers
|
|
// resolve paths from these, so a test that forgets to override one would
|
|
// otherwise rewrite the machine's real config/env files.
|
|
beforeEach(() => {
|
|
for (const key of Object.keys(process.env)) {
|
|
if (key.startsWith("PAPERCLIP_")) {
|
|
delete process.env[key];
|
|
}
|
|
}
|
|
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(ORIGINAL_CWD);
|
|
|
|
for (const key of Object.keys(process.env)) {
|
|
if (!(key in ORIGINAL_ENV)) {
|
|
delete process.env[key];
|
|
}
|
|
}
|
|
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
|
process.env[key] = value;
|
|
}
|
|
});
|
|
|
|
function buildLegacyConfig(sharedRoot: string, publicBaseUrl = "http://127.0.0.1:3100") {
|
|
return {
|
|
$meta: {
|
|
version: 1,
|
|
updatedAt: "2026-03-26T00:00:00.000Z",
|
|
source: "configure",
|
|
},
|
|
database: {
|
|
mode: "embedded-postgres" as const,
|
|
embeddedPostgresDataDir: path.join(sharedRoot, "db"),
|
|
embeddedPostgresPort: 54329,
|
|
backup: {
|
|
enabled: true,
|
|
intervalMinutes: 60,
|
|
retentionDays: 30,
|
|
dir: path.join(sharedRoot, "data", "backups"),
|
|
},
|
|
},
|
|
logging: {
|
|
mode: "file" as const,
|
|
logDir: path.join(sharedRoot, "logs"),
|
|
},
|
|
server: {
|
|
deploymentMode: "local_trusted" as const,
|
|
exposure: "private" as const,
|
|
host: "127.0.0.1",
|
|
port: 3100,
|
|
allowedHostnames: [],
|
|
serveUi: true,
|
|
},
|
|
auth: {
|
|
baseUrlMode: "explicit" as const,
|
|
publicBaseUrl,
|
|
disableSignUp: false,
|
|
},
|
|
storage: {
|
|
provider: "local_disk" as const,
|
|
localDisk: {
|
|
baseDir: path.join(sharedRoot, "data", "storage"),
|
|
},
|
|
s3: {
|
|
bucket: "paperclip",
|
|
region: "us-east-1",
|
|
prefix: "",
|
|
forcePathStyle: false,
|
|
},
|
|
},
|
|
secrets: {
|
|
provider: "local_encrypted" as const,
|
|
strictMode: false,
|
|
localEncrypted: {
|
|
keyFilePath: path.join(sharedRoot, "secrets", "master.key"),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildIsolatedConfig(instanceRoot: string, serverPort: number, databasePort: number) {
|
|
const config = buildLegacyConfig(instanceRoot, `http://127.0.0.1:${serverPort}`);
|
|
return {
|
|
...config,
|
|
database: {
|
|
...config.database,
|
|
embeddedPostgresPort: databasePort,
|
|
backup: {
|
|
...config.database.backup,
|
|
enabled: false,
|
|
},
|
|
},
|
|
server: {
|
|
...config.server,
|
|
port: serverPort,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("worktree config repair", () => {
|
|
it("repairs legacy repo-local worktree config and env files into an isolated instance", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-"));
|
|
const worktreeRoot = path.join(tempRoot, "PAP-884-ai-commits-component");
|
|
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
|
const configPath = path.join(paperclipDir, "config.json");
|
|
const envPath = path.join(paperclipDir, ".env");
|
|
const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default");
|
|
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
|
|
await fs.mkdir(paperclipDir, { recursive: true });
|
|
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8");
|
|
await fs.writeFile(
|
|
envPath,
|
|
[
|
|
"# Paperclip environment variables",
|
|
"PAPERCLIP_IN_WORKTREE=true",
|
|
"PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component",
|
|
"PAPERCLIP_AGENT_JWT_SECRET=shared-secret",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
process.chdir(worktreeRoot);
|
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
|
|
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
|
delete process.env.PORT;
|
|
delete process.env.PAPERCLIP_HOME;
|
|
delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
delete process.env.PAPERCLIP_CONFIG;
|
|
delete process.env.PAPERCLIP_CONTEXT;
|
|
|
|
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
|
|
|
expect(result).toEqual({
|
|
repairedConfig: true,
|
|
repairedEnv: true,
|
|
});
|
|
|
|
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
const repairedEnv = await fs.readFile(envPath, "utf8");
|
|
const instanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component");
|
|
|
|
expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db"));
|
|
expect(repairedConfig.database.backup.enabled).toBe(false);
|
|
expect(repairedConfig.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups"));
|
|
expect(repairedConfig.logging.logDir).toBe(path.join(instanceRoot, "logs"));
|
|
expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage"));
|
|
expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key"));
|
|
expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`);
|
|
expect(repairedEnv).toContain('PAPERCLIP_INSTANCE_ID="pap-884-ai-commits-component"');
|
|
expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(await fs.realpath(configPath))}`);
|
|
expect(repairedEnv).toContain(`PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`);
|
|
expect(repairedEnv).toContain('PAPERCLIP_DB_BACKUP_ENABLED="false"');
|
|
expect(repairedEnv).toContain("PAPERCLIP_AGENT_JWT_SECRET=shared-secret");
|
|
expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome);
|
|
expect(process.env.PORT).toBe("3101");
|
|
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("pap-884-ai-commits-component");
|
|
expect(process.env.PAPERCLIP_DB_BACKUP_ENABLED).toBe("false");
|
|
});
|
|
|
|
it("disables backups in an otherwise isolated existing worktree config", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-backup-migration-"));
|
|
const worktreeRoot = path.join(tempRoot, "disable-worktree-backups");
|
|
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
|
const configPath = path.join(paperclipDir, "config.json");
|
|
const envPath = path.join(paperclipDir, ".env");
|
|
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
const instanceRoot = path.join(isolatedHome, "instances", "disable-worktree-backups");
|
|
|
|
await fs.mkdir(paperclipDir, { recursive: true });
|
|
const legacyIsolatedConfig = buildIsolatedConfig(instanceRoot, 3110, 54339);
|
|
legacyIsolatedConfig.database.backup.enabled = true;
|
|
await fs.writeFile(configPath, JSON.stringify(legacyIsolatedConfig, null, 2) + "\n", "utf8");
|
|
await fs.writeFile(
|
|
envPath,
|
|
[
|
|
"# Paperclip environment variables",
|
|
"# Keep this operator note during repair",
|
|
`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`,
|
|
'PAPERCLIP_INSTANCE_ID="disable-worktree-backups"',
|
|
`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`,
|
|
'PAPERCLIP_DB_BACKUP_ENABLED="true" # managed worktree policy',
|
|
'PAPERCLIP_IN_WORKTREE="true"',
|
|
'PAPERCLIP_WORKTREE_NAME="disable-worktree-backups"',
|
|
"# Keep this trailing note too",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
process.chdir(worktreeRoot);
|
|
process.env.PAPERCLIP_HOME = isolatedHome;
|
|
process.env.PAPERCLIP_INSTANCE_ID = "disable-worktree-backups";
|
|
process.env.PAPERCLIP_CONFIG = configPath;
|
|
process.env.PAPERCLIP_DB_BACKUP_ENABLED = "true";
|
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
process.env.PAPERCLIP_WORKTREE_NAME = "disable-worktree-backups";
|
|
|
|
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
|
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
const repairedEnv = await fs.readFile(envPath, "utf8");
|
|
|
|
expect(result).toEqual({ repairedConfig: true, repairedEnv: true });
|
|
expect(repairedConfig.database.backup.enabled).toBe(false);
|
|
expect(repairedEnv).toContain(
|
|
'PAPERCLIP_DB_BACKUP_ENABLED="false" # managed worktree policy',
|
|
);
|
|
expect(repairedEnv).toContain("# Keep this operator note during repair");
|
|
expect(repairedEnv).toContain("# Keep this trailing note too");
|
|
expect(process.env.PAPERCLIP_DB_BACKUP_ENABLED).toBe("false");
|
|
});
|
|
|
|
it("preserves an externally supplied PORT while repairing worktree config", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-external-port-"));
|
|
const worktreeRoot = path.join(tempRoot, "PAP-10341-runtime-managed-port");
|
|
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
|
const configPath = path.join(paperclipDir, "config.json");
|
|
const envPath = path.join(paperclipDir, ".env");
|
|
const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default");
|
|
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
|
|
await fs.mkdir(paperclipDir, { recursive: true });
|
|
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8");
|
|
await fs.writeFile(
|
|
envPath,
|
|
[
|
|
"# Paperclip environment variables",
|
|
"PAPERCLIP_IN_WORKTREE=true",
|
|
"PAPERCLIP_WORKTREE_NAME=PAP-10341-runtime-managed-port",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
process.chdir(worktreeRoot);
|
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-10341-runtime-managed-port";
|
|
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
|
process.env.PORT = "32987";
|
|
delete process.env.PAPERCLIP_HOME;
|
|
delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
delete process.env.PAPERCLIP_CONFIG;
|
|
delete process.env.PAPERCLIP_CONTEXT;
|
|
|
|
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
|
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
|
|
expect(result.repairedConfig).toBe(true);
|
|
expect(repairedConfig.server.port).toBe(3101);
|
|
expect(process.env.PORT).toBe("32987");
|
|
expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome);
|
|
});
|
|
|
|
it("never rewrites a main-instance env when ambient worktree flags leak into the process", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-leak-"));
|
|
const homeDir = path.join(tempRoot, ".paperclip");
|
|
const instanceRoot = path.join(homeDir, "instances", "default");
|
|
const configPath = path.join(instanceRoot, "config.json");
|
|
const envPath = path.join(instanceRoot, ".env");
|
|
|
|
await fs.mkdir(instanceRoot, { recursive: true });
|
|
const originalConfig = JSON.stringify(buildLegacyConfig(instanceRoot), null, 2) + "\n";
|
|
await fs.writeFile(configPath, originalConfig, "utf8");
|
|
const cleanEnv = [
|
|
"# Paperclip environment variables",
|
|
"# Generated by `paperclip onboard`",
|
|
`PAPERCLIP_HOME=${JSON.stringify(homeDir)}`,
|
|
'PAPERCLIP_INSTANCE_ID="default"',
|
|
`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`,
|
|
"",
|
|
].join("\n");
|
|
await fs.writeFile(envPath, cleanEnv, "utf8");
|
|
|
|
process.chdir(tempRoot);
|
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
|
|
process.env.PAPERCLIP_HOME = homeDir;
|
|
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
|
process.env.PAPERCLIP_CONFIG = configPath;
|
|
delete process.env.PAPERCLIP_CONTEXT;
|
|
|
|
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
|
|
|
expect(result).toEqual({ repairedConfig: false, repairedEnv: false });
|
|
expect(await fs.readFile(envPath, "utf8")).toBe(cleanEnv);
|
|
expect(await fs.readFile(configPath, "utf8")).toBe(originalConfig);
|
|
expect(process.env.PAPERCLIP_HOME).toBe(homeDir);
|
|
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("default");
|
|
});
|
|
|
|
it("does not persist runtime ports into a main-instance config when ambient worktree flags leak in", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-leak-ports-"));
|
|
const homeDir = path.join(tempRoot, ".paperclip");
|
|
const instanceRoot = path.join(homeDir, "instances", "default");
|
|
const configPath = path.join(instanceRoot, "config.json");
|
|
|
|
await fs.mkdir(instanceRoot, { recursive: true });
|
|
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(instanceRoot), null, 2) + "\n", "utf8");
|
|
|
|
process.chdir(tempRoot);
|
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
|
|
process.env.PAPERCLIP_HOME = homeDir;
|
|
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
|
process.env.PAPERCLIP_CONFIG = configPath;
|
|
delete process.env.PORT;
|
|
delete process.env.DATABASE_URL;
|
|
|
|
maybePersistWorktreeRuntimePorts({ serverPort: 3999, databasePort: 54399 });
|
|
|
|
const writtenConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
expect(writtenConfig.server.port).toBe(3100);
|
|
expect(writtenConfig.database.embeddedPostgresPort).toBe(54329);
|
|
});
|
|
|
|
it("does not adopt a .paperclip config whose own env does not declare a worktree", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-unattested-"));
|
|
const repoRoot = path.join(tempRoot, "repo");
|
|
const paperclipDir = path.join(repoRoot, ".paperclip");
|
|
const configPath = path.join(paperclipDir, "config.json");
|
|
const envPath = path.join(paperclipDir, ".env");
|
|
|
|
await fs.mkdir(paperclipDir, { recursive: true });
|
|
const originalConfig =
|
|
JSON.stringify(buildLegacyConfig(path.join(tempRoot, "shared")), null, 2) + "\n";
|
|
await fs.writeFile(configPath, originalConfig, "utf8");
|
|
const nonWorktreeEnv = [
|
|
"# Paperclip environment variables",
|
|
`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`,
|
|
"",
|
|
].join("\n");
|
|
await fs.writeFile(envPath, nonWorktreeEnv, "utf8");
|
|
|
|
process.chdir(repoRoot);
|
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
|
|
process.env.PAPERCLIP_WORKTREES_DIR = path.join(tempRoot, ".paperclip-worktrees");
|
|
delete process.env.PAPERCLIP_HOME;
|
|
delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
delete process.env.PAPERCLIP_CONFIG;
|
|
|
|
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
|
|
|
expect(result).toEqual({ repairedConfig: false, repairedEnv: false });
|
|
expect(await fs.readFile(envPath, "utf8")).toBe(nonWorktreeEnv);
|
|
expect(await fs.readFile(configPath, "utf8")).toBe(originalConfig);
|
|
});
|
|
|
|
it("avoids sibling worktree ports when repairing legacy configs", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-ports-"));
|
|
const worktreeRoot = path.join(tempRoot, "PAP-880-thumbs-capture-for-evals-feature");
|
|
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
|
const configPath = path.join(paperclipDir, "config.json");
|
|
const envPath = path.join(paperclipDir, ".env");
|
|
const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default");
|
|
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox");
|
|
|
|
await fs.mkdir(paperclipDir, { recursive: true });
|
|
await fs.mkdir(siblingInstanceRoot, { recursive: true });
|
|
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8");
|
|
await fs.writeFile(
|
|
envPath,
|
|
[
|
|
"# Paperclip environment variables",
|
|
"PAPERCLIP_IN_WORKTREE=true",
|
|
"PAPERCLIP_WORKTREE_NAME=PAP-880-thumbs-capture-for-evals-feature",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(siblingInstanceRoot, "config.json"),
|
|
JSON.stringify(
|
|
{
|
|
...buildLegacyConfig(siblingInstanceRoot),
|
|
database: {
|
|
mode: "embedded-postgres",
|
|
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
|
|
embeddedPostgresPort: 54330,
|
|
backup: {
|
|
enabled: true,
|
|
intervalMinutes: 60,
|
|
retentionDays: 30,
|
|
dir: path.join(siblingInstanceRoot, "data", "backups"),
|
|
},
|
|
},
|
|
server: {
|
|
deploymentMode: "local_trusted",
|
|
exposure: "private",
|
|
host: "127.0.0.1",
|
|
port: 3101,
|
|
allowedHostnames: [],
|
|
serveUi: true,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
"utf8",
|
|
);
|
|
|
|
process.chdir(worktreeRoot);
|
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-880-thumbs-capture-for-evals-feature";
|
|
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
|
delete process.env.PAPERCLIP_HOME;
|
|
delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
delete process.env.PAPERCLIP_CONFIG;
|
|
delete process.env.PAPERCLIP_CONTEXT;
|
|
|
|
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
|
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
|
|
expect(result.repairedConfig).toBe(true);
|
|
expect(repairedConfig.server.port).toBe(3102);
|
|
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
|
|
});
|
|
|
|
it("serializes and persists cross-repo worktree port reservations", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-port-registry-"));
|
|
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
const firstWorktreeRoot = path.join(tempRoot, "repo-one", "PAP-14013-import-bulk-skills");
|
|
const secondWorktreeRoot = path.join(tempRoot, "repo-two", "PAP-14069-port-conflicts");
|
|
const firstConfigPath = path.join(firstWorktreeRoot, ".paperclip", "config.json");
|
|
const secondConfigPath = path.join(secondWorktreeRoot, ".paperclip", "config.json");
|
|
|
|
const writeWorktree = async (worktreeRoot: string, name: string) => {
|
|
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
|
const instanceRoot = path.join(isolatedHome, "instances", name.toLowerCase());
|
|
await fs.mkdir(paperclipDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(paperclipDir, "config.json"),
|
|
`${JSON.stringify(buildIsolatedConfig(instanceRoot, 45439, 55439), null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(paperclipDir, ".env"),
|
|
[
|
|
"# Paperclip environment variables",
|
|
"PAPERCLIP_IN_WORKTREE=true",
|
|
`PAPERCLIP_WORKTREE_NAME=${name}`,
|
|
`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`,
|
|
`PAPERCLIP_INSTANCE_ID=${name.toLowerCase()}`,
|
|
`PAPERCLIP_CONFIG=${JSON.stringify(path.join(paperclipDir, "config.json"))}`,
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
};
|
|
|
|
const activateWorktree = (worktreeRoot: string, name: string) => {
|
|
process.chdir(worktreeRoot);
|
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
process.env.PAPERCLIP_WORKTREE_NAME = name;
|
|
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
|
process.env.PAPERCLIP_HOME = isolatedHome;
|
|
process.env.PAPERCLIP_INSTANCE_ID = name.toLowerCase();
|
|
process.env.PAPERCLIP_CONFIG = path.join(worktreeRoot, ".paperclip", "config.json");
|
|
delete process.env.PORT;
|
|
delete process.env.DATABASE_URL;
|
|
};
|
|
|
|
await writeWorktree(firstWorktreeRoot, "PAP-14013-import-bulk-skills");
|
|
await writeWorktree(secondWorktreeRoot, "PAP-14069-port-conflicts");
|
|
const staleLockPath = path.join(isolatedHome, ".worktree-port-reservations.lock");
|
|
await fs.mkdir(staleLockPath, { recursive: true });
|
|
const staleLockTime = new Date(Date.now() - 6_000);
|
|
await fs.utimes(staleLockPath, staleLockTime, staleLockTime);
|
|
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
|
|
activateWorktree(firstWorktreeRoot, "PAP-14013-import-bulk-skills");
|
|
expect(maybeRepairLegacyWorktreeConfigAndEnvFiles().repairedConfig).toBe(false);
|
|
await expect(fs.stat(staleLockPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
|
|
activateWorktree(secondWorktreeRoot, "PAP-14069-port-conflicts");
|
|
expect(maybeRepairLegacyWorktreeConfigAndEnvFiles().repairedConfig).toBe(true);
|
|
|
|
const firstConfig = JSON.parse(await fs.readFile(firstConfigPath, "utf8"));
|
|
const secondConfig = JSON.parse(await fs.readFile(secondConfigPath, "utf8"));
|
|
const registry = JSON.parse(
|
|
await fs.readFile(path.join(isolatedHome, "worktree-port-reservations.json"), "utf8"),
|
|
);
|
|
|
|
expect(firstConfig.server.port).toBe(45439);
|
|
expect(firstConfig.database.embeddedPostgresPort).toBe(55439);
|
|
expect(secondConfig.server.port).toBe(45440);
|
|
expect(secondConfig.database.embeddedPostgresPort).toBe(55440);
|
|
expect(secondConfig.auth.publicBaseUrl).toBe("http://127.0.0.1:45440/");
|
|
expect(registry.configPaths).toEqual([firstConfigPath, secondConfigPath].sort());
|
|
expect(warning).toHaveBeenCalledWith(expect.stringContaining("Worktree port conflict detected"));
|
|
expect(warning).toHaveBeenCalledWith(expect.stringContaining("server: 45439 -> 45440"));
|
|
|
|
warning.mockClear();
|
|
expect(maybeRepairLegacyWorktreeConfigAndEnvFiles().repairedConfig).toBe(false);
|
|
const persistedConfig = JSON.parse(await fs.readFile(secondConfigPath, "utf8"));
|
|
expect(persistedConfig.server.port).toBe(45440);
|
|
expect(persistedConfig.database.embeddedPostgresPort).toBe(55440);
|
|
expect(warning).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores stale migrated env paths when the dev runner resolved the local config", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-migrated-env-"));
|
|
const worktreeRoot = path.join(tempRoot, "PAP-9940-what-can-we-learn");
|
|
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
|
const configPath = path.join(paperclipDir, "config.json");
|
|
const envPath = path.join(paperclipDir, ".env");
|
|
const oldHome = "/old/home/.paperclip-worktrees";
|
|
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
|
|
await fs.mkdir(paperclipDir, { recursive: true });
|
|
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(oldHome), null, 2) + "\n", "utf8");
|
|
await fs.writeFile(
|
|
envPath,
|
|
[
|
|
"# Paperclip environment variables",
|
|
"PAPERCLIP_HOME=/old/home/.paperclip-worktrees",
|
|
"PAPERCLIP_INSTANCE_ID=pap-9940-what-can-we-learn",
|
|
"PAPERCLIP_CONFIG=/old/home/paperclip/.paperclip/worktrees/PAP-9940-what-can-we-learn/.paperclip/config.json",
|
|
"PAPERCLIP_CONTEXT=/old/home/.paperclip-worktrees/context.json",
|
|
"PAPERCLIP_IN_WORKTREE=true",
|
|
"PAPERCLIP_WORKTREE_NAME=PAP-9940-what-can-we-learn",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
process.chdir(worktreeRoot);
|
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
process.env.PAPERCLIP_CONFIG = configPath;
|
|
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
|
delete process.env.PAPERCLIP_HOME;
|
|
delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
delete process.env.PAPERCLIP_CONTEXT;
|
|
|
|
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
|
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
const repairedEnv = await fs.readFile(envPath, "utf8");
|
|
const instanceRoot = path.join(isolatedHome, "instances", "pap-9940-what-can-we-learn");
|
|
|
|
expect(result).toEqual({
|
|
repairedConfig: true,
|
|
repairedEnv: true,
|
|
});
|
|
expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db"));
|
|
expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key"));
|
|
expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`);
|
|
expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`);
|
|
expect(repairedEnv).not.toContain("/old/home");
|
|
});
|
|
|
|
it("does not persist transient runtime home overrides over repo-local worktree env", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-runtime-override-"));
|
|
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
const transientHome = path.join(tempRoot, "tests", "e2e", ".tmp", "multiuser-authenticated");
|
|
const worktreeRoot = path.join(tempRoot, "PAP-989-multi-user-implementation-using-plan-from-pap-958");
|
|
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
|
const configPath = path.join(paperclipDir, "config.json");
|
|
const envPath = path.join(paperclipDir, ".env");
|
|
const instanceId = "pap-989-multi-user-implementation-using-plan-from-pap-958";
|
|
const stableInstanceRoot = path.join(isolatedHome, "instances", instanceId);
|
|
|
|
await fs.mkdir(paperclipDir, { recursive: true });
|
|
await fs.writeFile(
|
|
configPath,
|
|
JSON.stringify(
|
|
{
|
|
...buildLegacyConfig(transientHome),
|
|
database: {
|
|
mode: "embedded-postgres",
|
|
embeddedPostgresDataDir: path.join(transientHome, "instances", instanceId, "db"),
|
|
embeddedPostgresPort: 54334,
|
|
backup: {
|
|
enabled: true,
|
|
intervalMinutes: 60,
|
|
retentionDays: 30,
|
|
dir: path.join(transientHome, "instances", instanceId, "data", "backups"),
|
|
},
|
|
},
|
|
logging: {
|
|
mode: "file",
|
|
logDir: path.join(transientHome, "instances", instanceId, "logs"),
|
|
},
|
|
server: {
|
|
deploymentMode: "local_trusted",
|
|
exposure: "private",
|
|
host: "127.0.0.1",
|
|
port: 3104,
|
|
allowedHostnames: [],
|
|
serveUi: true,
|
|
},
|
|
storage: {
|
|
provider: "local_disk",
|
|
localDisk: {
|
|
baseDir: path.join(transientHome, "instances", instanceId, "data", "storage"),
|
|
},
|
|
s3: {
|
|
bucket: "paperclip",
|
|
region: "us-east-1",
|
|
prefix: "",
|
|
forcePathStyle: false,
|
|
},
|
|
},
|
|
secrets: {
|
|
provider: "local_encrypted",
|
|
strictMode: false,
|
|
localEncrypted: {
|
|
keyFilePath: path.join(transientHome, "instances", instanceId, "secrets", "master.key"),
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
envPath,
|
|
[
|
|
"# Paperclip environment variables",
|
|
`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`,
|
|
`PAPERCLIP_INSTANCE_ID=${JSON.stringify(instanceId)}`,
|
|
`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`,
|
|
`PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`,
|
|
'PAPERCLIP_IN_WORKTREE="true"',
|
|
'PAPERCLIP_WORKTREE_NAME="PAP-989-multi-user-implementation-using-plan-from-pap-958"',
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
process.chdir(worktreeRoot);
|
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-989-multi-user-implementation-using-plan-from-pap-958";
|
|
process.env.PAPERCLIP_HOME = transientHome;
|
|
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
|
process.env.PAPERCLIP_CONFIG = configPath;
|
|
|
|
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
|
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
const repairedEnv = await fs.readFile(envPath, "utf8");
|
|
|
|
expect(result).toEqual({
|
|
repairedConfig: true,
|
|
repairedEnv: true,
|
|
});
|
|
expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(stableInstanceRoot, "db"));
|
|
expect(repairedConfig.database.backup.dir).toBe(path.join(stableInstanceRoot, "data", "backups"));
|
|
expect(repairedConfig.logging.logDir).toBe(path.join(stableInstanceRoot, "logs"));
|
|
expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(stableInstanceRoot, "data", "storage"));
|
|
expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(
|
|
path.join(stableInstanceRoot, "secrets", "master.key"),
|
|
);
|
|
expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`);
|
|
expect(repairedEnv).toContain('PAPERCLIP_DB_BACKUP_ENABLED="false"');
|
|
expect(repairedEnv).not.toContain(`PAPERCLIP_HOME=${JSON.stringify(transientHome)}`);
|
|
expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome);
|
|
});
|
|
|
|
it("rebalances duplicate ports for already isolated worktree configs", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-rebalance-"));
|
|
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
const repoWorktreesRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees");
|
|
const siblingWorktreeRoot = path.join(repoWorktreesRoot, "PAP-878-create-a-mine-tab-in-inbox");
|
|
const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox");
|
|
const currentWorktreeRoot = path.join(repoWorktreesRoot, "PAP-884-ai-commits-component");
|
|
const paperclipDir = path.join(currentWorktreeRoot, ".paperclip");
|
|
const configPath = path.join(paperclipDir, "config.json");
|
|
const envPath = path.join(paperclipDir, ".env");
|
|
const currentInstanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component");
|
|
const siblingConfigPath = path.join(siblingWorktreeRoot, ".paperclip", "config.json");
|
|
|
|
await fs.mkdir(paperclipDir, { recursive: true });
|
|
await fs.mkdir(path.dirname(siblingConfigPath), { recursive: true });
|
|
await fs.writeFile(
|
|
configPath,
|
|
JSON.stringify(
|
|
{
|
|
...buildLegacyConfig(currentInstanceRoot),
|
|
database: {
|
|
mode: "embedded-postgres",
|
|
embeddedPostgresDataDir: path.join(currentInstanceRoot, "db"),
|
|
embeddedPostgresPort: 54330,
|
|
backup: {
|
|
enabled: true,
|
|
intervalMinutes: 60,
|
|
retentionDays: 30,
|
|
dir: path.join(currentInstanceRoot, "data", "backups"),
|
|
},
|
|
},
|
|
logging: {
|
|
mode: "file",
|
|
logDir: path.join(currentInstanceRoot, "logs"),
|
|
},
|
|
server: {
|
|
deploymentMode: "local_trusted",
|
|
exposure: "private",
|
|
host: "127.0.0.1",
|
|
port: 3101,
|
|
allowedHostnames: [],
|
|
serveUi: true,
|
|
},
|
|
storage: {
|
|
provider: "local_disk",
|
|
localDisk: {
|
|
baseDir: path.join(currentInstanceRoot, "data", "storage"),
|
|
},
|
|
s3: {
|
|
bucket: "paperclip",
|
|
region: "us-east-1",
|
|
prefix: "",
|
|
forcePathStyle: false,
|
|
},
|
|
},
|
|
secrets: {
|
|
provider: "local_encrypted",
|
|
strictMode: false,
|
|
localEncrypted: {
|
|
keyFilePath: path.join(currentInstanceRoot, "secrets", "master.key"),
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
envPath,
|
|
[
|
|
"# Paperclip environment variables",
|
|
"PAPERCLIP_IN_WORKTREE=true",
|
|
"PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
siblingConfigPath,
|
|
JSON.stringify(
|
|
{
|
|
...buildLegacyConfig(siblingInstanceRoot),
|
|
database: {
|
|
mode: "embedded-postgres",
|
|
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
|
|
embeddedPostgresPort: 54330,
|
|
backup: {
|
|
enabled: true,
|
|
intervalMinutes: 60,
|
|
retentionDays: 30,
|
|
dir: path.join(siblingInstanceRoot, "data", "backups"),
|
|
},
|
|
},
|
|
server: {
|
|
deploymentMode: "local_trusted",
|
|
exposure: "private",
|
|
host: "127.0.0.1",
|
|
port: 3101,
|
|
allowedHostnames: [],
|
|
serveUi: true,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
"utf8",
|
|
);
|
|
|
|
process.chdir(currentWorktreeRoot);
|
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
|
|
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
|
delete process.env.PAPERCLIP_HOME;
|
|
delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
delete process.env.PAPERCLIP_CONFIG;
|
|
delete process.env.PAPERCLIP_CONTEXT;
|
|
|
|
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
|
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
|
|
expect(result.repairedConfig).toBe(true);
|
|
expect(repairedConfig.server.port).toBe(3102);
|
|
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
|
|
});
|
|
|
|
it("persists runtime-selected worktree ports back into explicit-port auth URLs", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-ports-"));
|
|
const worktreeRoot = path.join(tempRoot, "PAP-878-create-a-mine-tab-in-inbox");
|
|
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
|
const configPath = path.join(paperclipDir, "config.json");
|
|
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
const instanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox");
|
|
|
|
await fs.mkdir(paperclipDir, { recursive: true });
|
|
await fs.writeFile(
|
|
configPath,
|
|
JSON.stringify(
|
|
{
|
|
...buildLegacyConfig(instanceRoot, "http://my-host.ts.net:3100"),
|
|
database: {
|
|
mode: "embedded-postgres",
|
|
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
|
|
embeddedPostgresPort: 54331,
|
|
backup: {
|
|
enabled: true,
|
|
intervalMinutes: 60,
|
|
retentionDays: 30,
|
|
dir: path.join(instanceRoot, "data", "backups"),
|
|
},
|
|
},
|
|
logging: {
|
|
mode: "file",
|
|
logDir: path.join(instanceRoot, "logs"),
|
|
},
|
|
server: {
|
|
deploymentMode: "local_trusted",
|
|
exposure: "private",
|
|
host: "127.0.0.1",
|
|
port: 3101,
|
|
allowedHostnames: [],
|
|
serveUi: true,
|
|
},
|
|
storage: {
|
|
provider: "local_disk",
|
|
localDisk: {
|
|
baseDir: path.join(instanceRoot, "data", "storage"),
|
|
},
|
|
s3: {
|
|
bucket: "paperclip",
|
|
region: "us-east-1",
|
|
prefix: "",
|
|
forcePathStyle: false,
|
|
},
|
|
},
|
|
secrets: {
|
|
provider: "local_encrypted",
|
|
strictMode: false,
|
|
localEncrypted: {
|
|
keyFilePath: path.join(instanceRoot, "secrets", "master.key"),
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
"utf8",
|
|
);
|
|
|
|
await fs.writeFile(
|
|
path.join(paperclipDir, ".env"),
|
|
["# Paperclip environment variables", "PAPERCLIP_IN_WORKTREE=true", ""].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
process.chdir(worktreeRoot);
|
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-878-create-a-mine-tab-in-inbox";
|
|
process.env.PAPERCLIP_HOME = isolatedHome;
|
|
process.env.PAPERCLIP_INSTANCE_ID = "pap-878-create-a-mine-tab-in-inbox";
|
|
process.env.PAPERCLIP_CONFIG = configPath;
|
|
delete process.env.PORT;
|
|
delete process.env.DATABASE_URL;
|
|
|
|
maybePersistWorktreeRuntimePorts({
|
|
serverPort: 3103,
|
|
databasePort: 54335,
|
|
});
|
|
|
|
const writtenConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
|
|
expect(writtenConfig.server.port).toBe(3103);
|
|
expect(writtenConfig.database.embeddedPostgresPort).toBe(54335);
|
|
expect(writtenConfig.auth.publicBaseUrl).toBe("http://my-host.ts.net:3103/");
|
|
});
|
|
|
|
it("does not rewrite no-port public auth URLs when persisting runtime-selected ports", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-public-ports-"));
|
|
const worktreeRoot = path.join(tempRoot, "PAP-125-public-base-url");
|
|
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
|
const configPath = path.join(paperclipDir, "config.json");
|
|
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
const instanceRoot = path.join(isolatedHome, "instances", "pap-125-public-base-url");
|
|
|
|
await fs.mkdir(paperclipDir, { recursive: true });
|
|
await fs.writeFile(
|
|
configPath,
|
|
JSON.stringify(
|
|
{
|
|
...buildLegacyConfig(instanceRoot, "https://paperclip.example"),
|
|
database: {
|
|
mode: "embedded-postgres",
|
|
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
|
|
embeddedPostgresPort: 54331,
|
|
backup: {
|
|
enabled: true,
|
|
intervalMinutes: 60,
|
|
retentionDays: 30,
|
|
dir: path.join(instanceRoot, "data", "backups"),
|
|
},
|
|
},
|
|
logging: {
|
|
mode: "file",
|
|
logDir: path.join(instanceRoot, "logs"),
|
|
},
|
|
server: {
|
|
deploymentMode: "local_trusted",
|
|
exposure: "private",
|
|
host: "127.0.0.1",
|
|
port: 3101,
|
|
allowedHostnames: [],
|
|
serveUi: true,
|
|
},
|
|
storage: {
|
|
provider: "local_disk",
|
|
localDisk: {
|
|
baseDir: path.join(instanceRoot, "data", "storage"),
|
|
},
|
|
s3: {
|
|
bucket: "paperclip",
|
|
region: "us-east-1",
|
|
prefix: "",
|
|
forcePathStyle: false,
|
|
},
|
|
},
|
|
secrets: {
|
|
provider: "local_encrypted",
|
|
strictMode: false,
|
|
localEncrypted: {
|
|
keyFilePath: path.join(instanceRoot, "secrets", "master.key"),
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
"utf8",
|
|
);
|
|
|
|
await fs.writeFile(
|
|
path.join(paperclipDir, ".env"),
|
|
["# Paperclip environment variables", "PAPERCLIP_IN_WORKTREE=true", ""].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
process.chdir(worktreeRoot);
|
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-125-public-base-url";
|
|
process.env.PAPERCLIP_HOME = isolatedHome;
|
|
process.env.PAPERCLIP_INSTANCE_ID = "pap-125-public-base-url";
|
|
process.env.PAPERCLIP_CONFIG = configPath;
|
|
delete process.env.PORT;
|
|
delete process.env.DATABASE_URL;
|
|
|
|
maybePersistWorktreeRuntimePorts({
|
|
serverPort: 3103,
|
|
databasePort: 54335,
|
|
});
|
|
|
|
const writtenConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
|
|
expect(writtenConfig.server.port).toBe(3103);
|
|
expect(writtenConfig.database.embeddedPostgresPort).toBe(54335);
|
|
expect(writtenConfig.auth.publicBaseUrl).toBe("https://paperclip.example");
|
|
});
|
|
|
|
it("preserves top-level and nested config extensions while persisting runtime ports", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-config-extensions-"));
|
|
const worktreeRoot = path.join(tempRoot, "config-extensions");
|
|
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
|
const configPath = path.join(paperclipDir, "config.json");
|
|
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
const instanceRoot = path.join(isolatedHome, "instances", "config-extensions");
|
|
const base = buildIsolatedConfig(instanceRoot, 3101, 54331);
|
|
const config = {
|
|
...base,
|
|
topLevelExtension: { enabled: true },
|
|
database: {
|
|
...base.database,
|
|
backup: {
|
|
...base.database.backup,
|
|
backupExtension: "keep",
|
|
},
|
|
},
|
|
server: {
|
|
...base.server,
|
|
serverExtension: "keep",
|
|
},
|
|
storage: {
|
|
...base.storage,
|
|
localDisk: {
|
|
...base.storage.localDisk,
|
|
driverExtension: "keep",
|
|
},
|
|
},
|
|
};
|
|
|
|
await fs.mkdir(paperclipDir, { recursive: true });
|
|
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
await fs.writeFile(
|
|
path.join(paperclipDir, ".env"),
|
|
["# Paperclip environment variables", "PAPERCLIP_IN_WORKTREE=true", ""].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
process.chdir(worktreeRoot);
|
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
process.env.PAPERCLIP_WORKTREE_NAME = "config-extensions";
|
|
process.env.PAPERCLIP_HOME = isolatedHome;
|
|
process.env.PAPERCLIP_INSTANCE_ID = "config-extensions";
|
|
process.env.PAPERCLIP_CONFIG = configPath;
|
|
delete process.env.PORT;
|
|
delete process.env.DATABASE_URL;
|
|
|
|
const open = vi.spyOn(fsSync, "openSync");
|
|
const sync = vi.spyOn(fsSync, "fsyncSync");
|
|
maybePersistWorktreeRuntimePorts({ serverPort: 3103, databasePort: 54335 });
|
|
|
|
expect(open).toHaveBeenCalledWith(paperclipDir, "r");
|
|
expect(sync).toHaveBeenCalled();
|
|
|
|
const writtenConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
expect(writtenConfig).toMatchObject({
|
|
topLevelExtension: { enabled: true },
|
|
database: {
|
|
embeddedPostgresPort: 54335,
|
|
backup: { backupExtension: "keep" },
|
|
},
|
|
server: {
|
|
port: 3103,
|
|
serverExtension: "keep",
|
|
},
|
|
storage: {
|
|
localDisk: { driverExtension: "keep" },
|
|
},
|
|
});
|
|
|
|
const stableTime = new Date("2020-01-01T00:00:00.000Z");
|
|
await fs.utimes(configPath, stableTime, stableTime);
|
|
maybePersistWorktreeRuntimePorts({ serverPort: 3103, databasePort: 54335 });
|
|
expect((await fs.stat(configPath)).mtimeMs).toBe(stableTime.getTime());
|
|
});
|
|
|
|
it("can update the in-memory config when auth URL already includes a port", () => {
|
|
const { config, changed } = applyRuntimePortSelectionToConfig(
|
|
buildLegacyConfig("/tmp/shared", "http://my-host.ts.net:3100"),
|
|
{
|
|
serverPort: 3104,
|
|
databasePort: 54340,
|
|
allowServerPortWrite: false,
|
|
allowDatabasePortWrite: true,
|
|
},
|
|
);
|
|
|
|
expect(changed).toBe(true);
|
|
expect(config.server.port).toBe(3100);
|
|
expect(config.database.embeddedPostgresPort).toBe(54340);
|
|
expect(config.auth.publicBaseUrl).toBe("http://my-host.ts.net:3104/");
|
|
});
|
|
|
|
it("does not rewrite the in-memory config when auth URL has no explicit port", () => {
|
|
const { config, changed } = applyRuntimePortSelectionToConfig(
|
|
buildLegacyConfig("/tmp/shared", "https://paperclip.example"),
|
|
{
|
|
serverPort: 3104,
|
|
databasePort: 54340,
|
|
allowServerPortWrite: false,
|
|
allowDatabasePortWrite: true,
|
|
},
|
|
);
|
|
|
|
expect(changed).toBe(true);
|
|
expect(config.server.port).toBe(3100);
|
|
expect(config.database.embeddedPostgresPort).toBe(54340);
|
|
expect(config.auth.publicBaseUrl).toBe("https://paperclip.example");
|
|
});
|
|
});
|