Merge branch 'master' into fix/windows-command-compat
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -126,6 +126,8 @@ 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, 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:
|
||||
|
||||
```bash
|
||||
|
||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
||||
'@changesets/cli':
|
||||
specifier: ^2.30.0
|
||||
version: 2.30.0(@types/node@25.2.3)
|
||||
'@playwright/test':
|
||||
specifier: ^1.58.2
|
||||
version: 1.58.2
|
||||
cross-env:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0
|
||||
@@ -1680,6 +1683,11 @@ packages:
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@playwright/test@1.58.2':
|
||||
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@radix-ui/colors@3.0.0':
|
||||
resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==}
|
||||
|
||||
@@ -4001,6 +4009,11 @@ packages:
|
||||
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
|
||||
engines: {node: '>=6 <7 || >=8'}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -4787,6 +4800,16 @@ packages:
|
||||
pkg-types@1.3.1:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
playwright-core@1.58.2:
|
||||
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.58.2:
|
||||
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
points-on-curve@0.2.0:
|
||||
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
|
||||
|
||||
@@ -7385,6 +7408,10 @@ snapshots:
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@playwright/test@1.58.2':
|
||||
dependencies:
|
||||
playwright: 1.58.2
|
||||
|
||||
'@radix-ui/colors@3.0.0': {}
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
@@ -9868,6 +9895,9 @@ snapshots:
|
||||
jsonfile: 4.0.0
|
||||
universalify: 0.1.2
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -10919,6 +10949,14 @@ snapshots:
|
||||
mlly: 1.8.1
|
||||
pathe: 2.0.3
|
||||
|
||||
playwright-core@1.58.2: {}
|
||||
|
||||
playwright@1.58.2:
|
||||
dependencies:
|
||||
playwright-core: 1.58.2
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
points-on-curve@0.2.0: {}
|
||||
|
||||
points-on-path@0.2.1:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user