Files
PaperClipAI/cli/src/commands/configure.ts
T
DottaandPaperclip 35132af161 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>
2026-08-07 00:41:19 -05:00

244 lines
7.0 KiB
TypeScript

import * as p from "@clack/prompts";
import pc from "picocolors";
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";
import { promptLogging } from "../prompts/logging.js";
import { defaultSecretsConfig, promptSecrets } from "../prompts/secrets.js";
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
import { promptServer } from "../prompts/server.js";
import {
resolveDefaultBackupDir,
resolveDefaultEmbeddedPostgresDir,
resolveDefaultLogsDir,
resolvePaperclipInstanceId,
} from "../config/home.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
type Section = "llm" | "database" | "logging" | "server" | "storage" | "secrets";
const SECTION_LABELS: Record<Section, string> = {
llm: "LLM Provider",
database: "Database",
logging: "Logging",
server: "Server",
storage: "Storage",
secrets: "Secrets",
};
function defaultConfig(): PaperclipConfig {
const instanceId = resolvePaperclipInstanceId();
return {
$meta: {
version: 1,
updatedAt: new Date().toISOString(),
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId),
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: resolveDefaultBackupDir(instanceId),
},
},
logging: {
mode: "file",
logDir: resolveDefaultLogsDir(instanceId),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
bind: "loopback",
host: "127.0.0.1",
port: 3100,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
telemetry: {
enabled: true,
},
storage: defaultStorageConfig(),
secrets: defaultSecretsConfig(),
};
}
export async function configure(opts: {
config?: string;
section?: string;
}): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclip configure ")));
const configPath = resolveConfigPath(opts.config);
if (!configExists(opts.config)) {
p.log.error("No config file found. Run `paperclipai onboard` first.");
p.outro("");
process.exitCode = 1;
return;
}
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) {
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;
if (section && !SECTION_LABELS[section]) {
p.log.error(`Unknown section: ${section}. Choose from: ${Object.keys(SECTION_LABELS).join(", ")}`);
p.outro("");
process.exitCode = 1;
return;
}
// Section selection loop
let continueLoop = true;
while (continueLoop) {
if (!section) {
const choice = await p.select({
message: "Which section do you want to configure?",
options: Object.entries(SECTION_LABELS).map(([value, label]) => ({
value: value as Section,
label,
})),
});
if (p.isCancel(choice)) {
p.cancel("Configuration cancelled.");
return;
}
section = choice;
}
p.log.step(pc.bold(SECTION_LABELS[section]));
switch (section) {
case "database":
config.database = await promptDatabase(config.database);
break;
case "llm": {
const llm = await promptLlm();
if (llm) {
config.llm = llm;
} else {
delete config.llm;
}
break;
}
case "logging":
config.logging = await promptLogging();
break;
case "server":
{
const { server, auth } = await promptServer({
currentServer: config.server,
currentAuth: config.auth,
});
config.server = server;
config.auth = auth;
}
break;
case "storage":
config.storage = await promptStorage(config.storage);
break;
case "secrets":
config.secrets = await promptSecrets(config.secrets);
{
const keyResult = ensureLocalSecretsKeyFile(config, configPath);
if (keyResult.status === "created") {
p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`);
} else if (keyResult.status === "existing") {
p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`));
} else if (keyResult.status === "skipped_provider") {
p.log.message(pc.dim("Skipping local key file management for non-local provider"));
} else {
p.log.message(pc.dim("Skipping local key file management because PAPERCLIP_SECRETS_MASTER_KEY is set"));
}
}
break;
}
config.$meta.updatedAt = new Date().toISOString();
config.$meta.source = "configure";
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) {
continueLoop = false;
} else {
const another = await p.confirm({
message: "Configure another section?",
initialValue: false,
});
if (p.isCancel(another) || !another) {
continueLoop = false;
} else {
section = undefined; // Reset to show picker again
}
}
}
p.outro("Configuration saved.");
}