feat(onboard): add timezone prompt to onboarding wizard

Adds a userTimezone prompt to the onboarding wizard that allows users
to set their preferred timezone for cron schedules during initial setup.

Features:
- Interactive mode: prompts for timezone with validation and auto-detection
- QuickStart mode: uses existing config or system timezone silently
- Non-interactive mode: supports --user-timezone CLI flag
- Both 'clawdbot onboard' and 'clawdbot setup --wizard' commands

The timezone is stored in agents.defaults.userTimezone and will be used
as the default for cron job schedules (requires PR #3329 for cron support).

Helper functions added:
- isValidTimezone(): validates IANA timezone strings
- detectSystemTimezone(): detects the system's local timezone
This commit is contained in:
Rayo 2026-01-28 13:21:18 +00:00
parent da421b9ef7
commit 6d0cb1c935
6 changed files with 112 additions and 0 deletions

View File

@ -41,6 +41,10 @@ export function registerOnboardCommand(program: Command) {
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/onboard", "docs.molt.bot/cli/onboard")}\n`,
)
.option("--workspace <dir>", "Agent workspace directory (default: ~/clawd)")
.option(
"--user-timezone <tz>",
"User timezone for cron schedules (e.g., America/New_York, Africa/Lagos)",
)
.option("--reset", "Reset config + credentials + sessions + workspace before running wizard")
.option("--non-interactive", "Run without prompts", false)
.option(
@ -105,6 +109,7 @@ export function registerOnboardCommand(program: Command) {
await onboardCommand(
{
workspace: opts.workspace as string | undefined,
userTimezone: opts.userTimezone as string | undefined,
nonInteractive: Boolean(opts.nonInteractive),
acceptRisk: Boolean(opts.acceptRisk),
flow: opts.flow as "quickstart" | "advanced" | "manual" | undefined,

View File

@ -20,6 +20,10 @@ export function registerSetupCommand(program: Command) {
"--workspace <dir>",
"Agent workspace directory (default: ~/clawd; stored as agents.defaults.workspace)",
)
.option(
"--user-timezone <tz>",
"User timezone for cron schedules (e.g., America/New_York, Africa/Lagos)",
)
.option("--wizard", "Run the interactive onboarding wizard", false)
.option("--non-interactive", "Run the wizard without prompts", false)
.option("--mode <mode>", "Wizard mode: local|remote")
@ -38,6 +42,7 @@ export function registerSetupCommand(program: Command) {
await onboardCommand(
{
workspace: opts.workspace as string | undefined,
userTimezone: opts.userTimezone as string | undefined,
nonInteractive: Boolean(opts.nonInteractive),
mode: opts.mode as "local" | "remote" | undefined,
remoteUrl: opts.remoteUrl as string | undefined,

View File

@ -424,3 +424,44 @@ function isValidIPv4(host: string): boolean {
return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n);
});
}
/**
* Validate an IANA timezone string.
* Uses Intl.supportedValuesOf('timeZone') when available (Node 18.6+),
* falls back to testing via DateTimeFormat.
*/
export function isValidTimezone(tz: string): boolean {
if (!tz || typeof tz !== "string") return false;
const trimmed = tz.trim();
if (!trimmed) return false;
// Try Intl.supportedValuesOf if available (Node 18.6+)
if (typeof Intl.supportedValuesOf === "function") {
try {
const supported = Intl.supportedValuesOf("timeZone");
return supported.includes(trimmed);
} catch {
// Fall through to DateTimeFormat check
}
}
// Fallback: try to create a DateTimeFormat with the timezone
try {
new Intl.DateTimeFormat("en-US", { timeZone: trimmed });
return true;
} catch {
return false;
}
}
/**
* Detect the system's local timezone.
* Returns the IANA timezone string (e.g., "America/New_York").
*/
export function detectSystemTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return "UTC";
}
}

View File

@ -8,7 +8,9 @@ import { healthCommand } from "../health.js";
import {
applyWizardMetadata,
DEFAULT_WORKSPACE,
detectSystemTimezone,
ensureWorkspaceAndSessions,
isValidTimezone,
resolveControlUiLinks,
waitForGatewayReachable,
} from "../onboard-helpers.js";
@ -35,6 +37,23 @@ export async function runNonInteractiveOnboardingLocal(params: {
defaultWorkspaceDir: DEFAULT_WORKSPACE,
});
// Resolve user timezone
const systemTimezone = detectSystemTimezone();
const existingTimezone = baseConfig.agents?.defaults?.userTimezone;
let userTimezone: string | undefined;
if (opts.userTimezone !== undefined) {
const trimmed = opts.userTimezone.trim();
if (trimmed && isValidTimezone(trimmed)) {
userTimezone = trimmed;
} else if (trimmed) {
runtime.log(`Invalid timezone "${trimmed}". Using system default: ${systemTimezone}`);
userTimezone = systemTimezone;
}
} else {
// Use existing or system timezone
userTimezone = existingTimezone ?? systemTimezone;
}
let nextConfig: MoltbotConfig = {
...baseConfig,
agents: {
@ -42,6 +61,7 @@ export async function runNonInteractiveOnboardingLocal(params: {
defaults: {
...baseConfig.agents?.defaults,
workspace: workspaceDir,
...(userTimezone ? { userTimezone } : {}),
},
},
gateway: {

View File

@ -46,6 +46,8 @@ export type OnboardOptions = {
/** "manual" is an alias for "advanced". */
flow?: "quickstart" | "advanced" | "manual";
workspace?: string;
/** User timezone for cron schedules (e.g., "America/New_York", "Europe/London"). */
userTimezone?: string;
nonInteractive?: boolean;
/** Required for non-interactive onboarding; skips the interactive risk prompt when true. */
acceptRisk?: boolean;

View File

@ -11,8 +11,10 @@ import { setupChannels } from "../commands/onboard-channels.js";
import {
applyWizardMetadata,
DEFAULT_WORKSPACE,
detectSystemTimezone,
ensureWorkspaceAndSessions,
handleReset,
isValidTimezone,
printWizardHeader,
probeGatewayReachable,
summarizeExistingConfig,
@ -335,6 +337,42 @@ export async function runOnboardingWizard(
const workspaceDir = resolveUserPath(workspaceInput.trim() || DEFAULT_WORKSPACE);
// Timezone prompt
const systemTimezone = detectSystemTimezone();
const existingTimezone = baseConfig.agents?.defaults?.userTimezone;
const timezoneDefault = existingTimezone ?? systemTimezone;
let userTimezone: string | undefined;
if (opts.userTimezone !== undefined) {
// CLI option provided
userTimezone = opts.userTimezone.trim() || undefined;
if (userTimezone && !isValidTimezone(userTimezone)) {
await prompter.note(
`Invalid timezone "${userTimezone}". Using system default: ${systemTimezone}`,
"Timezone",
);
userTimezone = systemTimezone;
}
} else if (flow === "quickstart") {
// QuickStart: use existing or system timezone silently
userTimezone = existingTimezone ?? systemTimezone;
} else {
// Manual flow: prompt for timezone
const timezoneInput = await prompter.text({
message: "Timezone (for cron schedules)",
initialValue: timezoneDefault,
placeholder: "e.g., America/New_York, Europe/London, Africa/Lagos",
validate: (value) => {
const trimmed = value.trim();
if (!trimmed) return; // Allow empty (will use system default)
if (!isValidTimezone(trimmed)) {
return `Invalid timezone. Examples: America/New_York, Europe/London, Africa/Lagos`;
}
},
});
userTimezone = timezoneInput.trim() || systemTimezone;
}
let nextConfig: MoltbotConfig = {
...baseConfig,
agents: {
@ -342,6 +380,7 @@ export async function runOnboardingWizard(
defaults: {
...baseConfig.agents?.defaults,
workspace: workspaceDir,
...(userTimezone ? { userTimezone } : {}),
},
},
gateway: {