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 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-23 14:40:59 -06:00
parent 2ddf6213fd
commit 5b983ca4d3
9 changed files with 352 additions and 13 deletions

View File

@@ -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.");
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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<void> {
p.intro(pc.bgCyan(pc.black(" paperclip onboard ")));
@@ -106,7 +107,7 @@ export async function onboard(opts: { config?: string }): Promise<void> {
// 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<void> {
database,
logging,
server,
auth,
storage,
secrets,
};
@@ -160,7 +162,8 @@ export async function onboard(opts: { config?: string }): Promise<void> {
`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<void> {
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!");
}