From 5b983ca4d32c1cd4d4b211c619e7b8a712d870dc Mon Sep 17 00:00:00 2001 From: Forgotten Date: Mon, 23 Feb 2026 14:40:59 -0600 Subject: [PATCH] feat(cli): add deployment mode prompts, auth bootstrap-ceo command, and doctor check Extend server setup prompts with deployment mode (local_trusted vs authenticated), exposure (private vs public), bind host, and auth config. Add auth bootstrap-ceo command that creates a one-time invite URL for the initial instance admin. Add deployment-auth-check to doctor diagnostics. Register the new command in the CLI entry point. Co-Authored-By: Claude Opus 4.6 --- cli/src/checks/deployment-auth-check.ts | 91 +++++++++++++++++++ cli/src/checks/index.ts | 1 + cli/src/commands/auth-bootstrap-ceo.ts | 116 ++++++++++++++++++++++++ cli/src/commands/configure.ts | 12 ++- cli/src/commands/doctor.ts | 20 ++-- cli/src/commands/onboard.ts | 11 ++- cli/src/config/schema.ts | 2 + cli/src/index.ts | 12 +++ cli/src/prompts/server.ts | 100 +++++++++++++++++++- 9 files changed, 352 insertions(+), 13 deletions(-) create mode 100644 cli/src/checks/deployment-auth-check.ts create mode 100644 cli/src/commands/auth-bootstrap-ceo.ts diff --git a/cli/src/checks/deployment-auth-check.ts b/cli/src/checks/deployment-auth-check.ts new file mode 100644 index 00000000..404ecdec --- /dev/null +++ b/cli/src/checks/deployment-auth-check.ts @@ -0,0 +1,91 @@ +import type { PaperclipConfig } from "../config/schema.js"; +import type { CheckResult } from "./index.js"; + +function isLoopbackHost(host: string) { + const normalized = host.trim().toLowerCase(); + return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; +} + +export function deploymentAuthCheck(config: PaperclipConfig): CheckResult { + const mode = config.server.deploymentMode; + const exposure = config.server.exposure; + const auth = config.auth; + + if (mode === "local_trusted") { + if (!isLoopbackHost(config.server.host)) { + return { + name: "Deployment/auth mode", + status: "fail", + message: `local_trusted requires loopback host binding (found ${config.server.host})`, + canRepair: false, + repairHint: "Run `paperclip configure --section server` and set host to 127.0.0.1", + }; + } + return { + name: "Deployment/auth mode", + status: "pass", + message: "local_trusted mode is configured for loopback-only access", + }; + } + + const secret = + process.env.BETTER_AUTH_SECRET?.trim() ?? + process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim(); + if (!secret) { + return { + name: "Deployment/auth mode", + status: "fail", + message: "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET)", + canRepair: false, + repairHint: "Set BETTER_AUTH_SECRET before starting Paperclip", + }; + } + + if (auth.baseUrlMode === "explicit" && !auth.publicBaseUrl) { + return { + name: "Deployment/auth mode", + status: "fail", + message: "auth.baseUrlMode=explicit requires auth.publicBaseUrl", + canRepair: false, + repairHint: "Run `paperclip configure --section server` and provide a base URL", + }; + } + + if (exposure === "public") { + if (auth.baseUrlMode !== "explicit" || !auth.publicBaseUrl) { + return { + name: "Deployment/auth mode", + status: "fail", + message: "authenticated/public requires explicit auth.publicBaseUrl", + canRepair: false, + repairHint: "Run `paperclip configure --section server` and select public exposure", + }; + } + try { + const url = new URL(auth.publicBaseUrl); + if (url.protocol !== "https:") { + return { + name: "Deployment/auth mode", + status: "warn", + message: "Public exposure should use an https:// auth.publicBaseUrl", + canRepair: false, + repairHint: "Use HTTPS in production for secure session cookies", + }; + } + } catch { + return { + name: "Deployment/auth mode", + status: "fail", + message: "auth.publicBaseUrl is not a valid URL", + canRepair: false, + repairHint: "Run `paperclip configure --section server` and provide a valid URL", + }; + } + } + + return { + name: "Deployment/auth mode", + status: "pass", + message: `Mode ${mode}/${exposure} with auth URL mode ${auth.baseUrlMode}`, + }; +} diff --git a/cli/src/checks/index.ts b/cli/src/checks/index.ts index 8a1d8b52..7c2cb861 100644 --- a/cli/src/checks/index.ts +++ b/cli/src/checks/index.ts @@ -9,6 +9,7 @@ export interface CheckResult { export { agentJwtSecretCheck } from "./agent-jwt-secret-check.js"; export { configCheck } from "./config-check.js"; +export { deploymentAuthCheck } from "./deployment-auth-check.js"; export { databaseCheck } from "./database-check.js"; export { llmCheck } from "./llm-check.js"; export { logCheck } from "./log-check.js"; diff --git a/cli/src/commands/auth-bootstrap-ceo.ts b/cli/src/commands/auth-bootstrap-ceo.ts new file mode 100644 index 00000000..785fce62 --- /dev/null +++ b/cli/src/commands/auth-bootstrap-ceo.ts @@ -0,0 +1,116 @@ +import { createHash, randomBytes } from "node:crypto"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { and, eq, gt, isNull } from "drizzle-orm"; +import { createDb, instanceUserRoles, invites } from "@paperclip/db"; +import { readConfig, resolveConfigPath } from "../config/store.js"; + +function hashToken(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +function createInviteToken() { + return `pcp_bootstrap_${randomBytes(24).toString("hex")}`; +} + +function resolveDbUrl(configPath?: string) { + const config = readConfig(configPath); + if (process.env.DATABASE_URL) return process.env.DATABASE_URL; + if (config?.database.mode === "postgres" && config.database.connectionString) { + return config.database.connectionString; + } + if (config?.database.mode === "embedded-postgres") { + const port = config.database.embeddedPostgresPort ?? 54329; + return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + } + return null; +} + +function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) { + if (explicitBaseUrl) return explicitBaseUrl.replace(/\/+$/, ""); + const config = readConfig(configPath); + if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) { + return config.auth.publicBaseUrl.replace(/\/+$/, ""); + } + const host = config?.server.host ?? "localhost"; + const port = config?.server.port ?? 3100; + const publicHost = host === "0.0.0.0" ? "localhost" : host; + return `http://${publicHost}:${port}`; +} + +export async function bootstrapCeoInvite(opts: { + config?: string; + force?: boolean; + expiresHours?: number; + baseUrl?: string; +}) { + const configPath = resolveConfigPath(opts.config); + const config = readConfig(configPath); + if (!config) { + p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`); + return; + } + + if (config.server.deploymentMode !== "authenticated") { + p.log.info("Deployment mode is local_trusted. Bootstrap CEO invite is only required for authenticated mode."); + return; + } + + const dbUrl = resolveDbUrl(configPath); + if (!dbUrl) { + p.log.error( + "Could not resolve database connection for bootstrap.", + ); + return; + } + + const db = createDb(dbUrl); + try { + const existingAdminCount = await db + .select() + .from(instanceUserRoles) + .where(eq(instanceUserRoles.role, "instance_admin")) + .then((rows) => rows.length); + + if (existingAdminCount > 0 && !opts.force) { + p.log.info("Instance already has an admin user. Use --force to generate a new bootstrap invite."); + return; + } + + const now = new Date(); + await db + .update(invites) + .set({ revokedAt: now, updatedAt: now }) + .where( + and( + eq(invites.inviteType, "bootstrap_ceo"), + isNull(invites.revokedAt), + isNull(invites.acceptedAt), + gt(invites.expiresAt, now), + ), + ); + + const token = createInviteToken(); + const expiresHours = Math.max(1, Math.min(24 * 30, opts.expiresHours ?? 72)); + const created = await db + .insert(invites) + .values({ + inviteType: "bootstrap_ceo", + tokenHash: hashToken(token), + allowedJoinTypes: "human", + expiresAt: new Date(Date.now() + expiresHours * 60 * 60 * 1000), + invitedByUserId: "system", + }) + .returning() + .then((rows) => rows[0]); + + const baseUrl = resolveBaseUrl(configPath, opts.baseUrl); + const inviteUrl = `${baseUrl}/invite/${token}`; + p.log.success("Created bootstrap CEO invite."); + p.log.message(`Invite URL: ${pc.cyan(inviteUrl)}`); + p.log.message(`Expires: ${pc.dim(created.expiresAt.toISOString())}`); + } catch (err) { + p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`); + p.log.info("If using embedded-postgres, start the Paperclip server and run this command again."); + } +} diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index 281d8783..7d412e4c 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -44,9 +44,15 @@ function defaultConfig(): PaperclipConfig { logDir: resolveDefaultLogsDir(instanceId), }, server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", port: 3100, serveUi: true, }, + auth: { + baseUrlMode: "auto", + }, storage: defaultStorageConfig(), secrets: defaultSecretsConfig(), }; @@ -124,7 +130,11 @@ export async function configure(opts: { config.logging = await promptLogging(); break; case "server": - config.server = await promptServer(); + { + const { server, auth } = await promptServer(); + config.server = server; + config.auth = auth; + } break; case "storage": config.storage = await promptStorage(config.storage); diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index 643c86ef..5d8ba086 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -6,6 +6,7 @@ import { agentJwtSecretCheck, configCheck, databaseCheck, + deploymentAuthCheck, llmCheck, logCheck, portCheck, @@ -55,42 +56,47 @@ export async function doctor(opts: { return printSummary(results); } - // 2. Agent JWT check + // 2. Deployment/auth mode check + const deploymentAuthResult = deploymentAuthCheck(config); + results.push(deploymentAuthResult); + printResult(deploymentAuthResult); + + // 3. Agent JWT check const jwtResult = agentJwtSecretCheck(); results.push(jwtResult); printResult(jwtResult); await maybeRepair(jwtResult, opts); - // 3. Secrets adapter check + // 4. Secrets adapter check const secretsResult = secretsCheck(config, configPath); results.push(secretsResult); printResult(secretsResult); await maybeRepair(secretsResult, opts); - // 4. Storage check + // 5. Storage check const storageResult = storageCheck(config, configPath); results.push(storageResult); printResult(storageResult); await maybeRepair(storageResult, opts); - // 5. Database check + // 6. Database check const dbResult = await databaseCheck(config, configPath); results.push(dbResult); printResult(dbResult); await maybeRepair(dbResult, opts); - // 6. LLM check + // 7. LLM check const llmResult = await llmCheck(config); results.push(llmResult); printResult(llmResult); - // 7. Log directory check + // 8. Log directory check const logResult = logCheck(config, configPath); results.push(logResult); printResult(logResult); await maybeRepair(logResult, opts); - // 8. Port check + // 9. Port check const portResult = await portCheck(config); results.push(portResult); printResult(portResult); diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index c0efa724..337e13b6 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -11,6 +11,7 @@ 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 { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js"; export async function onboard(opts: { config?: string }): Promise { p.intro(pc.bgCyan(pc.black(" paperclip onboard "))); @@ -106,7 +107,7 @@ export async function onboard(opts: { config?: string }): Promise { // Server p.log.step(pc.bold("Server")); - const server = await promptServer(); + const { server, auth } = await promptServer(); // Storage p.log.step(pc.bold("Storage")); @@ -142,6 +143,7 @@ export async function onboard(opts: { config?: string }): Promise { database, logging, server, + auth, storage, secrets, }; @@ -160,7 +162,8 @@ export async function onboard(opts: { config?: string }): Promise { `Database: ${database.mode}`, llm ? `LLM: ${llm.provider}` : "LLM: not configured", `Logging: ${logging.mode} → ${logging.logDir}`, - `Server: port ${server.port}`, + `Server: ${server.deploymentMode}/${server.exposure} @ ${server.host}:${server.port}`, + `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`, @@ -172,5 +175,9 @@ export async function onboard(opts: { config?: string }): Promise { 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`)})`, ); + if (server.deploymentMode === "authenticated") { + p.log.step("Generating bootstrap CEO invite"); + await bootstrapCeoInvite({ config: opts.config }); + } p.outro("You're all set!"); } diff --git a/cli/src/config/schema.ts b/cli/src/config/schema.ts index 8b649263..1edf6630 100644 --- a/cli/src/config/schema.ts +++ b/cli/src/config/schema.ts @@ -5,6 +5,7 @@ export { databaseConfigSchema, loggingConfigSchema, serverConfigSchema, + authConfigSchema, storageConfigSchema, storageLocalDiskConfigSchema, storageS3ConfigSchema, @@ -15,6 +16,7 @@ export { type DatabaseConfig, type LoggingConfig, type ServerConfig, + type AuthConfig, type StorageConfig, type StorageLocalDiskConfig, type StorageS3Config, diff --git a/cli/src/index.ts b/cli/src/index.ts index 2348bde7..c04b12d0 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -6,6 +6,7 @@ import { envCommand } from "./commands/env.js"; import { configure } from "./commands/configure.js"; import { heartbeatRun } from "./commands/heartbeat-run.js"; import { runCommand } from "./commands/run.js"; +import { bootstrapCeoInvite } from "./commands/auth-bootstrap-ceo.js"; import { registerContextCommands } from "./commands/client/context.js"; import { registerCompanyCommands } from "./commands/client/company.js"; import { registerIssueCommands } from "./commands/client/issue.js"; @@ -90,6 +91,17 @@ registerApprovalCommands(program); registerActivityCommands(program); registerDashboardCommands(program); +const auth = program.command("auth").description("Authentication and bootstrap utilities"); + +auth + .command("bootstrap-ceo") + .description("Create a one-time bootstrap invite URL for first instance admin") + .option("-c, --config ", "Path to config file") + .option("--force", "Create new invite even if admin already exists", false) + .option("--expires-hours ", "Invite expiration window in hours", (value) => Number(value)) + .option("--base-url ", "Public base URL used to print invite link") + .action(bootstrapCeoInvite); + program.parseAsync().catch((err) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts index 1f11c723..717ab0c2 100644 --- a/cli/src/prompts/server.ts +++ b/cli/src/prompts/server.ts @@ -1,7 +1,69 @@ import * as p from "@clack/prompts"; -import type { ServerConfig } from "../config/schema.js"; +import type { AuthConfig, ServerConfig } from "../config/schema.js"; + +export async function promptServer(): Promise<{ server: ServerConfig; auth: AuthConfig }> { + const deploymentModeSelection = await p.select({ + message: "Deployment mode", + options: [ + { + value: "local_trusted", + label: "Local trusted", + hint: "Easiest for local setup (no login, localhost-only)", + }, + { + value: "authenticated", + label: "Authenticated", + hint: "Login required; use for private network or public hosting", + }, + ], + initialValue: "local_trusted", + }); + + if (p.isCancel(deploymentModeSelection)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"]; + let exposure: ServerConfig["exposure"] = "private"; + if (deploymentMode === "authenticated") { + const exposureSelection = await p.select({ + message: "Exposure profile", + options: [ + { + value: "private", + label: "Private network", + hint: "Private access (for example Tailscale), lower setup friction", + }, + { + value: "public", + label: "Public internet", + hint: "Internet-facing deployment with stricter requirements", + }, + ], + initialValue: "private", + }); + if (p.isCancel(exposureSelection)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + exposure = exposureSelection as ServerConfig["exposure"]; + } + + const hostDefault = deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0"; + const hostStr = await p.text({ + message: "Bind host", + defaultValue: hostDefault, + placeholder: hostDefault, + validate: (val) => { + if (!val.trim()) return "Host is required"; + }, + }); + + if (p.isCancel(hostStr)) { + p.cancel("Setup cancelled."); + process.exit(0); + } -export async function promptServer(): Promise { const portStr = await p.text({ message: "Server port", defaultValue: "3100", @@ -20,5 +82,37 @@ export async function promptServer(): Promise { } const port = Number(portStr) || 3100; - return { port, serveUi: true }; + let auth: AuthConfig = { baseUrlMode: "auto" }; + if (deploymentMode === "authenticated" && exposure === "public") { + const urlInput = await p.text({ + message: "Public base URL", + placeholder: "https://paperclip.example.com", + validate: (val) => { + const candidate = val.trim(); + if (!candidate) return "Public base URL is required for public exposure"; + try { + const url = new URL(candidate); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return "URL must start with http:// or https://"; + } + return; + } catch { + return "Enter a valid URL"; + } + }, + }); + if (p.isCancel(urlInput)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + auth = { + baseUrlMode: "explicit", + publicBaseUrl: urlInput.trim().replace(/\/+$/, ""), + }; + } + + return { + server: { deploymentMode, exposure, host: hostStr.trim(), port, serveUi: true }, + auth, + }; }