mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
fix(config): preserve env files during managed updates
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2ea22d6cea
commit
31a54faef6
5 files changed
+351
-92
No files matched your search
@@ -76,4 +76,62 @@ describe("agent jwt env helpers", () => {
|
||||
expect(contents).toContain('PAPERCLIP_WORKTREE_COLOR="#439edb"');
|
||||
expect(readPaperclipEnvEntries(envPath).PAPERCLIP_WORKTREE_COLOR).toBe("#439edb");
|
||||
});
|
||||
|
||||
it("preserves operator content and CRLF while updating only managed entries", () => {
|
||||
const configPath = tempConfigPath();
|
||||
const envPath = resolveAgentJwtEnvFile(configPath);
|
||||
const original = [
|
||||
"# operator comment",
|
||||
"DATABASE_URL='postgres://operator:encoded@localhost/paperclip'",
|
||||
"",
|
||||
"export PAPERCLIP_HOME = '/old path' # managed path",
|
||||
"PAPERCLIP_DUPLICATE=stale",
|
||||
'PAPERCLIP_DUPLICATE="current"',
|
||||
"UNKNOWN_VALUE=operator-owned",
|
||||
"",
|
||||
].join("\r\n");
|
||||
fs.writeFileSync(envPath, original, { mode: 0o600 });
|
||||
|
||||
mergePaperclipEnvEntries(
|
||||
{
|
||||
PAPERCLIP_HOME: "/new path",
|
||||
PAPERCLIP_DUPLICATE: "current",
|
||||
PAPERCLIP_WORKTREE_COLOR: "#439edb",
|
||||
DATABASE_URL: "postgres://paperclip-must-not-overwrite",
|
||||
},
|
||||
envPath,
|
||||
);
|
||||
|
||||
const updated = fs.readFileSync(envPath, "utf8");
|
||||
expect(updated).toBe([
|
||||
"# operator comment",
|
||||
"DATABASE_URL='postgres://operator:encoded@localhost/paperclip'",
|
||||
"",
|
||||
'export PAPERCLIP_HOME = "/new path" # managed path',
|
||||
"PAPERCLIP_DUPLICATE=current",
|
||||
'PAPERCLIP_DUPLICATE="current"',
|
||||
"UNKNOWN_VALUE=operator-owned",
|
||||
'PAPERCLIP_WORKTREE_COLOR="#439edb"',
|
||||
"",
|
||||
].join("\r\n"));
|
||||
expect(updated.replaceAll("\r\n", "")).not.toContain("\n");
|
||||
});
|
||||
|
||||
it("does not replace the env file when managed values are already current", () => {
|
||||
const configPath = tempConfigPath();
|
||||
const envPath = resolveAgentJwtEnvFile(configPath);
|
||||
const original = [
|
||||
"# preserve this file byte-for-byte",
|
||||
"export PAPERCLIP_HOME = '/same path'",
|
||||
"UNKNOWN=\"operator encoding\"",
|
||||
"",
|
||||
].join("\n");
|
||||
fs.writeFileSync(envPath, original, { mode: 0o600 });
|
||||
const previousInode = fs.statSync(envPath).ino;
|
||||
|
||||
mergePaperclipEnvEntries({ PAPERCLIP_HOME: "/same path" }, envPath);
|
||||
|
||||
expect(fs.readFileSync(envPath, "utf8")).toBe(original);
|
||||
expect(fs.statSync(envPath).ino).toBe(previousInode);
|
||||
});
|
||||
});
|
||||
+20
-20
@@ -2,9 +2,11 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { config as loadDotenv, parse as parseEnvFileContents } from "dotenv";
|
||||
import { updateEnvFileContents, writeEnvFileAtomicallyIfChanged } from "@paperclipai/shared/env-file";
|
||||
import { resolveConfigPath } from "./store.js";
|
||||
|
||||
const JWT_SECRET_ENV_KEY = "PAPERCLIP_AGENT_JWT_SECRET";
|
||||
const PAPERCLIP_OWNED_ENV_KEY_PATTERN = /^PAPERCLIP_[A-Z0-9_]+$/;
|
||||
function resolveEnvFilePath(configPath?: string) {
|
||||
return path.resolve(path.dirname(resolveConfigPath(configPath)), ".env");
|
||||
}
|
||||
@@ -22,21 +24,20 @@ function parseEnvFile(contents: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatEnvValue(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:@-]+$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function renderEnvFile(entries: Record<string, string>) {
|
||||
const lines = [
|
||||
function emptyEnvFileContents() {
|
||||
return [
|
||||
"# Paperclip environment variables",
|
||||
"# Generated by Paperclip CLI commands",
|
||||
...Object.entries(entries).map(([key, value]) => `${key}=${formatEnvValue(value)}`),
|
||||
"",
|
||||
];
|
||||
return lines.join("\n");
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function paperclipOwnedEntries(entries: Record<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(entries).filter(
|
||||
([key, value]) => PAPERCLIP_OWNED_ENV_KEY_PATTERN.test(key) && value.trim().length > 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePaperclipEnvFile(configPath?: string): string {
|
||||
@@ -102,11 +103,11 @@ export function readPaperclipEnvEntries(filePath = resolveEnvFilePath()): Record
|
||||
}
|
||||
|
||||
export function writePaperclipEnvEntries(entries: Record<string, string>, filePath = resolveEnvFilePath()): void {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(filePath, renderEnvFile(entries), {
|
||||
mode: 0o600,
|
||||
const previousContents = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
|
||||
const nextContents = updateEnvFileContents(previousContents ?? emptyEnvFileContents(), paperclipOwnedEntries(entries), {
|
||||
valueEncoding: "minimal",
|
||||
});
|
||||
writeEnvFileAtomicallyIfChanged(filePath, previousContents, nextContents);
|
||||
}
|
||||
|
||||
export function mergePaperclipEnvEntries(
|
||||
@@ -114,12 +115,11 @@ export function mergePaperclipEnvEntries(
|
||||
filePath = resolveEnvFilePath(),
|
||||
): Record<string, string> {
|
||||
const current = readPaperclipEnvEntries(filePath);
|
||||
const managedEntries = paperclipOwnedEntries(entries);
|
||||
const next = {
|
||||
...current,
|
||||
...Object.fromEntries(
|
||||
Object.entries(entries).filter(([, value]) => typeof value === "string" && value.trim().length > 0),
|
||||
),
|
||||
...managedEntries,
|
||||
};
|
||||
writePaperclipEnvEntries(next, filePath);
|
||||
writePaperclipEnvEntries(managedEntries, filePath);
|
||||
return next;
|
||||
}
|
||||
Reference in new issue
Block a user