diff --git a/cli/src/__tests__/agent-jwt-env.test.ts b/cli/src/__tests__/agent-jwt-env.test.ts new file mode 100644 index 00000000..40bb1554 --- /dev/null +++ b/cli/src/__tests__/agent-jwt-env.test.ts @@ -0,0 +1,61 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + ensureAgentJwtSecret, + readAgentJwtSecretFromEnv, + resolveAgentJwtEnvFile, +} from "../config/env.js"; +import { agentJwtSecretCheck } from "../checks/agent-jwt-secret-check.js"; + +const ORIGINAL_ENV = { ...process.env }; + +function tempConfigPath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-jwt-env-")); + const configDir = path.join(dir, "custom"); + fs.mkdirSync(configDir, { recursive: true }); + return path.join(configDir, "config.json"); +} + +describe("agent jwt env helpers", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("writes .env next to explicit config path", () => { + const configPath = tempConfigPath(); + const result = ensureAgentJwtSecret(configPath); + + expect(result.created).toBe(true); + + const envPath = resolveAgentJwtEnvFile(configPath); + expect(fs.existsSync(envPath)).toBe(true); + const contents = fs.readFileSync(envPath, "utf-8"); + expect(contents).toContain("PAPERCLIP_AGENT_JWT_SECRET="); + }); + + it("loads secret from .env next to explicit config path", () => { + const configPath = tempConfigPath(); + const envPath = resolveAgentJwtEnvFile(configPath); + fs.writeFileSync(envPath, "PAPERCLIP_AGENT_JWT_SECRET=test-secret\n", { mode: 0o600 }); + + const loaded = readAgentJwtSecretFromEnv(configPath); + expect(loaded).toBe("test-secret"); + expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBe("test-secret"); + }); + + it("doctor check passes when secret exists in adjacent .env", () => { + const configPath = tempConfigPath(); + const envPath = resolveAgentJwtEnvFile(configPath); + fs.writeFileSync(envPath, "PAPERCLIP_AGENT_JWT_SECRET=check-secret\n", { mode: 0o600 }); + + const result = agentJwtSecretCheck(configPath); + expect(result.status).toBe("pass"); + }); +}); diff --git a/cli/src/checks/agent-jwt-secret-check.ts b/cli/src/checks/agent-jwt-secret-check.ts index a90f97d6..65a75cf5 100644 --- a/cli/src/checks/agent-jwt-secret-check.ts +++ b/cli/src/checks/agent-jwt-secret-check.ts @@ -6,8 +6,8 @@ import { } from "../config/env.js"; import type { CheckResult } from "./index.js"; -export function agentJwtSecretCheck(): CheckResult { - if (readAgentJwtSecretFromEnv()) { +export function agentJwtSecretCheck(configPath?: string): CheckResult { + if (readAgentJwtSecretFromEnv(configPath)) { return { name: "Agent JWT secret", status: "pass", @@ -15,7 +15,7 @@ export function agentJwtSecretCheck(): CheckResult { }; } - const envPath = resolveAgentJwtEnvFile(); + const envPath = resolveAgentJwtEnvFile(configPath); const fileSecret = readAgentJwtSecretFromEnvFile(envPath); if (fileSecret) { @@ -33,7 +33,7 @@ export function agentJwtSecretCheck(): CheckResult { message: `PAPERCLIP_AGENT_JWT_SECRET missing from environment and ${envPath}`, canRepair: true, repair: () => { - ensureAgentJwtSecret(); + ensureAgentJwtSecret(configPath); }, repairHint: `Run with --repair to create ${envPath} containing PAPERCLIP_AGENT_JWT_SECRET`, }; diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index 838f6d24..f6ec1f4f 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -64,7 +64,7 @@ export async function doctor(opts: { printResult(deploymentAuthResult); // 3. Agent JWT check - const jwtResult = agentJwtSecretCheck(); + const jwtResult = agentJwtSecretCheck(opts.config); results.push(jwtResult); printResult(jwtResult); await maybeRepair(jwtResult, opts); diff --git a/cli/src/commands/env.ts b/cli/src/commands/env.ts index ac2cba55..b8eb83a2 100644 --- a/cli/src/commands/env.ts +++ b/cli/src/commands/env.ts @@ -110,8 +110,8 @@ export async function envCommand(opts: { config?: string }): Promise { } function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: string): EnvVarRow[] { - const agentJwtEnvFile = resolveAgentJwtEnvFile(); - const jwtEnv = readAgentJwtSecretFromEnv(); + const agentJwtEnvFile = resolveAgentJwtEnvFile(configPath); + const jwtEnv = readAgentJwtSecretFromEnv(configPath); const jwtFile = jwtEnv ? null : readAgentJwtSecretFromEnvFile(agentJwtEnvFile); const jwtSource = jwtEnv ? "env" : jwtFile ? "file" : "missing"; diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 1c38ff0d..3c85a12f 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -10,23 +10,63 @@ import { promptLogging } from "../prompts/logging.js"; import { defaultSecretsConfig } from "../prompts/secrets.js"; import { defaultStorageConfig, promptStorage } from "../prompts/storage.js"; import { promptServer } from "../prompts/server.js"; -import { describeLocalInstancePaths, resolvePaperclipInstanceId } from "../config/home.js"; +import { + describeLocalInstancePaths, + resolveDefaultEmbeddedPostgresDir, + resolveDefaultLogsDir, + resolvePaperclipInstanceId, +} from "../config/home.js"; import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js"; import { printPaperclipCliBanner } from "../utils/banner.js"; -export async function onboard(opts: { config?: string }): Promise { +type SetupMode = "quickstart" | "advanced"; + +type OnboardOptions = { + config?: string; + run?: boolean; + invokedByRun?: boolean; +}; + +function quickstartDefaults(): Pick { + const instanceId = resolvePaperclipInstanceId(); + return { + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId), + embeddedPostgresPort: 54329, + }, + logging: { + mode: "file", + logDir: resolveDefaultLogsDir(instanceId), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "auto", + }, + storage: defaultStorageConfig(), + secrets: defaultSecretsConfig(), + }; +} + +export async function onboard(opts: OnboardOptions): Promise { printPaperclipCliBanner(); p.intro(pc.bgCyan(pc.black(" paperclipai onboard "))); + const configPath = resolveConfigPath(opts.config); const instance = describeLocalInstancePaths(resolvePaperclipInstanceId()); p.log.message( pc.dim( - `Local home: ${instance.homeDir} | instance: ${instance.instanceId} | config: ${resolveConfigPath(opts.config)}`, + `Local home: ${instance.homeDir} | instance: ${instance.instanceId} | config: ${configPath}`, ), ); - // Check for existing config if (configExists(opts.config)) { - const configPath = resolveConfigPath(opts.config); p.log.message(pc.dim(`${configPath} exists, updating config`)); try { @@ -40,92 +80,125 @@ export async function onboard(opts: { config?: string }): Promise { } } - // Database - p.log.step(pc.bold("Database")); - const database = await promptDatabase(); - - if (database.mode === "postgres" && database.connectionString) { - const s = p.spinner(); - s.start("Testing database connection..."); - try { - const { createDb } = await import("@paperclipai/db"); - const db = createDb(database.connectionString); - await db.execute("SELECT 1"); - s.stop("Database connection successful"); - } catch (err) { - s.stop(pc.yellow("Could not connect to database — you can fix this later with `paperclipai doctor`")); - } + const setupModeChoice = await p.select({ + message: "Choose setup path", + options: [ + { + value: "quickstart" as const, + label: "Quickstart", + hint: "Recommended: local defaults + ready to run", + }, + { + value: "advanced" as const, + label: "Advanced setup", + hint: "Customize database, server, storage, and more", + }, + ], + initialValue: "quickstart", + }); + if (p.isCancel(setupModeChoice)) { + p.cancel("Setup cancelled."); + return; } + const setupMode = setupModeChoice as SetupMode; - // LLM - p.log.step(pc.bold("LLM Provider")); - const llm = await promptLlm(); + let llm: PaperclipConfig["llm"] | undefined; + let { + database, + logging, + server, + auth, + storage, + secrets, + } = quickstartDefaults(); - if (llm?.apiKey) { - const s = p.spinner(); - s.start("Validating API key..."); - try { - if (llm.provider === "claude") { - const res = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "x-api-key": llm.apiKey, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - body: JSON.stringify({ - model: "claude-sonnet-4-5-20250929", - max_tokens: 1, - messages: [{ role: "user", content: "hi" }], - }), - }); - if (res.ok || res.status === 400) { - s.stop("API key is valid"); - } else if (res.status === 401) { - s.stop(pc.yellow("API key appears invalid — you can update it later")); - } else { - s.stop(pc.yellow("Could not validate API key — continuing anyway")); - } - } else { - const res = await fetch("https://api.openai.com/v1/models", { - headers: { Authorization: `Bearer ${llm.apiKey}` }, - }); - if (res.ok) { - s.stop("API key is valid"); - } else if (res.status === 401) { - s.stop(pc.yellow("API key appears invalid — you can update it later")); - } else { - s.stop(pc.yellow("Could not validate API key — continuing anyway")); - } + if (setupMode === "advanced") { + p.log.step(pc.bold("Database")); + database = await promptDatabase(); + + if (database.mode === "postgres" && database.connectionString) { + const s = p.spinner(); + s.start("Testing database connection..."); + try { + const { createDb } = await import("@paperclipai/db"); + const db = createDb(database.connectionString); + await db.execute("SELECT 1"); + s.stop("Database connection successful"); + } catch { + s.stop(pc.yellow("Could not connect to database — you can fix this later with `paperclipai doctor`")); } - } catch { - s.stop(pc.yellow("Could not reach API — continuing anyway")); } + + p.log.step(pc.bold("LLM Provider")); + llm = await promptLlm(); + + if (llm?.apiKey) { + const s = p.spinner(); + s.start("Validating API key..."); + try { + if (llm.provider === "claude") { + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": llm.apiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-5-20250929", + max_tokens: 1, + messages: [{ role: "user", content: "hi" }], + }), + }); + if (res.ok || res.status === 400) { + s.stop("API key is valid"); + } else if (res.status === 401) { + s.stop(pc.yellow("API key appears invalid — you can update it later")); + } else { + s.stop(pc.yellow("Could not validate API key — continuing anyway")); + } + } else { + const res = await fetch("https://api.openai.com/v1/models", { + headers: { Authorization: `Bearer ${llm.apiKey}` }, + }); + if (res.ok) { + s.stop("API key is valid"); + } else if (res.status === 401) { + s.stop(pc.yellow("API key appears invalid — you can update it later")); + } else { + s.stop(pc.yellow("Could not validate API key — continuing anyway")); + } + } + } catch { + s.stop(pc.yellow("Could not reach API — continuing anyway")); + } + } + + p.log.step(pc.bold("Logging")); + logging = await promptLogging(); + + p.log.step(pc.bold("Server")); + ({ server, auth } = await promptServer()); + + p.log.step(pc.bold("Storage")); + storage = await promptStorage(defaultStorageConfig()); + + p.log.step(pc.bold("Secrets")); + secrets = defaultSecretsConfig(); + p.log.message( + pc.dim( + `Using defaults: provider=${secrets.provider}, strictMode=${secrets.strictMode}, keyFile=${secrets.localEncrypted.keyFilePath}`, + ), + ); + } else { + p.log.step(pc.bold("Quickstart")); + p.log.message( + pc.dim("Using local defaults: embedded database, no LLM provider, file storage, and local encrypted secrets."), + ); } - // Logging - p.log.step(pc.bold("Logging")); - const logging = await promptLogging(); - - // Server - p.log.step(pc.bold("Server")); - const { server, auth } = await promptServer(); - - // Storage - p.log.step(pc.bold("Storage")); - const storage = await promptStorage(defaultStorageConfig()); - - // Secrets - p.log.step(pc.bold("Secrets")); - const secrets = defaultSecretsConfig(); - p.log.message( - pc.dim( - `Using defaults: provider=${secrets.provider}, strictMode=${secrets.strictMode}, keyFile=${secrets.localEncrypted.keyFilePath}`, - ), - ); - - const jwtSecret = ensureAgentJwtSecret(); - const envFilePath = resolveAgentJwtEnvFile(); + 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()) { @@ -134,7 +207,6 @@ export async function onboard(opts: { config?: string }): Promise { p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`); } - // Assemble and write config const config: PaperclipConfig = { $meta: { version: 1, @@ -150,7 +222,7 @@ export async function onboard(opts: { config?: string }): Promise { secrets, }; - const keyResult = ensureLocalSecretsKeyFile(config, resolveConfigPath(opts.config)); + 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") { @@ -163,24 +235,47 @@ export async function onboard(opts: { config?: string }): Promise { [ `Database: ${database.mode}`, llm ? `LLM: ${llm.provider}` : "LLM: not configured", - `Logging: ${logging.mode} → ${logging.logDir}`, + `Logging: ${logging.mode} -> ${logging.logDir}`, `Server: ${server.deploymentMode}/${server.exposure} @ ${server.host}:${server.port}`, `Allowed hosts: ${server.allowedHostnames.length > 0 ? server.allowedHostnames.join(", ") : "(loopback only)"}`, `Auth URL mode: ${auth.baseUrlMode}${auth.publicBaseUrl ? ` (${auth.publicBaseUrl})` : ""}`, `Storage: ${storage.provider}`, `Secrets: ${secrets.provider} (strict mode ${secrets.strictMode ? "on" : "off"})`, - `Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured`, + "Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured", ].join("\n"), "Configuration saved", ); - p.log.info(`Run ${pc.cyan("pnpm paperclipai doctor")} to verify your setup.`); - p.log.message( - `Before starting Paperclip, export ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from ${pc.dim(envFilePath)} (for example: ${pc.dim(`set -a; source ${envFilePath}; set +a`)})`, + p.note( + [ + `Run now: ${pc.cyan("paperclipai run")} (onboard + doctor + start in one command)`, + `Reconfigure later: ${pc.cyan("paperclipai configure")}`, + `Diagnose setup: ${pc.cyan("paperclipai doctor")}`, + ].join("\n"), + "Next commands", ); + if (server.deploymentMode === "authenticated") { p.log.step("Generating bootstrap CEO invite"); - await bootstrapCeoInvite({ config: opts.config }); + await bootstrapCeoInvite({ config: configPath }); } + + let shouldRunNow = opts.run === 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) { + const { runCommand } = await import("./run.js"); + await runCommand({ config: configPath, repair: true, yes: true }); + return; + } + p.outro("You're all set!"); } diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index 9b54b52f..cfdd5ad6 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -45,7 +45,7 @@ export async function runCommand(opts: RunOptions): Promise { } p.log.step("No config found. Starting onboarding..."); - await onboard({ config: configPath }); + await onboard({ config: configPath, invokedByRun: true }); } p.log.step("Running doctor checks..."); diff --git a/cli/src/config/env.ts b/cli/src/config/env.ts index a0291a33..908907ba 100644 --- a/cli/src/config/env.ts +++ b/cli/src/config/env.ts @@ -5,8 +5,8 @@ import { config as loadDotenv, parse as parseEnvFileContents } from "dotenv"; import { resolveConfigPath } from "./store.js"; const JWT_SECRET_ENV_KEY = "PAPERCLIP_AGENT_JWT_SECRET"; -function resolveEnvFilePath() { - return path.resolve(path.dirname(resolveConfigPath()), ".env"); +function resolveEnvFilePath(configPath?: string) { + return path.resolve(path.dirname(resolveConfigPath(configPath)), ".env"); } const loadedEnvFiles = new Set(); @@ -32,8 +32,8 @@ function renderEnvFile(entries: Record) { return lines.join("\n"); } -export function resolveAgentJwtEnvFile(): string { - return resolveEnvFilePath(); +export function resolveAgentJwtEnvFile(configPath?: string): string { + return resolveEnvFilePath(configPath); } export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void { @@ -44,8 +44,8 @@ export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void { loadDotenv({ path: filePath, override: false, quiet: true }); } -export function readAgentJwtSecretFromEnv(): string | null { - loadAgentJwtEnvFile(); +export function readAgentJwtSecretFromEnv(configPath?: string): string | null { + loadAgentJwtEnvFile(resolveEnvFilePath(configPath)); const raw = process.env[JWT_SECRET_ENV_KEY]; return isNonEmpty(raw) ? raw!.trim() : null; } @@ -59,18 +59,19 @@ export function readAgentJwtSecretFromEnvFile(filePath = resolveEnvFilePath()): return isNonEmpty(value) ? value!.trim() : null; } -export function ensureAgentJwtSecret(): { secret: string; created: boolean } { - const existingEnv = readAgentJwtSecretFromEnv(); +export function ensureAgentJwtSecret(configPath?: string): { secret: string; created: boolean } { + const existingEnv = readAgentJwtSecretFromEnv(configPath); if (existingEnv) { return { secret: existingEnv, created: false }; } - const existingFile = readAgentJwtSecretFromEnvFile(); + const envFilePath = resolveEnvFilePath(configPath); + const existingFile = readAgentJwtSecretFromEnvFile(envFilePath); const secret = existingFile ?? randomBytes(32).toString("hex"); const created = !existingFile; if (!existingFile) { - writeAgentJwtEnv(secret); + writeAgentJwtEnv(secret, envFilePath); } return { secret, created }; diff --git a/cli/src/index.ts b/cli/src/index.ts index 67cc09b4..50ad8d88 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -40,6 +40,7 @@ program .description("Interactive first-run setup wizard") .option("-c, --config ", "Path to config file") .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) + .option("--run", "Start Paperclip immediately after saving config", false) .action(onboard); program diff --git a/docs/cli/setup-commands.md b/docs/cli/setup-commands.md index 11798ce1..95eb1b79 100644 --- a/docs/cli/setup-commands.md +++ b/docs/cli/setup-commands.md @@ -33,12 +33,16 @@ Interactive first-time setup: pnpm paperclipai onboard ``` -Prompts for: +First prompt: -1. Deployment mode (`local_trusted` or `authenticated`) -2. Exposure policy (if authenticated: `private` or `public`) -3. Public URL (if authenticated + public) -4. Database and secrets configuration +1. `Quickstart` (recommended): local defaults (embedded database, no LLM provider, local disk storage, default secrets) +2. `Advanced setup`: full interactive configuration + +Start immediately after onboarding: + +```sh +pnpm paperclipai onboard --run +``` ## `paperclipai doctor`