fix(config): preserve env files during managed updates

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-06 12:45:09 +00:00
1 parent 2ea22d6cea
commit 31a54faef6
5 files changed
+351 -92

No files matched your search

+58
View File
@@ -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
View File
@@ -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;
}