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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user