feat(cli): add deployment mode prompts, auth bootstrap-ceo command, and doctor check
Extend server setup prompts with deployment mode (local_trusted vs authenticated), exposure (private vs public), bind host, and auth config. Add auth bootstrap-ceo command that creates a one-time invite URL for the initial instance admin. Add deployment-auth-check to doctor diagnostics. Register the new command in the CLI entry point. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
91
cli/src/checks/deployment-auth-check.ts
Normal file
91
cli/src/checks/deployment-auth-check.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { PaperclipConfig } from "../config/schema.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 {
|
||||||
|
const mode = config.server.deploymentMode;
|
||||||
|
const exposure = config.server.exposure;
|
||||||
|
const auth = config.auth;
|
||||||
|
|
||||||
|
if (mode === "local_trusted") {
|
||||||
|
if (!isLoopbackHost(config.server.host)) {
|
||||||
|
return {
|
||||||
|
name: "Deployment/auth mode",
|
||||||
|
status: "fail",
|
||||||
|
message: `local_trusted requires loopback host binding (found ${config.server.host})`,
|
||||||
|
canRepair: false,
|
||||||
|
repairHint: "Run `paperclip configure --section server` and set host to 127.0.0.1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: "Deployment/auth mode",
|
||||||
|
status: "pass",
|
||||||
|
message: "local_trusted mode is configured for loopback-only access",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret =
|
||||||
|
process.env.BETTER_AUTH_SECRET?.trim() ??
|
||||||
|
process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim();
|
||||||
|
if (!secret) {
|
||||||
|
return {
|
||||||
|
name: "Deployment/auth mode",
|
||||||
|
status: "fail",
|
||||||
|
message: "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET)",
|
||||||
|
canRepair: false,
|
||||||
|
repairHint: "Set BETTER_AUTH_SECRET before starting Paperclip",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.baseUrlMode === "explicit" && !auth.publicBaseUrl) {
|
||||||
|
return {
|
||||||
|
name: "Deployment/auth mode",
|
||||||
|
status: "fail",
|
||||||
|
message: "auth.baseUrlMode=explicit requires auth.publicBaseUrl",
|
||||||
|
canRepair: false,
|
||||||
|
repairHint: "Run `paperclip configure --section server` and provide a base URL",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exposure === "public") {
|
||||||
|
if (auth.baseUrlMode !== "explicit" || !auth.publicBaseUrl) {
|
||||||
|
return {
|
||||||
|
name: "Deployment/auth mode",
|
||||||
|
status: "fail",
|
||||||
|
message: "authenticated/public requires explicit auth.publicBaseUrl",
|
||||||
|
canRepair: false,
|
||||||
|
repairHint: "Run `paperclip configure --section server` and select public exposure",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(auth.publicBaseUrl);
|
||||||
|
if (url.protocol !== "https:") {
|
||||||
|
return {
|
||||||
|
name: "Deployment/auth mode",
|
||||||
|
status: "warn",
|
||||||
|
message: "Public exposure should use an https:// auth.publicBaseUrl",
|
||||||
|
canRepair: false,
|
||||||
|
repairHint: "Use HTTPS in production for secure session cookies",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
name: "Deployment/auth mode",
|
||||||
|
status: "fail",
|
||||||
|
message: "auth.publicBaseUrl is not a valid URL",
|
||||||
|
canRepair: false,
|
||||||
|
repairHint: "Run `paperclip configure --section server` and provide a valid URL",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "Deployment/auth mode",
|
||||||
|
status: "pass",
|
||||||
|
message: `Mode ${mode}/${exposure} with auth URL mode ${auth.baseUrlMode}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export interface CheckResult {
|
|||||||
|
|
||||||
export { agentJwtSecretCheck } from "./agent-jwt-secret-check.js";
|
export { agentJwtSecretCheck } from "./agent-jwt-secret-check.js";
|
||||||
export { configCheck } from "./config-check.js";
|
export { configCheck } from "./config-check.js";
|
||||||
|
export { deploymentAuthCheck } from "./deployment-auth-check.js";
|
||||||
export { databaseCheck } from "./database-check.js";
|
export { databaseCheck } from "./database-check.js";
|
||||||
export { llmCheck } from "./llm-check.js";
|
export { llmCheck } from "./llm-check.js";
|
||||||
export { logCheck } from "./log-check.js";
|
export { logCheck } from "./log-check.js";
|
||||||
|
|||||||
116
cli/src/commands/auth-bootstrap-ceo.ts
Normal file
116
cli/src/commands/auth-bootstrap-ceo.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import * as p from "@clack/prompts";
|
||||||
|
import pc from "picocolors";
|
||||||
|
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||||
|
import { createDb, instanceUserRoles, invites } from "@paperclip/db";
|
||||||
|
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||||
|
|
||||||
|
function hashToken(token: string) {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInviteToken() {
|
||||||
|
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDbUrl(configPath?: string) {
|
||||||
|
const config = readConfig(configPath);
|
||||||
|
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
||||||
|
if (config?.database.mode === "postgres" && config.database.connectionString) {
|
||||||
|
return config.database.connectionString;
|
||||||
|
}
|
||||||
|
if (config?.database.mode === "embedded-postgres") {
|
||||||
|
const port = config.database.embeddedPostgresPort ?? 54329;
|
||||||
|
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) {
|
||||||
|
if (explicitBaseUrl) return explicitBaseUrl.replace(/\/+$/, "");
|
||||||
|
const config = readConfig(configPath);
|
||||||
|
if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
|
||||||
|
return config.auth.publicBaseUrl.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
const host = config?.server.host ?? "localhost";
|
||||||
|
const port = config?.server.port ?? 3100;
|
||||||
|
const publicHost = host === "0.0.0.0" ? "localhost" : host;
|
||||||
|
return `http://${publicHost}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapCeoInvite(opts: {
|
||||||
|
config?: string;
|
||||||
|
force?: boolean;
|
||||||
|
expiresHours?: number;
|
||||||
|
baseUrl?: string;
|
||||||
|
}) {
|
||||||
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
const config = readConfig(configPath);
|
||||||
|
if (!config) {
|
||||||
|
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.server.deploymentMode !== "authenticated") {
|
||||||
|
p.log.info("Deployment mode is local_trusted. Bootstrap CEO invite is only required for authenticated mode.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbUrl = resolveDbUrl(configPath);
|
||||||
|
if (!dbUrl) {
|
||||||
|
p.log.error(
|
||||||
|
"Could not resolve database connection for bootstrap.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = createDb(dbUrl);
|
||||||
|
try {
|
||||||
|
const existingAdminCount = await db
|
||||||
|
.select()
|
||||||
|
.from(instanceUserRoles)
|
||||||
|
.where(eq(instanceUserRoles.role, "instance_admin"))
|
||||||
|
.then((rows) => rows.length);
|
||||||
|
|
||||||
|
if (existingAdminCount > 0 && !opts.force) {
|
||||||
|
p.log.info("Instance already has an admin user. Use --force to generate a new bootstrap invite.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await db
|
||||||
|
.update(invites)
|
||||||
|
.set({ revokedAt: now, updatedAt: now })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(invites.inviteType, "bootstrap_ceo"),
|
||||||
|
isNull(invites.revokedAt),
|
||||||
|
isNull(invites.acceptedAt),
|
||||||
|
gt(invites.expiresAt, now),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = createInviteToken();
|
||||||
|
const expiresHours = Math.max(1, Math.min(24 * 30, opts.expiresHours ?? 72));
|
||||||
|
const created = await db
|
||||||
|
.insert(invites)
|
||||||
|
.values({
|
||||||
|
inviteType: "bootstrap_ceo",
|
||||||
|
tokenHash: hashToken(token),
|
||||||
|
allowedJoinTypes: "human",
|
||||||
|
expiresAt: new Date(Date.now() + expiresHours * 60 * 60 * 1000),
|
||||||
|
invitedByUserId: "system",
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.then((rows) => rows[0]);
|
||||||
|
|
||||||
|
const baseUrl = resolveBaseUrl(configPath, opts.baseUrl);
|
||||||
|
const inviteUrl = `${baseUrl}/invite/${token}`;
|
||||||
|
p.log.success("Created bootstrap CEO invite.");
|
||||||
|
p.log.message(`Invite URL: ${pc.cyan(inviteUrl)}`);
|
||||||
|
p.log.message(`Expires: ${pc.dim(created.expiresAt.toISOString())}`);
|
||||||
|
} catch (err) {
|
||||||
|
p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
p.log.info("If using embedded-postgres, start the Paperclip server and run this command again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,9 +44,15 @@ function defaultConfig(): PaperclipConfig {
|
|||||||
logDir: resolveDefaultLogsDir(instanceId),
|
logDir: resolveDefaultLogsDir(instanceId),
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
deploymentMode: "local_trusted",
|
||||||
|
exposure: "private",
|
||||||
|
host: "127.0.0.1",
|
||||||
port: 3100,
|
port: 3100,
|
||||||
serveUi: true,
|
serveUi: true,
|
||||||
},
|
},
|
||||||
|
auth: {
|
||||||
|
baseUrlMode: "auto",
|
||||||
|
},
|
||||||
storage: defaultStorageConfig(),
|
storage: defaultStorageConfig(),
|
||||||
secrets: defaultSecretsConfig(),
|
secrets: defaultSecretsConfig(),
|
||||||
};
|
};
|
||||||
@@ -124,7 +130,11 @@ export async function configure(opts: {
|
|||||||
config.logging = await promptLogging();
|
config.logging = await promptLogging();
|
||||||
break;
|
break;
|
||||||
case "server":
|
case "server":
|
||||||
config.server = await promptServer();
|
{
|
||||||
|
const { server, auth } = await promptServer();
|
||||||
|
config.server = server;
|
||||||
|
config.auth = auth;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "storage":
|
case "storage":
|
||||||
config.storage = await promptStorage(config.storage);
|
config.storage = await promptStorage(config.storage);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
agentJwtSecretCheck,
|
agentJwtSecretCheck,
|
||||||
configCheck,
|
configCheck,
|
||||||
databaseCheck,
|
databaseCheck,
|
||||||
|
deploymentAuthCheck,
|
||||||
llmCheck,
|
llmCheck,
|
||||||
logCheck,
|
logCheck,
|
||||||
portCheck,
|
portCheck,
|
||||||
@@ -55,42 +56,47 @@ export async function doctor(opts: {
|
|||||||
return printSummary(results);
|
return printSummary(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Agent JWT check
|
// 2. Deployment/auth mode check
|
||||||
|
const deploymentAuthResult = deploymentAuthCheck(config);
|
||||||
|
results.push(deploymentAuthResult);
|
||||||
|
printResult(deploymentAuthResult);
|
||||||
|
|
||||||
|
// 3. Agent JWT check
|
||||||
const jwtResult = agentJwtSecretCheck();
|
const jwtResult = agentJwtSecretCheck();
|
||||||
results.push(jwtResult);
|
results.push(jwtResult);
|
||||||
printResult(jwtResult);
|
printResult(jwtResult);
|
||||||
await maybeRepair(jwtResult, opts);
|
await maybeRepair(jwtResult, opts);
|
||||||
|
|
||||||
// 3. Secrets adapter check
|
// 4. Secrets adapter check
|
||||||
const secretsResult = secretsCheck(config, configPath);
|
const secretsResult = secretsCheck(config, configPath);
|
||||||
results.push(secretsResult);
|
results.push(secretsResult);
|
||||||
printResult(secretsResult);
|
printResult(secretsResult);
|
||||||
await maybeRepair(secretsResult, opts);
|
await maybeRepair(secretsResult, opts);
|
||||||
|
|
||||||
// 4. Storage check
|
// 5. Storage check
|
||||||
const storageResult = storageCheck(config, configPath);
|
const storageResult = storageCheck(config, configPath);
|
||||||
results.push(storageResult);
|
results.push(storageResult);
|
||||||
printResult(storageResult);
|
printResult(storageResult);
|
||||||
await maybeRepair(storageResult, opts);
|
await maybeRepair(storageResult, opts);
|
||||||
|
|
||||||
// 5. Database check
|
// 6. Database check
|
||||||
const dbResult = await databaseCheck(config, configPath);
|
const dbResult = await databaseCheck(config, configPath);
|
||||||
results.push(dbResult);
|
results.push(dbResult);
|
||||||
printResult(dbResult);
|
printResult(dbResult);
|
||||||
await maybeRepair(dbResult, opts);
|
await maybeRepair(dbResult, opts);
|
||||||
|
|
||||||
// 6. LLM check
|
// 7. LLM check
|
||||||
const llmResult = await llmCheck(config);
|
const llmResult = await llmCheck(config);
|
||||||
results.push(llmResult);
|
results.push(llmResult);
|
||||||
printResult(llmResult);
|
printResult(llmResult);
|
||||||
|
|
||||||
// 7. Log directory check
|
// 8. Log directory check
|
||||||
const logResult = logCheck(config, configPath);
|
const logResult = logCheck(config, configPath);
|
||||||
results.push(logResult);
|
results.push(logResult);
|
||||||
printResult(logResult);
|
printResult(logResult);
|
||||||
await maybeRepair(logResult, opts);
|
await maybeRepair(logResult, opts);
|
||||||
|
|
||||||
// 8. Port check
|
// 9. Port check
|
||||||
const portResult = await portCheck(config);
|
const portResult = await portCheck(config);
|
||||||
results.push(portResult);
|
results.push(portResult);
|
||||||
printResult(portResult);
|
printResult(portResult);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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 { describeLocalInstancePaths, resolvePaperclipInstanceId } from "../config/home.js";
|
import { describeLocalInstancePaths, resolvePaperclipInstanceId } from "../config/home.js";
|
||||||
|
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
|
||||||
|
|
||||||
export async function onboard(opts: { config?: string }): Promise<void> {
|
export async function onboard(opts: { config?: string }): Promise<void> {
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclip onboard ")));
|
p.intro(pc.bgCyan(pc.black(" paperclip onboard ")));
|
||||||
@@ -106,7 +107,7 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
|||||||
|
|
||||||
// Server
|
// Server
|
||||||
p.log.step(pc.bold("Server"));
|
p.log.step(pc.bold("Server"));
|
||||||
const server = await promptServer();
|
const { server, auth } = await promptServer();
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
p.log.step(pc.bold("Storage"));
|
p.log.step(pc.bold("Storage"));
|
||||||
@@ -142,6 +143,7 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
|||||||
database,
|
database,
|
||||||
logging,
|
logging,
|
||||||
server,
|
server,
|
||||||
|
auth,
|
||||||
storage,
|
storage,
|
||||||
secrets,
|
secrets,
|
||||||
};
|
};
|
||||||
@@ -160,7 +162,8 @@ export async function onboard(opts: { config?: string }): 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: port ${server.port}`,
|
`Server: ${server.deploymentMode}/${server.exposure} @ ${server.host}:${server.port}`,
|
||||||
|
`Auth URL mode: ${auth.baseUrlMode}${auth.publicBaseUrl ? ` (${auth.publicBaseUrl})` : ""}`,
|
||||||
`Storage: ${storage.provider}`,
|
`Storage: ${storage.provider}`,
|
||||||
`Secrets: ${secrets.provider} (strict mode ${secrets.strictMode ? "on" : "off"})`,
|
`Secrets: ${secrets.provider} (strict mode ${secrets.strictMode ? "on" : "off"})`,
|
||||||
`Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured`,
|
`Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured`,
|
||||||
@@ -172,5 +175,9 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
|||||||
p.log.message(
|
p.log.message(
|
||||||
`Before starting Paperclip, export ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from ${pc.dim(envFilePath)} (for example: ${pc.dim(`set -a; source ${envFilePath}; set +a`)})`,
|
`Before starting Paperclip, export ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from ${pc.dim(envFilePath)} (for example: ${pc.dim(`set -a; source ${envFilePath}; set +a`)})`,
|
||||||
);
|
);
|
||||||
|
if (server.deploymentMode === "authenticated") {
|
||||||
|
p.log.step("Generating bootstrap CEO invite");
|
||||||
|
await bootstrapCeoInvite({ config: opts.config });
|
||||||
|
}
|
||||||
p.outro("You're all set!");
|
p.outro("You're all set!");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export {
|
|||||||
databaseConfigSchema,
|
databaseConfigSchema,
|
||||||
loggingConfigSchema,
|
loggingConfigSchema,
|
||||||
serverConfigSchema,
|
serverConfigSchema,
|
||||||
|
authConfigSchema,
|
||||||
storageConfigSchema,
|
storageConfigSchema,
|
||||||
storageLocalDiskConfigSchema,
|
storageLocalDiskConfigSchema,
|
||||||
storageS3ConfigSchema,
|
storageS3ConfigSchema,
|
||||||
@@ -15,6 +16,7 @@ export {
|
|||||||
type DatabaseConfig,
|
type DatabaseConfig,
|
||||||
type LoggingConfig,
|
type LoggingConfig,
|
||||||
type ServerConfig,
|
type ServerConfig,
|
||||||
|
type AuthConfig,
|
||||||
type StorageConfig,
|
type StorageConfig,
|
||||||
type StorageLocalDiskConfig,
|
type StorageLocalDiskConfig,
|
||||||
type StorageS3Config,
|
type StorageS3Config,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { envCommand } from "./commands/env.js";
|
|||||||
import { configure } from "./commands/configure.js";
|
import { configure } from "./commands/configure.js";
|
||||||
import { heartbeatRun } from "./commands/heartbeat-run.js";
|
import { heartbeatRun } from "./commands/heartbeat-run.js";
|
||||||
import { runCommand } from "./commands/run.js";
|
import { runCommand } from "./commands/run.js";
|
||||||
|
import { bootstrapCeoInvite } from "./commands/auth-bootstrap-ceo.js";
|
||||||
import { registerContextCommands } from "./commands/client/context.js";
|
import { registerContextCommands } from "./commands/client/context.js";
|
||||||
import { registerCompanyCommands } from "./commands/client/company.js";
|
import { registerCompanyCommands } from "./commands/client/company.js";
|
||||||
import { registerIssueCommands } from "./commands/client/issue.js";
|
import { registerIssueCommands } from "./commands/client/issue.js";
|
||||||
@@ -90,6 +91,17 @@ registerApprovalCommands(program);
|
|||||||
registerActivityCommands(program);
|
registerActivityCommands(program);
|
||||||
registerDashboardCommands(program);
|
registerDashboardCommands(program);
|
||||||
|
|
||||||
|
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
||||||
|
|
||||||
|
auth
|
||||||
|
.command("bootstrap-ceo")
|
||||||
|
.description("Create a one-time bootstrap invite URL for first instance admin")
|
||||||
|
.option("-c, --config <path>", "Path to config file")
|
||||||
|
.option("--force", "Create new invite even if admin already exists", false)
|
||||||
|
.option("--expires-hours <hours>", "Invite expiration window in hours", (value) => Number(value))
|
||||||
|
.option("--base-url <url>", "Public base URL used to print invite link")
|
||||||
|
.action(bootstrapCeoInvite);
|
||||||
|
|
||||||
program.parseAsync().catch((err) => {
|
program.parseAsync().catch((err) => {
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,7 +1,69 @@
|
|||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
import type { ServerConfig } from "../config/schema.js";
|
import type { AuthConfig, ServerConfig } from "../config/schema.js";
|
||||||
|
|
||||||
|
export async function promptServer(): Promise<{ server: ServerConfig; auth: AuthConfig }> {
|
||||||
|
const deploymentModeSelection = await p.select({
|
||||||
|
message: "Deployment mode",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "local_trusted",
|
||||||
|
label: "Local trusted",
|
||||||
|
hint: "Easiest for local setup (no login, localhost-only)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "authenticated",
|
||||||
|
label: "Authenticated",
|
||||||
|
hint: "Login required; use for private network or public hosting",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initialValue: "local_trusted",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(deploymentModeSelection)) {
|
||||||
|
p.cancel("Setup cancelled.");
|
||||||
|
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: "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: hostDefault,
|
||||||
|
placeholder: hostDefault,
|
||||||
|
validate: (val) => {
|
||||||
|
if (!val.trim()) return "Host is required";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(hostStr)) {
|
||||||
|
p.cancel("Setup cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
export async function promptServer(): Promise<ServerConfig> {
|
|
||||||
const portStr = await p.text({
|
const portStr = await p.text({
|
||||||
message: "Server port",
|
message: "Server port",
|
||||||
defaultValue: "3100",
|
defaultValue: "3100",
|
||||||
@@ -20,5 +82,37 @@ export async function promptServer(): Promise<ServerConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const port = Number(portStr) || 3100;
|
const port = Number(portStr) || 3100;
|
||||||
return { port, serveUi: true };
|
let auth: AuthConfig = { baseUrlMode: "auto" };
|
||||||
|
if (deploymentMode === "authenticated" && exposure === "public") {
|
||||||
|
const urlInput = await p.text({
|
||||||
|
message: "Public base URL",
|
||||||
|
placeholder: "https://paperclip.example.com",
|
||||||
|
validate: (val) => {
|
||||||
|
const candidate = val.trim();
|
||||||
|
if (!candidate) return "Public base URL is required for public exposure";
|
||||||
|
try {
|
||||||
|
const url = new URL(candidate);
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||||
|
return "URL must start with http:// or https://";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
return "Enter a valid URL";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (p.isCancel(urlInput)) {
|
||||||
|
p.cancel("Setup cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
auth = {
|
||||||
|
baseUrlMode: "explicit",
|
||||||
|
publicBaseUrl: urlInput.trim().replace(/\/+$/, ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: { deploymentMode, exposure, host: hostStr.trim(), port, serveUi: true },
|
||||||
|
auth,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user