Make onboarding reruns preserve existing config

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-28 21:44:15 -05:00
parent f83a77f41f
commit 54b05d6d68
6 changed files with 188 additions and 2 deletions

View file

@ -177,6 +177,8 @@ Open source. Self-hosted. No Paperclip account required.
npx paperclipai onboard --yes
```
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
Or manually:
```bash

View file

@ -177,6 +177,8 @@ Open source. Self-hosted. No Paperclip account required.
npx paperclipai onboard --yes
```
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
Or manually:
```bash

View file

@ -0,0 +1,105 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { onboard } from "../commands/onboard.js";
import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_ENV = { ...process.env };
function createExistingConfigFixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-"));
const runtimeRoot = path.join(root, "runtime");
const configPath = path.join(root, ".paperclip", "config.json");
const config: PaperclipConfig = {
$meta: {
version: 1,
updatedAt: "2026-03-29T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(runtimeRoot, "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(runtimeRoot, "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3100,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(runtimeRoot, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
},
},
};
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
return { configPath, configText: fs.readFileSync(configPath, "utf8") };
}
describe("onboard", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("preserves an existing config when rerun without flags", async () => {
const fixture = createExistingConfigFixture();
await onboard({ config: fixture.configPath });
expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText);
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
});
it("preserves an existing config when rerun with --yes", async () => {
const fixture = createExistingConfigFixture();
await onboard({ config: fixture.configPath, yes: true, invokedByRun: true });
expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText);
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
});
});

View file

@ -244,11 +244,12 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
),
);
let existingConfig: PaperclipConfig | null = null;
if (configExists(opts.config)) {
p.log.message(pc.dim(`${configPath} exists, updating config`));
p.log.message(pc.dim(`${configPath} exists`));
try {
readConfig(opts.config);
existingConfig = readConfig(opts.config);
} catch (err) {
p.log.message(
pc.yellow(
@ -258,6 +259,76 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
}
}
if (existingConfig) {
p.log.message(
pc.dim("Existing Paperclip install detected; keeping the current configuration unchanged."),
);
p.log.message(pc.dim(`Use ${pc.cyan("paperclipai configure")} if you want to change settings.`));
const jwtSecret = ensureAgentJwtSecret(configPath);
const envFilePath = resolveAgentJwtEnvFile(configPath);
if (jwtSecret.created) {
p.log.success(`Created ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
} else if (process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim()) {
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from environment`);
} else {
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
}
const keyResult = ensureLocalSecretsKeyFile(existingConfig, 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}`));
}
p.note(
[
"Existing config preserved",
`Database: ${existingConfig.database.mode}`,
existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured",
`Logging: ${existingConfig.logging.mode} -> ${existingConfig.logging.logDir}`,
`Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${existingConfig.server.host}:${existingConfig.server.port}`,
`Allowed hosts: ${existingConfig.server.allowedHostnames.length > 0 ? existingConfig.server.allowedHostnames.join(", ") : "(loopback only)"}`,
`Auth URL mode: ${existingConfig.auth.baseUrlMode}${existingConfig.auth.publicBaseUrl ? ` (${existingConfig.auth.publicBaseUrl})` : ""}`,
`Storage: ${existingConfig.storage.provider}`,
`Secrets: ${existingConfig.secrets.provider} (strict mode ${existingConfig.secrets.strictMode ? "on" : "off"})`,
"Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured",
].join("\n"),
"Configuration ready",
);
p.note(
[
`Run: ${pc.cyan("paperclipai run")}`,
`Reconfigure later: ${pc.cyan("paperclipai configure")}`,
`Diagnose setup: ${pc.cyan("paperclipai doctor")}`,
].join("\n"),
"Next commands",
);
let shouldRunNow = opts.run === true || opts.yes === true;
if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) {
const answer = await p.confirm({
message: "Start Paperclip now?",
initialValue: true,
});
if (!p.isCancel(answer)) {
shouldRunNow = answer;
}
}
if (shouldRunNow && !opts.invokedByRun) {
process.env.PAPERCLIP_OPEN_ON_LISTEN = "true";
const { runCommand } = await import("./run.js");
await runCommand({ config: configPath, repair: true, yes: true });
return;
}
p.outro("Existing Paperclip setup is ready.");
return;
}
let setupMode: SetupMode = "quickstart";
if (opts.yes) {
p.log.message(pc.dim("`--yes` enabled: using Quickstart defaults."));

View file

@ -33,6 +33,8 @@ Interactive first-time setup:
pnpm paperclipai onboard
```
If Paperclip is already configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to change settings on an existing install.
First prompt:
1. `Quickstart` (recommended): local defaults (embedded database, no LLM provider, local disk storage, default secrets)
@ -50,6 +52,8 @@ Non-interactive defaults + immediate start (opens browser on server listen):
pnpm paperclipai onboard --yes
```
On an existing install, `--yes` now preserves the current config and just starts Paperclip with that setup.
## `paperclipai doctor`
Health checks with optional auto-repair:

View file

@ -13,6 +13,8 @@ npx paperclipai onboard --yes
This walks you through setup, configures your environment, and gets Paperclip running.
If you already have a Paperclip install, rerunning `onboard` keeps your current config and data paths intact. Use `paperclipai configure` if you want to edit settings.
To start Paperclip again later:
```sh