fix: complete authenticated onboarding startup
This commit is contained in:
@@ -37,4 +37,4 @@ WORKDIR /home/paperclip/workspace
|
|||||||
EXPOSE 3100
|
EXPOSE 3100
|
||||||
USER paperclip
|
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\""]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as p from "@clack/prompts";
|
|||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||||
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
|
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
|
||||||
|
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||||
|
|
||||||
function hashToken(token: string) {
|
function hashToken(token: string) {
|
||||||
@@ -13,7 +14,8 @@ function createInviteToken() {
|
|||||||
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
|
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);
|
const config = readConfig(configPath);
|
||||||
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
||||||
if (config?.database.mode === "postgres" && config.database.connectionString) {
|
if (config?.database.mode === "postgres" && config.database.connectionString) {
|
||||||
@@ -49,8 +51,10 @@ export async function bootstrapCeoInvite(opts: {
|
|||||||
force?: boolean;
|
force?: boolean;
|
||||||
expiresHours?: number;
|
expiresHours?: number;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
dbUrl?: string;
|
||||||
}) {
|
}) {
|
||||||
const configPath = resolveConfigPath(opts.config);
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
loadPaperclipEnvFile(configPath);
|
||||||
const config = readConfig(configPath);
|
const config = readConfig(configPath);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
|
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
|
||||||
@@ -62,7 +66,7 @@ export async function bootstrapCeoInvite(opts: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbUrl = resolveDbUrl(configPath);
|
const dbUrl = resolveDbUrl(configPath, opts.dbUrl);
|
||||||
if (!dbUrl) {
|
if (!dbUrl) {
|
||||||
p.log.error(
|
p.log.error(
|
||||||
"Could not resolve database connection for bootstrap.",
|
"Could not resolve database connection for bootstrap.",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
storageCheck,
|
storageCheck,
|
||||||
type CheckResult,
|
type CheckResult,
|
||||||
} from "../checks/index.js";
|
} from "../checks/index.js";
|
||||||
|
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||||
|
|
||||||
const STATUS_ICON = {
|
const STATUS_ICON = {
|
||||||
@@ -31,6 +32,7 @@ export async function doctor(opts: {
|
|||||||
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
|
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
|
||||||
|
|
||||||
const configPath = resolveConfigPath(opts.config);
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
loadPaperclipEnvFile(configPath);
|
||||||
const results: CheckResult[] = [];
|
const results: CheckResult[] = [];
|
||||||
|
|
||||||
// 1. Config check (must pass before others)
|
// 1. Config check (must pass before others)
|
||||||
|
|||||||
@@ -229,6 +229,10 @@ function quickstartDefaultsFromEnv(): {
|
|||||||
return { defaults, usedEnvKeys, ignoredEnvKeys };
|
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> {
|
export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||||
printPaperclipCliBanner();
|
printPaperclipCliBanner();
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
|
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
|
||||||
@@ -450,7 +454,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||||||
"Next commands",
|
"Next commands",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (server.deploymentMode === "authenticated") {
|
if (canCreateBootstrapInviteImmediately({ database, server })) {
|
||||||
p.log.step("Generating bootstrap CEO invite");
|
p.log.step("Generating bootstrap CEO invite");
|
||||||
await bootstrapCeoInvite({ config: configPath });
|
await bootstrapCeoInvite({ config: configPath });
|
||||||
}
|
}
|
||||||
@@ -473,5 +477,15 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||||||
return;
|
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!");
|
p.outro("You're all set!");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import path from "node:path";
|
|||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
|
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
|
||||||
import { onboard } from "./onboard.js";
|
import { onboard } from "./onboard.js";
|
||||||
import { doctor } from "./doctor.js";
|
import { doctor } from "./doctor.js";
|
||||||
|
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||||
import { configExists, resolveConfigPath } from "../config/store.js";
|
import { configExists, resolveConfigPath } from "../config/store.js";
|
||||||
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
|
import { readConfig } from "../config/store.js";
|
||||||
import {
|
import {
|
||||||
describeLocalInstancePaths,
|
describeLocalInstancePaths,
|
||||||
resolvePaperclipHomeDir,
|
resolvePaperclipHomeDir,
|
||||||
@@ -19,6 +23,13 @@ interface RunOptions {
|
|||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StartedServer {
|
||||||
|
apiUrl: string;
|
||||||
|
databaseUrl: string;
|
||||||
|
host: string;
|
||||||
|
listenPort: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runCommand(opts: RunOptions): Promise<void> {
|
export async function runCommand(opts: RunOptions): Promise<void> {
|
||||||
const instanceId = resolvePaperclipInstanceId(opts.instance);
|
const instanceId = resolvePaperclipInstanceId(opts.instance);
|
||||||
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
||||||
@@ -31,6 +42,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
|||||||
|
|
||||||
const configPath = resolveConfigPath(opts.config);
|
const configPath = resolveConfigPath(opts.config);
|
||||||
process.env.PAPERCLIP_CONFIG = configPath;
|
process.env.PAPERCLIP_CONFIG = configPath;
|
||||||
|
loadPaperclipEnvFile(configPath);
|
||||||
|
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai run ")));
|
p.intro(pc.bgCyan(pc.black(" paperclipai run ")));
|
||||||
p.log.message(pc.dim(`Home: ${paths.homeDir}`));
|
p.log.message(pc.dim(`Home: ${paths.homeDir}`));
|
||||||
@@ -60,8 +72,23 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
|||||||
process.exit(1);
|
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...");
|
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 {
|
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)
|
// Dev mode: try local workspace path (monorepo with tsx)
|
||||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||||
const devEntry = path.resolve(projectRoot, "server/src/index.ts");
|
const devEntry = path.resolve(projectRoot, "server/src/index.ts");
|
||||||
if (fs.existsSync(devEntry)) {
|
if (fs.existsSync(devEntry)) {
|
||||||
maybeEnableUiDevMiddleware(devEntry);
|
maybeEnableUiDevMiddleware(devEntry);
|
||||||
await import(pathToFileURL(devEntry).href);
|
const mod = await import(pathToFileURL(devEntry).href);
|
||||||
return;
|
return await startServerFromModule(mod, devEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production mode: import the published @paperclipai/server package
|
// Production mode: import the published @paperclipai/server package
|
||||||
try {
|
try {
|
||||||
await import("@paperclipai/server");
|
const mod = await import("@paperclipai/server");
|
||||||
|
return await startServerFromModule(mod, "@paperclipai/server");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const missingSpecifier = getMissingModuleSpecifier(err);
|
const missingSpecifier = getMissingModuleSpecifier(err);
|
||||||
const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server";
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export function resolveAgentJwtEnvFile(configPath?: string): string {
|
|||||||
return resolveEnvFilePath(configPath);
|
return resolveEnvFilePath(configPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadPaperclipEnvFile(configPath?: string): void {
|
||||||
|
loadAgentJwtEnvFile(resolveEnvFilePath(configPath));
|
||||||
|
}
|
||||||
|
|
||||||
export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void {
|
export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void {
|
||||||
if (loadedEnvFiles.has(filePath)) return;
|
if (loadedEnvFiles.has(filePath)) return;
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ This is the best existing fit when you want:
|
|||||||
- a dedicated host port
|
- a dedicated host port
|
||||||
- an end-to-end `npx paperclipai ... onboard` check
|
- 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:
|
If you want to exercise onboarding from a fresh local checkout rather than npm, use:
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ DATA_DIR="${DATA_DIR:-$REPO_ROOT/data/docker-onboard-smoke}"
|
|||||||
HOST_UID="${HOST_UID:-$(id -u)}"
|
HOST_UID="${HOST_UID:-$(id -u)}"
|
||||||
PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}"
|
PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}"
|
||||||
PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}"
|
PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}"
|
||||||
BETTER_AUTH_SECRET="${BETTER_AUTH_SECRET:-paperclip-onboard-smoke-secret}"
|
|
||||||
DOCKER_TTY_ARGS=()
|
DOCKER_TTY_ARGS=()
|
||||||
|
|
||||||
if [[ -t 0 && -t 1 ]]; then
|
if [[ -t 0 && -t 1 ]]; then
|
||||||
@@ -39,6 +38,5 @@ docker run --rm \
|
|||||||
-e PORT=3100 \
|
-e PORT=3100 \
|
||||||
-e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \
|
-e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \
|
||||||
-e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \
|
-e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \
|
||||||
-e BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \
|
|
||||||
-v "$DATA_DIR:/paperclip" \
|
-v "$DATA_DIR:/paperclip" \
|
||||||
"$IMAGE_NAME"
|
"$IMAGE_NAME"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createServer } from "node:http";
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import { stdin, stdout } from "node:process";
|
import { stdin, stdout } from "node:process";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
import type { Request as ExpressRequest, RequestHandler } from "express";
|
import type { Request as ExpressRequest, RequestHandler } from "express";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
@@ -56,32 +57,42 @@ type EmbeddedPostgresCtor = new (opts: {
|
|||||||
onError?: (message: unknown) => void;
|
onError?: (message: unknown) => void;
|
||||||
}) => EmbeddedPostgresInstance;
|
}) => EmbeddedPostgresInstance;
|
||||||
|
|
||||||
const config = loadConfig();
|
|
||||||
if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) {
|
export interface StartedServer {
|
||||||
process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider;
|
server: ReturnType<typeof createServer>;
|
||||||
}
|
host: string;
|
||||||
if (process.env.PAPERCLIP_SECRETS_STRICT_MODE === undefined) {
|
listenPort: number;
|
||||||
process.env.PAPERCLIP_SECRETS_STRICT_MODE = config.secretsStrictMode ? "true" : "false";
|
apiUrl: string;
|
||||||
}
|
databaseUrl: string;
|
||||||
if (process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE === undefined) {
|
|
||||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = config.secretsMasterKeyFilePath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
| "skipped"
|
||||||
| "already applied"
|
| "already applied"
|
||||||
| "applied (empty database)"
|
| "applied (empty database)"
|
||||||
| "applied (pending migrations)"
|
| "applied (pending migrations)"
|
||||||
| "pending migrations skipped";
|
| "pending migrations skipped";
|
||||||
|
|
||||||
function formatPendingMigrationSummary(migrations: string[]): string {
|
function formatPendingMigrationSummary(migrations: string[]): string {
|
||||||
if (migrations.length === 0) return "none";
|
if (migrations.length === 0) return "none";
|
||||||
return migrations.length > 3
|
return migrations.length > 3
|
||||||
? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)`
|
? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)`
|
||||||
: migrations.join(", ");
|
: 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_PROMPT === "never") return false;
|
||||||
if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true") return true;
|
if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true") return true;
|
||||||
if (!stdin.isTTY || !stdout.isTTY) return true;
|
if (!stdin.isTTY || !stdout.isTTY) return true;
|
||||||
@@ -95,17 +106,17 @@ async function promptApplyMigrations(migrations: string[]): Promise<boolean> {
|
|||||||
} finally {
|
} finally {
|
||||||
prompt.close();
|
prompt.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnsureMigrationsOptions = {
|
type EnsureMigrationsOptions = {
|
||||||
autoApply?: boolean;
|
autoApply?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function ensureMigrations(
|
async function ensureMigrations(
|
||||||
connectionString: string,
|
connectionString: string,
|
||||||
label: string,
|
label: string,
|
||||||
opts?: EnsureMigrationsOptions,
|
opts?: EnsureMigrationsOptions,
|
||||||
): Promise<MigrationSummary> {
|
): Promise<MigrationSummary> {
|
||||||
const autoApply = opts?.autoApply === true;
|
const autoApply = opts?.autoApply === true;
|
||||||
let state = await inspectMigrations(connectionString);
|
let state = await inspectMigrations(connectionString);
|
||||||
if (state.status === "needsMigrations" && state.reason === "pending-migrations") {
|
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}`);
|
logger.info({ pendingMigrations: state.pendingMigrations }, `Applying ${state.pendingMigrations.length} pending migrations for ${label}`);
|
||||||
await applyPendingMigrations(connectionString);
|
await applyPendingMigrations(connectionString);
|
||||||
return "applied (pending migrations)";
|
return "applied (pending migrations)";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLoopbackHost(host: string): boolean {
|
function isLoopbackHost(host: string): boolean {
|
||||||
const normalized = host.trim().toLowerCase();
|
const normalized = host.trim().toLowerCase();
|
||||||
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
|
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOCAL_BOARD_USER_ID = "local-board";
|
const LOCAL_BOARD_USER_ID = "local-board";
|
||||||
const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local";
|
const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local";
|
||||||
const LOCAL_BOARD_USER_NAME = "Board";
|
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 now = new Date();
|
||||||
const existingUser = await db
|
const existingUser = await db
|
||||||
.select({ id: authUsers.id })
|
.select({ id: authUsers.id })
|
||||||
@@ -216,24 +227,24 @@ async function ensureLocalTrustedBoardPrincipal(db: any): Promise<void> {
|
|||||||
membershipRole: "owner",
|
membershipRole: "owner",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
let embeddedPostgres: EmbeddedPostgresInstance | null = null;
|
let embeddedPostgres: EmbeddedPostgresInstance | null = null;
|
||||||
let embeddedPostgresStartedByThisProcess = false;
|
let embeddedPostgresStartedByThisProcess = false;
|
||||||
let migrationSummary: MigrationSummary = "skipped";
|
let migrationSummary: MigrationSummary = "skipped";
|
||||||
let activeDatabaseConnectionString: string;
|
let activeDatabaseConnectionString: string;
|
||||||
let startupDbInfo:
|
let startupDbInfo:
|
||||||
| { mode: "external-postgres"; connectionString: string }
|
| { mode: "external-postgres"; connectionString: string }
|
||||||
| { mode: "embedded-postgres"; dataDir: string; port: number };
|
| { mode: "embedded-postgres"; dataDir: string; port: number };
|
||||||
if (config.databaseUrl) {
|
if (config.databaseUrl) {
|
||||||
migrationSummary = await ensureMigrations(config.databaseUrl, "PostgreSQL");
|
migrationSummary = await ensureMigrations(config.databaseUrl, "PostgreSQL");
|
||||||
|
|
||||||
db = createDb(config.databaseUrl);
|
db = createDb(config.databaseUrl);
|
||||||
logger.info("Using external PostgreSQL via DATABASE_URL/config");
|
logger.info("Using external PostgreSQL via DATABASE_URL/config");
|
||||||
activeDatabaseConnectionString = config.databaseUrl;
|
activeDatabaseConnectionString = config.databaseUrl;
|
||||||
startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl };
|
startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl };
|
||||||
} else {
|
} else {
|
||||||
const moduleName = "embedded-postgres";
|
const moduleName = "embedded-postgres";
|
||||||
let EmbeddedPostgres: EmbeddedPostgresCtor;
|
let EmbeddedPostgres: EmbeddedPostgresCtor;
|
||||||
try {
|
try {
|
||||||
@@ -370,20 +381,20 @@ if (config.databaseUrl) {
|
|||||||
logger.info("Embedded PostgreSQL ready");
|
logger.info("Embedded PostgreSQL ready");
|
||||||
activeDatabaseConnectionString = embeddedConnectionString;
|
activeDatabaseConnectionString = embeddedConnectionString;
|
||||||
startupDbInfo = { mode: "embedded-postgres", dataDir, port };
|
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(
|
throw new Error(
|
||||||
`local_trusted mode requires loopback host binding (received: ${config.host}). ` +
|
`local_trusted mode requires loopback host binding (received: ${config.host}). ` +
|
||||||
"Use authenticated mode for non-loopback deployments.",
|
"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");
|
throw new Error("local_trusted mode only supports private exposure");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.deploymentMode === "authenticated") {
|
if (config.deploymentMode === "authenticated") {
|
||||||
if (config.authBaseUrlMode === "explicit" && !config.authPublicBaseUrl) {
|
if (config.authBaseUrlMode === "explicit" && !config.authPublicBaseUrl) {
|
||||||
throw new Error("auth.baseUrlMode=explicit requires auth.publicBaseUrl");
|
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");
|
throw new Error("authenticated public exposure requires auth.publicBaseUrl");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let authReady = config.deploymentMode === "local_trusted";
|
let authReady = config.deploymentMode === "local_trusted";
|
||||||
let betterAuthHandler: RequestHandler | undefined;
|
let betterAuthHandler: RequestHandler | undefined;
|
||||||
let resolveSession:
|
let resolveSession:
|
||||||
| ((req: ExpressRequest) => Promise<BetterAuthSessionResult | null>)
|
| ((req: ExpressRequest) => Promise<BetterAuthSessionResult | null>)
|
||||||
| undefined;
|
| undefined;
|
||||||
let resolveSessionFromHeaders:
|
let resolveSessionFromHeaders:
|
||||||
| ((headers: Headers) => Promise<BetterAuthSessionResult | null>)
|
| ((headers: Headers) => Promise<BetterAuthSessionResult | null>)
|
||||||
| undefined;
|
| undefined;
|
||||||
if (config.deploymentMode === "local_trusted") {
|
if (config.deploymentMode === "local_trusted") {
|
||||||
await ensureLocalTrustedBoardPrincipal(db as any);
|
await ensureLocalTrustedBoardPrincipal(db as any);
|
||||||
}
|
}
|
||||||
if (config.deploymentMode === "authenticated") {
|
if (config.deploymentMode === "authenticated") {
|
||||||
const {
|
const {
|
||||||
createBetterAuthHandler,
|
createBetterAuthHandler,
|
||||||
createBetterAuthInstance,
|
createBetterAuthInstance,
|
||||||
@@ -447,11 +458,11 @@ if (config.deploymentMode === "authenticated") {
|
|||||||
resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers);
|
resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers);
|
||||||
await initializeBoardClaimChallenge(db as any, { deploymentMode: config.deploymentMode });
|
await initializeBoardClaimChallenge(db as any, { deploymentMode: config.deploymentMode });
|
||||||
authReady = true;
|
authReady = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
|
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
|
||||||
const storageService = createStorageServiceFromConfig(config);
|
const storageService = createStorageServiceFromConfig(config);
|
||||||
const app = await createApp(db as any, {
|
const app = await createApp(db as any, {
|
||||||
uiMode,
|
uiMode,
|
||||||
storageService,
|
storageService,
|
||||||
deploymentMode: config.deploymentMode,
|
deploymentMode: config.deploymentMode,
|
||||||
@@ -462,29 +473,29 @@ const app = await createApp(db as any, {
|
|||||||
companyDeletionEnabled: config.companyDeletionEnabled,
|
companyDeletionEnabled: config.companyDeletionEnabled,
|
||||||
betterAuthHandler,
|
betterAuthHandler,
|
||||||
resolveSession,
|
resolveSession,
|
||||||
});
|
});
|
||||||
const server = createServer(app as unknown as Parameters<typeof createServer>[0]);
|
const server = createServer(app as unknown as Parameters<typeof createServer>[0]);
|
||||||
const listenPort = await detectPort(config.port);
|
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})`);
|
logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeListenHost = config.host;
|
const runtimeListenHost = config.host;
|
||||||
const runtimeApiHost =
|
const runtimeApiHost =
|
||||||
runtimeListenHost === "0.0.0.0" || runtimeListenHost === "::"
|
runtimeListenHost === "0.0.0.0" || runtimeListenHost === "::"
|
||||||
? "localhost"
|
? "localhost"
|
||||||
: runtimeListenHost;
|
: runtimeListenHost;
|
||||||
process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost;
|
process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost;
|
||||||
process.env.PAPERCLIP_LISTEN_PORT = String(listenPort);
|
process.env.PAPERCLIP_LISTEN_PORT = String(listenPort);
|
||||||
process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`;
|
process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`;
|
||||||
|
|
||||||
setupLiveEventsWebSocketServer(server, db as any, {
|
setupLiveEventsWebSocketServer(server, db as any, {
|
||||||
deploymentMode: config.deploymentMode,
|
deploymentMode: config.deploymentMode,
|
||||||
resolveSessionFromHeaders,
|
resolveSessionFromHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (config.heartbeatSchedulerEnabled) {
|
if (config.heartbeatSchedulerEnabled) {
|
||||||
const heartbeat = heartbeatService(db as any);
|
const heartbeat = heartbeatService(db as any);
|
||||||
|
|
||||||
// Reap orphaned runs at startup (no threshold -- runningProcesses is empty)
|
// 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");
|
logger.error({ err }, "periodic reap of orphaned heartbeat runs failed");
|
||||||
});
|
});
|
||||||
}, config.heartbeatSchedulerIntervalMs);
|
}, config.heartbeatSchedulerIntervalMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.databaseBackupEnabled) {
|
if (config.databaseBackupEnabled) {
|
||||||
const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000;
|
const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000;
|
||||||
let backupInFlight = false;
|
let backupInFlight = false;
|
||||||
|
|
||||||
@@ -559,9 +570,17 @@ if (config.databaseBackupEnabled) {
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
void runScheduledBackup();
|
void runScheduledBackup();
|
||||||
}, backupIntervalMs);
|
}, 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}`);
|
logger.info(`Server listening on ${config.host}:${listenPort}`);
|
||||||
if (process.env.PAPERCLIP_OPEN_ON_LISTEN === "true") {
|
if (process.env.PAPERCLIP_OPEN_ON_LISTEN === "true") {
|
||||||
const openHost = config.host === "0.0.0.0" || config.host === "::" ? "127.0.0.1" : config.host;
|
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"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
|
resolveListen();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
|
||||||
const shutdown = async (signal: "SIGINT" | "SIGTERM") => {
|
const shutdown = async (signal: "SIGINT" | "SIGTERM") => {
|
||||||
logger.info({ signal }, "Stopping embedded PostgreSQL");
|
logger.info({ signal }, "Stopping embedded PostgreSQL");
|
||||||
try {
|
try {
|
||||||
@@ -628,4 +650,30 @@ if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
|
|||||||
process.once("SIGTERM", () => {
|
process.once("SIGTERM", () => {
|
||||||
void shutdown("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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user