fix(config): preserve extensions and guard invalid repairs (#11005)

## 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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-08-07 00:41:19 -05:00
1 parent 9ace548fd2
commit 35132af161
16 files changed
+940 -43

No files matched your search

+139
View File
@@ -0,0 +1,139 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
backupInvalidConfig,
readConfig,
writeConfig,
} from "../config/store.js";
import { paperclipConfigSchema, type PaperclipConfig } from "../config/schema.js";
const roots: string[] = [];
afterEach(() => {
vi.restoreAllMocks();
for (const root of roots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
function createConfigPath(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-config-store-"));
roots.push(root);
return path.join(root, "config.json");
}
function defaultConfig(): PaperclipConfig {
return paperclipConfigSchema.parse({
$meta: {
version: 1,
updatedAt: "2026-08-06T00:00:00.000Z",
source: "configure",
},
database: { mode: "embedded-postgres" },
logging: { mode: "file" },
server: {},
});
}
describe("config store", () => {
it("preserves top-level and nested extension keys during a known-field update", () => {
const configPath = createConfigPath();
fs.writeFileSync(configPath, JSON.stringify({
...defaultConfig(),
topLevelExtension: { enabled: true },
server: {
...defaultConfig().server,
serverExtension: "keep",
},
storage: {
...defaultConfig().storage,
localDisk: {
...defaultConfig().storage.localDisk,
driverExtension: "keep",
},
},
}, null, 2));
const source = readConfig(configPath)!;
const { topLevelExtension: _topLevelExtension, ...knownConfig } = source;
const { serverExtension: _serverExtension, ...knownServer } = source.server;
const { driverExtension: _driverExtension, ...knownLocalDisk } = source.storage.localDisk;
const update: PaperclipConfig = {
...knownConfig,
server: {
...knownServer,
port: 3200,
},
storage: {
...source.storage,
localDisk: knownLocalDisk,
},
};
expect(writeConfig(update, configPath)).toBe(true);
expect(JSON.parse(fs.readFileSync(configPath, "utf8"))).toMatchObject({
topLevelExtension: { enabled: true },
server: {
port: 3200,
serverExtension: "keep",
},
storage: {
localDisk: {
driverExtension: "keep",
},
},
});
});
it("skips semantic no-op writes and keeps the config mtime stable", () => {
const configPath = createConfigPath();
const source = defaultConfig();
fs.writeFileSync(configPath, `${JSON.stringify(source, null, 2)}\n`);
const stableTime = new Date("2020-01-01T00:00:00.000Z");
fs.utimesSync(configPath, stableTime, stableTime);
const update = {
...source,
$meta: {
...source.$meta,
source: "doctor" as const,
updatedAt: "2026-08-06T01:00:00.000Z",
},
};
expect(writeConfig(update, configPath)).toBe(false);
expect(fs.statSync(configPath).mtimeMs).toBe(stableTime.getTime());
expect(fs.existsSync(`${configPath}.backup`)).toBe(false);
});
it("backs up invalid bytes collision-safely and only replaces them through an atomic repair", () => {
const configPath = createConfigPath();
const invalidBytes = Buffer.from('{"server": invalid}\n', "utf8");
fs.writeFileSync(configPath, invalidBytes);
fs.writeFileSync(`${configPath}.invalid-1`, "existing backup");
const open = vi.spyOn(fs, "openSync");
const sync = vi.spyOn(fs, "fsyncSync");
const backupPath = backupInvalidConfig(configPath);
expect(backupPath).toBe(`${configPath}.invalid-2`);
expect(fs.readFileSync(backupPath)).toEqual(invalidBytes);
expect(open).toHaveBeenCalledWith(backupPath, "r");
expect(open).toHaveBeenCalledWith(path.dirname(configPath), "r");
expect(sync).toHaveBeenCalled();
expect(() => writeConfig(defaultConfig(), configPath)).toThrow(/Refusing to overwrite invalid config/);
expect(fs.readFileSync(configPath)).toEqual(invalidBytes);
open.mockClear();
sync.mockClear();
const rename = vi.spyOn(fs, "renameSync");
expect(writeConfig(defaultConfig(), configPath, { invalidBackupPath: backupPath })).toBe(true);
expect(rename).toHaveBeenCalledWith(expect.stringMatching(/config\.json\.tmp-\d+-\d+$/), configPath);
expect(open).toHaveBeenCalledWith(path.dirname(configPath), "r");
expect(open.mock.invocationCallOrder.at(-1)!).toBeGreaterThan(rename.mock.invocationCallOrder.at(-1)!);
expect(sync.mock.invocationCallOrder.at(-1)!).toBeGreaterThan(open.mock.invocationCallOrder.at(-1)!);
expect(readConfig(configPath)).not.toBeNull();
expect(fs.readdirSync(path.dirname(configPath)).some((entry) => entry.includes(".tmp-"))).toBe(false);
});
});
@@ -0,0 +1,83 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as prompts from "@clack/prompts";
import { configure } from "../commands/configure.js";
import { readConfig } from "../config/store.js";
vi.mock("@clack/prompts", () => ({
intro: vi.fn(),
outro: vi.fn(),
cancel: vi.fn(),
confirm: vi.fn(),
select: vi.fn(),
isCancel: vi.fn(() => false),
log: {
error: vi.fn(),
message: vi.fn(),
step: vi.fn(),
success: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("../prompts/server.js", () => ({
promptServer: vi.fn(async ({ currentServer, currentAuth }) => ({
server: currentServer,
auth: currentAuth,
})),
}));
const ORIGINAL_EXIT_CODE = process.exitCode;
let originalStdinIsTTY: boolean | undefined;
let originalStdoutIsTTY: boolean | undefined;
beforeEach(() => {
originalStdinIsTTY = process.stdin.isTTY;
originalStdoutIsTTY = process.stdout.isTTY;
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
vi.mocked(prompts.confirm).mockResolvedValue(true);
vi.spyOn(console, "log").mockImplementation(() => undefined);
});
afterEach(() => {
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
value: originalStdinIsTTY,
});
Object.defineProperty(process.stdout, "isTTY", {
configurable: true,
value: originalStdoutIsTTY,
});
process.exitCode = ORIGINAL_EXIT_CODE;
vi.restoreAllMocks();
});
describe("configure invalid-config repair", () => {
it("repairs only after confirmation and commits the staged config atomically", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-configure-repair-"));
const configPath = path.join(root, "config.json");
const invalidBytes = Buffer.from('{"server": invalid}\n', "utf8");
fs.writeFileSync(configPath, invalidBytes);
const rename = vi.spyOn(fs, "renameSync");
try {
await configure({ config: configPath, section: "server" });
expect(prompts.confirm).toHaveBeenCalledWith({
message: `Repair from defaults? The invalid original is backed up at ${configPath}.invalid-1.`,
initialValue: false,
});
expect(fs.readFileSync(`${configPath}.invalid-1`)).toEqual(invalidBytes);
expect(readConfig(configPath)).not.toBeNull();
expect(rename).toHaveBeenCalledWith(
expect.stringMatching(/config\.json\.tmp-\d+-\d+$/),
configPath,
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
+25
View File
@@ -96,4 +96,29 @@ describe("configure command", () => {
fs.rmSync(root, { recursive: true, force: true });
}
});
it("backs up invalid config bytes and refuses non-interactive replacement", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-configure-invalid-"));
const configPath = path.join(root, "config.json");
const invalidBytes = Buffer.from('{"server": invalid}\n', "utf8");
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
fs.writeFileSync(configPath, invalidBytes);
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false });
try {
await configure({ config: configPath, section: "server" });
expect(process.exitCode).toBe(1);
expect(fs.readFileSync(configPath)).toEqual(invalidBytes);
expect(fs.readFileSync(`${configPath}.invalid-1`)).toEqual(invalidBytes);
expect(fs.existsSync(`${configPath}.backup`)).toBe(false);
} finally {
if (stdinDescriptor) {
Object.defineProperty(process.stdin, "isTTY", stdinDescriptor);
} else {
delete (process.stdin as { isTTY?: boolean }).isTTY;
}
fs.rmSync(root, { recursive: true, force: true });
}
});
});
+16
View File
@@ -8,6 +8,7 @@ import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_ENV = { ...process.env };
const ORIGINAL_CWD = process.cwd();
const ORIGINAL_PATH = process.env.PATH;
const ORIGINAL_EXIT_CODE = process.exitCode;
function createExistingConfigFixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-"));
@@ -107,6 +108,7 @@ describe("onboard", () => {
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
process.chdir(ORIGINAL_CWD);
process.exitCode = ORIGINAL_EXIT_CODE;
});
it("preserves an existing config when rerun without flags", async () => {
@@ -129,6 +131,20 @@ describe("onboard", () => {
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
});
it("backs up invalid config bytes and refuses --yes replacement", async () => {
const configPath = createFreshConfigPath();
const invalidBytes = Buffer.from('{"database": invalid}\n', "utf8");
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, invalidBytes);
await onboard({ config: configPath, yes: true, invokedByRun: true });
expect(process.exitCode).toBe(1);
expect(fs.readFileSync(configPath)).toEqual(invalidBytes);
expect(fs.readFileSync(`${configPath}.invalid-1`)).toEqual(invalidBytes);
expect(fs.existsSync(`${configPath}.backup`)).toBe(false);
});
it("keeps --yes onboarding on local trusted loopback defaults", async () => {
const configPath = createFreshConfigPath();
process.env.HOST = "0.0.0.0";
+48 -8
View File
@@ -1,7 +1,16 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import { readConfig, writeConfig, configExists, resolveConfigPath } from "../config/store.js";
import type { PaperclipConfig } from "../config/schema.js";
import {
backupInvalidConfig,
readConfig,
writeConfig,
configExists,
resolveConfigPath,
} from "../config/store.js";
import {
findPaperclipConfigKeyWarnings,
type PaperclipConfig,
} from "../config/schema.js";
import { ensureLocalSecretsKeyFile } from "../config/secrets-key.js";
import { promptDatabase } from "../prompts/database.js";
import { promptLlm } from "../prompts/llm.js";
@@ -88,15 +97,39 @@ export async function configure(opts: {
}
let config: PaperclipConfig;
let invalidBackupPath: string | undefined;
try {
config = readConfig(opts.config) ?? defaultConfig();
for (const warning of findPaperclipConfigKeyWarnings(config)) {
p.log.warn(`Unknown config key ${warning.path}; did you mean ${warning.suggestion}? It will be preserved.`);
}
} catch (err) {
p.log.message(
pc.yellow(
`Existing config is invalid. Loading defaults so you can repair it now.\n${err instanceof Error ? err.message : String(err)}`,
),
const backupPath = backupInvalidConfig(opts.config);
p.log.warn(
`Existing config is invalid. Preserved the original bytes at ${backupPath}.\n${err instanceof Error ? err.message : String(err)}`,
);
if (!process.stdin.isTTY || !process.stdout.isTTY) {
p.log.error(
`Refusing to replace ${configPath} without confirmation. Rerun interactively to repair from defaults; the original and ${backupPath} are unchanged.`,
);
p.outro("");
process.exitCode = 1;
return;
}
const repair = await p.confirm({
message: `Repair from defaults? The invalid original is backed up at ${backupPath}.`,
initialValue: false,
});
if (p.isCancel(repair) || !repair) {
p.cancel(`Configuration left unchanged. Invalid backup: ${backupPath}`);
process.exitCode = 1;
return;
}
config = defaultConfig();
invalidBackupPath = backupPath;
}
let section: Section | undefined = opts.section as Section | undefined;
@@ -179,8 +212,15 @@ export async function configure(opts: {
config.$meta.updatedAt = new Date().toISOString();
config.$meta.source = "configure";
writeConfig(config, opts.config);
p.log.success(`${SECTION_LABELS[section]} configuration updated.`);
const written = writeConfig(config, opts.config, {
invalidBackupPath,
});
invalidBackupPath = undefined;
if (written) {
p.log.success(`${SECTION_LABELS[section]} configuration updated.`);
} else {
p.log.message(pc.dim(`${SECTION_LABELS[section]} configuration unchanged.`));
}
// If section was provided via CLI flag, don't loop
if (opts.section) {
+46 -7
View File
@@ -17,8 +17,17 @@ import {
type SecretProvider,
type StorageProvider,
} from "@paperclipai/shared";
import { configExists, readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
import type { PaperclipConfig } from "../config/schema.js";
import {
backupInvalidConfig,
configExists,
readConfig,
resolveConfigPath,
writeConfig,
} from "../config/store.js";
import {
findPaperclipConfigKeyWarnings,
type PaperclipConfig,
} from "../config/schema.js";
import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js";
import { ensureLocalSecretsKeyFile } from "../config/secrets-key.js";
import { promptDatabase } from "../prompts/database.js";
@@ -356,17 +365,45 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
);
let existingConfig: PaperclipConfig | null = null;
let invalidBackupPath: string | undefined;
if (configExists(opts.config)) {
p.log.message(pc.dim(`${configPath} exists`));
try {
existingConfig = readConfig(opts.config);
for (const warning of findPaperclipConfigKeyWarnings(existingConfig)) {
p.log.warn(`Unknown config key ${warning.path}; did you mean ${warning.suggestion}? It will be preserved.`);
}
} catch (err) {
p.log.message(
pc.yellow(
`Existing config appears invalid and will be updated.\n${err instanceof Error ? err.message : String(err)}`,
),
const backupPath = backupInvalidConfig(opts.config);
p.log.warn(
`Existing config is invalid. Preserved the original bytes at ${backupPath}.\n${err instanceof Error ? err.message : String(err)}`,
);
const canConfirmRepair =
opts.yes !== true &&
opts.invokedByRun !== true &&
process.stdin.isTTY === true &&
process.stdout.isTTY === true;
if (!canConfirmRepair) {
p.log.error(
`Refusing to replace ${configPath} without confirmation. Rerun interactively to repair from defaults; the original and ${backupPath} are unchanged.`,
);
p.outro("");
process.exitCode = 1;
return;
}
const repair = await p.confirm({
message: `Repair from defaults? The invalid original is backed up at ${backupPath}.`,
initialValue: false,
});
if (p.isCancel(repair) || !repair) {
p.cancel(`Configuration left unchanged. Invalid backup: ${backupPath}`);
process.exitCode = 1;
return;
}
invalidBackupPath = backupPath;
}
}
@@ -646,7 +683,9 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`));
}
writeConfig(config, opts.config);
writeConfig(config, opts.config, {
invalidBackupPath,
});
if (tc) trackInstallCompleted(tc, {
adapterType: server.deploymentMode,
+5
View File
@@ -8,11 +8,15 @@ export {
serverConfigSchema,
authConfigSchema,
telemetryConfigSchema,
updatesConfigSchema,
storageConfigSchema,
storageLocalDiskConfigSchema,
storageS3ConfigSchema,
secretsConfigSchema,
secretsLocalEncryptedConfigSchema,
mergePaperclipConfig,
findPaperclipConfigKeyWarnings,
type ConfigKeyWarning,
type PaperclipConfig,
type LlmConfig,
type DatabaseBackupConfig,
@@ -27,4 +31,5 @@ export {
type SecretsConfig,
type SecretsLocalEncryptedConfig,
type ConfigMeta,
type UpdatesConfig,
} from "../../../packages/shared/src/config-schema.js";
+120 -7
View File
@@ -1,6 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import { paperclipConfigSchema, type PaperclipConfig } from "./schema.js";
import { isDeepStrictEqual } from "node:util";
import {
mergePaperclipConfig,
paperclipConfigSchema,
type PaperclipConfig,
} from "./schema.js";
import {
resolveDefaultConfigPath,
resolvePaperclipInstanceId,
@@ -95,24 +100,132 @@ export function readConfig(configPath?: string): PaperclipConfig | null {
return parsed.data;
}
function effectiveConfig(config: PaperclipConfig): Record<string, unknown> {
const meta = { ...config.$meta } as Record<string, unknown>;
delete meta.updatedAt;
delete meta.source;
return {
...config,
$meta: meta,
};
}
function syncDirectory(directoryPath: string): void {
let directoryDescriptor: number | null = null;
try {
directoryDescriptor = fs.openSync(directoryPath, "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);
}
}
function durableCopyFile(sourcePath: string, destinationPath: string, flags = 0): void {
fs.copyFileSync(sourcePath, destinationPath, flags);
fs.chmodSync(destinationPath, 0o600);
const backupDescriptor = fs.openSync(destinationPath, "r");
try {
fs.fsyncSync(backupDescriptor);
} finally {
fs.closeSync(backupDescriptor);
}
syncDirectory(path.dirname(destinationPath));
}
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);
syncDirectory(path.dirname(filePath));
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;
}
}
}
export function backupInvalidConfig(configPath?: string): string {
const filePath = resolveConfigPath(configPath);
if (!fs.existsSync(filePath)) {
throw new Error(`Cannot back up missing config at ${filePath}`);
}
for (let suffix = 1; ; suffix += 1) {
const backupPath = `${filePath}.invalid-${suffix}`;
try {
durableCopyFile(filePath, backupPath, fs.constants.COPYFILE_EXCL);
return backupPath;
} catch (error) {
const code = error instanceof Error && "code" in error ? error.code : null;
if (code === "EEXIST") continue;
throw error;
}
}
}
export function writeConfig(
config: PaperclipConfig,
configPath?: string,
): void {
options: { invalidBackupPath?: string } = {},
): boolean {
const filePath = resolveConfigPath(configPath);
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
let nextConfig = paperclipConfigSchema.parse(config);
if (fs.existsSync(filePath)) {
try {
const source = paperclipConfigSchema.parse(migrateLegacyConfig(parseJson(filePath)));
nextConfig = paperclipConfigSchema.parse(mergePaperclipConfig(source, nextConfig));
if (isDeepStrictEqual(effectiveConfig(source), effectiveConfig(nextConfig))) {
return false;
}
} catch (error) {
const invalidBackupPath = options.invalidBackupPath;
if (!invalidBackupPath) {
throw new Error(
`Refusing to overwrite invalid config at ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (
!fs.existsSync(invalidBackupPath) ||
!fs.readFileSync(filePath).equals(fs.readFileSync(invalidBackupPath))
) {
throw new Error(
`Refusing to overwrite ${filePath} because it changed after the invalid backup was created`,
);
}
}
}
// Backup existing config before overwriting
if (fs.existsSync(filePath)) {
const backupPath = filePath + ".backup";
fs.copyFileSync(filePath, backupPath);
fs.chmodSync(backupPath, 0o600);
durableCopyFile(filePath, backupPath);
}
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n", {
mode: 0o600,
});
atomicWriteFile(filePath, JSON.stringify(nextConfig, null, 2) + "\n");
return true;
}
export function configExists(configPath?: string): boolean {