c5a963bfd9
LoadConfig() tried to create directories and write opal.yml as a side effect of loading config. On the server (where /etc/opal is in systemd ReadOnlyPaths), this failed, returning nil. All internal GetConfig() callers discarded the error, passing nil to BuildUrgencyCoefficients() which panicked on nil dereference. Redesign the config system with layered, read-only loading: - Defaults (always present) → YAML file (if exists) → OPAL_ env vars - LoadConfig never writes to the filesystem or returns nil - File creation moved to explicit InitConfig() for CLI first-run/setup - SaveConfig uses yaml.Marshal instead of manual field-by-field Viper calls, eliminating the three-place maintenance burden Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
271 lines
6.8 KiB
Go
271 lines
6.8 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
|
|
"git.jnss.me/joakim/opal/internal/engine"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// ParsedArgs represents preprocessed command arguments
|
|
type ParsedArgs struct {
|
|
Command string
|
|
Filters []string
|
|
Modifiers []string
|
|
}
|
|
|
|
// Context key for parsed args
|
|
type contextKey string
|
|
|
|
const parsedArgsKey contextKey = "parsedArgs"
|
|
|
|
// Global flags
|
|
var (
|
|
configDirFlag string
|
|
dataDirFlag string
|
|
)
|
|
|
|
// Command classification
|
|
var commandNames = []string{
|
|
"add", "done", "modify", "delete",
|
|
"start", "stop", "count", "projects", "tags",
|
|
"info", "edit", "server", "sync", "reports", "setup",
|
|
}
|
|
|
|
// Report names (dynamically populated)
|
|
var reportNames = []string{
|
|
"active", "all", "completed", "list", "minimal",
|
|
"newest", "next", "oldest", "overdue", "ready", "recurring",
|
|
"template", "waiting",
|
|
}
|
|
|
|
var commandsWithModifiers = map[string]bool{
|
|
"add": true,
|
|
"modify": true,
|
|
}
|
|
|
|
var rootCmd = &cobra.Command{
|
|
Use: "opal",
|
|
Short: "Opal task manager - taskwarrior-inspired CLI task management",
|
|
Long: `Opal is a powerful command-line task manager inspired by taskwarrior.
|
|
It supports filtering, tags, priorities, projects, and recurring tasks.`,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
// Default behavior: run configured default report (defaults to "list")
|
|
parsed := getParsedArgs(cmd)
|
|
|
|
// Get default report from config
|
|
cfg, err := engine.GetConfig()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
defaultReport := cfg.DefaultReport
|
|
if defaultReport == "" {
|
|
defaultReport = "list"
|
|
}
|
|
|
|
if err := runReport(defaultReport, parsed.Filters); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
},
|
|
}
|
|
|
|
func Execute() error {
|
|
// Check for help flags/command BEFORE preprocessing
|
|
// This allows Cobra to handle help naturally for the root command
|
|
if len(os.Args) > 1 {
|
|
firstArg := os.Args[1]
|
|
if firstArg == "-h" || firstArg == "--help" || firstArg == "help" {
|
|
// Let Cobra handle help - skip preprocessing
|
|
return rootCmd.Execute()
|
|
}
|
|
}
|
|
|
|
// Preprocess arguments BEFORE Cobra routing
|
|
if len(os.Args) > 1 {
|
|
parsed := preprocessArgs(os.Args[1:])
|
|
|
|
// Store in context for commands to use
|
|
ctx := context.WithValue(context.Background(), parsedArgsKey, parsed)
|
|
rootCmd.SetContext(ctx)
|
|
|
|
// Rewrite os.Args for Cobra based on parsed command
|
|
// This allows Cobra to route to the correct command
|
|
if parsed.Command != "list" || len(parsed.Filters) > 0 || len(parsed.Modifiers) > 0 {
|
|
// Reconstruct args: [command, ...filters, ...modifiers]
|
|
newArgs := []string{os.Args[0], parsed.Command}
|
|
newArgs = append(newArgs, parsed.Filters...)
|
|
newArgs = append(newArgs, parsed.Modifiers...)
|
|
os.Args = newArgs
|
|
}
|
|
}
|
|
|
|
return rootCmd.Execute()
|
|
}
|
|
|
|
// getParsedArgs retrieves preprocessed args from context
|
|
func getParsedArgs(cmd *cobra.Command) *ParsedArgs {
|
|
if v := cmd.Context().Value(parsedArgsKey); v != nil {
|
|
if parsed, ok := v.(*ParsedArgs); ok {
|
|
return parsed
|
|
}
|
|
}
|
|
return &ParsedArgs{}
|
|
}
|
|
|
|
// preprocessArgs parses command-line arguments before Cobra routing
|
|
// Returns: command name, filters, modifiers
|
|
func preprocessArgs(args []string) *ParsedArgs {
|
|
if len(args) == 0 {
|
|
return &ParsedArgs{
|
|
Command: "list", // Default command
|
|
Filters: []string{},
|
|
Modifiers: []string{},
|
|
}
|
|
}
|
|
|
|
// Find command position (check both regular commands and reports)
|
|
cmdIdx := -1
|
|
cmdName := ""
|
|
|
|
for i, arg := range args {
|
|
// Check regular commands
|
|
for _, name := range commandNames {
|
|
if arg == name {
|
|
cmdIdx = i
|
|
cmdName = name
|
|
break
|
|
}
|
|
}
|
|
// Check report names
|
|
if cmdIdx < 0 {
|
|
for _, name := range reportNames {
|
|
if arg == name {
|
|
cmdIdx = i
|
|
cmdName = name
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if cmdIdx >= 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
// If no command found, treat as filters for default list command
|
|
if cmdIdx == -1 {
|
|
return &ParsedArgs{
|
|
Command: "list",
|
|
Filters: args,
|
|
Modifiers: []string{},
|
|
}
|
|
}
|
|
|
|
// Split arguments around command
|
|
leftArgs := args[:cmdIdx] // Everything before command
|
|
rightArgs := []string{}
|
|
if cmdIdx+1 < len(args) {
|
|
rightArgs = args[cmdIdx+1:] // Everything after command
|
|
}
|
|
|
|
// Determine how to interpret right args
|
|
if commandsWithModifiers[cmdName] {
|
|
// Command accepts modifiers
|
|
// Left = filters, Right = modifiers
|
|
return &ParsedArgs{
|
|
Command: cmdName,
|
|
Filters: leftArgs,
|
|
Modifiers: rightArgs,
|
|
}
|
|
} else {
|
|
// Command doesn't accept modifiers
|
|
// Both left and right are filters
|
|
allFilters := append(leftArgs, rightArgs...)
|
|
return &ParsedArgs{
|
|
Command: cmdName,
|
|
Filters: allFilters,
|
|
Modifiers: []string{},
|
|
}
|
|
}
|
|
}
|
|
|
|
func init() {
|
|
// Add persistent flags for directory overrides
|
|
rootCmd.PersistentFlags().StringVar(&configDirFlag, "config-dir", "",
|
|
"Config directory (default: $XDG_CONFIG_HOME/opal or ~/.config/opal)")
|
|
rootCmd.PersistentFlags().StringVar(&dataDirFlag, "data-dir", "",
|
|
"Data directory (default: $XDG_DATA_HOME/opal or ~/.local/share/opal)")
|
|
|
|
// Use PersistentPreRun for initialization (runs for all subcommands unless overridden)
|
|
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
|
initializeApp()
|
|
}
|
|
|
|
// Add regular subcommands
|
|
rootCmd.AddCommand(addCmd)
|
|
rootCmd.AddCommand(doneCmd)
|
|
rootCmd.AddCommand(modifyCmd)
|
|
rootCmd.AddCommand(deleteCmd)
|
|
rootCmd.AddCommand(startCmd)
|
|
rootCmd.AddCommand(stopCmd)
|
|
rootCmd.AddCommand(countCmd)
|
|
rootCmd.AddCommand(projectsCmd)
|
|
rootCmd.AddCommand(tagsCmd)
|
|
rootCmd.AddCommand(infoCmd)
|
|
rootCmd.AddCommand(editCmd)
|
|
rootCmd.AddCommand(reportsCmd)
|
|
|
|
// Add report commands dynamically
|
|
reportCommands := CreateReportCommands()
|
|
for _, cmd := range reportCommands {
|
|
rootCmd.AddCommand(cmd)
|
|
}
|
|
}
|
|
|
|
func initializeApp() {
|
|
// Set directory overrides from flags if provided
|
|
if configDirFlag != "" {
|
|
engine.SetConfigDirOverride(configDirFlag)
|
|
}
|
|
if dataDirFlag != "" {
|
|
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)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// On first run, create the config file with defaults
|
|
if isFirstRun {
|
|
if err := engine.InitConfig(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error creating config: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
showFirstRunMessage()
|
|
}
|
|
|
|
// Load config (reads file if present, otherwise uses defaults)
|
|
if _, err := engine.LoadConfig(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
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, "")
|
|
}
|