250 lines
7.5 KiB
TypeScript
250 lines
7.5 KiB
TypeScript
import { randomToken } from "../commands/onboard-helpers.js";
|
|
import type { GatewayAuthChoice } from "../commands/onboard-types.js";
|
|
import type { ClawdbotConfig } from "../config/config.js";
|
|
import { findTailscaleBinary } from "../infra/tailscale.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import type {
|
|
GatewayWizardSettings,
|
|
QuickstartGatewayDefaults,
|
|
WizardFlow,
|
|
} from "./onboarding.types.js";
|
|
import type { WizardPrompter } from "./prompts.js";
|
|
|
|
type ConfigureGatewayOptions = {
|
|
flow: WizardFlow;
|
|
baseConfig: ClawdbotConfig;
|
|
nextConfig: ClawdbotConfig;
|
|
localPort: number;
|
|
quickstartGateway: QuickstartGatewayDefaults;
|
|
prompter: WizardPrompter;
|
|
runtime: RuntimeEnv;
|
|
};
|
|
|
|
type ConfigureGatewayResult = {
|
|
nextConfig: ClawdbotConfig;
|
|
settings: GatewayWizardSettings;
|
|
};
|
|
|
|
export async function configureGatewayForOnboarding(
|
|
opts: ConfigureGatewayOptions,
|
|
): Promise<ConfigureGatewayResult> {
|
|
const { flow, localPort, quickstartGateway, prompter } = opts;
|
|
let { nextConfig } = opts;
|
|
|
|
const port =
|
|
flow === "quickstart"
|
|
? quickstartGateway.port
|
|
: Number.parseInt(
|
|
String(
|
|
await prompter.text({
|
|
message: "Gateway port",
|
|
initialValue: String(localPort),
|
|
validate: (value) => (Number.isFinite(Number(value)) ? undefined : "Invalid port"),
|
|
}),
|
|
),
|
|
10,
|
|
);
|
|
|
|
let bind = (
|
|
flow === "quickstart"
|
|
? quickstartGateway.bind
|
|
: ((await prompter.select({
|
|
message: "Gateway bind",
|
|
options: [
|
|
{ value: "loopback", label: "Loopback (127.0.0.1)" },
|
|
{ value: "lan", label: "LAN (0.0.0.0)" },
|
|
{ value: "tailnet", label: "Tailnet (Tailscale IP)" },
|
|
{ value: "auto", label: "Auto (Loopback → LAN)" },
|
|
{ value: "custom", label: "Custom IP" },
|
|
],
|
|
})) as "loopback" | "lan" | "auto" | "custom" | "tailnet")
|
|
) as "loopback" | "lan" | "auto" | "custom" | "tailnet";
|
|
|
|
let customBindHost = quickstartGateway.customBindHost;
|
|
if (bind === "custom") {
|
|
const needsPrompt = flow !== "quickstart" || !customBindHost;
|
|
if (needsPrompt) {
|
|
const input = await prompter.text({
|
|
message: "Custom IP address",
|
|
placeholder: "192.168.1.100",
|
|
initialValue: customBindHost ?? "",
|
|
validate: (value) => {
|
|
if (!value) return "IP address is required for custom bind mode";
|
|
const trimmed = value.trim();
|
|
const parts = trimmed.split(".");
|
|
if (parts.length !== 4) return "Invalid IPv4 address (e.g., 192.168.1.100)";
|
|
if (
|
|
parts.every((part) => {
|
|
const n = parseInt(part, 10);
|
|
return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n);
|
|
})
|
|
)
|
|
return undefined;
|
|
return "Invalid IPv4 address (each octet must be 0-255)";
|
|
},
|
|
});
|
|
customBindHost = typeof input === "string" ? input.trim() : undefined;
|
|
}
|
|
}
|
|
|
|
let authMode = (
|
|
flow === "quickstart"
|
|
? quickstartGateway.authMode
|
|
: ((await prompter.select({
|
|
message: "Gateway auth",
|
|
options: [
|
|
{
|
|
value: "token",
|
|
label: "Token",
|
|
hint: "Recommended default (local + remote)",
|
|
},
|
|
{ value: "password", label: "Password" },
|
|
],
|
|
initialValue: "token",
|
|
})) as GatewayAuthChoice)
|
|
) as GatewayAuthChoice;
|
|
|
|
const tailscaleMode = (
|
|
flow === "quickstart"
|
|
? quickstartGateway.tailscaleMode
|
|
: ((await prompter.select({
|
|
message: "Tailscale exposure",
|
|
options: [
|
|
{ value: "off", label: "Off", hint: "No Tailscale exposure" },
|
|
{
|
|
value: "serve",
|
|
label: "Serve",
|
|
hint: "Private HTTPS for your tailnet (devices on Tailscale)",
|
|
},
|
|
{
|
|
value: "funnel",
|
|
label: "Funnel",
|
|
hint: "Public HTTPS via Tailscale Funnel (internet)",
|
|
},
|
|
],
|
|
})) as "off" | "serve" | "funnel")
|
|
) as "off" | "serve" | "funnel";
|
|
|
|
// Detect Tailscale binary before proceeding with serve/funnel setup.
|
|
if (tailscaleMode !== "off") {
|
|
const tailscaleBin = await findTailscaleBinary();
|
|
if (!tailscaleBin) {
|
|
await prompter.note(
|
|
[
|
|
"Tailscale binary not found in PATH or /Applications.",
|
|
"Ensure Tailscale is installed from:",
|
|
" https://tailscale.com/download/mac",
|
|
"",
|
|
"You can continue setup, but serve/funnel will fail at runtime.",
|
|
].join("\n"),
|
|
"Tailscale Warning",
|
|
);
|
|
}
|
|
}
|
|
|
|
let tailscaleResetOnExit = flow === "quickstart" ? quickstartGateway.tailscaleResetOnExit : false;
|
|
if (tailscaleMode !== "off" && flow !== "quickstart") {
|
|
await prompter.note(
|
|
["Docs:", "https://docs.clawd.bot/gateway/tailscale", "https://docs.clawd.bot/web"].join(
|
|
"\n",
|
|
),
|
|
"Tailscale",
|
|
);
|
|
tailscaleResetOnExit = Boolean(
|
|
await prompter.confirm({
|
|
message: "Reset Tailscale serve/funnel on exit?",
|
|
initialValue: false,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Safety + constraints:
|
|
// - Tailscale wants bind=loopback so we never expose a non-loopback server + tailscale serve/funnel at once.
|
|
// - Funnel requires password auth.
|
|
if (tailscaleMode !== "off" && bind !== "loopback") {
|
|
await prompter.note("Tailscale requires bind=loopback. Adjusting bind to loopback.", "Note");
|
|
bind = "loopback";
|
|
customBindHost = undefined;
|
|
}
|
|
|
|
if (tailscaleMode === "funnel" && authMode !== "password") {
|
|
await prompter.note("Tailscale funnel requires password auth.", "Note");
|
|
authMode = "password";
|
|
}
|
|
|
|
let gatewayToken: string | undefined;
|
|
if (authMode === "token") {
|
|
if (flow === "quickstart") {
|
|
gatewayToken = quickstartGateway.token ?? randomToken();
|
|
} else {
|
|
const tokenInput = await prompter.text({
|
|
message: "Gateway token (blank to generate)",
|
|
placeholder: "Needed for multi-machine or non-loopback access",
|
|
initialValue: quickstartGateway.token ?? "",
|
|
});
|
|
gatewayToken = String(tokenInput).trim() || randomToken();
|
|
}
|
|
}
|
|
|
|
if (authMode === "password") {
|
|
const password =
|
|
flow === "quickstart" && quickstartGateway.password
|
|
? quickstartGateway.password
|
|
: await prompter.text({
|
|
message: "Gateway password",
|
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
});
|
|
nextConfig = {
|
|
...nextConfig,
|
|
gateway: {
|
|
...nextConfig.gateway,
|
|
auth: {
|
|
...nextConfig.gateway?.auth,
|
|
mode: "password",
|
|
password: String(password).trim(),
|
|
},
|
|
},
|
|
};
|
|
} else if (authMode === "token") {
|
|
nextConfig = {
|
|
...nextConfig,
|
|
gateway: {
|
|
...nextConfig.gateway,
|
|
auth: {
|
|
...nextConfig.gateway?.auth,
|
|
mode: "token",
|
|
token: gatewayToken,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
nextConfig = {
|
|
...nextConfig,
|
|
gateway: {
|
|
...nextConfig.gateway,
|
|
port,
|
|
bind,
|
|
...(bind === "custom" && customBindHost ? { customBindHost } : {}),
|
|
tailscale: {
|
|
...nextConfig.gateway?.tailscale,
|
|
mode: tailscaleMode,
|
|
resetOnExit: tailscaleResetOnExit,
|
|
},
|
|
},
|
|
};
|
|
|
|
return {
|
|
nextConfig,
|
|
settings: {
|
|
port,
|
|
bind,
|
|
customBindHost: bind === "custom" ? customBindHost : undefined,
|
|
authMode,
|
|
gatewayToken,
|
|
tailscaleMode,
|
|
tailscaleResetOnExit,
|
|
},
|
|
};
|
|
}
|