refactor(wizard): split onboarding

This commit is contained in:
Peter Steinberger
2026-01-14 05:40:10 +00:00
parent 35cea9be25
commit b11eea07b0
4 changed files with 723 additions and 573 deletions

View File

@@ -0,0 +1,272 @@
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" },
{ value: "auto", label: "Auto" },
{ value: "custom", label: "Custom IP" },
],
})) as "loopback" | "lan" | "auto" | "custom")
) as "loopback" | "lan" | "auto" | "custom";
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: "off",
label: "Off (loopback only)",
hint: "Not recommended unless you fully trust local processes",
},
{
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.
// - Auth off only allowed for bind=loopback.
// - 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 (authMode === "off" && bind !== "loopback") {
await prompter.note(
"Non-loopback bind requires auth. Switching to token auth.",
"Note",
);
authMode = "token";
}
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 ?? randomToken(),
});
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,
},
};
}