feat: add interactive setup wizard for first-run configuration

Implement a comprehensive setup wizard to improve onboarding and
configuration experience for both personal and server deployments.

Key features:
- Interactive wizard with profile selection (personal/server/custom)
- Quick setup mode with sensible defaults
- First-run detection with helpful welcome message
- Directory configuration with validation
- Server OAuth/JWT configuration with auto-generation
- Environment file creation for server deployments
- Template generators for systemd service and env files

New commands:
- opal setup              # Interactive wizard
- opal setup --quick      # Quick setup with defaults
- opal setup --profile    # Use specific profile
- opal setup --show-systemd  # Show systemd template
- opal setup --show-env   # Show environment file template

Implementation:
- internal/wizard/prompts.go: Reusable prompt utilities
- internal/wizard/profiles.go: Profile definitions and templates
- cmd/setup.go: Main setup command implementation
- cmd/root.go: First-run detection and welcome message
- internal/engine/config.go: ConfigExists() and IsFirstRun() helpers

User experience:
- On first run, shows welcome message suggesting 'opal setup'
- Non-intrusive - creates defaults automatically if skipped
- Wizard guides through all configuration options
- Server setup includes OAuth/JWT configuration
- Environment file created with proper permissions (0600)
- Clear next steps displayed after completion
This commit is contained in:
2026-01-06 21:49:13 +01:00
parent 5d01c9f564
commit 140d9f7f25
6 changed files with 721 additions and 2 deletions
+23 -1
View File
@@ -31,7 +31,7 @@ var (
var commandNames = []string{
"add", "done", "modify", "delete",
"start", "stop", "count", "projects", "tags",
"info", "edit", "server", "sync", "reports",
"info", "edit", "server", "sync", "reports", "setup",
}
// Report names (dynamically populated)
@@ -224,6 +224,11 @@ func init() {
}
func initializeApp() {
// Skip initialization for commands that handle their own setup
if len(os.Args) > 1 && os.Args[1] == "setup" {
return
}
// Set directory overrides from flags if provided
if configDirFlag != "" {
engine.SetConfigDirOverride(configDirFlag)
@@ -232,6 +237,9 @@ func initializeApp() {
engine.SetDataDirOverride(dataDirFlag)
}
// Check for first run (before initializing DB/config)
isFirstRun := engine.IsFirstRun()
// Initialize database
if err := engine.InitDB(); err != nil {
fmt.Fprintf(os.Stderr, "Error initializing database: %v\n", err)
@@ -243,4 +251,18 @@ func initializeApp() {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
// Show first-run message after config is created
if isFirstRun {
showFirstRunMessage()
}
}
func showFirstRunMessage() {
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "👋 Welcome to Opal Task Manager!")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Default configuration created. To customize your setup,")
fmt.Fprintln(os.Stderr, "run: opal setup")
fmt.Fprintln(os.Stderr, "")
}
+384
View File
@@ -0,0 +1,384 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"git.jnss.me/joakim/opal/internal/engine"
"git.jnss.me/joakim/opal/internal/wizard"
"github.com/spf13/cobra"
)
var (
showSystemdFlag bool
showEnvFlag bool
quickFlag bool
profileFlag string
)
var setupCmd = &cobra.Command{
Use: "setup",
Short: "Interactive setup wizard",
Long: `Interactive setup wizard to configure Opal for first-time use.
This wizard will guide you through:
- Choosing a deployment profile (personal/server)
- Configuring storage directories
- Setting basic preferences
- Optional server configuration (OAuth/JWT)
- Optional sync setup
Examples:
opal setup # Run interactive wizard
opal setup --quick # Quick setup with defaults
opal setup --profile=server # Use server profile
opal setup --show-systemd # Show systemd service template
opal setup --show-env # Show environment file template`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// Skip normal initialization - setup command handles its own init
},
Run: runSetup,
}
func init() {
rootCmd.AddCommand(setupCmd)
setupCmd.Flags().BoolVar(&showSystemdFlag, "show-systemd", false, "Show systemd service template")
setupCmd.Flags().BoolVar(&showEnvFlag, "show-env", false, "Show environment file template")
setupCmd.Flags().BoolVar(&quickFlag, "quick", false, "Quick setup with defaults")
setupCmd.Flags().StringVar(&profileFlag, "profile", "", "Profile to use (personal/server/custom)")
}
func runSetup(cmd *cobra.Command, args []string) {
// Apply directory overrides from parent persistent flags
if cmd.Parent() != nil {
if configDir, _ := cmd.Parent().PersistentFlags().GetString("config-dir"); configDir != "" {
engine.SetConfigDirOverride(configDir)
}
if dataDir, _ := cmd.Parent().PersistentFlags().GetString("data-dir"); dataDir != "" {
engine.SetDataDirOverride(dataDir)
}
}
// Handle template flags
if showSystemdFlag {
showSystemdTemplate()
return
}
if showEnvFlag {
showEnvTemplate()
return
}
// Run the wizard
if quickFlag {
runQuickSetup()
} else {
runInteractiveSetup()
}
}
func showSystemdTemplate() {
template := wizard.SystemdServiceTemplate("/etc/opal", "/var/lib/opal")
fmt.Println(template)
fmt.Println("\nTo install:")
fmt.Println(" sudo nano /etc/systemd/system/opal.service")
fmt.Println(" # Paste the above content")
fmt.Println(" sudo systemctl daemon-reload")
fmt.Println(" sudo systemctl enable opal")
fmt.Println(" sudo systemctl start opal")
}
func showEnvTemplate() {
template := wizard.EnvFileTemplate(
"https://auth.example.com/application/o/opal/",
"your_client_id",
"your_client_secret",
"https://opal.example.com/auth/callback",
"generate_with_openssl_rand_hex_32",
)
fmt.Println(template)
fmt.Println("\nTo use:")
fmt.Println(" sudo nano /etc/opal/opal.env")
fmt.Println(" # Paste and customize the above content")
}
func runQuickSetup() {
wizard.PrintHeader("Opal Quick Setup")
fmt.Println("Creating default configuration...")
fmt.Println()
// Get actual directories (respecting overrides)
configDir, err := engine.GetConfigDir()
if err != nil {
wizard.PrintError(fmt.Sprintf("Failed to get config directory: %v", err))
os.Exit(1)
}
dataDir, err := engine.GetDataDir()
if err != nil {
wizard.PrintError(fmt.Sprintf("Failed to get data directory: %v", err))
os.Exit(1)
}
// Create config
cfg := engine.Config{
DefaultFilter: "status:pending",
DefaultSort: "due,priority",
DefaultReport: "list",
ColorOutput: true,
WeekStartDay: "monday",
DefaultDueTime: "",
NextLimit: 5,
SyncEnabled: false,
SyncStrategy: "last-write-wins",
SyncQueueOffline: true,
UrgencyDue: 12.0,
UrgencyPriorityH: 6.0,
UrgencyPriorityM: 3.9,
UrgencyPriorityD: 1.8,
UrgencyPriorityL: 0.0,
UrgencyActive: 4.0,
UrgencyAge: 2.0,
UrgencyAgeMax: 365,
UrgencyTags: 1.0,
UrgencyProject: 1.0,
UrgencyWaiting: -3.0,
UrgencyUrgentTag: "next",
UrgencyUrgentCoeff: 15.0,
}
// Create directories
if err := os.MkdirAll(configDir, 0755); err != nil {
wizard.PrintError(fmt.Sprintf("Failed to create config directory: %v", err))
os.Exit(1)
}
if err := os.MkdirAll(dataDir, 0755); err != nil {
wizard.PrintError(fmt.Sprintf("Failed to create data directory: %v", err))
os.Exit(1)
}
// Save config
if err := engine.SaveConfig(&cfg); err != nil {
wizard.PrintError(fmt.Sprintf("Failed to save config: %v", err))
os.Exit(1)
}
wizard.PrintSuccess("Configuration created!")
fmt.Printf("\nConfig: %s\n", configDir)
fmt.Printf("Data: %s\n", dataDir)
fmt.Println("\nYou're all set! Try: opal add 'My first task'")
}
func runInteractiveSetup() {
// Welcome
fmt.Println()
fmt.Println("═══════════════════════════════════════")
fmt.Println(" Welcome to Opal Task Manager!")
fmt.Println("═══════════════════════════════════════")
fmt.Println()
// Step 1: Profile selection
wizard.PrintHeader("Step 1: Choose Setup Profile")
profiles := wizard.GetProfiles()
choices := wizard.GetProfileChoices()
defaultProfile := 0
if profileFlag == "server" {
defaultProfile = 1
} else if profileFlag == "custom" {
defaultProfile = 2
}
profileIndex := wizard.PromptChoice("Select your setup profile:", choices, defaultProfile)
profile := profiles[profileIndex]
fmt.Println()
wizard.PrintInfo(fmt.Sprintf("Selected: %s", profile.Name))
// Step 2: Directory configuration
wizard.PrintHeader("Step 2: Storage Directories")
configDir := profile.ConfigDir
dataDir := profile.DataDir
if profile.Name == "Custom" || wizard.Confirm("Customize directory paths?") {
configDir = wizard.PromptString("Config directory", configDir)
dataDir = wizard.PromptString("Data directory", dataDir)
} else {
fmt.Printf("Config: %s\n", configDir)
fmt.Printf("Data: %s\n", dataDir)
}
// Step 3: Server configuration (if server profile)
var envFilePath string
if profile.IsServer {
wizard.PrintHeader("Step 3: Server Configuration")
if wizard.PromptBool("Configure OAuth/JWT settings now?", true) {
oauthIssuer := wizard.PromptString("OAuth Issuer URL", "https://auth.example.com/application/o/opal/")
oauthClientID := wizard.PromptString("OAuth Client ID", "")
oauthClientSecret := wizard.PromptPassword("OAuth Client Secret")
oauthRedirectURI := wizard.PromptString("OAuth Redirect URI", "https://opal.example.com/auth/callback")
jwtSecret := wizard.PromptOrGenerate("JWT Secret", func() (string, error) {
return wizard.GenerateRandomSecret(32)
})
// Create env file
envContent := wizard.EnvFileTemplate(oauthIssuer, oauthClientID, oauthClientSecret, oauthRedirectURI, jwtSecret)
envFilePath = filepath.Join(configDir, "opal.env")
// Create config directory first
if err := os.MkdirAll(configDir, 0755); err != nil {
wizard.PrintError(fmt.Sprintf("Failed to create config directory: %v", err))
os.Exit(1)
}
if err := os.WriteFile(envFilePath, []byte(envContent), 0600); err != nil {
wizard.PrintError(fmt.Sprintf("Failed to write env file: %v", err))
os.Exit(1)
}
wizard.PrintSuccess(fmt.Sprintf("Environment file created: %s", envFilePath))
} else {
wizard.PrintInfo("Skipping OAuth/JWT configuration")
wizard.PrintInfo("You'll need to set these environment variables manually:")
fmt.Println(" - OAUTH_ISSUER, OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET")
fmt.Println(" - OAUTH_REDIRECT_URI, JWT_SECRET")
}
}
// Step 4: Basic preferences
wizard.PrintHeader(fmt.Sprintf("Step %d: Task Management Preferences", getStep(profile.IsServer, 4, 3)))
weekStart := wizard.PromptChoice("Week starts on:", []string{"Monday", "Sunday"}, 0)
weekStartDay := "monday"
if weekStart == 1 {
weekStartDay = "sunday"
}
colorOutput := wizard.PromptBool("Enable colored output?", true)
defaultReport := wizard.PromptChoice("Default report:", []string{"list", "next", "ready"}, 0)
reportNames := []string{"list", "next", "ready"}
defaultFilter := wizard.PromptString("Default filter", "status:pending")
// Step 5: Sync configuration
wizard.PrintHeader(fmt.Sprintf("Step %d: Sync Configuration (Optional)", getStep(profile.IsServer, 5, 4)))
syncEnabled := wizard.PromptBool("Enable sync with server?", false)
syncURL := ""
syncAPIKey := ""
if syncEnabled {
syncURL = wizard.PromptString("Sync server URL", "")
syncAPIKey = wizard.PromptString("Sync API key", "")
}
// Step 6: Summary and confirmation
wizard.PrintHeader("Configuration Summary")
fmt.Printf("Profile: %s\n", profile.Name)
fmt.Printf("Config: %s\n", configDir)
fmt.Printf("Data: %s\n", dataDir)
if envFilePath != "" {
fmt.Printf("Environment: %s\n", envFilePath)
}
fmt.Printf("Week starts: %s\n", weekStartDay)
fmt.Printf("Color output: %v\n", colorOutput)
fmt.Printf("Default: %s\n", reportNames[defaultReport])
fmt.Printf("Filter: %s\n", defaultFilter)
if syncEnabled {
fmt.Printf("Sync: Enabled (%s)\n", syncURL)
} else {
fmt.Printf("Sync: Disabled\n")
}
fmt.Println()
if !wizard.Confirm("Save configuration?") {
fmt.Println("Setup cancelled.")
return
}
// Create configuration
cfg := &engine.Config{
DefaultFilter: defaultFilter,
DefaultSort: "due,priority",
DefaultReport: reportNames[defaultReport],
ColorOutput: colorOutput,
WeekStartDay: weekStartDay,
DefaultDueTime: "",
NextLimit: 5,
SyncEnabled: syncEnabled,
SyncURL: syncURL,
SyncAPIKey: syncAPIKey,
SyncStrategy: "last-write-wins",
SyncQueueOffline: true,
UrgencyDue: 12.0,
UrgencyPriorityH: 6.0,
UrgencyPriorityM: 3.9,
UrgencyPriorityD: 1.8,
UrgencyPriorityL: 0.0,
UrgencyActive: 4.0,
UrgencyAge: 2.0,
UrgencyAgeMax: 365,
UrgencyTags: 1.0,
UrgencyProject: 1.0,
UrgencyWaiting: -3.0,
UrgencyUrgentTag: "next",
UrgencyUrgentCoeff: 15.0,
}
// Set directory overrides
engine.SetConfigDirOverride(configDir)
engine.SetDataDirOverride(dataDir)
// Create directories
if err := os.MkdirAll(configDir, 0755); err != nil {
wizard.PrintError(fmt.Sprintf("Failed to create config directory: %v", err))
os.Exit(1)
}
if err := os.MkdirAll(dataDir, 0755); err != nil {
wizard.PrintError(fmt.Sprintf("Failed to create data directory: %v", err))
os.Exit(1)
}
// Save config
if err := engine.SaveConfig(cfg); err != nil {
wizard.PrintError(fmt.Sprintf("Failed to save config: %v", err))
os.Exit(1)
}
// Success!
fmt.Println()
wizard.PrintSuccess("Configuration saved!")
fmt.Println()
// Next steps
fmt.Println("Next steps:")
if profile.IsServer {
if envFilePath != "" {
fmt.Printf(" • Review environment file: %s\n", envFilePath)
}
configPath, _ := engine.GetConfigPath()
fmt.Printf(" • Review configuration: %s\n", configPath)
fmt.Println(" • Generate API key: opal server keygen --name 'admin'")
fmt.Println(" • Start server: opal server start")
fmt.Println()
fmt.Println("For systemd service setup:")
fmt.Println(" opal setup --show-systemd")
} else {
fmt.Println(" • Try it out: opal add 'My first task'")
fmt.Println(" • List tasks: opal list")
fmt.Println(" • Get help: opal --help")
}
fmt.Println()
}
func getStep(isServer bool, serverStep, clientStep int) int {
if isServer {
return serverStep
}
return clientStep
}