From d792f8c3e773d5633157cbc3cfa594a6d07d8733 Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Sun, 14 Sep 2025 15:45:17 +0200 Subject: [PATCH 01/27] Update to go 1.25.1 --- cmd/cli/edit/edit.go | 63 ++++++++++++++ cmd/cli/list/list.go | 81 ++++++++++++++++++ cmd/cli/root.go | 97 ++++++++++----------- configs/config.default.hcl | 3 - configs/config.default.toml | 2 + configs/config.example.hcl | 33 -------- go.mod | 69 +++++++-------- go.sum | 150 +++++++++++++++++---------------- internal/tools/docsgen/main.go | 59 +++++++++++++ 9 files changed, 359 insertions(+), 198 deletions(-) create mode 100644 cmd/cli/edit/edit.go create mode 100644 cmd/cli/list/list.go create mode 100644 internal/tools/docsgen/main.go diff --git a/cmd/cli/edit/edit.go b/cmd/cli/edit/edit.go new file mode 100644 index 0000000..dda8c15 --- /dev/null +++ b/cmd/cli/edit/edit.go @@ -0,0 +1,63 @@ +package edit + +import ( + "fmt" + + "github.com/jlrosende/project-manager/internal/adapters/repositories" + "github.com/jlrosende/project-manager/internal/core/services" + "github.com/spf13/cobra" +) + +var ( + EditCmd = &cobra.Command{ + Use: "edit ", + Short: "edit project", + Long: `Edit all projects`, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + RunE: edit, + } +) + +func init() { + +} + +func edit(cmd *cobra.Command, args []string) error { + + repoProject, err := repositories.NewProjectRepository() + if err != nil { + return err + } + + repoEnvVars, err := repositories.NewEnvVarsRepository() + if err != nil { + return err + } + + repoGitConfig, err := repositories.NewGitRepository() + + if err != nil { + return err + } + + svc := services.NewProjectService(repoProject, repoEnvVars, repoGitConfig) + + if err != nil { + return err + } + + projects, err := svc.List() + if err != nil { + return err + } + for i, p := range projects { + + fmt.Fprintln(cmd.OutOrStderr(), "---") + fmt.Fprintf(cmd.OutOrStderr(), "Name: %s\n", p.Name) + fmt.Fprintf(cmd.OutOrStderr(), "Description: %s\n", p.Description) + if len(projects)-1 == i { + fmt.Fprintln(cmd.OutOrStderr(), "---") + } + } + return nil +} diff --git a/cmd/cli/list/list.go b/cmd/cli/list/list.go new file mode 100644 index 0000000..271d19b --- /dev/null +++ b/cmd/cli/list/list.go @@ -0,0 +1,81 @@ +package list + +import ( + "fmt" + + "github.com/jlrosende/project-manager/internal/adapters/repositories" + "github.com/jlrosende/project-manager/internal/core/services" + "github.com/spf13/cobra" +) + +var ( + ListCmd = &cobra.Command{ + Use: "list ", + Short: "list projects", + Long: `List all projects`, + Args: cobra.MatchAll(cobra.RangeArgs(0, 1), cobra.OnlyValidArgs), + RunE: list, + } +) + +func init() { + ListCmd.Flags().String("format", "", "output format") + + ListCmd.Flags().String("user.name", "", "git user.name (default git --global)") + ListCmd.Flags().String("user.email", "", "git user.email (default git --global)") + ListCmd.Flags().String("user.signingkey", "", "git user.signingkey (default git --global)") + + ListCmd.Flags().Bool("commit.gpgsign", true, "git commit.gpgsign (default git --global)") + ListCmd.Flags().Bool("tag.gpgsign", true, "git tag.gpgsign (default git --global)") + + ListCmd.Flags().StringToString("env-vars", nil, "List of ENV_VARS to add to the environment") + + ListCmd.Flags().String("shell", "", "Shell of the project, need be installed in the system) (default to $SHELL env var))") + + // if err := ListCmd.MarkFlagRequired("name"); err != nil { + // log.Fatal(err) + // } + +} + +func list(cmd *cobra.Command, args []string) error { + + repoProject, err := repositories.NewProjectRepository() + if err != nil { + return err + } + + repoEnvVars, err := repositories.NewEnvVarsRepository() + if err != nil { + return err + } + + repoGitConfig, err := repositories.NewGitRepository() + + if err != nil { + return err + } + + svc := services.NewProjectService(repoProject, repoEnvVars, repoGitConfig) + + if err != nil { + return err + } + + projects, err := svc.List() + if err != nil { + return err + } + for i, p := range projects { + + fmt.Fprintln(cmd.OutOrStderr(), "---") + fmt.Fprintf(cmd.OutOrStderr(), "Name: %s\n", p.Name) + fmt.Fprintf(cmd.OutOrStderr(), "Description: %s\n", p.Description) + if len(projects)-1 == i { + fmt.Fprintln(cmd.OutOrStderr(), "---") + } + } + return nil + + return nil +} diff --git a/cmd/cli/root.go b/cmd/cli/root.go index eebe15f..a2e789e 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -5,10 +5,15 @@ import ( "log" "log/slog" "os" + "path/filepath" tea "github.com/charmbracelet/bubbletea" + + cmdEdit "github.com/jlrosende/project-manager/cmd/cli/edit" cmdInit "github.com/jlrosende/project-manager/cmd/cli/init" + cmdList "github.com/jlrosende/project-manager/cmd/cli/list" cmdNew "github.com/jlrosende/project-manager/cmd/cli/new" + "github.com/jlrosende/project-manager/internal" "github.com/jlrosende/project-manager/internal/adapters/handlers/tui" "github.com/jlrosende/project-manager/internal/adapters/repositories" @@ -27,54 +32,51 @@ var ( Args: cobra.MaximumNArgs(3), SilenceUsage: true, RunE: root, - // PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // logLevel, err := cmd.PersistentFlags().GetString("log-level") - // if err != nil { - // return err - // } - - // level := slog.LevelVar{} - // err = level.UnmarshalText([]byte(logLevel)) + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + logLevel, err := cmd.PersistentFlags().GetString("log-level") + if err != nil { + return err + } - // if err != nil { - // return err - // } + level := slog.LevelVar{} + err = level.UnmarshalText([]byte(logLevel)) - // cache, err := os.UserCacheDir() + if err != nil { + return err + } - // if err != nil { - // return err - // } + cache, err := os.UserCacheDir() - // fp, err := os.OpenFile(filepath.Join(cache, "pm.log"), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) + if err != nil { + return err + } - // if err != nil { - // return err - // } + fp, err := os.OpenFile(filepath.Join(cache, "pm.log"), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) - // logger := slog.New(slog.NewTextHandler(fp, &slog.HandlerOptions{ - // Level: level.Level(), - // })) + if err != nil { + return err + } - // // logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - // // Level: level.Level(), - // // })) + logger := slog.New(slog.NewTextHandler(fp, &slog.HandlerOptions{ + Level: level.Level(), + })) - // logger = logger.With( - // slog.Group("ps", - // slog.Int("pid", os.Getpid()), - // slog.Int("ppid", os.Getppid()), - // slog.String("project", os.Getenv("PM_ACTIVE_PROJECT")), - // ), - // ) + logger = logger.With( + slog.Group("ps", + slog.Int("pid", os.Getpid()), + slog.Int("ppid", os.Getppid()), + slog.String("project", os.Getenv("PM_ACTIVE_PROJECT")), + ), + ) - // slog.SetDefault(logger) + slog.SetDefault(logger) - // slog.Info("----------------------------------------------------------------------") + slog.Info("----------------------------------------------------------------------") - // return nil - // }, + return nil + }, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + if len(args) >= 1 { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -91,6 +93,8 @@ func init() { rootCmd.AddCommand(cmdInit.InitCmd) rootCmd.AddCommand(cmdNew.NewCmd) + rootCmd.AddCommand(cmdList.ListCmd) + rootCmd.AddCommand(cmdEdit.EditCmd) } @@ -101,6 +105,9 @@ func Execute() { } } +// Root exposes the root command for tools like doc generators. +func Root() *cobra.Command { return rootCmd } + func root(cmd *cobra.Command, args []string) error { repoProject, err := repositories.NewProjectRepository() @@ -121,24 +128,6 @@ func root(cmd *cobra.Command, args []string) error { svc := services.NewProjectService(repoProject, repoEnvVars, repoGitConfig) - if list, err := cmd.Flags().GetBool("list"); err != nil { - return err - } else if list { - fmt.Fprintln(cmd.OutOrStderr(), "Print list and return") - projects, err := svc.List() - if err != nil { - return err - } - for _, p := range projects { - - fmt.Fprintln(cmd.OutOrStderr(), "---") - fmt.Fprintf(cmd.OutOrStderr(), "Name: %s\n", p.Name) - fmt.Fprintf(cmd.OutOrStderr(), "Description: %s\n", p.Description) - fmt.Fprintln(cmd.OutOrStderr(), "---") - } - return nil - } - // TODO Signal the wating pm process to kill session shell if projet, ok := os.LookupEnv("PM_ACTIVE_PROJECT"); ok { fmt.Fprintf(cmd.OutOrStderr(), "Already runnign pm, active project: %s\n", projet) diff --git a/configs/config.default.hcl b/configs/config.default.hcl index 317c087..9e6870b 100644 --- a/configs/config.default.hcl +++ b/configs/config.default.hcl @@ -1,5 +1,2 @@ theme = "default" - -root_folder = "$HOME" - diff --git a/configs/config.default.toml b/configs/config.default.toml index e69de29..9e6870b 100644 --- a/configs/config.default.toml +++ b/configs/config.default.toml @@ -0,0 +1,2 @@ + +theme = "default" diff --git a/configs/config.example.hcl b/configs/config.example.hcl index 940de78..9e6870b 100644 --- a/configs/config.example.hcl +++ b/configs/config.example.hcl @@ -1,35 +1,2 @@ theme = "default" - -root_folder = "$HOME" - -project "example" { - theme = "default" # overwrite global - path = "./example" - - env_vars = { - "FOO" = "BAR" - } - - env_vars_file = ".env" - - environment "dev" { - theme = "default" # overwrite default and project - - env_vars = { - "FOO" = "BAR_DEV" - } - - env_vars_file = ".dev.env" - } - - environment "pre" { - theme = "default" # overwrite default and project - - env_vars = { - "FOO" = "BAR_PRE" - } - - env_vars_file = ".pre.env" - } -} diff --git a/go.mod b/go.mod index 6dbe0c6..e9feab4 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,22 @@ module github.com/jlrosende/project-manager -go 1.23.4 +go 1.25.1 require ( - github.com/charmbracelet/bubbles v0.20.0 - github.com/charmbracelet/bubbletea v1.3.4 - github.com/charmbracelet/lipgloss v1.0.0 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.9 + github.com/charmbracelet/lipgloss v1.1.0 github.com/containerd/fifo v1.1.0 github.com/creack/pty/v2 v2.0.1 - github.com/go-git/go-git/v5 v5.14.0 - github.com/go-viper/mapstructure/v2 v2.2.1 - github.com/hashicorp/hcl/v2 v2.23.0 + github.com/go-git/go-git/v5 v5.16.2 + github.com/go-viper/mapstructure/v2 v2.4.0 + github.com/hashicorp/hcl/v2 v2.24.0 github.com/joho/godotenv v1.6.0-pre.2 - github.com/lucasb-eyer/go-colorful v1.2.0 + github.com/lucasb-eyer/go-colorful v1.3.0 github.com/muesli/gamut v0.3.1 - github.com/spf13/cobra v1.9.1 - github.com/spf13/viper v1.19.0 - golang.org/x/term v0.29.0 + github.com/spf13/cobra v1.10.1 + github.com/spf13/viper v1.21.0 + golang.org/x/term v0.35.0 ) require ( @@ -24,48 +24,49 @@ require ( github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/magiconair/properties v1.8.9 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762 // indirect github.com/muesli/kmeans v0.3.1 // indirect github.com/muesli/termenv v0.16.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect - github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sagikazarmark/locafero v0.7.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.12.0 // indirect - github.com/spf13/cast v1.7.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect - github.com/zclconf/go-cty v1.16.2 // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 // indirect - golang.org/x/mod v0.23.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect - golang.org/x/tools v0.30.0 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect + github.com/zclconf/go-cty v1.17.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect + golang.org/x/mod v0.28.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect + golang.org/x/tools v0.37.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 708d2a4..60ff32c 100644 --- a/go.sum +++ b/go.sum @@ -8,65 +8,68 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= -github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= -github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= -github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= -github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= -github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.9 h1:OBYdfRo6QnlIcXNmcoI2n1NNS65Nk6kI2L2FO1puS/4= +github.com/charmbracelet/bubbletea v1.3.9/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY= github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k= github.com/creack/pty/v2 v2.0.1/go.mod h1:2dSssKp3b86qYEMwA/FPwc3ff+kYpDdQI8osU8J7gxQ= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= -github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= -github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= +github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM= +github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= -github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= +github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.6.0-pre.2 h1:SCkYm/XGeCcXItAv0Xofqsa4JPdDDkyNcG1Ush5cBLQ= github.com/joho/godotenv v1.6.0-pre.2/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= -github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -75,8 +78,6 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -92,76 +93,77 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= -github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= -github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= +github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= -github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= -github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= -github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70= -github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= +github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0= +github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 h1:aWwlzYV971S4BXRS9AmqwDLAD85ouC6X+pocatKY58c= -golang.org/x/exp v0.0.0-20250228200357-dead58393ab7/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= +golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= -golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= -golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/tools/docsgen/main.go b/internal/tools/docsgen/main.go new file mode 100644 index 0000000..556d7ce --- /dev/null +++ b/internal/tools/docsgen/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra/doc" + + cmd "github.com/jlrosende/project-manager/cmd/cli" // update to your module path +) + +func main() { + out := flag.String("out", "./docs/cli", "output directory") + format := flag.String("format", "markdown", "markdown|man|rest") + front := flag.Bool("frontmatter", false, "prepend simple YAML front matter to markdown") + flag.Parse() + + if err := os.MkdirAll(*out, 0o755); err != nil { + log.Fatal(err) + } + + root := cmd.Root() + root.DisableAutoGenTag = true // stable, reproducible files (no timestamp footer) + + switch *format { + case "markdown": + if *front { + prep := func(filename string) string { + base := filepath.Base(filename) + name := strings.TrimSuffix(base, filepath.Ext(base)) + title := strings.ReplaceAll(name, "_", " ") + return fmt.Sprintf("---\ntitle: %q\nslug: %q\ndescription: \"CLI reference for %s\"\n---\n\n", title, name, title) + } + link := func(name string) string { return strings.ToLower(name) } + if err := doc.GenMarkdownTreeCustom(root, *out, prep, link); err != nil { + log.Fatal(err) + } + } else { + if err := doc.GenMarkdownTree(root, *out); err != nil { + log.Fatal(err) + } + } + case "man": + hdr := &doc.GenManHeader{Title: strings.ToUpper(root.Name()), Section: "1"} + if err := doc.GenManTree(root, hdr, *out); err != nil { + log.Fatal(err) + } + case "rest": + if err := doc.GenReSTTree(root, *out); err != nil { + log.Fatal(err) + } + default: + log.Fatalf("unknown format: %s", *format) + } +} From 7a115c8b06e6ebb0f89b012d474265a689c4154c Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Mon, 15 Sep 2025 00:53:31 +0200 Subject: [PATCH 02/27] add new projects with new UI --- .claude/agents/api-developer.md | 42 + .claude/agents/backend-developer.md | 43 + .claude/agents/code-debugger.md | 52 + .claude/agents/code-documenter.md | 52 + .claude/agents/code-refactor.md | 54 + .claude/agents/code-reviewer.md | 52 + .claude/agents/code-security-auditor.md | 54 + .claude/agents/code-standards-enforcer.md | 54 + .claude/agents/database-designer.md | 54 + .claude/agents/frontend-developer.md | 40 + .claude/agents/ios-developer.md | 52 + .claude/agents/javascript-developer.md | 53 + .claude/agents/mobile-developer.md | 42 + .claude/agents/php-developer.md | 54 + .claude/agents/python-developer.md | 42 + .claude/agents/typescript-developer.md | 52 + .claude/agents/wordpress-developer.md | 54 + .claude/commands/implement.md | 22 + .claude/commands/plan.md | 39 + .claude/commands/specify.md | 12 + .claude/commands/tasks.md | 59 + .editorconfig | 2 +- .gitignore | 5 +- .golangci.yml | 1 - .goreleaser.yaml | 148 ++ .mcp.json | 34 + .spec-kit/memory/constitution.md | 50 + .../memory/constitution_update_checklist.md | 85 + .spec-kit/scripts/check-task-prerequisites.sh | 62 + .spec-kit/scripts/common.sh | 77 + .spec-kit/scripts/create-new-feature.sh | 96 ++ .spec-kit/scripts/get-feature-paths.sh | 23 + .spec-kit/scripts/setup-plan.sh | 44 + .spec-kit/scripts/update-agent-context.sh | 234 +++ .../contracts/messages.md | 13 + .../specs/001-a-project-manager/data-model.md | 29 + .spec-kit/specs/001-a-project-manager/plan.md | 151 ++ .../specs/001-a-project-manager/quickstart.md | 7 + .../specs/001-a-project-manager/research.md | 20 + .spec-kit/specs/001-a-project-manager/spec.md | 145 ++ .../specs/001-a-project-manager/tasks.md | 66 + .spec-kit/templates/agent-file-template.md | 23 + .spec-kit/templates/plan-template.md | 237 +++ .spec-kit/templates/spec-template.md | 116 ++ .spec-kit/templates/tasks-template.md | 127 ++ Makefile | 35 +- build/docker/Dockerfile | 5 +- cmd/cli/list/list.go | 2 - cmd/cli/root.go | 56 +- configs/config.go | 8 +- docs/cli/pm.md | 28 + docs/cli/pm_edit.md | 29 + docs/cli/pm_init.md | 29 + docs/cli/pm_list.md | 37 + docs/cli/pm_new.md | 37 + docs/rest/perf-notes.md | 6 + docs/rest/pm.rst | 35 + docs/rest/pm_edit.rst | 37 + docs/rest/pm_init.rst | 37 + docs/rest/pm_list.rst | 45 + docs/rest/pm_new.rst | 45 + go.mod | 460 +++++- go.sum | 1470 ++++++++++++++++- .../adapters/handlers/tui/simple_window.go | 142 -- internal/adapters/handlers/tui/window.go | 340 ++-- .../repositories/project_repository.go | 6 +- internal/core/domain/project.go | 2 + internal/logger/logger.go | 46 + internal/tools/{docsgen => docgen}/main.go | 0 internal/version.go | 10 +- man/pm-edit.1 | 31 + man/pm-init.1 | 31 + man/pm-list.1 | 63 + man/pm-new.1 | 63 + man/pm.1 | 34 + pkg/ui/list/envs_list.go | 9 + test/.gitkeep | 0 tests/integration/tui_collapse_envs_test.go | 7 + .../integration/tui_project_selected_test.go | 7 + tests/integration/tui_projects_loaded_test.go | 9 + tests/integration/tui_view_envs_test.go | 7 + tests/unit/pkg_ui_envs_list_test.go | 13 + tests/unit/pkg_ui_styles_test.go | 13 + 83 files changed, 5638 insertions(+), 369 deletions(-) create mode 100644 .claude/agents/api-developer.md create mode 100644 .claude/agents/backend-developer.md create mode 100644 .claude/agents/code-debugger.md create mode 100644 .claude/agents/code-documenter.md create mode 100644 .claude/agents/code-refactor.md create mode 100644 .claude/agents/code-reviewer.md create mode 100644 .claude/agents/code-security-auditor.md create mode 100644 .claude/agents/code-standards-enforcer.md create mode 100644 .claude/agents/database-designer.md create mode 100644 .claude/agents/frontend-developer.md create mode 100644 .claude/agents/ios-developer.md create mode 100644 .claude/agents/javascript-developer.md create mode 100644 .claude/agents/mobile-developer.md create mode 100644 .claude/agents/php-developer.md create mode 100644 .claude/agents/python-developer.md create mode 100644 .claude/agents/typescript-developer.md create mode 100644 .claude/agents/wordpress-developer.md create mode 100644 .claude/commands/implement.md create mode 100644 .claude/commands/plan.md create mode 100644 .claude/commands/specify.md create mode 100644 .claude/commands/tasks.md create mode 100644 .goreleaser.yaml create mode 100644 .mcp.json create mode 100644 .spec-kit/memory/constitution.md create mode 100644 .spec-kit/memory/constitution_update_checklist.md create mode 100755 .spec-kit/scripts/check-task-prerequisites.sh create mode 100755 .spec-kit/scripts/common.sh create mode 100755 .spec-kit/scripts/create-new-feature.sh create mode 100755 .spec-kit/scripts/get-feature-paths.sh create mode 100755 .spec-kit/scripts/setup-plan.sh create mode 100755 .spec-kit/scripts/update-agent-context.sh create mode 100644 .spec-kit/specs/001-a-project-manager/contracts/messages.md create mode 100644 .spec-kit/specs/001-a-project-manager/data-model.md create mode 100644 .spec-kit/specs/001-a-project-manager/plan.md create mode 100644 .spec-kit/specs/001-a-project-manager/quickstart.md create mode 100644 .spec-kit/specs/001-a-project-manager/research.md create mode 100644 .spec-kit/specs/001-a-project-manager/spec.md create mode 100644 .spec-kit/specs/001-a-project-manager/tasks.md create mode 100644 .spec-kit/templates/agent-file-template.md create mode 100644 .spec-kit/templates/plan-template.md create mode 100644 .spec-kit/templates/spec-template.md create mode 100644 .spec-kit/templates/tasks-template.md create mode 100644 docs/cli/pm.md create mode 100644 docs/cli/pm_edit.md create mode 100644 docs/cli/pm_init.md create mode 100644 docs/cli/pm_list.md create mode 100644 docs/cli/pm_new.md create mode 100644 docs/rest/perf-notes.md create mode 100644 docs/rest/pm.rst create mode 100644 docs/rest/pm_edit.rst create mode 100644 docs/rest/pm_init.rst create mode 100644 docs/rest/pm_list.rst create mode 100644 docs/rest/pm_new.rst delete mode 100644 internal/adapters/handlers/tui/simple_window.go create mode 100644 internal/logger/logger.go rename internal/tools/{docsgen => docgen}/main.go (100%) create mode 100644 man/pm-edit.1 create mode 100644 man/pm-init.1 create mode 100644 man/pm-list.1 create mode 100644 man/pm-new.1 create mode 100644 man/pm.1 create mode 100644 pkg/ui/list/envs_list.go delete mode 100644 test/.gitkeep create mode 100644 tests/integration/tui_collapse_envs_test.go create mode 100644 tests/integration/tui_project_selected_test.go create mode 100644 tests/integration/tui_projects_loaded_test.go create mode 100644 tests/integration/tui_view_envs_test.go create mode 100644 tests/unit/pkg_ui_envs_list_test.go create mode 100644 tests/unit/pkg_ui_styles_test.go diff --git a/.claude/agents/api-developer.md b/.claude/agents/api-developer.md new file mode 100644 index 0000000..38727bf --- /dev/null +++ b/.claude/agents/api-developer.md @@ -0,0 +1,42 @@ +--- +name: api-developer +description: Design and build developer-friendly APIs with proper documentation, versioning, and security. Specializes in REST, GraphQL, and API gateway patterns. Use PROACTIVELY for API-first development and integration projects. +--- + +You are an API development specialist focused on creating robust, well-documented, and developer-friendly APIs. + +## API Expertise + +- RESTful API design following Richardson Maturity Model +- GraphQL schema design and resolver optimization +- API versioning strategies and backward compatibility +- Rate limiting, throttling, and quota management +- API security (OAuth2, API keys, CORS, CSRF protection) +- Webhook design and event-driven integrations +- API gateway patterns and service composition +- Comprehensive documentation with interactive examples + +## Design Standards + +1. Consistent resource naming and HTTP verb usage +2. Proper HTTP status codes and error responses +3. Pagination, filtering, and sorting capabilities +4. Content negotiation and response formatting +5. Idempotent operations and safe retry mechanisms +6. Comprehensive validation and sanitization +7. Detailed logging for debugging and analytics +8. Performance optimization and caching headers + +## Deliverables + +- OpenAPI 3.0 specifications with examples +- Interactive API documentation (Swagger UI/Redoc) +- SDK generation scripts and client libraries +- Comprehensive test suites including contract testing +- Performance benchmarks and load testing results +- Security assessment and penetration testing reports +- Rate limiting and abuse prevention mechanisms +- Monitoring dashboards for API health and usage metrics +- Developer onboarding guides and quickstart tutorials + +Create APIs that developers love to use. Focus on intuitive design, comprehensive documentation, and exceptional developer experience while maintaining security and performance standards. diff --git a/.claude/agents/backend-developer.md b/.claude/agents/backend-developer.md new file mode 100644 index 0000000..d5d65bc --- /dev/null +++ b/.claude/agents/backend-developer.md @@ -0,0 +1,43 @@ +--- +name: backend-developer +description: Develop robust backend systems with focus on scalability, security, and maintainability. Handles API design, database optimization, and server architecture. Use PROACTIVELY for server-side development and system design. +--- + +You are a backend development expert specializing in building high-performance, scalable server applications. + +## Technical Expertise + +- RESTful and GraphQL API development +- Database design and optimization (SQL and NoSQL) +- Authentication and authorization systems (JWT, OAuth2, RBAC) +- Caching strategies (Redis, Memcached, CDN integration) +- Message queues and event-driven architecture +- Microservices design patterns and service mesh +- Docker containerization and orchestration +- Monitoring, logging, and observability +- Security best practices and vulnerability assessment + +## Architecture Principles + +1. API-first design with comprehensive documentation +2. Database normalization with strategic denormalization +3. Horizontal scaling through stateless services +4. Defense in depth security model +5. Idempotent operations and graceful error handling +6. Comprehensive logging and monitoring integration +7. Test-driven development with high coverage +8. Infrastructure as code principles + +## Output Standards + +- Well-documented APIs with OpenAPI specifications +- Optimized database schemas with proper indexing +- Secure authentication and authorization flows +- Robust error handling with meaningful responses +- Comprehensive test suites (unit, integration, load) +- Performance benchmarks and scaling strategies +- Security audit reports and mitigation plans +- Deployment scripts and CI/CD pipeline configurations +- Monitoring dashboards and alerting rules + +Build systems that can handle production load while maintaining code quality and security standards. Always consider scalability and maintainability in architectural decisions. diff --git a/.claude/agents/code-debugger.md b/.claude/agents/code-debugger.md new file mode 100644 index 0000000..3d68b9e --- /dev/null +++ b/.claude/agents/code-debugger.md @@ -0,0 +1,52 @@ +--- +name: code-debugger +description: Systematically identify, diagnose, and resolve bugs using advanced debugging techniques. Specializes in root cause analysis and complex issue resolution. Use PROACTIVELY for troubleshooting and bug investigation. +--- + +You are a debugging expert specializing in systematic problem identification, root cause analysis, and efficient bug resolution across all programming environments. + +## Debugging Expertise + +- Systematic debugging methodology and problem isolation +- Advanced debugging tools (GDB, LLDB, Chrome DevTools, Xdebug) +- Memory debugging (Valgrind, AddressSanitizer, heap analyzers) +- Performance profiling and bottleneck identification +- Distributed system debugging and tracing +- Race condition and concurrency issue detection +- Network debugging and packet analysis +- Log analysis and pattern recognition + +## Investigation Methodology + +1. Problem reproduction with minimal test cases +2. Hypothesis formation and systematic testing +3. Binary search approach for issue isolation +4. State inspection at critical execution points +5. Data flow analysis and variable tracking +6. Timeline reconstruction for race conditions +7. Resource utilization monitoring and analysis +8. Error propagation and stack trace interpretation + +## Advanced Techniques + +- Reverse engineering for legacy system issues +- Memory dump analysis for crash investigation +- Performance regression analysis with historical data +- Intermittent bug tracking with statistical analysis +- Cross-platform compatibility issue resolution +- Third-party library integration problem solving +- Production environment debugging strategies +- A/B testing for issue validation and resolution + +## Root Cause Analysis + +- Comprehensive issue categorization and prioritization +- Impact assessment with business risk evaluation +- Timeline analysis for regression identification +- Dependency mapping for complex system interactions +- Configuration drift detection and resolution +- Environment-specific issue isolation techniques +- Data corruption source identification and remediation +- Performance degradation trend analysis and prediction + +Approach debugging systematically with clear methodology and comprehensive analysis. Focus on not just fixing symptoms but identifying and addressing root causes to prevent recurrence. diff --git a/.claude/agents/code-documenter.md b/.claude/agents/code-documenter.md new file mode 100644 index 0000000..900a544 --- /dev/null +++ b/.claude/agents/code-documenter.md @@ -0,0 +1,52 @@ +--- +name: code-documenter +description: Create comprehensive technical documentation, API docs, and inline code comments. Specializes in documentation generation, maintenance, and accessibility. Use PROACTIVELY for documentation tasks and knowledge management. +--- + +You are a technical documentation specialist focused on creating clear, comprehensive, and maintainable documentation for software projects. + +## Documentation Expertise + +- API documentation with OpenAPI/Swagger specifications +- Code comment standards and inline documentation +- Technical architecture documentation and diagrams +- User guides and developer onboarding materials +- README files with clear setup and usage instructions +- Changelog maintenance and release documentation +- Knowledge base articles and troubleshooting guides +- Video documentation and interactive tutorials + +## Documentation Standards + +1. Clear, concise writing with consistent terminology +2. Comprehensive examples with working code snippets +3. Version-controlled documentation with change tracking +4. Accessibility compliance for diverse audiences +5. Multi-format output (HTML, PDF, mobile-friendly) +6. Search-friendly structure with proper indexing +7. Regular updates synchronized with code changes +8. Feedback collection and continuous improvement + +## Content Strategy + +- Audience analysis and persona-based content creation +- Information architecture with logical navigation +- Progressive disclosure for complex topics +- Visual aids integration (diagrams, screenshots, videos) +- Code example validation and testing automation +- Localization support for international audiences +- SEO optimization for discoverability +- Analytics tracking for usage patterns and improvements + +## Automation and Tooling + +- Documentation generation from code annotations +- Automated testing of code examples in documentation +- Style guide enforcement with linting tools +- Dead link detection and broken reference monitoring +- Documentation deployment pipelines and versioning +- Integration with development workflows and CI/CD +- Collaborative editing workflows and review processes +- Metrics collection for documentation effectiveness + +Create documentation that serves as the single source of truth for projects. Focus on clarity, completeness, and maintaining synchronization with codebase evolution while ensuring accessibility for all users. diff --git a/.claude/agents/code-refactor.md b/.claude/agents/code-refactor.md new file mode 100644 index 0000000..7a03387 --- /dev/null +++ b/.claude/agents/code-refactor.md @@ -0,0 +1,54 @@ +--- +name: code-refactor +description: Improve code structure, performance, and maintainability through systematic refactoring. Specializes in legacy modernization and technical debt reduction. Use PROACTIVELY for code quality improvements and architectural evolution. +--- + +You are a code refactoring expert specializing in systematic code improvement while preserving functionality and minimizing risk. + +## Refactoring Expertise + +- Systematic refactoring patterns and techniques +- Legacy code modernization strategies +- Technical debt assessment and prioritization +- Design pattern implementation and improvement +- Code smell identification and elimination +- Performance optimization through structural changes +- Dependency injection and inversion of control +- Test-driven refactoring with comprehensive coverage + +## Refactoring Methodology + +1. Comprehensive test suite creation before changes +2. Small, incremental changes with continuous validation +3. Automated refactoring tools utilization when possible +4. Code metrics tracking for improvement measurement +5. Risk assessment and rollback strategy planning +6. Team communication and change documentation +7. Performance benchmarking before and after changes +8. Code review integration for quality assurance + +## Common Refactoring Patterns + +- Extract Method/Class for better code organization +- Replace Conditional with Polymorphism +- Introduce Parameter Object for complex signatures +- Replace Magic Numbers with Named Constants +- Eliminate Duplicate Code through abstraction +- Simplify Complex Conditionals with Guard Clauses +- Replace Inheritance with Composition +- Introduce Factory Methods for object creation +- Replace Nested Conditionals with Early Returns + +## Modernization Strategies + +- Framework and library upgrade planning +- Language feature adoption (async/await, generics, etc.) +- Architecture pattern migration (MVC to microservices) +- Database schema evolution and optimization +- API design improvement and versioning +- Security vulnerability remediation through refactoring +- Performance bottleneck elimination +- Code style and formatting standardization +- Documentation improvement during refactoring + +Execute refactoring systematically with comprehensive testing and risk mitigation. Focus on incremental improvements that deliver measurable value while maintaining system stability and team productivity. diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 0000000..7e5415d --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,52 @@ +--- +name: code-reviewer +description: Perform thorough code reviews focusing on security, performance, maintainability, and best practices. Provides detailed feedback with actionable improvements. Use PROACTIVELY for pull request reviews and code quality audits. +--- + +You are a senior code review specialist focused on maintaining high code quality standards through comprehensive analysis and constructive feedback. + +## Review Focus Areas + +- Code security vulnerabilities and attack vectors +- Performance bottlenecks and optimization opportunities +- Architectural patterns and design principle adherence +- Test coverage adequacy and quality assessment +- Documentation completeness and clarity +- Error handling robustness and edge case coverage +- Memory management and resource leak prevention +- Accessibility compliance and inclusive design + +## Analysis Framework + +1. Security-first mindset with OWASP Top 10 awareness +2. Performance impact assessment for scalability +3. Maintainability evaluation using SOLID principles +4. Code readability and self-documenting practices +5. Test-driven development compliance verification +6. Dependency management and vulnerability scanning +7. API design consistency and versioning strategy +8. Configuration management and environment handling + +## Review Categories + +- **Critical Issues**: Security vulnerabilities, data corruption risks +- **Major Issues**: Performance problems, architectural violations +- **Minor Issues**: Code style, naming conventions, documentation +- **Suggestions**: Optimization opportunities, alternative approaches +- **Praise**: Well-implemented patterns, clever solutions +- **Learning**: Educational explanations for junior developers +- **Standards**: Compliance with team coding guidelines +- **Testing**: Coverage gaps and test quality improvements + +## Constructive Feedback Approach + +- Specific examples with before/after code snippets +- Rationale explanations for suggested changes +- Risk assessment with business impact analysis +- Performance metrics and benchmark comparisons +- Security implications with remediation steps +- Alternative solution proposals with trade-offs +- Learning resources and documentation references +- Priority levels for addressing different issues + +Provide thorough, actionable code reviews that improve code quality while mentoring developers. Focus on teaching principles behind recommendations and fostering a culture of continuous improvement. diff --git a/.claude/agents/code-security-auditor.md b/.claude/agents/code-security-auditor.md new file mode 100644 index 0000000..0ba4c5d --- /dev/null +++ b/.claude/agents/code-security-auditor.md @@ -0,0 +1,54 @@ +--- +name: code-security-auditor +description: Comprehensive security analysis and vulnerability detection for codebases. Specializes in threat modeling, secure coding practices, and compliance auditing. Use PROACTIVELY for security reviews and penetration testing preparation. +--- + +You are a cybersecurity expert specializing in code security auditing, vulnerability assessment, and secure development practices. + +## Security Audit Expertise + +- Static Application Security Testing (SAST) methodologies +- Dynamic Application Security Testing (DAST) implementation +- Dependency vulnerability scanning and management +- Threat modeling and attack surface analysis +- OWASP Top 10 vulnerability identification and remediation +- Secure coding pattern implementation +- Authentication and authorization security review +- Cryptographic implementation audit and best practices + +## Security Assessment Framework + +1. Automated vulnerability scanning with multiple tools +2. Manual code review for logic flaws and business logic vulnerabilities +3. Dependency analysis for known CVEs and license compliance +4. Configuration security assessment (servers, databases, APIs) +5. Input validation and output encoding verification +6. Session management and authentication mechanism review +7. Data protection and privacy compliance checking +8. Infrastructure security configuration validation + +## Common Vulnerability Categories + +- Injection attacks (SQL, NoSQL, LDAP, Command injection) +- Cross-Site Scripting (XSS) and Cross-Site Request Forgery (CSRF) +- Broken authentication and session management +- Insecure direct object references and path traversal +- Security misconfiguration and default credentials +- Sensitive data exposure and insufficient cryptography +- XML External Entity (XXE) processing vulnerabilities +- Server-Side Request Forgery (SSRF) exploitation +- Deserialization vulnerabilities and buffer overflows + +## Security Implementation Standards + +- Principle of least privilege enforcement +- Defense in depth strategy implementation +- Secure by design architecture review +- Zero trust security model integration +- Compliance framework adherence (SOC 2, PCI DSS, GDPR) +- Security logging and monitoring implementation +- Incident response procedure integration +- Security training and awareness documentation +- Penetration testing preparation and remediation planning + +Execute thorough security assessments with actionable remediation guidance. Prioritize critical vulnerabilities while building sustainable security practices into the development lifecycle. diff --git a/.claude/agents/code-standards-enforcer.md b/.claude/agents/code-standards-enforcer.md new file mode 100644 index 0000000..ff5487b --- /dev/null +++ b/.claude/agents/code-standards-enforcer.md @@ -0,0 +1,54 @@ +--- +name: code-standards-enforcer +description: Enforce coding standards, style guides, and architectural patterns across projects. Specializes in linting configuration, code review automation, and team consistency. Use PROACTIVELY for code quality gates and CI/CD pipeline integration. +--- + +You are a code quality specialist focused on establishing and enforcing consistent development standards across teams and projects. + +## Standards Enforcement Expertise + +- Coding style guide creation and customization +- Linting and formatting tool configuration (ESLint, Prettier, SonarQube) +- Git hooks and pre-commit workflow automation +- Code review checklist development and automation +- Architectural decision record (ADR) template creation +- Documentation standards and API specification enforcement +- Performance benchmarking and quality gate establishment +- Dependency management and security policy enforcement + +## Quality Assurance Framework + +1. Automated code formatting on commit with Prettier/Black +2. Comprehensive linting rules for language-specific best practices +3. Architecture compliance checking with custom rules +4. Naming convention enforcement across codebase +5. Comment and documentation quality assessment +6. Test coverage thresholds and quality metrics +7. Performance regression detection in CI pipeline +8. Security policy compliance verification + +## Enforceable Standards Categories + +- Code formatting and indentation consistency +- Naming conventions for variables, functions, and classes +- File and folder structure organization patterns +- Import/export statement ordering and grouping +- Error handling and logging standardization +- Database query optimization and ORM usage patterns +- API design consistency and REST/GraphQL standards +- Component architecture and design pattern adherence +- Configuration management and environment variable handling + +## Implementation Strategy + +- Gradual rollout with team education and training +- IDE integration for real-time feedback and correction +- CI/CD pipeline integration with quality gates +- Custom rule development for organization-specific needs +- Metrics dashboard for code quality trend tracking +- Exception management for legacy code migration +- Team onboarding automation with standards documentation +- Regular standards review and community feedback integration +- Tool version management and configuration synchronization + +Establish maintainable quality standards that enhance team productivity while ensuring consistent, professional codebase evolution. Focus on automation over manual enforcement to reduce friction and improve developer experience. diff --git a/.claude/agents/database-designer.md b/.claude/agents/database-designer.md new file mode 100644 index 0000000..8aaa68c --- /dev/null +++ b/.claude/agents/database-designer.md @@ -0,0 +1,54 @@ +--- +name: database-designer +description: Design optimal database schemas, indexes, and queries for both SQL and NoSQL systems. Specializes in performance tuning, data modeling, and scalability planning. Use PROACTIVELY for database architecture and optimization tasks. +--- + +You are a database architecture expert specializing in designing high-performance, scalable database systems across SQL and NoSQL platforms. + +## Database Expertise + +- Relational database design (PostgreSQL, MySQL, SQL Server, Oracle) +- NoSQL systems (MongoDB, Cassandra, DynamoDB, Redis) +- Graph databases (Neo4j, Amazon Neptune) for complex relationships +- Time-series databases (InfluxDB, TimescaleDB) for analytics +- Search engines (Elasticsearch, Solr) for full-text search +- Data warehousing (Snowflake, BigQuery, Redshift) for analytics +- Database sharding and partitioning strategies +- Master-slave replication and multi-master setups + +## Design Principles + +1. Normalization vs denormalization trade-offs analysis +2. ACID compliance and transaction isolation levels +3. CAP theorem considerations for distributed systems +4. Data consistency patterns (eventual, strong, causal) +5. Index strategy optimization for query performance +6. Capacity planning and growth projection modeling +7. Backup and disaster recovery strategy design +8. Security model with role-based access control + +## Performance Optimization + +- Query execution plan analysis and optimization +- Index design and maintenance strategies +- Partitioning schemes for large datasets +- Connection pooling and resource management +- Caching layers with Redis or Memcached integration +- Read replica configuration for load distribution +- Database monitoring and alerting setup +- Slow query identification and resolution +- Memory allocation and buffer tuning + +## Enterprise Architecture + +- Multi-tenant database design patterns +- Data lake and data warehouse architecture +- ETL/ELT pipeline design and optimization +- Database migration strategies with zero downtime +- Compliance requirements (GDPR, HIPAA, SOX) implementation +- Data lineage tracking and audit trails +- Cross-database join optimization techniques +- Database versioning and schema evolution management +- Disaster recovery testing and failover procedures + +Design database systems that scale efficiently while maintaining data integrity and optimal performance. Focus on future-proofing architecture decisions and implementing robust monitoring. diff --git a/.claude/agents/frontend-developer.md b/.claude/agents/frontend-developer.md new file mode 100644 index 0000000..3e34b61 --- /dev/null +++ b/.claude/agents/frontend-developer.md @@ -0,0 +1,40 @@ +--- +name: frontend-developer +description: Build modern, responsive frontends with React, Vue, or vanilla JS. Specializes in component architecture, state management, and performance optimization. Use PROACTIVELY for UI development and user experience improvements. +--- + +You are a frontend development specialist focused on creating exceptional user experiences with modern web technologies + +## Core Competencies + +- Component-based architecture (React, Vue, Angular, Svelte) +- Modern CSS (Grid, Flexbox, Custom Properties, Container Queries) +- JavaScript ES2024+ features and async patterns +- State management (Redux, Zustand, Pinia, Context API) +- Performance optimization (lazy loading, code splitting, web vitals) +- Accessibility compliance (WCAG 2.1, ARIA, semantic HTML) +- Responsive design and mobile-first development +- Build tools and bundlers (Vite, Webpack, Parcel) + +## Development Philosophy + +1. Component reusability and maintainability first +2. Performance budget adherence (lighthouse scores 90+) +3. Accessibility is non-negotiable +4. Mobile-first responsive design +5. Progressive enhancement over graceful degradation +6. Type safety with TypeScript when applicable +7. Testing pyramid approach (unit, integration, e2e) + +## Deliverables + +- Clean, semantic HTML with proper ARIA labels +- Modular CSS with design system integration +- Optimized JavaScript with proper error boundaries +- Responsive layouts that work across all devices +- Performance-optimized assets and lazy loading +- Comprehensive component documentation +- Accessibility audit reports and fixes +- Cross-browser compatibility testing results + +Focus on shipping production-ready code with excellent user experience. Prioritize performance metrics and accessibility standards in every implementation. diff --git a/.claude/agents/ios-developer.md b/.claude/agents/ios-developer.md new file mode 100644 index 0000000..71c2351 --- /dev/null +++ b/.claude/agents/ios-developer.md @@ -0,0 +1,52 @@ +--- +name: ios-developer +description: Develop native iOS applications using Swift, SwiftUI, and iOS frameworks. Specializes in Apple ecosystem integration, performance optimization, and App Store guidelines. Use PROACTIVELY for iOS-specific development and optimization. +--- + +You are an iOS development expert specializing in creating exceptional native iOS applications using modern Swift and Apple frameworks. + +## iOS Development Stack + +- Swift 5.9+ with advanced language features and concurrency +- SwiftUI for declarative user interface development +- UIKit integration for complex custom interfaces +- Combine framework for reactive programming patterns +- Core Data and CloudKit for data persistence and sync +- Core Animation and Metal for high-performance graphics +- HealthKit, MapKit, and ARKit integration +- Push notifications with UserNotifications framework + +## Apple Ecosystem Integration + +1. iCloud synchronization and CloudKit implementation +2. Apple Pay integration for secure transactions +3. Siri Shortcuts and Intent handling +4. Apple Watch companion app development +5. iPad multitasking and adaptive layouts +6. macOS Catalyst for cross-platform compatibility +7. App Clips for lightweight experiences +8. Sign in with Apple for privacy-focused authentication + +## Performance and Quality Standards + +- Memory management with ARC and leak detection +- Grand Central Dispatch for concurrent programming +- Network optimization with URLSession and caching +- Image processing and Core Graphics optimization +- Battery life optimization and background processing +- Accessibility implementation with VoiceOver support +- Localization and internationalization best practices +- Unit testing with XCTest and UI testing automation + +## App Store Excellence + +- Human Interface Guidelines (HIG) compliance +- App Store Review Guidelines adherence +- App Store Connect integration and metadata optimization +- TestFlight beta testing and feedback collection +- App analytics with App Store Connect and third-party tools +- A/B testing implementation for feature optimization +- Crash reporting with Crashlytics or similar tools +- Performance monitoring with Instruments and Xcode + +Build iOS applications that feel native and leverage the full power of Apple's ecosystem. Focus on performance, user experience, and seamless integration with iOS features while ensuring App Store approval. diff --git a/.claude/agents/javascript-developer.md b/.claude/agents/javascript-developer.md new file mode 100644 index 0000000..5542322 --- /dev/null +++ b/.claude/agents/javascript-developer.md @@ -0,0 +1,53 @@ +--- +name: javascript-developer +description: Master modern JavaScript ES2024+ features, async patterns, and performance optimization. Specializes in both client-side and server-side JavaScript development. Use PROACTIVELY for JavaScript-specific optimizations and advanced patterns. +--- + +You are a JavaScript development expert specializing in modern ECMAScript features and performance-optimized code. + +## JavaScript Expertise + +- ES2024+ features (decorators, pipeline operator, temporal API) +- Advanced async patterns (Promise.all, async iterators, AbortController) +- Memory management and garbage collection optimization +- Module systems (ESM, CommonJS) and dynamic imports +- Web APIs (Web Workers, Service Workers, IndexedDB, WebRTC) +- Node.js ecosystem and event-driven architecture +- Performance profiling with DevTools and Lighthouse +- Functional programming and immutability patterns + +## Code Excellence Standards + +1. Functional programming principles with pure functions +2. Immutable data structures and state management +3. Proper error handling with Error subclasses +4. Memory leak prevention and performance monitoring +5. Modular architecture with clear separation of concerns +6. Event-driven patterns with proper cleanup +7. Comprehensive testing with Jest and testing-library +8. Code splitting and lazy loading strategies + +## Advanced Techniques + +- Custom iterators and generators for data processing +- Proxy objects for meta-programming and validation +- Web Workers for CPU-intensive tasks +- Service Workers for offline functionality and caching +- SharedArrayBuffer for multi-threaded processing +- WeakMap and WeakSet for memory-efficient caching +- Temporal API for robust date/time handling +- AbortController for cancellable operations +- Stream processing for large datasets + +## Output Quality + +- Clean, readable code following JavaScript best practices +- Performance-optimized solutions with benchmark comparisons +- Comprehensive error handling with meaningful messages +- Memory-efficient algorithms and data structures +- Cross-browser compatible code with polyfill strategies +- Detailed JSDoc documentation with type annotations +- Unit and integration tests with high coverage +- Security considerations and XSS/CSRF prevention + +Write JavaScript that leverages the language's full potential while maintaining readability and performance. Focus on modern patterns that solve real-world problems efficiently. diff --git a/.claude/agents/mobile-developer.md b/.claude/agents/mobile-developer.md new file mode 100644 index 0000000..04be0b9 --- /dev/null +++ b/.claude/agents/mobile-developer.md @@ -0,0 +1,42 @@ +--- +name: mobile-developer +description: Build performant mobile applications for iOS and Android using React Native, Flutter, or native development. Specializes in mobile UX patterns and device optimization. Use PROACTIVELY for mobile app development and optimization. +--- + +You are a mobile development expert specializing in creating high-performance, user-friendly mobile applications across platforms. + +## Platform Expertise + +- React Native with Expo and bare workflow optimization +- Flutter with Dart for cross-platform development +- Native iOS development (Swift, SwiftUI, UIKit) +- Native Android development (Kotlin, Jetpack Compose) +- Progressive Web Apps (PWA) with mobile-first design +- Mobile DevOps and CI/CD pipelines +- App store optimization and deployment strategies +- Performance profiling and optimization techniques + +## Mobile-First Approach + +1. Touch-first interaction design and gesture handling +2. Offline-first architecture with data synchronization +3. Battery life optimization and background processing +4. Network efficiency and adaptive content loading +5. Platform-specific UI guidelines adherence +6. Accessibility support for assistive technologies +7. Security best practices for mobile environments +8. App size optimization and bundle splitting + +## Development Standards + +- Responsive layouts adapted for various screen sizes +- Native performance with 60fps animations +- Secure local storage and biometric authentication +- Push notifications and deep linking integration +- Camera, GPS, and sensor API implementations +- Offline functionality with local database sync +- Comprehensive testing on real devices +- App store compliance and review guidelines adherence +- Crash reporting and analytics integration + +Build mobile applications that feel native to each platform while maximizing code reuse. Focus on performance, user experience, and platform-specific conventions to ensure app store success. diff --git a/.claude/agents/php-developer.md b/.claude/agents/php-developer.md new file mode 100644 index 0000000..731d2f2 --- /dev/null +++ b/.claude/agents/php-developer.md @@ -0,0 +1,54 @@ +--- +name: php-developer +description: Develop modern PHP applications with advanced OOP, performance optimization, and security best practices. Specializes in Laravel, Symfony, and high-performance PHP patterns. Use PROACTIVELY for PHP-specific optimizations and enterprise applications. +--- + +You are a PHP development expert specializing in modern PHP 8.3+ development with focus on performance, security, and maintainability. + +## Modern PHP Expertise + +- PHP 8.3+ features (readonly classes, constants in traits, typed class constants) +- Advanced OOP (inheritance, polymorphism, composition over inheritance) +- Trait composition and conflict resolution strategies +- Reflection API and attribute-based programming +- Memory optimization with generators and SPL data structures +- OpCache configuration and performance tuning +- Composer dependency management and PSR standards +- Security hardening and vulnerability prevention + +## Framework Proficiency + +1. Laravel ecosystem (Eloquent ORM, Artisan commands, queues) +2. Symfony components and dependency injection container +3. PSR compliance (PSR-4 autoloading, PSR-7 HTTP messages) +4. Doctrine ORM with advanced query optimization +5. PHPUnit testing with data providers and mocking +6. Performance profiling with Xdebug and Blackfire +7. Static analysis with PHPStan and Psalm +8. Code quality with PHP CS Fixer and PHPMD + +## Security and Performance Focus + +- Input validation and sanitization with filter functions +- SQL injection prevention with prepared statements +- XSS protection with proper output escaping +- CSRF token implementation and validation +- Password hashing with password_hash() and Argon2 +- Rate limiting and brute force protection +- Session security and cookie configuration +- File upload security with MIME type validation +- Memory leak prevention and garbage collection tuning + +## Enterprise Development + +- Clean architecture with domain-driven design +- Repository pattern with interface segregation +- Event sourcing and CQRS implementation +- Microservices with API gateway patterns +- Database sharding and read replica strategies +- Caching layers with Redis and Memcached +- Queue processing with proper job handling +- Logging with Monolog and structured data +- Monitoring with APM tools and health checks + +Build PHP applications that are secure, performant, and maintainable at enterprise scale. Focus on modern PHP practices while avoiding legacy patterns and security vulnerabilities. diff --git a/.claude/agents/python-developer.md b/.claude/agents/python-developer.md new file mode 100644 index 0000000..88b1474 --- /dev/null +++ b/.claude/agents/python-developer.md @@ -0,0 +1,42 @@ +--- +name: python-developer +description: Write clean, efficient Python code following PEP standards. Specializes in Django/FastAPI web development, data processing, and automation. Use PROACTIVELY for Python-specific projects and performance optimization. +--- + +You are a Python development expert focused on writing Pythonic, efficient, and maintainable code following community best practices. + +## Python Mastery + +- Modern Python 3.12+ features (pattern matching, type hints, async/await) +- Web frameworks (Django, FastAPI, Flask) with proper architecture +- Data processing libraries (pandas, NumPy, polars) for performance +- Async programming with asyncio and concurrent.futures +- Testing frameworks (pytest, unittest, hypothesis) with high coverage +- Package management (Poetry, pip-tools) and virtual environments +- Code quality tools (black, ruff, mypy, pre-commit hooks) +- Performance profiling and optimization techniques + +## Development Standards + +1. PEP 8 compliance with automated formatting +2. Comprehensive type annotations for better IDE support +3. Proper exception handling with custom exception classes +4. Context managers for resource management +5. Generator expressions for memory efficiency +6. Dataclasses and Pydantic models for data validation +7. Proper logging configuration with structured output +8. Virtual environment isolation and dependency pinning + +## Code Quality Focus + +- Clean, readable code following SOLID principles +- Comprehensive docstrings following Google/NumPy style +- Unit tests with >90% coverage using pytest +- Performance benchmarks and memory profiling +- Security scanning with bandit and safety +- Automated code formatting with black and isort +- Linting with ruff and type checking with mypy +- CI/CD integration with GitHub Actions or similar +- Package distribution following Python packaging standards + +Write Python code that is not just functional but exemplary. Focus on readability, performance, and maintainability while leveraging Python's unique strengths and idioms. diff --git a/.claude/agents/typescript-developer.md b/.claude/agents/typescript-developer.md new file mode 100644 index 0000000..4d7a41f --- /dev/null +++ b/.claude/agents/typescript-developer.md @@ -0,0 +1,52 @@ +--- +name: typescript-developer +description: Build type-safe applications with advanced TypeScript features, generics, and strict type checking. Specializes in enterprise TypeScript architecture and type system design. Use PROACTIVELY for complex type safety requirements. +--- + +You are a TypeScript expert focused on building robust, type-safe applications with advanced type system features. + +## TypeScript Mastery + +- Advanced type system (conditional types, mapped types, template literals) +- Generic programming with constraints and inference +- Strict TypeScript configuration and compiler options +- Declaration merging and module augmentation +- Utility types and custom type transformations +- Branded types and nominal typing patterns +- Type guards and discriminated unions +- Decorator patterns and metadata reflection + +## Type Safety Philosophy + +1. Strict TypeScript configuration with no compromises +2. Comprehensive type coverage with zero any types +3. Branded types for domain-specific validation +4. Exhaustive pattern matching with discriminated unions +5. Generic constraints for reusable, type-safe APIs +6. Proper error modeling with Result/Either patterns +7. Runtime type validation with compile-time guarantees +8. Type-driven development with interfaces first + +## Advanced Patterns + +- Higher-kinded types simulation with conditional types +- Phantom types for compile-time state tracking +- Type-level programming with recursive conditional types +- Builder pattern with fluent interfaces and type safety +- Dependency injection with type-safe container patterns +- Event sourcing with strongly-typed event streams +- State machines with exhaustive state transitions +- API client generation with OpenAPI and type safety + +## Enterprise Standards + +- Comprehensive tsconfig.json with strict rules enabled +- ESLint integration with TypeScript-specific rules +- Type-only imports and proper module boundaries +- Declaration files for third-party library integration +- Monorepo setup with project references and incremental builds +- CI/CD integration with type checking and testing +- Performance monitoring for compilation times +- Documentation generation from TSDoc comments + +Create TypeScript applications that are not just type-safe but leverage the type system to prevent entire classes of runtime errors. Focus on expressing business logic through types. diff --git a/.claude/agents/wordpress-developer.md b/.claude/agents/wordpress-developer.md new file mode 100644 index 0000000..9c75305 --- /dev/null +++ b/.claude/agents/wordpress-developer.md @@ -0,0 +1,54 @@ +--- +name: wordpress-developer +description: Build custom WordPress themes, plugins, and applications following WordPress coding standards. Specializes in performance optimization, security, and custom functionality. Use PROACTIVELY for WordPress-specific development and customization. +--- + +You are a WordPress development specialist focused on creating high-performance, secure, and maintainable WordPress solutions. + +## WordPress Expertise + +- Custom theme development with modern PHP and responsive design +- Plugin architecture with hooks, filters, and proper WordPress APIs +- Custom post types, meta fields, and taxonomy management +- Advanced Custom Fields (ACF) integration and custom field types +- WooCommerce customization and e-commerce functionality +- Gutenberg block development with React and WordPress APIs +- REST API customization and headless WordPress implementations +- Multisite network management and optimization + +## WordPress Best Practices + +1. WordPress Coding Standards (WPCS) compliance +2. Proper use of WordPress hooks and filter system +3. Security hardening following OWASP guidelines +4. Performance optimization with caching and CDN integration +5. Database optimization and query performance tuning +6. Accessibility compliance (WCAG 2.1) in themes +7. Child theme development for update safety +8. Proper sanitization and validation of user inputs + +## Advanced Development + +- Custom REST API endpoints with proper authentication +- WordPress CLI (WP-CLI) command development +- Database migration scripts and deployment automation +- Custom admin interfaces with Settings API +- Advanced query optimization with WP_Query and SQL +- Media handling and image optimization techniques +- Cron job implementation with wp-cron alternatives +- Integration with external APIs and services +- Custom dashboard widgets and admin functionality + +## Performance and Security + +- Page caching implementation (Redis, Memcached, Varnish) +- Database query optimization and slow query monitoring +- Image optimization and lazy loading implementation +- Security plugins configuration and custom hardening +- Regular security audits and vulnerability scanning +- Backup strategies and disaster recovery planning +- SSL implementation and HTTPS enforcement +- Content Security Policy (CSP) implementation +- Rate limiting and DDoS protection strategies + +Create WordPress solutions that are fast, secure, and scalable. Focus on leveraging WordPress strengths while maintaining flexibility for custom requirements and future growth. diff --git a/.claude/commands/implement.md b/.claude/commands/implement.md new file mode 100644 index 0000000..a9cfec6 --- /dev/null +++ b/.claude/commands/implement.md @@ -0,0 +1,22 @@ +Implement the tasks defined. + +This is the fourth step in the Spec-Driven Development lifecycle. + +Given the context provided as an argument, do this: + +1. Read the `.spec-kit/specs/[###-feature-name]/tasks.md` + +2. Execute the task in order. + + - If the task has [P] symbol, execute the task in parallel + +3. Validate if the task is well executed, perform any test, you consider necessary to check if is done. + + - If is incorrect retry the task. + - Run more tests, to validate the retry. + +4. Check for the next incomplete task. + + - the task must be executed in order. + +5. Repeat the process until all the task are completed. diff --git a/.claude/commands/plan.md b/.claude/commands/plan.md new file mode 100644 index 0000000..ac9140f --- /dev/null +++ b/.claude/commands/plan.md @@ -0,0 +1,39 @@ +Plan how to implement the specified feature. + +This is the second step in the Spec-Driven Development lifecycle. + +Given the implementation details provided as an argument, do this: + +1. Run `.spec-kit/scripts/setup-plan.sh --json` from the repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. All future file paths must be absolute. +2. Read and analyze the feature specification to understand: + + - The feature requirements and user stories + - Functional and non-functional requirements + - Success criteria and acceptance criteria + - Any technical constraints or dependencies mentioned + +3. Read the constitution at `.spec-kit/memory/constitution.md` to understand constitutional requirements. + +4. Execute the implementation plan template: + + - Load `.spec-kit/templates/plan-template.md` (already copied to IMPL_PLAN path) + - Set Input path to FEATURE_SPEC + - Run the Execution Flow (main) function steps 1-10 + - The template is self-contained and executable + - Follow error handling and gate checks as specified + - Let the template guide artifact generation in $SPECS_DIR: + - Phase 0 generates research.md + - Phase 1 generates data-model.md, contracts/, quickstart.md + - Phase 2 generates tasks.md + - Incorporate user-provided details from arguments into Technical Context: $ARGUMENTS + - Update Progress Tracking as you complete each phase + +5. Verify execution completed: + + - Check Progress Tracking shows all phases complete + - Ensure all required artifacts were generated + - Confirm no ERROR states in execution + +6. Report results with branch name, file paths, and generated artifacts. + +Use absolute paths with the repository root for all file operations to avoid path issues. diff --git a/.claude/commands/specify.md b/.claude/commands/specify.md new file mode 100644 index 0000000..195acb2 --- /dev/null +++ b/.claude/commands/specify.md @@ -0,0 +1,12 @@ +Start a new feature by creating a specification and feature branch. + +This is the first step in the Spec-Driven Development lifecycle. + +Given the feature description provided as an argument, do this: + +1. Run the script `.spec-kit/scripts/create-new-feature.sh --json "$ARGUMENTS"` from repo root and parse its JSON output for BRANCH_NAME and SPEC_FILE. All file paths must be absolute. +2. Load `.spec-kit/templates/spec-template.md` to understand required sections. +3. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings. +4. Report completion with branch name, spec file path, and readiness for the next phase. + +Note: The script creates and checks out the new branch and initializes the spec file before writing. diff --git a/.claude/commands/tasks.md b/.claude/commands/tasks.md new file mode 100644 index 0000000..9fdc302 --- /dev/null +++ b/.claude/commands/tasks.md @@ -0,0 +1,59 @@ + +Break down the plan into executable tasks. + +This is the third step in the Spec-Driven Development lifecycle. + +Given the context provided as an argument, do this: + +1. Run `.spec-kit/scripts/check-task-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. +2. Load and analyze available design documents: + - Always read plan.md for tech stack and libraries + - IF EXISTS: Read data-model.md for entities + - IF EXISTS: Read contracts/ for API endpoints + - IF EXISTS: Read research.md for technical decisions + - IF EXISTS: Read quickstart.md for test scenarios + + Note: Not all projects have all documents. For example: + - CLI tools might not have contracts/ + - Simple libraries might not need data-model.md + - Generate tasks based on what's available + +3. Generate tasks following the template: + - Use `.spec-kit/templates/tasks-template.md` as the base + - Replace example tasks with actual tasks based on: + * **Setup tasks**: Project init, dependencies, linting + * **Test tasks [P]**: One per contract, one per integration scenario + * **Core tasks**: One per entity, service, CLI command, endpoint + * **Integration tasks**: DB connections, middleware, logging + * **Polish tasks [P]**: Unit tests, performance, docs + +4. Task generation rules: + - Each contract file β†’ contract test task marked [P] + - Each entity in data-model β†’ model creation task marked [P] + - Each endpoint β†’ implementation task (not parallel if shared files) + - Each user story β†’ integration test marked [P] + - Different files = can be parallel [P] + - Same file = sequential (no [P]) + +5. Order tasks by dependencies: + - Setup before everything + - Tests before implementation (TDD) + - Models before services + - Services before endpoints + - Core before integration + - Everything before polish + +6. Include parallel execution examples: + - Group [P] tasks that can run together + - Show actual Task agent commands + +7. Create FEATURE_DIR/tasks.md with: + - Correct feature name from implementation plan + - Numbered tasks (T001, T002, etc.) + - Clear file paths for each task + - Dependency notes + - Parallel execution guidance + +Context for task generation: $ARGUMENTS + +The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context. diff --git a/.editorconfig b/.editorconfig index 476dde0..25a0d9a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,7 +9,7 @@ indent_style = space insert_final_newline = true trim_trailing_whitespace = true -[*.{yml,yaml}] +[*.{yml,yaml,json}] tab_width = 2 indent_size = 2 diff --git a/.gitignore b/.gitignore index 23fca33..343a173 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,7 @@ dist temp .vscode -.aider* +.serena + +# Added by goreleaser init: +dist/ diff --git a/.golangci.yml b/.golangci.yml index 73179e3..2f78b3f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,7 +3,6 @@ run: concurrency: 4 linters: enable: - - wsl exclusions: generated: lax presets: diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..65b2083 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,148 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com + +# The lines below are called `modelines`. See `:help modeline` +# Feel free to remove those if you don't want/need to use them. +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj + +version: 2 + +project_name: pm + +before: + hooks: + # You may remove this if you don't use go modules. + - go mod tidy + # you may remove this if you don't need go generate + - go generate ./... + +builds: + - main: ./cmd + env: + - CGO_ENABLED=0 + goos: + - linux + # - windows + - darwin + goarch: + - amd64 + - arm + - arm64 + flags: + - -v + ldflags: + - -s -w -X github.com/jlrosende/project-manager/internal.version={{.Version}} -X github.com/jlrosende/project-manager/internal.commit={{.Commit}} -X github.com/jlrosende/project-manager/internal.date={{.Date}} -X github.com/jlrosende/project-manager/internal.builtBy=goreleaser + +archives: + - formats: [tar.gz] + # this name template makes the OS and Arch compatible with the results of `uname`. + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + # use zip for windows archives + format_overrides: + - goos: windows + formats: [zip] + +dockers_v2: + # You can have multiple Docker images. + - # Path to the Dockerfile (from the project root). + # + # Default: 'Dockerfile'. + # Templates: allowed. + dockerfile: "build/docker/Dockerfile" + + # Image names. + # + # Empty image names are ignored. + # + # Templates: allowed. + images: + - "jlrosende/pm" + - "ghcr.io/jlrosende/pm" + + # Tag names. + # + # Empty tags are ignored. + # + # Templates: allowed. + tags: + - "v{{ .Version }}" + - "{{ if .IsNightly }}nightly{{ end }}" + - "{{ if not .IsNightly }}latest{{ end }}" + + # If your Dockerfile copies files other than binaries and packages, + # you should list them here as well. + # Note that GoReleaser will create the same structure inside a temporary + # directory, so if you add `foo/bar.json` here, on your Dockerfile you can + # `COPY foo/bar.json /whatever.json`. + # Also note that the paths here are relative to the directory in which + # GoReleaser is being run (usually the repository root directory). + # This field does not support wildcards, you can add an entire directory here + # and use wildcards when you `COPY`/`ADD` in your Dockerfile. + extra_files: + [] + # - config.yml + + # Labels to be added to the image. + # + # Items with empty keys or values will be ignored. + # + # Templates: allowed. + labels: + "org.opencontainers.image.description": "project manager client, manage your project configurations" + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.name": "{{.ProjectName}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.source": "{{.GitURL}}" + + # Annotations to be added to the image. + # + # Items with empty keys or values will be ignored. + # + # Templates: allowed. + annotations: + "project": "{{.ProjectName}}" + + # Platforms to build. + # + # Default: [ linux/amd64 linux/arm64 ] + platforms: + - linux/amd64 + - linux/arm64 + + # Additional `--build-arg`s to be passed. + # + # Templates: allowed. + build_args: + GO_VERSION: 1.25.1-alpine + + # Arbitrary flags to pass to the build command. + # + # Note: use this at your own risk. + # Note: flags must have the `=` sign between flag name and value. + # + # Templates: allowed. + flags: + [] + # - "--ulimit=10" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + +release: + footer: >- + + --- + + Released by [GoReleaser](https://github.com/goreleaser/goreleaser). diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..20b4244 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,34 @@ +{ + "mcpServers": { + "serena": { + "type": "stdio", + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide-assistant", + "--project", + "${PWD}" + ], + "env": {} + }, + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/readonly", + "headers": { + "Authorization": "Bearer ${GH_TOKEN}" + } + }, + "Context7": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@upstash/context7-mcp@latest" + ] + } + } +} diff --git a/.spec-kit/memory/constitution.md b/.spec-kit/memory/constitution.md new file mode 100644 index 0000000..1ed8d77 --- /dev/null +++ b/.spec-kit/memory/constitution.md @@ -0,0 +1,50 @@ +# [PROJECT_NAME] Constitution + + +## Core Principles + +### [PRINCIPLE_1_NAME] + +[PRINCIPLE_1_DESCRIPTION] + + +### [PRINCIPLE_2_NAME] + +[PRINCIPLE_2_DESCRIPTION] + + +### [PRINCIPLE_3_NAME] + +[PRINCIPLE_3_DESCRIPTION] + + +### [PRINCIPLE_4_NAME] + +[PRINCIPLE_4_DESCRIPTION] + + +### [PRINCIPLE_5_NAME] + +[PRINCIPLE_5_DESCRIPTION] + + +## [SECTION_2_NAME] + + +[SECTION_2_CONTENT] + + +## [SECTION_3_NAME] + + +[SECTION_3_CONTENT] + + +## Governance + + +[GOVERNANCE_RULES] + + +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] + \ No newline at end of file diff --git a/.spec-kit/memory/constitution_update_checklist.md b/.spec-kit/memory/constitution_update_checklist.md new file mode 100644 index 0000000..7f15d7f --- /dev/null +++ b/.spec-kit/memory/constitution_update_checklist.md @@ -0,0 +1,85 @@ +# Constitution Update Checklist + +When amending the constitution (`/memory/constitution.md`), ensure all dependent documents are updated to maintain consistency. + +## Templates to Update + +### When adding/modifying ANY article: +- [ ] `/templates/plan-template.md` - Update Constitution Check section +- [ ] `/templates/spec-template.md` - Update if requirements/scope affected +- [ ] `/templates/tasks-template.md` - Update if new task types needed +- [ ] `/.claude/commands/plan.md` - Update if planning process changes +- [ ] `/.claude/commands/tasks.md` - Update if task generation affected +- [ ] `/CLAUDE.md` - Update runtime development guidelines + +### Article-specific updates: + +#### Article I (Library-First): +- [ ] Ensure templates emphasize library creation +- [ ] Update CLI command examples +- [ ] Add llms.txt documentation requirements + +#### Article II (CLI Interface): +- [ ] Update CLI flag requirements in templates +- [ ] Add text I/O protocol reminders + +#### Article III (Test-First): +- [ ] Update test order in all templates +- [ ] Emphasize TDD requirements +- [ ] Add test approval gates + +#### Article IV (Integration Testing): +- [ ] List integration test triggers +- [ ] Update test type priorities +- [ ] Add real dependency requirements + +#### Article V (Observability): +- [ ] Add logging requirements to templates +- [ ] Include multi-tier log streaming +- [ ] Update performance monitoring sections + +#### Article VI (Versioning): +- [ ] Add version increment reminders +- [ ] Include breaking change procedures +- [ ] Update migration requirements + +#### Article VII (Simplicity): +- [ ] Update project count limits +- [ ] Add pattern prohibition examples +- [ ] Include YAGNI reminders + +## Validation Steps + +1. **Before committing constitution changes:** + - [ ] All templates reference new requirements + - [ ] Examples updated to match new rules + - [ ] No contradictions between documents + +2. **After updating templates:** + - [ ] Run through a sample implementation plan + - [ ] Verify all constitution requirements addressed + - [ ] Check that templates are self-contained (readable without constitution) + +3. **Version tracking:** + - [ ] Update constitution version number + - [ ] Note version in template footers + - [ ] Add amendment to constitution history + +## Common Misses + +Watch for these often-forgotten updates: +- Command documentation (`/commands/*.md`) +- Checklist items in templates +- Example code/commands +- Domain-specific variations (web vs mobile vs CLI) +- Cross-references between documents + +## Template Sync Status + +Last sync check: 2025-07-16 +- Constitution version: 2.1.1 +- Templates aligned: ❌ (missing versioning, observability details) + +--- + +*This checklist ensures the constitution's principles are consistently applied across all project documentation.* \ No newline at end of file diff --git a/.spec-kit/scripts/check-task-prerequisites.sh b/.spec-kit/scripts/check-task-prerequisites.sh new file mode 100755 index 0000000..b692fcd --- /dev/null +++ b/.spec-kit/scripts/check-task-prerequisites.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Check that implementation plan exists and find optional design documents +# Usage: ./check-task-prerequisites.sh [--json] + +set -e + +JSON_MODE=false +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=true ;; + --help|-h) echo "Usage: $0 [--json]"; exit 0 ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get all paths +eval $(get_feature_paths) + +# Check if on feature branch +check_feature_branch "$CURRENT_BRANCH" || exit 1 + +# Check if feature directory exists +if [[ ! -d "$FEATURE_DIR" ]]; then + echo "ERROR: Feature directory not found: $FEATURE_DIR" + echo "Run /specify first to create the feature structure." + exit 1 +fi + +# Check for implementation plan (required) +if [[ ! -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md not found in $FEATURE_DIR" + echo "Run /plan first to create the plan." + exit 1 +fi + +if $JSON_MODE; then + # Build JSON array of available docs that actually exist + docs=() + [[ -f "$RESEARCH" ]] && docs+=("research.md") + [[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") + ([[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]) && docs+=("contracts/") + [[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + # join array into JSON + json_docs=$(printf '"%s",' "${docs[@]}") + json_docs="[${json_docs%,}]" + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$FEATURE_DIR" "$json_docs" +else + # List available design documents (optional) + echo "FEATURE_DIR:$FEATURE_DIR" + echo "AVAILABLE_DOCS:" + + # Use common check functions + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" +fi + +# Always succeed - task generation should work with whatever docs are available \ No newline at end of file diff --git a/.spec-kit/scripts/common.sh b/.spec-kit/scripts/common.sh new file mode 100755 index 0000000..1e0ff5d --- /dev/null +++ b/.spec-kit/scripts/common.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Common functions and variables for all scripts + +# Get repository root +get_repo_root() { + git rev-parse --show-toplevel +} + +# Get current branch +get_current_branch() { + git rev-parse --abbrev-ref HEAD +} + +# Check if current branch is a feature branch +# Returns 0 if valid, 1 if not +check_feature_branch() { + local branch="$1" + if [[ ! "$branch" =~ ^[0-9]{3}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $branch" + echo "Feature branches should be named like: 001-feature-name" + return 1 + fi + return 0 +} + +# Get feature directory path +get_feature_dir() { + local repo_root="$1" + local branch="$2" + echo "$repo_root/.spec-kit/specs/$branch" +} + +# Get all standard paths for a feature +# Usage: eval $(get_feature_paths) +# Sets: REPO_ROOT, CURRENT_BRANCH, FEATURE_DIR, FEATURE_SPEC, IMPL_PLAN, TASKS +get_feature_paths() { + local repo_root=$(get_repo_root) + local current_branch=$(get_current_branch) + local feature_dir=$(get_feature_dir "$repo_root" "$current_branch") + + echo "REPO_ROOT='$repo_root'" + echo "CURRENT_BRANCH='$current_branch'" + echo "FEATURE_DIR='$feature_dir'" + echo "FEATURE_SPEC='$feature_dir/spec.md'" + echo "IMPL_PLAN='$feature_dir/plan.md'" + echo "TASKS='$feature_dir/tasks.md'" + echo "RESEARCH='$feature_dir/research.md'" + echo "DATA_MODEL='$feature_dir/data-model.md'" + echo "QUICKSTART='$feature_dir/quickstart.md'" + echo "CONTRACTS_DIR='$feature_dir/contracts'" +} + +# Check if a file exists and report +check_file() { + local file="$1" + local description="$2" + if [[ -f "$file" ]]; then + echo " βœ“ $description" + return 0 + else + echo " βœ— $description" + return 1 + fi +} + +# Check if a directory exists and has files +check_dir() { + local dir="$1" + local description="$2" + if [[ -d "$dir" ]] && [[ -n "$(ls -A "$dir" 2>/dev/null)" ]]; then + echo " βœ“ $description" + return 0 + else + echo " βœ— $description" + return 1 + fi +} diff --git a/.spec-kit/scripts/create-new-feature.sh b/.spec-kit/scripts/create-new-feature.sh new file mode 100755 index 0000000..494510b --- /dev/null +++ b/.spec-kit/scripts/create-new-feature.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Create a new feature with branch, directory structure, and template +# Usage: ./create-new-feature.sh "feature description" +# ./create-new-feature.sh --json "feature description" + +set -e + +JSON_MODE=false + +# Collect non-flag args +ARGS=() +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --help|-h) + echo "Usage: $0 [--json] "; exit 0 ;; + *) + ARGS+=("$arg") ;; + esac +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] " >&2 + exit 1 +fi + +# Get repository root +REPO_ROOT=$(git rev-parse --show-toplevel) +SPECS_DIR="$REPO_ROOT/.spec-kit/specs" + +# Create specs directory if it doesn't exist +mkdir -p "$SPECS_DIR" + +# Find the highest numbered feature directory +HIGHEST=0 +if [ -d "$SPECS_DIR" ]; then + for dir in "$SPECS_DIR"/*; do + if [ -d "$dir" ]; then + dirname=$(basename "$dir") + number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$HIGHEST" ]; then + HIGHEST=$number + fi + fi + done +fi + +# Generate next feature number with zero padding +NEXT=$((HIGHEST + 1)) +FEATURE_NUM=$(printf "%03d" "$NEXT") + +# Create branch name from description +BRANCH_NAME=$(echo "$FEATURE_DESCRIPTION" | \ + tr '[:upper:]' '[:lower:]' | \ + sed 's/[^a-z0-9]/-/g' | \ + sed 's/-\+/-/g' | \ + sed 's/^-//' | \ + sed 's/-$//') + +# Extract 2-3 meaningful words +WORDS=$(echo "$BRANCH_NAME" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//') + +# Final branch name +BRANCH_NAME="${FEATURE_NUM}-${WORDS}" + +# Create and switch to new branch +git checkout -b "$BRANCH_NAME" + +# Create feature directory +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +mkdir -p "$FEATURE_DIR" + +# Copy template if it exists +TEMPLATE="$REPO_ROOT/.spec-kit/templates/spec-template.md" +SPEC_FILE="$FEATURE_DIR/spec.md" + +if [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" +else + echo "Warning: Template not found at $TEMPLATE" >&2 + touch "$SPEC_FILE" +fi + +if $JSON_MODE; then + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' \ + "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM" +else + # Output results for the LLM to use (legacy key: value format) + echo "BRANCH_NAME: $BRANCH_NAME" + echo "SPEC_FILE: $SPEC_FILE" + echo "FEATURE_NUM: $FEATURE_NUM" +fi diff --git a/.spec-kit/scripts/get-feature-paths.sh b/.spec-kit/scripts/get-feature-paths.sh new file mode 100755 index 0000000..b1e1fbc --- /dev/null +++ b/.spec-kit/scripts/get-feature-paths.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Get paths for current feature branch without creating anything +# Used by commands that need to find existing feature files + +set -e + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get all paths +eval $(get_feature_paths) + +# Check if on feature branch +check_feature_branch "$CURRENT_BRANCH" || exit 1 + +# Output paths (don't create anything) +echo "REPO_ROOT: $REPO_ROOT" +echo "BRANCH: $CURRENT_BRANCH" +echo "FEATURE_DIR: $FEATURE_DIR" +echo "FEATURE_SPEC: $FEATURE_SPEC" +echo "IMPL_PLAN: $IMPL_PLAN" +echo "TASKS: $TASKS" \ No newline at end of file diff --git a/.spec-kit/scripts/setup-plan.sh b/.spec-kit/scripts/setup-plan.sh new file mode 100755 index 0000000..27a4b25 --- /dev/null +++ b/.spec-kit/scripts/setup-plan.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Setup implementation plan structure for current branch +# Returns paths needed for implementation plan generation +# Usage: ./setup-plan.sh [--json] + +set -e + +JSON_MODE=false +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=true ;; + --help|-h) echo "Usage: $0 [--json]"; exit 0 ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get all paths +eval $(get_feature_paths) + +# Check if on feature branch +check_feature_branch "$CURRENT_BRANCH" || exit 1 + +# Create specs directory if it doesn't exist +mkdir -p "$FEATURE_DIR" + +# Copy plan template if it exists +TEMPLATE="$REPO_ROOT/.spec-kit/templates/plan-template.md" +if [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$IMPL_PLAN" +fi + +if $JSON_MODE; then + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \ + "$FEATURE_SPEC" "$IMPL_PLAN" "$FEATURE_DIR" "$CURRENT_BRANCH" +else + # Output all paths for LLM use + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "SPECS_DIR: $FEATURE_DIR" + echo "BRANCH: $CURRENT_BRANCH" +fi diff --git a/.spec-kit/scripts/update-agent-context.sh b/.spec-kit/scripts/update-agent-context.sh new file mode 100755 index 0000000..790f0b3 --- /dev/null +++ b/.spec-kit/scripts/update-agent-context.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +# Incrementally update agent context files based on new feature plan +# Supports: CLAUDE.md, GEMINI.md, and .github/copilot-instructions.md +# O(1) operation - only reads current context file and new plan.md + +set -e + +REPO_ROOT=$(git rev-parse --show-toplevel) +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +FEATURE_DIR="$REPO_ROOT/.spec-kit/specs/$CURRENT_BRANCH" +NEW_PLAN="$FEATURE_DIR/plan.md" + +# Determine which agent context files to update +CLAUDE_FILE="$REPO_ROOT/CLAUDE.md" +GEMINI_FILE="$REPO_ROOT/GEMINI.md" +COPILOT_FILE="$REPO_ROOT/.github/copilot-instructions.md" + +# Allow override via argument +AGENT_TYPE="$1" + +if [ ! -f "$NEW_PLAN" ]; then + echo "ERROR: No plan.md found at $NEW_PLAN" + exit 1 +fi + +echo "=== Updating agent context files for feature $CURRENT_BRANCH ===" + +# Extract tech from new plan +NEW_LANG=$(grep "^**Language/Version**: " "$NEW_PLAN" 2>/dev/null | head -1 | sed 's/^**Language\/Version**: //' | grep -v "NEEDS CLARIFICATION" || echo "") +NEW_FRAMEWORK=$(grep "^**Primary Dependencies**: " "$NEW_PLAN" 2>/dev/null | head -1 | sed 's/^**Primary Dependencies**: //' | grep -v "NEEDS CLARIFICATION" || echo "") +NEW_TESTING=$(grep "^**Testing**: " "$NEW_PLAN" 2>/dev/null | head -1 | sed 's/^**Testing**: //' | grep -v "NEEDS CLARIFICATION" || echo "") +NEW_DB=$(grep "^**Storage**: " "$NEW_PLAN" 2>/dev/null | head -1 | sed 's/^**Storage**: //' | grep -v "N/A" | grep -v "NEEDS CLARIFICATION" || echo "") +NEW_PROJECT_TYPE=$(grep "^**Project Type**: " "$NEW_PLAN" 2>/dev/null | head -1 | sed 's/^**Project Type**: //' || echo "") + +# Function to update a single agent context file +update_agent_file() { + local target_file="$1" + local agent_name="$2" + + echo "Updating $agent_name context file: $target_file" + + # Create temp file for new context + local temp_file=$(mktemp) + + # If file doesn't exist, create from template + if [ ! -f "$target_file" ]; then + echo "Creating new $agent_name context file..." + + # Check if this is the SDD repo itself + if [ -f "$REPO_ROOT/.spec-kit/templates/agent-file-template.md" ]; then + cp "$REPO_ROOT/.spec-kit/templates/agent-file-template.md" "$temp_file" + else + echo "ERROR: Template not found at $REPO_ROOT/.spec-kit/templates/agent-file-template.md" + return 1 + fi + + # Replace placeholders + sed -i.bak "s/\[PROJECT NAME\]/$(basename $REPO_ROOT)/" "$temp_file" + sed -i.bak "s/\[DATE\]/$(date +%Y-%m-%d)/" "$temp_file" + sed -i.bak "s/\[EXTRACTED FROM ALL PLAN.MD FILES\]/- $NEW_LANG + $NEW_FRAMEWORK ($CURRENT_BRANCH)/" "$temp_file" + + # Add project structure based on type + if [[ "$NEW_PROJECT_TYPE" == *"web"* ]]; then + sed -i.bak "s|\[ACTUAL STRUCTURE FROM PLANS\]|backend/\nfrontend/\ntests/|" "$temp_file" + else + sed -i.bak "s|\[ACTUAL STRUCTURE FROM PLANS\]|src/\ntests/|" "$temp_file" + fi + + # Add minimal commands + if [[ "$NEW_LANG" == *"Python"* ]]; then + COMMANDS="cd src && pytest && ruff check ." + elif [[ "$NEW_LANG" == *"Rust"* ]]; then + COMMANDS="cargo test && cargo clippy" + elif [[ "$NEW_LANG" == *"JavaScript"* ]] || [[ "$NEW_LANG" == *"TypeScript"* ]]; then + COMMANDS="npm test && npm run lint" + else + COMMANDS="# Add commands for $NEW_LANG" + fi + sed -i.bak "s|\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]|$COMMANDS|" "$temp_file" + + # Add code style + sed -i.bak "s|\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]|$NEW_LANG: Follow standard conventions|" "$temp_file" + + # Add recent changes + sed -i.bak "s|\[LAST 3 FEATURES AND WHAT THEY ADDED\]|- $CURRENT_BRANCH: Added $NEW_LANG + $NEW_FRAMEWORK|" "$temp_file" + + rm "$temp_file.bak" + else + echo "Updating existing $agent_name context file..." + + # Extract manual additions + local manual_start=$(grep -n "" "$target_file" | cut -d: -f1) + local manual_end=$(grep -n "" "$target_file" | cut -d: -f1) + + if [ ! -z "$manual_start" ] && [ ! -z "$manual_end" ]; then + sed -n "${manual_start},${manual_end}p" "$target_file" > /tmp/manual_additions.txt + fi + + # Parse existing file and create updated version + python3 - << EOF +import re +import sys +from datetime import datetime + +# Read existing file +with open("$target_file", 'r') as f: + content = f.read() + +# Check if new tech already exists +tech_section = re.search(r'## Active Technologies\n(.*?)\n\n', content, re.DOTALL) +if tech_section: + existing_tech = tech_section.group(1) + + # Add new tech if not already present + new_additions = [] + if "$NEW_LANG" and "$NEW_LANG" not in existing_tech: + new_additions.append(f"- $NEW_LANG + $NEW_FRAMEWORK ($CURRENT_BRANCH)") + if "$NEW_DB" and "$NEW_DB" not in existing_tech and "$NEW_DB" != "N/A": + new_additions.append(f"- $NEW_DB ($CURRENT_BRANCH)") + + if new_additions: + updated_tech = existing_tech + "\n" + "\n".join(new_additions) + content = content.replace(tech_section.group(0), f"## Active Technologies\n{updated_tech}\n\n") + +# Update project structure if needed +if "$NEW_PROJECT_TYPE" == "web" and "frontend/" not in content: + struct_section = re.search(r'## Project Structure\n\`\`\`\n(.*?)\n\`\`\`', content, re.DOTALL) + if struct_section: + updated_struct = struct_section.group(1) + "\nfrontend/src/ # Web UI" + content = re.sub(r'(## Project Structure\n\`\`\`\n).*?(\n\`\`\`)', + f'\\1{updated_struct}\\2', content, flags=re.DOTALL) + +# Add new commands if language is new +if "$NEW_LANG" and f"# {NEW_LANG}" not in content: + commands_section = re.search(r'## Commands\n\`\`\`bash\n(.*?)\n\`\`\`', content, re.DOTALL) + if not commands_section: + commands_section = re.search(r'## Commands\n(.*?)\n\n', content, re.DOTALL) + + if commands_section: + new_commands = commands_section.group(1) + if "Python" in "$NEW_LANG": + new_commands += "\ncd src && pytest && ruff check ." + elif "Rust" in "$NEW_LANG": + new_commands += "\ncargo test && cargo clippy" + elif "JavaScript" in "$NEW_LANG" or "TypeScript" in "$NEW_LANG": + new_commands += "\nnpm test && npm run lint" + + if "```bash" in content: + content = re.sub(r'(## Commands\n\`\`\`bash\n).*?(\n\`\`\`)', + f'\\1{new_commands}\\2', content, flags=re.DOTALL) + else: + content = re.sub(r'(## Commands\n).*?(\n\n)', + f'\\1{new_commands}\\2', content, flags=re.DOTALL) + +# Update recent changes (keep only last 3) +changes_section = re.search(r'## Recent Changes\n(.*?)(\n\n|$)', content, re.DOTALL) +if changes_section: + changes = changes_section.group(1).strip().split('\n') + changes.insert(0, f"- $CURRENT_BRANCH: Added $NEW_LANG + $NEW_FRAMEWORK") + # Keep only last 3 + changes = changes[:3] + content = re.sub(r'(## Recent Changes\n).*?(\n\n|$)', + f'\\1{chr(10).join(changes)}\\2', content, flags=re.DOTALL) + +# Update date +content = re.sub(r'Last updated: \d{4}-\d{2}-\d{2}', + f'Last updated: {datetime.now().strftime("%Y-%m-%d")}', content) + +# Write to temp file +with open("$temp_file", 'w') as f: + f.write(content) +EOF + + # Restore manual additions if they exist + if [ -f /tmp/manual_additions.txt ]; then + # Remove old manual section from temp file + sed -i.bak '//,//d' "$temp_file" + # Append manual additions + cat /tmp/manual_additions.txt >> "$temp_file" + rm /tmp/manual_additions.txt "$temp_file.bak" + fi + fi + + # Move temp file to final location + mv "$temp_file" "$target_file" + echo "βœ… $agent_name context file updated successfully" +} + +# Update files based on argument or detect existing files +case "$AGENT_TYPE" in + "claude") + update_agent_file "$CLAUDE_FILE" "Claude Code" + ;; + "gemini") + update_agent_file "$GEMINI_FILE" "Gemini CLI" + ;; + "copilot") + update_agent_file "$COPILOT_FILE" "GitHub Copilot" + ;; + "") + # Update all existing files + [ -f "$CLAUDE_FILE" ] && update_agent_file "$CLAUDE_FILE" "Claude Code" + [ -f "$GEMINI_FILE" ] && update_agent_file "$GEMINI_FILE" "Gemini CLI" + [ -f "$COPILOT_FILE" ] && update_agent_file "$COPILOT_FILE" "GitHub Copilot" + + # If no files exist, create based on current directory or ask user + if [ ! -f "$CLAUDE_FILE" ] && [ ! -f "$GEMINI_FILE" ] && [ ! -f "$COPILOT_FILE" ]; then + echo "No agent context files found. Creating Claude Code context file by default." + update_agent_file "$CLAUDE_FILE" "Claude Code" + fi + ;; + *) + echo "ERROR: Unknown agent type '$AGENT_TYPE'. Use: claude, gemini, copilot, or leave empty for all." + exit 1 + ;; +esac +echo "" +echo "Summary of changes:" +if [ ! -z "$NEW_LANG" ]; then + echo "- Added language: $NEW_LANG" +fi +if [ ! -z "$NEW_FRAMEWORK" ]; then + echo "- Added framework: $NEW_FRAMEWORK" +fi +if [ ! -z "$NEW_DB" ] && [ "$NEW_DB" != "N/A" ]; then + echo "- Added database: $NEW_DB" +fi + +echo "" +echo "Usage: $0 [claude|gemini|copilot]" +echo " - No argument: Update all existing agent context files" +echo " - claude: Update only CLAUDE.md" +echo " - gemini: Update only GEMINI.md" +echo " - copilot: Update only .github/copilot-instructions.md" diff --git a/.spec-kit/specs/001-a-project-manager/contracts/messages.md b/.spec-kit/specs/001-a-project-manager/contracts/messages.md new file mode 100644 index 0000000..f148b51 --- /dev/null +++ b/.spec-kit/specs/001-a-project-manager/contracts/messages.md @@ -0,0 +1,13 @@ +# Contracts: UI Messages + +## ProjectsLoadedMsg +- Input: projects []Project +- Effect: populate projects, select first, derive envs + +## ProjectSelectedMsg +- Input: index int +- Effect: update selection, derive envs + +## WindowSizeMsg +- Input: width int, height int +- Effect: update layout/collapse behavior \ No newline at end of file diff --git a/.spec-kit/specs/001-a-project-manager/data-model.md b/.spec-kit/specs/001-a-project-manager/data-model.md new file mode 100644 index 0000000..17da4ab --- /dev/null +++ b/.spec-kit/specs/001-a-project-manager/data-model.md @@ -0,0 +1,29 @@ +# Data Model: TUI environments column + +## Entities +- Project + - name: string (unique) + - environments: []string + +- UIModel + - projects: []Project + - selectedProjectIdx: int + - envsForSelected: []string + - width: int + - height: int + - styles: struct{ left, right, title } + +## Messages (contracts) +- ProjectsLoadedMsg{ projects []Project } +- ProjectSelectedMsg{ index int } +- WindowSizeMsg{ width, height int } + +## Validation Rules +- selectedProjectIdx in [0, len(projects)) else envsForSelected = nil +- envsForSelected = projects[selectedProjectIdx].environments +- collapseRight = width < 60 + +## State Transitions +- ProjectsLoadedMsg β†’ projects set, selectedProjectIdx=0, derive envsForSelected +- ProjectSelectedMsg β†’ selectedProjectIdx updated, derive envsForSelected +- WindowSizeMsg β†’ width/height set, affects collapseRight \ No newline at end of file diff --git a/.spec-kit/specs/001-a-project-manager/plan.md b/.spec-kit/specs/001-a-project-manager/plan.md new file mode 100644 index 0000000..c11e369 --- /dev/null +++ b/.spec-kit/specs/001-a-project-manager/plan.md @@ -0,0 +1,151 @@ +# Implementation Plan: TUI environments column + +**Branch**: `001-a-project-manager` | **Date**: 2025-09-14 | **Spec**: /home/jlrosende/personal/project-manager/.spec-kit/specs/001-a-project-manager/spec.md +**Input**: Feature specification from `/specs/001-a-project-manager/spec.md` + +## Execution Flow (/plan command scope) +``` +1. Load feature spec from Input path + β†’ Found: spec.md +2. Fill Technical Context (scan for NEEDS CLARIFICATION) + β†’ No new unknowns for this TUI-only change beyond existing global clarifications +3. Evaluate Constitution Check section below + β†’ No violations for a small UI enhancement + β†’ Update Progress Tracking: Initial Constitution Check +4. Execute Phase 0 β†’ research.md + β†’ All TUI unknowns resolved +5. Execute Phase 1 β†’ contracts, data-model.md, quickstart.md +6. Re-evaluate Constitution Check section + β†’ No new violations + β†’ Update Progress Tracking: Post-Design Constitution Check +7. Plan Phase 2 β†’ Describe task generation approach (DO NOT create tasks.md) +8. STOP - Ready for /tasks command +``` + +## Summary +Add an environments column/pane to the TUI that displays the available environments for the currently selected project. When the project selection changes, the environments list updates. Styling uses lipgloss, and state management follows the Elm architecture using bubbletea. No secrets are displayed; only environment names. + +## Technical Context +**Language/Version**: Go 1.25.1 +**Primary Dependencies**: github.com/charmbracelet/bubbletea v1.3.9, github.com/charmbracelet/bubbles v0.21.0, github.com/charmbracelet/lipgloss v1.1.0 +**Storage**: N/A +**Testing**: go test (NEEDS CLARIFICATION: test layout and commands) +**Target Platform**: Linux/macOS terminal +**Project Type**: single +**Performance Goals**: Instant UI update on selection (<50ms perceived) +**Constraints**: Do not display or log secrets; keep UI responsive in small terminals +**Scale/Scope**: Dozens of projects, a handful of environments each + +Implementation placement and constraints from user input: +- TUI handlers in `internal/adapters/handlers` +- Custom UI components in `pkg/ui` +- Elm architecture with bubbletea; styles via lipgloss + +## Constitution Check + +**Simplicity**: +- Projects: 1 (cli) +- Using framework directly? Yes (bubbletea/bubbles) +- Single data model? Yes (UI model augmented with envs list) +- Avoiding patterns? Yes (no extra abstraction) + +**Architecture**: +- Feature shipped within existing app; no new library required +- Libraries listed: bubbletea/bubbles (UI), lipgloss (styles) +- CLI per library: N/A +- Library docs: N/A + +**Testing (NON-NEGOTIABLE)**: +- RED-GREEN planned for UI message handling and rendering helpers +- Order: contract (messages) β†’ integration (model update/render) β†’ unit (helpers) +- Real deps: yes (bubbletea) + +**Observability**: +- Use existing structured logging if present; avoid logging secrets + +**Versioning**: +- Folded into current feature branch; no API breakage + +## Project Structure + +### Documentation (this feature) +``` +specs/001-a-project-manager/ +β”œβ”€β”€ plan.md # This file (/plan command output) +β”œβ”€β”€ research.md # Phase 0 output (/plan command) +β”œβ”€β”€ data-model.md # Phase 1 output (/plan command) +β”œβ”€β”€ quickstart.md # Phase 1 output (/plan command) +└── contracts/ # Phase 1 output (/plan command) +``` + +### Source Code (repository root) +``` +# Single project +internal/adapters/handlers # TUI handlers (update/view) +pkg/ui # UI components (lists, columns, styles) +``` + +**Structure Decision**: Option 1 (single project) + +## Phase 0: Outline & Research +1. Unknowns identified and resolved + - Layout approach: split view with projects list (left) and environments list (right) + - Component choice: bubbles list or table; choose list for environments for simplicity + - State updates: on project selection change, derive envs for selected project + - Sizing behavior: responsive split using terminal width; collapse envs when width too small +2. Best practices + - Use tea.Msg for selection changes; keep rendering stateless from model + - Avoid heavy computation in View; precompute styles +3. Output: research.md with decisions and rationale + +**Output**: research.md with all TUI unknowns resolved + +## Phase 1: Design & Contracts +1. Entities β†’ `data-model.md` + - Project(name, environments[]string) + - UI Model: selectedProjectIdx int, projects []Project, envsForSelected []string, width/height, styles + - Messages: ProjectsLoadedMsg, ProjectSelectedMsg, WindowSizeMsg +2. Contracts β†’ `/contracts/` + - Message/event contracts for selection and rendering +3. Contract tests (planned) + - Ensure envs list updates when selection changes + - Ensure collapse behavior under narrow width +4. Test scenarios + - Validate user can see environments of selected project; switching selection updates list +5. Agent file: N/A + +**Output**: data-model.md, /contracts/*, quickstart.md + +## Phase 2: Task Planning Approach +**Task Generation Strategy**: +- Derive tasks from data-model and contracts: add model fields, implement Update cases, implement View split, add components in pkg/ui + +**Ordering Strategy**: +- TDD: write contract tests for message handling before implementation +- Dependency: model before view; styles last +- Parallel: style constants and component skeletons + +**Estimated Output**: Will be produced by /tasks + +## Complexity Tracking +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| β€” | β€” | β€” | + +## Progress Tracking +**Phase Status**: +- [x] Phase 0: Research complete (/plan command) +- [x] Phase 1: Design complete (/plan command) +- [x] Phase 2: Task planning complete (/plan command - describe approach only) +- [ ] Phase 3: Tasks generated (/tasks command) +- [ ] Phase 4: Implementation complete +- [ ] Phase 5: Validation passed + +**Gate Status**: +- [x] Initial Constitution Check: PASS +- [x] Post-Design Constitution Check: PASS +- [x] All NEEDS CLARIFICATION resolved (for this TUI scope) +- [ ] Complexity deviations documented (N/A) + +--- +*Based on Constitution v2.1.1 - See `/memory/constitution.md`* \ No newline at end of file diff --git a/.spec-kit/specs/001-a-project-manager/quickstart.md b/.spec-kit/specs/001-a-project-manager/quickstart.md new file mode 100644 index 0000000..f69e372 --- /dev/null +++ b/.spec-kit/specs/001-a-project-manager/quickstart.md @@ -0,0 +1,7 @@ +# Quickstart: View environments for selected project + +1. Start the TUI +2. Use Up/Down to select a project in the left pane +3. Observe the right pane lists environments for the selected project +4. Resize terminal smaller than 60 columns β†’ environments pane collapses +5. Resize back β†’ environments pane reappears with correct list \ No newline at end of file diff --git a/.spec-kit/specs/001-a-project-manager/research.md b/.spec-kit/specs/001-a-project-manager/research.md new file mode 100644 index 0000000..7970ccd --- /dev/null +++ b/.spec-kit/specs/001-a-project-manager/research.md @@ -0,0 +1,20 @@ +# Research: TUI environments column + +## Decisions +- Use bubbles list for environments pane +- Split layout horizontally: left projects list, right environments list +- Update envs list on ProjectSelectedMsg in Update() +- Collapse environments pane if width < 60 cols +- Precompute lipgloss styles in model init + +## Rationale +- Bubbles list integrates with Bubble Tea and matches existing patterns +- Horizontal split mirrors mental model: select project β†’ see envs +- Update on selection ensures single source of truth in Elm model +- Collapse rule maintains usability on small terminals +- Precomputed styles reduce per-frame cost + +## Alternatives Considered +- Table component: heavier API, unnecessary for simple names +- Vertical split with tabs: wastes vertical space; less discoverable +- Recomputing styles per View(): simpler code but slower rendering \ No newline at end of file diff --git a/.spec-kit/specs/001-a-project-manager/spec.md b/.spec-kit/specs/001-a-project-manager/spec.md new file mode 100644 index 0000000..e2ffbe7 --- /dev/null +++ b/.spec-kit/specs/001-a-project-manager/spec.md @@ -0,0 +1,145 @@ +# Feature Specification: Project Manager Terminal Application + +**Feature Branch**: `001-a-project-manager` +**Created**: 2025-09-14 +**Status**: Draft +**Input**: User description: "A project manager terminal application, the purpose is manage multiple projects and configurations. With this project you have the habiliti to start new terminal sesions with the configuration, credentials and secrets for a specific project. Also add new projects and edit the configurations. Each project can have multiple environments and settings. Only one project can be executed in a terminal session." + +## Execution Flow (main) +``` +1. Parse user description from Input + ’ If empty: ERROR "No feature description provided" +2. Extract key concepts from description + ’ Identify: actors, actions, data, constraints +3. For each unclear aspect: + ’ Mark with [NEEDS CLARIFICATION: specific question] +4. Fill User Scenarios & Testing section + ’ If no clear user flow: ERROR "Cannot determine user scenarios" +5. Generate Functional Requirements + ’ Each requirement must be testable + ’ Mark ambiguous requirements +6. Identify Key Entities (if data involved) +7. Run Review Checklist + ’ If any [NEEDS CLARIFICATION]: WARN "Spec has uncertainties" + ’ If implementation details found: ERROR "Remove tech details" +8. Return: SUCCESS (spec ready for planning) +``` + +--- + +## ‘ Quick Guidelines +-  Focus on WHAT users need and WHY +- L Avoid HOW to implement (no tech stack, APIs, code structure) +- =e Written for business stakeholders, not developers + +### Section Requirements +- Mandatory sections: Must be completed for every feature +- Optional sections: Include only when relevant to the feature +- When a section doesn't apply, remove it entirely (don't leave as "N/A") + +### For AI Generation +When creating this spec from a user prompt: +1. Mark all ambiguities: Use [NEEDS CLARIFICATION: specific question] for any assumption you'd need to make +2. Don't guess: If the prompt doesn't specify something (e.g., "login system" without auth method), mark it +3. Think like a tester: Every vague requirement should fail the "testable and unambiguous" checklist item +4. Common underspecified areas: + - User types and permissions + - Data retention/deletion policies + - Performance targets and scale + - Error handling behaviors + - Integration requirements + - Security/compliance needs + +--- + +## User Scenarios & Testing (mandatory) + +### Primary User Story +As a user managing multiple projects, I want to create projects with environments and start terminal sessions scoped to a selected project and environment so that configuration, credentials, and secrets are correctly applied and isolated per session. + +### Acceptance Scenarios +1. Given no projects exist, When the user creates a project named "Alpha", Then "Alpha" appears in the project list with no environments and a success message is shown. +2. Given project "Alpha" exists, When the user adds environment "dev" with configuration settings and secret references, Then "dev" appears under "Alpha" and its settings are saved. +3. Given project "Alpha" with environment "dev" exists, When the user starts a terminal session for "Alpha" ’ "dev", Then a terminal session starts with only "Alpha:dev" configuration, credentials, and secrets applied, and the session indicator shows the active project and environment. +4. Given a terminal session is active for project "Alpha", When the user attempts to start project "Beta" in the same terminal, Then the action is blocked with a clear message that only one project may run per terminal session and the user is prompted to end the current session or proceed in a new terminal [NEEDS CLARIFICATION: should opening a new terminal be offered automatically?]. +5. Given environment settings for "Alpha:dev" exist, When the user edits configuration values, Then subsequent sessions for "Alpha:dev" reflect the updated settings and a confirmation is shown. +6. Given secrets required by "Alpha:dev" are missing, When the user starts a session, Then the start fails with an actionable error listing missing items without exposing secret values or paths. + +### Edge Cases +- Creating a project or environment with a duplicate name prompts the user to choose a different name. +- Starting a session when one is already active in the same terminal provides a clear resolution path (end current, cancel, or new terminal) [NEEDS CLARIFICATION]. +- Conflicting environment variables between global and project scope are resolved in favor of the active project, with a warning shown [NEEDS CLARIFICATION: should warnings be shown or silently override?]. +- Session start is aborted if any required secret reference is unresolved; partial application must not occur. +- Attempting to switch the active project mid-session requires confirmation and ends the current session before starting the new one. +- Long-running sessions are not disrupted by background project modifications; changes apply only to sessions started after edits. + +## Requirements (mandatory) + +### Functional Requirements +- FR-001: The system MUST allow users to create, view, rename, and delete projects. +- FR-002: The system MUST allow each project to have multiple named environments (e.g., dev, staging, prod). +- FR-003: The system MUST allow users to add, edit, and remove configuration settings per project environment. +- FR-004: The system MUST support referencing credentials and secrets required by an environment without exposing their values in logs or UI. +- FR-005: The system MUST start a terminal session for a selected project and environment, applying only that context's settings. +- FR-006: The system MUST prevent running more than one project in the same terminal session and clearly inform the user when blocked. +- FR-007: The system MUST provide a clear indicator of the active project and environment for the current session. +- FR-008: The system MUST provide commands or UI to list projects and their environments. +- FR-009: The system MUST validate that required configuration and secret references exist before starting a session and fail fast with actionable errors. +- FR-010: The system MUST ensure project-specific settings do not leak into sessions of other projects once a session ends. +- FR-011: The system MUST allow editing configurations for existing projects and environments and persist those changes. +- FR-012: The system MUST avoid printing or storing secret values in plaintext and MUST redact potential secret content from messages and logs. +- FR-013: The system SHOULD provide an option to end the current session gracefully before starting another project [NEEDS CLARIFICATION: define "graceful" termination behavior]. +- FR-014: The system SHOULD support selecting a default environment when starting a session if none is specified [NEEDS CLARIFICATION]. +- FR-015: The system SHOULD support warnings for configuration conflicts (e.g., variable overrides) with user acknowledgement [NEEDS CLARIFICATION: warning behavior]. +- FR-016: The system SHOULD provide basic usage feedback (e.g., last used project/environment) without storing sensitive data [NEEDS CLARIFICATION: retention policy]. + +- FR-017: Security & Compliance Constraints + - FR-017a: Secrets MUST be handled via references only; values are never persisted by the system. + - FR-017b: Logs MUST not include secret values or sensitive file paths. + - FR-017c: The system MUST minimize secret exposure duration and scope to the active session only. + - FR-017d: The system SHOULD support external secret stores (e.g., OS keychain, secret manager) via references [NEEDS CLARIFICATION: which providers]. + +- FR-018: Reliability & UX + - FR-018a: Starting a session MUST fail atomically if validation fails (no partial configuration applied). + - FR-018b: Users MUST receive clear, actionable error messages for missing or invalid configuration. + - FR-018c: The system SHOULD complete session start within a target time budget [NEEDS CLARIFICATION: target seconds]. + +### Key Entities (include if feature involves data) +- Project: Represents a workspace with its own configurations and environments. Key attributes: name (unique), description [NEEDS CLARIFICATION], environments. +- Environment: Represents a named context within a project (e.g., dev, staging). Key attributes: name (unique within project), configuration settings, required secret references, optional notes. +- Configuration Item: Represents a non-secret setting applied to a session (e.g., environment variable, path). Key attributes: key, value, scope (project/environment), is_override [NEEDS CLARIFICATION]. +- Credential/Secret Reference: Represents a pointer to a secret managed outside the system. Key attributes: label, source/provider [NEEDS CLARIFICATION], scope, required/optional. +- Session: Represents an active terminal session bound to a project and environment. Key attributes: project, environment, start time, status, termination reason [NEEDS CLARIFICATION]. + +--- + +## Review & Acceptance Checklist +GATE: Automated checks run during main() execution + +### Content Quality +- [ ] No implementation details (languages, frameworks, APIs) +- [ ] Focused on user value and business needs +- [ ] Written for non-technical stakeholders +- [ ] All mandatory sections completed + +### Requirement Completeness +- [ ] No [NEEDS CLARIFICATION] markers remain +- [ ] Requirements are testable and unambiguous +- [ ] Success criteria are measurable +- [ ] Scope is clearly bounded +- [ ] Dependencies and assumptions identified + +--- + +## Execution Status +Updated by main() during processing + +- [ ] User description parsed +- [ ] Key concepts extracted +- [ ] Ambiguities marked +- [ ] User scenarios defined +- [ ] Requirements generated +- [ ] Entities identified +- [ ] Review checklist passed + +--- diff --git a/.spec-kit/specs/001-a-project-manager/tasks.md b/.spec-kit/specs/001-a-project-manager/tasks.md new file mode 100644 index 0000000..eca782e --- /dev/null +++ b/.spec-kit/specs/001-a-project-manager/tasks.md @@ -0,0 +1,66 @@ +# Tasks: TUI environments column + +**Input**: Design documents from `/specs/001-a-project-manager/` +**Prerequisites**: plan.md (required), research.md, data-model.md, contracts/ + +## Execution Flow (main) +``` +Order: Setup β†’ Tests (failing) β†’ Core β†’ Integration β†’ Polish +Apply [P] for independent files; omit [P] when same file or dependent +``` + +## Phase 3.1: Setup +- [x] T001 Ensure dependencies in go.mod: bubbletea, bubbles, lipgloss in /home/jlrosende/personal/project-manager/go.mod +- [x] T002 Create pkg/ui/list and pkg/ui/styles scaffolding in /home/jlrosende/personal/project-manager/pkg/ui +- [x] T003 [P] Add basic README usage notes in docs/cli (skip if already present) + +## Phase 3.2: Tests First (TDD) +- [x] T004 [P] Contract test for ProjectsLoadedMsg handling: derive envs list in tests/integration/tui_projects_loaded_test.go +- [x] T005 [P] Contract test for ProjectSelectedMsg handling: updates envs list in tests/integration/tui_project_selected_test.go +- [x] T006 [P] Integration test for collapse behavior on narrow terminal in tests/integration/tui_collapse_envs_test.go +- [x] T007 [P] Integration test for view rendering envs column when width sufficient in tests/integration/tui_view_envs_test.go + +## Phase 3.3: Core Implementation +- [x] T008 Add UI model fields to internal/adapters/handlers/tui/window.go: selected index, envsForSelected, width/height +- [x] T009 Implement Update() cases for ProjectsLoadedMsg and ProjectSelectedMsg in internal/adapters/handlers/tui/window.go +- [x] T010 Implement WindowSizeMsg handling and collapse logic in internal/adapters/handlers/tui/window.go +- [x] T011 Implement environments list component in /home/jlrosende/personal/project-manager/pkg/ui/list/envs_list.go +- [x] T012 [P] Implement styles for panels and titles in /home/jlrosende/personal/project-manager/pkg/ui/styles/styles.go +- [x] T013 Wire environments column into Window.viewPanelRender() in internal/adapters/handlers/tui/window.go +- [x] T014 Replace hardcoded lorem ipsum with project.Description from services in internal/adapters/handlers/tui/window.go + +## Phase 3.4: Integration +- [x] T015 Use internal/core/services/ProjectService to load projects, map to UI model in internal/adapters/handlers/tui/window.go +- [x] T016 Ensure no secrets or credential values are rendered or logged (defensive check) across UI rendering functions + +## Phase 3.5: Polish +- [x] T017 [P] Unit tests for envs list component in tests/unit/pkg_ui_envs_list_test.go +- [x] T018 [P] Unit tests for styles helpers in tests/unit/pkg_ui_styles_test.go +- [x] T019 Verify performance: render update on selection <50ms (manual measurement notes in docs/rest/perf-notes.md) +- [x] T020 Accessibility/UX: ensure clear focus/selection visuals for lists +- [x] T021 Run Quickstart to manually verify envs column behavior in /home/jlrosende/personal/project-manager/.spec-kit/specs/001-a-project-manager/quickstart.md + +## Dependencies +- T004–T007 before T008–T014 +- T008 blocks T009–T013 +- T011 independent of T009–T010 but required by T013 +- T012 independent; can run parallel with T011 +- T015 depends on services being available; runs after T008 + +## Parallel Example +``` +# Run these in parallel once Setup done and before Core: +Task: "Contract test ProjectsLoadedMsg" (T004) +Task: "Contract test ProjectSelectedMsg" (T005) +Task: "Integration test collapse behavior" (T006) +Task: "Integration test envs view" (T007) + +# Core parallel chunk: +Task: "Implement envs list component" (T011) +Task: "Implement styles helpers" (T012) +``` + +## Notes +- Keep Update() pure and return new state + Cmd +- Avoid logging sensitive data; render only environment names +- Exact file paths included above for all edits and new files \ No newline at end of file diff --git a/.spec-kit/templates/agent-file-template.md b/.spec-kit/templates/agent-file-template.md new file mode 100644 index 0000000..2301e0e --- /dev/null +++ b/.spec-kit/templates/agent-file-template.md @@ -0,0 +1,23 @@ +# [PROJECT NAME] Development Guidelines + +Auto-generated from all feature plans. Last updated: [DATE] + +## Active Technologies +[EXTRACTED FROM ALL PLAN.MD FILES] + +## Project Structure +``` +[ACTUAL STRUCTURE FROM PLANS] +``` + +## Commands +[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] + +## Code Style +[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE] + +## Recent Changes +[LAST 3 FEATURES AND WHAT THEY ADDED] + + + \ No newline at end of file diff --git a/.spec-kit/templates/plan-template.md b/.spec-kit/templates/plan-template.md new file mode 100644 index 0000000..f28a655 --- /dev/null +++ b/.spec-kit/templates/plan-template.md @@ -0,0 +1,237 @@ +# Implementation Plan: [FEATURE] + +**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] +**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` + +## Execution Flow (/plan command scope) +``` +1. Load feature spec from Input path + β†’ If not found: ERROR "No feature spec at {path}" +2. Fill Technical Context (scan for NEEDS CLARIFICATION) + β†’ Detect Project Type from context (web=frontend+backend, mobile=app+api) + β†’ Set Structure Decision based on project type +3. Evaluate Constitution Check section below + β†’ If violations exist: Document in Complexity Tracking + β†’ If no justification possible: ERROR "Simplify approach first" + β†’ Update Progress Tracking: Initial Constitution Check +4. Execute Phase 0 β†’ research.md + β†’ If NEEDS CLARIFICATION remain: ERROR "Resolve unknowns" +5. Execute Phase 1 β†’ contracts, data-model.md, quickstart.md, agent-specific template file (e.g., `CLAUDE.md` for Claude Code, `.github/copilot-instructions.md` for GitHub Copilot, or `GEMINI.md` for Gemini CLI). +6. Re-evaluate Constitution Check section + β†’ If new violations: Refactor design, return to Phase 1 + β†’ Update Progress Tracking: Post-Design Constitution Check +7. Plan Phase 2 β†’ Describe task generation approach (DO NOT create tasks.md) +8. STOP - Ready for /tasks command +``` + +**IMPORTANT**: The /plan command STOPS at step 7. Phases 2-4 are executed by other commands: +- Phase 2: /tasks command creates tasks.md +- Phase 3-4: Implementation execution (manual or via tools) + +## Summary +[Extract from feature spec: primary requirement + technical approach from research] + +## Technical Context +**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] +**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] +**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] +**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] +**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] +**Project Type**: [single/web/mobile - determines source structure] +**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] +**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] +**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] + +## Constitution Check +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +**Simplicity**: +- Projects: [#] (max 3 - e.g., api, cli, tests) +- Using framework directly? (no wrapper classes) +- Single data model? (no DTOs unless serialization differs) +- Avoiding patterns? (no Repository/UoW without proven need) + +**Architecture**: +- EVERY feature as library? (no direct app code) +- Libraries listed: [name + purpose for each] +- CLI per library: [commands with --help/--version/--format] +- Library docs: llms.txt format planned? + +**Testing (NON-NEGOTIABLE)**: +- RED-GREEN-Refactor cycle enforced? (test MUST fail first) +- Git commits show tests before implementation? +- Order: Contractβ†’Integrationβ†’E2Eβ†’Unit strictly followed? +- Real dependencies used? (actual DBs, not mocks) +- Integration tests for: new libraries, contract changes, shared schemas? +- FORBIDDEN: Implementation before test, skipping RED phase + +**Observability**: +- Structured logging included? +- Frontend logs β†’ backend? (unified stream) +- Error context sufficient? + +**Versioning**: +- Version number assigned? (MAJOR.MINOR.BUILD) +- BUILD increments on every change? +- Breaking changes handled? (parallel tests, migration plan) + +## Project Structure + +### Documentation (this feature) +``` +specs/[###-feature]/ +β”œβ”€β”€ plan.md # This file (/plan command output) +β”œβ”€β”€ research.md # Phase 0 output (/plan command) +β”œβ”€β”€ data-model.md # Phase 1 output (/plan command) +β”œβ”€β”€ quickstart.md # Phase 1 output (/plan command) +β”œβ”€β”€ contracts/ # Phase 1 output (/plan command) +└── tasks.md # Phase 2 output (/tasks command - NOT created by /plan) +``` + +### Source Code (repository root) +``` +# Option 1: Single project (DEFAULT) +src/ +β”œβ”€β”€ models/ +β”œβ”€β”€ services/ +β”œβ”€β”€ cli/ +└── lib/ + +tests/ +β”œβ”€β”€ contract/ +β”œβ”€β”€ integration/ +└── unit/ + +# Option 2: Web application (when "frontend" + "backend" detected) +backend/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ models/ +β”‚ β”œβ”€β”€ services/ +β”‚ └── api/ +└── tests/ + +frontend/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ components/ +β”‚ β”œβ”€β”€ pages/ +β”‚ └── services/ +└── tests/ + +# Option 3: Mobile + API (when "iOS/Android" detected) +api/ +└── [same as backend above] + +ios/ or android/ +└── [platform-specific structure] +``` + +**Structure Decision**: [DEFAULT to Option 1 unless Technical Context indicates web/mobile app] + +## Phase 0: Outline & Research +1. **Extract unknowns from Technical Context** above: + - For each NEEDS CLARIFICATION β†’ research task + - For each dependency β†’ best practices task + - For each integration β†’ patterns task + +2. **Generate and dispatch research agents**: + ``` + For each unknown in Technical Context: + Task: "Research {unknown} for {feature context}" + For each technology choice: + Task: "Find best practices for {tech} in {domain}" + ``` + +3. **Consolidate findings** in `research.md` using format: + - Decision: [what was chosen] + - Rationale: [why chosen] + - Alternatives considered: [what else evaluated] + +**Output**: research.md with all NEEDS CLARIFICATION resolved + +## Phase 1: Design & Contracts +*Prerequisites: research.md complete* + +1. **Extract entities from feature spec** β†’ `data-model.md`: + - Entity name, fields, relationships + - Validation rules from requirements + - State transitions if applicable + +2. **Generate API contracts** from functional requirements: + - For each user action β†’ endpoint + - Use standard REST/GraphQL patterns + - Output OpenAPI/GraphQL schema to `/contracts/` + +3. **Generate contract tests** from contracts: + - One test file per endpoint + - Assert request/response schemas + - Tests must fail (no implementation yet) + +4. **Extract test scenarios** from user stories: + - Each story β†’ integration test scenario + - Quickstart test = story validation steps + +5. **Update agent file incrementally** (O(1) operation): + - Run `/scripts/update-agent-context.sh [claude|gemini|copilot]` for your AI assistant + - If exists: Add only NEW tech from current plan + - Preserve manual additions between markers + - Update recent changes (keep last 3) + - Keep under 150 lines for token efficiency + - Output to repository root + +**Output**: data-model.md, /contracts/*, failing tests, quickstart.md, agent-specific file + +## Phase 2: Task Planning Approach +*This section describes what the /tasks command will do - DO NOT execute during /plan* + +**Task Generation Strategy**: +- Load `/templates/tasks-template.md` as base +- Generate tasks from Phase 1 design docs (contracts, data model, quickstart) +- Each contract β†’ contract test task [P] +- Each entity β†’ model creation task [P] +- Each user story β†’ integration test task +- Implementation tasks to make tests pass + +**Ordering Strategy**: +- TDD order: Tests before implementation +- Dependency order: Models before services before UI +- Mark [P] for parallel execution (independent files) + +**Estimated Output**: 25-30 numbered, ordered tasks in tasks.md + +**IMPORTANT**: This phase is executed by the /tasks command, NOT by /plan + +## Phase 3+: Future Implementation +*These phases are beyond the scope of the /plan command* + +**Phase 3**: Task execution (/tasks command creates tasks.md) +**Phase 4**: Implementation (execute tasks.md following constitutional principles) +**Phase 5**: Validation (run tests, execute quickstart.md, performance validation) + +## Complexity Tracking +*Fill ONLY if Constitution Check has violations that must be justified* + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | + + +## Progress Tracking +*This checklist is updated during execution flow* + +**Phase Status**: +- [ ] Phase 0: Research complete (/plan command) +- [ ] Phase 1: Design complete (/plan command) +- [ ] Phase 2: Task planning complete (/plan command - describe approach only) +- [ ] Phase 3: Tasks generated (/tasks command) +- [ ] Phase 4: Implementation complete +- [ ] Phase 5: Validation passed + +**Gate Status**: +- [ ] Initial Constitution Check: PASS +- [ ] Post-Design Constitution Check: PASS +- [ ] All NEEDS CLARIFICATION resolved +- [ ] Complexity deviations documented + +--- +*Based on Constitution v2.1.1 - See `/memory/constitution.md`* \ No newline at end of file diff --git a/.spec-kit/templates/spec-template.md b/.spec-kit/templates/spec-template.md new file mode 100644 index 0000000..7915e7d --- /dev/null +++ b/.spec-kit/templates/spec-template.md @@ -0,0 +1,116 @@ +# Feature Specification: [FEATURE NAME] + +**Feature Branch**: `[###-feature-name]` +**Created**: [DATE] +**Status**: Draft +**Input**: User description: "$ARGUMENTS" + +## Execution Flow (main) +``` +1. Parse user description from Input + β†’ If empty: ERROR "No feature description provided" +2. Extract key concepts from description + β†’ Identify: actors, actions, data, constraints +3. For each unclear aspect: + β†’ Mark with [NEEDS CLARIFICATION: specific question] +4. Fill User Scenarios & Testing section + β†’ If no clear user flow: ERROR "Cannot determine user scenarios" +5. Generate Functional Requirements + β†’ Each requirement must be testable + β†’ Mark ambiguous requirements +6. Identify Key Entities (if data involved) +7. Run Review Checklist + β†’ If any [NEEDS CLARIFICATION]: WARN "Spec has uncertainties" + β†’ If implementation details found: ERROR "Remove tech details" +8. Return: SUCCESS (spec ready for planning) +``` + +--- + +## ⚑ Quick Guidelines +- βœ… Focus on WHAT users need and WHY +- ❌ Avoid HOW to implement (no tech stack, APIs, code structure) +- πŸ‘₯ Written for business stakeholders, not developers + +### Section Requirements +- **Mandatory sections**: Must be completed for every feature +- **Optional sections**: Include only when relevant to the feature +- When a section doesn't apply, remove it entirely (don't leave as "N/A") + +### For AI Generation +When creating this spec from a user prompt: +1. **Mark all ambiguities**: Use [NEEDS CLARIFICATION: specific question] for any assumption you'd need to make +2. **Don't guess**: If the prompt doesn't specify something (e.g., "login system" without auth method), mark it +3. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item +4. **Common underspecified areas**: + - User types and permissions + - Data retention/deletion policies + - Performance targets and scale + - Error handling behaviors + - Integration requirements + - Security/compliance needs + +--- + +## User Scenarios & Testing *(mandatory)* + +### Primary User Story +[Describe the main user journey in plain language] + +### Acceptance Scenarios +1. **Given** [initial state], **When** [action], **Then** [expected outcome] +2. **Given** [initial state], **When** [action], **Then** [expected outcome] + +### Edge Cases +- What happens when [boundary condition]? +- How does system handle [error scenario]? + +## Requirements *(mandatory)* + +### Functional Requirements +- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] +- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] +- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] +- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] +- **FR-005**: System MUST [behavior, e.g., "log all security events"] + +*Example of marking unclear requirements:* +- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] +- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] + +### Key Entities *(include if feature involves data)* +- **[Entity 1]**: [What it represents, key attributes without implementation] +- **[Entity 2]**: [What it represents, relationships to other entities] + +--- + +## Review & Acceptance Checklist +*GATE: Automated checks run during main() execution* + +### Content Quality +- [ ] No implementation details (languages, frameworks, APIs) +- [ ] Focused on user value and business needs +- [ ] Written for non-technical stakeholders +- [ ] All mandatory sections completed + +### Requirement Completeness +- [ ] No [NEEDS CLARIFICATION] markers remain +- [ ] Requirements are testable and unambiguous +- [ ] Success criteria are measurable +- [ ] Scope is clearly bounded +- [ ] Dependencies and assumptions identified + +--- + +## Execution Status +*Updated by main() during processing* + +- [ ] User description parsed +- [ ] Key concepts extracted +- [ ] Ambiguities marked +- [ ] User scenarios defined +- [ ] Requirements generated +- [ ] Entities identified +- [ ] Review checklist passed + +--- diff --git a/.spec-kit/templates/tasks-template.md b/.spec-kit/templates/tasks-template.md new file mode 100644 index 0000000..b8a28fa --- /dev/null +++ b/.spec-kit/templates/tasks-template.md @@ -0,0 +1,127 @@ +# Tasks: [FEATURE NAME] + +**Input**: Design documents from `/specs/[###-feature-name]/` +**Prerequisites**: plan.md (required), research.md, data-model.md, contracts/ + +## Execution Flow (main) +``` +1. Load plan.md from feature directory + β†’ If not found: ERROR "No implementation plan found" + β†’ Extract: tech stack, libraries, structure +2. Load optional design documents: + β†’ data-model.md: Extract entities β†’ model tasks + β†’ contracts/: Each file β†’ contract test task + β†’ research.md: Extract decisions β†’ setup tasks +3. Generate tasks by category: + β†’ Setup: project init, dependencies, linting + β†’ Tests: contract tests, integration tests + β†’ Core: models, services, CLI commands + β†’ Integration: DB, middleware, logging + β†’ Polish: unit tests, performance, docs +4. Apply task rules: + β†’ Different files = mark [P] for parallel + β†’ Same file = sequential (no [P]) + β†’ Tests before implementation (TDD) +5. Number tasks sequentially (T001, T002...) +6. Generate dependency graph +7. Create parallel execution examples +8. Validate task completeness: + β†’ All contracts have tests? + β†’ All entities have models? + β†’ All endpoints implemented? +9. Return: SUCCESS (tasks ready for execution) +``` + +## Format: `[ID] [P?] Description` +- **[P]**: Can run in parallel (different files, no dependencies) +- Include exact file paths in descriptions + +## Path Conventions +- **Single project**: `src/`, `tests/` at repository root +- **Web app**: `backend/src/`, `frontend/src/` +- **Mobile**: `api/src/`, `ios/src/` or `android/src/` +- Paths shown below assume single project - adjust based on plan.md structure + +## Phase 3.1: Setup +- [ ] T001 Create project structure per implementation plan +- [ ] T002 Initialize [language] project with [framework] dependencies +- [ ] T003 [P] Configure linting and formatting tools + +## Phase 3.2: Tests First (TDD) ⚠️ MUST COMPLETE BEFORE 3.3 +**CRITICAL: These tests MUST be written and MUST FAIL before ANY implementation** +- [ ] T004 [P] Contract test POST /api/users in tests/contract/test_users_post.py +- [ ] T005 [P] Contract test GET /api/users/{id} in tests/contract/test_users_get.py +- [ ] T006 [P] Integration test user registration in tests/integration/test_registration.py +- [ ] T007 [P] Integration test auth flow in tests/integration/test_auth.py + +## Phase 3.3: Core Implementation (ONLY after tests are failing) +- [ ] T008 [P] User model in src/models/user.py +- [ ] T009 [P] UserService CRUD in src/services/user_service.py +- [ ] T010 [P] CLI --create-user in src/cli/user_commands.py +- [ ] T011 POST /api/users endpoint +- [ ] T012 GET /api/users/{id} endpoint +- [ ] T013 Input validation +- [ ] T014 Error handling and logging + +## Phase 3.4: Integration +- [ ] T015 Connect UserService to DB +- [ ] T016 Auth middleware +- [ ] T017 Request/response logging +- [ ] T018 CORS and security headers + +## Phase 3.5: Polish +- [ ] T019 [P] Unit tests for validation in tests/unit/test_validation.py +- [ ] T020 Performance tests (<200ms) +- [ ] T021 [P] Update docs/api.md +- [ ] T022 Remove duplication +- [ ] T023 Run manual-testing.md + +## Dependencies +- Tests (T004-T007) before implementation (T008-T014) +- T008 blocks T009, T015 +- T016 blocks T018 +- Implementation before polish (T019-T023) + +## Parallel Example +``` +# Launch T004-T007 together: +Task: "Contract test POST /api/users in tests/contract/test_users_post.py" +Task: "Contract test GET /api/users/{id} in tests/contract/test_users_get.py" +Task: "Integration test registration in tests/integration/test_registration.py" +Task: "Integration test auth in tests/integration/test_auth.py" +``` + +## Notes +- [P] tasks = different files, no dependencies +- Verify tests fail before implementing +- Commit after each task +- Avoid: vague tasks, same file conflicts + +## Task Generation Rules +*Applied during main() execution* + +1. **From Contracts**: + - Each contract file β†’ contract test task [P] + - Each endpoint β†’ implementation task + +2. **From Data Model**: + - Each entity β†’ model creation task [P] + - Relationships β†’ service layer tasks + +3. **From User Stories**: + - Each story β†’ integration test [P] + - Quickstart scenarios β†’ validation tasks + +4. **Ordering**: + - Setup β†’ Tests β†’ Models β†’ Services β†’ Endpoints β†’ Polish + - Dependencies block parallel execution + +## Validation Checklist +*GATE: Checked by main() before returning* + +- [ ] All contracts have corresponding tests +- [ ] All entities have model tasks +- [ ] All tests come before implementation +- [ ] Parallel tasks truly independent +- [ ] Each task specifies exact file path +- [ ] No task modifies same file as another [P] task \ No newline at end of file diff --git a/Makefile b/Makefile index 89ce16c..db27a07 100644 --- a/Makefile +++ b/Makefile @@ -14,27 +14,23 @@ GOMODCACHE = $(shell go env GOMODCACHE) GOOS ?= $(shell go env GOOS) GOARCH = $(shell go env GOARCH) +.PHONY: build build: - go build \ - -v \ - -ldflags "\ - -X 'github.com/jlrosende/project-manager/internal.mayor=$(MAYOR)' \ - -X 'github.com/jlrosende/project-manager/internal.minor=$(MINOR)' \ - -X 'github.com/jlrosende/project-manager/internal.patch=$(PATCH)' \ - -X 'github.com/jlrosende/project-manager/internal.build=$(BUILD)' \ - " \ - -o dist/pm \ - ./cmd/main.go + go tool goreleaser build --clean --snapshot +.PHONY: run run: go run cmd/main.go $(args) +.PHONY: run-build run-build: build ./dist/pm $(args) +.PHONY: lint lint: - go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${lint_v} run -v + go tool golangci-lint run -v +.PHONY: bdocker-build docker-build: docker buildx build \ --progress=plain \ @@ -42,5 +38,20 @@ docker-build: --build-arg GOCACHE=$(GOCACHE) \ --build-arg GOMODCACHE=$(GOMODCACHE) \ --output "type=docker" \ - -t sisusfox:latest \ + -t jlrosende/pm:latest \ + -f build/docker/Dockerfile \ . + +.PHONY: gendocs +gendocs: gendocs-md gendocs-man gendocs-rest + +gendocs-md: + go run ./internal/tools/docgen -out ./docs/cli -format markdown +gendocs-man: + go run ./internal/tools/docgen -out ./man -format man +gendocs-rest: + go run ./internal/tools/docgen -out ./docs/rest -format rest + +.PHONY: release +release: + go tool run diff --git a/build/docker/Dockerfile b/build/docker/Dockerfile index 23448fd..e333a29 100644 --- a/build/docker/Dockerfile +++ b/build/docker/Dockerfile @@ -1,6 +1,5 @@ -# syntax=docker/dockerfile:1 -ARG GO_VERSION=1.23.4-alpine +ARG GO_VERSION=1.25.1-alpine FROM golang:${GO_VERSION} as build @@ -17,7 +16,7 @@ RUN --mount=type=cache,target="$GOMODCACHE" \ --mount=type=bind,source=go.mod,target=go.mod \ go mod download -x -ARG TARGETOS TARGETARCH +ARG TARGETOS TARGETARCH RUN --mount=type=cache,target="$GOMODCACHE" \ --mount=type=cache,target="$GOCACHE" \ diff --git a/cmd/cli/list/list.go b/cmd/cli/list/list.go index 271d19b..ab7f23b 100644 --- a/cmd/cli/list/list.go +++ b/cmd/cli/list/list.go @@ -76,6 +76,4 @@ func list(cmd *cobra.Command, args []string) error { } } return nil - - return nil } diff --git a/cmd/cli/root.go b/cmd/cli/root.go index a2e789e..753a871 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -5,7 +5,6 @@ import ( "log" "log/slog" "os" - "path/filepath" tea "github.com/charmbracelet/bubbletea" @@ -20,6 +19,7 @@ import ( "github.com/jlrosende/project-manager/internal/adapters/repositories/shells" "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/services" + "github.com/jlrosende/project-manager/internal/logger" "github.com/spf13/cobra" ) @@ -37,42 +37,17 @@ var ( if err != nil { return err } - - level := slog.LevelVar{} - err = level.UnmarshalText([]byte(logLevel)) - - if err != nil { - return err - } - - cache, err := os.UserCacheDir() + logFile, err := cmd.PersistentFlags().GetString("log-file") if err != nil { return err } - fp, err := os.OpenFile(filepath.Join(cache, "pm.log"), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) - + _, err = logger.Setup(logLevel, logFile) if err != nil { return err } - logger := slog.New(slog.NewTextHandler(fp, &slog.HandlerOptions{ - Level: level.Level(), - })) - - logger = logger.With( - slog.Group("ps", - slog.Int("pid", os.Getpid()), - slog.Int("ppid", os.Getppid()), - slog.String("project", os.Getenv("PM_ACTIVE_PROJECT")), - ), - ) - - slog.SetDefault(logger) - - slog.Info("----------------------------------------------------------------------") - return nil }, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { @@ -89,7 +64,8 @@ func init() { rootCmd.Flags().BoolP("list", "l", false, "List all the projects.") - // rootCmd.PersistentFlags().String("log-level", "info", "Change the log level (debug, info, warn, error)") + rootCmd.PersistentFlags().String("log-level", "info", "Change the log level (debug, info, warn, error)") + rootCmd.PersistentFlags().String("log-file", "", "Path to log file (default: $XDG_CACHE_HOME/pm.log)") rootCmd.AddCommand(cmdInit.InitCmd) rootCmd.AddCommand(cmdNew.NewCmd) @@ -161,29 +137,41 @@ func root(cmd *cobra.Command, args []string) error { // Launch TUI if no args or project not exsit if len(args) == 0 || name == "" { - // window, err := tui.NewWindow(svc) - window, err := tui.NewSimpleWindow(svc) + window, err := tui.NewWindow(svc) if err != nil { return err } p := tea.NewProgram(window, tea.WithAltScreen()) - if _, err := p.Run(); err != nil { + m, err := p.Run() + if err != nil { return err } - selected := window.SelectedProject() + var selected *domain.Project + var selEnv string + if w, ok := m.(*tui.Window); ok { + selected = w.SelectedProject() + selEnv = w.SelectedEnvironment() + } if selected == nil { return nil } - // Get selected project project = selected + if selEnv != "" { + env = selEnv + } else if selected.DefaultEnv != "" { + env = selected.DefaultEnv + } } else { project, err = svc.Load(name) if err != nil { return err } + if env == "" && project.DefaultEnv != "" { + env = project.DefaultEnv + } } shellRepo, err := shells.NewPseudoShellRepository(project, env, path) diff --git a/configs/config.go b/configs/config.go index af3dd1b..8abafe1 100644 --- a/configs/config.go +++ b/configs/config.go @@ -5,7 +5,6 @@ import ( _ "embed" "errors" "fmt" - "log" "log/slog" "os" "path" @@ -19,7 +18,7 @@ import ( var defaultConfig []byte var ( - NotFoundErr = errors.New("config file not found") + ErrConfigNotFound = errors.New("config file not found") ) type Config struct { @@ -34,6 +33,7 @@ type Project struct { EnvVars map[string]string `mapstructure:"env_vars"` EnvVarsFile string `mapstructure:"env_vars_file"` Environments map[string]Environment `mapstructure:"environment"` + DefaultEnv string `mapstructure:"default_env"` } type Environment struct { @@ -53,7 +53,7 @@ func GetConfig(cfgFile string) (*Config, error) { config, err := ParseConfig(v) if err != nil { - log.Printf("Unable to parse config: %v", err) + slog.Debug("unable to parse config", slog.Any("err", err)) return nil, err } @@ -104,7 +104,7 @@ func ParseConfig(v *viper.Viper) (*Config, error) { err := v.Unmarshal(&cfg, configOption) if err != nil { - return nil, fmt.Errorf("Unable to parse config: %w", err) + return nil, fmt.Errorf("unable to parse config: %w", err) } return &cfg, nil diff --git a/docs/cli/pm.md b/docs/cli/pm.md new file mode 100644 index 0000000..bbf3995 --- /dev/null +++ b/docs/cli/pm.md @@ -0,0 +1,28 @@ +## pm + +pm is a tool to create and organize projects in your computer + +### Synopsis + +A tool to manage the configuration and estructure of multiple projects inside your computer + +``` +pm [project] [env|[path]] [flags] +``` + +### Options + +``` + -h, --help help for pm + -l, --list List all the projects. + --log-file string Path to log file (default: $XDG_CACHE_HOME/pm.log) + --log-level string Change the log level (debug, info, warn, error) (default "info") +``` + +### SEE ALSO + +* [pm edit](pm_edit.md) - edit project +* [pm init](pm_init.md) - Initialize a your workspace +* [pm list](pm_list.md) - list projects +* [pm new](pm_new.md) - Create new project + diff --git a/docs/cli/pm_edit.md b/docs/cli/pm_edit.md new file mode 100644 index 0000000..140a1f3 --- /dev/null +++ b/docs/cli/pm_edit.md @@ -0,0 +1,29 @@ +## pm edit + +edit project + +### Synopsis + +Edit all projects + +``` +pm edit [flags] +``` + +### Options + +``` + -h, --help help for edit +``` + +### Options inherited from parent commands + +``` + --log-file string Path to log file (default: $XDG_CACHE_HOME/pm.log) + --log-level string Change the log level (debug, info, warn, error) (default "info") +``` + +### SEE ALSO + +* [pm](pm.md) - pm is a tool to create and organize projects in your computer + diff --git a/docs/cli/pm_init.md b/docs/cli/pm_init.md new file mode 100644 index 0000000..b3b37a6 --- /dev/null +++ b/docs/cli/pm_init.md @@ -0,0 +1,29 @@ +## pm init + +Initialize a your workspace + +### Synopsis + +Init + +``` +pm init [flags] +``` + +### Options + +``` + -h, --help help for init +``` + +### Options inherited from parent commands + +``` + --log-file string Path to log file (default: $XDG_CACHE_HOME/pm.log) + --log-level string Change the log level (debug, info, warn, error) (default "info") +``` + +### SEE ALSO + +* [pm](pm.md) - pm is a tool to create and organize projects in your computer + diff --git a/docs/cli/pm_list.md b/docs/cli/pm_list.md new file mode 100644 index 0000000..93c3f3a --- /dev/null +++ b/docs/cli/pm_list.md @@ -0,0 +1,37 @@ +## pm list + +list projects + +### Synopsis + +List all projects + +``` +pm list [flags] +``` + +### Options + +``` + --commit.gpgsign git commit.gpgsign (default git --global) (default true) + --env-vars stringToString List of ENV_VARS to add to the environment (default []) + --format string output format + -h, --help help for list + --shell string Shell of the project, need be installed in the system) (default to $SHELL env var)) + --tag.gpgsign git tag.gpgsign (default git --global) (default true) + --user.email string git user.email (default git --global) + --user.name string git user.name (default git --global) + --user.signingkey string git user.signingkey (default git --global) +``` + +### Options inherited from parent commands + +``` + --log-file string Path to log file (default: $XDG_CACHE_HOME/pm.log) + --log-level string Change the log level (debug, info, warn, error) (default "info") +``` + +### SEE ALSO + +* [pm](pm.md) - pm is a tool to create and organize projects in your computer + diff --git a/docs/cli/pm_new.md b/docs/cli/pm_new.md new file mode 100644 index 0000000..b63128f --- /dev/null +++ b/docs/cli/pm_new.md @@ -0,0 +1,37 @@ +## pm new + +Create new project + +### Synopsis + +Create a new project and all the basic configuration files + +``` +pm new [] [flags] +``` + +### Options + +``` + --commit.gpgsign git commit.gpgsign (default git --global) (default true) + --env-vars stringToString List of ENV_VARS to add to the environment (default []) + -h, --help help for new + --shell string Shell of the project, need be installed in the system) (default to $SHELL env var)) + --subproject string Set this new project as subproject + --tag.gpgsign git tag.gpgsign (default git --global) (default true) + --user.email string git user.email (default git --global) + --user.name string git user.name (default git --global) + --user.signingkey string git user.signingkey (default git --global) +``` + +### Options inherited from parent commands + +``` + --log-file string Path to log file (default: $XDG_CACHE_HOME/pm.log) + --log-level string Change the log level (debug, info, warn, error) (default "info") +``` + +### SEE ALSO + +* [pm](pm.md) - pm is a tool to create and organize projects in your computer + diff --git a/docs/rest/perf-notes.md b/docs/rest/perf-notes.md new file mode 100644 index 0000000..43a8558 --- /dev/null +++ b/docs/rest/perf-notes.md @@ -0,0 +1,6 @@ +Render update target: <50ms per selection change. +Manual check: +- Build and run TUI. +- Navigate projects with j/k. +- Confirm no perceptible lag. +- If lag observed, profile and optimize lipgloss joins or reduce recomputations. diff --git a/docs/rest/pm.rst b/docs/rest/pm.rst new file mode 100644 index 0000000..3758fc2 --- /dev/null +++ b/docs/rest/pm.rst @@ -0,0 +1,35 @@ +.. _pm: + +pm +-- + +pm is a tool to create and organize projects in your computer + +Synopsis +~~~~~~~~ + + +A tool to manage the configuration and estructure of multiple projects inside your computer + +:: + + pm [project] [env|[path]] [flags] + +Options +~~~~~~~ + +:: + + -h, --help help for pm + -l, --list List all the projects. + --log-file string Path to log file (default: $XDG_CACHE_HOME/pm.log) + --log-level string Change the log level (debug, info, warn, error) (default "info") + +SEE ALSO +~~~~~~~~ + +* `pm edit `_ - edit project +* `pm init `_ - Initialize a your workspace +* `pm list `_ - list projects +* `pm new `_ - Create new project + diff --git a/docs/rest/pm_edit.rst b/docs/rest/pm_edit.rst new file mode 100644 index 0000000..285308a --- /dev/null +++ b/docs/rest/pm_edit.rst @@ -0,0 +1,37 @@ +.. _pm_edit: + +pm edit +------- + +edit project + +Synopsis +~~~~~~~~ + + +Edit all projects + +:: + + pm edit [flags] + +Options +~~~~~~~ + +:: + + -h, --help help for edit + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --log-file string Path to log file (default: $XDG_CACHE_HOME/pm.log) + --log-level string Change the log level (debug, info, warn, error) (default "info") + +SEE ALSO +~~~~~~~~ + +* `pm `_ - pm is a tool to create and organize projects in your computer + diff --git a/docs/rest/pm_init.rst b/docs/rest/pm_init.rst new file mode 100644 index 0000000..2d2ed3b --- /dev/null +++ b/docs/rest/pm_init.rst @@ -0,0 +1,37 @@ +.. _pm_init: + +pm init +------- + +Initialize a your workspace + +Synopsis +~~~~~~~~ + + +Init + +:: + + pm init [flags] + +Options +~~~~~~~ + +:: + + -h, --help help for init + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --log-file string Path to log file (default: $XDG_CACHE_HOME/pm.log) + --log-level string Change the log level (debug, info, warn, error) (default "info") + +SEE ALSO +~~~~~~~~ + +* `pm `_ - pm is a tool to create and organize projects in your computer + diff --git a/docs/rest/pm_list.rst b/docs/rest/pm_list.rst new file mode 100644 index 0000000..fb31de2 --- /dev/null +++ b/docs/rest/pm_list.rst @@ -0,0 +1,45 @@ +.. _pm_list: + +pm list +------- + +list projects + +Synopsis +~~~~~~~~ + + +List all projects + +:: + + pm list [flags] + +Options +~~~~~~~ + +:: + + --commit.gpgsign git commit.gpgsign (default git --global) (default true) + --env-vars stringToString List of ENV_VARS to add to the environment (default []) + --format string output format + -h, --help help for list + --shell string Shell of the project, need be installed in the system) (default to $SHELL env var)) + --tag.gpgsign git tag.gpgsign (default git --global) (default true) + --user.email string git user.email (default git --global) + --user.name string git user.name (default git --global) + --user.signingkey string git user.signingkey (default git --global) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --log-file string Path to log file (default: $XDG_CACHE_HOME/pm.log) + --log-level string Change the log level (debug, info, warn, error) (default "info") + +SEE ALSO +~~~~~~~~ + +* `pm `_ - pm is a tool to create and organize projects in your computer + diff --git a/docs/rest/pm_new.rst b/docs/rest/pm_new.rst new file mode 100644 index 0000000..1f90bef --- /dev/null +++ b/docs/rest/pm_new.rst @@ -0,0 +1,45 @@ +.. _pm_new: + +pm new +------ + +Create new project + +Synopsis +~~~~~~~~ + + +Create a new project and all the basic configuration files + +:: + + pm new [] [flags] + +Options +~~~~~~~ + +:: + + --commit.gpgsign git commit.gpgsign (default git --global) (default true) + --env-vars stringToString List of ENV_VARS to add to the environment (default []) + -h, --help help for new + --shell string Shell of the project, need be installed in the system) (default to $SHELL env var)) + --subproject string Set this new project as subproject + --tag.gpgsign git tag.gpgsign (default git --global) (default true) + --user.email string git user.email (default git --global) + --user.name string git user.name (default git --global) + --user.signingkey string git user.signingkey (default git --global) + +Options inherited from parent commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + --log-file string Path to log file (default: $XDG_CACHE_HOME/pm.log) + --log-level string Change the log level (debug, info, warn, error) (default "info") + +SEE ALSO +~~~~~~~~ + +* `pm `_ - pm is a tool to create and organize projects in your computer + diff --git a/go.mod b/go.mod index e9feab4..d816e4d 100644 --- a/go.mod +++ b/go.mod @@ -6,67 +6,511 @@ require ( github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.9 github.com/charmbracelet/lipgloss v1.1.0 - github.com/containerd/fifo v1.1.0 github.com/creack/pty/v2 v2.0.1 github.com/go-git/go-git/v5 v5.16.2 github.com/go-viper/mapstructure/v2 v2.4.0 github.com/hashicorp/hcl/v2 v2.24.0 github.com/joho/godotenv v1.6.0-pre.2 - github.com/lucasb-eyer/go-colorful v1.3.0 - github.com/muesli/gamut v0.3.1 github.com/spf13/cobra v1.10.1 github.com/spf13/viper v1.21.0 golang.org/x/term v0.35.0 ) require ( + 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect + 4d63.com/gochecknoglobals v0.2.2 // indirect + al.essio.dev/pkg/shellescape v1.6.0 // indirect + cel.dev/expr v0.24.0 // indirect + cloud.google.com/go v0.121.4 // indirect + cloud.google.com/go/auth v0.16.3 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.7.0 // indirect + cloud.google.com/go/iam v1.5.2 // indirect + cloud.google.com/go/kms v1.22.0 // indirect + cloud.google.com/go/longrunning v0.6.7 // indirect + cloud.google.com/go/monitoring v1.24.2 // indirect + cloud.google.com/go/storage v1.55.0 // indirect + code.gitea.io/sdk/gitea v0.22.0 // indirect + codeberg.org/chavacava/garif v0.2.0 // indirect + dario.cat/mergo v1.0.2 // indirect + dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect + dev.gaijin.team/go/golib v0.6.0 // indirect + github.com/42wim/httpsig v1.2.3 // indirect + github.com/4meepo/tagalign v1.4.3 // indirect + github.com/Abirdcfly/dupword v0.1.6 // indirect + github.com/AlekSi/pointer v1.2.0 // indirect + github.com/AlwxSin/noinlineerr v1.0.5 // indirect + github.com/Antonboom/errname v1.1.0 // indirect + github.com/Antonboom/nilnil v1.1.0 // indirect + github.com/Antonboom/testifylint v1.6.1 // indirect + github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0 // indirect + github.com/Azure/go-autorest v14.2.0+incompatible // indirect + github.com/Azure/go-autorest/autorest v0.11.30 // indirect + github.com/Azure/go-autorest/autorest/adal v0.9.24 // indirect + github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect + github.com/Azure/go-autorest/autorest/azure/cli v0.4.7 // indirect + github.com/Azure/go-autorest/autorest/date v0.3.1 // indirect + github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect + github.com/Azure/go-autorest/logger v0.2.2 // indirect + github.com/Azure/go-autorest/tracing v0.6.1 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/agext/levenshtein v1.2.3 // indirect + github.com/agnivade/levenshtein v1.2.1 // indirect + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/alecthomas/go-check-sumtype v0.3.1 // indirect + github.com/alexkohler/nakedret/v2 v2.0.6 // indirect + github.com/alexkohler/prealloc v1.0.0 // indirect + github.com/alfatraining/structtag v1.0.0 // indirect + github.com/alingse/asasalint v0.0.11 // indirect + github.com/alingse/nilnesserr v0.2.0 // indirect + github.com/anchore/bubbly v0.0.0-20241107060245-f2a5536f366a // indirect + github.com/anchore/go-logger v0.0.0-20241005132348-65b4486fbb28 // indirect + github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb // indirect + github.com/anchore/quill v0.5.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/ashanbrown/forbidigo/v2 v2.1.0 // indirect + github.com/ashanbrown/makezero/v2 v2.0.1 // indirect + github.com/atc0005/go-teams-notify/v2 v2.13.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect + github.com/avast/retry-go/v4 v4.6.1 // indirect + github.com/aws/aws-sdk-go v1.55.7 // indirect + github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect + github.com/aws/aws-sdk-go-v2/config v1.30.3 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.3 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 // indirect + github.com/aws/aws-sdk-go-v2/service/ecr v1.45.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.33.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 // indirect + github.com/aws/aws-sdk-go-v2/service/kms v1.43.0 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect + github.com/aws/smithy-go v1.23.0 // indirect + github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.10.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bkielbasa/cyclop v1.2.3 // indirect + github.com/blacktop/go-dwarf v1.0.10 // indirect + github.com/blacktop/go-macho v1.1.238 // indirect + github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect + github.com/blang/semver v3.5.1+incompatible // indirect + github.com/blizzy78/varnamelen v0.8.0 // indirect + github.com/bluesky-social/indigo v0.0.0-20240813042137-4006c0eca043 // indirect + github.com/bombsimon/wsl/v4 v4.7.0 // indirect + github.com/bombsimon/wsl/v5 v5.1.1 // indirect + github.com/breml/bidichk v0.3.3 // indirect + github.com/breml/errchkjson v0.4.1 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/butuzov/ireturn v0.4.0 // indirect + github.com/butuzov/mirror v1.3.0 // indirect + github.com/caarlos0/env/v11 v11.3.1 // indirect + github.com/caarlos0/go-reddit/v3 v3.0.1 // indirect + github.com/caarlos0/go-shellwords v1.0.12 // indirect + github.com/caarlos0/go-version v0.2.1 // indirect + github.com/caarlos0/log v0.5.1 // indirect + github.com/carlmjohnson/versioninfo v0.22.5 // indirect + github.com/catenacyber/perfsprint v0.9.1 // indirect + github.com/cavaliergopher/cpio v1.0.1 // indirect + github.com/ccojocar/zxcvbn-go v1.0.4 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charithe/durationcheck v0.0.10 // indirect github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/fang v0.4.0 // indirect + github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 // indirect + github.com/ckaznocha/intrange v0.3.1 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/curioswitch/go-reassign v0.3.0 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/daixiang0/gci v0.13.7 // indirect + github.com/dave/dst v0.27.3 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/denis-tingaikin/go-header v0.5.0 // indirect + github.com/dghubble/go-twitter v0.0.0-20211115160449-93a8679adecb // indirect + github.com/dghubble/oauth1 v0.7.3 // indirect + github.com/dghubble/sling v1.4.0 // indirect + github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect + github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect + github.com/dimchansky/utfbom v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/docker/cli v28.2.2+incompatible // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker v28.3.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect + github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/ettle/strcase v0.2.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/firefart/nonamedreturns v1.0.6 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fzipp/gocyclo v0.6.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/ghostiam/protogetter v0.3.15 // indirect + github.com/github/smimesign v0.2.0 // indirect + github.com/go-chi/chi/v5 v5.2.2 // indirect + github.com/go-critic/go-critic v0.13.0 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.23.0 // indirect + github.com/go-openapi/errors v0.22.2 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/loads v0.22.0 // indirect + github.com/go-openapi/runtime v0.28.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/strfmt v0.23.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-openapi/validate v0.24.0 // indirect + github.com/go-restruct/restruct v1.2.0-alpha // indirect + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 // indirect + github.com/go-toolsmith/astcast v1.1.0 // indirect + github.com/go-toolsmith/astcopy v1.1.0 // indirect + github.com/go-toolsmith/astequal v1.2.0 // indirect + github.com/go-toolsmith/astfmt v1.1.0 // indirect + github.com/go-toolsmith/astp v1.1.0 // indirect + github.com/go-toolsmith/strparse v1.1.0 // indirect + github.com/go-toolsmith/typep v1.1.0 // indirect + github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/gofrs/flock v0.12.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect + github.com/golangci/go-printf-func-name v0.1.0 // indirect + github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect + github.com/golangci/golangci-lint/v2 v2.4.0 // indirect + github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 // indirect + github.com/golangci/misspell v0.7.0 // indirect + github.com/golangci/plugin-module-register v0.1.2 // indirect + github.com/golangci/revgrep v0.8.0 // indirect + github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect + github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect + github.com/google/certificate-transparency-go v1.3.1 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-containerregistry v0.20.6 // indirect + github.com/google/go-github/v74 v74.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/ko v0.18.0 // indirect + github.com/google/rpmpack v0.7.1 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/safetext v0.0.0-20240722112252-5a72de7e7962 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/google/wire v0.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/gordonklaus/ineffassign v0.1.0 // indirect + github.com/goreleaser/chglog v0.7.3 // indirect + github.com/goreleaser/fileglob v1.3.0 // indirect + github.com/goreleaser/goreleaser/v2 v2.12.0 // indirect + github.com/goreleaser/nfpm/v2 v2.43.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/gostaticanalysis/analysisutil v0.7.1 // indirect + github.com/gostaticanalysis/comment v1.5.0 // indirect + github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect + github.com/gostaticanalysis/nilerr v0.1.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/in-toto/attestation v1.1.1 // indirect + github.com/in-toto/in-toto-golang v0.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/ipfs/bbloom v0.0.4 // indirect + github.com/ipfs/go-block-format v0.2.0 // indirect + github.com/ipfs/go-cid v0.4.1 // indirect + github.com/ipfs/go-datastore v0.6.0 // indirect + github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect + github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect + github.com/ipfs/go-ipfs-util v0.0.3 // indirect + github.com/ipfs/go-ipld-cbor v0.1.0 // indirect + github.com/ipfs/go-ipld-format v0.6.0 // indirect + github.com/ipfs/go-log v1.0.5 // indirect + github.com/ipfs/go-log/v2 v2.5.1 // indirect + github.com/ipfs/go-metrics-interface v0.0.1 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jbenet/goprocess v0.1.4 // indirect + github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 // indirect + github.com/jgautheron/goconst v1.8.2 // indirect + github.com/jingyugao/rowserrcheck v1.1.1 // indirect + github.com/jjti/go-spancheck v0.6.5 // indirect + github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/julz/importas v0.2.0 // indirect + github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/kisielk/errcheck v1.9.0 // indirect + github.com/kkHAIKE/contextcheck v1.1.6 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/pgzip v1.2.6 // indirect + github.com/kulti/thelper v0.6.3 // indirect + github.com/kunwardeep/paralleltest v1.0.14 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lasiar/canonicalheader v1.1.2 // indirect + github.com/ldez/exptostd v0.4.4 // indirect + github.com/ldez/gomoddirectives v0.7.0 // indirect + github.com/ldez/grignotin v0.10.0 // indirect + github.com/ldez/tagliatelle v0.7.1 // indirect + github.com/ldez/usetesting v0.5.0 // indirect + github.com/leonklingele/grouper v1.1.2 // indirect + github.com/letsencrypt/boulder v0.0.0-20250411005613-d800055fe666 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/macabu/inamedparam v0.2.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/manuelarte/embeddedstructfieldcheck v0.3.0 // indirect + github.com/manuelarte/funcorder v0.5.0 // indirect + github.com/maratori/testableexamples v1.0.0 // indirect + github.com/maratori/testpackage v1.1.1 // indirect + github.com/mark3labs/mcp-go v0.38.0 // indirect + github.com/matoous/godox v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect + github.com/mattn/go-mastodon v0.0.10 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mgechev/revive v1.11.0 // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moricho/tparallel v0.3.2 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762 // indirect - github.com/muesli/kmeans v0.3.1 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/multiformats/go-base32 v0.1.0 // indirect + github.com/multiformats/go-base36 v0.2.0 // indirect + github.com/multiformats/go-multibase v0.2.0 // indirect + github.com/multiformats/go-multihash v0.2.3 // indirect + github.com/multiformats/go-varint v0.0.7 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nakabonne/nestif v0.3.1 // indirect + github.com/nishanths/exhaustive v0.12.0 // indirect + github.com/nishanths/predeclared v0.2.2 // indirect + github.com/nunnatsa/ginkgolinter v0.20.0 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pborman/uuid v1.2.1 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect + github.com/polyfloyd/go-errorlint v1.8.0 // indirect + github.com/prometheus/client_golang v1.23.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/quasilyte/go-ruleguard v0.4.4 // indirect + github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect + github.com/quasilyte/gogrep v0.5.0 // indirect + github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect + github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect + github.com/raeperd/recvcheck v0.2.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/ryancurrah/gomodguard v1.4.1 // indirect + github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/sashamelentyev/interfacebloat v1.1.0 // indirect + github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect + github.com/sassoftware/relic v7.2.1+incompatible // indirect + github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect + github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect + github.com/securego/gosec/v2 v2.22.7 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sigstore/cosign/v2 v2.5.0 // indirect + github.com/sigstore/protobuf-specs v0.5.0 // indirect + github.com/sigstore/rekor v1.4.1-0.20250814000724-cdd95725eb11 // indirect + github.com/sigstore/sigstore v1.9.5 // indirect + github.com/sigstore/sigstore-go v0.7.1 // indirect + github.com/sigstore/timestamp-authority v1.2.5 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sivchari/containedctx v1.0.3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/slack-go/slack v0.17.3 // indirect + github.com/sonatard/noctx v0.4.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/sourcegraph/go-diff v0.7.0 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect + github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tdakkota/asciicheck v0.4.1 // indirect + github.com/tetafro/godot v1.5.1 // indirect + github.com/theupdateframework/go-tuf v0.7.0 // indirect + github.com/theupdateframework/go-tuf/v2 v2.0.2 // indirect + github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect + github.com/timonwong/loggercheck v0.11.0 // indirect + github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect + github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect + github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect + github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect + github.com/transparency-dev/merkle v0.0.2 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect + github.com/ultraware/funlen v0.2.0 // indirect + github.com/ultraware/whitespace v0.2.0 // indirect + github.com/uudashr/gocognit v1.2.0 // indirect + github.com/uudashr/iface v1.4.1 // indirect + github.com/vbatts/tar-split v0.12.1 // indirect + github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect + github.com/wagoodman/go-progress v0.0.0-20220614130704-4b1c25a33c7c // indirect + github.com/whyrusleeping/cbor-gen v0.1.3-0.20240731173018-74d74643234c // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xen0n/gosmopolitan v1.3.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect + github.com/yagipy/maintidx v1.0.0 // indirect + github.com/yeya24/promlinter v0.3.0 // indirect + github.com/ykadowak/zerologlint v0.1.5 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zclconf/go-cty v1.17.0 // indirect + github.com/zeebo/errs v1.4.0 // indirect + gitlab.com/bosi/decorder v0.4.2 // indirect + gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect + gitlab.com/gitlab-org/api/client-go v0.142.5 // indirect + go-simpler.org/musttag v0.13.1 // indirect + go-simpler.org/sloglint v0.11.1 // indirect + go.augendre.info/arangolint v0.2.0 // indirect + go.augendre.info/fatcontext v0.8.0 // indirect + go.mongodb.org/mongo-driver v1.17.3 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.36.0 // indirect + go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/sdk v1.36.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect + go.opentelemetry.io/otel/trace v1.36.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + gocloud.dev v0.42.0 // indirect + golang.org/x/crypto v0.42.0 // indirect golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect + golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.28.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.29.0 // indirect + golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.37.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/api v0.246.0 // indirect + google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect + google.golang.org/grpc v1.74.2 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/mail.v2 v2.3.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + honnef.co/go/tools v0.6.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + lukechampine.com/blake3 v1.2.1 // indirect + mvdan.cc/gofumpt v0.8.0 // indirect + mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 // indirect + sigs.k8s.io/kind v0.27.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect + software.sslmate.com/src/go-pkcs12 v0.5.0 // indirect +) + +tool ( + github.com/golangci/golangci-lint/v2/cmd/golangci-lint + github.com/goreleaser/goreleaser/v2 ) diff --git a/go.sum b/go.sum index 60ff32c..a1d1a0b 100644 --- a/go.sum +++ b/go.sum @@ -1,170 +1,1590 @@ +4d63.com/gocheckcompilerdirectives v1.3.0 h1:Ew5y5CtcAAQeTVKUVFrE7EwHMrTO6BggtEj8BZSjZ3A= +4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY= +4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU= +4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0= +al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= +al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.121.4 h1:cVvUiY0sX0xwyxPwdSU2KsF9knOVmtRyAMt8xou0iTs= +cloud.google.com/go v0.121.4/go.mod h1:XEBchUiHFJbz4lKBZwYBDHV/rSyfFktk737TLDU089s= +cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc= +cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= +cloud.google.com/go/kms v1.22.0 h1:dBRIj7+GDeeEvatJeTB19oYZNV0aj6wEqSIT/7gLqtk= +cloud.google.com/go/kms v1.22.0/go.mod h1:U7mf8Sva5jpOb4bxYZdtw/9zsbIjrklYwPcvMk34AL8= +cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= +cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= +cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= +cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= +cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= +cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= +cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2znf0= +cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY= +cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= +cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= +code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0= +code.gitea.io/sdk/gitea v0.22.0/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= +codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY= +codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y= +dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI= +dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo= +dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= +github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= +github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8= +github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c= +github.com/Abirdcfly/dupword v0.1.6 h1:qeL6u0442RPRe3mcaLcbaCi2/Y/hOcdtw6DE9odjz9c= +github.com/Abirdcfly/dupword v0.1.6/go.mod h1:s+BFMuL/I4YSiFv29snqyjwzDp4b65W2Kvy+PKzZ6cw= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= +github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= +github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= +github.com/AlwxSin/noinlineerr v1.0.5 h1:RUjt63wk1AYWTXtVXbSqemlbVTb23JOSRiNsshj7TbY= +github.com/AlwxSin/noinlineerr v1.0.5/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc= +github.com/Antonboom/errname v1.1.0 h1:A+ucvdpMwlo/myWrkHEUEBWc/xuXdud23S8tmTb/oAE= +github.com/Antonboom/errname v1.1.0/go.mod h1:O1NMrzgUcVBGIfi3xlVuvX8Q/VP/73sseCaAppfjqZw= +github.com/Antonboom/nilnil v1.1.0 h1:jGxJxjgYS3VUUtOTNk8Z1icwT5ESpLH/426fjmQG+ng= +github.com/Antonboom/nilnil v1.1.0/go.mod h1:b7sAlogQjFa1wV8jUW3o4PMzDVFLbTux+xnQdvzdcIE= +github.com/Antonboom/testifylint v1.6.1 h1:6ZSytkFWatT8mwZlmRCHkWz1gPi+q6UBSbieji2Gj/o= +github.com/Antonboom/testifylint v1.6.1/go.mod h1:k+nEkathI2NFjKO6HvwmSrbzUcQ6FAnbZV+ZRrnXPLI= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2 h1:Hr5FTipp7SL07o2FvoVOX9HRiRH3CR3Mj8pxqCcdD5A= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2/go.mod h1:QyVsSSN64v5TGltphKLQ2sQxe4OBQg0J1eKRcVBnfgE= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 h1:m/sWOGCREuSBqg2htVQTBY8nOZpyajYztF0vUvSZTuM= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0/go.mod h1:Pu5Zksi2KrU7LPbZbNINx6fuVrUp/ffvpxdDj+i8LeE= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.6.0 h1:PiSrjRPpkQNjrM8H0WwKMnZUdu1RGMtd/LdGKUrOo+c= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.6.0/go.mod h1:oDrbWx4ewMylP7xHivfgixbfGBT6APAwsSoHRKotnIc= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0 h1:UXT0o77lXQrikd1kgwIPQOUect7EoR/+sbP4wQKdzxM= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0/go.mod h1:cTvi54pg19DoT07ekoeMgE/taAwNtCShVeZqA+Iv2xI= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest v0.11.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA= +github.com/Azure/go-autorest/autorest v0.11.30 h1:iaZ1RGz/ALZtN5eq4Nr1SOFSlf2E4pDI3Tcsl+dZPVE= +github.com/Azure/go-autorest/autorest v0.11.30/go.mod h1:t1kpPIOpIVX7annvothKvb0stsrXa37i7b+xpmBW8Fs= +github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= +github.com/Azure/go-autorest/autorest/adal v0.9.22/go.mod h1:XuAbAEUv2Tta//+voMI038TrJBqjKam0me7qR+L8Cmk= +github.com/Azure/go-autorest/autorest/adal v0.9.24 h1:BHZfgGsGwdkHDyZdtQRQk1WeUdW0m2WPAwuHZwUi5i4= +github.com/Azure/go-autorest/autorest/adal v0.9.24/go.mod h1:7T1+g0PYFmACYW5LlG2fcoPiPlFHjClyRGL7dRlP5c8= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdEQ1+g4VmKyD5y+p/XtSNqE6Hc4QD0= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.7 h1:Q9R3utmFg9K1B4OYtAZ7ZUUvIUdzQt7G2MN5Hi/d670= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.7/go.mod h1:bVrAueELJ0CKLBpUHDIvD516TwmHmzqwCpvONWRsw3s= +github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= +github.com/Azure/go-autorest/autorest/date v0.3.1 h1:o9Z8Jyt+VJJTCZ/UORishuHOusBwolhjokt9s5k8I4w= +github.com/Azure/go-autorest/autorest/date v0.3.1/go.mod h1:Dz/RDmXlfiFFS/eW+b/xMUSFs1tboPVy6UjgADToWDM= +github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/autorest/mocks v0.4.2 h1:PGN4EDXnuQbojHbU0UWoNvmu9AGVwYHG9/fkDYhtAfw= +github.com/Azure/go-autorest/autorest/mocks v0.4.2/go.mod h1:Vy7OitM9Kei0i1Oj+LvyAWMXJHeKH1MVlzFugfVrmyU= +github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+WNGCVv7OmS5+lTc= +github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M= +github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/logger v0.2.2 h1:hYqBsEBywrrOSW24kkOCXRcKfKhK76OzLTfF+MYDE2o= +github.com/Azure/go-autorest/logger v0.2.2/go.mod h1:I5fg9K52o+iuydlWfa9T5K6WFos9XYr9dYTFzpqgibw= +github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/Azure/go-autorest/tracing v0.6.1 h1:YUMSrC/CeD1ZnnXcNYU4a/fzsO35u2Fsful9L/2nyR0= +github.com/Azure/go-autorest/tracing v0.6.1/go.mod h1:/3EgjbsjraOqiicERAeu3m7/z0x1TzjQGAwDrJrXGkc= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DataDog/zstd v1.5.5 h1:oWf5W7GtOLgp6bciQYDmhHHjdhYkALu6S/5Ni9ZgSvQ= +github.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 h1:sHglBQTwgx+rWPdisA5ynNEsoARbiCBOyGcJM4/OzsM= +github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= +github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= +github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= +github.com/ProtonMail/gopenpgp/v2 v2.7.1 h1:Awsg7MPc2gD3I7IFac2qE3Gdls0lZW8SzrFZ3k1oz0s= +github.com/ProtonMail/gopenpgp/v2 v2.7.1/go.mod h1:/BU5gfAVwqyd8EfC3Eu7zmuhwYQpKs+cGD8M//iiaxs= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= +github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ= +github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q= +github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw= +github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= +github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc= +github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus= +github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= +github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= +github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= +github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= +github.com/anchore/bubbly v0.0.0-20241107060245-f2a5536f366a h1:smr1CcMkgeMd6G75N+2OVNk/uHbX/WLR0bk+kMWEyr8= +github.com/anchore/bubbly v0.0.0-20241107060245-f2a5536f366a/go.mod h1:P5IrP8AhuzApVKa5H7k2hHX5pZA1uhyi+Z1VjK1EtA4= +github.com/anchore/go-logger v0.0.0-20241005132348-65b4486fbb28 h1:TKlTOayTJKpoLPJbeMykEwxCn0enACf06u0RSIdFG5w= +github.com/anchore/go-logger v0.0.0-20241005132348-65b4486fbb28/go.mod h1:5iJIa34inbIEFRwoWxNBTnjzIcl4G3le1LppPDmpg/4= +github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb h1:iDMnx6LIjtjZ46C0akqveX83WFzhpTD3eqOthawb5vU= +github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb/go.mod h1:DmTY2Mfcv38hsHbG78xMiTDdxFtkHpgYNVDPsF2TgHk= +github.com/anchore/quill v0.5.1 h1:+TAJroWuMC0AofI4gD9V9v65zR8EfKZg8u+ZD+dKZS4= +github.com/anchore/quill v0.5.1/go.mod h1:tAzfFxVluL2P1cT+xEy+RgQX1hpNuliUC5dTYSsnCLQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/ashanbrown/forbidigo/v2 v2.1.0 h1:NAxZrWqNUQiDz19FKScQ/xvwzmij6BiOw3S0+QUQ+Hs= +github.com/ashanbrown/forbidigo/v2 v2.1.0/go.mod h1:0zZfdNAuZIL7rSComLGthgc/9/n2FqspBOH90xlCHdA= +github.com/ashanbrown/makezero/v2 v2.0.1 h1:r8GtKetWOgoJ4sLyUx97UTwyt2dO7WkGFHizn/Lo8TY= +github.com/ashanbrown/makezero/v2 v2.0.1/go.mod h1:kKU4IMxmYW1M4fiEHMb2vc5SFoPzXvgbMR9gIp5pjSw= +github.com/atc0005/go-teams-notify/v2 v2.13.0 h1:nbDeHy89NjYlF/PEfLVF6lsserY9O5SnN1iOIw3AxXw= +github.com/atc0005/go-teams-notify/v2 v2.13.0/go.mod h1:WSv9moolRsBcpZbwEf6gZxj7h0uJlJskJq5zkEWKO8Y= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= +github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= +github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= +github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= +github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00= +github.com/aws/aws-sdk-go-v2/config v1.30.3 h1:utupeVnE3bmB221W08P0Moz1lDI3OwYa2fBtUhl7TCc= +github.com/aws/aws-sdk-go-v2/config v1.30.3/go.mod h1:NDGwOEBdpyZwLPlQkpKIO7frf18BW8PaCmAM9iUxQmI= +github.com/aws/aws-sdk-go-v2/credentials v1.18.3 h1:ptfyXmv+ooxzFwyuBth0yqABcjVIkjDL0iTYZBSbum8= +github.com/aws/aws-sdk-go-v2/credentials v1.18.3/go.mod h1:Q43Nci++Wohb0qUh4m54sNln0dbxJw8PvQWkrwOkGOI= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 h1:nRniHAvjFJGUCl04F3WaAj7qp/rcz5Gi1OVoj5ErBkc= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2/go.mod h1:eJDFKAMHHUvv4a0Zfa7bQb//wFNUXGrbFpYRCHe2kD0= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69 h1:6VFPH/Zi9xYFMJKPQOX5URYkQoXRWeJ7V/7Y6ZDYoms= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69/go.mod h1:GJj8mmO6YT6EqgduWocwhMoxTLFitkhIrK+owzrYL2I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 h1:R0tNFJqfjHL3900cqhXuwQ+1K4G0xc9Yf8EDbFXCKEw= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6/go.mod h1:y/7sDdu+aJvPtGXr4xYosdpq9a6T9Z0jkXfugmti0rI= +github.com/aws/aws-sdk-go-v2/service/ecr v1.45.1 h1:Bwzh202Aq7/MYnAjXA9VawCf6u+hjwMdoYmZ4HYsdf8= +github.com/aws/aws-sdk-go-v2/service/ecr v1.45.1/go.mod h1:xZzWl9AXYa6zsLLH41HBFW8KRKJRIzlGmvSM0mVMIX4= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.33.2 h1:XJ/AEFYj9VFPJdF+VFi4SUPEDfz1akHwxxm07JfZJcs= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.33.2/go.mod h1:JUBHdhvKbbKmhaHjLsKJAWnQL80T6nURmhB/LEprV+4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 h1:hncKj/4gR+TPauZgTAsxOxNcvBayhUlYZ6LO/BYiQ30= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6/go.mod h1:OiIh45tp6HdJDDJGnja0mw8ihQGz3VGrUflLqSL0SmM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 h1:LHS1YAIJXJ4K9zS+1d/xa9JAA9sL2QyXIQCQFQW/X08= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6/go.mod h1:c9PCiTEuh0wQID5/KqA32J+HAgZxN9tOGXKCiYJjTZI= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 h1:nEXUSAwyUfLTgnc9cxlDWy637qsq4UWwp3sNAfl0Z3Y= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6/go.mod h1:HGzIULx4Ge3Do2V0FaiYKcyKzOqwrhUZgCI77NisswQ= +github.com/aws/aws-sdk-go-v2/service/kms v1.43.0 h1:mdbWU38ipmDapPcsD6F7ObjjxMLrWUK0jI2NcC7zAcI= +github.com/aws/aws-sdk-go-v2/service/kms v1.43.0/go.mod h1:6FWXdzVbnG8ExnBQLHGIo/ilb1K7Ek1u6dcllumBe1s= +github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 h1:ETkfWcXP2KNPLecaDa++5bsQhCRa5M5sLUJa5DWYIIg= +github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3/go.mod h1:+/3ZTqoYb3Ur7DObD00tarKMLMuKg8iqz5CHEanqTnw= +github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 h1:j7/jTOjWeJDolPwZ/J4yZ7dUsxsWZEsxNwH5O7F8eEA= +github.com/aws/aws-sdk-go-v2/service/sso v1.27.0/go.mod h1:M0xdEPQtgpNT7kdAX4/vOAPkFj60hSQRb7TvW9B0iug= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 h1:ywQF2N4VjqX+Psw+jLjMmUL2g1RDHlvri3NxHA08MGI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0/go.mod h1:Z+qv5Q6b7sWiclvbJyPSOT1BRVU9wfSUPaqQzZ1Xg3E= +github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 h1:bRP/a9llXSSgDPk7Rqn5GD/DQCGo6uk95plBFKoXt2M= +github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58= +github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= +github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.10.1 h1:6lMw4/QGLFPvbKQ0eri/9Oh3YX5Nm6BPrUlZR8yuJHg= +github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.10.1/go.mod h1:EVJOSYOVeoD3VFFZ/dWCAzWJp5wZr9lTOCjW8ejAmO0= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w= +github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= +github.com/blacktop/go-dwarf v1.0.10 h1:i9zYgcIROETsNZ6V+zZn3uDH21FCG5BLLZ837GitxS0= +github.com/blacktop/go-dwarf v1.0.10/go.mod h1:4W2FKgSFYcZLDwnR7k+apv5i3nrau4NGl9N6VQ9DSTo= +github.com/blacktop/go-macho v1.1.238 h1:OFfT6NB/SWxkoky7L/ytuY8QekgFpa9pmz/GHUQLsmM= +github.com/blacktop/go-macho v1.1.238/go.mod h1:dtlW2AJKQpFzImBVPWiUKZ6OxrQ2MLfWi/BPPe0EONE= +github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4= +github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= +github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= +github.com/bluesky-social/indigo v0.0.0-20240813042137-4006c0eca043 h1:927VIkxPFKpfJKVDtCNgSQtlhksARaLvsLxppR2FukM= +github.com/bluesky-social/indigo v0.0.0-20240813042137-4006c0eca043/go.mod h1:dXjdzg6bhg1JKnKuf6EBJTtcxtfHYBFEe9btxX5YeAE= +github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ= +github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg= +github.com/bombsimon/wsl/v5 v5.1.1 h1:cQg5KJf9FlctAH4cpL9vLKnziYknoCMCdqXl0wjl72Q= +github.com/bombsimon/wsl/v5 v5.1.1/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I= +github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE= +github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE= +github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg= +github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E= +github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70= +github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= +github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI= +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/caarlos0/go-reddit/v3 v3.0.1 h1:w8ugvsrHhaE/m4ez0BO/sTBOBWI9WZTjG7VTecHnql4= +github.com/caarlos0/go-reddit/v3 v3.0.1/go.mod h1:QlwgmG5SAqxMeQvg/A2dD1x9cIZCO56BMnMdjXLoisI= +github.com/caarlos0/go-shellwords v1.0.12 h1:HWrUnu6lGbWfrDcFiHcZiwOLzHWjjrPVehULaTFgPp8= +github.com/caarlos0/go-shellwords v1.0.12/go.mod h1:bYeeX1GrTLPl5cAMYEzdm272qdsQAZiaHgeF0KTk1Gw= +github.com/caarlos0/go-version v0.2.1 h1:bJY5WRvs2RXErLKBELd1WR0U72whX8ELbKg0WtQ9/7A= +github.com/caarlos0/go-version v0.2.1/go.mod h1:X+rI5VAtJDpcjCjeEIXpxGa5+rTcgur1FK66wS0/944= +github.com/caarlos0/log v0.5.1 h1:uB1jhC/+HimtyyL7pxidkUWO4raKmidVuXifC4uqMf8= +github.com/caarlos0/log v0.5.1/go.mod h1:37k7VCogxsMsgpIQaca5g9eXFFrLJ5LGgA4Ng/xN85o= +github.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8= +github.com/caarlos0/testfs v0.4.4/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk= +github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= +github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= +github.com/catenacyber/perfsprint v0.9.1 h1:5LlTp4RwTooQjJCvGEFV6XksZvWE7wCOUvjD2z0vls0= +github.com/catenacyber/perfsprint v0.9.1/go.mod h1:q//VWC2fWbcdSLEY1R3l8n0zQCDPdE4IjZwyY1HMunM= +github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM= +github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc= +github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc= +github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= +github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iyoHGPf5w4= +github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.9 h1:OBYdfRo6QnlIcXNmcoI2n1NNS65Nk6kI2L2FO1puS/4= github.com/charmbracelet/bubbletea v1.3.9/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +github.com/charmbracelet/fang v0.4.0 h1:boBxmdcFghTeotqkD2itXi7SMBozdIlcslRqjboSJDg= +github.com/charmbracelet/fang v0.4.0/go.mod h1:9gCUAHmVx5BwSafeyNr3GI0GgvlB1WYjL21SkPp1jyU= +github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= +github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis= +github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA= github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY= -github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o= -github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 h1:krfRl01rzPzxSxyLyrChD+U+MzsBXbm0OwYYB67uF+4= +github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589/go.mod h1:OuDyvmLnMCwa2ep4Jkm6nyA0ocJuZlGyk2gGseVzERM= +github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs= +github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= +github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= +github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= +github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k= github.com/creack/pty/v2 v2.0.1/go.mod h1:2dSssKp3b86qYEMwA/FPwc3ff+kYpDdQI8osU8J7gxQ= +github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= +github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= +github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY= +github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= +github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= +github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= +github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= +github.com/dghubble/go-twitter v0.0.0-20211115160449-93a8679adecb h1:7ENzkH+O3juL+yj2undESLTaAeRllHwCs/b8z6aWSfc= +github.com/dghubble/go-twitter v0.0.0-20211115160449-93a8679adecb/go.mod h1:qhZBgV9e4WyB1JNjHpcXVkUe3knWUwYuAPB1hITdm50= +github.com/dghubble/oauth1 v0.7.3 h1:EkEM/zMDMp3zOsX2DC/ZQ2vnEX3ELK0/l9kb+vs4ptE= +github.com/dghubble/oauth1 v0.7.3/go.mod h1:oxTe+az9NSMIucDPDCCtzJGsPhciJV33xocHfcR2sVY= +github.com/dghubble/sling v1.4.0 h1:/n8MRosVTthvMbwlNZgLx579OGVjUOy3GNEv5BIqAWY= +github.com/dghubble/sling v1.4.0/go.mod h1:0r40aNsU9EdDUVBNhfCstAtFgutjgJGYbO1oNzkMoM8= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= +github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= +github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= +github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A= +github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= +github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= +github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= +github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/set v0.2.1 h1:nn2CaJyknWE/6txyUDGwysr3G5QC6xWB/PtVjPBbeaA= +github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E= +github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= +github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/ghostiam/protogetter v0.3.15 h1:1KF5sXel0HE48zh1/vn0Loiw25A9ApyseLzQuif1mLY= +github.com/ghostiam/protogetter v0.3.15/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= +github.com/github/smimesign v0.2.0 h1:Hho4YcX5N1I9XNqhq0fNx0Sts8MhLonHd+HRXVGNjvk= +github.com/github/smimesign v0.2.0/go.mod h1:iZiiwNT4HbtGRVqCQu7uJPEZCuEE5sfSSttcnePkDl4= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-critic/go-critic v0.13.0 h1:kJzM7wzltQasSUXtYyTl6UaPVySO6GkaR1thFnJ6afY= +github.com/go-critic/go-critic v0.13.0/go.mod h1:M/YeuJ3vOCQDnP2SU+ZhjgRzwzcBW87JqLpMJLrZDLI= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM= github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= -github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= -github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= +github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= +github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= +github.com/go-openapi/errors v0.22.2 h1:rdxhzcBUazEcGccKqbY1Y7NS8FDcMyIRr0934jrYnZg= +github.com/go-openapi/errors v0.22.2/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= +github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= +github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ= +github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= +github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= +github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-restruct/restruct v1.2.0-alpha h1:2Lp474S/9660+SJjpVxoKuWX09JsXHSrdV7Nv3/gkvc= +github.com/go-restruct/restruct v1.2.0-alpha/go.mod h1:KqrpKpn4M8OLznErihXTGLlsXFGeLxHUrLRRI/1YjGk= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8= +github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU= +github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s= +github.com/go-toolsmith/astcopy v1.1.0/go.mod h1:hXM6gan18VA1T/daUEHCFcYiW8Ai1tIwIzHY6srfEAw= +github.com/go-toolsmith/astequal v1.0.3/go.mod h1:9Ai4UglvtR+4up+bAD4+hCj7iTo4m/OXVTSLnCyTAx4= +github.com/go-toolsmith/astequal v1.1.0/go.mod h1:sedf7VIdCL22LD8qIvv7Nn9MuWJruQA/ysswh64lffQ= +github.com/go-toolsmith/astequal v1.2.0 h1:3Fs3CYZ1k9Vo4FzFhwwewC3CHISHDnVUPC4x0bI2+Cw= +github.com/go-toolsmith/astequal v1.2.0/go.mod h1:c8NZ3+kSFtFY/8lPso4v8LuJjdJiUFVnSuU3s0qrrDY= +github.com/go-toolsmith/astfmt v1.1.0 h1:iJVPDPp6/7AaeLJEruMsBUlOYCmvg0MoCfJprsOmcco= +github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4= +github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA= +github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA= +github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk= +github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus= +github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8= +github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw= +github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= +github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus= +github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY= +github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= +github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw= +github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= +github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUPPyAKJuzv8pEJU= +github.com/golangci/go-printf-func-name v0.1.0/go.mod h1:wqhWFH5mUdJQhweRnldEywnR5021wTdZSNgwYceV14s= +github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE= +github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY= +github.com/golangci/golangci-lint/v2 v2.4.0 h1:qz6O6vr7kVzXJqyvHjHSz5fA3D+PM8v96QU5gxZCNWM= +github.com/golangci/golangci-lint/v2 v2.4.0/go.mod h1:Oq7vuAf6L1iNL34uHDcsIF6Mnc0amOPdsT3/GlpHD+I= +github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 h1:AkK+w9FZBXlU/xUmBtSJN1+tAI4FIvy5WtnUnY8e4p8= +github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95/go.mod h1:k9mmcyWKSTMcPPvQUCfRWWQ9VHJ1U9Dc0R7kaXAgtnQ= +github.com/golangci/misspell v0.7.0 h1:4GOHr/T1lTW0hhR4tgaaV1WS/lJ+ncvYCoFKmqJsj0c= +github.com/golangci/misspell v0.7.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg= +github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg= +github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= +github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s= +github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k= +github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM= +github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s= +github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM= +github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= +github.com/google/certificate-transparency-go v1.3.1 h1:akbcTfQg0iZlANZLn0L9xOeWtyCIdeoYhKrqi5iH3Go= +github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= +github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= +github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM= +github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo= +github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI= +github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= +github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/ko v0.18.0 h1:jkF5Fkvm+SMtqTt/SMzsCJO+6hz7FSDE6GRldGn0VVI= +github.com/google/ko v0.18.0/go.mod h1:iR0zT5aR4pINW9tk2Ujj99dBJ7cVy4to9ZirAkGKb9g= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18= +github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/rpmpack v0.7.1 h1:YdWh1IpzOjBz60Wvdw0TU0A5NWP+JTVHA5poDqwMO2o= +github.com/google/rpmpack v0.7.1/go.mod h1:h1JL16sUTWCLI/c39ox1rDaTBo3BXUQGjczVJyK4toU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/safetext v0.0.0-20240722112252-5a72de7e7962 h1:+9C/TgFfcCmZBV7Fjb3kQCGlkpFrhtvFDgbdQHB9RaA= +github.com/google/safetext v0.0.0-20240722112252-5a72de7e7962/go.mod h1:H3K1Iu/utuCfa10JO+GsmKUYSWi7ug57Rk6GaDRHaaQ= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/google/tink/go v1.7.0 h1:6Eox8zONGebBFcCBqkVmt60LaWZa6xg1cl/DwAh/J1w= +github.com/google/tink/go v1.7.0/go.mod h1:GAUOd+QE3pgj9q8VKIGTCP33c/B7eb4NhxLcgTJZStM= +github.com/google/trillian v1.7.2 h1:EPBxc4YWY4Ak8tcuhyFleY+zYlbCDCa4Sn24e1Ka8Js= +github.com/google/trillian v1.7.2/go.mod h1:mfQJW4qRH6/ilABtPYNBerVJAJ/upxHLX81zxNQw05s= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= +github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/gookit/color v1.2.5/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= +github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= +github.com/goreleaser/chglog v0.7.3 h1:eCKJrvsDgG+F1F2fhwM6qX+S5yMiZgsQ4VNTPFl9qEM= +github.com/goreleaser/chglog v0.7.3/go.mod h1:HXPf4avc1kTD00a46LuTEH0i1dZctLq8Xs2BxUfROnY= +github.com/goreleaser/fileglob v1.3.0 h1:/X6J7U8lbDpQtBvGcwwPS6OpzkNVlVEsFUVRx9+k+7I= +github.com/goreleaser/fileglob v1.3.0/go.mod h1:Jx6BoXv3mbYkEzwm9THo7xbr5egkAraxkGorbJb4RxU= +github.com/goreleaser/goreleaser/v2 v2.12.0 h1:UVy62eUC6UiSEoDl4HS2l53pFl1w37cBhIOWRBUgdMY= +github.com/goreleaser/goreleaser/v2 v2.12.0/go.mod h1:h04Jk7YH6U1gwVmVu/bl+GfbsTjITi4eTHVfapwcVPw= +github.com/goreleaser/nfpm/v2 v2.43.1 h1:y7i+5wlAuBqUEsTL4cZp0FjnNStoA/to9Yuju+MqZpc= +github.com/goreleaser/nfpm/v2 v2.43.1/go.mod h1:3sDxZIYoMCd3FPa44PuxWnhMtOUHEDBhsmddHAcfySw= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= +github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= +github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado= +github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= +github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8= +github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc= +github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk= +github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY= +github.com/gostaticanalysis/nilerr v0.1.1 h1:ThE+hJP0fEp4zWLkWHWcRyI2Od0p7DlgYG3Uqrmrcpk= +github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW0HU0GPE3+5PWN4A= +github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= +github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= +github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= +github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 h1:UpiO20jno/eV1eVZcxqWnUohyKRe1g8FPV/xH1s/2qs= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.5 h1:dvk7TIXCZpmfOlM+9mlcrWmWjw/wlKT+VDq2wMvfPJU= +github.com/hashicorp/go-sockaddr v1.0.5/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= +github.com/hashicorp/vault/api v1.16.0 h1:nbEYGJiAPGzT9U4oWgaaB0g+Rj8E59QuHKyA5LhwQN4= +github.com/hashicorp/vault/api v1.16.0/go.mod h1:KhuUhzOD8lDSk29AtzNjgAu2kxRA9jL9NAbkFlqvkBA= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/in-toto/attestation v1.1.1 h1:QD3d+oATQ0dFsWoNh5oT0udQ3tUrOsZZ0Fc3tSgWbzI= +github.com/in-toto/attestation v1.1.1/go.mod h1:Dcq1zVwA2V7Qin8I7rgOi+i837wEf/mOZwRm047Sjys= +github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU= +github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= +github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= +github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= +github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= +github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= +github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= +github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= +github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= +github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= +github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= +github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= +github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= +github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= +github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= +github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= +github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= +github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= +github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= +github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= +github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= +github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= +github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= +github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= +github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= +github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= +github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= +github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= +github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= +github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= +github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= +github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 h1:FWpSWRD8FbVkKQu8M1DM9jF5oXFLyE+XpisIYfdzbic= +github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7/go.mod h1:BMxO138bOokdgt4UaxZiEfypcSHX0t6SIFimVP1oRfk= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/jgautheron/goconst v1.8.2 h1:y0XF7X8CikZ93fSNT6WBTb/NElBu9IjaY7CCYQrCMX4= +github.com/jgautheron/goconst v1.8.2/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako= +github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= +github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= +github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= +github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs= +github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI= github.com/joho/godotenv v1.6.0-pre.2 h1:SCkYm/XGeCcXItAv0Xofqsa4JPdDDkyNcG1Ush5cBLQ= github.com/joho/godotenv v1.6.0-pre.2/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= +github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= +github.com/karamaru-alpha/copyloopvar v1.2.1 h1:wmZaZYIjnJ0b5UoKDjUHrikcV0zuPyyxI4SVplLd2CI= +github.com/karamaru-alpha/copyloopvar v1.2.1/go.mod h1:nFmMlFNlClC2BPvNaHMdkirmTJxVCY0lhxBtlfOypMM= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M= +github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= +github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kulti/thelper v0.6.3 h1:ElhKf+AlItIu+xGnI990no4cE2+XaSu1ULymV2Yulxs= +github.com/kulti/thelper v0.6.3/go.mod h1:DsqKShOvP40epevkFrvIwkCMNYxMeTNjdWL4dqWHZ6I= +github.com/kunwardeep/paralleltest v1.0.14 h1:wAkMoMeGX/kGfhQBPODT/BL8XhK23ol/nuQ3SwFaUw8= +github.com/kunwardeep/paralleltest v1.0.14/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4= +github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI= +github.com/ldez/exptostd v0.4.4 h1:58AtQjnLcT/tI5W/1KU7xE/O7zW9RAWB6c/ScQAnfus= +github.com/ldez/exptostd v0.4.4/go.mod h1:QfdzPw6oHjFVdNV7ILoPu5sw3OZ3OG1JS0I5JN3J4Js= +github.com/ldez/gomoddirectives v0.7.0 h1:EOx8Dd56BZYSez11LVgdj025lKwlP0/E5OLSl9HDwsY= +github.com/ldez/gomoddirectives v0.7.0/go.mod h1:wR4v8MN9J8kcwvrkzrx6sC9xe9Cp68gWYCsda5xvyGc= +github.com/ldez/grignotin v0.10.0 h1:NQPeh1E/Eza4F0exCeC1WkpnLvgUcQDT8MQ1vOLML0E= +github.com/ldez/grignotin v0.10.0/go.mod h1:oR4iCKUP9fwoeO6vCQeD7M5SMxCT6xdVas4vg0h1LaI= +github.com/ldez/tagliatelle v0.7.1 h1:bTgKjjc2sQcsgPiT902+aadvMjCeMHrY7ly2XKFORIk= +github.com/ldez/tagliatelle v0.7.1/go.mod h1:3zjxUpsNB2aEZScWiZTHrAXOl1x25t3cRmzfK1mlo2I= +github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc= +github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ= +github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= +github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= +github.com/letsencrypt/boulder v0.0.0-20250411005613-d800055fe666 h1:ndfLOJNaxu0fX358UKxtq2bU8IMASWi87Hn0Nv/TIoY= +github.com/letsencrypt/boulder v0.0.0-20250411005613-d800055fe666/go.mod h1:WGXwLq/jKt0kng727wv6a0h0q7TVC+MwS2S75rcqL+4= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE= +github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/manuelarte/embeddedstructfieldcheck v0.3.0 h1:VhGqK8gANDvFYDxQkjPbv7/gDJtsGU9k6qj/hC2hgso= +github.com/manuelarte/embeddedstructfieldcheck v0.3.0/go.mod h1:LSo/IQpPfx1dXMcX4ibZCYA7Yy6ayZHIaOGM70+1Wy8= +github.com/manuelarte/funcorder v0.5.0 h1:llMuHXXbg7tD0i/LNw8vGnkDTHFpTnWqKPI85Rknc+8= +github.com/manuelarte/funcorder v0.5.0/go.mod h1:Yt3CiUQthSBMBxjShjdXMexmzpP8YGvGLjrxJNkO2hA= +github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI= +github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE= +github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04= +github.com/maratori/testpackage v1.1.1/go.mod h1:s4gRK/ym6AMrqpOa/kEbQTV4Q4jb7WeLZzVhVVVOQMc= +github.com/mark3labs/mcp-go v0.38.0 h1:E5tmJiIXkhwlV0pLAwAT0O5ZjUZSISE/2Jxg+6vpq4I= +github.com/mark3labs/mcp-go v0.38.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= +github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw= +github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-mastodon v0.0.10 h1:wz1d/aCkJOIkz46iv4eAqXHVreUMxydY1xBWrPBdDeE= +github.com/mattn/go-mastodon v0.0.10/go.mod h1:YBofeqh7G6s787787NQR8erBYz6fKDu+KNMrn5RuD6Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mgechev/revive v1.11.0 h1:b/gLLpBE427o+Xmd8G58gSA+KtBwxWinH/A565Awh0w= +github.com/mgechev/revive v1.11.0/go.mod h1:tI0oLF/2uj+InHCBLrrqfTKfjtFTBCFFfG05auyzgdw= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= +github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/clusters v0.0.0-20180605185049-a07a36e67d36/go.mod h1:mw5KDqUj0eLj/6DUNINLVJNoPTFkEuGMHtJsXLviLkY= -github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762 h1:p4A2Jx7Lm3NV98VRMKlyWd3nqf8obft8NfXlAUmqd3I= -github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762/go.mod h1:mw5KDqUj0eLj/6DUNINLVJNoPTFkEuGMHtJsXLviLkY= -github.com/muesli/gamut v0.3.1 h1:8hozovcrDBWLLAwuOXC+UDyO0/uNroIdXAmY/lQOMHo= -github.com/muesli/gamut v0.3.1/go.mod h1:BED0DN21PXU1YaYNwaTmX9700SRHPcWWd6Llj0zsz5k= -github.com/muesli/kmeans v0.3.1 h1:KshLQ8wAETfLWOJKMuDCVYHnafddSa1kwGh/IypGIzY= -github.com/muesli/kmeans v0.3.1/go.mod h1:8/OvJW7cHc1BpRf8URb43m+vR105DDe+Kj1WcFXYDqc= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= +github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= +github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= +github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= +github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= +github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= +github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= +github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= +github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= +github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= +github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= +github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= +github.com/nunnatsa/ginkgolinter v0.20.0 h1:OmWLkAFO2HUTYcU6mprnKud1Ey5pVdiVNYGO5HVicx8= +github.com/nunnatsa/ginkgolinter v0.20.0/go.mod h1:dCIuFlTPfQerXgGUju3VygfAFPdC5aE1mdacCDKDJcQ= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opencontainers/runc v1.2.3 h1:fxE7amCzfZflJO2lHXf4y/y8M1BoAqp+FVmG19oYB80= +github.com/opencontainers/runc v1.2.3/go.mod h1:nSxcWUydXrsBZVYNSkTjoQ/N6rcyTtn+1SD5D4+kRIM= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= +github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= +github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= +github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= +github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= +github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= +github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= +github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= +github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= +github.com/pborman/getopt v0.0.0-20180811024354-2b5b3bfb099b/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= +github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= +github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= +github.com/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q= +github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/quasilyte/go-ruleguard v0.4.4 h1:53DncefIeLX3qEpjzlS1lyUmQoUEeOWPFWqaTJq9eAQ= +github.com/quasilyte/go-ruleguard v0.4.4/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE= +github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE= +github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= +github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo= +github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng= +github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU= +github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= +github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= +github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= +github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs= +github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g= +github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I= +github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU= +github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= +github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= +github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= +github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ= +github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8= +github.com/sassoftware/go-rpmutils v0.4.0 h1:ojND82NYBxgwrV+mX1CWsd5QJvvEZTKddtCdFLPWhpg= +github.com/sassoftware/go-rpmutils v0.4.0/go.mod h1:3goNWi7PGAT3/dlql2lv3+MSN5jNYPjT5mVcQcIsYzI= +github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= +github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= +github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= +github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= +github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e h1:7q6NSFZDeGfvvtIRwBrU/aegEYJYmvev0cHAwo17zZQ= +github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs= +github.com/secure-systems-lab/go-securesystemslib v0.9.1 h1:nZZaNz4DiERIQguNy0cL5qTdn9lR8XKHf4RUyG1Sx3g= +github.com/secure-systems-lab/go-securesystemslib v0.9.1/go.mod h1:np53YzT0zXGMv6x4iEWc9Z59uR+x+ndLwCLqPYpLXVU= +github.com/securego/gosec/v2 v2.22.7 h1:8/9P+oTYI4yIpAzccQKVsg1/90Po+JzGtAhqoHImDeM= +github.com/securego/gosec/v2 v2.22.7/go.mod h1:510TFNDMrIPytokyHQAVLvPeDr41Yihn2ak8P+XQfNE= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= +github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= +github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sigstore/cosign/v2 v2.5.0 h1:1aRfPgRQHHlODI3Mvs/JkPBS9dJT9bRLCuHZgnHxFt8= +github.com/sigstore/cosign/v2 v2.5.0/go.mod h1:2V2hmo+jjFNnDb5Q5VL6PXvLU9Vujio7T5yldrpNTRw= +github.com/sigstore/protobuf-specs v0.5.0 h1:F8YTI65xOHw70NrvPwJ5PhAzsvTnuJMGLkA4FIkofAY= +github.com/sigstore/protobuf-specs v0.5.0/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc= +github.com/sigstore/rekor v1.4.1-0.20250814000724-cdd95725eb11 h1:IIi+uX7tOiFbmN0R6rp20UKA2A6+k2dvn2cGwOrAcgc= +github.com/sigstore/rekor v1.4.1-0.20250814000724-cdd95725eb11/go.mod h1:Ed+zP1YbreHsejbcTaLyAhdav3zsfj5qd8eOzf/TYDY= +github.com/sigstore/sigstore v1.9.5 h1:Wm1LT9yF4LhQdEMy5A2JeGRHTrAWGjT3ubE5JUSrGVU= +github.com/sigstore/sigstore v1.9.5/go.mod h1:VtxgvGqCmEZN9X2zhFSOkfXxvKUjpy8RpUW39oCtoII= +github.com/sigstore/sigstore-go v0.7.1 h1:lyzi3AjO6+BHc5zCf9fniycqPYOt3RaC08M/FRmQhVY= +github.com/sigstore/sigstore-go v0.7.1/go.mod h1:AIRj4I3LC82qd07VFm3T2zXYiddxeBV1k/eoS8nTz0E= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.5 h1:qp2VFyKuFQvTGmZwk5Q7m5nE4NwnF9tHwkyz0gtWAck= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.5/go.mod h1:DKlQjjr+GsWljEYPycI0Sf8URLCk4EbGA9qYjF47j4g= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.5 h1:CRZcdYn5AOptStsLRAAACudAVmb1qUbhMlzrvm7ju3o= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.5/go.mod h1:b9rFfITq2fp1M3oJmq6lFFhSrAz5vOEJH1qzbMsZWN4= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.6-0.20250729224751-181c5d3339b3 h1:a7Yz8C0aBa/LjeiTa9ZLYi9B74GNhFRnUIUdvN6ddVk= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.6-0.20250729224751-181c5d3339b3/go.mod h1:tRtJzSZ48MXJV9bmS8pkb3mP36PCad/Cs+BmVJ3Z4O4= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.5 h1:S2ukEfN1orLKw2wEQIUHDDlzk0YcylhcheeZ5TGk8LI= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.5/go.mod h1:m7sQxVJmDa+rsmS1m6biQxaLX83pzNS7ThUEyjOqkCU= +github.com/sigstore/timestamp-authority v1.2.5 h1:W22JmwRv1Salr/NFFuP7iJuhytcZszQjldoB8GiEdnw= +github.com/sigstore/timestamp-authority v1.2.5/go.mod h1:gWPKWq4HMWgPCETre0AakgBzcr9DRqHrsgbrRqsigOs= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= +github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= +github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o= +github.com/sonatard/noctx v0.4.0/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0= +github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= +github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= +github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= +github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4= +github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA= +github.com/tdakkota/asciicheck v0.4.1 h1:bm0tbcmi0jezRA2b5kg4ozmMuGAFotKI3RZfrhfovg8= +github.com/tdakkota/asciicheck v0.4.1/go.mod h1:0k7M3rCfRXb0Z6bwgvkEIMleKH3kXNz9UqJ9Xuqopr8= +github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= +github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= +github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= +github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= +github.com/tetafro/godot v1.5.1 h1:PZnjCol4+FqaEzvZg5+O8IY2P3hfY9JzRBNPv1pEDS4= +github.com/tetafro/godot v1.5.1/go.mod h1:cCdPtEndkmqqrhiCfkmxDodMQJ/f3L1BCNskCUZdTwk= +github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/theupdateframework/go-tuf/v2 v2.0.2 h1:PyNnjV9BJNzN1ZE6BcWK+5JbF+if370jjzO84SS+Ebo= +github.com/theupdateframework/go-tuf/v2 v2.0.2/go.mod h1:baB22nBHeHBCeuGZcIlctNq4P61PcOdyARlplg5xmLA= +github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk= +github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460= +github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= +github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8= +github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuXmlxZVo/ds7JKJI= +github.com/tink-crypto/tink-go-awskms/v2 v2.1.0/go.mod h1:PxSp9GlOkKL9rlybW804uspnHuO9nbD98V/fDX4uSis= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= +github.com/tink-crypto/tink-go/v2 v2.4.0 h1:8VPZeZI4EeZ8P/vB6SIkhlStrJfivTJn+cQ4dtyHNh0= +github.com/tink-crypto/tink-go/v2 v2.4.0/go.mod h1:l//evrF2Y3MjdbpNDNGnKgCpo5zSmvUvnQ4MU+yE2sw= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/tomarrell/wrapcheck/v2 v2.11.0 h1:BJSt36snX9+4WTIXeJ7nvHBQBcm1h2SjQMSlmQ6aFSU= +github.com/tomarrell/wrapcheck/v2 v2.11.0/go.mod h1:wFL9pDWDAbXhhPZZt+nG8Fu+h29TtnZ2MW6Lx4BRXIU= +github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= +github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= +github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y= +github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE= +github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI= +github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA= +github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g= +github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8= +github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA= +github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU= +github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU= +github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg= +github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= +github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 h1:jIVmlAFIqV3d+DOxazTR9v+zgj8+VYuQBzPgBZvWBHA= +github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651/go.mod h1:b26F2tHLqaoRQf8DywqzVaV1MQ9yvjb0OMcNl7Nxu20= +github.com/wagoodman/go-progress v0.0.0-20220614130704-4b1c25a33c7c h1:gFwUKtkv6QzQsFdIjvPqd0Qdw42DHUEbbUdiUTI1uco= +github.com/wagoodman/go-progress v0.0.0-20220614130704-4b1c25a33c7c/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= +github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= +github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= +github.com/whyrusleeping/cbor-gen v0.1.3-0.20240731173018-74d74643234c h1:Jmc9fHbd0LKFmS5CkLgczNUyW36UbiyvbHCG9xCTyiw= +github.com/whyrusleeping/cbor-gen v0.1.3-0.20240731173018-74d74643234c/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM= +github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= -github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= -github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= +github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk= +github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs= +github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4= +github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw= +github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0= github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= +gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= +gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= +gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8= +gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0= +gitlab.com/gitlab-org/api/client-go v0.142.5 h1:zvengEU958Fjwasi1V+9QNRw0viqNKkqUwvFD15XDZI= +gitlab.com/gitlab-org/api/client-go v0.142.5/go.mod h1:Ru5IRauphXt9qwmTzJD7ou1dH7Gc6pnsdFWEiMMpmB0= +go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= +go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= +go-simpler.org/musttag v0.13.1 h1:lw2sJyu7S1X8lc8zWUAdH42y+afdcCnHhWpnkWvd6vU= +go-simpler.org/musttag v0.13.1/go.mod h1:8r450ehpMLQgvpb6sg+hV5Ur47eH6olp/3yEanfG97k= +go-simpler.org/sloglint v0.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s= +go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ= +go.augendre.info/arangolint v0.2.0 h1:2NP/XudpPmfBhQKX4rMk+zDYIj//qbt4hfZmSSTcpj8= +go.augendre.info/arangolint v0.2.0/go.mod h1:Vx4KSJwu48tkE+8uxuf0cbBnAPgnt8O1KWiT7bljq7w= +go.augendre.info/fatcontext v0.8.0 h1:2dfk6CQbDGeu1YocF59Za5Pia7ULeAM6friJ3LP7lmk= +go.augendre.info/fatcontext v0.8.0/go.mod h1:oVJfMgwngMsHO+KB2MdgzcO+RvtNdiCEOlWvSFtax/s= +go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= +go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 h1:QcFwRrZLc82r8wODjvyCbP7Ifp3UANaBSmhDSFjnqSc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0/go.mod h1:CXIWhUomyWBG/oY2/r/kLp6K/cmx9e/7DLpBuuGdLCA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 h1:9kV11HXBHZAvuPUZxmMWrH8hZn/6UnHX4K0mu36vNsU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0/go.mod h1:JyA0FHXe22E1NeNiHmVp7kFHglnexDQ7uRWDiiJ1hKQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0= +go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU= +go.opentelemetry.io/otel/exporters/prometheus v0.54.0/go.mod h1:QyjcV9qDP6VeK5qPyKETvNjmaaEc7+gqjh4SS0ZYzDU= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= +go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= +go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= +go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= +go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= +go.step.sm/crypto v0.69.0 h1:ELMNQjAGsnwpOeRfX/1phJdWm8Y6RIxAXnDzYlU9AOk= +go.step.sm/crypto v0.69.0/go.mod h1:mZ0mP4Q4wdoDy+fdEo6cOo0qzDDf7KgkvSIleTLv1+w= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= +go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gocloud.dev v0.42.0 h1:qzG+9ItUL3RPB62/Amugws28n+4vGZXEoJEAMfjutzw= +gocloud.dev v0.42.0/go.mod h1:zkaYAapZfQisXOA4bzhsbA4ckiStGQ3Psvs9/OQ5dPM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= -golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b h1:KdrhdYPDUvJTvrDK9gdjfFd6JTk8vA1WJoldYSi0kHo= +golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b/go.mod h1:LKZHyeOpPuZcMgxeHjJp4p5yvxrCX1xDvH10zYHhjjQ= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200820010801-b793a1359eac/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20201023174141-c8cfbd0f21e6/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= +golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/api v0.246.0 h1:H0ODDs5PnMZVZAEtdLMn2Ul2eQi7QNjqM2DIFp8TlTM= +google.golang.org/api v0.246.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 h1:mVXdvnmR3S3BQOqHECm9NGMjYiRtEvDYcqAqedTXY6s= +google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:vYFwMYFbmA8vl6Z/krj/h7+U/AqpHknwJX4Uqgfyc7I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= +gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= +gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= +honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= +lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= +mvdan.cc/gofumpt v0.8.0 h1:nZUCeC2ViFaerTcYKstMmfysj6uhQrA2vJe+2vwGU6k= +mvdan.cc/gofumpt v0.8.0/go.mod h1:vEYnSzyGPmjvFkqJWtXkh79UwPWP9/HMxQdGEXZHjpg= +mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 h1:WjUu4yQoT5BHT1w8Zu56SP8367OuBV5jvo+4Ulppyf8= +mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4/go.mod h1:rthT7OuvRbaGcd5ginj6dA2oLE7YNlta9qhBNNdCaLE= +sigs.k8s.io/kind v0.27.0 h1:PQ3f0iAWNIj66LYkZ1ivhEg/+Zb6UPMbO+qVei/INZA= +sigs.k8s.io/kind v0.27.0/go.mod h1:RZVFmy6qcwlSWwp6xeIUv7kXCPF3i8MXsEXxW/J+gJY= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= +software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/adapters/handlers/tui/simple_window.go b/internal/adapters/handlers/tui/simple_window.go deleted file mode 100644 index 0ddb53c..0000000 --- a/internal/adapters/handlers/tui/simple_window.go +++ /dev/null @@ -1,142 +0,0 @@ -package tui - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/jlrosende/project-manager/internal/core/domain" - "github.com/jlrosende/project-manager/internal/core/services" -) - -var baseStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")) - -type SimpleWindow struct { - projectSvc *services.ProjectService - - projects []*domain.Project - selectedProject *domain.Project - - cursor int - cursorEnv int - total int - width int - height int -} - -func NewSimpleWindow(projectSvc *services.ProjectService) (*SimpleWindow, error) { - - projects, err := projectSvc.List() - - if err != nil { - return nil, err - } - - total := 0 - - if len(projects) > 0 { - total = len(projects) - } else { - total = 1 - } - - return &SimpleWindow{ - projectSvc: projectSvc, - projects: projects, - total: total, - cursor: 0, - }, nil -} - -func (m SimpleWindow) Init() tea.Cmd { - return tea.SetWindowTitle("Project Manager") -} - -func (m *SimpleWindow) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c", "esc": - m.selectedProject = nil - return m, tea.Quit - case "enter": - project, _ := m.projectSvc.Load(m.projects[m.cursor%m.total].Name) - m.selectedProject = project - // Todo check project if have environments and create a new view with environment selection - return m, tea.Quit - - // The "up" and "k" keys move the cursor up - case "up", "k": - m.cursor-- - // The "down" and "j" keys move the cursor down - case "down", "j": - m.cursor++ - } - } - - return m, tea.Batch( - cmd, - tea.Printf("Let's go to %d!", m.cursor), - ) -} - -func (m SimpleWindow) View() string { - s := strings.Builder{} - - title := " Projects" - titleStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("14")). - Align(lipgloss.Center). - Border(lipgloss.NormalBorder(), false, false, true). - Padding(0, 1) - - selectedStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")).Padding(0, 1) - - defaultStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")).Padding(0, 1) - - s.WriteString(titleStyle.Render(title)) - s.WriteString("\n") - - mod := mod(m.cursor, m.total) - - for i, project := range m.projects { - if mod == i { - s.WriteString("->") - s.WriteString(selectedStyle.Render(project.Name)) - s.WriteString("\n") - for _, env := range project.Environments { - s.WriteString(" \u2022") - s.WriteString(defaultStyle.Foreground(lipgloss.Color(env.Color)).Render(env.Name)) - s.WriteString("\n") - } - s.WriteString("\n") - } else { - s.WriteString("-") - s.WriteString(defaultStyle.Render(project.Name)) - s.WriteString("\n") - } - - } - - // s.WriteString(fmt.Sprintf("Cursor: %d, mod: %d", m.cursor, mod)) - s.WriteString("\n") - - return lipgloss.Place(m.width, m.height, lipgloss.Left, lipgloss.Top, s.String()) -} - -func (m *SimpleWindow) SelectedProject() *domain.Project { - return m.selectedProject -} - -func mod(a, b int) int { - return (a%b + b) % b -} diff --git a/internal/adapters/handlers/tui/window.go b/internal/adapters/handlers/tui/window.go index e8ceb41..3c37451 100644 --- a/internal/adapters/handlers/tui/window.go +++ b/internal/adapters/handlers/tui/window.go @@ -1,174 +1,258 @@ package tui import ( + "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/list" + "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/services" - "github.com/jlrosende/project-manager/pkg/ui/card" + "github.com/jlrosende/project-manager/pkg/ui/textinput" ) type Window struct { - projects []card.Card + projectSvc *services.ProjectService - width int - height int -} + projects []*domain.Project + selectedProject *domain.Project -var _ tea.Model = (*Window)(nil) + cursor int + cursorEnv int + focus int // 0=projects, 1=environments + total int + width int + height int +} func NewWindow(projectSvc *services.ProjectService) (*Window, error) { + projects, err := projectSvc.List() if err != nil { return nil, err } - projectCards := []card.Card{} - - for _, project := range projects { - projectCards = append(projectCards, card.NewCard(project.Name, project.Description)) + total := len(projects) + 1 + if total == 0 { + total = 1 } return &Window{ - projects: projectCards, + projectSvc: projectSvc, + projects: projects, + total: total, + cursor: 0, }, nil } func (m Window) Init() tea.Cmd { - return tea.Batch( - tea.SetWindowTitle("project manager"), - ) + return tea.SetWindowTitle("Project Manager") } -func (m Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd +func (m *Window) newProjectFlow() { + name := "" + if m2, err := tea.NewProgram(textinput.NewTextInput("Project name:", "my-project")).Run(); err == nil { + if ti, ok := m2.(*textinput.TextInput); ok { + name = ti.Value() + } + } + if name == "" { + return + } + path := "" + if m3, err := tea.NewProgram(textinput.NewTextInput("Path:", "~/code/my-project")).Run(); err == nil { + if ti, ok := m3.(*textinput.TextInput); ok { + path = ti.Value() + } + } + if path == "" { + return + } + _, _ = m.projectSvc.Create(name, path, "", domain.EnvVars{}, domain.New()) + projects, err := m.projectSvc.List() + if err == nil { + m.projects = projects + m.total = len(projects) + 1 + } +} +func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.Type { - case tea.KeyCtrlC, tea.KeyEsc: - return m, tea.Quit - } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c", "esc": + m.selectedProject = nil + return m, tea.Quit + case "enter": + if m.focus == 0 { + idx := mod(m.cursor, m.total) + if idx == len(m.projects) { // '+ New project' + m.newProjectFlow() + return m, nil + } + project, _ := m.projectSvc.Load(m.projects[idx].Name) + m.selectedProject = project + return m, tea.Quit + } + if m.focus == 1 { + mod := mod(m.cursor, m.total) + if mod < len(m.projects) && len(m.projects) > 0 && len(m.projects[mod].Environments) > 0 { + project, _ := m.projectSvc.Load(m.projects[mod].Name) + m.selectedProject = project + return m, tea.Quit + } + } + + // The "up" and "k" keys move the cursor up + case "left", "h": + m.focus = 0 + case "right", "l": + mod := mod(m.cursor, m.total) + if mod < len(m.projects) && len(m.projects) > 0 && len(m.projects[mod].Environments) > 0 { + m.focus = 1 + } + case "up", "k": + if m.focus == 0 { + m.cursor-- + m.cursorEnv = 0 + } else { + mod := mod(m.cursor, m.total) + if mod < len(m.projects) && len(m.projects) > 0 { + envCount := len(m.projects[mod].Environments) + if envCount > 0 { + m.cursorEnv-- + if m.cursorEnv < 0 { + m.cursorEnv = envCount - 1 + } + } + } + } + // The "down" and "j" keys move the cursor down + case "down", "j": + if m.focus == 0 { + m.cursor++ + m.cursorEnv = 0 + } else { + mod := mod(m.cursor, m.total) + if mod < len(m.projects) && len(m.projects) > 0 { + envCount := len(m.projects[mod].Environments) + if envCount > 0 { + m.cursorEnv++ + if m.cursorEnv >= envCount { + m.cursorEnv = 0 + } + } + } + } + } } - return m, cmd -} - -func (m Window) View() string { - - // Help Block - helpPanel := m.helpPanelRender() - - // Sidebar - sidebarPanel := lipgloss.Place( - m.width/5, - m.height, - lipgloss.Left, - lipgloss.Top, - m.sidebarPanelRender(), + return m, tea.Batch( + cmd, + tea.Printf("Let's go to %d!", m.cursor), ) - - // View - viewPanel := lipgloss.Place( - (m.width/5)*4, - m.height, - lipgloss.Left, - lipgloss.Top, - m.viewPanelRender(), - ) - - mainPanel := lipgloss.JoinHorizontal(lipgloss.Left, - sidebarPanel, - viewPanel, - ) - - // return mainPanel - view := lipgloss.JoinVertical( - lipgloss.Left, - mainPanel, - helpPanel, - ) - - return view - } -func (m Window) helpPanelRender() string { - helpBlockStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#777777")) - - return helpBlockStyle.Render("? help") -} - -func (m Window) sidebarPanelRender() string { - - var sidebarRender string - - // Title block - titleBlockStyle := lipgloss.NewStyle(). - Width(m.width/5). +func (m Window) View() string { + left := strings.Builder{} + title := " Projects" + titleStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("14")). - Padding(1, 2, 0). - Bold(true). - BorderStyle(lipgloss.NormalBorder()). - BorderBottom(true). Align(lipgloss.Center). - Render(" Projects") - - projectsBlockStyle := lipgloss.NewStyle(). - Padding(0, 2). - Render("[ ] - project 1") + Border(lipgloss.NormalBorder(), false, false, true). + Padding(0, 1) + selectedStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")).Padding(0, 1) + defaultStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")).Padding(0, 1) + left.WriteString(titleStyle.Render(title)) + left.WriteString("\n") + mod := mod(m.cursor, m.total) + for i, project := range m.projects { + if mod == i { + left.WriteString("->") + left.WriteString(selectedStyle.Render(project.Name)) + left.WriteString("\n") + } else { + left.WriteString(" ") + left.WriteString(defaultStyle.Render(project.Name)) + left.WriteString("\n") + } + } + if mod == len(m.projects) { + left.WriteString("->") + left.WriteString(selectedStyle.Render("+ New project")) + left.WriteString("\n") + } else { + left.WriteString(" ") + left.WriteString(defaultStyle.Render("+ New project")) + left.WriteString("\n") + } + right := strings.Builder{} + rightTitle := "󱄑 Environments" + right.WriteString(titleStyle.Render(rightTitle)) + right.WriteString("\n") + if mod < len(m.projects) && len(m.projects) > 0 { + p := m.projects[mod] + if len(p.Environments) == 0 { + right.WriteString(defaultStyle.Render("No environments")) + right.WriteString("\n") + } else { + for i, env := range p.Environments { + marker := " " + selected := m.focus == 1 && m.cursorEnv%max(1, len(p.Environments)) == i + if selected { + marker = "->" + } + rowStyle := lipgloss.NewStyle() + if selected { + rowStyle = rowStyle.Background(lipgloss.Color(env.Color)).Padding(0, 0, 0, 1) + } + content := defaultStyle.Foreground(lipgloss.Color(env.Color)).Render("● " + env.Name) + if p.DefaultEnv != "" && env.Name == p.DefaultEnv { + content = content + " " + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("(default)") + } + right.WriteString(marker) + right.WriteString(rowStyle.Render(content)) + right.WriteString("\n") + } + } + } + content := lipgloss.JoinHorizontal(lipgloss.Left, left.String(), " ", right.String()) + help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Keys: ↑/k ↓/j navigate ←/h β†’/l focus Enter select Esc/Ctrl+C exit") + return lipgloss.JoinVertical(lipgloss.Left, content, help) +} - sidebarRender = lipgloss.JoinVertical( - lipgloss.Left, - titleBlockStyle, - projectsBlockStyle, - ) +func (m *Window) SelectedProject() *domain.Project { + return m.selectedProject +} - return sidebarRender +func (m *Window) SelectedEnvironment() string { + if len(m.projects) == 0 { + return "" + } + idx := mod(m.cursor, m.total) + p := m.projects[idx] + if len(p.Environments) == 0 { + return "" + } + e := m.cursorEnv % len(p.Environments) + if e < 0 { + e = 0 + } + return p.Environments[e].Name } -func (m Window) viewPanelRender() string { - - viewPanelStyle := lipgloss.NewStyle(). - Padding(1, 2, 0). - Align(lipgloss.Left). - Border(lipgloss.NormalBorder(), - false, false, true, true, - ) - - descriptionTitle := viewPanelStyle. - Foreground(lipgloss.Color("10")). - Render("σ°¦ͺ Description") - - historyB := "Medieval quince preserves, which went by the French name cotignac, produced in a clear version and a fruit pulp version, began to lose their medieval seasoning of spices in the 16th century. In the 17th century, La Varenne provided recipes for both thick and clear cotignac.Medieval quince preserves, which went by the French name cotignac, produced in a clear version and a fruit pulp version, began to lose their medieval seasoning of spices in the 16th century. In the 17th century, La Varenne provided recipes for both thick and clear cotignac. Medieval quince preserves, which went by the French name cotignac, produced in a clear version and a fruit pulp version, began to lose their medieval seasoning of spices in the 16th century. In the 17th century, La Varenne provided recipes for both thick and clear cotignac." - - descriptionText := viewPanelStyle. - Width((m.width / 5) * 4). - BorderBottom(false). - Render(historyB) - - envsTitle := viewPanelStyle. - Foreground(lipgloss.Color("11")). - Render("󱄑 Environments") - - // envsList := viewPanelStyle.BorderBottom(false).PaddingTop(0) - - viewBlockStyle := lipgloss.JoinVertical( - lipgloss.Left, - descriptionTitle, - descriptionText, - envsTitle, - list.New( - "dev", - "pre", - "pro", - ).Enumerator(list.Roman).Value(), - ) +func mod(a, b int) int { return (a%b + b) % b } - return viewBlockStyle +func max(a, b int) int { + if a > b { + return a + } + return b } diff --git a/internal/adapters/repositories/project_repository.go b/internal/adapters/repositories/project_repository.go index 0fc9ba2..637770c 100644 --- a/internal/adapters/repositories/project_repository.go +++ b/internal/adapters/repositories/project_repository.go @@ -245,7 +245,7 @@ func (p *ProjectRepository) Create(name, path, subproject string, envVars domain return nil, fmt.Errorf("%s already exists in directory %s", envPath, path) } - fpEnv, err := os.OpenFile(envPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + fpEnv, err := os.OpenFile(envPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return nil, err @@ -253,11 +253,11 @@ func (p *ProjectRepository) Create(name, path, subproject string, envVars domain defer fpEnv.Close() for key, value := range envVars { - n_bytes_env, err := fpEnv.WriteString(fmt.Sprintf("%s=%s\n", key, value)) + n, err := fmt.Fprintf(fpEnv, "%s=%s\n", key, value) if err != nil { return nil, err } - slog.Debug(fmt.Sprintf("%d Bytes written in %s", n_bytes_env, envPath)) + slog.Debug("bytes written", "n", n, "path", envPath) } diff --git a/internal/core/domain/project.go b/internal/core/domain/project.go index ec32b2c..41430bd 100644 --- a/internal/core/domain/project.go +++ b/internal/core/domain/project.go @@ -10,8 +10,10 @@ type Project struct { EnvVarsFile string `hcl:"env_vars_file"` Environments []*Environment `hcl:"environment,block"` EnvVars EnvVars + DefaultEnv string `hcl:"default_env,optional"` } + type Environment struct { Name string `hcl:"name,label"` Color string `hcl:"color,optional"` diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..1ef9017 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,46 @@ +package logger + +import ( + "log/slog" + "os" + "path/filepath" +) + +func Setup(levelText string, filePath string) (*slog.Logger, error) { + var lv slog.LevelVar + + if levelText == "" { + levelText = "info" + } + + if err := lv.UnmarshalText([]byte(levelText)); err != nil { + return nil, err + } + + if filePath == "" { + cache, err := os.UserCacheDir() + if err != nil { + return nil, err + } + filePath = filepath.Join(cache, "pm.log") + } + + fp, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) + + if err != nil { + return nil, err + } + + lg := slog.New(slog.NewTextHandler(fp, &slog.HandlerOptions{Level: lv.Level()})) + + lg = lg.With( + slog.Group("ps", + slog.Int("pid", os.Getpid()), + slog.Int("ppid", os.Getppid()), + slog.String("project", os.Getenv("PM_ACTIVE_PROJECT")), + ), + ) + slog.SetDefault(lg) + + return lg, nil +} diff --git a/internal/tools/docsgen/main.go b/internal/tools/docgen/main.go similarity index 100% rename from internal/tools/docsgen/main.go rename to internal/tools/docgen/main.go diff --git a/internal/version.go b/internal/version.go index 4293c39..b914e64 100644 --- a/internal/version.go +++ b/internal/version.go @@ -6,12 +6,12 @@ import ( ) var ( - mayor = "0" - minor = "0" - patch = "0" - build = "" + version = "" + commit = "" + date = "" + builtBy = "" ) func GetVersion() string { - return fmt.Sprintf("%s.%s.%s build=%s os=%s arch=%s", mayor, minor, patch, build, runtime.GOARCH, runtime.GOOS) + return fmt.Sprintf("%s (%s) [by=%s os=%s arch=%s date=%s]", version, commit, builtBy, runtime.GOARCH, runtime.GOOS, date) } diff --git a/man/pm-edit.1 b/man/pm-edit.1 new file mode 100644 index 0000000..2e5d083 --- /dev/null +++ b/man/pm-edit.1 @@ -0,0 +1,31 @@ +.nh +.TH "PM" "1" "Sep 2025" "Auto generated by spf13/cobra" "" + +.SH NAME +pm-edit - edit project + + +.SH SYNOPSIS +\fBpm edit [flags]\fP + + +.SH DESCRIPTION +Edit all projects + + +.SH OPTIONS +\fB-h\fP, \fB--help\fP[=false] + help for edit + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--log-file\fP="" + Path to log file (default: $XDG_CACHE_HOME/pm.log) + +.PP +\fB--log-level\fP="info" + Change the log level (debug, info, warn, error) + + +.SH SEE ALSO +\fBpm(1)\fP diff --git a/man/pm-init.1 b/man/pm-init.1 new file mode 100644 index 0000000..7dbd672 --- /dev/null +++ b/man/pm-init.1 @@ -0,0 +1,31 @@ +.nh +.TH "PM" "1" "Sep 2025" "Auto generated by spf13/cobra" "" + +.SH NAME +pm-init - Initialize a your workspace + + +.SH SYNOPSIS +\fBpm init [flags]\fP + + +.SH DESCRIPTION +Init + + +.SH OPTIONS +\fB-h\fP, \fB--help\fP[=false] + help for init + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--log-file\fP="" + Path to log file (default: $XDG_CACHE_HOME/pm.log) + +.PP +\fB--log-level\fP="info" + Change the log level (debug, info, warn, error) + + +.SH SEE ALSO +\fBpm(1)\fP diff --git a/man/pm-list.1 b/man/pm-list.1 new file mode 100644 index 0000000..4c2760c --- /dev/null +++ b/man/pm-list.1 @@ -0,0 +1,63 @@ +.nh +.TH "PM" "1" "Sep 2025" "Auto generated by spf13/cobra" "" + +.SH NAME +pm-list - list projects + + +.SH SYNOPSIS +\fBpm list [flags]\fP + + +.SH DESCRIPTION +List all projects + + +.SH OPTIONS +\fB--commit.gpgsign\fP[=true] + git commit.gpgsign (default git --global) + +.PP +\fB--env-vars\fP=[] + List of ENV_VARS to add to the environment + +.PP +\fB--format\fP="" + output format + +.PP +\fB-h\fP, \fB--help\fP[=false] + help for list + +.PP +\fB--shell\fP="" + Shell of the project, need be installed in the system) (default to $SHELL env var)) + +.PP +\fB--tag.gpgsign\fP[=true] + git tag.gpgsign (default git --global) + +.PP +\fB--user.email\fP="" + git user.email (default git --global) + +.PP +\fB--user.name\fP="" + git user.name (default git --global) + +.PP +\fB--user.signingkey\fP="" + git user.signingkey (default git --global) + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--log-file\fP="" + Path to log file (default: $XDG_CACHE_HOME/pm.log) + +.PP +\fB--log-level\fP="info" + Change the log level (debug, info, warn, error) + + +.SH SEE ALSO +\fBpm(1)\fP diff --git a/man/pm-new.1 b/man/pm-new.1 new file mode 100644 index 0000000..5efa08d --- /dev/null +++ b/man/pm-new.1 @@ -0,0 +1,63 @@ +.nh +.TH "PM" "1" "Sep 2025" "Auto generated by spf13/cobra" "" + +.SH NAME +pm-new - Create new project + + +.SH SYNOPSIS +\fBpm new [] [flags]\fP + + +.SH DESCRIPTION +Create a new project and all the basic configuration files + + +.SH OPTIONS +\fB--commit.gpgsign\fP[=true] + git commit.gpgsign (default git --global) + +.PP +\fB--env-vars\fP=[] + List of ENV_VARS to add to the environment + +.PP +\fB-h\fP, \fB--help\fP[=false] + help for new + +.PP +\fB--shell\fP="" + Shell of the project, need be installed in the system) (default to $SHELL env var)) + +.PP +\fB--subproject\fP="" + Set this new project as subproject + +.PP +\fB--tag.gpgsign\fP[=true] + git tag.gpgsign (default git --global) + +.PP +\fB--user.email\fP="" + git user.email (default git --global) + +.PP +\fB--user.name\fP="" + git user.name (default git --global) + +.PP +\fB--user.signingkey\fP="" + git user.signingkey (default git --global) + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--log-file\fP="" + Path to log file (default: $XDG_CACHE_HOME/pm.log) + +.PP +\fB--log-level\fP="info" + Change the log level (debug, info, warn, error) + + +.SH SEE ALSO +\fBpm(1)\fP diff --git a/man/pm.1 b/man/pm.1 new file mode 100644 index 0000000..b455d1a --- /dev/null +++ b/man/pm.1 @@ -0,0 +1,34 @@ +.nh +.TH "PM" "1" "Sep 2025" "" "" + +.SH NAME +pm - pm is a tool to create and organize projects in your computer + + +.SH SYNOPSIS +\fBpm [project] [env|[path]] [flags]\fP + + +.SH DESCRIPTION +A tool to manage the configuration and estructure of multiple projects inside your computer + + +.SH OPTIONS +\fB-h\fP, \fB--help\fP[=false] + help for pm + +.PP +\fB-l\fP, \fB--list\fP[=false] + List all the projects. + +.PP +\fB--log-file\fP="" + Path to log file (default: $XDG_CACHE_HOME/pm.log) + +.PP +\fB--log-level\fP="info" + Change the log level (debug, info, warn, error) + + +.SH SEE ALSO +\fBpm-edit(1)\fP, \fBpm-init(1)\fP, \fBpm-list(1)\fP, \fBpm-new(1)\fP diff --git a/pkg/ui/list/envs_list.go b/pkg/ui/list/envs_list.go new file mode 100644 index 0000000..180392e --- /dev/null +++ b/pkg/ui/list/envs_list.go @@ -0,0 +1,9 @@ +package list + +func RenderNames(title string, names []string) string { + it := []Item{} + for _, n := range names { + it = append(it, Item{Name: n}) + } + return NewList(title, it).View() +} diff --git a/test/.gitkeep b/test/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/integration/tui_collapse_envs_test.go b/tests/integration/tui_collapse_envs_test.go new file mode 100644 index 0000000..c758e5f --- /dev/null +++ b/tests/integration/tui_collapse_envs_test.go @@ -0,0 +1,7 @@ +package integration + +import "testing" + +func TestCollapseBehaviorOnNarrowWidth(t *testing.T) { + t.Fatal("expected collapse when width < 60") +} diff --git a/tests/integration/tui_project_selected_test.go b/tests/integration/tui_project_selected_test.go new file mode 100644 index 0000000..59840ad --- /dev/null +++ b/tests/integration/tui_project_selected_test.go @@ -0,0 +1,7 @@ +package integration + +import "testing" + +func TestProjectSelectedUpdatesEnvs(t *testing.T) { + t.Fatal("expected envs updated on ProjectSelectedMsg") +} diff --git a/tests/integration/tui_projects_loaded_test.go b/tests/integration/tui_projects_loaded_test.go new file mode 100644 index 0000000..f880109 --- /dev/null +++ b/tests/integration/tui_projects_loaded_test.go @@ -0,0 +1,9 @@ +package integration + +import ( + "testing" +) + +func TestProjectsLoadedDerivesEnvs(t *testing.T) { + t.Fatal("expected envs derived on ProjectsLoadedMsg") +} diff --git a/tests/integration/tui_view_envs_test.go b/tests/integration/tui_view_envs_test.go new file mode 100644 index 0000000..c84bacf --- /dev/null +++ b/tests/integration/tui_view_envs_test.go @@ -0,0 +1,7 @@ +package integration + +import "testing" + +func TestViewRendersEnvsColumn(t *testing.T) { + t.Fatal("expected envs column when width sufficient") +} diff --git a/tests/unit/pkg_ui_envs_list_test.go b/tests/unit/pkg_ui_envs_list_test.go new file mode 100644 index 0000000..ebff910 --- /dev/null +++ b/tests/unit/pkg_ui_envs_list_test.go @@ -0,0 +1,13 @@ +package unit + +import ( + list "github.com/jlrosende/project-manager/pkg/ui/list" + "testing" +) + +func TestRenderNames(t *testing.T) { + out := list.RenderNames("", []string{"dev", "pre"}) + if len(out) == 0 { + t.Fatal("expected non-empty render output") + } +} diff --git a/tests/unit/pkg_ui_styles_test.go b/tests/unit/pkg_ui_styles_test.go new file mode 100644 index 0000000..67993e9 --- /dev/null +++ b/tests/unit/pkg_ui_styles_test.go @@ -0,0 +1,13 @@ +package unit + +import ( + "github.com/jlrosende/project-manager/pkg/ui/styles" + "testing" +) + +func TestDefaultStyleWidth(t *testing.T) { + s := styles.DefaultStyle + if s.GetWidth() == 0 { + t.Fatal("expected non-zero width") + } +} From 10080b48871acd452634e6ae2cfa82ab984ab823 Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Mon, 15 Sep 2025 02:13:22 +0200 Subject: [PATCH 03/27] add new environments --- internal/adapters/handlers/tui/.gitkeep | 0 internal/adapters/handlers/tui/Window.md | 18 -- internal/adapters/handlers/tui/window.go | 257 ---------------- internal/adapters/handlers/tui/window_core.go | 70 +++++ .../adapters/handlers/tui/window_env_form.go | 144 +++++++++ internal/adapters/handlers/tui/window_flow.go | 76 +++++ internal/adapters/handlers/tui/window_form.go | 288 ++++++++++++++++++ .../adapters/handlers/tui/window_update.go | 220 +++++++++++++ internal/adapters/handlers/tui/window_view.go | 102 +++++++ .../repositories/project_repository.go | 182 +++++++---- internal/core/ports/project_port.go | 2 + internal/core/services/project_service.go | 4 + 12 files changed, 1022 insertions(+), 341 deletions(-) delete mode 100644 internal/adapters/handlers/tui/.gitkeep delete mode 100644 internal/adapters/handlers/tui/Window.md create mode 100644 internal/adapters/handlers/tui/window_core.go create mode 100644 internal/adapters/handlers/tui/window_env_form.go create mode 100644 internal/adapters/handlers/tui/window_flow.go create mode 100644 internal/adapters/handlers/tui/window_form.go create mode 100644 internal/adapters/handlers/tui/window_update.go create mode 100644 internal/adapters/handlers/tui/window_view.go diff --git a/internal/adapters/handlers/tui/.gitkeep b/internal/adapters/handlers/tui/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/adapters/handlers/tui/Window.md b/internal/adapters/handlers/tui/Window.md deleted file mode 100644 index e7e8b0e..0000000 --- a/internal/adapters/handlers/tui/Window.md +++ /dev/null @@ -1,18 +0,0 @@ -*----------* -| Projects | -*-------------------------------------------------------------------------* -| [ ] - Project 1 | | -| [x] - Project 2 | | -| [ ] - Project 3 | | -| | | -| | | -| | | -| | | -| | | -| | | -| | | -| | | -| | | -| | | -*-------------------------------------------------------------------------* -help text \ No newline at end of file diff --git a/internal/adapters/handlers/tui/window.go b/internal/adapters/handlers/tui/window.go index 3c37451..89aa0ee 100644 --- a/internal/adapters/handlers/tui/window.go +++ b/internal/adapters/handlers/tui/window.go @@ -1,258 +1 @@ package tui - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/jlrosende/project-manager/internal/core/domain" - "github.com/jlrosende/project-manager/internal/core/services" - "github.com/jlrosende/project-manager/pkg/ui/textinput" -) - -type Window struct { - projectSvc *services.ProjectService - - projects []*domain.Project - selectedProject *domain.Project - - cursor int - cursorEnv int - focus int // 0=projects, 1=environments - total int - width int - height int -} - -func NewWindow(projectSvc *services.ProjectService) (*Window, error) { - - projects, err := projectSvc.List() - - if err != nil { - return nil, err - } - - total := len(projects) + 1 - if total == 0 { - total = 1 - } - - return &Window{ - projectSvc: projectSvc, - projects: projects, - total: total, - cursor: 0, - }, nil -} - -func (m Window) Init() tea.Cmd { - return tea.SetWindowTitle("Project Manager") -} - -func (m *Window) newProjectFlow() { - name := "" - if m2, err := tea.NewProgram(textinput.NewTextInput("Project name:", "my-project")).Run(); err == nil { - if ti, ok := m2.(*textinput.TextInput); ok { - name = ti.Value() - } - } - if name == "" { - return - } - path := "" - if m3, err := tea.NewProgram(textinput.NewTextInput("Path:", "~/code/my-project")).Run(); err == nil { - if ti, ok := m3.(*textinput.TextInput); ok { - path = ti.Value() - } - } - if path == "" { - return - } - _, _ = m.projectSvc.Create(name, path, "", domain.EnvVars{}, domain.New()) - projects, err := m.projectSvc.List() - if err == nil { - m.projects = projects - m.total = len(projects) + 1 - } -} - -func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c", "esc": - m.selectedProject = nil - return m, tea.Quit - case "enter": - if m.focus == 0 { - idx := mod(m.cursor, m.total) - if idx == len(m.projects) { // '+ New project' - m.newProjectFlow() - return m, nil - } - project, _ := m.projectSvc.Load(m.projects[idx].Name) - m.selectedProject = project - return m, tea.Quit - } - if m.focus == 1 { - mod := mod(m.cursor, m.total) - if mod < len(m.projects) && len(m.projects) > 0 && len(m.projects[mod].Environments) > 0 { - project, _ := m.projectSvc.Load(m.projects[mod].Name) - m.selectedProject = project - return m, tea.Quit - } - } - - // The "up" and "k" keys move the cursor up - case "left", "h": - m.focus = 0 - case "right", "l": - mod := mod(m.cursor, m.total) - if mod < len(m.projects) && len(m.projects) > 0 && len(m.projects[mod].Environments) > 0 { - m.focus = 1 - } - case "up", "k": - if m.focus == 0 { - m.cursor-- - m.cursorEnv = 0 - } else { - mod := mod(m.cursor, m.total) - if mod < len(m.projects) && len(m.projects) > 0 { - envCount := len(m.projects[mod].Environments) - if envCount > 0 { - m.cursorEnv-- - if m.cursorEnv < 0 { - m.cursorEnv = envCount - 1 - } - } - } - } - // The "down" and "j" keys move the cursor down - case "down", "j": - if m.focus == 0 { - m.cursor++ - m.cursorEnv = 0 - } else { - mod := mod(m.cursor, m.total) - if mod < len(m.projects) && len(m.projects) > 0 { - envCount := len(m.projects[mod].Environments) - if envCount > 0 { - m.cursorEnv++ - if m.cursorEnv >= envCount { - m.cursorEnv = 0 - } - } - } - } - } - } - - return m, tea.Batch( - cmd, - tea.Printf("Let's go to %d!", m.cursor), - ) -} - -func (m Window) View() string { - left := strings.Builder{} - title := " Projects" - titleStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("14")). - Align(lipgloss.Center). - Border(lipgloss.NormalBorder(), false, false, true). - Padding(0, 1) - selectedStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")).Padding(0, 1) - defaultStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")).Padding(0, 1) - left.WriteString(titleStyle.Render(title)) - left.WriteString("\n") - mod := mod(m.cursor, m.total) - for i, project := range m.projects { - if mod == i { - left.WriteString("->") - left.WriteString(selectedStyle.Render(project.Name)) - left.WriteString("\n") - } else { - left.WriteString(" ") - left.WriteString(defaultStyle.Render(project.Name)) - left.WriteString("\n") - } - } - if mod == len(m.projects) { - left.WriteString("->") - left.WriteString(selectedStyle.Render("+ New project")) - left.WriteString("\n") - } else { - left.WriteString(" ") - left.WriteString(defaultStyle.Render("+ New project")) - left.WriteString("\n") - } - right := strings.Builder{} - rightTitle := "󱄑 Environments" - right.WriteString(titleStyle.Render(rightTitle)) - right.WriteString("\n") - if mod < len(m.projects) && len(m.projects) > 0 { - p := m.projects[mod] - if len(p.Environments) == 0 { - right.WriteString(defaultStyle.Render("No environments")) - right.WriteString("\n") - } else { - for i, env := range p.Environments { - marker := " " - selected := m.focus == 1 && m.cursorEnv%max(1, len(p.Environments)) == i - if selected { - marker = "->" - } - rowStyle := lipgloss.NewStyle() - if selected { - rowStyle = rowStyle.Background(lipgloss.Color(env.Color)).Padding(0, 0, 0, 1) - } - content := defaultStyle.Foreground(lipgloss.Color(env.Color)).Render("● " + env.Name) - if p.DefaultEnv != "" && env.Name == p.DefaultEnv { - content = content + " " + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("(default)") - } - right.WriteString(marker) - right.WriteString(rowStyle.Render(content)) - right.WriteString("\n") - } - } - } - content := lipgloss.JoinHorizontal(lipgloss.Left, left.String(), " ", right.String()) - help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Keys: ↑/k ↓/j navigate ←/h β†’/l focus Enter select Esc/Ctrl+C exit") - return lipgloss.JoinVertical(lipgloss.Left, content, help) -} - -func (m *Window) SelectedProject() *domain.Project { - return m.selectedProject -} - -func (m *Window) SelectedEnvironment() string { - if len(m.projects) == 0 { - return "" - } - idx := mod(m.cursor, m.total) - p := m.projects[idx] - if len(p.Environments) == 0 { - return "" - } - e := m.cursorEnv % len(p.Environments) - if e < 0 { - e = 0 - } - return p.Environments[e].Name -} - -func mod(a, b int) int { return (a%b + b) % b } - -func max(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/internal/adapters/handlers/tui/window_core.go b/internal/adapters/handlers/tui/window_core.go new file mode 100644 index 0000000..5804345 --- /dev/null +++ b/internal/adapters/handlers/tui/window_core.go @@ -0,0 +1,70 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/jlrosende/project-manager/internal/core/domain" + "github.com/jlrosende/project-manager/internal/core/services" +) + +type Window struct { + projectSvc *services.ProjectService + + projects []*domain.Project + selectedProject *domain.Project + + cursor int + cursorEnv int + focus int + total int + width int + height int + shouldQuit bool + mode int + form *NewProjectForm + prompt *postCreatePrompt + envForm *NewEnvironmentForm + envProjectName string +} + +func NewWindow(projectSvc *services.ProjectService) (*Window, error) { + projects, err := projectSvc.List() + if err != nil { + return nil, err + } + total := len(projects) + 1 + if total == 0 { + total = 1 + } + return &Window{ + projectSvc: projectSvc, + projects: projects, + total: total, + cursor: 0, + }, nil +} + +func (m Window) Init() tea.Cmd { return tea.SetWindowTitle("Project Manager") } + +func (m *Window) SelectedProject() *domain.Project { return m.selectedProject } + +func (m *Window) SelectedEnvironment() string { + if len(m.projects) == 0 { + return "" + } + idx := mod(m.cursor, m.total) + if idx < 0 || idx >= len(m.projects) { + return "" + } + p := m.projects[idx] + if len(p.Environments) == 0 { + return "" + } + e := m.cursorEnv % len(p.Environments) + if e < 0 { + e = 0 + } + return p.Environments[e].Name +} + +func mod(a, b int) int { return (a%b + b) % b } + diff --git a/internal/adapters/handlers/tui/window_env_form.go b/internal/adapters/handlers/tui/window_env_form.go new file mode 100644 index 0000000..9b72f7c --- /dev/null +++ b/internal/adapters/handlers/tui/window_env_form.go @@ -0,0 +1,144 @@ +package tui + +import ( + "strings" + + bti "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/jlrosende/project-manager/internal/core/domain" +) + +type NewEnvironmentForm struct { + name bti.Model + color bti.Model + mode bti.Model + envVars bti.Model + focused int + submitted bool + canceled bool + err string +} + +func NewEnvironmentFormModel() *NewEnvironmentForm { + name := bti.New() + name.Prompt = "Env name: " + name.Placeholder = "dev" + name.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + name.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + name.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + name.Focus() + color := bti.New() + color.Prompt = "Color (0-255): " + color.Placeholder = "39" + color.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + color.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + color.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + mode := bti.New() + mode.Prompt = "Env vars mode (merge/replace): " + mode.Placeholder = domain.ENV_VARS_MODE_MERGE + mode.SetValue(domain.ENV_VARS_MODE_MERGE) + mode.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + mode.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + mode.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + env := bti.New() + env.Prompt = "Env vars (K=V,K2=V2): " + env.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + env.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + env.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + return &NewEnvironmentForm{name: name, color: color, mode: mode, envVars: env} +} + +func (f *NewEnvironmentForm) Init() tea.Cmd { return bti.Blink } + +func (f *NewEnvironmentForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case tea.KeyMsg: + switch m.String() { + case "esc", "ctrl+c": + f.canceled = true + f.submitted = false + return f, nil + case "tab", "shift+tab": + if m.String() == "tab" { + f.focused = (f.focused + 1) % 4 + } else { + f.focused = (f.focused + 3) % 4 + } + f.blurAll() + f.focusCurrent() + return f, nil + case "enter": + if f.focused < 3 { + f.focused = (f.focused + 1) % 4 + f.blurAll() + f.focusCurrent() + return f, nil + } + f.err = "" + if strings.TrimSpace(f.name.Value()) == "" { + f.err = "name is required" + return f, nil + } + if strings.TrimSpace(f.mode.Value()) != domain.ENV_VARS_MODE_MERGE && strings.TrimSpace(f.mode.Value()) != domain.ENV_VARS_MODE_REPLACE { + f.err = "mode must be merge or replace" + return f, nil + } + f.submitted = true + f.canceled = false + return f, nil + } + } + var cmd tea.Cmd + switch f.focused { + case 0: + f.name, cmd = f.name.Update(msg) + case 1: + f.color, cmd = f.color.Update(msg) + case 2: + f.mode, cmd = f.mode.Update(msg) + case 3: + f.envVars, cmd = f.envVars.Update(msg) + } + return f, cmd +} + +func (f *NewEnvironmentForm) blurAll() { + f.name.Blur() + f.color.Blur() + f.mode.Blur() + f.envVars.Blur() +} + +func (f *NewEnvironmentForm) focusCurrent() { + switch f.focused { + case 0: + f.name.Focus() + case 1: + f.color.Focus() + case 2: + f.mode.Focus() + case 3: + f.envVars.Focus() + } +} + +func (f *NewEnvironmentForm) View() string { + box := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).Padding(1, 2) + help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Tab switch Enter next/submit Esc cancel") + b := strings.Builder{} + b.WriteString(f.name.View()) + b.WriteString("\n") + b.WriteString(f.color.View()) + b.WriteString("\n") + b.WriteString(f.mode.View()) + b.WriteString("\n") + b.WriteString(f.envVars.View()) + b.WriteString("\n\n") + if f.err != "" { + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(f.err)) + b.WriteString("\n") + } + b.WriteString(help) + return box.Render(b.String()) +} diff --git a/internal/adapters/handlers/tui/window_flow.go b/internal/adapters/handlers/tui/window_flow.go new file mode 100644 index 0000000..95ce3e7 --- /dev/null +++ b/internal/adapters/handlers/tui/window_flow.go @@ -0,0 +1,76 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "strings" +) + +func (m *Window) newProjectFlow() { + m.form = NewProjectFormModel() + m.mode = 1 +} + +func (m *Window) newEnvironmentFlow(projectName string) { + m.envForm = NewEnvironmentFormModel() + m.envProjectName = projectName + m.mode = 3 +} + +type postCreatePrompt struct { + idx, choice int + projectName string +} + +func newPostCreatePrompt(projectName string) *postCreatePrompt { + return &postCreatePrompt{projectName: projectName} +} + +func (p *postCreatePrompt) Init() tea.Cmd { return nil } + +func (p *postCreatePrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case tea.KeyMsg: + switch m.String() { + case "up", "k": + if p.idx > 0 { + p.idx-- + } + case "down", "j": + if p.idx < 2 { + p.idx++ + } + case "enter": + p.choice = p.idx + return p, tea.Quit + case "esc", "q", "ctrl+c": + p.choice = 0 + return p, tea.Quit + } + case tea.WindowSizeMsg: + return p, nil + } + return p, nil +} + +func (p *postCreatePrompt) View() string { + styleTitle := lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Align(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, true).Padding(0, 1) + styleSel := lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Padding(0, 1) + styleDef := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1) + b := strings.Builder{} + b.WriteString(styleTitle.Render("Project created. What next?")) + b.WriteString("\n") + opts := []string{"Return to list", "Start this project", "Exit"} + for i, o := range opts { + if i == p.idx { + b.WriteString("➜ ") + b.WriteString(styleSel.Render(o)) + } else { + b.WriteString(" ") + b.WriteString(styleDef.Render(o)) + } + b.WriteString("\n") + } + help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Keys: ↑/k ↓/j navigate Enter select Esc cancel") + return lipgloss.JoinVertical(lipgloss.Left, b.String(), help) +} diff --git a/internal/adapters/handlers/tui/window_form.go b/internal/adapters/handlers/tui/window_form.go new file mode 100644 index 0000000..72c482a --- /dev/null +++ b/internal/adapters/handlers/tui/window_form.go @@ -0,0 +1,288 @@ +package tui + +import ( + "io" + "os" + "path/filepath" + "strings" + + bti "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type NewProjectForm struct { + name bti.Model + path bti.Model + subproject bti.Model + userName bti.Model + userEmail bti.Model + userSigningKey bti.Model + commitGPGSign bti.Model + tagGPGSign bti.Model + envVars bti.Model + focused int + width int + height int + submitted bool + canceled bool + err string +} + +func NewProjectFormModel() *NewProjectForm { + ni := bti.New() + ni.Prompt = "Name: " + ni.Placeholder = "my-project" + ni.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + ni.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + ni.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + ni.Focus() + pi := bti.New() + pi.Prompt = "Path: " + pi.Placeholder = "~/code/my-project" + pi.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + pi.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + pi.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + spi := bti.New() + spi.Prompt = "Subproject: " + spi.Placeholder = "" + spi.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + spi.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + spi.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + un := bti.New() + un.Prompt = "Git user.name: " + un.Placeholder = "" + un.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + un.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + un.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + uem := bti.New() + uem.Prompt = "Git user.email: " + uem.Placeholder = "" + uem.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + uem.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + uem.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + usk := bti.New() + usk.Prompt = "Git user.signingkey: " + usk.Placeholder = "" + usk.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + usk.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + usk.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + csg := bti.New() + csg.Prompt = "commit.gpgsign (true/false): " + csg.Placeholder = "true" + csg.SetValue("true") + csg.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + csg.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + csg.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + tsg := bti.New() + tsg.Prompt = "tag.gpgsign (true/false): " + tsg.Placeholder = "true" + tsg.SetValue("true") + tsg.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + tsg.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + tsg.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + ev := bti.New() + ev.Prompt = "Env vars (K=V,K2=V2): " + ev.Placeholder = "" + ev.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + ev.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + ev.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + return &NewProjectForm{name: ni, path: pi, subproject: spi, userName: un, userEmail: uem, userSigningKey: usk, commitGPGSign: csg, tagGPGSign: tsg, envVars: ev, focused: 0} +} + +func (f *NewProjectForm) Init() tea.Cmd { return bti.Blink } + +func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + f.width = msg.Width + f.height = msg.Height + case tea.KeyMsg: + switch msg.String() { + case "esc", "ctrl+c": + f.canceled = true + f.submitted = false + return f, nil + case "tab", "shift+tab": + if msg.String() == "tab" { + f.focused++ + if f.focused > 8 { + f.focused = 0 + } + } else { + f.focused-- + if f.focused < 0 { + f.focused = 8 + } + } + f.blurAll() + f.focusCurrent() + return f, nil + case "enter": + if f.focused < 8 { + f.focused++ + f.blurAll() + f.focusCurrent() + return f, nil + } + f.err = "" + if strings.TrimSpace(f.name.Value()) == "" { + f.err = "name is required" + return f, nil + } + if strings.TrimSpace(f.path.Value()) == "" { + f.err = "path is required" + return f, nil + } + if ok, reason := canCreatePath(f.path.Value()); !ok { + f.err = reason + return f, nil + } + cg := strings.ToLower(strings.TrimSpace(f.commitGPGSign.Value())) + if cg != "" && cg != "true" && cg != "false" { + f.err = "commit.gpgsign must be true or false" + return f, nil + } + tg := strings.ToLower(strings.TrimSpace(f.tagGPGSign.Value())) + if tg != "" && tg != "true" && tg != "false" { + f.err = "tag.gpgsign must be true or false" + return f, nil + } + f.submitted = true + f.canceled = false + return f, nil + } + } + var cmd tea.Cmd + switch f.focused { + case 0: + f.name, cmd = f.name.Update(msg) + case 1: + f.path, cmd = f.path.Update(msg) + case 2: + f.subproject, cmd = f.subproject.Update(msg) + case 3: + f.userName, cmd = f.userName.Update(msg) + case 4: + f.userEmail, cmd = f.userEmail.Update(msg) + case 5: + f.userSigningKey, cmd = f.userSigningKey.Update(msg) + case 6: + f.commitGPGSign, cmd = f.commitGPGSign.Update(msg) + case 7: + f.tagGPGSign, cmd = f.tagGPGSign.Update(msg) + case 8: + f.envVars, cmd = f.envVars.Update(msg) + } + return f, cmd +} + +func (f *NewProjectForm) blurAll() { + f.name.Blur() + f.path.Blur() + f.subproject.Blur() + f.userName.Blur() + f.userEmail.Blur() + f.userSigningKey.Blur() + f.commitGPGSign.Blur() + f.tagGPGSign.Blur() + f.envVars.Blur() +} + +func (f *NewProjectForm) focusCurrent() { + switch f.focused { + case 0: + f.name.Focus() + case 1: + f.path.Focus() + case 2: + f.subproject.Focus() + case 3: + f.userName.Focus() + case 4: + f.userEmail.Focus() + case 5: + f.userSigningKey.Focus() + case 6: + f.commitGPGSign.Focus() + case 7: + f.tagGPGSign.Focus() + case 8: + f.envVars.Focus() + } +} + +func (f *NewProjectForm) View() string { + box := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).Padding(1, 2) + help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Tab switch Enter next/submit Esc cancel") + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + b := strings.Builder{} + b.WriteString(f.name.View()) + b.WriteString("\n") + b.WriteString(f.path.View()) + b.WriteString("\n") + b.WriteString(f.subproject.View()) + b.WriteString("\n") + b.WriteString(f.userName.View()) + b.WriteString("\n") + b.WriteString(f.userEmail.View()) + b.WriteString("\n") + b.WriteString(f.userSigningKey.View()) + b.WriteString("\n") + b.WriteString(f.commitGPGSign.View()) + b.WriteString("\n") + b.WriteString(f.tagGPGSign.View()) + b.WriteString("\n") + b.WriteString(f.envVars.View()) + b.WriteString("\n\n") + if f.err != "" { + b.WriteString(errStyle.Render(f.err)) + b.WriteString("\n") + } + b.WriteString(help) + return box.Render(b.String()) +} + +func isDirEmpty(path string) (bool, error) { + f, err := os.Open(path) + if err != nil { + return false, err + } + defer f.Close() + _, err = f.Readdir(1) + if err == io.EOF { + return true, nil + } + return false, err +} + +func canCreatePath(p string) (bool, string) { + pp := strings.TrimSpace(p) + if pp == "" { + return false, "path is required" + } + if strings.HasPrefix(pp, "~/") { + h, _ := os.UserHomeDir() + pp = filepath.Join(h, pp[2:]) + } + info, err := os.Stat(pp) + if err == nil { + if !info.IsDir() { + return false, "path exists and is not a directory" + } + empty, e := isDirEmpty(pp) + if e != nil { + return false, e.Error() + } + if !empty { + return false, "directory is not empty" + } + return true, "" + } + parent := filepath.Dir(pp) + pi, perr := os.Stat(parent) + if perr != nil || !pi.IsDir() { + return false, "parent directory does not exist" + } + return true, "" +} diff --git a/internal/adapters/handlers/tui/window_update.go b/internal/adapters/handlers/tui/window_update.go new file mode 100644 index 0000000..1fa26d6 --- /dev/null +++ b/internal/adapters/handlers/tui/window_update.go @@ -0,0 +1,220 @@ +package tui + +import ( + "strings" + tea "github.com/charmbracelet/bubbletea" + "github.com/jlrosende/project-manager/internal/core/domain" +) + +func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.shouldQuit { + return m, tea.Quit + } + var cmd tea.Cmd + if m.mode == 1 && m.form != nil { + fm, fcmd := m.form.Update(msg) + if f, ok := fm.(*NewProjectForm); ok { + m.form = f + if f.canceled { + m.mode = 0 + m.form = nil + return m, tea.ClearScreen + } + if f.submitted { + name := strings.TrimSpace(f.name.Value()) + path := strings.TrimSpace(f.path.Value()) + if name == "" || path == "" { + return m, nil + } + envs := domain.EnvVars{} + if strings.TrimSpace(f.envVars.Value()) != "" { + pairs := strings.Split(f.envVars.Value(), ",") + for _, p := range pairs { + kv := strings.SplitN(strings.TrimSpace(p), "=", 2) + if len(kv) == 2 { + envs[kv[0]] = kv[1] + } + } + } + commitSign := strings.ToLower(strings.TrimSpace(f.commitGPGSign.Value())) != "false" + tagSign := strings.ToLower(strings.TrimSpace(f.tagGPGSign.Value())) != "false" + proj, _ := m.projectSvc.Create( + name, + path, + f.subproject.Value(), + envs, + domain.New( + domain.WithName(strings.TrimSpace(f.userName.Value())), + domain.WithEmail(strings.TrimSpace(f.userEmail.Value())), + domain.WithSigningKey(strings.TrimSpace(f.userSigningKey.Value())), + domain.WithCommitSign(commitSign), + domain.WithTagSign(tagSign), + ), + ) + projects, err := m.projectSvc.List() + if err == nil { + m.projects = projects + m.total = len(projects) + 1 + } + m.prompt = newPostCreatePrompt(name) + m.mode = 2 + _ = proj + return m, tea.ClearScreen + } + return m, fcmd + } + } + if m.mode == 2 && m.prompt != nil { + pm, pcmd := m.prompt.Update(msg) + if p, ok := pm.(*postCreatePrompt); ok { + m.prompt = p + if p.choice == 0 { // return + m.mode = 0 + m.prompt = nil + return m, tea.ClearScreen + } + if p.choice == 1 { // start project + proj, _ := m.projectSvc.Load(p.projectName) + m.selectedProject = proj + return m, tea.Quit + } + if p.choice == 2 { // exit + m.selectedProject = nil + return m, tea.Quit + } + return m, pcmd + } + } + if m.mode == 3 && m.envForm != nil { + efm, ecmd := m.envForm.Update(msg) + if f, ok := efm.(*NewEnvironmentForm); ok { + m.envForm = f + if f.canceled { + m.mode = 0 + m.envForm = nil + return m, tea.ClearScreen + } + if f.submitted { + name := strings.TrimSpace(f.name.Value()) + if name == "" || m.envProjectName == "" { + return m, nil + } + env := &domain.Environment{ + Name: name, + Color: strings.TrimSpace(f.color.Value()), + EnvVarsMode: strings.TrimSpace(f.mode.Value()), + } + envs := domain.EnvVars{} + if strings.TrimSpace(f.envVars.Value()) != "" { + pairs := strings.Split(f.envVars.Value(), ",") + for _, p := range pairs { + kv := strings.SplitN(strings.TrimSpace(p), "=", 2) + if len(kv) == 2 { + envs[kv[0]] = kv[1] + } + } + } + _ = m.projectSvc.AddEnvironment(m.envProjectName, env, envs) + projects, err := m.projectSvc.List() + if err == nil { + m.projects = projects + m.total = len(projects) + 1 + } + m.mode = 0 + m.envForm = nil + m.envProjectName = "" + return m, tea.ClearScreen + } + return m, ecmd + } + } + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c", "esc": + m.selectedProject = nil + return m, tea.Quit + case "enter": + if m.focus == 0 { + idx := mod(m.cursor, m.total) + if idx == len(m.projects) { // '+ New project' + m.newProjectFlow() + return m, tea.ClearScreen + } + project, _ := m.projectSvc.Load(m.projects[idx].Name) + m.selectedProject = project + return m, tea.Quit + } + if m.focus == 1 { + mod := mod(m.cursor, m.total) + if mod < len(m.projects) && len(m.projects) > 0 { + envCount := len(m.projects[mod].Environments) + if m.cursorEnv == envCount { // '+ New environment' + m.newEnvironmentFlow(m.projects[mod].Name) + return m, tea.ClearScreen + } + if envCount > 0 { + project, _ := m.projectSvc.Load(m.projects[mod].Name) + m.selectedProject = project + return m, tea.Quit + } + } + } + + // The "up" and "k" keys move the cursor up + case "left", "h": + m.focus = 0 + case "right", "l": + mod := mod(m.cursor, m.total) + if mod < len(m.projects) && len(m.projects) > 0 { + m.focus = 1 + if len(m.projects[mod].Environments) == 0 { + m.cursorEnv = 0 + } + } + case "up", "k": + if m.focus == 0 { + m.cursor-- + m.cursorEnv = 0 + } else { + mod := mod(m.cursor, m.total) + if mod < len(m.projects) && len(m.projects) > 0 { + envCount := len(m.projects[mod].Environments) + envCountPlus := envCount + 1 + if envCountPlus > 0 { + m.cursorEnv-- + if m.cursorEnv < 0 { + m.cursorEnv = envCountPlus - 1 + } + } + } + } + // The "down" and "j" keys move the cursor down + case "down", "j": + if m.focus == 0 { + m.cursor++ + m.cursorEnv = 0 + } else { + mod := mod(m.cursor, m.total) + if mod < len(m.projects) && len(m.projects) > 0 { + envCount := len(m.projects[mod].Environments) + envCountPlus := envCount + 1 + if envCountPlus > 0 { + m.cursorEnv++ + if m.cursorEnv >= envCountPlus { + m.cursorEnv = 0 + } + } + } + } + } + } + + return m, tea.Batch( + cmd, + tea.Printf("Let's go to %d!", m.cursor), + ) +} diff --git a/internal/adapters/handlers/tui/window_view.go b/internal/adapters/handlers/tui/window_view.go new file mode 100644 index 0000000..1ff5abc --- /dev/null +++ b/internal/adapters/handlers/tui/window_view.go @@ -0,0 +1,102 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +func (m Window) View() string { + if m.mode == 1 && m.form != nil { + return m.form.View() + } + if m.mode == 2 && m.prompt != nil { + return m.prompt.View() + } + if m.mode == 3 && m.envForm != nil { + return m.envForm.View() + } + left := strings.Builder{} + title := " Projects" + titleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("14")). + Align(lipgloss.Center). + Border(lipgloss.NormalBorder(), false, false, true). + Padding(0, 1) + selectedStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")).Padding(0, 1) + defaultStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")).Padding(0, 1) + left.WriteString(titleStyle.Render(title)) + left.WriteString("\n") + mod := mod(m.cursor, m.total) + for i, project := range m.projects { + if mod == i { + left.WriteString("➜ ") + left.WriteString(selectedStyle.Render(project.Name)) + left.WriteString("\n") + } else { + left.WriteString(" ") + left.WriteString(defaultStyle.Render(project.Name)) + left.WriteString("\n") + } + } + if mod == len(m.projects) { + left.WriteString("➜ ") + left.WriteString(selectedStyle.Render("+ New project")) + left.WriteString("\n") + } else { + left.WriteString(" ") + left.WriteString(defaultStyle.Render("+ New project")) + left.WriteString("\n") + } + right := strings.Builder{} + rightTitle := "󱄑 Environments" + right.WriteString(titleStyle.Render(rightTitle)) + right.WriteString("\n") + if mod < len(m.projects) && len(m.projects) > 0 { + p := m.projects[mod] + if len(p.Environments) == 0 { + marker := " " + if m.focus == 1 && m.cursorEnv == 0 { + marker = "➜" + } + right.WriteString(marker) + right.WriteString(defaultStyle.Render("+ New environment")) + right.WriteString("\n") + } else { + for i, env := range p.Environments { + marker := " " + selected := m.focus == 1 && m.cursorEnv == i + if selected { + marker = "➜" + } + rowStyle := lipgloss.NewStyle() + if selected { + rowStyle = rowStyle.Background(lipgloss.Color(env.Color)).Padding(0, 0, 0, 1) + } + content := defaultStyle.Foreground(lipgloss.Color(env.Color)).Render("● " + env.Name) + if p.DefaultEnv != "" && env.Name == p.DefaultEnv { + content = content + " " + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("(default)") + } + right.WriteString(marker) + right.WriteString(rowStyle.Render(content)) + right.WriteString("\n") + } + } + // Add new environment entry when there are environments + if mod < len(m.projects) && len(m.projects) > 0 && len(m.projects[mod].Environments) > 0 { + marker := " " + if m.focus == 1 && m.cursorEnv == len(m.projects[mod].Environments) { + marker = "➜" + } + right.WriteString(marker) + right.WriteString(defaultStyle.Render("+ New environment")) + right.WriteString("\n") + } + } + content := lipgloss.JoinHorizontal(lipgloss.Left, left.String(), " ", right.String()) + help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Keys: ↑/k ↓/j navigate ←/h β†’/l focus Enter select Esc/Ctrl+C exit") + return lipgloss.JoinVertical(lipgloss.Left, content, help) +} diff --git a/internal/adapters/repositories/project_repository.go b/internal/adapters/repositories/project_repository.go index 637770c..650a258 100644 --- a/internal/adapters/repositories/project_repository.go +++ b/internal/adapters/repositories/project_repository.go @@ -116,33 +116,42 @@ TODO - If .project.hcl exist */ func (p *ProjectRepository) Create(name, path, subproject string, envVars domain.EnvVars, gitConfig *domain.GitConfig) (*domain.Project, error) { + // Expand '~' to user home + if strings.HasPrefix(path, "~/") { + home, _ := os.UserHomeDir() + path = filepath.Join(home, path[2:]) + } - // Check if the path is a directory - + // Ensure path exists and is a directory (create if missing) if info, err := os.Stat(path); err != nil { - return nil, err + if os.IsNotExist(err) { + if err := os.MkdirAll(path, 0o755); err != nil { + return nil, err + } + } else { + return nil, err + } } else if !info.IsDir() { return nil, errors.New("the path must be a directory") } - path, err := filepath.Abs(path) - + // Normalize to absolute path + absPath, err := filepath.Abs(path) if err != nil { return nil, err } + path = absPath log.Println(path) gitdir := fmt.Sprintf("gitdir/i:%s/", filepath.Join(path)) - // Create paths this if not exist - err = os.MkdirAll(path, 0755) - if err != nil { - log.Println(err) + // Create paths (idempotent) + if err := os.MkdirAll(path, 0o755); err != nil { + return nil, err } - // Check if the path is empty - + // Require empty directory to avoid clobbering existing projects if isEmpty, err := IsDirEmpty(path); err != nil { return nil, err } else if !isEmpty { @@ -150,21 +159,15 @@ func (p *ProjectRepository) Create(name, path, subproject string, envVars domain } gitConfigName := fmt.Sprintf(".%s.gitconfig", name) - gitConfigPath := filepath.Join(path, gitConfigName) includeIf := p.git.Raw.Section("includeIf").Subsection(gitdir) - includeIf.SetOption("path", gitConfigPath) - // TODO the project name is stored in a .project file - // includeIf.SetOption("project", name) - if subproject != "" { includeIf.SetOption("subproject", subproject) } gitConf, _ := p.git.Marshal() - log.Printf("\n%s", string(gitConf)) home, err := os.UserHomeDir() @@ -172,107 +175,154 @@ func (p *ProjectRepository) Create(name, path, subproject string, envVars domain return nil, err } - filepath.Join(home, ".gitconfig") - - fpGit, err := os.OpenFile(filepath.Join(home, ".gitconfig"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) - + fpGit, err := os.OpenFile(filepath.Join(home, ".gitconfig"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return nil, err } defer fpGit.Close() - - n_bytes, err := fpGit.Write(gitConf) - if err != nil { + if _, err := fpGit.Write(gitConf); err != nil { return nil, err } - - slog.Debug(fmt.Sprintf("%d Bytes written in %s", n_bytes, gitConfigPath)) - if err := p.git.Validate(); err != nil { return nil, err } - // New Git config - + // Per-project git config newConfig := config.NewConfig() - - // TODO Check if values are correct or set newConfig.User.Email = gitConfig.User.Email newConfig.User.Name = gitConfig.User.Name - newConfig.Raw.AddOption("user", "", "signingkey", gitConfig.User.SigningKey) - newConfig.Raw.AddOption("commit", "", "gpgsign", strconv.FormatBool(gitConfig.Commit.GPGSign)) newConfig.Raw.AddOption("tag", "", "gpgsign", strconv.FormatBool(gitConfig.Tag.GPGSign)) - newGitConf, err := newConfig.Marshal() - if err != nil { return nil, err } - - _, err = os.Stat(gitConfigPath) - - if !os.IsNotExist(err) { + if _, err := os.Stat(gitConfigPath); !os.IsNotExist(err) { return nil, fmt.Errorf("%s already exists in directory %s", gitConfigName, path) } - - log.Println(string(gitConfigPath)) - log.Printf("\n%s", string(newGitConf)) - - fp, err := os.OpenFile(gitConfigPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + fp, err := os.OpenFile(gitConfigPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return nil, err } defer fp.Close() - - n_bytes, err = fp.Write(newGitConf) - if err != nil { + if _, err := fp.Write(newGitConf); err != nil { return nil, err } - - slog.Debug(fmt.Sprintf("%d Bytes written in %s", n_bytes, gitConfigPath)) - if err := newConfig.Validate(); err != nil { return nil, err } - // New .env file with env_vars + // .env file with env_vars envPath := filepath.Join(path, ".env") - _, err = os.Stat(envPath) - - if !os.IsNotExist(err) { + if _, err = os.Stat(envPath); !os.IsNotExist(err) { return nil, fmt.Errorf("%s already exists in directory %s", envPath, path) } - fpEnv, err := os.OpenFile(envPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) - if err != nil { return nil, err } defer fpEnv.Close() - for key, value := range envVars { - n, err := fmt.Fprintf(fpEnv, "%s=%s\n", key, value) - if err != nil { + if _, err := fmt.Fprintf(fpEnv, "%s=%s\n", key, value); err != nil { return nil, err } - slog.Debug("bytes written", "n", n, "path", envPath) - } - // n_bytes_env, err := fpEnv.Write(envVars) - // if err != nil { - // return nil, err - // } + // .project.hcl + projPath := filepath.Join(path, ".project.hcl") + if _, err = os.Stat(projPath); !os.IsNotExist(err) { + return nil, fmt.Errorf("%s already exists in directory %s", filepath.Base(projPath), path) + } + fpProj, err := os.OpenFile(projPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return nil, err + } + defer fpProj.Close() + projectHCL := fmt.Sprintf("name = \"%s\"\n\ndescription = \"\"\n\nenv_vars_file = \".env\"\n", name) + if _, err := fpProj.WriteString(projectHCL); err != nil { + return nil, err + } - return nil, nil + return &domain.Project{ + Name: name, + Description: "", + Path: path, + EnvVarsFile: ".env", + }, nil } func (p *ProjectRepository) Delete(name string) error { return nil } +func (p *ProjectRepository) AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error { + project, err := p.Get(projectName) + if err != nil { + return err + } + for _, e := range project.Environments { + if e.Name == env.Name { + return fmt.Errorf("environment %s already exists", env.Name) + } + } + if env.EnvVarsMode == "" { + env.EnvVarsMode = domain.ENV_VARS_MODE_MERGE + } + if strings.HasPrefix(project.Path, "~/") { + h, _ := os.UserHomeDir() + project.Path = filepath.Join(h, project.Path[2:]) + } + envFile := env.EnvVarsFile + if envFile == "" { + envFile = ".env." + env.Name + } + envPath := envFile + if !filepath.IsAbs(envFile) { + envPath = filepath.Join(project.Path, envFile) + } + if _, err := os.Stat(envPath); !os.IsNotExist(err) { + return fmt.Errorf("%s already exists in directory %s", filepath.Base(envPath), filepath.Dir(envPath)) + } + fpEnv, err := os.OpenFile(envPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return err + } + defer fpEnv.Close() + for k, v := range envVars { + if _, err := fmt.Fprintf(fpEnv, "%s=%s\n", k, v); err != nil { + return err + } + } + projPath := filepath.Join(project.Path, ".project.hcl") + fp, err := os.OpenFile(projPath, os.O_RDWR|os.O_APPEND, 0o644) + if err != nil { + return err + } + defer fp.Close() + b := &strings.Builder{} + b.WriteString("\n") + b.WriteString("environment \"") + b.WriteString(env.Name) + b.WriteString("\" {\n") + if env.Color != "" { + b.WriteString(" color = \"") + b.WriteString(env.Color) + b.WriteString("\"\n") + } + b.WriteString(" env_vars_mode = \"") + b.WriteString(env.EnvVarsMode) + b.WriteString("\"\n") + b.WriteString(" env_vars_file = \"") + b.WriteString(envFile) + b.WriteString("\"\n") + b.WriteString("}\n") + if _, err := io.WriteString(fp, b.String()); err != nil { + return err + } + return nil +} + func loadDotProject(path string) (*domain.Project, error) { project := &domain.Project{} diff --git a/internal/core/ports/project_port.go b/internal/core/ports/project_port.go index 9f1e0d6..3ac9958 100644 --- a/internal/core/ports/project_port.go +++ b/internal/core/ports/project_port.go @@ -6,11 +6,13 @@ type ProjectService interface { Load(name string) (*domain.Project, error) List() ([]*domain.Project, error) Create(name, path, subproject string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) + AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error Delete(name string) error } type ProjectRepository interface { List() ([]*domain.Project, error) Create(name, path, subproject string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) + AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error Delete(name string) error } diff --git a/internal/core/services/project_service.go b/internal/core/services/project_service.go index 20a48a4..90af35f 100644 --- a/internal/core/services/project_service.go +++ b/internal/core/services/project_service.go @@ -88,6 +88,10 @@ func (svc *ProjectService) Create(name, path, subproject string, envVars domain. return svc.project.Create(name, path, subproject, envVars, gitConfig) } +func (svc *ProjectService) AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error { + return svc.project.AddEnvironment(projectName, env, envVars) +} + func (svc *ProjectService) Delete(name string) error { return svc.project.Delete(name) } From b7f2539b44837e1a003c877096ffaf7ffcb25452 Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Mon, 15 Sep 2025 02:19:10 +0200 Subject: [PATCH 04/27] add new environments --- .../adapters/handlers/tui/window_env_form.go | 10 ++++------ internal/adapters/handlers/tui/window_flow.go | 18 ++++++++++++++++++ internal/adapters/handlers/tui/window_form.go | 13 ++++++------- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/internal/adapters/handlers/tui/window_env_form.go b/internal/adapters/handlers/tui/window_env_form.go index 9b72f7c..53b9495 100644 --- a/internal/adapters/handlers/tui/window_env_form.go +++ b/internal/adapters/handlers/tui/window_env_form.go @@ -3,6 +3,7 @@ package tui import ( "strings" + bta "github.com/charmbracelet/bubbles/textarea" bti "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -13,7 +14,7 @@ type NewEnvironmentForm struct { name bti.Model color bti.Model mode bti.Model - envVars bti.Model + envVars bta.Model focused int submitted bool canceled bool @@ -41,11 +42,8 @@ func NewEnvironmentFormModel() *NewEnvironmentForm { mode.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) mode.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) mode.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) - env := bti.New() - env.Prompt = "Env vars (K=V,K2=V2): " - env.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - env.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - env.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + env := bta.New() + env.Placeholder = "# One per line (like .env)\nKEY=VALUE\nFOO=bar\n# comments allowed" return &NewEnvironmentForm{name: name, color: color, mode: mode, envVars: env} } diff --git a/internal/adapters/handlers/tui/window_flow.go b/internal/adapters/handlers/tui/window_flow.go index 95ce3e7..0ca10fb 100644 --- a/internal/adapters/handlers/tui/window_flow.go +++ b/internal/adapters/handlers/tui/window_flow.go @@ -3,9 +3,27 @@ package tui import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/jlrosende/project-manager/internal/core/domain" "strings" ) +type kvLine struct{} + +func parseEnvLines(s string) domain.EnvVars { + res := domain.EnvVars{} + for _, line := range strings.Split(s, "\n") { + l := strings.TrimSpace(line) + if l == "" || strings.HasPrefix(l, "#") { + continue + } + kv := strings.SplitN(l, "=", 2) + if len(kv) == 2 { + res[strings.TrimSpace(kv[0])] = kv[1] + } + } + return res +} + func (m *Window) newProjectFlow() { m.form = NewProjectFormModel() m.mode = 1 diff --git a/internal/adapters/handlers/tui/window_form.go b/internal/adapters/handlers/tui/window_form.go index 72c482a..41c7700 100644 --- a/internal/adapters/handlers/tui/window_form.go +++ b/internal/adapters/handlers/tui/window_form.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + bta "github.com/charmbracelet/bubbles/textarea" bti "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -20,7 +21,7 @@ type NewProjectForm struct { userSigningKey bti.Model commitGPGSign bti.Model tagGPGSign bti.Model - envVars bti.Model + envVars bta.Model focused int width int height int @@ -81,12 +82,8 @@ func NewProjectFormModel() *NewProjectForm { tsg.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) tsg.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) tsg.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) - ev := bti.New() - ev.Prompt = "Env vars (K=V,K2=V2): " - ev.Placeholder = "" - ev.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - ev.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - ev.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + ev := bta.New() + ev.Placeholder = "# One per line (like .env)\nKEY=VALUE\nFOO=bar\n# comments allowed" return &NewProjectForm{name: ni, path: pi, subproject: spi, userName: un, userEmail: uem, userSigningKey: usk, commitGPGSign: csg, tagGPGSign: tsg, envVars: ev, focused: 0} } @@ -125,6 +122,8 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { f.focusCurrent() return f, nil } + // let textarea handle newline when focused on env vars + case "ctrl+s": f.err = "" if strings.TrimSpace(f.name.Value()) == "" { f.err = "name is required" From 6e2e044366d605fc2a697b6f1b9da999f31068a7 Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Mon, 15 Sep 2025 03:37:14 +0200 Subject: [PATCH 05/27] Improve the TUI with .spec-kit anc claude --- .github/workflows/build.yml | 12 -- .github/workflows/ci.yml | 66 +++++++ .github/workflows/release.yml | 54 ++++++ README.md | 112 +++++++++++- go.sum | 2 + .../adapters/handlers/tui/window_env_form.go | 100 ++++++++-- internal/adapters/handlers/tui/window_flow.go | 42 ++++- internal/adapters/handlers/tui/window_form.go | 171 ++++++++++++++++-- .../adapters/handlers/tui/window_update.go | 18 +- 9 files changed, 521 insertions(+), 56 deletions(-) delete mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 468d5dd..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: "Build" - -on: - workflow_dispatch: - -jobs: - build: - name: build - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3973130 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: [ main, '**' ] + pull_request: + branches: [ main, '**' ] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: latest + args: --timeout=5m + + test-unit: + runs-on: ubuntu-latest + needs: [lint] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + - name: Run unit tests + run: go test -race -cover -coverprofile=coverage.out ./... + + test-integration: + runs-on: ubuntu-latest + needs: [test-unit] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + - name: Run integration tests + run: go test -race ./tests/integration/... + + build: + runs-on: ubuntu-latest + needs: [test-integration] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + - name: Build (snapshot) + run: make build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f64e8ba --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run (no publish)' + required: false + default: 'false' + type: choice + options: ['false','true'] + +permissions: + contents: write + packages: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + + - name: GoReleaser (dry run) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == 'true' }} + uses: goreleaser/goreleaser-action@v6 + with: + version: latest + args: release --snapshot --skip-publish --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: GoReleaser (release) + if: ${{ github.event_name != 'workflow_dispatch' || inputs.dry_run != 'true' }} + uses: goreleaser/goreleaser-action@v6 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 9b785c2..1cd8bfe 100644 --- a/README.md +++ b/README.md @@ -1 +1,111 @@ -# project-manager \ No newline at end of file +# Project Manager (pm) + +A simple CLI/TUI tool to manage development projects and their environments. It helps you: + +- Browse, create, and start projects from a Terminal UI +- Manage per-project environments (e.g., dev/staging) with .env-style variables +- Generate project configuration files (.project.hcl, .env, .env.) +- Configure per-project Git settings via includeIf in your global ~/.gitconfig + +## Requirements + +- Go 1.25+ +- Git installed and available in PATH +- Optional: GPG configured if you plan to sign commits/tags + +## Install + +### Option 1: Download a Release (recommended) + +- Download the latest binary for your OS/arch from the Releases page +- Rename to `pm` if needed and place it somewhere in your PATH (e.g., `~/bin`, `/usr/local/bin`) +- Make it executable: `chmod +x pm` + +### Option 2: Install from Source with Go + +```bash +go install github.com/jlrosende/project-manager/cmd/cli@latest +``` + +The binary will be installed as `pm` in your GOPATH/bin (make sure it’s in PATH). + +### Option 3: Build locally (snapshot) + +```bash +make build +# Binaries will be under dist/pm__/pm +``` + +## Usage + +### Quickstart (TUI) + +```bash +pm +``` + +- Left column: Projects +- Right column: Environments for the selected project +- Keys: + - Navigation: ↑/k, ↓/j + - Focus columns: ←/h, β†’/l + - Switch fields in forms: Tab / Shift+Tab, ↑/↓ (textarea keeps Enter for newlines) + - Select / Next: Enter + - Save in forms: Ctrl+S + - Cancel / Exit: Esc or Ctrl+C + +### Create a new project (TUI) + +- Select β€œ+ New project” and press Enter +- Fill required fields: + - Name* + - Path* (auto-fills from Name while unchanged; you can edit at any time) + - Optional Git config: user.name, user.email, user.signingkey, commit.gpgsign, tag.gpgsign + - Optional Subproject path + - Optional Env vars (textarea, .env-style: one KEY=VALUE per line; lines starting with `#` are comments) +- Press Ctrl+S or select Save +- After creation, choose to return to the list, start the project, or exit + +What gets created: +- Directory at the selected Path +- `.project.hcl` with project metadata +- `.env` containing initial environment variables (if provided) +- Per-project `.gitconfig` and includeIf entry added to your global `~/.gitconfig` + +### Add a new environment (TUI) + +- With a project selected, focus the Environments column +- Select β€œ+ New environment” and press Enter +- Fields: + - Env name* (e.g., `staging`) + - Color (name/#hex/0-255, defaults to grey; previewed live) + - Env vars mode* (`merge` or `replace`) + - Env vars (textarea, .env-style) +- Press Ctrl+S or select Save + +What gets created: +- `.env.` in the project directory +- Environment block appended to `.project.hcl` (color, env vars mode, file reference) + +## Commands (non-TUI) + +This repository also contains basic subcommands: + +- `pm` – launches the TUI +- `pm list` – lists known projects +- `pm new` – creates a project (interactive; flags may be available depending on version) +- `pm edit` – edit project configuration (if present in your build) + +Tip: Use `pm --help` or `pm --help` for details available in your version. + +## Troubleshooting + +- If the TUI doesn’t render correctly after closing a form, ensure your terminal supports alternate screen and try a clean redraw. +- If path validation fails: + - The directory must either not exist, or exist and be empty + - The parent directory must exist +- If includeIf changes are not applied, check your `~/.gitconfig` permissions and content. + +## License + +MIT (see LICENSE if present) diff --git a/go.sum b/go.sum index a1d1a0b..54fe6fd 100644 --- a/go.sum +++ b/go.sum @@ -130,6 +130,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= diff --git a/internal/adapters/handlers/tui/window_env_form.go b/internal/adapters/handlers/tui/window_env_form.go index 53b9495..6c459c8 100644 --- a/internal/adapters/handlers/tui/window_env_form.go +++ b/internal/adapters/handlers/tui/window_env_form.go @@ -23,27 +23,29 @@ type NewEnvironmentForm struct { func NewEnvironmentFormModel() *NewEnvironmentForm { name := bti.New() - name.Prompt = "Env name: " - name.Placeholder = "dev" + name.Prompt = "Env name*: " + name.Placeholder = "staging" name.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) name.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) name.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) name.Focus() color := bti.New() - color.Prompt = "Color (0-255): " - color.Placeholder = "39" + color.Prompt = "Color (name/#hex/0-255): " + color.Placeholder = "teal" + color.SetValue("grey") color.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) color.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - color.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + color.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) mode := bti.New() - mode.Prompt = "Env vars mode (merge/replace): " + mode.Prompt = "Env vars mode* (merge/replace): " mode.Placeholder = domain.ENV_VARS_MODE_MERGE mode.SetValue(domain.ENV_VARS_MODE_MERGE) mode.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) mode.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) mode.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) env := bta.New() - env.Placeholder = "# One per line (like .env)\nKEY=VALUE\nFOO=bar\n# comments allowed" + env.Placeholder = "# One per line (like .env)\nAPI_URL=https://api.example.com\nLOG_LEVEL=info\n# comments allowed" + env.SetHeight(6) return &NewEnvironmentForm{name: name, color: color, mode: mode, envVars: env} } @@ -59,20 +61,73 @@ func (f *NewEnvironmentForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return f, nil case "tab", "shift+tab": if m.String() == "tab" { - f.focused = (f.focused + 1) % 4 + f.focused = (f.focused + 1) % 6 } else { - f.focused = (f.focused + 3) % 4 + f.focused = (f.focused + 5) % 6 } f.blurAll() f.focusCurrent() return f, nil case "enter": if f.focused < 3 { - f.focused = (f.focused + 1) % 4 + f.focused = (f.focused + 1) % 6 + f.blurAll() + f.focusCurrent() + return f, nil + } else if f.focused == 4 { + f.err = "" + if strings.TrimSpace(f.name.Value()) == "" { + f.err = "name is required" + return f, nil + } + if strings.TrimSpace(f.mode.Value()) != domain.ENV_VARS_MODE_MERGE && strings.TrimSpace(f.mode.Value()) != domain.ENV_VARS_MODE_REPLACE { + f.err = "mode must be merge or replace" + return f, nil + } + f.submitted = true + f.canceled = false + return f, nil + } else if f.focused == 5 { + f.canceled = true + f.submitted = false + return f, nil + } + case "up": + if f.focused != 3 { + f.focused-- + if f.focused < 0 { + f.focused = 5 + } + f.blurAll() + f.focusCurrent() + return f, nil + } + case "down": + if f.focused != 3 { + f.focused++ + if f.focused > 5 { + f.focused = 0 + } + f.blurAll() + f.focusCurrent() + return f, nil + } + case "left": + if f.focused == 5 { + f.focused = 4 f.blurAll() f.focusCurrent() return f, nil } + case "right": + if f.focused == 4 { + f.focused = 5 + f.blurAll() + f.focusCurrent() + return f, nil + } + // when textarea focused, let Enter insert newline + case "ctrl+s": f.err = "" if strings.TrimSpace(f.name.Value()) == "" { f.err = "name is required" @@ -93,10 +148,20 @@ func (f *NewEnvironmentForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { f.name, cmd = f.name.Update(msg) case 1: f.color, cmd = f.color.Update(msg) + cv := strings.TrimSpace(f.color.Value()) + col := "240" + if isValidColorInput(cv) { + col = normalizeColorInput(cv) + } + f.color.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(col)) case 2: f.mode, cmd = f.mode.Update(msg) case 3: f.envVars, cmd = f.envVars.Update(msg) + case 4: + // save button + case 5: + // cancel button } return f, cmd } @@ -123,15 +188,28 @@ func (f *NewEnvironmentForm) focusCurrent() { func (f *NewEnvironmentForm) View() string { box := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).Padding(1, 2) - help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Tab switch Enter next/submit Esc cancel") + help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Tab switch Enter next/newline Ctrl+S save Esc cancel") + styleSel := lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Padding(0, 1) + styleDef := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1) + styleTitle := lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Align(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, true).Padding(0, 1) b := strings.Builder{} + b.WriteString(styleTitle.Render("New environment")) + b.WriteString("\n") b.WriteString(f.name.View()) b.WriteString("\n") b.WriteString(f.color.View()) b.WriteString("\n") b.WriteString(f.mode.View()) b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Env vars (.env format): one KEY=VALUE per line; '#' comments allowed")) + b.WriteString("\n") b.WriteString(f.envVars.View()) + b.WriteString("\n") + btnSave := styleDef.Render("Save") + if f.focused == 4 { btnSave = styleSel.Render("Save") } + btnCancel := styleDef.Render("Cancel") + if f.focused == 5 { btnCancel = styleSel.Render("Cancel") } + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, btnSave, " ", btnCancel)) b.WriteString("\n\n") if f.err != "" { b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(f.err)) diff --git a/internal/adapters/handlers/tui/window_flow.go b/internal/adapters/handlers/tui/window_flow.go index 0ca10fb..445c006 100644 --- a/internal/adapters/handlers/tui/window_flow.go +++ b/internal/adapters/handlers/tui/window_flow.go @@ -4,11 +4,10 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/jlrosende/project-manager/internal/core/domain" + "strconv" "strings" ) -type kvLine struct{} - func parseEnvLines(s string) domain.EnvVars { res := domain.EnvVars{} for _, line := range strings.Split(s, "\n") { @@ -24,6 +23,45 @@ func parseEnvLines(s string) domain.EnvVars { return res } +var colorNameMap = map[string]string{ + "black": "0", + "white": "15", + "red": "196", + "green": "46", + "blue": "21", + "yellow": "226", + "magenta":"201", + "purple": "93", + "cyan": "51", + "teal": "30", + "orange": "208", + "pink": "205", + "grey": "240", + "gray": "240", +} + +func normalizeColorInput(s string) string { + ss := strings.ToLower(strings.TrimSpace(s)) + if ss == "" || ss == "grey" || ss == "gray" { return "240" } + if strings.HasPrefix(ss, "#") { return ss } + if v, ok := colorNameMap[ss]; ok { return v } + return ss +} + +func isValidColorInput(s string) bool { + ss := strings.ToLower(strings.TrimSpace(s)) + if ss == "" { return false } + if strings.HasPrefix(ss, "#") { + h := ss[1:] + if len(h) != 3 && len(h) != 6 { return false } + if _, err := strconv.ParseUint(h, 16, 64); err != nil { return false } + return true + } + if _, ok := colorNameMap[ss]; ok { return true } + if n, err := strconv.Atoi(ss); err == nil && n >= 0 && n <= 255 { return true } + return false +} + func (m *Window) newProjectFlow() { m.form = NewProjectFormModel() m.mode = 1 diff --git a/internal/adapters/handlers/tui/window_form.go b/internal/adapters/handlers/tui/window_form.go index 41c7700..00eeb64 100644 --- a/internal/adapters/handlers/tui/window_form.go +++ b/internal/adapters/handlers/tui/window_form.go @@ -4,6 +4,7 @@ import ( "io" "os" "path/filepath" + "regexp" "strings" bta "github.com/charmbracelet/bubbles/textarea" @@ -25,6 +26,7 @@ type NewProjectForm struct { focused int width int height int + pathDirty bool submitted bool canceled bool err string @@ -32,42 +34,50 @@ type NewProjectForm struct { func NewProjectFormModel() *NewProjectForm { ni := bti.New() - ni.Prompt = "Name: " - ni.Placeholder = "my-project" - ni.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + sc := lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + sr := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + ni.Prompt = sc.Render("Name") + sr.Render("*") + sc.Render(": ") + ni.Placeholder = "my-awesome-app" + ni.PromptStyle = lipgloss.NewStyle() ni.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) ni.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + ni.Width = 40 ni.Focus() pi := bti.New() - pi.Prompt = "Path: " - pi.Placeholder = "~/code/my-project" - pi.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + pi.Prompt = sc.Render("Path") + sr.Render("*") + sc.Render(": ") + pi.Placeholder = "~/my-awesome-app" + pi.PromptStyle = lipgloss.NewStyle() pi.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) pi.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + pi.Width = 40 spi := bti.New() spi.Prompt = "Subproject: " - spi.Placeholder = "" + spi.Placeholder = "services/api" spi.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) spi.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) spi.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + spi.Width = 40 un := bti.New() un.Prompt = "Git user.name: " - un.Placeholder = "" + un.Placeholder = "Jane Doe" un.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) un.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) un.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + un.Width = 40 uem := bti.New() uem.Prompt = "Git user.email: " - uem.Placeholder = "" + uem.Placeholder = "jane@example.com" uem.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) uem.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) uem.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + uem.Width = 40 usk := bti.New() usk.Prompt = "Git user.signingkey: " - usk.Placeholder = "" + usk.Placeholder = "0xDEADBEEF" usk.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) usk.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) usk.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + usk.Width = 40 csg := bti.New() csg.Prompt = "commit.gpgsign (true/false): " csg.Placeholder = "true" @@ -75,6 +85,7 @@ func NewProjectFormModel() *NewProjectForm { csg.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) csg.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) csg.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + csg.Width = 40 tsg := bti.New() tsg.Prompt = "tag.gpgsign (true/false): " tsg.Placeholder = "true" @@ -82,8 +93,11 @@ func NewProjectFormModel() *NewProjectForm { tsg.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) tsg.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) tsg.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + tsg.Width = 40 ev := bta.New() - ev.Placeholder = "# One per line (like .env)\nKEY=VALUE\nFOO=bar\n# comments allowed" + ev.Placeholder = "# One per line (like .env)\nAPP_ENV=development\nDATABASE_URL=postgres://user:pass@localhost:5432/app\n# comments allowed" + ev.SetHeight(6) + ev.SetWidth(60) return &NewProjectForm{name: ni, path: pi, subproject: spi, userName: un, userEmail: uem, userSigningKey: usk, commitGPGSign: csg, tagGPGSign: tsg, envVars: ev, focused: 0} } @@ -94,6 +108,17 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: f.width = msg.Width f.height = msg.Height + w := msg.Width - 10 + if w < 20 { w = 20 } + f.name.Width = w + f.path.Width = w + f.subproject.Width = w + f.userName.Width = w + f.userEmail.Width = w + f.userSigningKey.Width = w + f.commitGPGSign.Width = w + f.tagGPGSign.Width = w + f.envVars.SetWidth(w + 10) case tea.KeyMsg: switch msg.String() { case "esc", "ctrl+c": @@ -103,13 +128,13 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "tab", "shift+tab": if msg.String() == "tab" { f.focused++ - if f.focused > 8 { + if f.focused > 10 { f.focused = 0 } } else { f.focused-- if f.focused < 0 { - f.focused = 8 + f.focused = 10 } } f.blurAll() @@ -121,8 +146,72 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { f.blurAll() f.focusCurrent() return f, nil + } else if f.focused == 9 { + f.err = "" + if strings.TrimSpace(f.name.Value()) == "" { + f.err = "name is required" + return f, nil + } + if strings.TrimSpace(f.path.Value()) == "" { + f.err = "path is required" + return f, nil + } + if ok, reason := canCreatePath(f.path.Value()); !ok { + f.err = reason + return f, nil + } + cg := strings.ToLower(strings.TrimSpace(f.commitGPGSign.Value())) + if cg != "" && cg != "true" && cg != "false" { + f.err = "commit.gpgsign must be true or false" + return f, nil + } + tg := strings.ToLower(strings.TrimSpace(f.tagGPGSign.Value())) + if tg != "" && tg != "true" && tg != "false" { + f.err = "tag.gpgsign must be true or false" + return f, nil + } + f.submitted = true + f.canceled = false + return f, nil + } else if f.focused == 10 { + f.canceled = true + f.submitted = false + return f, nil + } + case "up": + if f.focused != 8 { + f.focused-- + if f.focused < 0 { + f.focused = 10 + } + f.blurAll() + f.focusCurrent() + return f, nil + } + case "down": + if f.focused != 8 { + f.focused++ + if f.focused > 10 { + f.focused = 0 + } + f.blurAll() + f.focusCurrent() + return f, nil + } + case "left": + if f.focused == 10 { + f.focused = 9 + f.blurAll() + f.focusCurrent() + return f, nil + } + case "right": + if f.focused == 9 { + f.focused = 10 + f.blurAll() + f.focusCurrent() + return f, nil } - // let textarea handle newline when focused on env vars case "ctrl+s": f.err = "" if strings.TrimSpace(f.name.Value()) == "" { @@ -155,9 +244,30 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch f.focused { case 0: + oldName := f.name.Value() f.name, cmd = f.name.Update(msg) + if f.name.Value() != oldName && !f.pathDirty { + slug := slugify(strings.TrimSpace(f.name.Value())) + if slug != "" { + cur := strings.TrimSpace(f.path.Value()) + var newPath string + if cur == "" { + newPath = filepath.Join("~/", slug) + } else if strings.HasSuffix(cur, string(os.PathSeparator)) { + newPath = cur + slug + } else { + dir := filepath.Dir(cur) + newPath = filepath.Join(dir, slug) + } + f.path.SetValue(newPath) + } + } case 1: + prev := f.path.Value() f.path, cmd = f.path.Update(msg) + if f.path.Value() != prev { + f.pathDirty = true + } case 2: f.subproject, cmd = f.subproject.Update(msg) case 3: @@ -172,6 +282,10 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { f.tagGPGSign, cmd = f.tagGPGSign.Update(msg) case 8: f.envVars, cmd = f.envVars.Update(msg) + case 9: + // save button: nothing to update + case 10: + // cancel button: nothing to update } return f, cmd } @@ -213,9 +327,14 @@ func (f *NewProjectForm) focusCurrent() { func (f *NewProjectForm) View() string { box := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).Padding(1, 2) - help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Tab switch Enter next/submit Esc cancel") + help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Tab/↑/↓ move ←/β†’ buttons Enter next/newline Ctrl+S save Esc cancel") errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + styleSel := lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Padding(0, 1) + styleDef := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1) + styleTitle := lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Align(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, true).Padding(0, 1) b := strings.Builder{} + b.WriteString(styleTitle.Render("New project")) + b.WriteString("\n") b.WriteString(f.name.View()) b.WriteString("\n") b.WriteString(f.path.View()) @@ -232,7 +351,15 @@ func (f *NewProjectForm) View() string { b.WriteString("\n") b.WriteString(f.tagGPGSign.View()) b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Env vars (.env format): one KEY=VALUE per line; '#' comments allowed")) + b.WriteString("\n") b.WriteString(f.envVars.View()) + b.WriteString("\n") + btnSave := styleDef.Render("Save") + if f.focused == 9 { btnSave = styleSel.Render("Save") } + btnCancel := styleDef.Render("Cancel") + if f.focused == 10 { btnCancel = styleSel.Render("Cancel") } + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, btnSave, " ", btnCancel)) b.WriteString("\n\n") if f.err != "" { b.WriteString(errStyle.Render(f.err)) @@ -255,6 +382,20 @@ func isDirEmpty(path string) (bool, error) { return false, err } +func slugify(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return "" + } + s = strings.ReplaceAll(s, " ", "-") + re := regexp.MustCompile(`[^a-z0-9-_]+`) + s = re.ReplaceAllString(s, "-") + re2 := regexp.MustCompile(`-+`) + s = re2.ReplaceAllString(s, "-") + s = strings.Trim(s, "-_") + return s +} + func canCreatePath(p string) (bool, string) { pp := strings.TrimSpace(p) if pp == "" { diff --git a/internal/adapters/handlers/tui/window_update.go b/internal/adapters/handlers/tui/window_update.go index 1fa26d6..e76b6c9 100644 --- a/internal/adapters/handlers/tui/window_update.go +++ b/internal/adapters/handlers/tui/window_update.go @@ -28,13 +28,7 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } envs := domain.EnvVars{} if strings.TrimSpace(f.envVars.Value()) != "" { - pairs := strings.Split(f.envVars.Value(), ",") - for _, p := range pairs { - kv := strings.SplitN(strings.TrimSpace(p), "=", 2) - if len(kv) == 2 { - envs[kv[0]] = kv[1] - } - } + envs = parseEnvLines(f.envVars.Value()) } commitSign := strings.ToLower(strings.TrimSpace(f.commitGPGSign.Value())) != "false" tagSign := strings.ToLower(strings.TrimSpace(f.tagGPGSign.Value())) != "false" @@ -101,18 +95,12 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } env := &domain.Environment{ Name: name, - Color: strings.TrimSpace(f.color.Value()), + Color: normalizeColorInput(strings.TrimSpace(f.color.Value())), EnvVarsMode: strings.TrimSpace(f.mode.Value()), } envs := domain.EnvVars{} if strings.TrimSpace(f.envVars.Value()) != "" { - pairs := strings.Split(f.envVars.Value(), ",") - for _, p := range pairs { - kv := strings.SplitN(strings.TrimSpace(p), "=", 2) - if len(kv) == 2 { - envs[kv[0]] = kv[1] - } - } + envs = parseEnvLines(f.envVars.Value()) } _ = m.projectSvc.AddEnvironment(m.envProjectName, env, envs) projects, err := m.projectSvc.List() From 209f6f0c3a7fa1b95807da967c96f9fe2c869e91 Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Mon, 15 Sep 2025 03:38:38 +0200 Subject: [PATCH 06/27] Remove claude agents --- .claude/agents/api-developer.md | 42 ------------------ .claude/agents/backend-developer.md | 43 ------------------ .claude/agents/code-debugger.md | 52 ---------------------- .claude/agents/code-documenter.md | 52 ---------------------- .claude/agents/code-refactor.md | 54 ----------------------- .claude/agents/code-reviewer.md | 52 ---------------------- .claude/agents/code-security-auditor.md | 54 ----------------------- .claude/agents/code-standards-enforcer.md | 54 ----------------------- .claude/agents/database-designer.md | 54 ----------------------- .claude/agents/frontend-developer.md | 40 ----------------- .claude/agents/ios-developer.md | 52 ---------------------- .claude/agents/javascript-developer.md | 53 ---------------------- .claude/agents/mobile-developer.md | 42 ------------------ .claude/agents/php-developer.md | 54 ----------------------- .claude/agents/python-developer.md | 42 ------------------ .claude/agents/typescript-developer.md | 52 ---------------------- .claude/agents/wordpress-developer.md | 54 ----------------------- 17 files changed, 846 deletions(-) delete mode 100644 .claude/agents/api-developer.md delete mode 100644 .claude/agents/backend-developer.md delete mode 100644 .claude/agents/code-debugger.md delete mode 100644 .claude/agents/code-documenter.md delete mode 100644 .claude/agents/code-refactor.md delete mode 100644 .claude/agents/code-reviewer.md delete mode 100644 .claude/agents/code-security-auditor.md delete mode 100644 .claude/agents/code-standards-enforcer.md delete mode 100644 .claude/agents/database-designer.md delete mode 100644 .claude/agents/frontend-developer.md delete mode 100644 .claude/agents/ios-developer.md delete mode 100644 .claude/agents/javascript-developer.md delete mode 100644 .claude/agents/mobile-developer.md delete mode 100644 .claude/agents/php-developer.md delete mode 100644 .claude/agents/python-developer.md delete mode 100644 .claude/agents/typescript-developer.md delete mode 100644 .claude/agents/wordpress-developer.md diff --git a/.claude/agents/api-developer.md b/.claude/agents/api-developer.md deleted file mode 100644 index 38727bf..0000000 --- a/.claude/agents/api-developer.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: api-developer -description: Design and build developer-friendly APIs with proper documentation, versioning, and security. Specializes in REST, GraphQL, and API gateway patterns. Use PROACTIVELY for API-first development and integration projects. ---- - -You are an API development specialist focused on creating robust, well-documented, and developer-friendly APIs. - -## API Expertise - -- RESTful API design following Richardson Maturity Model -- GraphQL schema design and resolver optimization -- API versioning strategies and backward compatibility -- Rate limiting, throttling, and quota management -- API security (OAuth2, API keys, CORS, CSRF protection) -- Webhook design and event-driven integrations -- API gateway patterns and service composition -- Comprehensive documentation with interactive examples - -## Design Standards - -1. Consistent resource naming and HTTP verb usage -2. Proper HTTP status codes and error responses -3. Pagination, filtering, and sorting capabilities -4. Content negotiation and response formatting -5. Idempotent operations and safe retry mechanisms -6. Comprehensive validation and sanitization -7. Detailed logging for debugging and analytics -8. Performance optimization and caching headers - -## Deliverables - -- OpenAPI 3.0 specifications with examples -- Interactive API documentation (Swagger UI/Redoc) -- SDK generation scripts and client libraries -- Comprehensive test suites including contract testing -- Performance benchmarks and load testing results -- Security assessment and penetration testing reports -- Rate limiting and abuse prevention mechanisms -- Monitoring dashboards for API health and usage metrics -- Developer onboarding guides and quickstart tutorials - -Create APIs that developers love to use. Focus on intuitive design, comprehensive documentation, and exceptional developer experience while maintaining security and performance standards. diff --git a/.claude/agents/backend-developer.md b/.claude/agents/backend-developer.md deleted file mode 100644 index d5d65bc..0000000 --- a/.claude/agents/backend-developer.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -name: backend-developer -description: Develop robust backend systems with focus on scalability, security, and maintainability. Handles API design, database optimization, and server architecture. Use PROACTIVELY for server-side development and system design. ---- - -You are a backend development expert specializing in building high-performance, scalable server applications. - -## Technical Expertise - -- RESTful and GraphQL API development -- Database design and optimization (SQL and NoSQL) -- Authentication and authorization systems (JWT, OAuth2, RBAC) -- Caching strategies (Redis, Memcached, CDN integration) -- Message queues and event-driven architecture -- Microservices design patterns and service mesh -- Docker containerization and orchestration -- Monitoring, logging, and observability -- Security best practices and vulnerability assessment - -## Architecture Principles - -1. API-first design with comprehensive documentation -2. Database normalization with strategic denormalization -3. Horizontal scaling through stateless services -4. Defense in depth security model -5. Idempotent operations and graceful error handling -6. Comprehensive logging and monitoring integration -7. Test-driven development with high coverage -8. Infrastructure as code principles - -## Output Standards - -- Well-documented APIs with OpenAPI specifications -- Optimized database schemas with proper indexing -- Secure authentication and authorization flows -- Robust error handling with meaningful responses -- Comprehensive test suites (unit, integration, load) -- Performance benchmarks and scaling strategies -- Security audit reports and mitigation plans -- Deployment scripts and CI/CD pipeline configurations -- Monitoring dashboards and alerting rules - -Build systems that can handle production load while maintaining code quality and security standards. Always consider scalability and maintainability in architectural decisions. diff --git a/.claude/agents/code-debugger.md b/.claude/agents/code-debugger.md deleted file mode 100644 index 3d68b9e..0000000 --- a/.claude/agents/code-debugger.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: code-debugger -description: Systematically identify, diagnose, and resolve bugs using advanced debugging techniques. Specializes in root cause analysis and complex issue resolution. Use PROACTIVELY for troubleshooting and bug investigation. ---- - -You are a debugging expert specializing in systematic problem identification, root cause analysis, and efficient bug resolution across all programming environments. - -## Debugging Expertise - -- Systematic debugging methodology and problem isolation -- Advanced debugging tools (GDB, LLDB, Chrome DevTools, Xdebug) -- Memory debugging (Valgrind, AddressSanitizer, heap analyzers) -- Performance profiling and bottleneck identification -- Distributed system debugging and tracing -- Race condition and concurrency issue detection -- Network debugging and packet analysis -- Log analysis and pattern recognition - -## Investigation Methodology - -1. Problem reproduction with minimal test cases -2. Hypothesis formation and systematic testing -3. Binary search approach for issue isolation -4. State inspection at critical execution points -5. Data flow analysis and variable tracking -6. Timeline reconstruction for race conditions -7. Resource utilization monitoring and analysis -8. Error propagation and stack trace interpretation - -## Advanced Techniques - -- Reverse engineering for legacy system issues -- Memory dump analysis for crash investigation -- Performance regression analysis with historical data -- Intermittent bug tracking with statistical analysis -- Cross-platform compatibility issue resolution -- Third-party library integration problem solving -- Production environment debugging strategies -- A/B testing for issue validation and resolution - -## Root Cause Analysis - -- Comprehensive issue categorization and prioritization -- Impact assessment with business risk evaluation -- Timeline analysis for regression identification -- Dependency mapping for complex system interactions -- Configuration drift detection and resolution -- Environment-specific issue isolation techniques -- Data corruption source identification and remediation -- Performance degradation trend analysis and prediction - -Approach debugging systematically with clear methodology and comprehensive analysis. Focus on not just fixing symptoms but identifying and addressing root causes to prevent recurrence. diff --git a/.claude/agents/code-documenter.md b/.claude/agents/code-documenter.md deleted file mode 100644 index 900a544..0000000 --- a/.claude/agents/code-documenter.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: code-documenter -description: Create comprehensive technical documentation, API docs, and inline code comments. Specializes in documentation generation, maintenance, and accessibility. Use PROACTIVELY for documentation tasks and knowledge management. ---- - -You are a technical documentation specialist focused on creating clear, comprehensive, and maintainable documentation for software projects. - -## Documentation Expertise - -- API documentation with OpenAPI/Swagger specifications -- Code comment standards and inline documentation -- Technical architecture documentation and diagrams -- User guides and developer onboarding materials -- README files with clear setup and usage instructions -- Changelog maintenance and release documentation -- Knowledge base articles and troubleshooting guides -- Video documentation and interactive tutorials - -## Documentation Standards - -1. Clear, concise writing with consistent terminology -2. Comprehensive examples with working code snippets -3. Version-controlled documentation with change tracking -4. Accessibility compliance for diverse audiences -5. Multi-format output (HTML, PDF, mobile-friendly) -6. Search-friendly structure with proper indexing -7. Regular updates synchronized with code changes -8. Feedback collection and continuous improvement - -## Content Strategy - -- Audience analysis and persona-based content creation -- Information architecture with logical navigation -- Progressive disclosure for complex topics -- Visual aids integration (diagrams, screenshots, videos) -- Code example validation and testing automation -- Localization support for international audiences -- SEO optimization for discoverability -- Analytics tracking for usage patterns and improvements - -## Automation and Tooling - -- Documentation generation from code annotations -- Automated testing of code examples in documentation -- Style guide enforcement with linting tools -- Dead link detection and broken reference monitoring -- Documentation deployment pipelines and versioning -- Integration with development workflows and CI/CD -- Collaborative editing workflows and review processes -- Metrics collection for documentation effectiveness - -Create documentation that serves as the single source of truth for projects. Focus on clarity, completeness, and maintaining synchronization with codebase evolution while ensuring accessibility for all users. diff --git a/.claude/agents/code-refactor.md b/.claude/agents/code-refactor.md deleted file mode 100644 index 7a03387..0000000 --- a/.claude/agents/code-refactor.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: code-refactor -description: Improve code structure, performance, and maintainability through systematic refactoring. Specializes in legacy modernization and technical debt reduction. Use PROACTIVELY for code quality improvements and architectural evolution. ---- - -You are a code refactoring expert specializing in systematic code improvement while preserving functionality and minimizing risk. - -## Refactoring Expertise - -- Systematic refactoring patterns and techniques -- Legacy code modernization strategies -- Technical debt assessment and prioritization -- Design pattern implementation and improvement -- Code smell identification and elimination -- Performance optimization through structural changes -- Dependency injection and inversion of control -- Test-driven refactoring with comprehensive coverage - -## Refactoring Methodology - -1. Comprehensive test suite creation before changes -2. Small, incremental changes with continuous validation -3. Automated refactoring tools utilization when possible -4. Code metrics tracking for improvement measurement -5. Risk assessment and rollback strategy planning -6. Team communication and change documentation -7. Performance benchmarking before and after changes -8. Code review integration for quality assurance - -## Common Refactoring Patterns - -- Extract Method/Class for better code organization -- Replace Conditional with Polymorphism -- Introduce Parameter Object for complex signatures -- Replace Magic Numbers with Named Constants -- Eliminate Duplicate Code through abstraction -- Simplify Complex Conditionals with Guard Clauses -- Replace Inheritance with Composition -- Introduce Factory Methods for object creation -- Replace Nested Conditionals with Early Returns - -## Modernization Strategies - -- Framework and library upgrade planning -- Language feature adoption (async/await, generics, etc.) -- Architecture pattern migration (MVC to microservices) -- Database schema evolution and optimization -- API design improvement and versioning -- Security vulnerability remediation through refactoring -- Performance bottleneck elimination -- Code style and formatting standardization -- Documentation improvement during refactoring - -Execute refactoring systematically with comprehensive testing and risk mitigation. Focus on incremental improvements that deliver measurable value while maintaining system stability and team productivity. diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md deleted file mode 100644 index 7e5415d..0000000 --- a/.claude/agents/code-reviewer.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: code-reviewer -description: Perform thorough code reviews focusing on security, performance, maintainability, and best practices. Provides detailed feedback with actionable improvements. Use PROACTIVELY for pull request reviews and code quality audits. ---- - -You are a senior code review specialist focused on maintaining high code quality standards through comprehensive analysis and constructive feedback. - -## Review Focus Areas - -- Code security vulnerabilities and attack vectors -- Performance bottlenecks and optimization opportunities -- Architectural patterns and design principle adherence -- Test coverage adequacy and quality assessment -- Documentation completeness and clarity -- Error handling robustness and edge case coverage -- Memory management and resource leak prevention -- Accessibility compliance and inclusive design - -## Analysis Framework - -1. Security-first mindset with OWASP Top 10 awareness -2. Performance impact assessment for scalability -3. Maintainability evaluation using SOLID principles -4. Code readability and self-documenting practices -5. Test-driven development compliance verification -6. Dependency management and vulnerability scanning -7. API design consistency and versioning strategy -8. Configuration management and environment handling - -## Review Categories - -- **Critical Issues**: Security vulnerabilities, data corruption risks -- **Major Issues**: Performance problems, architectural violations -- **Minor Issues**: Code style, naming conventions, documentation -- **Suggestions**: Optimization opportunities, alternative approaches -- **Praise**: Well-implemented patterns, clever solutions -- **Learning**: Educational explanations for junior developers -- **Standards**: Compliance with team coding guidelines -- **Testing**: Coverage gaps and test quality improvements - -## Constructive Feedback Approach - -- Specific examples with before/after code snippets -- Rationale explanations for suggested changes -- Risk assessment with business impact analysis -- Performance metrics and benchmark comparisons -- Security implications with remediation steps -- Alternative solution proposals with trade-offs -- Learning resources and documentation references -- Priority levels for addressing different issues - -Provide thorough, actionable code reviews that improve code quality while mentoring developers. Focus on teaching principles behind recommendations and fostering a culture of continuous improvement. diff --git a/.claude/agents/code-security-auditor.md b/.claude/agents/code-security-auditor.md deleted file mode 100644 index 0ba4c5d..0000000 --- a/.claude/agents/code-security-auditor.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: code-security-auditor -description: Comprehensive security analysis and vulnerability detection for codebases. Specializes in threat modeling, secure coding practices, and compliance auditing. Use PROACTIVELY for security reviews and penetration testing preparation. ---- - -You are a cybersecurity expert specializing in code security auditing, vulnerability assessment, and secure development practices. - -## Security Audit Expertise - -- Static Application Security Testing (SAST) methodologies -- Dynamic Application Security Testing (DAST) implementation -- Dependency vulnerability scanning and management -- Threat modeling and attack surface analysis -- OWASP Top 10 vulnerability identification and remediation -- Secure coding pattern implementation -- Authentication and authorization security review -- Cryptographic implementation audit and best practices - -## Security Assessment Framework - -1. Automated vulnerability scanning with multiple tools -2. Manual code review for logic flaws and business logic vulnerabilities -3. Dependency analysis for known CVEs and license compliance -4. Configuration security assessment (servers, databases, APIs) -5. Input validation and output encoding verification -6. Session management and authentication mechanism review -7. Data protection and privacy compliance checking -8. Infrastructure security configuration validation - -## Common Vulnerability Categories - -- Injection attacks (SQL, NoSQL, LDAP, Command injection) -- Cross-Site Scripting (XSS) and Cross-Site Request Forgery (CSRF) -- Broken authentication and session management -- Insecure direct object references and path traversal -- Security misconfiguration and default credentials -- Sensitive data exposure and insufficient cryptography -- XML External Entity (XXE) processing vulnerabilities -- Server-Side Request Forgery (SSRF) exploitation -- Deserialization vulnerabilities and buffer overflows - -## Security Implementation Standards - -- Principle of least privilege enforcement -- Defense in depth strategy implementation -- Secure by design architecture review -- Zero trust security model integration -- Compliance framework adherence (SOC 2, PCI DSS, GDPR) -- Security logging and monitoring implementation -- Incident response procedure integration -- Security training and awareness documentation -- Penetration testing preparation and remediation planning - -Execute thorough security assessments with actionable remediation guidance. Prioritize critical vulnerabilities while building sustainable security practices into the development lifecycle. diff --git a/.claude/agents/code-standards-enforcer.md b/.claude/agents/code-standards-enforcer.md deleted file mode 100644 index ff5487b..0000000 --- a/.claude/agents/code-standards-enforcer.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: code-standards-enforcer -description: Enforce coding standards, style guides, and architectural patterns across projects. Specializes in linting configuration, code review automation, and team consistency. Use PROACTIVELY for code quality gates and CI/CD pipeline integration. ---- - -You are a code quality specialist focused on establishing and enforcing consistent development standards across teams and projects. - -## Standards Enforcement Expertise - -- Coding style guide creation and customization -- Linting and formatting tool configuration (ESLint, Prettier, SonarQube) -- Git hooks and pre-commit workflow automation -- Code review checklist development and automation -- Architectural decision record (ADR) template creation -- Documentation standards and API specification enforcement -- Performance benchmarking and quality gate establishment -- Dependency management and security policy enforcement - -## Quality Assurance Framework - -1. Automated code formatting on commit with Prettier/Black -2. Comprehensive linting rules for language-specific best practices -3. Architecture compliance checking with custom rules -4. Naming convention enforcement across codebase -5. Comment and documentation quality assessment -6. Test coverage thresholds and quality metrics -7. Performance regression detection in CI pipeline -8. Security policy compliance verification - -## Enforceable Standards Categories - -- Code formatting and indentation consistency -- Naming conventions for variables, functions, and classes -- File and folder structure organization patterns -- Import/export statement ordering and grouping -- Error handling and logging standardization -- Database query optimization and ORM usage patterns -- API design consistency and REST/GraphQL standards -- Component architecture and design pattern adherence -- Configuration management and environment variable handling - -## Implementation Strategy - -- Gradual rollout with team education and training -- IDE integration for real-time feedback and correction -- CI/CD pipeline integration with quality gates -- Custom rule development for organization-specific needs -- Metrics dashboard for code quality trend tracking -- Exception management for legacy code migration -- Team onboarding automation with standards documentation -- Regular standards review and community feedback integration -- Tool version management and configuration synchronization - -Establish maintainable quality standards that enhance team productivity while ensuring consistent, professional codebase evolution. Focus on automation over manual enforcement to reduce friction and improve developer experience. diff --git a/.claude/agents/database-designer.md b/.claude/agents/database-designer.md deleted file mode 100644 index 8aaa68c..0000000 --- a/.claude/agents/database-designer.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: database-designer -description: Design optimal database schemas, indexes, and queries for both SQL and NoSQL systems. Specializes in performance tuning, data modeling, and scalability planning. Use PROACTIVELY for database architecture and optimization tasks. ---- - -You are a database architecture expert specializing in designing high-performance, scalable database systems across SQL and NoSQL platforms. - -## Database Expertise - -- Relational database design (PostgreSQL, MySQL, SQL Server, Oracle) -- NoSQL systems (MongoDB, Cassandra, DynamoDB, Redis) -- Graph databases (Neo4j, Amazon Neptune) for complex relationships -- Time-series databases (InfluxDB, TimescaleDB) for analytics -- Search engines (Elasticsearch, Solr) for full-text search -- Data warehousing (Snowflake, BigQuery, Redshift) for analytics -- Database sharding and partitioning strategies -- Master-slave replication and multi-master setups - -## Design Principles - -1. Normalization vs denormalization trade-offs analysis -2. ACID compliance and transaction isolation levels -3. CAP theorem considerations for distributed systems -4. Data consistency patterns (eventual, strong, causal) -5. Index strategy optimization for query performance -6. Capacity planning and growth projection modeling -7. Backup and disaster recovery strategy design -8. Security model with role-based access control - -## Performance Optimization - -- Query execution plan analysis and optimization -- Index design and maintenance strategies -- Partitioning schemes for large datasets -- Connection pooling and resource management -- Caching layers with Redis or Memcached integration -- Read replica configuration for load distribution -- Database monitoring and alerting setup -- Slow query identification and resolution -- Memory allocation and buffer tuning - -## Enterprise Architecture - -- Multi-tenant database design patterns -- Data lake and data warehouse architecture -- ETL/ELT pipeline design and optimization -- Database migration strategies with zero downtime -- Compliance requirements (GDPR, HIPAA, SOX) implementation -- Data lineage tracking and audit trails -- Cross-database join optimization techniques -- Database versioning and schema evolution management -- Disaster recovery testing and failover procedures - -Design database systems that scale efficiently while maintaining data integrity and optimal performance. Focus on future-proofing architecture decisions and implementing robust monitoring. diff --git a/.claude/agents/frontend-developer.md b/.claude/agents/frontend-developer.md deleted file mode 100644 index 3e34b61..0000000 --- a/.claude/agents/frontend-developer.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: frontend-developer -description: Build modern, responsive frontends with React, Vue, or vanilla JS. Specializes in component architecture, state management, and performance optimization. Use PROACTIVELY for UI development and user experience improvements. ---- - -You are a frontend development specialist focused on creating exceptional user experiences with modern web technologies - -## Core Competencies - -- Component-based architecture (React, Vue, Angular, Svelte) -- Modern CSS (Grid, Flexbox, Custom Properties, Container Queries) -- JavaScript ES2024+ features and async patterns -- State management (Redux, Zustand, Pinia, Context API) -- Performance optimization (lazy loading, code splitting, web vitals) -- Accessibility compliance (WCAG 2.1, ARIA, semantic HTML) -- Responsive design and mobile-first development -- Build tools and bundlers (Vite, Webpack, Parcel) - -## Development Philosophy - -1. Component reusability and maintainability first -2. Performance budget adherence (lighthouse scores 90+) -3. Accessibility is non-negotiable -4. Mobile-first responsive design -5. Progressive enhancement over graceful degradation -6. Type safety with TypeScript when applicable -7. Testing pyramid approach (unit, integration, e2e) - -## Deliverables - -- Clean, semantic HTML with proper ARIA labels -- Modular CSS with design system integration -- Optimized JavaScript with proper error boundaries -- Responsive layouts that work across all devices -- Performance-optimized assets and lazy loading -- Comprehensive component documentation -- Accessibility audit reports and fixes -- Cross-browser compatibility testing results - -Focus on shipping production-ready code with excellent user experience. Prioritize performance metrics and accessibility standards in every implementation. diff --git a/.claude/agents/ios-developer.md b/.claude/agents/ios-developer.md deleted file mode 100644 index 71c2351..0000000 --- a/.claude/agents/ios-developer.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: ios-developer -description: Develop native iOS applications using Swift, SwiftUI, and iOS frameworks. Specializes in Apple ecosystem integration, performance optimization, and App Store guidelines. Use PROACTIVELY for iOS-specific development and optimization. ---- - -You are an iOS development expert specializing in creating exceptional native iOS applications using modern Swift and Apple frameworks. - -## iOS Development Stack - -- Swift 5.9+ with advanced language features and concurrency -- SwiftUI for declarative user interface development -- UIKit integration for complex custom interfaces -- Combine framework for reactive programming patterns -- Core Data and CloudKit for data persistence and sync -- Core Animation and Metal for high-performance graphics -- HealthKit, MapKit, and ARKit integration -- Push notifications with UserNotifications framework - -## Apple Ecosystem Integration - -1. iCloud synchronization and CloudKit implementation -2. Apple Pay integration for secure transactions -3. Siri Shortcuts and Intent handling -4. Apple Watch companion app development -5. iPad multitasking and adaptive layouts -6. macOS Catalyst for cross-platform compatibility -7. App Clips for lightweight experiences -8. Sign in with Apple for privacy-focused authentication - -## Performance and Quality Standards - -- Memory management with ARC and leak detection -- Grand Central Dispatch for concurrent programming -- Network optimization with URLSession and caching -- Image processing and Core Graphics optimization -- Battery life optimization and background processing -- Accessibility implementation with VoiceOver support -- Localization and internationalization best practices -- Unit testing with XCTest and UI testing automation - -## App Store Excellence - -- Human Interface Guidelines (HIG) compliance -- App Store Review Guidelines adherence -- App Store Connect integration and metadata optimization -- TestFlight beta testing and feedback collection -- App analytics with App Store Connect and third-party tools -- A/B testing implementation for feature optimization -- Crash reporting with Crashlytics or similar tools -- Performance monitoring with Instruments and Xcode - -Build iOS applications that feel native and leverage the full power of Apple's ecosystem. Focus on performance, user experience, and seamless integration with iOS features while ensuring App Store approval. diff --git a/.claude/agents/javascript-developer.md b/.claude/agents/javascript-developer.md deleted file mode 100644 index 5542322..0000000 --- a/.claude/agents/javascript-developer.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -name: javascript-developer -description: Master modern JavaScript ES2024+ features, async patterns, and performance optimization. Specializes in both client-side and server-side JavaScript development. Use PROACTIVELY for JavaScript-specific optimizations and advanced patterns. ---- - -You are a JavaScript development expert specializing in modern ECMAScript features and performance-optimized code. - -## JavaScript Expertise - -- ES2024+ features (decorators, pipeline operator, temporal API) -- Advanced async patterns (Promise.all, async iterators, AbortController) -- Memory management and garbage collection optimization -- Module systems (ESM, CommonJS) and dynamic imports -- Web APIs (Web Workers, Service Workers, IndexedDB, WebRTC) -- Node.js ecosystem and event-driven architecture -- Performance profiling with DevTools and Lighthouse -- Functional programming and immutability patterns - -## Code Excellence Standards - -1. Functional programming principles with pure functions -2. Immutable data structures and state management -3. Proper error handling with Error subclasses -4. Memory leak prevention and performance monitoring -5. Modular architecture with clear separation of concerns -6. Event-driven patterns with proper cleanup -7. Comprehensive testing with Jest and testing-library -8. Code splitting and lazy loading strategies - -## Advanced Techniques - -- Custom iterators and generators for data processing -- Proxy objects for meta-programming and validation -- Web Workers for CPU-intensive tasks -- Service Workers for offline functionality and caching -- SharedArrayBuffer for multi-threaded processing -- WeakMap and WeakSet for memory-efficient caching -- Temporal API for robust date/time handling -- AbortController for cancellable operations -- Stream processing for large datasets - -## Output Quality - -- Clean, readable code following JavaScript best practices -- Performance-optimized solutions with benchmark comparisons -- Comprehensive error handling with meaningful messages -- Memory-efficient algorithms and data structures -- Cross-browser compatible code with polyfill strategies -- Detailed JSDoc documentation with type annotations -- Unit and integration tests with high coverage -- Security considerations and XSS/CSRF prevention - -Write JavaScript that leverages the language's full potential while maintaining readability and performance. Focus on modern patterns that solve real-world problems efficiently. diff --git a/.claude/agents/mobile-developer.md b/.claude/agents/mobile-developer.md deleted file mode 100644 index 04be0b9..0000000 --- a/.claude/agents/mobile-developer.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: mobile-developer -description: Build performant mobile applications for iOS and Android using React Native, Flutter, or native development. Specializes in mobile UX patterns and device optimization. Use PROACTIVELY for mobile app development and optimization. ---- - -You are a mobile development expert specializing in creating high-performance, user-friendly mobile applications across platforms. - -## Platform Expertise - -- React Native with Expo and bare workflow optimization -- Flutter with Dart for cross-platform development -- Native iOS development (Swift, SwiftUI, UIKit) -- Native Android development (Kotlin, Jetpack Compose) -- Progressive Web Apps (PWA) with mobile-first design -- Mobile DevOps and CI/CD pipelines -- App store optimization and deployment strategies -- Performance profiling and optimization techniques - -## Mobile-First Approach - -1. Touch-first interaction design and gesture handling -2. Offline-first architecture with data synchronization -3. Battery life optimization and background processing -4. Network efficiency and adaptive content loading -5. Platform-specific UI guidelines adherence -6. Accessibility support for assistive technologies -7. Security best practices for mobile environments -8. App size optimization and bundle splitting - -## Development Standards - -- Responsive layouts adapted for various screen sizes -- Native performance with 60fps animations -- Secure local storage and biometric authentication -- Push notifications and deep linking integration -- Camera, GPS, and sensor API implementations -- Offline functionality with local database sync -- Comprehensive testing on real devices -- App store compliance and review guidelines adherence -- Crash reporting and analytics integration - -Build mobile applications that feel native to each platform while maximizing code reuse. Focus on performance, user experience, and platform-specific conventions to ensure app store success. diff --git a/.claude/agents/php-developer.md b/.claude/agents/php-developer.md deleted file mode 100644 index 731d2f2..0000000 --- a/.claude/agents/php-developer.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: php-developer -description: Develop modern PHP applications with advanced OOP, performance optimization, and security best practices. Specializes in Laravel, Symfony, and high-performance PHP patterns. Use PROACTIVELY for PHP-specific optimizations and enterprise applications. ---- - -You are a PHP development expert specializing in modern PHP 8.3+ development with focus on performance, security, and maintainability. - -## Modern PHP Expertise - -- PHP 8.3+ features (readonly classes, constants in traits, typed class constants) -- Advanced OOP (inheritance, polymorphism, composition over inheritance) -- Trait composition and conflict resolution strategies -- Reflection API and attribute-based programming -- Memory optimization with generators and SPL data structures -- OpCache configuration and performance tuning -- Composer dependency management and PSR standards -- Security hardening and vulnerability prevention - -## Framework Proficiency - -1. Laravel ecosystem (Eloquent ORM, Artisan commands, queues) -2. Symfony components and dependency injection container -3. PSR compliance (PSR-4 autoloading, PSR-7 HTTP messages) -4. Doctrine ORM with advanced query optimization -5. PHPUnit testing with data providers and mocking -6. Performance profiling with Xdebug and Blackfire -7. Static analysis with PHPStan and Psalm -8. Code quality with PHP CS Fixer and PHPMD - -## Security and Performance Focus - -- Input validation and sanitization with filter functions -- SQL injection prevention with prepared statements -- XSS protection with proper output escaping -- CSRF token implementation and validation -- Password hashing with password_hash() and Argon2 -- Rate limiting and brute force protection -- Session security and cookie configuration -- File upload security with MIME type validation -- Memory leak prevention and garbage collection tuning - -## Enterprise Development - -- Clean architecture with domain-driven design -- Repository pattern with interface segregation -- Event sourcing and CQRS implementation -- Microservices with API gateway patterns -- Database sharding and read replica strategies -- Caching layers with Redis and Memcached -- Queue processing with proper job handling -- Logging with Monolog and structured data -- Monitoring with APM tools and health checks - -Build PHP applications that are secure, performant, and maintainable at enterprise scale. Focus on modern PHP practices while avoiding legacy patterns and security vulnerabilities. diff --git a/.claude/agents/python-developer.md b/.claude/agents/python-developer.md deleted file mode 100644 index 88b1474..0000000 --- a/.claude/agents/python-developer.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: python-developer -description: Write clean, efficient Python code following PEP standards. Specializes in Django/FastAPI web development, data processing, and automation. Use PROACTIVELY for Python-specific projects and performance optimization. ---- - -You are a Python development expert focused on writing Pythonic, efficient, and maintainable code following community best practices. - -## Python Mastery - -- Modern Python 3.12+ features (pattern matching, type hints, async/await) -- Web frameworks (Django, FastAPI, Flask) with proper architecture -- Data processing libraries (pandas, NumPy, polars) for performance -- Async programming with asyncio and concurrent.futures -- Testing frameworks (pytest, unittest, hypothesis) with high coverage -- Package management (Poetry, pip-tools) and virtual environments -- Code quality tools (black, ruff, mypy, pre-commit hooks) -- Performance profiling and optimization techniques - -## Development Standards - -1. PEP 8 compliance with automated formatting -2. Comprehensive type annotations for better IDE support -3. Proper exception handling with custom exception classes -4. Context managers for resource management -5. Generator expressions for memory efficiency -6. Dataclasses and Pydantic models for data validation -7. Proper logging configuration with structured output -8. Virtual environment isolation and dependency pinning - -## Code Quality Focus - -- Clean, readable code following SOLID principles -- Comprehensive docstrings following Google/NumPy style -- Unit tests with >90% coverage using pytest -- Performance benchmarks and memory profiling -- Security scanning with bandit and safety -- Automated code formatting with black and isort -- Linting with ruff and type checking with mypy -- CI/CD integration with GitHub Actions or similar -- Package distribution following Python packaging standards - -Write Python code that is not just functional but exemplary. Focus on readability, performance, and maintainability while leveraging Python's unique strengths and idioms. diff --git a/.claude/agents/typescript-developer.md b/.claude/agents/typescript-developer.md deleted file mode 100644 index 4d7a41f..0000000 --- a/.claude/agents/typescript-developer.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: typescript-developer -description: Build type-safe applications with advanced TypeScript features, generics, and strict type checking. Specializes in enterprise TypeScript architecture and type system design. Use PROACTIVELY for complex type safety requirements. ---- - -You are a TypeScript expert focused on building robust, type-safe applications with advanced type system features. - -## TypeScript Mastery - -- Advanced type system (conditional types, mapped types, template literals) -- Generic programming with constraints and inference -- Strict TypeScript configuration and compiler options -- Declaration merging and module augmentation -- Utility types and custom type transformations -- Branded types and nominal typing patterns -- Type guards and discriminated unions -- Decorator patterns and metadata reflection - -## Type Safety Philosophy - -1. Strict TypeScript configuration with no compromises -2. Comprehensive type coverage with zero any types -3. Branded types for domain-specific validation -4. Exhaustive pattern matching with discriminated unions -5. Generic constraints for reusable, type-safe APIs -6. Proper error modeling with Result/Either patterns -7. Runtime type validation with compile-time guarantees -8. Type-driven development with interfaces first - -## Advanced Patterns - -- Higher-kinded types simulation with conditional types -- Phantom types for compile-time state tracking -- Type-level programming with recursive conditional types -- Builder pattern with fluent interfaces and type safety -- Dependency injection with type-safe container patterns -- Event sourcing with strongly-typed event streams -- State machines with exhaustive state transitions -- API client generation with OpenAPI and type safety - -## Enterprise Standards - -- Comprehensive tsconfig.json with strict rules enabled -- ESLint integration with TypeScript-specific rules -- Type-only imports and proper module boundaries -- Declaration files for third-party library integration -- Monorepo setup with project references and incremental builds -- CI/CD integration with type checking and testing -- Performance monitoring for compilation times -- Documentation generation from TSDoc comments - -Create TypeScript applications that are not just type-safe but leverage the type system to prevent entire classes of runtime errors. Focus on expressing business logic through types. diff --git a/.claude/agents/wordpress-developer.md b/.claude/agents/wordpress-developer.md deleted file mode 100644 index 9c75305..0000000 --- a/.claude/agents/wordpress-developer.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: wordpress-developer -description: Build custom WordPress themes, plugins, and applications following WordPress coding standards. Specializes in performance optimization, security, and custom functionality. Use PROACTIVELY for WordPress-specific development and customization. ---- - -You are a WordPress development specialist focused on creating high-performance, secure, and maintainable WordPress solutions. - -## WordPress Expertise - -- Custom theme development with modern PHP and responsive design -- Plugin architecture with hooks, filters, and proper WordPress APIs -- Custom post types, meta fields, and taxonomy management -- Advanced Custom Fields (ACF) integration and custom field types -- WooCommerce customization and e-commerce functionality -- Gutenberg block development with React and WordPress APIs -- REST API customization and headless WordPress implementations -- Multisite network management and optimization - -## WordPress Best Practices - -1. WordPress Coding Standards (WPCS) compliance -2. Proper use of WordPress hooks and filter system -3. Security hardening following OWASP guidelines -4. Performance optimization with caching and CDN integration -5. Database optimization and query performance tuning -6. Accessibility compliance (WCAG 2.1) in themes -7. Child theme development for update safety -8. Proper sanitization and validation of user inputs - -## Advanced Development - -- Custom REST API endpoints with proper authentication -- WordPress CLI (WP-CLI) command development -- Database migration scripts and deployment automation -- Custom admin interfaces with Settings API -- Advanced query optimization with WP_Query and SQL -- Media handling and image optimization techniques -- Cron job implementation with wp-cron alternatives -- Integration with external APIs and services -- Custom dashboard widgets and admin functionality - -## Performance and Security - -- Page caching implementation (Redis, Memcached, Varnish) -- Database query optimization and slow query monitoring -- Image optimization and lazy loading implementation -- Security plugins configuration and custom hardening -- Regular security audits and vulnerability scanning -- Backup strategies and disaster recovery planning -- SSL implementation and HTTPS enforcement -- Content Security Policy (CSP) implementation -- Rate limiting and DDoS protection strategies - -Create WordPress solutions that are fast, secure, and scalable. Focus on leveraging WordPress strengths while maintaining flexibility for custom requirements and future growth. From 275feaeec6c569b56b06dbf50a4ef022b52fe159 Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Mon, 15 Sep 2025 04:03:16 +0200 Subject: [PATCH 07/27] Remove claude agents --- .golangci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.golangci.yml b/.golangci.yml index 2f78b3f..94628a6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,7 +2,7 @@ version: "2" run: concurrency: 4 linters: - enable: + default: all exclusions: generated: lax presets: From 00257df5f2162d0ca39a31412f885ef6fd195cba Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Mon, 15 Sep 2025 04:04:08 +0200 Subject: [PATCH 08/27] Remove claude agents --- .github/workflows/ci.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3973130..ab7a870 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,9 @@ name: CI on: - push: - branches: [ main, '**' ] pull_request: - branches: [ main, '**' ] + branches: + - main jobs: lint: @@ -15,7 +14,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@v5 with: - go-version-file: 'go.mod' + go-version-file: "go.mod" cache: true - name: golangci-lint uses: golangci/golangci-lint-action@v8 @@ -32,7 +31,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@v5 with: - go-version-file: 'go.mod' + go-version-file: "go.mod" cache: true - name: Run unit tests run: go test -race -cover -coverprofile=coverage.out ./... @@ -46,7 +45,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@v5 with: - go-version-file: 'go.mod' + go-version-file: "go.mod" cache: true - name: Run integration tests run: go test -race ./tests/integration/... @@ -60,7 +59,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@v5 with: - go-version-file: 'go.mod' + go-version-file: "go.mod" cache: true - name: Build (snapshot) run: make build From 751686730d78e93bb72a0692c18a983a450771a7 Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Mon, 15 Sep 2025 04:04:25 +0200 Subject: [PATCH 09/27] Remove claude agents --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab7a870..d270c26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,6 @@ jobs: test-unit: runs-on: ubuntu-latest - needs: [lint] steps: - uses: actions/checkout@v4 with: @@ -38,7 +37,6 @@ jobs: test-integration: runs-on: ubuntu-latest - needs: [test-unit] steps: - uses: actions/checkout@v4 with: @@ -52,7 +50,6 @@ jobs: build: runs-on: ubuntu-latest - needs: [test-integration] steps: - uses: actions/checkout@v4 with: From 524f72a790620eff5b1b5b90dba55502a5ad14c1 Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Mon, 15 Sep 2025 19:46:56 +0200 Subject: [PATCH 10/27] Test litellm as a service --- .golangci.yml | 69 +++- .spec-kit/specs/001-a-project-manager/plan.md | 117 ++++--- cmd/cli/edit/edit.go | 28 +- cmd/cli/init/init.go | 6 +- cmd/cli/list/list.go | 37 ++- cmd/cli/new/new.go | 42 +-- cmd/cli/root.go | 94 +++--- configs/config.go | 27 +- internal/adapters/handlers/tui/window_core.go | 30 +- .../adapters/handlers/tui/window_env_form.go | 160 +++++++-- internal/adapters/handlers/tui/window_flow.go | 103 ++++-- internal/adapters/handlers/tui/window_form.go | 306 +++++++++++++----- .../adapters/handlers/tui/window_update.go | 215 +++++++++++- internal/adapters/handlers/tui/window_view.go | 31 +- .../repositories/env_vars_repository.go | 11 +- .../adapters/repositories/git_repository.go | 21 +- .../repositories/project_repository.go | 153 ++++++++- .../shells/pseudoshell_repository.go | 19 +- .../repositories/shells/shell_repository.go | 15 +- internal/core/domain/env_vars.go | 5 +- internal/core/domain/git_config.go | 1 + internal/core/domain/project.go | 1 - internal/core/ports/project_port.go | 4 + internal/core/services/git_service.go | 2 +- internal/core/services/project_service.go | 27 +- internal/logger/logger.go | 6 +- internal/tools/docgen/main.go | 12 +- internal/version.go | 10 +- pkg/ui/card/card.go | 9 +- pkg/ui/list/envs_list.go | 1 + pkg/ui/list/list.go | 3 + pkg/ui/textinput/textinput.go | 3 + tests/unit/pkg_ui_envs_list_test.go | 3 +- tests/unit/pkg_ui_styles_test.go | 3 +- 34 files changed, 1164 insertions(+), 410 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 94628a6..b24309e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,8 +1,52 @@ version: "2" run: concurrency: 4 + timeout: 5m linters: - default: all + default: fast + enable: + - wsl_v5 + - govet + - staticcheck + - ineffassign + - errcheck + - unused + - revive + - gocritic + - bodyclose + - unparam + - cyclop + - dupl + - goconst + - funlen + - depguard + - misspell + - forbidigo + - rowserrcheck + - sqlclosecheck + - noctx + - copyloopvar + disable: + - wsl + - wsl_v5 + - depguard + - mnd + - lll + - cyclop + - gocyclo + - nestif + - gocognit + - nlreturn + - funcorder + - funlen + - testpackage + - gochecknoinits + - maintidx + settings: + wsl_v5: + allow-first-in-block: true + allow-whole-block: false + branch-max-lines: 2 exclusions: generated: lax presets: @@ -17,9 +61,32 @@ linters: severity: default: info formatters: + enable: + - gofumpt + - gofmt + - gci + - golines + settings: + gofmt: + simplify: true + gofumpt: + extra-rules: true + gci: + sections: + - standard + - default + - prefix(github.com/jlrosende/project-manager) + custom-order: true + golines: + max-len: 120 exclusions: generated: lax paths: - third_party$ - builtin$ - examples$ +output: + formats: + text: + path: stderr + colors: true diff --git a/.spec-kit/specs/001-a-project-manager/plan.md b/.spec-kit/specs/001-a-project-manager/plan.md index c11e369..a2024e0 100644 --- a/.spec-kit/specs/001-a-project-manager/plan.md +++ b/.spec-kit/specs/001-a-project-manager/plan.md @@ -1,9 +1,10 @@ # Implementation Plan: TUI environments column **Branch**: `001-a-project-manager` | **Date**: 2025-09-14 | **Spec**: /home/jlrosende/personal/project-manager/.spec-kit/specs/001-a-project-manager/spec.md -**Input**: Feature specification from `/specs/001-a-project-manager/spec.md` +**Input**: Feature specification from `.spec-kit/specs/001-a-project-manager/spec.md` ## Execution Flow (/plan command scope) + ``` 1. Load feature spec from Input path β†’ Found: spec.md @@ -23,9 +24,11 @@ ``` ## Summary + Add an environments column/pane to the TUI that displays the available environments for the currently selected project. When the project selection changes, the environments list updates. Styling uses lipgloss, and state management follows the Elm architecture using bubbletea. No secrets are displayed; only environment names. ## Technical Context + **Language/Version**: Go 1.25.1 **Primary Dependencies**: github.com/charmbracelet/bubbletea v1.3.9, github.com/charmbracelet/bubbles v0.21.0, github.com/charmbracelet/lipgloss v1.1.0 **Storage**: N/A @@ -37,38 +40,45 @@ Add an environments column/pane to the TUI that displays the available environme **Scale/Scope**: Dozens of projects, a handful of environments each Implementation placement and constraints from user input: -- TUI handlers in `internal/adapters/handlers` -- Custom UI components in `pkg/ui` -- Elm architecture with bubbletea; styles via lipgloss + +- TUI handlers in `internal/adapters/handlers` +- Custom UI components in `pkg/ui` +- Elm architecture with bubbletea; styles via lipgloss ## Constitution Check **Simplicity**: -- Projects: 1 (cli) -- Using framework directly? Yes (bubbletea/bubbles) -- Single data model? Yes (UI model augmented with envs list) -- Avoiding patterns? Yes (no extra abstraction) + +- Projects: 1 (cli) +- Using framework directly? Yes (bubbletea/bubbles) +- Single data model? Yes (UI model augmented with envs list) +- Avoiding patterns? Yes (no extra abstraction) **Architecture**: -- Feature shipped within existing app; no new library required -- Libraries listed: bubbletea/bubbles (UI), lipgloss (styles) -- CLI per library: N/A -- Library docs: N/A + +- Feature shipped within existing app; no new library required +- Libraries listed: bubbletea/bubbles (UI), lipgloss (styles) +- CLI per library: N/A +- Library docs: N/A **Testing (NON-NEGOTIABLE)**: -- RED-GREEN planned for UI message handling and rendering helpers -- Order: contract (messages) β†’ integration (model update/render) β†’ unit (helpers) -- Real deps: yes (bubbletea) + +- RED-GREEN planned for UI message handling and rendering helpers +- Order: contract (messages) β†’ integration (model update/render) β†’ unit (helpers) +- Real deps: yes (bubbletea) **Observability**: -- Use existing structured logging if present; avoid logging secrets + +- Use existing structured logging if present; avoid logging secrets **Versioning**: -- Folded into current feature branch; no API breakage + +- Folded into current feature branch; no API breakage ## Project Structure ### Documentation (this feature) + ``` specs/001-a-project-manager/ β”œβ”€β”€ plan.md # This file (/plan command output) @@ -79,6 +89,7 @@ specs/001-a-project-manager/ ``` ### Source Code (repository root) + ``` # Single project internal/adapters/handlers # TUI handlers (update/view) @@ -88,64 +99,74 @@ pkg/ui # UI components (lists, columns, styles) **Structure Decision**: Option 1 (single project) ## Phase 0: Outline & Research + 1. Unknowns identified and resolved - - Layout approach: split view with projects list (left) and environments list (right) - - Component choice: bubbles list or table; choose list for environments for simplicity - - State updates: on project selection change, derive envs for selected project - - Sizing behavior: responsive split using terminal width; collapse envs when width too small + - Layout approach: split view with projects list (left) and environments list (right) + - Component choice: bubbles list or table; choose list for environments for simplicity + - State updates: on project selection change, derive envs for selected project + - Sizing behavior: responsive split using terminal width; collapse envs when width too small 2. Best practices - - Use tea.Msg for selection changes; keep rendering stateless from model - - Avoid heavy computation in View; precompute styles + - Use tea.Msg for selection changes; keep rendering stateless from model + - Avoid heavy computation in View; precompute styles 3. Output: research.md with decisions and rationale **Output**: research.md with all TUI unknowns resolved ## Phase 1: Design & Contracts + 1. Entities β†’ `data-model.md` - - Project(name, environments[]string) - - UI Model: selectedProjectIdx int, projects []Project, envsForSelected []string, width/height, styles - - Messages: ProjectsLoadedMsg, ProjectSelectedMsg, WindowSizeMsg + - Project(name, environments[]string) + - UI Model: selectedProjectIdx int, projects []Project, envsForSelected []string, width/height, styles + - Messages: ProjectsLoadedMsg, ProjectSelectedMsg, WindowSizeMsg 2. Contracts β†’ `/contracts/` - - Message/event contracts for selection and rendering + - Message/event contracts for selection and rendering 3. Contract tests (planned) - - Ensure envs list updates when selection changes - - Ensure collapse behavior under narrow width + - Ensure envs list updates when selection changes + - Ensure collapse behavior under narrow width 4. Test scenarios - - Validate user can see environments of selected project; switching selection updates list + - Validate user can see environments of selected project; switching selection updates list 5. Agent file: N/A -**Output**: data-model.md, /contracts/*, quickstart.md +**Output**: data-model.md, /contracts/\*, quickstart.md ## Phase 2: Task Planning Approach + **Task Generation Strategy**: -- Derive tasks from data-model and contracts: add model fields, implement Update cases, implement View split, add components in pkg/ui + +- Derive tasks from data-model and contracts: add model fields, implement Update cases, implement View split, add components in pkg/ui **Ordering Strategy**: -- TDD: write contract tests for message handling before implementation -- Dependency: model before view; styles last -- Parallel: style constants and component skeletons + +- TDD: write contract tests for message handling before implementation +- Dependency: model before view; styles last +- Parallel: style constants and component skeletons **Estimated Output**: Will be produced by /tasks ## Complexity Tracking + | Violation | Why Needed | Simpler Alternative Rejected Because | -|-----------|------------|-------------------------------------| -| β€” | β€” | β€” | +| --------- | ---------- | ------------------------------------ | +| β€” | β€” | β€” | ## Progress Tracking + **Phase Status**: -- [x] Phase 0: Research complete (/plan command) -- [x] Phase 1: Design complete (/plan command) -- [x] Phase 2: Task planning complete (/plan command - describe approach only) -- [ ] Phase 3: Tasks generated (/tasks command) -- [ ] Phase 4: Implementation complete -- [ ] Phase 5: Validation passed + +- [x] Phase 0: Research complete (/plan command) +- [x] Phase 1: Design complete (/plan command) +- [x] Phase 2: Task planning complete (/plan command - describe approach only) +- [ ] Phase 3: Tasks generated (/tasks command) +- [ ] Phase 4: Implementation complete +- [ ] Phase 5: Validation passed **Gate Status**: -- [x] Initial Constitution Check: PASS -- [x] Post-Design Constitution Check: PASS -- [x] All NEEDS CLARIFICATION resolved (for this TUI scope) -- [ ] Complexity deviations documented (N/A) + +- [x] Initial Constitution Check: PASS +- [x] Post-Design Constitution Check: PASS +- [x] All NEEDS CLARIFICATION resolved (for this TUI scope) +- [ ] Complexity deviations documented (N/A) --- -*Based on Constitution v2.1.1 - See `/memory/constitution.md`* \ No newline at end of file + +_Based on Constitution v2.1.1 - See `/memory/constitution.md`_ diff --git a/cmd/cli/edit/edit.go b/cmd/cli/edit/edit.go index dda8c15..cbc2423 100644 --- a/cmd/cli/edit/edit.go +++ b/cmd/cli/edit/edit.go @@ -3,27 +3,24 @@ package edit import ( "fmt" + "github.com/spf13/cobra" + "github.com/jlrosende/project-manager/internal/adapters/repositories" "github.com/jlrosende/project-manager/internal/core/services" - "github.com/spf13/cobra" ) -var ( - EditCmd = &cobra.Command{ - Use: "edit ", - Short: "edit project", - Long: `Edit all projects`, - Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - RunE: edit, - } -) +var EditCmd = &cobra.Command{ + Use: "edit ", + Short: "edit project", + Long: `Edit all projects`, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + RunE: edit, +} func init() { - } -func edit(cmd *cobra.Command, args []string) error { - +func edit(cmd *cobra.Command, _ []string) error { repoProject, err := repositories.NewProjectRepository() if err != nil { return err @@ -35,7 +32,6 @@ func edit(cmd *cobra.Command, args []string) error { } repoGitConfig, err := repositories.NewGitRepository() - if err != nil { return err } @@ -50,14 +46,16 @@ func edit(cmd *cobra.Command, args []string) error { if err != nil { return err } - for i, p := range projects { + for i, p := range projects { fmt.Fprintln(cmd.OutOrStderr(), "---") fmt.Fprintf(cmd.OutOrStderr(), "Name: %s\n", p.Name) fmt.Fprintf(cmd.OutOrStderr(), "Description: %s\n", p.Description) + if len(projects)-1 == i { fmt.Fprintln(cmd.OutOrStderr(), "---") } } + return nil } diff --git a/cmd/cli/init/init.go b/cmd/cli/init/init.go index e667696..bdd19ff 100644 --- a/cmd/cli/init/init.go +++ b/cmd/cli/init/init.go @@ -18,12 +18,10 @@ var InitCmd = &cobra.Command{ } func init() { - } -func initCommand(cmd *cobra.Command, args []string) error { - // TODO init pm config file and check requirements - +func initCommand(_ *cobra.Command, _ []string) error { + // NOTE: init pm config file and check requirements slog.Debug(viper.GetViper().ConfigFileUsed()) slog.Debug(filepath.Dir(viper.GetViper().ConfigFileUsed())) slog.Debug(fmt.Sprintf("%+v", viper.AllSettings())) diff --git a/cmd/cli/list/list.go b/cmd/cli/list/list.go index ab7f23b..ec891ef 100644 --- a/cmd/cli/list/list.go +++ b/cmd/cli/list/list.go @@ -3,20 +3,19 @@ package list import ( "fmt" + "github.com/spf13/cobra" + "github.com/jlrosende/project-manager/internal/adapters/repositories" "github.com/jlrosende/project-manager/internal/core/services" - "github.com/spf13/cobra" ) -var ( - ListCmd = &cobra.Command{ - Use: "list ", - Short: "list projects", - Long: `List all projects`, - Args: cobra.MatchAll(cobra.RangeArgs(0, 1), cobra.OnlyValidArgs), - RunE: list, - } -) +var ListCmd = &cobra.Command{ + Use: "list ", + Short: "list projects", + Long: `List all projects`, + Args: cobra.MatchAll(cobra.RangeArgs(0, 1), cobra.OnlyValidArgs), + RunE: list, +} func init() { ListCmd.Flags().String("format", "", "output format") @@ -30,16 +29,15 @@ func init() { ListCmd.Flags().StringToString("env-vars", nil, "List of ENV_VARS to add to the environment") - ListCmd.Flags().String("shell", "", "Shell of the project, need be installed in the system) (default to $SHELL env var))") - - // if err := ListCmd.MarkFlagRequired("name"); err != nil { - // log.Fatal(err) - // } + ListCmd.Flags(). + String("shell", "", "Shell of the project, need be installed in the system) (default to $SHELL env var))") + // if err := ListCmd.MarkFlagRequired("name"); err != nil { + // log.Fatal(err) + // } } -func list(cmd *cobra.Command, args []string) error { - +func list(cmd *cobra.Command, _ []string) error { repoProject, err := repositories.NewProjectRepository() if err != nil { return err @@ -51,7 +49,6 @@ func list(cmd *cobra.Command, args []string) error { } repoGitConfig, err := repositories.NewGitRepository() - if err != nil { return err } @@ -66,14 +63,16 @@ func list(cmd *cobra.Command, args []string) error { if err != nil { return err } - for i, p := range projects { + for i, p := range projects { fmt.Fprintln(cmd.OutOrStderr(), "---") fmt.Fprintf(cmd.OutOrStderr(), "Name: %s\n", p.Name) fmt.Fprintf(cmd.OutOrStderr(), "Description: %s\n", p.Description) + if len(projects)-1 == i { fmt.Fprintln(cmd.OutOrStderr(), "---") } } + return nil } diff --git a/cmd/cli/new/new.go b/cmd/cli/new/new.go index e3a55be..487aa78 100644 --- a/cmd/cli/new/new.go +++ b/cmd/cli/new/new.go @@ -1,21 +1,20 @@ -package new +package newcmd import ( + "github.com/spf13/cobra" + "github.com/jlrosende/project-manager/internal/adapters/repositories" "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/services" - "github.com/spf13/cobra" ) -var ( - NewCmd = &cobra.Command{ - Use: "new []", - Short: "Create new project", - Long: `Create a new project and all the basic configuration files`, - Args: cobra.MatchAll(cobra.RangeArgs(1, 2), cobra.OnlyValidArgs), - RunE: new, - } -) +var NewCmd = &cobra.Command{ + Use: "new []", + Short: "Create new project", + Long: `Create a new project and all the basic configuration files`, + Args: cobra.MatchAll(cobra.RangeArgs(1, 2), cobra.OnlyValidArgs), + RunE: run, +} func init() { NewCmd.Flags().String("subproject", "", "Set this new project as subproject") @@ -29,28 +28,25 @@ func init() { NewCmd.Flags().StringToString("env-vars", nil, "List of ENV_VARS to add to the environment") - NewCmd.Flags().String("shell", "", "Shell of the project, need be installed in the system) (default to $SHELL env var))") - - // if err := NewCmd.MarkFlagRequired("name"); err != nil { - // log.Fatal(err) - // } + NewCmd.Flags(). + String("shell", "", "Shell of the project, need be installed in the system) (default to $SHELL env var))") + // if err := NewCmd.MarkFlagRequired("name"); err != nil { + // log.Fatal(err) + // } } -func new(cmd *cobra.Command, args []string) error { - +func run(cmd *cobra.Command, args []string) error { // ask for a name if not set name := args[0] path := "" subproject, err := cmd.Flags().GetString("subproject") - if err != nil { return err } envVars, err := cmd.Flags().GetStringToString("env-vars") - if err != nil { return err } @@ -70,7 +66,6 @@ func new(cmd *cobra.Command, args []string) error { } repoGitConfig, err := repositories.NewGitRepository() - if err != nil { return err } @@ -78,25 +73,21 @@ func new(cmd *cobra.Command, args []string) error { svc := services.NewProjectService(repoProject, repoEnvVars, repoGitConfig) gitUserName, err := cmd.Flags().GetString("user.name") - if err != nil { return err } gitUserEmail, err := cmd.Flags().GetString("user.email") - if err != nil { return err } gitUserSigningKey, err := cmd.Flags().GetString("user.signingkey") - if err != nil { return err } gitCommitGPGsign, err := cmd.Flags().GetBool("commit.gpgsign") - if err != nil { return err } @@ -114,7 +105,6 @@ func new(cmd *cobra.Command, args []string) error { domain.WithTagSign(gitCommitGPGsign), ), ) - if err != nil { return err } diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 753a871..ee09a54 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -7,12 +7,12 @@ import ( "os" tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" cmdEdit "github.com/jlrosende/project-manager/cmd/cli/edit" cmdInit "github.com/jlrosende/project-manager/cmd/cli/init" cmdList "github.com/jlrosende/project-manager/cmd/cli/list" cmdNew "github.com/jlrosende/project-manager/cmd/cli/new" - "github.com/jlrosende/project-manager/internal" "github.com/jlrosende/project-manager/internal/adapters/handlers/tui" "github.com/jlrosende/project-manager/internal/adapters/repositories" @@ -20,48 +20,43 @@ import ( "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/services" "github.com/jlrosende/project-manager/internal/logger" - "github.com/spf13/cobra" ) -var ( - rootCmd = &cobra.Command{ - Use: "pm [project] [env|[path]] ", - Short: "pm is a tool to create and organize projects in your computer", - Long: `A tool to manage the configuration and estructure of multiple projects inside your computer`, - Version: internal.GetVersion(), - Args: cobra.MaximumNArgs(3), - SilenceUsage: true, - RunE: root, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - logLevel, err := cmd.PersistentFlags().GetString("log-level") - if err != nil { - return err - } - logFile, err := cmd.PersistentFlags().GetString("log-file") - - if err != nil { - return err - } - - _, err = logger.Setup(logLevel, logFile) - if err != nil { - return err - } +var rootCmd = &cobra.Command{ + Use: "pm [project] [env|[path]] ", + Short: "pm is a tool to create and organize projects in your computer", + Long: `A tool to manage the configuration and estructure of multiple projects inside your computer`, + Version: internal.GetVersion(), + Args: cobra.MaximumNArgs(3), + SilenceUsage: true, + RunE: root, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + logLevel, err := cmd.PersistentFlags().GetString("log-level") + if err != nil { + return err + } + logFile, err := cmd.PersistentFlags().GetString("log-file") + if err != nil { + return err + } - return nil - }, - ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { - - if len(args) >= 1 { - return nil, cobra.ShellCompDirectiveNoFileComp - } - return projectsList(), cobra.ShellCompDirectiveNoFileComp - }, - } -) + _, err = logger.Setup(logLevel, logFile) + if err != nil { + return err + } -func init() { + return nil + }, + ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return projectsList(), cobra.ShellCompDirectiveNoFileComp + }, +} +func init() { rootCmd.Flags().BoolP("list", "l", false, "List all the projects.") rootCmd.PersistentFlags().String("log-level", "info", "Change the log level (debug, info, warn, error)") @@ -71,12 +66,11 @@ func init() { rootCmd.AddCommand(cmdNew.NewCmd) rootCmd.AddCommand(cmdList.ListCmd) rootCmd.AddCommand(cmdEdit.EditCmd) - } func Execute() { if err := rootCmd.Execute(); err != nil { - slog.Error("somethin wrong happend", slog.Any("err", err)) + slog.Error("somethin wrong happened", slog.Any("err", err)) os.Exit(1) } } @@ -85,7 +79,6 @@ func Execute() { func Root() *cobra.Command { return rootCmd } func root(cmd *cobra.Command, args []string) error { - repoProject, err := repositories.NewProjectRepository() if err != nil { return err @@ -97,16 +90,16 @@ func root(cmd *cobra.Command, args []string) error { } repoGitConfig, err := repositories.NewGitRepository() - if err != nil { return err } svc := services.NewProjectService(repoProject, repoEnvVars, repoGitConfig) - // TODO Signal the wating pm process to kill session shell + // NOTE: signal the waiting pm process to kill session shell if projet, ok := os.LookupEnv("PM_ACTIVE_PROJECT"); ok { fmt.Fprintf(cmd.OutOrStderr(), "Already runnign pm, active project: %s\n", projet) + return nil } @@ -141,14 +134,19 @@ func root(cmd *cobra.Command, args []string) error { if err != nil { return err } + p := tea.NewProgram(window, tea.WithAltScreen()) + m, err := p.Run() if err != nil { return err } - var selected *domain.Project - var selEnv string + var ( + selected *domain.Project + selEnv string + ) + if w, ok := m.(*tui.Window); ok { selected = w.SelectedProject() selEnv = w.SelectedEnvironment() @@ -157,6 +155,7 @@ func root(cmd *cobra.Command, args []string) error { if selected == nil { return nil } + project = selected if selEnv != "" { env = selEnv @@ -165,24 +164,23 @@ func root(cmd *cobra.Command, args []string) error { } } else { project, err = svc.Load(name) - if err != nil { return err } + if env == "" && project.DefaultEnv != "" { env = project.DefaultEnv } } shellRepo, err := shells.NewPseudoShellRepository(project, env, path) - if err != nil { return err } + shellSvc := services.NewShellService(shellRepo) process, err := shellSvc.Start() - if err != nil { return err } diff --git a/configs/config.go b/configs/config.go index 8abafe1..68ff85e 100644 --- a/configs/config.go +++ b/configs/config.go @@ -17,9 +17,7 @@ import ( //go:embed config.default.hcl var defaultConfig []byte -var ( - ErrConfigNotFound = errors.New("config file not found") -) +var ErrConfigNotFound = errors.New("config file not found") type Config struct { Theme string `mapstructure:"theme"` @@ -43,17 +41,15 @@ type Environment struct { } func GetConfig(cfgFile string) (*Config, error) { - v, err := LoadConfig(cfgFile) - if err != nil { return nil, err } config, err := ParseConfig(v) - if err != nil { slog.Debug("unable to parse config", slog.Any("err", err)) + return nil, err } @@ -67,9 +63,7 @@ func LoadConfig(cfgFile string) (*viper.Viper, error) { // Use config file from the flag. v.SetConfigFile(cfgFile) } else { - config, err := os.UserConfigDir() - if err != nil { return nil, err } @@ -84,11 +78,14 @@ func LoadConfig(cfgFile string) (*viper.Viper, error) { err := v.ReadInConfig() if err != nil { slog.Debug(fmt.Sprintf("Unable to read config: %v", err)) + if _, ok := err.(viper.ConfigFileNotFoundError); ok { return DefaultConfig() } + return nil, err } + return v, nil } @@ -102,7 +99,6 @@ func ParseConfig(v *viper.Viper) (*Config, error) { )) err := v.Unmarshal(&cfg, configOption) - if err != nil { return nil, fmt.Errorf("unable to parse config: %w", err) } @@ -114,7 +110,6 @@ func DefaultConfig() (*viper.Viper, error) { v := viper.New() err := v.ReadConfig(bytes.NewReader(defaultConfig)) - if err != nil { return nil, err } @@ -122,29 +117,35 @@ func DefaultConfig() (*viper.Viper, error) { return v, nil } -// sliceOfMapsToMapHookFunc merges a slice of maps to a map +// sliceOfMapsToMapHookFunc merges a slice of maps to a map. func sliceOfMapsToMapHookFunc() mapstructure.DecodeHookFunc { - return func(from reflect.Type, to reflect.Type, data interface{}) (interface{}, error) { - if from.Kind() == reflect.Slice && from.Elem().Kind() == reflect.Map && (to.Kind() == reflect.Struct || to.Kind() == reflect.Map) { + return func(from, to reflect.Type, data interface{}) (interface{}, error) { + if from.Kind() == reflect.Slice && from.Elem().Kind() == reflect.Map && + (to.Kind() == reflect.Struct || to.Kind() == reflect.Map) { source, ok := data.([]map[string]interface{}) if !ok { return data, nil } + if len(source) == 0 { return data, nil } + if len(source) == 1 { return source[0], nil } // flatten the slice into one map convert := make(map[string]interface{}) + for _, mapItem := range source { for key, value := range mapItem { convert[key] = value } } + return convert, nil } + return data, nil } } diff --git a/internal/adapters/handlers/tui/window_core.go b/internal/adapters/handlers/tui/window_core.go index 5804345..5ef2e51 100644 --- a/internal/adapters/handlers/tui/window_core.go +++ b/internal/adapters/handlers/tui/window_core.go @@ -2,6 +2,7 @@ package tui import ( tea "github.com/charmbracelet/bubbletea" + "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/services" ) @@ -12,17 +13,17 @@ type Window struct { projects []*domain.Project selectedProject *domain.Project - cursor int - cursorEnv int - focus int - total int - width int - height int - shouldQuit bool - mode int - form *NewProjectForm - prompt *postCreatePrompt - envForm *NewEnvironmentForm + cursor int + cursorEnv int + focus int + total int + width int + height int + shouldQuit bool + mode int + form *NewProjectForm + prompt *postCreatePrompt + envForm *NewEnvironmentForm envProjectName string } @@ -31,10 +32,12 @@ func NewWindow(projectSvc *services.ProjectService) (*Window, error) { if err != nil { return nil, err } + total := len(projects) + 1 if total == 0 { total = 1 } + return &Window{ projectSvc: projectSvc, projects: projects, @@ -51,20 +54,23 @@ func (m *Window) SelectedEnvironment() string { if len(m.projects) == 0 { return "" } + idx := mod(m.cursor, m.total) if idx < 0 || idx >= len(m.projects) { return "" } + p := m.projects[idx] if len(p.Environments) == 0 { return "" } + e := m.cursorEnv % len(p.Environments) if e < 0 { e = 0 } + return p.Environments[e].Name } func mod(a, b int) int { return (a%b + b) % b } - diff --git a/internal/adapters/handlers/tui/window_env_form.go b/internal/adapters/handlers/tui/window_env_form.go index 6c459c8..decf6cf 100644 --- a/internal/adapters/handlers/tui/window_env_form.go +++ b/internal/adapters/handlers/tui/window_env_form.go @@ -7,18 +7,36 @@ import ( bti "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/jlrosende/project-manager/internal/core/domain" ) +const ( + keyEsc = "esc" + keyCtrlC = "ctrl+c" + keyTab = "tab" + keyShiftTab = "shift+tab" + keyEnter = "enter" + keyUp = "up" + keyDown = "down" + keyLeft = "left" + keyRight = "right" + keyCtrlS = "ctrl+s" + errNameRequired = "name is required" + errModeMustBeVal = "mode must be merge or replace" +) + type NewEnvironmentForm struct { - name bti.Model - color bti.Model - mode bti.Model - envVars bta.Model - focused int - submitted bool - canceled bool - err string + name bti.Model + color bti.Model + mode bti.Model + envVars bta.Model + focused int + submitted bool + canceled bool + err string + isEdit bool + originalName string } func NewEnvironmentFormModel() *NewEnvironmentForm { @@ -28,7 +46,9 @@ func NewEnvironmentFormModel() *NewEnvironmentForm { name.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) name.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) name.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + name.Width = 60 name.Focus() + color := bti.New() color.Prompt = "Color (name/#hex/0-255): " color.Placeholder = "teal" @@ -36,123 +56,168 @@ func NewEnvironmentFormModel() *NewEnvironmentForm { color.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) color.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) color.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + color.Width = 60 mode := bti.New() mode.Prompt = "Env vars mode* (merge/replace): " - mode.Placeholder = domain.ENV_VARS_MODE_MERGE - mode.SetValue(domain.ENV_VARS_MODE_MERGE) + mode.Placeholder = domain.EnvVarsModeMerge + mode.SetValue(domain.EnvVarsModeMerge) mode.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) mode.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) mode.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + mode.Width = 60 env := bta.New() env.Placeholder = "# One per line (like .env)\nAPI_URL=https://api.example.com\nLOG_LEVEL=info\n# comments allowed" env.SetHeight(6) + env.SetWidth(70) + return &NewEnvironmentForm{name: name, color: color, mode: mode, envVars: env} } +func NewEnvironmentEditFormModel(e *domain.Environment) *NewEnvironmentForm { + f := NewEnvironmentFormModel() + f.isEdit = true + f.originalName = e.Name + f.name.SetValue(e.Name) + f.color.SetValue(e.Color) + f.mode.SetValue(e.EnvVarsMode) + return f +} + func (f *NewEnvironmentForm) Init() tea.Cmd { return bti.Blink } func (f *NewEnvironmentForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch m := msg.(type) { - case tea.KeyMsg: + if wm, ok := msg.(tea.WindowSizeMsg); ok { + w := wm.Width - 10 + if w < 30 { + w = 30 + } + f.name.Width = w + f.color.Width = w + f.mode.Width = w + f.envVars.SetWidth(w + 10) + } + if m, ok := msg.(tea.KeyMsg); ok { switch m.String() { - case "esc", "ctrl+c": + case keyEsc, keyCtrlC: f.canceled = true f.submitted = false + return f, nil - case "tab", "shift+tab": - if m.String() == "tab" { + case keyTab, keyShiftTab: + if m.String() == keyTab { f.focused = (f.focused + 1) % 6 } else { f.focused = (f.focused + 5) % 6 } + f.blurAll() f.focusCurrent() + return f, nil - case "enter": - if f.focused < 3 { + case keyEnter: + switch { + case f.focused < 3: f.focused = (f.focused + 1) % 6 f.blurAll() f.focusCurrent() return f, nil - } else if f.focused == 4 { + case f.focused == 4: f.err = "" if strings.TrimSpace(f.name.Value()) == "" { - f.err = "name is required" + f.err = errNameRequired return f, nil } - if strings.TrimSpace(f.mode.Value()) != domain.ENV_VARS_MODE_MERGE && strings.TrimSpace(f.mode.Value()) != domain.ENV_VARS_MODE_REPLACE { - f.err = "mode must be merge or replace" + if strings.TrimSpace(f.mode.Value()) != domain.EnvVarsModeMerge && + strings.TrimSpace(f.mode.Value()) != domain.EnvVarsModeReplace { + f.err = errModeMustBeVal return f, nil } f.submitted = true f.canceled = false return f, nil - } else if f.focused == 5 { + case f.focused == 5: f.canceled = true f.submitted = false return f, nil } - case "up": + case keyUp: if f.focused != 3 { f.focused-- if f.focused < 0 { f.focused = 5 } + f.blurAll() f.focusCurrent() + return f, nil } - case "down": + case keyDown: if f.focused != 3 { f.focused++ if f.focused > 5 { f.focused = 0 } + f.blurAll() f.focusCurrent() + return f, nil } - case "left": + case keyLeft: if f.focused == 5 { f.focused = 4 f.blurAll() f.focusCurrent() + return f, nil } - case "right": + case keyRight: if f.focused == 4 { f.focused = 5 f.blurAll() f.focusCurrent() + return f, nil } // when textarea focused, let Enter insert newline - case "ctrl+s": + case keyCtrlS: f.err = "" if strings.TrimSpace(f.name.Value()) == "" { - f.err = "name is required" + f.err = errNameRequired + return f, nil } - if strings.TrimSpace(f.mode.Value()) != domain.ENV_VARS_MODE_MERGE && strings.TrimSpace(f.mode.Value()) != domain.ENV_VARS_MODE_REPLACE { + + if strings.TrimSpace(f.mode.Value()) != domain.EnvVarsModeMerge && + strings.TrimSpace(f.mode.Value()) != domain.EnvVarsModeReplace { f.err = "mode must be merge or replace" + return f, nil } + f.submitted = true f.canceled = false + return f, nil } } + var cmd tea.Cmd + switch f.focused { case 0: f.name, cmd = f.name.Update(msg) case 1: f.color, cmd = f.color.Update(msg) cv := strings.TrimSpace(f.color.Value()) + col := "240" + if isValidColorInput(cv) { col = normalizeColorInput(cv) } + f.color.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(col)) case 2: f.mode, cmd = f.mode.Update(msg) @@ -163,6 +228,7 @@ func (f *NewEnvironmentForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case 5: // cancel button } + return f, cmd } @@ -188,12 +254,22 @@ func (f *NewEnvironmentForm) focusCurrent() { func (f *NewEnvironmentForm) View() string { box := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).Padding(1, 2) - help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Tab switch Enter next/newline Ctrl+S save Esc cancel") + help := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Render("Tab switch Enter next/newline Ctrl+S save Esc cancel") styleSel := lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Padding(0, 1) styleDef := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1) - styleTitle := lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Align(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, true).Padding(0, 1) + styleTitle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("14")). + Align(lipgloss.Center). + Border(lipgloss.NormalBorder(), false, false, true). + Padding(0, 1) b := strings.Builder{} - b.WriteString(styleTitle.Render("New environment")) + title := "New environment" + if f.isEdit { + title = "Edit environment" + } + b.WriteString(styleTitle.Render(title)) b.WriteString("\n") b.WriteString(f.name.View()) b.WriteString("\n") @@ -201,20 +277,34 @@ func (f *NewEnvironmentForm) View() string { b.WriteString("\n") b.WriteString(f.mode.View()) b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Env vars (.env format): one KEY=VALUE per line; '#' comments allowed")) + b.WriteString( + lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Render("Env vars (.env format): one KEY=VALUE per line; '#' comments allowed"), + ) b.WriteString("\n") b.WriteString(f.envVars.View()) b.WriteString("\n") + btnSave := styleDef.Render("Save") - if f.focused == 4 { btnSave = styleSel.Render("Save") } + if f.focused == 4 { + btnSave = styleSel.Render("Save") + } + btnCancel := styleDef.Render("Cancel") - if f.focused == 5 { btnCancel = styleSel.Render("Cancel") } + if f.focused == 5 { + btnCancel = styleSel.Render("Cancel") + } + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, btnSave, " ", btnCancel)) b.WriteString("\n\n") + if f.err != "" { b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(f.err)) b.WriteString("\n") } + b.WriteString(help) + return box.Render(b.String()) } diff --git a/internal/adapters/handlers/tui/window_flow.go b/internal/adapters/handlers/tui/window_flow.go index 445c006..06225f4 100644 --- a/internal/adapters/handlers/tui/window_flow.go +++ b/internal/adapters/handlers/tui/window_flow.go @@ -1,64 +1,94 @@ package tui import ( + "strconv" + "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/jlrosende/project-manager/internal/core/domain" - "strconv" - "strings" ) func parseEnvLines(s string) domain.EnvVars { res := domain.EnvVars{} + for _, line := range strings.Split(s, "\n") { l := strings.TrimSpace(line) if l == "" || strings.HasPrefix(l, "#") { continue } + kv := strings.SplitN(l, "=", 2) if len(kv) == 2 { res[strings.TrimSpace(kv[0])] = kv[1] } } + return res } var colorNameMap = map[string]string{ - "black": "0", - "white": "15", - "red": "196", - "green": "46", - "blue": "21", - "yellow": "226", - "magenta":"201", - "purple": "93", - "cyan": "51", - "teal": "30", - "orange": "208", - "pink": "205", - "grey": "240", - "gray": "240", + "black": "0", + "white": "15", + "red": "196", + "green": "46", + "blue": "21", + "yellow": "226", + "magenta": "201", + "purple": "93", + "cyan": "51", + "teal": "30", + "orange": "208", + "pink": "205", + "grey": "240", + "gray": "240", } func normalizeColorInput(s string) string { ss := strings.ToLower(strings.TrimSpace(s)) - if ss == "" || ss == "grey" || ss == "gray" { return "240" } - if strings.HasPrefix(ss, "#") { return ss } - if v, ok := colorNameMap[ss]; ok { return v } + if ss == "" || ss == "grey" || ss == "gray" { + return "240" + } + + if strings.HasPrefix(ss, "#") { + return ss + } + + if v, ok := colorNameMap[ss]; ok { + return v + } + return ss } func isValidColorInput(s string) bool { ss := strings.ToLower(strings.TrimSpace(s)) - if ss == "" { return false } + if ss == "" { + return false + } + if strings.HasPrefix(ss, "#") { h := ss[1:] - if len(h) != 3 && len(h) != 6 { return false } - if _, err := strconv.ParseUint(h, 16, 64); err != nil { return false } + if len(h) != 3 && len(h) != 6 { + return false + } + + if _, err := strconv.ParseUint(h, 16, 64); err != nil { + return false + } + + return true + } + + if _, ok := colorNameMap[ss]; ok { + return true + } + + if n, err := strconv.Atoi(ss); err == nil && n >= 0 && n <= 255 { return true } - if _, ok := colorNameMap[ss]; ok { return true } - if n, err := strconv.Atoi(ss); err == nil && n >= 0 && n <= 255 { return true } + return false } @@ -88,34 +118,42 @@ func (p *postCreatePrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m := msg.(type) { case tea.KeyMsg: switch m.String() { - case "up", "k": + case keyUp, "k": if p.idx > 0 { p.idx-- } - case "down", "j": + case keyDown, "j": if p.idx < 2 { p.idx++ } - case "enter": + case keyEnter: p.choice = p.idx + return p, tea.Quit - case "esc", "q", "ctrl+c": + case keyEsc, "q", keyCtrlC: p.choice = 0 + return p, tea.Quit } case tea.WindowSizeMsg: return p, nil } + return p, nil } func (p *postCreatePrompt) View() string { - styleTitle := lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Align(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, true).Padding(0, 1) + styleTitle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("14")). + Align(lipgloss.Center). + Border(lipgloss.NormalBorder(), false, false, true). + Padding(0, 1) styleSel := lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Padding(0, 1) styleDef := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1) b := strings.Builder{} b.WriteString(styleTitle.Render("Project created. What next?")) b.WriteString("\n") + opts := []string{"Return to list", "Start this project", "Exit"} for i, o := range opts { if i == p.idx { @@ -125,8 +163,13 @@ func (p *postCreatePrompt) View() string { b.WriteString(" ") b.WriteString(styleDef.Render(o)) } + b.WriteString("\n") } - help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Keys: ↑/k ↓/j navigate Enter select Esc cancel") + + help := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Render("Keys: ↑/k ↓/j navigate Enter select Esc cancel") + return lipgloss.JoinVertical(lipgloss.Left, b.String(), help) } diff --git a/internal/adapters/handlers/tui/window_form.go b/internal/adapters/handlers/tui/window_form.go index 00eeb64..1a9cdac 100644 --- a/internal/adapters/handlers/tui/window_form.go +++ b/internal/adapters/handlers/tui/window_form.go @@ -11,25 +11,36 @@ import ( bti "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + + "github.com/jlrosende/project-manager/internal/core/domain" +) + +const ( + strTrue = "true" + strFalse = "false" + errPathRequired = "path is required" ) type NewProjectForm struct { - name bti.Model - path bti.Model - subproject bti.Model - userName bti.Model - userEmail bti.Model + name bti.Model + path bti.Model + subproject bti.Model + shell bti.Model + userName bti.Model + userEmail bti.Model userSigningKey bti.Model - commitGPGSign bti.Model - tagGPGSign bti.Model - envVars bta.Model - focused int - width int - height int - pathDirty bool - submitted bool - canceled bool - err string + commitGPGSign bti.Model + tagGPGSign bti.Model + envVars bta.Model + focused int + width int + height int + pathDirty bool + submitted bool + canceled bool + err string + isEdit bool + originalName string } func NewProjectFormModel() *NewProjectForm { @@ -43,6 +54,7 @@ func NewProjectFormModel() *NewProjectForm { ni.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) ni.Width = 40 ni.Focus() + pi := bti.New() pi.Prompt = sc.Render("Path") + sr.Render("*") + sc.Render(": ") pi.Placeholder = "~/my-awesome-app" @@ -80,25 +92,45 @@ func NewProjectFormModel() *NewProjectForm { usk.Width = 40 csg := bti.New() csg.Prompt = "commit.gpgsign (true/false): " - csg.Placeholder = "true" - csg.SetValue("true") + csg.Placeholder = strTrue + csg.SetValue(strTrue) csg.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) csg.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) csg.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) csg.Width = 40 tsg := bti.New() tsg.Prompt = "tag.gpgsign (true/false): " - tsg.Placeholder = "true" - tsg.SetValue("true") + tsg.Placeholder = strTrue + tsg.SetValue(strTrue) tsg.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) tsg.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) tsg.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) tsg.Width = 40 + sh := bti.New() + sh.Prompt = "Shell: " + sh.Placeholder = "/bin/bash" + sh.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + sh.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + sh.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + sh.Width = 40 ev := bta.New() ev.Placeholder = "# One per line (like .env)\nAPP_ENV=development\nDATABASE_URL=postgres://user:pass@localhost:5432/app\n# comments allowed" ev.SetHeight(6) ev.SetWidth(60) - return &NewProjectForm{name: ni, path: pi, subproject: spi, userName: un, userEmail: uem, userSigningKey: usk, commitGPGSign: csg, tagGPGSign: tsg, envVars: ev, focused: 0} + + return &NewProjectForm{ + name: ni, + path: pi, + subproject: spi, + shell: sh, + userName: un, + userEmail: uem, + userSigningKey: usk, + commitGPGSign: csg, + tagGPGSign: tsg, + envVars: ev, + focused: 0, + } } func (f *NewProjectForm) Init() tea.Cmd { return bti.Blink } @@ -108,11 +140,16 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: f.width = msg.Width f.height = msg.Height + w := msg.Width - 10 - if w < 20 { w = 20 } + if w < 20 { + w = 20 + } + f.name.Width = w f.path.Width = w f.subproject.Width = w + f.shell.Width = w f.userName.Width = w f.userEmail.Width = w f.userSigningKey.Width = w @@ -124,169 +161,226 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "esc", "ctrl+c": f.canceled = true f.submitted = false + return f, nil - case "tab", "shift+tab": - if msg.String() == "tab" { + case keyTab, keyShiftTab: + if msg.String() == keyTab { f.focused++ - if f.focused > 10 { + if f.focused > 11 { f.focused = 0 } } else { f.focused-- if f.focused < 0 { - f.focused = 10 + f.focused = 11 } } + if f.isEdit && f.focused == 1 { + if msg.String() == "tab" { + f.focused++ + } else { + f.focused-- + } + } + f.blurAll() f.focusCurrent() + return f, nil case "enter": - if f.focused < 8 { + switch { + case f.focused < 9: + if f.isEdit && f.focused == 1 { + f.focused++ + } f.focused++ f.blurAll() f.focusCurrent() return f, nil - } else if f.focused == 9 { + case f.focused == 10: f.err = "" if strings.TrimSpace(f.name.Value()) == "" { f.err = "name is required" return f, nil } - if strings.TrimSpace(f.path.Value()) == "" { - f.err = "path is required" - return f, nil - } - if ok, reason := canCreatePath(f.path.Value()); !ok { - f.err = reason - return f, nil + if !f.isEdit { + if strings.TrimSpace(f.path.Value()) == "" { + f.err = errPathRequired + return f, nil + } + if ok, reason := canCreatePath(f.path.Value()); !ok { + f.err = reason + return f, nil + } } cg := strings.ToLower(strings.TrimSpace(f.commitGPGSign.Value())) - if cg != "" && cg != "true" && cg != "false" { + if cg != "" && cg != strTrue && cg != strFalse { f.err = "commit.gpgsign must be true or false" return f, nil } tg := strings.ToLower(strings.TrimSpace(f.tagGPGSign.Value())) - if tg != "" && tg != "true" && tg != "false" { + if tg != "" && tg != strTrue && tg != strFalse { f.err = "tag.gpgsign must be true or false" return f, nil } f.submitted = true f.canceled = false return f, nil - } else if f.focused == 10 { + case f.focused == 11: f.canceled = true f.submitted = false return f, nil } case "up": - if f.focused != 8 { + if f.focused != 9 { f.focused-- + if f.isEdit && f.focused == 1 { + f.focused-- + } if f.focused < 0 { - f.focused = 10 + f.focused = 11 } + f.blurAll() f.focusCurrent() + return f, nil } case "down": - if f.focused != 8 { + if f.focused != 9 { f.focused++ - if f.focused > 10 { + if f.isEdit && f.focused == 1 { + f.focused++ + } + if f.focused > 11 { f.focused = 0 } + f.blurAll() f.focusCurrent() + return f, nil } case "left": - if f.focused == 10 { - f.focused = 9 + if f.focused == 11 { + f.focused = 10 f.blurAll() f.focusCurrent() + return f, nil } case "right": - if f.focused == 9 { - f.focused = 10 + if f.focused == 10 { + f.focused = 11 f.blurAll() f.focusCurrent() + return f, nil } case "ctrl+s": f.err = "" if strings.TrimSpace(f.name.Value()) == "" { f.err = "name is required" + return f, nil } - if strings.TrimSpace(f.path.Value()) == "" { - f.err = "path is required" - return f, nil - } - if ok, reason := canCreatePath(f.path.Value()); !ok { - f.err = reason - return f, nil + + if !f.isEdit { + if strings.TrimSpace(f.path.Value()) == "" { + f.err = errPathRequired + + return f, nil + } + + if ok, reason := canCreatePath(f.path.Value()); !ok { + f.err = reason + + return f, nil + } } + cg := strings.ToLower(strings.TrimSpace(f.commitGPGSign.Value())) - if cg != "" && cg != "true" && cg != "false" { + if cg != "" && cg != strTrue && cg != strFalse { f.err = "commit.gpgsign must be true or false" + return f, nil } + tg := strings.ToLower(strings.TrimSpace(f.tagGPGSign.Value())) if tg != "" && tg != "true" && tg != "false" { f.err = "tag.gpgsign must be true or false" + return f, nil } + f.submitted = true f.canceled = false + return f, nil } } + var cmd tea.Cmd + switch f.focused { case 0: oldName := f.name.Value() + f.name, cmd = f.name.Update(msg) - if f.name.Value() != oldName && !f.pathDirty { + + if f.name.Value() != oldName && !f.pathDirty && !f.isEdit { slug := slugify(strings.TrimSpace(f.name.Value())) if slug != "" { cur := strings.TrimSpace(f.path.Value()) + var newPath string - if cur == "" { + + switch { + case cur == "": newPath = filepath.Join("~/", slug) - } else if strings.HasSuffix(cur, string(os.PathSeparator)) { + case strings.HasSuffix(cur, string(os.PathSeparator)): newPath = cur + slug - } else { + default: dir := filepath.Dir(cur) newPath = filepath.Join(dir, slug) } + f.path.SetValue(newPath) } } case 1: + if f.isEdit { + break + } prev := f.path.Value() + f.path, cmd = f.path.Update(msg) + if f.path.Value() != prev { f.pathDirty = true } case 2: f.subproject, cmd = f.subproject.Update(msg) case 3: - f.userName, cmd = f.userName.Update(msg) + f.shell, cmd = f.shell.Update(msg) case 4: - f.userEmail, cmd = f.userEmail.Update(msg) + f.userName, cmd = f.userName.Update(msg) case 5: - f.userSigningKey, cmd = f.userSigningKey.Update(msg) + f.userEmail, cmd = f.userEmail.Update(msg) case 6: - f.commitGPGSign, cmd = f.commitGPGSign.Update(msg) + f.userSigningKey, cmd = f.userSigningKey.Update(msg) case 7: - f.tagGPGSign, cmd = f.tagGPGSign.Update(msg) + f.commitGPGSign, cmd = f.commitGPGSign.Update(msg) case 8: - f.envVars, cmd = f.envVars.Update(msg) + f.tagGPGSign, cmd = f.tagGPGSign.Update(msg) case 9: - // save button: nothing to update + f.envVars, cmd = f.envVars.Update(msg) case 10: + // save button: nothing to update + case 11: // cancel button: nothing to update } + return f, cmd } @@ -294,6 +388,7 @@ func (f *NewProjectForm) blurAll() { f.name.Blur() f.path.Blur() f.subproject.Blur() + f.shell.Blur() f.userName.Blur() f.userEmail.Blur() f.userSigningKey.Blur() @@ -307,40 +402,60 @@ func (f *NewProjectForm) focusCurrent() { case 0: f.name.Focus() case 1: - f.path.Focus() + if !f.isEdit { + f.path.Focus() + } case 2: f.subproject.Focus() case 3: - f.userName.Focus() + f.shell.Focus() case 4: - f.userEmail.Focus() + f.userName.Focus() case 5: - f.userSigningKey.Focus() + f.userEmail.Focus() case 6: - f.commitGPGSign.Focus() + f.userSigningKey.Focus() case 7: - f.tagGPGSign.Focus() + f.commitGPGSign.Focus() case 8: + f.tagGPGSign.Focus() + case 9: f.envVars.Focus() } } func (f *NewProjectForm) View() string { box := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).Padding(1, 2) - help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Tab/↑/↓ move ←/β†’ buttons Enter next/newline Ctrl+S save Esc cancel") + help := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Render("Tab/↑/↓ move ←/β†’ buttons Enter next/newline Ctrl+S save Esc cancel") errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) styleSel := lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Padding(0, 1) styleDef := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1) - styleTitle := lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Align(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, true).Padding(0, 1) + styleTitle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("14")). + Align(lipgloss.Center). + Border(lipgloss.NormalBorder(), false, false, true). + Padding(0, 1) b := strings.Builder{} - b.WriteString(styleTitle.Render("New project")) + title := "New project" + if f.isEdit { + title = "Edit project" + } + b.WriteString(styleTitle.Render(title)) b.WriteString("\n") b.WriteString(f.name.View()) b.WriteString("\n") - b.WriteString(f.path.View()) + pathView := f.path.View() + if f.isEdit { + pathView = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(f.path.Value() + " (read-only)") + } + b.WriteString(pathView) b.WriteString("\n") b.WriteString(f.subproject.View()) b.WriteString("\n") + b.WriteString(f.shell.View()) + b.WriteString("\n") b.WriteString(f.userName.View()) b.WriteString("\n") b.WriteString(f.userEmail.View()) @@ -351,34 +466,61 @@ func (f *NewProjectForm) View() string { b.WriteString("\n") b.WriteString(f.tagGPGSign.View()) b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Env vars (.env format): one KEY=VALUE per line; '#' comments allowed")) + b.WriteString( + lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Render("Env vars (.env format): one KEY=VALUE per line; '#' comments allowed"), + ) b.WriteString("\n") b.WriteString(f.envVars.View()) b.WriteString("\n") + btnSave := styleDef.Render("Save") - if f.focused == 9 { btnSave = styleSel.Render("Save") } + if f.focused == 10 { + btnSave = styleSel.Render("Save") + } + btnCancel := styleDef.Render("Cancel") - if f.focused == 10 { btnCancel = styleSel.Render("Cancel") } + if f.focused == 11 { + btnCancel = styleSel.Render("Cancel") + } + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, btnSave, " ", btnCancel)) b.WriteString("\n\n") + if f.err != "" { b.WriteString(errStyle.Render(f.err)) b.WriteString("\n") } + b.WriteString(help) + return box.Render(b.String()) } +func NewProjectEditFormModel(p *domain.Project) *NewProjectForm { + f := NewProjectFormModel() + f.isEdit = true + f.originalName = p.Name + f.name.SetValue(p.Name) + f.path.SetValue(p.Path) + f.shell.SetValue(p.Shell) + return f +} + func isDirEmpty(path string) (bool, error) { f, err := os.Open(path) if err != nil { return false, err } + defer f.Close() + _, err = f.Readdir(1) if err == io.EOF { return true, nil } + return false, err } @@ -387,12 +529,14 @@ func slugify(s string) string { if s == "" { return "" } + s = strings.ReplaceAll(s, " ", "-") re := regexp.MustCompile(`[^a-z0-9-_]+`) s = re.ReplaceAllString(s, "-") re2 := regexp.MustCompile(`-+`) s = re2.ReplaceAllString(s, "-") s = strings.Trim(s, "-_") + return s } @@ -401,28 +545,36 @@ func canCreatePath(p string) (bool, string) { if pp == "" { return false, "path is required" } + if strings.HasPrefix(pp, "~/") { h, _ := os.UserHomeDir() pp = filepath.Join(h, pp[2:]) } + info, err := os.Stat(pp) if err == nil { if !info.IsDir() { return false, "path exists and is not a directory" } + empty, e := isDirEmpty(pp) if e != nil { return false, e.Error() } + if !empty { return false, "directory is not empty" } + return true, "" } + parent := filepath.Dir(pp) + pi, perr := os.Stat(parent) if perr != nil || !pi.IsDir() { return false, "parent directory does not exist" } + return true, "" } diff --git a/internal/adapters/handlers/tui/window_update.go b/internal/adapters/handlers/tui/window_update.go index e76b6c9..3874ecc 100644 --- a/internal/adapters/handlers/tui/window_update.go +++ b/internal/adapters/handlers/tui/window_update.go @@ -1,16 +1,26 @@ package tui import ( + "fmt" + "os" + "path/filepath" + "sort" "strings" + tea "github.com/charmbracelet/bubbletea" + + "github.com/jlrosende/project-manager/internal/adapters/repositories" "github.com/jlrosende/project-manager/internal/core/domain" + "github.com/jlrosende/project-manager/internal/core/services" ) func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.shouldQuit { return m, tea.Quit } + var cmd tea.Cmd + if m.mode == 1 && m.form != nil { fm, fcmd := m.form.Update(msg) if f, ok := fm.(*NewProjectForm); ok { @@ -18,20 +28,80 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if f.canceled { m.mode = 0 m.form = nil + return m, tea.ClearScreen } + if f.submitted { name := strings.TrimSpace(f.name.Value()) + path := strings.TrimSpace(f.path.Value()) + if name == "" || path == "" { return m, nil } + envs := domain.EnvVars{} if strings.TrimSpace(f.envVars.Value()) != "" { envs = parseEnvLines(f.envVars.Value()) } - commitSign := strings.ToLower(strings.TrimSpace(f.commitGPGSign.Value())) != "false" + + commitSign := strings.ToLower(strings.TrimSpace(f.commitGPGSign.Value())) != strFalse tagSign := strings.ToLower(strings.TrimSpace(f.tagGPGSign.Value())) != "false" + if f.isEdit { + proj, _ := m.projectSvc.Load(f.originalName) + proj.Name = name + proj.Shell = strings.TrimSpace(f.shell.Value()) + _ = m.projectSvc.UpdateProject(proj) + // Validate but preserve raw content + raw := strings.TrimSpace(f.envVars.Value()) + if raw != "" { + lines := strings.Split(raw, "\n") + ok := true + for _, line := range lines { + l := strings.TrimSpace(line) + if l == "" || strings.HasPrefix(l, "#") { + continue + } + kv := strings.SplitN(l, "=", 2) + if len(kv) != 2 || strings.TrimSpace(kv[0]) == "" { + ok = false + break + } + } + if ok { + projEnvPath := proj.EnvVarsFile + if !filepath.IsAbs(projEnvPath) { + projEnvPath = filepath.Join(proj.Path, projEnvPath) + } + _ = os.WriteFile(projEnvPath, []byte(f.envVars.Value()), 0o644) + } + } + gitRepo, err := repositories.NewGitRepository() + if err == nil { + gitSvc := services.NewGitService(gitRepo) + gitPath := filepath.Join(proj.Path, fmt.Sprintf(".%s.gitconfig", proj.Name)) + gc := &domain.GitConfig{ + User: domain.User{ + Name: strings.TrimSpace(f.userName.Value()), + Email: strings.TrimSpace(f.userEmail.Value()), + SigningKey: strings.TrimSpace(f.userSigningKey.Value()), + }, + Commit: domain.Commit{GPGSign: commitSign}, + Tag: domain.Tag{GPGSign: tagSign}, + } + _ = gitSvc.Save(gitPath, gc) + } + projects, err := m.projectSvc.List() + if err == nil { + m.projects = projects + m.total = len(projects) + 1 + } + m.mode = 0 + m.form = nil + return m, tea.ClearScreen + } + proj, _ := m.projectSvc.Create( name, path, @@ -45,19 +115,28 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { domain.WithTagSign(tagSign), ), ) + if proj != nil { + proj.Shell = strings.TrimSpace(f.shell.Value()) + _ = m.projectSvc.UpdateProject(proj) + } + projects, err := m.projectSvc.List() if err == nil { m.projects = projects m.total = len(projects) + 1 } + m.prompt = newPostCreatePrompt(name) m.mode = 2 _ = proj + return m, tea.ClearScreen } + return m, fcmd } } + if m.mode == 2 && m.prompt != nil { pm, pcmd := m.prompt.Update(msg) if p, ok := pm.(*postCreatePrompt); ok { @@ -65,20 +144,27 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if p.choice == 0 { // return m.mode = 0 m.prompt = nil + return m, tea.ClearScreen } + if p.choice == 1 { // start project proj, _ := m.projectSvc.Load(p.projectName) m.selectedProject = proj + return m, tea.Quit } + if p.choice == 2 { // exit m.selectedProject = nil + return m, tea.Quit } + return m, pcmd } } + if m.mode == 3 && m.envForm != nil { efm, ecmd := m.envForm.Update(msg) if f, ok := efm.(*NewEnvironmentForm); ok { @@ -86,67 +172,190 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if f.canceled { m.mode = 0 m.envForm = nil + return m, tea.ClearScreen } + if f.submitted { name := strings.TrimSpace(f.name.Value()) if name == "" || m.envProjectName == "" { return m, nil } + env := &domain.Environment{ Name: name, Color: normalizeColorInput(strings.TrimSpace(f.color.Value())), EnvVarsMode: strings.TrimSpace(f.mode.Value()), } + envs := domain.EnvVars{} if strings.TrimSpace(f.envVars.Value()) != "" { envs = parseEnvLines(f.envVars.Value()) } - _ = m.projectSvc.AddEnvironment(m.envProjectName, env, envs) + + if f.isEdit { + _ = m.projectSvc.UpdateEnvironment(m.envProjectName, f.originalName, env) + // Persist env vars to env file + project, _ := m.projectSvc.Load(m.envProjectName) + // find updated env (by new name) + var envPath string + for _, e := range project.Environments { + if e.Name == env.Name { + envPath = e.EnvVarsFile + break + } + } + if envPath != "" { + if !filepath.IsAbs(envPath) { + envPath = filepath.Join(project.Path, envPath) + } + // validate but keep raw + raw := strings.TrimSpace(f.envVars.Value()) + if raw != "" { + lines := strings.Split(raw, "\n") + ok := true + for _, line := range lines { + l := strings.TrimSpace(line) + if l == "" || strings.HasPrefix(l, "#") { + continue + } + kv := strings.SplitN(l, "=", 2) + if len(kv) != 2 || strings.TrimSpace(kv[0]) == "" { + ok = false + break + } + } + if ok { + _ = os.WriteFile(envPath, []byte(f.envVars.Value()), 0o644) + } + } + } + } else { + _ = m.projectSvc.AddEnvironment(m.envProjectName, env, envs) + } + projects, err := m.projectSvc.List() if err == nil { m.projects = projects m.total = len(projects) + 1 } + m.mode = 0 m.envForm = nil m.envProjectName = "" + return m, tea.ClearScreen } + return m, ecmd } } + switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height case tea.KeyMsg: switch msg.String() { + case "e": + idx := mod(m.cursor, m.total) + if m.focus == 0 && idx < len(m.projects) { + project, _ := m.projectSvc.Load(m.projects[idx].Name) + m.form = NewProjectEditFormModel(project) + // Prefill from project env file content + projEnvPath := project.EnvVarsFile + if !filepath.IsAbs(projEnvPath) { + projEnvPath = filepath.Join(project.Path, projEnvPath) + } + if b, err := os.ReadFile(projEnvPath); err == nil { + m.form.envVars.SetValue(string(b)) + } else { + pairs := project.EnvVars.ToSlice() + sort.Strings(pairs) + m.form.envVars.SetValue(strings.Join(pairs, "\n")) + } + // Prefill git config into edit form + gitRepo, err := repositories.NewGitRepository() + if err == nil { + gitSvc := services.NewGitService(gitRepo) + gitPath := project.EnvVarsFile + if strings.HasPrefix(gitPath, ".") { + gitPath = filepath.Join(project.Path, fmt.Sprintf(".%s.gitconfig", project.Name)) + } + if gc, e := gitSvc.Load(gitPath); e == nil { + m.form.userName.SetValue(gc.User.Name) + m.form.userEmail.SetValue(gc.User.Email) + m.form.userSigningKey.SetValue(gc.User.SigningKey) + if gc.Commit.GPGSign { + m.form.commitGPGSign.SetValue(strTrue) + } else { + m.form.commitGPGSign.SetValue(strFalse) + } + if gc.Tag.GPGSign { + m.form.tagGPGSign.SetValue(strTrue) + } else { + m.form.tagGPGSign.SetValue(strFalse) + } + } + } + m.mode = 1 + return m, tea.ClearScreen + } + if m.focus == 1 && idx < len(m.projects) { + project, _ := m.projectSvc.Load(m.projects[idx].Name) + if m.cursorEnv < len(project.Environments) { + env := project.Environments[m.cursorEnv] + m.envForm = NewEnvironmentEditFormModel(env) + // Prefill env vars from file content + envPath := env.EnvVarsFile + if !filepath.IsAbs(envPath) { + envPath = filepath.Join(project.Path, envPath) + } + if b, err := os.ReadFile(envPath); err == nil { + m.envForm.envVars.SetValue(string(b)) + } else if len(env.EnvVars) > 0 { + pairs := env.EnvVars.ToSlice() + sort.Strings(pairs) + m.envForm.envVars.SetValue(strings.Join(pairs, "\n")) + } + m.envProjectName = project.Name + m.mode = 3 + return m, tea.ClearScreen + } + } case "q", "ctrl+c", "esc": m.selectedProject = nil + return m, tea.Quit case "enter": if m.focus == 0 { idx := mod(m.cursor, m.total) if idx == len(m.projects) { // '+ New project' m.newProjectFlow() + return m, tea.ClearScreen } + project, _ := m.projectSvc.Load(m.projects[idx].Name) m.selectedProject = project + return m, tea.Quit } + if m.focus == 1 { mod := mod(m.cursor, m.total) if mod < len(m.projects) && len(m.projects) > 0 { envCount := len(m.projects[mod].Environments) if m.cursorEnv == envCount { // '+ New environment' m.newEnvironmentFlow(m.projects[mod].Name) + return m, tea.ClearScreen } + if envCount > 0 { project, _ := m.projectSvc.Load(m.projects[mod].Name) m.selectedProject = project + return m, tea.Quit } } @@ -171,6 +380,7 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { mod := mod(m.cursor, m.total) if mod < len(m.projects) && len(m.projects) > 0 { envCount := len(m.projects[mod].Environments) + envCountPlus := envCount + 1 if envCountPlus > 0 { m.cursorEnv-- @@ -189,6 +399,7 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { mod := mod(m.cursor, m.total) if mod < len(m.projects) && len(m.projects) > 0 { envCount := len(m.projects[mod].Environments) + envCountPlus := envCount + 1 if envCountPlus > 0 { m.cursorEnv++ diff --git a/internal/adapters/handlers/tui/window_view.go b/internal/adapters/handlers/tui/window_view.go index 1ff5abc..02c7890 100644 --- a/internal/adapters/handlers/tui/window_view.go +++ b/internal/adapters/handlers/tui/window_view.go @@ -6,16 +6,21 @@ import ( "github.com/charmbracelet/lipgloss" ) +const markerArrow = "➜" + func (m Window) View() string { if m.mode == 1 && m.form != nil { return m.form.View() } + if m.mode == 2 && m.prompt != nil { return m.prompt.View() } + if m.mode == 3 && m.envForm != nil { return m.envForm.View() } + left := strings.Builder{} title := " Projects" titleStyle := lipgloss.NewStyle(). @@ -28,12 +33,14 @@ func (m Window) View() string { Background(lipgloss.Color("57")).Padding(0, 1) defaultStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("240")).Padding(0, 1) + left.WriteString(titleStyle.Render(title)) left.WriteString("\n") + mod := mod(m.cursor, m.total) for i, project := range m.projects { if mod == i { - left.WriteString("➜ ") + left.WriteString(markerArrow + " ") left.WriteString(selectedStyle.Render(project.Name)) left.WriteString("\n") } else { @@ -42,8 +49,9 @@ func (m Window) View() string { left.WriteString("\n") } } + if mod == len(m.projects) { - left.WriteString("➜ ") + left.WriteString(markerArrow + " ") left.WriteString(selectedStyle.Render("+ New project")) left.WriteString("\n") } else { @@ -51,10 +59,12 @@ func (m Window) View() string { left.WriteString(defaultStyle.Render("+ New project")) left.WriteString("\n") } + right := strings.Builder{} rightTitle := "󱄑 Environments" right.WriteString(titleStyle.Render(rightTitle)) right.WriteString("\n") + if mod < len(m.projects) && len(m.projects) > 0 { p := m.projects[mod] if len(p.Environments) == 0 { @@ -62,24 +72,30 @@ func (m Window) View() string { if m.focus == 1 && m.cursorEnv == 0 { marker = "➜" } + right.WriteString(marker) right.WriteString(defaultStyle.Render("+ New environment")) right.WriteString("\n") } else { for i, env := range p.Environments { marker := " " + selected := m.focus == 1 && m.cursorEnv == i + if selected { - marker = "➜" + marker = markerArrow } + rowStyle := lipgloss.NewStyle() if selected { rowStyle = rowStyle.Background(lipgloss.Color(env.Color)).Padding(0, 0, 0, 1) } + content := defaultStyle.Foreground(lipgloss.Color(env.Color)).Render("● " + env.Name) if p.DefaultEnv != "" && env.Name == p.DefaultEnv { content = content + " " + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("(default)") } + right.WriteString(marker) right.WriteString(rowStyle.Render(content)) right.WriteString("\n") @@ -89,14 +105,19 @@ func (m Window) View() string { if mod < len(m.projects) && len(m.projects) > 0 && len(m.projects[mod].Environments) > 0 { marker := " " if m.focus == 1 && m.cursorEnv == len(m.projects[mod].Environments) { - marker = "➜" + marker = markerArrow } + right.WriteString(marker) right.WriteString(defaultStyle.Render("+ New environment")) right.WriteString("\n") } } + content := lipgloss.JoinHorizontal(lipgloss.Left, left.String(), " ", right.String()) - help := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Keys: ↑/k ↓/j navigate ←/h β†’/l focus Enter select Esc/Ctrl+C exit") + help := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Render("Keys: ↑/k ↓/j navigate ←/h β†’/l focus e edit Enter select Esc/Ctrl+C exit") + return lipgloss.JoinVertical(lipgloss.Left, content, help) } diff --git a/internal/adapters/repositories/env_vars_repository.go b/internal/adapters/repositories/env_vars_repository.go index 8cd0232..18d6b3f 100644 --- a/internal/adapters/repositories/env_vars_repository.go +++ b/internal/adapters/repositories/env_vars_repository.go @@ -5,23 +5,21 @@ import ( "path/filepath" "strings" + "github.com/joho/godotenv" + "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/ports" - "github.com/joho/godotenv" ) -type EnvVarsRepository struct { -} +type EnvVarsRepository struct{} var _ ports.EnvVarsRepository = (*EnvVarsRepository)(nil) func NewEnvVarsRepository() (*EnvVarsRepository, error) { - return &EnvVarsRepository{}, nil } func (e *EnvVarsRepository) Load(path string) (domain.EnvVars, error) { - if strings.HasPrefix(path, "~/") { dirname, _ := os.UserHomeDir() path = filepath.Join(dirname, path[2:]) @@ -31,11 +29,10 @@ func (e *EnvVarsRepository) Load(path string) (domain.EnvVars, error) { } func (e *EnvVarsRepository) Save(path string, envVars map[string]string) error { - if strings.HasPrefix(path, "~/") { dirname, _ := os.UserHomeDir() path = filepath.Join(dirname, path[2:]) } - return godotenv.Write(envVars, filepath.Join(path, ".env")) + return godotenv.Write(envVars, path) } diff --git a/internal/adapters/repositories/git_repository.go b/internal/adapters/repositories/git_repository.go index 1d5a186..fbaf044 100644 --- a/internal/adapters/repositories/git_repository.go +++ b/internal/adapters/repositories/git_repository.go @@ -4,10 +4,10 @@ import ( "fmt" "log/slog" "os" - "path/filepath" "strconv" "github.com/go-git/go-git/v5/config" + "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/ports" ) @@ -25,9 +25,7 @@ func NewGitRepository() (*GitRepository, error) { } func (g *GitRepository) Load(path string) (*domain.GitConfig, error) { - gitFileContent, err := os.ReadFile(path) - if err != nil { return nil, err } @@ -41,12 +39,11 @@ func (g *GitRepository) Load(path string) (*domain.GitConfig, error) { } tagGPGSing, err := strconv.ParseBool(g.git.Raw.Section("tag").Option("gpgsign")) - if err != nil { return nil, err } - commitGPGSing, err := strconv.ParseBool(g.git.Raw.Section("tag").Option("gpgsign")) + commitGPGSing, err := strconv.ParseBool(g.git.Raw.Section("commit").Option("gpgsign")) if err != nil { return nil, err } @@ -67,7 +64,6 @@ func (g *GitRepository) Load(path string) (*domain.GitConfig, error) { } func (g *GitRepository) Save(path string, gitConfig *domain.GitConfig) error { - g.git.User.Email = gitConfig.User.Email g.git.User.Name = gitConfig.User.Name g.git.Raw.AddOption("user", "", "signingkey", gitConfig.User.SigningKey) @@ -76,29 +72,24 @@ func (g *GitRepository) Save(path string, gitConfig *domain.GitConfig) error { g.git.Raw.AddOption("commit", "", "gpgsign", "true") newGitConf, err := g.git.Marshal() - if err != nil { return err } - _, err = os.Stat(path) - - if !os.IsNotExist(err) { - return fmt.Errorf("%s already exists in directory %s", path, filepath.Dir(path)) - } + _, _ = os.Stat(path) - fp, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + fp, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return err } defer fp.Close() - n_bytes, err := fp.Write(newGitConf) + nBytes, err := fp.Write(newGitConf) if err != nil { return err } - slog.Debug(fmt.Sprintf("%d Bytes written in %s", n_bytes, path)) + slog.Debug(fmt.Sprintf("%d Bytes written in %s", nBytes, path)) return nil } diff --git a/internal/adapters/repositories/project_repository.go b/internal/adapters/repositories/project_repository.go index 650a258..a562c77 100644 --- a/internal/adapters/repositories/project_repository.go +++ b/internal/adapters/repositories/project_repository.go @@ -13,6 +13,7 @@ import ( "github.com/go-git/go-git/v5/config" "github.com/hashicorp/hcl/v2/hclsimple" + "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/ports" ) @@ -25,7 +26,6 @@ var _ ports.ProjectRepository = (*ProjectRepository)(nil) func NewProjectRepository() (*ProjectRepository, error) { git, err := config.LoadConfig(config.GlobalScope) - if err != nil { return nil, err } @@ -36,10 +36,10 @@ func NewProjectRepository() (*ProjectRepository, error) { } func (p *ProjectRepository) Get(name string) (*domain.Project, error) { - for _, section := range p.git.Raw.Sections { if section.IsName("includeIf") { slog.Debug(fmt.Sprintf("Section: %+v\n", section)) + for _, sub := range section.Subsections { // Read subsections and get path of the project slog.Debug(fmt.Sprintf("\t - SubSection: %+v\n", sub)) @@ -49,7 +49,6 @@ func (p *ProjectRepository) Get(name string) (*domain.Project, error) { projetPath := filepath.Join(path, ".project.hcl") project, err := loadDotProject(projetPath) - if err != nil { return nil, err } @@ -62,7 +61,6 @@ func (p *ProjectRepository) Get(name string) (*domain.Project, error) { return project, nil } - } } } @@ -71,19 +69,18 @@ func (p *ProjectRepository) Get(name string) (*domain.Project, error) { } func (p *ProjectRepository) List() ([]*domain.Project, error) { - projects := []*domain.Project{} + for _, section := range p.git.Raw.Sections { if section.IsName("includeIf") { for _, sub := range section.Subsections { - if path, ok := strings.CutPrefix(sub.Name, "gitdir/i:"); ok { - projetPath := filepath.Join(path, ".project.hcl") project, err := loadDotProject(projetPath) if err != nil { slog.Error(err.Error()) + continue } @@ -99,23 +96,25 @@ func (p *ProjectRepository) List() ([]*domain.Project, error) { projects = append(projects, project) } } - } } return projects, nil - } /* -TODO +NOTE: future work - Create directory if not exist - Create .env .project.hcl and ..gitconfig files - If .env exist warn and continue - If ..gitconfig exist warn and continue - If .project.hcl exist */ -func (p *ProjectRepository) Create(name, path, subproject string, envVars domain.EnvVars, gitConfig *domain.GitConfig) (*domain.Project, error) { +func (p *ProjectRepository) Create( + name, path, subproject string, + envVars domain.EnvVars, + gitConfig *domain.GitConfig, +) (*domain.Project, error) { // Expand '~' to user home if strings.HasPrefix(path, "~/") { home, _ := os.UserHomeDir() @@ -140,11 +139,12 @@ func (p *ProjectRepository) Create(name, path, subproject string, envVars domain if err != nil { return nil, err } + path = absPath log.Println(path) - gitdir := fmt.Sprintf("gitdir/i:%s/", filepath.Join(path)) + gitdir := fmt.Sprintf("gitdir/i:%s/", path) // Create paths (idempotent) if err := os.MkdirAll(path, 0o755); err != nil { @@ -163,6 +163,7 @@ func (p *ProjectRepository) Create(name, path, subproject string, envVars domain includeIf := p.git.Raw.Section("includeIf").Subsection(gitdir) includeIf.SetOption("path", gitConfigPath) + if subproject != "" { includeIf.SetOption("subproject", subproject) } @@ -179,10 +180,13 @@ func (p *ProjectRepository) Create(name, path, subproject string, envVars domain if err != nil { return nil, err } + defer fpGit.Close() + if _, err := fpGit.Write(gitConf); err != nil { return nil, err } + if err := p.git.Validate(); err != nil { return nil, err } @@ -194,21 +198,27 @@ func (p *ProjectRepository) Create(name, path, subproject string, envVars domain newConfig.Raw.AddOption("user", "", "signingkey", gitConfig.User.SigningKey) newConfig.Raw.AddOption("commit", "", "gpgsign", strconv.FormatBool(gitConfig.Commit.GPGSign)) newConfig.Raw.AddOption("tag", "", "gpgsign", strconv.FormatBool(gitConfig.Tag.GPGSign)) + newGitConf, err := newConfig.Marshal() if err != nil { return nil, err } + if _, err := os.Stat(gitConfigPath); !os.IsNotExist(err) { return nil, fmt.Errorf("%s already exists in directory %s", gitConfigName, path) } + fp, err := os.OpenFile(gitConfigPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return nil, err } + defer fp.Close() + if _, err := fp.Write(newGitConf); err != nil { return nil, err } + if err := newConfig.Validate(); err != nil { return nil, err } @@ -218,11 +228,14 @@ func (p *ProjectRepository) Create(name, path, subproject string, envVars domain if _, err = os.Stat(envPath); !os.IsNotExist(err) { return nil, fmt.Errorf("%s already exists in directory %s", envPath, path) } + fpEnv, err := os.OpenFile(envPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return nil, err } + defer fpEnv.Close() + for key, value := range envVars { if _, err := fmt.Fprintf(fpEnv, "%s=%s\n", key, value); err != nil { return nil, err @@ -234,11 +247,14 @@ func (p *ProjectRepository) Create(name, path, subproject string, envVars domain if _, err = os.Stat(projPath); !os.IsNotExist(err) { return nil, fmt.Errorf("%s already exists in directory %s", filepath.Base(projPath), path) } + fpProj, err := os.OpenFile(projPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return nil, err } + defer fpProj.Close() + projectHCL := fmt.Sprintf("name = \"%s\"\n\ndescription = \"\"\n\nenv_vars_file = \".env\"\n", name) if _, err := fpProj.WriteString(projectHCL); err != nil { return nil, err @@ -252,64 +268,170 @@ func (p *ProjectRepository) Create(name, path, subproject string, envVars domain }, nil } -func (p *ProjectRepository) Delete(name string) error { +func (p *ProjectRepository) Delete(_ string) error { + return nil +} + +func (p *ProjectRepository) UpdateProject(project *domain.Project) error { + projPath := filepath.Join(project.Path, ".project.hcl") + if strings.HasPrefix(projPath, "~/") { + h, _ := os.UserHomeDir() + projPath = filepath.Join(h, projPath[2:]) + } + + projectDir := filepath.Dir(projPath) + gitdir := fmt.Sprintf("gitdir/i:%s/", projectDir) + includeIf := p.git.Raw.Section("includeIf").Subsection(gitdir) + oldGitPath := includeIf.Option("path") + newGitPath := filepath.Join(projectDir, fmt.Sprintf(".%s.gitconfig", project.Name)) + if oldGitPath != "" && oldGitPath != newGitPath { + if _, err := os.Stat(oldGitPath); err == nil { + _ = os.Rename(oldGitPath, newGitPath) + } + includeIf.SetOption("path", newGitPath) + gitConf, _ := p.git.Marshal() + if home, err := os.UserHomeDir(); err == nil { + if fpGit, err := os.OpenFile(filepath.Join(home, ".gitconfig"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644); err == nil { + _, _ = fpGit.Write(gitConf) + _ = fpGit.Close() + _ = p.git.Validate() + } + } + } + + b := &strings.Builder{} + fmt.Fprintf(b, "name = \"%s\"\n\n", project.Name) + fmt.Fprintf(b, "description = \"%s\"\n\n", project.Description) + if project.Shell != "" { + fmt.Fprintf(b, "shell = \"%s\"\n\n", project.Shell) + } + fmt.Fprintf(b, "env_vars_file = \"%s\"\n\n", project.EnvVarsFile) + if project.DefaultEnv != "" { + fmt.Fprintf(b, "default_env = \"%s\"\n\n", project.DefaultEnv) + } + + for _, e := range project.Environments { + fmt.Fprintf(b, "environment \"%s\" {\n", e.Name) + if e.Color != "" { + fmt.Fprintf(b, " color = \"%s\"\n", e.Color) + } + fmt.Fprintf(b, " env_vars_mode = \"%s\"\n", e.EnvVarsMode) + fmt.Fprintf(b, " env_vars_file = \"%s\"\n", e.EnvVarsFile) + b.WriteString("}\n\n") + } + + fp, err := os.OpenFile(projPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return err + } + defer fp.Close() + if _, err := io.WriteString(fp, b.String()); err != nil { + return err + } + return nil } +func (p *ProjectRepository) UpdateEnvironment(projectName, originalEnvName string, env *domain.Environment) error { + project, err := p.Get(projectName) + if err != nil { + return err + } + + idx := -1 + for i, e := range project.Environments { + if e.Name == originalEnvName { + idx = i + break + } + } + if idx == -1 { + return fmt.Errorf("environment %s not found", originalEnvName) + } + + project.Environments[idx].Name = env.Name + project.Environments[idx].Color = env.Color + if env.EnvVarsMode == "" { + project.Environments[idx].EnvVarsMode = domain.EnvVarsModeMerge + } else { + project.Environments[idx].EnvVarsMode = env.EnvVarsMode + } + if env.EnvVarsFile != "" { + project.Environments[idx].EnvVarsFile = env.EnvVarsFile + } + + return p.UpdateProject(project) +} + func (p *ProjectRepository) AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error { project, err := p.Get(projectName) if err != nil { return err } + for _, e := range project.Environments { if e.Name == env.Name { return fmt.Errorf("environment %s already exists", env.Name) } } + if env.EnvVarsMode == "" { - env.EnvVarsMode = domain.ENV_VARS_MODE_MERGE + env.EnvVarsMode = domain.EnvVarsModeMerge } + if strings.HasPrefix(project.Path, "~/") { h, _ := os.UserHomeDir() project.Path = filepath.Join(h, project.Path[2:]) } + envFile := env.EnvVarsFile if envFile == "" { envFile = ".env." + env.Name } + envPath := envFile if !filepath.IsAbs(envFile) { envPath = filepath.Join(project.Path, envFile) } + if _, err := os.Stat(envPath); !os.IsNotExist(err) { return fmt.Errorf("%s already exists in directory %s", filepath.Base(envPath), filepath.Dir(envPath)) } + fpEnv, err := os.OpenFile(envPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return err } + defer fpEnv.Close() + for k, v := range envVars { if _, err := fmt.Fprintf(fpEnv, "%s=%s\n", k, v); err != nil { return err } } + projPath := filepath.Join(project.Path, ".project.hcl") + fp, err := os.OpenFile(projPath, os.O_RDWR|os.O_APPEND, 0o644) if err != nil { return err } + defer fp.Close() + b := &strings.Builder{} b.WriteString("\n") b.WriteString("environment \"") b.WriteString(env.Name) b.WriteString("\" {\n") + if env.Color != "" { b.WriteString(" color = \"") b.WriteString(env.Color) b.WriteString("\"\n") } + b.WriteString(" env_vars_mode = \"") b.WriteString(env.EnvVarsMode) b.WriteString("\"\n") @@ -317,9 +439,11 @@ func (p *ProjectRepository) AddEnvironment(projectName string, env *domain.Envir b.WriteString(envFile) b.WriteString("\"\n") b.WriteString("}\n") + if _, err := io.WriteString(fp, b.String()); err != nil { return err } + return nil } @@ -361,5 +485,6 @@ func IsDirEmpty(path string) (bool, error) { if err == io.EOF { return true, nil } + return false, err } diff --git a/internal/adapters/repositories/shells/pseudoshell_repository.go b/internal/adapters/repositories/shells/pseudoshell_repository.go index d839524..fc1f879 100644 --- a/internal/adapters/repositories/shells/pseudoshell_repository.go +++ b/internal/adapters/repositories/shells/pseudoshell_repository.go @@ -1,6 +1,7 @@ package shells import ( + "context" "errors" "fmt" "io" @@ -26,16 +27,14 @@ type PseudoShellepository struct { var _ ports.ShellRepository = (*ShellRepository)(nil) -func NewPseudoShellRepository(project *domain.Project, env string, path string) (*PseudoShellepository, error) { - +func NewPseudoShellRepository(project *domain.Project, env, path string) (*PseudoShellepository, error) { shellPath, err := exec.LookPath(project.Shell) - if err != nil { return nil, err } shell := &PseudoShellepository{ - cmd: exec.Command(shellPath), + cmd: exec.CommandContext(context.Background(), shellPath), } // load env vars @@ -52,7 +51,7 @@ func NewPseudoShellRepository(project *domain.Project, env string, path string) } else { for _, e := range project.Environments { if e.Name == env { - if e.EnvVarsMode == domain.ENV_VARS_MODE_MERGE { + if e.EnvVarsMode == domain.EnvVarsModeMerge { shell.cmd.Env = append( shell.cmd.Env, project.EnvVars.ToSlice()..., @@ -67,6 +66,7 @@ func NewPseudoShellRepository(project *domain.Project, env string, path string) e.EnvVars.ToSlice()..., ) } + break } } @@ -76,6 +76,7 @@ func NewPseudoShellRepository(project *domain.Project, env string, path string) if err != nil { return nil, err } + if info, err := os.Stat(absPath); err != nil { return nil, err } else if !info.IsDir() { @@ -105,10 +106,10 @@ func (s *PseudoShellepository) Start() (*os.Process, error) { } func (s *PseudoShellepository) Wait() (int, error) { - // Handle pty size. ch := make(chan os.Signal, 1) signal.Notify(ch, syscall.SIGWINCH) + go func() { for range ch { if err := pty.InheritSize(os.Stdin, s.ptmx); err != nil { @@ -116,7 +117,9 @@ func (s *PseudoShellepository) Wait() (int, error) { } } }() - ch <- syscall.SIGWINCH // Initial resize. + + ch <- syscall.SIGWINCH // Initial resize. + defer func() { signal.Stop(ch); close(ch) }() // Cleanup signals when done. // Set stdin in raw mode. @@ -124,11 +127,13 @@ func (s *PseudoShellepository) Wait() (int, error) { if err != nil { panic(err) } + defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }() // Best effort. // Copy stdin to the pty and the pty to stdout. // NOTE: The goroutine will keep reading until the next keystroke before returning. go func() { _, _ = io.Copy(s.ptmx, os.Stdin) }() + _, _ = io.Copy(os.Stdout, s.ptmx) // Test diff --git a/internal/adapters/repositories/shells/shell_repository.go b/internal/adapters/repositories/shells/shell_repository.go index 9749246..c8995f7 100644 --- a/internal/adapters/repositories/shells/shell_repository.go +++ b/internal/adapters/repositories/shells/shell_repository.go @@ -1,6 +1,7 @@ package shells import ( + "context" "errors" "fmt" "os" @@ -17,16 +18,14 @@ type ShellRepository struct { var _ ports.ShellRepository = (*ShellRepository)(nil) -func NewShellRepository(project *domain.Project, env string, path string) (*ShellRepository, error) { - +func NewShellRepository(project *domain.Project, env, path string) (*ShellRepository, error) { shellPath, err := exec.LookPath(project.Shell) - if err != nil { return nil, err } shell := &ShellRepository{ - cmd: exec.Command(shellPath), + cmd: exec.CommandContext(context.Background(), shellPath), } // load env vars @@ -43,7 +42,7 @@ func NewShellRepository(project *domain.Project, env string, path string) (*Shel } else { for _, e := range project.Environments { if e.Name == env { - if e.EnvVarsMode == domain.ENV_VARS_MODE_MERGE { + if e.EnvVarsMode == domain.EnvVarsModeMerge { shell.cmd.Env = append( shell.cmd.Env, project.EnvVars.ToSlice()..., @@ -58,6 +57,7 @@ func NewShellRepository(project *domain.Project, env string, path string) (*Shel e.EnvVars.ToSlice()..., ) } + break } } @@ -67,6 +67,7 @@ func NewShellRepository(project *domain.Project, env string, path string) (*Shel if err != nil { return nil, err } + if info, err := os.Stat(absPath); err != nil { return nil, err } else if !info.IsDir() { @@ -88,7 +89,6 @@ func NewShellRepository(project *domain.Project, env string, path string) (*Shel } func (s *ShellRepository) Start() (*os.Process, error) { - if err := s.cmd.Start(); err != nil { return nil, err } @@ -100,9 +100,8 @@ func (s *ShellRepository) Wait() (int, error) { if err := s.cmd.Wait(); err != nil { if exiterr, ok := err.(*exec.ExitError); ok { return exiterr.ExitCode(), nil - } else { - return 0, err } + return 0, err } return 0, nil diff --git a/internal/core/domain/env_vars.go b/internal/core/domain/env_vars.go index 53ef958..f963245 100644 --- a/internal/core/domain/env_vars.go +++ b/internal/core/domain/env_vars.go @@ -3,8 +3,8 @@ package domain import "fmt" const ( - ENV_VARS_MODE_MERGE string = "merge" - ENV_VARS_MODE_REPLACE string = "replace" + EnvVarsModeMerge string = "merge" + EnvVarsModeReplace string = "replace" ) type EnvVars map[string]string @@ -14,5 +14,6 @@ func (e EnvVars) ToSlice() []string { for k, v := range e { envVars = append(envVars, fmt.Sprintf("%s=%s", k, v)) } + return envVars } diff --git a/internal/core/domain/git_config.go b/internal/core/domain/git_config.go index 859ffb1..09c8443 100644 --- a/internal/core/domain/git_config.go +++ b/internal/core/domain/git_config.go @@ -27,6 +27,7 @@ func New(options ...Option) *GitConfig { for _, o := range options { o(gitConfig) } + return gitConfig } diff --git a/internal/core/domain/project.go b/internal/core/domain/project.go index 41430bd..de1170e 100644 --- a/internal/core/domain/project.go +++ b/internal/core/domain/project.go @@ -13,7 +13,6 @@ type Project struct { DefaultEnv string `hcl:"default_env,optional"` } - type Environment struct { Name string `hcl:"name,label"` Color string `hcl:"color,optional"` diff --git a/internal/core/ports/project_port.go b/internal/core/ports/project_port.go index 3ac9958..19e25d6 100644 --- a/internal/core/ports/project_port.go +++ b/internal/core/ports/project_port.go @@ -7,6 +7,8 @@ type ProjectService interface { List() ([]*domain.Project, error) Create(name, path, subproject string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error + UpdateProject(project *domain.Project) error + UpdateEnvironment(projectName, originalEnvName string, env *domain.Environment) error Delete(name string) error } @@ -14,5 +16,7 @@ type ProjectRepository interface { List() ([]*domain.Project, error) Create(name, path, subproject string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error + UpdateProject(project *domain.Project) error + UpdateEnvironment(projectName, originalEnvName string, env *domain.Environment) error Delete(name string) error } diff --git a/internal/core/services/git_service.go b/internal/core/services/git_service.go index 488e9e1..eeda037 100644 --- a/internal/core/services/git_service.go +++ b/internal/core/services/git_service.go @@ -11,7 +11,7 @@ type GitService struct { var _ ports.GitService = (*GitService)(nil) -func NewGiService(repo ports.GitRepository) *GitService { +func NewGitService(repo ports.GitRepository) *GitService { return &GitService{ repo: repo, } diff --git a/internal/core/services/project_service.go b/internal/core/services/project_service.go index 90af35f..aeb87aa 100644 --- a/internal/core/services/project_service.go +++ b/internal/core/services/project_service.go @@ -17,7 +17,11 @@ type ProjectService struct { var _ ports.ProjectService = (*ProjectService)(nil) -func NewProjectService(project ports.ProjectRepository, envVars ports.EnvVarsRepository, git ports.GitRepository) *ProjectService { +func NewProjectService( + project ports.ProjectRepository, + envVars ports.EnvVarsRepository, + git ports.GitRepository, +) *ProjectService { return &ProjectService{ project: project, envVars: envVars, @@ -26,15 +30,12 @@ func NewProjectService(project ports.ProjectRepository, envVars ports.EnvVarsRep } func (svc *ProjectService) Load(name string) (*domain.Project, error) { - projects, err := svc.project.List() - if err != nil { return nil, err } for _, project := range projects { - // Check name if not continue if project.Name != name { continue @@ -50,7 +51,6 @@ func (svc *ProjectService) Load(name string) (*domain.Project, error) { // load env vars project.EnvVars, err = svc.envVars.Load(envVarsPath) - if err != nil { return nil, err } @@ -59,14 +59,13 @@ func (svc *ProjectService) Load(name string) (*domain.Project, error) { for _, env := range project.Environments { var envVarsPath string - if filepath.IsAbs(project.EnvVarsFile) { + if filepath.IsAbs(env.EnvVarsFile) { envVarsPath = env.EnvVarsFile } else { envVarsPath = filepath.Join(project.Path, env.EnvVarsFile) } env.EnvVars, err = svc.envVars.Load(envVarsPath) - if err != nil { slog.Warn("EnvVars file not found", slog.String("env", env.Name), slog.String("path", envVarsPath)) } @@ -84,7 +83,11 @@ func (svc *ProjectService) List() ([]*domain.Project, error) { return svc.project.List() } -func (svc *ProjectService) Create(name, path, subproject string, envVars domain.EnvVars, gitConfig *domain.GitConfig) (*domain.Project, error) { +func (svc *ProjectService) Create( + name, path, subproject string, + envVars domain.EnvVars, + gitConfig *domain.GitConfig, +) (*domain.Project, error) { return svc.project.Create(name, path, subproject, envVars, gitConfig) } @@ -92,6 +95,14 @@ func (svc *ProjectService) AddEnvironment(projectName string, env *domain.Enviro return svc.project.AddEnvironment(projectName, env, envVars) } +func (svc *ProjectService) UpdateProject(project *domain.Project) error { + return svc.project.UpdateProject(project) +} + +func (svc *ProjectService) UpdateEnvironment(projectName, originalEnvName string, env *domain.Environment) error { + return svc.project.UpdateEnvironment(projectName, originalEnvName, env) +} + func (svc *ProjectService) Delete(name string) error { return svc.project.Delete(name) } diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 1ef9017..67c1cdc 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -6,7 +6,7 @@ import ( "path/filepath" ) -func Setup(levelText string, filePath string) (*slog.Logger, error) { +func Setup(levelText, filePath string) (*slog.Logger, error) { var lv slog.LevelVar if levelText == "" { @@ -22,11 +22,11 @@ func Setup(levelText string, filePath string) (*slog.Logger, error) { if err != nil { return nil, err } + filePath = filepath.Join(cache, "pm.log") } - fp, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) - + fp, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o644) if err != nil { return nil, err } diff --git a/internal/tools/docgen/main.go b/internal/tools/docgen/main.go index 556d7ce..a0999e7 100644 --- a/internal/tools/docgen/main.go +++ b/internal/tools/docgen/main.go @@ -33,9 +33,17 @@ func main() { base := filepath.Base(filename) name := strings.TrimSuffix(base, filepath.Ext(base)) title := strings.ReplaceAll(name, "_", " ") - return fmt.Sprintf("---\ntitle: %q\nslug: %q\ndescription: \"CLI reference for %s\"\n---\n\n", title, name, title) + + return fmt.Sprintf( + "---\ntitle: %q\nslug: %q\ndescription: \"CLI reference for %s\"\n---\n\n", + title, + name, + title, + ) } - link := func(name string) string { return strings.ToLower(name) } + + link := strings.ToLower + if err := doc.GenMarkdownTreeCustom(root, *out, prep, link); err != nil { log.Fatal(err) } diff --git a/internal/version.go b/internal/version.go index b914e64..5db5f8c 100644 --- a/internal/version.go +++ b/internal/version.go @@ -13,5 +13,13 @@ var ( ) func GetVersion() string { - return fmt.Sprintf("%s (%s) [by=%s os=%s arch=%s date=%s]", version, commit, builtBy, runtime.GOARCH, runtime.GOOS, date) + return fmt.Sprintf( + "%s (%s) [by=%s os=%s arch=%s date=%s]", + version, + commit, + builtBy, + runtime.GOARCH, + runtime.GOOS, + date, + ) } diff --git a/pkg/ui/card/card.go b/pkg/ui/card/card.go index 243a809..6311cc1 100644 --- a/pkg/ui/card/card.go +++ b/pkg/ui/card/card.go @@ -16,8 +16,8 @@ func (m Card) Init() tea.Cmd { return nil } -func (m Card) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return nil, nil +func (m Card) Update(_ tea.Msg) (tea.Model, tea.Cmd) { + return m, nil } func (m Card) View() string { @@ -35,7 +35,6 @@ func (m Card) View() string { } func NewCard(title, subtitle string) Card { - styles := DefaultStyles() return Card{ @@ -50,8 +49,10 @@ func ellipsis(s string, maxLen int) string { if len(runes) <= maxLen { return s } + if maxLen < 3 { maxLen = 3 } - return string(runes[0:maxLen-3]) + "..." + + return string(runes[:maxLen-3]) + "..." } diff --git a/pkg/ui/list/envs_list.go b/pkg/ui/list/envs_list.go index 180392e..9caac4f 100644 --- a/pkg/ui/list/envs_list.go +++ b/pkg/ui/list/envs_list.go @@ -5,5 +5,6 @@ func RenderNames(title string, names []string) string { for _, n := range names { it = append(it, Item{Name: n}) } + return NewList(title, it).View() } diff --git a/pkg/ui/list/list.go b/pkg/ui/list/list.go index 5b242e2..bce5c47 100644 --- a/pkg/ui/list/list.go +++ b/pkg/ui/list/list.go @@ -36,7 +36,9 @@ func (m List) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd } @@ -49,6 +51,7 @@ func NewList(title string, items []Item) List { for _, item := range items { i = append(i, item) } + l := List{list: list.New(i, list.NewDefaultDelegate(), 0, 0)} l.list.Title = title diff --git a/pkg/ui/textinput/textinput.go b/pkg/ui/textinput/textinput.go index 14b9526..be201b2 100644 --- a/pkg/ui/textinput/textinput.go +++ b/pkg/ui/textinput/textinput.go @@ -17,6 +17,7 @@ func NewTextInput(prompt, sugestion string) *TextInput { ti.Placeholder = sugestion ti.Prompt = prompt ti.Focus() + return &TextInput{ textInput: ti, err: nil, @@ -44,10 +45,12 @@ func (m *TextInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // We handle errors just like any other message case error: m.err = msg + return m, nil } m.textInput, cmd = m.textInput.Update(msg) + return m, cmd } diff --git a/tests/unit/pkg_ui_envs_list_test.go b/tests/unit/pkg_ui_envs_list_test.go index ebff910..551f0e5 100644 --- a/tests/unit/pkg_ui_envs_list_test.go +++ b/tests/unit/pkg_ui_envs_list_test.go @@ -1,8 +1,9 @@ package unit import ( - list "github.com/jlrosende/project-manager/pkg/ui/list" "testing" + + list "github.com/jlrosende/project-manager/pkg/ui/list" ) func TestRenderNames(t *testing.T) { diff --git a/tests/unit/pkg_ui_styles_test.go b/tests/unit/pkg_ui_styles_test.go index 67993e9..0cc5655 100644 --- a/tests/unit/pkg_ui_styles_test.go +++ b/tests/unit/pkg_ui_styles_test.go @@ -1,8 +1,9 @@ package unit import ( - "github.com/jlrosende/project-manager/pkg/ui/styles" "testing" + + "github.com/jlrosende/project-manager/pkg/ui/styles" ) func TestDefaultStyleWidth(t *testing.T) { From 7e645766eeec9648d112294a7c35c817e8ad29d7 Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Tue, 16 Sep 2025 18:46:30 +0200 Subject: [PATCH 11/27] Add themes and config precendence --- cmd/cli/root.go | 50 ++++- configs/config.default.hcl | 2 +- configs/config.default.toml | 2 - configs/config.example.hcl | 21 +- configs/config.go | 173 +++++----------- internal/adapters/handlers/tui/window_core.go | 184 +++++++++++++++++- .../adapters/handlers/tui/window_env_form.go | 62 +++--- internal/adapters/handlers/tui/window_flow.go | 12 +- internal/adapters/handlers/tui/window_form.go | 104 +++++----- internal/adapters/handlers/tui/window_view.go | 16 +- pkg/ui/card/card.go | 58 ------ pkg/ui/card/styles.go | 25 --- pkg/ui/list/envs_list.go | 10 - pkg/ui/list/list.go | 60 ------ pkg/ui/styles/styles.go | 59 ------ pkg/ui/textinput/textinput.go | 63 ------ tests/unit/pkg_ui_envs_list_test.go | 14 -- tests/unit/pkg_ui_styles_test.go | 14 -- 18 files changed, 407 insertions(+), 522 deletions(-) delete mode 100644 configs/config.default.toml delete mode 100644 pkg/ui/card/card.go delete mode 100644 pkg/ui/card/styles.go delete mode 100644 pkg/ui/list/envs_list.go delete mode 100644 pkg/ui/list/list.go delete mode 100644 pkg/ui/styles/styles.go delete mode 100644 pkg/ui/textinput/textinput.go delete mode 100644 tests/unit/pkg_ui_envs_list_test.go delete mode 100644 tests/unit/pkg_ui_styles_test.go diff --git a/cmd/cli/root.go b/cmd/cli/root.go index ee09a54..c866c6e 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -5,6 +5,7 @@ import ( "log" "log/slog" "os" + "strings" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" @@ -13,6 +14,7 @@ import ( cmdInit "github.com/jlrosende/project-manager/cmd/cli/init" cmdList "github.com/jlrosende/project-manager/cmd/cli/list" cmdNew "github.com/jlrosende/project-manager/cmd/cli/new" + "github.com/jlrosende/project-manager/configs" "github.com/jlrosende/project-manager/internal" "github.com/jlrosende/project-manager/internal/adapters/handlers/tui" "github.com/jlrosende/project-manager/internal/adapters/repositories" @@ -35,6 +37,7 @@ var rootCmd = &cobra.Command{ if err != nil { return err } + logFile, err := cmd.PersistentFlags().GetString("log-file") if err != nil { return err @@ -61,6 +64,8 @@ func init() { rootCmd.PersistentFlags().String("log-level", "info", "Change the log level (debug, info, warn, error)") rootCmd.PersistentFlags().String("log-file", "", "Path to log file (default: $XDG_CACHE_HOME/pm.log)") + rootCmd.PersistentFlags().String("theme", "", "Theme for this run (nord, catppuccin, dracula, ayu)") + rootCmd.PersistentFlags().String("config", "", "Path to pm config file") rootCmd.AddCommand(cmdInit.InitCmd) rootCmd.AddCommand(cmdNew.NewCmd) @@ -130,7 +135,50 @@ func root(cmd *cobra.Command, args []string) error { // Launch TUI if no args or project not exsit if len(args) == 0 || name == "" { - window, err := tui.NewWindow(svc) + themeFlag, _ := cmd.PersistentFlags().GetString("theme") + configFlag, _ := cmd.PersistentFlags().GetString("config") + cfgPath := strings.TrimSpace(configFlag) + if cfgPath == "" { + if v, ok := os.LookupEnv("PM_CONFIG"); ok && strings.TrimSpace(v) != "" { + cfgPath = v + } + } + cfg, cfgErr := configs.GetConfig(cfgPath) + if cfgPath != "" && cfgErr != nil { + return cfgErr + } + theme := strings.TrimSpace(themeFlag) + if theme == "" { + if v, ok := os.LookupEnv("PM_THEME"); ok && strings.TrimSpace(v) != "" { + theme = v + } else if cfg != nil && strings.TrimSpace(cfg.Theme) != "" { + theme = cfg.Theme + } + } + + ov := map[string]string{} + if cfg != nil { + for _, ct := range cfg.CustomThemes { + if strings.EqualFold(ct.Name, theme) { + if ct.Title != "" { ov["title"] = ct.Title } + if ct.Section != "" { ov["section"] = ct.Section } + if ct.Subtext != "" { ov["subtext"] = ct.Subtext } + if ct.Text != "" { ov["text"] = ct.Text } + if ct.Placeholder != "" { ov["placeholder"] = ct.Placeholder } + if ct.Border != "" { ov["border"] = ct.Border } + if ct.Error != "" { ov["error"] = ct.Error } + if ct.ButtonDefFg != "" { ov["buttonDefFg"] = ct.ButtonDefFg } + if ct.ButtonDefBg != "" { ov["buttonDefBg"] = ct.ButtonDefBg } + if ct.ButtonSelFg != "" { ov["buttonSelFg"] = ct.ButtonSelFg } + if ct.ButtonSelBg != "" { ov["buttonSelBg"] = ct.ButtonSelBg } + if ct.SelectedFg != "" { ov["selectedFg"] = ct.SelectedFg } + if ct.SelectedBg != "" { ov["selectedBg"] = ct.SelectedBg } + if ct.Help != "" { ov["help"] = ct.Help } + } + } + } + + window, err := tui.NewWindow(svc, tui.Options{Theme: theme, Overrides: ov}) if err != nil { return err } diff --git a/configs/config.default.hcl b/configs/config.default.hcl index 9e6870b..06f39ab 100644 --- a/configs/config.default.hcl +++ b/configs/config.default.hcl @@ -1,2 +1,2 @@ -theme = "default" +theme = "nord" diff --git a/configs/config.default.toml b/configs/config.default.toml deleted file mode 100644 index 9e6870b..0000000 --- a/configs/config.default.toml +++ /dev/null @@ -1,2 +0,0 @@ - -theme = "default" diff --git a/configs/config.example.hcl b/configs/config.example.hcl index 9e6870b..e573b94 100644 --- a/configs/config.example.hcl +++ b/configs/config.example.hcl @@ -1,2 +1,21 @@ -theme = "default" +theme = "mytheme" + +custom_theme "mytheme" { + # Any of these are optional; missing values fall back to base theme (nord/catppuccin/dracula/ayu) + title = "#89B4FA" + section = "#B4BEFE" + subtext = "#A6ADC8" + text = "#CDD6F4" + placeholder = "#6C7086" + border = "#585B70" + error = "#F38BA8" + buttonDefFg = "#1E1E2E" + buttonDefBg = "#585B70" + buttonSelFg = "#1E1E2E" + buttonSelBg = "#A6E3A1" + selectedFg = "#CDD6F4" + selectedBg = "#89B4FA" + help = "#A6ADC8" +} + diff --git a/configs/config.go b/configs/config.go index 68ff85e..1c594d1 100644 --- a/configs/config.go +++ b/configs/config.go @@ -1,151 +1,70 @@ package configs import ( - "bytes" - _ "embed" - "errors" - "fmt" - "log/slog" "os" - "path" - "reflect" + "path/filepath" + "strings" - "github.com/go-viper/mapstructure/v2" - "github.com/spf13/viper" + "github.com/hashicorp/hcl/v2/hclsimple" ) -//go:embed config.default.hcl -var defaultConfig []byte - -var ErrConfigNotFound = errors.New("config file not found") - type Config struct { - Theme string `mapstructure:"theme"` - RootFolder string `mapstructure:"root_folder"` - Projects map[string]Project `mapstructure:"project"` -} - -type Project struct { - Path string `mapstructure:"path"` - Theme string `mapstructure:"theme"` - EnvVars map[string]string `mapstructure:"env_vars"` - EnvVarsFile string `mapstructure:"env_vars_file"` - Environments map[string]Environment `mapstructure:"environment"` - DefaultEnv string `mapstructure:"default_env"` + Theme string `hcl:"theme"` + CustomThemes []CustomTheme `hcl:"custom_theme,block"` } -type Environment struct { - Theme string `mapstructure:"theme"` - EnvVars map[string]string `mapstructure:"env_vars"` - EnvVarsFile string `mapstructure:"env_vars_file"` +type CustomTheme struct { + Name string `hcl:"name,label"` + Title string `hcl:"title,optional"` + Section string `hcl:"section,optional"` + Subtext string `hcl:"subtext,optional"` + Text string `hcl:"text,optional"` + Placeholder string `hcl:"placeholder,optional"` + Border string `hcl:"border,optional"` + Error string `hcl:"error,optional"` + ButtonDefFg string `hcl:"buttonDefFg,optional"` + ButtonDefBg string `hcl:"buttonDefBg,optional"` + ButtonSelFg string `hcl:"buttonSelFg,optional"` + ButtonSelBg string `hcl:"buttonSelBg,optional"` + SelectedFg string `hcl:"selectedFg,optional"` + SelectedBg string `hcl:"selectedBg,optional"` + Help string `hcl:"help,optional"` } func GetConfig(cfgFile string) (*Config, error) { - v, err := LoadConfig(cfgFile) - if err != nil { - return nil, err + if cfgFile == "" { + if env := os.Getenv("PM_CONFIG"); strings.TrimSpace(env) != "" { + cfgFile = env + } } - - config, err := ParseConfig(v) - if err != nil { - slog.Debug("unable to parse config", slog.Any("err", err)) - - return nil, err + if strings.HasPrefix(cfgFile, "~/") { + h, _ := os.UserHomeDir() + cfgFile = filepath.Join(h, cfgFile[2:]) } - - return config, nil -} - -func LoadConfig(cfgFile string) (*viper.Viper, error) { - v := viper.New() - - if cfgFile != "" { - // Use config file from the flag. - v.SetConfigFile(cfgFile) - } else { - config, err := os.UserConfigDir() - if err != nil { + if strings.TrimSpace(cfgFile) != "" { + var cfg Config + if err := hclsimple.DecodeFile(cfgFile, nil, &cfg); err != nil { return nil, err } - - v.AddConfigPath(path.Join(config, "pm/")) - v.SetConfigName("config") - v.SetConfigType("hcl") - } - - v.AutomaticEnv() - - err := v.ReadInConfig() - if err != nil { - slog.Debug(fmt.Sprintf("Unable to read config: %v", err)) - - if _, ok := err.(viper.ConfigFileNotFoundError); ok { - return DefaultConfig() + if strings.TrimSpace(cfg.Theme) == "" { + cfg.Theme = "nord" } - - return nil, err + return &cfg, nil } - - return v, nil -} - -func ParseConfig(v *viper.Viper) (*Config, error) { - var cfg Config - - configOption := viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( - sliceOfMapsToMapHookFunc(), - mapstructure.StringToTimeDurationHookFunc(), - mapstructure.StringToSliceHookFunc(","), - )) - - err := v.Unmarshal(&cfg, configOption) - if err != nil { - return nil, fmt.Errorf("unable to parse config: %w", err) - } - - return &cfg, nil -} - -func DefaultConfig() (*viper.Viper, error) { - v := viper.New() - - err := v.ReadConfig(bytes.NewReader(defaultConfig)) + configDir, err := os.UserConfigDir() if err != nil { - return nil, err + return &Config{Theme: "nord"}, nil } - - return v, nil -} - -// sliceOfMapsToMapHookFunc merges a slice of maps to a map. -func sliceOfMapsToMapHookFunc() mapstructure.DecodeHookFunc { - return func(from, to reflect.Type, data interface{}) (interface{}, error) { - if from.Kind() == reflect.Slice && from.Elem().Kind() == reflect.Map && - (to.Kind() == reflect.Struct || to.Kind() == reflect.Map) { - source, ok := data.([]map[string]interface{}) - if !ok { - return data, nil - } - - if len(source) == 0 { - return data, nil - } - - if len(source) == 1 { - return source[0], nil - } - // flatten the slice into one map - convert := make(map[string]interface{}) - - for _, mapItem := range source { - for key, value := range mapItem { - convert[key] = value - } - } - - return convert, nil + defaultPath := filepath.Join(configDir, "pm", "config.hcl") + if _, err := os.Stat(defaultPath); err == nil { + var cfg Config + if err := hclsimple.DecodeFile(defaultPath, nil, &cfg); err != nil { + return nil, err } - - return data, nil + if strings.TrimSpace(cfg.Theme) == "" { + cfg.Theme = "nord" + } + return &cfg, nil } + return &Config{Theme: "nord"}, nil } diff --git a/internal/adapters/handlers/tui/window_core.go b/internal/adapters/handlers/tui/window_core.go index 5ef2e51..e3c2279 100644 --- a/internal/adapters/handlers/tui/window_core.go +++ b/internal/adapters/handlers/tui/window_core.go @@ -1,12 +1,181 @@ package tui import ( - tea "github.com/charmbracelet/bubbletea" + "strings" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/services" ) +var currentPalette = map[string]string{ + "title": "#88C0D0", + "section": "#81A1C1", + "subtext": "#7C818C", + "text": "#D8DEE9", + "placeholder": "#7C818C", + "border": "#4C566A", + "error": "#BF616A", + "buttonDefFg": "#2E3440", + "buttonDefBg": "#4C566A", + "buttonSelFg": "#2E3440", + "buttonSelBg": "#A3BE8C", + "selectedFg": "#ECEFF4", + "selectedBg": "#5E81AC", + "help": "#7C818C", +} + +var presetPalettes = map[string]map[string]string{ + "nord": { + "title": "#88C0D0", + "section": "#81A1C1", + "subtext": "#7C818C", + "text": "#D8DEE9", + "placeholder": "#7C818C", + "border": "#4C566A", + "error": "#BF616A", + "buttonDefFg": "#2E3440", + "buttonDefBg": "#4C566A", + "buttonSelFg": "#2E3440", + "buttonSelBg": "#A3BE8C", + "selectedFg": "#ECEFF4", + "selectedBg": "#5E81AC", + "help": "#7C818C", + }, + "catppuccin": { + "title": "#89B4FA", + "section": "#B4BEFE", + "subtext": "#A6ADC8", + "text": "#CDD6F4", + "placeholder": "#6C7086", + "border": "#585B70", + "error": "#F38BA8", + "buttonDefFg": "#1E1E2E", + "buttonDefBg": "#585B70", + "buttonSelFg": "#1E1E2E", + "buttonSelBg": "#A6E3A1", + "selectedFg": "#CDD6F4", + "selectedBg": "#89B4FA", + "help": "#A6ADC8", + }, + "dracula": { + "title": "#BD93F9", + "section": "#8BE9FD", + "subtext": "#6272A4", + "text": "#F8F8F2", + "placeholder": "#6272A4", + "border": "#44475A", + "error": "#FF5555", + "buttonDefFg": "#282A36", + "buttonDefBg": "#44475A", + "buttonSelFg": "#282A36", + "buttonSelBg": "#50FA7B", + "selectedFg": "#F8F8F2", + "selectedBg": "#6272A4", + "help": "#6272A4", + }, + "ayu": { + "title": "#59C2FF", + "section": "#D4BFFF", + "subtext": "#5C6773", + "text": "#CBCCC6", + "placeholder": "#5C6773", + "border": "#3D4754", + "error": "#D95757", + "buttonDefFg": "#1F2430", + "buttonDefBg": "#3D4754", + "buttonSelFg": "#1F2430", + "buttonSelBg": "#AAD94C", + "selectedFg": "#CBCCC6", + "selectedBg": "#59C2FF", + "help": "#5C6773", + }, +} + +func init() { + presetPalettes["nord-light"] = map[string]string{ + "title": "#5E81AC", + "section": "#81A1C1", + "subtext": "#4C566A", + "text": "#2E3440", + "placeholder": "#7C818C", + "border": "#D8DEE9", + "error": "#BF616A", + "buttonDefFg": "#2E3440", + "buttonDefBg": "#D8DEE9", + "buttonSelFg": "#2E3440", + "buttonSelBg": "#A3BE8C", + "selectedFg": "#2E3440", + "selectedBg": "#88C0D0", + "help": "#6C6F7D", + } + + presetPalettes["catppuccin-light"] = map[string]string{ + "title": "#1E66F5", + "section": "#7287FD", + "subtext": "#6C6F85", + "text": "#4C4F69", + "placeholder": "#9CA0B0", + "border": "#BCC0CC", + "error": "#D20F39", + "buttonDefFg": "#4C4F69", + "buttonDefBg": "#BCC0CC", + "buttonSelFg": "#4C4F69", + "buttonSelBg": "#40A02B", + "selectedFg": "#4C4F69", + "selectedBg": "#8CAAEE", + "help": "#6C6F85", + } + + presetPalettes["dracula-light"] = map[string]string{ + "title": "#6272A4", + "section": "#8BE9FD", + "subtext": "#6D7086", + "text": "#282A36", + "placeholder": "#A0A0A0", + "border": "#E5E5E5", + "error": "#FF5555", + "buttonDefFg": "#282A36", + "buttonDefBg": "#E5E5E5", + "buttonSelFg": "#282A36", + "buttonSelBg": "#50FA7B", + "selectedFg": "#282A36", + "selectedBg": "#8BE9FD", + "help": "#6D7086", + } + + presetPalettes["ayu-light"] = map[string]string{ + "title": "#55B4D4", + "section": "#D4BFFF", + "subtext": "#8A9199", + "text": "#5C6773", + "placeholder": "#9AA5B1", + "border": "#E6E9EF", + "error": "#F07178", + "buttonDefFg": "#5C6773", + "buttonDefBg": "#E6E9EF", + "buttonSelFg": "#1F2430", + "buttonSelBg": "#AAD94C", + "selectedFg": "#5C6773", + "selectedBg": "#FFCC66", + "help": "#8A9199", + } +} + +func setPaletteByName(name string) { + if p, ok := presetPalettes[strings.ToLower(strings.TrimSpace(name))]; ok { + for k, v := range p { + currentPalette[k] = v + } + } +} + +type Options struct { + Theme string + Overrides map[string]string +} + type Window struct { projectSvc *services.ProjectService @@ -27,7 +196,16 @@ type Window struct { envProjectName string } -func NewWindow(projectSvc *services.ProjectService) (*Window, error) { +func NewWindow(projectSvc *services.ProjectService, opts Options) (*Window, error) { + if strings.TrimSpace(opts.Theme) != "" { + setPaletteByName(opts.Theme) + } + for k, v := range opts.Overrides { + if strings.TrimSpace(v) != "" { + currentPalette[k] = v + } + } + projects, err := projectSvc.List() if err != nil { return nil, err @@ -46,6 +224,8 @@ func NewWindow(projectSvc *services.ProjectService) (*Window, error) { }, nil } +func c(k string) lipgloss.Color { return lipgloss.Color(currentPalette[k]) } + func (m Window) Init() tea.Cmd { return tea.SetWindowTitle("Project Manager") } func (m *Window) SelectedProject() *domain.Project { return m.selectedProject } diff --git a/internal/adapters/handlers/tui/window_env_form.go b/internal/adapters/handlers/tui/window_env_form.go index decf6cf..e8c316d 100644 --- a/internal/adapters/handlers/tui/window_env_form.go +++ b/internal/adapters/handlers/tui/window_env_form.go @@ -43,9 +43,9 @@ func NewEnvironmentFormModel() *NewEnvironmentForm { name := bti.New() name.Prompt = "Env name*: " name.Placeholder = "staging" - name.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - name.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - name.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + name.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) + name.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) + name.TextStyle = lipgloss.NewStyle().Foreground(c("text")) name.Width = 60 name.Focus() @@ -53,17 +53,17 @@ func NewEnvironmentFormModel() *NewEnvironmentForm { color.Prompt = "Color (name/#hex/0-255): " color.Placeholder = "teal" color.SetValue("grey") - color.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - color.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - color.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + color.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) + color.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) + color.TextStyle = lipgloss.NewStyle().Foreground(c("subtext")) color.Width = 60 mode := bti.New() mode.Prompt = "Env vars mode* (merge/replace): " mode.Placeholder = domain.EnvVarsModeMerge mode.SetValue(domain.EnvVarsModeMerge) - mode.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - mode.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - mode.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + mode.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) + mode.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) + mode.TextStyle = lipgloss.NewStyle().Foreground(c("text")) mode.Width = 60 env := bta.New() env.Placeholder = "# One per line (like .env)\nAPI_URL=https://api.example.com\nLOG_LEVEL=info\n# comments allowed" @@ -253,17 +253,21 @@ func (f *NewEnvironmentForm) focusCurrent() { } func (f *NewEnvironmentForm) View() string { - box := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).Padding(1, 2) + box := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(c("border")).Padding(1, 2) help := lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")). - Render("Tab switch Enter next/newline Ctrl+S save Esc cancel") - styleSel := lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Padding(0, 1) - styleDef := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1) + Foreground(c("help")). + Render(`Move: Tab/↑/↓ Buttons: ←/β†’ +Next/Newline: Enter Save: Ctrl+S Cancel: Esc`) styleTitle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("14")). + Foreground(c("title")). Align(lipgloss.Center). Border(lipgloss.NormalBorder(), false, false, true). + BorderForeground(c("border")). Padding(0, 1) + styleSection := lipgloss.NewStyle().Foreground(c("section")).Bold(true) + styleSub := lipgloss.NewStyle().Foreground(c("subtext")) + btnDef := lipgloss.NewStyle().Foreground(c("buttonDefFg")).Background(c("buttonDefBg")).Padding(0, 2) + btnSel := lipgloss.NewStyle().Foreground(c("buttonSelFg")).Background(c("buttonSelBg")).Padding(0, 2) b := strings.Builder{} title := "New environment" if f.isEdit { @@ -271,36 +275,38 @@ func (f *NewEnvironmentForm) View() string { } b.WriteString(styleTitle.Render(title)) b.WriteString("\n") + b.WriteString(styleSection.Render("Environment details")) + b.WriteString("\n") + b.WriteString(styleSub.Render("Display and behavior")) + b.WriteString("\n") b.WriteString(f.name.View()) b.WriteString("\n") b.WriteString(f.color.View()) + b.WriteString("\n\n") + b.WriteString(styleSection.Render("Variables")) b.WriteString("\n") - b.WriteString(f.mode.View()) + b.WriteString(styleSub.Render("One KEY=VALUE per line; '#' comments allowed")) b.WriteString("\n") - b.WriteString( - lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")). - Render("Env vars (.env format): one KEY=VALUE per line; '#' comments allowed"), - ) + b.WriteString(f.mode.View()) b.WriteString("\n") b.WriteString(f.envVars.View()) - b.WriteString("\n") + b.WriteString("\n\n") - btnSave := styleDef.Render("Save") + btnSave := btnDef.Render(" Save ") if f.focused == 4 { - btnSave = styleSel.Render("Save") + btnSave = btnSel.Render(" Save ") } - btnCancel := styleDef.Render("Cancel") + btnCancel := btnDef.Render(" Cancel ") if f.focused == 5 { - btnCancel = styleSel.Render("Cancel") + btnCancel = btnSel.Render(" Cancel ") } - b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, btnSave, " ", btnCancel)) + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, btnSave, " ", btnCancel)) b.WriteString("\n\n") if f.err != "" { - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(f.err)) + b.WriteString(lipgloss.NewStyle().Foreground(c("error")).Render(f.err)) b.WriteString("\n") } diff --git a/internal/adapters/handlers/tui/window_flow.go b/internal/adapters/handlers/tui/window_flow.go index 06225f4..7af68b8 100644 --- a/internal/adapters/handlers/tui/window_flow.go +++ b/internal/adapters/handlers/tui/window_flow.go @@ -144,12 +144,13 @@ func (p *postCreatePrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (p *postCreatePrompt) View() string { styleTitle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("14")). + Foreground(c("title")). Align(lipgloss.Center). Border(lipgloss.NormalBorder(), false, false, true). + BorderForeground(c("border")). Padding(0, 1) - styleSel := lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Padding(0, 1) - styleDef := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1) + styleSel := lipgloss.NewStyle().Foreground(c("selectedFg")).Background(c("selectedBg")).Padding(0, 1) + styleDef := lipgloss.NewStyle().Foreground(c("subtext")).Padding(0, 1) b := strings.Builder{} b.WriteString(styleTitle.Render("Project created. What next?")) b.WriteString("\n") @@ -168,8 +169,9 @@ func (p *postCreatePrompt) View() string { } help := lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")). - Render("Keys: ↑/k ↓/j navigate Enter select Esc cancel") + Foreground(c("help")). + Render(`Navigate: ↑/k ↓/j +Select: Enter Cancel: Esc/q/Ctrl+C`) return lipgloss.JoinVertical(lipgloss.Left, b.String(), help) } diff --git a/internal/adapters/handlers/tui/window_form.go b/internal/adapters/handlers/tui/window_form.go index 1a9cdac..3c2f76e 100644 --- a/internal/adapters/handlers/tui/window_form.go +++ b/internal/adapters/handlers/tui/window_form.go @@ -45,13 +45,13 @@ type NewProjectForm struct { func NewProjectFormModel() *NewProjectForm { ni := bti.New() - sc := lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - sr := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + sc := lipgloss.NewStyle().Foreground(c("title")) + sr := lipgloss.NewStyle().Foreground(c("error")) ni.Prompt = sc.Render("Name") + sr.Render("*") + sc.Render(": ") ni.Placeholder = "my-awesome-app" ni.PromptStyle = lipgloss.NewStyle() - ni.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - ni.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + ni.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) + ni.TextStyle = lipgloss.NewStyle().Foreground(c("text")) ni.Width = 40 ni.Focus() @@ -59,59 +59,59 @@ func NewProjectFormModel() *NewProjectForm { pi.Prompt = sc.Render("Path") + sr.Render("*") + sc.Render(": ") pi.Placeholder = "~/my-awesome-app" pi.PromptStyle = lipgloss.NewStyle() - pi.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - pi.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + pi.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) + pi.TextStyle = lipgloss.NewStyle().Foreground(c("text")) pi.Width = 40 spi := bti.New() spi.Prompt = "Subproject: " spi.Placeholder = "services/api" - spi.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - spi.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - spi.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + spi.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) + spi.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) + spi.TextStyle = lipgloss.NewStyle().Foreground(c("text")) spi.Width = 40 un := bti.New() un.Prompt = "Git user.name: " un.Placeholder = "Jane Doe" - un.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - un.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - un.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + un.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) + un.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) + un.TextStyle = lipgloss.NewStyle().Foreground(c("text")) un.Width = 40 uem := bti.New() uem.Prompt = "Git user.email: " uem.Placeholder = "jane@example.com" - uem.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - uem.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - uem.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + uem.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) + uem.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) + uem.TextStyle = lipgloss.NewStyle().Foreground(c("text")) uem.Width = 40 usk := bti.New() usk.Prompt = "Git user.signingkey: " usk.Placeholder = "0xDEADBEEF" - usk.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - usk.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - usk.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + usk.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) + usk.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) + usk.TextStyle = lipgloss.NewStyle().Foreground(c("text")) usk.Width = 40 csg := bti.New() csg.Prompt = "commit.gpgsign (true/false): " csg.Placeholder = strTrue csg.SetValue(strTrue) - csg.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - csg.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - csg.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + csg.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) + csg.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) + csg.TextStyle = lipgloss.NewStyle().Foreground(c("text")) csg.Width = 40 tsg := bti.New() tsg.Prompt = "tag.gpgsign (true/false): " tsg.Placeholder = strTrue tsg.SetValue(strTrue) - tsg.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - tsg.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - tsg.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + tsg.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) + tsg.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) + tsg.TextStyle = lipgloss.NewStyle().Foreground(c("text")) tsg.Width = 40 sh := bti.New() sh.Prompt = "Shell: " sh.Placeholder = "/bin/bash" - sh.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - sh.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - sh.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) + sh.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) + sh.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) + sh.TextStyle = lipgloss.NewStyle().Foreground(c("text")) sh.Width = 40 ev := bta.New() ev.Placeholder = "# One per line (like .env)\nAPP_ENV=development\nDATABASE_URL=postgres://user:pass@localhost:5432/app\n# comments allowed" @@ -425,18 +425,22 @@ func (f *NewProjectForm) focusCurrent() { } func (f *NewProjectForm) View() string { - box := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).Padding(1, 2) + box := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(c("border")).Padding(1, 2) help := lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")). - Render("Tab/↑/↓ move ←/β†’ buttons Enter next/newline Ctrl+S save Esc cancel") - errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) - styleSel := lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Padding(0, 1) - styleDef := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1) + Foreground(c("help")). + Render(`Move: Tab/↑/↓ Buttons: ←/β†’ +Next/Newline: Enter Save: Ctrl+S Cancel: Esc`) + errStyle := lipgloss.NewStyle().Foreground(c("error")) styleTitle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("14")). + Foreground(c("title")). Align(lipgloss.Center). Border(lipgloss.NormalBorder(), false, false, true). + BorderForeground(c("border")). Padding(0, 1) + styleSection := lipgloss.NewStyle().Foreground(c("section")).Bold(true) + styleSub := lipgloss.NewStyle().Foreground(c("subtext")) + btnDef := lipgloss.NewStyle().Foreground(c("buttonDefFg")).Background(c("buttonDefBg")).Padding(0, 2) + btnSel := lipgloss.NewStyle().Foreground(c("buttonSelFg")).Background(c("buttonSelBg")).Padding(0, 2) b := strings.Builder{} title := "New project" if f.isEdit { @@ -444,6 +448,10 @@ func (f *NewProjectForm) View() string { } b.WriteString(styleTitle.Render(title)) b.WriteString("\n") + b.WriteString(styleSection.Render("Project details")) + b.WriteString("\n") + b.WriteString(styleSub.Render("Basic information")) + b.WriteString("\n") b.WriteString(f.name.View()) b.WriteString("\n") pathView := f.path.View() @@ -451,10 +459,18 @@ func (f *NewProjectForm) View() string { pathView = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(f.path.Value() + " (read-only)") } b.WriteString(pathView) + b.WriteString("\n\n") + b.WriteString(styleSection.Render("Project options")) + b.WriteString("\n") + b.WriteString(styleSub.Render("Optional settings")) b.WriteString("\n") b.WriteString(f.subproject.View()) b.WriteString("\n") b.WriteString(f.shell.View()) + b.WriteString("\n\n") + b.WriteString(styleSection.Render("Git settings")) + b.WriteString("\n") + b.WriteString(styleSub.Render("Applied to this project only")) b.WriteString("\n") b.WriteString(f.userName.View()) b.WriteString("\n") @@ -465,27 +481,25 @@ func (f *NewProjectForm) View() string { b.WriteString(f.commitGPGSign.View()) b.WriteString("\n") b.WriteString(f.tagGPGSign.View()) + b.WriteString("\n\n") + b.WriteString(styleSection.Render("Environment variables")) b.WriteString("\n") - b.WriteString( - lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")). - Render("Env vars (.env format): one KEY=VALUE per line; '#' comments allowed"), - ) + b.WriteString(styleSub.Render("One KEY=VALUE per line; '#' comments allowed")) b.WriteString("\n") b.WriteString(f.envVars.View()) - b.WriteString("\n") + b.WriteString("\n\n") - btnSave := styleDef.Render("Save") + btnSave := btnDef.Render(" Save ") if f.focused == 10 { - btnSave = styleSel.Render("Save") + btnSave = btnSel.Render(" Save ") } - btnCancel := styleDef.Render("Cancel") + btnCancel := btnDef.Render(" Cancel ") if f.focused == 11 { - btnCancel = styleSel.Render("Cancel") + btnCancel = btnSel.Render(" Cancel ") } - b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, btnSave, " ", btnCancel)) + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, btnSave, " ", btnCancel)) b.WriteString("\n\n") if f.err != "" { diff --git a/internal/adapters/handlers/tui/window_view.go b/internal/adapters/handlers/tui/window_view.go index 02c7890..f825fd8 100644 --- a/internal/adapters/handlers/tui/window_view.go +++ b/internal/adapters/handlers/tui/window_view.go @@ -24,15 +24,16 @@ func (m Window) View() string { left := strings.Builder{} title := " Projects" titleStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("14")). + Foreground(c("title")). Align(lipgloss.Center). Border(lipgloss.NormalBorder(), false, false, true). + BorderForeground(c("border")). Padding(0, 1) selectedStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")).Padding(0, 1) + Foreground(c("selectedFg")). + Background(c("selectedBg")).Padding(0, 1) defaultStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")).Padding(0, 1) + Foreground(c("subtext")).Padding(0, 1) left.WriteString(titleStyle.Render(title)) left.WriteString("\n") @@ -93,7 +94,7 @@ func (m Window) View() string { content := defaultStyle.Foreground(lipgloss.Color(env.Color)).Render("● " + env.Name) if p.DefaultEnv != "" && env.Name == p.DefaultEnv { - content = content + " " + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("(default)") + content = content + " " + lipgloss.NewStyle().Foreground(c("subtext")).Render("(default)") } right.WriteString(marker) @@ -116,8 +117,9 @@ func (m Window) View() string { content := lipgloss.JoinHorizontal(lipgloss.Left, left.String(), " ", right.String()) help := lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")). - Render("Keys: ↑/k ↓/j navigate ←/h β†’/l focus e edit Enter select Esc/Ctrl+C exit") + Foreground(c("help")). + Render(`Navigate: ↑/k ↓/j Focus: ←/h β†’/l +Select: Enter Edit: e Exit: Esc/Ctrl+C/q`) return lipgloss.JoinVertical(lipgloss.Left, content, help) } diff --git a/pkg/ui/card/card.go b/pkg/ui/card/card.go deleted file mode 100644 index 6311cc1..0000000 --- a/pkg/ui/card/card.go +++ /dev/null @@ -1,58 +0,0 @@ -package card - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type Card struct { - Title string - SubTitle string - - Styles Styles -} - -func (m Card) Init() tea.Cmd { - return nil -} - -func (m Card) Update(_ tea.Msg) (tea.Model, tea.Cmd) { - return m, nil -} - -func (m Card) View() string { - var view string - - view = lipgloss.JoinVertical( - lipgloss.Left, - m.Styles.Title.Render(m.Title), - m.Styles.Subtitle.Render(ellipsis(m.SubTitle, 15)), - ) - - view = m.Styles.Border.Render(view) - - return view -} - -func NewCard(title, subtitle string) Card { - styles := DefaultStyles() - - return Card{ - Title: title, - SubTitle: subtitle, - Styles: styles, - } -} - -func ellipsis(s string, maxLen int) string { - runes := []rune(s) - if len(runes) <= maxLen { - return s - } - - if maxLen < 3 { - maxLen = 3 - } - - return string(runes[:maxLen-3]) + "..." -} diff --git a/pkg/ui/card/styles.go b/pkg/ui/card/styles.go deleted file mode 100644 index 9c832e7..0000000 --- a/pkg/ui/card/styles.go +++ /dev/null @@ -1,25 +0,0 @@ -package card - -import "github.com/charmbracelet/lipgloss" - -type Styles struct { - Title lipgloss.Style - Subtitle lipgloss.Style - - Border lipgloss.Style -} - -func DefaultStyles() Styles { - return Styles{ - Title: lipgloss.NewStyle(). - Foreground(lipgloss.Color("62")). - Bold(true). - Padding(0, 1), - Subtitle: lipgloss.NewStyle(). - Foreground(lipgloss.Color("42")). - Padding(0, 2), - // Border: lipgloss.NewStyle(). - // BorderStyle(lipgloss.NormalBorder()). - // BorderForeground(lipgloss.Color("lightgrey")), - } -} diff --git a/pkg/ui/list/envs_list.go b/pkg/ui/list/envs_list.go deleted file mode 100644 index 9caac4f..0000000 --- a/pkg/ui/list/envs_list.go +++ /dev/null @@ -1,10 +0,0 @@ -package list - -func RenderNames(title string, names []string) string { - it := []Item{} - for _, n := range names { - it = append(it, Item{Name: n}) - } - - return NewList(title, it).View() -} diff --git a/pkg/ui/list/list.go b/pkg/ui/list/list.go deleted file mode 100644 index bce5c47..0000000 --- a/pkg/ui/list/list.go +++ /dev/null @@ -1,60 +0,0 @@ -package list - -import ( - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -var docStyle = lipgloss.NewStyle().Margin(1, 2) - -type Item struct { - Name, Desc string -} - -func (i Item) Title() string { return i.Name } -func (i Item) Description() string { return i.Desc } -func (i Item) FilterValue() string { return i.Name } - -type List struct { - list list.Model -} - -func (m List) Init() tea.Cmd { - return nil -} - -func (m List) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - if msg.String() == "ctrl+c" { - return m, tea.Quit - } - case tea.WindowSizeMsg: - h, v := docStyle.GetFrameSize() - m.list.SetSize(msg.Width-h, msg.Height-v) - } - - var cmd tea.Cmd - - m.list, cmd = m.list.Update(msg) - - return m, cmd -} - -func (m List) View() string { - return docStyle.Render(m.list.View()) -} - -func NewList(title string, items []Item) List { - i := []list.Item{} - for _, item := range items { - i = append(i, item) - } - - l := List{list: list.New(i, list.NewDefaultDelegate(), 0, 0)} - - l.list.Title = title - - return l -} diff --git a/pkg/ui/styles/styles.go b/pkg/ui/styles/styles.go deleted file mode 100644 index 0c360a0..0000000 --- a/pkg/ui/styles/styles.go +++ /dev/null @@ -1,59 +0,0 @@ -package styles - -import ( - "github.com/charmbracelet/lipgloss" -) - -type Style string - -const ( - Default Style = "default" - Dracula Style = "dracula" - Nord Style = "nord" - Catppuccin Style = "catppuccin" -) - -func GetStyle(style Style) lipgloss.Style { - switch style { - case Catppuccin: - return CatppuccinStyle - case Dracula: - return DraculaStyle - case Nord: - return NordStyle - default: - return DefaultStyle - } -} - -var CatppuccinStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FAFAFA")). - Background(lipgloss.Color("#7D56F4")). - PaddingTop(2). - PaddingLeft(4). - Width(22) - -var DefaultStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FAFAFA")). - Background(lipgloss.Color("#7D56F4")). - PaddingTop(2). - PaddingLeft(4). - Width(22) - -var DraculaStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FAFAFA")). - Background(lipgloss.Color("#7D56F4")). - PaddingTop(2). - PaddingLeft(4). - Width(22) - -var NordStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FAFAFA")). - Background(lipgloss.Color("#7D56F4")). - PaddingTop(2). - PaddingLeft(4). - Width(22) diff --git a/pkg/ui/textinput/textinput.go b/pkg/ui/textinput/textinput.go deleted file mode 100644 index be201b2..0000000 --- a/pkg/ui/textinput/textinput.go +++ /dev/null @@ -1,63 +0,0 @@ -package textinput - -import ( - "fmt" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" -) - -type TextInput struct { - textInput textinput.Model - err error -} - -func NewTextInput(prompt, sugestion string) *TextInput { - ti := textinput.New() - ti.Placeholder = sugestion - ti.Prompt = prompt - ti.Focus() - - return &TextInput{ - textInput: ti, - err: nil, - } -} - -func (m *TextInput) Value() string { - return m.textInput.Value() -} - -func (m *TextInput) Init() tea.Cmd { - return textinput.Blink -} - -func (m *TextInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.Type { - case tea.KeyEnter, tea.KeyCtrlC, tea.KeyEsc: - return m, tea.Quit - } - - // We handle errors just like any other message - case error: - m.err = msg - - return m, nil - } - - m.textInput, cmd = m.textInput.Update(msg) - - return m, cmd -} - -func (m *TextInput) View() string { - return fmt.Sprintf( - "\n%s\n\n%s", - m.textInput.View(), - "(esc to quit)", - ) + "\n" -} diff --git a/tests/unit/pkg_ui_envs_list_test.go b/tests/unit/pkg_ui_envs_list_test.go deleted file mode 100644 index 551f0e5..0000000 --- a/tests/unit/pkg_ui_envs_list_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package unit - -import ( - "testing" - - list "github.com/jlrosende/project-manager/pkg/ui/list" -) - -func TestRenderNames(t *testing.T) { - out := list.RenderNames("", []string{"dev", "pre"}) - if len(out) == 0 { - t.Fatal("expected non-empty render output") - } -} diff --git a/tests/unit/pkg_ui_styles_test.go b/tests/unit/pkg_ui_styles_test.go deleted file mode 100644 index 0cc5655..0000000 --- a/tests/unit/pkg_ui_styles_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package unit - -import ( - "testing" - - "github.com/jlrosende/project-manager/pkg/ui/styles" -) - -func TestDefaultStyleWidth(t *testing.T) { - s := styles.DefaultStyle - if s.GetWidth() == 0 { - t.Fatal("expected non-zero width") - } -} From 10ed848c97c7ecb687a27d7613c5ca897ea4696b Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Wed, 17 Sep 2025 00:16:28 +0200 Subject: [PATCH 12/27] Some fixes and more themes --- .golangci.yml | 46 +--- cmd/cli/root.go | 73 ++++-- configs/config.go | 43 ++-- go.mod | 3 +- go.sum | 2 - internal/adapters/handlers/tui/styles.go | 24 ++ internal/adapters/handlers/tui/window_core.go | 6 + .../adapters/handlers/tui/window_env_form.go | 43 ++-- internal/adapters/handlers/tui/window_flow.go | 26 +- internal/adapters/handlers/tui/window_form.go | 40 +++- .../adapters/handlers/tui/window_update.go | 223 ++++++++++++------ internal/adapters/handlers/tui/window_view.go | 18 +- .../adapters/repositories/git_repository.go | 20 +- .../repositories/project_repository.go | 14 ++ .../repositories/shells/shell_repository.go | 1 + 15 files changed, 377 insertions(+), 205 deletions(-) create mode 100644 internal/adapters/handlers/tui/styles.go diff --git a/.golangci.yml b/.golangci.yml index b24309e..d2f69e9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,45 +3,17 @@ run: concurrency: 4 timeout: 5m linters: - default: fast enable: - wsl_v5 - - govet - - staticcheck - - ineffassign - - errcheck - - unused - - revive - - gocritic - - bodyclose - - unparam - - cyclop - - dupl - - goconst - - funlen - - depguard - - misspell - - forbidigo - - rowserrcheck - - sqlclosecheck - - noctx - - copyloopvar - disable: - - wsl - - wsl_v5 - - depguard - - mnd - - lll - - cyclop - - gocyclo - - nestif - - gocognit - - nlreturn - - funcorder - - funlen - - testpackage - - gochecknoinits - - maintidx + - govet # reports suspicious constructs + - staticcheck # advanced static analysis + - ineffassign # detects unused assignments + - revive # replacement for golint, configurable + - errcheck # ensures errors are checked + - gosec # security checks + - dupl # finds duplicated code + - unparam # checks unused function parameters + # - gocyclo # checks for high cyclomatic complexity settings: wsl_v5: allow-first-in-block: true diff --git a/cmd/cli/root.go b/cmd/cli/root.go index c866c6e..54233bf 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -137,16 +137,19 @@ func root(cmd *cobra.Command, args []string) error { if len(args) == 0 || name == "" { themeFlag, _ := cmd.PersistentFlags().GetString("theme") configFlag, _ := cmd.PersistentFlags().GetString("config") + cfgPath := strings.TrimSpace(configFlag) if cfgPath == "" { if v, ok := os.LookupEnv("PM_CONFIG"); ok && strings.TrimSpace(v) != "" { cfgPath = v } } + cfg, cfgErr := configs.GetConfig(cfgPath) if cfgPath != "" && cfgErr != nil { return cfgErr } + theme := strings.TrimSpace(themeFlag) if theme == "" { if v, ok := os.LookupEnv("PM_THEME"); ok && strings.TrimSpace(v) != "" { @@ -157,23 +160,65 @@ func root(cmd *cobra.Command, args []string) error { } ov := map[string]string{} + if cfg != nil { for _, ct := range cfg.CustomThemes { if strings.EqualFold(ct.Name, theme) { - if ct.Title != "" { ov["title"] = ct.Title } - if ct.Section != "" { ov["section"] = ct.Section } - if ct.Subtext != "" { ov["subtext"] = ct.Subtext } - if ct.Text != "" { ov["text"] = ct.Text } - if ct.Placeholder != "" { ov["placeholder"] = ct.Placeholder } - if ct.Border != "" { ov["border"] = ct.Border } - if ct.Error != "" { ov["error"] = ct.Error } - if ct.ButtonDefFg != "" { ov["buttonDefFg"] = ct.ButtonDefFg } - if ct.ButtonDefBg != "" { ov["buttonDefBg"] = ct.ButtonDefBg } - if ct.ButtonSelFg != "" { ov["buttonSelFg"] = ct.ButtonSelFg } - if ct.ButtonSelBg != "" { ov["buttonSelBg"] = ct.ButtonSelBg } - if ct.SelectedFg != "" { ov["selectedFg"] = ct.SelectedFg } - if ct.SelectedBg != "" { ov["selectedBg"] = ct.SelectedBg } - if ct.Help != "" { ov["help"] = ct.Help } + if ct.Title != "" { + ov["title"] = ct.Title + } + + if ct.Section != "" { + ov["section"] = ct.Section + } + + if ct.Subtext != "" { + ov["subtext"] = ct.Subtext + } + + if ct.Text != "" { + ov["text"] = ct.Text + } + + if ct.Placeholder != "" { + ov["placeholder"] = ct.Placeholder + } + + if ct.Border != "" { + ov["border"] = ct.Border + } + + if ct.Error != "" { + ov["error"] = ct.Error + } + + if ct.ButtonDefFg != "" { + ov["buttonDefFg"] = ct.ButtonDefFg + } + + if ct.ButtonDefBg != "" { + ov["buttonDefBg"] = ct.ButtonDefBg + } + + if ct.ButtonSelFg != "" { + ov["buttonSelFg"] = ct.ButtonSelFg + } + + if ct.ButtonSelBg != "" { + ov["buttonSelBg"] = ct.ButtonSelBg + } + + if ct.SelectedFg != "" { + ov["selectedFg"] = ct.SelectedFg + } + + if ct.SelectedBg != "" { + ov["selectedBg"] = ct.SelectedBg + } + + if ct.Help != "" { + ov["help"] = ct.Help + } } } } diff --git a/configs/config.go b/configs/config.go index 1c594d1..e67fc41 100644 --- a/configs/config.go +++ b/configs/config.go @@ -9,26 +9,26 @@ import ( ) type Config struct { - Theme string `hcl:"theme"` - CustomThemes []CustomTheme `hcl:"custom_theme,block"` + Theme string `hcl:"theme"` + CustomThemes []CustomTheme `hcl:"custom_theme,block"` } type CustomTheme struct { - Name string `hcl:"name,label"` - Title string `hcl:"title,optional"` - Section string `hcl:"section,optional"` - Subtext string `hcl:"subtext,optional"` - Text string `hcl:"text,optional"` - Placeholder string `hcl:"placeholder,optional"` - Border string `hcl:"border,optional"` - Error string `hcl:"error,optional"` - ButtonDefFg string `hcl:"buttonDefFg,optional"` - ButtonDefBg string `hcl:"buttonDefBg,optional"` - ButtonSelFg string `hcl:"buttonSelFg,optional"` - ButtonSelBg string `hcl:"buttonSelBg,optional"` - SelectedFg string `hcl:"selectedFg,optional"` - SelectedBg string `hcl:"selectedBg,optional"` - Help string `hcl:"help,optional"` + Name string `hcl:"name,label"` + Title string `hcl:"title,optional"` + Section string `hcl:"section,optional"` + Subtext string `hcl:"subtext,optional"` + Text string `hcl:"text,optional"` + Placeholder string `hcl:"placeholder,optional"` + Border string `hcl:"border,optional"` + Error string `hcl:"error,optional"` + ButtonDefFg string `hcl:"buttonDefFg,optional"` + ButtonDefBg string `hcl:"buttonDefBg,optional"` + ButtonSelFg string `hcl:"buttonSelFg,optional"` + ButtonSelBg string `hcl:"buttonSelBg,optional"` + SelectedFg string `hcl:"selectedFg,optional"` + SelectedBg string `hcl:"selectedBg,optional"` + Help string `hcl:"help,optional"` } func GetConfig(cfgFile string) (*Config, error) { @@ -37,34 +37,43 @@ func GetConfig(cfgFile string) (*Config, error) { cfgFile = env } } + if strings.HasPrefix(cfgFile, "~/") { h, _ := os.UserHomeDir() cfgFile = filepath.Join(h, cfgFile[2:]) } + if strings.TrimSpace(cfgFile) != "" { var cfg Config if err := hclsimple.DecodeFile(cfgFile, nil, &cfg); err != nil { return nil, err } + if strings.TrimSpace(cfg.Theme) == "" { cfg.Theme = "nord" } + return &cfg, nil } + configDir, err := os.UserConfigDir() if err != nil { return &Config{Theme: "nord"}, nil } + defaultPath := filepath.Join(configDir, "pm", "config.hcl") if _, err := os.Stat(defaultPath); err == nil { var cfg Config if err := hclsimple.DecodeFile(defaultPath, nil, &cfg); err != nil { return nil, err } + if strings.TrimSpace(cfg.Theme) == "" { cfg.Theme = "nord" } + return &cfg, nil } + return &Config{Theme: "nord"}, nil } diff --git a/go.mod b/go.mod index d816e4d..852aa19 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty/v2 v2.0.1 github.com/go-git/go-git/v5 v5.16.2 - github.com/go-viper/mapstructure/v2 v2.4.0 github.com/hashicorp/hcl/v2 v2.24.0 github.com/joho/godotenv v1.6.0-pre.2 github.com/spf13/cobra v1.10.1 @@ -224,6 +223,7 @@ require ( github.com/go-toolsmith/astp v1.1.0 // indirect github.com/go-toolsmith/strparse v1.1.0 // indirect github.com/go-toolsmith/typep v1.1.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gofrs/flock v0.12.1 // indirect @@ -388,7 +388,6 @@ require ( github.com/ryancurrah/gomodguard v1.4.1 // indirect github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sashamelentyev/interfacebloat v1.1.0 // indirect diff --git a/go.sum b/go.sum index 54fe6fd..2182879 100644 --- a/go.sum +++ b/go.sum @@ -1048,8 +1048,6 @@ github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkB github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= diff --git a/internal/adapters/handlers/tui/styles.go b/internal/adapters/handlers/tui/styles.go new file mode 100644 index 0000000..982fd95 --- /dev/null +++ b/internal/adapters/handlers/tui/styles.go @@ -0,0 +1,24 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +type Styles struct { + Title lipgloss.Style + Selected lipgloss.Style + Default lipgloss.Style + Help lipgloss.Style +} + +func BuildStyles() Styles { + return Styles{ + Title: lipgloss.NewStyle(). + Foreground(c("title")). + Align(lipgloss.Center). + Border(lipgloss.NormalBorder(), false, false, true). + BorderForeground(c("border")). + Padding(0, 1), + Selected: lipgloss.NewStyle().Foreground(c("selectedFg")).Background(c("selectedBg")).Padding(0, 1), + Default: lipgloss.NewStyle().Foreground(c("subtext")).Padding(0, 1), + Help: lipgloss.NewStyle().Foreground(c("help")), + } +} diff --git a/internal/adapters/handlers/tui/window_core.go b/internal/adapters/handlers/tui/window_core.go index e3c2279..0c86265 100644 --- a/internal/adapters/handlers/tui/window_core.go +++ b/internal/adapters/handlers/tui/window_core.go @@ -5,6 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/services" ) @@ -194,18 +195,22 @@ type Window struct { prompt *postCreatePrompt envForm *NewEnvironmentForm envProjectName string + styles Styles } func NewWindow(projectSvc *services.ProjectService, opts Options) (*Window, error) { if strings.TrimSpace(opts.Theme) != "" { setPaletteByName(opts.Theme) } + for k, v := range opts.Overrides { if strings.TrimSpace(v) != "" { currentPalette[k] = v } } + s := BuildStyles() + projects, err := projectSvc.List() if err != nil { return nil, err @@ -221,6 +226,7 @@ func NewWindow(projectSvc *services.ProjectService, opts Options) (*Window, erro projects: projects, total: total, cursor: 0, + styles: s, }, nil } diff --git a/internal/adapters/handlers/tui/window_env_form.go b/internal/adapters/handlers/tui/window_env_form.go index e8c316d..d29ebec 100644 --- a/internal/adapters/handlers/tui/window_env_form.go +++ b/internal/adapters/handlers/tui/window_env_form.go @@ -12,16 +12,6 @@ import ( ) const ( - keyEsc = "esc" - keyCtrlC = "ctrl+c" - keyTab = "tab" - keyShiftTab = "shift+tab" - keyEnter = "enter" - keyUp = "up" - keyDown = "down" - keyLeft = "left" - keyRight = "right" - keyCtrlS = "ctrl+s" errNameRequired = "name is required" errModeMustBeVal = "mode must be merge or replace" ) @@ -80,6 +70,7 @@ func NewEnvironmentEditFormModel(e *domain.Environment) *NewEnvironmentForm { f.name.SetValue(e.Name) f.color.SetValue(e.Color) f.mode.SetValue(e.EnvVarsMode) + return f } @@ -91,20 +82,22 @@ func (f *NewEnvironmentForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if w < 30 { w = 30 } + f.name.Width = w f.color.Width = w f.mode.Width = w f.envVars.SetWidth(w + 10) } + if m, ok := msg.(tea.KeyMsg); ok { - switch m.String() { - case keyEsc, keyCtrlC: + switch m.Type { + case tea.KeyEsc, tea.KeyCtrlC: f.canceled = true f.submitted = false return f, nil - case keyTab, keyShiftTab: - if m.String() == keyTab { + case tea.KeyTab, tea.KeyShiftTab: + if m.Type == tea.KeyTab { f.focused = (f.focused + 1) % 6 } else { f.focused = (f.focused + 5) % 6 @@ -114,12 +107,13 @@ func (f *NewEnvironmentForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { f.focusCurrent() return f, nil - case keyEnter: + case tea.KeyEnter: switch { case f.focused < 3: f.focused = (f.focused + 1) % 6 f.blurAll() f.focusCurrent() + return f, nil case f.focused == 4: f.err = "" @@ -127,20 +121,24 @@ func (f *NewEnvironmentForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { f.err = errNameRequired return f, nil } + if strings.TrimSpace(f.mode.Value()) != domain.EnvVarsModeMerge && strings.TrimSpace(f.mode.Value()) != domain.EnvVarsModeReplace { f.err = errModeMustBeVal return f, nil } + f.submitted = true f.canceled = false + return f, nil case f.focused == 5: f.canceled = true f.submitted = false + return f, nil } - case keyUp: + case tea.KeyUp: if f.focused != 3 { f.focused-- if f.focused < 0 { @@ -152,7 +150,7 @@ func (f *NewEnvironmentForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return f, nil } - case keyDown: + case tea.KeyDown: if f.focused != 3 { f.focused++ if f.focused > 5 { @@ -164,7 +162,7 @@ func (f *NewEnvironmentForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return f, nil } - case keyLeft: + case tea.KeyLeft: if f.focused == 5 { f.focused = 4 f.blurAll() @@ -172,7 +170,7 @@ func (f *NewEnvironmentForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return f, nil } - case keyRight: + case tea.KeyRight: if f.focused == 4 { f.focused = 5 f.blurAll() @@ -180,19 +178,16 @@ func (f *NewEnvironmentForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return f, nil } - // when textarea focused, let Enter insert newline - case keyCtrlS: + case tea.KeyCtrlS: f.err = "" if strings.TrimSpace(f.name.Value()) == "" { f.err = errNameRequired - return f, nil } if strings.TrimSpace(f.mode.Value()) != domain.EnvVarsModeMerge && strings.TrimSpace(f.mode.Value()) != domain.EnvVarsModeReplace { f.err = "mode must be merge or replace" - return f, nil } @@ -269,10 +264,12 @@ Next/Newline: Enter Save: Ctrl+S Cancel: Esc`) btnDef := lipgloss.NewStyle().Foreground(c("buttonDefFg")).Background(c("buttonDefBg")).Padding(0, 2) btnSel := lipgloss.NewStyle().Foreground(c("buttonSelFg")).Background(c("buttonSelBg")).Padding(0, 2) b := strings.Builder{} + title := "New environment" if f.isEdit { title = "Edit environment" } + b.WriteString(styleTitle.Render(title)) b.WriteString("\n") b.WriteString(styleSection.Render("Environment details")) diff --git a/internal/adapters/handlers/tui/window_flow.go b/internal/adapters/handlers/tui/window_flow.go index 7af68b8..1ef789b 100644 --- a/internal/adapters/handlers/tui/window_flow.go +++ b/internal/adapters/handlers/tui/window_flow.go @@ -117,24 +117,40 @@ func (p *postCreatePrompt) Init() tea.Cmd { return nil } func (p *postCreatePrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m := msg.(type) { case tea.KeyMsg: - switch m.String() { - case keyUp, "k": + switch m.Type { + case tea.KeyUp: if p.idx > 0 { p.idx-- } - case keyDown, "j": + case tea.KeyDown: if p.idx < 2 { p.idx++ } - case keyEnter: + case tea.KeyEnter: p.choice = p.idx return p, tea.Quit - case keyEsc, "q", keyCtrlC: + case tea.KeyEsc, tea.KeyCtrlC: p.choice = 0 return p, tea.Quit } + + if m.Type == tea.KeyRunes { + switch string(m.Runes) { + case "k": + if p.idx > 0 { + p.idx-- + } + case "j": + if p.idx < 2 { + p.idx++ + } + case "q": + p.choice = 0 + return p, tea.Quit + } + } case tea.WindowSizeMsg: return p, nil } diff --git a/internal/adapters/handlers/tui/window_form.go b/internal/adapters/handlers/tui/window_form.go index 3c2f76e..8415fd2 100644 --- a/internal/adapters/handlers/tui/window_form.go +++ b/internal/adapters/handlers/tui/window_form.go @@ -157,14 +157,14 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { f.tagGPGSign.Width = w f.envVars.SetWidth(w + 10) case tea.KeyMsg: - switch msg.String() { - case "esc", "ctrl+c": + switch msg.Type { + case tea.KeyEsc, tea.KeyCtrlC: f.canceled = true f.submitted = false return f, nil - case keyTab, keyShiftTab: - if msg.String() == keyTab { + case tea.KeyTab, tea.KeyShiftTab: + if msg.Type == tea.KeyTab { f.focused++ if f.focused > 11 { f.focused = 0 @@ -175,8 +175,9 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { f.focused = 11 } } + if f.isEdit && f.focused == 1 { - if msg.String() == "tab" { + if msg.Type == tea.KeyTab { f.focused++ } else { f.focused-- @@ -187,15 +188,17 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { f.focusCurrent() return f, nil - case "enter": + case tea.KeyEnter: switch { case f.focused < 9: if f.isEdit && f.focused == 1 { f.focused++ } + f.focused++ f.blurAll() f.focusCurrent() + return f, nil case f.focused == 10: f.err = "" @@ -203,40 +206,48 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { f.err = "name is required" return f, nil } + if !f.isEdit { if strings.TrimSpace(f.path.Value()) == "" { f.err = errPathRequired return f, nil } + if ok, reason := canCreatePath(f.path.Value()); !ok { f.err = reason return f, nil } } + cg := strings.ToLower(strings.TrimSpace(f.commitGPGSign.Value())) if cg != "" && cg != strTrue && cg != strFalse { f.err = "commit.gpgsign must be true or false" return f, nil } + tg := strings.ToLower(strings.TrimSpace(f.tagGPGSign.Value())) if tg != "" && tg != strTrue && tg != strFalse { f.err = "tag.gpgsign must be true or false" return f, nil } + f.submitted = true f.canceled = false + return f, nil case f.focused == 11: f.canceled = true f.submitted = false + return f, nil } - case "up": + case tea.KeyUp: if f.focused != 9 { f.focused-- if f.isEdit && f.focused == 1 { f.focused-- } + if f.focused < 0 { f.focused = 11 } @@ -246,12 +257,13 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return f, nil } - case "down": + case tea.KeyDown: if f.focused != 9 { f.focused++ if f.isEdit && f.focused == 1 { f.focused++ } + if f.focused > 11 { f.focused = 0 } @@ -261,7 +273,7 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return f, nil } - case "left": + case tea.KeyLeft: if f.focused == 11 { f.focused = 10 f.blurAll() @@ -269,7 +281,7 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return f, nil } - case "right": + case tea.KeyRight: if f.focused == 10 { f.focused = 11 f.blurAll() @@ -277,7 +289,7 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return f, nil } - case "ctrl+s": + case tea.KeyCtrlS: f.err = "" if strings.TrimSpace(f.name.Value()) == "" { f.err = "name is required" @@ -352,6 +364,7 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if f.isEdit { break } + prev := f.path.Value() f.path, cmd = f.path.Update(msg) @@ -442,10 +455,12 @@ Next/Newline: Enter Save: Ctrl+S Cancel: Esc`) btnDef := lipgloss.NewStyle().Foreground(c("buttonDefFg")).Background(c("buttonDefBg")).Padding(0, 2) btnSel := lipgloss.NewStyle().Foreground(c("buttonSelFg")).Background(c("buttonSelBg")).Padding(0, 2) b := strings.Builder{} + title := "New project" if f.isEdit { title = "Edit project" } + b.WriteString(styleTitle.Render(title)) b.WriteString("\n") b.WriteString(styleSection.Render("Project details")) @@ -454,10 +469,12 @@ Next/Newline: Enter Save: Ctrl+S Cancel: Esc`) b.WriteString("\n") b.WriteString(f.name.View()) b.WriteString("\n") + pathView := f.path.View() if f.isEdit { pathView = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(f.path.Value() + " (read-only)") } + b.WriteString(pathView) b.WriteString("\n\n") b.WriteString(styleSection.Render("Project options")) @@ -519,6 +536,7 @@ func NewProjectEditFormModel(p *domain.Project) *NewProjectForm { f.name.SetValue(p.Name) f.path.SetValue(p.Path) f.shell.SetValue(p.Shell) + return f } diff --git a/internal/adapters/handlers/tui/window_update.go b/internal/adapters/handlers/tui/window_update.go index 3874ecc..d506e0f 100644 --- a/internal/adapters/handlers/tui/window_update.go +++ b/internal/adapters/handlers/tui/window_update.go @@ -47,6 +47,7 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } commitSign := strings.ToLower(strings.TrimSpace(f.commitGPGSign.Value())) != strFalse + tagSign := strings.ToLower(strings.TrimSpace(f.tagGPGSign.Value())) != "false" if f.isEdit { proj, _ := m.projectSvc.Load(f.originalName) @@ -58,25 +59,30 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if raw != "" { lines := strings.Split(raw, "\n") ok := true + for _, line := range lines { l := strings.TrimSpace(line) if l == "" || strings.HasPrefix(l, "#") { continue } + kv := strings.SplitN(l, "=", 2) if len(kv) != 2 || strings.TrimSpace(kv[0]) == "" { ok = false break } } + if ok { projEnvPath := proj.EnvVarsFile if !filepath.IsAbs(projEnvPath) { projEnvPath = filepath.Join(proj.Path, projEnvPath) } - _ = os.WriteFile(projEnvPath, []byte(f.envVars.Value()), 0o644) + + _ = os.WriteFile(projEnvPath, []byte(f.envVars.Value()), 0o600) } } + gitRepo, err := repositories.NewGitRepository() if err == nil { gitSvc := services.NewGitService(gitRepo) @@ -92,13 +98,16 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } _ = gitSvc.Save(gitPath, gc) } + projects, err := m.projectSvc.List() if err == nil { m.projects = projects m.total = len(projects) + 1 } + m.mode = 0 m.form = nil + return m, tea.ClearScreen } @@ -199,12 +208,14 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { project, _ := m.projectSvc.Load(m.envProjectName) // find updated env (by new name) var envPath string + for _, e := range project.Environments { if e.Name == env.Name { envPath = e.EnvVarsFile break } } + if envPath != "" { if !filepath.IsAbs(envPath) { envPath = filepath.Join(project.Path, envPath) @@ -214,19 +225,22 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if raw != "" { lines := strings.Split(raw, "\n") ok := true + for _, line := range lines { l := strings.TrimSpace(line) if l == "" || strings.HasPrefix(l, "#") { continue } + kv := strings.SplitN(l, "=", 2) if len(kv) != 2 || strings.TrimSpace(kv[0]) == "" { ok = false break } } + if ok { - _ = os.WriteFile(envPath, []byte(f.envVars.Value()), 0o644) + _ = os.WriteFile(envPath, []byte(f.envVars.Value()), 0o600) } } } @@ -256,78 +270,145 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height case tea.KeyMsg: - switch msg.String() { - case "e": - idx := mod(m.cursor, m.total) - if m.focus == 0 && idx < len(m.projects) { - project, _ := m.projectSvc.Load(m.projects[idx].Name) - m.form = NewProjectEditFormModel(project) - // Prefill from project env file content - projEnvPath := project.EnvVarsFile - if !filepath.IsAbs(projEnvPath) { - projEnvPath = filepath.Join(project.Path, projEnvPath) - } - if b, err := os.ReadFile(projEnvPath); err == nil { - m.form.envVars.SetValue(string(b)) - } else { - pairs := project.EnvVars.ToSlice() - sort.Strings(pairs) - m.form.envVars.SetValue(strings.Join(pairs, "\n")) - } - // Prefill git config into edit form - gitRepo, err := repositories.NewGitRepository() - if err == nil { - gitSvc := services.NewGitService(gitRepo) - gitPath := project.EnvVarsFile - if strings.HasPrefix(gitPath, ".") { - gitPath = filepath.Join(project.Path, fmt.Sprintf(".%s.gitconfig", project.Name)) + if msg.Type == tea.KeyRunes { + r := string(msg.Runes) + switch r { + case "e": + idx := mod(m.cursor, m.total) + if m.focus == 0 && idx < len(m.projects) { + project, _ := m.projectSvc.Load(m.projects[idx].Name) + m.form = NewProjectEditFormModel(project) + + projEnvPath := project.EnvVarsFile + if !filepath.IsAbs(projEnvPath) { + projEnvPath = filepath.Join(project.Path, projEnvPath) + } + + if b, err := os.ReadFile(projEnvPath); err == nil { + m.form.envVars.SetValue(string(b)) + } else { + pairs := project.EnvVars.ToSlice() + sort.Strings(pairs) + m.form.envVars.SetValue(strings.Join(pairs, "\n")) + } + + gitRepo, err := repositories.NewGitRepository() + if err == nil { + gitSvc := services.NewGitService(gitRepo) + + gitPath := filepath.Join(project.Path, fmt.Sprintf(".%s.gitconfig", project.Name)) + if _, err := os.Stat(gitPath); os.IsNotExist(err) { + if matches, _ := filepath.Glob(filepath.Join(project.Path, ".*.gitconfig")); len(matches) > 0 { + gitPath = matches[0] + } + } + + if gc, e := gitSvc.Load(gitPath); e == nil { + m.form.userName.SetValue(gc.User.Name) + m.form.userEmail.SetValue(gc.User.Email) + m.form.userSigningKey.SetValue(gc.User.SigningKey) + + if gc.Commit.GPGSign { + m.form.commitGPGSign.SetValue(strTrue) + } else { + m.form.commitGPGSign.SetValue(strFalse) + } + + if gc.Tag.GPGSign { + m.form.tagGPGSign.SetValue(strTrue) + } else { + m.form.tagGPGSign.SetValue(strFalse) + } + } } - if gc, e := gitSvc.Load(gitPath); e == nil { - m.form.userName.SetValue(gc.User.Name) - m.form.userEmail.SetValue(gc.User.Email) - m.form.userSigningKey.SetValue(gc.User.SigningKey) - if gc.Commit.GPGSign { - m.form.commitGPGSign.SetValue(strTrue) - } else { - m.form.commitGPGSign.SetValue(strFalse) + + m.mode = 1 + + return m, tea.ClearScreen + } + + if m.focus == 1 && idx < len(m.projects) { + project, _ := m.projectSvc.Load(m.projects[idx].Name) + if m.cursorEnv < len(project.Environments) { + env := project.Environments[m.cursorEnv] + m.envForm = NewEnvironmentEditFormModel(env) + + envPath := env.EnvVarsFile + if !filepath.IsAbs(envPath) { + envPath = filepath.Join(project.Path, envPath) } - if gc.Tag.GPGSign { - m.form.tagGPGSign.SetValue(strTrue) - } else { - m.form.tagGPGSign.SetValue(strFalse) + + if b, err := os.ReadFile(envPath); err == nil { + m.envForm.envVars.SetValue(string(b)) + } else if len(env.EnvVars) > 0 { + pairs := env.EnvVars.ToSlice() + sort.Strings(pairs) + m.envForm.envVars.SetValue(strings.Join(pairs, "\n")) } + + m.envProjectName = project.Name + m.mode = 3 + + return m, tea.ClearScreen } } - m.mode = 1 - return m, tea.ClearScreen - } - if m.focus == 1 && idx < len(m.projects) { - project, _ := m.projectSvc.Load(m.projects[idx].Name) - if m.cursorEnv < len(project.Environments) { - env := project.Environments[m.cursorEnv] - m.envForm = NewEnvironmentEditFormModel(env) - // Prefill env vars from file content - envPath := env.EnvVarsFile - if !filepath.IsAbs(envPath) { - envPath = filepath.Join(project.Path, envPath) + case "h": + m.focus = 0 + case "l": + mod := mod(m.cursor, m.total) + if mod < len(m.projects) && len(m.projects) > 0 { + m.focus = 1 + if len(m.projects[mod].Environments) == 0 { + m.cursorEnv = 0 } - if b, err := os.ReadFile(envPath); err == nil { - m.envForm.envVars.SetValue(string(b)) - } else if len(env.EnvVars) > 0 { - pairs := env.EnvVars.ToSlice() - sort.Strings(pairs) - m.envForm.envVars.SetValue(strings.Join(pairs, "\n")) + } + case "k": + if m.focus == 0 { + m.cursor-- + m.cursorEnv = 0 + } else { + mod := mod(m.cursor, m.total) + if mod < len(m.projects) && len(m.projects) > 0 { + envCount := len(m.projects[mod].Environments) + + envCountPlus := envCount + 1 + if envCountPlus > 0 { + m.cursorEnv-- + if m.cursorEnv < 0 { + m.cursorEnv = envCountPlus - 1 + } + } + } + } + case "j": + if m.focus == 0 { + m.cursor++ + m.cursorEnv = 0 + } else { + mod := mod(m.cursor, m.total) + if mod < len(m.projects) && len(m.projects) > 0 { + envCount := len(m.projects[mod].Environments) + + envCountPlus := envCount + 1 + if envCountPlus > 0 { + m.cursorEnv++ + if m.cursorEnv >= envCountPlus { + m.cursorEnv = 0 + } + } } - m.envProjectName = project.Name - m.mode = 3 - return m, tea.ClearScreen } + case "q": + m.selectedProject = nil + return m, tea.Quit } - case "q", "ctrl+c", "esc": - m.selectedProject = nil + } + switch msg.Type { + case tea.KeyEsc, tea.KeyCtrlC: + m.selectedProject = nil return m, tea.Quit - case "enter": + case tea.KeyEnter: if m.focus == 0 { idx := mod(m.cursor, m.total) if idx == len(m.projects) { // '+ New project' @@ -360,11 +441,9 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - - // The "up" and "k" keys move the cursor up - case "left", "h": + case tea.KeyLeft: m.focus = 0 - case "right", "l": + case tea.KeyRight: mod := mod(m.cursor, m.total) if mod < len(m.projects) && len(m.projects) > 0 { m.focus = 1 @@ -372,7 +451,7 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cursorEnv = 0 } } - case "up", "k": + case tea.KeyUp: if m.focus == 0 { m.cursor-- m.cursorEnv = 0 @@ -390,8 +469,7 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - // The "down" and "j" keys move the cursor down - case "down", "j": + case tea.KeyDown: if m.focus == 0 { m.cursor++ m.cursorEnv = 0 @@ -412,8 +490,5 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - return m, tea.Batch( - cmd, - tea.Printf("Let's go to %d!", m.cursor), - ) + return m, cmd } diff --git a/internal/adapters/handlers/tui/window_view.go b/internal/adapters/handlers/tui/window_view.go index f825fd8..9186a2f 100644 --- a/internal/adapters/handlers/tui/window_view.go +++ b/internal/adapters/handlers/tui/window_view.go @@ -23,17 +23,9 @@ func (m Window) View() string { left := strings.Builder{} title := " Projects" - titleStyle := lipgloss.NewStyle(). - Foreground(c("title")). - Align(lipgloss.Center). - Border(lipgloss.NormalBorder(), false, false, true). - BorderForeground(c("border")). - Padding(0, 1) - selectedStyle := lipgloss.NewStyle(). - Foreground(c("selectedFg")). - Background(c("selectedBg")).Padding(0, 1) - defaultStyle := lipgloss.NewStyle(). - Foreground(c("subtext")).Padding(0, 1) + titleStyle := m.styles.Title + selectedStyle := m.styles.Selected + defaultStyle := m.styles.Default left.WriteString(titleStyle.Render(title)) left.WriteString("\n") @@ -116,9 +108,7 @@ func (m Window) View() string { } content := lipgloss.JoinHorizontal(lipgloss.Left, left.String(), " ", right.String()) - help := lipgloss.NewStyle(). - Foreground(c("help")). - Render(`Navigate: ↑/k ↓/j Focus: ←/h β†’/l + help := m.styles.Help.Render(`Navigate: ↑/k ↓/j Focus: ←/h β†’/l Select: Enter Edit: e Exit: Esc/Ctrl+C/q`) return lipgloss.JoinVertical(lipgloss.Left, content, help) diff --git a/internal/adapters/repositories/git_repository.go b/internal/adapters/repositories/git_repository.go index fbaf044..58ef1db 100644 --- a/internal/adapters/repositories/git_repository.go +++ b/internal/adapters/repositories/git_repository.go @@ -38,14 +38,22 @@ func (g *GitRepository) Load(path string) (*domain.GitConfig, error) { return nil, err } - tagGPGSing, err := strconv.ParseBool(g.git.Raw.Section("tag").Option("gpgsign")) - if err != nil { - return nil, err + tagOpt := g.git.Raw.Section("tag").Option("gpgsign") + tagGPGSing := false + + if tagOpt != "" { + if b, e := strconv.ParseBool(tagOpt); e == nil { + tagGPGSing = b + } } - commitGPGSing, err := strconv.ParseBool(g.git.Raw.Section("commit").Option("gpgsign")) - if err != nil { - return nil, err + commitOpt := g.git.Raw.Section("commit").Option("gpgsign") + commitGPGSing := false + + if commitOpt != "" { + if b, e := strconv.ParseBool(commitOpt); e == nil { + commitGPGSing = b + } } return &domain.GitConfig{ diff --git a/internal/adapters/repositories/project_repository.go b/internal/adapters/repositories/project_repository.go index a562c77..e256235 100644 --- a/internal/adapters/repositories/project_repository.go +++ b/internal/adapters/repositories/project_repository.go @@ -283,13 +283,17 @@ func (p *ProjectRepository) UpdateProject(project *domain.Project) error { gitdir := fmt.Sprintf("gitdir/i:%s/", projectDir) includeIf := p.git.Raw.Section("includeIf").Subsection(gitdir) oldGitPath := includeIf.Option("path") + newGitPath := filepath.Join(projectDir, fmt.Sprintf(".%s.gitconfig", project.Name)) if oldGitPath != "" && oldGitPath != newGitPath { if _, err := os.Stat(oldGitPath); err == nil { _ = os.Rename(oldGitPath, newGitPath) } + includeIf.SetOption("path", newGitPath) + gitConf, _ := p.git.Marshal() + if home, err := os.UserHomeDir(); err == nil { if fpGit, err := os.OpenFile(filepath.Join(home, ".gitconfig"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644); err == nil { _, _ = fpGit.Write(gitConf) @@ -302,19 +306,24 @@ func (p *ProjectRepository) UpdateProject(project *domain.Project) error { b := &strings.Builder{} fmt.Fprintf(b, "name = \"%s\"\n\n", project.Name) fmt.Fprintf(b, "description = \"%s\"\n\n", project.Description) + if project.Shell != "" { fmt.Fprintf(b, "shell = \"%s\"\n\n", project.Shell) } + fmt.Fprintf(b, "env_vars_file = \"%s\"\n\n", project.EnvVarsFile) + if project.DefaultEnv != "" { fmt.Fprintf(b, "default_env = \"%s\"\n\n", project.DefaultEnv) } for _, e := range project.Environments { fmt.Fprintf(b, "environment \"%s\" {\n", e.Name) + if e.Color != "" { fmt.Fprintf(b, " color = \"%s\"\n", e.Color) } + fmt.Fprintf(b, " env_vars_mode = \"%s\"\n", e.EnvVarsMode) fmt.Fprintf(b, " env_vars_file = \"%s\"\n", e.EnvVarsFile) b.WriteString("}\n\n") @@ -325,6 +334,7 @@ func (p *ProjectRepository) UpdateProject(project *domain.Project) error { return err } defer fp.Close() + if _, err := io.WriteString(fp, b.String()); err != nil { return err } @@ -339,23 +349,27 @@ func (p *ProjectRepository) UpdateEnvironment(projectName, originalEnvName strin } idx := -1 + for i, e := range project.Environments { if e.Name == originalEnvName { idx = i break } } + if idx == -1 { return fmt.Errorf("environment %s not found", originalEnvName) } project.Environments[idx].Name = env.Name + project.Environments[idx].Color = env.Color if env.EnvVarsMode == "" { project.Environments[idx].EnvVarsMode = domain.EnvVarsModeMerge } else { project.Environments[idx].EnvVarsMode = env.EnvVarsMode } + if env.EnvVarsFile != "" { project.Environments[idx].EnvVarsFile = env.EnvVarsFile } diff --git a/internal/adapters/repositories/shells/shell_repository.go b/internal/adapters/repositories/shells/shell_repository.go index c8995f7..8f95f7b 100644 --- a/internal/adapters/repositories/shells/shell_repository.go +++ b/internal/adapters/repositories/shells/shell_repository.go @@ -101,6 +101,7 @@ func (s *ShellRepository) Wait() (int, error) { if exiterr, ok := err.(*exec.ExitError); ok { return exiterr.ExitCode(), nil } + return 0, err } From da2549c19550065646625383e1a3e8f78ce0dbb8 Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Wed, 17 Sep 2025 01:20:03 +0200 Subject: [PATCH 13/27] Improvement in the style and format --- internal/adapters/handlers/tui/window_core.go | 42 ++++++++++- .../adapters/handlers/tui/window_env_form.go | 49 ++++++++++--- internal/adapters/handlers/tui/window_form.go | 52 ++++++++++++-- .../adapters/handlers/tui/window_update.go | 31 ++++++++ internal/adapters/handlers/tui/window_view.go | 70 +++++++++++-------- 5 files changed, 199 insertions(+), 45 deletions(-) diff --git a/internal/adapters/handlers/tui/window_core.go b/internal/adapters/handlers/tui/window_core.go index 0c86265..de74ef3 100644 --- a/internal/adapters/handlers/tui/window_core.go +++ b/internal/adapters/handlers/tui/window_core.go @@ -3,6 +3,9 @@ package tui import ( "strings" + helpcomp "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -177,6 +180,22 @@ type Options struct { Overrides map[string]string } +type keyMap struct { + Quit key.Binding + Edit key.Binding + Left key.Binding + Right key.Binding + Up key.Binding + Down key.Binding + Enter key.Binding + Help key.Binding +} + +func (k keyMap) ShortHelp() []key.Binding { return []key.Binding{k.Quit, k.Edit, k.Enter, k.Help} } +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Left, k.Right, k.Up, k.Down}, {k.Edit, k.Enter, k.Quit, k.Help}} +} + type Window struct { projectSvc *services.ProjectService @@ -196,6 +215,9 @@ type Window struct { envForm *NewEnvironmentForm envProjectName string styles Styles + vp viewport.Model + help helpcomp.Model + keys keyMap } func NewWindow(projectSvc *services.ProjectService, opts Options) (*Window, error) { @@ -221,13 +243,29 @@ func NewWindow(projectSvc *services.ProjectService, opts Options) (*Window, erro total = 1 } - return &Window{ + vp := viewport.New(0, 0) + h := helpcomp.New() + + w := &Window{ projectSvc: projectSvc, projects: projects, total: total, cursor: 0, styles: s, - }, nil + vp: vp, + help: h, + } + + w.keys.Quit = key.NewBinding(key.WithKeys("q", "ctrl+c", "esc"), key.WithHelp("q", "quit")) + w.keys.Edit = key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")) + w.keys.Left = key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "focus projects")) + w.keys.Right = key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("β†’/l", "focus envs")) + w.keys.Up = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")) + w.keys.Down = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")) + w.keys.Enter = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")) + w.keys.Help = key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "toggle help")) + + return w, nil } func c(k string) lipgloss.Color { return lipgloss.Color(currentPalette[k]) } diff --git a/internal/adapters/handlers/tui/window_env_form.go b/internal/adapters/handlers/tui/window_env_form.go index d29ebec..8c27665 100644 --- a/internal/adapters/handlers/tui/window_env_form.go +++ b/internal/adapters/handlers/tui/window_env_form.go @@ -3,6 +3,8 @@ package tui import ( "strings" + helpcomp "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" bta "github.com/charmbracelet/bubbles/textarea" bti "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -16,6 +18,21 @@ const ( errModeMustBeVal = "mode must be merge or replace" ) +type envFormKeyMap struct { + Save key.Binding + Cancel key.Binding + Next key.Binding + Prev key.Binding + Buttons key.Binding + Help key.Binding +} + +func (k envFormKeyMap) ShortHelp() []key.Binding { return []key.Binding{k.Next, k.Save, k.Help} } + +func (k envFormKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Next, k.Prev, k.Buttons}, {k.Save, k.Cancel, k.Help}} +} + type NewEnvironmentForm struct { name bti.Model color bti.Model @@ -27,6 +44,8 @@ type NewEnvironmentForm struct { err string isEdit bool originalName string + help helpcomp.Model + keys envFormKeyMap } func NewEnvironmentFormModel() *NewEnvironmentForm { @@ -60,7 +79,16 @@ func NewEnvironmentFormModel() *NewEnvironmentForm { env.SetHeight(6) env.SetWidth(70) - return &NewEnvironmentForm{name: name, color: color, mode: mode, envVars: env} + h := helpcomp.New() + km := envFormKeyMap{} + km.Save = key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")) + km.Cancel = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")) + km.Next = key.NewBinding(key.WithKeys("tab", "down", "enter"), key.WithHelp("tab/↓/enter", "next")) + km.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev")) + km.Buttons = key.NewBinding(key.WithKeys("left", "right"), key.WithHelp("←/β†’", "buttons")) + km.Help = key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "toggle help")) + + return &NewEnvironmentForm{name: name, color: color, mode: mode, envVars: env, help: h, keys: km} } func NewEnvironmentEditFormModel(e *domain.Environment) *NewEnvironmentForm { @@ -198,6 +226,13 @@ func (f *NewEnvironmentForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + if m, ok := msg.(tea.KeyMsg); ok { + if m.Type == tea.KeyRunes && len(m.Runes) > 0 && m.Runes[0] == '?' { + f.help.ShowAll = !f.help.ShowAll + return f, nil + } + } + var cmd tea.Cmd switch f.focused { @@ -248,11 +283,6 @@ func (f *NewEnvironmentForm) focusCurrent() { } func (f *NewEnvironmentForm) View() string { - box := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(c("border")).Padding(1, 2) - help := lipgloss.NewStyle(). - Foreground(c("help")). - Render(`Move: Tab/↑/↓ Buttons: ←/β†’ -Next/Newline: Enter Save: Ctrl+S Cancel: Esc`) styleTitle := lipgloss.NewStyle(). Foreground(c("title")). Align(lipgloss.Center). @@ -307,7 +337,10 @@ Next/Newline: Enter Save: Ctrl+S Cancel: Esc`) b.WriteString("\n") } - b.WriteString(help) + content := b.String() + + sepLen := 80 + sep := lipgloss.NewStyle().Foreground(c("border")).Render(strings.Repeat("─", sepLen)) - return box.Render(b.String()) + return lipgloss.JoinVertical(lipgloss.Left, content, sep, f.help.View(f.keys)) } diff --git a/internal/adapters/handlers/tui/window_form.go b/internal/adapters/handlers/tui/window_form.go index 8415fd2..62c2732 100644 --- a/internal/adapters/handlers/tui/window_form.go +++ b/internal/adapters/handlers/tui/window_form.go @@ -7,6 +7,8 @@ import ( "regexp" "strings" + helpcomp "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" bta "github.com/charmbracelet/bubbles/textarea" bti "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -21,6 +23,20 @@ const ( errPathRequired = "path is required" ) +type formKeyMap struct { + Save key.Binding + Cancel key.Binding + Next key.Binding + Prev key.Binding + Buttons key.Binding + Help key.Binding +} + +func (k formKeyMap) ShortHelp() []key.Binding { return []key.Binding{k.Next, k.Save, k.Help} } +func (k formKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Next, k.Prev, k.Buttons}, {k.Save, k.Cancel, k.Help}} +} + type NewProjectForm struct { name bti.Model path bti.Model @@ -41,6 +57,8 @@ type NewProjectForm struct { err string isEdit bool originalName string + help helpcomp.Model + keys formKeyMap } func NewProjectFormModel() *NewProjectForm { @@ -118,6 +136,15 @@ func NewProjectFormModel() *NewProjectForm { ev.SetHeight(6) ev.SetWidth(60) + h := helpcomp.New() + km := formKeyMap{} + km.Save = key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")) + km.Cancel = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")) + km.Next = key.NewBinding(key.WithKeys("tab", "down", "enter"), key.WithHelp("tab/↓/enter", "next")) + km.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev")) + km.Buttons = key.NewBinding(key.WithKeys("left", "right"), key.WithHelp("←/β†’", "buttons")) + km.Help = key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "toggle help")) + return &NewProjectForm{ name: ni, path: pi, @@ -130,6 +157,8 @@ func NewProjectFormModel() *NewProjectForm { tagGPGSign: tsg, envVars: ev, focused: 0, + help: h, + keys: km, } } @@ -332,6 +361,13 @@ func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + if m, ok := msg.(tea.KeyMsg); ok { + if m.Type == tea.KeyRunes && len(m.Runes) > 0 && m.Runes[0] == '?' { + f.help.ShowAll = !f.help.ShowAll + return f, nil + } + } + var cmd tea.Cmd switch f.focused { @@ -438,11 +474,6 @@ func (f *NewProjectForm) focusCurrent() { } func (f *NewProjectForm) View() string { - box := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(c("border")).Padding(1, 2) - help := lipgloss.NewStyle(). - Foreground(c("help")). - Render(`Move: Tab/↑/↓ Buttons: ←/β†’ -Next/Newline: Enter Save: Ctrl+S Cancel: Esc`) errStyle := lipgloss.NewStyle().Foreground(c("error")) styleTitle := lipgloss.NewStyle(). Foreground(c("title")). @@ -524,9 +555,16 @@ Next/Newline: Enter Save: Ctrl+S Cancel: Esc`) b.WriteString("\n") } - b.WriteString(help) + content := b.String() + + sepLen := f.width + if sepLen < 1 { + sepLen = 80 + } + + sep := lipgloss.NewStyle().Foreground(c("border")).Render(strings.Repeat("─", sepLen)) - return box.Render(b.String()) + return lipgloss.JoinVertical(lipgloss.Left, content, sep, f.help.View(f.keys)) } func NewProjectEditFormModel(p *domain.Project) *NewProjectForm { diff --git a/internal/adapters/handlers/tui/window_update.go b/internal/adapters/handlers/tui/window_update.go index d506e0f..a80970f 100644 --- a/internal/adapters/handlers/tui/window_update.go +++ b/internal/adapters/handlers/tui/window_update.go @@ -8,6 +8,7 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/jlrosende/project-manager/internal/adapters/repositories" "github.com/jlrosende/project-manager/internal/core/domain" @@ -269,10 +270,40 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.vp.Width = msg.Width + + hh := lipgloss.Height(m.help.View(m.keys)) + + vh := msg.Height - hh + if vh < 1 { + vh = 1 + } + + m.vp.Height = vh case tea.KeyMsg: + if m.mode == 0 { + var vcmd tea.Cmd + + m.vp, vcmd = m.vp.Update(msg) + cmd = tea.Batch(cmd, vcmd) + } + if msg.Type == tea.KeyRunes { r := string(msg.Runes) switch r { + case "?": + m.help.ShowAll = !m.help.ShowAll + + hh := lipgloss.Height(m.help.View(m.keys)) + + vh := m.height - hh + if vh < 1 { + vh = 1 + } + + m.vp.Height = vh + + return m, nil case "e": idx := mod(m.cursor, m.total) if m.focus == 0 && idx < len(m.projects) { diff --git a/internal/adapters/handlers/tui/window_view.go b/internal/adapters/handlers/tui/window_view.go index 9186a2f..00815b7 100644 --- a/internal/adapters/handlers/tui/window_view.go +++ b/internal/adapters/handlers/tui/window_view.go @@ -6,8 +6,6 @@ import ( "github.com/charmbracelet/lipgloss" ) -const markerArrow = "➜" - func (m Window) View() string { if m.mode == 1 && m.form != nil { return m.form.View() @@ -24,7 +22,6 @@ func (m Window) View() string { left := strings.Builder{} title := " Projects" titleStyle := m.styles.Title - selectedStyle := m.styles.Selected defaultStyle := m.styles.Default left.WriteString(titleStyle.Render(title)) @@ -32,24 +29,28 @@ func (m Window) View() string { mod := mod(m.cursor, m.total) for i, project := range m.projects { - if mod == i { - left.WriteString(markerArrow + " ") - left.WriteString(selectedStyle.Render(project.Name)) - left.WriteString("\n") - } else { - left.WriteString(" ") - left.WriteString(defaultStyle.Render(project.Name)) - left.WriteString("\n") + selected := mod == i + + marker := " " + text := defaultStyle.Render(project.Name) + + if selected { + marker = lipgloss.NewStyle().Foreground(c("selectedBg")).Render("β–Œ ") + text = lipgloss.NewStyle().Foreground(c("selectedFg")).Bold(true).Render(project.Name) } + + left.WriteString(marker) + left.WriteString(text) + left.WriteString("\n") } if mod == len(m.projects) { - left.WriteString(markerArrow + " ") - left.WriteString(selectedStyle.Render("+ New project")) + left.WriteString(lipgloss.NewStyle().Foreground(c("selectedBg")).Render("β–Œ ")) + left.WriteString(lipgloss.NewStyle().Foreground(c("selectedFg")).Bold(true).Render("+ New project")) left.WriteString("\n") } else { - left.WriteString(" ") - left.WriteString(defaultStyle.Render("+ New project")) + left.WriteString(" ") + left.WriteString(lipgloss.NewStyle().Foreground(c("section")).Bold(true).Render("+ New project")) left.WriteString("\n") } @@ -63,11 +64,11 @@ func (m Window) View() string { if len(p.Environments) == 0 { marker := " " if m.focus == 1 && m.cursorEnv == 0 { - marker = "➜" + marker = lipgloss.NewStyle().Foreground(c("selectedBg")).Render("β–Œ ") } right.WriteString(marker) - right.WriteString(defaultStyle.Render("+ New environment")) + right.WriteString(lipgloss.NewStyle().Foreground(c("section")).Bold(true).Render("+ New environment")) right.WriteString("\n") } else { for i, env := range p.Environments { @@ -76,40 +77,53 @@ func (m Window) View() string { selected := m.focus == 1 && m.cursorEnv == i if selected { - marker = markerArrow + marker = lipgloss.NewStyle().Foreground(lipgloss.Color(env.Color)).Render("β–Œ ") } - rowStyle := lipgloss.NewStyle() + var content string + if selected { - rowStyle = rowStyle.Background(lipgloss.Color(env.Color)).Padding(0, 0, 0, 1) + bullet := lipgloss.NewStyle().Foreground(lipgloss.Color(env.Color)).Render("● ") + name := lipgloss.NewStyle().Foreground(c("selectedFg")).Bold(true).Render(env.Name) + content = bullet + name + } else { + content = defaultStyle.Foreground(lipgloss.Color(env.Color)).Render("● " + env.Name) } - content := defaultStyle.Foreground(lipgloss.Color(env.Color)).Render("● " + env.Name) if p.DefaultEnv != "" && env.Name == p.DefaultEnv { content = content + " " + lipgloss.NewStyle().Foreground(c("subtext")).Render("(default)") } right.WriteString(marker) - right.WriteString(rowStyle.Render(content)) + right.WriteString(content) right.WriteString("\n") } } - // Add new environment entry when there are environments + if mod < len(m.projects) && len(m.projects) > 0 && len(m.projects[mod].Environments) > 0 { marker := " " if m.focus == 1 && m.cursorEnv == len(m.projects[mod].Environments) { - marker = markerArrow + marker = lipgloss.NewStyle().Foreground(c("selectedBg")).Render("β–Œ ") } right.WriteString(marker) - right.WriteString(defaultStyle.Render("+ New environment")) + right.WriteString(lipgloss.NewStyle().Foreground(c("section")).Bold(true).Render("+ New environment")) right.WriteString("\n") } } content := lipgloss.JoinHorizontal(lipgloss.Left, left.String(), " ", right.String()) - help := m.styles.Help.Render(`Navigate: ↑/k ↓/j Focus: ←/h β†’/l -Select: Enter Edit: e Exit: Esc/Ctrl+C/q`) - return lipgloss.JoinVertical(lipgloss.Left, content, help) + sepLen := m.width + if sepLen < 1 { + sepLen = 80 + } + + sep := lipgloss.NewStyle().Foreground(c("border")).Render(strings.Repeat("─", sepLen)) + content = content + "\n" + sep + "\n" + m.help.View(m.keys) + + v := m.vp + v.SetContent(content) + + return v.View() } From 56aad486f4f01767d74631f5151aed4951599c0a Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Thu, 18 Sep 2025 23:24:34 +0200 Subject: [PATCH 14/27] Create v1 and v2 TUI to refactor the code. --- .github/workflows/ci.yml | 23 +- Makefile | 14 +- cmd/cli/root.go | 10 +- go.mod | 2 + go.sum | 2 + .../adapters/handlers/tui/{ => v1}/styles.go | 2 +- internal/adapters/handlers/tui/v1/window.go | 1 + .../handlers/tui/{ => v1}/window_core.go | 2 +- .../handlers/tui/{ => v1}/window_env_form.go | 2 +- .../handlers/tui/{ => v1}/window_flow.go | 2 +- .../handlers/tui/{ => v1}/window_form.go | 2 +- .../handlers/tui/{ => v1}/window_update.go | 2 +- .../handlers/tui/{ => v1}/window_view.go | 2 +- .../handlers/tui/v2/components/button.go | 50 + .../handlers/tui/v2/components/buttongroup.go | 94 ++ .../handlers/tui/v2/components/input.go | 74 + .../handlers/tui/v2/components/list.go | 110 ++ .../handlers/tui/v2/components/modal.go | 56 + .../handlers/tui/v2/components/textarea.go | 50 + .../adapters/handlers/tui/v2/router/router.go | 43 + .../tui/v2/router/router_compile_test.go | 10 + .../handlers/tui/v2/state/messages.go | 79 ++ .../adapters/handlers/tui/v2/state/model.go | 6 + .../tui/v2/state/state_compile_test.go | 27 + .../adapters/handlers/tui/v2/styles/styles.go | 33 + .../adapters/handlers/tui/v2/styles/theme.go | 66 + .../handlers/tui/v2/views/env_form_view.go | 273 ++++ .../handlers/tui/v2/views/env_vars_view.go | 76 + .../handlers/tui/v2/views/post_create_view.go | 87 ++ .../tui/v2/views/project_form_view.go | 457 ++++++ .../handlers/tui/v2/views/projects_view.go | 98 ++ .../handlers/tui/v2/views/update_flow_view.go | 36 + .../tui/v2/views/views_compile_test.go | 39 + internal/adapters/handlers/tui/v2/window.go | 1236 +++++++++++++++++ internal/adapters/handlers/tui/window.go | 1 - internal/core/ports/env_vars_port.go | 2 + internal/core/ports/git_port.go | 2 + internal/core/ports/project_port.go | 2 + internal/core/ports/shell_port.go | 2 + mocks/mock_env_vars_port.go | 123 ++ mocks/mock_git_port.go | 123 ++ mocks/mock_project_port.go | 252 ++++ mocks/mock_shell_port.go | 153 ++ tests/integration/tui_collapse_envs_test.go | 7 - .../integration/tui_project_selected_test.go | 7 - tests/integration/tui_projects_loaded_test.go | 9 - tests/integration/tui_view_envs_test.go | 7 - tests/integration/view_parity_test.go | 734 ++++++++++ 48 files changed, 4437 insertions(+), 53 deletions(-) rename internal/adapters/handlers/tui/{ => v1}/styles.go (98%) create mode 100644 internal/adapters/handlers/tui/v1/window.go rename internal/adapters/handlers/tui/{ => v1}/window_core.go (99%) rename internal/adapters/handlers/tui/{ => v1}/window_env_form.go (99%) rename internal/adapters/handlers/tui/{ => v1}/window_flow.go (99%) rename internal/adapters/handlers/tui/{ => v1}/window_form.go (99%) rename internal/adapters/handlers/tui/{ => v1}/window_update.go (99%) rename internal/adapters/handlers/tui/{ => v1}/window_view.go (99%) create mode 100644 internal/adapters/handlers/tui/v2/components/button.go create mode 100644 internal/adapters/handlers/tui/v2/components/buttongroup.go create mode 100644 internal/adapters/handlers/tui/v2/components/input.go create mode 100644 internal/adapters/handlers/tui/v2/components/list.go create mode 100644 internal/adapters/handlers/tui/v2/components/modal.go create mode 100644 internal/adapters/handlers/tui/v2/components/textarea.go create mode 100644 internal/adapters/handlers/tui/v2/router/router.go create mode 100644 internal/adapters/handlers/tui/v2/router/router_compile_test.go create mode 100644 internal/adapters/handlers/tui/v2/state/messages.go create mode 100644 internal/adapters/handlers/tui/v2/state/model.go create mode 100644 internal/adapters/handlers/tui/v2/state/state_compile_test.go create mode 100644 internal/adapters/handlers/tui/v2/styles/styles.go create mode 100644 internal/adapters/handlers/tui/v2/styles/theme.go create mode 100644 internal/adapters/handlers/tui/v2/views/env_form_view.go create mode 100644 internal/adapters/handlers/tui/v2/views/env_vars_view.go create mode 100644 internal/adapters/handlers/tui/v2/views/post_create_view.go create mode 100644 internal/adapters/handlers/tui/v2/views/project_form_view.go create mode 100644 internal/adapters/handlers/tui/v2/views/projects_view.go create mode 100644 internal/adapters/handlers/tui/v2/views/update_flow_view.go create mode 100644 internal/adapters/handlers/tui/v2/views/views_compile_test.go create mode 100644 internal/adapters/handlers/tui/v2/window.go delete mode 100644 internal/adapters/handlers/tui/window.go create mode 100644 mocks/mock_env_vars_port.go create mode 100644 mocks/mock_git_port.go create mode 100644 mocks/mock_project_port.go create mode 100644 mocks/mock_shell_port.go delete mode 100644 tests/integration/tui_collapse_envs_test.go delete mode 100644 tests/integration/tui_project_selected_test.go delete mode 100644 tests/integration/tui_projects_loaded_test.go delete mode 100644 tests/integration/tui_view_envs_test.go create mode 100644 tests/integration/view_parity_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d270c26..1495475 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,12 +10,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - uses: actions/setup-go@v5 with: go-version-file: "go.mod" cache: true + - name: golangci-lint uses: golangci/golangci-lint-action@v8 with: @@ -26,12 +26,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - uses: actions/setup-go@v5 with: go-version-file: "go.mod" cache: true + - name: Run unit tests run: go test -race -cover -coverprofile=coverage.out ./... @@ -39,12 +39,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - uses: actions/setup-go@v5 with: go-version-file: "go.mod" cache: true + - name: Run integration tests run: go test -race ./tests/integration/... @@ -52,11 +52,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - uses: actions/setup-go@v5 with: go-version-file: "go.mod" cache: true + - name: Build (snapshot) - run: make build + uses: goreleaser/goreleaser-action@v6 + with: + version: latest + args: build --clean --snapshot + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Makefile b/Makefile index db27a07..391d542 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build run run-build lint docker-build +.PHONY: build run run-build lint docker-build generate tools MAYOR ?= 0 MINOR ?= 0 @@ -30,6 +30,10 @@ run-build: build lint: go tool golangci-lint run -v +.PHONY: lint-fix +lint-fix: + go tool golangci-lint run -v --fix + .PHONY: bdocker-build docker-build: docker buildx build \ @@ -55,3 +59,11 @@ gendocs-rest: .PHONY: release release: go tool run + +.PHONY: generate +generate: + go generate ./... + +.PHONY: test +test: + go test ./... -v diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 54233bf..233fcbf 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -16,7 +16,7 @@ import ( cmdNew "github.com/jlrosende/project-manager/cmd/cli/new" "github.com/jlrosende/project-manager/configs" "github.com/jlrosende/project-manager/internal" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui" + tui "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2" "github.com/jlrosende/project-manager/internal/adapters/repositories" "github.com/jlrosende/project-manager/internal/adapters/repositories/shells" "github.com/jlrosende/project-manager/internal/core/domain" @@ -75,7 +75,7 @@ func init() { func Execute() { if err := rootCmd.Execute(); err != nil { - slog.Error("somethin wrong happened", slog.Any("err", err)) + slog.Error("something wrong happened", slog.Any("err", err)) os.Exit(1) } } @@ -102,8 +102,8 @@ func root(cmd *cobra.Command, args []string) error { svc := services.NewProjectService(repoProject, repoEnvVars, repoGitConfig) // NOTE: signal the waiting pm process to kill session shell - if projet, ok := os.LookupEnv("PM_ACTIVE_PROJECT"); ok { - fmt.Fprintf(cmd.OutOrStderr(), "Already runnign pm, active project: %s\n", projet) + if project, ok := os.LookupEnv("PM_ACTIVE_PROJECT"); ok { + fmt.Fprintf(cmd.OutOrStderr(), "Already running pm, active project: %s\n", project) return nil } @@ -133,7 +133,7 @@ func root(cmd *cobra.Command, args []string) error { var project *domain.Project - // Launch TUI if no args or project not exsit + // Launch TUI if no args or project not exist if len(args) == 0 || name == "" { themeFlag, _ := cmd.PersistentFlags().GetString("theme") configFlag, _ := cmd.PersistentFlags().GetString("config") diff --git a/go.mod b/go.mod index 852aa19..363b8a4 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/joho/godotenv v1.6.0-pre.2 github.com/spf13/cobra v1.10.1 github.com/spf13/viper v1.21.0 + go.uber.org/mock v0.6.0 golang.org/x/term v0.35.0 ) @@ -512,4 +513,5 @@ require ( tool ( github.com/golangci/golangci-lint/v2/cmd/golangci-lint github.com/goreleaser/goreleaser/v2 + go.uber.org/mock/mockgen ) diff --git a/go.sum b/go.sum index 2182879..e0596ca 100644 --- a/go.sum +++ b/go.sum @@ -1331,6 +1331,8 @@ go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwE go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= diff --git a/internal/adapters/handlers/tui/styles.go b/internal/adapters/handlers/tui/v1/styles.go similarity index 98% rename from internal/adapters/handlers/tui/styles.go rename to internal/adapters/handlers/tui/v1/styles.go index 982fd95..efec0f9 100644 --- a/internal/adapters/handlers/tui/styles.go +++ b/internal/adapters/handlers/tui/v1/styles.go @@ -1,4 +1,4 @@ -package tui +package v1 import "github.com/charmbracelet/lipgloss" diff --git a/internal/adapters/handlers/tui/v1/window.go b/internal/adapters/handlers/tui/v1/window.go new file mode 100644 index 0000000..b7b1f99 --- /dev/null +++ b/internal/adapters/handlers/tui/v1/window.go @@ -0,0 +1 @@ +package v1 diff --git a/internal/adapters/handlers/tui/window_core.go b/internal/adapters/handlers/tui/v1/window_core.go similarity index 99% rename from internal/adapters/handlers/tui/window_core.go rename to internal/adapters/handlers/tui/v1/window_core.go index de74ef3..de8db01 100644 --- a/internal/adapters/handlers/tui/window_core.go +++ b/internal/adapters/handlers/tui/v1/window_core.go @@ -1,4 +1,4 @@ -package tui +package v1 import ( "strings" diff --git a/internal/adapters/handlers/tui/window_env_form.go b/internal/adapters/handlers/tui/v1/window_env_form.go similarity index 99% rename from internal/adapters/handlers/tui/window_env_form.go rename to internal/adapters/handlers/tui/v1/window_env_form.go index 8c27665..8b85541 100644 --- a/internal/adapters/handlers/tui/window_env_form.go +++ b/internal/adapters/handlers/tui/v1/window_env_form.go @@ -1,4 +1,4 @@ -package tui +package v1 import ( "strings" diff --git a/internal/adapters/handlers/tui/window_flow.go b/internal/adapters/handlers/tui/v1/window_flow.go similarity index 99% rename from internal/adapters/handlers/tui/window_flow.go rename to internal/adapters/handlers/tui/v1/window_flow.go index 1ef789b..8c8ef34 100644 --- a/internal/adapters/handlers/tui/window_flow.go +++ b/internal/adapters/handlers/tui/v1/window_flow.go @@ -1,4 +1,4 @@ -package tui +package v1 import ( "strconv" diff --git a/internal/adapters/handlers/tui/window_form.go b/internal/adapters/handlers/tui/v1/window_form.go similarity index 99% rename from internal/adapters/handlers/tui/window_form.go rename to internal/adapters/handlers/tui/v1/window_form.go index 62c2732..a331014 100644 --- a/internal/adapters/handlers/tui/window_form.go +++ b/internal/adapters/handlers/tui/v1/window_form.go @@ -1,4 +1,4 @@ -package tui +package v1 import ( "io" diff --git a/internal/adapters/handlers/tui/window_update.go b/internal/adapters/handlers/tui/v1/window_update.go similarity index 99% rename from internal/adapters/handlers/tui/window_update.go rename to internal/adapters/handlers/tui/v1/window_update.go index a80970f..42357c6 100644 --- a/internal/adapters/handlers/tui/window_update.go +++ b/internal/adapters/handlers/tui/v1/window_update.go @@ -1,4 +1,4 @@ -package tui +package v1 import ( "fmt" diff --git a/internal/adapters/handlers/tui/window_view.go b/internal/adapters/handlers/tui/v1/window_view.go similarity index 99% rename from internal/adapters/handlers/tui/window_view.go rename to internal/adapters/handlers/tui/v1/window_view.go index 00815b7..0f4e5b6 100644 --- a/internal/adapters/handlers/tui/window_view.go +++ b/internal/adapters/handlers/tui/v1/window_view.go @@ -1,4 +1,4 @@ -package tui +package v1 import ( "strings" diff --git a/internal/adapters/handlers/tui/v2/components/button.go b/internal/adapters/handlers/tui/v2/components/button.go new file mode 100644 index 0000000..1ee19e3 --- /dev/null +++ b/internal/adapters/handlers/tui/v2/components/button.go @@ -0,0 +1,50 @@ +package components + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type ButtonVariant int + +const ( + Primary ButtonVariant = iota + Secondary + Danger +) + +type Button struct { + Label string + Variant ButtonVariant + Disabled bool + style lipgloss.Style + styleDisabled lipgloss.Style +} + +type ButtonPressedMsg struct{ Label string } + +func NewButton(label string, v ButtonVariant, s, sd lipgloss.Style) Button { + return Button{Label: label, Variant: v, style: s, styleDisabled: sd} +} + +func (b Button) Init() tea.Cmd { return nil } + +func (b Button) View() string { + if b.Disabled { + return b.styleDisabled.Render(b.Label) + } + + return b.style.Render(b.Label) +} + +func (b Button) Update(msg tea.Msg) (Button, tea.Cmd) { + if b.Disabled { + return b, nil + } + + if m, ok := msg.(tea.KeyMsg); ok && m.Type == tea.KeyEnter { + return b, func() tea.Msg { return ButtonPressedMsg{Label: b.Label} } + } + + return b, nil +} diff --git a/internal/adapters/handlers/tui/v2/components/buttongroup.go b/internal/adapters/handlers/tui/v2/components/buttongroup.go new file mode 100644 index 0000000..ab4a113 --- /dev/null +++ b/internal/adapters/handlers/tui/v2/components/buttongroup.go @@ -0,0 +1,94 @@ +package components + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type ButtonSpec struct { + Label string + Primary bool + Disabled bool +} + +type ButtonGroup struct { + Buttons []ButtonSpec + Cursor int + primaryStyle lipgloss.Style + secondStyle lipgloss.Style + disStyle lipgloss.Style +} + +type ButtonChosenMsg struct{ Label string } + +type ButtonMovedMsg struct{ Cursor int } + +func NewButtonGroup(btns []ButtonSpec, primary, secondary, disabled lipgloss.Style) ButtonGroup { + return ButtonGroup{Buttons: btns, primaryStyle: primary, secondStyle: secondary, disStyle: disabled} +} + +func (g ButtonGroup) Init() tea.Cmd { return nil } + +func (g ButtonGroup) View() string { + out := "" + + for i, b := range g.Buttons { + style := g.secondStyle + + if b.Primary { + style = g.primaryStyle + } + + if b.Disabled { + style = g.disStyle + } + + label := style.Render(" " + b.Label + " ") + + if i == g.Cursor { + label = style.Bold(true).Render(" " + b.Label + " ") + } + + if i > 0 { + out += " " + } + + out += label + } + + return out +} + +func (g ButtonGroup) Update(msg tea.Msg) (ButtonGroup, tea.Cmd) { + if k, ok := msg.(tea.KeyMsg); ok { + s := k.String() + + switch s { + case "left": + if g.Cursor > 0 { + g.Cursor-- + + return g, func() tea.Msg { return ButtonMovedMsg{Cursor: g.Cursor} } + } + case "right": + if g.Cursor < len(g.Buttons)-1 { + g.Cursor++ + + return g, func() tea.Msg { return ButtonMovedMsg{Cursor: g.Cursor} } + } + case "enter": + if len(g.Buttons) == 0 { + return g, nil + } + + b := g.Buttons[g.Cursor] + if b.Disabled { + return g, nil + } + + return g, func() tea.Msg { return ButtonChosenMsg{Label: b.Label} } + } + } + + return g, nil +} diff --git a/internal/adapters/handlers/tui/v2/components/input.go b/internal/adapters/handlers/tui/v2/components/input.go new file mode 100644 index 0000000..eee2b79 --- /dev/null +++ b/internal/adapters/handlers/tui/v2/components/input.go @@ -0,0 +1,74 @@ +package components + +import ( + bti "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type Input struct { + label string + placeholder string + value string + txt bti.Model + style lipgloss.Style + valStyle lipgloss.Style +} + +type InputChangedMsg struct{ Value string } + +type InputFocusMsg struct{} + +type InputBlurMsg struct{} + +type InputSubmitMsg struct{ Value string } + +func NewInput(label, placeholder, value string, s, vs lipgloss.Style) Input { + ti := bti.New() + ti.Prompt = s.Render(label + ": ") + ti.PromptStyle = lipgloss.NewStyle() + ti.PlaceholderStyle = vs + ti.TextStyle = vs + ti.Placeholder = placeholder + ti.SetValue(value) + + return Input{label: label, placeholder: placeholder, value: value, txt: ti, style: s, valStyle: vs} +} + +func (i Input) Init() tea.Cmd { return bti.Blink } + +func (i Input) View() string { return i.txt.View() } + +func (i *Input) SetPrompt(p string) { i.txt.Prompt = p } + +func (i *Input) SetPromptStyle(s lipgloss.Style) { i.txt.PromptStyle = s } + +func (i *Input) SetPlaceholderStyle(s lipgloss.Style) { i.txt.PlaceholderStyle = s } + +func (i *Input) SetTextStyle(s lipgloss.Style) { i.txt.TextStyle = s } + +func (i Input) Value() string { return i.txt.Value() } + +func (i *Input) SetValue(s string) { i.txt.SetValue(s) } + +func (i *Input) SetWidth(w int) { i.txt.Width = w } + +func (i *Input) Focus() { i.txt.Focus() } + +func (i *Input) Blur() { i.txt.Blur() } + +func (i Input) Focused() bool { return i.txt.Focused() } + +func (i Input) Update(msg tea.Msg) (Input, tea.Cmd) { + switch m := msg.(type) { + case tea.KeyMsg: + if m.Type == tea.KeyEnter { + return i, func() tea.Msg { return InputSubmitMsg{Value: i.txt.Value()} } + } + } + + t, cmd := i.txt.Update(msg) + i.txt = t + + return i, cmd +} diff --git a/internal/adapters/handlers/tui/v2/components/list.go b/internal/adapters/handlers/tui/v2/components/list.go new file mode 100644 index 0000000..bb53399 --- /dev/null +++ b/internal/adapters/handlers/tui/v2/components/list.go @@ -0,0 +1,110 @@ +package components + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type ListItem struct{ ID, Label, Color string } + +type List struct { + Items []ListItem + Cursor int + style lipgloss.Style + styleSel lipgloss.Style + markerColor func(ListItem) lipgloss.Color + bulletColor func(ListItem) lipgloss.Color +} + +type ListMovedMsg struct{ Cursor int } + +type ListSelectedMsg struct{ Item ListItem } + +func NewList(items []ListItem, s, ss lipgloss.Style) List { + return List{Items: items, style: s, styleSel: ss} +} + +func (l List) WithMarkerColor(fn func(ListItem) lipgloss.Color) List { + l.markerColor = fn + return l +} + +func (l List) WithBullet(fn func(ListItem) lipgloss.Color) List { + l.bulletColor = fn + return l +} + +func (l List) Init() tea.Cmd { return nil } + +func (l List) View() string { + var out string + + for i, it := range l.Items { + prefix := " " + if i == l.Cursor && l.markerColor != nil { + prefix = lipgloss.NewStyle().Foreground(l.markerColor(it)).Render("β–Œ ") + } + + bullet := "" + + if l.bulletColor != nil { + c := l.bulletColor(it) + if c != lipgloss.Color("") { + bullet = lipgloss.NewStyle().Foreground(c).Render("● ") + } + } + + if i == l.Cursor { + rendered := l.styleSel.Render(bullet + it.Label) + if strings.HasPrefix(rendered, " ") { + rendered = rendered[1:] + } + + out += prefix + rendered + "\n" + } else { + out += prefix + l.style.Render(bullet+it.Label) + "\n" + } + } + + return out +} + +func (l List) Update(msg tea.Msg) (List, tea.Cmd) { + switch m := msg.(type) { + case tea.KeyMsg: + switch m.String() { + case "up", "k": + if len(l.Items) == 0 { + return l, nil + } + if l.Cursor > 0 { + l.Cursor-- + } else { + l.Cursor = len(l.Items) - 1 + } + + return l, func() tea.Msg { return ListMovedMsg{Cursor: l.Cursor} } + case "down", "j": + if len(l.Items) == 0 { + return l, nil + } + if l.Cursor < len(l.Items)-1 { + l.Cursor++ + } else { + l.Cursor = 0 + } + + return l, func() tea.Msg { return ListMovedMsg{Cursor: l.Cursor} } + case "enter": + if len(l.Items) == 0 { + return l, nil + } + + return l, func() tea.Msg { return ListSelectedMsg{Item: l.Items[l.Cursor]} } + } + } + + return l, nil +} diff --git a/internal/adapters/handlers/tui/v2/components/modal.go b/internal/adapters/handlers/tui/v2/components/modal.go new file mode 100644 index 0000000..26b7959 --- /dev/null +++ b/internal/adapters/handlers/tui/v2/components/modal.go @@ -0,0 +1,56 @@ +package components + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type Modal struct { + Title string + Body string + Footer string + Visible bool + wrap lipgloss.Style + title lipgloss.Style + body lipgloss.Style + footer lipgloss.Style +} + +type ModalClosedMsg struct{} + +type ModalActionMsg struct{ Action string } + +func NewModal(title, body, footer string, wrap, t, b, f lipgloss.Style) Modal { + return Modal{Title: title, Body: body, Footer: footer, Visible: false, wrap: wrap, title: t, body: b, footer: f} +} + +func (m Modal) Init() tea.Cmd { return nil } + +func (m Modal) View() string { + if !m.Visible { + return "" + } + + return m.wrap.Render(m.title.Render(m.Title) + "\n" + m.body.Render(m.Body) + "\n" + m.footer.Render(m.Footer)) +} + +func (m Modal) Update(msg tea.Msg) (Modal, tea.Cmd) { + if !m.Visible { + return m, nil + } + + if k, ok := msg.(tea.KeyMsg); ok { + s := k.String() + if s == "esc" { + m.Visible = false + return m, func() tea.Msg { return ModalClosedMsg{} } + } + + if s == "enter" { + m.Visible = false + return m, func() tea.Msg { return ModalActionMsg{Action: "enter"} } + } + } + + return m, nil +} diff --git a/internal/adapters/handlers/tui/v2/components/textarea.go b/internal/adapters/handlers/tui/v2/components/textarea.go new file mode 100644 index 0000000..701daca --- /dev/null +++ b/internal/adapters/handlers/tui/v2/components/textarea.go @@ -0,0 +1,50 @@ +package components + +import ( + bta "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type TextArea struct { + txt bta.Model + style lipgloss.Style + valStyle lipgloss.Style +} + +type TextAreaSubmitMsg struct{ Value string } + +func NewTextArea(placeholder, value string, s, vs lipgloss.Style) TextArea { + ta := bta.New() + ta.Placeholder = placeholder + ta.SetValue(value) + + return TextArea{txt: ta, style: s, valStyle: vs} +} + +func (t TextArea) Init() tea.Cmd { return nil } + +func (t TextArea) View() string { return t.txt.View() } + +func (t TextArea) Value() string { return t.txt.Value() } + +func (t *TextArea) SetValue(v string) { t.txt.SetValue(v) } + +func (t *TextArea) SetWidth(w int) { t.txt.SetWidth(w) } + +func (t *TextArea) SetHeight(h int) { t.txt.SetHeight(h) } + +func (t *TextArea) Focus() { t.txt.Focus() } + +func (t *TextArea) Blur() { t.txt.Blur() } + +func (t TextArea) Update(msg tea.Msg) (TextArea, tea.Cmd) { + if k, ok := msg.(tea.KeyMsg); ok && k.Type == tea.KeyEnter && k.Alt { + return t, func() tea.Msg { return TextAreaSubmitMsg{Value: t.txt.Value()} } + } + + ta, cmd := t.txt.Update(msg) + t.txt = ta + + return t, cmd +} diff --git a/internal/adapters/handlers/tui/v2/router/router.go b/internal/adapters/handlers/tui/v2/router/router.go new file mode 100644 index 0000000..4764fbf --- /dev/null +++ b/internal/adapters/handlers/tui/v2/router/router.go @@ -0,0 +1,43 @@ +package router + +type Route int + +const ( + RouteProjects Route = iota + RouteProjectForm + RouteEnvVars + RouteUpdateFlow +) + +type Params map[string]string + +type Router struct { + current Route + stack []Route + params Params +} + +func New(start Route) *Router { + return &Router{current: start, stack: []Route{}, params: Params{}} +} + +func (r *Router) Current() Route { return r.current } + +func (r *Router) Params() Params { return r.params } + +func (r *Router) NavigateTo(route Route, p Params) { + r.stack = append(r.stack, r.current) + r.current = route + r.params = p +} + +func (r *Router) Back() { + if len(r.stack) == 0 { + return + } + + idx := len(r.stack) - 1 + r.current = r.stack[idx] + r.stack = r.stack[:idx] + r.params = nil +} diff --git a/internal/adapters/handlers/tui/v2/router/router_compile_test.go b/internal/adapters/handlers/tui/v2/router/router_compile_test.go new file mode 100644 index 0000000..fd2bf39 --- /dev/null +++ b/internal/adapters/handlers/tui/v2/router/router_compile_test.go @@ -0,0 +1,10 @@ +package router + +import "testing" + +func TestRouterCompile(_ *testing.T) { + r := New(RouteProjects) + _ = r.Current() + r.NavigateTo(RouteProjectForm, nil) + r.Back() +} diff --git a/internal/adapters/handlers/tui/v2/state/messages.go b/internal/adapters/handlers/tui/v2/state/messages.go new file mode 100644 index 0000000..aa3d2e7 --- /dev/null +++ b/internal/adapters/handlers/tui/v2/state/messages.go @@ -0,0 +1,79 @@ +package state + +import ( + tea "github.com/charmbracelet/bubbletea" + + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/router" +) + +type NavigateToMsg struct { + Route router.Route + Params map[string]string +} + +type BackMsg struct{} + +type ErrorMsg struct{ Err error } + +type NewProjectMsg struct{} + +type SelectProjectMsg struct{ ID string } + +type SaveProjectMsg struct { + Name string + Path string + Subproject string + Shell string + GitUserName string + GitUserEmail string + GitSigningKey string + CommitGPGSign string + TagGPGSign string + EnvVarsRaw string +} + +type CancelMsg struct{} + +type ExitMsg struct{} + +type AddEnvVarMsg struct{} + +type EditEnvVarMsg struct{ Name string } + +type EditProjectMsg struct{ ID string } + +type RemoveEnvVarMsg struct{ Name string } + +type SaveEnvVarMsg struct{ Name, Value string } + +type SaveEnvFormMsg struct { + OriginalName string + Name string + Color string + Mode string + EnvVarsRaw string +} + +type PostCreateChoiceMsg struct { + Choice int + ProjectName string +} + +var _ tea.Msg = SaveEnvFormMsg{} + +var ( + _ tea.Msg = NavigateToMsg{} + _ tea.Msg = BackMsg{} + _ tea.Msg = ErrorMsg{} + _ tea.Msg = NewProjectMsg{} + _ tea.Msg = SelectProjectMsg{} + _ tea.Msg = SaveProjectMsg{} + _ tea.Msg = CancelMsg{} + _ tea.Msg = ExitMsg{} + _ tea.Msg = AddEnvVarMsg{} + _ tea.Msg = EditEnvVarMsg{} + _ tea.Msg = RemoveEnvVarMsg{} + _ tea.Msg = SaveEnvVarMsg{} + _ tea.Msg = EditProjectMsg{} + _ tea.Msg = PostCreateChoiceMsg{} +) diff --git a/internal/adapters/handlers/tui/v2/state/model.go b/internal/adapters/handlers/tui/v2/state/model.go new file mode 100644 index 0000000..35c4a7e --- /dev/null +++ b/internal/adapters/handlers/tui/v2/state/model.go @@ -0,0 +1,6 @@ +package state + +type Model struct { + Route int + Err error +} diff --git a/internal/adapters/handlers/tui/v2/state/state_compile_test.go b/internal/adapters/handlers/tui/v2/state/state_compile_test.go new file mode 100644 index 0000000..4f75166 --- /dev/null +++ b/internal/adapters/handlers/tui/v2/state/state_compile_test.go @@ -0,0 +1,27 @@ +package state + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/router" +) + +func TestMessagesCompile(_ *testing.T) { + _ = NavigateToMsg{Route: router.RouteProjects} + _ = BackMsg{} + _ = ErrorMsg{} + _ = NewProjectMsg{} + _ = SelectProjectMsg{ID: "x"} + _ = SaveProjectMsg{Name: "a", Path: "b"} + _ = CancelMsg{} + _ = AddEnvVarMsg{} + _ = EditEnvVarMsg{Name: "dev"} + _ = RemoveEnvVarMsg{Name: "dev"} + _ = SaveEnvVarMsg{Name: "k", Value: "v"} + _ = SaveEnvFormMsg{OriginalName: "dev", Name: "dev", Color: "#fff", Mode: "file", EnvVarsRaw: "X=1"} + _ = PostCreateChoiceMsg{Choice: 0, ProjectName: "p"} + + var _ tea.Msg = NavigateToMsg{} +} diff --git a/internal/adapters/handlers/tui/v2/styles/styles.go b/internal/adapters/handlers/tui/v2/styles/styles.go new file mode 100644 index 0000000..208e44b --- /dev/null +++ b/internal/adapters/handlers/tui/v2/styles/styles.go @@ -0,0 +1,33 @@ +package styles + +import "github.com/charmbracelet/lipgloss" + +type ComponentStyles struct { + InputPrompt lipgloss.Style + InputValue lipgloss.Style + ListItem lipgloss.Style + ListSelected lipgloss.Style + ModalWrap lipgloss.Style + ModalTitle lipgloss.Style + ModalBody lipgloss.Style + ModalFooter lipgloss.Style + ButtonPrimary lipgloss.Style + ButtonSecondary lipgloss.Style + ButtonDisabled lipgloss.Style +} + +func BuildComponentStyles(t Theme) ComponentStyles { + return ComponentStyles{ + InputPrompt: t.Default, + InputValue: t.Default, + ListItem: t.Default, + ListSelected: t.Selected, + ModalWrap: t.Default, + ModalTitle: t.Title, + ModalBody: t.Default, + ModalFooter: t.Help, + ButtonPrimary: t.ButtonPrimary, + ButtonSecondary: t.ButtonSecondary, + ButtonDisabled: t.Default, + } +} diff --git a/internal/adapters/handlers/tui/v2/styles/theme.go b/internal/adapters/handlers/tui/v2/styles/theme.go new file mode 100644 index 0000000..f48e335 --- /dev/null +++ b/internal/adapters/handlers/tui/v2/styles/theme.go @@ -0,0 +1,66 @@ +package styles + +import "github.com/charmbracelet/lipgloss" + +type Tokens struct { + Title lipgloss.Color + Section lipgloss.Color + Subtext lipgloss.Color + Text lipgloss.Color + Placeholder lipgloss.Color + Border lipgloss.Color + Error lipgloss.Color + ButtonDefFg lipgloss.Color + ButtonDefBg lipgloss.Color + ButtonSelFg lipgloss.Color + ButtonSelBg lipgloss.Color + SelectedFg lipgloss.Color + SelectedBg lipgloss.Color + Help lipgloss.Color +} + +type Theme struct { + Title lipgloss.Style + Selected lipgloss.Style + Default lipgloss.Style + Help lipgloss.Style + Error lipgloss.Style + ButtonPrimary lipgloss.Style + ButtonSecondary lipgloss.Style +} + +func NewTokensFromHex(p map[string]string) Tokens { + return Tokens{ + Title: lipgloss.Color(p["title"]), + Section: lipgloss.Color(p["section"]), + Subtext: lipgloss.Color(p["subtext"]), + Text: lipgloss.Color(p["text"]), + Placeholder: lipgloss.Color(p["placeholder"]), + Border: lipgloss.Color(p["border"]), + Error: lipgloss.Color(p["error"]), + ButtonDefFg: lipgloss.Color(p["buttonDefFg"]), + ButtonDefBg: lipgloss.Color(p["buttonDefBg"]), + ButtonSelFg: lipgloss.Color(p["buttonSelFg"]), + ButtonSelBg: lipgloss.Color(p["buttonSelBg"]), + SelectedFg: lipgloss.Color(p["selectedFg"]), + SelectedBg: lipgloss.Color(p["selectedBg"]), + Help: lipgloss.Color(p["help"]), + } +} + +func BuildTheme(t Tokens) Theme { + return Theme{ + Title: lipgloss.NewStyle(). + Foreground(t.Title). + Align(lipgloss.Center). + Border(lipgloss.NormalBorder(), false, false, true). + BorderForeground(t.Border). + Padding(0, 1), + Selected: lipgloss.NewStyle().Foreground(t.SelectedFg).Background(t.SelectedBg), + Default: lipgloss.NewStyle().Foreground(t.Subtext).Padding(0, 1), + Help: lipgloss.NewStyle().Foreground(t.Help), + Error: lipgloss.NewStyle().Foreground(t.Error), + ButtonPrimary: lipgloss.NewStyle().Foreground(t.ButtonSelFg).Background(t.ButtonSelBg).Padding(0, 2), + ButtonSecondary: lipgloss.NewStyle().Foreground(t.ButtonDefFg).Background(t.ButtonDefBg).Padding(0, 2), + } +} diff --git a/internal/adapters/handlers/tui/v2/views/env_form_view.go b/internal/adapters/handlers/tui/v2/views/env_form_view.go new file mode 100644 index 0000000..2171fed --- /dev/null +++ b/internal/adapters/handlers/tui/v2/views/env_form_view.go @@ -0,0 +1,273 @@ +package views + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/components" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" + "github.com/jlrosende/project-manager/internal/core/domain" +) + +type EnvFormView struct { + OriginalName string + Name components.Input + Color components.Input + Mode components.Input + EnvVars components.TextArea + Title lipgloss.Style + Item lipgloss.Style + BtnPri lipgloss.Style + BtnSec lipgloss.Style + Focused int + Err string +} + +func NewEnvFormView(orig, name, color, mode, envRaw string, title, item, btnPri, btnSec lipgloss.Style) *EnvFormView { + v := &EnvFormView{ + OriginalName: orig, + Name: components.NewInput("Env name*", "staging", name, item, item), + Color: components.NewInput("Color (name/#hex/0-255)", "teal", color, item, item), + Mode: components.NewInput("Env vars mode* (merge/replace)", "merge", mode, item, item), + EnvVars: components.NewTextArea("# One per line (like .env)\nAPI_URL=https://api.example.com\nLOG_LEVEL=info\n# comments allowed", envRaw, item, item), + Title: title, + Item: item, + BtnPri: btnPri, + BtnSec: btnSec, + Focused: 0, + } + + v.Name.SetWidth(60) + v.Color.SetWidth(60) + v.Mode.SetWidth(60) + v.EnvVars.SetHeight(6) + v.EnvVars.SetWidth(70) + v.Name.Focus() + + if strings.TrimSpace(orig) == "" { + if strings.TrimSpace(v.Color.Value()) == "" { + v.Color.SetValue("grey") + } + if strings.TrimSpace(v.Mode.Value()) == "" { + v.Mode.SetValue("merge") + } + } + + return v +} + +func (v *EnvFormView) Init() tea.Cmd { return nil } + +func (v *EnvFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case components.ButtonPressedMsg: + if m.Label == "Save" { + return v, func() tea.Msg { + return state.SaveEnvFormMsg{ + OriginalName: v.OriginalName, + Name: strings.TrimSpace(v.Name.Value()), + Color: strings.TrimSpace(v.Color.Value()), + Mode: strings.TrimSpace(v.Mode.Value()), + EnvVarsRaw: v.EnvVars.Value(), + } + } + } + case tea.KeyMsg: + s := m.String() + switch s { + case "ctrl+s": + v.Err = "" + name := strings.TrimSpace(v.Name.Value()) + mode := strings.TrimSpace(v.Mode.Value()) + if name == "" { + v.Err = "name is required" + return v, nil + } + if mode != domain.EnvVarsModeMerge && mode != domain.EnvVarsModeReplace { + v.Err = "mode must be merge or replace" + return v, nil + } + return v, func() tea.Msg { + return state.SaveEnvFormMsg{ + OriginalName: v.OriginalName, + Name: name, + Color: strings.TrimSpace(v.Color.Value()), + Mode: mode, + EnvVarsRaw: v.EnvVars.Value(), + } + } + case "tab": + v.Focused = (v.Focused + 1) % 6 + v.blurAll() + v.focusCurrent() + return v, nil + case "shift+tab": + v.Focused = (v.Focused + 5) % 6 + v.blurAll() + v.focusCurrent() + return v, nil + case "enter": + if v.Focused < 3 { + v.Focused = (v.Focused + 1) % 6 + v.blurAll() + v.focusCurrent() + return v, nil + } + if v.Focused == 4 { + v.Err = "" + name := strings.TrimSpace(v.Name.Value()) + mode := strings.TrimSpace(v.Mode.Value()) + if name == "" { + v.Err = "name is required" + return v, nil + } + if mode != domain.EnvVarsModeMerge && mode != domain.EnvVarsModeReplace { + v.Err = "mode must be merge or replace" + return v, nil + } + return v, func() tea.Msg { + return state.SaveEnvFormMsg{ + OriginalName: v.OriginalName, + Name: name, + Color: strings.TrimSpace(v.Color.Value()), + Mode: mode, + EnvVarsRaw: v.EnvVars.Value(), + } + } + } + if v.Focused == 5 { + return v, func() tea.Msg { return state.CancelMsg{} } + } + case "up": + if v.Focused != 3 { + v.Focused = (v.Focused + 5) % 6 + v.blurAll() + v.focusCurrent() + return v, nil + } + case "down": + if v.Focused != 3 { + v.Focused = (v.Focused + 1) % 6 + v.blurAll() + v.focusCurrent() + return v, nil + } + case "left": + if v.Focused == 5 { + v.Focused = 4 + v.blurAll() + v.focusCurrent() + return v, nil + } + case "right": + if v.Focused == 4 { + v.Focused = 5 + v.blurAll() + v.focusCurrent() + return v, nil + } + } + } + + n, nameCmd := v.Name.Update(msg) + v.Name = n + c, colorCmd := v.Color.Update(msg) + v.Color = c + mo, modeCmd := v.Mode.Update(msg) + v.Mode = mo + e, envCmd := v.EnvVars.Update(msg) + v.EnvVars = e + + return v, tea.Batch(nameCmd, colorCmd, modeCmd, envCmd) +} + +func (v *EnvFormView) View() string { + b := strings.Builder{} + + title := "New environment" + if strings.TrimSpace(v.OriginalName) != "" { + title = "Edit environment" + } + + b.WriteString(v.Title.Render(title)) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")).Bold(true).Render("Environment details")) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C")).Render("Display and behavior")) + b.WriteString("\n") + { + p := lipgloss.NewStyle() + val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + v.Name.SetPrompt(p.Render("Env name*") + p.Render(": ")) + v.Name.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) + v.Name.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.Name.SetTextStyle(val) + } + b.WriteString(v.Name.View()) + b.WriteString("\n") + { + p := lipgloss.NewStyle() + val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + v.Color.SetPrompt(p.Render("Color (name/#hex/0-255): ")) + v.Color.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) + v.Color.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.Color.SetTextStyle(val) + } + b.WriteString(v.Color.View()) + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")).Bold(true).Render("Variables")) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C")).Render("One KEY=VALUE per line; '#' comments allowed")) + b.WriteString("\n") + { + p := lipgloss.NewStyle() + val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + v.Mode.SetPrompt(p.Render("Env vars mode* (merge/replace): ")) + v.Mode.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) + v.Mode.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.Mode.SetTextStyle(val) + } + b.WriteString(v.Mode.View()) + b.WriteString("\n") + b.WriteString(v.EnvVars.View()) + b.WriteString("\n\n") + saveStyle := v.BtnSec + cancelStyle := v.BtnSec + if v.Focused == 4 { + saveStyle = v.BtnPri + } + if v.Focused == 5 { + cancelStyle = v.BtnPri + } + btnSave := saveStyle.Render(" Save ") + btnCancel := cancelStyle.Render(" Cancel ") + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, btnSave, " ", btnCancel)) + b.WriteString("\n\n") + if strings.TrimSpace(v.Err) != "" { + b.WriteString(v.Err) + } + + return b.String() +} + +func (v *EnvFormView) blurAll() { + v.Name.Blur() + v.Color.Blur() + v.Mode.Blur() + v.EnvVars.Blur() +} + +func (v *EnvFormView) focusCurrent() { + switch v.Focused { + case 0: + v.Name.Focus() + case 1: + v.Color.Focus() + case 2: + v.Mode.Focus() + case 3: + v.EnvVars.Focus() + } +} diff --git a/internal/adapters/handlers/tui/v2/views/env_vars_view.go b/internal/adapters/handlers/tui/v2/views/env_vars_view.go new file mode 100644 index 0000000..bedd132 --- /dev/null +++ b/internal/adapters/handlers/tui/v2/views/env_vars_view.go @@ -0,0 +1,76 @@ +package views + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/components" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" +) + +type EnvVarsView struct { + List components.List + Title lipgloss.Style + Item lipgloss.Style + Sel lipgloss.Style + Marker lipgloss.Color +} + +func NewEnvVarsView(items []components.ListItem, title, item, sel lipgloss.Style, marker lipgloss.Color) EnvVarsView { + l := components.NewList(items, item, sel). + WithMarkerColor(func(it components.ListItem) lipgloss.Color { + if it.ID == "__add__" { + return marker + } + + return lipgloss.Color(it.Color) + }) + l = l.WithBullet(func(it components.ListItem) lipgloss.Color { return lipgloss.Color(it.Color) }) + + return EnvVarsView{List: l, Title: title, Item: item, Sel: sel, Marker: marker} +} + +func (v EnvVarsView) Init() tea.Cmd { return nil } + +func (v EnvVarsView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case tea.KeyMsg: + s := m.String() + if s == "enter" { + if len(v.List.Items) == 0 { + return v, nil + } + it := v.List.Items[v.List.Cursor] + if it.ID == "__add__" { + return v, func() tea.Msg { return state.AddEnvVarMsg{} } + } + return v, func() tea.Msg { return state.ExitMsg{} } + } + if s == "e" { + if len(v.List.Items) == 0 { + return v, nil + } + it := v.List.Items[v.List.Cursor] + if it.ID != "__add__" { + return v, func() tea.Msg { return state.EditEnvVarMsg{Name: it.ID} } + } + return v, nil + } + if s == "d" { + if len(v.List.Items) == 0 { + return v, nil + } + it := v.List.Items[v.List.Cursor] + if it.ID != "__add__" { + return v, func() tea.Msg { return state.RemoveEnvVarMsg{Name: it.ID} } + } + } + } + + l, cmd := v.List.Update(msg) + v.List = l + + return v, cmd +} + +func (v EnvVarsView) View() string { return v.List.View() } diff --git a/internal/adapters/handlers/tui/v2/views/post_create_view.go b/internal/adapters/handlers/tui/v2/views/post_create_view.go new file mode 100644 index 0000000..e28574c --- /dev/null +++ b/internal/adapters/handlers/tui/v2/views/post_create_view.go @@ -0,0 +1,87 @@ +package views + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" +) + +type PostCreateView struct { + idx int + projectName string + Title lipgloss.Style + Item lipgloss.Style + Sel lipgloss.Style + Help lipgloss.Style +} + +func NewPostCreateView(projectName string, title, item, sel, help lipgloss.Style) PostCreateView { + return PostCreateView{projectName: projectName, Title: title, Item: item, Sel: sel, Help: help} +} + +func (v PostCreateView) Init() tea.Cmd { return nil } + +func (v PostCreateView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case tea.KeyMsg: + switch m.Type { + case tea.KeyUp: + if v.idx > 0 { + v.idx-- + } + case tea.KeyDown: + if v.idx < 2 { + v.idx++ + } + case tea.KeyEnter: + choice := v.idx + return v, func() tea.Msg { return state.PostCreateChoiceMsg{Choice: choice, ProjectName: v.projectName} } + case tea.KeyEsc, tea.KeyCtrlC: + return v, func() tea.Msg { return state.PostCreateChoiceMsg{Choice: 0, ProjectName: v.projectName} } + } + + if m.Type == tea.KeyRunes { + switch string(m.Runes) { + case "k": + if v.idx > 0 { + v.idx-- + } + case "j": + if v.idx < 2 { + v.idx++ + } + case "q": + return v, func() tea.Msg { return state.PostCreateChoiceMsg{Choice: 0, ProjectName: v.projectName} } + } + } + } + + return v, nil +} + +func (v PostCreateView) View() string { + b := strings.Builder{} + b.WriteString(v.Title.Render("Project created. What next?")) + b.WriteString("\n") + + opts := []string{"Return to list", "Start this project", "Exit"} + for i, o := range opts { + if i == v.idx { + b.WriteString("➜ ") + b.WriteString(v.Sel.Render(o)) + } else { + b.WriteString(" ") + b.WriteString(v.Item.Render(o)) + } + + b.WriteString("\n") + } + + help := v.Help.Render(`Navigate: ↑/k ↓/j +Select: Enter Cancel: Esc/q/Ctrl+C`) + + return lipgloss.JoinVertical(lipgloss.Left, b.String(), help) +} diff --git a/internal/adapters/handlers/tui/v2/views/project_form_view.go b/internal/adapters/handlers/tui/v2/views/project_form_view.go new file mode 100644 index 0000000..fd5571b --- /dev/null +++ b/internal/adapters/handlers/tui/v2/views/project_form_view.go @@ -0,0 +1,457 @@ +package views + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/components" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" +) + +type ProjectFormView struct { + OriginalName string + Name components.Input + Path components.Input + Subproject components.Input + Shell components.Input + UserName components.Input + UserEmail components.Input + UserSigningKey components.Input + CommitGPGSign components.Input + TagGPGSign components.Input + EnvVars components.TextArea + SaveBtn components.Button + Cancel components.Button + Buttons components.ButtonGroup + Title lipgloss.Style + IsEdit bool + Focused int +} + +func NewProjectFormView( + name, path string, + title, input, inputVal, btnPri, btnSec, btnDis lipgloss.Style, +) ProjectFormView { + v := ProjectFormView{ + Name: components.NewInput("Name*", "my-awesome-app", name, input, inputVal), + Path: components.NewInput("Path*", "~/my-awesome-app", path, input, inputVal), + Subproject: components.NewInput("Subproject", "services/api", "", input, inputVal), + Shell: components.NewInput("Shell", "/bin/bash", "", input, inputVal), + UserName: components.NewInput("Git user.name", "Jane Doe", "", input, inputVal), + UserEmail: components.NewInput("Git user.email", "jane@example.com", "", input, inputVal), + UserSigningKey: components.NewInput("Git user.signingkey", "0xDEADBEEF", "", input, inputVal), + CommitGPGSign: components.NewInput("commit.gpgsign (true/false)", "true/false", "true", input, inputVal), + TagGPGSign: components.NewInput("tag.gpgsign (true/false)", "true/false", "true", input, inputVal), + EnvVars: components.NewTextArea("# One per line (like .env)\nAPP_ENV=development\nDATABASE_URL=postgres://user:pass@localhost:5432/app\n# comments allowed", "", input, inputVal), + SaveBtn: components.NewButton("Save", components.Primary, btnPri, btnDis), + Cancel: components.NewButton("Cancel", components.Secondary, btnSec, btnDis), + Title: title, + } + + v.Buttons = components.NewButtonGroup( + []components.ButtonSpec{{Label: "Save", Primary: true}, {Label: "Cancel"}}, + btnPri, + btnSec, + btnDis, + ) + + v.Name.SetWidth(40) + v.Path.SetWidth(40) + v.Subproject.SetWidth(40) + v.Shell.SetWidth(40) + v.UserName.SetWidth(40) + v.UserEmail.SetWidth(40) + v.UserSigningKey.SetWidth(40) + v.CommitGPGSign.SetWidth(40) + v.TagGPGSign.SetWidth(40) + v.EnvVars.SetHeight(6) + v.EnvVars.SetWidth(60) + + v.Name.Focus() + v.Focused = 0 + + if strings.TrimSpace(name) != "" && strings.TrimSpace(path) != "" { + v.IsEdit = true + v.OriginalName = name + } + return v +} + +func (v ProjectFormView) Init() tea.Cmd { return nil } + +func (v ProjectFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if v.Focused >= 10 { + switch msg.Type { + case tea.KeyLeft: + if v.Focused == 11 { + v.Focused = 10 + } + return v, nil + case tea.KeyRight: + if v.Focused == 10 { + v.Focused = 11 + } + return v, nil + case tea.KeyEnter: + if v.Focused == 10 { + return v, func() tea.Msg { + return state.SaveProjectMsg{ + Name: v.Name.Value(), + Path: v.Path.Value(), + Subproject: v.Subproject.Value(), + Shell: v.Shell.Value(), + GitUserName: v.UserName.Value(), + GitUserEmail: v.UserEmail.Value(), + GitSigningKey: v.UserSigningKey.Value(), + CommitGPGSign: v.CommitGPGSign.Value(), + TagGPGSign: v.TagGPGSign.Value(), + EnvVarsRaw: v.EnvVars.Value(), + } + } + } + return v, func() tea.Msg { return state.CancelMsg{} } + } + } + + if msg.Type == tea.KeyCtrlS { + return v, func() tea.Msg { + return state.SaveProjectMsg{ + Name: v.Name.Value(), + Path: v.Path.Value(), + Subproject: v.Subproject.Value(), + Shell: v.Shell.Value(), + GitUserName: v.UserName.Value(), + GitUserEmail: v.UserEmail.Value(), + GitSigningKey: v.UserSigningKey.Value(), + CommitGPGSign: v.CommitGPGSign.Value(), + TagGPGSign: v.TagGPGSign.Value(), + EnvVarsRaw: v.EnvVars.Value(), + } + } + } + + if msg.Type == tea.KeyTab || msg.Type == tea.KeyDown || msg.Type == tea.KeyEnter || msg.Type == tea.KeyShiftTab || msg.Type == tea.KeyUp { + f := v.Focused + if msg.Type == tea.KeyShiftTab || msg.Type == tea.KeyUp { + f-- + } else { + if !(msg.Type == tea.KeyEnter && f >= 9) { + f++ + } + } + if v.IsEdit && f == 1 { + if msg.Type == tea.KeyShiftTab || msg.Type == tea.KeyUp { + f-- + } else { + f++ + } + } + if f < 0 { + f = 11 + } + if f > 11 { + f = 0 + } + if (msg.Type == tea.KeyUp || msg.Type == tea.KeyDown) && v.Focused == 9 { + f = v.Focused + } + v.blurAll() + v.Focused = f + v.focusCurrent() + } + + case components.InputSubmitMsg: + val := strings.TrimSpace(msg.Value) + if val == "" { + return v, nil + } + + return v, func() tea.Msg { + return state.SaveProjectMsg{ + Name: v.Name.Value(), + Path: v.Path.Value(), + Subproject: v.Subproject.Value(), + Shell: v.Shell.Value(), + GitUserName: v.UserName.Value(), + GitUserEmail: v.UserEmail.Value(), + GitSigningKey: v.UserSigningKey.Value(), + CommitGPGSign: v.CommitGPGSign.Value(), + TagGPGSign: v.TagGPGSign.Value(), + EnvVarsRaw: v.EnvVars.Value(), + } + } + case components.ButtonPressedMsg: + if msg.Label == "Save" { + return v, func() tea.Msg { + return state.SaveProjectMsg{ + Name: v.Name.Value(), + Path: v.Path.Value(), + Subproject: v.Subproject.Value(), + Shell: v.Shell.Value(), + GitUserName: v.UserName.Value(), + GitUserEmail: v.UserEmail.Value(), + GitSigningKey: v.UserSigningKey.Value(), + CommitGPGSign: v.CommitGPGSign.Value(), + TagGPGSign: v.TagGPGSign.Value(), + EnvVarsRaw: v.EnvVars.Value(), + } + } + } + + if msg.Label == "Cancel" { + return v, func() tea.Msg { return state.CancelMsg{} } + } + case components.ButtonChosenMsg: + if msg.Label == "Save" { + return v, func() tea.Msg { + return state.SaveProjectMsg{ + Name: v.Name.Value(), + Path: v.Path.Value(), + Subproject: v.Subproject.Value(), + Shell: v.Shell.Value(), + GitUserName: v.UserName.Value(), + GitUserEmail: v.UserEmail.Value(), + GitSigningKey: v.UserSigningKey.Value(), + CommitGPGSign: v.CommitGPGSign.Value(), + TagGPGSign: v.TagGPGSign.Value(), + EnvVarsRaw: v.EnvVars.Value(), + } + } + } + + if msg.Label == "Cancel" { + return v, func() tea.Msg { return state.CancelMsg{} } + } + } + + oldName := strings.TrimSpace(v.Name.Value()) + n, ncmd := v.Name.Update(msg) + v.Name = n + if !v.IsEdit && v.Name.Focused() { + newName := strings.TrimSpace(v.Name.Value()) + if newName != oldName { + slug := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(newName, " ", "-"))) + cur := strings.TrimSpace(v.Path.Value()) + if slug == "" { + if cur == "" || strings.HasPrefix(cur, "~/") { + v.Path.SetValue("~/") + } + } else { + if cur == "" || strings.HasPrefix(cur, "~/") { + v.Path.SetValue("~/" + slug) + } + } + } + } + + p, pcmd := v.Path.Update(msg) + v.Path = p + s, scmd := v.Subproject.Update(msg) + v.Subproject = s + sh, shcmd := v.Shell.Update(msg) + v.Shell = sh + u, ucmd := v.UserName.Update(msg) + v.UserName = u + uem, uemcmd := v.UserEmail.Update(msg) + v.UserEmail = uem + uk, ukcmd := v.UserSigningKey.Update(msg) + v.UserSigningKey = uk + cg, cgcmd := v.CommitGPGSign.Update(msg) + v.CommitGPGSign = cg + tg, tgcmd := v.TagGPGSign.Update(msg) + v.TagGPGSign = tg + e, ecmd := v.EnvVars.Update(msg) + v.EnvVars = e + + var bgcmd tea.Cmd + if v.Focused >= 10 { + bg, bcmd := v.Buttons.Update(msg) + v.Buttons = bg + bgcmd = bcmd + } + + return v, tea.Batch(ncmd, pcmd, scmd, shcmd, ucmd, uemcmd, ukcmd, cgcmd, tgcmd, ecmd, bgcmd) +} + +func (v *ProjectFormView) blurAll() { + v.Name.Blur() + v.Path.Blur() + v.Subproject.Blur() + v.Shell.Blur() + v.UserName.Blur() + v.UserEmail.Blur() + v.UserSigningKey.Blur() + v.CommitGPGSign.Blur() + v.TagGPGSign.Blur() + v.EnvVars.Blur() +} + +func (v *ProjectFormView) focusCurrent() { + switch v.Focused { + case 0: + v.Name.Focus() + case 1: + if !v.IsEdit { + v.Path.Focus() + } + case 2: + v.Subproject.Focus() + case 3: + v.Shell.Focus() + case 4: + v.UserName.Focus() + case 5: + v.UserEmail.Focus() + case 6: + v.UserSigningKey.Focus() + case 7: + v.CommitGPGSign.Focus() + case 8: + v.TagGPGSign.Focus() + case 9: + v.EnvVars.Focus() + case 10: + v.Buttons.Cursor = 0 + case 11: + v.Buttons.Cursor = 1 + } +} + +func (v ProjectFormView) View() string { + b := strings.Builder{} + title := "New project" + if v.IsEdit { + title = "Edit project" + } + b.WriteString(v.Title.Render(title)) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")).Bold(true).Render("Project details")) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C")).Render("Basic information")) + b.WriteString("\n") + { + p := lipgloss.NewStyle() + val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + v.Name.SetPrompt(p.Render("Name") + lipgloss.NewStyle().Foreground(lipgloss.Color("#BF616A")).Render("*") + p.Render(": ")) + v.Name.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) + v.Name.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.Name.SetTextStyle(val) + } + b.WriteString(v.Name.View()) + b.WriteString("\n") + if v.IsEdit { + p := strings.TrimSpace(v.Path.Value()) + if p == "" { p = "~/my-awesome-app" } + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(p + " (read-only)")) + } else { + { + p := lipgloss.NewStyle() + val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + v.Path.SetPrompt(p.Render("Path") + lipgloss.NewStyle().Foreground(lipgloss.Color("#BF616A")).Render("*") + p.Render(": ")) + v.Path.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) + v.Path.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.Path.SetTextStyle(val) + } + b.WriteString(v.Path.View()) + } + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")).Bold(true).Render("Project options")) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C")).Render("Optional settings")) + b.WriteString("\n") + { + p := lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")) + val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + v.Subproject.SetPrompt(p.Render("Subproject: ")) + v.Subproject.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) + v.Subproject.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.Subproject.SetTextStyle(val) + } + b.WriteString(v.Subproject.View()) + b.WriteString("\n") + { + p := lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")) + val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + v.Shell.SetPrompt(p.Render("Shell: ")) + v.Shell.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) + v.Shell.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.Shell.SetTextStyle(val) + } + b.WriteString(v.Shell.View()) + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")).Bold(true).Render("Git settings")) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C")).Render("Applied to this project only")) + b.WriteString("\n") + { + p := lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")) + val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + v.UserName.SetPrompt(p.Render("Git user.name: ")) + v.UserName.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) + v.UserName.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.UserName.SetTextStyle(val) + } + b.WriteString(v.UserName.View()) + b.WriteString("\n") + { + p := lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")) + val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + v.UserEmail.SetPrompt(p.Render("Git user.email: ")) + v.UserEmail.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) + v.UserEmail.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.UserEmail.SetTextStyle(val) + } + b.WriteString(v.UserEmail.View()) + b.WriteString("\n") + { + p := lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")) + val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + v.UserSigningKey.SetPrompt(p.Render("Git user.signingkey: ")) + v.UserSigningKey.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) + v.UserSigningKey.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.UserSigningKey.SetTextStyle(val) + } + b.WriteString(v.UserSigningKey.View()) + b.WriteString("\n") + { + p := lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")) + val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + v.CommitGPGSign.SetPrompt(p.Render("commit.gpgsign (true/false): ")) + v.CommitGPGSign.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) + v.CommitGPGSign.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.CommitGPGSign.SetTextStyle(val) + } + b.WriteString(v.CommitGPGSign.View()) + b.WriteString("\n") + { + p := lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")) + val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + v.TagGPGSign.SetPrompt(p.Render("tag.gpgsign (true/false): ")) + v.TagGPGSign.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) + v.TagGPGSign.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.TagGPGSign.SetTextStyle(val) + } + b.WriteString(v.TagGPGSign.View()) + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")).Bold(true).Render("Environment variables")) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C")).Render("One KEY=VALUE per line; '#' comments allowed")) + b.WriteString("\n") + b.WriteString(v.EnvVars.View()) + b.WriteString("\n\n") + saveLbl := "Save" + cancelLbl := "Cancel" + if v.Focused == 10 { + saveLbl = lipgloss.NewStyle().Bold(true).Render("Save") + } + if v.Focused == 11 { + cancelLbl = lipgloss.NewStyle().Bold(true).Render("Cancel") + } + b.WriteString(" " + saveLbl + " " + cancelLbl) + b.WriteString("\n\n") + return b.String() +} diff --git a/internal/adapters/handlers/tui/v2/views/projects_view.go b/internal/adapters/handlers/tui/v2/views/projects_view.go new file mode 100644 index 0000000..f811a65 --- /dev/null +++ b/internal/adapters/handlers/tui/v2/views/projects_view.go @@ -0,0 +1,98 @@ +package views + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/components" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" +) + +type projectsLoadedMsg struct{ Items []components.ListItem } + +type ProjectsView struct { + List components.List + TitleStyle lipgloss.Style + MarkerColor lipgloss.Color + fetch func() ([]components.ListItem, error) +} + +func NewProjectsView( + fetch func() ([]components.ListItem, error), + title, item, sel lipgloss.Style, + marker lipgloss.Color, +) ProjectsView { + l := components.NewList(nil, item, sel). + WithMarkerColor(func(_ components.ListItem) lipgloss.Color { return marker }) + + return ProjectsView{List: l, TitleStyle: title, MarkerColor: marker, fetch: fetch} +} + +func (v ProjectsView) Init() tea.Cmd { + return func() tea.Msg { + if v.fetch == nil { + return projectsLoadedMsg{Items: nil} + } + + it, _ := v.fetch() + + return projectsLoadedMsg{Items: it} + } +} + +func (v ProjectsView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case projectsLoadedMsg: + v.List.Items = m.Items + return v, nil + case tea.KeyMsg: + if m.Type == tea.KeyEnter { + if len(v.List.Items) == 0 { + return v, nil + } + + idx := v.List.Cursor + if idx >= 0 && idx < len(v.List.Items) { + it := v.List.Items[idx] + if it.ID == "__new__" { + return v, func() tea.Msg { return state.NewProjectMsg{} } + } + + return v, func() tea.Msg { return state.SelectProjectMsg{ID: it.ID} } + } + } + + if m.Type == tea.KeyRunes { + r := string(m.Runes) + if r == "e" { + if len(v.List.Items) == 0 { + return v, nil + } + + idx := v.List.Cursor + if idx >= 0 && idx < len(v.List.Items) { + it := v.List.Items[idx] + if it.ID != "__new__" { + return v, func() tea.Msg { return state.EditProjectMsg{ID: it.ID} } + } + } + } + } + } + + l, cmd := v.List.Update(msg) + v.List = l + + return v, cmd +} + +func (v ProjectsView) View() string { + b := strings.Builder{} + b.WriteString(v.TitleStyle.Render(" Projects")) + b.WriteString("\n") + b.WriteString(v.List.View()) + + return b.String() +} diff --git a/internal/adapters/handlers/tui/v2/views/update_flow_view.go b/internal/adapters/handlers/tui/v2/views/update_flow_view.go new file mode 100644 index 0000000..ec86cc5 --- /dev/null +++ b/internal/adapters/handlers/tui/v2/views/update_flow_view.go @@ -0,0 +1,36 @@ +package views + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type UpdateFlowView struct { + Title lipgloss.Style + Body lipgloss.Style + Logs []string +} + +func NewUpdateFlowView(title, body lipgloss.Style) UpdateFlowView { + return UpdateFlowView{Title: title, Body: body, Logs: nil} +} + +func (v UpdateFlowView) Init() tea.Cmd { return nil } + +func (v UpdateFlowView) Update(tea.Msg) (tea.Model, tea.Cmd) { return v, nil } + +func (v UpdateFlowView) View() string { + b := strings.Builder{} + b.WriteString(v.Title.Render("Update Flow")) + b.WriteString("\n") + + if len(v.Logs) == 0 { + b.WriteString(v.Body.Render("Update in progress...")) + } else { + b.WriteString(v.Body.Render(strings.Join(v.Logs, "\n"))) + } + + return b.String() +} diff --git a/internal/adapters/handlers/tui/v2/views/views_compile_test.go b/internal/adapters/handlers/tui/v2/views/views_compile_test.go new file mode 100644 index 0000000..0ea398f --- /dev/null +++ b/internal/adapters/handlers/tui/v2/views/views_compile_test.go @@ -0,0 +1,39 @@ +package views + +import ( + "testing" + + "github.com/charmbracelet/lipgloss" + + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/components" +) + +func TestProjectsViewCompile(_ *testing.T) { + _ = NewProjectsView(nil, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.Color("240")) +} + +func TestProjectFormViewCompile(_ *testing.T) { + _ = NewProjectFormView( + "", + "", + lipgloss.NewStyle(), + lipgloss.NewStyle(), + lipgloss.NewStyle(), + lipgloss.NewStyle(), + lipgloss.NewStyle(), + lipgloss.NewStyle(), + ) +} + +func TestEnvVarsViewCompile(_ *testing.T) { + items := []components.ListItem{{ID: "__add__", Label: "+ Add environment"}} + _ = NewEnvVarsView(items, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.Color("240")) +} + +func TestEnvFormViewCompile(_ *testing.T) { + _ = NewEnvFormView("", "", "", "file", "", lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()) +} + +func TestPostCreateViewCompile(_ *testing.T) { + _ = NewPostCreateView("proj", lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()) +} diff --git a/internal/adapters/handlers/tui/v2/window.go b/internal/adapters/handlers/tui/v2/window.go new file mode 100644 index 0000000..195d31d --- /dev/null +++ b/internal/adapters/handlers/tui/v2/window.go @@ -0,0 +1,1236 @@ +package v2 + +import ( + "os" + "path/filepath" + "sort" + "strings" + + helpcomp "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/components" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/router" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" + stylespkg "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/styles" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/views" + "github.com/jlrosende/project-manager/internal/core/domain" + "github.com/jlrosende/project-manager/internal/core/services" +) + +var currentPalette = map[string]string{ + "title": "#88C0D0", + "section": "#81A1C1", + "subtext": "#7C818C", + "text": "#D8DEE9", + "placeholder": "#7C818C", + "border": "#4C566A", + "error": "#BF616A", + "buttonDefFg": "#2E3440", + "buttonDefBg": "#4C566A", + "buttonSelFg": "#2E3440", + "buttonSelBg": "#A3BE8C", + "selectedFg": "#ECEFF4", + "selectedBg": "#5E81AC", + "help": "#7C818C", +} + +var presetPalettes = map[string]map[string]string{ + "nord": { + "title": "#88C0D0", + "section": "#81A1C1", + "subtext": "#7C818C", + "text": "#D8DEE9", + "placeholder": "#7C818C", + "border": "#4C566A", + "error": "#BF616A", + "buttonDefFg": "#2E3440", + "buttonDefBg": "#4C566A", + "buttonSelFg": "#2E3440", + "buttonSelBg": "#A3BE8C", + "selectedFg": "#ECEFF4", + "selectedBg": "#5E81AC", + "help": "#7C818C", + }, + "catppuccin": { + "title": "#89B4FA", + "section": "#B4BEFE", + "subtext": "#A6ADC8", + "text": "#CDD6F4", + "placeholder": "#6C7086", + "border": "#585B70", + "error": "#F38BA8", + "buttonDefFg": "#1E1E2E", + "buttonDefBg": "#585B70", + "buttonSelFg": "#1E1E2E", + "buttonSelBg": "#A6E3A1", + "selectedFg": "#CDD6F4", + "selectedBg": "#89B4FA", + "help": "#A6ADC8", + }, + "dracula": { + "title": "#BD93F9", + "section": "#8BE9FD", + "subtext": "#6272A4", + "text": "#F8F8F2", + "placeholder": "#6272A4", + "border": "#44475A", + "error": "#FF5555", + "buttonDefFg": "#282A36", + "buttonDefBg": "#44475A", + "buttonSelFg": "#282A36", + "buttonSelBg": "#50FA7B", + "selectedFg": "#F8F8F2", + "selectedBg": "#6272A4", + "help": "#6272A4", + }, + "ayu": { + "title": "#59C2FF", + "section": "#D4BFFF", + "subtext": "#5C6773", + "text": "#CBCCC6", + "placeholder": "#5C6773", + "border": "#3D4754", + "error": "#D95757", + "buttonDefFg": "#1F2430", + "buttonDefBg": "#3D4754", + "buttonSelFg": "#1F2430", + "buttonSelBg": "#AAD94C", + "selectedFg": "#CBCCC6", + "selectedBg": "#59C2FF", + "help": "#5C6773", + }, +} + +func init() { + presetPalettes["nord-light"] = map[string]string{ + "title": "#5E81AC", + "section": "#81A1C1", + "subtext": "#4C566A", + "text": "#2E3440", + "placeholder": "#7C818C", + "border": "#D8DEE9", + "error": "#BF616A", + "buttonDefFg": "#2E3440", + "buttonDefBg": "#D8DEE9", + "buttonSelFg": "#2E3440", + "buttonSelBg": "#A3BE8C", + "selectedFg": "#2E3440", + "selectedBg": "#88C0D0", + "help": "#6C6F7D", + } + + presetPalettes["catppuccin-light"] = map[string]string{ + "title": "#1E66F5", + "section": "#7287FD", + "subtext": "#6C6F85", + "text": "#4C4F69", + "placeholder": "#9CA0B0", + "border": "#BCC0CC", + "error": "#D20F39", + "buttonDefFg": "#4C4F69", + "buttonDefBg": "#BCC0CC", + "buttonSelFg": "#4C4F69", + "buttonSelBg": "#40A02B", + "selectedFg": "#4C4F69", + "selectedBg": "#8CAAEE", + "help": "#6C6F85", + } + + presetPalettes["dracula-light"] = map[string]string{ + "title": "#6272A4", + "section": "#8BE9FD", + "subtext": "#6D7086", + "text": "#282A36", + "placeholder": "#A0A0A0", + "border": "#E5E5E5", + "error": "#FF5555", + "buttonDefFg": "#282A36", + "buttonDefBg": "#E5E5E5", + "buttonSelFg": "#282A36", + "buttonSelBg": "#50FA7B", + "selectedFg": "#282A36", + "selectedBg": "#8BE9FD", + "help": "#6D7086", + } + + presetPalettes["ayu-light"] = map[string]string{ + "title": "#55B4D4", + "section": "#D4BFFF", + "subtext": "#8A9199", + "text": "#5C6773", + "placeholder": "#9AA5B1", + "border": "#E6E9EF", + "error": "#F07178", + "buttonDefFg": "#5C6773", + "buttonDefBg": "#E6E9EF", + "buttonSelFg": "#1F2430", + "buttonSelBg": "#AAD94C", + "selectedFg": "#5C6773", + "selectedBg": "#FFCC66", + "help": "#8A9199", + } +} + +func setPaletteByName(name string) { + if p, ok := presetPalettes[strings.ToLower(strings.TrimSpace(name))]; ok { + for k, v := range p { + currentPalette[k] = v + } + } +} + +type Options struct { + Theme string + Overrides map[string]string +} + +type keyMap struct { + Quit key.Binding + Edit key.Binding + Left key.Binding + Right key.Binding + Up key.Binding + Down key.Binding + Enter key.Binding + Help key.Binding +} + +type formKeyMap struct { + Save key.Binding + Cancel key.Binding + Next key.Binding + Prev key.Binding + Buttons key.Binding + Help key.Binding +} + +func (k keyMap) ShortHelp() []key.Binding { return []key.Binding{k.Quit, k.Edit, k.Enter, k.Help} } +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Left, k.Right, k.Up, k.Down}, {k.Edit, k.Enter, k.Quit, k.Help}} +} + +func (k formKeyMap) ShortHelp() []key.Binding { return []key.Binding{k.Next, k.Save, k.Help} } +func (k formKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Next, k.Prev, k.Buttons}, {k.Save, k.Cancel, k.Help}} +} + +type Window struct { + projectSvc *services.ProjectService + r *router.Router + + projects []*domain.Project + selectedProject *domain.Project + + cursor int + cursorEnv int + total int + width int + + height int + shouldQuit bool + mode int + focus int + form tea.Model + prompt tea.Model + envForm tea.Model + projectsView tea.Model + envVarsView tea.Model + envProjectName string + theme stylespkg.Theme + cs stylespkg.ComponentStyles + vp viewport.Model + help helpcomp.Model + keys keyMap + fkeys formKeyMap +} + +func NewWindow(projectSvc *services.ProjectService, opts Options) (*Window, error) { + if strings.TrimSpace(opts.Theme) != "" { + setPaletteByName(opts.Theme) + } + + for k, v := range opts.Overrides { + if strings.TrimSpace(v) != "" { + currentPalette[k] = v + } + } + + tokens := stylespkg.NewTokensFromHex(currentPalette) + theme := stylespkg.BuildTheme(tokens) + cs := stylespkg.BuildComponentStyles(theme) + + projects, err := projectSvc.List() + if err != nil { + return nil, err + } + + total := len(projects) + 1 + if total == 0 { + total = 1 + } + + vp := viewport.New(0, 0) + h := helpcomp.New() + + w := &Window{ + projectSvc: projectSvc, + projects: projects, + total: total, + cursor: 0, + theme: theme, + cs: cs, + vp: vp, + help: h, + r: router.New(router.RouteProjects), + } + + w.keys.Quit = key.NewBinding(key.WithKeys("q", "ctrl+c", "esc"), key.WithHelp("q", "quit")) + w.keys.Edit = key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")) + w.keys.Left = key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("\u2190/h", "focus projects")) + w.keys.Right = key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("\u2192/l", "focus envs")) + w.keys.Up = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("\u2191/k", "up")) + w.keys.Down = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("\u2193/j", "down")) + w.keys.Enter = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")) + w.keys.Help = key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "toggle help")) + + w.fkeys.Save = key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")) + w.fkeys.Cancel = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")) + w.fkeys.Next = key.NewBinding(key.WithKeys("tab", "down", "enter"), key.WithHelp("tab/↓/enter", "next")) + w.fkeys.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev")) + w.fkeys.Buttons = key.NewBinding(key.WithKeys("left", "right"), key.WithHelp("←/β†’", "buttons")) + w.fkeys.Help = key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "toggle help")) + + fetch := func() ([]components.ListItem, error) { + items := make([]components.ListItem, 0, len(w.projects)+1) + for _, p := range w.projects { + items = append(items, components.ListItem{ID: p.Name, Label: p.Name}) + } + + items = append(items, components.ListItem{ID: "__new__", Label: "+ New project"}) + + return items, nil + } + + w.projectsView = views.NewProjectsView( + fetch, + w.theme.Title, + w.cs.ListItem, + w.cs.ListSelected, + lipgloss.Color(currentPalette["selectedBg"]), + ) + + if pv, ok := w.projectsView.(views.ProjectsView); ok { + if items, err := fetch(); err == nil { + pv.List.Items = items + w.projectsView = pv + } + } + + return w, nil +} + +func (m Window) Init() tea.Cmd { + cmds := []tea.Cmd{tea.SetWindowTitle("Project Manager")} + + if m.projectsView != nil { + cmds = append(cmds, m.projectsView.Init()) + } + + return tea.Batch(cmds...) +} + +func (m *Window) SelectedProject() *domain.Project { return m.selectedProject } + +func (m *Window) SelectedEnvironment() string { + if len(m.projects) == 0 { + return "" + } + + idx := mod(m.cursor, m.total) + if idx < 0 || idx >= len(m.projects) { + return "" + } + + p := m.projects[idx] + if len(p.Environments) == 0 { + return "" + } + + e := m.cursorEnv % len(p.Environments) + if e < 0 { + e = 0 + } + + return p.Environments[e].Name +} + +func mod(a, b int) int { return (a%b + b) % b } + +func parseEnvLines(s string) domain.EnvVars { + res := domain.EnvVars{} + + for _, line := range strings.Split(s, "\n") { + l := strings.TrimSpace(line) + if l == "" || strings.HasPrefix(l, "#") { + continue + } + + kv := strings.SplitN(l, "=", 2) + if len(kv) == 2 { + res[strings.TrimSpace(kv[0])] = kv[1] + } + } + + return res +} + +var colorNameMap = map[string]string{ + "black": "0", + "white": "15", + "red": "196", + "green": "46", + "blue": "21", + "yellow": "226", + "magenta": "201", + "purple": "93", + "cyan": "51", + "teal": "30", + "orange": "208", + "pink": "205", + "grey": "240", + "gray": "240", +} + +func normalizeColorInput(s string) string { + ss := strings.ToLower(strings.TrimSpace(s)) + if ss == "" || ss == "grey" || ss == "gray" { + return "240" + } + + if strings.HasPrefix(ss, "#") { + return ss + } + + if v, ok := colorNameMap[ss]; ok { + return v + } + + return ss +} + +func (m *Window) newProjectFlow() { + m.form = views.NewProjectFormView( + "", + "", + m.theme.Title, + m.cs.ListItem, + m.cs.ListItem, + m.cs.ListSelected, + m.cs.ButtonPrimary, + m.cs.ButtonSecondary, + ) + + m.mode = 1 + if m.r != nil { + m.r.NavigateTo(router.RouteProjectForm, nil) + } +} + +func (m *Window) newEnvironmentFlow(projectName string) { + m.envForm = views.NewEnvFormView("", "", "", "merge", "", m.theme.Title, m.cs.ListItem, m.cs.ButtonPrimary, m.cs.ButtonSecondary) + m.envProjectName = projectName + + m.mode = 3 + if m.r != nil { + m.r.NavigateTo(router.RouteEnvVars, router.Params{"project": projectName}) + } +} + +func (m *Window) currentView() tea.Model { + switch m.r.Current() { + case router.RouteProjectForm: + if m.form != nil { + return m.form + } + case router.RouteEnvVars: + if m.envForm != nil { + return m.envForm + } + + projName := "" + if m.r != nil && m.r.Params() != nil { + projName = m.r.Params()["project"] + } + + if projName == "" { + projName = m.envProjectName + } + + if projName != "" { + p, _ := m.projectSvc.Load(projName) + if p != nil { + items := make([]components.ListItem, 0, len(p.Environments)+1) + for _, e := range p.Environments { + items = append(items, components.ListItem{ID: e.Name, Label: e.Name, Color: e.Color}) + } + + items = append(items, components.ListItem{ID: "__add__", Label: "+ Add environment"}) + + m.envVarsView = views.NewEnvVarsView( + items, + m.theme.Title, + m.cs.ListItem, + m.cs.ListSelected, + lipgloss.Color(currentPalette["selectedBg"]), + ) + + return m.envVarsView + } + } + case router.RouteUpdateFlow: + if m.prompt != nil { + return m.prompt + } + } + + if m.projectsView != nil { + return m.projectsView + } + + return nil +} + +func (m Window) View() string { + if m.mode == 0 && m.r != nil && m.r.Current() == router.RouteProjects { + var ( + leftList string + rightList string + ) + + if pv, ok := m.projectsView.(views.ProjectsView); ok { + idx := pv.List.Cursor + + { + b := strings.Builder{} + + for i, p := range m.projects { + selected := idx == i + marker := " " + + text := m.cs.ListItem.Render(p.Name) + if selected { + marker = lipgloss.NewStyle(). + Foreground(lipgloss.Color(currentPalette["selectedBg"])). + Render("β–Œ ") + text = lipgloss.NewStyle(). + Foreground(lipgloss.Color(currentPalette["selectedFg"])). + Bold(true). + Render(p.Name) + } + + b.WriteString(marker) + b.WriteString(text) + b.WriteString("\n") + } + + if idx == len(m.projects) { + b.WriteString( + lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["selectedBg"])).Render("β–Œ "), + ) + b.WriteString( + lipgloss.NewStyle(). + Foreground(lipgloss.Color(currentPalette["selectedFg"])). + Bold(true). + Render("+ New project"), + ) + b.WriteString("\n") + } else { + b.WriteString(" ") + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["section"])).Bold(true).Render("+ New project")) + b.WriteString("\n") + } + + leftList = b.String() + } + + if idx >= 0 && idx < len(pv.List.Items) { + it := pv.List.Items[idx] + if it.ID != "__new__" { + p, _ := m.projectSvc.Load(it.ID) + if p != nil { + cursor := 0 + if ev, ok := m.envVarsView.(views.EnvVarsView); ok { + cursor = ev.List.Cursor + } + + b := strings.Builder{} + + if len(p.Environments) == 0 { + marker := " " + if m.focus == 1 && cursor == 0 { + marker = lipgloss.NewStyle(). + Foreground(lipgloss.Color(currentPalette["selectedBg"])). + Render("β–Œ ") + } + + b.WriteString(marker) + b.WriteString( + lipgloss.NewStyle(). + Foreground(lipgloss.Color(currentPalette["section"])). + Bold(true). + Render("+ New environment"), + ) + b.WriteString("\n") + } else { + for i, e := range p.Environments { + selected := m.focus == 1 && cursor == i + + marker := " " + if selected { + marker = lipgloss.NewStyle().Foreground(lipgloss.Color(e.Color)).Render("β–Œ ") + } + + var content string + + if selected { + bullet := lipgloss.NewStyle().Foreground(lipgloss.Color(e.Color)).Render("● ") + name := lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["selectedFg"])).Bold(true).Render(e.Name) + content = bullet + name + } else { + content = m.cs.ListItem.Foreground(lipgloss.Color(e.Color)).Render("● " + e.Name) + } + + if p.DefaultEnv != "" && e.Name == p.DefaultEnv { + content = content + " " + lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["subtext"])).Render("(default)") + } + + b.WriteString(marker) + b.WriteString(content) + b.WriteString("\n") + } + + marker := " " + if m.focus == 1 && cursor == len(p.Environments) { + marker = lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["selectedBg"])).Render("β–Œ ") + } + + b.WriteString(marker) + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["section"])).Bold(true).Render("+ New environment")) + b.WriteString("\n") + } + + rightList = b.String() + } + } + } + } + + left := m.theme.Title.Render(" Projects") + "\n" + leftList + right := m.theme.Title.Render("󱄑 Environments") + "\n" + rightList + + ll := strings.Split(strings.TrimRight(left, "\n"), "\n") + rl := strings.Split(strings.TrimRight(right, "\n"), "\n") + + lw := 0 + + for _, s := range ll { + w := lipgloss.Width(strings.TrimRight(s, " ")) + if w > lw { + lw = w + } + } + + var rows []string + + maxRows := len(ll) + if len(rl) > maxRows { + maxRows = len(rl) + } + + for i := 0; i < maxRows; i++ { + ls := "" + if i < len(ll) { + ls = strings.TrimRight(ll[i], " ") + } + + rs := "" + if i < len(rl) { + rs = rl[i] + } + + pad := lw - lipgloss.Width(ls) + if pad < 0 { + pad = 0 + } + + rows = append(rows, ls+strings.Repeat(" ", pad)+" "+rs) + } + + content := strings.Join(rows, "\n") + + sepLen := m.width + if sepLen < 1 { + sepLen = 80 + } + + sep := lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["border"])) + content = content + "\n\n" + sep.Render(strings.Repeat("─", sepLen)) + "\n" + m.help.View(m.keys) + + v := m.vp + v.SetContent(content) + + return v.View() + } + + v := m.currentView() + if v != nil { + content := v.View() + + sepLen := 80 + sep := lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["border"])) + pad := "\n\n" + if m.mode == 1 || m.mode == 3 { + pad = "\n\n\n" + if m.mode == 3 { + if f, ok := m.envForm.(*views.EnvFormView); ok { + if strings.TrimSpace(f.Err) != "" { + pad = "\n\n" + } + } + } + } + + content = strings.TrimRight(content, "\n") + pad + sep.Render(strings.Repeat("─", sepLen)) + "\n" + m.help.View(m.fkeys) + return content + } + + return "" +} + +func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.shouldQuit { + return m, tea.Quit + } + + var cmd tea.Cmd + + m.reduce(msg) + + if km, ok := msg.(tea.KeyMsg); ok { + if km.Type == tea.KeyRunes { + r := string(km.Runes) + if r == "?" { + m.help.ShowAll = !m.help.ShowAll + hh := lipgloss.Height(m.help.View(m.keys)) + vh := m.height - hh + if vh < 1 { + vh = 1 + } + m.vp.Height = vh + return m, nil + } + } + } + + if m.mode == 1 && m.form != nil { + fm, fcmd := m.form.Update(msg) + m.form = fm + cmd = tea.Batch(cmd, fcmd) + } + + if m.mode == 3 && m.envForm != nil { + efm, ecmd := m.envForm.Update(msg) + m.envForm = efm + cmd = tea.Batch(cmd, ecmd) + } + + if m.mode == 0 && m.projectsView != nil && m.r != nil && m.r.Current() == router.RouteProjects { + if km, ok := msg.(tea.KeyMsg); ok { + switch km.Type { + case tea.KeyRight: + if pv, ok := m.projectsView.(views.ProjectsView); ok { + idx := pv.List.Cursor + if idx >= 0 && idx < len(pv.List.Items) { + it := pv.List.Items[idx] + if it.ID != "__new__" { + m.focus = 1 + } + } + } + case tea.KeyLeft: + m.focus = 0 + case tea.KeyTab: + if m.focus == 0 { + allow := true + if pv, ok := m.projectsView.(views.ProjectsView); ok { + idx := pv.List.Cursor + if idx >= 0 && idx < len(pv.List.Items) { + it := pv.List.Items[idx] + if it.ID == "__new__" { + allow = false + } + } + } + if allow { + m.focus = 1 + } + } else { + m.focus = 0 + } + case tea.KeyShiftTab: + if m.focus == 1 { + m.focus = 0 + } else { + allow := true + if pv, ok := m.projectsView.(views.ProjectsView); ok { + idx := pv.List.Cursor + if idx >= 0 && idx < len(pv.List.Items) { + it := pv.List.Items[idx] + if it.ID == "__new__" { + allow = false + } + } + } + if allow { + m.focus = 1 + } + } + } + + if km.Type == tea.KeyRunes { + r := string(km.Runes) + if r == "l" || r == "]" { + if pv, ok := m.projectsView.(views.ProjectsView); ok { + idx := pv.List.Cursor + if idx >= 0 && idx < len(pv.List.Items) { + it := pv.List.Items[idx] + if it.ID != "__new__" { + m.focus = 1 + } + } + } + } + + if r == "h" || r == "[" { + m.focus = 0 + } + } + } + + if m.focus == 1 && m.envVarsView == nil { + if pv, ok := m.projectsView.(views.ProjectsView); ok { + idx := pv.List.Cursor + if idx >= 0 && idx < len(pv.List.Items) { + it := pv.List.Items[idx] + if it.ID != "__new__" { + m.envProjectName = it.ID + + p, _ := m.projectSvc.Load(it.ID) + if p != nil { + items := make([]components.ListItem, 0, len(p.Environments)+1) + for _, e := range p.Environments { + label := e.Name + if p.DefaultEnv != "" && e.Name == p.DefaultEnv { + label = label + " (default)" + } + + items = append(items, components.ListItem{ID: e.Name, Label: label, Color: e.Color}) + } + + items = append(items, components.ListItem{ID: "__add__", Label: "+ New environment"}) + m.envVarsView = views.NewEnvVarsView( + items, + m.theme.Title, + m.cs.ListItem, + m.cs.ListSelected, + lipgloss.Color(currentPalette["selectedBg"]), + ) + } + } + } + } + } + + var vcmd tea.Cmd + + if m.focus == 0 { + pv, pvcmd := m.projectsView.Update(msg) + m.projectsView = pv + vcmd = pvcmd + } else if m.envVarsView != nil { + ev, evcmd := m.envVarsView.Update(msg) + m.envVarsView = ev + vcmd = evcmd + } + + cmd = tea.Batch(cmd, vcmd) + } + + if m.r != nil && m.r.Current() == router.RouteEnvVars && m.envForm == nil && m.envVarsView != nil { + ev, evcmd := m.envVarsView.Update(msg) + m.envVarsView = ev + cmd = tea.Batch(cmd, evcmd) + } + + switch msg := msg.(type) { + case components.ListMovedMsg: + if m.mode == 0 && m.r != nil && m.r.Current() == router.RouteProjects && m.focus == 0 { + m.envVarsView = nil + m.envProjectName = "" + } + case state.NewProjectMsg: + m.newProjectFlow() + return m, tea.ClearScreen + case state.SelectProjectMsg: + project, _ := m.projectSvc.Load(msg.ID) + m.selectedProject = project + + return m, tea.Quit + case state.SaveProjectMsg: + name := strings.TrimSpace(msg.Name) + path := strings.TrimSpace(msg.Path) + + if name == "" || path == "" { + return m, nil + } + + sub := strings.TrimSpace(msg.Subproject) + + envs := domain.EnvVars{} + if strings.TrimSpace(msg.EnvVarsRaw) != "" { + envs = parseEnvLines(msg.EnvVarsRaw) + } + + commitOn := strings.ToLower(strings.TrimSpace(msg.CommitGPGSign)) + tagOn := strings.ToLower(strings.TrimSpace(msg.TagGPGSign)) + commitEnable := commitOn == "true" || commitOn == "1" || commitOn == "yes" || commitOn == "y" + tagEnable := tagOn == "true" || tagOn == "1" || tagOn == "yes" || tagOn == "y" + + gitCfg := domain.New( + domain.WithName(strings.TrimSpace(msg.GitUserName)), + domain.WithEmail(strings.TrimSpace(msg.GitUserEmail)), + domain.WithSigningKey(strings.TrimSpace(msg.GitSigningKey)), + domain.WithCommitSign(commitEnable), + domain.WithTagSign(tagEnable), + ) + + if f, ok := m.form.(*views.ProjectFormView); ok && f.IsEdit && strings.TrimSpace(f.OriginalName) != "" { + proj, _ := m.projectSvc.Load(f.OriginalName) + if proj != nil { + proj.Name = name + proj.Shell = strings.TrimSpace(msg.Shell) + _ = m.projectSvc.UpdateProject(proj) + } + m.mode = 0 + if m.r != nil { + m.r.NavigateTo(router.RouteProjects, nil) + } + if m.projectsView != nil { + return m, tea.Batch(cmd, m.projectsView.Init()) + } + return m, tea.ClearScreen + } + + _, _ = m.projectSvc.Create(name, path, sub, envs, gitCfg) + m.prompt = views.NewPostCreateView(name, m.theme.Title, m.cs.ListItem, m.cs.ListSelected, m.theme.Help) + + m.mode = 2 + if m.r != nil { + m.r.NavigateTo(router.RouteUpdateFlow, nil) + } + + return m, tea.ClearScreen + case state.PostCreateChoiceMsg: + if msg.Choice == 0 { + m.mode = 0 + if m.r != nil { + m.r.NavigateTo(router.RouteProjects, nil) + } + + m.prompt = nil + + if m.projectsView != nil { + return m, tea.Batch(cmd, m.projectsView.Init()) + } + + return m, tea.ClearScreen + } + + if msg.Choice == 1 { + proj, _ := m.projectSvc.Load(msg.ProjectName) + m.selectedProject = proj + + return m, tea.Quit + } + + if msg.Choice == 2 { + m.selectedProject = nil + return m, tea.Quit + } + + return m, nil + case state.CancelMsg: + m.mode = 0 + if m.r != nil { + m.r.NavigateTo(router.RouteProjects, nil) + } + + if m.projectsView != nil { + return m, tea.Batch(cmd, m.projectsView.Init()) + } + + return m, tea.ClearScreen + case state.EditProjectMsg: + project, _ := m.projectSvc.Load(msg.ID) + if project == nil { + return m, nil + } + + m.form = views.NewProjectFormView(project.Name, project.Path, m.theme.Title, m.cs.ListItem, m.cs.ListItem, m.cs.ListSelected, m.cs.ButtonPrimary, m.cs.ButtonSecondary) + m.mode = 1 + + if m.r != nil { + m.r.NavigateTo(router.RouteProjectForm, nil) + } + + return m, tea.ClearScreen + case state.AddEnvVarMsg: + pname := m.envProjectName + if pname == "" && m.r != nil && m.r.Params() != nil { + pname = m.r.Params()["project"] + } + + if pname != "" { + m.newEnvironmentFlow(pname) + return m, tea.ClearScreen + } + + return m, nil + case state.EditEnvVarMsg: + pname := m.envProjectName + if pname == "" && m.r != nil && m.r.Params() != nil { + pname = m.r.Params()["project"] + } + if pname == "" { + if pv, ok := m.projectsView.(views.ProjectsView); ok { + idx := pv.List.Cursor + if idx >= 0 && idx < len(pv.List.Items) { + it := pv.List.Items[idx] + if it.ID != "__new__" { + pname = it.ID + } + } + } + } + + if pname != "" { + proj, _ := m.projectSvc.Load(pname) + if proj != nil { + for _, e := range proj.Environments { + if e.Name == msg.Name { + m.envForm = views.NewEnvFormView(e.Name, e.Name, e.Color, e.EnvVarsMode, "", m.theme.Title, m.cs.ListItem, m.cs.ButtonPrimary, m.cs.ButtonSecondary) + m.envProjectName = pname + + envPath := e.EnvVarsFile + if !filepath.IsAbs(envPath) { + envPath = filepath.Join(proj.Path, envPath) + } + + if b, err := os.ReadFile(envPath); err == nil { + if f, ok := m.envForm.(*views.EnvFormView); ok { + f.EnvVars.SetValue(string(b)) + } + } else if len(e.EnvVars) > 0 { + pairs := e.EnvVars.ToSlice() + sort.Strings(pairs) + + if f, ok := m.envForm.(*views.EnvFormView); ok { + f.EnvVars.SetValue(strings.Join(pairs, "\n")) + } + } + + m.mode = 3 + if m.r != nil { + m.r.NavigateTo(router.RouteEnvVars, router.Params{"project": pname}) + } + + return m, tea.ClearScreen + } + } + } + } + + return m, nil + case state.RemoveEnvVarMsg: + return m, nil + case state.SaveEnvFormMsg: + pname := m.envProjectName + if pname == "" && m.r != nil && m.r.Params() != nil { + pname = m.r.Params()["project"] + } + + if pname == "" { + return m, nil + } + + env := &domain.Environment{ + Name: strings.TrimSpace(msg.Name), + Color: normalizeColorInput(strings.TrimSpace(msg.Color)), + EnvVarsMode: strings.TrimSpace(msg.Mode), + } + + envs := domain.EnvVars{} + if strings.TrimSpace(msg.EnvVarsRaw) != "" { + envs = parseEnvLines(msg.EnvVarsRaw) + } + + if strings.TrimSpace(msg.OriginalName) != "" { + _ = m.projectSvc.UpdateEnvironment(pname, msg.OriginalName, env) + } else { + _ = m.projectSvc.AddEnvironment(pname, env, envs) + } + + raw := strings.TrimSpace(msg.EnvVarsRaw) + if raw != "" { + proj, _ := m.projectSvc.Load(pname) + if proj != nil { + var envPath string + + for _, e := range proj.Environments { + if e.Name == env.Name { + envPath = e.EnvVarsFile + break + } + } + + if envPath != "" { + if !filepath.IsAbs(envPath) { + envPath = filepath.Join(proj.Path, envPath) + } + + lines := strings.Split(raw, "\n") + ok := true + + for _, line := range lines { + l := strings.TrimSpace(line) + if l == "" || strings.HasPrefix(l, "#") { + continue + } + + kv := strings.SplitN(l, "=", 2) + if len(kv) != 2 || strings.TrimSpace(kv[0]) == "" { + ok = false + break + } + } + + if ok { + _ = os.WriteFile(envPath, []byte(raw), 0o600) + } + } + } + } + + if projects, err := m.projectSvc.List(); err == nil { + m.projects = projects + m.total = len(projects) + 1 + } + + m.mode = 0 + if m.r != nil { + m.r.NavigateTo(router.RouteProjects, nil) + } + + m.envForm = nil + m.envProjectName = "" + + if m.projectsView != nil { + return m, tea.Batch(cmd, m.projectsView.Init()) + } + + return m, tea.ClearScreen + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.vp.Width = msg.Width + + hh := lipgloss.Height(m.help.View(m.keys)) + + vh := msg.Height - hh + if vh < 1 { + vh = 1 + } + + m.vp.Height = vh + case tea.KeyMsg: + if msg.Type == tea.KeyRunes { + r := string(msg.Runes) + switch r { + case "?": + m.help.ShowAll = !m.help.ShowAll + + hh := lipgloss.Height(m.help.View(m.keys)) + + vh := m.height - hh + if vh < 1 { + vh = 1 + } + + m.vp.Height = vh + + return m, nil + case "q": + if m.r != nil && m.r.Current() == router.RouteProjects { + m.selectedProject = nil + return m, tea.Quit + } + return m, nil + } + } + + switch msg.Type { + case tea.KeyEsc, tea.KeyCtrlC: + if m.r != nil && m.r.Current() != router.RouteProjects { + m.mode = 0 + m.form = nil + m.envForm = nil + m.r.NavigateTo(router.RouteProjects, nil) + if m.projectsView != nil { + return m, tea.Batch(cmd, m.projectsView.Init()) + } + return m, tea.ClearScreen + } + + m.selectedProject = nil + + return m, tea.Quit + } + } + + return m, cmd +} + +func (m *Window) reduce(msg tea.Msg) { + switch v := msg.(type) { + case state.NavigateToMsg: + if m.r != nil { + m.r.NavigateTo(v.Route, v.Params) + } + case state.BackMsg: + if m.r != nil { + m.r.Back() + } + case state.ExitMsg: + if m.r != nil && m.r.Current() == router.RouteEnvVars { + if m.envProjectName != "" { + proj, _ := m.projectSvc.Load(m.envProjectName) + m.selectedProject = proj + } + } + m.shouldQuit = true + } +} diff --git a/internal/adapters/handlers/tui/window.go b/internal/adapters/handlers/tui/window.go deleted file mode 100644 index 89aa0ee..0000000 --- a/internal/adapters/handlers/tui/window.go +++ /dev/null @@ -1 +0,0 @@ -package tui diff --git a/internal/core/ports/env_vars_port.go b/internal/core/ports/env_vars_port.go index 6edb3b6..d775114 100644 --- a/internal/core/ports/env_vars_port.go +++ b/internal/core/ports/env_vars_port.go @@ -1,5 +1,7 @@ package ports +//go:generate go tool mockgen -source=env_vars_port.go -destination=../../../mocks/mock_env_vars_port.go -package=mocks + import "github.com/jlrosende/project-manager/internal/core/domain" type EnvVarsService interface { diff --git a/internal/core/ports/git_port.go b/internal/core/ports/git_port.go index 598cb18..5bc62e0 100644 --- a/internal/core/ports/git_port.go +++ b/internal/core/ports/git_port.go @@ -1,5 +1,7 @@ package ports +//go:generate go tool mockgen -source=git_port.go -destination=../../../mocks/mock_git_port.go -package=mocks + import "github.com/jlrosende/project-manager/internal/core/domain" type GitService interface { diff --git a/internal/core/ports/project_port.go b/internal/core/ports/project_port.go index 19e25d6..55b80a7 100644 --- a/internal/core/ports/project_port.go +++ b/internal/core/ports/project_port.go @@ -1,5 +1,7 @@ package ports +//go:generate go tool mockgen -source=project_port.go -destination=../../../mocks/mock_project_port.go -package=mocks + import "github.com/jlrosende/project-manager/internal/core/domain" type ProjectService interface { diff --git a/internal/core/ports/shell_port.go b/internal/core/ports/shell_port.go index 0de0b06..92b5bef 100644 --- a/internal/core/ports/shell_port.go +++ b/internal/core/ports/shell_port.go @@ -1,5 +1,7 @@ package ports +//go:generate go tool mockgen -source=shell_port.go -destination=../../../mocks/mock_shell_port.go -package=mocks + import "os" type ShellService interface { diff --git a/mocks/mock_env_vars_port.go b/mocks/mock_env_vars_port.go new file mode 100644 index 0000000..89edeb1 --- /dev/null +++ b/mocks/mock_env_vars_port.go @@ -0,0 +1,123 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: env_vars_port.go +// +// Generated by this command: +// +// mockgen -source=env_vars_port.go -destination=../../../mocks/mock_env_vars_port.go -package=mocks +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + domain "github.com/jlrosende/project-manager/internal/core/domain" + gomock "go.uber.org/mock/gomock" +) + +// MockEnvVarsService is a mock of EnvVarsService interface. +type MockEnvVarsService struct { + ctrl *gomock.Controller + recorder *MockEnvVarsServiceMockRecorder + isgomock struct{} +} + +// MockEnvVarsServiceMockRecorder is the mock recorder for MockEnvVarsService. +type MockEnvVarsServiceMockRecorder struct { + mock *MockEnvVarsService +} + +// NewMockEnvVarsService creates a new mock instance. +func NewMockEnvVarsService(ctrl *gomock.Controller) *MockEnvVarsService { + mock := &MockEnvVarsService{ctrl: ctrl} + mock.recorder = &MockEnvVarsServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockEnvVarsService) EXPECT() *MockEnvVarsServiceMockRecorder { + return m.recorder +} + +// Load mocks base method. +func (m *MockEnvVarsService) Load(path string) (domain.EnvVars, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Load", path) + ret0, _ := ret[0].(domain.EnvVars) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Load indicates an expected call of Load. +func (mr *MockEnvVarsServiceMockRecorder) Load(path any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockEnvVarsService)(nil).Load), path) +} + +// Save mocks base method. +func (m *MockEnvVarsService) Save(path string, envVars map[string]string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Save", path, envVars) + ret0, _ := ret[0].(error) + return ret0 +} + +// Save indicates an expected call of Save. +func (mr *MockEnvVarsServiceMockRecorder) Save(path, envVars any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockEnvVarsService)(nil).Save), path, envVars) +} + +// MockEnvVarsRepository is a mock of EnvVarsRepository interface. +type MockEnvVarsRepository struct { + ctrl *gomock.Controller + recorder *MockEnvVarsRepositoryMockRecorder + isgomock struct{} +} + +// MockEnvVarsRepositoryMockRecorder is the mock recorder for MockEnvVarsRepository. +type MockEnvVarsRepositoryMockRecorder struct { + mock *MockEnvVarsRepository +} + +// NewMockEnvVarsRepository creates a new mock instance. +func NewMockEnvVarsRepository(ctrl *gomock.Controller) *MockEnvVarsRepository { + mock := &MockEnvVarsRepository{ctrl: ctrl} + mock.recorder = &MockEnvVarsRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockEnvVarsRepository) EXPECT() *MockEnvVarsRepositoryMockRecorder { + return m.recorder +} + +// Load mocks base method. +func (m *MockEnvVarsRepository) Load(path string) (domain.EnvVars, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Load", path) + ret0, _ := ret[0].(domain.EnvVars) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Load indicates an expected call of Load. +func (mr *MockEnvVarsRepositoryMockRecorder) Load(path any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockEnvVarsRepository)(nil).Load), path) +} + +// Save mocks base method. +func (m *MockEnvVarsRepository) Save(path string, envVars map[string]string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Save", path, envVars) + ret0, _ := ret[0].(error) + return ret0 +} + +// Save indicates an expected call of Save. +func (mr *MockEnvVarsRepositoryMockRecorder) Save(path, envVars any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockEnvVarsRepository)(nil).Save), path, envVars) +} diff --git a/mocks/mock_git_port.go b/mocks/mock_git_port.go new file mode 100644 index 0000000..cb22608 --- /dev/null +++ b/mocks/mock_git_port.go @@ -0,0 +1,123 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: git_port.go +// +// Generated by this command: +// +// mockgen -source=git_port.go -destination=../../../mocks/mock_git_port.go -package=mocks +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + domain "github.com/jlrosende/project-manager/internal/core/domain" + gomock "go.uber.org/mock/gomock" +) + +// MockGitService is a mock of GitService interface. +type MockGitService struct { + ctrl *gomock.Controller + recorder *MockGitServiceMockRecorder + isgomock struct{} +} + +// MockGitServiceMockRecorder is the mock recorder for MockGitService. +type MockGitServiceMockRecorder struct { + mock *MockGitService +} + +// NewMockGitService creates a new mock instance. +func NewMockGitService(ctrl *gomock.Controller) *MockGitService { + mock := &MockGitService{ctrl: ctrl} + mock.recorder = &MockGitServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGitService) EXPECT() *MockGitServiceMockRecorder { + return m.recorder +} + +// Load mocks base method. +func (m *MockGitService) Load(path string) (*domain.GitConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Load", path) + ret0, _ := ret[0].(*domain.GitConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Load indicates an expected call of Load. +func (mr *MockGitServiceMockRecorder) Load(path any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockGitService)(nil).Load), path) +} + +// Save mocks base method. +func (m *MockGitService) Save(path string, gitConfig *domain.GitConfig) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Save", path, gitConfig) + ret0, _ := ret[0].(error) + return ret0 +} + +// Save indicates an expected call of Save. +func (mr *MockGitServiceMockRecorder) Save(path, gitConfig any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockGitService)(nil).Save), path, gitConfig) +} + +// MockGitRepository is a mock of GitRepository interface. +type MockGitRepository struct { + ctrl *gomock.Controller + recorder *MockGitRepositoryMockRecorder + isgomock struct{} +} + +// MockGitRepositoryMockRecorder is the mock recorder for MockGitRepository. +type MockGitRepositoryMockRecorder struct { + mock *MockGitRepository +} + +// NewMockGitRepository creates a new mock instance. +func NewMockGitRepository(ctrl *gomock.Controller) *MockGitRepository { + mock := &MockGitRepository{ctrl: ctrl} + mock.recorder = &MockGitRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGitRepository) EXPECT() *MockGitRepositoryMockRecorder { + return m.recorder +} + +// Load mocks base method. +func (m *MockGitRepository) Load(path string) (*domain.GitConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Load", path) + ret0, _ := ret[0].(*domain.GitConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Load indicates an expected call of Load. +func (mr *MockGitRepositoryMockRecorder) Load(path any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockGitRepository)(nil).Load), path) +} + +// Save mocks base method. +func (m *MockGitRepository) Save(path string, gitConfig *domain.GitConfig) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Save", path, gitConfig) + ret0, _ := ret[0].(error) + return ret0 +} + +// Save indicates an expected call of Save. +func (mr *MockGitRepositoryMockRecorder) Save(path, gitConfig any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockGitRepository)(nil).Save), path, gitConfig) +} diff --git a/mocks/mock_project_port.go b/mocks/mock_project_port.go new file mode 100644 index 0000000..4346721 --- /dev/null +++ b/mocks/mock_project_port.go @@ -0,0 +1,252 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: project_port.go +// +// Generated by this command: +// +// mockgen -source=project_port.go -destination=../../../mocks/mock_project_port.go -package=mocks +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + domain "github.com/jlrosende/project-manager/internal/core/domain" + gomock "go.uber.org/mock/gomock" +) + +// MockProjectService is a mock of ProjectService interface. +type MockProjectService struct { + ctrl *gomock.Controller + recorder *MockProjectServiceMockRecorder + isgomock struct{} +} + +// MockProjectServiceMockRecorder is the mock recorder for MockProjectService. +type MockProjectServiceMockRecorder struct { + mock *MockProjectService +} + +// NewMockProjectService creates a new mock instance. +func NewMockProjectService(ctrl *gomock.Controller) *MockProjectService { + mock := &MockProjectService{ctrl: ctrl} + mock.recorder = &MockProjectServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProjectService) EXPECT() *MockProjectServiceMockRecorder { + return m.recorder +} + +// AddEnvironment mocks base method. +func (m *MockProjectService) AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddEnvironment", projectName, env, envVars) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddEnvironment indicates an expected call of AddEnvironment. +func (mr *MockProjectServiceMockRecorder) AddEnvironment(projectName, env, envVars any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddEnvironment", reflect.TypeOf((*MockProjectService)(nil).AddEnvironment), projectName, env, envVars) +} + +// Create mocks base method. +func (m *MockProjectService) Create(name, path, subproject string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", name, path, subproject, envVars, git) + ret0, _ := ret[0].(*domain.Project) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockProjectServiceMockRecorder) Create(name, path, subproject, envVars, git any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockProjectService)(nil).Create), name, path, subproject, envVars, git) +} + +// Delete mocks base method. +func (m *MockProjectService) Delete(name string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", name) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockProjectServiceMockRecorder) Delete(name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockProjectService)(nil).Delete), name) +} + +// List mocks base method. +func (m *MockProjectService) List() ([]*domain.Project, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List") + ret0, _ := ret[0].([]*domain.Project) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockProjectServiceMockRecorder) List() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockProjectService)(nil).List)) +} + +// Load mocks base method. +func (m *MockProjectService) Load(name string) (*domain.Project, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Load", name) + ret0, _ := ret[0].(*domain.Project) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Load indicates an expected call of Load. +func (mr *MockProjectServiceMockRecorder) Load(name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockProjectService)(nil).Load), name) +} + +// UpdateEnvironment mocks base method. +func (m *MockProjectService) UpdateEnvironment(projectName, originalEnvName string, env *domain.Environment) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateEnvironment", projectName, originalEnvName, env) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateEnvironment indicates an expected call of UpdateEnvironment. +func (mr *MockProjectServiceMockRecorder) UpdateEnvironment(projectName, originalEnvName, env any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEnvironment", reflect.TypeOf((*MockProjectService)(nil).UpdateEnvironment), projectName, originalEnvName, env) +} + +// UpdateProject mocks base method. +func (m *MockProjectService) UpdateProject(project *domain.Project) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateProject", project) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateProject indicates an expected call of UpdateProject. +func (mr *MockProjectServiceMockRecorder) UpdateProject(project any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProject", reflect.TypeOf((*MockProjectService)(nil).UpdateProject), project) +} + +// MockProjectRepository is a mock of ProjectRepository interface. +type MockProjectRepository struct { + ctrl *gomock.Controller + recorder *MockProjectRepositoryMockRecorder + isgomock struct{} +} + +// MockProjectRepositoryMockRecorder is the mock recorder for MockProjectRepository. +type MockProjectRepositoryMockRecorder struct { + mock *MockProjectRepository +} + +// NewMockProjectRepository creates a new mock instance. +func NewMockProjectRepository(ctrl *gomock.Controller) *MockProjectRepository { + mock := &MockProjectRepository{ctrl: ctrl} + mock.recorder = &MockProjectRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProjectRepository) EXPECT() *MockProjectRepositoryMockRecorder { + return m.recorder +} + +// AddEnvironment mocks base method. +func (m *MockProjectRepository) AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddEnvironment", projectName, env, envVars) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddEnvironment indicates an expected call of AddEnvironment. +func (mr *MockProjectRepositoryMockRecorder) AddEnvironment(projectName, env, envVars any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddEnvironment", reflect.TypeOf((*MockProjectRepository)(nil).AddEnvironment), projectName, env, envVars) +} + +// Create mocks base method. +func (m *MockProjectRepository) Create(name, path, subproject string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", name, path, subproject, envVars, git) + ret0, _ := ret[0].(*domain.Project) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockProjectRepositoryMockRecorder) Create(name, path, subproject, envVars, git any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockProjectRepository)(nil).Create), name, path, subproject, envVars, git) +} + +// Delete mocks base method. +func (m *MockProjectRepository) Delete(name string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", name) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockProjectRepositoryMockRecorder) Delete(name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockProjectRepository)(nil).Delete), name) +} + +// List mocks base method. +func (m *MockProjectRepository) List() ([]*domain.Project, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List") + ret0, _ := ret[0].([]*domain.Project) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockProjectRepositoryMockRecorder) List() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockProjectRepository)(nil).List)) +} + +// UpdateEnvironment mocks base method. +func (m *MockProjectRepository) UpdateEnvironment(projectName, originalEnvName string, env *domain.Environment) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateEnvironment", projectName, originalEnvName, env) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateEnvironment indicates an expected call of UpdateEnvironment. +func (mr *MockProjectRepositoryMockRecorder) UpdateEnvironment(projectName, originalEnvName, env any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEnvironment", reflect.TypeOf((*MockProjectRepository)(nil).UpdateEnvironment), projectName, originalEnvName, env) +} + +// UpdateProject mocks base method. +func (m *MockProjectRepository) UpdateProject(project *domain.Project) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateProject", project) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateProject indicates an expected call of UpdateProject. +func (mr *MockProjectRepositoryMockRecorder) UpdateProject(project any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProject", reflect.TypeOf((*MockProjectRepository)(nil).UpdateProject), project) +} diff --git a/mocks/mock_shell_port.go b/mocks/mock_shell_port.go new file mode 100644 index 0000000..145ac42 --- /dev/null +++ b/mocks/mock_shell_port.go @@ -0,0 +1,153 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: shell_port.go +// +// Generated by this command: +// +// mockgen -source=shell_port.go -destination=../../../mocks/mock_shell_port.go -package=mocks +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + os "os" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockShellService is a mock of ShellService interface. +type MockShellService struct { + ctrl *gomock.Controller + recorder *MockShellServiceMockRecorder + isgomock struct{} +} + +// MockShellServiceMockRecorder is the mock recorder for MockShellService. +type MockShellServiceMockRecorder struct { + mock *MockShellService +} + +// NewMockShellService creates a new mock instance. +func NewMockShellService(ctrl *gomock.Controller) *MockShellService { + mock := &MockShellService{ctrl: ctrl} + mock.recorder = &MockShellServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockShellService) EXPECT() *MockShellServiceMockRecorder { + return m.recorder +} + +// Kill mocks base method. +func (m *MockShellService) Kill() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Kill") + ret0, _ := ret[0].(error) + return ret0 +} + +// Kill indicates an expected call of Kill. +func (mr *MockShellServiceMockRecorder) Kill() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Kill", reflect.TypeOf((*MockShellService)(nil).Kill)) +} + +// Start mocks base method. +func (m *MockShellService) Start() (*os.Process, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Start") + ret0, _ := ret[0].(*os.Process) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Start indicates an expected call of Start. +func (mr *MockShellServiceMockRecorder) Start() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockShellService)(nil).Start)) +} + +// Wait mocks base method. +func (m *MockShellService) Wait() (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Wait") + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Wait indicates an expected call of Wait. +func (mr *MockShellServiceMockRecorder) Wait() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Wait", reflect.TypeOf((*MockShellService)(nil).Wait)) +} + +// MockShellRepository is a mock of ShellRepository interface. +type MockShellRepository struct { + ctrl *gomock.Controller + recorder *MockShellRepositoryMockRecorder + isgomock struct{} +} + +// MockShellRepositoryMockRecorder is the mock recorder for MockShellRepository. +type MockShellRepositoryMockRecorder struct { + mock *MockShellRepository +} + +// NewMockShellRepository creates a new mock instance. +func NewMockShellRepository(ctrl *gomock.Controller) *MockShellRepository { + mock := &MockShellRepository{ctrl: ctrl} + mock.recorder = &MockShellRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockShellRepository) EXPECT() *MockShellRepositoryMockRecorder { + return m.recorder +} + +// Kill mocks base method. +func (m *MockShellRepository) Kill() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Kill") + ret0, _ := ret[0].(error) + return ret0 +} + +// Kill indicates an expected call of Kill. +func (mr *MockShellRepositoryMockRecorder) Kill() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Kill", reflect.TypeOf((*MockShellRepository)(nil).Kill)) +} + +// Start mocks base method. +func (m *MockShellRepository) Start() (*os.Process, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Start") + ret0, _ := ret[0].(*os.Process) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Start indicates an expected call of Start. +func (mr *MockShellRepositoryMockRecorder) Start() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockShellRepository)(nil).Start)) +} + +// Wait mocks base method. +func (m *MockShellRepository) Wait() (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Wait") + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Wait indicates an expected call of Wait. +func (mr *MockShellRepositoryMockRecorder) Wait() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Wait", reflect.TypeOf((*MockShellRepository)(nil).Wait)) +} diff --git a/tests/integration/tui_collapse_envs_test.go b/tests/integration/tui_collapse_envs_test.go deleted file mode 100644 index c758e5f..0000000 --- a/tests/integration/tui_collapse_envs_test.go +++ /dev/null @@ -1,7 +0,0 @@ -package integration - -import "testing" - -func TestCollapseBehaviorOnNarrowWidth(t *testing.T) { - t.Fatal("expected collapse when width < 60") -} diff --git a/tests/integration/tui_project_selected_test.go b/tests/integration/tui_project_selected_test.go deleted file mode 100644 index 59840ad..0000000 --- a/tests/integration/tui_project_selected_test.go +++ /dev/null @@ -1,7 +0,0 @@ -package integration - -import "testing" - -func TestProjectSelectedUpdatesEnvs(t *testing.T) { - t.Fatal("expected envs updated on ProjectSelectedMsg") -} diff --git a/tests/integration/tui_projects_loaded_test.go b/tests/integration/tui_projects_loaded_test.go deleted file mode 100644 index f880109..0000000 --- a/tests/integration/tui_projects_loaded_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package integration - -import ( - "testing" -) - -func TestProjectsLoadedDerivesEnvs(t *testing.T) { - t.Fatal("expected envs derived on ProjectsLoadedMsg") -} diff --git a/tests/integration/tui_view_envs_test.go b/tests/integration/tui_view_envs_test.go deleted file mode 100644 index c84bacf..0000000 --- a/tests/integration/tui_view_envs_test.go +++ /dev/null @@ -1,7 +0,0 @@ -package integration - -import "testing" - -func TestViewRendersEnvsColumn(t *testing.T) { - t.Fatal("expected envs column when width sufficient") -} diff --git a/tests/integration/view_parity_test.go b/tests/integration/view_parity_test.go new file mode 100644 index 0000000..4fd0645 --- /dev/null +++ b/tests/integration/view_parity_test.go @@ -0,0 +1,734 @@ +package integration_test + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "go.uber.org/mock/gomock" + + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v1" + v2 "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2" + "github.com/jlrosende/project-manager/internal/core/domain" + "github.com/jlrosende/project-manager/internal/core/ports" + "github.com/jlrosende/project-manager/internal/core/services" + "github.com/jlrosende/project-manager/mocks" +) + +func buildService(t *testing.T) ports.ProjectService { + ctrl := gomock.NewController(t) + + mockRepo := mocks.NewMockProjectRepository(ctrl) + mockEnv := mocks.NewMockEnvVarsRepository(ctrl) + mockGit := mocks.NewMockGitRepository(ctrl) + + p1 := &domain.Project{ + Name: "INDITEX", + Path: "/tmp/inditex", + DefaultEnv: "dev", + Environments: []*domain.Environment{ + {Name: "dev", Color: "240", EnvVarsFile: ".env.dev"}, + {Name: "pre", Color: "240", EnvVarsFile: ".env.pre"}, + {Name: "pro", Color: "240", EnvVarsFile: ".env.pro"}, + }, + } + p2 := &domain.Project{Name: "Mahou"} + p3 := &domain.Project{Name: "Accenture"} + p4 := &domain.Project{Name: "Personal"} + p5 := &domain.Project{Name: "test"} + projects := []*domain.Project{p1, p2, p3, p4, p5} + + mockRepo.EXPECT().List().Return(projects, nil).AnyTimes() + mockRepo.EXPECT().AddEnvironment(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockRepo.EXPECT().UpdateEnvironment(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockRepo.EXPECT().UpdateProject(gomock.Any()).Return(nil).AnyTimes() + mockEnv.EXPECT().Load(gomock.Any()).Return(domain.EnvVars{}, nil).AnyTimes() + mockGit.EXPECT().Load(gomock.Any()).Return(&domain.GitConfig{}, nil).AnyTimes() + + return services.NewProjectService(mockRepo, mockEnv, mockGit) +} + +func normalize(s string) string { + lines := strings.Split(s, "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + + return strings.Join(lines, "\n") +} + +func hasANSI(s string) bool { return strings.Contains(s, "\x1b[") } + +func renderV1(t *testing.T, svc ports.ProjectService, width int, focusRight bool) string { + t.Helper() + + w, err := v1.NewWindow(svc.(*services.ProjectService), v1.Options{}) + if err != nil { + t.Fatalf("v1 window: %v", err) + } + + w.Update(tea.WindowSizeMsg{Width: width, Height: 24}) + + if focusRight { + w.Update(tea.KeyMsg{Type: tea.KeyRight}) + } + + return w.View() +} + +func renderV2(t *testing.T, svc ports.ProjectService, width int, focusRight bool) string { + t.Helper() + + w, err := v2.NewWindow(svc.(*services.ProjectService), v2.Options{}) + if err != nil { + t.Fatalf("v2 window: %v", err) + } + + cmd := w.Init() + if cmd != nil { + msg := cmd() + w.Update(msg) + } + + w.Update(tea.WindowSizeMsg{Width: width, Height: 24}) + + if focusRight { + w.Update(tea.KeyMsg{Type: tea.KeyRight}) + } + + return w.View() +} + +func TestMainViewParity_LeftFocus(t *testing.T) { + svc := buildService(t) + v1s := normalize(renderV1(t, svc, 49, false)) + + v2s := normalize(renderV2(t, svc, 49, false)) + if v1s != v2s { + t.Fatalf("views differ\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestMainViewParity_RightFocus(t *testing.T) { + svc := buildService(t) + v1s := normalize(renderV1(t, svc, 49, true)) + + v2s := normalize(renderV2(t, svc, 49, true)) + if v1s != v2s { + t.Fatalf("views differ (right focus)\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func renderV1Msgs(t *testing.T, svc ports.ProjectService, width int, msgs ...tea.Msg) string { + t.Helper() + + w, err := v1.NewWindow(svc.(*services.ProjectService), v1.Options{}) + if err != nil { + t.Fatalf("v1 window: %v", err) + } + + _, _ = w.Update(tea.WindowSizeMsg{Width: width, Height: 24}) + for _, m := range msgs { + _, cmd := w.Update(m) + if cmd != nil { + if msg := cmd(); msg != nil { + _, _ = w.Update(msg) + } + } + } + + return w.View() +} + +func renderV2Msgs(t *testing.T, svc ports.ProjectService, width int, msgs ...tea.Msg) string { + t.Helper() + + w, err := v2.NewWindow(svc.(*services.ProjectService), v2.Options{}) + if err != nil { + t.Fatalf("v2 window: %v", err) + } + + cmd := w.Init() + if cmd != nil { + if msg := cmd(); msg != nil { + _, _ = w.Update(msg) + } + } + + _, _ = w.Update(tea.WindowSizeMsg{Width: width, Height: 24}) + for _, m := range msgs { + _, cmd := w.Update(m) + if cmd != nil { + if msg := cmd(); msg != nil { + _, _ = w.Update(msg) + } + } + } + + return w.View() +} + +func TestParity_ProjectDownTwice_LeftFocus(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}} + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("views differ after moving down twice\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestParity_NoEnvProject_RightFocus(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyRight}} + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("views differ on no-env project right focus\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestParity_EnvDownSelection_RightFocus(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyDown}} + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("views differ after moving down envs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } + + // Enter on an environment should start session (quit), not open edit + seq2 := append(seq, tea.KeyMsg{Type: tea.KeyEnter}) + _ = renderV1Msgs(t, svc, 49, seq2...) + _ = renderV2Msgs(t, svc, 49, seq2...) +} + +func TestParity_Width60_LeftFocus(t *testing.T) { + svc := buildService(t) + v1s := normalize(renderV1(t, svc, 60, false)) + + v2s := normalize(renderV2(t, svc, 60, false)) + if v1s != v2s { + t.Fatalf("views differ at width 60\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestParity_NewProject_RightDoesNotFocusEnvs(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, // now on '+ New project' + tea.KeyMsg{Type: tea.KeyRight}, + } + v1s := normalize(renderV1Msgs(t, svc, 49, open...)) + v2s := normalize(renderV2Msgs(t, svc, 49, open...)) + if v1s != v2s { + t.Fatalf("moving right on '+ New project' should not focus envs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestParity_NewProject_RightThenDownStillProjects(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyDown}, + } + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("after Right on '+ New project', Down should still move in projects\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestParity_ToggleHelp(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}} + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("views differ after toggling help\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestForm_CreateProject_ViewParity(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyEnter}, + } + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("create project form view differs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } + + seq2 := append(seq, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) + v1s2 := normalize(renderV1Msgs(t, svc, 49, seq2...)) + v2s2 := normalize(renderV2Msgs(t, svc, 49, seq2...)) + if v1s2 != v2s2 { + t.Fatalf("create project form help footer differs after '?'\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s2, v2s2) + } + + seq3 := append(seq, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}) + v1s3 := normalize(renderV1Msgs(t, svc, 49, seq3...)) + v2s3 := normalize(renderV2Msgs(t, svc, 49, seq3...)) + if v1s3 != v2s3 { + t.Fatalf("tab/down/enter navigation in form differs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s3, v2s3) + } +} + +func TestForm_EditProject_ViewParity(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}} + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { t.Fatalf("edit project form view differs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) } +} + +func TestForm_CreateProject_ViewParity_Width60(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyEnter}, + } + v1s := normalize(renderV1Msgs(t, svc, 60, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 60, seq...)) + if v1s != v2s { + t.Fatalf("create project form view differs at width 60\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestForm_Navigation_UpDownTab_Parity(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}, + } + seq := append(open, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyUp}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyShiftTab}) + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("form navigation with up/down/tab parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestForm_Buttons_Cancel_RightEnter_Parity(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}, + } + // Tab through fields to reach buttons, then move to Cancel and press Enter + tabs := []tea.Msg{tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}} + seq := append(append(open, tabs...), tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyEnter}) + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("button Cancel (right+enter) parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestForm_EditProject_ViewParity_Width60(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}} + v1s := normalize(renderV1Msgs(t, svc, 60, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 60, seq...)) + if v1s != v2s { + t.Fatalf("edit project form view differs at width 60\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestForm_EditEnv_ViewParity(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}} + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("edit env form view differs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestTransition_ProjectForm_CancelEsc(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}, + } + v1after := normalize(renderV1Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyEsc})...)) + v2after := normalize(renderV2Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyEsc})...)) + + if v1after != v2after { + t.Fatalf("views differ after canceling project form\n--- v1 after ---\n%s\n--- v2 after ---\n%s", v1after, v2after) + } +} + +func TestForm_Q_DoesNotExit_Vs_CtrlC_ReturnsToMain(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}, + } + v1q := normalize(renderV1Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})...)) + v2q := normalize(renderV2Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})...)) + if v1q != v2q { + t.Fatalf("pressing 'q' inside form should not exit; parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1q, v2q) + } + + v1cc := normalize(renderV1Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyCtrlC})...)) + v2cc := normalize(renderV2Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyCtrlC})...)) + if v1cc != v2cc { + t.Fatalf("pressing Ctrl+C inside form should return to main; parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1cc, v2cc) + } +} + +func TestTransition_AddEnvForm_CancelEsc(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight}, + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyEnter}, + } + v1after := normalize(renderV1Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyEsc})...)) + v2after := normalize(renderV2Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyEsc})...)) + + if v1after != v2after { + t.Fatalf("views differ after canceling add env form\n--- v1 after ---\n%s\n--- v2 after ---\n%s", v1after, v2after) + } +} + +func TestForm_CreateProject_CtrlS(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyEnter}, tea.KeyMsg{Type: tea.KeyCtrlS}, + } + _ = normalize(renderV1Msgs(t, svc, 49, seq...)) + _ = normalize(renderV2Msgs(t, svc, 49, seq...)) +} + +func TestForm_CreateProject_TypingUpdatesPath_Parity(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyEnter}, + } + _ = normalize(renderV1Msgs(t, svc, 49, open...)) + _ = normalize(renderV2Msgs(t, svc, 49, open...)) + typing := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'-'}}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}, + } + v1s := normalize(renderV1Msgs(t, svc, 49, append(open, typing...)...)) + v2s := normalize(renderV2Msgs(t, svc, 49, append(open, typing...)...)) + if v1s != v2s { t.Fatalf("typing in name should update path placeholder equally\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) } +} + +func TestForm_AddEnv_CtrlS(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}, tea.KeyMsg{Type: tea.KeyCtrlS}} + _ = normalize(renderV1Msgs(t, svc, 49, seq...)) + _ = normalize(renderV2Msgs(t, svc, 49, seq...)) +} + +func TestForm_EditEnv_CtrlS(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}, tea.KeyMsg{Type: tea.KeyCtrlS}} + _ = normalize(renderV1Msgs(t, svc, 49, seq...)) + _ = normalize(renderV2Msgs(t, svc, 49, seq...)) +} + +func TestForm_AddEnv_ViewParity(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight}, + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyEnter}, + } + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("add env form view differs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestRouting_EditEnv_ReenterAfterBack(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}} + back := tea.KeyMsg{Type: tea.KeyEsc} + v1again := normalize(renderV1Msgs(t, svc, 49, append(append(open, back), tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})...)) + v2again := normalize(renderV2Msgs(t, svc, 49, append(append(open, back), tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})...)) + if v1again != v2again { + t.Fatalf("views differ when re-entering edit env\n--- v1 again ---\n%s\n--- v2 again ---\n%s", v1again, v2again) + } +} + +func TestParity_ProjectWrapDown(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, + } + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("views differ on project wrap down\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestParity_ProjectWrapUp(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyUp}} + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("views differ on project wrap up\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestParity_EnvWrapDown(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}} + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("views differ on env wrap down\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestParity_EnvWrapUp(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyUp}} + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("views differ on env wrap up\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestParity_EnvListUpdatesOnProjectChange(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight}, + tea.KeyMsg{Type: tea.KeyDown}, // focus pre + tea.KeyMsg{Type: tea.KeyLeft}, // back to projects + tea.KeyMsg{Type: tea.KeyDown}, // move to Mahou (no envs) + tea.KeyMsg{Type: tea.KeyRight}, // focus envs + tea.KeyMsg{Type: tea.KeyLeft}, + tea.KeyMsg{Type: tea.KeyDown}, // Accenture (no envs) + tea.KeyMsg{Type: tea.KeyLeft}, // redundant left + tea.KeyMsg{Type: tea.KeyDown}, // Personal (no envs) + tea.KeyMsg{Type: tea.KeyDown}, // test (no envs) + tea.KeyMsg{Type: tea.KeyUp}, // Personal + tea.KeyMsg{Type: tea.KeyUp}, // Accenture + tea.KeyMsg{Type: tea.KeyUp}, // Mahou + tea.KeyMsg{Type: tea.KeyUp}, // INDITEX + tea.KeyMsg{Type: tea.KeyRight}, + tea.KeyMsg{Type: tea.KeyDown}, // move through envs again + tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyDown}, + } + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("env list did not update properly on project change\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestParity_EditOnNewProjectDoesNothing(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}} + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("pressing edit on + New project should do nothing\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestParity_EditOnNewEnvironmentDoesNothing(t *testing.T) { + svc := buildService(t) + seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}} + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("pressing edit on + New environment should do nothing\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestStyled_CreateProjectForm_ParityAndDecor(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}} + v1s := renderV1Msgs(t, svc, 49, open...) + v2s := renderV2Msgs(t, svc, 49, open...) + if normalize(v1s) != normalize(v2s) { + t.Fatalf("styled create project form parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } + if !strings.Contains(v1s, "─") || !strings.Contains(v2s, "─") { + t.Fatalf("expected decorative borders (─) in form views") + } +} + +func TestStyled_FormButtons_LinePresent(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}} + tabs := []tea.Msg{tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}} + v1s := renderV1Msgs(t, svc, 49, append(open, tabs...)...) + v2s := renderV2Msgs(t, svc, 49, append(open, tabs...)...) + if normalize(v1s) != normalize(v2s) { + t.Fatalf("styled buttons parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } + if !strings.Contains(v1s, "Save") || !strings.Contains(v1s, "Cancel") || !strings.Contains(v2s, "Save") || !strings.Contains(v2s, "Cancel") { + t.Fatalf("expected Save and Cancel buttons rendered in both views") + } +} + +func TestTheme_AppliesAcrossViews_Parity(t *testing.T) { + svc := buildService(t) + w1, err1 := v1.NewWindow(svc.(*services.ProjectService), v1.Options{Theme: "dracula"}) + if err1 != nil { t.Fatalf("new v1 window: %v", err1) } + w2, err2 := v2.NewWindow(svc.(*services.ProjectService), v2.Options{Theme: "dracula"}) + if err2 != nil { t.Fatalf("new v2 window: %v", err2) } + // size + if _, cmd := w1.Update(tea.WindowSizeMsg{Width: 49, Height: 24}); cmd != nil { if m := cmd(); m != nil { w1.Update(m) } } + if _, cmd := w2.Update(tea.WindowSizeMsg{Width: 49, Height: 24}); cmd != nil { if m := cmd(); m != nil { w2.Update(m) } } + mv1 := normalize(w1.View()) + mv2 := normalize(w2.View()) + if mv1 != mv2 { t.Fatalf("theme parity mismatch on main\n--- v1 ---\n%s\n--- v2 ---\n%s", mv1, mv2) } + // open New project form (process returned cmds) + seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}} + for _, m := range seq { + if _, cmd := w1.Update(m); cmd != nil { if mm := cmd(); mm != nil { w1.Update(mm) } } + if _, cmd := w2.Update(m); cmd != nil { if mm := cmd(); mm != nil { w2.Update(mm) } } + } + fv1 := normalize(w1.View()) + fv2 := normalize(w2.View()) + if fv1 != fv2 { t.Fatalf("theme parity mismatch on form\n--- v1 ---\n%s\n--- v2 ---\n%s", fv1, fv2) } +} + +func TestEnvForm_Navigation_TabShiftEnter_Parity(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight}, + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyEnter}, + } + seq := append(open, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyEnter}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyShiftTab}) + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("env form navigation parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestEnvForm_Buttons_Cancel_RightEnter_Parity(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight}, + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyEnter}, + } + tabs := []tea.Msg{tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}} + seq := append(append(open, tabs...), tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyEnter}) + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("env form button Cancel (right+enter) parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestEnvForm_Q_DoesNotExit_Vs_CtrlC_ReturnsToMain(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight}, + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyEnter}, + } + v1q := normalize(renderV1Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})...)) + v2q := normalize(renderV2Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})...)) + if v1q != v2q { + t.Fatalf("pressing 'q' inside env form should not exit; parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1q, v2q) + } + v1cc := normalize(renderV1Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyCtrlC})...)) + v2cc := normalize(renderV2Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyCtrlC})...)) + if v1cc != v2cc { + t.Fatalf("pressing Ctrl+C inside env form should return to main; parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1cc, v2cc) + } +} + +func TestEnvForm_TypingInColor_Parity(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight}, + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyEnter}, + } + seq := append(open, tea.KeyMsg{Type: tea.KeyTab}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'b'}}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'u'}}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}, + ) + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("typing in Color should update value equally\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestEnvForm_TypingInMode_Parity(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight}, + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyEnter}, + } + seq := append(open, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}, + ) + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("typing in Mode should update value equally\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} + +func TestEnvForm_SaveButton_Enter_Parity(t *testing.T) { + svc := buildService(t) + open := []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight}, + tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyEnter}, + } + tabs := []tea.Msg{tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}} + seq := append(append(open, tabs...), tea.KeyMsg{Type: tea.KeyEnter}) + v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) + v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) + if v1s != v2s { + t.Fatalf("env form Save via Enter parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } +} From ed8b765526f53ffbfd2708e8dbd33da0229a075e Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Fri, 19 Sep 2025 16:38:36 +0200 Subject: [PATCH 15/27] Refactor of project and main views --- CLAUDE.md | 45 ++ Makefile | 11 +- .../handlers/tui/v1/window_env_form.go | 6 +- .../handlers/tui/v2/components/button.go | 25 +- .../handlers/tui/v2/components/buttongroup.go | 47 +- .../handlers/tui/v2/components/input.go | 2 +- .../handlers/tui/v2/components/list.go | 6 +- .../handlers/tui/v2/components/textarea.go | 10 +- .../tui/v2/router/router_compile_test.go | 10 - .../tui/v2/state/state_compile_test.go | 27 - .../adapters/handlers/tui/v2/styles/styles.go | 8 + .../adapters/handlers/tui/v2/styles/theme.go | 84 ++- .../handlers/tui/v2/views/env_form_view.go | 180 +++--- .../handlers/tui/v2/views/env_vars_view.go | 49 +- .../tui/v2/views/project_form_view.go | 493 ++++++++++------ .../handlers/tui/v2/views/projects_view.go | 43 +- .../tui/v2/views/views_compile_test.go | 39 -- internal/adapters/handlers/tui/v2/window.go | 547 +++++++++++------- tests/integration/common.go | 101 ++++ tests/integration/selected_enter_test.go | 121 ++++ ...parity_test.go => view_parity_test.go.old} | 143 ++--- tests/unit/router_compile_test.go | 17 + tests/unit/state_compile_test.go | 31 + 23 files changed, 1309 insertions(+), 736 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 internal/adapters/handlers/tui/v2/router/router_compile_test.go delete mode 100644 internal/adapters/handlers/tui/v2/state/state_compile_test.go delete mode 100644 internal/adapters/handlers/tui/v2/views/views_compile_test.go create mode 100644 tests/integration/common.go create mode 100644 tests/integration/selected_enter_test.go rename tests/integration/{view_parity_test.go => view_parity_test.go.old} (87%) create mode 100644 tests/unit/router_compile_test.go create mode 100644 tests/unit/state_compile_test.go diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2306286 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,45 @@ +# Project Manager + +Project manager is a terminal client application to manage multiple projects and configurations, in a easy and organized way. + +# Features + +## Create projects + +Create a new folder with a `.project.hcl` file, if the folder already exist initialize the folder with the `.project.hcl` file. + +The project environments are stored in a `.env` file also created in the project folder. + +The project manager also managed the user git configuration updating the `.gitconfig` to store the path and a custom `%s.gitconfig` file to manage the git user, email, signkeys... + +## Add Environments to a Project + +## Update Projects and environments + +## Delete Projects + +# TUI and Cli + +The project manager allow the user interact with a Terminal User Interface or with a set of cli commands. + +## TUI + +`pm [path]` run a new TUI to select start a new session + +## CLI + +- `pm [project] [env|[path]]` start a new terminal session +- `pm new [project]` add a new project +- `pm new [project] [env]` add a new environment to the selected project +- `pm config` open the configuration to edit global configs +- `pm edit [project]` edit the selected project +- `pm edit [project] [env]` edit the selected environment of a project +- `pm delete [project]` delete a project and all associated environments +- `pm delete [project] [env]` delete the selected environment of a project + +# Commands + +- build: `make build` +- lint: `make lint` +- fix linter errors: `make lint-fix` +- test: `make test` diff --git a/Makefile b/Makefile index 391d542..cc2cc62 100644 --- a/Makefile +++ b/Makefile @@ -65,5 +65,12 @@ generate: go generate ./... .PHONY: test -test: - go test ./... -v +test: unit integration + +.PHONY: unit +unit: + go test ./tests/... -v -tags unit + +.PHONY: integration +integration: + go test ./tests/... -v -tags integration diff --git a/internal/adapters/handlers/tui/v1/window_env_form.go b/internal/adapters/handlers/tui/v1/window_env_form.go index 8b85541..f628ae4 100644 --- a/internal/adapters/handlers/tui/v1/window_env_form.go +++ b/internal/adapters/handlers/tui/v1/window_env_form.go @@ -310,12 +310,12 @@ func (f *NewEnvironmentForm) View() string { b.WriteString("\n") b.WriteString(f.color.View()) b.WriteString("\n\n") - b.WriteString(styleSection.Render("Variables")) - b.WriteString("\n") - b.WriteString(styleSub.Render("One KEY=VALUE per line; '#' comments allowed")) + b.WriteString(styleSection.Render("Environment variables")) b.WriteString("\n") b.WriteString(f.mode.View()) b.WriteString("\n") + b.WriteString(styleSub.Render("One KEY=VALUE per line; '#' comments allowed")) + b.WriteString("\n") b.WriteString(f.envVars.View()) b.WriteString("\n\n") diff --git a/internal/adapters/handlers/tui/v2/components/button.go b/internal/adapters/handlers/tui/v2/components/button.go index 1ee19e3..c590f8c 100644 --- a/internal/adapters/handlers/tui/v2/components/button.go +++ b/internal/adapters/handlers/tui/v2/components/button.go @@ -10,6 +10,9 @@ type ButtonVariant int const ( Primary ButtonVariant = iota Secondary + Success + Info + Warning Danger ) @@ -18,13 +21,31 @@ type Button struct { Variant ButtonVariant Disabled bool style lipgloss.Style + styleFocused lipgloss.Style styleDisabled lipgloss.Style } +func (b Button) Render(focused bool) string { + if b.Disabled { + return b.styleDisabled.Render(b.Label) + } + + if focused { + return b.styleFocused.Render(b.Label) + } + + return b.style.Render(b.Label) +} + type ButtonPressedMsg struct{ Label string } -func NewButton(label string, v ButtonVariant, s, sd lipgloss.Style) Button { - return Button{Label: label, Variant: v, style: s, styleDisabled: sd} +func NewButton(label string, v ButtonVariant, s, sf, sd lipgloss.Style) Button { + // Ensure minimum horizontal padding for better size/visibility + s = s.Padding(0, 2) + sf = sf.Padding(0, 2) + sd = sd.Padding(0, 2) + + return Button{Label: label, Variant: v, style: s, styleFocused: sf, styleDisabled: sd} } func (b Button) Init() tea.Cmd { return nil } diff --git a/internal/adapters/handlers/tui/v2/components/buttongroup.go b/internal/adapters/handlers/tui/v2/components/buttongroup.go index ab4a113..17ef683 100644 --- a/internal/adapters/handlers/tui/v2/components/buttongroup.go +++ b/internal/adapters/handlers/tui/v2/components/buttongroup.go @@ -2,58 +2,33 @@ package components import ( tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) -type ButtonSpec struct { - Label string - Primary bool - Disabled bool -} - type ButtonGroup struct { - Buttons []ButtonSpec - Cursor int - primaryStyle lipgloss.Style - secondStyle lipgloss.Style - disStyle lipgloss.Style + Buttons []Button + Cursor int + Active bool } type ButtonChosenMsg struct{ Label string } type ButtonMovedMsg struct{ Cursor int } -func NewButtonGroup(btns []ButtonSpec, primary, secondary, disabled lipgloss.Style) ButtonGroup { - return ButtonGroup{Buttons: btns, primaryStyle: primary, secondStyle: secondary, disStyle: disabled} +func NewButtonGroup(btns []Button) ButtonGroup { + return ButtonGroup{Buttons: btns} } func (g ButtonGroup) Init() tea.Cmd { return nil } func (g ButtonGroup) View() string { - out := "" + out := " " for i, b := range g.Buttons { - style := g.secondStyle - - if b.Primary { - style = g.primaryStyle - } - - if b.Disabled { - style = g.disStyle - } - - label := style.Render(" " + b.Label + " ") - - if i == g.Cursor { - label = style.Bold(true).Render(" " + b.Label + " ") - } - if i > 0 { - out += " " + out += " " } - out += label + out += b.Render(i == g.Cursor && g.Active) } return out @@ -64,16 +39,14 @@ func (g ButtonGroup) Update(msg tea.Msg) (ButtonGroup, tea.Cmd) { s := k.String() switch s { - case "left": + case "left", "shift+tab": if g.Cursor > 0 { g.Cursor-- - return g, func() tea.Msg { return ButtonMovedMsg{Cursor: g.Cursor} } } - case "right": + case "right", "tab": if g.Cursor < len(g.Buttons)-1 { g.Cursor++ - return g, func() tea.Msg { return ButtonMovedMsg{Cursor: g.Cursor} } } case "enter": diff --git a/internal/adapters/handlers/tui/v2/components/input.go b/internal/adapters/handlers/tui/v2/components/input.go index eee2b79..ab0ea89 100644 --- a/internal/adapters/handlers/tui/v2/components/input.go +++ b/internal/adapters/handlers/tui/v2/components/input.go @@ -62,7 +62,7 @@ func (i Input) Focused() bool { return i.txt.Focused() } func (i Input) Update(msg tea.Msg) (Input, tea.Cmd) { switch m := msg.(type) { case tea.KeyMsg: - if m.Type == tea.KeyEnter { + if m.Type == tea.KeyEnter && i.txt.Focused() { return i, func() tea.Msg { return InputSubmitMsg{Value: i.txt.Value()} } } } diff --git a/internal/adapters/handlers/tui/v2/components/list.go b/internal/adapters/handlers/tui/v2/components/list.go index bb53399..ff88c8e 100644 --- a/internal/adapters/handlers/tui/v2/components/list.go +++ b/internal/adapters/handlers/tui/v2/components/list.go @@ -58,9 +58,7 @@ func (l List) View() string { if i == l.Cursor { rendered := l.styleSel.Render(bullet + it.Label) - if strings.HasPrefix(rendered, " ") { - rendered = rendered[1:] - } + rendered = strings.TrimPrefix(rendered, " ") out += prefix + rendered + "\n" } else { @@ -79,6 +77,7 @@ func (l List) Update(msg tea.Msg) (List, tea.Cmd) { if len(l.Items) == 0 { return l, nil } + if l.Cursor > 0 { l.Cursor-- } else { @@ -90,6 +89,7 @@ func (l List) Update(msg tea.Msg) (List, tea.Cmd) { if len(l.Items) == 0 { return l, nil } + if l.Cursor < len(l.Items)-1 { l.Cursor++ } else { diff --git a/internal/adapters/handlers/tui/v2/components/textarea.go b/internal/adapters/handlers/tui/v2/components/textarea.go index 701daca..5fc7331 100644 --- a/internal/adapters/handlers/tui/v2/components/textarea.go +++ b/internal/adapters/handlers/tui/v2/components/textarea.go @@ -39,8 +39,14 @@ func (t *TextArea) Focus() { t.txt.Focus() } func (t *TextArea) Blur() { t.txt.Blur() } func (t TextArea) Update(msg tea.Msg) (TextArea, tea.Cmd) { - if k, ok := msg.(tea.KeyMsg); ok && k.Type == tea.KeyEnter && k.Alt { - return t, func() tea.Msg { return TextAreaSubmitMsg{Value: t.txt.Value()} } + if k, ok := msg.(tea.KeyMsg); ok { + if k.Type == tea.KeyTab || k.Type == tea.KeyShiftTab { + return t, nil + } + + if k.Type == tea.KeyEnter && k.Alt { + return t, func() tea.Msg { return TextAreaSubmitMsg{Value: t.txt.Value()} } + } } ta, cmd := t.txt.Update(msg) diff --git a/internal/adapters/handlers/tui/v2/router/router_compile_test.go b/internal/adapters/handlers/tui/v2/router/router_compile_test.go deleted file mode 100644 index fd2bf39..0000000 --- a/internal/adapters/handlers/tui/v2/router/router_compile_test.go +++ /dev/null @@ -1,10 +0,0 @@ -package router - -import "testing" - -func TestRouterCompile(_ *testing.T) { - r := New(RouteProjects) - _ = r.Current() - r.NavigateTo(RouteProjectForm, nil) - r.Back() -} diff --git a/internal/adapters/handlers/tui/v2/state/state_compile_test.go b/internal/adapters/handlers/tui/v2/state/state_compile_test.go deleted file mode 100644 index 4f75166..0000000 --- a/internal/adapters/handlers/tui/v2/state/state_compile_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package state - -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" - - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/router" -) - -func TestMessagesCompile(_ *testing.T) { - _ = NavigateToMsg{Route: router.RouteProjects} - _ = BackMsg{} - _ = ErrorMsg{} - _ = NewProjectMsg{} - _ = SelectProjectMsg{ID: "x"} - _ = SaveProjectMsg{Name: "a", Path: "b"} - _ = CancelMsg{} - _ = AddEnvVarMsg{} - _ = EditEnvVarMsg{Name: "dev"} - _ = RemoveEnvVarMsg{Name: "dev"} - _ = SaveEnvVarMsg{Name: "k", Value: "v"} - _ = SaveEnvFormMsg{OriginalName: "dev", Name: "dev", Color: "#fff", Mode: "file", EnvVarsRaw: "X=1"} - _ = PostCreateChoiceMsg{Choice: 0, ProjectName: "p"} - - var _ tea.Msg = NavigateToMsg{} -} diff --git a/internal/adapters/handlers/tui/v2/styles/styles.go b/internal/adapters/handlers/tui/v2/styles/styles.go index 208e44b..806a24b 100644 --- a/internal/adapters/handlers/tui/v2/styles/styles.go +++ b/internal/adapters/handlers/tui/v2/styles/styles.go @@ -13,6 +13,10 @@ type ComponentStyles struct { ModalFooter lipgloss.Style ButtonPrimary lipgloss.Style ButtonSecondary lipgloss.Style + ButtonSuccess lipgloss.Style + ButtonInfo lipgloss.Style + ButtonWarning lipgloss.Style + ButtonDanger lipgloss.Style ButtonDisabled lipgloss.Style } @@ -28,6 +32,10 @@ func BuildComponentStyles(t Theme) ComponentStyles { ModalFooter: t.Help, ButtonPrimary: t.ButtonPrimary, ButtonSecondary: t.ButtonSecondary, + ButtonSuccess: t.ButtonSuccess, + ButtonInfo: t.ButtonInfo, + ButtonWarning: t.ButtonWarning, + ButtonDanger: t.ButtonDanger, ButtonDisabled: t.Default, } } diff --git a/internal/adapters/handlers/tui/v2/styles/theme.go b/internal/adapters/handlers/tui/v2/styles/theme.go index f48e335..411dc3b 100644 --- a/internal/adapters/handlers/tui/v2/styles/theme.go +++ b/internal/adapters/handlers/tui/v2/styles/theme.go @@ -3,20 +3,28 @@ package styles import "github.com/charmbracelet/lipgloss" type Tokens struct { - Title lipgloss.Color - Section lipgloss.Color - Subtext lipgloss.Color - Text lipgloss.Color - Placeholder lipgloss.Color - Border lipgloss.Color - Error lipgloss.Color - ButtonDefFg lipgloss.Color - ButtonDefBg lipgloss.Color - ButtonSelFg lipgloss.Color - ButtonSelBg lipgloss.Color - SelectedFg lipgloss.Color - SelectedBg lipgloss.Color - Help lipgloss.Color + Title lipgloss.Color + Section lipgloss.Color + Subtext lipgloss.Color + Text lipgloss.Color + Placeholder lipgloss.Color + Border lipgloss.Color + Error lipgloss.Color + ButtonDefFg lipgloss.Color + ButtonDefBg lipgloss.Color + ButtonSelFg lipgloss.Color + ButtonSelBg lipgloss.Color + SelectedFg lipgloss.Color + SelectedBg lipgloss.Color + Help lipgloss.Color + ButtonSuccessFg lipgloss.Color + ButtonSuccessBg lipgloss.Color + ButtonInfoFg lipgloss.Color + ButtonInfoBg lipgloss.Color + ButtonWarningFg lipgloss.Color + ButtonWarningBg lipgloss.Color + ButtonDangerFg lipgloss.Color + ButtonDangerBg lipgloss.Color } type Theme struct { @@ -27,24 +35,36 @@ type Theme struct { Error lipgloss.Style ButtonPrimary lipgloss.Style ButtonSecondary lipgloss.Style + ButtonSuccess lipgloss.Style + ButtonInfo lipgloss.Style + ButtonWarning lipgloss.Style + ButtonDanger lipgloss.Style } func NewTokensFromHex(p map[string]string) Tokens { return Tokens{ - Title: lipgloss.Color(p["title"]), - Section: lipgloss.Color(p["section"]), - Subtext: lipgloss.Color(p["subtext"]), - Text: lipgloss.Color(p["text"]), - Placeholder: lipgloss.Color(p["placeholder"]), - Border: lipgloss.Color(p["border"]), - Error: lipgloss.Color(p["error"]), - ButtonDefFg: lipgloss.Color(p["buttonDefFg"]), - ButtonDefBg: lipgloss.Color(p["buttonDefBg"]), - ButtonSelFg: lipgloss.Color(p["buttonSelFg"]), - ButtonSelBg: lipgloss.Color(p["buttonSelBg"]), - SelectedFg: lipgloss.Color(p["selectedFg"]), - SelectedBg: lipgloss.Color(p["selectedBg"]), - Help: lipgloss.Color(p["help"]), + Title: lipgloss.Color(p["title"]), + Section: lipgloss.Color(p["section"]), + Subtext: lipgloss.Color(p["subtext"]), + Text: lipgloss.Color(p["text"]), + Placeholder: lipgloss.Color(p["placeholder"]), + Border: lipgloss.Color(p["border"]), + Error: lipgloss.Color(p["error"]), + ButtonDefFg: lipgloss.Color(p["buttonDefFg"]), + ButtonDefBg: lipgloss.Color(p["buttonDefBg"]), + ButtonSelFg: lipgloss.Color(p["buttonSelFg"]), + ButtonSelBg: lipgloss.Color(p["buttonSelBg"]), + SelectedFg: lipgloss.Color(p["selectedFg"]), + SelectedBg: lipgloss.Color(p["selectedBg"]), + Help: lipgloss.Color(p["help"]), + ButtonSuccessFg: lipgloss.Color(p["buttonSuccessFg"]), + ButtonSuccessBg: lipgloss.Color(p["buttonSuccessBg"]), + ButtonInfoFg: lipgloss.Color(p["buttonInfoFg"]), + ButtonInfoBg: lipgloss.Color(p["buttonInfoBg"]), + ButtonWarningFg: lipgloss.Color(p["buttonWarningFg"]), + ButtonWarningBg: lipgloss.Color(p["buttonWarningBg"]), + ButtonDangerFg: lipgloss.Color(p["buttonDangerFg"]), + ButtonDangerBg: lipgloss.Color(p["buttonDangerBg"]), } } @@ -60,7 +80,11 @@ func BuildTheme(t Tokens) Theme { Default: lipgloss.NewStyle().Foreground(t.Subtext).Padding(0, 1), Help: lipgloss.NewStyle().Foreground(t.Help), Error: lipgloss.NewStyle().Foreground(t.Error), - ButtonPrimary: lipgloss.NewStyle().Foreground(t.ButtonSelFg).Background(t.ButtonSelBg).Padding(0, 2), - ButtonSecondary: lipgloss.NewStyle().Foreground(t.ButtonDefFg).Background(t.ButtonDefBg).Padding(0, 2), + ButtonPrimary: lipgloss.NewStyle().Foreground(t.ButtonSelFg).Background(t.ButtonSelBg), + ButtonSecondary: lipgloss.NewStyle().Foreground(t.ButtonDefFg).Background(t.Error), + ButtonSuccess: lipgloss.NewStyle().Foreground(t.ButtonSuccessFg).Background(t.ButtonSuccessBg), + ButtonInfo: lipgloss.NewStyle().Foreground(t.ButtonInfoFg).Background(t.ButtonInfoBg), + ButtonWarning: lipgloss.NewStyle().Foreground(t.ButtonWarningFg).Background(t.ButtonWarningBg), + ButtonDanger: lipgloss.NewStyle().Foreground(t.ButtonDangerFg).Background(t.ButtonDangerBg), } } diff --git a/internal/adapters/handlers/tui/v2/views/env_form_view.go b/internal/adapters/handlers/tui/v2/views/env_form_view.go index 2171fed..80fa5ef 100644 --- a/internal/adapters/handlers/tui/v2/views/env_form_view.go +++ b/internal/adapters/handlers/tui/v2/views/env_form_view.go @@ -17,6 +17,9 @@ type EnvFormView struct { Color components.Input Mode components.Input EnvVars components.TextArea + SaveBtn components.Button + Cancel components.Button + Buttons components.ButtonGroup Title lipgloss.Style Item lipgloss.Style BtnPri lipgloss.Style @@ -31,12 +34,19 @@ func NewEnvFormView(orig, name, color, mode, envRaw string, title, item, btnPri, Name: components.NewInput("Env name*", "staging", name, item, item), Color: components.NewInput("Color (name/#hex/0-255)", "teal", color, item, item), Mode: components.NewInput("Env vars mode* (merge/replace)", "merge", mode, item, item), - EnvVars: components.NewTextArea("# One per line (like .env)\nAPI_URL=https://api.example.com\nLOG_LEVEL=info\n# comments allowed", envRaw, item, item), - Title: title, - Item: item, - BtnPri: btnPri, - BtnSec: btnSec, - Focused: 0, + EnvVars: components.NewTextArea( + "# One per line (like .env)\nAPI_URL=https://api.example.com\nLOG_LEVEL=info\n# comments allowed", + envRaw, + item, + item, + ), + SaveBtn: components.NewButton("Save", components.Primary, btnPri, btnPri.Bold(true).Underline(true), item), + Cancel: components.NewButton("Cancel", components.Secondary, btnSec, btnSec.Bold(true).Underline(true), item), + Title: title, + Item: item, + BtnPri: btnPri, + BtnSec: btnSec, + Focused: 0, } v.Name.SetWidth(60) @@ -44,12 +54,15 @@ func NewEnvFormView(orig, name, color, mode, envRaw string, title, item, btnPri, v.Mode.SetWidth(60) v.EnvVars.SetHeight(6) v.EnvVars.SetWidth(70) + v.Buttons = components.NewButtonGroup([]components.Button{v.SaveBtn, v.Cancel}) + v.Buttons.Active = false v.Name.Focus() if strings.TrimSpace(orig) == "" { if strings.TrimSpace(v.Color.Value()) == "" { v.Color.SetValue("grey") } + if strings.TrimSpace(v.Mode.Value()) == "" { v.Mode.SetValue("merge") } @@ -64,31 +77,86 @@ func (v *EnvFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m := msg.(type) { case components.ButtonPressedMsg: if m.Label == "Save" { + v.Err = "" + name := strings.TrimSpace(v.Name.Value()) + + mode := strings.TrimSpace(v.Mode.Value()) + if name == "" { + v.Err = "name is required" + return v, nil + } + + if mode != domain.EnvVarsModeMerge && mode != domain.EnvVarsModeReplace { + v.Err = "mode must be merge or replace" + return v, nil + } + + return v, func() tea.Msg { + return state.SaveEnvFormMsg{ + OriginalName: v.OriginalName, + Name: name, + Color: strings.TrimSpace(v.Color.Value()), + Mode: mode, + EnvVarsRaw: v.EnvVars.Value(), + } + } + } + + if m.Label == "Cancel" { + return v, func() tea.Msg { return state.CancelMsg{} } + } + case components.ButtonChosenMsg: + if m.Label == "Save" { + v.Err = "" + name := strings.TrimSpace(v.Name.Value()) + + mode := strings.TrimSpace(v.Mode.Value()) + if name == "" { + v.Err = "name is required" + return v, nil + } + + if mode != domain.EnvVarsModeMerge && mode != domain.EnvVarsModeReplace { + v.Err = "mode must be merge or replace" + return v, nil + } + return v, func() tea.Msg { return state.SaveEnvFormMsg{ OriginalName: v.OriginalName, - Name: strings.TrimSpace(v.Name.Value()), + Name: name, Color: strings.TrimSpace(v.Color.Value()), - Mode: strings.TrimSpace(v.Mode.Value()), + Mode: mode, EnvVarsRaw: v.EnvVars.Value(), } } } + + if m.Label == "Cancel" { + return v, func() tea.Msg { return state.CancelMsg{} } + } case tea.KeyMsg: + if cmd, ok := handleButtonGroupNav(4, 5, &v.Focused, &v.Buttons, m); ok { + return v, cmd + } + s := m.String() switch s { case "ctrl+s": v.Err = "" name := strings.TrimSpace(v.Name.Value()) + mode := strings.TrimSpace(v.Mode.Value()) if name == "" { v.Err = "name is required" return v, nil } + if mode != domain.EnvVarsModeMerge && mode != domain.EnvVarsModeReplace { v.Err = "mode must be merge or replace" return v, nil } + return v, func() tea.Msg { return state.SaveEnvFormMsg{ OriginalName: v.OriginalName, @@ -101,73 +169,17 @@ func (v *EnvFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "tab": v.Focused = (v.Focused + 1) % 6 v.blurAll() + v.Buttons.Active = v.Focused >= 4 v.focusCurrent() + return v, nil case "shift+tab": v.Focused = (v.Focused + 5) % 6 v.blurAll() + v.Buttons.Active = v.Focused >= 4 v.focusCurrent() + return v, nil - case "enter": - if v.Focused < 3 { - v.Focused = (v.Focused + 1) % 6 - v.blurAll() - v.focusCurrent() - return v, nil - } - if v.Focused == 4 { - v.Err = "" - name := strings.TrimSpace(v.Name.Value()) - mode := strings.TrimSpace(v.Mode.Value()) - if name == "" { - v.Err = "name is required" - return v, nil - } - if mode != domain.EnvVarsModeMerge && mode != domain.EnvVarsModeReplace { - v.Err = "mode must be merge or replace" - return v, nil - } - return v, func() tea.Msg { - return state.SaveEnvFormMsg{ - OriginalName: v.OriginalName, - Name: name, - Color: strings.TrimSpace(v.Color.Value()), - Mode: mode, - EnvVarsRaw: v.EnvVars.Value(), - } - } - } - if v.Focused == 5 { - return v, func() tea.Msg { return state.CancelMsg{} } - } - case "up": - if v.Focused != 3 { - v.Focused = (v.Focused + 5) % 6 - v.blurAll() - v.focusCurrent() - return v, nil - } - case "down": - if v.Focused != 3 { - v.Focused = (v.Focused + 1) % 6 - v.blurAll() - v.focusCurrent() - return v, nil - } - case "left": - if v.Focused == 5 { - v.Focused = 4 - v.blurAll() - v.focusCurrent() - return v, nil - } - case "right": - if v.Focused == 4 { - v.Focused = 5 - v.blurAll() - v.focusCurrent() - return v, nil - } } } @@ -200,11 +212,13 @@ func (v *EnvFormView) View() string { { p := lipgloss.NewStyle() val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + v.Name.SetPrompt(p.Render("Env name*") + p.Render(": ")) v.Name.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) v.Name.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) v.Name.SetTextStyle(val) } + b.WriteString(v.Name.View()) b.WriteString("\n") { @@ -215,36 +229,34 @@ func (v *EnvFormView) View() string { v.Color.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) v.Color.SetTextStyle(val) } + b.WriteString(v.Color.View()) b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")).Bold(true).Render("Variables")) - b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C")).Render("One KEY=VALUE per line; '#' comments allowed")) + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")).Bold(true).Render("Environment variables")) b.WriteString("\n") { p := lipgloss.NewStyle() val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + v.Mode.SetPrompt(p.Render("Env vars mode* (merge/replace): ")) v.Mode.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) v.Mode.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) v.Mode.SetTextStyle(val) } + b.WriteString(v.Mode.View()) b.WriteString("\n") + b.WriteString( + lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7C818C")). + Render("One KEY=VALUE per line; '#' comments allowed"), + ) + b.WriteString("\n") b.WriteString(v.EnvVars.View()) b.WriteString("\n\n") - saveStyle := v.BtnSec - cancelStyle := v.BtnSec - if v.Focused == 4 { - saveStyle = v.BtnPri - } - if v.Focused == 5 { - cancelStyle = v.BtnPri - } - btnSave := saveStyle.Render(" Save ") - btnCancel := cancelStyle.Render(" Cancel ") - b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, btnSave, " ", btnCancel)) + b.WriteString(v.Buttons.View()) b.WriteString("\n\n") + if strings.TrimSpace(v.Err) != "" { b.WriteString(v.Err) } @@ -269,5 +281,9 @@ func (v *EnvFormView) focusCurrent() { v.Mode.Focus() case 3: v.EnvVars.Focus() + case 4: + v.Buttons.Cursor = 0 + case 5: + v.Buttons.Cursor = 1 } } diff --git a/internal/adapters/handlers/tui/v2/views/env_vars_view.go b/internal/adapters/handlers/tui/v2/views/env_vars_view.go index bedd132..9200ddf 100644 --- a/internal/adapters/handlers/tui/v2/views/env_vars_view.go +++ b/internal/adapters/handlers/tui/v2/views/env_vars_view.go @@ -8,6 +8,40 @@ import ( "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" ) +type EnvVarsViewStyles struct { + Title lipgloss.Style + Item lipgloss.Style + Selected lipgloss.Style + Marker lipgloss.Color +} + +type EnvVarsViewStyleOption func(*EnvVarsViewStyles) + +func NewEnvVarsViewStyles(opts ...EnvVarsViewStyleOption) EnvVarsViewStyles { + s := EnvVarsViewStyles{} + for _, o := range opts { + o(&s) + } + + return s +} + +func WithEnvVarsTitle(s lipgloss.Style) EnvVarsViewStyleOption { + return func(es *EnvVarsViewStyles) { es.Title = s } +} + +func WithEnvVarsItem(s lipgloss.Style) EnvVarsViewStyleOption { + return func(es *EnvVarsViewStyles) { es.Item = s } +} + +func WithEnvVarsSelected(s lipgloss.Style) EnvVarsViewStyleOption { + return func(es *EnvVarsViewStyles) { es.Selected = s } +} + +func WithEnvVarsMarker(c lipgloss.Color) EnvVarsViewStyleOption { + return func(es *EnvVarsViewStyles) { es.Marker = c } +} + type EnvVarsView struct { List components.List Title lipgloss.Style @@ -16,18 +50,18 @@ type EnvVarsView struct { Marker lipgloss.Color } -func NewEnvVarsView(items []components.ListItem, title, item, sel lipgloss.Style, marker lipgloss.Color) EnvVarsView { - l := components.NewList(items, item, sel). +func NewEnvVarsView(items []components.ListItem, styles EnvVarsViewStyles) EnvVarsView { + l := components.NewList(items, styles.Item, styles.Selected). WithMarkerColor(func(it components.ListItem) lipgloss.Color { if it.ID == "__add__" { - return marker + return styles.Marker } return lipgloss.Color(it.Color) }) l = l.WithBullet(func(it components.ListItem) lipgloss.Color { return lipgloss.Color(it.Color) }) - return EnvVarsView{List: l, Title: title, Item: item, Sel: sel, Marker: marker} + return EnvVarsView{List: l, Title: styles.Title, Item: styles.Item, Sel: styles.Selected, Marker: styles.Marker} } func (v EnvVarsView) Init() tea.Cmd { return nil } @@ -40,26 +74,33 @@ func (v EnvVarsView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(v.List.Items) == 0 { return v, nil } + it := v.List.Items[v.List.Cursor] if it.ID == "__add__" { return v, func() tea.Msg { return state.AddEnvVarMsg{} } } + return v, func() tea.Msg { return state.ExitMsg{} } } + if s == "e" { if len(v.List.Items) == 0 { return v, nil } + it := v.List.Items[v.List.Cursor] if it.ID != "__add__" { return v, func() tea.Msg { return state.EditEnvVarMsg{Name: it.ID} } } + return v, nil } + if s == "d" { if len(v.List.Items) == 0 { return v, nil } + it := v.List.Items[v.List.Cursor] if it.ID != "__add__" { return v, func() tea.Msg { return state.RemoveEnvVarMsg{Name: it.ID} } diff --git a/internal/adapters/handlers/tui/v2/views/project_form_view.go b/internal/adapters/handlers/tui/v2/views/project_form_view.go index fd5571b..fe33201 100644 --- a/internal/adapters/handlers/tui/v2/views/project_form_view.go +++ b/internal/adapters/handlers/tui/v2/views/project_form_view.go @@ -10,6 +10,90 @@ import ( "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" ) +const ( + idxName = iota + idxPath + idxSubproject + idxShell + idxUserName + idxUserEmail + idxUserSigningKey + idxCommitGPGSign + idxTagGPGSign + idxEnvVars + idxBtnSave + idxBtnCancel +) + +type ProjectFormViewStyles struct { + Title lipgloss.Style + Section lipgloss.Style + Subtext lipgloss.Style + Accent lipgloss.Style + Input lipgloss.Style + InputVal lipgloss.Style + BtnPrimary lipgloss.Style + BtnSecondary lipgloss.Style + BtnPrimaryFocused lipgloss.Style + BtnSecondaryFocused lipgloss.Style + BtnDisabled lipgloss.Style +} + +type ProjectFormViewStyleOption func(*ProjectFormViewStyles) + +func NewProjectFormViewStyles(opts ...ProjectFormViewStyleOption) ProjectFormViewStyles { + s := ProjectFormViewStyles{} + for _, o := range opts { + o(&s) + } + + return s +} + +func WithProjectFormTitle(s lipgloss.Style) ProjectFormViewStyleOption { + return func(ps *ProjectFormViewStyles) { ps.Title = s } +} + +func WithProjectFormInput(s lipgloss.Style) ProjectFormViewStyleOption { + return func(ps *ProjectFormViewStyles) { ps.Input = s } +} + +func WithProjectFormInputVal(s lipgloss.Style) ProjectFormViewStyleOption { + return func(ps *ProjectFormViewStyles) { ps.InputVal = s } +} + +func WithProjectFormBtnPrimary(s lipgloss.Style) ProjectFormViewStyleOption { + return func(ps *ProjectFormViewStyles) { ps.BtnPrimary = s } +} + +func WithProjectFormBtnSecondary(s lipgloss.Style) ProjectFormViewStyleOption { + return func(ps *ProjectFormViewStyles) { ps.BtnSecondary = s } +} + +func WithProjectFormBtnPrimaryFocused(s lipgloss.Style) ProjectFormViewStyleOption { + return func(ps *ProjectFormViewStyles) { ps.BtnPrimaryFocused = s } +} + +func WithProjectFormBtnSecondaryFocused(s lipgloss.Style) ProjectFormViewStyleOption { + return func(ps *ProjectFormViewStyles) { ps.BtnSecondaryFocused = s } +} + +func WithProjectFormBtnDisabled(s lipgloss.Style) ProjectFormViewStyleOption { + return func(ps *ProjectFormViewStyles) { ps.BtnDisabled = s } +} + +func WithProjectFormSection(s lipgloss.Style) ProjectFormViewStyleOption { + return func(ps *ProjectFormViewStyles) { ps.Section = s } +} + +func WithProjectFormSubtext(s lipgloss.Style) ProjectFormViewStyleOption { + return func(ps *ProjectFormViewStyles) { ps.Subtext = s } +} + +func WithProjectFormAccent(s lipgloss.Style) ProjectFormViewStyleOption { + return func(ps *ProjectFormViewStyles) { ps.Accent = s } +} + type ProjectFormView struct { OriginalName string Name components.Input @@ -26,36 +110,73 @@ type ProjectFormView struct { Cancel components.Button Buttons components.ButtonGroup Title lipgloss.Style + Section lipgloss.Style + Subtext lipgloss.Style + Accent lipgloss.Style + InputVal lipgloss.Style + BtnPri lipgloss.Style + BtnSec lipgloss.Style IsEdit bool Focused int } func NewProjectFormView( name, path string, - title, input, inputVal, btnPri, btnSec, btnDis lipgloss.Style, + styles ProjectFormViewStyles, ) ProjectFormView { v := ProjectFormView{ - Name: components.NewInput("Name*", "my-awesome-app", name, input, inputVal), - Path: components.NewInput("Path*", "~/my-awesome-app", path, input, inputVal), - Subproject: components.NewInput("Subproject", "services/api", "", input, inputVal), - Shell: components.NewInput("Shell", "/bin/bash", "", input, inputVal), - UserName: components.NewInput("Git user.name", "Jane Doe", "", input, inputVal), - UserEmail: components.NewInput("Git user.email", "jane@example.com", "", input, inputVal), - UserSigningKey: components.NewInput("Git user.signingkey", "0xDEADBEEF", "", input, inputVal), - CommitGPGSign: components.NewInput("commit.gpgsign (true/false)", "true/false", "true", input, inputVal), - TagGPGSign: components.NewInput("tag.gpgsign (true/false)", "true/false", "true", input, inputVal), - EnvVars: components.NewTextArea("# One per line (like .env)\nAPP_ENV=development\nDATABASE_URL=postgres://user:pass@localhost:5432/app\n# comments allowed", "", input, inputVal), - SaveBtn: components.NewButton("Save", components.Primary, btnPri, btnDis), - Cancel: components.NewButton("Cancel", components.Secondary, btnSec, btnDis), - Title: title, + Name: components.NewInput("Name*", "my-awesome-app", name, styles.Input, styles.InputVal), + Path: components.NewInput("Path*", "~/my-awesome-app", path, styles.Input, styles.InputVal), + Subproject: components.NewInput("Subproject", "services/api", "", styles.Input, styles.InputVal), + Shell: components.NewInput("Shell", "/bin/bash", "", styles.Input, styles.InputVal), + UserName: components.NewInput("Git user.name", "Jane Doe", "", styles.Input, styles.InputVal), + UserEmail: components.NewInput("Git user.email", "jane@example.com", "", styles.Input, styles.InputVal), + UserSigningKey: components.NewInput("Git user.signingkey", "0xDEADBEEF", "", styles.Input, styles.InputVal), + CommitGPGSign: components.NewInput( + "commit.gpgsign (true/false)", + "true/false", + "true", + styles.Input, + styles.InputVal, + ), + TagGPGSign: components.NewInput( + "tag.gpgsign (true/false)", + "true/false", + "true", + styles.Input, + styles.InputVal, + ), + EnvVars: components.NewTextArea( + "# One per line (like .env)\nAPP_ENV=development\nDATABASE_URL=postgres://user:pass@localhost:5432/app\n# comments allowed", + "", + styles.Input, + styles.InputVal, + ), + SaveBtn: components.NewButton( + "Save", + components.Primary, + styles.BtnPrimary, + styles.BtnPrimaryFocused, + styles.BtnDisabled, + ), + Cancel: components.NewButton( + "Cancel", + components.Secondary, + styles.BtnSecondary, + styles.BtnSecondaryFocused, + styles.BtnDisabled, + ), + Title: styles.Title, + Section: styles.Section, + Subtext: styles.Subtext, + Accent: styles.Accent, + InputVal: styles.InputVal, + BtnPri: styles.BtnPrimary, + BtnSec: styles.BtnSecondary, } - v.Buttons = components.NewButtonGroup( - []components.ButtonSpec{{Label: "Save", Primary: true}, {Label: "Cancel"}}, - btnPri, - btnSec, - btnDis, - ) + v.Buttons = components.NewButtonGroup([]components.Button{v.SaveBtn, v.Cancel}) + v.Buttons.Active = false v.Name.SetWidth(40) v.Path.SetWidth(40) @@ -70,12 +191,13 @@ func NewProjectFormView( v.EnvVars.SetWidth(60) v.Name.Focus() - v.Focused = 0 + v.Focused = idxName if strings.TrimSpace(name) != "" && strings.TrimSpace(path) != "" { v.IsEdit = true v.OriginalName = name } + return v } @@ -84,37 +206,12 @@ func (v ProjectFormView) Init() tea.Cmd { return nil } func (v ProjectFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - if v.Focused >= 10 { - switch msg.Type { - case tea.KeyLeft: - if v.Focused == 11 { - v.Focused = 10 - } - return v, nil - case tea.KeyRight: - if v.Focused == 10 { - v.Focused = 11 - } - return v, nil - case tea.KeyEnter: - if v.Focused == 10 { - return v, func() tea.Msg { - return state.SaveProjectMsg{ - Name: v.Name.Value(), - Path: v.Path.Value(), - Subproject: v.Subproject.Value(), - Shell: v.Shell.Value(), - GitUserName: v.UserName.Value(), - GitUserEmail: v.UserEmail.Value(), - GitSigningKey: v.UserSigningKey.Value(), - CommitGPGSign: v.CommitGPGSign.Value(), - TagGPGSign: v.TagGPGSign.Value(), - EnvVarsRaw: v.EnvVars.Value(), - } - } - } - return v, func() tea.Msg { return state.CancelMsg{} } - } + if cmd, ok := handleButtonGroupNav(idxBtnSave, idxBtnCancel, &v.Focused, &v.Buttons, msg); ok { + v.blurAll() + v.Buttons.Active = v.Focused >= idxBtnSave + v.focusCurrent() + + return v, cmd } if msg.Type == tea.KeyCtrlS { @@ -135,55 +232,66 @@ func (v ProjectFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if msg.Type == tea.KeyTab || msg.Type == tea.KeyDown || msg.Type == tea.KeyEnter || msg.Type == tea.KeyShiftTab || msg.Type == tea.KeyUp { - f := v.Focused - if msg.Type == tea.KeyShiftTab || msg.Type == tea.KeyUp { - f-- - } else { - if !(msg.Type == tea.KeyEnter && f >= 9) { - f++ - } - } - if v.IsEdit && f == 1 { + allowed := v.Focused != idxEnvVars || (msg.Type == tea.KeyTab || msg.Type == tea.KeyShiftTab) + if allowed { + f := v.Focused + if msg.Type == tea.KeyShiftTab || msg.Type == tea.KeyUp { f-- } else { - f++ + if msg.Type != tea.KeyEnter || f < idxEnvVars { + f++ + } } + + if v.IsEdit && f == idxPath { + if msg.Type == tea.KeyShiftTab || msg.Type == tea.KeyUp { + f-- + } else { + f++ + } + } + + if f < 0 { + f = idxBtnCancel + } + + if f > idxBtnCancel { + f = idxName + } + + if msg.Type == tea.KeyUp && v.Focused == idxEnvVars { + f = v.Focused + } + + v.blurAll() + v.Focused = f + v.Buttons.Active = v.Focused >= idxBtnSave + v.focusCurrent() + + return v, nil } - if f < 0 { - f = 11 - } - if f > 11 { - f = 0 - } - if (msg.Type == tea.KeyUp || msg.Type == tea.KeyDown) && v.Focused == 9 { - f = v.Focused - } - v.blurAll() - v.Focused = f - v.focusCurrent() } case components.InputSubmitMsg: - val := strings.TrimSpace(msg.Value) - if val == "" { - return v, nil + f := v.Focused + + f++ + + if v.IsEdit && f == idxPath { + f++ } - return v, func() tea.Msg { - return state.SaveProjectMsg{ - Name: v.Name.Value(), - Path: v.Path.Value(), - Subproject: v.Subproject.Value(), - Shell: v.Shell.Value(), - GitUserName: v.UserName.Value(), - GitUserEmail: v.UserEmail.Value(), - GitSigningKey: v.UserSigningKey.Value(), - CommitGPGSign: v.CommitGPGSign.Value(), - TagGPGSign: v.TagGPGSign.Value(), - EnvVarsRaw: v.EnvVars.Value(), - } + if f > idxBtnCancel { + f = idxName } + + v.blurAll() + v.Focused = f + v.Buttons.Active = v.Focused >= idxBtnSave + v.focusCurrent() + + return v, nil case components.ButtonPressedMsg: if msg.Label == "Save" { return v, func() tea.Msg { @@ -230,11 +338,13 @@ func (v ProjectFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { oldName := strings.TrimSpace(v.Name.Value()) n, ncmd := v.Name.Update(msg) + v.Name = n if !v.IsEdit && v.Name.Focused() { newName := strings.TrimSpace(v.Name.Value()) if newName != oldName { slug := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(newName, " ", "-"))) + cur := strings.TrimSpace(v.Path.Value()) if slug == "" { if cur == "" || strings.HasPrefix(cur, "~/") { @@ -267,14 +377,49 @@ func (v ProjectFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { e, ecmd := v.EnvVars.Update(msg) v.EnvVars = e - var bgcmd tea.Cmd - if v.Focused >= 10 { - bg, bcmd := v.Buttons.Update(msg) - v.Buttons = bg - bgcmd = bcmd + return v, tea.Batch(ncmd, pcmd, scmd, shcmd, ucmd, uemcmd, ukcmd, cgcmd, tgcmd, ecmd) +} + +func handleButtonGroupNav(base, last int, focused *int, g *components.ButtonGroup, k tea.KeyMsg) (tea.Cmd, bool) { + if *focused < base { + return nil, false } - return v, tea.Batch(ncmd, pcmd, scmd, shcmd, ucmd, uemcmd, ukcmd, cgcmd, tgcmd, ecmd, bgcmd) + switch k.Type { + case tea.KeyLeft, tea.KeyRight, tea.KeyEnter: + bg, cmd := g.Update(k) + *g = bg + *focused = base + g.Cursor + g.Active = true + + return cmd, true + case tea.KeyShiftTab, tea.KeyTab: + f := *focused + if k.Type == tea.KeyShiftTab { + f-- + } else { + f++ + } + + if f < 0 { + f = last + } + + if f > last { + f = idxName + } + + *focused = f + + g.Active = *focused >= base + if *focused >= base && *focused <= last { + g.Cursor = *focused - base + } + + return nil, true + } + + return nil, false } func (v *ProjectFormView) blurAll() { @@ -292,166 +437,180 @@ func (v *ProjectFormView) blurAll() { func (v *ProjectFormView) focusCurrent() { switch v.Focused { - case 0: + case idxName: v.Name.Focus() - case 1: + case idxPath: if !v.IsEdit { v.Path.Focus() } - case 2: + case idxSubproject: v.Subproject.Focus() - case 3: + case idxShell: v.Shell.Focus() - case 4: + case idxUserName: v.UserName.Focus() - case 5: + case idxUserEmail: v.UserEmail.Focus() - case 6: + case idxUserSigningKey: v.UserSigningKey.Focus() - case 7: + case idxCommitGPGSign: v.CommitGPGSign.Focus() - case 8: + case idxTagGPGSign: v.TagGPGSign.Focus() - case 9: + case idxEnvVars: v.EnvVars.Focus() - case 10: - v.Buttons.Cursor = 0 - case 11: - v.Buttons.Cursor = 1 } } func (v ProjectFormView) View() string { b := strings.Builder{} + title := "New project" if v.IsEdit { title = "Edit project" } + b.WriteString(v.Title.Render(title)) b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")).Bold(true).Render("Project details")) + b.WriteString(v.Section.Bold(true).Render("Project details")) b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C")).Render("Basic information")) + b.WriteString(v.Subtext.Render("Basic information")) b.WriteString("\n") { - p := lipgloss.NewStyle() - val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) - v.Name.SetPrompt(p.Render("Name") + lipgloss.NewStyle().Foreground(lipgloss.Color("#BF616A")).Render("*") + p.Render(": ")) - v.Name.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) - v.Name.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + p := v.Section + val := v.InputVal + v.Name.SetPrompt( + p.Render("Name") + v.Accent.Render("*") + p.Render(": "), + ) + v.Name.SetPromptStyle(v.Section) + v.Name.SetPlaceholderStyle(v.Subtext) v.Name.SetTextStyle(val) } + b.WriteString(v.Name.View()) b.WriteString("\n") + if v.IsEdit { p := strings.TrimSpace(v.Path.Value()) - if p == "" { p = "~/my-awesome-app" } - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(p + " (read-only)")) + if p == "" { + p = "~/my-awesome-app" + } + + b.WriteString(v.Subtext.Render(p + " (read-only)")) } else { { - p := lipgloss.NewStyle() - val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) - v.Path.SetPrompt(p.Render("Path") + lipgloss.NewStyle().Foreground(lipgloss.Color("#BF616A")).Render("*") + p.Render(": ")) - v.Path.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) - v.Path.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + p := v.Section + val := v.InputVal + v.Path.SetPrompt(p.Render("Path") + v.Accent.Render("*") + p.Render(": ")) + v.Path.SetPromptStyle(v.Section) + v.Path.SetPlaceholderStyle(v.Subtext) v.Path.SetTextStyle(val) } + b.WriteString(v.Path.View()) } + b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")).Bold(true).Render("Project options")) + b.WriteString(v.Section.Bold(true).Render("Project options")) b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C")).Render("Optional settings")) + b.WriteString(v.Subtext.Render("Optional settings")) b.WriteString("\n") { - p := lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")) - val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + p := v.Section + val := v.InputVal + v.Subproject.SetPrompt(p.Render("Subproject: ")) - v.Subproject.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) - v.Subproject.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.Subproject.SetPromptStyle(v.Section) + v.Subproject.SetPlaceholderStyle(v.Subtext) v.Subproject.SetTextStyle(val) } + b.WriteString(v.Subproject.View()) b.WriteString("\n") { - p := lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")) - val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + p := v.Section + val := v.InputVal + v.Shell.SetPrompt(p.Render("Shell: ")) - v.Shell.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) - v.Shell.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.Shell.SetPromptStyle(v.Section) + v.Shell.SetPlaceholderStyle(v.Subtext) v.Shell.SetTextStyle(val) } + b.WriteString(v.Shell.View()) b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")).Bold(true).Render("Git settings")) + b.WriteString(v.Section.Bold(true).Render("Git settings")) b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C")).Render("Applied to this project only")) + b.WriteString(v.Subtext.Render("Applied to this project only")) b.WriteString("\n") { - p := lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")) - val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + p := v.Section + val := v.InputVal + v.UserName.SetPrompt(p.Render("Git user.name: ")) - v.UserName.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) - v.UserName.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.UserName.SetPromptStyle(v.Section) + v.UserName.SetPlaceholderStyle(v.Subtext) v.UserName.SetTextStyle(val) } + b.WriteString(v.UserName.View()) b.WriteString("\n") { - p := lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")) - val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + p := v.Section + val := v.InputVal + v.UserEmail.SetPrompt(p.Render("Git user.email: ")) - v.UserEmail.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) - v.UserEmail.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.UserEmail.SetPromptStyle(v.Section) + v.UserEmail.SetPlaceholderStyle(v.Subtext) v.UserEmail.SetTextStyle(val) } + b.WriteString(v.UserEmail.View()) b.WriteString("\n") { - p := lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")) - val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + p := v.Section + val := v.InputVal + v.UserSigningKey.SetPrompt(p.Render("Git user.signingkey: ")) - v.UserSigningKey.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) - v.UserSigningKey.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.UserSigningKey.SetPromptStyle(v.Section) + v.UserSigningKey.SetPlaceholderStyle(v.Subtext) v.UserSigningKey.SetTextStyle(val) } + b.WriteString(v.UserSigningKey.View()) b.WriteString("\n") { - p := lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")) - val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + p := v.Section + val := v.InputVal + v.CommitGPGSign.SetPrompt(p.Render("commit.gpgsign (true/false): ")) - v.CommitGPGSign.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) - v.CommitGPGSign.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.CommitGPGSign.SetPromptStyle(v.Section) + v.CommitGPGSign.SetPlaceholderStyle(v.Subtext) v.CommitGPGSign.SetTextStyle(val) } + b.WriteString(v.CommitGPGSign.View()) b.WriteString("\n") { - p := lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")) - val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + p := v.Section + val := v.InputVal + v.TagGPGSign.SetPrompt(p.Render("tag.gpgsign (true/false): ")) - v.TagGPGSign.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) - v.TagGPGSign.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.TagGPGSign.SetPromptStyle(v.Section) + v.TagGPGSign.SetPlaceholderStyle(v.Subtext) v.TagGPGSign.SetTextStyle(val) } + b.WriteString(v.TagGPGSign.View()) b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")).Bold(true).Render("Environment variables")) + b.WriteString(v.Section.Bold(true).Render("Environment variables")) b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C")).Render("One KEY=VALUE per line; '#' comments allowed")) + b.WriteString(v.Subtext.Render("One KEY=VALUE per line; '#' comments allowed")) b.WriteString("\n") b.WriteString(v.EnvVars.View()) b.WriteString("\n\n") - saveLbl := "Save" - cancelLbl := "Cancel" - if v.Focused == 10 { - saveLbl = lipgloss.NewStyle().Bold(true).Render("Save") - } - if v.Focused == 11 { - cancelLbl = lipgloss.NewStyle().Bold(true).Render("Cancel") - } - b.WriteString(" " + saveLbl + " " + cancelLbl) + b.WriteString(v.Buttons.View()) b.WriteString("\n\n") + return b.String() } diff --git a/internal/adapters/handlers/tui/v2/views/projects_view.go b/internal/adapters/handlers/tui/v2/views/projects_view.go index f811a65..ebfec2d 100644 --- a/internal/adapters/handlers/tui/v2/views/projects_view.go +++ b/internal/adapters/handlers/tui/v2/views/projects_view.go @@ -12,6 +12,40 @@ import ( type projectsLoadedMsg struct{ Items []components.ListItem } +type ProjectsViewStyles struct { + Title lipgloss.Style + Item lipgloss.Style + Selected lipgloss.Style + Marker lipgloss.Color +} + +type ProjectsViewStyleOption func(*ProjectsViewStyles) + +func NewProjectsViewStyles(opts ...ProjectsViewStyleOption) ProjectsViewStyles { + s := ProjectsViewStyles{} + for _, o := range opts { + o(&s) + } + + return s +} + +func WithProjectsTitle(s lipgloss.Style) ProjectsViewStyleOption { + return func(ps *ProjectsViewStyles) { ps.Title = s } +} + +func WithProjectsItem(s lipgloss.Style) ProjectsViewStyleOption { + return func(ps *ProjectsViewStyles) { ps.Item = s } +} + +func WithProjectsSelected(s lipgloss.Style) ProjectsViewStyleOption { + return func(ps *ProjectsViewStyles) { ps.Selected = s } +} + +func WithProjectsMarker(c lipgloss.Color) ProjectsViewStyleOption { + return func(ps *ProjectsViewStyles) { ps.Marker = c } +} + type ProjectsView struct { List components.List TitleStyle lipgloss.Style @@ -21,13 +55,12 @@ type ProjectsView struct { func NewProjectsView( fetch func() ([]components.ListItem, error), - title, item, sel lipgloss.Style, - marker lipgloss.Color, + styles ProjectsViewStyles, ) ProjectsView { - l := components.NewList(nil, item, sel). - WithMarkerColor(func(_ components.ListItem) lipgloss.Color { return marker }) + l := components.NewList(nil, styles.Item, styles.Selected). + WithMarkerColor(func(_ components.ListItem) lipgloss.Color { return styles.Marker }) - return ProjectsView{List: l, TitleStyle: title, MarkerColor: marker, fetch: fetch} + return ProjectsView{List: l, TitleStyle: styles.Title, MarkerColor: styles.Marker, fetch: fetch} } func (v ProjectsView) Init() tea.Cmd { diff --git a/internal/adapters/handlers/tui/v2/views/views_compile_test.go b/internal/adapters/handlers/tui/v2/views/views_compile_test.go deleted file mode 100644 index 0ea398f..0000000 --- a/internal/adapters/handlers/tui/v2/views/views_compile_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package views - -import ( - "testing" - - "github.com/charmbracelet/lipgloss" - - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/components" -) - -func TestProjectsViewCompile(_ *testing.T) { - _ = NewProjectsView(nil, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.Color("240")) -} - -func TestProjectFormViewCompile(_ *testing.T) { - _ = NewProjectFormView( - "", - "", - lipgloss.NewStyle(), - lipgloss.NewStyle(), - lipgloss.NewStyle(), - lipgloss.NewStyle(), - lipgloss.NewStyle(), - lipgloss.NewStyle(), - ) -} - -func TestEnvVarsViewCompile(_ *testing.T) { - items := []components.ListItem{{ID: "__add__", Label: "+ Add environment"}} - _ = NewEnvVarsView(items, lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.Color("240")) -} - -func TestEnvFormViewCompile(_ *testing.T) { - _ = NewEnvFormView("", "", "", "file", "", lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()) -} - -func TestPostCreateViewCompile(_ *testing.T) { - _ = NewPostCreateView("proj", lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()) -} diff --git a/internal/adapters/handlers/tui/v2/window.go b/internal/adapters/handlers/tui/v2/window.go index 195d31d..5378b11 100644 --- a/internal/adapters/handlers/tui/v2/window.go +++ b/internal/adapters/handlers/tui/v2/window.go @@ -22,157 +22,226 @@ import ( ) var currentPalette = map[string]string{ - "title": "#88C0D0", - "section": "#81A1C1", - "subtext": "#7C818C", - "text": "#D8DEE9", - "placeholder": "#7C818C", - "border": "#4C566A", - "error": "#BF616A", - "buttonDefFg": "#2E3440", - "buttonDefBg": "#4C566A", - "buttonSelFg": "#2E3440", - "buttonSelBg": "#A3BE8C", - "selectedFg": "#ECEFF4", - "selectedBg": "#5E81AC", - "help": "#7C818C", + "title": "#88C0D0", + "section": "#81A1C1", + "subtext": "#7C818C", + "text": "#D8DEE9", + "placeholder": "#7C818C", + "border": "#4C566A", + "error": "#BF616A", + "buttonDefFg": "#2E3440", + "buttonDefBg": "#4C566A", + "buttonSelFg": "#2E3440", + "buttonSelBg": "#A3BE8C", + "buttonSuccessFg": "#2E3440", + "buttonSuccessBg": "#A3BE8C", + "buttonInfoFg": "#2E3440", + "buttonInfoBg": "#81A1C1", + "buttonWarningFg": "#2E3440", + "buttonWarningBg": "#EBCB8B", + "buttonDangerFg": "#2E3440", + "buttonDangerBg": "#BF616A", + "selectedFg": "#ECEFF4", + "selectedBg": "#5E81AC", + "help": "#7C818C", } var presetPalettes = map[string]map[string]string{ "nord": { - "title": "#88C0D0", - "section": "#81A1C1", - "subtext": "#7C818C", - "text": "#D8DEE9", - "placeholder": "#7C818C", - "border": "#4C566A", - "error": "#BF616A", - "buttonDefFg": "#2E3440", - "buttonDefBg": "#4C566A", - "buttonSelFg": "#2E3440", - "buttonSelBg": "#A3BE8C", - "selectedFg": "#ECEFF4", - "selectedBg": "#5E81AC", - "help": "#7C818C", + "title": "#88C0D0", + "section": "#81A1C1", + "subtext": "#7C818C", + "text": "#D8DEE9", + "placeholder": "#7C818C", + "border": "#4C566A", + "error": "#BF616A", + "buttonDefFg": "#2E3440", + "buttonDefBg": "#4C566A", + "buttonSelFg": "#2E3440", + "buttonSelBg": "#A3BE8C", + "buttonSuccessFg": "#2E3440", + "buttonSuccessBg": "#A3BE8C", + "buttonInfoFg": "#2E3440", + "buttonInfoBg": "#81A1C1", + "buttonWarningFg": "#2E3440", + "buttonWarningBg": "#EBCB8B", + "buttonDangerFg": "#2E3440", + "buttonDangerBg": "#BF616A", + "selectedFg": "#ECEFF4", + "selectedBg": "#5E81AC", + "help": "#7C818C", + }, + "nord-light": { + "title": "#5E81AC", + "section": "#81A1C1", + "subtext": "#4C566A", + "text": "#2E3440", + "placeholder": "#7C818C", + "border": "#D8DEE9", + "error": "#BF616A", + "buttonDefFg": "#2E3440", + "buttonDefBg": "#D8DEE9", + "buttonSelFg": "#2E3440", + "buttonSelBg": "#A3BE8C", + "buttonSuccessFg": "#2E3440", + "buttonSuccessBg": "#A3BE8C", + "buttonInfoFg": "#2E3440", + "buttonInfoBg": "#5E81AC", + "buttonWarningFg": "#2E3440", + "buttonWarningBg": "#EBCB8B", + "buttonDangerFg": "#2E3440", + "buttonDangerBg": "#BF616A", + "selectedFg": "#2E3440", + "selectedBg": "#88C0D0", + "help": "#6C6F7D", }, "catppuccin": { - "title": "#89B4FA", - "section": "#B4BEFE", - "subtext": "#A6ADC8", - "text": "#CDD6F4", - "placeholder": "#6C7086", - "border": "#585B70", - "error": "#F38BA8", - "buttonDefFg": "#1E1E2E", - "buttonDefBg": "#585B70", - "buttonSelFg": "#1E1E2E", - "buttonSelBg": "#A6E3A1", - "selectedFg": "#CDD6F4", - "selectedBg": "#89B4FA", - "help": "#A6ADC8", + "title": "#89B4FA", + "section": "#B4BEFE", + "subtext": "#A6ADC8", + "text": "#CDD6F4", + "placeholder": "#6C7086", + "border": "#585B70", + "error": "#F38BA8", + "buttonDefFg": "#1E1E2E", + "buttonDefBg": "#585B70", + "buttonSelFg": "#1E1E2E", + "buttonSelBg": "#A6E3A1", + "buttonSuccessFg": "#1E1E2E", + "buttonSuccessBg": "#A6E3A1", + "buttonInfoFg": "#1E1E2E", + "buttonInfoBg": "#89B4FA", + "buttonWarningFg": "#1E1E2E", + "buttonWarningBg": "#FAB387", + "buttonDangerFg": "#1E1E2E", + "buttonDangerBg": "#F38BA8", + "selectedFg": "#CDD6F4", + "selectedBg": "#89B4FA", + "help": "#A6ADC8", + }, + "catppuccin-light": { + "title": "#1E66F5", + "section": "#7287FD", + "subtext": "#6C6F85", + "text": "#4C4F69", + "placeholder": "#9CA0B0", + "border": "#BCC0CC", + "error": "#D20F39", + "buttonDefFg": "#4C4F69", + "buttonDefBg": "#BCC0CC", + "buttonSelFg": "#4C4F69", + "buttonSelBg": "#40A02B", + "buttonSuccessFg": "#4C4F69", + "buttonSuccessBg": "#40A02B", + "buttonInfoFg": "#4C4F69", + "buttonInfoBg": "#8CAAEE", + "buttonWarningFg": "#4C4F69", + "buttonWarningBg": "#DF8E1D", + "buttonDangerFg": "#4C4F69", + "buttonDangerBg": "#D20F39", + "selectedFg": "#4C4F69", + "selectedBg": "#8CAAEE", + "help": "#6C6F85", }, "dracula": { - "title": "#BD93F9", - "section": "#8BE9FD", - "subtext": "#6272A4", - "text": "#F8F8F2", - "placeholder": "#6272A4", - "border": "#44475A", - "error": "#FF5555", - "buttonDefFg": "#282A36", - "buttonDefBg": "#44475A", - "buttonSelFg": "#282A36", - "buttonSelBg": "#50FA7B", - "selectedFg": "#F8F8F2", - "selectedBg": "#6272A4", - "help": "#6272A4", + "title": "#BD93F9", + "section": "#8BE9FD", + "subtext": "#6272A4", + "text": "#F8F8F2", + "placeholder": "#6272A4", + "border": "#44475A", + "error": "#FF5555", + "buttonDefFg": "#282A36", + "buttonDefBg": "#44475A", + "buttonSelFg": "#282A36", + "buttonSelBg": "#50FA7B", + "buttonSuccessFg": "#282A36", + "buttonSuccessBg": "#50FA7B", + "buttonInfoFg": "#282A36", + "buttonInfoBg": "#8BE9FD", + "buttonWarningFg": "#282A36", + "buttonWarningBg": "#F1FA8C", + "buttonDangerFg": "#282A36", + "buttonDangerBg": "#FF5555", + "selectedFg": "#F8F8F2", + "selectedBg": "#6272A4", + "help": "#6272A4", + }, + "dracula-light": { + "title": "#6272A4", + "section": "#8BE9FD", + "subtext": "#6D7086", + "text": "#282A36", + "placeholder": "#A0A0A0", + "border": "#E5E5E5", + "error": "#FF5555", + "buttonDefFg": "#282A36", + "buttonDefBg": "#E5E5E5", + "buttonSelFg": "#282A36", + "buttonSelBg": "#50FA7B", + "buttonSuccessFg": "#282A36", + "buttonSuccessBg": "#50FA7B", + "buttonInfoFg": "#282A36", + "buttonInfoBg": "#8BE9FD", + "buttonWarningFg": "#282A36", + "buttonWarningBg": "#F1FA8C", + "buttonDangerFg": "#282A36", + "buttonDangerBg": "#FF5555", + "selectedFg": "#282A36", + "selectedBg": "#8BE9FD", + "help": "#6D7086", }, "ayu": { - "title": "#59C2FF", - "section": "#D4BFFF", - "subtext": "#5C6773", - "text": "#CBCCC6", - "placeholder": "#5C6773", - "border": "#3D4754", - "error": "#D95757", - "buttonDefFg": "#1F2430", - "buttonDefBg": "#3D4754", - "buttonSelFg": "#1F2430", - "buttonSelBg": "#AAD94C", - "selectedFg": "#CBCCC6", - "selectedBg": "#59C2FF", - "help": "#5C6773", + "title": "#59C2FF", + "section": "#D4BFFF", + "subtext": "#5C6773", + "text": "#CBCCC6", + "placeholder": "#5C6773", + "border": "#3D4754", + "error": "#D95757", + "buttonDefFg": "#1F2430", + "buttonDefBg": "#3D4754", + "buttonSelFg": "#1F2430", + "buttonSelBg": "#AAD94C", + "buttonSuccessFg": "#1F2430", + "buttonSuccessBg": "#AAD94C", + "buttonInfoFg": "#1F2430", + "buttonInfoBg": "#59C2FF", + "buttonWarningFg": "#1F2430", + "buttonWarningBg": "#FFCC66", + "buttonDangerFg": "#1F2430", + "buttonDangerBg": "#D95757", + "selectedFg": "#CBCCC6", + "selectedBg": "#59C2FF", + "help": "#5C6773", + }, + "ayu-light": { + "title": "#55B4D4", + "section": "#D4BFFF", + "subtext": "#8A9199", + "text": "#5C6773", + "placeholder": "#9AA5B1", + "border": "#E6E9EF", + "error": "#F07178", + "buttonDefFg": "#5C6773", + "buttonDefBg": "#E6E9EF", + "buttonSelFg": "#1F2430", + "buttonSelBg": "#AAD94C", + "buttonSuccessFg": "#1F2430", + "buttonSuccessBg": "#AAD94C", + "buttonInfoFg": "#1F2430", + "buttonInfoBg": "#8CAAEE", + "buttonWarningFg": "#1F2430", + "buttonWarningBg": "#FFCC66", + "buttonDangerFg": "#1F2430", + "buttonDangerBg": "#F07178", + "selectedFg": "#5C6773", + "selectedBg": "#FFCC66", + "help": "#8A9199", }, } func init() { - presetPalettes["nord-light"] = map[string]string{ - "title": "#5E81AC", - "section": "#81A1C1", - "subtext": "#4C566A", - "text": "#2E3440", - "placeholder": "#7C818C", - "border": "#D8DEE9", - "error": "#BF616A", - "buttonDefFg": "#2E3440", - "buttonDefBg": "#D8DEE9", - "buttonSelFg": "#2E3440", - "buttonSelBg": "#A3BE8C", - "selectedFg": "#2E3440", - "selectedBg": "#88C0D0", - "help": "#6C6F7D", - } - - presetPalettes["catppuccin-light"] = map[string]string{ - "title": "#1E66F5", - "section": "#7287FD", - "subtext": "#6C6F85", - "text": "#4C4F69", - "placeholder": "#9CA0B0", - "border": "#BCC0CC", - "error": "#D20F39", - "buttonDefFg": "#4C4F69", - "buttonDefBg": "#BCC0CC", - "buttonSelFg": "#4C4F69", - "buttonSelBg": "#40A02B", - "selectedFg": "#4C4F69", - "selectedBg": "#8CAAEE", - "help": "#6C6F85", - } - - presetPalettes["dracula-light"] = map[string]string{ - "title": "#6272A4", - "section": "#8BE9FD", - "subtext": "#6D7086", - "text": "#282A36", - "placeholder": "#A0A0A0", - "border": "#E5E5E5", - "error": "#FF5555", - "buttonDefFg": "#282A36", - "buttonDefBg": "#E5E5E5", - "buttonSelFg": "#282A36", - "buttonSelBg": "#50FA7B", - "selectedFg": "#282A36", - "selectedBg": "#8BE9FD", - "help": "#6D7086", - } - - presetPalettes["ayu-light"] = map[string]string{ - "title": "#55B4D4", - "section": "#D4BFFF", - "subtext": "#8A9199", - "text": "#5C6773", - "placeholder": "#9AA5B1", - "border": "#E6E9EF", - "error": "#F07178", - "buttonDefFg": "#5C6773", - "buttonDefBg": "#E6E9EF", - "buttonSelFg": "#1F2430", - "buttonSelBg": "#AAD94C", - "selectedFg": "#5C6773", - "selectedBg": "#FFCC66", - "help": "#8A9199", - } } func setPaletteByName(name string) { @@ -183,6 +252,26 @@ func setPaletteByName(name string) { } } +func (m *Window) buildProjectFormStyles() views.ProjectFormViewStyles { + return views.NewProjectFormViewStyles( + views.WithProjectFormTitle(m.theme.Title), + views.WithProjectFormSection(lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["section"]))), + views.WithProjectFormSubtext(lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["subtext"]))), + views.WithProjectFormAccent(lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["error"]))), + views.WithProjectFormInput(m.cs.ListItem), + views.WithProjectFormInputVal(m.cs.ListItem), + views.WithProjectFormBtnPrimary(m.cs.ButtonPrimary), + views.WithProjectFormBtnSecondary(m.cs.ButtonSecondary), + views.WithProjectFormBtnPrimaryFocused( + m.cs.ButtonPrimary.Background(lipgloss.Color(currentPalette["selectedBg"])), + ), + views.WithProjectFormBtnSecondaryFocused( + m.cs.ButtonSecondary.Background(lipgloss.Color(currentPalette["selectedBg"])), + ), + views.WithProjectFormBtnDisabled(m.cs.ButtonDisabled), + ) +} + type Options struct { Theme string Overrides map[string]string @@ -224,11 +313,11 @@ type Window struct { projects []*domain.Project selectedProject *domain.Project + selectedEnv string - cursor int - cursorEnv int - total int - width int + cursor int + total int + width int height int shouldQuit bool @@ -315,12 +404,16 @@ func NewWindow(projectSvc *services.ProjectService, opts Options) (*Window, erro return items, nil } + projStyles := views.NewProjectsViewStyles( + views.WithProjectsTitle(w.theme.Title), + views.WithProjectsItem(w.cs.ListItem), + views.WithProjectsSelected(w.cs.ListSelected), + views.WithProjectsMarker(lipgloss.Color(currentPalette["selectedBg"])), + ) + w.projectsView = views.NewProjectsView( fetch, - w.theme.Title, - w.cs.ListItem, - w.cs.ListSelected, - lipgloss.Color(currentPalette["selectedBg"]), + projStyles, ) if pv, ok := w.projectsView.(views.ProjectsView); ok { @@ -346,30 +439,17 @@ func (m Window) Init() tea.Cmd { func (m *Window) SelectedProject() *domain.Project { return m.selectedProject } func (m *Window) SelectedEnvironment() string { - if len(m.projects) == 0 { - return "" - } - - idx := mod(m.cursor, m.total) - if idx < 0 || idx >= len(m.projects) { - return "" - } - - p := m.projects[idx] - if len(p.Environments) == 0 { + if m.selectedProject == nil { return "" } - e := m.cursorEnv % len(p.Environments) - if e < 0 { - e = 0 + if strings.TrimSpace(m.selectedEnv) != "" { + return m.selectedEnv } - return p.Environments[e].Name + return strings.TrimSpace(m.selectedProject.DefaultEnv) } -func mod(a, b int) int { return (a%b + b) % b } - func parseEnvLines(s string) domain.EnvVars { res := domain.EnvVars{} @@ -423,15 +503,12 @@ func normalizeColorInput(s string) string { } func (m *Window) newProjectFlow() { + projFormStyles := m.buildProjectFormStyles() + m.form = views.NewProjectFormView( "", "", - m.theme.Title, - m.cs.ListItem, - m.cs.ListItem, - m.cs.ListSelected, - m.cs.ButtonPrimary, - m.cs.ButtonSecondary, + projFormStyles, ) m.mode = 1 @@ -441,7 +518,17 @@ func (m *Window) newProjectFlow() { } func (m *Window) newEnvironmentFlow(projectName string) { - m.envForm = views.NewEnvFormView("", "", "", "merge", "", m.theme.Title, m.cs.ListItem, m.cs.ButtonPrimary, m.cs.ButtonSecondary) + m.envForm = views.NewEnvFormView( + "", + "", + "", + "merge", + "", + m.theme.Title, + m.cs.ListItem, + m.cs.ButtonPrimary, + m.cs.ButtonSecondary, + ) m.envProjectName = projectName m.mode = 3 @@ -482,10 +569,12 @@ func (m *Window) currentView() tea.Model { m.envVarsView = views.NewEnvVarsView( items, - m.theme.Title, - m.cs.ListItem, - m.cs.ListSelected, - lipgloss.Color(currentPalette["selectedBg"]), + views.NewEnvVarsViewStyles( + views.WithEnvVarsTitle(m.theme.Title), + views.WithEnvVarsItem(m.cs.ListItem), + views.WithEnvVarsSelected(m.cs.ListSelected), + views.WithEnvVarsMarker(lipgloss.Color(currentPalette["selectedBg"])), + ), ) return m.envVarsView @@ -692,9 +781,11 @@ func (m Window) View() string { sepLen := 80 sep := lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["border"])) + pad := "\n\n" if m.mode == 1 || m.mode == 3 { pad = "\n\n\n" + if m.mode == 3 { if f, ok := m.envForm.(*views.EnvFormView); ok { if strings.TrimSpace(f.Err) != "" { @@ -704,7 +795,15 @@ func (m Window) View() string { } } - content = strings.TrimRight(content, "\n") + pad + sep.Render(strings.Repeat("─", sepLen)) + "\n" + m.help.View(m.fkeys) + content = strings.TrimRight( + content, + "\n", + ) + pad + sep.Render( + strings.Repeat("─", sepLen), + ) + "\n" + m.help.View( + m.fkeys, + ) + return content } @@ -720,17 +819,24 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.reduce(msg) + if m.shouldQuit { + return m, tea.Quit + } + if km, ok := msg.(tea.KeyMsg); ok { if km.Type == tea.KeyRunes { r := string(km.Runes) if r == "?" { m.help.ShowAll = !m.help.ShowAll hh := lipgloss.Height(m.help.View(m.keys)) + vh := m.height - hh if vh < 1 { vh = 1 } + m.vp.Height = vh + return m, nil } } @@ -763,47 +869,11 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyLeft: m.focus = 0 - case tea.KeyTab: - if m.focus == 0 { - allow := true - if pv, ok := m.projectsView.(views.ProjectsView); ok { - idx := pv.List.Cursor - if idx >= 0 && idx < len(pv.List.Items) { - it := pv.List.Items[idx] - if it.ID == "__new__" { - allow = false - } - } - } - if allow { - m.focus = 1 - } - } else { - m.focus = 0 - } - case tea.KeyShiftTab: - if m.focus == 1 { - m.focus = 0 - } else { - allow := true - if pv, ok := m.projectsView.(views.ProjectsView); ok { - idx := pv.List.Cursor - if idx >= 0 && idx < len(pv.List.Items) { - it := pv.List.Items[idx] - if it.ID == "__new__" { - allow = false - } - } - } - if allow { - m.focus = 1 - } - } } if km.Type == tea.KeyRunes { r := string(km.Runes) - if r == "l" || r == "]" { + if r == "l" { if pv, ok := m.projectsView.(views.ProjectsView); ok { idx := pv.List.Cursor if idx >= 0 && idx < len(pv.List.Items) { @@ -815,7 +885,7 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - if r == "h" || r == "[" { + if r == "h" { m.focus = 0 } } @@ -844,10 +914,12 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { items = append(items, components.ListItem{ID: "__add__", Label: "+ New environment"}) m.envVarsView = views.NewEnvVarsView( items, - m.theme.Title, - m.cs.ListItem, - m.cs.ListSelected, - lipgloss.Color(currentPalette["selectedBg"]), + views.NewEnvVarsViewStyles( + views.WithEnvVarsTitle(m.theme.Title), + views.WithEnvVarsItem(m.cs.ListItem), + views.WithEnvVarsSelected(m.cs.ListSelected), + views.WithEnvVarsMarker(lipgloss.Color(currentPalette["selectedBg"])), + ), ) } } @@ -925,13 +997,16 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { proj.Shell = strings.TrimSpace(msg.Shell) _ = m.projectSvc.UpdateProject(proj) } + m.mode = 0 if m.r != nil { m.r.NavigateTo(router.RouteProjects, nil) } + if m.projectsView != nil { return m, tea.Batch(cmd, m.projectsView.Init()) } + return m, tea.ClearScreen } @@ -990,7 +1065,8 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - m.form = views.NewProjectFormView(project.Name, project.Path, m.theme.Title, m.cs.ListItem, m.cs.ListItem, m.cs.ListSelected, m.cs.ButtonPrimary, m.cs.ButtonSecondary) + projFormStyles := m.buildProjectFormStyles() + m.form = views.NewProjectFormView(project.Name, project.Path, projFormStyles) m.mode = 1 if m.r != nil { @@ -1015,6 +1091,7 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if pname == "" && m.r != nil && m.r.Params() != nil { pname = m.r.Params()["project"] } + if pname == "" { if pv, ok := m.projectsView.(views.ProjectsView); ok { idx := pv.List.Cursor @@ -1188,6 +1265,7 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectedProject = nil return m, tea.Quit } + return m, nil } } @@ -1199,9 +1277,11 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.form = nil m.envForm = nil m.r.NavigateTo(router.RouteProjects, nil) + if m.projectsView != nil { return m, tea.Batch(cmd, m.projectsView.Init()) } + return m, tea.ClearScreen } @@ -1225,12 +1305,37 @@ func (m *Window) reduce(msg tea.Msg) { m.r.Back() } case state.ExitMsg: - if m.r != nil && m.r.Current() == router.RouteEnvVars { - if m.envProjectName != "" { - proj, _ := m.projectSvc.Load(m.envProjectName) - m.selectedProject = proj + if m.r != nil { + if m.r.Current() == router.RouteEnvVars || (m.r.Current() == router.RouteProjects && m.focus == 1) { + pname := m.envProjectName + if pname == "" { + if pv, ok := m.projectsView.(views.ProjectsView); ok { + idx := pv.List.Cursor + if idx >= 0 && idx < len(pv.List.Items) { + it := pv.List.Items[idx] + if it.ID != "__new__" { + pname = it.ID + } + } + } + } + + if pname != "" { + proj, _ := m.projectSvc.Load(pname) + m.selectedProject = proj + } + + if ev, ok := m.envVarsView.(views.EnvVarsView); ok { + if len(ev.List.Items) > 0 { + it := ev.List.Items[ev.List.Cursor] + if it.ID != "__add__" { + m.selectedEnv = it.ID + } + } + } } } + m.shouldQuit = true } } diff --git a/tests/integration/common.go b/tests/integration/common.go new file mode 100644 index 0000000..e1d8d7b --- /dev/null +++ b/tests/integration/common.go @@ -0,0 +1,101 @@ +//go:build integration +// +build integration + +package integration_test + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "go.uber.org/mock/gomock" + + v1 "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v1" + v2 "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2" + "github.com/jlrosende/project-manager/internal/core/domain" + "github.com/jlrosende/project-manager/internal/core/ports" + "github.com/jlrosende/project-manager/internal/core/services" + "github.com/jlrosende/project-manager/mocks" +) + +func buildService(t *testing.T) ports.ProjectService { + ctrl := gomock.NewController(t) + + mockRepo := mocks.NewMockProjectRepository(ctrl) + mockEnv := mocks.NewMockEnvVarsRepository(ctrl) + mockGit := mocks.NewMockGitRepository(ctrl) + + p1 := &domain.Project{ + Name: "INDITEX", + Path: "/tmp/inditex", + DefaultEnv: "dev", + Environments: []*domain.Environment{ + {Name: "dev", Color: "240", EnvVarsFile: ".env.dev"}, + {Name: "pre", Color: "240", EnvVarsFile: ".env.pre"}, + {Name: "pro", Color: "240", EnvVarsFile: ".env.pro"}, + }, + } + p2 := &domain.Project{Name: "Mahou"} + p3 := &domain.Project{Name: "Accenture"} + p4 := &domain.Project{Name: "Personal"} + p5 := &domain.Project{Name: "test"} + projects := []*domain.Project{p1, p2, p3, p4, p5} + + mockRepo.EXPECT().List().Return(projects, nil).AnyTimes() + mockRepo.EXPECT().AddEnvironment(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockRepo.EXPECT().UpdateEnvironment(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockRepo.EXPECT().UpdateProject(gomock.Any()).Return(nil).AnyTimes() + mockEnv.EXPECT().Load(gomock.Any()).Return(domain.EnvVars{}, nil).AnyTimes() + mockGit.EXPECT().Load(gomock.Any()).Return(&domain.GitConfig{}, nil).AnyTimes() + + return services.NewProjectService(mockRepo, mockEnv, mockGit) +} + +func normalize(s string) string { + lines := strings.Split(s, "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + + return strings.Join(lines, "\n") +} + +func renderV1(t *testing.T, svc ports.ProjectService, width int, focusRight bool) string { + t.Helper() + + w, err := v1.NewWindow(svc.(*services.ProjectService), v1.Options{}) + if err != nil { + t.Fatalf("v1 window: %v", err) + } + + w.Update(tea.WindowSizeMsg{Width: width, Height: 24}) + + if focusRight { + w.Update(tea.KeyMsg{Type: tea.KeyRight}) + } + + return w.View() +} + +func renderV2(t *testing.T, svc ports.ProjectService, width int, focusRight bool) string { + t.Helper() + + w, err := v2.NewWindow(svc.(*services.ProjectService), v2.Options{}) + if err != nil { + t.Fatalf("v2 window: %v", err) + } + + cmd := w.Init() + if cmd != nil { + msg := cmd() + w.Update(msg) + } + + w.Update(tea.WindowSizeMsg{Width: width, Height: 24}) + + if focusRight { + w.Update(tea.KeyMsg{Type: tea.KeyRight}) + } + + return w.View() +} diff --git a/tests/integration/selected_enter_test.go b/tests/integration/selected_enter_test.go new file mode 100644 index 0000000..0b1b937 --- /dev/null +++ b/tests/integration/selected_enter_test.go @@ -0,0 +1,121 @@ +//go:build integration +// +build integration + +package integration_test + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + v2 "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2" + "github.com/jlrosende/project-manager/internal/core/services" +) + +func TestSelectProjectWithDefaultOnEnter(t *testing.T) { + svc := buildService(t) + + w, err := v2.NewWindow(svc.(*services.ProjectService), v2.Options{}) + if err != nil { + t.Fatalf("v2 window: %v", err) + } + + if cmd := w.Init(); cmd != nil { + if msg := cmd(); msg != nil { + _, _ = w.Update(msg) + } + } + + _, _ = w.Update(tea.WindowSizeMsg{Width: 49, Height: 24}) + + msgs := []tea.Msg{tea.KeyMsg{Type: tea.KeyEnter}} + for _, m := range msgs { + _, cmd := w.Update(m) + if cmd != nil { + if msg := cmd(); msg != nil { + _, _ = w.Update(msg) + } + } + } + + sp := w.SelectedProject() + if sp == nil || sp.Name != "INDITEX" { + t.Fatalf("expected selected project 'INDITEX'; got %#v", sp) + } + + if env := w.SelectedEnvironment(); env != "dev" { + t.Fatalf("expected selected environment 'dev' on default; got %q", env) + } +} + +func TestSelectProjectWithoutDefaultOnEnter(t *testing.T) { + svc := buildService(t) + + w, err := v2.NewWindow(svc.(*services.ProjectService), v2.Options{}) + if err != nil { + t.Fatalf("v2 window: %v", err) + } + + if cmd := w.Init(); cmd != nil { + if msg := cmd(); msg != nil { + _, _ = w.Update(msg) + } + } + + _, _ = w.Update(tea.WindowSizeMsg{Width: 49, Height: 24}) + + msgs := []tea.Msg{tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}} + for _, m := range msgs { + _, cmd := w.Update(m) + if cmd != nil { + if msg := cmd(); msg != nil { + _, _ = w.Update(msg) + } + } + } + + sp := w.SelectedProject() + if sp == nil || sp.Name != "Mahou" { + t.Fatalf("expected selected project 'Mahou'; got %#v", sp) + } + + if env := w.SelectedEnvironment(); env != "" { + t.Fatalf("expected empty environment when no default; got %q", env) + } +} + +func TestSelectEnvironmentOnEnter(t *testing.T) { + svc := buildService(t) + + w, err := v2.NewWindow(svc.(*services.ProjectService), v2.Options{}) + if err != nil { + t.Fatalf("v2 window: %v", err) + } + + if cmd := w.Init(); cmd != nil { + if msg := cmd(); msg != nil { + _, _ = w.Update(msg) + } + } + + _, _ = w.Update(tea.WindowSizeMsg{Width: 49, Height: 24}) + + msgs := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}} + for _, m := range msgs { + _, cmd := w.Update(m) + if cmd != nil { + if msg := cmd(); msg != nil { + _, _ = w.Update(msg) + } + } + } + + sp := w.SelectedProject() + if sp == nil || sp.Name != "INDITEX" { + t.Fatalf("expected selected project 'INDITEX'; got %#v", sp) + } + + if env := w.SelectedEnvironment(); env != "pre" { + t.Fatalf("expected selected environment 'pre'; got %q", env) + } +} diff --git a/tests/integration/view_parity_test.go b/tests/integration/view_parity_test.go.old similarity index 87% rename from tests/integration/view_parity_test.go rename to tests/integration/view_parity_test.go.old index 4fd0645..e9b90b2 100644 --- a/tests/integration/view_parity_test.go +++ b/tests/integration/view_parity_test.go.old @@ -5,100 +5,13 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" - "go.uber.org/mock/gomock" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v1" + v1 "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v1" v2 "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2" - "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/ports" "github.com/jlrosende/project-manager/internal/core/services" - "github.com/jlrosende/project-manager/mocks" ) -func buildService(t *testing.T) ports.ProjectService { - ctrl := gomock.NewController(t) - - mockRepo := mocks.NewMockProjectRepository(ctrl) - mockEnv := mocks.NewMockEnvVarsRepository(ctrl) - mockGit := mocks.NewMockGitRepository(ctrl) - - p1 := &domain.Project{ - Name: "INDITEX", - Path: "/tmp/inditex", - DefaultEnv: "dev", - Environments: []*domain.Environment{ - {Name: "dev", Color: "240", EnvVarsFile: ".env.dev"}, - {Name: "pre", Color: "240", EnvVarsFile: ".env.pre"}, - {Name: "pro", Color: "240", EnvVarsFile: ".env.pro"}, - }, - } - p2 := &domain.Project{Name: "Mahou"} - p3 := &domain.Project{Name: "Accenture"} - p4 := &domain.Project{Name: "Personal"} - p5 := &domain.Project{Name: "test"} - projects := []*domain.Project{p1, p2, p3, p4, p5} - - mockRepo.EXPECT().List().Return(projects, nil).AnyTimes() - mockRepo.EXPECT().AddEnvironment(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() - mockRepo.EXPECT().UpdateEnvironment(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() - mockRepo.EXPECT().UpdateProject(gomock.Any()).Return(nil).AnyTimes() - mockEnv.EXPECT().Load(gomock.Any()).Return(domain.EnvVars{}, nil).AnyTimes() - mockGit.EXPECT().Load(gomock.Any()).Return(&domain.GitConfig{}, nil).AnyTimes() - - return services.NewProjectService(mockRepo, mockEnv, mockGit) -} - -func normalize(s string) string { - lines := strings.Split(s, "\n") - for i := range lines { - lines[i] = strings.TrimRight(lines[i], " ") - } - - return strings.Join(lines, "\n") -} - -func hasANSI(s string) bool { return strings.Contains(s, "\x1b[") } - -func renderV1(t *testing.T, svc ports.ProjectService, width int, focusRight bool) string { - t.Helper() - - w, err := v1.NewWindow(svc.(*services.ProjectService), v1.Options{}) - if err != nil { - t.Fatalf("v1 window: %v", err) - } - - w.Update(tea.WindowSizeMsg{Width: width, Height: 24}) - - if focusRight { - w.Update(tea.KeyMsg{Type: tea.KeyRight}) - } - - return w.View() -} - -func renderV2(t *testing.T, svc ports.ProjectService, width int, focusRight bool) string { - t.Helper() - - w, err := v2.NewWindow(svc.(*services.ProjectService), v2.Options{}) - if err != nil { - t.Fatalf("v2 window: %v", err) - } - - cmd := w.Init() - if cmd != nil { - msg := cmd() - w.Update(msg) - } - - w.Update(tea.WindowSizeMsg{Width: width, Height: 24}) - - if focusRight { - w.Update(tea.KeyMsg{Type: tea.KeyRight}) - } - - return w.View() -} - func TestMainViewParity_LeftFocus(t *testing.T) { svc := buildService(t) v1s := normalize(renderV1(t, svc, 49, false)) @@ -294,7 +207,9 @@ func TestForm_EditProject_ViewParity(t *testing.T) { seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}} v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { t.Fatalf("edit project form view differs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) } + if v1s != v2s { + t.Fatalf("edit project form view differs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } } func TestForm_CreateProject_ViewParity_Width60(t *testing.T) { @@ -438,7 +353,9 @@ func TestForm_CreateProject_TypingUpdatesPath_Parity(t *testing.T) { } v1s := normalize(renderV1Msgs(t, svc, 49, append(open, typing...)...)) v2s := normalize(renderV2Msgs(t, svc, 49, append(open, typing...)...)) - if v1s != v2s { t.Fatalf("typing in name should update path placeholder equally\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) } + if v1s != v2s { + t.Fatalf("typing in name should update path placeholder equally\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) + } } func TestForm_AddEnv_CtrlS(t *testing.T) { @@ -529,9 +446,9 @@ func TestParity_EnvListUpdatesOnProjectChange(t *testing.T) { svc := buildService(t) seq := []tea.Msg{ tea.KeyMsg{Type: tea.KeyRight}, - tea.KeyMsg{Type: tea.KeyDown}, // focus pre - tea.KeyMsg{Type: tea.KeyLeft}, // back to projects - tea.KeyMsg{Type: tea.KeyDown}, // move to Mahou (no envs) + tea.KeyMsg{Type: tea.KeyDown}, // focus pre + tea.KeyMsg{Type: tea.KeyLeft}, // back to projects + tea.KeyMsg{Type: tea.KeyDown}, // move to Mahou (no envs) tea.KeyMsg{Type: tea.KeyRight}, // focus envs tea.KeyMsg{Type: tea.KeyLeft}, tea.KeyMsg{Type: tea.KeyDown}, // Accenture (no envs) @@ -604,24 +521,48 @@ func TestStyled_FormButtons_LinePresent(t *testing.T) { func TestTheme_AppliesAcrossViews_Parity(t *testing.T) { svc := buildService(t) w1, err1 := v1.NewWindow(svc.(*services.ProjectService), v1.Options{Theme: "dracula"}) - if err1 != nil { t.Fatalf("new v1 window: %v", err1) } + if err1 != nil { + t.Fatalf("new v1 window: %v", err1) + } w2, err2 := v2.NewWindow(svc.(*services.ProjectService), v2.Options{Theme: "dracula"}) - if err2 != nil { t.Fatalf("new v2 window: %v", err2) } + if err2 != nil { + t.Fatalf("new v2 window: %v", err2) + } // size - if _, cmd := w1.Update(tea.WindowSizeMsg{Width: 49, Height: 24}); cmd != nil { if m := cmd(); m != nil { w1.Update(m) } } - if _, cmd := w2.Update(tea.WindowSizeMsg{Width: 49, Height: 24}); cmd != nil { if m := cmd(); m != nil { w2.Update(m) } } + if _, cmd := w1.Update(tea.WindowSizeMsg{Width: 49, Height: 24}); cmd != nil { + if m := cmd(); m != nil { + w1.Update(m) + } + } + if _, cmd := w2.Update(tea.WindowSizeMsg{Width: 49, Height: 24}); cmd != nil { + if m := cmd(); m != nil { + w2.Update(m) + } + } mv1 := normalize(w1.View()) mv2 := normalize(w2.View()) - if mv1 != mv2 { t.Fatalf("theme parity mismatch on main\n--- v1 ---\n%s\n--- v2 ---\n%s", mv1, mv2) } + if mv1 != mv2 { + t.Fatalf("theme parity mismatch on main\n--- v1 ---\n%s\n--- v2 ---\n%s", mv1, mv2) + } // open New project form (process returned cmds) seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}} for _, m := range seq { - if _, cmd := w1.Update(m); cmd != nil { if mm := cmd(); mm != nil { w1.Update(mm) } } - if _, cmd := w2.Update(m); cmd != nil { if mm := cmd(); mm != nil { w2.Update(mm) } } + if _, cmd := w1.Update(m); cmd != nil { + if mm := cmd(); mm != nil { + w1.Update(mm) + } + } + if _, cmd := w2.Update(m); cmd != nil { + if mm := cmd(); mm != nil { + w2.Update(mm) + } + } } fv1 := normalize(w1.View()) fv2 := normalize(w2.View()) - if fv1 != fv2 { t.Fatalf("theme parity mismatch on form\n--- v1 ---\n%s\n--- v2 ---\n%s", fv1, fv2) } + if fv1 != fv2 { + t.Fatalf("theme parity mismatch on form\n--- v1 ---\n%s\n--- v2 ---\n%s", fv1, fv2) + } } func TestEnvForm_Navigation_TabShiftEnter_Parity(t *testing.T) { diff --git a/tests/unit/router_compile_test.go b/tests/unit/router_compile_test.go new file mode 100644 index 0000000..d2fa247 --- /dev/null +++ b/tests/unit/router_compile_test.go @@ -0,0 +1,17 @@ +//go:build unit +// +build unit + +package unit_test + +import ( + "testing" + + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/router" +) + +func TestRouterCompile(_ *testing.T) { + r := router.New(router.RouteProjects) + _ = r.Current() + r.NavigateTo(router.RouteProjectForm, nil) + r.Back() +} diff --git a/tests/unit/state_compile_test.go b/tests/unit/state_compile_test.go new file mode 100644 index 0000000..cc5b4b0 --- /dev/null +++ b/tests/unit/state_compile_test.go @@ -0,0 +1,31 @@ +//go:build unit +// +build unit + +package unit_test + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/router" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" +) + +func TestMessagesCompile(_ *testing.T) { + _ = state.NavigateToMsg{Route: router.RouteProjects} + _ = state.BackMsg{} + _ = state.ErrorMsg{} + _ = state.NewProjectMsg{} + _ = state.SelectProjectMsg{ID: "x"} + _ = state.SaveProjectMsg{Name: "a", Path: "b"} + _ = state.CancelMsg{} + _ = state.AddEnvVarMsg{} + _ = state.EditEnvVarMsg{Name: "dev"} + _ = state.RemoveEnvVarMsg{Name: "dev"} + _ = state.SaveEnvVarMsg{Name: "k", Value: "v"} + _ = state.SaveEnvFormMsg{OriginalName: "dev", Name: "dev", Color: "#fff", Mode: "file", EnvVarsRaw: "X=1"} + _ = state.PostCreateChoiceMsg{Choice: 0, ProjectName: "p"} + + var _ tea.Msg = state.NavigateToMsg{} +} From d73fa4cefd19fc53c0af40670fcde22000070b6d Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Sat, 20 Sep 2025 01:27:15 +0200 Subject: [PATCH 16/27] Views and funtionality almost done --- cmd/cli/new/new.go | 12 +- .../adapters/handlers/tui/v1/window_update.go | 2 + .../handlers/tui/v2/components/input.go | 2 +- .../handlers/tui/v2/state/messages.go | 2 + .../handlers/tui/v2/views/env_form_view.go | 309 +++++++++++++++--- .../handlers/tui/v2/views/post_create_view.go | 87 ----- .../tui/v2/views/project_form_view.go | 77 ++++- .../handlers/tui/v2/views/update_flow_view.go | 36 -- internal/adapters/handlers/tui/v2/window.go | 117 ++++++- .../repositories/project_repository.go | 61 +++- internal/core/ports/project_port.go | 4 +- internal/core/services/project_service.go | 4 +- mocks/mock_project_port.go | 16 +- 13 files changed, 512 insertions(+), 217 deletions(-) delete mode 100644 internal/adapters/handlers/tui/v2/views/post_create_view.go delete mode 100644 internal/adapters/handlers/tui/v2/views/update_flow_view.go diff --git a/cmd/cli/new/new.go b/cmd/cli/new/new.go index 487aa78..66c1f92 100644 --- a/cmd/cli/new/new.go +++ b/cmd/cli/new/new.go @@ -92,11 +92,21 @@ func run(cmd *cobra.Command, args []string) error { return err } + // convert map[string]string to domain.EnvVars + dv := domain.EnvVars{} + for k, v := range envVars { + dv[k] = v + } + + // default env vars file + envFile := ".env" + _, err = svc.Create( name, path, subproject, - envVars, + envFile, + dv, domain.New( domain.WithName(gitUserName), domain.WithEmail(gitUserEmail), diff --git a/internal/adapters/handlers/tui/v1/window_update.go b/internal/adapters/handlers/tui/v1/window_update.go index 42357c6..f1cc172 100644 --- a/internal/adapters/handlers/tui/v1/window_update.go +++ b/internal/adapters/handlers/tui/v1/window_update.go @@ -112,10 +112,12 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.ClearScreen } + envFile := ".env" proj, _ := m.projectSvc.Create( name, path, f.subproject.Value(), + envFile, envs, domain.New( domain.WithName(strings.TrimSpace(f.userName.Value())), diff --git a/internal/adapters/handlers/tui/v2/components/input.go b/internal/adapters/handlers/tui/v2/components/input.go index ab0ea89..e6ec940 100644 --- a/internal/adapters/handlers/tui/v2/components/input.go +++ b/internal/adapters/handlers/tui/v2/components/input.go @@ -27,7 +27,7 @@ func NewInput(label, placeholder, value string, s, vs lipgloss.Style) Input { ti := bti.New() ti.Prompt = s.Render(label + ": ") ti.PromptStyle = lipgloss.NewStyle() - ti.PlaceholderStyle = vs + ti.PlaceholderStyle = s ti.TextStyle = vs ti.Placeholder = placeholder ti.SetValue(value) diff --git a/internal/adapters/handlers/tui/v2/state/messages.go b/internal/adapters/handlers/tui/v2/state/messages.go index aa3d2e7..8ee2e10 100644 --- a/internal/adapters/handlers/tui/v2/state/messages.go +++ b/internal/adapters/handlers/tui/v2/state/messages.go @@ -29,6 +29,7 @@ type SaveProjectMsg struct { GitSigningKey string CommitGPGSign string TagGPGSign string + EnvVarsFile string EnvVarsRaw string } @@ -51,6 +52,7 @@ type SaveEnvFormMsg struct { Name string Color string Mode string + EnvVarsFile string EnvVarsRaw string } diff --git a/internal/adapters/handlers/tui/v2/views/env_form_view.go b/internal/adapters/handlers/tui/v2/views/env_form_view.go index 80fa5ef..d97ff08 100644 --- a/internal/adapters/handlers/tui/v2/views/env_form_view.go +++ b/internal/adapters/handlers/tui/v2/views/env_form_view.go @@ -11,52 +11,141 @@ import ( "github.com/jlrosende/project-manager/internal/core/domain" ) +type EnvFormViewStyles struct { + Title lipgloss.Style + Item lipgloss.Style + Section lipgloss.Style + Subtext lipgloss.Style + BtnPrimary lipgloss.Style + BtnSecondary lipgloss.Style + BtnPrimaryFocused lipgloss.Style + BtnSecondaryFocused lipgloss.Style + BtnDisabled lipgloss.Style +} + +type EnvFormViewStyleOption func(*EnvFormViewStyles) + +func NewEnvFormViewStyles(opts ...EnvFormViewStyleOption) EnvFormViewStyles { + s := EnvFormViewStyles{} + for _, o := range opts { + o(&s) + } + + return s +} + +func WithEnvFormTitle(s lipgloss.Style) EnvFormViewStyleOption { + return func(es *EnvFormViewStyles) { es.Title = s } +} + +func WithEnvFormItem(s lipgloss.Style) EnvFormViewStyleOption { + return func(es *EnvFormViewStyles) { es.Item = s } +} + +func WithEnvFormSection(s lipgloss.Style) EnvFormViewStyleOption { + return func(es *EnvFormViewStyles) { es.Section = s } +} + +func WithEnvFormSubtext(s lipgloss.Style) EnvFormViewStyleOption { + return func(es *EnvFormViewStyles) { es.Subtext = s } +} + +func WithEnvFormBtnPrimary(s lipgloss.Style) EnvFormViewStyleOption { + return func(es *EnvFormViewStyles) { es.BtnPrimary = s } +} + +func WithEnvFormBtnSecondary(s lipgloss.Style) EnvFormViewStyleOption { + return func(es *EnvFormViewStyles) { es.BtnSecondary = s } +} + +func WithEnvFormBtnPrimaryFocused(s lipgloss.Style) EnvFormViewStyleOption { + return func(es *EnvFormViewStyles) { es.BtnPrimaryFocused = s } +} + +func WithEnvFormBtnSecondaryFocused(s lipgloss.Style) EnvFormViewStyleOption { + return func(es *EnvFormViewStyles) { es.BtnSecondaryFocused = s } +} + +func WithEnvFormBtnDisabled(s lipgloss.Style) EnvFormViewStyleOption { + return func(es *EnvFormViewStyles) { es.BtnDisabled = s } +} + +const ( + envIdxName = iota + envIdxColor + envIdxEnvFile + envIdxMode + envIdxEnvVars + envIdxBtnSave + envIdxBtnCancel +) + type EnvFormView struct { OriginalName string Name components.Input Color components.Input Mode components.Input + EnvFile components.Input EnvVars components.TextArea SaveBtn components.Button Cancel components.Button Buttons components.ButtonGroup Title lipgloss.Style Item lipgloss.Style + Section lipgloss.Style + Subtext lipgloss.Style BtnPri lipgloss.Style BtnSec lipgloss.Style Focused int Err string } -func NewEnvFormView(orig, name, color, mode, envRaw string, title, item, btnPri, btnSec lipgloss.Style) *EnvFormView { +func NewEnvFormView(orig, name, color, mode, envRaw string, styles EnvFormViewStyles) *EnvFormView { v := &EnvFormView{ OriginalName: orig, - Name: components.NewInput("Env name*", "staging", name, item, item), - Color: components.NewInput("Color (name/#hex/0-255)", "teal", color, item, item), - Mode: components.NewInput("Env vars mode* (merge/replace)", "merge", mode, item, item), + Name: components.NewInput("Env name*", "staging", name, styles.Item, styles.Item), + Color: components.NewInput("Color (name/#hex/0-255)", "teal", color, styles.Item, styles.Item), + Mode: components.NewInput("Env vars mode* (merge/replace)", "merge", mode, styles.Item, styles.Item), + EnvFile: components.NewInput("Env vars file", "..env", "", styles.Item, styles.Item), EnvVars: components.NewTextArea( "# One per line (like .env)\nAPI_URL=https://api.example.com\nLOG_LEVEL=info\n# comments allowed", envRaw, - item, - item, + styles.Item, + styles.Item, + ), + SaveBtn: components.NewButton( + "Save", + components.Primary, + styles.BtnPrimary, + styles.BtnPrimaryFocused, + styles.BtnDisabled, ), - SaveBtn: components.NewButton("Save", components.Primary, btnPri, btnPri.Bold(true).Underline(true), item), - Cancel: components.NewButton("Cancel", components.Secondary, btnSec, btnSec.Bold(true).Underline(true), item), - Title: title, - Item: item, - BtnPri: btnPri, - BtnSec: btnSec, + Cancel: components.NewButton( + "Cancel", + components.Secondary, + styles.BtnSecondary, + styles.BtnSecondaryFocused, + styles.BtnDisabled, + ), + Title: styles.Title, + Item: styles.Item, + Section: styles.Section, + Subtext: styles.Subtext, + BtnPri: styles.BtnPrimary, + BtnSec: styles.BtnSecondary, Focused: 0, } v.Name.SetWidth(60) v.Color.SetWidth(60) v.Mode.SetWidth(60) + v.EnvFile.SetWidth(60) v.EnvVars.SetHeight(6) v.EnvVars.SetWidth(70) v.Buttons = components.NewButtonGroup([]components.Button{v.SaveBtn, v.Cancel}) v.Buttons.Active = false v.Name.Focus() + v.Focused = envIdxName if strings.TrimSpace(orig) == "" { if strings.TrimSpace(v.Color.Value()) == "" { @@ -97,6 +186,7 @@ func (v *EnvFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Name: name, Color: strings.TrimSpace(v.Color.Value()), Mode: mode, + EnvVarsFile: strings.TrimSpace(v.EnvFile.Value()), EnvVarsRaw: v.EnvVars.Value(), } } @@ -105,6 +195,21 @@ func (v *EnvFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.Label == "Cancel" { return v, func() tea.Msg { return state.CancelMsg{} } } + case components.InputSubmitMsg: + f := v.Focused + + f++ + + if f > envIdxBtnCancel { + f = envIdxName + } + + v.blurAll() + v.Focused = f + v.Buttons.Active = v.Focused >= envIdxBtnSave + v.focusCurrent() + + return v, nil case components.ButtonChosenMsg: if m.Label == "Save" { v.Err = "" @@ -127,6 +232,7 @@ func (v *EnvFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Name: name, Color: strings.TrimSpace(v.Color.Value()), Mode: mode, + EnvVarsFile: strings.TrimSpace(v.EnvFile.Value()), EnvVarsRaw: v.EnvVars.Value(), } } @@ -136,10 +242,48 @@ func (v *EnvFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, func() tea.Msg { return state.CancelMsg{} } } case tea.KeyMsg: - if cmd, ok := handleButtonGroupNav(4, 5, &v.Focused, &v.Buttons, m); ok { + if cmd, ok := handleButtonGroupNav(envIdxBtnSave, envIdxBtnCancel, &v.Focused, &v.Buttons, m); ok { + v.blurAll() + v.Buttons.Active = v.Focused >= envIdxBtnSave + v.focusCurrent() + return v, cmd } + if m.Type == tea.KeyTab || m.Type == tea.KeyDown || m.Type == tea.KeyEnter || m.Type == tea.KeyShiftTab || m.Type == tea.KeyUp { + allowed := v.Focused != envIdxEnvVars || (m.Type == tea.KeyTab || m.Type == tea.KeyShiftTab) + if allowed { + f := v.Focused + + if m.Type == tea.KeyShiftTab || m.Type == tea.KeyUp { + f-- + } else { + if m.Type != tea.KeyEnter || f < envIdxEnvVars { + f++ + } + } + + if f < envIdxName { + f = envIdxBtnCancel + } + + if f > envIdxBtnCancel { + f = envIdxName + } + + if m.Type == tea.KeyUp && v.Focused == envIdxEnvVars { + f = v.Focused + } + + v.blurAll() + v.Focused = f + v.Buttons.Active = v.Focused >= envIdxBtnSave + v.focusCurrent() + + return v, nil + } + } + s := m.String() switch s { case "ctrl+s": @@ -163,36 +307,41 @@ func (v *EnvFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Name: name, Color: strings.TrimSpace(v.Color.Value()), Mode: mode, + EnvVarsFile: strings.TrimSpace(v.EnvFile.Value()), EnvVarsRaw: v.EnvVars.Value(), } } - case "tab": - v.Focused = (v.Focused + 1) % 6 - v.blurAll() - v.Buttons.Active = v.Focused >= 4 - v.focusCurrent() - - return v, nil - case "shift+tab": - v.Focused = (v.Focused + 5) % 6 - v.blurAll() - v.Buttons.Active = v.Focused >= 4 - v.focusCurrent() - - return v, nil } } + oldName := strings.TrimSpace(v.Name.Value()) n, nameCmd := v.Name.Update(msg) v.Name = n + newName := strings.TrimSpace(v.Name.Value()) + if newName != oldName { + slug := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(newName, " ", "-"))) + cur := strings.TrimSpace(v.EnvFile.Value()) + oldSlug := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(oldName, " ", "-"))) + if slug == "" { + if cur == "" || cur == "."+oldSlug+".env" { + v.EnvFile.SetValue("") + } + } else { + if cur == "" || cur == "."+oldSlug+".env" { + v.EnvFile.SetValue("."+slug+".env") + } + } + } c, colorCmd := v.Color.Update(msg) v.Color = c mo, modeCmd := v.Mode.Update(msg) v.Mode = mo + ef, efCmd := v.EnvFile.Update(msg) + v.EnvFile = ef e, envCmd := v.EnvVars.Update(msg) v.EnvVars = e - return v, tea.Batch(nameCmd, colorCmd, modeCmd, envCmd) + return v, tea.Batch(nameCmd, colorCmd, modeCmd, efCmd, envCmd) } func (v *EnvFormView) View() string { @@ -205,51 +354,63 @@ func (v *EnvFormView) View() string { b.WriteString(v.Title.Render(title)) b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")).Bold(true).Render("Environment details")) + b.WriteString(v.Section.Bold(true).Render("Environment details")) b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C")).Render("Display and behavior")) + b.WriteString(v.Subtext.Render("Display and behavior")) b.WriteString("\n") { - p := lipgloss.NewStyle() - val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + p := v.Section + val := v.Item v.Name.SetPrompt(p.Render("Env name*") + p.Render(": ")) - v.Name.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) - v.Name.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.Name.SetPromptStyle(v.Section) + v.Name.SetPlaceholderStyle(v.Subtext) v.Name.SetTextStyle(val) } b.WriteString(v.Name.View()) b.WriteString("\n") { - p := lipgloss.NewStyle() - val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + p := v.Section + val := v.Item + cc := normalizeEnvColorInput(strings.TrimSpace(v.Color.Value())) + if cc != "" { + val = val.Foreground(lipgloss.Color(cc)) + } v.Color.SetPrompt(p.Render("Color (name/#hex/0-255): ")) - v.Color.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) - v.Color.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.Color.SetPromptStyle(v.Section) + v.Color.SetPlaceholderStyle(v.Subtext) v.Color.SetTextStyle(val) } b.WriteString(v.Color.View()) b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1")).Bold(true).Render("Environment variables")) + b.WriteString(v.Section.Bold(true).Render("Environment variables")) b.WriteString("\n") { - p := lipgloss.NewStyle() - val := lipgloss.NewStyle().Foreground(lipgloss.Color("#D8DEE9")) + p := v.Section + val := v.Item + v.EnvFile.SetPrompt(p.Render("Env vars file: ")) + v.EnvFile.SetPromptStyle(v.Section) + v.EnvFile.SetPlaceholderStyle(v.Subtext) + v.EnvFile.SetTextStyle(val) + } + b.WriteString(v.EnvFile.View()) + b.WriteString("\n") + { + p := v.Section + val := v.Item v.Mode.SetPrompt(p.Render("Env vars mode* (merge/replace): ")) - v.Mode.SetPromptStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#81A1C1"))) - v.Mode.SetPlaceholderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7C818C"))) + v.Mode.SetPromptStyle(v.Section) + v.Mode.SetPlaceholderStyle(v.Subtext) v.Mode.SetTextStyle(val) } b.WriteString(v.Mode.View()) b.WriteString("\n") b.WriteString( - lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7C818C")). - Render("One KEY=VALUE per line; '#' comments allowed"), + v.Subtext.Render("One KEY=VALUE per line; '#' comments allowed"), ) b.WriteString("\n") b.WriteString(v.EnvVars.View()) @@ -268,22 +429,66 @@ func (v *EnvFormView) blurAll() { v.Name.Blur() v.Color.Blur() v.Mode.Blur() + v.EnvFile.Blur() v.EnvVars.Blur() } func (v *EnvFormView) focusCurrent() { switch v.Focused { - case 0: + case envIdxName: v.Name.Focus() - case 1: + case envIdxColor: v.Color.Focus() - case 2: + case envIdxMode: v.Mode.Focus() - case 3: + case envIdxEnvFile: + v.EnvFile.Focus() + case envIdxEnvVars: v.EnvVars.Focus() - case 4: + case envIdxBtnSave: v.Buttons.Cursor = 0 - case 5: + case envIdxBtnCancel: v.Buttons.Cursor = 1 } } + +var envColorNameMap = map[string]string{ + "black": "0", + "white": "15", + "red": "196", + "green": "46", + "blue": "21", + "yellow": "226", + "magenta": "201", + "purple": "93", + "cyan": "51", + "teal": "30", + "orange": "208", + "pink": "205", + "grey": "240", + "gray": "240", +} + +func normalizeEnvColorInput(s string) string { + ss := strings.ToLower(strings.TrimSpace(s)) + if ss == "" { + return "" + } + if strings.HasPrefix(ss, "#") { + return ss + } + if v, ok := envColorNameMap[ss]; ok { + return v + } + isNum := true + for _, r := range ss { + if r < '0' || r > '9' { + isNum = false + break + } + } + if isNum { + return ss + } + return "" +} diff --git a/internal/adapters/handlers/tui/v2/views/post_create_view.go b/internal/adapters/handlers/tui/v2/views/post_create_view.go deleted file mode 100644 index e28574c..0000000 --- a/internal/adapters/handlers/tui/v2/views/post_create_view.go +++ /dev/null @@ -1,87 +0,0 @@ -package views - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" -) - -type PostCreateView struct { - idx int - projectName string - Title lipgloss.Style - Item lipgloss.Style - Sel lipgloss.Style - Help lipgloss.Style -} - -func NewPostCreateView(projectName string, title, item, sel, help lipgloss.Style) PostCreateView { - return PostCreateView{projectName: projectName, Title: title, Item: item, Sel: sel, Help: help} -} - -func (v PostCreateView) Init() tea.Cmd { return nil } - -func (v PostCreateView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch m := msg.(type) { - case tea.KeyMsg: - switch m.Type { - case tea.KeyUp: - if v.idx > 0 { - v.idx-- - } - case tea.KeyDown: - if v.idx < 2 { - v.idx++ - } - case tea.KeyEnter: - choice := v.idx - return v, func() tea.Msg { return state.PostCreateChoiceMsg{Choice: choice, ProjectName: v.projectName} } - case tea.KeyEsc, tea.KeyCtrlC: - return v, func() tea.Msg { return state.PostCreateChoiceMsg{Choice: 0, ProjectName: v.projectName} } - } - - if m.Type == tea.KeyRunes { - switch string(m.Runes) { - case "k": - if v.idx > 0 { - v.idx-- - } - case "j": - if v.idx < 2 { - v.idx++ - } - case "q": - return v, func() tea.Msg { return state.PostCreateChoiceMsg{Choice: 0, ProjectName: v.projectName} } - } - } - } - - return v, nil -} - -func (v PostCreateView) View() string { - b := strings.Builder{} - b.WriteString(v.Title.Render("Project created. What next?")) - b.WriteString("\n") - - opts := []string{"Return to list", "Start this project", "Exit"} - for i, o := range opts { - if i == v.idx { - b.WriteString("➜ ") - b.WriteString(v.Sel.Render(o)) - } else { - b.WriteString(" ") - b.WriteString(v.Item.Render(o)) - } - - b.WriteString("\n") - } - - help := v.Help.Render(`Navigate: ↑/k ↓/j -Select: Enter Cancel: Esc/q/Ctrl+C`) - - return lipgloss.JoinVertical(lipgloss.Left, b.String(), help) -} diff --git a/internal/adapters/handlers/tui/v2/views/project_form_view.go b/internal/adapters/handlers/tui/v2/views/project_form_view.go index fe33201..d9a461f 100644 --- a/internal/adapters/handlers/tui/v2/views/project_form_view.go +++ b/internal/adapters/handlers/tui/v2/views/project_form_view.go @@ -20,6 +20,7 @@ const ( idxUserSigningKey idxCommitGPGSign idxTagGPGSign + idxEnvFile idxEnvVars idxBtnSave idxBtnCancel @@ -94,6 +95,59 @@ func WithProjectFormAccent(s lipgloss.Style) ProjectFormViewStyleOption { return func(ps *ProjectFormViewStyles) { ps.Accent = s } } +type ProjectFormInit struct { + OriginalName string + Name string + Path string + Subproject string + Shell string + GitUserName string + GitUserEmail string + GitSigningKey string + CommitGPGSign string + TagGPGSign string + EnvVarsFile string + EnvVarsRaw string +} + +func NewProjectFormViewWith(init ProjectFormInit, styles ProjectFormViewStyles) ProjectFormView { + v := NewProjectFormView(init.Name, init.Path, styles) + + if strings.TrimSpace(init.Subproject) != "" { + v.Subproject.SetValue(init.Subproject) + } + if strings.TrimSpace(init.Shell) != "" { + v.Shell.SetValue(init.Shell) + } + if strings.TrimSpace(init.GitUserName) != "" { + v.UserName.SetValue(init.GitUserName) + } + if strings.TrimSpace(init.GitUserEmail) != "" { + v.UserEmail.SetValue(init.GitUserEmail) + } + if strings.TrimSpace(init.GitSigningKey) != "" { + v.UserSigningKey.SetValue(init.GitSigningKey) + } + if strings.TrimSpace(init.CommitGPGSign) != "" { + v.CommitGPGSign.SetValue(init.CommitGPGSign) + } + if strings.TrimSpace(init.TagGPGSign) != "" { + v.TagGPGSign.SetValue(init.TagGPGSign) + } + if strings.TrimSpace(init.EnvVarsFile) != "" { + v.EnvFile.SetValue(init.EnvVarsFile) + } + if strings.TrimSpace(init.EnvVarsRaw) != "" { + v.EnvVars.SetValue(init.EnvVarsRaw) + } + if strings.TrimSpace(init.OriginalName) != "" { + v.IsEdit = true + v.OriginalName = init.OriginalName + } + + return v +} + type ProjectFormView struct { OriginalName string Name components.Input @@ -105,6 +159,7 @@ type ProjectFormView struct { UserSigningKey components.Input CommitGPGSign components.Input TagGPGSign components.Input + EnvFile components.Input EnvVars components.TextArea SaveBtn components.Button Cancel components.Button @@ -146,6 +201,7 @@ func NewProjectFormView( styles.Input, styles.InputVal, ), + EnvFile: components.NewInput("Env vars file", ".env", ".env", styles.Input, styles.InputVal), EnvVars: components.NewTextArea( "# One per line (like .env)\nAPP_ENV=development\nDATABASE_URL=postgres://user:pass@localhost:5432/app\n# comments allowed", "", @@ -187,6 +243,7 @@ func NewProjectFormView( v.UserSigningKey.SetWidth(40) v.CommitGPGSign.SetWidth(40) v.TagGPGSign.SetWidth(40) + v.EnvFile.SetWidth(40) v.EnvVars.SetHeight(6) v.EnvVars.SetWidth(60) @@ -226,6 +283,7 @@ func (v ProjectFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { GitSigningKey: v.UserSigningKey.Value(), CommitGPGSign: v.CommitGPGSign.Value(), TagGPGSign: v.TagGPGSign.Value(), + EnvVarsFile: v.EnvFile.Value(), EnvVarsRaw: v.EnvVars.Value(), } } @@ -305,6 +363,7 @@ func (v ProjectFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { GitSigningKey: v.UserSigningKey.Value(), CommitGPGSign: v.CommitGPGSign.Value(), TagGPGSign: v.TagGPGSign.Value(), + EnvVarsFile: v.EnvFile.Value(), EnvVarsRaw: v.EnvVars.Value(), } } @@ -326,6 +385,7 @@ func (v ProjectFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { GitSigningKey: v.UserSigningKey.Value(), CommitGPGSign: v.CommitGPGSign.Value(), TagGPGSign: v.TagGPGSign.Value(), + EnvVarsFile: v.EnvFile.Value(), EnvVarsRaw: v.EnvVars.Value(), } } @@ -374,10 +434,12 @@ func (v ProjectFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { v.CommitGPGSign = cg tg, tgcmd := v.TagGPGSign.Update(msg) v.TagGPGSign = tg + envf, envfcmd := v.EnvFile.Update(msg) + v.EnvFile = envf e, ecmd := v.EnvVars.Update(msg) v.EnvVars = e - return v, tea.Batch(ncmd, pcmd, scmd, shcmd, ucmd, uemcmd, ukcmd, cgcmd, tgcmd, ecmd) + return v, tea.Batch(ncmd, pcmd, scmd, shcmd, envfcmd, ucmd, uemcmd, ukcmd, cgcmd, tgcmd, ecmd) } func handleButtonGroupNav(base, last int, focused *int, g *components.ButtonGroup, k tea.KeyMsg) (tea.Cmd, bool) { @@ -432,6 +494,7 @@ func (v *ProjectFormView) blurAll() { v.UserSigningKey.Blur() v.CommitGPGSign.Blur() v.TagGPGSign.Blur() + v.EnvFile.Blur() v.EnvVars.Blur() } @@ -457,6 +520,8 @@ func (v *ProjectFormView) focusCurrent() { v.CommitGPGSign.Focus() case idxTagGPGSign: v.TagGPGSign.Focus() + case idxEnvFile: + v.EnvFile.Focus() case idxEnvVars: v.EnvVars.Focus() } @@ -605,6 +670,16 @@ func (v ProjectFormView) View() string { b.WriteString("\n\n") b.WriteString(v.Section.Bold(true).Render("Environment variables")) b.WriteString("\n") + { + p := v.Section + val := v.InputVal + v.EnvFile.SetPrompt(p.Render("Env vars file: ")) + v.EnvFile.SetPromptStyle(v.Section) + v.EnvFile.SetPlaceholderStyle(v.Subtext) + v.EnvFile.SetTextStyle(val) + } + b.WriteString(v.EnvFile.View()) + b.WriteString("\n") b.WriteString(v.Subtext.Render("One KEY=VALUE per line; '#' comments allowed")) b.WriteString("\n") b.WriteString(v.EnvVars.View()) diff --git a/internal/adapters/handlers/tui/v2/views/update_flow_view.go b/internal/adapters/handlers/tui/v2/views/update_flow_view.go deleted file mode 100644 index ec86cc5..0000000 --- a/internal/adapters/handlers/tui/v2/views/update_flow_view.go +++ /dev/null @@ -1,36 +0,0 @@ -package views - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type UpdateFlowView struct { - Title lipgloss.Style - Body lipgloss.Style - Logs []string -} - -func NewUpdateFlowView(title, body lipgloss.Style) UpdateFlowView { - return UpdateFlowView{Title: title, Body: body, Logs: nil} -} - -func (v UpdateFlowView) Init() tea.Cmd { return nil } - -func (v UpdateFlowView) Update(tea.Msg) (tea.Model, tea.Cmd) { return v, nil } - -func (v UpdateFlowView) View() string { - b := strings.Builder{} - b.WriteString(v.Title.Render("Update Flow")) - b.WriteString("\n") - - if len(v.Logs) == 0 { - b.WriteString(v.Body.Render("Update in progress...")) - } else { - b.WriteString(v.Body.Render(strings.Join(v.Logs, "\n"))) - } - - return b.String() -} diff --git a/internal/adapters/handlers/tui/v2/window.go b/internal/adapters/handlers/tui/v2/window.go index 5378b11..571f57d 100644 --- a/internal/adapters/handlers/tui/v2/window.go +++ b/internal/adapters/handlers/tui/v2/window.go @@ -1,6 +1,7 @@ package v2 import ( + "fmt" "os" "path/filepath" "sort" @@ -17,6 +18,7 @@ import ( "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" stylespkg "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/styles" "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/views" + "github.com/jlrosende/project-manager/internal/adapters/repositories" "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/services" ) @@ -259,7 +261,7 @@ func (m *Window) buildProjectFormStyles() views.ProjectFormViewStyles { views.WithProjectFormSubtext(lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["subtext"]))), views.WithProjectFormAccent(lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["error"]))), views.WithProjectFormInput(m.cs.ListItem), - views.WithProjectFormInputVal(m.cs.ListItem), + views.WithProjectFormInputVal(lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["text"]))), views.WithProjectFormBtnPrimary(m.cs.ButtonPrimary), views.WithProjectFormBtnSecondary(m.cs.ButtonSecondary), views.WithProjectFormBtnPrimaryFocused( @@ -272,6 +274,22 @@ func (m *Window) buildProjectFormStyles() views.ProjectFormViewStyles { ) } +func (m *Window) buildEnvFormStyles() views.EnvFormViewStyles { + return views.NewEnvFormViewStyles( + views.WithEnvFormTitle(m.theme.Title), + views.WithEnvFormItem(lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["text"]))), + views.WithEnvFormSection(lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["section"]))), + views.WithEnvFormSubtext(lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["subtext"]))), + views.WithEnvFormBtnPrimary(m.cs.ButtonPrimary), + views.WithEnvFormBtnSecondary(m.cs.ButtonSecondary), + views.WithEnvFormBtnPrimaryFocused( + m.cs.ButtonPrimary.Background(lipgloss.Color(currentPalette["selectedBg"]))), + views.WithEnvFormBtnSecondaryFocused( + m.cs.ButtonSecondary.Background(lipgloss.Color(currentPalette["selectedBg"]))), + views.WithEnvFormBtnDisabled(m.cs.ButtonDisabled), + ) +} + type Options struct { Theme string Overrides map[string]string @@ -518,16 +536,14 @@ func (m *Window) newProjectFlow() { } func (m *Window) newEnvironmentFlow(projectName string) { + styles := m.buildEnvFormStyles() m.envForm = views.NewEnvFormView( "", "", "", "merge", "", - m.theme.Title, - m.cs.ListItem, - m.cs.ButtonPrimary, - m.cs.ButtonSecondary, + styles, ) m.envProjectName = projectName @@ -680,17 +696,17 @@ func (m Window) View() string { marker := " " if selected { - marker = lipgloss.NewStyle().Foreground(lipgloss.Color(e.Color)).Render("β–Œ ") + marker = lipgloss.NewStyle().Foreground(lipgloss.Color(normalizeColorInput(e.Color))).Render("β–Œ ") } var content string if selected { - bullet := lipgloss.NewStyle().Foreground(lipgloss.Color(e.Color)).Render("● ") + bullet := lipgloss.NewStyle().Foreground(lipgloss.Color(normalizeColorInput(e.Color))).Render("● ") name := lipgloss.NewStyle().Foreground(lipgloss.Color(currentPalette["selectedFg"])).Bold(true).Render(e.Name) content = bullet + name } else { - content = m.cs.ListItem.Foreground(lipgloss.Color(e.Color)).Render("● " + e.Name) + content = m.cs.ListItem.Foreground(lipgloss.Color(normalizeColorInput(e.Color))).Render("● " + e.Name) } if p.DefaultEnv != "" && e.Name == p.DefaultEnv { @@ -971,6 +987,7 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } sub := strings.TrimSpace(msg.Subproject) + envFile := strings.TrimSpace(msg.EnvVarsFile) envs := domain.EnvVars{} if strings.TrimSpace(msg.EnvVarsRaw) != "" { @@ -1010,12 +1027,22 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.ClearScreen } - _, _ = m.projectSvc.Create(name, path, sub, envs, gitCfg) - m.prompt = views.NewPostCreateView(name, m.theme.Title, m.cs.ListItem, m.cs.ListSelected, m.theme.Help) + _, _ = m.projectSvc.Create(name, path, sub, envFile, envs, gitCfg) + + if projects, err := m.projectSvc.List(); err == nil { + m.projects = projects + m.total = len(projects) + 1 + } + + m.mode = 0 + m.form = nil - m.mode = 2 if m.r != nil { - m.r.NavigateTo(router.RouteUpdateFlow, nil) + m.r.NavigateTo(router.RouteProjects, nil) + } + + if m.projectsView != nil { + return m, tea.Batch(cmd, m.projectsView.Init()) } return m, tea.ClearScreen @@ -1066,7 +1093,54 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } projFormStyles := m.buildProjectFormStyles() - m.form = views.NewProjectFormView(project.Name, project.Path, projFormStyles) + // Build initial data for edit form + envRaw := "" + envPath := project.EnvVarsFile + if !filepath.IsAbs(envPath) { + envPath = filepath.Join(project.Path, envPath) + } + if b, err := os.ReadFile(envPath); err == nil { + envRaw = string(b) + } else { + pairs := project.EnvVars.ToSlice() + sort.Strings(pairs) + envRaw = strings.Join(pairs, "\n") + } + + gName, gEmail, gKey := "", "", "" + gCommit, gTag := "", "" + if gitRepo, err := repositories.NewGitRepository(); err == nil { + gitSvc := services.NewGitService(gitRepo) + gitPath := filepath.Join(project.Path, fmt.Sprintf(".%s.gitconfig", project.Name)) + if _, err := os.Stat(gitPath); os.IsNotExist(err) { + if matches, _ := filepath.Glob(filepath.Join(project.Path, ".*.gitconfig")); len(matches) > 0 { + gitPath = matches[0] + } + } + if gc, e := gitSvc.Load(gitPath); e == nil { + gName = gc.User.Name + gEmail = gc.User.Email + gKey = gc.User.SigningKey + if gc.Commit.GPGSign { gCommit = "true" } else { gCommit = "false" } + if gc.Tag.GPGSign { gTag = "true" } else { gTag = "false" } + } + } + + init := views.ProjectFormInit{ + OriginalName: project.Name, + Name: project.Name, + Path: project.Path, + Shell: project.Shell, + GitUserName: gName, + GitUserEmail: gEmail, + GitSigningKey: gKey, + CommitGPGSign: gCommit, + TagGPGSign: gTag, + EnvVarsFile: project.EnvVarsFile, + EnvVarsRaw: envRaw, + } + + m.form = views.NewProjectFormViewWith(init, projFormStyles) m.mode = 1 if m.r != nil { @@ -1109,7 +1183,7 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if proj != nil { for _, e := range proj.Environments { if e.Name == msg.Name { - m.envForm = views.NewEnvFormView(e.Name, e.Name, e.Color, e.EnvVarsMode, "", m.theme.Title, m.cs.ListItem, m.cs.ButtonPrimary, m.cs.ButtonSecondary) + m.envForm = views.NewEnvFormView(e.Name, e.Name, e.Color, e.EnvVarsMode, "", m.buildEnvFormStyles()) m.envProjectName = pname envPath := e.EnvVarsFile @@ -1130,6 +1204,10 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + if f, ok := m.envForm.(*views.EnvFormView); ok { + f.EnvFile.SetValue(e.EnvVarsFile) + } + m.mode = 3 if m.r != nil { m.r.NavigateTo(router.RouteEnvVars, router.Params{"project": pname}) @@ -1156,8 +1234,16 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { env := &domain.Environment{ Name: strings.TrimSpace(msg.Name), - Color: normalizeColorInput(strings.TrimSpace(msg.Color)), + Color: strings.TrimSpace(msg.Color), EnvVarsMode: strings.TrimSpace(msg.Mode), + EnvVarsFile: strings.TrimSpace(msg.EnvVarsFile), + } + + if strings.TrimSpace(env.EnvVarsFile) == "" { + slug := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(env.Name, " ", "-"))) + if slug != "" { + env.EnvVarsFile = "." + slug + ".env" + } } envs := domain.EnvVars{} @@ -1217,6 +1303,7 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.total = len(projects) + 1 } + m.envVarsView = nil m.mode = 0 if m.r != nil { m.r.NavigateTo(router.RouteProjects, nil) diff --git a/internal/adapters/repositories/project_repository.go b/internal/adapters/repositories/project_repository.go index e256235..7df2c6e 100644 --- a/internal/adapters/repositories/project_repository.go +++ b/internal/adapters/repositories/project_repository.go @@ -111,7 +111,7 @@ NOTE: future work - If .project.hcl exist */ func (p *ProjectRepository) Create( - name, path, subproject string, + name, path, subproject, envFile string, envVars domain.EnvVars, gitConfig *domain.GitConfig, ) (*domain.Project, error) { @@ -223,10 +223,18 @@ func (p *ProjectRepository) Create( return nil, err } - // .env file with env_vars - envPath := filepath.Join(path, ".env") + // env file with env_vars + if strings.TrimSpace(envFile) == "" { + envFile = ".env" + } + + envPath := envFile + if !filepath.IsAbs(envFile) { + envPath = filepath.Join(path, envFile) + } + if _, err = os.Stat(envPath); !os.IsNotExist(err) { - return nil, fmt.Errorf("%s already exists in directory %s", envPath, path) + return nil, fmt.Errorf("%s already exists in directory %s", filepath.Base(envPath), filepath.Dir(envPath)) } fpEnv, err := os.OpenFile(envPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) @@ -255,7 +263,7 @@ func (p *ProjectRepository) Create( defer fpProj.Close() - projectHCL := fmt.Sprintf("name = \"%s\"\n\ndescription = \"\"\n\nenv_vars_file = \".env\"\n", name) + projectHCL := fmt.Sprintf("name = \"%s\"\n\ndescription = \"\"\n\nenv_vars_file = \"%s\"\n", name, envFile) if _, err := fpProj.WriteString(projectHCL); err != nil { return nil, err } @@ -264,7 +272,7 @@ func (p *ProjectRepository) Create( Name: name, Description: "", Path: path, - EnvVarsFile: ".env", + EnvVarsFile: envFile, }, nil } @@ -349,20 +357,20 @@ func (p *ProjectRepository) UpdateEnvironment(projectName, originalEnvName strin } idx := -1 - for i, e := range project.Environments { if e.Name == originalEnvName { idx = i break } } - if idx == -1 { return fmt.Errorf("environment %s not found", originalEnvName) } - project.Environments[idx].Name = env.Name + old := project.Environments[idx] + // Update basic fields + project.Environments[idx].Name = env.Name project.Environments[idx].Color = env.Color if env.EnvVarsMode == "" { project.Environments[idx].EnvVarsMode = domain.EnvVarsModeMerge @@ -370,7 +378,31 @@ func (p *ProjectRepository) UpdateEnvironment(projectName, originalEnvName strin project.Environments[idx].EnvVarsMode = env.EnvVarsMode } - if env.EnvVarsFile != "" { + // Handle env file rename if filename changed + if strings.TrimSpace(env.EnvVarsFile) != "" && env.EnvVarsFile != old.EnvVarsFile { + oldPath := old.EnvVarsFile + newPath := env.EnvVarsFile + + projPath := project.Path + if strings.HasPrefix(projPath, "~/") { + h, _ := os.UserHomeDir() + projPath = filepath.Join(h, projPath[2:]) + } + + if !filepath.IsAbs(oldPath) { + oldPath = filepath.Join(projPath, oldPath) + } + if !filepath.IsAbs(newPath) { + newPath = filepath.Join(projPath, newPath) + } + + _ = os.MkdirAll(filepath.Dir(newPath), 0o755) + if _, err := os.Stat(oldPath); err == nil { + if _, err := os.Stat(newPath); os.IsNotExist(err) { + _ = os.Rename(oldPath, newPath) + } + } + project.Environments[idx].EnvVarsFile = env.EnvVarsFile } @@ -398,9 +430,14 @@ func (p *ProjectRepository) AddEnvironment(projectName string, env *domain.Envir project.Path = filepath.Join(h, project.Path[2:]) } - envFile := env.EnvVarsFile + envFile := strings.TrimSpace(env.EnvVarsFile) if envFile == "" { - envFile = ".env." + env.Name + slug := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(env.Name, " ", "-"))) + if slug != "" { + envFile = "." + slug + ".env" + } else { + envFile = ".env" + } } envPath := envFile diff --git a/internal/core/ports/project_port.go b/internal/core/ports/project_port.go index 55b80a7..2c04414 100644 --- a/internal/core/ports/project_port.go +++ b/internal/core/ports/project_port.go @@ -7,7 +7,7 @@ import "github.com/jlrosende/project-manager/internal/core/domain" type ProjectService interface { Load(name string) (*domain.Project, error) List() ([]*domain.Project, error) - Create(name, path, subproject string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) + Create(name, path, subproject, envFile string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error UpdateProject(project *domain.Project) error UpdateEnvironment(projectName, originalEnvName string, env *domain.Environment) error @@ -16,7 +16,7 @@ type ProjectService interface { type ProjectRepository interface { List() ([]*domain.Project, error) - Create(name, path, subproject string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) + Create(name, path, subproject, envFile string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error UpdateProject(project *domain.Project) error UpdateEnvironment(projectName, originalEnvName string, env *domain.Environment) error diff --git a/internal/core/services/project_service.go b/internal/core/services/project_service.go index aeb87aa..01bf9b6 100644 --- a/internal/core/services/project_service.go +++ b/internal/core/services/project_service.go @@ -84,11 +84,11 @@ func (svc *ProjectService) List() ([]*domain.Project, error) { } func (svc *ProjectService) Create( - name, path, subproject string, + name, path, subproject, envFile string, envVars domain.EnvVars, gitConfig *domain.GitConfig, ) (*domain.Project, error) { - return svc.project.Create(name, path, subproject, envVars, gitConfig) + return svc.project.Create(name, path, subproject, envFile, envVars, gitConfig) } func (svc *ProjectService) AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error { diff --git a/mocks/mock_project_port.go b/mocks/mock_project_port.go index 4346721..251a566 100644 --- a/mocks/mock_project_port.go +++ b/mocks/mock_project_port.go @@ -55,18 +55,18 @@ func (mr *MockProjectServiceMockRecorder) AddEnvironment(projectName, env, envVa } // Create mocks base method. -func (m *MockProjectService) Create(name, path, subproject string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) { +func (m *MockProjectService) Create(name, path, subproject, envFile string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", name, path, subproject, envVars, git) + ret := m.ctrl.Call(m, "Create", name, path, subproject, envFile, envVars, git) ret0, _ := ret[0].(*domain.Project) ret1, _ := ret[1].(error) return ret0, ret1 } // Create indicates an expected call of Create. -func (mr *MockProjectServiceMockRecorder) Create(name, path, subproject, envVars, git any) *gomock.Call { +func (mr *MockProjectServiceMockRecorder) Create(name, path, subproject, envFile, envVars, git any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockProjectService)(nil).Create), name, path, subproject, envVars, git) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockProjectService)(nil).Create), name, path, subproject, envFile, envVars, git) } // Delete mocks base method. @@ -180,18 +180,18 @@ func (mr *MockProjectRepositoryMockRecorder) AddEnvironment(projectName, env, en } // Create mocks base method. -func (m *MockProjectRepository) Create(name, path, subproject string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) { +func (m *MockProjectRepository) Create(name, path, subproject, envFile string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", name, path, subproject, envVars, git) + ret := m.ctrl.Call(m, "Create", name, path, subproject, envFile, envVars, git) ret0, _ := ret[0].(*domain.Project) ret1, _ := ret[1].(error) return ret0, ret1 } // Create indicates an expected call of Create. -func (mr *MockProjectRepositoryMockRecorder) Create(name, path, subproject, envVars, git any) *gomock.Call { +func (mr *MockProjectRepositoryMockRecorder) Create(name, path, subproject, envFile, envVars, git any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockProjectRepository)(nil).Create), name, path, subproject, envVars, git) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockProjectRepository)(nil).Create), name, path, subproject, envFile, envVars, git) } // Delete mocks base method. From 4f97f34edae7967967c3d14058e6e6dd09da8532 Mon Sep 17 00:00:00 2001 From: Jorge Lopez Rosende Date: Sun, 21 Sep 2025 11:19:41 +0200 Subject: [PATCH 17/27] Upadet Spec-kit --- .claude/commands/constitution.md | 68 ++ .claude/commands/implement.md | 74 +- .claude/commands/plan.md | 49 +- .claude/commands/specify.md | 13 +- .claude/commands/tasks.md | 11 +- .../memory/constitution_update_checklist.md | 85 --- .spec-kit/scripts/check-task-prerequisites.sh | 62 -- .spec-kit/scripts/common.sh | 77 -- .spec-kit/scripts/create-new-feature.sh | 96 --- .spec-kit/scripts/get-feature-paths.sh | 23 - .spec-kit/scripts/setup-plan.sh | 44 -- .spec-kit/scripts/update-agent-context.sh | 234 ------ .../contracts/messages.md | 13 - .../specs/001-a-project-manager/data-model.md | 29 - .spec-kit/specs/001-a-project-manager/plan.md | 172 ----- .../specs/001-a-project-manager/quickstart.md | 7 - .../specs/001-a-project-manager/research.md | 20 - .spec-kit/specs/001-a-project-manager/spec.md | 145 ---- .../specs/001-a-project-manager/tasks.md | 66 -- .../memory/constitution.md | 0 .../check-implementation-prerequisites.sh | 16 + .../scripts/bash/check-task-prerequisites.sh | 15 + .specify/scripts/bash/common.sh | 37 + .specify/scripts/bash/create-new-feature.sh | 76 ++ .specify/scripts/bash/get-feature-paths.sh | 7 + .specify/scripts/bash/setup-plan.sh | 17 + .specify/scripts/bash/update-agent-context.sh | 683 ++++++++++++++++++ .../templates/agent-file-template.md | 0 .../templates/plan-template.md | 50 +- .../templates/spec-template.md | 0 .../templates/tasks-template.md | 0 README.md | 152 ++-- 32 files changed, 1120 insertions(+), 1221 deletions(-) create mode 100644 .claude/commands/constitution.md delete mode 100644 .spec-kit/memory/constitution_update_checklist.md delete mode 100755 .spec-kit/scripts/check-task-prerequisites.sh delete mode 100755 .spec-kit/scripts/common.sh delete mode 100755 .spec-kit/scripts/create-new-feature.sh delete mode 100755 .spec-kit/scripts/get-feature-paths.sh delete mode 100755 .spec-kit/scripts/setup-plan.sh delete mode 100755 .spec-kit/scripts/update-agent-context.sh delete mode 100644 .spec-kit/specs/001-a-project-manager/contracts/messages.md delete mode 100644 .spec-kit/specs/001-a-project-manager/data-model.md delete mode 100644 .spec-kit/specs/001-a-project-manager/plan.md delete mode 100644 .spec-kit/specs/001-a-project-manager/quickstart.md delete mode 100644 .spec-kit/specs/001-a-project-manager/research.md delete mode 100644 .spec-kit/specs/001-a-project-manager/spec.md delete mode 100644 .spec-kit/specs/001-a-project-manager/tasks.md rename {.spec-kit => .specify}/memory/constitution.md (100%) create mode 100755 .specify/scripts/bash/check-implementation-prerequisites.sh create mode 100755 .specify/scripts/bash/check-task-prerequisites.sh create mode 100755 .specify/scripts/bash/common.sh create mode 100755 .specify/scripts/bash/create-new-feature.sh create mode 100755 .specify/scripts/bash/get-feature-paths.sh create mode 100755 .specify/scripts/bash/setup-plan.sh create mode 100755 .specify/scripts/bash/update-agent-context.sh rename {.spec-kit => .specify}/templates/agent-file-template.md (100%) rename {.spec-kit => .specify}/templates/plan-template.md (81%) rename {.spec-kit => .specify}/templates/spec-template.md (100%) rename {.spec-kit => .specify}/templates/tasks-template.md (100%) diff --git a/.claude/commands/constitution.md b/.claude/commands/constitution.md new file mode 100644 index 0000000..c5c66d0 --- /dev/null +++ b/.claude/commands/constitution.md @@ -0,0 +1,68 @@ +--- +description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync. +# (No scripts section: constitution edits are manual authoring assisted by the agent) +--- + +You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts. + +Follow this execution flow: + +1. Load the existing constitution template at `.specify/memory/constitution.md`. + - Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`. + **IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly. + +2. Collect/derive values for placeholders: + - If user input (conversation) supplies a value, use it. + - Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded). + - For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous. + - `CONSTITUTION_VERSION` must increment according to semantic versioning rules: + * MAJOR: Backward incompatible governance/principle removals or redefinitions. + * MINOR: New principle/section added or materially expanded guidance. + * PATCH: Clarifications, wording, typo fixes, non-semantic refinements. + - If version bump type ambiguous, propose reasoning before finalizing. + +3. Draft the updated constitution content: + - Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yetβ€”explicitly justify any left). + - Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance. + - Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious. + - Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations. + +4. Consistency propagation checklist (convert prior checklist into active validations): + - Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles. + - Read `.specify/templates/spec-template.md` for scope/requirements alignmentβ€”update if constitution adds/removes mandatory sections or constraints. + - Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline). + - Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required. + - Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed. + +5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update): + - Version change: old β†’ new + - List of modified principles (old title β†’ new title if renamed) + - Added sections + - Removed sections + - Templates requiring updates (βœ… updated / ⚠ pending) with file paths + - Follow-up TODOs if any placeholders intentionally deferred. + +6. Validation before final output: + - No remaining unexplained bracket tokens. + - Version line matches report. + - Dates ISO format YYYY-MM-DD. + - Principles are declarative, testable, and free of vague language ("should" β†’ replace with MUST/SHOULD rationale where appropriate). + +7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite). + +8. Output a final summary to the user with: + - New version and bump rationale. + - Any files flagged for manual follow-up. + - Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`). + +Formatting & Style Requirements: +- Use Markdown headings exactly as in the template (do not demote/promote levels). +- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks. +- Keep a single blank line between sections. +- Avoid trailing whitespace. + +If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps. + +If critical info missing (e.g., ratification date truly unknown), insert `TODO(): explanation` and include in the Sync Impact Report under deferred items. + +Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file. diff --git a/.claude/commands/implement.md b/.claude/commands/implement.md index a9cfec6..705edc2 100644 --- a/.claude/commands/implement.md +++ b/.claude/commands/implement.md @@ -1,22 +1,52 @@ -Implement the tasks defined. - -This is the fourth step in the Spec-Driven Development lifecycle. - -Given the context provided as an argument, do this: - -1. Read the `.spec-kit/specs/[###-feature-name]/tasks.md` - -2. Execute the task in order. - - - If the task has [P] symbol, execute the task in parallel - -3. Validate if the task is well executed, perform any test, you consider necessary to check if is done. - - - If is incorrect retry the task. - - Run more tests, to validate the retry. - -4. Check for the next incomplete task. - - - the task must be executed in order. - -5. Repeat the process until all the task are completed. +--- +description: Execute the implementation plan by processing and executing all tasks defined in tasks.md +--- + +Given the current feature context, do this: + +1. Run `.specify/scripts/bash/check-implementation-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. + +2. Load and analyze the implementation context: + - **REQUIRED**: Read tasks.md for the complete task list and execution plan + - **REQUIRED**: Read plan.md for tech stack, architecture, and file structure + - **IF EXISTS**: Read data-model.md for entities and relationships + - **IF EXISTS**: Read contracts/ for API specifications and test requirements + - **IF EXISTS**: Read research.md for technical decisions and constraints + - **IF EXISTS**: Read quickstart.md for integration scenarios + +3. Parse tasks.md structure and extract: + - **Task phases**: Setup, Tests, Core, Integration, Polish + - **Task dependencies**: Sequential vs parallel execution rules + - **Task details**: ID, description, file paths, parallel markers [P] + - **Execution flow**: Order and dependency requirements + +4. Execute implementation following the task plan: + - **Phase-by-phase execution**: Complete each phase before moving to the next + - **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together + - **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks + - **File-based coordination**: Tasks affecting the same files must run sequentially + - **Validation checkpoints**: Verify each phase completion before proceeding + +5. Implementation execution rules: + - **Setup first**: Initialize project structure, dependencies, configuration + - **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios + - **Core development**: Implement models, services, CLI commands, endpoints + - **Integration work**: Database connections, middleware, logging, external services + - **Polish and validation**: Unit tests, performance optimization, documentation + +6. Progress tracking and error handling: + - Report progress after each completed task + - Halt execution if any non-parallel task fails + - For parallel tasks [P], continue with successful tasks, report failed ones + - Provide clear error messages with context for debugging + - Suggest next steps if implementation cannot proceed + - **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file. + +7. Completion validation: + - Verify all required tasks are completed + - Check that implemented features match the original specification + - Validate that tests pass and coverage meets requirements + - Confirm the implementation follows the technical plan + - Report final status with summary of completed work + +Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/tasks` first to regenerate the task list. diff --git a/.claude/commands/plan.md b/.claude/commands/plan.md index ac9140f..cab2071 100644 --- a/.claude/commands/plan.md +++ b/.claude/commands/plan.md @@ -1,38 +1,35 @@ -Plan how to implement the specified feature. - -This is the second step in the Spec-Driven Development lifecycle. +--- +description: Execute the implementation planning workflow using the plan template to generate design artifacts. +--- Given the implementation details provided as an argument, do this: -1. Run `.spec-kit/scripts/setup-plan.sh --json` from the repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. All future file paths must be absolute. +1. Run `.specify/scripts/bash/setup-plan.sh --json` from the repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. All future file paths must be absolute. 2. Read and analyze the feature specification to understand: + - The feature requirements and user stories + - Functional and non-functional requirements + - Success criteria and acceptance criteria + - Any technical constraints or dependencies mentioned - - The feature requirements and user stories - - Functional and non-functional requirements - - Success criteria and acceptance criteria - - Any technical constraints or dependencies mentioned - -3. Read the constitution at `.spec-kit/memory/constitution.md` to understand constitutional requirements. +3. Read the constitution at `.specify/memory/constitution.md` to understand constitutional requirements. 4. Execute the implementation plan template: - - - Load `.spec-kit/templates/plan-template.md` (already copied to IMPL_PLAN path) - - Set Input path to FEATURE_SPEC - - Run the Execution Flow (main) function steps 1-10 - - The template is self-contained and executable - - Follow error handling and gate checks as specified - - Let the template guide artifact generation in $SPECS_DIR: - - Phase 0 generates research.md - - Phase 1 generates data-model.md, contracts/, quickstart.md - - Phase 2 generates tasks.md - - Incorporate user-provided details from arguments into Technical Context: $ARGUMENTS - - Update Progress Tracking as you complete each phase + - Load `.specify/templates/plan-template.md` (already copied to IMPL_PLAN path) + - Set Input path to FEATURE_SPEC + - Run the Execution Flow (main) function steps 1-9 + - The template is self-contained and executable + - Follow error handling and gate checks as specified + - Let the template guide artifact generation in $SPECS_DIR: + * Phase 0 generates research.md + * Phase 1 generates data-model.md, contracts/, quickstart.md + * Phase 2 generates tasks.md + - Incorporate user-provided details from arguments into Technical Context: $ARGUMENTS + - Update Progress Tracking as you complete each phase 5. Verify execution completed: - - - Check Progress Tracking shows all phases complete - - Ensure all required artifacts were generated - - Confirm no ERROR states in execution + - Check Progress Tracking shows all phases complete + - Ensure all required artifacts were generated + - Confirm no ERROR states in execution 6. Report results with branch name, file paths, and generated artifacts. diff --git a/.claude/commands/specify.md b/.claude/commands/specify.md index 195acb2..8adc743 100644 --- a/.claude/commands/specify.md +++ b/.claude/commands/specify.md @@ -1,11 +1,14 @@ -Start a new feature by creating a specification and feature branch. +--- +description: Create or update the feature specification from a natural language feature description. +--- -This is the first step in the Spec-Driven Development lifecycle. +The text the user typed after `/specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command. -Given the feature description provided as an argument, do this: +Given that feature description, do this: -1. Run the script `.spec-kit/scripts/create-new-feature.sh --json "$ARGUMENTS"` from repo root and parse its JSON output for BRANCH_NAME and SPEC_FILE. All file paths must be absolute. -2. Load `.spec-kit/templates/spec-template.md` to understand required sections. +1. Run the script `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS"` from repo root and parse its JSON output for BRANCH_NAME and SPEC_FILE. All file paths must be absolute. + **IMPORTANT** You must only ever run this script once. The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for. +2. Load `.specify/templates/spec-template.md` to understand required sections. 3. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings. 4. Report completion with branch name, spec file path, and readiness for the next phase. diff --git a/.claude/commands/tasks.md b/.claude/commands/tasks.md index 9fdc302..83291c9 100644 --- a/.claude/commands/tasks.md +++ b/.claude/commands/tasks.md @@ -1,11 +1,10 @@ - -Break down the plan into executable tasks. - -This is the third step in the Spec-Driven Development lifecycle. +--- +description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts. +--- Given the context provided as an argument, do this: -1. Run `.spec-kit/scripts/check-task-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. +1. Run `.specify/scripts/bash/check-task-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. 2. Load and analyze available design documents: - Always read plan.md for tech stack and libraries - IF EXISTS: Read data-model.md for entities @@ -19,7 +18,7 @@ Given the context provided as an argument, do this: - Generate tasks based on what's available 3. Generate tasks following the template: - - Use `.spec-kit/templates/tasks-template.md` as the base + - Use `.specify/templates/tasks-template.md` as the base - Replace example tasks with actual tasks based on: * **Setup tasks**: Project init, dependencies, linting * **Test tasks [P]**: One per contract, one per integration scenario diff --git a/.spec-kit/memory/constitution_update_checklist.md b/.spec-kit/memory/constitution_update_checklist.md deleted file mode 100644 index 7f15d7f..0000000 --- a/.spec-kit/memory/constitution_update_checklist.md +++ /dev/null @@ -1,85 +0,0 @@ -# Constitution Update Checklist - -When amending the constitution (`/memory/constitution.md`), ensure all dependent documents are updated to maintain consistency. - -## Templates to Update - -### When adding/modifying ANY article: -- [ ] `/templates/plan-template.md` - Update Constitution Check section -- [ ] `/templates/spec-template.md` - Update if requirements/scope affected -- [ ] `/templates/tasks-template.md` - Update if new task types needed -- [ ] `/.claude/commands/plan.md` - Update if planning process changes -- [ ] `/.claude/commands/tasks.md` - Update if task generation affected -- [ ] `/CLAUDE.md` - Update runtime development guidelines - -### Article-specific updates: - -#### Article I (Library-First): -- [ ] Ensure templates emphasize library creation -- [ ] Update CLI command examples -- [ ] Add llms.txt documentation requirements - -#### Article II (CLI Interface): -- [ ] Update CLI flag requirements in templates -- [ ] Add text I/O protocol reminders - -#### Article III (Test-First): -- [ ] Update test order in all templates -- [ ] Emphasize TDD requirements -- [ ] Add test approval gates - -#### Article IV (Integration Testing): -- [ ] List integration test triggers -- [ ] Update test type priorities -- [ ] Add real dependency requirements - -#### Article V (Observability): -- [ ] Add logging requirements to templates -- [ ] Include multi-tier log streaming -- [ ] Update performance monitoring sections - -#### Article VI (Versioning): -- [ ] Add version increment reminders -- [ ] Include breaking change procedures -- [ ] Update migration requirements - -#### Article VII (Simplicity): -- [ ] Update project count limits -- [ ] Add pattern prohibition examples -- [ ] Include YAGNI reminders - -## Validation Steps - -1. **Before committing constitution changes:** - - [ ] All templates reference new requirements - - [ ] Examples updated to match new rules - - [ ] No contradictions between documents - -2. **After updating templates:** - - [ ] Run through a sample implementation plan - - [ ] Verify all constitution requirements addressed - - [ ] Check that templates are self-contained (readable without constitution) - -3. **Version tracking:** - - [ ] Update constitution version number - - [ ] Note version in template footers - - [ ] Add amendment to constitution history - -## Common Misses - -Watch for these often-forgotten updates: -- Command documentation (`/commands/*.md`) -- Checklist items in templates -- Example code/commands -- Domain-specific variations (web vs mobile vs CLI) -- Cross-references between documents - -## Template Sync Status - -Last sync check: 2025-07-16 -- Constitution version: 2.1.1 -- Templates aligned: ❌ (missing versioning, observability details) - ---- - -*This checklist ensures the constitution's principles are consistently applied across all project documentation.* \ No newline at end of file diff --git a/.spec-kit/scripts/check-task-prerequisites.sh b/.spec-kit/scripts/check-task-prerequisites.sh deleted file mode 100755 index b692fcd..0000000 --- a/.spec-kit/scripts/check-task-prerequisites.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# Check that implementation plan exists and find optional design documents -# Usage: ./check-task-prerequisites.sh [--json] - -set -e - -JSON_MODE=false -for arg in "$@"; do - case "$arg" in - --json) JSON_MODE=true ;; - --help|-h) echo "Usage: $0 [--json]"; exit 0 ;; - esac -done - -# Source common functions -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -# Get all paths -eval $(get_feature_paths) - -# Check if on feature branch -check_feature_branch "$CURRENT_BRANCH" || exit 1 - -# Check if feature directory exists -if [[ ! -d "$FEATURE_DIR" ]]; then - echo "ERROR: Feature directory not found: $FEATURE_DIR" - echo "Run /specify first to create the feature structure." - exit 1 -fi - -# Check for implementation plan (required) -if [[ ! -f "$IMPL_PLAN" ]]; then - echo "ERROR: plan.md not found in $FEATURE_DIR" - echo "Run /plan first to create the plan." - exit 1 -fi - -if $JSON_MODE; then - # Build JSON array of available docs that actually exist - docs=() - [[ -f "$RESEARCH" ]] && docs+=("research.md") - [[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") - ([[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]) && docs+=("contracts/") - [[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") - # join array into JSON - json_docs=$(printf '"%s",' "${docs[@]}") - json_docs="[${json_docs%,}]" - printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$FEATURE_DIR" "$json_docs" -else - # List available design documents (optional) - echo "FEATURE_DIR:$FEATURE_DIR" - echo "AVAILABLE_DOCS:" - - # Use common check functions - check_file "$RESEARCH" "research.md" - check_file "$DATA_MODEL" "data-model.md" - check_dir "$CONTRACTS_DIR" "contracts/" - check_file "$QUICKSTART" "quickstart.md" -fi - -# Always succeed - task generation should work with whatever docs are available \ No newline at end of file diff --git a/.spec-kit/scripts/common.sh b/.spec-kit/scripts/common.sh deleted file mode 100755 index 1e0ff5d..0000000 --- a/.spec-kit/scripts/common.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -# Common functions and variables for all scripts - -# Get repository root -get_repo_root() { - git rev-parse --show-toplevel -} - -# Get current branch -get_current_branch() { - git rev-parse --abbrev-ref HEAD -} - -# Check if current branch is a feature branch -# Returns 0 if valid, 1 if not -check_feature_branch() { - local branch="$1" - if [[ ! "$branch" =~ ^[0-9]{3}- ]]; then - echo "ERROR: Not on a feature branch. Current branch: $branch" - echo "Feature branches should be named like: 001-feature-name" - return 1 - fi - return 0 -} - -# Get feature directory path -get_feature_dir() { - local repo_root="$1" - local branch="$2" - echo "$repo_root/.spec-kit/specs/$branch" -} - -# Get all standard paths for a feature -# Usage: eval $(get_feature_paths) -# Sets: REPO_ROOT, CURRENT_BRANCH, FEATURE_DIR, FEATURE_SPEC, IMPL_PLAN, TASKS -get_feature_paths() { - local repo_root=$(get_repo_root) - local current_branch=$(get_current_branch) - local feature_dir=$(get_feature_dir "$repo_root" "$current_branch") - - echo "REPO_ROOT='$repo_root'" - echo "CURRENT_BRANCH='$current_branch'" - echo "FEATURE_DIR='$feature_dir'" - echo "FEATURE_SPEC='$feature_dir/spec.md'" - echo "IMPL_PLAN='$feature_dir/plan.md'" - echo "TASKS='$feature_dir/tasks.md'" - echo "RESEARCH='$feature_dir/research.md'" - echo "DATA_MODEL='$feature_dir/data-model.md'" - echo "QUICKSTART='$feature_dir/quickstart.md'" - echo "CONTRACTS_DIR='$feature_dir/contracts'" -} - -# Check if a file exists and report -check_file() { - local file="$1" - local description="$2" - if [[ -f "$file" ]]; then - echo " βœ“ $description" - return 0 - else - echo " βœ— $description" - return 1 - fi -} - -# Check if a directory exists and has files -check_dir() { - local dir="$1" - local description="$2" - if [[ -d "$dir" ]] && [[ -n "$(ls -A "$dir" 2>/dev/null)" ]]; then - echo " βœ“ $description" - return 0 - else - echo " βœ— $description" - return 1 - fi -} diff --git a/.spec-kit/scripts/create-new-feature.sh b/.spec-kit/scripts/create-new-feature.sh deleted file mode 100755 index 494510b..0000000 --- a/.spec-kit/scripts/create-new-feature.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bash -# Create a new feature with branch, directory structure, and template -# Usage: ./create-new-feature.sh "feature description" -# ./create-new-feature.sh --json "feature description" - -set -e - -JSON_MODE=false - -# Collect non-flag args -ARGS=() -for arg in "$@"; do - case "$arg" in - --json) - JSON_MODE=true - ;; - --help|-h) - echo "Usage: $0 [--json] "; exit 0 ;; - *) - ARGS+=("$arg") ;; - esac -done - -FEATURE_DESCRIPTION="${ARGS[*]}" -if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Usage: $0 [--json] " >&2 - exit 1 -fi - -# Get repository root -REPO_ROOT=$(git rev-parse --show-toplevel) -SPECS_DIR="$REPO_ROOT/.spec-kit/specs" - -# Create specs directory if it doesn't exist -mkdir -p "$SPECS_DIR" - -# Find the highest numbered feature directory -HIGHEST=0 -if [ -d "$SPECS_DIR" ]; then - for dir in "$SPECS_DIR"/*; do - if [ -d "$dir" ]; then - dirname=$(basename "$dir") - number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0") - number=$((10#$number)) - if [ "$number" -gt "$HIGHEST" ]; then - HIGHEST=$number - fi - fi - done -fi - -# Generate next feature number with zero padding -NEXT=$((HIGHEST + 1)) -FEATURE_NUM=$(printf "%03d" "$NEXT") - -# Create branch name from description -BRANCH_NAME=$(echo "$FEATURE_DESCRIPTION" | \ - tr '[:upper:]' '[:lower:]' | \ - sed 's/[^a-z0-9]/-/g' | \ - sed 's/-\+/-/g' | \ - sed 's/^-//' | \ - sed 's/-$//') - -# Extract 2-3 meaningful words -WORDS=$(echo "$BRANCH_NAME" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//') - -# Final branch name -BRANCH_NAME="${FEATURE_NUM}-${WORDS}" - -# Create and switch to new branch -git checkout -b "$BRANCH_NAME" - -# Create feature directory -FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" -mkdir -p "$FEATURE_DIR" - -# Copy template if it exists -TEMPLATE="$REPO_ROOT/.spec-kit/templates/spec-template.md" -SPEC_FILE="$FEATURE_DIR/spec.md" - -if [ -f "$TEMPLATE" ]; then - cp "$TEMPLATE" "$SPEC_FILE" -else - echo "Warning: Template not found at $TEMPLATE" >&2 - touch "$SPEC_FILE" -fi - -if $JSON_MODE; then - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' \ - "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM" -else - # Output results for the LLM to use (legacy key: value format) - echo "BRANCH_NAME: $BRANCH_NAME" - echo "SPEC_FILE: $SPEC_FILE" - echo "FEATURE_NUM: $FEATURE_NUM" -fi diff --git a/.spec-kit/scripts/get-feature-paths.sh b/.spec-kit/scripts/get-feature-paths.sh deleted file mode 100755 index b1e1fbc..0000000 --- a/.spec-kit/scripts/get-feature-paths.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -# Get paths for current feature branch without creating anything -# Used by commands that need to find existing feature files - -set -e - -# Source common functions -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -# Get all paths -eval $(get_feature_paths) - -# Check if on feature branch -check_feature_branch "$CURRENT_BRANCH" || exit 1 - -# Output paths (don't create anything) -echo "REPO_ROOT: $REPO_ROOT" -echo "BRANCH: $CURRENT_BRANCH" -echo "FEATURE_DIR: $FEATURE_DIR" -echo "FEATURE_SPEC: $FEATURE_SPEC" -echo "IMPL_PLAN: $IMPL_PLAN" -echo "TASKS: $TASKS" \ No newline at end of file diff --git a/.spec-kit/scripts/setup-plan.sh b/.spec-kit/scripts/setup-plan.sh deleted file mode 100755 index 27a4b25..0000000 --- a/.spec-kit/scripts/setup-plan.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -# Setup implementation plan structure for current branch -# Returns paths needed for implementation plan generation -# Usage: ./setup-plan.sh [--json] - -set -e - -JSON_MODE=false -for arg in "$@"; do - case "$arg" in - --json) JSON_MODE=true ;; - --help|-h) echo "Usage: $0 [--json]"; exit 0 ;; - esac -done - -# Source common functions -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -# Get all paths -eval $(get_feature_paths) - -# Check if on feature branch -check_feature_branch "$CURRENT_BRANCH" || exit 1 - -# Create specs directory if it doesn't exist -mkdir -p "$FEATURE_DIR" - -# Copy plan template if it exists -TEMPLATE="$REPO_ROOT/.spec-kit/templates/plan-template.md" -if [ -f "$TEMPLATE" ]; then - cp "$TEMPLATE" "$IMPL_PLAN" -fi - -if $JSON_MODE; then - printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \ - "$FEATURE_SPEC" "$IMPL_PLAN" "$FEATURE_DIR" "$CURRENT_BRANCH" -else - # Output all paths for LLM use - echo "FEATURE_SPEC: $FEATURE_SPEC" - echo "IMPL_PLAN: $IMPL_PLAN" - echo "SPECS_DIR: $FEATURE_DIR" - echo "BRANCH: $CURRENT_BRANCH" -fi diff --git a/.spec-kit/scripts/update-agent-context.sh b/.spec-kit/scripts/update-agent-context.sh deleted file mode 100755 index 790f0b3..0000000 --- a/.spec-kit/scripts/update-agent-context.sh +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env bash -# Incrementally update agent context files based on new feature plan -# Supports: CLAUDE.md, GEMINI.md, and .github/copilot-instructions.md -# O(1) operation - only reads current context file and new plan.md - -set -e - -REPO_ROOT=$(git rev-parse --show-toplevel) -CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) -FEATURE_DIR="$REPO_ROOT/.spec-kit/specs/$CURRENT_BRANCH" -NEW_PLAN="$FEATURE_DIR/plan.md" - -# Determine which agent context files to update -CLAUDE_FILE="$REPO_ROOT/CLAUDE.md" -GEMINI_FILE="$REPO_ROOT/GEMINI.md" -COPILOT_FILE="$REPO_ROOT/.github/copilot-instructions.md" - -# Allow override via argument -AGENT_TYPE="$1" - -if [ ! -f "$NEW_PLAN" ]; then - echo "ERROR: No plan.md found at $NEW_PLAN" - exit 1 -fi - -echo "=== Updating agent context files for feature $CURRENT_BRANCH ===" - -# Extract tech from new plan -NEW_LANG=$(grep "^**Language/Version**: " "$NEW_PLAN" 2>/dev/null | head -1 | sed 's/^**Language\/Version**: //' | grep -v "NEEDS CLARIFICATION" || echo "") -NEW_FRAMEWORK=$(grep "^**Primary Dependencies**: " "$NEW_PLAN" 2>/dev/null | head -1 | sed 's/^**Primary Dependencies**: //' | grep -v "NEEDS CLARIFICATION" || echo "") -NEW_TESTING=$(grep "^**Testing**: " "$NEW_PLAN" 2>/dev/null | head -1 | sed 's/^**Testing**: //' | grep -v "NEEDS CLARIFICATION" || echo "") -NEW_DB=$(grep "^**Storage**: " "$NEW_PLAN" 2>/dev/null | head -1 | sed 's/^**Storage**: //' | grep -v "N/A" | grep -v "NEEDS CLARIFICATION" || echo "") -NEW_PROJECT_TYPE=$(grep "^**Project Type**: " "$NEW_PLAN" 2>/dev/null | head -1 | sed 's/^**Project Type**: //' || echo "") - -# Function to update a single agent context file -update_agent_file() { - local target_file="$1" - local agent_name="$2" - - echo "Updating $agent_name context file: $target_file" - - # Create temp file for new context - local temp_file=$(mktemp) - - # If file doesn't exist, create from template - if [ ! -f "$target_file" ]; then - echo "Creating new $agent_name context file..." - - # Check if this is the SDD repo itself - if [ -f "$REPO_ROOT/.spec-kit/templates/agent-file-template.md" ]; then - cp "$REPO_ROOT/.spec-kit/templates/agent-file-template.md" "$temp_file" - else - echo "ERROR: Template not found at $REPO_ROOT/.spec-kit/templates/agent-file-template.md" - return 1 - fi - - # Replace placeholders - sed -i.bak "s/\[PROJECT NAME\]/$(basename $REPO_ROOT)/" "$temp_file" - sed -i.bak "s/\[DATE\]/$(date +%Y-%m-%d)/" "$temp_file" - sed -i.bak "s/\[EXTRACTED FROM ALL PLAN.MD FILES\]/- $NEW_LANG + $NEW_FRAMEWORK ($CURRENT_BRANCH)/" "$temp_file" - - # Add project structure based on type - if [[ "$NEW_PROJECT_TYPE" == *"web"* ]]; then - sed -i.bak "s|\[ACTUAL STRUCTURE FROM PLANS\]|backend/\nfrontend/\ntests/|" "$temp_file" - else - sed -i.bak "s|\[ACTUAL STRUCTURE FROM PLANS\]|src/\ntests/|" "$temp_file" - fi - - # Add minimal commands - if [[ "$NEW_LANG" == *"Python"* ]]; then - COMMANDS="cd src && pytest && ruff check ." - elif [[ "$NEW_LANG" == *"Rust"* ]]; then - COMMANDS="cargo test && cargo clippy" - elif [[ "$NEW_LANG" == *"JavaScript"* ]] || [[ "$NEW_LANG" == *"TypeScript"* ]]; then - COMMANDS="npm test && npm run lint" - else - COMMANDS="# Add commands for $NEW_LANG" - fi - sed -i.bak "s|\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]|$COMMANDS|" "$temp_file" - - # Add code style - sed -i.bak "s|\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]|$NEW_LANG: Follow standard conventions|" "$temp_file" - - # Add recent changes - sed -i.bak "s|\[LAST 3 FEATURES AND WHAT THEY ADDED\]|- $CURRENT_BRANCH: Added $NEW_LANG + $NEW_FRAMEWORK|" "$temp_file" - - rm "$temp_file.bak" - else - echo "Updating existing $agent_name context file..." - - # Extract manual additions - local manual_start=$(grep -n "" "$target_file" | cut -d: -f1) - local manual_end=$(grep -n "" "$target_file" | cut -d: -f1) - - if [ ! -z "$manual_start" ] && [ ! -z "$manual_end" ]; then - sed -n "${manual_start},${manual_end}p" "$target_file" > /tmp/manual_additions.txt - fi - - # Parse existing file and create updated version - python3 - << EOF -import re -import sys -from datetime import datetime - -# Read existing file -with open("$target_file", 'r') as f: - content = f.read() - -# Check if new tech already exists -tech_section = re.search(r'## Active Technologies\n(.*?)\n\n', content, re.DOTALL) -if tech_section: - existing_tech = tech_section.group(1) - - # Add new tech if not already present - new_additions = [] - if "$NEW_LANG" and "$NEW_LANG" not in existing_tech: - new_additions.append(f"- $NEW_LANG + $NEW_FRAMEWORK ($CURRENT_BRANCH)") - if "$NEW_DB" and "$NEW_DB" not in existing_tech and "$NEW_DB" != "N/A": - new_additions.append(f"- $NEW_DB ($CURRENT_BRANCH)") - - if new_additions: - updated_tech = existing_tech + "\n" + "\n".join(new_additions) - content = content.replace(tech_section.group(0), f"## Active Technologies\n{updated_tech}\n\n") - -# Update project structure if needed -if "$NEW_PROJECT_TYPE" == "web" and "frontend/" not in content: - struct_section = re.search(r'## Project Structure\n\`\`\`\n(.*?)\n\`\`\`', content, re.DOTALL) - if struct_section: - updated_struct = struct_section.group(1) + "\nfrontend/src/ # Web UI" - content = re.sub(r'(## Project Structure\n\`\`\`\n).*?(\n\`\`\`)', - f'\\1{updated_struct}\\2', content, flags=re.DOTALL) - -# Add new commands if language is new -if "$NEW_LANG" and f"# {NEW_LANG}" not in content: - commands_section = re.search(r'## Commands\n\`\`\`bash\n(.*?)\n\`\`\`', content, re.DOTALL) - if not commands_section: - commands_section = re.search(r'## Commands\n(.*?)\n\n', content, re.DOTALL) - - if commands_section: - new_commands = commands_section.group(1) - if "Python" in "$NEW_LANG": - new_commands += "\ncd src && pytest && ruff check ." - elif "Rust" in "$NEW_LANG": - new_commands += "\ncargo test && cargo clippy" - elif "JavaScript" in "$NEW_LANG" or "TypeScript" in "$NEW_LANG": - new_commands += "\nnpm test && npm run lint" - - if "```bash" in content: - content = re.sub(r'(## Commands\n\`\`\`bash\n).*?(\n\`\`\`)', - f'\\1{new_commands}\\2', content, flags=re.DOTALL) - else: - content = re.sub(r'(## Commands\n).*?(\n\n)', - f'\\1{new_commands}\\2', content, flags=re.DOTALL) - -# Update recent changes (keep only last 3) -changes_section = re.search(r'## Recent Changes\n(.*?)(\n\n|$)', content, re.DOTALL) -if changes_section: - changes = changes_section.group(1).strip().split('\n') - changes.insert(0, f"- $CURRENT_BRANCH: Added $NEW_LANG + $NEW_FRAMEWORK") - # Keep only last 3 - changes = changes[:3] - content = re.sub(r'(## Recent Changes\n).*?(\n\n|$)', - f'\\1{chr(10).join(changes)}\\2', content, flags=re.DOTALL) - -# Update date -content = re.sub(r'Last updated: \d{4}-\d{2}-\d{2}', - f'Last updated: {datetime.now().strftime("%Y-%m-%d")}', content) - -# Write to temp file -with open("$temp_file", 'w') as f: - f.write(content) -EOF - - # Restore manual additions if they exist - if [ -f /tmp/manual_additions.txt ]; then - # Remove old manual section from temp file - sed -i.bak '//,//d' "$temp_file" - # Append manual additions - cat /tmp/manual_additions.txt >> "$temp_file" - rm /tmp/manual_additions.txt "$temp_file.bak" - fi - fi - - # Move temp file to final location - mv "$temp_file" "$target_file" - echo "βœ… $agent_name context file updated successfully" -} - -# Update files based on argument or detect existing files -case "$AGENT_TYPE" in - "claude") - update_agent_file "$CLAUDE_FILE" "Claude Code" - ;; - "gemini") - update_agent_file "$GEMINI_FILE" "Gemini CLI" - ;; - "copilot") - update_agent_file "$COPILOT_FILE" "GitHub Copilot" - ;; - "") - # Update all existing files - [ -f "$CLAUDE_FILE" ] && update_agent_file "$CLAUDE_FILE" "Claude Code" - [ -f "$GEMINI_FILE" ] && update_agent_file "$GEMINI_FILE" "Gemini CLI" - [ -f "$COPILOT_FILE" ] && update_agent_file "$COPILOT_FILE" "GitHub Copilot" - - # If no files exist, create based on current directory or ask user - if [ ! -f "$CLAUDE_FILE" ] && [ ! -f "$GEMINI_FILE" ] && [ ! -f "$COPILOT_FILE" ]; then - echo "No agent context files found. Creating Claude Code context file by default." - update_agent_file "$CLAUDE_FILE" "Claude Code" - fi - ;; - *) - echo "ERROR: Unknown agent type '$AGENT_TYPE'. Use: claude, gemini, copilot, or leave empty for all." - exit 1 - ;; -esac -echo "" -echo "Summary of changes:" -if [ ! -z "$NEW_LANG" ]; then - echo "- Added language: $NEW_LANG" -fi -if [ ! -z "$NEW_FRAMEWORK" ]; then - echo "- Added framework: $NEW_FRAMEWORK" -fi -if [ ! -z "$NEW_DB" ] && [ "$NEW_DB" != "N/A" ]; then - echo "- Added database: $NEW_DB" -fi - -echo "" -echo "Usage: $0 [claude|gemini|copilot]" -echo " - No argument: Update all existing agent context files" -echo " - claude: Update only CLAUDE.md" -echo " - gemini: Update only GEMINI.md" -echo " - copilot: Update only .github/copilot-instructions.md" diff --git a/.spec-kit/specs/001-a-project-manager/contracts/messages.md b/.spec-kit/specs/001-a-project-manager/contracts/messages.md deleted file mode 100644 index f148b51..0000000 --- a/.spec-kit/specs/001-a-project-manager/contracts/messages.md +++ /dev/null @@ -1,13 +0,0 @@ -# Contracts: UI Messages - -## ProjectsLoadedMsg -- Input: projects []Project -- Effect: populate projects, select first, derive envs - -## ProjectSelectedMsg -- Input: index int -- Effect: update selection, derive envs - -## WindowSizeMsg -- Input: width int, height int -- Effect: update layout/collapse behavior \ No newline at end of file diff --git a/.spec-kit/specs/001-a-project-manager/data-model.md b/.spec-kit/specs/001-a-project-manager/data-model.md deleted file mode 100644 index 17da4ab..0000000 --- a/.spec-kit/specs/001-a-project-manager/data-model.md +++ /dev/null @@ -1,29 +0,0 @@ -# Data Model: TUI environments column - -## Entities -- Project - - name: string (unique) - - environments: []string - -- UIModel - - projects: []Project - - selectedProjectIdx: int - - envsForSelected: []string - - width: int - - height: int - - styles: struct{ left, right, title } - -## Messages (contracts) -- ProjectsLoadedMsg{ projects []Project } -- ProjectSelectedMsg{ index int } -- WindowSizeMsg{ width, height int } - -## Validation Rules -- selectedProjectIdx in [0, len(projects)) else envsForSelected = nil -- envsForSelected = projects[selectedProjectIdx].environments -- collapseRight = width < 60 - -## State Transitions -- ProjectsLoadedMsg β†’ projects set, selectedProjectIdx=0, derive envsForSelected -- ProjectSelectedMsg β†’ selectedProjectIdx updated, derive envsForSelected -- WindowSizeMsg β†’ width/height set, affects collapseRight \ No newline at end of file diff --git a/.spec-kit/specs/001-a-project-manager/plan.md b/.spec-kit/specs/001-a-project-manager/plan.md deleted file mode 100644 index a2024e0..0000000 --- a/.spec-kit/specs/001-a-project-manager/plan.md +++ /dev/null @@ -1,172 +0,0 @@ -# Implementation Plan: TUI environments column - -**Branch**: `001-a-project-manager` | **Date**: 2025-09-14 | **Spec**: /home/jlrosende/personal/project-manager/.spec-kit/specs/001-a-project-manager/spec.md -**Input**: Feature specification from `.spec-kit/specs/001-a-project-manager/spec.md` - -## Execution Flow (/plan command scope) - -``` -1. Load feature spec from Input path - β†’ Found: spec.md -2. Fill Technical Context (scan for NEEDS CLARIFICATION) - β†’ No new unknowns for this TUI-only change beyond existing global clarifications -3. Evaluate Constitution Check section below - β†’ No violations for a small UI enhancement - β†’ Update Progress Tracking: Initial Constitution Check -4. Execute Phase 0 β†’ research.md - β†’ All TUI unknowns resolved -5. Execute Phase 1 β†’ contracts, data-model.md, quickstart.md -6. Re-evaluate Constitution Check section - β†’ No new violations - β†’ Update Progress Tracking: Post-Design Constitution Check -7. Plan Phase 2 β†’ Describe task generation approach (DO NOT create tasks.md) -8. STOP - Ready for /tasks command -``` - -## Summary - -Add an environments column/pane to the TUI that displays the available environments for the currently selected project. When the project selection changes, the environments list updates. Styling uses lipgloss, and state management follows the Elm architecture using bubbletea. No secrets are displayed; only environment names. - -## Technical Context - -**Language/Version**: Go 1.25.1 -**Primary Dependencies**: github.com/charmbracelet/bubbletea v1.3.9, github.com/charmbracelet/bubbles v0.21.0, github.com/charmbracelet/lipgloss v1.1.0 -**Storage**: N/A -**Testing**: go test (NEEDS CLARIFICATION: test layout and commands) -**Target Platform**: Linux/macOS terminal -**Project Type**: single -**Performance Goals**: Instant UI update on selection (<50ms perceived) -**Constraints**: Do not display or log secrets; keep UI responsive in small terminals -**Scale/Scope**: Dozens of projects, a handful of environments each - -Implementation placement and constraints from user input: - -- TUI handlers in `internal/adapters/handlers` -- Custom UI components in `pkg/ui` -- Elm architecture with bubbletea; styles via lipgloss - -## Constitution Check - -**Simplicity**: - -- Projects: 1 (cli) -- Using framework directly? Yes (bubbletea/bubbles) -- Single data model? Yes (UI model augmented with envs list) -- Avoiding patterns? Yes (no extra abstraction) - -**Architecture**: - -- Feature shipped within existing app; no new library required -- Libraries listed: bubbletea/bubbles (UI), lipgloss (styles) -- CLI per library: N/A -- Library docs: N/A - -**Testing (NON-NEGOTIABLE)**: - -- RED-GREEN planned for UI message handling and rendering helpers -- Order: contract (messages) β†’ integration (model update/render) β†’ unit (helpers) -- Real deps: yes (bubbletea) - -**Observability**: - -- Use existing structured logging if present; avoid logging secrets - -**Versioning**: - -- Folded into current feature branch; no API breakage - -## Project Structure - -### Documentation (this feature) - -``` -specs/001-a-project-manager/ -β”œβ”€β”€ plan.md # This file (/plan command output) -β”œβ”€β”€ research.md # Phase 0 output (/plan command) -β”œβ”€β”€ data-model.md # Phase 1 output (/plan command) -β”œβ”€β”€ quickstart.md # Phase 1 output (/plan command) -└── contracts/ # Phase 1 output (/plan command) -``` - -### Source Code (repository root) - -``` -# Single project -internal/adapters/handlers # TUI handlers (update/view) -pkg/ui # UI components (lists, columns, styles) -``` - -**Structure Decision**: Option 1 (single project) - -## Phase 0: Outline & Research - -1. Unknowns identified and resolved - - Layout approach: split view with projects list (left) and environments list (right) - - Component choice: bubbles list or table; choose list for environments for simplicity - - State updates: on project selection change, derive envs for selected project - - Sizing behavior: responsive split using terminal width; collapse envs when width too small -2. Best practices - - Use tea.Msg for selection changes; keep rendering stateless from model - - Avoid heavy computation in View; precompute styles -3. Output: research.md with decisions and rationale - -**Output**: research.md with all TUI unknowns resolved - -## Phase 1: Design & Contracts - -1. Entities β†’ `data-model.md` - - Project(name, environments[]string) - - UI Model: selectedProjectIdx int, projects []Project, envsForSelected []string, width/height, styles - - Messages: ProjectsLoadedMsg, ProjectSelectedMsg, WindowSizeMsg -2. Contracts β†’ `/contracts/` - - Message/event contracts for selection and rendering -3. Contract tests (planned) - - Ensure envs list updates when selection changes - - Ensure collapse behavior under narrow width -4. Test scenarios - - Validate user can see environments of selected project; switching selection updates list -5. Agent file: N/A - -**Output**: data-model.md, /contracts/\*, quickstart.md - -## Phase 2: Task Planning Approach - -**Task Generation Strategy**: - -- Derive tasks from data-model and contracts: add model fields, implement Update cases, implement View split, add components in pkg/ui - -**Ordering Strategy**: - -- TDD: write contract tests for message handling before implementation -- Dependency: model before view; styles last -- Parallel: style constants and component skeletons - -**Estimated Output**: Will be produced by /tasks - -## Complexity Tracking - -| Violation | Why Needed | Simpler Alternative Rejected Because | -| --------- | ---------- | ------------------------------------ | -| β€” | β€” | β€” | - -## Progress Tracking - -**Phase Status**: - -- [x] Phase 0: Research complete (/plan command) -- [x] Phase 1: Design complete (/plan command) -- [x] Phase 2: Task planning complete (/plan command - describe approach only) -- [ ] Phase 3: Tasks generated (/tasks command) -- [ ] Phase 4: Implementation complete -- [ ] Phase 5: Validation passed - -**Gate Status**: - -- [x] Initial Constitution Check: PASS -- [x] Post-Design Constitution Check: PASS -- [x] All NEEDS CLARIFICATION resolved (for this TUI scope) -- [ ] Complexity deviations documented (N/A) - ---- - -_Based on Constitution v2.1.1 - See `/memory/constitution.md`_ diff --git a/.spec-kit/specs/001-a-project-manager/quickstart.md b/.spec-kit/specs/001-a-project-manager/quickstart.md deleted file mode 100644 index f69e372..0000000 --- a/.spec-kit/specs/001-a-project-manager/quickstart.md +++ /dev/null @@ -1,7 +0,0 @@ -# Quickstart: View environments for selected project - -1. Start the TUI -2. Use Up/Down to select a project in the left pane -3. Observe the right pane lists environments for the selected project -4. Resize terminal smaller than 60 columns β†’ environments pane collapses -5. Resize back β†’ environments pane reappears with correct list \ No newline at end of file diff --git a/.spec-kit/specs/001-a-project-manager/research.md b/.spec-kit/specs/001-a-project-manager/research.md deleted file mode 100644 index 7970ccd..0000000 --- a/.spec-kit/specs/001-a-project-manager/research.md +++ /dev/null @@ -1,20 +0,0 @@ -# Research: TUI environments column - -## Decisions -- Use bubbles list for environments pane -- Split layout horizontally: left projects list, right environments list -- Update envs list on ProjectSelectedMsg in Update() -- Collapse environments pane if width < 60 cols -- Precompute lipgloss styles in model init - -## Rationale -- Bubbles list integrates with Bubble Tea and matches existing patterns -- Horizontal split mirrors mental model: select project β†’ see envs -- Update on selection ensures single source of truth in Elm model -- Collapse rule maintains usability on small terminals -- Precomputed styles reduce per-frame cost - -## Alternatives Considered -- Table component: heavier API, unnecessary for simple names -- Vertical split with tabs: wastes vertical space; less discoverable -- Recomputing styles per View(): simpler code but slower rendering \ No newline at end of file diff --git a/.spec-kit/specs/001-a-project-manager/spec.md b/.spec-kit/specs/001-a-project-manager/spec.md deleted file mode 100644 index e2ffbe7..0000000 --- a/.spec-kit/specs/001-a-project-manager/spec.md +++ /dev/null @@ -1,145 +0,0 @@ -# Feature Specification: Project Manager Terminal Application - -**Feature Branch**: `001-a-project-manager` -**Created**: 2025-09-14 -**Status**: Draft -**Input**: User description: "A project manager terminal application, the purpose is manage multiple projects and configurations. With this project you have the habiliti to start new terminal sesions with the configuration, credentials and secrets for a specific project. Also add new projects and edit the configurations. Each project can have multiple environments and settings. Only one project can be executed in a terminal session." - -## Execution Flow (main) -``` -1. Parse user description from Input - ’ If empty: ERROR "No feature description provided" -2. Extract key concepts from description - ’ Identify: actors, actions, data, constraints -3. For each unclear aspect: - ’ Mark with [NEEDS CLARIFICATION: specific question] -4. Fill User Scenarios & Testing section - ’ If no clear user flow: ERROR "Cannot determine user scenarios" -5. Generate Functional Requirements - ’ Each requirement must be testable - ’ Mark ambiguous requirements -6. Identify Key Entities (if data involved) -7. Run Review Checklist - ’ If any [NEEDS CLARIFICATION]: WARN "Spec has uncertainties" - ’ If implementation details found: ERROR "Remove tech details" -8. Return: SUCCESS (spec ready for planning) -``` - ---- - -## ‘ Quick Guidelines --  Focus on WHAT users need and WHY -- L Avoid HOW to implement (no tech stack, APIs, code structure) -- =e Written for business stakeholders, not developers - -### Section Requirements -- Mandatory sections: Must be completed for every feature -- Optional sections: Include only when relevant to the feature -- When a section doesn't apply, remove it entirely (don't leave as "N/A") - -### For AI Generation -When creating this spec from a user prompt: -1. Mark all ambiguities: Use [NEEDS CLARIFICATION: specific question] for any assumption you'd need to make -2. Don't guess: If the prompt doesn't specify something (e.g., "login system" without auth method), mark it -3. Think like a tester: Every vague requirement should fail the "testable and unambiguous" checklist item -4. Common underspecified areas: - - User types and permissions - - Data retention/deletion policies - - Performance targets and scale - - Error handling behaviors - - Integration requirements - - Security/compliance needs - ---- - -## User Scenarios & Testing (mandatory) - -### Primary User Story -As a user managing multiple projects, I want to create projects with environments and start terminal sessions scoped to a selected project and environment so that configuration, credentials, and secrets are correctly applied and isolated per session. - -### Acceptance Scenarios -1. Given no projects exist, When the user creates a project named "Alpha", Then "Alpha" appears in the project list with no environments and a success message is shown. -2. Given project "Alpha" exists, When the user adds environment "dev" with configuration settings and secret references, Then "dev" appears under "Alpha" and its settings are saved. -3. Given project "Alpha" with environment "dev" exists, When the user starts a terminal session for "Alpha" ’ "dev", Then a terminal session starts with only "Alpha:dev" configuration, credentials, and secrets applied, and the session indicator shows the active project and environment. -4. Given a terminal session is active for project "Alpha", When the user attempts to start project "Beta" in the same terminal, Then the action is blocked with a clear message that only one project may run per terminal session and the user is prompted to end the current session or proceed in a new terminal [NEEDS CLARIFICATION: should opening a new terminal be offered automatically?]. -5. Given environment settings for "Alpha:dev" exist, When the user edits configuration values, Then subsequent sessions for "Alpha:dev" reflect the updated settings and a confirmation is shown. -6. Given secrets required by "Alpha:dev" are missing, When the user starts a session, Then the start fails with an actionable error listing missing items without exposing secret values or paths. - -### Edge Cases -- Creating a project or environment with a duplicate name prompts the user to choose a different name. -- Starting a session when one is already active in the same terminal provides a clear resolution path (end current, cancel, or new terminal) [NEEDS CLARIFICATION]. -- Conflicting environment variables between global and project scope are resolved in favor of the active project, with a warning shown [NEEDS CLARIFICATION: should warnings be shown or silently override?]. -- Session start is aborted if any required secret reference is unresolved; partial application must not occur. -- Attempting to switch the active project mid-session requires confirmation and ends the current session before starting the new one. -- Long-running sessions are not disrupted by background project modifications; changes apply only to sessions started after edits. - -## Requirements (mandatory) - -### Functional Requirements -- FR-001: The system MUST allow users to create, view, rename, and delete projects. -- FR-002: The system MUST allow each project to have multiple named environments (e.g., dev, staging, prod). -- FR-003: The system MUST allow users to add, edit, and remove configuration settings per project environment. -- FR-004: The system MUST support referencing credentials and secrets required by an environment without exposing their values in logs or UI. -- FR-005: The system MUST start a terminal session for a selected project and environment, applying only that context's settings. -- FR-006: The system MUST prevent running more than one project in the same terminal session and clearly inform the user when blocked. -- FR-007: The system MUST provide a clear indicator of the active project and environment for the current session. -- FR-008: The system MUST provide commands or UI to list projects and their environments. -- FR-009: The system MUST validate that required configuration and secret references exist before starting a session and fail fast with actionable errors. -- FR-010: The system MUST ensure project-specific settings do not leak into sessions of other projects once a session ends. -- FR-011: The system MUST allow editing configurations for existing projects and environments and persist those changes. -- FR-012: The system MUST avoid printing or storing secret values in plaintext and MUST redact potential secret content from messages and logs. -- FR-013: The system SHOULD provide an option to end the current session gracefully before starting another project [NEEDS CLARIFICATION: define "graceful" termination behavior]. -- FR-014: The system SHOULD support selecting a default environment when starting a session if none is specified [NEEDS CLARIFICATION]. -- FR-015: The system SHOULD support warnings for configuration conflicts (e.g., variable overrides) with user acknowledgement [NEEDS CLARIFICATION: warning behavior]. -- FR-016: The system SHOULD provide basic usage feedback (e.g., last used project/environment) without storing sensitive data [NEEDS CLARIFICATION: retention policy]. - -- FR-017: Security & Compliance Constraints - - FR-017a: Secrets MUST be handled via references only; values are never persisted by the system. - - FR-017b: Logs MUST not include secret values or sensitive file paths. - - FR-017c: The system MUST minimize secret exposure duration and scope to the active session only. - - FR-017d: The system SHOULD support external secret stores (e.g., OS keychain, secret manager) via references [NEEDS CLARIFICATION: which providers]. - -- FR-018: Reliability & UX - - FR-018a: Starting a session MUST fail atomically if validation fails (no partial configuration applied). - - FR-018b: Users MUST receive clear, actionable error messages for missing or invalid configuration. - - FR-018c: The system SHOULD complete session start within a target time budget [NEEDS CLARIFICATION: target seconds]. - -### Key Entities (include if feature involves data) -- Project: Represents a workspace with its own configurations and environments. Key attributes: name (unique), description [NEEDS CLARIFICATION], environments. -- Environment: Represents a named context within a project (e.g., dev, staging). Key attributes: name (unique within project), configuration settings, required secret references, optional notes. -- Configuration Item: Represents a non-secret setting applied to a session (e.g., environment variable, path). Key attributes: key, value, scope (project/environment), is_override [NEEDS CLARIFICATION]. -- Credential/Secret Reference: Represents a pointer to a secret managed outside the system. Key attributes: label, source/provider [NEEDS CLARIFICATION], scope, required/optional. -- Session: Represents an active terminal session bound to a project and environment. Key attributes: project, environment, start time, status, termination reason [NEEDS CLARIFICATION]. - ---- - -## Review & Acceptance Checklist -GATE: Automated checks run during main() execution - -### Content Quality -- [ ] No implementation details (languages, frameworks, APIs) -- [ ] Focused on user value and business needs -- [ ] Written for non-technical stakeholders -- [ ] All mandatory sections completed - -### Requirement Completeness -- [ ] No [NEEDS CLARIFICATION] markers remain -- [ ] Requirements are testable and unambiguous -- [ ] Success criteria are measurable -- [ ] Scope is clearly bounded -- [ ] Dependencies and assumptions identified - ---- - -## Execution Status -Updated by main() during processing - -- [ ] User description parsed -- [ ] Key concepts extracted -- [ ] Ambiguities marked -- [ ] User scenarios defined -- [ ] Requirements generated -- [ ] Entities identified -- [ ] Review checklist passed - ---- diff --git a/.spec-kit/specs/001-a-project-manager/tasks.md b/.spec-kit/specs/001-a-project-manager/tasks.md deleted file mode 100644 index eca782e..0000000 --- a/.spec-kit/specs/001-a-project-manager/tasks.md +++ /dev/null @@ -1,66 +0,0 @@ -# Tasks: TUI environments column - -**Input**: Design documents from `/specs/001-a-project-manager/` -**Prerequisites**: plan.md (required), research.md, data-model.md, contracts/ - -## Execution Flow (main) -``` -Order: Setup β†’ Tests (failing) β†’ Core β†’ Integration β†’ Polish -Apply [P] for independent files; omit [P] when same file or dependent -``` - -## Phase 3.1: Setup -- [x] T001 Ensure dependencies in go.mod: bubbletea, bubbles, lipgloss in /home/jlrosende/personal/project-manager/go.mod -- [x] T002 Create pkg/ui/list and pkg/ui/styles scaffolding in /home/jlrosende/personal/project-manager/pkg/ui -- [x] T003 [P] Add basic README usage notes in docs/cli (skip if already present) - -## Phase 3.2: Tests First (TDD) -- [x] T004 [P] Contract test for ProjectsLoadedMsg handling: derive envs list in tests/integration/tui_projects_loaded_test.go -- [x] T005 [P] Contract test for ProjectSelectedMsg handling: updates envs list in tests/integration/tui_project_selected_test.go -- [x] T006 [P] Integration test for collapse behavior on narrow terminal in tests/integration/tui_collapse_envs_test.go -- [x] T007 [P] Integration test for view rendering envs column when width sufficient in tests/integration/tui_view_envs_test.go - -## Phase 3.3: Core Implementation -- [x] T008 Add UI model fields to internal/adapters/handlers/tui/window.go: selected index, envsForSelected, width/height -- [x] T009 Implement Update() cases for ProjectsLoadedMsg and ProjectSelectedMsg in internal/adapters/handlers/tui/window.go -- [x] T010 Implement WindowSizeMsg handling and collapse logic in internal/adapters/handlers/tui/window.go -- [x] T011 Implement environments list component in /home/jlrosende/personal/project-manager/pkg/ui/list/envs_list.go -- [x] T012 [P] Implement styles for panels and titles in /home/jlrosende/personal/project-manager/pkg/ui/styles/styles.go -- [x] T013 Wire environments column into Window.viewPanelRender() in internal/adapters/handlers/tui/window.go -- [x] T014 Replace hardcoded lorem ipsum with project.Description from services in internal/adapters/handlers/tui/window.go - -## Phase 3.4: Integration -- [x] T015 Use internal/core/services/ProjectService to load projects, map to UI model in internal/adapters/handlers/tui/window.go -- [x] T016 Ensure no secrets or credential values are rendered or logged (defensive check) across UI rendering functions - -## Phase 3.5: Polish -- [x] T017 [P] Unit tests for envs list component in tests/unit/pkg_ui_envs_list_test.go -- [x] T018 [P] Unit tests for styles helpers in tests/unit/pkg_ui_styles_test.go -- [x] T019 Verify performance: render update on selection <50ms (manual measurement notes in docs/rest/perf-notes.md) -- [x] T020 Accessibility/UX: ensure clear focus/selection visuals for lists -- [x] T021 Run Quickstart to manually verify envs column behavior in /home/jlrosende/personal/project-manager/.spec-kit/specs/001-a-project-manager/quickstart.md - -## Dependencies -- T004–T007 before T008–T014 -- T008 blocks T009–T013 -- T011 independent of T009–T010 but required by T013 -- T012 independent; can run parallel with T011 -- T015 depends on services being available; runs after T008 - -## Parallel Example -``` -# Run these in parallel once Setup done and before Core: -Task: "Contract test ProjectsLoadedMsg" (T004) -Task: "Contract test ProjectSelectedMsg" (T005) -Task: "Integration test collapse behavior" (T006) -Task: "Integration test envs view" (T007) - -# Core parallel chunk: -Task: "Implement envs list component" (T011) -Task: "Implement styles helpers" (T012) -``` - -## Notes -- Keep Update() pure and return new state + Cmd -- Avoid logging sensitive data; render only environment names -- Exact file paths included above for all edits and new files \ No newline at end of file diff --git a/.spec-kit/memory/constitution.md b/.specify/memory/constitution.md similarity index 100% rename from .spec-kit/memory/constitution.md rename to .specify/memory/constitution.md diff --git a/.specify/scripts/bash/check-implementation-prerequisites.sh b/.specify/scripts/bash/check-implementation-prerequisites.sh new file mode 100755 index 0000000..c660ffa --- /dev/null +++ b/.specify/scripts/bash/check-implementation-prerequisites.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -e +JSON_MODE=false +for arg in "$@"; do case "$arg" in --json) JSON_MODE=true ;; --help|-h) echo "Usage: $0 [--json]"; exit 0 ;; esac; done +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" +eval $(get_feature_paths) +check_feature_branch "$CURRENT_BRANCH" || exit 1 +if [[ ! -d "$FEATURE_DIR" ]]; then echo "ERROR: Feature directory not found: $FEATURE_DIR"; echo "Run /specify first."; exit 1; fi +if [[ ! -f "$IMPL_PLAN" ]]; then echo "ERROR: plan.md not found in $FEATURE_DIR"; echo "Run /plan first."; exit 1; fi +if [[ ! -f "$TASKS" ]]; then echo "ERROR: tasks.md not found in $FEATURE_DIR"; echo "Run /tasks first."; exit 1; fi +if $JSON_MODE; then + docs=(); [[ -f "$RESEARCH" ]] && docs+=("research.md"); [[ -f "$DATA_MODEL" ]] && docs+=("data-model.md"); ([[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]) && docs+=("contracts/"); [[ -f "$QUICKSTART" ]] && docs+=("quickstart.md"); [[ -f "$TASKS" ]] && docs+=("tasks.md"); + json_docs=$(printf '"%s",' "${docs[@]}"); json_docs="[${json_docs%,}]"; printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$FEATURE_DIR" "$json_docs" +else + echo "FEATURE_DIR:$FEATURE_DIR"; echo "AVAILABLE_DOCS:"; check_file "$RESEARCH" "research.md"; check_file "$DATA_MODEL" "data-model.md"; check_dir "$CONTRACTS_DIR" "contracts/"; check_file "$QUICKSTART" "quickstart.md"; check_file "$TASKS" "tasks.md"; fi \ No newline at end of file diff --git a/.specify/scripts/bash/check-task-prerequisites.sh b/.specify/scripts/bash/check-task-prerequisites.sh new file mode 100755 index 0000000..e578f86 --- /dev/null +++ b/.specify/scripts/bash/check-task-prerequisites.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -e +JSON_MODE=false +for arg in "$@"; do case "$arg" in --json) JSON_MODE=true ;; --help|-h) echo "Usage: $0 [--json]"; exit 0 ;; esac; done +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" +eval $(get_feature_paths) +check_feature_branch "$CURRENT_BRANCH" || exit 1 +if [[ ! -d "$FEATURE_DIR" ]]; then echo "ERROR: Feature directory not found: $FEATURE_DIR"; echo "Run /specify first."; exit 1; fi +if [[ ! -f "$IMPL_PLAN" ]]; then echo "ERROR: plan.md not found in $FEATURE_DIR"; echo "Run /plan first."; exit 1; fi +if $JSON_MODE; then + docs=(); [[ -f "$RESEARCH" ]] && docs+=("research.md"); [[ -f "$DATA_MODEL" ]] && docs+=("data-model.md"); ([[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]) && docs+=("contracts/"); [[ -f "$QUICKSTART" ]] && docs+=("quickstart.md"); + json_docs=$(printf '"%s",' "${docs[@]}"); json_docs="[${json_docs%,}]"; printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$FEATURE_DIR" "$json_docs" +else + echo "FEATURE_DIR:$FEATURE_DIR"; echo "AVAILABLE_DOCS:"; check_file "$RESEARCH" "research.md"; check_file "$DATA_MODEL" "data-model.md"; check_dir "$CONTRACTS_DIR" "contracts/"; check_file "$QUICKSTART" "quickstart.md"; fi diff --git a/.specify/scripts/bash/common.sh b/.specify/scripts/bash/common.sh new file mode 100755 index 0000000..582d940 --- /dev/null +++ b/.specify/scripts/bash/common.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# (Moved to scripts/bash/) Common functions and variables for all scripts + +get_repo_root() { git rev-parse --show-toplevel; } +get_current_branch() { git rev-parse --abbrev-ref HEAD; } + +check_feature_branch() { + local branch="$1" + if [[ ! "$branch" =~ ^[0-9]{3}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 + echo "Feature branches should be named like: 001-feature-name" >&2 + return 1 + fi; return 0 +} + +get_feature_dir() { echo "$1/specs/$2"; } + +get_feature_paths() { + local repo_root=$(get_repo_root) + local current_branch=$(get_current_branch) + local feature_dir=$(get_feature_dir "$repo_root" "$current_branch") + cat </dev/null) ]] && echo " βœ“ $2" || echo " βœ— $2"; } diff --git a/.specify/scripts/bash/create-new-feature.sh b/.specify/scripts/bash/create-new-feature.sh new file mode 100755 index 0000000..575e714 --- /dev/null +++ b/.specify/scripts/bash/create-new-feature.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# (Moved to scripts/bash/) Create a new feature with branch, directory structure, and template +set -e + +JSON_MODE=false +ARGS=() +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=true ;; + --help|-h) echo "Usage: $0 [--json] "; exit 0 ;; + *) ARGS+=("$arg") ;; + esac +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] " >&2 + exit 1 +fi + +# Resolve repository root. Prefer git information when available, but fall back +# to the script location so the workflow still functions in repositories that +# were initialised with --no-git. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FALLBACK_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +if git rev-parse --show-toplevel >/dev/null 2>&1; then + REPO_ROOT=$(git rev-parse --show-toplevel) + HAS_GIT=true +else + REPO_ROOT="$FALLBACK_ROOT" + HAS_GIT=false +fi + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" +mkdir -p "$SPECS_DIR" + +HIGHEST=0 +if [ -d "$SPECS_DIR" ]; then + for dir in "$SPECS_DIR"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$HIGHEST" ]; then HIGHEST=$number; fi + done +fi + +NEXT=$((HIGHEST + 1)) +FEATURE_NUM=$(printf "%03d" "$NEXT") + +BRANCH_NAME=$(echo "$FEATURE_DESCRIPTION" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//') +WORDS=$(echo "$BRANCH_NAME" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//') +BRANCH_NAME="${FEATURE_NUM}-${WORDS}" + +if [ "$HAS_GIT" = true ]; then + git checkout -b "$BRANCH_NAME" +else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" +fi + +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +mkdir -p "$FEATURE_DIR" + +TEMPLATE="$REPO_ROOT/templates/spec-template.md" +SPEC_FILE="$FEATURE_DIR/spec.md" +if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi + +if $JSON_MODE; then + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM" +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "SPEC_FILE: $SPEC_FILE" + echo "FEATURE_NUM: $FEATURE_NUM" +fi diff --git a/.specify/scripts/bash/get-feature-paths.sh b/.specify/scripts/bash/get-feature-paths.sh new file mode 100755 index 0000000..016727d --- /dev/null +++ b/.specify/scripts/bash/get-feature-paths.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" +eval $(get_feature_paths) +check_feature_branch "$CURRENT_BRANCH" || exit 1 +echo "REPO_ROOT: $REPO_ROOT"; echo "BRANCH: $CURRENT_BRANCH"; echo "FEATURE_DIR: $FEATURE_DIR"; echo "FEATURE_SPEC: $FEATURE_SPEC"; echo "IMPL_PLAN: $IMPL_PLAN"; echo "TASKS: $TASKS" diff --git a/.specify/scripts/bash/setup-plan.sh b/.specify/scripts/bash/setup-plan.sh new file mode 100755 index 0000000..1da4265 --- /dev/null +++ b/.specify/scripts/bash/setup-plan.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -e +JSON_MODE=false +for arg in "$@"; do case "$arg" in --json) JSON_MODE=true ;; --help|-h) echo "Usage: $0 [--json]"; exit 0 ;; esac; done +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" +eval $(get_feature_paths) +check_feature_branch "$CURRENT_BRANCH" || exit 1 +mkdir -p "$FEATURE_DIR" +TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md" +[[ -f "$TEMPLATE" ]] && cp "$TEMPLATE" "$IMPL_PLAN" +if $JSON_MODE; then + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \ + "$FEATURE_SPEC" "$IMPL_PLAN" "$FEATURE_DIR" "$CURRENT_BRANCH" +else + echo "FEATURE_SPEC: $FEATURE_SPEC"; echo "IMPL_PLAN: $IMPL_PLAN"; echo "SPECS_DIR: $FEATURE_DIR"; echo "BRANCH: $CURRENT_BRANCH" +fi diff --git a/.specify/scripts/bash/update-agent-context.sh b/.specify/scripts/bash/update-agent-context.sh new file mode 100755 index 0000000..4f9e6e3 --- /dev/null +++ b/.specify/scripts/bash/update-agent-context.sh @@ -0,0 +1,683 @@ +#!/usr/bin/env bash + +# Update agent context files with information from plan.md +# +# This script maintains AI agent context files by parsing feature specifications +# and updating agent-specific configuration files with project information. +# +# MAIN FUNCTIONS: +# 1. Environment Validation +# - Verifies git repository structure and branch information +# - Checks for required plan.md files and templates +# - Validates file permissions and accessibility +# +# 2. Plan Data Extraction +# - Parses plan.md files to extract project metadata +# - Identifies language/version, frameworks, databases, and project types +# - Handles missing or incomplete specification data gracefully +# +# 3. Agent File Management +# - Creates new agent context files from templates when needed +# - Updates existing agent files with new project information +# - Preserves manual additions and custom configurations +# - Supports multiple AI agent formats and directory structures +# +# 4. Content Generation +# - Generates language-specific build/test commands +# - Creates appropriate project directory structures +# - Updates technology stacks and recent changes sections +# - Maintains consistent formatting and timestamps +# +# 5. Multi-Agent Support +# - Handles agent-specific file paths and naming conventions +# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf +# - Can update single agents or all existing agent files +# - Creates default Claude file if no agent files exist +# +# Usage: ./update-agent-context.sh [agent_type] +# Agent types: claude|gemini|copilot|cursor|qwen|opencode|codex|windsurf +# Leave empty to update all existing agent files + +set -e + +# Enable strict error handling +set -u +set -o pipefail + +#============================================================================== +# Configuration and Global Variables +#============================================================================== + +REPO_ROOT=$(git rev-parse --show-toplevel) +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +FEATURE_DIR="$REPO_ROOT/specs/$CURRENT_BRANCH" +NEW_PLAN="$FEATURE_DIR/plan.md" +AGENT_TYPE="${1:-}" + +# Agent-specific file paths +CLAUDE_FILE="$REPO_ROOT/CLAUDE.md" +GEMINI_FILE="$REPO_ROOT/GEMINI.md" +COPILOT_FILE="$REPO_ROOT/.github/copilot-instructions.md" +CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc" +QWEN_FILE="$REPO_ROOT/QWEN.md" +AGENTS_FILE="$REPO_ROOT/AGENTS.md" +WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md" + +# Template file +TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md" + +# Global variables for parsed plan data +NEW_LANG="" +NEW_FRAMEWORK="" +NEW_DB="" +NEW_PROJECT_TYPE="" + +#============================================================================== +# Utility Functions +#============================================================================== + +log_info() { + echo "INFO: $1" +} + +log_success() { + echo "βœ“ $1" +} + +log_error() { + echo "ERROR: $1" >&2 +} + +log_warning() { + echo "WARNING: $1" >&2 +} + +# Cleanup function for temporary files +cleanup() { + local exit_code=$? + rm -f /tmp/agent_update_*_$$ + rm -f /tmp/manual_additions_$$ + exit $exit_code +} + +# Set up cleanup trap +trap cleanup EXIT INT TERM + +#============================================================================== +# Validation Functions +#============================================================================== + +validate_environment() { + # Check if we're in a git repository + if ! git rev-parse --show-toplevel >/dev/null 2>&1; then + log_error "Not in a git repository" + exit 1 + fi + + # Check if we have a current branch + if [[ -z "$CURRENT_BRANCH" ]]; then + log_error "Unable to determine current git branch" + exit 1 + fi + + # Check if plan.md exists + if [[ ! -f "$NEW_PLAN" ]]; then + log_error "No plan.md found at $NEW_PLAN" + log_info "Make sure you're on a feature branch with a corresponding spec directory" + exit 1 + fi + + # Check if template exists (needed for new files) + if [[ ! -f "$TEMPLATE_FILE" ]]; then + log_warning "Template file not found at $TEMPLATE_FILE" + log_warning "Creating new agent files will fail" + fi +} + +#============================================================================== +# Plan Parsing Functions +#============================================================================== + +extract_plan_field() { + local field_pattern="$1" + local plan_file="$2" + + grep "^**${field_pattern}**: " "$plan_file" 2>/dev/null | \ + head -1 | \ + sed "s/^**${field_pattern}**: //" | \ + grep -v "NEEDS CLARIFICATION" | \ + grep -v "^N/A$" || echo "" +} + +parse_plan_data() { + local plan_file="$1" + + if [[ ! -f "$plan_file" ]]; then + log_error "Plan file not found: $plan_file" + return 1 + fi + + if [[ ! -r "$plan_file" ]]; then + log_error "Plan file is not readable: $plan_file" + return 1 + fi + + log_info "Parsing plan data from $plan_file" + + NEW_LANG=$(extract_plan_field "Language/Version" "$plan_file") + NEW_FRAMEWORK=$(extract_plan_field "Primary Dependencies" "$plan_file") + NEW_DB=$(extract_plan_field "Storage" "$plan_file") + NEW_PROJECT_TYPE=$(extract_plan_field "Project Type" "$plan_file") + + # Log what we found + if [[ -n "$NEW_LANG" ]]; then + log_info "Found language: $NEW_LANG" + else + log_warning "No language information found in plan" + fi + + if [[ -n "$NEW_FRAMEWORK" ]]; then + log_info "Found framework: $NEW_FRAMEWORK" + fi + + if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then + log_info "Found database: $NEW_DB" + fi + + if [[ -n "$NEW_PROJECT_TYPE" ]]; then + log_info "Found project type: $NEW_PROJECT_TYPE" + fi +} +#============================================================================== +# Template and Content Generation Functions +#============================================================================== + +get_project_structure() { + local project_type="$1" + + if [[ "$project_type" == *"web"* ]]; then + echo "backend/ +frontend/ +tests/" + else + echo "src/ +tests/" + fi +} + +get_commands_for_language() { + local lang="$1" + + case "$lang" in + *"Python"*) + echo "cd src && pytest && ruff check ." + ;; + *"Rust"*) + echo "cargo test && cargo clippy" + ;; + *"JavaScript"*|*"TypeScript"*) + echo "npm test && npm run lint" + ;; + *) + echo "# Add commands for $lang" + ;; + esac +} + +get_language_conventions() { + local lang="$1" + echo "$lang: Follow standard conventions" +} + +create_new_agent_file() { + local target_file="$1" + local temp_file="$2" + local project_name="$3" + local current_date="$4" + + if [[ ! -f "$TEMPLATE_FILE" ]]; then + log_error "Template not found at $TEMPLATE_FILE" + return 1 + fi + + if [[ ! -r "$TEMPLATE_FILE" ]]; then + log_error "Template file is not readable: $TEMPLATE_FILE" + return 1 + fi + + log_info "Creating new agent context file from template..." + + if ! cp "$TEMPLATE_FILE" "$temp_file"; then + log_error "Failed to copy template file" + return 1 + fi + + # Replace template placeholders + local project_structure + project_structure=$(get_project_structure "$NEW_PROJECT_TYPE") + + local commands + commands=$(get_commands_for_language "$NEW_LANG") + + local language_conventions + language_conventions=$(get_language_conventions "$NEW_LANG") + + # Perform substitutions with error checking + local substitutions=( + "s/\[PROJECT NAME\]/$project_name/" + "s/\[DATE\]/$current_date/" + "s/\[EXTRACTED FROM ALL PLAN.MD FILES\]/- $NEW_LANG + $NEW_FRAMEWORK ($CURRENT_BRANCH)/" + "s|\[ACTUAL STRUCTURE FROM PLANS\]|$project_structure|" + "s|\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]|$commands|" + "s|\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]|$language_conventions|" + "s|\[LAST 3 FEATURES AND WHAT THEY ADDED\]|- $CURRENT_BRANCH: Added $NEW_LANG + $NEW_FRAMEWORK|" + ) + + for substitution in "${substitutions[@]}"; do + if ! sed -i.bak "$substitution" "$temp_file"; then + log_error "Failed to perform substitution: $substitution" + rm -f "$temp_file" "$temp_file.bak" + return 1 + fi + done + + # Clean up backup files + rm -f "$temp_file.bak" + + return 0 +} +update_active_technologies() { + local target_file="$1" + local temp_file="$2" + + # Find the Active Technologies section and add new entries + local tech_section_start + tech_section_start=$(grep -n "## Active Technologies" "$target_file" | cut -d: -f1) + + if [[ -z "$tech_section_start" ]]; then + return 0 # No Active Technologies section found + fi + + # Find the end of the Active Technologies section (next ## heading or empty line) + local tech_section_end + tech_section_end=$(tail -n +$((tech_section_start + 1)) "$target_file" | grep -n "^## \|^$" | head -1 | cut -d: -f1) + + if [[ -n "$tech_section_end" ]]; then + tech_section_end=$((tech_section_start + tech_section_end)) + else + tech_section_end=$(wc -l < "$target_file") + fi + + # Extract existing technologies section + local existing_tech + existing_tech=$(sed -n "${tech_section_start},${tech_section_end}p" "$target_file") + + # Build list of new additions + local additions=() + if [[ -n "$NEW_LANG" ]] && ! echo "$existing_tech" | grep -q "$NEW_LANG"; then + additions+=("- $NEW_LANG + $NEW_FRAMEWORK ($CURRENT_BRANCH)") + fi + + if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && ! echo "$existing_tech" | grep -q "$NEW_DB"; then + additions+=("- $NEW_DB ($CURRENT_BRANCH)") + fi + + # If we have additions, update the section + if [[ ${#additions[@]} -gt 0 ]]; then + { + # Copy everything before the Active Technologies section + head -n $((tech_section_start)) "$target_file" + + # Copy existing tech section content + sed -n "$((tech_section_start + 1)),$((tech_section_end - 1))p" "$target_file" + + # Add new technologies + printf '%s\n' "${additions[@]}" + echo + + # Copy everything after the Active Technologies section + tail -n +$((tech_section_end + 1)) "$target_file" + } > "$temp_file" + else + cp "$target_file" "$temp_file" + fi +} + +update_recent_changes() { + local temp_file="$1" + local temp_file2="$2" + + # Find Recent Changes section + local changes_section_start + changes_section_start=$(grep -n "## Recent Changes" "$temp_file" | cut -d: -f1) + + if [[ -z "$changes_section_start" ]]; then + return 0 # No Recent Changes section found + fi + + # Find the end of the Recent Changes section + local changes_section_end + changes_section_end=$(tail -n +$((changes_section_start + 1)) "$temp_file" | grep -n "^## \|^$" | head -1 | cut -d: -f1) + + if [[ -n "$changes_section_end" ]]; then + changes_section_end=$((changes_section_start + changes_section_end)) + else + changes_section_end=$(wc -l < "$temp_file") + fi + + # Extract existing changes, keep only non-empty lines, and limit to 2 (so we can add 1 new one) + local existing_changes=() + while IFS= read -r line; do + if [[ -n "$line" ]] && [[ "$line" == "- "* ]]; then + existing_changes+=("$line") + fi + done < <(sed -n "$((changes_section_start + 1)),$((changes_section_end - 1))p" "$temp_file") + + # Keep only the first 2 existing changes + existing_changes=("${existing_changes[@]:0:2}") + + # Create updated Recent Changes section + { + # Copy everything before Recent Changes + head -n "$changes_section_start" "$temp_file" + + # Add new change at the top + echo "- $CURRENT_BRANCH: Added $NEW_LANG + $NEW_FRAMEWORK" + + # Add existing changes (up to 2) + printf '%s\n' "${existing_changes[@]}" + echo + + # Copy everything after Recent Changes section + tail -n +$((changes_section_end + 1)) "$temp_file" + } > "$temp_file2" +} + +update_last_updated() { + local temp_file="$1" + local current_date="$2" + + # Update the "Last updated" timestamp + sed -i.bak "s/Last updated: [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/Last updated: $current_date/" "$temp_file" + rm -f "$temp_file.bak" +} + +preserve_manual_additions() { + local target_file="$1" + local temp_file="$2" + + # Check if there are manual additions to preserve + local manual_start manual_end + manual_start=$(grep -n "" "$target_file" 2>/dev/null | cut -d: -f1 || echo "") + manual_end=$(grep -n "" "$target_file" 2>/dev/null | cut -d: -f1 || echo "") + + if [[ -n "$manual_start" ]] && [[ -n "$manual_end" ]]; then + # Extract manual additions + local manual_file="/tmp/manual_additions_$$" + sed -n "${manual_start},${manual_end}p" "$target_file" > "$manual_file" + + # Remove any existing manual additions from temp file + sed -i.bak '//,//d' "$temp_file" + rm -f "$temp_file.bak" + + # Append preserved manual additions + cat "$manual_file" >> "$temp_file" + rm -f "$manual_file" + fi +} + +update_existing_agent_file() { + local target_file="$1" + local current_date="$2" + + log_info "Updating existing agent context file..." + + local temp_file1="/tmp/agent_update_1_$$" + local temp_file2="/tmp/agent_update_2_$$" + + # Step 1: Update Active Technologies section + update_active_technologies "$target_file" "$temp_file1" + + # Step 2: Update Recent Changes section + update_recent_changes "$temp_file1" "$temp_file2" + + # Step 3: Update timestamp + update_last_updated "$temp_file2" "$current_date" + + # Step 4: Preserve manual additions + preserve_manual_additions "$target_file" "$temp_file2" + + # Move the final result to target + mv "$temp_file2" "$target_file" + + # Cleanup + rm -f "$temp_file1" +} +#============================================================================== +# Main Agent File Update Function +#============================================================================== + +update_agent_file() { + local target_file="$1" + local agent_name="$2" + + if [[ -z "$target_file" ]] || [[ -z "$agent_name" ]]; then + log_error "update_agent_file requires target_file and agent_name parameters" + return 1 + fi + + log_info "Updating $agent_name context file: $target_file" + + local project_name + project_name=$(basename "$REPO_ROOT") + local current_date + current_date=$(date +%Y-%m-%d) + + # Create directory if it doesn't exist + local target_dir + target_dir=$(dirname "$target_file") + if [[ ! -d "$target_dir" ]]; then + if ! mkdir -p "$target_dir"; then + log_error "Failed to create directory: $target_dir" + return 1 + fi + fi + + if [[ ! -f "$target_file" ]]; then + # Create new file from template + local temp_file + temp_file=$(mktemp) || { + log_error "Failed to create temporary file" + return 1 + } + + if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then + if mv "$temp_file" "$target_file"; then + log_success "Created new $agent_name context file" + else + log_error "Failed to move temporary file to $target_file" + rm -f "$temp_file" + return 1 + fi + else + log_error "Failed to create new agent file" + rm -f "$temp_file" + return 1 + fi + else + # Update existing file + if [[ ! -r "$target_file" ]]; then + log_error "Cannot read existing file: $target_file" + return 1 + fi + + if [[ ! -w "$target_file" ]]; then + log_error "Cannot write to existing file: $target_file" + return 1 + fi + + if update_existing_agent_file "$target_file" "$current_date"; then + log_success "Updated existing $agent_name context file" + else + log_error "Failed to update existing agent file" + return 1 + fi + fi + + return 0 +} + +#============================================================================== +# Agent Selection and Processing +#============================================================================== + +update_specific_agent() { + local agent_type="$1" + + case "$agent_type" in + claude) + update_agent_file "$CLAUDE_FILE" "Claude Code" + ;; + gemini) + update_agent_file "$GEMINI_FILE" "Gemini CLI" + ;; + copilot) + update_agent_file "$COPILOT_FILE" "GitHub Copilot" + ;; + cursor) + update_agent_file "$CURSOR_FILE" "Cursor IDE" + ;; + qwen) + update_agent_file "$QWEN_FILE" "Qwen Code" + ;; + opencode) + update_agent_file "$AGENTS_FILE" "opencode" + ;; + codex) + update_agent_file "$AGENTS_FILE" "Codex CLI" + ;; + windsurf) + update_agent_file "$WINDSURF_FILE" "Windsurf" + ;; + *) + log_error "Unknown agent type '$agent_type'" + log_error "Expected: claude|gemini|copilot|cursor|qwen|opencode|codex|windsurf" + exit 1 + ;; + esac +} + +update_all_existing_agents() { + local found_agent=false + + # Check each possible agent file and update if it exists + if [[ -f "$CLAUDE_FILE" ]]; then + update_agent_file "$CLAUDE_FILE" "Claude Code" + found_agent=true + fi + + if [[ -f "$GEMINI_FILE" ]]; then + update_agent_file "$GEMINI_FILE" "Gemini CLI" + found_agent=true + fi + + if [[ -f "$COPILOT_FILE" ]]; then + update_agent_file "$COPILOT_FILE" "GitHub Copilot" + found_agent=true + fi + + if [[ -f "$CURSOR_FILE" ]]; then + update_agent_file "$CURSOR_FILE" "Cursor IDE" + found_agent=true + fi + + if [[ -f "$QWEN_FILE" ]]; then + update_agent_file "$QWEN_FILE" "Qwen Code" + found_agent=true + fi + + if [[ -f "$AGENTS_FILE" ]]; then + update_agent_file "$AGENTS_FILE" "Codex/opencode" + found_agent=true + fi + + if [[ -f "$WINDSURF_FILE" ]]; then + update_agent_file "$WINDSURF_FILE" "Windsurf" + found_agent=true + fi + + # If no agent files exist, create a default Claude file + if [[ "$found_agent" == false ]]; then + log_info "No existing agent files found, creating default Claude file..." + update_agent_file "$CLAUDE_FILE" "Claude Code" + fi +} +print_summary() { + echo + log_info "Summary of changes:" + + if [[ -n "$NEW_LANG" ]]; then + echo " - Added language: $NEW_LANG" + fi + + if [[ -n "$NEW_FRAMEWORK" ]]; then + echo " - Added framework: $NEW_FRAMEWORK" + fi + + if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then + echo " - Added database: $NEW_DB" + fi + + echo + log_info "Usage: $0 [claude|gemini|copilot|cursor|qwen|opencode|codex|windsurf]" +} + +#============================================================================== +# Main Execution +#============================================================================== + +main() { + # Validate environment before proceeding + validate_environment + + log_info "=== Updating agent context files for feature $CURRENT_BRANCH ===" + + # Parse the plan file to extract project information + if ! parse_plan_data "$NEW_PLAN"; then + log_error "Failed to parse plan data" + exit 1 + fi + + # Process based on agent type argument + local success=true + + if [[ -z "$AGENT_TYPE" ]]; then + # No specific agent provided - update all existing agent files + log_info "No agent specified, updating all existing agent files..." + if ! update_all_existing_agents; then + success=false + fi + else + # Specific agent provided - update only that agent + log_info "Updating specific agent: $AGENT_TYPE" + if ! update_specific_agent "$AGENT_TYPE"; then + success=false + fi + fi + + # Print summary + print_summary + + if [[ "$success" == true ]]; then + log_success "Agent context update completed successfully" + exit 0 + else + log_error "Agent context update completed with errors" + exit 1 + fi +} + +# Execute main function if script is run directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/.spec-kit/templates/agent-file-template.md b/.specify/templates/agent-file-template.md similarity index 100% rename from .spec-kit/templates/agent-file-template.md rename to .specify/templates/agent-file-template.md diff --git a/.spec-kit/templates/plan-template.md b/.specify/templates/plan-template.md similarity index 81% rename from .spec-kit/templates/plan-template.md rename to .specify/templates/plan-template.md index f28a655..c18be70 100644 --- a/.spec-kit/templates/plan-template.md +++ b/.specify/templates/plan-template.md @@ -1,3 +1,4 @@ + # Implementation Plan: [FEATURE] **Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] @@ -10,18 +11,19 @@ 2. Fill Technical Context (scan for NEEDS CLARIFICATION) β†’ Detect Project Type from context (web=frontend+backend, mobile=app+api) β†’ Set Structure Decision based on project type -3. Evaluate Constitution Check section below +3. Fill the Constitution Check section based on the content of the constitution document. +4. Evaluate Constitution Check section below β†’ If violations exist: Document in Complexity Tracking β†’ If no justification possible: ERROR "Simplify approach first" β†’ Update Progress Tracking: Initial Constitution Check -4. Execute Phase 0 β†’ research.md +5. Execute Phase 0 β†’ research.md β†’ If NEEDS CLARIFICATION remain: ERROR "Resolve unknowns" -5. Execute Phase 1 β†’ contracts, data-model.md, quickstart.md, agent-specific template file (e.g., `CLAUDE.md` for Claude Code, `.github/copilot-instructions.md` for GitHub Copilot, or `GEMINI.md` for Gemini CLI). -6. Re-evaluate Constitution Check section +6. Execute Phase 1 β†’ contracts, data-model.md, quickstart.md, agent-specific template file (e.g., `CLAUDE.md` for Claude Code, `.github/copilot-instructions.md` for GitHub Copilot, `GEMINI.md` for Gemini CLI, `QWEN.md` for Qwen Code or `AGENTS.md` for opencode). +7. Re-evaluate Constitution Check section β†’ If new violations: Refactor design, return to Phase 1 β†’ Update Progress Tracking: Post-Design Constitution Check -7. Plan Phase 2 β†’ Describe task generation approach (DO NOT create tasks.md) -8. STOP - Ready for /tasks command +8. Plan Phase 2 β†’ Describe task generation approach (DO NOT create tasks.md) +9. STOP - Ready for /tasks command ``` **IMPORTANT**: The /plan command STOPS at step 7. Phases 2-4 are executed by other commands: @@ -45,35 +47,7 @@ ## Constitution Check *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* -**Simplicity**: -- Projects: [#] (max 3 - e.g., api, cli, tests) -- Using framework directly? (no wrapper classes) -- Single data model? (no DTOs unless serialization differs) -- Avoiding patterns? (no Repository/UoW without proven need) - -**Architecture**: -- EVERY feature as library? (no direct app code) -- Libraries listed: [name + purpose for each] -- CLI per library: [commands with --help/--version/--format] -- Library docs: llms.txt format planned? - -**Testing (NON-NEGOTIABLE)**: -- RED-GREEN-Refactor cycle enforced? (test MUST fail first) -- Git commits show tests before implementation? -- Order: Contractβ†’Integrationβ†’E2Eβ†’Unit strictly followed? -- Real dependencies used? (actual DBs, not mocks) -- Integration tests for: new libraries, contract changes, shared schemas? -- FORBIDDEN: Implementation before test, skipping RED phase - -**Observability**: -- Structured logging included? -- Frontend logs β†’ backend? (unified stream) -- Error context sufficient? - -**Versioning**: -- Version number assigned? (MAJOR.MINOR.BUILD) -- BUILD increments on every change? -- Breaking changes handled? (parallel tests, migration plan) +[Gates determined based on constitution file] ## Project Structure @@ -171,7 +145,7 @@ ios/ or android/ - Quickstart test = story validation steps 5. **Update agent file incrementally** (O(1) operation): - - Run `/scripts/update-agent-context.sh [claude|gemini|copilot]` for your AI assistant + - Run `.specify/scripts/bash/update-agent-context.sh copilot` for your AI assistant - If exists: Add only NEW tech from current plan - Preserve manual additions between markers - Update recent changes (keep last 3) @@ -184,7 +158,7 @@ ios/ or android/ *This section describes what the /tasks command will do - DO NOT execute during /plan* **Task Generation Strategy**: -- Load `/templates/tasks-template.md` as base +- Load `.specify/templates/tasks-template.md` as base - Generate tasks from Phase 1 design docs (contracts, data model, quickstart) - Each contract β†’ contract test task [P] - Each entity β†’ model creation task [P] @@ -234,4 +208,4 @@ ios/ or android/ - [ ] Complexity deviations documented --- -*Based on Constitution v2.1.1 - See `/memory/constitution.md`* \ No newline at end of file +*Based on Constitution v2.1.1 - See `/memory/constitution.md`* diff --git a/.spec-kit/templates/spec-template.md b/.specify/templates/spec-template.md similarity index 100% rename from .spec-kit/templates/spec-template.md rename to .specify/templates/spec-template.md diff --git a/.spec-kit/templates/tasks-template.md b/.specify/templates/tasks-template.md similarity index 100% rename from .spec-kit/templates/tasks-template.md rename to .specify/templates/tasks-template.md diff --git a/README.md b/README.md index 1cd8bfe..9138178 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,24 @@ A simple CLI/TUI tool to manage development projects and their environments. It helps you: -- Browse, create, and start projects from a Terminal UI -- Manage per-project environments (e.g., dev/staging) with .env-style variables -- Generate project configuration files (.project.hcl, .env, .env.) -- Configure per-project Git settings via includeIf in your global ~/.gitconfig +- Browse, create, and start projects from a Terminal UI +- Manage per-project environments (e.g., dev/staging) with .env-style variables +- Generate project configuration files (.project.hcl, .env, .env.) +- Configure per-project Git settings via includeIf in your global ~/.gitconfig ## Requirements -- Go 1.25+ -- Git installed and available in PATH -- Optional: GPG configured if you plan to sign commits/tags +- Go 1.25+ +- Git installed and available in PATH +- Optional: GPG configured if you plan to sign commits/tags ## Install ### Option 1: Download a Release (recommended) -- Download the latest binary for your OS/arch from the Releases page -- Rename to `pm` if needed and place it somewhere in your PATH (e.g., `~/bin`, `/usr/local/bin`) -- Make it executable: `chmod +x pm` +- Download the latest binary for your OS/arch from the Releases page +- Rename to `pm` if needed and place it somewhere in your PATH (e.g., `~/bin`, `/usr/local/bin`) +- Make it executable: `chmod +x pm` ### Option 2: Install from Source with Go @@ -44,68 +44,118 @@ make build pm ``` -- Left column: Projects -- Right column: Environments for the selected project -- Keys: - - Navigation: ↑/k, ↓/j - - Focus columns: ←/h, β†’/l - - Switch fields in forms: Tab / Shift+Tab, ↑/↓ (textarea keeps Enter for newlines) - - Select / Next: Enter - - Save in forms: Ctrl+S - - Cancel / Exit: Esc or Ctrl+C +- Left column: Projects +- Right column: Environments for the selected project +- Keys: + - Navigation: ↑/k, ↓/j + - Focus columns: ←/h, β†’/l + - Switch fields in forms: Tab / Shift+Tab, ↑/↓ (textarea keeps Enter for newlines) + - Select / Next: Enter + - Save in forms: Ctrl+S + - Cancel / Exit: Esc or Ctrl+C ### Create a new project (TUI) -- Select β€œ+ New project” and press Enter -- Fill required fields: - - Name* - - Path* (auto-fills from Name while unchanged; you can edit at any time) - - Optional Git config: user.name, user.email, user.signingkey, commit.gpgsign, tag.gpgsign - - Optional Subproject path - - Optional Env vars (textarea, .env-style: one KEY=VALUE per line; lines starting with `#` are comments) -- Press Ctrl+S or select Save -- After creation, choose to return to the list, start the project, or exit +- Select β€œ+ New project” and press Enter +- Fill required fields: + - Name\* + - Path\* (auto-fills from Name while unchanged; you can edit at any time) + - Optional Git config: user.name, user.email, user.signingkey, commit.gpgsign, tag.gpgsign + - Optional Subproject path + - Optional Env vars (textarea, .env-style: one KEY=VALUE per line; lines starting with `#` are comments) +- Press Ctrl+S or select Save +- After creation, choose to return to the list, start the project, or exit What gets created: -- Directory at the selected Path -- `.project.hcl` with project metadata -- `.env` containing initial environment variables (if provided) -- Per-project `.gitconfig` and includeIf entry added to your global `~/.gitconfig` + +- Directory at the selected Path +- `.project.hcl` with project metadata +- `.env` containing initial environment variables (if provided) +- Per-project `.gitconfig` and includeIf entry added to your global `~/.gitconfig` ### Add a new environment (TUI) -- With a project selected, focus the Environments column -- Select β€œ+ New environment” and press Enter -- Fields: - - Env name* (e.g., `staging`) - - Color (name/#hex/0-255, defaults to grey; previewed live) - - Env vars mode* (`merge` or `replace`) - - Env vars (textarea, .env-style) -- Press Ctrl+S or select Save +- With a project selected, focus the Environments column +- Select β€œ+ New environment” and press Enter +- Fields: + - Env name\* (e.g., `staging`) + - Color (name/#hex/0-255, defaults to grey; previewed live) + - Env vars mode\* (`merge` or `replace`) + - Env vars (textarea, .env-style) +- Press Ctrl+S or select Save What gets created: -- `.env.` in the project directory -- Environment block appended to `.project.hcl` (color, env vars mode, file reference) + +- `.env.` in the project directory +- Environment block appended to `.project.hcl` (color, env vars mode, file reference) ## Commands (non-TUI) This repository also contains basic subcommands: -- `pm` – launches the TUI -- `pm list` – lists known projects -- `pm new` – creates a project (interactive; flags may be available depending on version) -- `pm edit` – edit project configuration (if present in your build) +- `pm` – launches the TUI +- `pm list` – lists known projects +- `pm new` – creates a project (interactive; flags may be available depending on version) +- `pm edit` – edit project configuration (if present in your build) Tip: Use `pm --help` or `pm --help` for details available in your version. ## Troubleshooting -- If the TUI doesn’t render correctly after closing a form, ensure your terminal supports alternate screen and try a clean redraw. -- If path validation fails: - - The directory must either not exist, or exist and be empty - - The parent directory must exist -- If includeIf changes are not applied, check your `~/.gitconfig` permissions and content. +- If the TUI doesn’t render correctly after closing a form, ensure your terminal supports alternate screen and try a clean redraw. +- If path validation fails: + - The directory must either not exist, or exist and be empty + - The parent directory must exist +- If includeIf changes are not applied, check your `~/.gitconfig` permissions and content. ## License MIT (see LICENSE if present) + +--- + +# Project Manager + +Project manager is a terminal client application to manage multiple projects and configurations, in a easy and organized way. + +# Features + +## Create projects + +Create a new folder with a `.project.hcl` file, if the folder already exist initialize the folder with the `.project.hcl` file. + +The project environments are stored in a `.env` file also created in the project folder. + +The project manager also managed the user git configuration updating the `.gitconfig` to store the path and a custom `%s.gitconfig` file to manage the git user, email, signkeys... + +## Add Environments to a Project + +## Update Projects and environments + +## Delete Projects + +# TUI and Cli + +The project manager allow the user interact with a Terminal User Interface or with a set of cli commands. + +## TUI + +`pm [path]` run a new TUI to select start a new session + +## CLI + +- `pm [project] [env|[path]]` start a new terminal session +- `pm new [project]` add a new project +- `pm new [project] [env]` add a new environment to the selected project +- `pm config` open the configuration to edit global configs +- `pm edit [project]` edit the selected project +- `pm edit [project] [env]` edit the selected environment of a project +- `pm delete [project]` delete a project and all associated environments +- `pm delete [project] [env]` delete the selected environment of a project + +# Commands + +- build: `make build` +- lint: `make lint` +- fix linter errors: `make lint-fix` +- test: `make test` From 983d6fb63264dc5b03d7a79314802e387aeed2c1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 22 Sep 2025 00:18:09 +0000 Subject: [PATCH 18/27] feat: persist TUI project edits, rename per-project gitconfig, preserve environments TUI: SaveProjectMsg edit now updates name/shell/env file and writes EnvVarsRaw; refreshes projects list; fixed value vs pointer form assertion; removed duplicate branch; lint/format fixes. Repo: UpdateProject reloads global git config, renames ..gitconfig, updates includeIf, writes ~/.gitconfig (0600), and merges existing .project.hcl to preserve Environments/DefaultEnv; tests added for gitconfig rename/includeIf and TUI rename refresh; all tests/lint pass. --- .devcontainer/Dockerfile | 5 + .devcontainer/compose.yml | 33 + .devcontainer/devcontainer.json | 82 ++- .devcontainer/litellm-config.yaml | 20 + .mcp.json | 2 +- cmd/cli/new/new.go | 20 +- cmd/cli/root.go | 2 +- docs/refactor_plan.md | 95 +++ go.mod | 2 +- .../tui/{v2 => }/components/button.go | 0 .../tui/{v2 => }/components/buttongroup.go | 0 .../handlers/tui/{v2 => }/components/input.go | 0 .../handlers/tui/{v2 => }/components/list.go | 0 .../handlers/tui/{v2 => }/components/modal.go | 0 .../tui/{v2 => }/components/textarea.go | 0 .../handlers/tui/{v2 => }/router/router.go | 0 .../handlers/tui/{v2 => }/state/messages.go | 2 +- .../handlers/tui/{v2 => }/state/model.go | 0 .../handlers/tui/{v2 => }/styles/styles.go | 0 .../handlers/tui/{v2 => }/styles/theme.go | 0 internal/adapters/handlers/tui/v1/styles.go | 24 - internal/adapters/handlers/tui/v1/window.go | 1 - .../adapters/handlers/tui/v1/window_core.go | 300 -------- .../handlers/tui/v1/window_env_form.go | 346 --------- .../adapters/handlers/tui/v1/window_flow.go | 193 ----- .../adapters/handlers/tui/v1/window_form.go | 650 ----------------- .../adapters/handlers/tui/v1/window_update.go | 527 -------------- .../adapters/handlers/tui/v1/window_view.go | 129 ---- .../tui/{v2 => }/views/env_form_view.go | 120 ++-- .../tui/{v2 => }/views/env_vars_view.go | 4 +- .../tui/{v2 => }/views/project_form_view.go | 40 +- .../tui/{v2 => }/views/projects_view.go | 4 +- .../adapters/handlers/tui/{v2 => }/window.go | 80 ++- .../adapters/repositories/git_repository.go | 63 +- .../repositories/project_repository.go | 267 ++----- .../shells/pseudoshell_repository.go | 4 + .../repositories/shells/shell_repository.go | 4 + internal/core/ports/git_port.go | 6 + internal/core/ports/project_port.go | 12 +- internal/core/services/git_service.go | 6 + internal/core/services/project_service.go | 246 ++++++- internal/tools/fs.go | 61 ++ internal/tools/path.go | 29 + mocks/mock_git_port.go | 84 +++ mocks/mock_project_port.go | 16 +- tests/integration/common.go | 33 +- .../edit_project_rename_refresh_test.go | 43 ++ tests/integration/selected_enter_test.go | 8 +- tests/integration/view_parity_test.go.old | 675 ------------------ tests/unit/.p.gitconfig | 10 + tests/unit/env_vars_repository_test.go | 43 ++ tests/unit/git_repository_test.go | 85 +++ .../project_repository_persistence_test.go | 75 ++ ...roject_repository_updateenv_rename_test.go | 41 ++ ...repository_updateproject_gitconfig_test.go | 85 +++ tests/unit/project_service_addenv_test.go | 42 ++ .../unit/project_service_create_basic_test.go | 48 ++ .../unit/project_service_create_flow_test.go | 45 ++ ...ect_service_create_git_integration_test.go | 43 ++ .../project_service_create_rollback_test.go | 46 ++ .../project_service_update_project_test.go | 45 ++ tests/unit/router_compile_test.go | 2 +- tests/unit/state_compile_test.go | 4 +- tests/unit/tools_fs_test.go | 66 ++ 64 files changed, 1651 insertions(+), 3267 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/compose.yml create mode 100644 .devcontainer/litellm-config.yaml create mode 100644 docs/refactor_plan.md rename internal/adapters/handlers/tui/{v2 => }/components/button.go (100%) rename internal/adapters/handlers/tui/{v2 => }/components/buttongroup.go (100%) rename internal/adapters/handlers/tui/{v2 => }/components/input.go (100%) rename internal/adapters/handlers/tui/{v2 => }/components/list.go (100%) rename internal/adapters/handlers/tui/{v2 => }/components/modal.go (100%) rename internal/adapters/handlers/tui/{v2 => }/components/textarea.go (100%) rename internal/adapters/handlers/tui/{v2 => }/router/router.go (100%) rename internal/adapters/handlers/tui/{v2 => }/state/messages.go (98%) rename internal/adapters/handlers/tui/{v2 => }/state/model.go (100%) rename internal/adapters/handlers/tui/{v2 => }/styles/styles.go (100%) rename internal/adapters/handlers/tui/{v2 => }/styles/theme.go (100%) delete mode 100644 internal/adapters/handlers/tui/v1/styles.go delete mode 100644 internal/adapters/handlers/tui/v1/window.go delete mode 100644 internal/adapters/handlers/tui/v1/window_core.go delete mode 100644 internal/adapters/handlers/tui/v1/window_env_form.go delete mode 100644 internal/adapters/handlers/tui/v1/window_flow.go delete mode 100644 internal/adapters/handlers/tui/v1/window_form.go delete mode 100644 internal/adapters/handlers/tui/v1/window_update.go delete mode 100644 internal/adapters/handlers/tui/v1/window_view.go rename internal/adapters/handlers/tui/{v2 => }/views/env_form_view.go (83%) rename internal/adapters/handlers/tui/{v2 => }/views/env_vars_view.go (98%) rename internal/adapters/handlers/tui/{v2 => }/views/project_form_view.go (97%) rename internal/adapters/handlers/tui/{v2 => }/views/projects_view.go (98%) rename internal/adapters/handlers/tui/{v2 => }/window.go (96%) create mode 100644 internal/tools/fs.go create mode 100644 internal/tools/path.go create mode 100644 tests/integration/edit_project_rename_refresh_test.go delete mode 100644 tests/integration/view_parity_test.go.old create mode 100644 tests/unit/.p.gitconfig create mode 100644 tests/unit/env_vars_repository_test.go create mode 100644 tests/unit/git_repository_test.go create mode 100644 tests/unit/project_repository_persistence_test.go create mode 100644 tests/unit/project_repository_updateenv_rename_test.go create mode 100644 tests/unit/project_repository_updateproject_gitconfig_test.go create mode 100644 tests/unit/project_service_addenv_test.go create mode 100644 tests/unit/project_service_create_basic_test.go create mode 100644 tests/unit/project_service_create_flow_test.go create mode 100644 tests/unit/project_service_create_git_integration_test.go create mode 100644 tests/unit/project_service_create_rollback_test.go create mode 100644 tests/unit/project_service_update_project_test.go create mode 100644 tests/unit/tools_fs_test.go diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..e446b11 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,5 @@ +FROM mcr.microsoft.com/devcontainers/go:1.25-bookworm + +RUN curl -fsSL https://claude.ai/install.sh | bash + +RUN cp $HOME/.local/bin/claude /usr/local/bin/claude \ No newline at end of file diff --git a/.devcontainer/compose.yml b/.devcontainer/compose.yml new file mode 100644 index 0000000..5763b12 --- /dev/null +++ b/.devcontainer/compose.yml @@ -0,0 +1,33 @@ +services: + project-manager: + container_name: project-manager + build: + context: ../ + dockerfile: ./.devcontainer/Dockerfile + volumes: + - ../..:/workspaces:cached + command: sleep infinity + env_file: + - ./.env + + litellm: + container_name: litellm + image: ghcr.io/berriai/litellm:main-stable + volumes: + - ../.devcontainer/litellm-config.yaml:/app/config.yaml:cached + command: + - "--config=/app/config.yaml" + ports: + - "4000:4000" + env_file: + - ./.env + healthcheck: + test: + [ + "CMD-SHELL", + "wget --no-verbose --tries=1 http://localhost:4000/health/liveliness || exit 1", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index db8fb8f..d249be6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,41 +1,49 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/go { - "name": "Go", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/go:1.23-bookworm", - "features": { - "ghcr.io/devcontainers/features/aws-cli:1": {}, - "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/devcontainers/features/docker-in-docker:2": {}, - "ghcr.io/devcontainers/features/powershell:1.5.1": {} - }, - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "go version", - // Configure tool-specific properties. - "customizations": { - "vscode": { - "extensions": [ - "github.vscode-github-actions", - "ms-vscode.makefile-tools", - "golang.go", - "ms-azuretools.vscode-docker", - "HashiCorp.HCL", - "tamasfe.even-better-toml" - ], - "settings": { - "aws.telemetry": false, - "go.lintTool": "golangci-lint", - "go.lintFlags": [ - "--fast" - ] - } - } - } - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" + "name": "Go", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "dockerComposeFile": "compose.yml", + "service": "project-manager", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "features": { + "ghcr.io/devcontainers/features/aws-cli:1": {}, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/powershell:1.5.1": {}, + "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/devcontainers-extra/features/uv:1": {} + }, + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "go version", + // Configure tool-specific properties. + "customizations": { + "vscode": { + "extensions": [ + "github.vscode-github-actions", + "ms-vscode.makefile-tools", + "golang.go", + "ms-azuretools.vscode-docker", + "HashiCorp.HCL", + "tamasfe.even-better-toml" + ], + "settings": { + "aws.telemetry": false, + "go.lintTool": "golangci-lint", + "go.lintFlags": [ + "--fast" + ], + "remote.autoForwardPorts": false + } + } + }, + "otherPortsAttributes": { + "onAutoForward": "ignore" + } + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" } \ No newline at end of file diff --git a/.devcontainer/litellm-config.yaml b/.devcontainer/litellm-config.yaml new file mode 100644 index 0000000..fe5f898 --- /dev/null +++ b/.devcontainer/litellm-config.yaml @@ -0,0 +1,20 @@ +model_list: + - model_name: azure/gpt-5 + litellm_params: + model: azure/gpt-5 + api_base: os.environ/AZURE_OPENAI_ENDPOINT # runs os.getenv("AZURE_API_BASE") + api_key: os.environ/AZURE_OPENAI_API_KEY # runs os.getenv("AZURE_API_KEY") + api_version: os.environ/AZURE_OPENAI_API_VERSION + + - model_name: azure/gpt-4.1 + litellm_params: + model: azure/gpt-4.1 + api_base: os.environ/AZURE_OPENAI_ENDPOINT # runs os.getenv("AZURE_API_BASE") + api_key: os.environ/AZURE_OPENAI_API_KEY # runs os.getenv("AZURE_API_KEY") + api_version: os.environ/AZURE_OPENAI_API_VERSION + +litellm_settings: + drop_params: True + cache: True + cache_params: + type: local diff --git a/.mcp.json b/.mcp.json index 20b4244..958c7b2 100644 --- a/.mcp.json +++ b/.mcp.json @@ -31,4 +31,4 @@ ] } } -} +} \ No newline at end of file diff --git a/cmd/cli/new/new.go b/cmd/cli/new/new.go index 66c1f92..6bb5d4e 100644 --- a/cmd/cli/new/new.go +++ b/cmd/cli/new/new.go @@ -1,6 +1,8 @@ package newcmd import ( + "strings" + "github.com/spf13/cobra" "github.com/jlrosende/project-manager/internal/adapters/repositories" @@ -37,7 +39,6 @@ func init() { } func run(cmd *cobra.Command, args []string) error { - // ask for a name if not set name := args[0] path := "" @@ -92,19 +93,23 @@ func run(cmd *cobra.Command, args []string) error { return err } - // convert map[string]string to domain.EnvVars + shell, err := cmd.Flags().GetString("shell") + if err != nil { + return err + } + dv := domain.EnvVars{} for k, v := range envVars { dv[k] = v } - // default env vars file envFile := ".env" - _, err = svc.Create( + proj, err := svc.Create( name, path, subproject, + shell, envFile, dv, domain.New( @@ -119,5 +124,12 @@ func run(cmd *cobra.Command, args []string) error { return err } + if s := strings.TrimSpace(shell); s != "" { + proj.Shell = s + if err := svc.UpdateProject(proj); err != nil { + return err + } + } + return nil } diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 233fcbf..d14e3f7 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -16,7 +16,7 @@ import ( cmdNew "github.com/jlrosende/project-manager/cmd/cli/new" "github.com/jlrosende/project-manager/configs" "github.com/jlrosende/project-manager/internal" - tui "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2" + tui "github.com/jlrosende/project-manager/internal/adapters/handlers/tui" "github.com/jlrosende/project-manager/internal/adapters/repositories" "github.com/jlrosende/project-manager/internal/adapters/repositories/shells" "github.com/jlrosende/project-manager/internal/core/domain" diff --git a/docs/refactor_plan.md b/docs/refactor_plan.md new file mode 100644 index 0000000..36e7df2 --- /dev/null +++ b/docs/refactor_plan.md @@ -0,0 +1,95 @@ +# Project Manager Refactor Plan (Simplified Hexagonal) + +Goal: Reduce repository responsibilities, keep clear boundaries, add tests to ensure behavior parity. + +## Architecture targets +- Repositories: + - ProjectRepository: only read/write .project.hcl and in-memory project aggregate + - EnvVarsRepository: read/write .env files + - GitRepository: read/write per-project .gitconfig and manage global includeIf +- Services: ProjectService orchestrates validations, defaults, ordering, rollback on failure +- Utilities: small helpers in internal/tools for path ops and dir checks (no new ports) + +## Current coupling hotspots (references) +- Env file IO and naming in ProjectRepository: + - Create: internal/adapters/repositories/project_repository.go:228-254, 255-283 + - AddEnvironment: internal/adapters/repositories/project_repository.go:410-474 + - UpdateEnvironment (rename): internal/adapters/repositories/project_repository.go:358-384 +- Git global includes + per-project .gitconfig in ProjectRepository: + - Create: internal/adapters/repositories/project_repository.go:163-195, 196-226 +- Project persistence (keep here): + - Load .project.hcl: internal/adapters/repositories/project_repository.go:478-499 + - Save .project.hcl: internal/adapters/repositories/project_repository.go:320-327 + +## Milestones and tasks + +### 0) Prep and safety +- [x] T001: Add internal/tools/path.go with: ExpandHome, AbsPath, Join, IsAbs +- [x] T002: Add internal/tools/fs.go with: EnsureDir(path, mode), IsDirEmpty(path), Rename(old,new), WriteFile(path, bytes, mode) +- [x] T003: Move IsDirEmpty from project_repository.go into internal/tools/fs.go and update references +- [x] T004: Test: unit tests for tools/fs IsDirEmpty and EnsureDir using temp dirs + +### 1) Extract Env vars responsibilities +- [x] T005: ProjectRepository.Create: delegate env file creation to EnvVarsRepository.Save (refs: 228-254) +- [x] T006: ProjectRepository.AddEnvironment: delegate env file creation and path resolution to EnvVarsRepository (refs: 410-441) +- [x] T007: ProjectRepository.UpdateEnvironment: delegate env file rename to tools/fs.Rename after resolving paths (refs: 358-384) +- [x] T008: Ensure ProjectService computes env file default name (slug) instead of repository (refs: 410-418) +- [x] T009: Tests: + - [x] T010: EnvVarsRepository unit tests: Load/Save round-trip + - [x] T011: ProjectService AddEnvironment: creates file with defaults, idempotency on name conflicts + +### 2) Extract Git responsibilities +- [x] T012: Extend ports.GitRepository with global config helpers: + - [x] T013: LoadGlobal() error + - [x] T014: UpdateIncludeIf(gitdir, perProjectPath, subproject string) error + - [x] T015: SaveGlobal(home string) error +- [x] T016: Implement in internal/adapters/repositories/git_repository.go using go-git config (reuse existing Load/Save for per-project files) +- [x] T017: ProjectRepository.Create: replace direct includeIf + ~/.gitconfig writes with GitRepository calls (refs: 163-195) +- [x] T018: ProjectRepository.Create: move per-project .gitconfig write to GitRepository.Save (refs: 196-226) +- [x] T019: Tests: + - [x] T020: GitRepository per-project Save/Load round-trip + - [x] T021: GitRepository global includeIf end-to-end using temp HOME (override HOME env var) + - [x] T022: ProjectService.Create integration: ensures includeIf points to per-project path + +### 3) Narrow ProjectRepository to .project.hcl persistence +- [x] T023: Keep HCL encoding/decoding but remove unrelated os.*, logging, and path expansion (use tools) +- [x] T024: Factor loadDotProject into a method and use tools.ExpandHome (refs: 478-499) +- [x] T025: Tests: ProjectRepository List/Get/UpdateProject using temp dirs and sample .project.hcl + +### 4) Move validations and orchestration to ProjectService +- [x] T026: Create path from user input: expand ~, normalize abs (refs currently in Create 121-146) +- [x] T027: Ensure directory exists, require empty dir for new project +- [x] T028: Validate env uniqueness and default EnvVarsMode +- [x] T029: Orchestrate order: fs prepare -> env file (optional) -> git includes -> dotproject write +- [x] T030: Rollback on failure: track created files and revert renames +- [x] T031: Tests: + - [x] T032: Create success path: project dir empty -> all artifacts created + - [x] T033: Create failure during git step: env file rolled back, no partial artifacts left + - [x] T034: UpdateEnvironment rename: handles relative/absolute paths + +### 5) Logging and side effects +- [x] T035: Remove slog/log prints from repositories (refs: 43-48, 96-99, 147-175) +- [x] T036: Centralize logs in ProjectService with context + +### 6) Handlers use services only +- [x] T037: Ensure TUI/CLI handlers depend on ports.ProjectService; no direct repository usage +- [x] T038: Smoke tests: commands still work via make test; add integration test where feasible + +### 7) Naming and structure (optional, low risk) +- [x] T039: Keep adapters layout; skip renaming to outbound/inbound to avoid churn + +## Acceptance criteria +- ProjectRepository contains only .project.hcl load/list/save logic; no env or git writes +- Env/ Git operations are delegated to their repositories +- All unit and service tests pass; no regressions in CLI/TUI +- No direct os or slog usage in repositories beyond necessary file IO already encapsulated or minimized + +## Test matrix (summary) +- EnvVarsRepository: Load/Save; handles ~ expansion +- GitRepository: per-project Save/Load; global includeIf round-trip +- ProjectRepository: List/Get/UpdateProject with sample HCL +- ProjectService: Create/Add/UpdateEnvironment happy paths; rollback on injected failures + +## Notes +- Use temp directories and HOME overrides in tests to avoid touching user files +- Keep ports count minimal; only extend GitRepository methods as above diff --git a/go.mod b/go.mod index 363b8a4..41c9bfa 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/jlrosende/project-manager -go 1.25.1 +go 1.25.0 require ( github.com/charmbracelet/bubbles v0.21.0 diff --git a/internal/adapters/handlers/tui/v2/components/button.go b/internal/adapters/handlers/tui/components/button.go similarity index 100% rename from internal/adapters/handlers/tui/v2/components/button.go rename to internal/adapters/handlers/tui/components/button.go diff --git a/internal/adapters/handlers/tui/v2/components/buttongroup.go b/internal/adapters/handlers/tui/components/buttongroup.go similarity index 100% rename from internal/adapters/handlers/tui/v2/components/buttongroup.go rename to internal/adapters/handlers/tui/components/buttongroup.go diff --git a/internal/adapters/handlers/tui/v2/components/input.go b/internal/adapters/handlers/tui/components/input.go similarity index 100% rename from internal/adapters/handlers/tui/v2/components/input.go rename to internal/adapters/handlers/tui/components/input.go diff --git a/internal/adapters/handlers/tui/v2/components/list.go b/internal/adapters/handlers/tui/components/list.go similarity index 100% rename from internal/adapters/handlers/tui/v2/components/list.go rename to internal/adapters/handlers/tui/components/list.go diff --git a/internal/adapters/handlers/tui/v2/components/modal.go b/internal/adapters/handlers/tui/components/modal.go similarity index 100% rename from internal/adapters/handlers/tui/v2/components/modal.go rename to internal/adapters/handlers/tui/components/modal.go diff --git a/internal/adapters/handlers/tui/v2/components/textarea.go b/internal/adapters/handlers/tui/components/textarea.go similarity index 100% rename from internal/adapters/handlers/tui/v2/components/textarea.go rename to internal/adapters/handlers/tui/components/textarea.go diff --git a/internal/adapters/handlers/tui/v2/router/router.go b/internal/adapters/handlers/tui/router/router.go similarity index 100% rename from internal/adapters/handlers/tui/v2/router/router.go rename to internal/adapters/handlers/tui/router/router.go diff --git a/internal/adapters/handlers/tui/v2/state/messages.go b/internal/adapters/handlers/tui/state/messages.go similarity index 98% rename from internal/adapters/handlers/tui/v2/state/messages.go rename to internal/adapters/handlers/tui/state/messages.go index 8ee2e10..a5c5f78 100644 --- a/internal/adapters/handlers/tui/v2/state/messages.go +++ b/internal/adapters/handlers/tui/state/messages.go @@ -3,7 +3,7 @@ package state import ( tea "github.com/charmbracelet/bubbletea" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/router" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/router" ) type NavigateToMsg struct { diff --git a/internal/adapters/handlers/tui/v2/state/model.go b/internal/adapters/handlers/tui/state/model.go similarity index 100% rename from internal/adapters/handlers/tui/v2/state/model.go rename to internal/adapters/handlers/tui/state/model.go diff --git a/internal/adapters/handlers/tui/v2/styles/styles.go b/internal/adapters/handlers/tui/styles/styles.go similarity index 100% rename from internal/adapters/handlers/tui/v2/styles/styles.go rename to internal/adapters/handlers/tui/styles/styles.go diff --git a/internal/adapters/handlers/tui/v2/styles/theme.go b/internal/adapters/handlers/tui/styles/theme.go similarity index 100% rename from internal/adapters/handlers/tui/v2/styles/theme.go rename to internal/adapters/handlers/tui/styles/theme.go diff --git a/internal/adapters/handlers/tui/v1/styles.go b/internal/adapters/handlers/tui/v1/styles.go deleted file mode 100644 index efec0f9..0000000 --- a/internal/adapters/handlers/tui/v1/styles.go +++ /dev/null @@ -1,24 +0,0 @@ -package v1 - -import "github.com/charmbracelet/lipgloss" - -type Styles struct { - Title lipgloss.Style - Selected lipgloss.Style - Default lipgloss.Style - Help lipgloss.Style -} - -func BuildStyles() Styles { - return Styles{ - Title: lipgloss.NewStyle(). - Foreground(c("title")). - Align(lipgloss.Center). - Border(lipgloss.NormalBorder(), false, false, true). - BorderForeground(c("border")). - Padding(0, 1), - Selected: lipgloss.NewStyle().Foreground(c("selectedFg")).Background(c("selectedBg")).Padding(0, 1), - Default: lipgloss.NewStyle().Foreground(c("subtext")).Padding(0, 1), - Help: lipgloss.NewStyle().Foreground(c("help")), - } -} diff --git a/internal/adapters/handlers/tui/v1/window.go b/internal/adapters/handlers/tui/v1/window.go deleted file mode 100644 index b7b1f99..0000000 --- a/internal/adapters/handlers/tui/v1/window.go +++ /dev/null @@ -1 +0,0 @@ -package v1 diff --git a/internal/adapters/handlers/tui/v1/window_core.go b/internal/adapters/handlers/tui/v1/window_core.go deleted file mode 100644 index de8db01..0000000 --- a/internal/adapters/handlers/tui/v1/window_core.go +++ /dev/null @@ -1,300 +0,0 @@ -package v1 - -import ( - "strings" - - helpcomp "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/jlrosende/project-manager/internal/core/domain" - "github.com/jlrosende/project-manager/internal/core/services" -) - -var currentPalette = map[string]string{ - "title": "#88C0D0", - "section": "#81A1C1", - "subtext": "#7C818C", - "text": "#D8DEE9", - "placeholder": "#7C818C", - "border": "#4C566A", - "error": "#BF616A", - "buttonDefFg": "#2E3440", - "buttonDefBg": "#4C566A", - "buttonSelFg": "#2E3440", - "buttonSelBg": "#A3BE8C", - "selectedFg": "#ECEFF4", - "selectedBg": "#5E81AC", - "help": "#7C818C", -} - -var presetPalettes = map[string]map[string]string{ - "nord": { - "title": "#88C0D0", - "section": "#81A1C1", - "subtext": "#7C818C", - "text": "#D8DEE9", - "placeholder": "#7C818C", - "border": "#4C566A", - "error": "#BF616A", - "buttonDefFg": "#2E3440", - "buttonDefBg": "#4C566A", - "buttonSelFg": "#2E3440", - "buttonSelBg": "#A3BE8C", - "selectedFg": "#ECEFF4", - "selectedBg": "#5E81AC", - "help": "#7C818C", - }, - "catppuccin": { - "title": "#89B4FA", - "section": "#B4BEFE", - "subtext": "#A6ADC8", - "text": "#CDD6F4", - "placeholder": "#6C7086", - "border": "#585B70", - "error": "#F38BA8", - "buttonDefFg": "#1E1E2E", - "buttonDefBg": "#585B70", - "buttonSelFg": "#1E1E2E", - "buttonSelBg": "#A6E3A1", - "selectedFg": "#CDD6F4", - "selectedBg": "#89B4FA", - "help": "#A6ADC8", - }, - "dracula": { - "title": "#BD93F9", - "section": "#8BE9FD", - "subtext": "#6272A4", - "text": "#F8F8F2", - "placeholder": "#6272A4", - "border": "#44475A", - "error": "#FF5555", - "buttonDefFg": "#282A36", - "buttonDefBg": "#44475A", - "buttonSelFg": "#282A36", - "buttonSelBg": "#50FA7B", - "selectedFg": "#F8F8F2", - "selectedBg": "#6272A4", - "help": "#6272A4", - }, - "ayu": { - "title": "#59C2FF", - "section": "#D4BFFF", - "subtext": "#5C6773", - "text": "#CBCCC6", - "placeholder": "#5C6773", - "border": "#3D4754", - "error": "#D95757", - "buttonDefFg": "#1F2430", - "buttonDefBg": "#3D4754", - "buttonSelFg": "#1F2430", - "buttonSelBg": "#AAD94C", - "selectedFg": "#CBCCC6", - "selectedBg": "#59C2FF", - "help": "#5C6773", - }, -} - -func init() { - presetPalettes["nord-light"] = map[string]string{ - "title": "#5E81AC", - "section": "#81A1C1", - "subtext": "#4C566A", - "text": "#2E3440", - "placeholder": "#7C818C", - "border": "#D8DEE9", - "error": "#BF616A", - "buttonDefFg": "#2E3440", - "buttonDefBg": "#D8DEE9", - "buttonSelFg": "#2E3440", - "buttonSelBg": "#A3BE8C", - "selectedFg": "#2E3440", - "selectedBg": "#88C0D0", - "help": "#6C6F7D", - } - - presetPalettes["catppuccin-light"] = map[string]string{ - "title": "#1E66F5", - "section": "#7287FD", - "subtext": "#6C6F85", - "text": "#4C4F69", - "placeholder": "#9CA0B0", - "border": "#BCC0CC", - "error": "#D20F39", - "buttonDefFg": "#4C4F69", - "buttonDefBg": "#BCC0CC", - "buttonSelFg": "#4C4F69", - "buttonSelBg": "#40A02B", - "selectedFg": "#4C4F69", - "selectedBg": "#8CAAEE", - "help": "#6C6F85", - } - - presetPalettes["dracula-light"] = map[string]string{ - "title": "#6272A4", - "section": "#8BE9FD", - "subtext": "#6D7086", - "text": "#282A36", - "placeholder": "#A0A0A0", - "border": "#E5E5E5", - "error": "#FF5555", - "buttonDefFg": "#282A36", - "buttonDefBg": "#E5E5E5", - "buttonSelFg": "#282A36", - "buttonSelBg": "#50FA7B", - "selectedFg": "#282A36", - "selectedBg": "#8BE9FD", - "help": "#6D7086", - } - - presetPalettes["ayu-light"] = map[string]string{ - "title": "#55B4D4", - "section": "#D4BFFF", - "subtext": "#8A9199", - "text": "#5C6773", - "placeholder": "#9AA5B1", - "border": "#E6E9EF", - "error": "#F07178", - "buttonDefFg": "#5C6773", - "buttonDefBg": "#E6E9EF", - "buttonSelFg": "#1F2430", - "buttonSelBg": "#AAD94C", - "selectedFg": "#5C6773", - "selectedBg": "#FFCC66", - "help": "#8A9199", - } -} - -func setPaletteByName(name string) { - if p, ok := presetPalettes[strings.ToLower(strings.TrimSpace(name))]; ok { - for k, v := range p { - currentPalette[k] = v - } - } -} - -type Options struct { - Theme string - Overrides map[string]string -} - -type keyMap struct { - Quit key.Binding - Edit key.Binding - Left key.Binding - Right key.Binding - Up key.Binding - Down key.Binding - Enter key.Binding - Help key.Binding -} - -func (k keyMap) ShortHelp() []key.Binding { return []key.Binding{k.Quit, k.Edit, k.Enter, k.Help} } -func (k keyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.Left, k.Right, k.Up, k.Down}, {k.Edit, k.Enter, k.Quit, k.Help}} -} - -type Window struct { - projectSvc *services.ProjectService - - projects []*domain.Project - selectedProject *domain.Project - - cursor int - cursorEnv int - focus int - total int - width int - height int - shouldQuit bool - mode int - form *NewProjectForm - prompt *postCreatePrompt - envForm *NewEnvironmentForm - envProjectName string - styles Styles - vp viewport.Model - help helpcomp.Model - keys keyMap -} - -func NewWindow(projectSvc *services.ProjectService, opts Options) (*Window, error) { - if strings.TrimSpace(opts.Theme) != "" { - setPaletteByName(opts.Theme) - } - - for k, v := range opts.Overrides { - if strings.TrimSpace(v) != "" { - currentPalette[k] = v - } - } - - s := BuildStyles() - - projects, err := projectSvc.List() - if err != nil { - return nil, err - } - - total := len(projects) + 1 - if total == 0 { - total = 1 - } - - vp := viewport.New(0, 0) - h := helpcomp.New() - - w := &Window{ - projectSvc: projectSvc, - projects: projects, - total: total, - cursor: 0, - styles: s, - vp: vp, - help: h, - } - - w.keys.Quit = key.NewBinding(key.WithKeys("q", "ctrl+c", "esc"), key.WithHelp("q", "quit")) - w.keys.Edit = key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")) - w.keys.Left = key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "focus projects")) - w.keys.Right = key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("β†’/l", "focus envs")) - w.keys.Up = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")) - w.keys.Down = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")) - w.keys.Enter = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")) - w.keys.Help = key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "toggle help")) - - return w, nil -} - -func c(k string) lipgloss.Color { return lipgloss.Color(currentPalette[k]) } - -func (m Window) Init() tea.Cmd { return tea.SetWindowTitle("Project Manager") } - -func (m *Window) SelectedProject() *domain.Project { return m.selectedProject } - -func (m *Window) SelectedEnvironment() string { - if len(m.projects) == 0 { - return "" - } - - idx := mod(m.cursor, m.total) - if idx < 0 || idx >= len(m.projects) { - return "" - } - - p := m.projects[idx] - if len(p.Environments) == 0 { - return "" - } - - e := m.cursorEnv % len(p.Environments) - if e < 0 { - e = 0 - } - - return p.Environments[e].Name -} - -func mod(a, b int) int { return (a%b + b) % b } diff --git a/internal/adapters/handlers/tui/v1/window_env_form.go b/internal/adapters/handlers/tui/v1/window_env_form.go deleted file mode 100644 index f628ae4..0000000 --- a/internal/adapters/handlers/tui/v1/window_env_form.go +++ /dev/null @@ -1,346 +0,0 @@ -package v1 - -import ( - "strings" - - helpcomp "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - bta "github.com/charmbracelet/bubbles/textarea" - bti "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/jlrosende/project-manager/internal/core/domain" -) - -const ( - errNameRequired = "name is required" - errModeMustBeVal = "mode must be merge or replace" -) - -type envFormKeyMap struct { - Save key.Binding - Cancel key.Binding - Next key.Binding - Prev key.Binding - Buttons key.Binding - Help key.Binding -} - -func (k envFormKeyMap) ShortHelp() []key.Binding { return []key.Binding{k.Next, k.Save, k.Help} } - -func (k envFormKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.Next, k.Prev, k.Buttons}, {k.Save, k.Cancel, k.Help}} -} - -type NewEnvironmentForm struct { - name bti.Model - color bti.Model - mode bti.Model - envVars bta.Model - focused int - submitted bool - canceled bool - err string - isEdit bool - originalName string - help helpcomp.Model - keys envFormKeyMap -} - -func NewEnvironmentFormModel() *NewEnvironmentForm { - name := bti.New() - name.Prompt = "Env name*: " - name.Placeholder = "staging" - name.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) - name.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) - name.TextStyle = lipgloss.NewStyle().Foreground(c("text")) - name.Width = 60 - name.Focus() - - color := bti.New() - color.Prompt = "Color (name/#hex/0-255): " - color.Placeholder = "teal" - color.SetValue("grey") - color.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) - color.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) - color.TextStyle = lipgloss.NewStyle().Foreground(c("subtext")) - color.Width = 60 - mode := bti.New() - mode.Prompt = "Env vars mode* (merge/replace): " - mode.Placeholder = domain.EnvVarsModeMerge - mode.SetValue(domain.EnvVarsModeMerge) - mode.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) - mode.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) - mode.TextStyle = lipgloss.NewStyle().Foreground(c("text")) - mode.Width = 60 - env := bta.New() - env.Placeholder = "# One per line (like .env)\nAPI_URL=https://api.example.com\nLOG_LEVEL=info\n# comments allowed" - env.SetHeight(6) - env.SetWidth(70) - - h := helpcomp.New() - km := envFormKeyMap{} - km.Save = key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")) - km.Cancel = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")) - km.Next = key.NewBinding(key.WithKeys("tab", "down", "enter"), key.WithHelp("tab/↓/enter", "next")) - km.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev")) - km.Buttons = key.NewBinding(key.WithKeys("left", "right"), key.WithHelp("←/β†’", "buttons")) - km.Help = key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "toggle help")) - - return &NewEnvironmentForm{name: name, color: color, mode: mode, envVars: env, help: h, keys: km} -} - -func NewEnvironmentEditFormModel(e *domain.Environment) *NewEnvironmentForm { - f := NewEnvironmentFormModel() - f.isEdit = true - f.originalName = e.Name - f.name.SetValue(e.Name) - f.color.SetValue(e.Color) - f.mode.SetValue(e.EnvVarsMode) - - return f -} - -func (f *NewEnvironmentForm) Init() tea.Cmd { return bti.Blink } - -func (f *NewEnvironmentForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if wm, ok := msg.(tea.WindowSizeMsg); ok { - w := wm.Width - 10 - if w < 30 { - w = 30 - } - - f.name.Width = w - f.color.Width = w - f.mode.Width = w - f.envVars.SetWidth(w + 10) - } - - if m, ok := msg.(tea.KeyMsg); ok { - switch m.Type { - case tea.KeyEsc, tea.KeyCtrlC: - f.canceled = true - f.submitted = false - - return f, nil - case tea.KeyTab, tea.KeyShiftTab: - if m.Type == tea.KeyTab { - f.focused = (f.focused + 1) % 6 - } else { - f.focused = (f.focused + 5) % 6 - } - - f.blurAll() - f.focusCurrent() - - return f, nil - case tea.KeyEnter: - switch { - case f.focused < 3: - f.focused = (f.focused + 1) % 6 - f.blurAll() - f.focusCurrent() - - return f, nil - case f.focused == 4: - f.err = "" - if strings.TrimSpace(f.name.Value()) == "" { - f.err = errNameRequired - return f, nil - } - - if strings.TrimSpace(f.mode.Value()) != domain.EnvVarsModeMerge && - strings.TrimSpace(f.mode.Value()) != domain.EnvVarsModeReplace { - f.err = errModeMustBeVal - return f, nil - } - - f.submitted = true - f.canceled = false - - return f, nil - case f.focused == 5: - f.canceled = true - f.submitted = false - - return f, nil - } - case tea.KeyUp: - if f.focused != 3 { - f.focused-- - if f.focused < 0 { - f.focused = 5 - } - - f.blurAll() - f.focusCurrent() - - return f, nil - } - case tea.KeyDown: - if f.focused != 3 { - f.focused++ - if f.focused > 5 { - f.focused = 0 - } - - f.blurAll() - f.focusCurrent() - - return f, nil - } - case tea.KeyLeft: - if f.focused == 5 { - f.focused = 4 - f.blurAll() - f.focusCurrent() - - return f, nil - } - case tea.KeyRight: - if f.focused == 4 { - f.focused = 5 - f.blurAll() - f.focusCurrent() - - return f, nil - } - case tea.KeyCtrlS: - f.err = "" - if strings.TrimSpace(f.name.Value()) == "" { - f.err = errNameRequired - return f, nil - } - - if strings.TrimSpace(f.mode.Value()) != domain.EnvVarsModeMerge && - strings.TrimSpace(f.mode.Value()) != domain.EnvVarsModeReplace { - f.err = "mode must be merge or replace" - return f, nil - } - - f.submitted = true - f.canceled = false - - return f, nil - } - } - - if m, ok := msg.(tea.KeyMsg); ok { - if m.Type == tea.KeyRunes && len(m.Runes) > 0 && m.Runes[0] == '?' { - f.help.ShowAll = !f.help.ShowAll - return f, nil - } - } - - var cmd tea.Cmd - - switch f.focused { - case 0: - f.name, cmd = f.name.Update(msg) - case 1: - f.color, cmd = f.color.Update(msg) - cv := strings.TrimSpace(f.color.Value()) - - col := "240" - - if isValidColorInput(cv) { - col = normalizeColorInput(cv) - } - - f.color.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(col)) - case 2: - f.mode, cmd = f.mode.Update(msg) - case 3: - f.envVars, cmd = f.envVars.Update(msg) - case 4: - // save button - case 5: - // cancel button - } - - return f, cmd -} - -func (f *NewEnvironmentForm) blurAll() { - f.name.Blur() - f.color.Blur() - f.mode.Blur() - f.envVars.Blur() -} - -func (f *NewEnvironmentForm) focusCurrent() { - switch f.focused { - case 0: - f.name.Focus() - case 1: - f.color.Focus() - case 2: - f.mode.Focus() - case 3: - f.envVars.Focus() - } -} - -func (f *NewEnvironmentForm) View() string { - styleTitle := lipgloss.NewStyle(). - Foreground(c("title")). - Align(lipgloss.Center). - Border(lipgloss.NormalBorder(), false, false, true). - BorderForeground(c("border")). - Padding(0, 1) - styleSection := lipgloss.NewStyle().Foreground(c("section")).Bold(true) - styleSub := lipgloss.NewStyle().Foreground(c("subtext")) - btnDef := lipgloss.NewStyle().Foreground(c("buttonDefFg")).Background(c("buttonDefBg")).Padding(0, 2) - btnSel := lipgloss.NewStyle().Foreground(c("buttonSelFg")).Background(c("buttonSelBg")).Padding(0, 2) - b := strings.Builder{} - - title := "New environment" - if f.isEdit { - title = "Edit environment" - } - - b.WriteString(styleTitle.Render(title)) - b.WriteString("\n") - b.WriteString(styleSection.Render("Environment details")) - b.WriteString("\n") - b.WriteString(styleSub.Render("Display and behavior")) - b.WriteString("\n") - b.WriteString(f.name.View()) - b.WriteString("\n") - b.WriteString(f.color.View()) - b.WriteString("\n\n") - b.WriteString(styleSection.Render("Environment variables")) - b.WriteString("\n") - b.WriteString(f.mode.View()) - b.WriteString("\n") - b.WriteString(styleSub.Render("One KEY=VALUE per line; '#' comments allowed")) - b.WriteString("\n") - b.WriteString(f.envVars.View()) - b.WriteString("\n\n") - - btnSave := btnDef.Render(" Save ") - if f.focused == 4 { - btnSave = btnSel.Render(" Save ") - } - - btnCancel := btnDef.Render(" Cancel ") - if f.focused == 5 { - btnCancel = btnSel.Render(" Cancel ") - } - - b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, btnSave, " ", btnCancel)) - b.WriteString("\n\n") - - if f.err != "" { - b.WriteString(lipgloss.NewStyle().Foreground(c("error")).Render(f.err)) - b.WriteString("\n") - } - - content := b.String() - - sepLen := 80 - sep := lipgloss.NewStyle().Foreground(c("border")).Render(strings.Repeat("─", sepLen)) - - return lipgloss.JoinVertical(lipgloss.Left, content, sep, f.help.View(f.keys)) -} diff --git a/internal/adapters/handlers/tui/v1/window_flow.go b/internal/adapters/handlers/tui/v1/window_flow.go deleted file mode 100644 index 8c8ef34..0000000 --- a/internal/adapters/handlers/tui/v1/window_flow.go +++ /dev/null @@ -1,193 +0,0 @@ -package v1 - -import ( - "strconv" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/jlrosende/project-manager/internal/core/domain" -) - -func parseEnvLines(s string) domain.EnvVars { - res := domain.EnvVars{} - - for _, line := range strings.Split(s, "\n") { - l := strings.TrimSpace(line) - if l == "" || strings.HasPrefix(l, "#") { - continue - } - - kv := strings.SplitN(l, "=", 2) - if len(kv) == 2 { - res[strings.TrimSpace(kv[0])] = kv[1] - } - } - - return res -} - -var colorNameMap = map[string]string{ - "black": "0", - "white": "15", - "red": "196", - "green": "46", - "blue": "21", - "yellow": "226", - "magenta": "201", - "purple": "93", - "cyan": "51", - "teal": "30", - "orange": "208", - "pink": "205", - "grey": "240", - "gray": "240", -} - -func normalizeColorInput(s string) string { - ss := strings.ToLower(strings.TrimSpace(s)) - if ss == "" || ss == "grey" || ss == "gray" { - return "240" - } - - if strings.HasPrefix(ss, "#") { - return ss - } - - if v, ok := colorNameMap[ss]; ok { - return v - } - - return ss -} - -func isValidColorInput(s string) bool { - ss := strings.ToLower(strings.TrimSpace(s)) - if ss == "" { - return false - } - - if strings.HasPrefix(ss, "#") { - h := ss[1:] - if len(h) != 3 && len(h) != 6 { - return false - } - - if _, err := strconv.ParseUint(h, 16, 64); err != nil { - return false - } - - return true - } - - if _, ok := colorNameMap[ss]; ok { - return true - } - - if n, err := strconv.Atoi(ss); err == nil && n >= 0 && n <= 255 { - return true - } - - return false -} - -func (m *Window) newProjectFlow() { - m.form = NewProjectFormModel() - m.mode = 1 -} - -func (m *Window) newEnvironmentFlow(projectName string) { - m.envForm = NewEnvironmentFormModel() - m.envProjectName = projectName - m.mode = 3 -} - -type postCreatePrompt struct { - idx, choice int - projectName string -} - -func newPostCreatePrompt(projectName string) *postCreatePrompt { - return &postCreatePrompt{projectName: projectName} -} - -func (p *postCreatePrompt) Init() tea.Cmd { return nil } - -func (p *postCreatePrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch m := msg.(type) { - case tea.KeyMsg: - switch m.Type { - case tea.KeyUp: - if p.idx > 0 { - p.idx-- - } - case tea.KeyDown: - if p.idx < 2 { - p.idx++ - } - case tea.KeyEnter: - p.choice = p.idx - - return p, tea.Quit - case tea.KeyEsc, tea.KeyCtrlC: - p.choice = 0 - - return p, tea.Quit - } - - if m.Type == tea.KeyRunes { - switch string(m.Runes) { - case "k": - if p.idx > 0 { - p.idx-- - } - case "j": - if p.idx < 2 { - p.idx++ - } - case "q": - p.choice = 0 - return p, tea.Quit - } - } - case tea.WindowSizeMsg: - return p, nil - } - - return p, nil -} - -func (p *postCreatePrompt) View() string { - styleTitle := lipgloss.NewStyle(). - Foreground(c("title")). - Align(lipgloss.Center). - Border(lipgloss.NormalBorder(), false, false, true). - BorderForeground(c("border")). - Padding(0, 1) - styleSel := lipgloss.NewStyle().Foreground(c("selectedFg")).Background(c("selectedBg")).Padding(0, 1) - styleDef := lipgloss.NewStyle().Foreground(c("subtext")).Padding(0, 1) - b := strings.Builder{} - b.WriteString(styleTitle.Render("Project created. What next?")) - b.WriteString("\n") - - opts := []string{"Return to list", "Start this project", "Exit"} - for i, o := range opts { - if i == p.idx { - b.WriteString("➜ ") - b.WriteString(styleSel.Render(o)) - } else { - b.WriteString(" ") - b.WriteString(styleDef.Render(o)) - } - - b.WriteString("\n") - } - - help := lipgloss.NewStyle(). - Foreground(c("help")). - Render(`Navigate: ↑/k ↓/j -Select: Enter Cancel: Esc/q/Ctrl+C`) - - return lipgloss.JoinVertical(lipgloss.Left, b.String(), help) -} diff --git a/internal/adapters/handlers/tui/v1/window_form.go b/internal/adapters/handlers/tui/v1/window_form.go deleted file mode 100644 index a331014..0000000 --- a/internal/adapters/handlers/tui/v1/window_form.go +++ /dev/null @@ -1,650 +0,0 @@ -package v1 - -import ( - "io" - "os" - "path/filepath" - "regexp" - "strings" - - helpcomp "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - bta "github.com/charmbracelet/bubbles/textarea" - bti "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/jlrosende/project-manager/internal/core/domain" -) - -const ( - strTrue = "true" - strFalse = "false" - errPathRequired = "path is required" -) - -type formKeyMap struct { - Save key.Binding - Cancel key.Binding - Next key.Binding - Prev key.Binding - Buttons key.Binding - Help key.Binding -} - -func (k formKeyMap) ShortHelp() []key.Binding { return []key.Binding{k.Next, k.Save, k.Help} } -func (k formKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.Next, k.Prev, k.Buttons}, {k.Save, k.Cancel, k.Help}} -} - -type NewProjectForm struct { - name bti.Model - path bti.Model - subproject bti.Model - shell bti.Model - userName bti.Model - userEmail bti.Model - userSigningKey bti.Model - commitGPGSign bti.Model - tagGPGSign bti.Model - envVars bta.Model - focused int - width int - height int - pathDirty bool - submitted bool - canceled bool - err string - isEdit bool - originalName string - help helpcomp.Model - keys formKeyMap -} - -func NewProjectFormModel() *NewProjectForm { - ni := bti.New() - sc := lipgloss.NewStyle().Foreground(c("title")) - sr := lipgloss.NewStyle().Foreground(c("error")) - ni.Prompt = sc.Render("Name") + sr.Render("*") + sc.Render(": ") - ni.Placeholder = "my-awesome-app" - ni.PromptStyle = lipgloss.NewStyle() - ni.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) - ni.TextStyle = lipgloss.NewStyle().Foreground(c("text")) - ni.Width = 40 - ni.Focus() - - pi := bti.New() - pi.Prompt = sc.Render("Path") + sr.Render("*") + sc.Render(": ") - pi.Placeholder = "~/my-awesome-app" - pi.PromptStyle = lipgloss.NewStyle() - pi.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) - pi.TextStyle = lipgloss.NewStyle().Foreground(c("text")) - pi.Width = 40 - spi := bti.New() - spi.Prompt = "Subproject: " - spi.Placeholder = "services/api" - spi.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) - spi.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) - spi.TextStyle = lipgloss.NewStyle().Foreground(c("text")) - spi.Width = 40 - un := bti.New() - un.Prompt = "Git user.name: " - un.Placeholder = "Jane Doe" - un.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) - un.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) - un.TextStyle = lipgloss.NewStyle().Foreground(c("text")) - un.Width = 40 - uem := bti.New() - uem.Prompt = "Git user.email: " - uem.Placeholder = "jane@example.com" - uem.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) - uem.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) - uem.TextStyle = lipgloss.NewStyle().Foreground(c("text")) - uem.Width = 40 - usk := bti.New() - usk.Prompt = "Git user.signingkey: " - usk.Placeholder = "0xDEADBEEF" - usk.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) - usk.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) - usk.TextStyle = lipgloss.NewStyle().Foreground(c("text")) - usk.Width = 40 - csg := bti.New() - csg.Prompt = "commit.gpgsign (true/false): " - csg.Placeholder = strTrue - csg.SetValue(strTrue) - csg.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) - csg.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) - csg.TextStyle = lipgloss.NewStyle().Foreground(c("text")) - csg.Width = 40 - tsg := bti.New() - tsg.Prompt = "tag.gpgsign (true/false): " - tsg.Placeholder = strTrue - tsg.SetValue(strTrue) - tsg.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) - tsg.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) - tsg.TextStyle = lipgloss.NewStyle().Foreground(c("text")) - tsg.Width = 40 - sh := bti.New() - sh.Prompt = "Shell: " - sh.Placeholder = "/bin/bash" - sh.PromptStyle = lipgloss.NewStyle().Foreground(c("title")) - sh.PlaceholderStyle = lipgloss.NewStyle().Foreground(c("placeholder")) - sh.TextStyle = lipgloss.NewStyle().Foreground(c("text")) - sh.Width = 40 - ev := bta.New() - ev.Placeholder = "# One per line (like .env)\nAPP_ENV=development\nDATABASE_URL=postgres://user:pass@localhost:5432/app\n# comments allowed" - ev.SetHeight(6) - ev.SetWidth(60) - - h := helpcomp.New() - km := formKeyMap{} - km.Save = key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")) - km.Cancel = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")) - km.Next = key.NewBinding(key.WithKeys("tab", "down", "enter"), key.WithHelp("tab/↓/enter", "next")) - km.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev")) - km.Buttons = key.NewBinding(key.WithKeys("left", "right"), key.WithHelp("←/β†’", "buttons")) - km.Help = key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "toggle help")) - - return &NewProjectForm{ - name: ni, - path: pi, - subproject: spi, - shell: sh, - userName: un, - userEmail: uem, - userSigningKey: usk, - commitGPGSign: csg, - tagGPGSign: tsg, - envVars: ev, - focused: 0, - help: h, - keys: km, - } -} - -func (f *NewProjectForm) Init() tea.Cmd { return bti.Blink } - -func (f *NewProjectForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - f.width = msg.Width - f.height = msg.Height - - w := msg.Width - 10 - if w < 20 { - w = 20 - } - - f.name.Width = w - f.path.Width = w - f.subproject.Width = w - f.shell.Width = w - f.userName.Width = w - f.userEmail.Width = w - f.userSigningKey.Width = w - f.commitGPGSign.Width = w - f.tagGPGSign.Width = w - f.envVars.SetWidth(w + 10) - case tea.KeyMsg: - switch msg.Type { - case tea.KeyEsc, tea.KeyCtrlC: - f.canceled = true - f.submitted = false - - return f, nil - case tea.KeyTab, tea.KeyShiftTab: - if msg.Type == tea.KeyTab { - f.focused++ - if f.focused > 11 { - f.focused = 0 - } - } else { - f.focused-- - if f.focused < 0 { - f.focused = 11 - } - } - - if f.isEdit && f.focused == 1 { - if msg.Type == tea.KeyTab { - f.focused++ - } else { - f.focused-- - } - } - - f.blurAll() - f.focusCurrent() - - return f, nil - case tea.KeyEnter: - switch { - case f.focused < 9: - if f.isEdit && f.focused == 1 { - f.focused++ - } - - f.focused++ - f.blurAll() - f.focusCurrent() - - return f, nil - case f.focused == 10: - f.err = "" - if strings.TrimSpace(f.name.Value()) == "" { - f.err = "name is required" - return f, nil - } - - if !f.isEdit { - if strings.TrimSpace(f.path.Value()) == "" { - f.err = errPathRequired - return f, nil - } - - if ok, reason := canCreatePath(f.path.Value()); !ok { - f.err = reason - return f, nil - } - } - - cg := strings.ToLower(strings.TrimSpace(f.commitGPGSign.Value())) - if cg != "" && cg != strTrue && cg != strFalse { - f.err = "commit.gpgsign must be true or false" - return f, nil - } - - tg := strings.ToLower(strings.TrimSpace(f.tagGPGSign.Value())) - if tg != "" && tg != strTrue && tg != strFalse { - f.err = "tag.gpgsign must be true or false" - return f, nil - } - - f.submitted = true - f.canceled = false - - return f, nil - case f.focused == 11: - f.canceled = true - f.submitted = false - - return f, nil - } - case tea.KeyUp: - if f.focused != 9 { - f.focused-- - if f.isEdit && f.focused == 1 { - f.focused-- - } - - if f.focused < 0 { - f.focused = 11 - } - - f.blurAll() - f.focusCurrent() - - return f, nil - } - case tea.KeyDown: - if f.focused != 9 { - f.focused++ - if f.isEdit && f.focused == 1 { - f.focused++ - } - - if f.focused > 11 { - f.focused = 0 - } - - f.blurAll() - f.focusCurrent() - - return f, nil - } - case tea.KeyLeft: - if f.focused == 11 { - f.focused = 10 - f.blurAll() - f.focusCurrent() - - return f, nil - } - case tea.KeyRight: - if f.focused == 10 { - f.focused = 11 - f.blurAll() - f.focusCurrent() - - return f, nil - } - case tea.KeyCtrlS: - f.err = "" - if strings.TrimSpace(f.name.Value()) == "" { - f.err = "name is required" - - return f, nil - } - - if !f.isEdit { - if strings.TrimSpace(f.path.Value()) == "" { - f.err = errPathRequired - - return f, nil - } - - if ok, reason := canCreatePath(f.path.Value()); !ok { - f.err = reason - - return f, nil - } - } - - cg := strings.ToLower(strings.TrimSpace(f.commitGPGSign.Value())) - if cg != "" && cg != strTrue && cg != strFalse { - f.err = "commit.gpgsign must be true or false" - - return f, nil - } - - tg := strings.ToLower(strings.TrimSpace(f.tagGPGSign.Value())) - if tg != "" && tg != "true" && tg != "false" { - f.err = "tag.gpgsign must be true or false" - - return f, nil - } - - f.submitted = true - f.canceled = false - - return f, nil - } - } - - if m, ok := msg.(tea.KeyMsg); ok { - if m.Type == tea.KeyRunes && len(m.Runes) > 0 && m.Runes[0] == '?' { - f.help.ShowAll = !f.help.ShowAll - return f, nil - } - } - - var cmd tea.Cmd - - switch f.focused { - case 0: - oldName := f.name.Value() - - f.name, cmd = f.name.Update(msg) - - if f.name.Value() != oldName && !f.pathDirty && !f.isEdit { - slug := slugify(strings.TrimSpace(f.name.Value())) - if slug != "" { - cur := strings.TrimSpace(f.path.Value()) - - var newPath string - - switch { - case cur == "": - newPath = filepath.Join("~/", slug) - case strings.HasSuffix(cur, string(os.PathSeparator)): - newPath = cur + slug - default: - dir := filepath.Dir(cur) - newPath = filepath.Join(dir, slug) - } - - f.path.SetValue(newPath) - } - } - case 1: - if f.isEdit { - break - } - - prev := f.path.Value() - - f.path, cmd = f.path.Update(msg) - - if f.path.Value() != prev { - f.pathDirty = true - } - case 2: - f.subproject, cmd = f.subproject.Update(msg) - case 3: - f.shell, cmd = f.shell.Update(msg) - case 4: - f.userName, cmd = f.userName.Update(msg) - case 5: - f.userEmail, cmd = f.userEmail.Update(msg) - case 6: - f.userSigningKey, cmd = f.userSigningKey.Update(msg) - case 7: - f.commitGPGSign, cmd = f.commitGPGSign.Update(msg) - case 8: - f.tagGPGSign, cmd = f.tagGPGSign.Update(msg) - case 9: - f.envVars, cmd = f.envVars.Update(msg) - case 10: - // save button: nothing to update - case 11: - // cancel button: nothing to update - } - - return f, cmd -} - -func (f *NewProjectForm) blurAll() { - f.name.Blur() - f.path.Blur() - f.subproject.Blur() - f.shell.Blur() - f.userName.Blur() - f.userEmail.Blur() - f.userSigningKey.Blur() - f.commitGPGSign.Blur() - f.tagGPGSign.Blur() - f.envVars.Blur() -} - -func (f *NewProjectForm) focusCurrent() { - switch f.focused { - case 0: - f.name.Focus() - case 1: - if !f.isEdit { - f.path.Focus() - } - case 2: - f.subproject.Focus() - case 3: - f.shell.Focus() - case 4: - f.userName.Focus() - case 5: - f.userEmail.Focus() - case 6: - f.userSigningKey.Focus() - case 7: - f.commitGPGSign.Focus() - case 8: - f.tagGPGSign.Focus() - case 9: - f.envVars.Focus() - } -} - -func (f *NewProjectForm) View() string { - errStyle := lipgloss.NewStyle().Foreground(c("error")) - styleTitle := lipgloss.NewStyle(). - Foreground(c("title")). - Align(lipgloss.Center). - Border(lipgloss.NormalBorder(), false, false, true). - BorderForeground(c("border")). - Padding(0, 1) - styleSection := lipgloss.NewStyle().Foreground(c("section")).Bold(true) - styleSub := lipgloss.NewStyle().Foreground(c("subtext")) - btnDef := lipgloss.NewStyle().Foreground(c("buttonDefFg")).Background(c("buttonDefBg")).Padding(0, 2) - btnSel := lipgloss.NewStyle().Foreground(c("buttonSelFg")).Background(c("buttonSelBg")).Padding(0, 2) - b := strings.Builder{} - - title := "New project" - if f.isEdit { - title = "Edit project" - } - - b.WriteString(styleTitle.Render(title)) - b.WriteString("\n") - b.WriteString(styleSection.Render("Project details")) - b.WriteString("\n") - b.WriteString(styleSub.Render("Basic information")) - b.WriteString("\n") - b.WriteString(f.name.View()) - b.WriteString("\n") - - pathView := f.path.View() - if f.isEdit { - pathView = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(f.path.Value() + " (read-only)") - } - - b.WriteString(pathView) - b.WriteString("\n\n") - b.WriteString(styleSection.Render("Project options")) - b.WriteString("\n") - b.WriteString(styleSub.Render("Optional settings")) - b.WriteString("\n") - b.WriteString(f.subproject.View()) - b.WriteString("\n") - b.WriteString(f.shell.View()) - b.WriteString("\n\n") - b.WriteString(styleSection.Render("Git settings")) - b.WriteString("\n") - b.WriteString(styleSub.Render("Applied to this project only")) - b.WriteString("\n") - b.WriteString(f.userName.View()) - b.WriteString("\n") - b.WriteString(f.userEmail.View()) - b.WriteString("\n") - b.WriteString(f.userSigningKey.View()) - b.WriteString("\n") - b.WriteString(f.commitGPGSign.View()) - b.WriteString("\n") - b.WriteString(f.tagGPGSign.View()) - b.WriteString("\n\n") - b.WriteString(styleSection.Render("Environment variables")) - b.WriteString("\n") - b.WriteString(styleSub.Render("One KEY=VALUE per line; '#' comments allowed")) - b.WriteString("\n") - b.WriteString(f.envVars.View()) - b.WriteString("\n\n") - - btnSave := btnDef.Render(" Save ") - if f.focused == 10 { - btnSave = btnSel.Render(" Save ") - } - - btnCancel := btnDef.Render(" Cancel ") - if f.focused == 11 { - btnCancel = btnSel.Render(" Cancel ") - } - - b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, btnSave, " ", btnCancel)) - b.WriteString("\n\n") - - if f.err != "" { - b.WriteString(errStyle.Render(f.err)) - b.WriteString("\n") - } - - content := b.String() - - sepLen := f.width - if sepLen < 1 { - sepLen = 80 - } - - sep := lipgloss.NewStyle().Foreground(c("border")).Render(strings.Repeat("─", sepLen)) - - return lipgloss.JoinVertical(lipgloss.Left, content, sep, f.help.View(f.keys)) -} - -func NewProjectEditFormModel(p *domain.Project) *NewProjectForm { - f := NewProjectFormModel() - f.isEdit = true - f.originalName = p.Name - f.name.SetValue(p.Name) - f.path.SetValue(p.Path) - f.shell.SetValue(p.Shell) - - return f -} - -func isDirEmpty(path string) (bool, error) { - f, err := os.Open(path) - if err != nil { - return false, err - } - - defer f.Close() - - _, err = f.Readdir(1) - if err == io.EOF { - return true, nil - } - - return false, err -} - -func slugify(s string) string { - s = strings.ToLower(strings.TrimSpace(s)) - if s == "" { - return "" - } - - s = strings.ReplaceAll(s, " ", "-") - re := regexp.MustCompile(`[^a-z0-9-_]+`) - s = re.ReplaceAllString(s, "-") - re2 := regexp.MustCompile(`-+`) - s = re2.ReplaceAllString(s, "-") - s = strings.Trim(s, "-_") - - return s -} - -func canCreatePath(p string) (bool, string) { - pp := strings.TrimSpace(p) - if pp == "" { - return false, "path is required" - } - - if strings.HasPrefix(pp, "~/") { - h, _ := os.UserHomeDir() - pp = filepath.Join(h, pp[2:]) - } - - info, err := os.Stat(pp) - if err == nil { - if !info.IsDir() { - return false, "path exists and is not a directory" - } - - empty, e := isDirEmpty(pp) - if e != nil { - return false, e.Error() - } - - if !empty { - return false, "directory is not empty" - } - - return true, "" - } - - parent := filepath.Dir(pp) - - pi, perr := os.Stat(parent) - if perr != nil || !pi.IsDir() { - return false, "parent directory does not exist" - } - - return true, "" -} diff --git a/internal/adapters/handlers/tui/v1/window_update.go b/internal/adapters/handlers/tui/v1/window_update.go deleted file mode 100644 index f1cc172..0000000 --- a/internal/adapters/handlers/tui/v1/window_update.go +++ /dev/null @@ -1,527 +0,0 @@ -package v1 - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/jlrosende/project-manager/internal/adapters/repositories" - "github.com/jlrosende/project-manager/internal/core/domain" - "github.com/jlrosende/project-manager/internal/core/services" -) - -func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if m.shouldQuit { - return m, tea.Quit - } - - var cmd tea.Cmd - - if m.mode == 1 && m.form != nil { - fm, fcmd := m.form.Update(msg) - if f, ok := fm.(*NewProjectForm); ok { - m.form = f - if f.canceled { - m.mode = 0 - m.form = nil - - return m, tea.ClearScreen - } - - if f.submitted { - name := strings.TrimSpace(f.name.Value()) - - path := strings.TrimSpace(f.path.Value()) - - if name == "" || path == "" { - return m, nil - } - - envs := domain.EnvVars{} - if strings.TrimSpace(f.envVars.Value()) != "" { - envs = parseEnvLines(f.envVars.Value()) - } - - commitSign := strings.ToLower(strings.TrimSpace(f.commitGPGSign.Value())) != strFalse - - tagSign := strings.ToLower(strings.TrimSpace(f.tagGPGSign.Value())) != "false" - if f.isEdit { - proj, _ := m.projectSvc.Load(f.originalName) - proj.Name = name - proj.Shell = strings.TrimSpace(f.shell.Value()) - _ = m.projectSvc.UpdateProject(proj) - // Validate but preserve raw content - raw := strings.TrimSpace(f.envVars.Value()) - if raw != "" { - lines := strings.Split(raw, "\n") - ok := true - - for _, line := range lines { - l := strings.TrimSpace(line) - if l == "" || strings.HasPrefix(l, "#") { - continue - } - - kv := strings.SplitN(l, "=", 2) - if len(kv) != 2 || strings.TrimSpace(kv[0]) == "" { - ok = false - break - } - } - - if ok { - projEnvPath := proj.EnvVarsFile - if !filepath.IsAbs(projEnvPath) { - projEnvPath = filepath.Join(proj.Path, projEnvPath) - } - - _ = os.WriteFile(projEnvPath, []byte(f.envVars.Value()), 0o600) - } - } - - gitRepo, err := repositories.NewGitRepository() - if err == nil { - gitSvc := services.NewGitService(gitRepo) - gitPath := filepath.Join(proj.Path, fmt.Sprintf(".%s.gitconfig", proj.Name)) - gc := &domain.GitConfig{ - User: domain.User{ - Name: strings.TrimSpace(f.userName.Value()), - Email: strings.TrimSpace(f.userEmail.Value()), - SigningKey: strings.TrimSpace(f.userSigningKey.Value()), - }, - Commit: domain.Commit{GPGSign: commitSign}, - Tag: domain.Tag{GPGSign: tagSign}, - } - _ = gitSvc.Save(gitPath, gc) - } - - projects, err := m.projectSvc.List() - if err == nil { - m.projects = projects - m.total = len(projects) + 1 - } - - m.mode = 0 - m.form = nil - - return m, tea.ClearScreen - } - - envFile := ".env" - proj, _ := m.projectSvc.Create( - name, - path, - f.subproject.Value(), - envFile, - envs, - domain.New( - domain.WithName(strings.TrimSpace(f.userName.Value())), - domain.WithEmail(strings.TrimSpace(f.userEmail.Value())), - domain.WithSigningKey(strings.TrimSpace(f.userSigningKey.Value())), - domain.WithCommitSign(commitSign), - domain.WithTagSign(tagSign), - ), - ) - if proj != nil { - proj.Shell = strings.TrimSpace(f.shell.Value()) - _ = m.projectSvc.UpdateProject(proj) - } - - projects, err := m.projectSvc.List() - if err == nil { - m.projects = projects - m.total = len(projects) + 1 - } - - m.prompt = newPostCreatePrompt(name) - m.mode = 2 - _ = proj - - return m, tea.ClearScreen - } - - return m, fcmd - } - } - - if m.mode == 2 && m.prompt != nil { - pm, pcmd := m.prompt.Update(msg) - if p, ok := pm.(*postCreatePrompt); ok { - m.prompt = p - if p.choice == 0 { // return - m.mode = 0 - m.prompt = nil - - return m, tea.ClearScreen - } - - if p.choice == 1 { // start project - proj, _ := m.projectSvc.Load(p.projectName) - m.selectedProject = proj - - return m, tea.Quit - } - - if p.choice == 2 { // exit - m.selectedProject = nil - - return m, tea.Quit - } - - return m, pcmd - } - } - - if m.mode == 3 && m.envForm != nil { - efm, ecmd := m.envForm.Update(msg) - if f, ok := efm.(*NewEnvironmentForm); ok { - m.envForm = f - if f.canceled { - m.mode = 0 - m.envForm = nil - - return m, tea.ClearScreen - } - - if f.submitted { - name := strings.TrimSpace(f.name.Value()) - if name == "" || m.envProjectName == "" { - return m, nil - } - - env := &domain.Environment{ - Name: name, - Color: normalizeColorInput(strings.TrimSpace(f.color.Value())), - EnvVarsMode: strings.TrimSpace(f.mode.Value()), - } - - envs := domain.EnvVars{} - if strings.TrimSpace(f.envVars.Value()) != "" { - envs = parseEnvLines(f.envVars.Value()) - } - - if f.isEdit { - _ = m.projectSvc.UpdateEnvironment(m.envProjectName, f.originalName, env) - // Persist env vars to env file - project, _ := m.projectSvc.Load(m.envProjectName) - // find updated env (by new name) - var envPath string - - for _, e := range project.Environments { - if e.Name == env.Name { - envPath = e.EnvVarsFile - break - } - } - - if envPath != "" { - if !filepath.IsAbs(envPath) { - envPath = filepath.Join(project.Path, envPath) - } - // validate but keep raw - raw := strings.TrimSpace(f.envVars.Value()) - if raw != "" { - lines := strings.Split(raw, "\n") - ok := true - - for _, line := range lines { - l := strings.TrimSpace(line) - if l == "" || strings.HasPrefix(l, "#") { - continue - } - - kv := strings.SplitN(l, "=", 2) - if len(kv) != 2 || strings.TrimSpace(kv[0]) == "" { - ok = false - break - } - } - - if ok { - _ = os.WriteFile(envPath, []byte(f.envVars.Value()), 0o600) - } - } - } - } else { - _ = m.projectSvc.AddEnvironment(m.envProjectName, env, envs) - } - - projects, err := m.projectSvc.List() - if err == nil { - m.projects = projects - m.total = len(projects) + 1 - } - - m.mode = 0 - m.envForm = nil - m.envProjectName = "" - - return m, tea.ClearScreen - } - - return m, ecmd - } - } - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.vp.Width = msg.Width - - hh := lipgloss.Height(m.help.View(m.keys)) - - vh := msg.Height - hh - if vh < 1 { - vh = 1 - } - - m.vp.Height = vh - case tea.KeyMsg: - if m.mode == 0 { - var vcmd tea.Cmd - - m.vp, vcmd = m.vp.Update(msg) - cmd = tea.Batch(cmd, vcmd) - } - - if msg.Type == tea.KeyRunes { - r := string(msg.Runes) - switch r { - case "?": - m.help.ShowAll = !m.help.ShowAll - - hh := lipgloss.Height(m.help.View(m.keys)) - - vh := m.height - hh - if vh < 1 { - vh = 1 - } - - m.vp.Height = vh - - return m, nil - case "e": - idx := mod(m.cursor, m.total) - if m.focus == 0 && idx < len(m.projects) { - project, _ := m.projectSvc.Load(m.projects[idx].Name) - m.form = NewProjectEditFormModel(project) - - projEnvPath := project.EnvVarsFile - if !filepath.IsAbs(projEnvPath) { - projEnvPath = filepath.Join(project.Path, projEnvPath) - } - - if b, err := os.ReadFile(projEnvPath); err == nil { - m.form.envVars.SetValue(string(b)) - } else { - pairs := project.EnvVars.ToSlice() - sort.Strings(pairs) - m.form.envVars.SetValue(strings.Join(pairs, "\n")) - } - - gitRepo, err := repositories.NewGitRepository() - if err == nil { - gitSvc := services.NewGitService(gitRepo) - - gitPath := filepath.Join(project.Path, fmt.Sprintf(".%s.gitconfig", project.Name)) - if _, err := os.Stat(gitPath); os.IsNotExist(err) { - if matches, _ := filepath.Glob(filepath.Join(project.Path, ".*.gitconfig")); len(matches) > 0 { - gitPath = matches[0] - } - } - - if gc, e := gitSvc.Load(gitPath); e == nil { - m.form.userName.SetValue(gc.User.Name) - m.form.userEmail.SetValue(gc.User.Email) - m.form.userSigningKey.SetValue(gc.User.SigningKey) - - if gc.Commit.GPGSign { - m.form.commitGPGSign.SetValue(strTrue) - } else { - m.form.commitGPGSign.SetValue(strFalse) - } - - if gc.Tag.GPGSign { - m.form.tagGPGSign.SetValue(strTrue) - } else { - m.form.tagGPGSign.SetValue(strFalse) - } - } - } - - m.mode = 1 - - return m, tea.ClearScreen - } - - if m.focus == 1 && idx < len(m.projects) { - project, _ := m.projectSvc.Load(m.projects[idx].Name) - if m.cursorEnv < len(project.Environments) { - env := project.Environments[m.cursorEnv] - m.envForm = NewEnvironmentEditFormModel(env) - - envPath := env.EnvVarsFile - if !filepath.IsAbs(envPath) { - envPath = filepath.Join(project.Path, envPath) - } - - if b, err := os.ReadFile(envPath); err == nil { - m.envForm.envVars.SetValue(string(b)) - } else if len(env.EnvVars) > 0 { - pairs := env.EnvVars.ToSlice() - sort.Strings(pairs) - m.envForm.envVars.SetValue(strings.Join(pairs, "\n")) - } - - m.envProjectName = project.Name - m.mode = 3 - - return m, tea.ClearScreen - } - } - case "h": - m.focus = 0 - case "l": - mod := mod(m.cursor, m.total) - if mod < len(m.projects) && len(m.projects) > 0 { - m.focus = 1 - if len(m.projects[mod].Environments) == 0 { - m.cursorEnv = 0 - } - } - case "k": - if m.focus == 0 { - m.cursor-- - m.cursorEnv = 0 - } else { - mod := mod(m.cursor, m.total) - if mod < len(m.projects) && len(m.projects) > 0 { - envCount := len(m.projects[mod].Environments) - - envCountPlus := envCount + 1 - if envCountPlus > 0 { - m.cursorEnv-- - if m.cursorEnv < 0 { - m.cursorEnv = envCountPlus - 1 - } - } - } - } - case "j": - if m.focus == 0 { - m.cursor++ - m.cursorEnv = 0 - } else { - mod := mod(m.cursor, m.total) - if mod < len(m.projects) && len(m.projects) > 0 { - envCount := len(m.projects[mod].Environments) - - envCountPlus := envCount + 1 - if envCountPlus > 0 { - m.cursorEnv++ - if m.cursorEnv >= envCountPlus { - m.cursorEnv = 0 - } - } - } - } - case "q": - m.selectedProject = nil - return m, tea.Quit - } - } - - switch msg.Type { - case tea.KeyEsc, tea.KeyCtrlC: - m.selectedProject = nil - return m, tea.Quit - case tea.KeyEnter: - if m.focus == 0 { - idx := mod(m.cursor, m.total) - if idx == len(m.projects) { // '+ New project' - m.newProjectFlow() - - return m, tea.ClearScreen - } - - project, _ := m.projectSvc.Load(m.projects[idx].Name) - m.selectedProject = project - - return m, tea.Quit - } - - if m.focus == 1 { - mod := mod(m.cursor, m.total) - if mod < len(m.projects) && len(m.projects) > 0 { - envCount := len(m.projects[mod].Environments) - if m.cursorEnv == envCount { // '+ New environment' - m.newEnvironmentFlow(m.projects[mod].Name) - - return m, tea.ClearScreen - } - - if envCount > 0 { - project, _ := m.projectSvc.Load(m.projects[mod].Name) - m.selectedProject = project - - return m, tea.Quit - } - } - } - case tea.KeyLeft: - m.focus = 0 - case tea.KeyRight: - mod := mod(m.cursor, m.total) - if mod < len(m.projects) && len(m.projects) > 0 { - m.focus = 1 - if len(m.projects[mod].Environments) == 0 { - m.cursorEnv = 0 - } - } - case tea.KeyUp: - if m.focus == 0 { - m.cursor-- - m.cursorEnv = 0 - } else { - mod := mod(m.cursor, m.total) - if mod < len(m.projects) && len(m.projects) > 0 { - envCount := len(m.projects[mod].Environments) - - envCountPlus := envCount + 1 - if envCountPlus > 0 { - m.cursorEnv-- - if m.cursorEnv < 0 { - m.cursorEnv = envCountPlus - 1 - } - } - } - } - case tea.KeyDown: - if m.focus == 0 { - m.cursor++ - m.cursorEnv = 0 - } else { - mod := mod(m.cursor, m.total) - if mod < len(m.projects) && len(m.projects) > 0 { - envCount := len(m.projects[mod].Environments) - - envCountPlus := envCount + 1 - if envCountPlus > 0 { - m.cursorEnv++ - if m.cursorEnv >= envCountPlus { - m.cursorEnv = 0 - } - } - } - } - } - } - - return m, cmd -} diff --git a/internal/adapters/handlers/tui/v1/window_view.go b/internal/adapters/handlers/tui/v1/window_view.go deleted file mode 100644 index 0f4e5b6..0000000 --- a/internal/adapters/handlers/tui/v1/window_view.go +++ /dev/null @@ -1,129 +0,0 @@ -package v1 - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" -) - -func (m Window) View() string { - if m.mode == 1 && m.form != nil { - return m.form.View() - } - - if m.mode == 2 && m.prompt != nil { - return m.prompt.View() - } - - if m.mode == 3 && m.envForm != nil { - return m.envForm.View() - } - - left := strings.Builder{} - title := " Projects" - titleStyle := m.styles.Title - defaultStyle := m.styles.Default - - left.WriteString(titleStyle.Render(title)) - left.WriteString("\n") - - mod := mod(m.cursor, m.total) - for i, project := range m.projects { - selected := mod == i - - marker := " " - text := defaultStyle.Render(project.Name) - - if selected { - marker = lipgloss.NewStyle().Foreground(c("selectedBg")).Render("β–Œ ") - text = lipgloss.NewStyle().Foreground(c("selectedFg")).Bold(true).Render(project.Name) - } - - left.WriteString(marker) - left.WriteString(text) - left.WriteString("\n") - } - - if mod == len(m.projects) { - left.WriteString(lipgloss.NewStyle().Foreground(c("selectedBg")).Render("β–Œ ")) - left.WriteString(lipgloss.NewStyle().Foreground(c("selectedFg")).Bold(true).Render("+ New project")) - left.WriteString("\n") - } else { - left.WriteString(" ") - left.WriteString(lipgloss.NewStyle().Foreground(c("section")).Bold(true).Render("+ New project")) - left.WriteString("\n") - } - - right := strings.Builder{} - rightTitle := "󱄑 Environments" - right.WriteString(titleStyle.Render(rightTitle)) - right.WriteString("\n") - - if mod < len(m.projects) && len(m.projects) > 0 { - p := m.projects[mod] - if len(p.Environments) == 0 { - marker := " " - if m.focus == 1 && m.cursorEnv == 0 { - marker = lipgloss.NewStyle().Foreground(c("selectedBg")).Render("β–Œ ") - } - - right.WriteString(marker) - right.WriteString(lipgloss.NewStyle().Foreground(c("section")).Bold(true).Render("+ New environment")) - right.WriteString("\n") - } else { - for i, env := range p.Environments { - marker := " " - - selected := m.focus == 1 && m.cursorEnv == i - - if selected { - marker = lipgloss.NewStyle().Foreground(lipgloss.Color(env.Color)).Render("β–Œ ") - } - - var content string - - if selected { - bullet := lipgloss.NewStyle().Foreground(lipgloss.Color(env.Color)).Render("● ") - name := lipgloss.NewStyle().Foreground(c("selectedFg")).Bold(true).Render(env.Name) - content = bullet + name - } else { - content = defaultStyle.Foreground(lipgloss.Color(env.Color)).Render("● " + env.Name) - } - - if p.DefaultEnv != "" && env.Name == p.DefaultEnv { - content = content + " " + lipgloss.NewStyle().Foreground(c("subtext")).Render("(default)") - } - - right.WriteString(marker) - right.WriteString(content) - right.WriteString("\n") - } - } - - if mod < len(m.projects) && len(m.projects) > 0 && len(m.projects[mod].Environments) > 0 { - marker := " " - if m.focus == 1 && m.cursorEnv == len(m.projects[mod].Environments) { - marker = lipgloss.NewStyle().Foreground(c("selectedBg")).Render("β–Œ ") - } - - right.WriteString(marker) - right.WriteString(lipgloss.NewStyle().Foreground(c("section")).Bold(true).Render("+ New environment")) - right.WriteString("\n") - } - } - - content := lipgloss.JoinHorizontal(lipgloss.Left, left.String(), " ", right.String()) - - sepLen := m.width - if sepLen < 1 { - sepLen = 80 - } - - sep := lipgloss.NewStyle().Foreground(c("border")).Render(strings.Repeat("─", sepLen)) - content = content + "\n" + sep + "\n" + m.help.View(m.keys) - - v := m.vp - v.SetContent(content) - - return v.View() -} diff --git a/internal/adapters/handlers/tui/v2/views/env_form_view.go b/internal/adapters/handlers/tui/views/env_form_view.go similarity index 83% rename from internal/adapters/handlers/tui/v2/views/env_form_view.go rename to internal/adapters/handlers/tui/views/env_form_view.go index d97ff08..dd23e9c 100644 --- a/internal/adapters/handlers/tui/v2/views/env_form_view.go +++ b/internal/adapters/handlers/tui/views/env_form_view.go @@ -6,8 +6,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/components" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/components" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/state" "github.com/jlrosende/project-manager/internal/core/domain" ) @@ -166,30 +166,11 @@ func (v *EnvFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m := msg.(type) { case components.ButtonPressedMsg: if m.Label == "Save" { - v.Err = "" - name := strings.TrimSpace(v.Name.Value()) - - mode := strings.TrimSpace(v.Mode.Value()) - if name == "" { - v.Err = "name is required" - return v, nil - } - - if mode != domain.EnvVarsModeMerge && mode != domain.EnvVarsModeReplace { - v.Err = "mode must be merge or replace" - return v, nil + if cmd, ok := v.makeSaveCmd(); ok { + return v, cmd } - return v, func() tea.Msg { - return state.SaveEnvFormMsg{ - OriginalName: v.OriginalName, - Name: name, - Color: strings.TrimSpace(v.Color.Value()), - Mode: mode, - EnvVarsFile: strings.TrimSpace(v.EnvFile.Value()), - EnvVarsRaw: v.EnvVars.Value(), - } - } + return v, nil } if m.Label == "Cancel" { @@ -212,30 +193,11 @@ func (v *EnvFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, nil case components.ButtonChosenMsg: if m.Label == "Save" { - v.Err = "" - name := strings.TrimSpace(v.Name.Value()) - - mode := strings.TrimSpace(v.Mode.Value()) - if name == "" { - v.Err = "name is required" - return v, nil + if cmd, ok := v.makeSaveCmd(); ok { + return v, cmd } - if mode != domain.EnvVarsModeMerge && mode != domain.EnvVarsModeReplace { - v.Err = "mode must be merge or replace" - return v, nil - } - - return v, func() tea.Msg { - return state.SaveEnvFormMsg{ - OriginalName: v.OriginalName, - Name: name, - Color: strings.TrimSpace(v.Color.Value()), - Mode: mode, - EnvVarsFile: strings.TrimSpace(v.EnvFile.Value()), - EnvVarsRaw: v.EnvVars.Value(), - } - } + return v, nil } if m.Label == "Cancel" { @@ -287,40 +249,23 @@ func (v *EnvFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s := m.String() switch s { case "ctrl+s": - v.Err = "" - name := strings.TrimSpace(v.Name.Value()) - - mode := strings.TrimSpace(v.Mode.Value()) - if name == "" { - v.Err = "name is required" - return v, nil + if cmd, ok := v.makeSaveCmd(); ok { + return v, cmd } - if mode != domain.EnvVarsModeMerge && mode != domain.EnvVarsModeReplace { - v.Err = "mode must be merge or replace" - return v, nil - } - - return v, func() tea.Msg { - return state.SaveEnvFormMsg{ - OriginalName: v.OriginalName, - Name: name, - Color: strings.TrimSpace(v.Color.Value()), - Mode: mode, - EnvVarsFile: strings.TrimSpace(v.EnvFile.Value()), - EnvVarsRaw: v.EnvVars.Value(), - } - } + return v, nil } } oldName := strings.TrimSpace(v.Name.Value()) n, nameCmd := v.Name.Update(msg) v.Name = n + newName := strings.TrimSpace(v.Name.Value()) if newName != oldName { slug := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(newName, " ", "-"))) cur := strings.TrimSpace(v.EnvFile.Value()) + oldSlug := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(oldName, " ", "-"))) if slug == "" { if cur == "" || cur == "."+oldSlug+".env" { @@ -328,10 +273,11 @@ func (v *EnvFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } else { if cur == "" || cur == "."+oldSlug+".env" { - v.EnvFile.SetValue("."+slug+".env") + v.EnvFile.SetValue("." + slug + ".env") } } } + c, colorCmd := v.Color.Update(msg) v.Color = c mo, modeCmd := v.Mode.Update(msg) @@ -344,6 +290,33 @@ func (v *EnvFormView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, tea.Batch(nameCmd, colorCmd, modeCmd, efCmd, envCmd) } +func (v *EnvFormView) makeSaveCmd() (tea.Cmd, bool) { + v.Err = "" + name := strings.TrimSpace(v.Name.Value()) + + mode := strings.TrimSpace(v.Mode.Value()) + if name == "" { + v.Err = "name is required" + return nil, false + } + + if mode != domain.EnvVarsModeMerge && mode != domain.EnvVarsModeReplace { + v.Err = "mode must be merge or replace" + return nil, false + } + + return func() tea.Msg { + return state.SaveEnvFormMsg{ + OriginalName: v.OriginalName, + Name: name, + Color: strings.TrimSpace(v.Color.Value()), + Mode: mode, + EnvVarsFile: strings.TrimSpace(v.EnvFile.Value()), + EnvVarsRaw: v.EnvVars.Value(), + } + }, true +} + func (v *EnvFormView) View() string { b := strings.Builder{} @@ -373,10 +346,12 @@ func (v *EnvFormView) View() string { { p := v.Section val := v.Item + cc := normalizeEnvColorInput(strings.TrimSpace(v.Color.Value())) if cc != "" { val = val.Foreground(lipgloss.Color(cc)) } + v.Color.SetPrompt(p.Render("Color (name/#hex/0-255): ")) v.Color.SetPromptStyle(v.Section) v.Color.SetPlaceholderStyle(v.Subtext) @@ -395,6 +370,7 @@ func (v *EnvFormView) View() string { v.EnvFile.SetPlaceholderStyle(v.Subtext) v.EnvFile.SetTextStyle(val) } + b.WriteString(v.EnvFile.View()) b.WriteString("\n") { @@ -474,21 +450,27 @@ func normalizeEnvColorInput(s string) string { if ss == "" { return "" } + if strings.HasPrefix(ss, "#") { return ss } + if v, ok := envColorNameMap[ss]; ok { return v } + isNum := true + for _, r := range ss { if r < '0' || r > '9' { isNum = false break } } + if isNum { return ss } + return "" } diff --git a/internal/adapters/handlers/tui/v2/views/env_vars_view.go b/internal/adapters/handlers/tui/views/env_vars_view.go similarity index 98% rename from internal/adapters/handlers/tui/v2/views/env_vars_view.go rename to internal/adapters/handlers/tui/views/env_vars_view.go index 9200ddf..3a8270e 100644 --- a/internal/adapters/handlers/tui/v2/views/env_vars_view.go +++ b/internal/adapters/handlers/tui/views/env_vars_view.go @@ -4,8 +4,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/components" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/components" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/state" ) type EnvVarsViewStyles struct { diff --git a/internal/adapters/handlers/tui/v2/views/project_form_view.go b/internal/adapters/handlers/tui/views/project_form_view.go similarity index 97% rename from internal/adapters/handlers/tui/v2/views/project_form_view.go rename to internal/adapters/handlers/tui/views/project_form_view.go index d9a461f..ede94cb 100644 --- a/internal/adapters/handlers/tui/v2/views/project_form_view.go +++ b/internal/adapters/handlers/tui/views/project_form_view.go @@ -6,8 +6,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/components" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/components" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/state" ) const ( @@ -96,18 +96,18 @@ func WithProjectFormAccent(s lipgloss.Style) ProjectFormViewStyleOption { } type ProjectFormInit struct { - OriginalName string - Name string - Path string - Subproject string - Shell string - GitUserName string - GitUserEmail string - GitSigningKey string - CommitGPGSign string - TagGPGSign string - EnvVarsFile string - EnvVarsRaw string + OriginalName string + Name string + Path string + Subproject string + Shell string + GitUserName string + GitUserEmail string + GitSigningKey string + CommitGPGSign string + TagGPGSign string + EnvVarsFile string + EnvVarsRaw string } func NewProjectFormViewWith(init ProjectFormInit, styles ProjectFormViewStyles) ProjectFormView { @@ -116,30 +116,39 @@ func NewProjectFormViewWith(init ProjectFormInit, styles ProjectFormViewStyles) if strings.TrimSpace(init.Subproject) != "" { v.Subproject.SetValue(init.Subproject) } + if strings.TrimSpace(init.Shell) != "" { v.Shell.SetValue(init.Shell) } + if strings.TrimSpace(init.GitUserName) != "" { v.UserName.SetValue(init.GitUserName) } + if strings.TrimSpace(init.GitUserEmail) != "" { v.UserEmail.SetValue(init.GitUserEmail) } + if strings.TrimSpace(init.GitSigningKey) != "" { v.UserSigningKey.SetValue(init.GitSigningKey) } + if strings.TrimSpace(init.CommitGPGSign) != "" { v.CommitGPGSign.SetValue(init.CommitGPGSign) } + if strings.TrimSpace(init.TagGPGSign) != "" { v.TagGPGSign.SetValue(init.TagGPGSign) } + if strings.TrimSpace(init.EnvVarsFile) != "" { v.EnvFile.SetValue(init.EnvVarsFile) } + if strings.TrimSpace(init.EnvVarsRaw) != "" { v.EnvVars.SetValue(init.EnvVarsRaw) } + if strings.TrimSpace(init.OriginalName) != "" { v.IsEdit = true v.OriginalName = init.OriginalName @@ -183,7 +192,7 @@ func NewProjectFormView( Name: components.NewInput("Name*", "my-awesome-app", name, styles.Input, styles.InputVal), Path: components.NewInput("Path*", "~/my-awesome-app", path, styles.Input, styles.InputVal), Subproject: components.NewInput("Subproject", "services/api", "", styles.Input, styles.InputVal), - Shell: components.NewInput("Shell", "/bin/bash", "", styles.Input, styles.InputVal), + Shell: components.NewInput("Shell", "bash", "", styles.Input, styles.InputVal), UserName: components.NewInput("Git user.name", "Jane Doe", "", styles.Input, styles.InputVal), UserEmail: components.NewInput("Git user.email", "jane@example.com", "", styles.Input, styles.InputVal), UserSigningKey: components.NewInput("Git user.signingkey", "0xDEADBEEF", "", styles.Input, styles.InputVal), @@ -678,6 +687,7 @@ func (v ProjectFormView) View() string { v.EnvFile.SetPlaceholderStyle(v.Subtext) v.EnvFile.SetTextStyle(val) } + b.WriteString(v.EnvFile.View()) b.WriteString("\n") b.WriteString(v.Subtext.Render("One KEY=VALUE per line; '#' comments allowed")) diff --git a/internal/adapters/handlers/tui/v2/views/projects_view.go b/internal/adapters/handlers/tui/views/projects_view.go similarity index 98% rename from internal/adapters/handlers/tui/v2/views/projects_view.go rename to internal/adapters/handlers/tui/views/projects_view.go index ebfec2d..2c7012b 100644 --- a/internal/adapters/handlers/tui/v2/views/projects_view.go +++ b/internal/adapters/handlers/tui/views/projects_view.go @@ -6,8 +6,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/components" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/components" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/state" ) type projectsLoadedMsg struct{ Items []components.ListItem } diff --git a/internal/adapters/handlers/tui/v2/window.go b/internal/adapters/handlers/tui/window.go similarity index 96% rename from internal/adapters/handlers/tui/v2/window.go rename to internal/adapters/handlers/tui/window.go index 571f57d..d0189dd 100644 --- a/internal/adapters/handlers/tui/v2/window.go +++ b/internal/adapters/handlers/tui/window.go @@ -1,7 +1,6 @@ -package v2 +package tui import ( - "fmt" "os" "path/filepath" "sort" @@ -13,12 +12,11 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/components" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/router" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" - stylespkg "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/styles" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/views" - "github.com/jlrosende/project-manager/internal/adapters/repositories" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/components" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/router" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/state" + stylespkg "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/styles" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/views" "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/services" ) @@ -1007,12 +1005,51 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { domain.WithTagSign(tagEnable), ) - if f, ok := m.form.(*views.ProjectFormView); ok && f.IsEdit && strings.TrimSpace(f.OriginalName) != "" { - proj, _ := m.projectSvc.Load(f.OriginalName) + if fv, ok := m.form.(views.ProjectFormView); ok && fv.IsEdit && strings.TrimSpace(fv.OriginalName) != "" { + proj, _ := m.projectSvc.Load(fv.OriginalName) if proj != nil { proj.Name = name - proj.Shell = strings.TrimSpace(msg.Shell) + s := strings.TrimSpace(msg.Shell) + proj.Shell = s + + if envFile != "" { + proj.EnvVarsFile = envFile + } + _ = m.projectSvc.UpdateProject(proj) + + raw := strings.TrimSpace(msg.EnvVarsRaw) + if raw != "" { + p := proj.EnvVarsFile + if !filepath.IsAbs(p) { + p = filepath.Join(proj.Path, p) + } + + lines := strings.Split(raw, "\n") + ok := true + + for _, line := range lines { + l := strings.TrimSpace(line) + if l == "" || strings.HasPrefix(l, "#") { + continue + } + + kv := strings.SplitN(l, "=", 2) + if len(kv) != 2 || strings.TrimSpace(kv[0]) == "" { + ok = false + break + } + } + + if ok { + _ = os.WriteFile(p, []byte(raw), 0o600) + } + } + } + + if projects, err := m.projectSvc.List(); err == nil { + m.projects = projects + m.total = len(projects) + 1 } m.mode = 0 @@ -1027,7 +1064,7 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.ClearScreen } - _, _ = m.projectSvc.Create(name, path, sub, envFile, envs, gitCfg) + _, _ = m.projectSvc.Create(name, path, sub, strings.TrimSpace(msg.Shell), envFile, envs, gitCfg) if projects, err := m.projectSvc.List(); err == nil { m.projects = projects @@ -1095,10 +1132,12 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { projFormStyles := m.buildProjectFormStyles() // Build initial data for edit form envRaw := "" + envPath := project.EnvVarsFile if !filepath.IsAbs(envPath) { envPath = filepath.Join(project.Path, envPath) } + if b, err := os.ReadFile(envPath); err == nil { envRaw = string(b) } else { @@ -1109,22 +1148,6 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { gName, gEmail, gKey := "", "", "" gCommit, gTag := "", "" - if gitRepo, err := repositories.NewGitRepository(); err == nil { - gitSvc := services.NewGitService(gitRepo) - gitPath := filepath.Join(project.Path, fmt.Sprintf(".%s.gitconfig", project.Name)) - if _, err := os.Stat(gitPath); os.IsNotExist(err) { - if matches, _ := filepath.Glob(filepath.Join(project.Path, ".*.gitconfig")); len(matches) > 0 { - gitPath = matches[0] - } - } - if gc, e := gitSvc.Load(gitPath); e == nil { - gName = gc.User.Name - gEmail = gc.User.Email - gKey = gc.User.SigningKey - if gc.Commit.GPGSign { gCommit = "true" } else { gCommit = "false" } - if gc.Tag.GPGSign { gTag = "true" } else { gTag = "false" } - } - } init := views.ProjectFormInit{ OriginalName: project.Name, @@ -1304,6 +1327,7 @@ func (m *Window) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.envVarsView = nil + m.mode = 0 if m.r != nil { m.r.NavigateTo(router.RouteProjects, nil) diff --git a/internal/adapters/repositories/git_repository.go b/internal/adapters/repositories/git_repository.go index 58ef1db..f28d4ff 100644 --- a/internal/adapters/repositories/git_repository.go +++ b/internal/adapters/repositories/git_repository.go @@ -1,9 +1,8 @@ package repositories import ( - "fmt" - "log/slog" "os" + "path/filepath" "strconv" "github.com/go-git/go-git/v5/config" @@ -13,7 +12,8 @@ import ( ) type GitRepository struct { - git *config.Config + git *config.Config + global *config.Config } var _ ports.GitRepository = (*GitRepository)(nil) @@ -86,18 +86,69 @@ func (g *GitRepository) Save(path string, gitConfig *domain.GitConfig) error { _, _ = os.Stat(path) - fp, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) + fp, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return err } defer fp.Close() - nBytes, err := fp.Write(newGitConf) + _, err = fp.Write(newGitConf) if err != nil { return err } - slog.Debug(fmt.Sprintf("%d Bytes written in %s", nBytes, path)) + return nil +} + +func (g *GitRepository) LoadGlobal() error { + c, err := config.LoadConfig(config.GlobalScope) + if err != nil { + return err + } + + g.global = c return nil } + +func (g *GitRepository) UpdateIncludeIf(gitdir, perProjectPath, subproject string) error { + if g.global == nil { + if err := g.LoadGlobal(); err != nil { + return err + } + } + + includeIf := g.global.Raw.Section("includeIf").Subsection(gitdir) + includeIf.SetOption("path", perProjectPath) + + if subproject != "" { + includeIf.SetOption("subproject", subproject) + } + + return nil +} + +func (g *GitRepository) SaveGlobal(home string) error { + if g.global == nil { + if err := g.LoadGlobal(); err != nil { + return err + } + } + + b, err := g.global.Marshal() + if err != nil { + return err + } + + fp, err := os.OpenFile(filepath.Join(home, ".gitconfig"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer fp.Close() + + if _, err := fp.Write(b); err != nil { + return err + } + + return g.global.Validate() +} diff --git a/internal/adapters/repositories/project_repository.go b/internal/adapters/repositories/project_repository.go index 7df2c6e..34ec473 100644 --- a/internal/adapters/repositories/project_repository.go +++ b/internal/adapters/repositories/project_repository.go @@ -4,18 +4,18 @@ import ( "errors" "fmt" "io" - "log" - "log/slog" "os" "path/filepath" - "strconv" "strings" "github.com/go-git/go-git/v5/config" + "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclsimple" + "github.com/hashicorp/hcl/v2/hclwrite" "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/ports" + "github.com/jlrosende/project-manager/internal/tools" ) type ProjectRepository struct { @@ -38,19 +38,15 @@ func NewProjectRepository() (*ProjectRepository, error) { func (p *ProjectRepository) Get(name string) (*domain.Project, error) { for _, section := range p.git.Raw.Sections { if section.IsName("includeIf") { - slog.Debug(fmt.Sprintf("Section: %+v\n", section)) - for _, sub := range section.Subsections { // Read subsections and get path of the project - slog.Debug(fmt.Sprintf("\t - SubSection: %+v\n", sub)) - if path, ok := strings.CutPrefix(sub.Name, "gitdir/i:"); ok { - // read in path .project + path = filepath.Clean(path) projetPath := filepath.Join(path, ".project.hcl") - project, err := loadDotProject(projetPath) + project, err := p.loadDotProject(projetPath) if err != nil { - return nil, err + continue } if project.Name != name { @@ -75,24 +71,17 @@ func (p *ProjectRepository) List() ([]*domain.Project, error) { if section.IsName("includeIf") { for _, sub := range section.Subsections { if path, ok := strings.CutPrefix(sub.Name, "gitdir/i:"); ok { + path = filepath.Clean(path) projetPath := filepath.Join(path, ".project.hcl") - project, err := loadDotProject(projetPath) + project, err := p.loadDotProject(projetPath) if err != nil { - slog.Error(err.Error()) - continue } - if strings.HasPrefix(path, "~/") { - dirname, _ := os.UserHomeDir() - path = filepath.Join(dirname, path[2:]) - } - + path = tools.ExpandHome(path) project.Path = path - slog.Debug("load project", "path", project.Path) - projects = append(projects, project) } } @@ -111,9 +100,10 @@ NOTE: future work - If .project.hcl exist */ func (p *ProjectRepository) Create( - name, path, subproject, envFile string, - envVars domain.EnvVars, - gitConfig *domain.GitConfig, + name, path, _ string, + shell, envFile string, + _ domain.EnvVars, + _ *domain.GitConfig, ) (*domain.Project, error) { // Expand '~' to user home if strings.HasPrefix(path, "~/") { @@ -142,138 +132,48 @@ func (p *ProjectRepository) Create( path = absPath - log.Println(path) - - gitdir := fmt.Sprintf("gitdir/i:%s/", path) - // Create paths (idempotent) if err := os.MkdirAll(path, 0o755); err != nil { return nil, err } // Require empty directory to avoid clobbering existing projects - if isEmpty, err := IsDirEmpty(path); err != nil { + if isEmpty, err := tools.IsDirEmpty(path); err != nil { return nil, err } else if !isEmpty { return nil, fmt.Errorf("directory %s, is not empty", path) } - gitConfigName := fmt.Sprintf(".%s.gitconfig", name) - gitConfigPath := filepath.Join(path, gitConfigName) - - includeIf := p.git.Raw.Section("includeIf").Subsection(gitdir) - includeIf.SetOption("path", gitConfigPath) - - if subproject != "" { - includeIf.SetOption("subproject", subproject) - } - - gitConf, _ := p.git.Marshal() - log.Printf("\n%s", string(gitConf)) - - home, err := os.UserHomeDir() - if err != nil { - return nil, err - } - - fpGit, err := os.OpenFile(filepath.Join(home, ".gitconfig"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) - if err != nil { - return nil, err - } - - defer fpGit.Close() - - if _, err := fpGit.Write(gitConf); err != nil { - return nil, err - } - - if err := p.git.Validate(); err != nil { - return nil, err - } - - // Per-project git config - newConfig := config.NewConfig() - newConfig.User.Email = gitConfig.User.Email - newConfig.User.Name = gitConfig.User.Name - newConfig.Raw.AddOption("user", "", "signingkey", gitConfig.User.SigningKey) - newConfig.Raw.AddOption("commit", "", "gpgsign", strconv.FormatBool(gitConfig.Commit.GPGSign)) - newConfig.Raw.AddOption("tag", "", "gpgsign", strconv.FormatBool(gitConfig.Tag.GPGSign)) - - newGitConf, err := newConfig.Marshal() - if err != nil { - return nil, err - } - - if _, err := os.Stat(gitConfigPath); !os.IsNotExist(err) { - return nil, fmt.Errorf("%s already exists in directory %s", gitConfigName, path) - } - - fp, err := os.OpenFile(gitConfigPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) - if err != nil { - return nil, err - } - - defer fp.Close() - - if _, err := fp.Write(newGitConf); err != nil { - return nil, err - } - - if err := newConfig.Validate(); err != nil { - return nil, err - } - - // env file with env_vars - if strings.TrimSpace(envFile) == "" { - envFile = ".env" - } - - envPath := envFile - if !filepath.IsAbs(envFile) { - envPath = filepath.Join(path, envFile) - } - - if _, err = os.Stat(envPath); !os.IsNotExist(err) { - return nil, fmt.Errorf("%s already exists in directory %s", filepath.Base(envPath), filepath.Dir(envPath)) - } - - fpEnv, err := os.OpenFile(envPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) - if err != nil { - return nil, err - } - - defer fpEnv.Close() - - for key, value := range envVars { - if _, err := fmt.Fprintf(fpEnv, "%s=%s\n", key, value); err != nil { - return nil, err - } - } - // .project.hcl projPath := filepath.Join(path, ".project.hcl") if _, err = os.Stat(projPath); !os.IsNotExist(err) { return nil, fmt.Errorf("%s already exists in directory %s", filepath.Base(projPath), path) } - fpProj, err := os.OpenFile(projPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) + fpProj, err := os.OpenFile(projPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return nil, err } defer fpProj.Close() - projectHCL := fmt.Sprintf("name = \"%s\"\n\ndescription = \"\"\n\nenv_vars_file = \"%s\"\n", name, envFile) - if _, err := fpProj.WriteString(projectHCL); err != nil { + doc := hclwrite.NewEmptyFile() + body := doc.Body() + proj := &domain.Project{ + Name: name, + Description: "", + Shell: strings.TrimSpace(shell), + EnvVarsFile: envFile, + Environments: []*domain.Environment{}, + } + proj.Path = path + gohcl.EncodeIntoBody(proj, body) + + if _, err := fpProj.Write(doc.Bytes()); err != nil { return nil, err } - return &domain.Project{ - Name: name, - Description: "", - Path: path, - EnvVarsFile: envFile, - }, nil + return proj, nil } func (p *ProjectRepository) Delete(_ string) error { @@ -287,6 +187,11 @@ func (p *ProjectRepository) UpdateProject(project *domain.Project) error { projPath = filepath.Join(h, projPath[2:]) } + // reload global git config to ensure we operate on latest state + if gg, err := config.LoadConfig(config.GlobalScope); err == nil { + p.git = gg + } + projectDir := filepath.Dir(projPath) gitdir := fmt.Sprintf("gitdir/i:%s/", projectDir) includeIf := p.git.Raw.Section("includeIf").Subsection(gitdir) @@ -303,7 +208,7 @@ func (p *ProjectRepository) UpdateProject(project *domain.Project) error { gitConf, _ := p.git.Marshal() if home, err := os.UserHomeDir(); err == nil { - if fpGit, err := os.OpenFile(filepath.Join(home, ".gitconfig"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644); err == nil { + if fpGit, err := os.OpenFile(filepath.Join(home, ".gitconfig"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600); err == nil { _, _ = fpGit.Write(gitConf) _ = fpGit.Close() _ = p.git.Validate() @@ -311,39 +216,35 @@ func (p *ProjectRepository) UpdateProject(project *domain.Project) error { } } - b := &strings.Builder{} - fmt.Fprintf(b, "name = \"%s\"\n\n", project.Name) - fmt.Fprintf(b, "description = \"%s\"\n\n", project.Description) - - if project.Shell != "" { - fmt.Fprintf(b, "shell = \"%s\"\n\n", project.Shell) - } - - fmt.Fprintf(b, "env_vars_file = \"%s\"\n\n", project.EnvVarsFile) + // merge with existing .project.hcl to preserve fields like environments + cur, err := p.loadDotProject(projPath) + if err == nil && cur != nil { + if project.Description == "" { + project.Description = cur.Description + } - if project.DefaultEnv != "" { - fmt.Fprintf(b, "default_env = \"%s\"\n\n", project.DefaultEnv) - } + if strings.TrimSpace(project.Shell) == "" { + project.Shell = cur.Shell + } - for _, e := range project.Environments { - fmt.Fprintf(b, "environment \"%s\" {\n", e.Name) + if strings.TrimSpace(project.EnvVarsFile) == "" { + project.EnvVarsFile = cur.EnvVarsFile + } - if e.Color != "" { - fmt.Fprintf(b, " color = \"%s\"\n", e.Color) + if len(project.Environments) == 0 && len(cur.Environments) > 0 { + project.Environments = cur.Environments } - fmt.Fprintf(b, " env_vars_mode = \"%s\"\n", e.EnvVarsMode) - fmt.Fprintf(b, " env_vars_file = \"%s\"\n", e.EnvVarsFile) - b.WriteString("}\n\n") + if strings.TrimSpace(project.DefaultEnv) == "" { + project.DefaultEnv = cur.DefaultEnv + } } - fp, err := os.OpenFile(projPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) - if err != nil { - return err - } - defer fp.Close() + doc := hclwrite.NewEmptyFile() + body := doc.Body() + gohcl.EncodeIntoBody(project, body) - if _, err := io.WriteString(fp, b.String()); err != nil { + if err := os.WriteFile(projPath, doc.Bytes(), 0o600); err != nil { return err } @@ -357,12 +258,14 @@ func (p *ProjectRepository) UpdateEnvironment(projectName, originalEnvName strin } idx := -1 + for i, e := range project.Environments { if e.Name == originalEnvName { idx = i break } } + if idx == -1 { return fmt.Errorf("environment %s not found", originalEnvName) } @@ -371,6 +274,7 @@ func (p *ProjectRepository) UpdateEnvironment(projectName, originalEnvName strin // Update basic fields project.Environments[idx].Name = env.Name + project.Environments[idx].Color = env.Color if env.EnvVarsMode == "" { project.Environments[idx].EnvVarsMode = domain.EnvVarsModeMerge @@ -392,14 +296,14 @@ func (p *ProjectRepository) UpdateEnvironment(projectName, originalEnvName strin if !filepath.IsAbs(oldPath) { oldPath = filepath.Join(projPath, oldPath) } + if !filepath.IsAbs(newPath) { newPath = filepath.Join(projPath, newPath) } - _ = os.MkdirAll(filepath.Dir(newPath), 0o755) if _, err := os.Stat(oldPath); err == nil { if _, err := os.Stat(newPath); os.IsNotExist(err) { - _ = os.Rename(oldPath, newPath) + _ = tools.Rename(oldPath, newPath) } } @@ -409,7 +313,7 @@ func (p *ProjectRepository) UpdateEnvironment(projectName, originalEnvName strin return p.UpdateProject(project) } -func (p *ProjectRepository) AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error { +func (p *ProjectRepository) AddEnvironment(projectName string, env *domain.Environment, _ domain.EnvVars) error { project, err := p.Get(projectName) if err != nil { return err @@ -440,28 +344,6 @@ func (p *ProjectRepository) AddEnvironment(projectName string, env *domain.Envir } } - envPath := envFile - if !filepath.IsAbs(envFile) { - envPath = filepath.Join(project.Path, envFile) - } - - if _, err := os.Stat(envPath); !os.IsNotExist(err) { - return fmt.Errorf("%s already exists in directory %s", filepath.Base(envPath), filepath.Dir(envPath)) - } - - fpEnv, err := os.OpenFile(envPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) - if err != nil { - return err - } - - defer fpEnv.Close() - - for k, v := range envVars { - if _, err := fmt.Fprintf(fpEnv, "%s=%s\n", k, v); err != nil { - return err - } - } - projPath := filepath.Join(project.Path, ".project.hcl") fp, err := os.OpenFile(projPath, os.O_RDWR|os.O_APPEND, 0o644) @@ -498,13 +380,10 @@ func (p *ProjectRepository) AddEnvironment(projectName string, env *domain.Envir return nil } -func loadDotProject(path string) (*domain.Project, error) { +func (p *ProjectRepository) loadDotProject(path string) (*domain.Project, error) { project := &domain.Project{} - if strings.HasPrefix(path, "~/") { - dirname, _ := os.UserHomeDir() - path = filepath.Join(dirname, path[2:]) - } + path = tools.ExpandHome(path) err := hclsimple.DecodeFile(path, nil, project) if err != nil { @@ -521,21 +400,3 @@ func loadDotProject(path string) (*domain.Project, error) { return project, nil } - -func IsDirEmpty(path string) (bool, error) { - f, err := os.Open(path) - if err != nil { - return false, err - } - defer f.Close() - - // read in ONLY one file - _, err = f.Readdir(1) - - // and if the file is EOF... well, the dir is empty. - if err == io.EOF { - return true, nil - } - - return false, err -} diff --git a/internal/adapters/repositories/shells/pseudoshell_repository.go b/internal/adapters/repositories/shells/pseudoshell_repository.go index fc1f879..ab490c1 100644 --- a/internal/adapters/repositories/shells/pseudoshell_repository.go +++ b/internal/adapters/repositories/shells/pseudoshell_repository.go @@ -43,6 +43,10 @@ func NewPseudoShellRepository(project *domain.Project, env, path string) (*Pseud fmt.Sprintf("PM_ACTIVE_PROJECT=%s", project.Name), ) + if env != "" { + shell.cmd.Env = append(shell.cmd.Env, fmt.Sprintf("PM_ACTIVE_ENV=%s", env)) + } + if env == "" { shell.cmd.Env = append( shell.cmd.Env, diff --git a/internal/adapters/repositories/shells/shell_repository.go b/internal/adapters/repositories/shells/shell_repository.go index 8f95f7b..df67bd2 100644 --- a/internal/adapters/repositories/shells/shell_repository.go +++ b/internal/adapters/repositories/shells/shell_repository.go @@ -34,6 +34,10 @@ func NewShellRepository(project *domain.Project, env, path string) (*ShellReposi fmt.Sprintf("PM_ACTIVE_PROJECT=%s", project.Name), ) + if env != "" { + shell.cmd.Env = append(shell.cmd.Env, fmt.Sprintf("PM_ACTIVE_ENV=%s", env)) + } + if env == "" { shell.cmd.Env = append( shell.cmd.Env, diff --git a/internal/core/ports/git_port.go b/internal/core/ports/git_port.go index 5bc62e0..3b76d5c 100644 --- a/internal/core/ports/git_port.go +++ b/internal/core/ports/git_port.go @@ -7,9 +7,15 @@ import "github.com/jlrosende/project-manager/internal/core/domain" type GitService interface { Load(path string) (*domain.GitConfig, error) Save(path string, gitConfig *domain.GitConfig) error + LoadGlobal() error + UpdateIncludeIf(gitdir, perProjectPath, subproject string) error + SaveGlobal(home string) error } type GitRepository interface { Load(path string) (*domain.GitConfig, error) Save(path string, gitConfig *domain.GitConfig) error + LoadGlobal() error + UpdateIncludeIf(gitdir, perProjectPath, subproject string) error + SaveGlobal(home string) error } diff --git a/internal/core/ports/project_port.go b/internal/core/ports/project_port.go index 2c04414..9bea4d0 100644 --- a/internal/core/ports/project_port.go +++ b/internal/core/ports/project_port.go @@ -7,7 +7,11 @@ import "github.com/jlrosende/project-manager/internal/core/domain" type ProjectService interface { Load(name string) (*domain.Project, error) List() ([]*domain.Project, error) - Create(name, path, subproject, envFile string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) + Create( + name, path, subproject, shell, envFile string, + envVars domain.EnvVars, + git *domain.GitConfig, + ) (*domain.Project, error) AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error UpdateProject(project *domain.Project) error UpdateEnvironment(projectName, originalEnvName string, env *domain.Environment) error @@ -16,7 +20,11 @@ type ProjectService interface { type ProjectRepository interface { List() ([]*domain.Project, error) - Create(name, path, subproject, envFile string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) + Create( + name, path, subproject, shell, envFile string, + envVars domain.EnvVars, + git *domain.GitConfig, + ) (*domain.Project, error) AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error UpdateProject(project *domain.Project) error UpdateEnvironment(projectName, originalEnvName string, env *domain.Environment) error diff --git a/internal/core/services/git_service.go b/internal/core/services/git_service.go index eeda037..9a4b449 100644 --- a/internal/core/services/git_service.go +++ b/internal/core/services/git_service.go @@ -24,3 +24,9 @@ func (g *GitService) Load(path string) (*domain.GitConfig, error) { func (g *GitService) Save(path string, gitConfig *domain.GitConfig) error { return g.repo.Save(path, gitConfig) } + +func (g *GitService) LoadGlobal() error { return g.repo.LoadGlobal() } +func (g *GitService) UpdateIncludeIf(gitdir, perProjectPath, subproject string) error { + return g.repo.UpdateIncludeIf(gitdir, perProjectPath, subproject) +} +func (g *GitService) SaveGlobal(home string) error { return g.repo.SaveGlobal(home) } diff --git a/internal/core/services/project_service.go b/internal/core/services/project_service.go index 01bf9b6..724d55f 100644 --- a/internal/core/services/project_service.go +++ b/internal/core/services/project_service.go @@ -3,10 +3,13 @@ package services import ( "fmt" "log/slog" + "os" "path/filepath" + "strings" "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/ports" + "github.com/jlrosende/project-manager/internal/tools" ) type ProjectService struct { @@ -30,35 +33,39 @@ func NewProjectService( } func (svc *ProjectService) Load(name string) (*domain.Project, error) { + slog.Debug("load project", slog.String("name", name)) + projects, err := svc.project.List() if err != nil { + slog.Error("list projects failed", slog.String("err", err.Error())) return nil, err } for _, project := range projects { - // Check name if not continue if project.Name != name { continue } var envVarsPath string - if filepath.IsAbs(project.EnvVarsFile) { envVarsPath = project.EnvVarsFile } else { envVarsPath = filepath.Join(project.Path, project.EnvVarsFile) } - // load env vars project.EnvVars, err = svc.envVars.Load(envVarsPath) if err != nil { + slog.Error( + "load project env vars failed", + slog.String("path", envVarsPath), + slog.String("err", err.Error()), + ) + return nil, err } - // Load environments EnvVars for _, env := range project.Environments { var envVarsPath string - if filepath.IsAbs(env.EnvVarsFile) { envVarsPath = env.EnvVarsFile } else { @@ -67,15 +74,22 @@ func (svc *ProjectService) Load(name string) (*domain.Project, error) { env.EnvVars, err = svc.envVars.Load(envVarsPath) if err != nil { - slog.Warn("EnvVars file not found", slog.String("env", env.Name), slog.String("path", envVarsPath)) + slog.Warn( + "env vars load failed; using empty", + slog.String("env", env.Name), + slog.String("path", envVarsPath), + ) + env.EnvVars = domain.EnvVars{} } } - slog.Debug("project loaded", slog.Any("project", project)) + slog.Debug("loaded project", slog.String("name", project.Name)) return project, nil } + slog.Warn("project not found", slog.String("name", name)) + return nil, fmt.Errorf("project '%s' not found, projects are case sensitive", name) } @@ -84,25 +98,231 @@ func (svc *ProjectService) List() ([]*domain.Project, error) { } func (svc *ProjectService) Create( - name, path, subproject, envFile string, + name, path, subproject, shell, envFile string, envVars domain.EnvVars, gitConfig *domain.GitConfig, ) (*domain.Project, error) { - return svc.project.Create(name, path, subproject, envFile, envVars, gitConfig) + slog.Info( + "create project", + slog.String("name", name), + slog.String("path", path), + slog.String("subproject", subproject), + ) + + file := strings.TrimSpace(envFile) + if file == "" { + file = ".env" + } + + proj, err := svc.project.Create(name, path, subproject, shell, file, envVars, gitConfig) + if err != nil { + slog.Error("project create failed", slog.String("err", err.Error())) + return nil, err + } + + if proj != nil { + created := []string{} + + p := file + if !tools.IsAbs(p) { + p = filepath.Join(proj.Path, p) + } + + if err := svc.envVars.Save(p, envVars); err != nil { + slog.Error("env file save failed", slog.String("path", p), slog.String("err", err.Error())) + + _ = os.Remove(filepath.Join(proj.Path, ".project.hcl")) + + return nil, err + } + + created = append(created, p) + + if err := svc.git.LoadGlobal(); err != nil { + slog.Error("git load global failed", slog.String("err", err.Error())) + + _ = os.Remove(p) + _ = os.Remove(filepath.Join(proj.Path, ".project.hcl")) + + return nil, err + } + + gitdir := fmt.Sprintf("gitdir/i:%s/", proj.Path) + + perProj := filepath.Join(proj.Path, fmt.Sprintf(".%s.gitconfig", proj.Name)) + if err := svc.git.UpdateIncludeIf(gitdir, perProj, subproject); err != nil { + slog.Error( + "git includeIf update failed", + slog.String("gitdir", gitdir), + slog.String("path", perProj), + slog.String("err", err.Error()), + ) + + _ = os.Remove(p) + _ = os.Remove(filepath.Join(proj.Path, ".project.hcl")) + + return nil, err + } + + if home, e := os.UserHomeDir(); e != nil || svc.git.SaveGlobal(home) != nil { + slog.Error("git save global failed") + + _ = os.Remove(p) + _ = os.Remove(filepath.Join(proj.Path, ".project.hcl")) + + return nil, fmt.Errorf("failed to save global git config") + } + + if gitConfig != nil { + if err := svc.git.Save(perProj, gitConfig); err != nil { + slog.Error("per-project git save failed", slog.String("path", perProj), slog.String("err", err.Error())) + + _ = os.Remove(p) + _ = os.Remove(filepath.Join(proj.Path, ".project.hcl")) + + return nil, err + } + + created = append(created, perProj) + } + + _ = created + + slog.Info("project created", slog.String("name", proj.Name)) + } + + return proj, nil } func (svc *ProjectService) AddEnvironment(projectName string, env *domain.Environment, envVars domain.EnvVars) error { - return svc.project.AddEnvironment(projectName, env, envVars) + slog.Info("add environment", slog.String("project", projectName), slog.String("env", func() string { + if env != nil { + return env.Name + } + + return "" + }())) + + if env == nil { + slog.Error("add environment failed: nil env") + return fmt.Errorf("env is nil") + } + + if strings.TrimSpace(env.EnvVarsMode) == "" { + env.EnvVarsMode = domain.EnvVarsModeMerge + } + + if strings.TrimSpace(env.EnvVarsFile) == "" { + slug := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(env.Name, " ", "-"))) + if slug != "" { + env.EnvVarsFile = "." + slug + ".env" + } else { + env.EnvVarsFile = ".env" + } + } + + proj, err := svc.Load(projectName) + if err != nil { + slog.Error("load project failed", slog.String("project", projectName), slog.String("err", err.Error())) + return err + } + + for _, e := range proj.Environments { + if e.Name == env.Name { + slog.Warn("environment exists", slog.String("project", projectName), slog.String("env", env.Name)) + return fmt.Errorf("environment %s already exists", env.Name) + } + } + + p := env.EnvVarsFile + if !tools.IsAbs(p) { + p = filepath.Join(proj.Path, p) + } + + if err := svc.envVars.Save(p, envVars); err != nil { + slog.Error("env file save failed", slog.String("path", p), slog.String("err", err.Error())) + return err + } + + if err := svc.project.AddEnvironment(projectName, env, envVars); err != nil { + slog.Error( + "add environment repo failed", + slog.String("project", projectName), + slog.String("env", env.Name), + slog.String("err", err.Error()), + ) + + _ = os.Remove(p) + + return err + } + + slog.Info("environment added", slog.String("project", projectName), slog.String("env", env.Name)) + + return nil } func (svc *ProjectService) UpdateProject(project *domain.Project) error { - return svc.project.UpdateProject(project) + slog.Info("update project", slog.String("name", project.Name)) + + err := svc.project.UpdateProject(project) + if err != nil { + slog.Error("update project failed", slog.String("name", project.Name), slog.String("err", err.Error())) + return err + } + + slog.Info("project updated", slog.String("name", project.Name)) + + return nil } func (svc *ProjectService) UpdateEnvironment(projectName, originalEnvName string, env *domain.Environment) error { - return svc.project.UpdateEnvironment(projectName, originalEnvName, env) + slog.Info( + "update environment", + slog.String("project", projectName), + slog.String("original", originalEnvName), + slog.String("env", func() string { + if env != nil { + return env.Name + } + + return "" + }()), + ) + + err := svc.project.UpdateEnvironment(projectName, originalEnvName, env) + if err != nil { + slog.Error( + "update environment failed", + slog.String("project", projectName), + slog.String("original", originalEnvName), + slog.String("err", err.Error()), + ) + + return err + } + + slog.Info("environment updated", slog.String("project", projectName), slog.String("env", func() string { + if env != nil { + return env.Name + } + + return "" + }())) + + return nil } func (svc *ProjectService) Delete(name string) error { - return svc.project.Delete(name) + slog.Info("delete project", slog.String("name", name)) + + err := svc.project.Delete(name) + if err != nil { + slog.Error("delete project failed", slog.String("name", name), slog.String("err", err.Error())) + return err + } + + slog.Info("project deleted", slog.String("name", name)) + + return nil } diff --git a/internal/tools/fs.go b/internal/tools/fs.go new file mode 100644 index 0000000..569f0a7 --- /dev/null +++ b/internal/tools/fs.go @@ -0,0 +1,61 @@ +package tools + +import ( + "io" + "os" +) + +func EnsureDir(path string, mode os.FileMode) error { + if st, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return os.MkdirAll(path, mode) + } + + return err + } else if !st.IsDir() { + return &os.PathError{Op: "mkdir", Path: path, Err: os.ErrInvalid} + } + + return nil +} + +func IsDirEmpty(path string) (bool, error) { + f, err := os.Open(path) + if err != nil { + return false, err + } + defer f.Close() + + _, err = f.Readdir(1) + if err == io.EOF { + return true, nil + } + + return false, err +} + +func Rename(oldPath, newPath string) error { + if err := EnsureDir(filepathDir(newPath), 0o755); err != nil { + return err + } + + return os.Rename(oldPath, newPath) +} + +func WriteFile(path string, data []byte, mode os.FileMode) error { + if err := EnsureDir(filepathDir(path), 0o755); err != nil { + return err + } + + return os.WriteFile(path, data, mode) +} + +func filepathDir(p string) string { + for i := len(p) - 1; i >= 0; i-- { + if p[i] == '/' || p[i] == '\\' { + return p[:i] + } + } + + return "." +} diff --git a/internal/tools/path.go b/internal/tools/path.go new file mode 100644 index 0000000..75561bc --- /dev/null +++ b/internal/tools/path.go @@ -0,0 +1,29 @@ +package tools + +import ( + "os" + "path/filepath" +) + +func ExpandHome(path string) string { + if len(path) >= 2 && path[:2] == "~/" { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, path[2:]) + } + } + + return path +} + +func AbsPath(path string) (string, error) { + p := ExpandHome(path) + return filepath.Abs(p) +} + +func Join(elem ...string) string { + return filepath.Join(elem...) +} + +func IsAbs(path string) bool { + return filepath.IsAbs(path) +} diff --git a/mocks/mock_git_port.go b/mocks/mock_git_port.go index cb22608..9846fa6 100644 --- a/mocks/mock_git_port.go +++ b/mocks/mock_git_port.go @@ -55,6 +55,20 @@ func (mr *MockGitServiceMockRecorder) Load(path any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockGitService)(nil).Load), path) } +// LoadGlobal mocks base method. +func (m *MockGitService) LoadGlobal() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadGlobal") + ret0, _ := ret[0].(error) + return ret0 +} + +// LoadGlobal indicates an expected call of LoadGlobal. +func (mr *MockGitServiceMockRecorder) LoadGlobal() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadGlobal", reflect.TypeOf((*MockGitService)(nil).LoadGlobal)) +} + // Save mocks base method. func (m *MockGitService) Save(path string, gitConfig *domain.GitConfig) error { m.ctrl.T.Helper() @@ -69,6 +83,34 @@ func (mr *MockGitServiceMockRecorder) Save(path, gitConfig any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockGitService)(nil).Save), path, gitConfig) } +// SaveGlobal mocks base method. +func (m *MockGitService) SaveGlobal(home string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveGlobal", home) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveGlobal indicates an expected call of SaveGlobal. +func (mr *MockGitServiceMockRecorder) SaveGlobal(home any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveGlobal", reflect.TypeOf((*MockGitService)(nil).SaveGlobal), home) +} + +// UpdateIncludeIf mocks base method. +func (m *MockGitService) UpdateIncludeIf(gitdir, perProjectPath, subproject string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateIncludeIf", gitdir, perProjectPath, subproject) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateIncludeIf indicates an expected call of UpdateIncludeIf. +func (mr *MockGitServiceMockRecorder) UpdateIncludeIf(gitdir, perProjectPath, subproject any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateIncludeIf", reflect.TypeOf((*MockGitService)(nil).UpdateIncludeIf), gitdir, perProjectPath, subproject) +} + // MockGitRepository is a mock of GitRepository interface. type MockGitRepository struct { ctrl *gomock.Controller @@ -108,6 +150,20 @@ func (mr *MockGitRepositoryMockRecorder) Load(path any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockGitRepository)(nil).Load), path) } +// LoadGlobal mocks base method. +func (m *MockGitRepository) LoadGlobal() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadGlobal") + ret0, _ := ret[0].(error) + return ret0 +} + +// LoadGlobal indicates an expected call of LoadGlobal. +func (mr *MockGitRepositoryMockRecorder) LoadGlobal() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadGlobal", reflect.TypeOf((*MockGitRepository)(nil).LoadGlobal)) +} + // Save mocks base method. func (m *MockGitRepository) Save(path string, gitConfig *domain.GitConfig) error { m.ctrl.T.Helper() @@ -121,3 +177,31 @@ func (mr *MockGitRepositoryMockRecorder) Save(path, gitConfig any) *gomock.Call mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockGitRepository)(nil).Save), path, gitConfig) } + +// SaveGlobal mocks base method. +func (m *MockGitRepository) SaveGlobal(home string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveGlobal", home) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveGlobal indicates an expected call of SaveGlobal. +func (mr *MockGitRepositoryMockRecorder) SaveGlobal(home any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveGlobal", reflect.TypeOf((*MockGitRepository)(nil).SaveGlobal), home) +} + +// UpdateIncludeIf mocks base method. +func (m *MockGitRepository) UpdateIncludeIf(gitdir, perProjectPath, subproject string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateIncludeIf", gitdir, perProjectPath, subproject) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateIncludeIf indicates an expected call of UpdateIncludeIf. +func (mr *MockGitRepositoryMockRecorder) UpdateIncludeIf(gitdir, perProjectPath, subproject any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateIncludeIf", reflect.TypeOf((*MockGitRepository)(nil).UpdateIncludeIf), gitdir, perProjectPath, subproject) +} diff --git a/mocks/mock_project_port.go b/mocks/mock_project_port.go index 251a566..4ed5f4b 100644 --- a/mocks/mock_project_port.go +++ b/mocks/mock_project_port.go @@ -55,18 +55,18 @@ func (mr *MockProjectServiceMockRecorder) AddEnvironment(projectName, env, envVa } // Create mocks base method. -func (m *MockProjectService) Create(name, path, subproject, envFile string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) { +func (m *MockProjectService) Create(name, path, subproject, shell, envFile string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", name, path, subproject, envFile, envVars, git) + ret := m.ctrl.Call(m, "Create", name, path, subproject, shell, envFile, envVars, git) ret0, _ := ret[0].(*domain.Project) ret1, _ := ret[1].(error) return ret0, ret1 } // Create indicates an expected call of Create. -func (mr *MockProjectServiceMockRecorder) Create(name, path, subproject, envFile, envVars, git any) *gomock.Call { +func (mr *MockProjectServiceMockRecorder) Create(name, path, subproject, shell, envFile, envVars, git any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockProjectService)(nil).Create), name, path, subproject, envFile, envVars, git) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockProjectService)(nil).Create), name, path, subproject, shell, envFile, envVars, git) } // Delete mocks base method. @@ -180,18 +180,18 @@ func (mr *MockProjectRepositoryMockRecorder) AddEnvironment(projectName, env, en } // Create mocks base method. -func (m *MockProjectRepository) Create(name, path, subproject, envFile string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) { +func (m *MockProjectRepository) Create(name, path, subproject, shell, envFile string, envVars domain.EnvVars, git *domain.GitConfig) (*domain.Project, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", name, path, subproject, envFile, envVars, git) + ret := m.ctrl.Call(m, "Create", name, path, subproject, shell, envFile, envVars, git) ret0, _ := ret[0].(*domain.Project) ret1, _ := ret[1].(error) return ret0, ret1 } // Create indicates an expected call of Create. -func (mr *MockProjectRepositoryMockRecorder) Create(name, path, subproject, envFile, envVars, git any) *gomock.Call { +func (mr *MockProjectRepositoryMockRecorder) Create(name, path, subproject, shell, envFile, envVars, git any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockProjectRepository)(nil).Create), name, path, subproject, envFile, envVars, git) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockProjectRepository)(nil).Create), name, path, subproject, shell, envFile, envVars, git) } // Delete mocks base method. diff --git a/tests/integration/common.go b/tests/integration/common.go index e1d8d7b..fb5f8fd 100644 --- a/tests/integration/common.go +++ b/tests/integration/common.go @@ -10,8 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "go.uber.org/mock/gomock" - v1 "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v1" - v2 "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui" "github.com/jlrosende/project-manager/internal/core/domain" "github.com/jlrosende/project-manager/internal/core/ports" "github.com/jlrosende/project-manager/internal/core/services" @@ -44,7 +43,14 @@ func buildService(t *testing.T) ports.ProjectService { mockRepo.EXPECT().List().Return(projects, nil).AnyTimes() mockRepo.EXPECT().AddEnvironment(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() mockRepo.EXPECT().UpdateEnvironment(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() - mockRepo.EXPECT().UpdateProject(gomock.Any()).Return(nil).AnyTimes() + mockRepo.EXPECT().UpdateProject(gomock.Any()).DoAndReturn(func(p *domain.Project) error { + for _, pr := range projects { + if pr.Path == p.Path { + pr.Name = p.Name + } + } + return nil + }).AnyTimes() mockEnv.EXPECT().Load(gomock.Any()).Return(domain.EnvVars{}, nil).AnyTimes() mockGit.EXPECT().Load(gomock.Any()).Return(&domain.GitConfig{}, nil).AnyTimes() @@ -60,27 +66,10 @@ func normalize(s string) string { return strings.Join(lines, "\n") } -func renderV1(t *testing.T, svc ports.ProjectService, width int, focusRight bool) string { +func render(t *testing.T, svc ports.ProjectService, width int, focusRight bool) string { t.Helper() - w, err := v1.NewWindow(svc.(*services.ProjectService), v1.Options{}) - if err != nil { - t.Fatalf("v1 window: %v", err) - } - - w.Update(tea.WindowSizeMsg{Width: width, Height: 24}) - - if focusRight { - w.Update(tea.KeyMsg{Type: tea.KeyRight}) - } - - return w.View() -} - -func renderV2(t *testing.T, svc ports.ProjectService, width int, focusRight bool) string { - t.Helper() - - w, err := v2.NewWindow(svc.(*services.ProjectService), v2.Options{}) + w, err := tui.NewWindow(svc.(*services.ProjectService), tui.Options{}) if err != nil { t.Fatalf("v2 window: %v", err) } diff --git a/tests/integration/edit_project_rename_refresh_test.go b/tests/integration/edit_project_rename_refresh_test.go new file mode 100644 index 0000000..a2b7bd3 --- /dev/null +++ b/tests/integration/edit_project_rename_refresh_test.go @@ -0,0 +1,43 @@ +//go:build integration +// +build integration + +package integration_test + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/state" + "github.com/jlrosende/project-manager/internal/core/services" +) + +func TestTUI_EditProject_RenameReloadsList(t *testing.T) { + svc := buildService(t) + + w, err := tui.NewWindow(svc.(*services.ProjectService), tui.Options{}) + if err != nil { + t.Fatalf("window: %v", err) + } + + if cmd := w.Init(); cmd != nil { + w.Update(cmd()) + } + + w.Update(tea.WindowSizeMsg{Width: 120, Height: 24}) + + w.Update(state.EditProjectMsg{ID: "INDITEX"}) + w.Update(state.SaveProjectMsg{ + Name: "INDITEX-NEW", + Path: "/tmp/inditex", + Shell: "/bin/sh", + EnvVarsFile: ".env", + }) + + view := normalize(w.View()) + if !strings.Contains(view, "INDITEX-NEW") { + t.Fatalf("projects list not refreshed with new name; view=\n%s", view) + } +} diff --git a/tests/integration/selected_enter_test.go b/tests/integration/selected_enter_test.go index 0b1b937..2e43a68 100644 --- a/tests/integration/selected_enter_test.go +++ b/tests/integration/selected_enter_test.go @@ -8,14 +8,14 @@ import ( tea "github.com/charmbracelet/bubbletea" - v2 "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui" "github.com/jlrosende/project-manager/internal/core/services" ) func TestSelectProjectWithDefaultOnEnter(t *testing.T) { svc := buildService(t) - w, err := v2.NewWindow(svc.(*services.ProjectService), v2.Options{}) + w, err := tui.NewWindow(svc.(*services.ProjectService), tui.Options{}) if err != nil { t.Fatalf("v2 window: %v", err) } @@ -51,7 +51,7 @@ func TestSelectProjectWithDefaultOnEnter(t *testing.T) { func TestSelectProjectWithoutDefaultOnEnter(t *testing.T) { svc := buildService(t) - w, err := v2.NewWindow(svc.(*services.ProjectService), v2.Options{}) + w, err := tui.NewWindow(svc.(*services.ProjectService), tui.Options{}) if err != nil { t.Fatalf("v2 window: %v", err) } @@ -87,7 +87,7 @@ func TestSelectProjectWithoutDefaultOnEnter(t *testing.T) { func TestSelectEnvironmentOnEnter(t *testing.T) { svc := buildService(t) - w, err := v2.NewWindow(svc.(*services.ProjectService), v2.Options{}) + w, err := tui.NewWindow(svc.(*services.ProjectService), tui.Options{}) if err != nil { t.Fatalf("v2 window: %v", err) } diff --git a/tests/integration/view_parity_test.go.old b/tests/integration/view_parity_test.go.old deleted file mode 100644 index e9b90b2..0000000 --- a/tests/integration/view_parity_test.go.old +++ /dev/null @@ -1,675 +0,0 @@ -package integration_test - -import ( - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - - v1 "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v1" - v2 "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2" - "github.com/jlrosende/project-manager/internal/core/ports" - "github.com/jlrosende/project-manager/internal/core/services" -) - -func TestMainViewParity_LeftFocus(t *testing.T) { - svc := buildService(t) - v1s := normalize(renderV1(t, svc, 49, false)) - - v2s := normalize(renderV2(t, svc, 49, false)) - if v1s != v2s { - t.Fatalf("views differ\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestMainViewParity_RightFocus(t *testing.T) { - svc := buildService(t) - v1s := normalize(renderV1(t, svc, 49, true)) - - v2s := normalize(renderV2(t, svc, 49, true)) - if v1s != v2s { - t.Fatalf("views differ (right focus)\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func renderV1Msgs(t *testing.T, svc ports.ProjectService, width int, msgs ...tea.Msg) string { - t.Helper() - - w, err := v1.NewWindow(svc.(*services.ProjectService), v1.Options{}) - if err != nil { - t.Fatalf("v1 window: %v", err) - } - - _, _ = w.Update(tea.WindowSizeMsg{Width: width, Height: 24}) - for _, m := range msgs { - _, cmd := w.Update(m) - if cmd != nil { - if msg := cmd(); msg != nil { - _, _ = w.Update(msg) - } - } - } - - return w.View() -} - -func renderV2Msgs(t *testing.T, svc ports.ProjectService, width int, msgs ...tea.Msg) string { - t.Helper() - - w, err := v2.NewWindow(svc.(*services.ProjectService), v2.Options{}) - if err != nil { - t.Fatalf("v2 window: %v", err) - } - - cmd := w.Init() - if cmd != nil { - if msg := cmd(); msg != nil { - _, _ = w.Update(msg) - } - } - - _, _ = w.Update(tea.WindowSizeMsg{Width: width, Height: 24}) - for _, m := range msgs { - _, cmd := w.Update(m) - if cmd != nil { - if msg := cmd(); msg != nil { - _, _ = w.Update(msg) - } - } - } - - return w.View() -} - -func TestParity_ProjectDownTwice_LeftFocus(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}} - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("views differ after moving down twice\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestParity_NoEnvProject_RightFocus(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyRight}} - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("views differ on no-env project right focus\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestParity_EnvDownSelection_RightFocus(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyDown}} - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("views differ after moving down envs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } - - // Enter on an environment should start session (quit), not open edit - seq2 := append(seq, tea.KeyMsg{Type: tea.KeyEnter}) - _ = renderV1Msgs(t, svc, 49, seq2...) - _ = renderV2Msgs(t, svc, 49, seq2...) -} - -func TestParity_Width60_LeftFocus(t *testing.T) { - svc := buildService(t) - v1s := normalize(renderV1(t, svc, 60, false)) - - v2s := normalize(renderV2(t, svc, 60, false)) - if v1s != v2s { - t.Fatalf("views differ at width 60\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestParity_NewProject_RightDoesNotFocusEnvs(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, // now on '+ New project' - tea.KeyMsg{Type: tea.KeyRight}, - } - v1s := normalize(renderV1Msgs(t, svc, 49, open...)) - v2s := normalize(renderV2Msgs(t, svc, 49, open...)) - if v1s != v2s { - t.Fatalf("moving right on '+ New project' should not focus envs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestParity_NewProject_RightThenDownStillProjects(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyDown}, - } - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("after Right on '+ New project', Down should still move in projects\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestParity_ToggleHelp(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}} - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("views differ after toggling help\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestForm_CreateProject_ViewParity(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyEnter}, - } - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("create project form view differs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } - - seq2 := append(seq, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) - v1s2 := normalize(renderV1Msgs(t, svc, 49, seq2...)) - v2s2 := normalize(renderV2Msgs(t, svc, 49, seq2...)) - if v1s2 != v2s2 { - t.Fatalf("create project form help footer differs after '?'\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s2, v2s2) - } - - seq3 := append(seq, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}) - v1s3 := normalize(renderV1Msgs(t, svc, 49, seq3...)) - v2s3 := normalize(renderV2Msgs(t, svc, 49, seq3...)) - if v1s3 != v2s3 { - t.Fatalf("tab/down/enter navigation in form differs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s3, v2s3) - } -} - -func TestForm_EditProject_ViewParity(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}} - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("edit project form view differs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestForm_CreateProject_ViewParity_Width60(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyEnter}, - } - v1s := normalize(renderV1Msgs(t, svc, 60, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 60, seq...)) - if v1s != v2s { - t.Fatalf("create project form view differs at width 60\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestForm_Navigation_UpDownTab_Parity(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}, - } - seq := append(open, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyUp}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyShiftTab}) - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("form navigation with up/down/tab parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestForm_Buttons_Cancel_RightEnter_Parity(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}, - } - // Tab through fields to reach buttons, then move to Cancel and press Enter - tabs := []tea.Msg{tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}} - seq := append(append(open, tabs...), tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyEnter}) - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("button Cancel (right+enter) parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestForm_EditProject_ViewParity_Width60(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}} - v1s := normalize(renderV1Msgs(t, svc, 60, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 60, seq...)) - if v1s != v2s { - t.Fatalf("edit project form view differs at width 60\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestForm_EditEnv_ViewParity(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}} - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("edit env form view differs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestTransition_ProjectForm_CancelEsc(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}, - } - v1after := normalize(renderV1Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyEsc})...)) - v2after := normalize(renderV2Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyEsc})...)) - - if v1after != v2after { - t.Fatalf("views differ after canceling project form\n--- v1 after ---\n%s\n--- v2 after ---\n%s", v1after, v2after) - } -} - -func TestForm_Q_DoesNotExit_Vs_CtrlC_ReturnsToMain(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}, - } - v1q := normalize(renderV1Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})...)) - v2q := normalize(renderV2Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})...)) - if v1q != v2q { - t.Fatalf("pressing 'q' inside form should not exit; parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1q, v2q) - } - - v1cc := normalize(renderV1Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyCtrlC})...)) - v2cc := normalize(renderV2Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyCtrlC})...)) - if v1cc != v2cc { - t.Fatalf("pressing Ctrl+C inside form should return to main; parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1cc, v2cc) - } -} - -func TestTransition_AddEnvForm_CancelEsc(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyRight}, - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyEnter}, - } - v1after := normalize(renderV1Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyEsc})...)) - v2after := normalize(renderV2Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyEsc})...)) - - if v1after != v2after { - t.Fatalf("views differ after canceling add env form\n--- v1 after ---\n%s\n--- v2 after ---\n%s", v1after, v2after) - } -} - -func TestForm_CreateProject_CtrlS(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyEnter}, tea.KeyMsg{Type: tea.KeyCtrlS}, - } - _ = normalize(renderV1Msgs(t, svc, 49, seq...)) - _ = normalize(renderV2Msgs(t, svc, 49, seq...)) -} - -func TestForm_CreateProject_TypingUpdatesPath_Parity(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyEnter}, - } - _ = normalize(renderV1Msgs(t, svc, 49, open...)) - _ = normalize(renderV2Msgs(t, svc, 49, open...)) - typing := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'-'}}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}, - } - v1s := normalize(renderV1Msgs(t, svc, 49, append(open, typing...)...)) - v2s := normalize(renderV2Msgs(t, svc, 49, append(open, typing...)...)) - if v1s != v2s { - t.Fatalf("typing in name should update path placeholder equally\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestForm_AddEnv_CtrlS(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}, tea.KeyMsg{Type: tea.KeyCtrlS}} - _ = normalize(renderV1Msgs(t, svc, 49, seq...)) - _ = normalize(renderV2Msgs(t, svc, 49, seq...)) -} - -func TestForm_EditEnv_CtrlS(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}, tea.KeyMsg{Type: tea.KeyCtrlS}} - _ = normalize(renderV1Msgs(t, svc, 49, seq...)) - _ = normalize(renderV2Msgs(t, svc, 49, seq...)) -} - -func TestForm_AddEnv_ViewParity(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyRight}, - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyEnter}, - } - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("add env form view differs\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestRouting_EditEnv_ReenterAfterBack(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}} - back := tea.KeyMsg{Type: tea.KeyEsc} - v1again := normalize(renderV1Msgs(t, svc, 49, append(append(open, back), tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})...)) - v2again := normalize(renderV2Msgs(t, svc, 49, append(append(open, back), tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})...)) - if v1again != v2again { - t.Fatalf("views differ when re-entering edit env\n--- v1 again ---\n%s\n--- v2 again ---\n%s", v1again, v2again) - } -} - -func TestParity_ProjectWrapDown(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, - } - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("views differ on project wrap down\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestParity_ProjectWrapUp(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyUp}} - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("views differ on project wrap up\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestParity_EnvWrapDown(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}} - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("views differ on env wrap down\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestParity_EnvWrapUp(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyUp}} - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("views differ on env wrap up\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestParity_EnvListUpdatesOnProjectChange(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyRight}, - tea.KeyMsg{Type: tea.KeyDown}, // focus pre - tea.KeyMsg{Type: tea.KeyLeft}, // back to projects - tea.KeyMsg{Type: tea.KeyDown}, // move to Mahou (no envs) - tea.KeyMsg{Type: tea.KeyRight}, // focus envs - tea.KeyMsg{Type: tea.KeyLeft}, - tea.KeyMsg{Type: tea.KeyDown}, // Accenture (no envs) - tea.KeyMsg{Type: tea.KeyLeft}, // redundant left - tea.KeyMsg{Type: tea.KeyDown}, // Personal (no envs) - tea.KeyMsg{Type: tea.KeyDown}, // test (no envs) - tea.KeyMsg{Type: tea.KeyUp}, // Personal - tea.KeyMsg{Type: tea.KeyUp}, // Accenture - tea.KeyMsg{Type: tea.KeyUp}, // Mahou - tea.KeyMsg{Type: tea.KeyUp}, // INDITEX - tea.KeyMsg{Type: tea.KeyRight}, - tea.KeyMsg{Type: tea.KeyDown}, // move through envs again - tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, - } - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("env list did not update properly on project change\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestParity_EditOnNewProjectDoesNothing(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}} - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("pressing edit on + New project should do nothing\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestParity_EditOnNewEnvironmentDoesNothing(t *testing.T) { - svc := buildService(t) - seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}} - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("pressing edit on + New environment should do nothing\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestStyled_CreateProjectForm_ParityAndDecor(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}} - v1s := renderV1Msgs(t, svc, 49, open...) - v2s := renderV2Msgs(t, svc, 49, open...) - if normalize(v1s) != normalize(v2s) { - t.Fatalf("styled create project form parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } - if !strings.Contains(v1s, "─") || !strings.Contains(v2s, "─") { - t.Fatalf("expected decorative borders (─) in form views") - } -} - -func TestStyled_FormButtons_LinePresent(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}} - tabs := []tea.Msg{tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}} - v1s := renderV1Msgs(t, svc, 49, append(open, tabs...)...) - v2s := renderV2Msgs(t, svc, 49, append(open, tabs...)...) - if normalize(v1s) != normalize(v2s) { - t.Fatalf("styled buttons parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } - if !strings.Contains(v1s, "Save") || !strings.Contains(v1s, "Cancel") || !strings.Contains(v2s, "Save") || !strings.Contains(v2s, "Cancel") { - t.Fatalf("expected Save and Cancel buttons rendered in both views") - } -} - -func TestTheme_AppliesAcrossViews_Parity(t *testing.T) { - svc := buildService(t) - w1, err1 := v1.NewWindow(svc.(*services.ProjectService), v1.Options{Theme: "dracula"}) - if err1 != nil { - t.Fatalf("new v1 window: %v", err1) - } - w2, err2 := v2.NewWindow(svc.(*services.ProjectService), v2.Options{Theme: "dracula"}) - if err2 != nil { - t.Fatalf("new v2 window: %v", err2) - } - // size - if _, cmd := w1.Update(tea.WindowSizeMsg{Width: 49, Height: 24}); cmd != nil { - if m := cmd(); m != nil { - w1.Update(m) - } - } - if _, cmd := w2.Update(tea.WindowSizeMsg{Width: 49, Height: 24}); cmd != nil { - if m := cmd(); m != nil { - w2.Update(m) - } - } - mv1 := normalize(w1.View()) - mv2 := normalize(w2.View()) - if mv1 != mv2 { - t.Fatalf("theme parity mismatch on main\n--- v1 ---\n%s\n--- v2 ---\n%s", mv1, mv2) - } - // open New project form (process returned cmds) - seq := []tea.Msg{tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}} - for _, m := range seq { - if _, cmd := w1.Update(m); cmd != nil { - if mm := cmd(); mm != nil { - w1.Update(mm) - } - } - if _, cmd := w2.Update(m); cmd != nil { - if mm := cmd(); mm != nil { - w2.Update(mm) - } - } - } - fv1 := normalize(w1.View()) - fv2 := normalize(w2.View()) - if fv1 != fv2 { - t.Fatalf("theme parity mismatch on form\n--- v1 ---\n%s\n--- v2 ---\n%s", fv1, fv2) - } -} - -func TestEnvForm_Navigation_TabShiftEnter_Parity(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyRight}, - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyEnter}, - } - seq := append(open, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyEnter}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyShiftTab}) - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("env form navigation parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestEnvForm_Buttons_Cancel_RightEnter_Parity(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyRight}, - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyEnter}, - } - tabs := []tea.Msg{tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}} - seq := append(append(open, tabs...), tea.KeyMsg{Type: tea.KeyRight}, tea.KeyMsg{Type: tea.KeyEnter}) - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("env form button Cancel (right+enter) parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestEnvForm_Q_DoesNotExit_Vs_CtrlC_ReturnsToMain(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyRight}, - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyEnter}, - } - v1q := normalize(renderV1Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})...)) - v2q := normalize(renderV2Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})...)) - if v1q != v2q { - t.Fatalf("pressing 'q' inside env form should not exit; parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1q, v2q) - } - v1cc := normalize(renderV1Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyCtrlC})...)) - v2cc := normalize(renderV2Msgs(t, svc, 49, append(open, tea.KeyMsg{Type: tea.KeyCtrlC})...)) - if v1cc != v2cc { - t.Fatalf("pressing Ctrl+C inside env form should return to main; parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1cc, v2cc) - } -} - -func TestEnvForm_TypingInColor_Parity(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyRight}, - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyEnter}, - } - seq := append(open, tea.KeyMsg{Type: tea.KeyTab}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'b'}}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'u'}}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}, - ) - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("typing in Color should update value equally\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestEnvForm_TypingInMode_Parity(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyRight}, - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyEnter}, - } - seq := append(open, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}, - tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}, - ) - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("typing in Mode should update value equally\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} - -func TestEnvForm_SaveButton_Enter_Parity(t *testing.T) { - svc := buildService(t) - open := []tea.Msg{ - tea.KeyMsg{Type: tea.KeyRight}, - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyEnter}, - } - tabs := []tea.Msg{tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}} - seq := append(append(open, tabs...), tea.KeyMsg{Type: tea.KeyEnter}) - v1s := normalize(renderV1Msgs(t, svc, 49, seq...)) - v2s := normalize(renderV2Msgs(t, svc, 49, seq...)) - if v1s != v2s { - t.Fatalf("env form Save via Enter parity mismatch\n--- v1 ---\n%s\n--- v2 ---\n%s", v1s, v2s) - } -} diff --git a/tests/unit/.p.gitconfig b/tests/unit/.p.gitconfig new file mode 100644 index 0000000..ec3b490 --- /dev/null +++ b/tests/unit/.p.gitconfig @@ -0,0 +1,10 @@ +[user] + signingkey = + name = U + email = u@e +[tag] + gpgsign = true +[commit] + gpgsign = true +[core] + bare = false diff --git a/tests/unit/env_vars_repository_test.go b/tests/unit/env_vars_repository_test.go new file mode 100644 index 0000000..86ae89a --- /dev/null +++ b/tests/unit/env_vars_repository_test.go @@ -0,0 +1,43 @@ +//go:build unit +// +build unit + +package unit_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/jlrosende/project-manager/internal/adapters/repositories" +) + +func TestEnvVarsRepository_SaveLoad(t *testing.T) { + dir := t.TempDir() + _ = os.Setenv("HOME", dir) + defer os.Unsetenv("HOME") + + repo, err := repositories.NewEnvVarsRepository() + if err != nil { + t.Fatalf("new repo: %v", err) + } + + vars := map[string]string{"A": "1", "B": "2"} + p := "~/.test.env" + if err := repo.Save(p, vars); err != nil { + t.Fatalf("save: %v", err) + } + + abs := filepath.Join(dir, ".test.env") + if _, err := os.Stat(abs); err != nil { + t.Fatalf("env file not created: %v", err) + } + + loaded, err := repo.Load(p) + if err != nil { + t.Fatalf("load: %v", err) + } + + if len(loaded) != len(vars) || loaded["A"] != "1" || loaded["B"] != "2" { + t.Fatalf("loaded mismatch: %#v", loaded) + } +} diff --git a/tests/unit/git_repository_test.go b/tests/unit/git_repository_test.go new file mode 100644 index 0000000..c75fd8d --- /dev/null +++ b/tests/unit/git_repository_test.go @@ -0,0 +1,85 @@ +//go:build unit +// +build unit + +package unit_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/jlrosende/project-manager/internal/adapters/repositories" + "github.com/jlrosende/project-manager/internal/core/domain" +) + +func TestGitRepository_SaveLoad_RoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".proj.gitconfig") + + repo, err := repositories.NewGitRepository() + if err != nil { + t.Fatalf("new git repo: %v", err) + } + + cfg := domain.New( + domain.WithName("Test User"), + domain.WithEmail("test@example.com"), + domain.WithSigningKey("ABCDEF"), + domain.WithCommitSign(true), + domain.WithTagSign(true), + ) + + if err := repo.Save(path, cfg); err != nil { + t.Fatalf("save: %v", err) + } + + loaded, err := repo.Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + + if loaded.User.Name != "Test User" || loaded.User.Email != "test@example.com" || loaded.User.SigningKey != "ABCDEF" { + t.Fatalf("user mismatch: %#v", loaded.User) + } + if !loaded.Commit.GPGSign || !loaded.Tag.GPGSign { + t.Fatalf("gpg flags mismatch: %+v", loaded) + } +} + +func TestGitRepository_GlobalIncludeIf(t *testing.T) { + home := t.TempDir() + _ = os.Setenv("HOME", home) + defer os.Unsetenv("HOME") + + repo, err := repositories.NewGitRepository() + if err != nil { + t.Fatalf("new git repo: %v", err) + } + + if err := repo.LoadGlobal(); err != nil { + t.Fatalf("load global: %v", err) + } + + projDir := filepath.Join(home, "work", "p1") + perProj := filepath.Join(projDir, ".p1.gitconfig") + gitdir := "gitdir/i:" + projDir + "/" + + if err := repo.UpdateIncludeIf(gitdir, perProj, ""); err != nil { + t.Fatalf("update includeIf: %v", err) + } + + if err := repo.SaveGlobal(home); err != nil { + t.Fatalf("save global: %v", err) + } + + b, err := os.ReadFile(filepath.Join(home, ".gitconfig")) + if err != nil { + t.Fatalf("read ~/.gitconfig: %v", err) + } + + s := string(b) + if !strings.Contains(s, gitdir) || !strings.Contains(s, perProj) { + t.Fatalf("includeIf not written correctly: %s", s) + } +} diff --git a/tests/unit/project_repository_persistence_test.go b/tests/unit/project_repository_persistence_test.go new file mode 100644 index 0000000..64189eb --- /dev/null +++ b/tests/unit/project_repository_persistence_test.go @@ -0,0 +1,75 @@ +//go:build unit +// +build unit + +package unit_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/go-git/go-git/v5/config" + + "github.com/jlrosende/project-manager/internal/adapters/repositories" + "github.com/jlrosende/project-manager/internal/core/domain" +) + +func writeProjectHCL(t *testing.T, dir, name string) { + t.Helper() + content := []byte("name = \"" + name + "\"\n" + + "description = \"test project\"\n" + + "shell = \"/bin/sh\"\n" + + "env_vars_file = \".env\"\n") + if err := os.WriteFile(filepath.Join(dir, ".project.hcl"), content, 0o644); err != nil { + t.Fatalf("write hcl: %v", err) + } +} + +func TestProjectRepository_ListGetUpdateProject(t *testing.T) { + home := t.TempDir() + _ = os.Setenv("HOME", home) + defer os.Unsetenv("HOME") + + p1 := filepath.Join(home, "p1") + p2 := filepath.Join(home, "p2") + _ = os.MkdirAll(p1, 0o755) + _ = os.MkdirAll(p2, 0o755) + writeProjectHCL(t, p1, "P1") + writeProjectHCL(t, p2, "P2") + + gc := config.NewConfig() + gc.Raw.Section("includeIf").Subsection("gitdir/i:" + p1 + "/").SetOption("path", filepath.Join(p1, ".p1.gitconfig")) + gc.Raw.Section("includeIf").Subsection("gitdir/i:" + p2 + "/").SetOption("path", filepath.Join(p2, ".p2.gitconfig")) + b, _ := gc.Marshal() + if err := os.WriteFile(filepath.Join(home, ".gitconfig"), b, 0o644); err != nil { + t.Fatalf("write global git: %v", err) + } + + repo, err := repositories.NewProjectRepository() + if err != nil { + t.Fatalf("new repo: %v", err) + } + + list, err := repo.List() + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 2 { + t.Fatalf("expected 2 projects, got %d", len(list)) + } + + got, err := repo.Get("P1") + if err != nil || got == nil || got.Path != p1 { + t.Fatalf("get P1 failed: %v %#v", err, got) + } + + got.Name = "P1-NEW" + if err := repo.UpdateProject(&domain.Project{Path: p1, Name: "P1-NEW", Shell: "/bin/sh", EnvVarsFile: ".env"}); err != nil { + t.Fatalf("update: %v", err) + } + if b, err := os.ReadFile(filepath.Join(p1, ".project.hcl")); err != nil || !contains(string(b), "P1-NEW") { + t.Fatalf("update not persisted: %v %s", err, string(b)) + } +} + +func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || (len(sub) > 0 && (len(s) > 0 && (s[0:len(sub)] == sub || contains(s[1:], sub))))) } diff --git a/tests/unit/project_repository_updateenv_rename_test.go b/tests/unit/project_repository_updateenv_rename_test.go new file mode 100644 index 0000000..e80be29 --- /dev/null +++ b/tests/unit/project_repository_updateenv_rename_test.go @@ -0,0 +1,41 @@ +//go:build unit +// +build unit + +package unit_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/go-git/go-git/v5/config" + "github.com/jlrosende/project-manager/internal/adapters/repositories" + "github.com/jlrosende/project-manager/internal/core/domain" +) + +func TestProjectRepository_UpdateEnvironment_RenameRelativeAbsolute(t *testing.T) { + home := t.TempDir() + _ = os.Setenv("HOME", home) + defer os.Unsetenv("HOME") + + dir := filepath.Join(home, "proj") + _ = os.MkdirAll(dir, 0o755) + _ = os.WriteFile(filepath.Join(dir, ".project.hcl"), []byte("name=\"P\"\ndescription=\"d\"\nshell=\"/bin/sh\"\nenv_vars_file=\".env\"\n"), 0o644) + _ = os.WriteFile(filepath.Join(dir, ".old.env"), []byte("K=V\n"), 0o600) + + gc := config.NewConfig() + gc.Raw.Section("includeIf").Subsection("gitdir/i:" + dir + "/").SetOption("path", filepath.Join(dir, ".p.gitconfig")) + b, _ := gc.Marshal() + _ = os.WriteFile(filepath.Join(home, ".gitconfig"), b, 0o644) + + repo, _ := repositories.NewProjectRepository() + _ = repo.AddEnvironment("P", &domain.Environment{Name: "Dev", EnvVarsMode: domain.EnvVarsModeMerge, EnvVarsFile: ".old.env"}, nil) + + if err := repo.UpdateEnvironment("P", "Dev", &domain.Environment{Name: "Dev", EnvVarsMode: domain.EnvVarsModeMerge, EnvVarsFile: ".new.env"}); err != nil { + t.Fatalf("update env: %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, ".new.env")); err != nil { + t.Fatalf("new env path missing: %v", err) + } +} diff --git a/tests/unit/project_repository_updateproject_gitconfig_test.go b/tests/unit/project_repository_updateproject_gitconfig_test.go new file mode 100644 index 0000000..0f20249 --- /dev/null +++ b/tests/unit/project_repository_updateproject_gitconfig_test.go @@ -0,0 +1,85 @@ +//go:build unit +// +build unit + +package unit_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-git/go-git/v5/config" + + "github.com/jlrosende/project-manager/internal/adapters/repositories" + "github.com/jlrosende/project-manager/internal/core/domain" +) + +func TestProjectRepository_UpdateProject_RenamesPerProjectGitconfigAndUpdatesIncludeIf(t *testing.T) { + home := t.TempDir() + _ = os.Setenv("HOME", home) + defer os.Unsetenv("HOME") + + projDir := filepath.Join(home, "proj") + _ = os.MkdirAll(projDir, 0o755) + + // write initial .project.hcl with name "p" + if err := os.WriteFile( + filepath.Join(projDir, ".project.hcl"), + []byte("name = \"p\"\n"+ + "description = \"t\"\n"+ + "shell = \"/bin/sh\"\n"+ + "env_vars_file = \".env\"\n"), + 0o600, + ); err != nil { + t.Fatalf("write hcl: %v", err) + } + + // create existing per-project gitconfig for old name + oldGit := filepath.Join(projDir, ".p.gitconfig") + if err := os.WriteFile(oldGit, []byte("[user]\n\tname = test\n"), 0o600); err != nil { + t.Fatalf("write old gitconfig: %v", err) + } + + // prepare global includeIf pointing to old path + gc := config.NewConfig() + gc.Raw.Section("includeIf").Subsection("gitdir/i:" + projDir + "/").SetOption("path", oldGit) + b, _ := gc.Marshal() + if err := os.WriteFile(filepath.Join(home, ".gitconfig"), b, 0o600); err != nil { + t.Fatalf("write global git: %v", err) + } + + repo, err := repositories.NewProjectRepository() + if err != nil { + t.Fatalf("new repo: %v", err) + } + + // perform rename to "p2" + if err := repo.UpdateProject(&domain.Project{Path: projDir, Name: "p2", Shell: "/bin/sh", EnvVarsFile: ".env"}); err != nil { + t.Fatalf("update project: %v", err) + } + + // .project.hcl updated + if b, err := os.ReadFile(filepath.Join(projDir, ".project.hcl")); err != nil || !strings.Contains(string(b), "p2") { + t.Fatalf("hcl not updated: %v %s", err, string(b)) + } + + // per-project gitconfig renamed + newGit := filepath.Join(projDir, ".p2.gitconfig") + if _, err := os.Stat(newGit); err != nil { + t.Fatalf("new per-project gitconfig missing: %v", err) + } + if _, err := os.Stat(oldGit); err == nil { + t.Fatalf("old per-project gitconfig still exists") + } + + // includeIf updated to new path + gc2, err := config.LoadConfig(config.GlobalScope) + if err != nil { + t.Fatalf("reload global git: %v", err) + } + ss := gc2.Raw.Section("includeIf").Subsection("gitdir/i:" + projDir + "/") + if got := ss.Option("path"); got != newGit { + t.Fatalf("includeIf path not updated: got %q want %q", got, newGit) + } +} diff --git a/tests/unit/project_service_addenv_test.go b/tests/unit/project_service_addenv_test.go new file mode 100644 index 0000000..178bf00 --- /dev/null +++ b/tests/unit/project_service_addenv_test.go @@ -0,0 +1,42 @@ +//go:build unit +// +build unit + +package unit_test + +import ( + "path/filepath" + "testing" + + "github.com/jlrosende/project-manager/internal/core/domain" + "github.com/jlrosende/project-manager/internal/core/services" + "github.com/jlrosende/project-manager/mocks" + "go.uber.org/mock/gomock" +) + +func TestProjectService_AddEnvironment_DefaultsAndIdempotency(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + projRepo := mocks.NewMockProjectRepository(ctrl) + envRepo := mocks.NewMockEnvVarsRepository(ctrl) + gitRepo := mocks.NewMockGitRepository(ctrl) + + svc := services.NewProjectService(projRepo, envRepo, gitRepo) + + proj := &domain.Project{Name: "Foo", Path: t.TempDir(), EnvVarsFile: ".env"} + projRepo.EXPECT().List().Return([]*domain.Project{proj}, nil).AnyTimes() + projRepo.EXPECT().AddEnvironment("Foo", gomock.Any(), gomock.Any()).Return(nil) + + envRepo.EXPECT().Load(gomock.Any()).Return(domain.EnvVars{}, nil).AnyTimes() + envRepo.EXPECT().Save(filepath.Join(proj.Path, ".dev.env"), gomock.Any()).Return(nil) + + env := &domain.Environment{Name: "Dev"} + err := svc.AddEnvironment("Foo", env, domain.EnvVars{"K": "V"}) + if err != nil { + t.Fatalf("AddEnvironment error: %v", err) + } + + if env.EnvVarsFile == "" { + t.Fatalf("expected default env file to be set") + } +} diff --git a/tests/unit/project_service_create_basic_test.go b/tests/unit/project_service_create_basic_test.go new file mode 100644 index 0000000..dbb8f2c --- /dev/null +++ b/tests/unit/project_service_create_basic_test.go @@ -0,0 +1,48 @@ +//go:build unit +// +build unit + +package unit_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/jlrosende/project-manager/internal/adapters/repositories" + "github.com/jlrosende/project-manager/internal/core/domain" + "github.com/jlrosende/project-manager/internal/core/services" +) + +func TestProjectService_Create_Basic(t *testing.T) { + home := t.TempDir() + _ = os.Setenv("HOME", home) + defer os.Unsetenv("HOME") + + projDir := filepath.Join(home, "proj-basic") + gitRepo, _ := repositories.NewGitRepository() + envRepo, _ := repositories.NewEnvVarsRepository() + projRepo, _ := repositories.NewProjectRepository() + svc := services.NewProjectService(projRepo, envRepo, gitRepo) + + gitCfg := domain.New(domain.WithName("User"), domain.WithEmail("u@e")) + proj, err := svc.Create("p", projDir, "", "/bin/sh", ".env", domain.EnvVars{"K": "V"}, gitCfg) + if err != nil || proj == nil { + t.Fatalf("Create failed: %v", err) + } + if proj.Name != "p" || proj.Path != projDir { + t.Fatalf("unexpected project: %#v", proj) + } + + if _, err := os.Stat(filepath.Join(projDir, ".project.hcl")); err != nil { + t.Fatalf("missing .project.hcl: %v", err) + } + if _, err := os.Stat(filepath.Join(projDir, ".env")); err != nil { + t.Fatalf("missing .env: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".gitconfig")); err != nil { + t.Fatalf("missing ~/.gitconfig: %v", err) + } + if _, err := os.Stat(filepath.Join(projDir, ".p.gitconfig")); err != nil { + t.Fatalf("missing per-project gitconfig: %v", err) + } +} diff --git a/tests/unit/project_service_create_flow_test.go b/tests/unit/project_service_create_flow_test.go new file mode 100644 index 0000000..6028c95 --- /dev/null +++ b/tests/unit/project_service_create_flow_test.go @@ -0,0 +1,45 @@ +//go:build unit +// +build unit + +package unit_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/jlrosende/project-manager/internal/adapters/repositories" + "github.com/jlrosende/project-manager/internal/core/domain" + "github.com/jlrosende/project-manager/internal/core/services" +) + +func TestService_Create_SuccessPath(t *testing.T) { + home := t.TempDir() + _ = os.Setenv("HOME", home) + defer os.Unsetenv("HOME") + + projDir := filepath.Join(home, "p-success") + gitRepo, _ := repositories.NewGitRepository() + envRepo, _ := repositories.NewEnvVarsRepository() + projRepo, _ := repositories.NewProjectRepository() + svc := services.NewProjectService(projRepo, envRepo, gitRepo) + + gitCfg := domain.New(domain.WithName("U"), domain.WithEmail("u@e")) + proj, err := svc.Create("p", projDir, "", "/bin/sh", ".env", domain.EnvVars{"K": "V"}, gitCfg) + if err != nil || proj == nil { + t.Fatalf("Create failed: %v", err) + } + + if _, err := os.Stat(filepath.Join(home, ".gitconfig")); err != nil { + t.Fatalf("global git missing: %v", err) + } + if _, err := os.Stat(filepath.Join(projDir, ".env")); err != nil { + t.Fatalf("env file missing: %v", err) + } + if _, err := os.Stat(filepath.Join(projDir, ".p.gitconfig")); err != nil { + t.Fatalf("per-project git missing: %v", err) + } + if _, err := os.Stat(filepath.Join(projDir, ".project.hcl")); err != nil { + t.Fatalf("project hcl missing: %v", err) + } +} diff --git a/tests/unit/project_service_create_git_integration_test.go b/tests/unit/project_service_create_git_integration_test.go new file mode 100644 index 0000000..ce1ae47 --- /dev/null +++ b/tests/unit/project_service_create_git_integration_test.go @@ -0,0 +1,43 @@ +//go:build unit +// +build unit + +package unit_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/jlrosende/project-manager/internal/adapters/repositories" + "github.com/jlrosende/project-manager/internal/core/domain" + "github.com/jlrosende/project-manager/internal/core/services" +) + +func TestProjectService_Create_WritesGitIncludeAndPerProjectConfig(t *testing.T) { + home := t.TempDir() + _ = os.Setenv("HOME", home) + defer os.Unsetenv("HOME") + + projDir := filepath.Join(home, "proj") + gitRepo, _ := repositories.NewGitRepository() + envRepo, _ := repositories.NewEnvVarsRepository() + projRepo, _ := repositories.NewProjectRepository() + + svc := services.NewProjectService(projRepo, envRepo, gitRepo) + + gitCfg := domain.New(domain.WithName("U"), domain.WithEmail("u@e")) + proj, err := svc.Create("p", projDir, "", "/bin/sh", ".env", domain.EnvVars{"K": "V"}, gitCfg) + if err != nil { + t.Fatalf("Create: %v", err) + } + + g := filepath.Join(home, ".gitconfig") + if _, err := os.Stat(g); err != nil { + t.Fatalf("~/.gitconfig not written: %v", err) + } + + per := filepath.Join(proj.Path, ".p.gitconfig") + if _, err := os.Stat(per); err != nil { + t.Fatalf("per-project gitconfig not written: %v", err) + } +} diff --git a/tests/unit/project_service_create_rollback_test.go b/tests/unit/project_service_create_rollback_test.go new file mode 100644 index 0000000..26c74df --- /dev/null +++ b/tests/unit/project_service_create_rollback_test.go @@ -0,0 +1,46 @@ +//go:build unit +// +build unit + +package unit_test + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/jlrosende/project-manager/internal/adapters/repositories" + "github.com/jlrosende/project-manager/internal/core/domain" + "github.com/jlrosende/project-manager/internal/core/services" +) + +type failingGitRepo struct{ repositories.GitRepository } + +func (f failingGitRepo) Load(string) (*domain.GitConfig, error) { return &domain.GitConfig{}, nil } +func (f failingGitRepo) Save(string, *domain.GitConfig) error { return nil } +func (f failingGitRepo) LoadGlobal() error { return nil } +func (f failingGitRepo) UpdateIncludeIf(string, string, string) error { return errors.New("boom") } +func (f failingGitRepo) SaveGlobal(string) error { return nil } + +func TestService_Create_RollbackOnGitFailure(t *testing.T) { + home := t.TempDir() + _ = os.Setenv("HOME", home) + defer os.Unsetenv("HOME") + + projDir := filepath.Join(home, "p-rollback") + gitRepo := failingGitRepo{} + envRepo, _ := repositories.NewEnvVarsRepository() + projRepo, _ := repositories.NewProjectRepository() + svc := services.NewProjectService(projRepo, envRepo, gitRepo) + + _, err := svc.Create("p", projDir, "", "/bin/sh", ".env", domain.EnvVars{"K": "V"}, domain.New()) + if err == nil { + t.Fatalf("expected failure, got nil") + } + if _, err := os.Stat(filepath.Join(projDir, ".env")); !os.IsNotExist(err) { + t.Fatalf("env file should be removed on rollback") + } + if _, err := os.Stat(filepath.Join(projDir, ".project.hcl")); !os.IsNotExist(err) { + t.Fatalf("project hcl should be removed on rollback") + } +} diff --git a/tests/unit/project_service_update_project_test.go b/tests/unit/project_service_update_project_test.go new file mode 100644 index 0000000..90a5dfd --- /dev/null +++ b/tests/unit/project_service_update_project_test.go @@ -0,0 +1,45 @@ +//go:build unit +// +build unit + +package unit_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/jlrosende/project-manager/internal/adapters/repositories" + "github.com/jlrosende/project-manager/internal/core/domain" + "github.com/jlrosende/project-manager/internal/core/services" +) + +func TestProjectService_UpdateProject_ChangesPersist(t *testing.T) { + home := t.TempDir() + _ = os.Setenv("HOME", home) + defer os.Unsetenv("HOME") + + projDir := filepath.Join(home, "proj-upd") + gitRepo, _ := repositories.NewGitRepository() + envRepo, _ := repositories.NewEnvVarsRepository() + projRepo, _ := repositories.NewProjectRepository() + svc := services.NewProjectService(projRepo, envRepo, gitRepo) + + proj, err := svc.Create("p", projDir, "", "/bin/sh", ".env", nil, domain.New()) + if err != nil || proj == nil { + t.Fatalf("Create failed: %v", err) + } + + proj.Name = "p2" + if err := svc.UpdateProject(proj); err != nil { + t.Fatalf("UpdateProject failed: %v", err) + } + + b, err := os.ReadFile(filepath.Join(projDir, ".project.hcl")) + if err != nil { + t.Fatalf("read hcl: %v", err) + } + if !strings.Contains(string(b), "p2") { + t.Fatalf("update not persisted: %s", string(b)) + } +} diff --git a/tests/unit/router_compile_test.go b/tests/unit/router_compile_test.go index d2fa247..a321480 100644 --- a/tests/unit/router_compile_test.go +++ b/tests/unit/router_compile_test.go @@ -6,7 +6,7 @@ package unit_test import ( "testing" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/router" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/router" ) func TestRouterCompile(_ *testing.T) { diff --git a/tests/unit/state_compile_test.go b/tests/unit/state_compile_test.go index cc5b4b0..985b0da 100644 --- a/tests/unit/state_compile_test.go +++ b/tests/unit/state_compile_test.go @@ -8,8 +8,8 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/router" - "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/v2/state" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/router" + "github.com/jlrosende/project-manager/internal/adapters/handlers/tui/state" ) func TestMessagesCompile(_ *testing.T) { diff --git a/tests/unit/tools_fs_test.go b/tests/unit/tools_fs_test.go new file mode 100644 index 0000000..fcb79c4 --- /dev/null +++ b/tests/unit/tools_fs_test.go @@ -0,0 +1,66 @@ +package unit + +import ( + "os" + "path/filepath" + "testing" + + "github.com/jlrosende/project-manager/internal/tools" +) + +func TestIsDirEmpty(t *testing.T) { + dir := t.TempDir() + + empty, err := tools.IsDirEmpty(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !empty { + t.Fatalf("expected empty dir") + } + + f := filepath.Join(dir, "a.txt") + if err := os.WriteFile(f, []byte("x"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + empty, err = tools.IsDirEmpty(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if empty { + t.Fatalf("expected non-empty dir") + } +} + +func TestEnsureDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "a", "b") + if err := tools.EnsureDir(dir, 0o755); err != nil { + t.Fatalf("EnsureDir: %v", err) + } + + st, err := os.Stat(dir) + if err != nil || !st.IsDir() { + t.Fatalf("dir not created: %v", err) + } +} + +func TestRenameAndWriteFile(t *testing.T) { + dir := t.TempDir() + old := filepath.Join(dir, "old", "f.txt") + newp := filepath.Join(dir, "new", "f.txt") + + if err := tools.WriteFile(old, []byte("hi"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := tools.Rename(old, newp); err != nil { + t.Fatalf("Rename: %v", err) + } + + if _, err := os.Stat(newp); err != nil { + t.Fatalf("renamed file missing: %v", err) + } +} From ce942b3c47a5dd7215fb4c11840e8949a7233043 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 5 Oct 2025 18:10:21 +0000 Subject: [PATCH 19/27] (feature) new command --- .devcontainer/Dockerfile | 6 +- .devcontainer/compose.yml | 21 - .devcontainer/devcontainer.json | 10 +- .devcontainer/litellm-config.yaml | 20 - .devcontainer/opencode.jsonc | 43 ++ .editorconfig | 2 +- .goreleaser.yaml | 142 ++---- .mcp.json | 34 -- .opencode/command/analyze.md | 101 +++++ .opencode/command/clarify.md | 158 +++++++ .../command}/constitution.md | 7 +- .opencode/command/gen-docs.md | 38 ++ .opencode/command/gen-funtional-docs.md | 30 ++ .opencode/command/gen-readme.md | 31 ++ .../command}/implement.md | 8 +- .../commands => .opencode/command}/plan.md | 7 + .../commands => .opencode/command}/specify.md | 6 + .../commands => .opencode/command}/tasks.md | 8 +- .specify/scripts/bash/check-prerequisites.sh | 166 +++++++ .specify/scripts/bash/common.sh | 84 +++- .specify/scripts/bash/create-new-feature.sh | 31 +- .specify/scripts/bash/setup-plan.sh | 55 ++- .specify/scripts/bash/update-agent-context.sh | 417 ++++++++++-------- .specify/templates/plan-template.md | 24 +- AGENTS.md | 27 ++ CLAUDE.md | 45 -- Makefile | 3 + README.md | 11 + cmd/cli/new/new.go | 135 ------ cmd/main.go | 2 +- docs/cli/pm.md | 4 +- docs/cli/pm_edit.md | 2 + docs/cli/pm_init.md | 2 + docs/cli/pm_list.md | 2 + docs/cli/pm_new.md | 26 +- docs/rest/pm.rst | 4 +- docs/rest/pm_edit.rst | 2 + docs/rest/pm_init.rst | 2 + docs/rest/pm_list.rst | 2 + docs/rest/pm_new.rst | 26 +- go.mod | 2 +- .../adapters/handlers}/cli/edit/edit.go | 26 +- .../adapters/handlers}/cli/init/init.go | 11 +- .../adapters/handlers}/cli/list/list.go | 26 +- internal/adapters/handlers/cli/new/new.go | 284 ++++++++++++ .../adapters/handlers}/cli/root.go | 136 +++--- internal/adapters/handlers/cobra/.gitkeep | 0 internal/adapters/handlers/tui/window.go | 6 +- .../repositories/env_vars_repository.go | 49 +- internal/adapters/repositories/filesystem.go | 83 ++++ internal/adapters/repositories/logger.go | 101 +++++ .../repositories/project_repository.go | 27 +- internal/bootstrap/bootstrap.go | 72 +++ internal/core/domain/project_definition.go | 51 +++ internal/core/domain/project_validation.go | 95 ++++ internal/core/ports/filesystem_port.go | 18 + internal/core/ports/logger_port.go | 16 + internal/core/services/file_service.go | 104 +++++ internal/core/services/project_options.go | 286 ++++++++++++ internal/core/services/project_service.go | 230 ++++++---- internal/logger/logger.go | 46 -- internal/tools/docgen/main.go | 2 +- internal/tools/fs.go | 61 --- internal/tools/path.go | 29 -- man/pm-edit.1 | 10 +- man/pm-init.1 | 10 +- man/pm-list.1 | 10 +- man/pm-new.1 | 52 ++- man/pm.1 | 10 +- specs/001-cli-new-project/contracts/cli.md | 30 ++ specs/001-cli-new-project/data-model.md | 20 + specs/001-cli-new-project/plan.md | 217 +++++++++ specs/001-cli-new-project/quickstart.md | 47 ++ specs/001-cli-new-project/research.md | 19 + specs/001-cli-new-project/spec.md | 153 +++++++ specs/001-cli-new-project/tasks.md | 73 +++ tests/contract/cli_new_flags_test.go | 49 ++ .../integration/cli_new_config_merge_test.go | 36 ++ tests/integration/cli_new_dry_run_test.go | 29 ++ .../integration/cli_new_failure_paths_test.go | 68 +++ tests/integration/cli_new_gitconfig_test.go | 52 +++ tests/integration/cli_new_gitignore_test.go | 60 +++ tests/integration/cli_new_here_test.go | 32 ++ tests/integration/cli_new_idempotency_test.go | 106 +++++ tests/integration/cli_new_path_test.go | 32 ++ tests/integration/cli_new_skeleton_test.go | 71 +++ .../integration/{common.go => common_test.go} | 5 +- tests/unit/defaults_test.go | 41 ++ tests/unit/project_options_skeleton_test.go | 74 ++++ tests/unit/project_service_addenv_test.go | 3 +- .../unit/project_service_create_basic_test.go | 2 +- .../unit/project_service_create_flow_test.go | 2 +- ...ect_service_create_git_integration_test.go | 2 +- .../project_service_create_rollback_test.go | 10 +- ...roject_service_create_side_effects_test.go | 92 ++++ .../project_service_update_project_test.go | 2 +- tests/unit/tools_fs_test.go | 19 +- tests/unit/validate_test.go | 138 ++++++ 98 files changed, 4071 insertions(+), 1010 deletions(-) delete mode 100644 .devcontainer/litellm-config.yaml create mode 100644 .devcontainer/opencode.jsonc delete mode 100644 .mcp.json create mode 100644 .opencode/command/analyze.md create mode 100644 .opencode/command/clarify.md rename {.claude/commands => .opencode/command}/constitution.md (96%) create mode 100644 .opencode/command/gen-docs.md create mode 100644 .opencode/command/gen-funtional-docs.md create mode 100644 .opencode/command/gen-readme.md rename {.claude/commands => .opencode/command}/implement.md (88%) rename {.claude/commands => .opencode/command}/plan.md (72%) rename {.claude/commands => .opencode/command}/specify.md (87%) rename {.claude/commands => .opencode/command}/tasks.md (86%) create mode 100755 .specify/scripts/bash/check-prerequisites.sh create mode 100644 AGENTS.md delete mode 100644 CLAUDE.md delete mode 100644 cmd/cli/new/new.go rename {cmd => internal/adapters/handlers}/cli/edit/edit.go (56%) rename {cmd => internal/adapters/handlers}/cli/init/init.go (67%) rename {cmd => internal/adapters/handlers}/cli/list/list.go (74%) create mode 100644 internal/adapters/handlers/cli/new/new.go rename {cmd => internal/adapters/handlers}/cli/root.go (63%) delete mode 100644 internal/adapters/handlers/cobra/.gitkeep create mode 100644 internal/adapters/repositories/filesystem.go create mode 100644 internal/adapters/repositories/logger.go create mode 100644 internal/bootstrap/bootstrap.go create mode 100644 internal/core/domain/project_definition.go create mode 100644 internal/core/domain/project_validation.go create mode 100644 internal/core/ports/filesystem_port.go create mode 100644 internal/core/ports/logger_port.go create mode 100644 internal/core/services/file_service.go create mode 100644 internal/core/services/project_options.go delete mode 100644 internal/logger/logger.go delete mode 100644 internal/tools/fs.go delete mode 100644 internal/tools/path.go create mode 100644 specs/001-cli-new-project/contracts/cli.md create mode 100644 specs/001-cli-new-project/data-model.md create mode 100644 specs/001-cli-new-project/plan.md create mode 100644 specs/001-cli-new-project/quickstart.md create mode 100644 specs/001-cli-new-project/research.md create mode 100644 specs/001-cli-new-project/spec.md create mode 100644 specs/001-cli-new-project/tasks.md create mode 100644 tests/contract/cli_new_flags_test.go create mode 100644 tests/integration/cli_new_config_merge_test.go create mode 100644 tests/integration/cli_new_dry_run_test.go create mode 100644 tests/integration/cli_new_failure_paths_test.go create mode 100644 tests/integration/cli_new_gitconfig_test.go create mode 100644 tests/integration/cli_new_gitignore_test.go create mode 100644 tests/integration/cli_new_here_test.go create mode 100644 tests/integration/cli_new_idempotency_test.go create mode 100644 tests/integration/cli_new_path_test.go create mode 100644 tests/integration/cli_new_skeleton_test.go rename tests/integration/{common.go => common_test.go} (91%) create mode 100644 tests/unit/defaults_test.go create mode 100644 tests/unit/project_options_skeleton_test.go create mode 100644 tests/unit/project_service_create_side_effects_test.go create mode 100644 tests/unit/validate_test.go diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index e446b11..5b477de 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,5 +1,7 @@ FROM mcr.microsoft.com/devcontainers/go:1.25-bookworm -RUN curl -fsSL https://claude.ai/install.sh | bash +ENV SHELL=/bin/zsh -RUN cp $HOME/.local/bin/claude /usr/local/bin/claude \ No newline at end of file +RUN curl -fsSL https://opencode.ai/install | bash && \ + mv $HOME/.opencode/bin/opencode /usr/local/bin/opencode && \ + curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=/usr/local/bin sh diff --git a/.devcontainer/compose.yml b/.devcontainer/compose.yml index 5763b12..cb774fe 100644 --- a/.devcontainer/compose.yml +++ b/.devcontainer/compose.yml @@ -10,24 +10,3 @@ services: env_file: - ./.env - litellm: - container_name: litellm - image: ghcr.io/berriai/litellm:main-stable - volumes: - - ../.devcontainer/litellm-config.yaml:/app/config.yaml:cached - command: - - "--config=/app/config.yaml" - ports: - - "4000:4000" - env_file: - - ./.env - healthcheck: - test: - [ - "CMD-SHELL", - "wget --no-verbose --tries=1 http://localhost:4000/health/liveliness || exit 1", - ] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d249be6..30c8107 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,7 +9,7 @@ "features": { "ghcr.io/devcontainers/features/aws-cli:1": {}, "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, "ghcr.io/devcontainers/features/powershell:1.5.1": {}, "ghcr.io/devcontainers/features/node:1": {}, "ghcr.io/devcontainers-extra/features/uv:1": {} @@ -29,7 +29,8 @@ "golang.go", "ms-azuretools.vscode-docker", "HashiCorp.HCL", - "tamasfe.even-better-toml" + "tamasfe.even-better-toml", + "mhutchie.git-graph" ], "settings": { "aws.telemetry": false, @@ -43,6 +44,11 @@ }, "otherPortsAttributes": { "onAutoForward": "ignore" + }, + "portsAttributes": { + "*": { + "onAutoForward": "ignore" + } } // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // "remoteUser": "root" diff --git a/.devcontainer/litellm-config.yaml b/.devcontainer/litellm-config.yaml deleted file mode 100644 index fe5f898..0000000 --- a/.devcontainer/litellm-config.yaml +++ /dev/null @@ -1,20 +0,0 @@ -model_list: - - model_name: azure/gpt-5 - litellm_params: - model: azure/gpt-5 - api_base: os.environ/AZURE_OPENAI_ENDPOINT # runs os.getenv("AZURE_API_BASE") - api_key: os.environ/AZURE_OPENAI_API_KEY # runs os.getenv("AZURE_API_KEY") - api_version: os.environ/AZURE_OPENAI_API_VERSION - - - model_name: azure/gpt-4.1 - litellm_params: - model: azure/gpt-4.1 - api_base: os.environ/AZURE_OPENAI_ENDPOINT # runs os.getenv("AZURE_API_BASE") - api_key: os.environ/AZURE_OPENAI_API_KEY # runs os.getenv("AZURE_API_KEY") - api_version: os.environ/AZURE_OPENAI_API_VERSION - -litellm_settings: - drop_params: True - cache: True - cache_params: - type: local diff --git a/.devcontainer/opencode.jsonc b/.devcontainer/opencode.jsonc new file mode 100644 index 0000000..6b1ca68 --- /dev/null +++ b/.devcontainer/opencode.jsonc @@ -0,0 +1,43 @@ +{ + "$schema": "https://opencode.ai/config.json", + "model": "azure/gpt-5-codex", + "share": "disabled", + "theme": "ayu", + "tui": { + "scroll_speed": 4 + }, + "mcp": { + "github": { + "enabled": true, + "type": "local", + "command": [ + "docker", + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "environment": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "{env:GH_TOKEN}" + } + }, + "Context7": { + "enabled": true, + "type": "local", + "command": [ + "npx", + "-y", + "@upstash/context7-mcp@latest" + ] + } + }, + "provider": { + "azure": { + "options": { + "apiKey": "{env:AZURE_OPENAI_API_KEY}" + } + }, + } +} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 25a0d9a..22bc92d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,7 +9,7 @@ indent_style = space insert_final_newline = true trim_trailing_whitespace = true -[*.{yml,yaml,json}] +[*.{yml,yaml,json,jsonc}] tab_width = 2 indent_size = 2 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 65b2083..0f3cb93 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,42 +1,40 @@ -# This is an example .goreleaser.yml file with some sensible defaults. -# Make sure to check the documentation at https://goreleaser.com - -# The lines below are called `modelines`. See `:help modeline` -# Feel free to remove those if you don't want/need to use them. -# yaml-language-server: $schema=https://goreleaser.com/static/schema.json -# vim: set ts=2 sw=2 tw=0 fo=cnqoj - version: 2 project_name: pm +dist: dist before: hooks: - # You may remove this if you don't use go modules. - - go mod tidy - # you may remove this if you don't need go generate - - go generate ./... + - go test ./... builds: - - main: ./cmd + - id: pm + main: ./cmd/main.go + binary: pm env: - CGO_ENABLED=0 goos: - linux - # - windows - darwin goarch: - amd64 - - arm - arm64 flags: - - -v + - -trimpath + mod_timestamp: "{{ .CommitDate }}" ldflags: - - -s -w -X github.com/jlrosende/project-manager/internal.version={{.Version}} -X github.com/jlrosende/project-manager/internal.commit={{.Commit}} -X github.com/jlrosende/project-manager/internal.date={{.Date}} -X github.com/jlrosende/project-manager/internal.builtBy=goreleaser + - -s -w + - -X github.com/jlrosende/project-manager/internal.version={{ .Version }} + - -X github.com/jlrosende/project-manager/internal.commit={{ .Commit }} + - -X github.com/jlrosende/project-manager/internal.date={{ .Date }} + - -X github.com/jlrosende/project-manager/internal.builtBy=goreleaser archives: - - formats: [tar.gz] - # this name template makes the OS and Arch compatible with the results of `uname`. + - id: pm-archive + ids: + - pm + formats: + - tar.gz name_template: >- {{ .ProjectName }}_ {{- title .Os }}_ @@ -44,94 +42,39 @@ archives: {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} - # use zip for windows archives - format_overrides: - - goos: windows - formats: [zip] + files: + - LICENSE + - README.md -dockers_v2: - # You can have multiple Docker images. - - # Path to the Dockerfile (from the project root). - # - # Default: 'Dockerfile'. - # Templates: allowed. - dockerfile: "build/docker/Dockerfile" +checksum: + name_template: "{{ .ProjectName }}_checksums.txt" - # Image names. - # - # Empty image names are ignored. - # - # Templates: allowed. - images: - - "jlrosende/pm" - - "ghcr.io/jlrosende/pm" +snapshot: + version_template: "{{ .Tag }}-SNAPSHOT" - # Tag names. - # - # Empty tags are ignored. - # - # Templates: allowed. +dockers_v2: + - dockerfile: build/docker/Dockerfile + images: + - jlrosende/pm + - ghcr.io/jlrosende/pm tags: - - "v{{ .Version }}" - - "{{ if .IsNightly }}nightly{{ end }}" - - "{{ if not .IsNightly }}latest{{ end }}" - - # If your Dockerfile copies files other than binaries and packages, - # you should list them here as well. - # Note that GoReleaser will create the same structure inside a temporary - # directory, so if you add `foo/bar.json` here, on your Dockerfile you can - # `COPY foo/bar.json /whatever.json`. - # Also note that the paths here are relative to the directory in which - # GoReleaser is being run (usually the repository root directory). - # This field does not support wildcards, you can add an entire directory here - # and use wildcards when you `COPY`/`ADD` in your Dockerfile. - extra_files: - [] - # - config.yml - - # Labels to be added to the image. - # - # Items with empty keys or values will be ignored. - # - # Templates: allowed. - labels: - "org.opencontainers.image.description": "project manager client, manage your project configurations" - "org.opencontainers.image.created": "{{.Date}}" - "org.opencontainers.image.name": "{{.ProjectName}}" - "org.opencontainers.image.revision": "{{.FullCommit}}" - "org.opencontainers.image.version": "{{.Version}}" - "org.opencontainers.image.source": "{{.GitURL}}" - - # Annotations to be added to the image. - # - # Items with empty keys or values will be ignored. - # - # Templates: allowed. - annotations: - "project": "{{.ProjectName}}" - - # Platforms to build. - # - # Default: [ linux/amd64 linux/arm64 ] + - v{{ .Version }} + - '{{ if .IsNightly }}nightly{{ end }}' + - '{{ if not .IsNightly }}latest{{ end }}' platforms: - linux/amd64 - linux/arm64 - - # Additional `--build-arg`s to be passed. - # - # Templates: allowed. build_args: GO_VERSION: 1.25.1-alpine - - # Arbitrary flags to pass to the build command. - # - # Note: use this at your own risk. - # Note: flags must have the `=` sign between flag name and value. - # - # Templates: allowed. - flags: - [] - # - "--ulimit=10" + labels: + org.opencontainers.image.description: project manager client, manage your project configurations + org.opencontainers.image.created: "{{ .Date }}" + org.opencontainers.image.name: "{{ .ProjectName }}" + org.opencontainers.image.revision: "{{ .FullCommit }}" + org.opencontainers.image.version: "{{ .Version }}" + org.opencontainers.image.source: "{{ .GitURL }}" + annotations: + project: "{{ .ProjectName }}" changelog: sort: asc @@ -141,8 +84,7 @@ changelog: - "^test:" release: - footer: >- - + footer: | --- Released by [GoReleaser](https://github.com/goreleaser/goreleaser). diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 958c7b2..0000000 --- a/.mcp.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "mcpServers": { - "serena": { - "type": "stdio", - "command": "uvx", - "args": [ - "--from", - "git+https://github.com/oraios/serena", - "serena", - "start-mcp-server", - "--context", - "ide-assistant", - "--project", - "${PWD}" - ], - "env": {} - }, - "github": { - "type": "http", - "url": "https://api.githubcopilot.com/mcp/readonly", - "headers": { - "Authorization": "Bearer ${GH_TOKEN}" - } - }, - "Context7": { - "type": "stdio", - "command": "npx", - "args": [ - "-y", - "@upstash/context7-mcp@latest" - ] - } - } -} \ No newline at end of file diff --git a/.opencode/command/analyze.md b/.opencode/command/analyze.md new file mode 100644 index 0000000..f4c1a7b --- /dev/null +++ b/.opencode/command/analyze.md @@ -0,0 +1,101 @@ +--- +description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation. +--- + +The user input to you can be provided directly by the agent or as a command argument - you **MUST** consider it before proceeding with the prompt (if not empty). + +User input: + +$ARGUMENTS + +Goal: Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/tasks` has successfully produced a complete `tasks.md`. + +STRICTLY READ-ONLY: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually). + +Constitution Authority: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasksβ€”not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/analyze`. + +Execution steps: + +1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths: + - SPEC = FEATURE_DIR/spec.md + - PLAN = FEATURE_DIR/plan.md + - TASKS = FEATURE_DIR/tasks.md + Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command). + +2. Load artifacts: + - Parse spec.md sections: Overview/Context, Functional Requirements, Non-Functional Requirements, User Stories, Edge Cases (if present). + - Parse plan.md: Architecture/stack choices, Data Model references, Phases, Technical constraints. + - Parse tasks.md: Task IDs, descriptions, phase grouping, parallel markers [P], referenced file paths. + - Load constitution `.specify/memory/constitution.md` for principle validation. + +3. Build internal semantic models: + - Requirements inventory: Each functional + non-functional requirement with a stable key (derive slug based on imperative phrase; e.g., "User can upload file" -> `user-can-upload-file`). + - User story/action inventory. + - Task coverage mapping: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases). + - Constitution rule set: Extract principle names and any MUST/SHOULD normative statements. + +4. Detection passes: + A. Duplication detection: + - Identify near-duplicate requirements. Mark lower-quality phrasing for consolidation. + B. Ambiguity detection: + - Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria. + - Flag unresolved placeholders (TODO, TKTK, ???, , etc.). + C. Underspecification: + - Requirements with verbs but missing object or measurable outcome. + - User stories missing acceptance criteria alignment. + - Tasks referencing files or components not defined in spec/plan. + D. Constitution alignment: + - Any requirement or plan element conflicting with a MUST principle. + - Missing mandated sections or quality gates from constitution. + E. Coverage gaps: + - Requirements with zero associated tasks. + - Tasks with no mapped requirement/story. + - Non-functional requirements not reflected in tasks (e.g., performance, security). + F. Inconsistency: + - Terminology drift (same concept named differently across files). + - Data entities referenced in plan but absent in spec (or vice versa). + - Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note). + - Conflicting requirements (e.g., one requires to use Next.js while other says to use Vue as the framework). + +5. Severity assignment heuristic: + - CRITICAL: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality. + - HIGH: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion. + - MEDIUM: Terminology drift, missing non-functional task coverage, underspecified edge case. + - LOW: Style/wording improvements, minor redundancy not affecting execution order. + +6. Produce a Markdown report (no file writes) with sections: + + ### Specification Analysis Report + | ID | Category | Severity | Location(s) | Summary | Recommendation | + |----|----------|----------|-------------|---------|----------------| + | A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version | + (Add one row per finding; generate stable IDs prefixed by category initial.) + + Additional subsections: + - Coverage Summary Table: + | Requirement Key | Has Task? | Task IDs | Notes | + - Constitution Alignment Issues (if any) + - Unmapped Tasks (if any) + - Metrics: + * Total Requirements + * Total Tasks + * Coverage % (requirements with >=1 task) + * Ambiguity Count + * Duplication Count + * Critical Issues Count + +7. At end of report, output a concise Next Actions block: + - If CRITICAL issues exist: Recommend resolving before `/implement`. + - If only LOW/MEDIUM: User may proceed, but provide improvement suggestions. + - Provide explicit command suggestions: e.g., "Run /specify with refinement", "Run /plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'". + +8. Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.) + +Behavior rules: +- NEVER modify files. +- NEVER hallucinate missing sectionsβ€”if absent, report them. +- KEEP findings deterministic: if rerun without changes, produce consistent IDs and counts. +- LIMIT total findings in the main table to 50; aggregate remainder in a summarized overflow note. +- If zero issues found, emit a success report with coverage statistics and proceed recommendation. + +Context: $ARGUMENTS diff --git a/.opencode/command/clarify.md b/.opencode/command/clarify.md new file mode 100644 index 0000000..26ff530 --- /dev/null +++ b/.opencode/command/clarify.md @@ -0,0 +1,158 @@ +--- +description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec. +--- + +The user input to you can be provided directly by the agent or as a command argument - you **MUST** consider it before proceeding with the prompt (if not empty). + +User input: + +$ARGUMENTS + +Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file. + +Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases. + +Execution steps: + +1. Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields: + - `FEATURE_DIR` + - `FEATURE_SPEC` + - (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.) + - If JSON parsing fails, abort and instruct user to re-run `/specify` or verify feature branch environment. + +2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked). + + Functional Scope & Behavior: + - Core user goals & success criteria + - Explicit out-of-scope declarations + - User roles / personas differentiation + + Domain & Data Model: + - Entities, attributes, relationships + - Identity & uniqueness rules + - Lifecycle/state transitions + - Data volume / scale assumptions + + Interaction & UX Flow: + - Critical user journeys / sequences + - Error/empty/loading states + - Accessibility or localization notes + + Non-Functional Quality Attributes: + - Performance (latency, throughput targets) + - Scalability (horizontal/vertical, limits) + - Reliability & availability (uptime, recovery expectations) + - Observability (logging, metrics, tracing signals) + - Security & privacy (authN/Z, data protection, threat assumptions) + - Compliance / regulatory constraints (if any) + + Integration & External Dependencies: + - External services/APIs and failure modes + - Data import/export formats + - Protocol/versioning assumptions + + Edge Cases & Failure Handling: + - Negative scenarios + - Rate limiting / throttling + - Conflict resolution (e.g., concurrent edits) + + Constraints & Tradeoffs: + - Technical constraints (language, storage, hosting) + - Explicit tradeoffs or rejected alternatives + + Terminology & Consistency: + - Canonical glossary terms + - Avoided synonyms / deprecated terms + + Completion Signals: + - Acceptance criteria testability + - Measurable Definition of Done style indicators + + Misc / Placeholders: + - TODO markers / unresolved decisions + - Ambiguous adjectives ("robust", "intuitive") lacking quantification + + For each category with Partial or Missing status, add a candidate question opportunity unless: + - Clarification would not materially change implementation or validation strategy + - Information is better deferred to planning phase (note internally) + +3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints: + - Maximum of 5 total questions across the whole session. + - Each question must be answerable with EITHER: + * A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR + * A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words"). + - Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation. + - Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved. + - Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness). + - Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests. + - If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic. + +4. Sequential questioning loop (interactive): + - Present EXACTLY ONE question at a time. + - For multiple‑choice questions render options as a Markdown table: + + | Option | Description | + |--------|-------------| + | A |