Files
gems/opal-task/cmd/setup.go
T
joakim 6a4fdb6850 fix: show default directories before asking to customize in setup wizard
Display the default config and data directories before prompting
the user if they want to customize them. This gives better context
for the decision.

Before: Only showed directories if user chose not to customize
After: Shows directories first, then asks if user wants to change them
2026-01-06 21:54:21 +01:00

389 lines
12 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) {
// 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
// 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
}