mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Fix worktree auth seeding and repair persistence
This commit is contained in:
1 parent
192674a1db
commit
0a11242c59
4 files changed
+229
-6
No files matched your search
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { authUsers, createDb, getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
copyGitHooksToWorktreeGitDir,
|
||||
@@ -28,6 +29,8 @@ import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_CWD = process.cwd();
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip;
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(ORIGINAL_CWD);
|
||||
@@ -344,6 +347,89 @@ describe("worktree helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
itEmbeddedPostgres(
|
||||
"seeds authenticated users into minimally cloned worktree instances",
|
||||
async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-"));
|
||||
const worktreeRoot = path.join(tempRoot, "PAP-999-auth-seed");
|
||||
const sourceHome = path.join(tempRoot, "source-home");
|
||||
const sourceConfigDir = path.join(sourceHome, "instances", "source");
|
||||
const sourceConfigPath = path.join(sourceConfigDir, "config.json");
|
||||
const sourceEnvPath = path.join(sourceConfigDir, ".env");
|
||||
const sourceKeyPath = path.join(sourceConfigDir, "secrets", "master.key");
|
||||
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const originalCwd = process.cwd();
|
||||
const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-");
|
||||
|
||||
try {
|
||||
const sourceDbClient = createDb(sourceDb.connectionString);
|
||||
await sourceDbClient.insert(authUsers).values({
|
||||
id: "user-existing",
|
||||
email: "existing@paperclip.ing",
|
||||
name: "Existing User",
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
|
||||
fs.mkdirSync(worktreeRoot, { recursive: true });
|
||||
|
||||
const sourceConfig = buildSourceConfig();
|
||||
sourceConfig.database = {
|
||||
mode: "postgres",
|
||||
connectionString: sourceDb.connectionString,
|
||||
};
|
||||
sourceConfig.logging.logDir = path.join(sourceConfigDir, "logs");
|
||||
sourceConfig.storage.localDisk.baseDir = path.join(sourceConfigDir, "storage");
|
||||
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
|
||||
|
||||
fs.writeFileSync(sourceConfigPath, JSON.stringify(sourceConfig, null, 2) + "\n", "utf8");
|
||||
fs.writeFileSync(sourceEnvPath, "", "utf8");
|
||||
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
|
||||
|
||||
process.chdir(worktreeRoot);
|
||||
await worktreeInitCommand({
|
||||
name: "PAP-999-auth-seed",
|
||||
home: worktreeHome,
|
||||
fromConfig: sourceConfigPath,
|
||||
force: true,
|
||||
});
|
||||
|
||||
const targetConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(worktreeRoot, ".paperclip", "config.json"), "utf8"),
|
||||
) as PaperclipConfig;
|
||||
const { default: EmbeddedPostgres } = await import("embedded-postgres");
|
||||
const targetPg = new EmbeddedPostgres({
|
||||
databaseDir: targetConfig.database.embeddedPostgresDataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port: targetConfig.database.embeddedPostgresPort,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
await targetPg.start();
|
||||
try {
|
||||
const targetDb = createDb(
|
||||
`postgres://paperclip:paperclip@127.0.0.1:${targetConfig.database.embeddedPostgresPort}/paperclip`,
|
||||
);
|
||||
const seededUsers = await targetDb.select().from(authUsers);
|
||||
expect(seededUsers.some((row) => row.email === "existing@paperclip.ing")).toBe(true);
|
||||
} finally {
|
||||
await targetPg.stop();
|
||||
}
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
await sourceDb.cleanup();
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
20000,
|
||||
);
|
||||
|
||||
it("avoids ports already claimed by sibling worktree instance configs", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
|
||||
@@ -32,17 +32,34 @@ source_env_path="$(dirname "$source_config_path")/.env"
|
||||
mkdir -p "$paperclip_dir"
|
||||
|
||||
run_isolated_worktree_init() {
|
||||
local base_cli_runner="$base_cwd/cli/node_modules/tsx/dist/cli.mjs"
|
||||
local base_cli_entry="$base_cwd/cli/src/index.ts"
|
||||
|
||||
if [[ -f "$base_cli_runner" && -f "$base_cli_entry" ]]; then
|
||||
(
|
||||
cd "$worktree_cwd"
|
||||
node "$base_cli_runner" "$base_cli_entry" worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
|
||||
)
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then
|
||||
pnpm paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
|
||||
(
|
||||
cd "$worktree_cwd"
|
||||
pnpm paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
|
||||
)
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v paperclipai >/dev/null 2>&1; then
|
||||
paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
|
||||
(
|
||||
cd "$worktree_cwd"
|
||||
paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
|
||||
)
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
return 127
|
||||
}
|
||||
|
||||
write_fallback_worktree_config() {
|
||||
@@ -296,6 +313,11 @@ EOF
|
||||
}
|
||||
|
||||
if ! run_isolated_worktree_init; then
|
||||
status=$?
|
||||
if [[ "$status" -ne 127 ]]; then
|
||||
exit "$status"
|
||||
fi
|
||||
|
||||
echo "paperclipai CLI not available in this workspace; writing isolated fallback config without DB seeding." >&2
|
||||
write_fallback_worktree_config
|
||||
fi
|
||||
|
||||
@@ -206,6 +206,113 @@ describe("worktree config repair", () => {
|
||||
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
|
||||
});
|
||||
|
||||
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: false,
|
||||
});
|
||||
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).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");
|
||||
|
||||
@@ -118,11 +118,19 @@ function resolveWorktreeRuntimeContext(
|
||||
|
||||
const configPath = resolvePaperclipConfigPath(overrideConfigPath);
|
||||
const envPath = resolvePaperclipEnvPath(configPath);
|
||||
const persistedEnv = readEnvEntries(envPath);
|
||||
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 worktreeName =
|
||||
nonEmpty(persistedEnv.PAPERCLIP_WORKTREE_NAME) ??
|
||||
nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ??
|
||||
path.basename(worktreeRoot);
|
||||
const instanceId =
|
||||
nonEmpty(persistedEnv.PAPERCLIP_INSTANCE_ID) ??
|
||||
nonEmpty(env.PAPERCLIP_INSTANCE_ID) ??
|
||||
sanitizeWorktreeInstanceId(worktreeName);
|
||||
const homeDir = resolveHomeAwarePath(
|
||||
nonEmpty(env.PAPERCLIP_HOME) ??
|
||||
nonEmpty(persistedEnv.PAPERCLIP_HOME) ??
|
||||
nonEmpty(env.PAPERCLIP_HOME) ??
|
||||
nonEmpty(env.PAPERCLIP_WORKTREES_DIR) ??
|
||||
"~/.paperclip-worktrees",
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user