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:
+37
-1
@@ -1,5 +1,20 @@
|
||||
# Opal task manager
|
||||
This is the counterpart to jade, where we track tasks.
|
||||
|
||||
## Quick Start
|
||||
|
||||
**First-time setup:**
|
||||
```bash
|
||||
opal setup # Interactive setup wizard
|
||||
opal setup --quick # Quick setup with defaults
|
||||
```
|
||||
|
||||
**Basic usage:**
|
||||
```bash
|
||||
opal add "Buy groceries" +personal due:tomorrow
|
||||
opal list
|
||||
opal done 1
|
||||
```
|
||||
## Syntax
|
||||
`opal [<filter>] [<command>] [<modifier>]`
|
||||
|
||||
@@ -31,7 +46,28 @@ A task can be recurring. Then we have a template task and instances of that task
|
||||
`opal add Change sheets due:sun recur:1w` - A task to be done every sunday.
|
||||
A recurring task is given a status of recurring which hides it from view. The recurring task you create is called the template task, from which recurring tasks instances are created. So the template remains hidden, and the recurring instances that spawn from it are the tasks that you will see and complete.
|
||||
|
||||
## Storage
|
||||
## Setup & Configuration
|
||||
|
||||
### Interactive Setup Wizard
|
||||
|
||||
Run the setup wizard on first use or to reconfigure:
|
||||
|
||||
```bash
|
||||
opal setup # 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
|
||||
```
|
||||
|
||||
The wizard guides you through:
|
||||
- Choosing a deployment profile (personal/server)
|
||||
- Configuring storage directories
|
||||
- Setting task management preferences
|
||||
- Optional server configuration (OAuth/JWT)
|
||||
- Optional sync setup
|
||||
|
||||
### Storage
|
||||
|
||||
**Configuration:** `~/.config/opal/opal.yml` (or `$XDG_CONFIG_HOME/opal/opal.yml`)
|
||||
**Database:** `~/.local/share/opal/opal.db` (or `$XDG_DATA_HOME/opal/opal.db`)
|
||||
|
||||
+23
-1
@@ -31,7 +31,7 @@ var (
|
||||
var commandNames = []string{
|
||||
"add", "done", "modify", "delete",
|
||||
"start", "stop", "count", "projects", "tags",
|
||||
"info", "edit", "server", "sync", "reports",
|
||||
"info", "edit", "server", "sync", "reports", "setup",
|
||||
}
|
||||
|
||||
// Report names (dynamically populated)
|
||||
@@ -224,6 +224,11 @@ func init() {
|
||||
}
|
||||
|
||||
func initializeApp() {
|
||||
// Skip initialization for commands that handle their own setup
|
||||
if len(os.Args) > 1 && os.Args[1] == "setup" {
|
||||
return
|
||||
}
|
||||
|
||||
// Set directory overrides from flags if provided
|
||||
if configDirFlag != "" {
|
||||
engine.SetConfigDirOverride(configDirFlag)
|
||||
@@ -232,6 +237,9 @@ func initializeApp() {
|
||||
engine.SetDataDirOverride(dataDirFlag)
|
||||
}
|
||||
|
||||
// Check for first run (before initializing DB/config)
|
||||
isFirstRun := engine.IsFirstRun()
|
||||
|
||||
// Initialize database
|
||||
if err := engine.InitDB(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error initializing database: %v\n", err)
|
||||
@@ -243,4 +251,18 @@ func initializeApp() {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Show first-run message after config is created
|
||||
if isFirstRun {
|
||||
showFirstRunMessage()
|
||||
}
|
||||
}
|
||||
|
||||
func showFirstRunMessage() {
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "👋 Welcome to Opal Task Manager!")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Default configuration created. To customize your setup,")
|
||||
fmt.Fprintln(os.Stderr, "run: opal setup")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
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
|
||||
|
||||
if profile.Name == "Custom" || wizard.Confirm("Customize directory paths?") {
|
||||
configDir = wizard.PromptString("Config directory", configDir)
|
||||
dataDir = wizard.PromptString("Data directory", dataDir)
|
||||
} else {
|
||||
fmt.Printf("Config: %s\n", configDir)
|
||||
fmt.Printf("Data: %s\n", 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
|
||||
}
|
||||
@@ -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