Introduce bind presets for deployment setup

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-10 07:32:16 -05:00 committed by Dotta
parent e1bf9d66a7
commit 2a84e53c1b
35 changed files with 915 additions and 176 deletions

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 { inferBindModeFromHost } from "@paperclipai/shared";
import { loadPaperclipEnvFile } from "../config/env.js";
import { readConfig, resolveConfigPath } from "../config/store.js";
@ -40,9 +41,13 @@ function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) {
if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
return config.auth.publicBaseUrl.replace(/\/+$/, "");
}
const host = config?.server.host ?? "localhost";
const bind = config?.server.bind ?? inferBindModeFromHost(config?.server.host);
const host =
bind === "custom"
? config?.server.customBindHost ?? config?.server.host ?? "localhost"
: config?.server.host ?? "localhost";
const port = config?.server.port ?? 3100;
const publicHost = host === "0.0.0.0" ? "localhost" : host;
const publicHost = host === "0.0.0.0" || bind === "lan" ? "localhost" : host;
return `http://${publicHost}:${port}`;
}

View file

@ -54,6 +54,7 @@ function defaultConfig(): PaperclipConfig {
server: {
deploymentMode: "local_trusted",
exposure: "private",
bind: "loopback",
host: "127.0.0.1",
port: 3100,
allowedHostnames: [],

View file

@ -3,10 +3,14 @@ import path from "node:path";
import pc from "picocolors";
import {
AUTH_BASE_URL_MODES,
BIND_MODES,
DEPLOYMENT_EXPOSURES,
DEPLOYMENT_MODES,
SECRET_PROVIDERS,
STORAGE_PROVIDERS,
inferBindModeFromHost,
resolveRuntimeBind,
type BindMode,
type AuthBaseUrlMode,
type DeploymentExposure,
type DeploymentMode,
@ -23,6 +27,7 @@ import { promptLogging } from "../prompts/logging.js";
import { defaultSecretsConfig } from "../prompts/secrets.js";
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
import { promptServer } from "../prompts/server.js";
import { buildPresetServerConfig } from "../config/server-bind.js";
import {
describeLocalInstancePaths,
expandHomePrefix,
@ -46,6 +51,7 @@ type OnboardOptions = {
run?: boolean;
yes?: boolean;
invokedByRun?: boolean;
bind?: BindMode;
};
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
@ -59,6 +65,9 @@ const ONBOARD_ENV_KEYS = [
"PAPERCLIP_DB_BACKUP_DIR",
"PAPERCLIP_DEPLOYMENT_MODE",
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
"PAPERCLIP_BIND",
"PAPERCLIP_BIND_HOST",
"PAPERCLIP_TAILNET_BIND_HOST",
"HOST",
"PORT",
"SERVE_UI",
@ -104,29 +113,62 @@ function resolvePathFromEnv(rawValue: string | undefined): string | null {
return path.resolve(expandHomePrefix(rawValue.trim()));
}
function quickstartDefaultsFromEnv(): {
function describeServerBinding(server: Pick<PaperclipConfig["server"], "bind" | "customBindHost" | "host" | "port">): string {
const bind = server.bind ?? inferBindModeFromHost(server.host);
const detail =
bind === "custom"
? server.customBindHost ?? server.host
: bind === "tailnet"
? "detected tailscale address"
: server.host;
return `${bind}${detail ? ` (${detail})` : ""}:${server.port}`;
}
function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): {
defaults: OnboardDefaults;
usedEnvKeys: string[];
ignoredEnvKeys: Array<{ key: string; reason: string }>;
} {
const preferTrustedLocal = opts?.preferTrustedLocal ?? false;
const instanceId = resolvePaperclipInstanceId();
const defaultStorage = defaultStorageConfig();
const defaultSecrets = defaultSecretsConfig();
const databaseUrl = process.env.DATABASE_URL?.trim() || undefined;
const publicUrl =
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
process.env.BETTER_AUTH_URL?.trim() ||
process.env.BETTER_AUTH_BASE_URL?.trim() ||
undefined;
const deploymentMode =
parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted";
const publicUrl = preferTrustedLocal
? undefined
: (
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
process.env.BETTER_AUTH_URL?.trim() ||
process.env.BETTER_AUTH_BASE_URL?.trim() ||
undefined
);
const deploymentMode = preferTrustedLocal
? "local_trusted"
: (parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted");
const deploymentExposureFromEnv = parseEnumFromEnv<DeploymentExposure>(
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE,
DEPLOYMENT_EXPOSURES,
);
const deploymentExposure =
deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private");
const bindFromEnv = parseEnumFromEnv<BindMode>(process.env.PAPERCLIP_BIND, BIND_MODES);
const customBindHostFromEnv = process.env.PAPERCLIP_BIND_HOST?.trim() || undefined;
const hostFromEnv = process.env.HOST?.trim() || undefined;
const configuredBindHost = customBindHostFromEnv ?? hostFromEnv;
const bind = preferTrustedLocal
? "loopback"
: (
deploymentMode === "local_trusted"
? "loopback"
: (bindFromEnv ?? (configuredBindHost ? inferBindModeFromHost(configuredBindHost) : "lan"))
);
const resolvedBind = resolveRuntimeBind({
bind,
host: hostFromEnv ?? (bind === "loopback" ? "127.0.0.1" : "0.0.0.0"),
customBindHost: customBindHostFromEnv,
tailnetBindHost: process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim(),
});
const authPublicBaseUrl = publicUrl;
const authBaseUrlModeFromEnv = parseEnumFromEnv<AuthBaseUrlMode>(
process.env.PAPERCLIP_AUTH_BASE_URL_MODE,
@ -183,7 +225,9 @@ function quickstartDefaultsFromEnv(): {
server: {
deploymentMode,
exposure: deploymentExposure,
host: process.env.HOST ?? "127.0.0.1",
bind: resolvedBind.bind,
...(resolvedBind.customBindHost ? { customBindHost: resolvedBind.customBindHost } : {}),
host: resolvedBind.host,
port: Number(process.env.PORT) || 3100,
allowedHostnames: Array.from(new Set([...allowedHostnamesFromEnv, ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : [])])),
serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true,
@ -220,12 +264,49 @@ function quickstartDefaultsFromEnv(): {
},
};
const ignoredEnvKeys: Array<{ key: string; reason: string }> = [];
if (preferTrustedLocal) {
const forcedLocalReason = "Ignored because --yes quickstart forces trusted local loopback defaults";
for (const key of [
"PAPERCLIP_DEPLOYMENT_MODE",
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
"PAPERCLIP_BIND",
"PAPERCLIP_BIND_HOST",
"HOST",
"PAPERCLIP_AUTH_BASE_URL_MODE",
"PAPERCLIP_AUTH_PUBLIC_BASE_URL",
"PAPERCLIP_PUBLIC_URL",
"BETTER_AUTH_URL",
"BETTER_AUTH_BASE_URL",
] as const) {
if (process.env[key] !== undefined) {
ignoredEnvKeys.push({ key, reason: forcedLocalReason });
}
}
}
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE !== undefined) {
ignoredEnvKeys.push({
key: "PAPERCLIP_DEPLOYMENT_EXPOSURE",
reason: "Ignored because deployment mode local_trusted always forces private exposure",
});
}
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_BIND !== undefined) {
ignoredEnvKeys.push({
key: "PAPERCLIP_BIND",
reason: "Ignored because deployment mode local_trusted always uses loopback reachability",
});
}
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_BIND_HOST !== undefined) {
ignoredEnvKeys.push({
key: "PAPERCLIP_BIND_HOST",
reason: "Ignored because deployment mode local_trusted always uses loopback reachability",
});
}
if (deploymentMode === "local_trusted" && process.env.HOST !== undefined) {
ignoredEnvKeys.push({
key: "HOST",
reason: "Ignored because deployment mode local_trusted always uses loopback reachability",
});
}
const ignoredKeySet = new Set(ignoredEnvKeys.map((entry) => entry.key));
const usedEnvKeys = ONBOARD_ENV_KEYS.filter(
@ -239,6 +320,10 @@ function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "data
}
export async function onboard(opts: OnboardOptions): Promise<void> {
if (opts.bind && !["loopback", "lan", "tailnet"].includes(opts.bind)) {
throw new Error(`Unsupported bind preset for onboard: ${opts.bind}. Use loopback, lan, or tailnet.`);
}
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
const configPath = resolveConfigPath(opts.config);
@ -293,7 +378,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
`Database: ${existingConfig.database.mode}`,
existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured",
`Logging: ${existingConfig.logging.mode} -> ${existingConfig.logging.logDir}`,
`Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${existingConfig.server.host}:${existingConfig.server.port}`,
`Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${describeServerBinding(existingConfig.server)}`,
`Allowed hosts: ${existingConfig.server.allowedHostnames.length > 0 ? existingConfig.server.allowedHostnames.join(", ") : "(loopback only)"}`,
`Auth URL mode: ${existingConfig.auth.baseUrlMode}${existingConfig.auth.publicBaseUrl ? ` (${existingConfig.auth.publicBaseUrl})` : ""}`,
`Storage: ${existingConfig.storage.provider}`,
@ -336,7 +421,13 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
let setupMode: SetupMode = "quickstart";
if (opts.yes) {
p.log.message(pc.dim("`--yes` enabled: using Quickstart defaults."));
p.log.message(
pc.dim(
opts.bind
? `\`--yes\` enabled: using Quickstart defaults with bind=${opts.bind}.`
: "`--yes` enabled: using Quickstart defaults.",
),
);
} else {
const setupModeChoice = await p.select({
message: "Choose setup path",
@ -365,7 +456,9 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
if (tc) trackInstallStarted(tc);
let llm: PaperclipConfig["llm"] | undefined;
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv({
preferTrustedLocal: opts.yes === true && !opts.bind,
});
let {
database,
logging,
@ -375,6 +468,16 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
secrets,
} = derivedDefaults;
if (opts.bind === "loopback" || opts.bind === "lan" || opts.bind === "tailnet") {
const preset = buildPresetServerConfig(opts.bind, {
port: server.port,
allowedHostnames: server.allowedHostnames,
serveUi: server.serveUi,
});
server = preset.server;
auth = preset.auth;
}
if (setupMode === "advanced") {
p.log.step(pc.bold("Database"));
database = await promptDatabase(database);
@ -462,7 +565,13 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
);
} else {
p.log.step(pc.bold("Quickstart"));
p.log.message(pc.dim("Using quickstart defaults."));
p.log.message(
pc.dim(
opts.bind
? `Using quickstart defaults with bind=${opts.bind}.`
: `Using quickstart defaults: ${server.deploymentMode}/${server.exposure} @ ${describeServerBinding(server)}.`,
),
);
if (usedEnvKeys.length > 0) {
p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`));
} else {
@ -521,7 +630,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
`Database: ${database.mode}`,
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
`Logging: ${logging.mode} -> ${logging.logDir}`,
`Server: ${server.deploymentMode}/${server.exposure} @ ${server.host}:${server.port}`,
`Server: ${server.deploymentMode}/${server.exposure} @ ${describeServerBinding(server)}`,
`Allowed hosts: ${server.allowedHostnames.length > 0 ? server.allowedHostnames.join(", ") : "(loopback only)"}`,
`Auth URL mode: ${auth.baseUrlMode}${auth.publicBaseUrl ? ` (${auth.publicBaseUrl})` : ""}`,
`Storage: ${storage.provider}`,

View file

@ -22,6 +22,7 @@ interface RunOptions {
instance?: string;
repair?: boolean;
yes?: boolean;
bind?: "loopback" | "lan" | "tailnet";
}
interface StartedServer {
@ -58,7 +59,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
}
p.log.step("No config found. Starting onboarding...");
await onboard({ config: configPath, invokedByRun: true });
await onboard({ config: configPath, invokedByRun: true, bind: opts.bind });
}
p.log.step("Running doctor checks...");

View file

@ -214,6 +214,8 @@ export function buildWorktreeConfig(input: {
server: {
deploymentMode: source?.server.deploymentMode ?? "local_trusted",
exposure: source?.server.exposure ?? "private",
...(source?.server.bind ? { bind: source.server.bind } : {}),
...(source?.server.customBindHost ? { customBindHost: source.server.customBindHost } : {}),
host: source?.server.host ?? "127.0.0.1",
port: serverPort,
allowedHostnames: source?.server.allowedHostnames ?? [],