fix: complete authenticated onboarding startup

This commit is contained in:
Dotta
2026-03-09 11:26:58 -05:00
parent 3ec96fdb73
commit 8360b2e3e3
9 changed files with 652 additions and 542 deletions

View File

@@ -37,4 +37,4 @@ WORKDIR /home/paperclip/workspace
EXPOSE 3100
USER paperclip
CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --data-dir \"$PAPERCLIP_HOME\" & app_pid=$!; cleanup() { if kill -0 \"$app_pid\" >/dev/null 2>&1; then kill \"$app_pid\" >/dev/null 2>&1 || true; fi; }; trap cleanup EXIT INT TERM; ready=0; for _ in $(seq 1 60); do if curl -fsS \"http://127.0.0.1:${PORT}/api/health\" >/dev/null 2>&1; then ready=1; break; fi; sleep 1; done; if [ \"$ready\" -eq 1 ]; then echo; echo \"==> Creating bootstrap CEO invite after server startup\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" auth bootstrap-ceo --data-dir \"$PAPERCLIP_HOME\" || true; else echo; echo \"==> Warning: server did not become healthy within 60s; skipping bootstrap invite\"; fi; wait \"$app_pid\""]
CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --data-dir \"$PAPERCLIP_HOME\""]

View File

@@ -3,6 +3,7 @@ import * as p from "@clack/prompts";
import pc from "picocolors";
import { and, eq, gt, isNull } from "drizzle-orm";
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
import { loadPaperclipEnvFile } from "../config/env.js";
import { readConfig, resolveConfigPath } from "../config/store.js";
function hashToken(token: string) {
@@ -13,7 +14,8 @@ function createInviteToken() {
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
}
function resolveDbUrl(configPath?: string) {
function resolveDbUrl(configPath?: string, explicitDbUrl?: string) {
if (explicitDbUrl) return explicitDbUrl;
const config = readConfig(configPath);
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
if (config?.database.mode === "postgres" && config.database.connectionString) {
@@ -49,8 +51,10 @@ export async function bootstrapCeoInvite(opts: {
force?: boolean;
expiresHours?: number;
baseUrl?: string;
dbUrl?: string;
}) {
const configPath = resolveConfigPath(opts.config);
loadPaperclipEnvFile(configPath);
const config = readConfig(configPath);
if (!config) {
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
@@ -62,7 +66,7 @@ export async function bootstrapCeoInvite(opts: {
return;
}
const dbUrl = resolveDbUrl(configPath);
const dbUrl = resolveDbUrl(configPath, opts.dbUrl);
if (!dbUrl) {
p.log.error(
"Could not resolve database connection for bootstrap.",

View File

@@ -14,6 +14,7 @@ import {
storageCheck,
type CheckResult,
} from "../checks/index.js";
import { loadPaperclipEnvFile } from "../config/env.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
const STATUS_ICON = {
@@ -31,6 +32,7 @@ export async function doctor(opts: {
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
const configPath = resolveConfigPath(opts.config);
loadPaperclipEnvFile(configPath);
const results: CheckResult[] = [];
// 1. Config check (must pass before others)

View File

@@ -229,6 +229,10 @@ function quickstartDefaultsFromEnv(): {
return { defaults, usedEnvKeys, ignoredEnvKeys };
}
function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "database" | "server">): boolean {
return config.server.deploymentMode === "authenticated" && config.database.mode !== "embedded-postgres";
}
export async function onboard(opts: OnboardOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
@@ -450,7 +454,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
"Next commands",
);
if (server.deploymentMode === "authenticated") {
if (canCreateBootstrapInviteImmediately({ database, server })) {
p.log.step("Generating bootstrap CEO invite");
await bootstrapCeoInvite({ config: configPath });
}
@@ -473,5 +477,15 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
return;
}
if (server.deploymentMode === "authenticated" && database.mode === "embedded-postgres") {
p.log.info(
[
"Bootstrap CEO invite will be created after the server starts.",
`Next: ${pc.cyan("paperclipai run")}`,
`Then: ${pc.cyan("paperclipai auth bootstrap-ceo")}`,
].join("\n"),
);
}
p.outro("You're all set!");
}

View File

@@ -3,9 +3,13 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
import { onboard } from "./onboard.js";
import { doctor } from "./doctor.js";
import { loadPaperclipEnvFile } from "../config/env.js";
import { configExists, resolveConfigPath } from "../config/store.js";
import type { PaperclipConfig } from "../config/schema.js";
import { readConfig } from "../config/store.js";
import {
describeLocalInstancePaths,
resolvePaperclipHomeDir,
@@ -19,6 +23,13 @@ interface RunOptions {
yes?: boolean;
}
interface StartedServer {
apiUrl: string;
databaseUrl: string;
host: string;
listenPort: number;
}
export async function runCommand(opts: RunOptions): Promise<void> {
const instanceId = resolvePaperclipInstanceId(opts.instance);
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
@@ -31,6 +42,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config);
process.env.PAPERCLIP_CONFIG = configPath;
loadPaperclipEnvFile(configPath);
p.intro(pc.bgCyan(pc.black(" paperclipai run ")));
p.log.message(pc.dim(`Home: ${paths.homeDir}`));
@@ -60,8 +72,23 @@ export async function runCommand(opts: RunOptions): Promise<void> {
process.exit(1);
}
const config = readConfig(configPath);
if (!config) {
p.log.error(`No config found at ${configPath}.`);
process.exit(1);
}
p.log.step("Starting Paperclip server...");
await importServerEntry();
const startedServer = await importServerEntry();
if (shouldGenerateBootstrapInviteAfterStart(config)) {
p.log.step("Generating bootstrap CEO invite");
await bootstrapCeoInvite({
config: configPath,
dbUrl: startedServer.databaseUrl,
baseUrl: startedServer.apiUrl.replace(/\/api$/, ""),
});
}
}
function formatError(err: unknown): string {
@@ -101,19 +128,20 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void {
}
}
async function importServerEntry(): Promise<void> {
async function importServerEntry(): Promise<StartedServer> {
// Dev mode: try local workspace path (monorepo with tsx)
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const devEntry = path.resolve(projectRoot, "server/src/index.ts");
if (fs.existsSync(devEntry)) {
maybeEnableUiDevMiddleware(devEntry);
await import(pathToFileURL(devEntry).href);
return;
const mod = await import(pathToFileURL(devEntry).href);
return await startServerFromModule(mod, devEntry);
}
// Production mode: import the published @paperclipai/server package
try {
await import("@paperclipai/server");
const mod = await import("@paperclipai/server");
return await startServerFromModule(mod, "@paperclipai/server");
} catch (err) {
const missingSpecifier = getMissingModuleSpecifier(err);
const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server";
@@ -130,3 +158,15 @@ async function importServerEntry(): Promise<void> {
);
}
}
function shouldGenerateBootstrapInviteAfterStart(config: PaperclipConfig): boolean {
return config.server.deploymentMode === "authenticated" && config.database.mode === "embedded-postgres";
}
async function startServerFromModule(mod: unknown, label: string): Promise<StartedServer> {
const startServer = (mod as { startServer?: () => Promise<StartedServer> }).startServer;
if (typeof startServer !== "function") {
throw new Error(`Paperclip server entrypoint did not export startServer(): ${label}`);
}
return await startServer();
}

View File

@@ -36,6 +36,10 @@ export function resolveAgentJwtEnvFile(configPath?: string): string {
return resolveEnvFilePath(configPath);
}
export function loadPaperclipEnvFile(configPath?: string): void {
loadAgentJwtEnvFile(resolveEnvFilePath(configPath));
}
export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void {
if (loadedEnvFiles.has(filePath)) return;

View File

@@ -126,7 +126,7 @@ This is the best existing fit when you want:
- a dedicated host port
- an end-to-end `npx paperclipai ... onboard` check
In authenticated/private mode, this smoke path also injects a smoke-only `BETTER_AUTH_SECRET` by default and prints the bootstrap CEO invite after the server becomes healthy.
In authenticated/private mode, the expected result is a full authenticated onboarding flow, including printing the bootstrap CEO invite once startup completes.
If you want to exercise onboarding from a fresh local checkout rather than npm, use:

View File

@@ -9,7 +9,6 @@ DATA_DIR="${DATA_DIR:-$REPO_ROOT/data/docker-onboard-smoke}"
HOST_UID="${HOST_UID:-$(id -u)}"
PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}"
PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}"
BETTER_AUTH_SECRET="${BETTER_AUTH_SECRET:-paperclip-onboard-smoke-secret}"
DOCKER_TTY_ARGS=()
if [[ -t 0 && -t 1 ]]; then
@@ -39,6 +38,5 @@ docker run --rm \
-e PORT=3100 \
-e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \
-e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \
-e BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \
-v "$DATA_DIR:/paperclip" \
"$IMAGE_NAME"

View File

@@ -4,6 +4,7 @@ import { createServer } from "node:http";
import { resolve } from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { pathToFileURL } from "node:url";
import type { Request as ExpressRequest, RequestHandler } from "express";
import { and, eq } from "drizzle-orm";
import {
@@ -56,32 +57,42 @@ type EmbeddedPostgresCtor = new (opts: {
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
const config = loadConfig();
if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) {
process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider;
}
if (process.env.PAPERCLIP_SECRETS_STRICT_MODE === undefined) {
process.env.PAPERCLIP_SECRETS_STRICT_MODE = config.secretsStrictMode ? "true" : "false";
}
if (process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE === undefined) {
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = config.secretsMasterKeyFilePath;
export interface StartedServer {
server: ReturnType<typeof createServer>;
host: string;
listenPort: number;
apiUrl: string;
databaseUrl: string;
}
type MigrationSummary =
export async function startServer(): Promise<StartedServer> {
const config = loadConfig();
if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) {
process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider;
}
if (process.env.PAPERCLIP_SECRETS_STRICT_MODE === undefined) {
process.env.PAPERCLIP_SECRETS_STRICT_MODE = config.secretsStrictMode ? "true" : "false";
}
if (process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE === undefined) {
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = config.secretsMasterKeyFilePath;
}
type MigrationSummary =
| "skipped"
| "already applied"
| "applied (empty database)"
| "applied (pending migrations)"
| "pending migrations skipped";
function formatPendingMigrationSummary(migrations: string[]): string {
function formatPendingMigrationSummary(migrations: string[]): string {
if (migrations.length === 0) return "none";
return migrations.length > 3
? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)`
: migrations.join(", ");
}
}
async function promptApplyMigrations(migrations: string[]): Promise<boolean> {
async function promptApplyMigrations(migrations: string[]): Promise<boolean> {
if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return false;
if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true") return true;
if (!stdin.isTTY || !stdout.isTTY) return true;
@@ -95,17 +106,17 @@ async function promptApplyMigrations(migrations: string[]): Promise<boolean> {
} finally {
prompt.close();
}
}
}
type EnsureMigrationsOptions = {
type EnsureMigrationsOptions = {
autoApply?: boolean;
};
};
async function ensureMigrations(
async function ensureMigrations(
connectionString: string,
label: string,
opts?: EnsureMigrationsOptions,
): Promise<MigrationSummary> {
): Promise<MigrationSummary> {
const autoApply = opts?.autoApply === true;
let state = await inspectMigrations(connectionString);
if (state.status === "needsMigrations" && state.reason === "pending-migrations") {
@@ -151,18 +162,18 @@ async function ensureMigrations(
logger.info({ pendingMigrations: state.pendingMigrations }, `Applying ${state.pendingMigrations.length} pending migrations for ${label}`);
await applyPendingMigrations(connectionString);
return "applied (pending migrations)";
}
}
function isLoopbackHost(host: string): boolean {
function isLoopbackHost(host: string): boolean {
const normalized = host.trim().toLowerCase();
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
}
}
const LOCAL_BOARD_USER_ID = "local-board";
const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local";
const LOCAL_BOARD_USER_NAME = "Board";
const LOCAL_BOARD_USER_ID = "local-board";
const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local";
const LOCAL_BOARD_USER_NAME = "Board";
async function ensureLocalTrustedBoardPrincipal(db: any): Promise<void> {
async function ensureLocalTrustedBoardPrincipal(db: any): Promise<void> {
const now = new Date();
const existingUser = await db
.select({ id: authUsers.id })
@@ -216,24 +227,24 @@ async function ensureLocalTrustedBoardPrincipal(db: any): Promise<void> {
membershipRole: "owner",
});
}
}
}
let db;
let embeddedPostgres: EmbeddedPostgresInstance | null = null;
let embeddedPostgresStartedByThisProcess = false;
let migrationSummary: MigrationSummary = "skipped";
let activeDatabaseConnectionString: string;
let startupDbInfo:
let db;
let embeddedPostgres: EmbeddedPostgresInstance | null = null;
let embeddedPostgresStartedByThisProcess = false;
let migrationSummary: MigrationSummary = "skipped";
let activeDatabaseConnectionString: string;
let startupDbInfo:
| { mode: "external-postgres"; connectionString: string }
| { mode: "embedded-postgres"; dataDir: string; port: number };
if (config.databaseUrl) {
if (config.databaseUrl) {
migrationSummary = await ensureMigrations(config.databaseUrl, "PostgreSQL");
db = createDb(config.databaseUrl);
logger.info("Using external PostgreSQL via DATABASE_URL/config");
activeDatabaseConnectionString = config.databaseUrl;
startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl };
} else {
} else {
const moduleName = "embedded-postgres";
let EmbeddedPostgres: EmbeddedPostgresCtor;
try {
@@ -370,20 +381,20 @@ if (config.databaseUrl) {
logger.info("Embedded PostgreSQL ready");
activeDatabaseConnectionString = embeddedConnectionString;
startupDbInfo = { mode: "embedded-postgres", dataDir, port };
}
}
if (config.deploymentMode === "local_trusted" && !isLoopbackHost(config.host)) {
if (config.deploymentMode === "local_trusted" && !isLoopbackHost(config.host)) {
throw new Error(
`local_trusted mode requires loopback host binding (received: ${config.host}). ` +
"Use authenticated mode for non-loopback deployments.",
);
}
}
if (config.deploymentMode === "local_trusted" && config.deploymentExposure !== "private") {
if (config.deploymentMode === "local_trusted" && config.deploymentExposure !== "private") {
throw new Error("local_trusted mode only supports private exposure");
}
}
if (config.deploymentMode === "authenticated") {
if (config.deploymentMode === "authenticated") {
if (config.authBaseUrlMode === "explicit" && !config.authPublicBaseUrl) {
throw new Error("auth.baseUrlMode=explicit requires auth.publicBaseUrl");
}
@@ -395,20 +406,20 @@ if (config.deploymentMode === "authenticated") {
throw new Error("authenticated public exposure requires auth.publicBaseUrl");
}
}
}
}
let authReady = config.deploymentMode === "local_trusted";
let betterAuthHandler: RequestHandler | undefined;
let resolveSession:
let authReady = config.deploymentMode === "local_trusted";
let betterAuthHandler: RequestHandler | undefined;
let resolveSession:
| ((req: ExpressRequest) => Promise<BetterAuthSessionResult | null>)
| undefined;
let resolveSessionFromHeaders:
let resolveSessionFromHeaders:
| ((headers: Headers) => Promise<BetterAuthSessionResult | null>)
| undefined;
if (config.deploymentMode === "local_trusted") {
if (config.deploymentMode === "local_trusted") {
await ensureLocalTrustedBoardPrincipal(db as any);
}
if (config.deploymentMode === "authenticated") {
}
if (config.deploymentMode === "authenticated") {
const {
createBetterAuthHandler,
createBetterAuthInstance,
@@ -447,11 +458,11 @@ if (config.deploymentMode === "authenticated") {
resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers);
await initializeBoardClaimChallenge(db as any, { deploymentMode: config.deploymentMode });
authReady = true;
}
}
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
const storageService = createStorageServiceFromConfig(config);
const app = await createApp(db as any, {
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
const storageService = createStorageServiceFromConfig(config);
const app = await createApp(db as any, {
uiMode,
storageService,
deploymentMode: config.deploymentMode,
@@ -462,29 +473,29 @@ const app = await createApp(db as any, {
companyDeletionEnabled: config.companyDeletionEnabled,
betterAuthHandler,
resolveSession,
});
const server = createServer(app as unknown as Parameters<typeof createServer>[0]);
const listenPort = await detectPort(config.port);
});
const server = createServer(app as unknown as Parameters<typeof createServer>[0]);
const listenPort = await detectPort(config.port);
if (listenPort !== config.port) {
if (listenPort !== config.port) {
logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`);
}
}
const runtimeListenHost = config.host;
const runtimeApiHost =
const runtimeListenHost = config.host;
const runtimeApiHost =
runtimeListenHost === "0.0.0.0" || runtimeListenHost === "::"
? "localhost"
: runtimeListenHost;
process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost;
process.env.PAPERCLIP_LISTEN_PORT = String(listenPort);
process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`;
process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost;
process.env.PAPERCLIP_LISTEN_PORT = String(listenPort);
process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`;
setupLiveEventsWebSocketServer(server, db as any, {
setupLiveEventsWebSocketServer(server, db as any, {
deploymentMode: config.deploymentMode,
resolveSessionFromHeaders,
});
});
if (config.heartbeatSchedulerEnabled) {
if (config.heartbeatSchedulerEnabled) {
const heartbeat = heartbeatService(db as any);
// Reap orphaned runs at startup (no threshold -- runningProcesses is empty)
@@ -511,9 +522,9 @@ if (config.heartbeatSchedulerEnabled) {
logger.error({ err }, "periodic reap of orphaned heartbeat runs failed");
});
}, config.heartbeatSchedulerIntervalMs);
}
}
if (config.databaseBackupEnabled) {
if (config.databaseBackupEnabled) {
const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000;
let backupInFlight = false;
@@ -559,9 +570,17 @@ if (config.databaseBackupEnabled) {
setInterval(() => {
void runScheduledBackup();
}, backupIntervalMs);
}
}
server.listen(listenPort, config.host, () => {
await new Promise<void>((resolveListen, rejectListen) => {
const onError = (err: Error) => {
server.off("error", onError);
rejectListen(err);
};
server.once("error", onError);
server.listen(listenPort, config.host, () => {
server.off("error", onError);
logger.info(`Server listening on ${config.host}:${listenPort}`);
if (process.env.PAPERCLIP_OPEN_ON_LISTEN === "true") {
const openHost = config.host === "0.0.0.0" || config.host === "::" ? "127.0.0.1" : config.host;
@@ -608,9 +627,12 @@ server.listen(listenPort, config.host, () => {
].join("\n"),
);
}
});
if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
resolveListen();
});
});
if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
const shutdown = async (signal: "SIGINT" | "SIGTERM") => {
logger.info({ signal }, "Stopping embedded PostgreSQL");
try {
@@ -628,4 +650,30 @@ if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
process.once("SIGTERM", () => {
void shutdown("SIGTERM");
});
}
return {
server,
host: config.host,
listenPort,
apiUrl: process.env.PAPERCLIP_API_URL ?? `http://${runtimeApiHost}:${listenPort}`,
databaseUrl: activeDatabaseConnectionString,
};
}
function isMainModule(metaUrl: string): boolean {
const entry = process.argv[1];
if (!entry) return false;
try {
return pathToFileURL(resolve(entry)).href === metaUrl;
} catch {
return false;
}
}
if (isMainModule(import.meta.url)) {
void startServer().catch((err) => {
logger.error({ err }, "Paperclip server failed to start");
process.exit(1);
});
}