Merge branch 'pr-11005' into pap16587-combined

This commit is contained in:
Dotta committed 2026-08-07 05:21:36 +00:00
commit ead4149b1a
16 files changed
+940 -43

No files matched your search

+23 -1
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readConfigFile } from "../config-file.js";
const ORIGINAL_PAPERCLIP_CONFIG = process.env.PAPERCLIP_CONFIG;
@@ -42,6 +42,7 @@ describe("readConfigFile", () => {
});
afterEach(() => {
vi.restoreAllMocks();
if (ORIGINAL_PAPERCLIP_CONFIG === undefined) {
delete process.env.PAPERCLIP_CONFIG;
} else {
@@ -89,4 +90,25 @@ describe("readConfigFile", () => {
},
});
});
it("warns about likely misspellings without stripping them", () => {
const config = {
...(minimalConfig() as Record<string, unknown>),
server: {
ports: 3200,
},
};
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
writeConfig(configPath, config);
expect(readConfigFile()).toMatchObject({
server: {
port: 3100,
ports: 3200,
},
});
expect(warn).toHaveBeenCalledWith(
"Unknown config key server.ports; did you mean server.port? It will be preserved.",
);
});
});
@@ -1,3 +1,4 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -981,6 +982,83 @@ describe("worktree config repair", () => {
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"),
+12 -2
View File
@@ -1,5 +1,9 @@
import fs from "node:fs";
import { paperclipConfigSchema, type PaperclipConfig } from "@paperclipai/shared";
import {
findPaperclipConfigKeyWarnings,
paperclipConfigSchema,
type PaperclipConfig,
} from "@paperclipai/shared";
import { ZodError } from "zod";
import { resolvePaperclipConfigPath } from "./paths.js";
@@ -26,7 +30,13 @@ export function readConfigFile(): PaperclipConfig | null {
}
try {
return paperclipConfigSchema.parse(raw);
const config = paperclipConfigSchema.parse(raw);
for (const warning of findPaperclipConfigKeyWarnings(config)) {
console.warn(
`Unknown config key ${warning.path}; did you mean ${warning.suggestion}? It will be preserved.`,
);
}
return config;
} catch (error) {
if (error instanceof ZodError) {
throw new Error(`Invalid Paperclip config at ${configPath}: ${formatConfigValidationError(error)}`);
+59 -3
View File
@@ -1,7 +1,12 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { PaperclipConfig } from "@paperclipai/shared";
import { isDeepStrictEqual } from "node:util";
import {
mergePaperclipConfig,
paperclipConfigSchema,
type PaperclipConfig,
} from "@paperclipai/shared";
import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js";
function nonEmpty(value: string | null | undefined): string | null {
@@ -305,9 +310,60 @@ function resolveWorktreeRuntimeContext(
};
}
function writeConfigFile(configPath: string, config: PaperclipConfig): void {
function atomicWriteFile(filePath: string, contents: string): void {
let attempt = 0;
while (true) {
const temporaryPath = `${filePath}.tmp-${process.pid}-${attempt}`;
attempt += 1;
let fileDescriptor: number | null = null;
try {
fileDescriptor = fs.openSync(temporaryPath, "wx", 0o600);
fs.writeFileSync(fileDescriptor, contents, "utf8");
fs.fsyncSync(fileDescriptor);
fs.closeSync(fileDescriptor);
fileDescriptor = null;
fs.renameSync(temporaryPath, filePath);
let directoryDescriptor: number | null = null;
try {
directoryDescriptor = fs.openSync(path.dirname(filePath), "r");
fs.fsyncSync(directoryDescriptor);
} catch (error) {
const code = error instanceof Error && "code" in error ? error.code : null;
if (
process.platform !== "win32" ||
!["EACCES", "EINVAL", "EISDIR", "ENOTSUP", "EPERM"].includes(String(code))
) {
throw error;
}
} finally {
if (directoryDescriptor !== null) fs.closeSync(directoryDescriptor);
}
return;
} catch (error) {
if (fileDescriptor !== null) fs.closeSync(fileDescriptor);
fs.rmSync(temporaryPath, { force: true });
const code = error instanceof Error && "code" in error ? error.code : null;
if (code === "EEXIST") continue;
throw error;
}
}
}
function writeConfigFile(configPath: string, config: PaperclipConfig): boolean {
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
const update = paperclipConfigSchema.parse(config);
const source = fs.existsSync(configPath)
? paperclipConfigSchema.parse(JSON.parse(fs.readFileSync(configPath, "utf8")))
: null;
const nextConfig = source
? paperclipConfigSchema.parse(mergePaperclipConfig(source, update))
: update;
if (source && isDeepStrictEqual(source, nextConfig)) return false;
atomicWriteFile(configPath, JSON.stringify(nextConfig, null, 2) + "\n");
return true;
}
function resolveRepoManagedWorktreesRoot(worktreeRoot: string): string | null {