import { slugifyWorkspaceName } from '@wellformed/shared'; import { config } from '../config.js'; import { adoptLegacyDatabase, type AdoptionResult } from '../db/adopt.js'; import { closeControlDatabase, openControlDatabase, transactControl, type ControlDb, } from '../control/db.js'; import { createAccount, getAccountByEmail } from '../control/accounts.js'; import { listAllWorkspaces, type WorkspaceRecord } from '../control/workspaces.js'; import { hashPassword } from '../auth/password.js'; import type { DemoChoice } from '../db/demo.js'; import { availableSlug, provisionWorkspace } from './provision.js'; import { TenantRegistry } from './registry.js'; /** * One installation: the control database, the pool of workspace databases, and * whatever had to happen before either could be used. * * Both the server and the CLI open an installation, so the adoption of a * pre-tenancy database happens exactly once no matter which runs first. */ export interface Installation { control: ControlDb; registry: TenantRegistry; adoption: AdoptionResult; close(): void; } export function openInstallation( overrides: { controlDbPath?: string; tenantsDir?: string; adopt?: boolean } = {}, ): Installation { const control = openControlDatabase(overrides.controlDbPath ?? config.controlDbPath); const registry = new TenantRegistry({ directory: overrides.tenantsDir ?? config.tenantsDir, maxOpen: config.maxOpenWorkspaces, idleMs: config.workspaceIdleMs, }); const adoption = overrides.adopt === false ? { adopted: false, accountsMoved: 0 } : adoptLegacyDatabase(control, { file: config.legacyDbPath, slug: config.legacySlug, name: config.legacyName, }); return { control, registry, adoption, close: () => { registry.closeAll(); closeControlDatabase(control); }, }; } export interface OwnerInput { email: string; password: string; name?: string; workspaceName?: string; slug?: string; /** What to build inside the workspace. Omitted means the full demo. */ demo?: DemoChoice; } /** * Create an account and its first workspace without going through HTTP. * * The scripted equivalent of signing up: what a deploy script or a self-hoster * with signups closed uses to get the first person in. */ export async function provisionOwner( control: ControlDb, registry: TenantRegistry, input: OwnerInput, ): Promise<{ workspace: WorkspaceRecord; created: boolean }> { const email = input.email.trim().toLowerCase(); const workspaceName = input.workspaceName ?? 'Workspace'; const existing = getAccountByEmail(control, email); if (existing) { const owned = listAllWorkspaces(control); const already = owned.find((w) => w.createdBy === existing.id); if (already) return { workspace: already, created: false }; } const hash = await hashPassword(input.password); const account = existing ?? transactControl(control, () => createAccount(control, { email, name: input.name ?? 'Admin', passwordHash: hash }), ); const slug = input.slug ?? availableSlug(control, slugifyWorkspaceName(workspaceName)); const workspace = provisionWorkspace(control, registry, { slug, name: workspaceName, ownerId: account.id, // The scripted road defaults to the full demo: an operator provisioning a // first workspace wants something to point at, and can say --empty. demo: input.demo ?? 'full', }); return { workspace, created: true }; }