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:
2026-01-06 21:49:13 +01:00
parent 5d01c9f564
commit 140d9f7f25
6 changed files with 721 additions and 2 deletions
+118
View File
@@ -0,0 +1,118 @@
package wizard
import (
"fmt"
"os"
"path/filepath"
)
// Profile represents a configuration profile
type Profile struct {
Name string
Description string
ConfigDir string
DataDir string
IsServer bool
}
// GetProfiles returns available setup profiles
func GetProfiles() []Profile {
home, _ := os.UserHomeDir()
return []Profile{
{
Name: "Personal",
Description: "Single user, default locations",
ConfigDir: filepath.Join(home, ".config", "opal"),
DataDir: filepath.Join(home, ".local", "share", "opal"),
IsServer: false,
},
{
Name: "Server",
Description: "Production deployment, system paths",
ConfigDir: "/etc/opal",
DataDir: "/var/lib/opal",
IsServer: true,
},
{
Name: "Custom",
Description: "Configure all options manually",
ConfigDir: "",
DataDir: "",
IsServer: false,
},
}
}
// GetProfileChoices returns profile names for display
func GetProfileChoices() []string {
profiles := GetProfiles()
choices := make([]string, len(profiles))
for i, p := range profiles {
choices[i] = fmt.Sprintf("%s - %s", p.Name, p.Description)
}
return choices
}
// SystemdServiceTemplate returns a systemd service file template
func SystemdServiceTemplate(configDir, dataDir string) string {
return fmt.Sprintf(`[Unit]
Description=Opal Task API Server
After=network.target
[Service]
Type=simple
User=opal
Group=opal
WorkingDirectory=%s
EnvironmentFile=%s/opal.env
ExecStart=/usr/local/bin/opal server start --addr :8080
Restart=always
RestartSec=5
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=%s
ReadOnlyPaths=%s
[Install]
WantedBy=multi-user.target
`, dataDir, configDir, dataDir, configDir)
}
// EnvFileTemplate returns an environment file template
func EnvFileTemplate(oauthIssuer, oauthClientID, oauthClientSecret, oauthRedirectURI, jwtSecret string) string {
return fmt.Sprintf(`# Opal Server Configuration
# Generated by opal setup wizard
# Server
SERVER_ADDR=:8080
# Directory Configuration (optional - defaults to /etc/opal and /var/lib/opal)
# OPAL_CONFIG_DIR=/etc/opal
# OPAL_DATA_DIR=/var/lib/opal
# OAuth Configuration
OAUTH_ENABLED=true
OAUTH_ISSUER=%s
OAUTH_CLIENT_ID=%s
OAUTH_CLIENT_SECRET=%s
OAUTH_REDIRECT_URI=%s
# JWT Configuration
JWT_SECRET=%s
JWT_EXPIRY=3600
# Refresh Token Configuration
REFRESH_TOKEN_EXPIRY=604800
`,
oauthIssuer,
oauthClientID,
oauthClientSecret,
oauthRedirectURI,
jwtSecret,
)
}
+144
View File
@@ -0,0 +1,144 @@
package wizard
import (
"bufio"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"strconv"
"strings"
)
// PromptString prompts for a string value with a default
func PromptString(prompt string, defaultValue string) string {
if defaultValue != "" {
fmt.Printf("%s [%s]: ", prompt, defaultValue)
} else {
fmt.Printf("%s: ", prompt)
}
reader := bufio.NewReader(os.Stdin)
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
return defaultValue
}
return input
}
// PromptPassword prompts for a password (hidden input would be nice, but we'll use simple input for now)
func PromptPassword(prompt string) string {
fmt.Printf("%s: ", prompt)
reader := bufio.NewReader(os.Stdin)
input, _ := reader.ReadString('\n')
return strings.TrimSpace(input)
}
// PromptBool prompts for a yes/no answer
func PromptBool(prompt string, defaultValue bool) bool {
defaultStr := "y/N"
if defaultValue {
defaultStr = "Y/n"
}
fmt.Printf("%s [%s]: ", prompt, defaultStr)
reader := bufio.NewReader(os.Stdin)
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(strings.ToLower(input))
if input == "" {
return defaultValue
}
return input == "y" || input == "yes"
}
// PromptChoice prompts for a choice from a list
func PromptChoice(prompt string, choices []string, defaultIndex int) int {
fmt.Println(prompt)
for i, choice := range choices {
marker := " "
if i == defaultIndex {
marker = "●"
} else {
marker = "○"
}
fmt.Printf(" %s %d. %s\n", marker, i+1, choice)
}
fmt.Println()
fmt.Printf("Choice [%d]: ", defaultIndex+1)
reader := bufio.NewReader(os.Stdin)
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
return defaultIndex
}
choice, err := strconv.Atoi(input)
if err != nil || choice < 1 || choice > len(choices) {
fmt.Println("Invalid choice, using default")
return defaultIndex
}
return choice - 1
}
// GenerateRandomSecret generates a random hex string for JWT secrets
func GenerateRandomSecret(bytes int) (string, error) {
b := make([]byte, bytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// PromptOrGenerate prompts for a value or generates one
func PromptOrGenerate(prompt string, generator func() (string, error)) string {
fmt.Printf("%s (press Enter to generate): ", prompt)
reader := bufio.NewReader(os.Stdin)
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
generated, err := generator()
if err != nil {
fmt.Printf("Error generating: %v\n", err)
return ""
}
fmt.Printf("Generated: %s\n", generated)
return generated
}
return input
}
// PrintHeader prints a formatted section header
func PrintHeader(title string) {
fmt.Println()
fmt.Println(title)
fmt.Println(strings.Repeat("-", len(title)))
}
// PrintSuccess prints a success message
func PrintSuccess(message string) {
fmt.Printf("✓ %s\n", message)
}
// PrintError prints an error message
func PrintError(message string) {
fmt.Fprintf(os.Stderr, "✗ %s\n", message)
}
// PrintInfo prints an informational message
func PrintInfo(message string) {
fmt.Printf(" %s\n", message)
}
// Confirm asks for confirmation before proceeding
func Confirm(prompt string) bool {
return PromptBool(prompt, false)
}