c5a963bfd9
LoadConfig() tried to create directories and write opal.yml as a side effect of loading config. On the server (where /etc/opal is in systemd ReadOnlyPaths), this failed, returning nil. All internal GetConfig() callers discarded the error, passing nil to BuildUrgencyCoefficients() which panicked on nil dereference. Redesign the config system with layered, read-only loading: - Defaults (always present) → YAML file (if exists) → OPAL_ env vars - LoadConfig never writes to the filesystem or returns nil - File creation moved to explicit InitConfig() for CLI first-run/setup - SaveConfig uses yaml.Marshal instead of manual field-by-field Viper calls, eliminating the three-place maintenance burden Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
342 lines
10 KiB
Go
342 lines
10 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 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 default config
|
|
if err := engine.SaveConfig(engine.DefaultConfig()); 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 from defaults, then apply user choices
|
|
cfg := engine.DefaultConfig()
|
|
cfg.DefaultFilter = defaultFilter
|
|
cfg.DefaultReport = reportNames[defaultReport]
|
|
cfg.ColorOutput = colorOutput
|
|
cfg.WeekStartDay = weekStartDay
|
|
cfg.SyncEnabled = syncEnabled
|
|
cfg.SyncURL = syncURL
|
|
cfg.SyncAPIKey = syncAPIKey
|
|
|
|
// 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
|
|
}
|