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:
@@ -219,6 +219,21 @@ func GetSyncConflictLogPath() (string, error) {
|
||||
return filepath.Join(dataDir, "sync_conflicts.log"), nil
|
||||
}
|
||||
|
||||
// ConfigExists checks if a configuration file exists
|
||||
func ConfigExists() bool {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = os.Stat(configPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsFirstRun checks if this is the first time opal is being run
|
||||
func IsFirstRun() bool {
|
||||
return !ConfigExists()
|
||||
}
|
||||
|
||||
// LoadConfig loads the configuration from file or creates default
|
||||
func LoadConfig() (*Config, error) {
|
||||
if globalConfig != nil {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user