Files
gems/opal-task/cmd/setup.go
T
joakim 7c97440366 fix: use PersistentPreRun instead of OnInitialize for proper command isolation
Replace cobra.OnInitialize with PersistentPreRun pattern to fix initialization
issues with setup and server commands. This follows Cobra best practices and
allows subcommands to properly override initialization behavior.

Problem:
- OnInitialize runs globally before command parsing
- os.Args check for 'setup' was fragile and broke with flags
- Setup wizard failed on server: 'unable to open database file: no such file
  or directory'

Solution:
- Use rootCmd.PersistentPreRun for initialization (inherited by all commands)
- setup and server commands override with their own PersistentPreRun
- Directory overrides still applied correctly in all cases
- Removes fragile os.Args parsing

Benefits:
- Works regardless of flag order
- Follows Cobra's intended design patterns
- Only 3 files modified (root.go, setup.go, server.go)
- Commands that need custom init (setup/server) simply override
- All other commands get automatic initialization
- Cleaner, more maintainable code

Testing:
- ✓ opal setup works without initialization errors
- ✓ opal list initializes database correctly
- ✓ First-run detection still works
- ✓ Directory overrides work with flags in any position
- ✓ Server command handles its own initialization
2026-01-06 22:18:09 +01:00

388 lines
11 KiB
Go

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) {
// Override root's PersistentPreRun to skip initialization
// But still apply directory overrides from flags
if configDirFlag != "" {
engine.SetConfigDirOverride(configDirFlag)
}
if dataDirFlag != "" {
engine.SetDataDirOverride(dataDirFlag)
}
},
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) {
// Directory overrides are already applied in PersistentPreRun
// 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
// Show default directories first
if profile.Name != "Custom" {
fmt.Printf("Config: %s\n", configDir)
fmt.Printf("Data: %s\n", dataDir)
fmt.Println()
}
if profile.Name == "Custom" || wizard.Confirm("Customize directory paths?") {
configDir = wizard.PromptString("Config directory", configDir)
dataDir = wizard.PromptString("Data directory", 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
}