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
+37 -1
View File
@@ -1,5 +1,20 @@
# Opal task manager # Opal task manager
This is the counterpart to jade, where we track tasks. 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 ## Syntax
`opal [<filter>] [<command>] [<modifier>]` `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. `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. 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`) **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`) **Database:** `~/.local/share/opal/opal.db` (or `$XDG_DATA_HOME/opal/opal.db`)
+23 -1
View File
@@ -31,7 +31,7 @@ var (
var commandNames = []string{ var commandNames = []string{
"add", "done", "modify", "delete", "add", "done", "modify", "delete",
"start", "stop", "count", "projects", "tags", "start", "stop", "count", "projects", "tags",
"info", "edit", "server", "sync", "reports", "info", "edit", "server", "sync", "reports", "setup",
} }
// Report names (dynamically populated) // Report names (dynamically populated)
@@ -224,6 +224,11 @@ func init() {
} }
func initializeApp() { 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 // Set directory overrides from flags if provided
if configDirFlag != "" { if configDirFlag != "" {
engine.SetConfigDirOverride(configDirFlag) engine.SetConfigDirOverride(configDirFlag)
@@ -232,6 +237,9 @@ func initializeApp() {
engine.SetDataDirOverride(dataDirFlag) engine.SetDataDirOverride(dataDirFlag)
} }
// Check for first run (before initializing DB/config)
isFirstRun := engine.IsFirstRun()
// Initialize database // Initialize database
if err := engine.InitDB(); err != nil { if err := engine.InitDB(); err != nil {
fmt.Fprintf(os.Stderr, "Error initializing database: %v\n", err) 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) fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1) 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, "")
} }
+384
View File
@@ -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
}
+15
View File
@@ -219,6 +219,21 @@ func GetSyncConflictLogPath() (string, error) {
return filepath.Join(dataDir, "sync_conflicts.log"), nil 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 // LoadConfig loads the configuration from file or creates default
func LoadConfig() (*Config, error) { func LoadConfig() (*Config, error) {
if globalConfig != nil { if globalConfig != nil {
+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)
}