Files
gems/opal-task/cmd/setup.go
T
joakim feb5406077 feat: add shell completions, command grouping, and dynamic completions
Add completion command for bash/zsh/fish/powershell generation. Organize
help text using Cobra command groups (Task Commands, Reports, Other).
Register dynamic ValidArgsFunction on filter-accepting commands to
suggest +tag and project:name completions from the database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:59:21 +01:00

343 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() {
setupCmd.GroupID = "other"
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
}