| 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 "@paperclipai/db"; |
| import { loadPaperclipEnvFile } from "../config/env.js"; |
| 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, 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) { |
| 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 fromEnv = |
| process.env.PAPERCLIP_PUBLIC_URL ?? |
| process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? |
| process.env.BETTER_AUTH_URL ?? |
| process.env.BETTER_AUTH_BASE_URL; |
| if (fromEnv?.trim()) return fromEnv.trim().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; |
| 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.`); |
| 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, opts.dbUrl); |
| if (!dbUrl) { |
| p.log.error( |
| "Could not resolve database connection for bootstrap.", |
| ); |
| return; |
| } |
|
|
| const db = createDb(dbUrl); |
| const closableDb = db as typeof db & { |
| $client?: { |
| end?: (options?: { timeout?: number }) => Promise<void>; |
| }; |
| }; |
| 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."); |
| } finally { |
| await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined); |
| } |
| } |
|
|