mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Introduce bind presets for deployment setup
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
e1bf9d66a7
commit
2a84e53c1b
35 changed files with 915 additions and 176 deletions
|
|
@ -177,6 +177,14 @@ Open source. Self-hosted. No Paperclip account required.
|
||||||
npx paperclipai onboard --yes
|
npx paperclipai onboard --yes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx paperclipai onboard --yes --bind lan
|
||||||
|
# or:
|
||||||
|
npx paperclipai onboard --yes --bind tailnet
|
||||||
|
```
|
||||||
|
|
||||||
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
|
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
|
||||||
|
|
||||||
Or manually:
|
Or manually:
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,14 @@ Open source. Self-hosted. No Paperclip account required.
|
||||||
npx paperclipai onboard --yes
|
npx paperclipai onboard --yes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx paperclipai onboard --yes --bind lan
|
||||||
|
# or:
|
||||||
|
npx paperclipai onboard --yes --bind tailnet
|
||||||
|
```
|
||||||
|
|
||||||
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
|
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
|
||||||
|
|
||||||
Or manually:
|
Or manually:
|
||||||
|
|
|
||||||
35
cli/src/__tests__/network-bind.test.ts
Normal file
35
cli/src/__tests__/network-bind.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveRuntimeBind, validateConfiguredBindMode } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
describe("network bind helpers", () => {
|
||||||
|
it("rejects non-loopback bind modes in local_trusted", () => {
|
||||||
|
expect(
|
||||||
|
validateConfiguredBindMode({
|
||||||
|
deploymentMode: "local_trusted",
|
||||||
|
deploymentExposure: "private",
|
||||||
|
bind: "lan",
|
||||||
|
host: "0.0.0.0",
|
||||||
|
}),
|
||||||
|
).toContain("local_trusted requires server.bind=loopback");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves tailnet bind using the detected tailscale address", () => {
|
||||||
|
const resolved = resolveRuntimeBind({
|
||||||
|
bind: "tailnet",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
tailnetBindHost: "100.64.0.8",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved.errors).toEqual([]);
|
||||||
|
expect(resolved.host).toBe("100.64.0.8");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a custom bind host when bind=custom", () => {
|
||||||
|
const resolved = resolveRuntimeBind({
|
||||||
|
bind: "custom",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved.errors).toContain("server.customBindHost is required when server.bind=custom");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -74,6 +74,11 @@ function createExistingConfigFixture() {
|
||||||
return { configPath, configText: fs.readFileSync(configPath, "utf8") };
|
return { configPath, configText: fs.readFileSync(configPath, "utf8") };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFreshConfigPath() {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-fresh-"));
|
||||||
|
return path.join(root, ".paperclip", "config.json");
|
||||||
|
}
|
||||||
|
|
||||||
describe("onboard", () => {
|
describe("onboard", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env = { ...ORIGINAL_ENV };
|
process.env = { ...ORIGINAL_ENV };
|
||||||
|
|
@ -105,4 +110,43 @@ describe("onboard", () => {
|
||||||
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
|
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
|
||||||
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
|
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps --yes onboarding on local trusted loopback defaults", async () => {
|
||||||
|
const configPath = createFreshConfigPath();
|
||||||
|
process.env.HOST = "0.0.0.0";
|
||||||
|
process.env.PAPERCLIP_BIND = "lan";
|
||||||
|
|
||||||
|
await onboard({ config: configPath, yes: true, invokedByRun: true });
|
||||||
|
|
||||||
|
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
||||||
|
expect(raw.server.deploymentMode).toBe("local_trusted");
|
||||||
|
expect(raw.server.exposure).toBe("private");
|
||||||
|
expect(raw.server.bind).toBe("loopback");
|
||||||
|
expect(raw.server.host).toBe("127.0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports authenticated/private quickstart bind presets", async () => {
|
||||||
|
const configPath = createFreshConfigPath();
|
||||||
|
|
||||||
|
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
|
||||||
|
|
||||||
|
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
||||||
|
expect(raw.server.deploymentMode).toBe("authenticated");
|
||||||
|
expect(raw.server.exposure).toBe("private");
|
||||||
|
expect(raw.server.bind).toBe("tailnet");
|
||||||
|
expect(raw.server.host).toBe("0.0.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores deployment env overrides during --yes quickstart", async () => {
|
||||||
|
const configPath = createFreshConfigPath();
|
||||||
|
process.env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
|
||||||
|
|
||||||
|
await onboard({ config: configPath, yes: true, invokedByRun: true });
|
||||||
|
|
||||||
|
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
||||||
|
expect(raw.server.deploymentMode).toBe("local_trusted");
|
||||||
|
expect(raw.server.exposure).toBe("private");
|
||||||
|
expect(raw.server.bind).toBe("loopback");
|
||||||
|
expect(raw.server.host).toBe("127.0.0.1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,21 @@
|
||||||
|
import { inferBindModeFromHost } from "@paperclipai/shared";
|
||||||
import type { PaperclipConfig } from "../config/schema.js";
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
import type { CheckResult } from "./index.js";
|
import type { CheckResult } from "./index.js";
|
||||||
|
|
||||||
function isLoopbackHost(host: string) {
|
|
||||||
const normalized = host.trim().toLowerCase();
|
|
||||||
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
|
export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
|
||||||
const mode = config.server.deploymentMode;
|
const mode = config.server.deploymentMode;
|
||||||
const exposure = config.server.exposure;
|
const exposure = config.server.exposure;
|
||||||
const auth = config.auth;
|
const auth = config.auth;
|
||||||
|
const bind = config.server.bind ?? inferBindModeFromHost(config.server.host);
|
||||||
|
|
||||||
if (mode === "local_trusted") {
|
if (mode === "local_trusted") {
|
||||||
if (!isLoopbackHost(config.server.host)) {
|
if (bind !== "loopback") {
|
||||||
return {
|
return {
|
||||||
name: "Deployment/auth mode",
|
name: "Deployment/auth mode",
|
||||||
status: "fail",
|
status: "fail",
|
||||||
message: `local_trusted requires loopback host binding (found ${config.server.host})`,
|
message: `local_trusted requires loopback binding (found ${bind})`,
|
||||||
canRepair: false,
|
canRepair: false,
|
||||||
repairHint: "Run `paperclipai configure --section server` and set host to 127.0.0.1",
|
repairHint: "Run `paperclipai configure --section server` and choose Local trusted / loopback reachability",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -86,6 +83,6 @@ export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
|
||||||
return {
|
return {
|
||||||
name: "Deployment/auth mode",
|
name: "Deployment/auth mode",
|
||||||
status: "pass",
|
status: "pass",
|
||||||
message: `Mode ${mode}/${exposure} with auth URL mode ${auth.baseUrlMode}`,
|
message: `Mode ${mode}/${exposure} with bind ${bind} and auth URL mode ${auth.baseUrlMode}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { inferBindModeFromHost } from "@paperclipai/shared";
|
||||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||||
import { readConfig, resolveConfigPath } from "../config/store.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) {
|
if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
|
||||||
return config.auth.publicBaseUrl.replace(/\/+$/, "");
|
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 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}`;
|
return `http://${publicHost}:${port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ function defaultConfig(): PaperclipConfig {
|
||||||
server: {
|
server: {
|
||||||
deploymentMode: "local_trusted",
|
deploymentMode: "local_trusted",
|
||||||
exposure: "private",
|
exposure: "private",
|
||||||
|
bind: "loopback",
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 3100,
|
port: 3100,
|
||||||
allowedHostnames: [],
|
allowedHostnames: [],
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,14 @@ import path from "node:path";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
import {
|
import {
|
||||||
AUTH_BASE_URL_MODES,
|
AUTH_BASE_URL_MODES,
|
||||||
|
BIND_MODES,
|
||||||
DEPLOYMENT_EXPOSURES,
|
DEPLOYMENT_EXPOSURES,
|
||||||
DEPLOYMENT_MODES,
|
DEPLOYMENT_MODES,
|
||||||
SECRET_PROVIDERS,
|
SECRET_PROVIDERS,
|
||||||
STORAGE_PROVIDERS,
|
STORAGE_PROVIDERS,
|
||||||
|
inferBindModeFromHost,
|
||||||
|
resolveRuntimeBind,
|
||||||
|
type BindMode,
|
||||||
type AuthBaseUrlMode,
|
type AuthBaseUrlMode,
|
||||||
type DeploymentExposure,
|
type DeploymentExposure,
|
||||||
type DeploymentMode,
|
type DeploymentMode,
|
||||||
|
|
@ -23,6 +27,7 @@ import { promptLogging } from "../prompts/logging.js";
|
||||||
import { defaultSecretsConfig } from "../prompts/secrets.js";
|
import { defaultSecretsConfig } from "../prompts/secrets.js";
|
||||||
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
|
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
|
||||||
import { promptServer } from "../prompts/server.js";
|
import { promptServer } from "../prompts/server.js";
|
||||||
|
import { buildPresetServerConfig } from "../config/server-bind.js";
|
||||||
import {
|
import {
|
||||||
describeLocalInstancePaths,
|
describeLocalInstancePaths,
|
||||||
expandHomePrefix,
|
expandHomePrefix,
|
||||||
|
|
@ -46,6 +51,7 @@ type OnboardOptions = {
|
||||||
run?: boolean;
|
run?: boolean;
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
invokedByRun?: boolean;
|
invokedByRun?: boolean;
|
||||||
|
bind?: BindMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
|
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
|
||||||
|
|
@ -59,6 +65,9 @@ const ONBOARD_ENV_KEYS = [
|
||||||
"PAPERCLIP_DB_BACKUP_DIR",
|
"PAPERCLIP_DB_BACKUP_DIR",
|
||||||
"PAPERCLIP_DEPLOYMENT_MODE",
|
"PAPERCLIP_DEPLOYMENT_MODE",
|
||||||
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
||||||
|
"PAPERCLIP_BIND",
|
||||||
|
"PAPERCLIP_BIND_HOST",
|
||||||
|
"PAPERCLIP_TAILNET_BIND_HOST",
|
||||||
"HOST",
|
"HOST",
|
||||||
"PORT",
|
"PORT",
|
||||||
"SERVE_UI",
|
"SERVE_UI",
|
||||||
|
|
@ -104,29 +113,62 @@ function resolvePathFromEnv(rawValue: string | undefined): string | null {
|
||||||
return path.resolve(expandHomePrefix(rawValue.trim()));
|
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;
|
defaults: OnboardDefaults;
|
||||||
usedEnvKeys: string[];
|
usedEnvKeys: string[];
|
||||||
ignoredEnvKeys: Array<{ key: string; reason: string }>;
|
ignoredEnvKeys: Array<{ key: string; reason: string }>;
|
||||||
} {
|
} {
|
||||||
|
const preferTrustedLocal = opts?.preferTrustedLocal ?? false;
|
||||||
const instanceId = resolvePaperclipInstanceId();
|
const instanceId = resolvePaperclipInstanceId();
|
||||||
const defaultStorage = defaultStorageConfig();
|
const defaultStorage = defaultStorageConfig();
|
||||||
const defaultSecrets = defaultSecretsConfig();
|
const defaultSecrets = defaultSecretsConfig();
|
||||||
const databaseUrl = process.env.DATABASE_URL?.trim() || undefined;
|
const databaseUrl = process.env.DATABASE_URL?.trim() || undefined;
|
||||||
const publicUrl =
|
const publicUrl = preferTrustedLocal
|
||||||
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
|
? undefined
|
||||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
|
: (
|
||||||
process.env.BETTER_AUTH_URL?.trim() ||
|
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
|
||||||
process.env.BETTER_AUTH_BASE_URL?.trim() ||
|
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
|
||||||
undefined;
|
process.env.BETTER_AUTH_URL?.trim() ||
|
||||||
const deploymentMode =
|
process.env.BETTER_AUTH_BASE_URL?.trim() ||
|
||||||
parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted";
|
undefined
|
||||||
|
);
|
||||||
|
const deploymentMode = preferTrustedLocal
|
||||||
|
? "local_trusted"
|
||||||
|
: (parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted");
|
||||||
const deploymentExposureFromEnv = parseEnumFromEnv<DeploymentExposure>(
|
const deploymentExposureFromEnv = parseEnumFromEnv<DeploymentExposure>(
|
||||||
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE,
|
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE,
|
||||||
DEPLOYMENT_EXPOSURES,
|
DEPLOYMENT_EXPOSURES,
|
||||||
);
|
);
|
||||||
const deploymentExposure =
|
const deploymentExposure =
|
||||||
deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private");
|
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 authPublicBaseUrl = publicUrl;
|
||||||
const authBaseUrlModeFromEnv = parseEnumFromEnv<AuthBaseUrlMode>(
|
const authBaseUrlModeFromEnv = parseEnumFromEnv<AuthBaseUrlMode>(
|
||||||
process.env.PAPERCLIP_AUTH_BASE_URL_MODE,
|
process.env.PAPERCLIP_AUTH_BASE_URL_MODE,
|
||||||
|
|
@ -183,7 +225,9 @@ function quickstartDefaultsFromEnv(): {
|
||||||
server: {
|
server: {
|
||||||
deploymentMode,
|
deploymentMode,
|
||||||
exposure: deploymentExposure,
|
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,
|
port: Number(process.env.PORT) || 3100,
|
||||||
allowedHostnames: Array.from(new Set([...allowedHostnamesFromEnv, ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : [])])),
|
allowedHostnames: Array.from(new Set([...allowedHostnamesFromEnv, ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : [])])),
|
||||||
serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true,
|
serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true,
|
||||||
|
|
@ -220,12 +264,49 @@ function quickstartDefaultsFromEnv(): {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const ignoredEnvKeys: Array<{ key: string; reason: string }> = [];
|
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) {
|
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE !== undefined) {
|
||||||
ignoredEnvKeys.push({
|
ignoredEnvKeys.push({
|
||||||
key: "PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
key: "PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
||||||
reason: "Ignored because deployment mode local_trusted always forces private 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 ignoredKeySet = new Set(ignoredEnvKeys.map((entry) => entry.key));
|
||||||
const usedEnvKeys = ONBOARD_ENV_KEYS.filter(
|
const usedEnvKeys = ONBOARD_ENV_KEYS.filter(
|
||||||
|
|
@ -239,6 +320,10 @@ function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function onboard(opts: OnboardOptions): Promise<void> {
|
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();
|
printPaperclipCliBanner();
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
|
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
|
||||||
const configPath = resolveConfigPath(opts.config);
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
|
@ -293,7 +378,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||||
`Database: ${existingConfig.database.mode}`,
|
`Database: ${existingConfig.database.mode}`,
|
||||||
existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured",
|
existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured",
|
||||||
`Logging: ${existingConfig.logging.mode} -> ${existingConfig.logging.logDir}`,
|
`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)"}`,
|
`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})` : ""}`,
|
`Auth URL mode: ${existingConfig.auth.baseUrlMode}${existingConfig.auth.publicBaseUrl ? ` (${existingConfig.auth.publicBaseUrl})` : ""}`,
|
||||||
`Storage: ${existingConfig.storage.provider}`,
|
`Storage: ${existingConfig.storage.provider}`,
|
||||||
|
|
@ -336,7 +421,13 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||||
|
|
||||||
let setupMode: SetupMode = "quickstart";
|
let setupMode: SetupMode = "quickstart";
|
||||||
if (opts.yes) {
|
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 {
|
} else {
|
||||||
const setupModeChoice = await p.select({
|
const setupModeChoice = await p.select({
|
||||||
message: "Choose setup path",
|
message: "Choose setup path",
|
||||||
|
|
@ -365,7 +456,9 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||||
if (tc) trackInstallStarted(tc);
|
if (tc) trackInstallStarted(tc);
|
||||||
|
|
||||||
let llm: PaperclipConfig["llm"] | undefined;
|
let llm: PaperclipConfig["llm"] | undefined;
|
||||||
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
|
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv({
|
||||||
|
preferTrustedLocal: opts.yes === true && !opts.bind,
|
||||||
|
});
|
||||||
let {
|
let {
|
||||||
database,
|
database,
|
||||||
logging,
|
logging,
|
||||||
|
|
@ -375,6 +468,16 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||||
secrets,
|
secrets,
|
||||||
} = derivedDefaults;
|
} = 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") {
|
if (setupMode === "advanced") {
|
||||||
p.log.step(pc.bold("Database"));
|
p.log.step(pc.bold("Database"));
|
||||||
database = await promptDatabase(database);
|
database = await promptDatabase(database);
|
||||||
|
|
@ -462,7 +565,13 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
p.log.step(pc.bold("Quickstart"));
|
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) {
|
if (usedEnvKeys.length > 0) {
|
||||||
p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`));
|
p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`));
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -521,7 +630,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||||
`Database: ${database.mode}`,
|
`Database: ${database.mode}`,
|
||||||
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
|
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
|
||||||
`Logging: ${logging.mode} -> ${logging.logDir}`,
|
`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)"}`,
|
`Allowed hosts: ${server.allowedHostnames.length > 0 ? server.allowedHostnames.join(", ") : "(loopback only)"}`,
|
||||||
`Auth URL mode: ${auth.baseUrlMode}${auth.publicBaseUrl ? ` (${auth.publicBaseUrl})` : ""}`,
|
`Auth URL mode: ${auth.baseUrlMode}${auth.publicBaseUrl ? ` (${auth.publicBaseUrl})` : ""}`,
|
||||||
`Storage: ${storage.provider}`,
|
`Storage: ${storage.provider}`,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ interface RunOptions {
|
||||||
instance?: string;
|
instance?: string;
|
||||||
repair?: boolean;
|
repair?: boolean;
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
|
bind?: "loopback" | "lan" | "tailnet";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StartedServer {
|
interface StartedServer {
|
||||||
|
|
@ -58,7 +59,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
p.log.step("No config found. Starting onboarding...");
|
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...");
|
p.log.step("Running doctor checks...");
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,8 @@ export function buildWorktreeConfig(input: {
|
||||||
server: {
|
server: {
|
||||||
deploymentMode: source?.server.deploymentMode ?? "local_trusted",
|
deploymentMode: source?.server.deploymentMode ?? "local_trusted",
|
||||||
exposure: source?.server.exposure ?? "private",
|
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",
|
host: source?.server.host ?? "127.0.0.1",
|
||||||
port: serverPort,
|
port: serverPort,
|
||||||
allowedHostnames: source?.server.allowedHostnames ?? [],
|
allowedHostnames: source?.server.allowedHostnames ?? [],
|
||||||
|
|
|
||||||
156
cli/src/config/server-bind.ts
Normal file
156
cli/src/config/server-bind.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import {
|
||||||
|
ALL_INTERFACES_BIND_HOST,
|
||||||
|
LOOPBACK_BIND_HOST,
|
||||||
|
inferBindModeFromHost,
|
||||||
|
isAllInterfacesHost,
|
||||||
|
isLoopbackHost,
|
||||||
|
type BindMode,
|
||||||
|
type DeploymentExposure,
|
||||||
|
type DeploymentMode,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import type { AuthConfig, ServerConfig } from "./schema.js";
|
||||||
|
|
||||||
|
type BaseServerInput = {
|
||||||
|
port: number;
|
||||||
|
allowedHostnames: string[];
|
||||||
|
serveUi: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function inferConfiguredBind(server?: Partial<ServerConfig>): BindMode {
|
||||||
|
if (server?.bind) return server.bind;
|
||||||
|
return inferBindModeFromHost(server?.customBindHost ?? server?.host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPresetServerConfig(
|
||||||
|
bind: Exclude<BindMode, "custom">,
|
||||||
|
input: BaseServerInput,
|
||||||
|
): { server: ServerConfig; auth: AuthConfig } {
|
||||||
|
const host = bind === "loopback" ? LOOPBACK_BIND_HOST : ALL_INTERFACES_BIND_HOST;
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: {
|
||||||
|
deploymentMode: bind === "loopback" ? "local_trusted" : "authenticated",
|
||||||
|
exposure: "private",
|
||||||
|
bind,
|
||||||
|
customBindHost: undefined,
|
||||||
|
host,
|
||||||
|
port: input.port,
|
||||||
|
allowedHostnames: input.allowedHostnames,
|
||||||
|
serveUi: input.serveUi,
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
baseUrlMode: "auto",
|
||||||
|
disableSignUp: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCustomServerConfig(input: BaseServerInput & {
|
||||||
|
deploymentMode: DeploymentMode;
|
||||||
|
exposure: DeploymentExposure;
|
||||||
|
host: string;
|
||||||
|
publicBaseUrl?: string;
|
||||||
|
}): { server: ServerConfig; auth: AuthConfig } {
|
||||||
|
const normalizedHost = input.host.trim();
|
||||||
|
const bind = isLoopbackHost(normalizedHost)
|
||||||
|
? "loopback"
|
||||||
|
: isAllInterfacesHost(normalizedHost)
|
||||||
|
? "lan"
|
||||||
|
: "custom";
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: {
|
||||||
|
deploymentMode: input.deploymentMode,
|
||||||
|
exposure: input.deploymentMode === "local_trusted" ? "private" : input.exposure,
|
||||||
|
bind,
|
||||||
|
customBindHost: bind === "custom" ? normalizedHost : undefined,
|
||||||
|
host: normalizedHost,
|
||||||
|
port: input.port,
|
||||||
|
allowedHostnames: input.allowedHostnames,
|
||||||
|
serveUi: input.serveUi,
|
||||||
|
},
|
||||||
|
auth:
|
||||||
|
input.deploymentMode === "authenticated" && input.exposure === "public"
|
||||||
|
? {
|
||||||
|
baseUrlMode: "explicit",
|
||||||
|
disableSignUp: false,
|
||||||
|
publicBaseUrl: input.publicBaseUrl,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
baseUrlMode: "auto",
|
||||||
|
disableSignUp: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveQuickstartServerConfig(input: {
|
||||||
|
bind?: BindMode | null;
|
||||||
|
deploymentMode?: DeploymentMode | null;
|
||||||
|
exposure?: DeploymentExposure | null;
|
||||||
|
host?: string | null;
|
||||||
|
port: number;
|
||||||
|
allowedHostnames: string[];
|
||||||
|
serveUi: boolean;
|
||||||
|
publicBaseUrl?: string;
|
||||||
|
}): { server: ServerConfig; auth: AuthConfig } {
|
||||||
|
const trimmedHost = input.host?.trim();
|
||||||
|
const explicitBind = input.bind ?? null;
|
||||||
|
|
||||||
|
if (explicitBind === "loopback" || explicitBind === "lan" || explicitBind === "tailnet") {
|
||||||
|
return buildPresetServerConfig(explicitBind, {
|
||||||
|
port: input.port,
|
||||||
|
allowedHostnames: input.allowedHostnames,
|
||||||
|
serveUi: input.serveUi,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (explicitBind === "custom") {
|
||||||
|
return buildCustomServerConfig({
|
||||||
|
deploymentMode: input.deploymentMode ?? "authenticated",
|
||||||
|
exposure: input.exposure ?? "private",
|
||||||
|
host: trimmedHost || LOOPBACK_BIND_HOST,
|
||||||
|
port: input.port,
|
||||||
|
allowedHostnames: input.allowedHostnames,
|
||||||
|
serveUi: input.serveUi,
|
||||||
|
publicBaseUrl: input.publicBaseUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedHost) {
|
||||||
|
return buildCustomServerConfig({
|
||||||
|
deploymentMode: input.deploymentMode ?? (isLoopbackHost(trimmedHost) ? "local_trusted" : "authenticated"),
|
||||||
|
exposure: input.exposure ?? "private",
|
||||||
|
host: trimmedHost,
|
||||||
|
port: input.port,
|
||||||
|
allowedHostnames: input.allowedHostnames,
|
||||||
|
serveUi: input.serveUi,
|
||||||
|
publicBaseUrl: input.publicBaseUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.deploymentMode === "authenticated") {
|
||||||
|
if (input.exposure === "public") {
|
||||||
|
return buildCustomServerConfig({
|
||||||
|
deploymentMode: "authenticated",
|
||||||
|
exposure: "public",
|
||||||
|
host: ALL_INTERFACES_BIND_HOST,
|
||||||
|
port: input.port,
|
||||||
|
allowedHostnames: input.allowedHostnames,
|
||||||
|
serveUi: input.serveUi,
|
||||||
|
publicBaseUrl: input.publicBaseUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildPresetServerConfig("lan", {
|
||||||
|
port: input.port,
|
||||||
|
allowedHostnames: input.allowedHostnames,
|
||||||
|
serveUi: input.serveUi,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildPresetServerConfig("loopback", {
|
||||||
|
port: input.port,
|
||||||
|
allowedHostnames: input.allowedHostnames,
|
||||||
|
serveUi: input.serveUi,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -50,7 +50,8 @@ program
|
||||||
.description("Interactive first-run setup wizard")
|
.description("Interactive first-run setup wizard")
|
||||||
.option("-c, --config <path>", "Path to config file")
|
.option("-c, --config <path>", "Path to config file")
|
||||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||||
.option("-y, --yes", "Accept defaults (quickstart + start immediately)", false)
|
.option("--bind <mode>", "Quickstart reachability preset (loopback, lan, tailnet)")
|
||||||
|
.option("-y, --yes", "Accept quickstart defaults (trusted local loopback unless --bind is set) and start immediately", false)
|
||||||
.option("--run", "Start Paperclip immediately after saving config", false)
|
.option("--run", "Start Paperclip immediately after saving config", false)
|
||||||
.action(onboard);
|
.action(onboard);
|
||||||
|
|
||||||
|
|
@ -108,6 +109,7 @@ program
|
||||||
.option("-c, --config <path>", "Path to config file")
|
.option("-c, --config <path>", "Path to config file")
|
||||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||||
.option("-i, --instance <id>", "Local instance id (default: default)")
|
.option("-i, --instance <id>", "Local instance id (default: default)")
|
||||||
|
.option("--bind <mode>", "On first run, use onboarding reachability preset (loopback, lan, tailnet)")
|
||||||
.option("--repair", "Attempt automatic repairs during doctor", true)
|
.option("--repair", "Attempt automatic repairs during doctor", true)
|
||||||
.option("--no-repair", "Disable automatic repairs during doctor")
|
.option("--no-repair", "Disable automatic repairs during doctor")
|
||||||
.action(runCommand);
|
.action(runCommand);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
|
import { isLoopbackHost, type BindMode } from "@paperclipai/shared";
|
||||||
import type { AuthConfig, ServerConfig } from "../config/schema.js";
|
import type { AuthConfig, ServerConfig } from "../config/schema.js";
|
||||||
import { parseHostnameCsv } from "../config/hostnames.js";
|
import { parseHostnameCsv } from "../config/hostnames.js";
|
||||||
|
import {
|
||||||
|
buildCustomServerConfig,
|
||||||
|
buildPresetServerConfig,
|
||||||
|
inferConfiguredBind,
|
||||||
|
} from "../config/server-bind.js";
|
||||||
|
|
||||||
|
function cancelled(): never {
|
||||||
|
p.cancel("Setup cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
export async function promptServer(opts?: {
|
export async function promptServer(opts?: {
|
||||||
currentServer?: Partial<ServerConfig>;
|
currentServer?: Partial<ServerConfig>;
|
||||||
|
|
@ -8,69 +19,37 @@ export async function promptServer(opts?: {
|
||||||
}): Promise<{ server: ServerConfig; auth: AuthConfig }> {
|
}): Promise<{ server: ServerConfig; auth: AuthConfig }> {
|
||||||
const currentServer = opts?.currentServer;
|
const currentServer = opts?.currentServer;
|
||||||
const currentAuth = opts?.currentAuth;
|
const currentAuth = opts?.currentAuth;
|
||||||
|
const currentBind = inferConfiguredBind(currentServer);
|
||||||
|
|
||||||
const deploymentModeSelection = await p.select({
|
const bindSelection = await p.select({
|
||||||
message: "Deployment mode",
|
message: "Reachability",
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
value: "local_trusted",
|
value: "loopback" as const,
|
||||||
label: "Local trusted",
|
label: "Trusted local",
|
||||||
hint: "Easiest for local setup (no login, localhost-only)",
|
hint: "Recommended for first run: localhost only, no login friction",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "authenticated",
|
value: "lan" as const,
|
||||||
label: "Authenticated",
|
label: "Private network",
|
||||||
hint: "Login required; use for private network or public hosting",
|
hint: "Broad private bind for LAN, VPN, or legacy --tailscale-auth style access",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "tailnet" as const,
|
||||||
|
label: "Tailnet",
|
||||||
|
hint: "Private authenticated access using the machine's detected Tailscale address",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "custom" as const,
|
||||||
|
label: "Custom",
|
||||||
|
hint: "Choose exact auth mode, exposure, and host manually",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
initialValue: currentServer?.deploymentMode ?? "local_trusted",
|
initialValue: currentBind,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (p.isCancel(deploymentModeSelection)) {
|
if (p.isCancel(bindSelection)) cancelled();
|
||||||
p.cancel("Setup cancelled.");
|
const bind = bindSelection as BindMode;
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"];
|
|
||||||
|
|
||||||
let exposure: ServerConfig["exposure"] = "private";
|
|
||||||
if (deploymentMode === "authenticated") {
|
|
||||||
const exposureSelection = await p.select({
|
|
||||||
message: "Exposure profile",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
value: "private",
|
|
||||||
label: "Private network",
|
|
||||||
hint: "Private access (for example Tailscale), lower setup friction",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "public",
|
|
||||||
label: "Public internet",
|
|
||||||
hint: "Internet-facing deployment with stricter requirements",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
initialValue: currentServer?.exposure ?? "private",
|
|
||||||
});
|
|
||||||
if (p.isCancel(exposureSelection)) {
|
|
||||||
p.cancel("Setup cancelled.");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
exposure = exposureSelection as ServerConfig["exposure"];
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostDefault = deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0";
|
|
||||||
const hostStr = await p.text({
|
|
||||||
message: "Bind host",
|
|
||||||
defaultValue: currentServer?.host ?? hostDefault,
|
|
||||||
placeholder: hostDefault,
|
|
||||||
validate: (val) => {
|
|
||||||
if (!val.trim()) return "Host is required";
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(hostStr)) {
|
|
||||||
p.cancel("Setup cancelled.");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const portStr = await p.text({
|
const portStr = await p.text({
|
||||||
message: "Server port",
|
message: "Server port",
|
||||||
|
|
@ -84,15 +63,109 @@ export async function promptServer(opts?: {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (p.isCancel(portStr)) {
|
if (p.isCancel(portStr)) cancelled();
|
||||||
p.cancel("Setup cancelled.");
|
const port = Number(portStr) || 3100;
|
||||||
process.exit(0);
|
const serveUi = currentServer?.serveUi ?? true;
|
||||||
|
|
||||||
|
if (bind === "loopback") {
|
||||||
|
return buildPresetServerConfig("loopback", {
|
||||||
|
port,
|
||||||
|
allowedHostnames: [],
|
||||||
|
serveUi,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bind === "lan" || bind === "tailnet") {
|
||||||
|
const allowedHostnamesInput = await p.text({
|
||||||
|
message: "Allowed private hostnames (comma-separated, optional)",
|
||||||
|
defaultValue: (currentServer?.allowedHostnames ?? []).join(", "),
|
||||||
|
placeholder:
|
||||||
|
bind === "tailnet"
|
||||||
|
? "your-machine.tailnet.ts.net"
|
||||||
|
: "dotta-macbook-pro, host.docker.internal",
|
||||||
|
validate: (val) => {
|
||||||
|
try {
|
||||||
|
parseHostnameCsv(val);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
return err instanceof Error ? err.message : "Invalid hostname list";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(allowedHostnamesInput)) cancelled();
|
||||||
|
|
||||||
|
return buildPresetServerConfig(bind, {
|
||||||
|
port,
|
||||||
|
allowedHostnames: parseHostnameCsv(allowedHostnamesInput),
|
||||||
|
serveUi,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deploymentModeSelection = await p.select({
|
||||||
|
message: "Auth mode",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "local_trusted",
|
||||||
|
label: "Local trusted",
|
||||||
|
hint: "No login required; only safe with loopback-only or similarly trusted access",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "authenticated",
|
||||||
|
label: "Authenticated",
|
||||||
|
hint: "Login required; supports both private-network and public deployments",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initialValue: currentServer?.deploymentMode ?? "authenticated",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(deploymentModeSelection)) cancelled();
|
||||||
|
const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"];
|
||||||
|
|
||||||
|
let exposure: ServerConfig["exposure"] = "private";
|
||||||
|
if (deploymentMode === "authenticated") {
|
||||||
|
const exposureSelection = await p.select({
|
||||||
|
message: "Exposure profile",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "private",
|
||||||
|
label: "Private network",
|
||||||
|
hint: "Private access only, with automatic URL handling",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "public",
|
||||||
|
label: "Public internet",
|
||||||
|
hint: "Internet-facing deployment with explicit public URL requirements",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initialValue: currentServer?.exposure ?? "private",
|
||||||
|
});
|
||||||
|
if (p.isCancel(exposureSelection)) cancelled();
|
||||||
|
exposure = exposureSelection as ServerConfig["exposure"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultHost =
|
||||||
|
currentServer?.customBindHost ??
|
||||||
|
currentServer?.host ??
|
||||||
|
(deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0");
|
||||||
|
const host = await p.text({
|
||||||
|
message: "Bind host",
|
||||||
|
defaultValue: defaultHost,
|
||||||
|
placeholder: defaultHost,
|
||||||
|
validate: (val) => {
|
||||||
|
if (!val.trim()) return "Host is required";
|
||||||
|
if (deploymentMode === "local_trusted" && !isLoopbackHost(val.trim())) {
|
||||||
|
return "Local trusted mode requires a loopback host such as 127.0.0.1";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(host)) cancelled();
|
||||||
|
|
||||||
let allowedHostnames: string[] = [];
|
let allowedHostnames: string[] = [];
|
||||||
if (deploymentMode === "authenticated" && exposure === "private") {
|
if (deploymentMode === "authenticated" && exposure === "private") {
|
||||||
const allowedHostnamesInput = await p.text({
|
const allowedHostnamesInput = await p.text({
|
||||||
message: "Allowed hostnames (comma-separated, optional)",
|
message: "Allowed private hostnames (comma-separated, optional)",
|
||||||
defaultValue: (currentServer?.allowedHostnames ?? []).join(", "),
|
defaultValue: (currentServer?.allowedHostnames ?? []).join(", "),
|
||||||
placeholder: "dotta-macbook-pro, your-host.tailnet.ts.net",
|
placeholder: "dotta-macbook-pro, your-host.tailnet.ts.net",
|
||||||
validate: (val) => {
|
validate: (val) => {
|
||||||
|
|
@ -105,15 +178,11 @@ export async function promptServer(opts?: {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (p.isCancel(allowedHostnamesInput)) {
|
if (p.isCancel(allowedHostnamesInput)) cancelled();
|
||||||
p.cancel("Setup cancelled.");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
allowedHostnames = parseHostnameCsv(allowedHostnamesInput);
|
allowedHostnames = parseHostnameCsv(allowedHostnamesInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
const port = Number(portStr) || 3100;
|
let publicBaseUrl: string | undefined;
|
||||||
let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false };
|
|
||||||
if (deploymentMode === "authenticated" && exposure === "public") {
|
if (deploymentMode === "authenticated" && exposure === "public") {
|
||||||
const urlInput = await p.text({
|
const urlInput = await p.text({
|
||||||
message: "Public base URL",
|
message: "Public base URL",
|
||||||
|
|
@ -133,32 +202,17 @@ export async function promptServer(opts?: {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (p.isCancel(urlInput)) {
|
if (p.isCancel(urlInput)) cancelled();
|
||||||
p.cancel("Setup cancelled.");
|
publicBaseUrl = urlInput.trim().replace(/\/+$/, "");
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
auth = {
|
|
||||||
baseUrlMode: "explicit",
|
|
||||||
disableSignUp: false,
|
|
||||||
publicBaseUrl: urlInput.trim().replace(/\/+$/, ""),
|
|
||||||
};
|
|
||||||
} else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) {
|
|
||||||
auth = {
|
|
||||||
baseUrlMode: "explicit",
|
|
||||||
disableSignUp: false,
|
|
||||||
publicBaseUrl: currentAuth.publicBaseUrl,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return buildCustomServerConfig({
|
||||||
server: {
|
deploymentMode,
|
||||||
deploymentMode,
|
exposure,
|
||||||
exposure,
|
host: host.trim(),
|
||||||
host: hostStr.trim(),
|
port,
|
||||||
port,
|
allowedHostnames,
|
||||||
allowedHostnames,
|
serveUi,
|
||||||
serveUi: currentServer?.serveUi ?? true,
|
publicBaseUrl,
|
||||||
},
|
});
|
||||||
auth,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,12 @@ Mode taxonomy and design intent are documented in `doc/DEPLOYMENT-MODES.md`.
|
||||||
Current CLI behavior:
|
Current CLI behavior:
|
||||||
|
|
||||||
- `paperclipai onboard` and `paperclipai configure --section server` set deployment mode in config
|
- `paperclipai onboard` and `paperclipai configure --section server` set deployment mode in config
|
||||||
|
- server onboarding/configure ask for reachability intent and write `server.bind`
|
||||||
|
- `paperclipai run --bind <loopback|lan|tailnet>` passes a quickstart bind preset into first-run onboarding when config is missing
|
||||||
- runtime can override mode with `PAPERCLIP_DEPLOYMENT_MODE`
|
- runtime can override mode with `PAPERCLIP_DEPLOYMENT_MODE`
|
||||||
- `paperclipai run` and `paperclipai doctor` do not yet expose a direct `--mode` flag
|
- `paperclipai run` and `paperclipai doctor` still do not expose a direct low-level `--mode` flag
|
||||||
|
|
||||||
Target behavior (planned) is documented in `doc/DEPLOYMENT-MODES.md` section 5.
|
Canonical behavior is documented in `doc/DEPLOYMENT-MODES.md`.
|
||||||
|
|
||||||
Allow an authenticated/private hostname (for example custom Tailscale DNS):
|
Allow an authenticated/private hostname (for example custom Tailscale DNS):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ Paperclip supports two runtime modes:
|
||||||
|
|
||||||
This keeps one authenticated auth stack while still separating low-friction private-network defaults from internet-facing hardening requirements.
|
This keeps one authenticated auth stack while still separating low-friction private-network defaults from internet-facing hardening requirements.
|
||||||
|
|
||||||
|
Paperclip now treats **bind** as a separate concern from auth:
|
||||||
|
|
||||||
|
- auth model: `local_trusted` vs `authenticated`, plus `private/public`
|
||||||
|
- reachability model: `server.bind = loopback | lan | tailnet | custom`
|
||||||
|
|
||||||
## 2. Canonical Model
|
## 2. Canonical Model
|
||||||
|
|
||||||
| Runtime Mode | Exposure | Human auth | Primary use |
|
| Runtime Mode | Exposure | Human auth | Primary use |
|
||||||
|
|
@ -25,6 +30,15 @@ This keeps one authenticated auth stack while still separating low-friction priv
|
||||||
| `authenticated` | `private` | Login required | Private-network access (for example Tailscale/VPN/LAN) |
|
| `authenticated` | `private` | Login required | Private-network access (for example Tailscale/VPN/LAN) |
|
||||||
| `authenticated` | `public` | Login required | Internet-facing/cloud deployment |
|
| `authenticated` | `public` | Login required | Internet-facing/cloud deployment |
|
||||||
|
|
||||||
|
## Reachability Model
|
||||||
|
|
||||||
|
| Bind | Meaning | Typical use |
|
||||||
|
|---|---|---|
|
||||||
|
| `loopback` | Listen on localhost only | default local usage, reverse-proxy deployments |
|
||||||
|
| `lan` | Listen on all interfaces (`0.0.0.0`) | LAN/VPN/private-network access |
|
||||||
|
| `tailnet` | Listen on a detected Tailscale IP | Tailscale-only access |
|
||||||
|
| `custom` | Listen on an explicit host/IP | advanced interface-specific setups |
|
||||||
|
|
||||||
## 3. Security Policy
|
## 3. Security Policy
|
||||||
|
|
||||||
## `local_trusted`
|
## `local_trusted`
|
||||||
|
|
@ -38,12 +52,14 @@ This keeps one authenticated auth stack while still separating low-friction priv
|
||||||
- login required
|
- login required
|
||||||
- low-friction URL handling (`auto` base URL mode)
|
- low-friction URL handling (`auto` base URL mode)
|
||||||
- private-host trust policy required
|
- private-host trust policy required
|
||||||
|
- bind can be `loopback`, `lan`, `tailnet`, or `custom`
|
||||||
|
|
||||||
## `authenticated + public`
|
## `authenticated + public`
|
||||||
|
|
||||||
- login required
|
- login required
|
||||||
- explicit public URL required
|
- explicit public URL required
|
||||||
- stricter deployment checks and failures in doctor
|
- stricter deployment checks and failures in doctor
|
||||||
|
- recommended bind is `loopback` behind a reverse proxy; direct `lan/custom` is advanced
|
||||||
|
|
||||||
## 4. Onboarding UX Contract
|
## 4. Onboarding UX Contract
|
||||||
|
|
||||||
|
|
@ -55,14 +71,22 @@ pnpm paperclipai onboard
|
||||||
|
|
||||||
Server prompt behavior:
|
Server prompt behavior:
|
||||||
|
|
||||||
1. ask mode, default `local_trusted`
|
1. quickstart `--yes` defaults to `server.bind=loopback` and therefore `local_trusted/private`
|
||||||
2. option copy:
|
2. advanced server setup asks reachability first:
|
||||||
- `local_trusted`: "Easiest for local setup (no login, localhost-only)"
|
- `Trusted local` → `bind=loopback`, `local_trusted/private`
|
||||||
- `authenticated`: "Login required; use for private network or public hosting"
|
- `Private network` → `bind=lan`, `authenticated/private`
|
||||||
3. if `authenticated`, ask exposure:
|
- `Tailnet` → `bind=tailnet`, `authenticated/private`
|
||||||
- `private`: "Private network access (for example Tailscale), lower setup friction"
|
- `Custom` → manual mode/exposure/host entry
|
||||||
- `public`: "Internet-facing deployment, stricter security requirements"
|
3. raw host entry is only required for the `Custom` path
|
||||||
4. ask explicit public URL only for `authenticated + public`
|
4. explicit public URL is only required for `authenticated + public`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm paperclipai onboard --yes
|
||||||
|
pnpm paperclipai onboard --yes --bind lan
|
||||||
|
pnpm paperclipai run --bind tailnet
|
||||||
|
```
|
||||||
|
|
||||||
`configure --section server` follows the same interactive behavior.
|
`configure --section server` follows the same interactive behavior.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,23 @@ pnpm dev:stop
|
||||||
Tailscale/private-auth dev mode:
|
Tailscale/private-auth dev mode:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm dev --tailscale-auth
|
pnpm dev --bind lan
|
||||||
```
|
```
|
||||||
|
|
||||||
This runs dev as `authenticated/private` and binds the server to `0.0.0.0` for private-network access.
|
This runs dev as `authenticated/private` with a private-network bind preset.
|
||||||
|
|
||||||
|
For Tailscale-only reachability on a detected tailnet address:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm dev --bind tailnet
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy aliases still map to the old broad private-network behavior:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm dev --tailscale-auth
|
||||||
|
pnpm dev --authenticated-private
|
||||||
|
```
|
||||||
|
|
||||||
Allow additional private hostnames (for example custom Tailscale hostnames):
|
Allow additional private hostnames (for example custom Tailscale hostnames):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ Use this exact checklist.
|
||||||
1. Start Paperclip in auth mode.
|
1. Start Paperclip in auth mode.
|
||||||
```bash
|
```bash
|
||||||
cd <paperclip-repo-root>
|
cd <paperclip-repo-root>
|
||||||
pnpm dev --tailscale-auth
|
pnpm dev --bind lan
|
||||||
```
|
```
|
||||||
Then verify:
|
Then verify:
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ Show resolved environment configuration:
|
||||||
pnpm paperclipai env
|
pnpm paperclipai env
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This now includes bind-oriented deployment settings such as `PAPERCLIP_BIND` and `PAPERCLIP_BIND_HOST` when configured.
|
||||||
|
|
||||||
## `paperclipai allowed-hostname`
|
## `paperclipai allowed-hostname`
|
||||||
|
|
||||||
Allow a private hostname for authenticated/private mode:
|
Allow a private hostname for authenticated/private mode:
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,14 @@ title: Deployment Modes
|
||||||
summary: local_trusted vs authenticated (private/public)
|
summary: local_trusted vs authenticated (private/public)
|
||||||
---
|
---
|
||||||
|
|
||||||
Paperclip supports two runtime modes with different security profiles.
|
Paperclip supports two runtime modes with different security profiles. Reachability is configured separately with `bind`.
|
||||||
|
|
||||||
## `local_trusted`
|
## `local_trusted`
|
||||||
|
|
||||||
The default mode. Optimized for single-operator local use.
|
The default mode. Optimized for single-operator local use.
|
||||||
|
|
||||||
- **Host binding**: loopback only (localhost)
|
- **Host binding**: loopback only (localhost)
|
||||||
|
- **Bind**: `loopback`
|
||||||
- **Authentication**: no login required
|
- **Authentication**: no login required
|
||||||
- **Use case**: local development, solo experimentation
|
- **Use case**: local development, solo experimentation
|
||||||
- **Board identity**: auto-created local board user
|
- **Board identity**: auto-created local board user
|
||||||
|
|
@ -31,6 +32,7 @@ For private network access (Tailscale, VPN, LAN).
|
||||||
- **Authentication**: login required via Better Auth
|
- **Authentication**: login required via Better Auth
|
||||||
- **URL handling**: auto base URL mode (lower friction)
|
- **URL handling**: auto base URL mode (lower friction)
|
||||||
- **Host trust**: private-host trust policy required
|
- **Host trust**: private-host trust policy required
|
||||||
|
- **Bind**: choose `loopback`, `lan`, `tailnet`, or `custom`
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm paperclipai onboard
|
pnpm paperclipai onboard
|
||||||
|
|
@ -50,6 +52,7 @@ For internet-facing deployment.
|
||||||
- **Authentication**: login required
|
- **Authentication**: login required
|
||||||
- **URL**: explicit public URL required
|
- **URL**: explicit public URL required
|
||||||
- **Security**: stricter deployment checks in doctor
|
- **Security**: stricter deployment checks in doctor
|
||||||
|
- **Bind**: usually `loopback` behind a reverse proxy; `lan/custom` is advanced
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm paperclipai onboard
|
pnpm paperclipai onboard
|
||||||
|
|
@ -81,5 +84,5 @@ pnpm paperclipai configure --section server
|
||||||
Runtime override via environment variable:
|
Runtime override via environment variable:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
PAPERCLIP_DEPLOYMENT_MODE=authenticated pnpm paperclipai run
|
PAPERCLIP_DEPLOYMENT_MODE=authenticated PAPERCLIP_BIND=lan pnpm paperclipai run
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,14 @@ All environment variables that Paperclip uses for server configuration.
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `PORT` | `3100` | Server port |
|
| `PORT` | `3100` | Server port |
|
||||||
| `HOST` | `127.0.0.1` | Server host binding |
|
| `PAPERCLIP_BIND` | `loopback` | Reachability preset: `loopback`, `lan`, `tailnet`, or `custom` |
|
||||||
|
| `PAPERCLIP_BIND_HOST` | (unset) | Required when `PAPERCLIP_BIND=custom` |
|
||||||
|
| `HOST` | `127.0.0.1` | Legacy host override; prefer `PAPERCLIP_BIND` for new setups |
|
||||||
| `DATABASE_URL` | (embedded) | PostgreSQL connection string |
|
| `DATABASE_URL` | (embedded) | PostgreSQL connection string |
|
||||||
| `PAPERCLIP_HOME` | `~/.paperclip` | Base directory for all Paperclip data |
|
| `PAPERCLIP_HOME` | `~/.paperclip` | Base directory for all Paperclip data |
|
||||||
| `PAPERCLIP_INSTANCE_ID` | `default` | Instance identifier (for multiple local instances) |
|
| `PAPERCLIP_INSTANCE_ID` | `default` | Instance identifier (for multiple local instances) |
|
||||||
| `PAPERCLIP_DEPLOYMENT_MODE` | `local_trusted` | Runtime mode override |
|
| `PAPERCLIP_DEPLOYMENT_MODE` | `local_trusted` | Runtime mode override |
|
||||||
|
| `PAPERCLIP_DEPLOYMENT_EXPOSURE` | `private` | Exposure policy when deployment mode is `authenticated` |
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,19 +38,26 @@ This does:
|
||||||
2. Runs `paperclipai doctor` with repair enabled
|
2. Runs `paperclipai doctor` with repair enabled
|
||||||
3. Starts the server when checks pass
|
3. Starts the server when checks pass
|
||||||
|
|
||||||
## Tailscale/Private Auth Dev Mode
|
## Bind Presets In Dev
|
||||||
|
|
||||||
To run in `authenticated/private` mode for network access:
|
Default `pnpm dev` stays in `local_trusted` with loopback-only binding.
|
||||||
|
|
||||||
|
To open Paperclip to a private network with login enabled:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm dev --bind lan
|
||||||
|
```
|
||||||
|
|
||||||
|
For Tailscale-only binding on a detected tailnet address:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm dev --bind tailnet
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy aliases still work and map to the older broad private-network behavior:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm dev --tailscale-auth
|
pnpm dev --tailscale-auth
|
||||||
```
|
|
||||||
|
|
||||||
This binds the server to `0.0.0.0` for private-network access.
|
|
||||||
|
|
||||||
Alias:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm dev --authenticated-private
|
pnpm dev --authenticated-private
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
title: Tailscale Private Access
|
title: Tailscale Private Access
|
||||||
summary: Run Paperclip with Tailscale-friendly host binding and connect from other devices
|
summary: Run Paperclip with Tailscale-friendly bind presets and connect from other devices
|
||||||
---
|
---
|
||||||
|
|
||||||
Use this when you want to access Paperclip over Tailscale (or a private LAN/VPN) instead of only `localhost`.
|
Use this when you want to access Paperclip over Tailscale (or a private LAN/VPN) instead of only `localhost`.
|
||||||
|
|
@ -8,20 +8,25 @@ Use this when you want to access Paperclip over Tailscale (or a private LAN/VPN)
|
||||||
## 1. Start Paperclip in private authenticated mode
|
## 1. Start Paperclip in private authenticated mode
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm dev --tailscale-auth
|
pnpm dev --bind tailnet
|
||||||
```
|
```
|
||||||
|
|
||||||
This configures:
|
Recommended behavior:
|
||||||
|
|
||||||
- `PAPERCLIP_DEPLOYMENT_MODE=authenticated`
|
- `PAPERCLIP_DEPLOYMENT_MODE=authenticated`
|
||||||
- `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`
|
- `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`
|
||||||
- `PAPERCLIP_AUTH_BASE_URL_MODE=auto`
|
- `PAPERCLIP_BIND=tailnet`
|
||||||
- `HOST=0.0.0.0` (bind on all interfaces)
|
|
||||||
|
|
||||||
Equivalent flag:
|
If you want the old broad private-network behavior instead, use:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
pnpm dev --bind lan
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy aliases still map to `authenticated/private + bind=lan`:
|
||||||
|
|
||||||
pnpm dev --authenticated-private
|
pnpm dev --authenticated-private
|
||||||
|
pnpm dev --tailscale-auth
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Find your reachable Tailscale address
|
## 2. Find your reachable Tailscale address
|
||||||
|
|
@ -73,5 +78,5 @@ Expected result:
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
- Login or redirect errors on a private hostname: add it with `paperclipai allowed-hostname`.
|
- Login or redirect errors on a private hostname: add it with `paperclipai allowed-hostname`.
|
||||||
- App only works on `localhost`: make sure you started with `--tailscale-auth` (or set `HOST=0.0.0.0` in private mode).
|
- App only works on `localhost`: make sure you started with `--bind lan` or `--bind tailnet` instead of plain `pnpm dev`.
|
||||||
- Can connect locally but not remotely: verify both devices are on the same Tailscale network and port `3100` is reachable.
|
- Can connect locally but not remotely: verify both devices are on the same Tailscale network and port `3100` is reachable.
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
|
||||||
|
|
||||||
### 1) Start Paperclip
|
### 1) Start Paperclip
|
||||||
```bash
|
```bash
|
||||||
pnpm dev --tailscale-auth
|
pnpm dev --bind lan
|
||||||
curl -fsS http://127.0.0.1:3100/api/health
|
curl -fsS http://127.0.0.1:3100/api/health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
AUTH_BASE_URL_MODES,
|
AUTH_BASE_URL_MODES,
|
||||||
|
BIND_MODES,
|
||||||
DEPLOYMENT_EXPOSURES,
|
DEPLOYMENT_EXPOSURES,
|
||||||
DEPLOYMENT_MODES,
|
DEPLOYMENT_MODES,
|
||||||
SECRET_PROVIDERS,
|
SECRET_PROVIDERS,
|
||||||
STORAGE_PROVIDERS,
|
STORAGE_PROVIDERS,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
|
import { validateConfiguredBindMode } from "./network-bind.js";
|
||||||
|
|
||||||
export const configMetaSchema = z.object({
|
export const configMetaSchema = z.object({
|
||||||
version: z.literal(1),
|
version: z.literal(1),
|
||||||
|
|
@ -46,6 +48,8 @@ export const loggingConfigSchema = z.object({
|
||||||
export const serverConfigSchema = z.object({
|
export const serverConfigSchema = z.object({
|
||||||
deploymentMode: z.enum(DEPLOYMENT_MODES).default("local_trusted"),
|
deploymentMode: z.enum(DEPLOYMENT_MODES).default("local_trusted"),
|
||||||
exposure: z.enum(DEPLOYMENT_EXPOSURES).default("private"),
|
exposure: z.enum(DEPLOYMENT_EXPOSURES).default("private"),
|
||||||
|
bind: z.enum(BIND_MODES).optional(),
|
||||||
|
customBindHost: z.string().optional(),
|
||||||
host: z.string().default("127.0.0.1"),
|
host: z.string().default("127.0.0.1"),
|
||||||
port: z.number().int().min(1).max(65535).default(3100),
|
port: z.number().int().min(1).max(65535).default(3100),
|
||||||
allowedHostnames: z.array(z.string().min(1)).default([]),
|
allowedHostnames: z.array(z.string().min(1)).default([]),
|
||||||
|
|
@ -132,15 +136,26 @@ export const paperclipConfigSchema = z
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
if (value.server.deploymentMode === "local_trusted") {
|
if (value.server.deploymentMode === "local_trusted" && value.server.exposure !== "private") {
|
||||||
if (value.server.exposure !== "private") {
|
ctx.addIssue({
|
||||||
ctx.addIssue({
|
code: z.ZodIssueCode.custom,
|
||||||
code: z.ZodIssueCode.custom,
|
message: "server.exposure must be private when deploymentMode is local_trusted",
|
||||||
message: "server.exposure must be private when deploymentMode is local_trusted",
|
path: ["server", "exposure"],
|
||||||
path: ["server", "exposure"],
|
});
|
||||||
});
|
}
|
||||||
}
|
|
||||||
return;
|
for (const message of validateConfiguredBindMode({
|
||||||
|
deploymentMode: value.server.deploymentMode,
|
||||||
|
deploymentExposure: value.server.exposure,
|
||||||
|
bind: value.server.bind,
|
||||||
|
host: value.server.host,
|
||||||
|
customBindHost: value.server.customBindHost,
|
||||||
|
})) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message,
|
||||||
|
path: message.includes("customBindHost") ? ["server", "customBindHost"] : ["server", "bind"],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.auth.baseUrlMode === "explicit" && !value.auth.publicBaseUrl) {
|
if (value.auth.baseUrlMode === "explicit" && !value.auth.publicBaseUrl) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ export type DeploymentMode = (typeof DEPLOYMENT_MODES)[number];
|
||||||
export const DEPLOYMENT_EXPOSURES = ["private", "public"] as const;
|
export const DEPLOYMENT_EXPOSURES = ["private", "public"] as const;
|
||||||
export type DeploymentExposure = (typeof DEPLOYMENT_EXPOSURES)[number];
|
export type DeploymentExposure = (typeof DEPLOYMENT_EXPOSURES)[number];
|
||||||
|
|
||||||
|
export const BIND_MODES = ["loopback", "lan", "tailnet", "custom"] as const;
|
||||||
|
export type BindMode = (typeof BIND_MODES)[number];
|
||||||
|
|
||||||
export const AUTH_BASE_URL_MODES = ["auto", "explicit"] as const;
|
export const AUTH_BASE_URL_MODES = ["auto", "explicit"] as const;
|
||||||
export type AuthBaseUrlMode = (typeof AUTH_BASE_URL_MODES)[number];
|
export type AuthBaseUrlMode = (typeof AUTH_BASE_URL_MODES)[number];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export {
|
||||||
COMPANY_STATUSES,
|
COMPANY_STATUSES,
|
||||||
DEPLOYMENT_MODES,
|
DEPLOYMENT_MODES,
|
||||||
DEPLOYMENT_EXPOSURES,
|
DEPLOYMENT_EXPOSURES,
|
||||||
|
BIND_MODES,
|
||||||
AUTH_BASE_URL_MODES,
|
AUTH_BASE_URL_MODES,
|
||||||
AGENT_STATUSES,
|
AGENT_STATUSES,
|
||||||
AGENT_ADAPTER_TYPES,
|
AGENT_ADAPTER_TYPES,
|
||||||
|
|
@ -79,6 +80,7 @@ export {
|
||||||
type CompanyStatus,
|
type CompanyStatus,
|
||||||
type DeploymentMode,
|
type DeploymentMode,
|
||||||
type DeploymentExposure,
|
type DeploymentExposure,
|
||||||
|
type BindMode,
|
||||||
type AuthBaseUrlMode,
|
type AuthBaseUrlMode,
|
||||||
type AgentStatus,
|
type AgentStatus,
|
||||||
type AgentAdapterType,
|
type AgentAdapterType,
|
||||||
|
|
@ -149,6 +151,16 @@ export {
|
||||||
type PluginBridgeErrorCode,
|
type PluginBridgeErrorCode,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ALL_INTERFACES_BIND_HOST,
|
||||||
|
LOOPBACK_BIND_HOST,
|
||||||
|
inferBindModeFromHost,
|
||||||
|
isAllInterfacesHost,
|
||||||
|
isLoopbackHost,
|
||||||
|
resolveRuntimeBind,
|
||||||
|
validateConfiguredBindMode,
|
||||||
|
} from "./network-bind.js";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
Company,
|
Company,
|
||||||
FeedbackVote,
|
FeedbackVote,
|
||||||
|
|
|
||||||
105
packages/shared/src/network-bind.ts
Normal file
105
packages/shared/src/network-bind.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import type { BindMode, DeploymentExposure, DeploymentMode } from "./constants.js";
|
||||||
|
|
||||||
|
export const LOOPBACK_BIND_HOST = "127.0.0.1";
|
||||||
|
export const ALL_INTERFACES_BIND_HOST = "0.0.0.0";
|
||||||
|
|
||||||
|
function normalizeHost(host: string | null | undefined): string | undefined {
|
||||||
|
const trimmed = host?.trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoopbackHost(host: string | null | undefined): boolean {
|
||||||
|
const normalized = normalizeHost(host)?.toLowerCase();
|
||||||
|
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAllInterfacesHost(host: string | null | undefined): boolean {
|
||||||
|
const normalized = normalizeHost(host)?.toLowerCase();
|
||||||
|
return normalized === "0.0.0.0" || normalized === "::";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferBindModeFromHost(
|
||||||
|
host: string | null | undefined,
|
||||||
|
opts?: { tailnetBindHost?: string | null | undefined },
|
||||||
|
): BindMode {
|
||||||
|
const normalized = normalizeHost(host);
|
||||||
|
const tailnetBindHost = normalizeHost(opts?.tailnetBindHost);
|
||||||
|
|
||||||
|
if (!normalized || isLoopbackHost(normalized)) return "loopback";
|
||||||
|
if (isAllInterfacesHost(normalized)) return "lan";
|
||||||
|
if (tailnetBindHost && normalized === tailnetBindHost) return "tailnet";
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateConfiguredBindMode(input: {
|
||||||
|
deploymentMode: DeploymentMode;
|
||||||
|
deploymentExposure: DeploymentExposure;
|
||||||
|
bind?: BindMode | null | undefined;
|
||||||
|
host?: string | null | undefined;
|
||||||
|
customBindHost?: string | null | undefined;
|
||||||
|
}): string[] {
|
||||||
|
const bind = input.bind ?? inferBindModeFromHost(input.host);
|
||||||
|
const customBindHost = normalizeHost(input.customBindHost);
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (input.deploymentMode === "local_trusted" && bind !== "loopback") {
|
||||||
|
errors.push("local_trusted requires server.bind=loopback");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bind === "custom" && !customBindHost) {
|
||||||
|
const legacyHost = normalizeHost(input.host);
|
||||||
|
if (!legacyHost || isLoopbackHost(legacyHost) || isAllInterfacesHost(legacyHost)) {
|
||||||
|
errors.push("server.customBindHost is required when server.bind=custom");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.deploymentMode === "authenticated" && input.deploymentExposure === "public" && bind === "tailnet") {
|
||||||
|
errors.push("server.bind=tailnet is only supported for authenticated/private deployments");
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRuntimeBind(input: {
|
||||||
|
bind?: BindMode | null | undefined;
|
||||||
|
host?: string | null | undefined;
|
||||||
|
customBindHost?: string | null | undefined;
|
||||||
|
tailnetBindHost?: string | null | undefined;
|
||||||
|
}): {
|
||||||
|
bind: BindMode;
|
||||||
|
host: string;
|
||||||
|
customBindHost?: string;
|
||||||
|
errors: string[];
|
||||||
|
} {
|
||||||
|
const bind = input.bind ?? inferBindModeFromHost(input.host, { tailnetBindHost: input.tailnetBindHost });
|
||||||
|
const legacyHost = normalizeHost(input.host);
|
||||||
|
const customBindHost =
|
||||||
|
normalizeHost(input.customBindHost) ??
|
||||||
|
(bind === "custom" && legacyHost && !isLoopbackHost(legacyHost) && !isAllInterfacesHost(legacyHost)
|
||||||
|
? legacyHost
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
switch (bind) {
|
||||||
|
case "loopback":
|
||||||
|
return { bind, host: LOOPBACK_BIND_HOST, customBindHost, errors: [] };
|
||||||
|
case "lan":
|
||||||
|
return { bind, host: ALL_INTERFACES_BIND_HOST, customBindHost, errors: [] };
|
||||||
|
case "custom":
|
||||||
|
return customBindHost
|
||||||
|
? { bind, host: customBindHost, customBindHost, errors: [] }
|
||||||
|
: { bind, host: legacyHost ?? LOOPBACK_BIND_HOST, errors: ["server.customBindHost is required when server.bind=custom"] };
|
||||||
|
case "tailnet": {
|
||||||
|
const tailnetBindHost = normalizeHost(input.tailnetBindHost);
|
||||||
|
return tailnetBindHost
|
||||||
|
? { bind, host: tailnetBindHost, customBindHost, errors: [] }
|
||||||
|
: {
|
||||||
|
bind,
|
||||||
|
host: legacyHost ?? LOOPBACK_BIND_HOST,
|
||||||
|
customBindHost,
|
||||||
|
errors: [
|
||||||
|
"server.bind=tailnet requires a detected Tailscale address or PAPERCLIP_TAILNET_BIND_HOST",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } f
|
||||||
import path from "node:path";
|
import path 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 { BIND_MODES, type BindMode } from "@paperclipai/shared";
|
||||||
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs";
|
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs";
|
||||||
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||||
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
|
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
|
||||||
|
|
@ -62,13 +63,36 @@ const tailscaleAuthFlagNames = new Set([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let tailscaleAuth = false;
|
let tailscaleAuth = false;
|
||||||
|
let bindMode: BindMode | null = null;
|
||||||
|
let bindHost: string | null = null;
|
||||||
const forwardedArgs: string[] = [];
|
const forwardedArgs: string[] = [];
|
||||||
|
|
||||||
for (const arg of cliArgs) {
|
for (let index = 0; index < cliArgs.length; index += 1) {
|
||||||
|
const arg = cliArgs[index];
|
||||||
if (tailscaleAuthFlagNames.has(arg)) {
|
if (tailscaleAuthFlagNames.has(arg)) {
|
||||||
tailscaleAuth = true;
|
tailscaleAuth = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (arg === "--bind") {
|
||||||
|
const value = cliArgs[index + 1];
|
||||||
|
if (!value || value.startsWith("--") || !BIND_MODES.includes(value as BindMode)) {
|
||||||
|
console.error(`[paperclip] invalid --bind value. Use one of: ${BIND_MODES.join(", ")}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
bindMode = value as BindMode;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--bind-host") {
|
||||||
|
const value = cliArgs[index + 1];
|
||||||
|
if (!value || value.startsWith("--")) {
|
||||||
|
console.error("[paperclip] --bind-host requires a value");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
bindHost = value;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
forwardedArgs.push(arg);
|
forwardedArgs.push(arg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,6 +102,16 @@ if (process.env.npm_config_tailscale_auth === "true") {
|
||||||
if (process.env.npm_config_authenticated_private === "true") {
|
if (process.env.npm_config_authenticated_private === "true") {
|
||||||
tailscaleAuth = true;
|
tailscaleAuth = true;
|
||||||
}
|
}
|
||||||
|
if (!bindMode && process.env.npm_config_bind && BIND_MODES.includes(process.env.npm_config_bind as BindMode)) {
|
||||||
|
bindMode = process.env.npm_config_bind as BindMode;
|
||||||
|
}
|
||||||
|
if (!bindHost && process.env.npm_config_bind_host) {
|
||||||
|
bindHost = process.env.npm_config_bind_host;
|
||||||
|
}
|
||||||
|
if (bindMode === "custom" && !bindHost) {
|
||||||
|
console.error("[paperclip] --bind custom requires --bind-host <host>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
|
@ -94,13 +128,36 @@ if (mode === "watch") {
|
||||||
env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true";
|
env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tailscaleAuth) {
|
if (tailscaleAuth || bindMode) {
|
||||||
env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
|
const effectiveBind = bindMode ?? "lan";
|
||||||
env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private";
|
if (tailscaleAuth) {
|
||||||
env.PAPERCLIP_AUTH_BASE_URL_MODE = "auto";
|
console.log("[paperclip] note: --tailscale-auth/--authenticated-private are legacy aliases for --bind lan");
|
||||||
env.HOST = "0.0.0.0";
|
}
|
||||||
console.log("[paperclip] dev mode: authenticated/private (tailscale-friendly) on 0.0.0.0");
|
env.PAPERCLIP_BIND = effectiveBind;
|
||||||
|
if (bindHost) {
|
||||||
|
env.PAPERCLIP_BIND_HOST = bindHost;
|
||||||
|
} else {
|
||||||
|
delete env.PAPERCLIP_BIND_HOST;
|
||||||
|
}
|
||||||
|
if (effectiveBind === "loopback" && !tailscaleAuth) {
|
||||||
|
delete env.PAPERCLIP_DEPLOYMENT_MODE;
|
||||||
|
delete env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
|
||||||
|
delete env.PAPERCLIP_AUTH_BASE_URL_MODE;
|
||||||
|
console.log("[paperclip] dev mode: local_trusted (bind=loopback)");
|
||||||
|
} else {
|
||||||
|
env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
|
||||||
|
env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private";
|
||||||
|
env.PAPERCLIP_AUTH_BASE_URL_MODE = "auto";
|
||||||
|
console.log(
|
||||||
|
`[paperclip] dev mode: authenticated/private (bind=${effectiveBind}${bindHost ? `:${bindHost}` : ""})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
delete env.PAPERCLIP_BIND;
|
||||||
|
delete env.PAPERCLIP_BIND_HOST;
|
||||||
|
delete env.PAPERCLIP_DEPLOYMENT_MODE;
|
||||||
|
delete env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
|
||||||
|
delete env.PAPERCLIP_AUTH_BASE_URL_MODE;
|
||||||
console.log("[paperclip] dev mode: local_trusted (default)");
|
console.log("[paperclip] dev mode: local_trusted (default)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,7 +165,7 @@ const serverPort = Number.parseInt(env.PORT ?? process.env.PORT ?? "3100", 10) |
|
||||||
const devService = createDevServiceIdentity({
|
const devService = createDevServiceIdentity({
|
||||||
mode,
|
mode,
|
||||||
forwardedArgs,
|
forwardedArgs,
|
||||||
tailscaleAuth,
|
networkProfile: tailscaleAuth ? `legacy:${bindMode ?? "lan"}` : (bindMode ?? "default"),
|
||||||
port: serverPort,
|
port: serverPort,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)
|
||||||
export function createDevServiceIdentity(input: {
|
export function createDevServiceIdentity(input: {
|
||||||
mode: "watch" | "dev";
|
mode: "watch" | "dev";
|
||||||
forwardedArgs: string[];
|
forwardedArgs: string[];
|
||||||
tailscaleAuth: boolean;
|
networkProfile: string;
|
||||||
port: number;
|
port: number;
|
||||||
}) {
|
}) {
|
||||||
const envFingerprint = createHash("sha256")
|
const envFingerprint = createHash("sha256")
|
||||||
|
|
@ -16,7 +16,7 @@ export function createDevServiceIdentity(input: {
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
mode: input.mode,
|
mode: input.mode,
|
||||||
forwardedArgs: input.forwardedArgs,
|
forwardedArgs: input.forwardedArgs,
|
||||||
tailscaleAuth: input.tailscaleAuth,
|
networkProfile: input.networkProfile,
|
||||||
port: input.port,
|
port: input.port,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,8 @@ async function main() {
|
||||||
server: {
|
server: {
|
||||||
deploymentMode: sourceConfig?.server?.deploymentMode ?? "local_trusted",
|
deploymentMode: sourceConfig?.server?.deploymentMode ?? "local_trusted",
|
||||||
exposure: sourceConfig?.server?.exposure ?? "private",
|
exposure: sourceConfig?.server?.exposure ?? "private",
|
||||||
|
...(sourceConfig?.server?.bind ? { bind: sourceConfig.server.bind } : {}),
|
||||||
|
...(sourceConfig?.server?.customBindHost ? { customBindHost: sourceConfig.server.customBindHost } : {}),
|
||||||
host: sourceConfig?.server?.host ?? "127.0.0.1",
|
host: sourceConfig?.server?.host ?? "127.0.0.1",
|
||||||
port: serverPort,
|
port: serverPort,
|
||||||
allowedHostnames: sourceConfig?.server?.allowedHostnames ?? [],
|
allowedHostnames: sourceConfig?.server?.allowedHostnames ?? [],
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,8 @@ vi.mock("../config.js", () => ({
|
||||||
loadConfig: vi.fn(() => ({
|
loadConfig: vi.fn(() => ({
|
||||||
deploymentMode: "authenticated",
|
deploymentMode: "authenticated",
|
||||||
deploymentExposure: "private",
|
deploymentExposure: "private",
|
||||||
|
bind: "loopback",
|
||||||
|
customBindHost: undefined,
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 3210,
|
port: 3210,
|
||||||
allowedHostnames: [],
|
allowedHostnames: [],
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { readConfigFile } from "./config-file.js";
|
import { readConfigFile } from "./config-file.js";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import { existsSync, realpathSync } from "node:fs";
|
import { existsSync, realpathSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { config as loadDotenv } from "dotenv";
|
import { config as loadDotenv } from "dotenv";
|
||||||
|
|
@ -6,15 +7,20 @@ import { resolvePaperclipEnvPath } from "./paths.js";
|
||||||
import { maybeRepairLegacyWorktreeConfigAndEnvFiles } from "./worktree-config.js";
|
import { maybeRepairLegacyWorktreeConfigAndEnvFiles } from "./worktree-config.js";
|
||||||
import {
|
import {
|
||||||
AUTH_BASE_URL_MODES,
|
AUTH_BASE_URL_MODES,
|
||||||
|
BIND_MODES,
|
||||||
DEPLOYMENT_EXPOSURES,
|
DEPLOYMENT_EXPOSURES,
|
||||||
DEPLOYMENT_MODES,
|
DEPLOYMENT_MODES,
|
||||||
SECRET_PROVIDERS,
|
SECRET_PROVIDERS,
|
||||||
STORAGE_PROVIDERS,
|
STORAGE_PROVIDERS,
|
||||||
|
type BindMode,
|
||||||
type AuthBaseUrlMode,
|
type AuthBaseUrlMode,
|
||||||
type DeploymentExposure,
|
type DeploymentExposure,
|
||||||
type DeploymentMode,
|
type DeploymentMode,
|
||||||
type SecretProvider,
|
type SecretProvider,
|
||||||
type StorageProvider,
|
type StorageProvider,
|
||||||
|
inferBindModeFromHost,
|
||||||
|
resolveRuntimeBind,
|
||||||
|
validateConfiguredBindMode,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
resolveDefaultBackupDir,
|
resolveDefaultBackupDir,
|
||||||
|
|
@ -44,6 +50,8 @@ type DatabaseMode = "embedded-postgres" | "postgres";
|
||||||
export interface Config {
|
export interface Config {
|
||||||
deploymentMode: DeploymentMode;
|
deploymentMode: DeploymentMode;
|
||||||
deploymentExposure: DeploymentExposure;
|
deploymentExposure: DeploymentExposure;
|
||||||
|
bind: BindMode;
|
||||||
|
customBindHost: string | undefined;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
allowedHostnames: string[];
|
allowedHostnames: string[];
|
||||||
|
|
@ -78,6 +86,24 @@ export interface Config {
|
||||||
telemetryEnabled: boolean;
|
telemetryEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectTailnetBindHost(): string | undefined {
|
||||||
|
const explicit = process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stdout = execFileSync("tailscale", ["ip", "-4"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
});
|
||||||
|
return stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find(Boolean);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function loadConfig(): Config {
|
export function loadConfig(): Config {
|
||||||
const fileConfig = readConfigFile();
|
const fileConfig = readConfigFile();
|
||||||
const fileDatabaseMode =
|
const fileDatabaseMode =
|
||||||
|
|
@ -148,6 +174,18 @@ export function loadConfig(): Config {
|
||||||
deploymentMode === "local_trusted"
|
deploymentMode === "local_trusted"
|
||||||
? "private"
|
? "private"
|
||||||
: (deploymentExposureFromEnv ?? fileConfig?.server.exposure ?? "private");
|
: (deploymentExposureFromEnv ?? fileConfig?.server.exposure ?? "private");
|
||||||
|
const bindFromEnvRaw = process.env.PAPERCLIP_BIND;
|
||||||
|
const bindFromEnv =
|
||||||
|
bindFromEnvRaw && BIND_MODES.includes(bindFromEnvRaw as BindMode)
|
||||||
|
? (bindFromEnvRaw as BindMode)
|
||||||
|
: null;
|
||||||
|
const configuredHost = process.env.HOST ?? fileConfig?.server.host ?? "127.0.0.1";
|
||||||
|
const tailnetBindHost = detectTailnetBindHost();
|
||||||
|
const bind =
|
||||||
|
bindFromEnv ??
|
||||||
|
fileConfig?.server.bind ??
|
||||||
|
inferBindModeFromHost(configuredHost, { tailnetBindHost });
|
||||||
|
const customBindHost = process.env.PAPERCLIP_BIND_HOST ?? fileConfig?.server.customBindHost;
|
||||||
const authBaseUrlModeFromEnvRaw = process.env.PAPERCLIP_AUTH_BASE_URL_MODE;
|
const authBaseUrlModeFromEnvRaw = process.env.PAPERCLIP_AUTH_BASE_URL_MODE;
|
||||||
const authBaseUrlModeFromEnv =
|
const authBaseUrlModeFromEnv =
|
||||||
authBaseUrlModeFromEnvRaw &&
|
authBaseUrlModeFromEnvRaw &&
|
||||||
|
|
@ -223,11 +261,32 @@ export function loadConfig(): Config {
|
||||||
fileDatabaseBackup?.dir ??
|
fileDatabaseBackup?.dir ??
|
||||||
resolveDefaultBackupDir(),
|
resolveDefaultBackupDir(),
|
||||||
);
|
);
|
||||||
|
const bindValidationErrors = validateConfiguredBindMode({
|
||||||
|
deploymentMode,
|
||||||
|
deploymentExposure,
|
||||||
|
bind,
|
||||||
|
host: configuredHost,
|
||||||
|
customBindHost,
|
||||||
|
});
|
||||||
|
if (bindValidationErrors.length > 0) {
|
||||||
|
throw new Error(bindValidationErrors[0]);
|
||||||
|
}
|
||||||
|
const resolvedBind = resolveRuntimeBind({
|
||||||
|
bind,
|
||||||
|
host: configuredHost,
|
||||||
|
customBindHost,
|
||||||
|
tailnetBindHost,
|
||||||
|
});
|
||||||
|
if (resolvedBind.errors.length > 0) {
|
||||||
|
throw new Error(resolvedBind.errors[0]);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deploymentMode,
|
deploymentMode,
|
||||||
deploymentExposure,
|
deploymentExposure,
|
||||||
host: process.env.HOST ?? fileConfig?.server.host ?? "127.0.0.1",
|
bind: resolvedBind.bind,
|
||||||
|
customBindHost: resolvedBind.customBindHost,
|
||||||
|
host: resolvedBind.host,
|
||||||
port: Number(process.env.PORT) || fileConfig?.server.port || 3100,
|
port: Number(process.env.PORT) || fileConfig?.server.port || 3100,
|
||||||
allowedHostnames,
|
allowedHostnames,
|
||||||
authBaseUrlMode,
|
authBaseUrlMode,
|
||||||
|
|
|
||||||
|
|
@ -701,9 +701,10 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
logger.warn({ err, url }, "Failed to open browser on startup");
|
logger.warn({ err, url }, "Failed to open browser on startup");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
printStartupBanner({
|
printStartupBanner({
|
||||||
host: config.host,
|
bind: config.bind,
|
||||||
deploymentMode: config.deploymentMode,
|
host: config.host,
|
||||||
|
deploymentMode: config.deploymentMode,
|
||||||
deploymentExposure: config.deploymentExposure,
|
deploymentExposure: config.deploymentExposure,
|
||||||
authReady,
|
authReady,
|
||||||
requestedPort: config.port,
|
requestedPort: config.port,
|
||||||
|
|
|
||||||
|
|
@ -928,7 +928,7 @@ function buildOnboardingDiscoveryDiagnostics(input: {
|
||||||
code: "openclaw_onboarding_private_loopback_bind",
|
code: "openclaw_onboarding_private_loopback_bind",
|
||||||
level: "warn",
|
level: "warn",
|
||||||
message: "Paperclip is bound to loopback in authenticated/private mode.",
|
message: "Paperclip is bound to loopback in authenticated/private mode.",
|
||||||
hint: "Run with a reachable bind host or use pnpm dev --tailscale-auth for private-network onboarding."
|
hint: "Use a reachable private bind mode such as `pnpm dev --bind lan` or `pnpm dev --bind tailnet` for private-network onboarding."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js";
|
import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js";
|
||||||
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
import type { BindMode, DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
||||||
|
|
||||||
import { parse as parseEnvFileContents } from "dotenv";
|
import { parse as parseEnvFileContents } from "dotenv";
|
||||||
|
|
||||||
|
|
@ -18,6 +18,7 @@ type EmbeddedPostgresInfo = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type StartupBannerOptions = {
|
type StartupBannerOptions = {
|
||||||
|
bind: BindMode;
|
||||||
host: string;
|
host: string;
|
||||||
deploymentMode: DeploymentMode;
|
deploymentMode: DeploymentMode;
|
||||||
deploymentExposure: DeploymentExposure;
|
deploymentExposure: DeploymentExposure;
|
||||||
|
|
@ -148,6 +149,7 @@ export function printStartupBanner(opts: StartupBannerOptions): void {
|
||||||
color(" ───────────────────────────────────────────────────────", "blue"),
|
color(" ───────────────────────────────────────────────────────", "blue"),
|
||||||
row("Mode", `${dbMode} | ${uiMode}`),
|
row("Mode", `${dbMode} | ${uiMode}`),
|
||||||
row("Deploy", `${opts.deploymentMode} (${opts.deploymentExposure})`),
|
row("Deploy", `${opts.deploymentMode} (${opts.deploymentExposure})`),
|
||||||
|
row("Bind", `${opts.bind} ${color(`(${opts.host})`, "dim")}`),
|
||||||
row("Auth", opts.authReady ? color("ready", "green") : color("not-ready", "yellow")),
|
row("Auth", opts.authReady ? color("ready", "green") : color("not-ready", "yellow")),
|
||||||
row("Server", portValue),
|
row("Server", portValue),
|
||||||
row("API", `${apiUrl} ${color(`(health: ${apiUrl}/health)`, "dim")}`),
|
row("API", `${apiUrl} ${color(`(health: ${apiUrl}/health)`, "dim")}`),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue