refactor(wizard): split onboarding
This commit is contained in:
272
src/wizard/onboarding.gateway-config.ts
Normal file
272
src/wizard/onboarding.gateway-config.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user