refactor: delete pkg/cli, migrate imports to core/cli
pkg/cli now lives in forge.lthn.ai/core/cli as its own module. All cmd/gocmd imports updated. qa docblock check stubbed pending go-devops circular dependency resolution. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1734acaae0
commit
57ad74d4e2
49 changed files with 35 additions and 5202 deletions
|
|
@ -7,7 +7,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/cmd/qa"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
@ -614,24 +613,8 @@ func runInternalCheck(check QACheck) (string, error) {
|
|||
return "", runGoFuzz(duration, "", "", qaVerbose)
|
||||
|
||||
case "docblock":
|
||||
result, err := qa.CheckDocblockCoverage([]string{"./..."})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result.Threshold = qaDocblockThreshold
|
||||
result.Passed = result.Coverage >= qaDocblockThreshold
|
||||
|
||||
if !result.Passed {
|
||||
var output strings.Builder
|
||||
output.WriteString(fmt.Sprintf("Docblock coverage: %.1f%% (threshold: %.1f%%)\n",
|
||||
result.Coverage, qaDocblockThreshold))
|
||||
for _, m := range result.Missing {
|
||||
output.WriteString(fmt.Sprintf("%s:%d\n", m.File, m.Line))
|
||||
}
|
||||
return output.String(), cli.Err("docblock coverage %.1f%% below threshold %.1f%%",
|
||||
result.Coverage, qaDocblockThreshold)
|
||||
}
|
||||
return fmt.Sprintf("Docblock coverage: %.1f%%", result.Coverage), nil
|
||||
// TODO: migrate to go-devops/cmd/qa.CheckDocblockCoverage once circular dep resolved
|
||||
return "", cli.Err("docblock check not yet available (pending go-devops migration)")
|
||||
|
||||
default:
|
||||
return "", cli.Err("unknown internal check: %s", check.Name)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"os"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
|
|||
10
go.mod
10
go.mod
|
|
@ -2,19 +2,16 @@ module forge.lthn.ai/core/go
|
|||
|
||||
go 1.26.0
|
||||
|
||||
require forge.lthn.ai/core/go-crypt v0.0.1
|
||||
|
||||
require (
|
||||
forge.lthn.ai/Snider/Borg v0.2.1
|
||||
forge.lthn.ai/core/cli v0.0.1
|
||||
forge.lthn.ai/core/go-crypt v0.0.1
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/term v0.40.0
|
||||
golang.org/x/text v0.34.0
|
||||
google.golang.org/grpc v1.79.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
|
|
@ -34,6 +31,7 @@ require (
|
|||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
|
||||
github.com/aws/smithy-go v1.24.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.4 // indirect
|
||||
|
|
@ -66,6 +64,7 @@ require (
|
|||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
|
|
@ -75,6 +74,7 @@ require (
|
|||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
gonum.org/v1/gonum v0.17.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
|
|
|
|||
23
go.sum
23
go.sum
|
|
@ -1,5 +1,9 @@
|
|||
forge.lthn.ai/Snider/Borg v0.2.1 h1:tsbbLQukDm4fyTkBDi98cwzoWkcCVXBOl9lhoxNDWJ4=
|
||||
forge.lthn.ai/core/go-crypt v0.0.1 h1:i8CFFbpda528HL9uUcGvvRHsXSbX/j8FezGRKHBg2dA=
|
||||
forge.lthn.ai/Snider/Borg v0.2.1 h1:Uf/YtUJLL8jlxTCjvP4J+5GHe3LLeALGtbh7zj8d8Qc=
|
||||
forge.lthn.ai/Snider/Borg v0.2.1/go.mod h1:MVfolb7F6/A2LOIijcbBhWImu5db5NSMcSjvShMoMCA=
|
||||
forge.lthn.ai/core/cli v0.0.1 h1:nqpc4Tv8a4H/ERei+/71DVQxkCFU8HPFJP4120qPXgk=
|
||||
forge.lthn.ai/core/cli v0.0.1/go.mod h1:xa3Nqw3sUtYYJ1k+1jYul18tgs6sBevCUsGsHJI1hHA=
|
||||
forge.lthn.ai/core/go-crypt v0.0.1 h1:fmFc2SJ/VOXDRjkcYoLWfL7lI4HfPJeVS/Na6zHHcvw=
|
||||
forge.lthn.ai/core/go-crypt v0.0.1/go.mod h1:/j/rUN2ZMV7x1B5BYxH3QdwkgZg0HNBw5XuyFZeyxBY=
|
||||
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/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||
|
|
@ -31,13 +35,21 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
|||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI=
|
||||
github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE=
|
||||
github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g=
|
||||
github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
|
|
@ -79,11 +91,13 @@ 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/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=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
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=
|
||||
|
|
@ -125,10 +139,15 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
|
|||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
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/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
|
|
|
|||
163
pkg/cli/ansi.go
163
pkg/cli/ansi.go
|
|
@ -1,163 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ANSI escape codes
|
||||
const (
|
||||
ansiReset = "\033[0m"
|
||||
ansiBold = "\033[1m"
|
||||
ansiDim = "\033[2m"
|
||||
ansiItalic = "\033[3m"
|
||||
ansiUnderline = "\033[4m"
|
||||
)
|
||||
|
||||
var (
|
||||
colorEnabled = true
|
||||
colorEnabledMu sync.RWMutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
// NO_COLOR standard: https://no-color.org/
|
||||
// If NO_COLOR is set (to any value, including empty), disable colors.
|
||||
if _, exists := os.LookupEnv("NO_COLOR"); exists {
|
||||
colorEnabled = false
|
||||
return
|
||||
}
|
||||
|
||||
// TERM=dumb indicates a terminal without color support.
|
||||
if os.Getenv("TERM") == "dumb" {
|
||||
colorEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// ColorEnabled returns true if ANSI color output is enabled.
|
||||
func ColorEnabled() bool {
|
||||
colorEnabledMu.RLock()
|
||||
defer colorEnabledMu.RUnlock()
|
||||
return colorEnabled
|
||||
}
|
||||
|
||||
// SetColorEnabled enables or disables ANSI color output.
|
||||
// This overrides the NO_COLOR environment variable check.
|
||||
func SetColorEnabled(enabled bool) {
|
||||
colorEnabledMu.Lock()
|
||||
colorEnabled = enabled
|
||||
colorEnabledMu.Unlock()
|
||||
}
|
||||
|
||||
// AnsiStyle represents terminal text styling.
|
||||
// Use NewStyle() to create, chain methods, call Render().
|
||||
type AnsiStyle struct {
|
||||
bold bool
|
||||
dim bool
|
||||
italic bool
|
||||
underline bool
|
||||
fg string
|
||||
bg string
|
||||
}
|
||||
|
||||
// NewStyle creates a new empty style.
|
||||
func NewStyle() *AnsiStyle {
|
||||
return &AnsiStyle{}
|
||||
}
|
||||
|
||||
// Bold enables bold text.
|
||||
func (s *AnsiStyle) Bold() *AnsiStyle {
|
||||
s.bold = true
|
||||
return s
|
||||
}
|
||||
|
||||
// Dim enables dim text.
|
||||
func (s *AnsiStyle) Dim() *AnsiStyle {
|
||||
s.dim = true
|
||||
return s
|
||||
}
|
||||
|
||||
// Italic enables italic text.
|
||||
func (s *AnsiStyle) Italic() *AnsiStyle {
|
||||
s.italic = true
|
||||
return s
|
||||
}
|
||||
|
||||
// Underline enables underlined text.
|
||||
func (s *AnsiStyle) Underline() *AnsiStyle {
|
||||
s.underline = true
|
||||
return s
|
||||
}
|
||||
|
||||
// Foreground sets foreground color from hex string.
|
||||
func (s *AnsiStyle) Foreground(hex string) *AnsiStyle {
|
||||
s.fg = fgColorHex(hex)
|
||||
return s
|
||||
}
|
||||
|
||||
// Background sets background color from hex string.
|
||||
func (s *AnsiStyle) Background(hex string) *AnsiStyle {
|
||||
s.bg = bgColorHex(hex)
|
||||
return s
|
||||
}
|
||||
|
||||
// Render applies the style to text.
|
||||
// Returns plain text if NO_COLOR is set or colors are disabled.
|
||||
func (s *AnsiStyle) Render(text string) string {
|
||||
if s == nil || !ColorEnabled() {
|
||||
return text
|
||||
}
|
||||
|
||||
var codes []string
|
||||
if s.bold {
|
||||
codes = append(codes, ansiBold)
|
||||
}
|
||||
if s.dim {
|
||||
codes = append(codes, ansiDim)
|
||||
}
|
||||
if s.italic {
|
||||
codes = append(codes, ansiItalic)
|
||||
}
|
||||
if s.underline {
|
||||
codes = append(codes, ansiUnderline)
|
||||
}
|
||||
if s.fg != "" {
|
||||
codes = append(codes, s.fg)
|
||||
}
|
||||
if s.bg != "" {
|
||||
codes = append(codes, s.bg)
|
||||
}
|
||||
|
||||
if len(codes) == 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
return strings.Join(codes, "") + text + ansiReset
|
||||
}
|
||||
|
||||
// fgColorHex converts a hex string to an ANSI foreground color code.
|
||||
func fgColorHex(hex string) string {
|
||||
r, g, b := hexToRGB(hex)
|
||||
return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)
|
||||
}
|
||||
|
||||
// bgColorHex converts a hex string to an ANSI background color code.
|
||||
func bgColorHex(hex string) string {
|
||||
r, g, b := hexToRGB(hex)
|
||||
return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)
|
||||
}
|
||||
|
||||
// hexToRGB converts a hex string to RGB values.
|
||||
func hexToRGB(hex string) (int, int, int) {
|
||||
hex = strings.TrimPrefix(hex, "#")
|
||||
if len(hex) != 6 {
|
||||
return 255, 255, 255
|
||||
}
|
||||
// Use 8-bit parsing since RGB values are 0-255, avoiding integer overflow on 32-bit systems.
|
||||
r, _ := strconv.ParseUint(hex[0:2], 16, 8)
|
||||
g, _ := strconv.ParseUint(hex[2:4], 16, 8)
|
||||
b, _ := strconv.ParseUint(hex[4:6], 16, 8)
|
||||
return int(r), int(g), int(b)
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnsiStyle_Render(t *testing.T) {
|
||||
// Ensure colors are enabled for this test
|
||||
SetColorEnabled(true)
|
||||
defer SetColorEnabled(true) // Reset after test
|
||||
|
||||
s := NewStyle().Bold().Foreground("#ff0000")
|
||||
got := s.Render("test")
|
||||
if got == "test" {
|
||||
t.Error("Expected styled output")
|
||||
}
|
||||
if !strings.Contains(got, "test") {
|
||||
t.Error("Output should contain text")
|
||||
}
|
||||
if !strings.Contains(got, "[1m") {
|
||||
t.Error("Output should contain bold code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorEnabled_Good(t *testing.T) {
|
||||
// Save original state
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// Test enabling
|
||||
SetColorEnabled(true)
|
||||
if !ColorEnabled() {
|
||||
t.Error("ColorEnabled should return true")
|
||||
}
|
||||
|
||||
// Test disabling
|
||||
SetColorEnabled(false)
|
||||
if ColorEnabled() {
|
||||
t.Error("ColorEnabled should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_ColorDisabled_Good(t *testing.T) {
|
||||
// Save original state
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// Disable colors
|
||||
SetColorEnabled(false)
|
||||
|
||||
s := NewStyle().Bold().Foreground("#ff0000")
|
||||
got := s.Render("test")
|
||||
|
||||
// Should return plain text without ANSI codes
|
||||
if got != "test" {
|
||||
t.Errorf("Expected plain 'test', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_ColorEnabled_Good(t *testing.T) {
|
||||
// Save original state
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// Enable colors
|
||||
SetColorEnabled(true)
|
||||
|
||||
s := NewStyle().Bold()
|
||||
got := s.Render("test")
|
||||
|
||||
// Should contain ANSI codes
|
||||
if !strings.Contains(got, "\033[") {
|
||||
t.Error("Expected ANSI codes when colors enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseASCII_Good(t *testing.T) {
|
||||
// Save original state
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// Enable first, then UseASCII should disable colors
|
||||
SetColorEnabled(true)
|
||||
UseASCII()
|
||||
if ColorEnabled() {
|
||||
t.Error("UseASCII should disable colors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_NilStyle_Good(t *testing.T) {
|
||||
var s *AnsiStyle
|
||||
got := s.Render("test")
|
||||
if got != "test" {
|
||||
t.Errorf("Nil style should return plain text, got %q", got)
|
||||
}
|
||||
}
|
||||
160
pkg/cli/app.go
160
pkg/cli/app.go
|
|
@ -1,160 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
"forge.lthn.ai/core/go/pkg/workspace"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
// AppName is the CLI application name.
|
||||
AppName = "core"
|
||||
)
|
||||
|
||||
// Build-time variables set via ldflags (SemVer 2.0.0):
|
||||
//
|
||||
// go build -ldflags="-X forge.lthn.ai/core/go/pkg/cli.AppVersion=1.2.0 \
|
||||
// -X forge.lthn.ai/core/go/pkg/cli.BuildCommit=df94c24 \
|
||||
// -X forge.lthn.ai/core/go/pkg/cli.BuildDate=2026-02-06 \
|
||||
// -X forge.lthn.ai/core/go/pkg/cli.BuildPreRelease=dev.8"
|
||||
var (
|
||||
AppVersion = "0.0.0"
|
||||
BuildCommit = "unknown"
|
||||
BuildDate = "unknown"
|
||||
BuildPreRelease = ""
|
||||
)
|
||||
|
||||
// SemVer returns the full SemVer 2.0.0 version string.
|
||||
// - Release: 1.2.0
|
||||
// - Pre-release: 1.2.0-dev.8
|
||||
// - Full: 1.2.0-dev.8+df94c24.20260206
|
||||
func SemVer() string {
|
||||
v := AppVersion
|
||||
if BuildPreRelease != "" {
|
||||
v += "-" + BuildPreRelease
|
||||
}
|
||||
if BuildCommit != "unknown" {
|
||||
v += "+" + BuildCommit
|
||||
if BuildDate != "unknown" {
|
||||
v += "." + BuildDate
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Main initialises and runs the CLI application.
|
||||
// Pass command services via WithCommands to register CLI commands
|
||||
// through the Core framework lifecycle.
|
||||
//
|
||||
// cli.Main(
|
||||
// cli.WithCommands("config", config.AddConfigCommands),
|
||||
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
|
||||
// )
|
||||
//
|
||||
// Exits with code 1 on error or panic.
|
||||
func Main(commands ...framework.Option) {
|
||||
// Recovery from panics
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error("recovered from panic", "error", r, "stack", string(debug.Stack()))
|
||||
Shutdown()
|
||||
Fatal(fmt.Errorf("panic: %v", r))
|
||||
}
|
||||
}()
|
||||
|
||||
// Core services load first, then command services
|
||||
services := []framework.Option{
|
||||
framework.WithName("i18n", NewI18nService(I18nOptions{})),
|
||||
framework.WithName("log", NewLogService(log.Options{
|
||||
Level: log.LevelInfo,
|
||||
})),
|
||||
framework.WithName("workspace", workspace.New),
|
||||
}
|
||||
services = append(services, commands...)
|
||||
|
||||
// Initialise CLI runtime with services
|
||||
if err := Init(Options{
|
||||
AppName: AppName,
|
||||
Version: SemVer(),
|
||||
Services: services,
|
||||
}); err != nil {
|
||||
Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
defer Shutdown()
|
||||
|
||||
// Add completion command to the CLI's root
|
||||
RootCmd().AddCommand(completionCmd)
|
||||
|
||||
if err := Execute(); err != nil {
|
||||
code := 1
|
||||
var exitErr *ExitError
|
||||
if As(err, &exitErr) {
|
||||
code = exitErr.Code
|
||||
}
|
||||
Error(err.Error())
|
||||
os.Exit(code)
|
||||
}
|
||||
}
|
||||
|
||||
// completionCmd generates shell completion scripts.
|
||||
var completionCmd = &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
Short: "Generate shell completion script",
|
||||
Long: `Generate shell completion script for the specified shell.
|
||||
|
||||
To load completions:
|
||||
|
||||
Bash:
|
||||
$ source <(core completion bash)
|
||||
|
||||
# To load completions for each session, execute once:
|
||||
# Linux:
|
||||
$ core completion bash > /etc/bash_completion.d/core
|
||||
# macOS:
|
||||
$ core completion bash > $(brew --prefix)/etc/bash_completion.d/core
|
||||
|
||||
Zsh:
|
||||
# If shell completion is not already enabled in your environment,
|
||||
# you will need to enable it. You can execute the following once:
|
||||
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
|
||||
|
||||
# To load completions for each session, execute once:
|
||||
$ core completion zsh > "${fpath[1]}/_core"
|
||||
|
||||
# You will need to start a new shell for this setup to take effect.
|
||||
|
||||
Fish:
|
||||
$ core completion fish | source
|
||||
|
||||
# To load completions for each session, execute once:
|
||||
$ core completion fish > ~/.config/fish/completions/core.fish
|
||||
|
||||
PowerShell:
|
||||
PS> core completion powershell | Out-String | Invoke-Expression
|
||||
|
||||
# To load completions for every new session, run:
|
||||
PS> core completion powershell > core.ps1
|
||||
# and source this file from your PowerShell profile.
|
||||
`,
|
||||
DisableFlagsInUseLine: true,
|
||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
_ = cmd.Root().GenBashCompletion(os.Stdout)
|
||||
case "zsh":
|
||||
_ = cmd.Root().GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
_ = cmd.Root().GenFishCompletion(os.Stdout, true)
|
||||
case "powershell":
|
||||
_ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestPanicRecovery_Good verifies that the panic recovery mechanism
|
||||
// catches panics and calls the appropriate shutdown and error handling.
|
||||
func TestPanicRecovery_Good(t *testing.T) {
|
||||
t.Run("recovery captures panic value and stack", func(t *testing.T) {
|
||||
var recovered any
|
||||
var capturedStack []byte
|
||||
var shutdownCalled bool
|
||||
|
||||
// Simulate the panic recovery pattern from Main()
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
recovered = r
|
||||
capturedStack = debug.Stack()
|
||||
shutdownCalled = true // simulates Shutdown() call
|
||||
}
|
||||
}()
|
||||
|
||||
panic("test panic")
|
||||
}()
|
||||
|
||||
assert.Equal(t, "test panic", recovered)
|
||||
assert.True(t, shutdownCalled, "Shutdown should be called after panic recovery")
|
||||
assert.NotEmpty(t, capturedStack, "Stack trace should be captured")
|
||||
assert.Contains(t, string(capturedStack), "TestPanicRecovery_Good")
|
||||
})
|
||||
|
||||
t.Run("recovery handles error type panics", func(t *testing.T) {
|
||||
var recovered any
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
recovered = r
|
||||
}
|
||||
}()
|
||||
|
||||
panic(fmt.Errorf("error panic"))
|
||||
}()
|
||||
|
||||
err, ok := recovered.(error)
|
||||
assert.True(t, ok, "Recovered value should be an error")
|
||||
assert.Equal(t, "error panic", err.Error())
|
||||
})
|
||||
|
||||
t.Run("recovery handles nil panic gracefully", func(t *testing.T) {
|
||||
recoveryExecuted := false
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
recoveryExecuted = true
|
||||
}
|
||||
}()
|
||||
|
||||
// No panic occurs
|
||||
}()
|
||||
|
||||
assert.False(t, recoveryExecuted, "Recovery block should not execute without panic")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPanicRecovery_Bad tests error conditions in panic recovery.
|
||||
func TestPanicRecovery_Bad(t *testing.T) {
|
||||
t.Run("recovery handles concurrent panics", func(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
recoveryCount := 0
|
||||
var mu sync.Mutex
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
mu.Lock()
|
||||
recoveryCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
panic(fmt.Sprintf("panic from goroutine %d", id))
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
assert.Equal(t, 3, recoveryCount, "All goroutine panics should be recovered")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPanicRecovery_Ugly tests edge cases in panic recovery.
|
||||
func TestPanicRecovery_Ugly(t *testing.T) {
|
||||
t.Run("recovery handles typed panic values", func(t *testing.T) {
|
||||
type customError struct {
|
||||
code int
|
||||
msg string
|
||||
}
|
||||
|
||||
var recovered any
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
recovered = recover()
|
||||
}()
|
||||
|
||||
panic(customError{code: 500, msg: "internal error"})
|
||||
}()
|
||||
|
||||
ce, ok := recovered.(customError)
|
||||
assert.True(t, ok, "Should recover custom type")
|
||||
assert.Equal(t, 500, ce.code)
|
||||
assert.Equal(t, "internal error", ce.msg)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMainPanicRecoveryPattern verifies the exact pattern used in Main().
|
||||
func TestMainPanicRecoveryPattern(t *testing.T) {
|
||||
t.Run("pattern logs error and calls shutdown", func(t *testing.T) {
|
||||
var logBuffer bytes.Buffer
|
||||
var shutdownCalled bool
|
||||
var fatalErr error
|
||||
|
||||
// Mock implementations
|
||||
mockLogError := func(msg string, args ...any) {
|
||||
fmt.Fprintf(&logBuffer, msg, args...)
|
||||
}
|
||||
mockShutdown := func() {
|
||||
shutdownCalled = true
|
||||
}
|
||||
mockFatal := func(err error) {
|
||||
fatalErr = err
|
||||
}
|
||||
|
||||
// Execute the pattern from Main()
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
mockLogError("recovered from panic: %v", r)
|
||||
mockShutdown()
|
||||
mockFatal(fmt.Errorf("panic: %v", r))
|
||||
}
|
||||
}()
|
||||
|
||||
panic("simulated crash")
|
||||
}()
|
||||
|
||||
assert.Contains(t, logBuffer.String(), "recovered from panic: simulated crash")
|
||||
assert.True(t, shutdownCalled, "Shutdown must be called on panic")
|
||||
assert.NotNil(t, fatalErr, "Fatal must be called with error")
|
||||
assert.Equal(t, "panic: simulated crash", fatalErr.Error())
|
||||
})
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CheckBuilder provides fluent API for check results.
|
||||
type CheckBuilder struct {
|
||||
name string
|
||||
status string
|
||||
style *AnsiStyle
|
||||
icon string
|
||||
duration string
|
||||
}
|
||||
|
||||
// Check starts building a check result line.
|
||||
//
|
||||
// cli.Check("audit").Pass()
|
||||
// cli.Check("fmt").Fail().Duration("2.3s")
|
||||
// cli.Check("test").Skip()
|
||||
func Check(name string) *CheckBuilder {
|
||||
return &CheckBuilder{name: name}
|
||||
}
|
||||
|
||||
// Pass marks the check as passed.
|
||||
func (c *CheckBuilder) Pass() *CheckBuilder {
|
||||
c.status = "passed"
|
||||
c.style = SuccessStyle
|
||||
c.icon = Glyph(":check:")
|
||||
return c
|
||||
}
|
||||
|
||||
// Fail marks the check as failed.
|
||||
func (c *CheckBuilder) Fail() *CheckBuilder {
|
||||
c.status = "failed"
|
||||
c.style = ErrorStyle
|
||||
c.icon = Glyph(":cross:")
|
||||
return c
|
||||
}
|
||||
|
||||
// Skip marks the check as skipped.
|
||||
func (c *CheckBuilder) Skip() *CheckBuilder {
|
||||
c.status = "skipped"
|
||||
c.style = DimStyle
|
||||
c.icon = "-"
|
||||
return c
|
||||
}
|
||||
|
||||
// Warn marks the check as warning.
|
||||
func (c *CheckBuilder) Warn() *CheckBuilder {
|
||||
c.status = "warning"
|
||||
c.style = WarningStyle
|
||||
c.icon = Glyph(":warn:")
|
||||
return c
|
||||
}
|
||||
|
||||
// Duration adds duration to the check result.
|
||||
func (c *CheckBuilder) Duration(d string) *CheckBuilder {
|
||||
c.duration = d
|
||||
return c
|
||||
}
|
||||
|
||||
// Message adds a custom message instead of status.
|
||||
func (c *CheckBuilder) Message(msg string) *CheckBuilder {
|
||||
c.status = msg
|
||||
return c
|
||||
}
|
||||
|
||||
// String returns the formatted check line.
|
||||
func (c *CheckBuilder) String() string {
|
||||
icon := c.icon
|
||||
if c.style != nil {
|
||||
icon = c.style.Render(c.icon)
|
||||
}
|
||||
|
||||
status := c.status
|
||||
if c.style != nil && c.status != "" {
|
||||
status = c.style.Render(c.status)
|
||||
}
|
||||
|
||||
if c.duration != "" {
|
||||
return fmt.Sprintf(" %s %-20s %-10s %s", icon, c.name, status, DimStyle.Render(c.duration))
|
||||
}
|
||||
if status != "" {
|
||||
return fmt.Sprintf(" %s %s %s", icon, c.name, status)
|
||||
}
|
||||
return fmt.Sprintf(" %s %s", icon, c.name)
|
||||
}
|
||||
|
||||
// Print outputs the check result.
|
||||
func (c *CheckBuilder) Print() {
|
||||
fmt.Println(c.String())
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCheckBuilder(t *testing.T) {
|
||||
UseASCII() // Deterministic output
|
||||
|
||||
// Pass
|
||||
c := Check("foo").Pass()
|
||||
got := c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Pass")
|
||||
}
|
||||
|
||||
// Fail
|
||||
c = Check("foo").Fail()
|
||||
got = c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Fail")
|
||||
}
|
||||
|
||||
// Skip
|
||||
c = Check("foo").Skip()
|
||||
got = c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Skip")
|
||||
}
|
||||
|
||||
// Warn
|
||||
c = Check("foo").Warn()
|
||||
got = c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Warn")
|
||||
}
|
||||
|
||||
// Duration
|
||||
c = Check("foo").Pass().Duration("1s")
|
||||
got = c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Duration")
|
||||
}
|
||||
|
||||
// Message
|
||||
c = Check("foo").Message("status")
|
||||
got = c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Message")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Command Type Re-export
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Command is the cobra command type.
|
||||
// Re-exported for convenience so packages don't need to import cobra directly.
|
||||
type Command = cobra.Command
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Command Builders
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// NewCommand creates a new command with a RunE handler.
|
||||
// This is the standard way to create commands that may return errors.
|
||||
//
|
||||
// cmd := cli.NewCommand("build", "Build the project", "", func(cmd *cli.Command, args []string) error {
|
||||
// // Build logic
|
||||
// return nil
|
||||
// })
|
||||
func NewCommand(use, short, long string, run func(cmd *Command, args []string) error) *Command {
|
||||
cmd := &Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
RunE: run,
|
||||
}
|
||||
if long != "" {
|
||||
cmd.Long = long
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewGroup creates a new command group (no RunE).
|
||||
// Use this for parent commands that only contain subcommands.
|
||||
//
|
||||
// devCmd := cli.NewGroup("dev", "Development commands", "")
|
||||
// devCmd.AddCommand(buildCmd, testCmd)
|
||||
func NewGroup(use, short, long string) *Command {
|
||||
cmd := &Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
}
|
||||
if long != "" {
|
||||
cmd.Long = long
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewRun creates a new command with a simple Run handler (no error return).
|
||||
// Use when the command cannot fail.
|
||||
//
|
||||
// cmd := cli.NewRun("version", "Show version", "", func(cmd *cli.Command, args []string) {
|
||||
// cli.Println("v1.0.0")
|
||||
// })
|
||||
func NewRun(use, short, long string, run func(cmd *Command, args []string)) *Command {
|
||||
cmd := &Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Run: run,
|
||||
}
|
||||
if long != "" {
|
||||
cmd.Long = long
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Flag Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// StringFlag adds a string flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var output string
|
||||
// cli.StringFlag(cmd, &output, "output", "o", "", "Output file path")
|
||||
func StringFlag(cmd *Command, ptr *string, name, short, def, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().StringVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().StringVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// BoolFlag adds a boolean flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var verbose bool
|
||||
// cli.BoolFlag(cmd, &verbose, "verbose", "v", false, "Enable verbose output")
|
||||
func BoolFlag(cmd *Command, ptr *bool, name, short string, def bool, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().BoolVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().BoolVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// IntFlag adds an integer flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var count int
|
||||
// cli.IntFlag(cmd, &count, "count", "n", 10, "Number of items")
|
||||
func IntFlag(cmd *Command, ptr *int, name, short string, def int, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().IntVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().IntVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// StringSliceFlag adds a string slice flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var tags []string
|
||||
// cli.StringSliceFlag(cmd, &tags, "tag", "t", nil, "Tags to apply")
|
||||
func StringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().StringSliceVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().StringSliceVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Persistent Flag Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// PersistentStringFlag adds a persistent string flag (inherited by subcommands).
|
||||
func PersistentStringFlag(cmd *Command, ptr *string, name, short, def, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().StringVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().StringVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentBoolFlag adds a persistent boolean flag (inherited by subcommands).
|
||||
func PersistentBoolFlag(cmd *Command, ptr *bool, name, short string, def bool, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().BoolVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().BoolVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Command Configuration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// WithArgs sets the Args validation function for a command.
|
||||
// Returns the command for chaining.
|
||||
//
|
||||
// cmd := cli.NewCommand("build", "Build", "", run).WithArgs(cobra.ExactArgs(1))
|
||||
func WithArgs(cmd *Command, args cobra.PositionalArgs) *Command {
|
||||
cmd.Args = args
|
||||
return cmd
|
||||
}
|
||||
|
||||
// WithExample sets the Example field for a command.
|
||||
// Returns the command for chaining.
|
||||
func WithExample(cmd *Command, example string) *Command {
|
||||
cmd.Example = example
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ExactArgs returns a PositionalArgs that accepts exactly N arguments.
|
||||
func ExactArgs(n int) cobra.PositionalArgs {
|
||||
return cobra.ExactArgs(n)
|
||||
}
|
||||
|
||||
// MinimumNArgs returns a PositionalArgs that accepts minimum N arguments.
|
||||
func MinimumNArgs(n int) cobra.PositionalArgs {
|
||||
return cobra.MinimumNArgs(n)
|
||||
}
|
||||
|
||||
// MaximumNArgs returns a PositionalArgs that accepts maximum N arguments.
|
||||
func MaximumNArgs(n int) cobra.PositionalArgs {
|
||||
return cobra.MaximumNArgs(n)
|
||||
}
|
||||
|
||||
// NoArgs returns a PositionalArgs that accepts no arguments.
|
||||
func NoArgs() cobra.PositionalArgs {
|
||||
return cobra.NoArgs
|
||||
}
|
||||
|
||||
// ArbitraryArgs returns a PositionalArgs that accepts any arguments.
|
||||
func ArbitraryArgs() cobra.PositionalArgs {
|
||||
return cobra.ArbitraryArgs
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
// Package cli provides the CLI runtime and utilities.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// WithCommands creates a framework Option that registers a command group.
|
||||
// The register function receives the root command during service startup,
|
||||
// allowing commands to participate in the Core lifecycle.
|
||||
//
|
||||
// cli.Main(
|
||||
// cli.WithCommands("config", config.AddConfigCommands),
|
||||
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
|
||||
// )
|
||||
func WithCommands(name string, register func(root *Command)) framework.Option {
|
||||
return framework.WithName("cmd."+name, func(c *framework.Core) (any, error) {
|
||||
return &commandService{core: c, register: register}, nil
|
||||
})
|
||||
}
|
||||
|
||||
type commandService struct {
|
||||
core *framework.Core
|
||||
register func(root *Command)
|
||||
}
|
||||
|
||||
func (s *commandService) OnStartup(_ context.Context) error {
|
||||
if root, ok := s.core.App.(*cobra.Command); ok {
|
||||
s.register(root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,455 +0,0 @@
|
|||
// Package cli provides the CLI runtime and utilities.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Mode represents the CLI execution mode.
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
// ModeInteractive indicates TTY attached with coloured output.
|
||||
ModeInteractive Mode = iota
|
||||
// ModePipe indicates stdout is piped, colours disabled.
|
||||
ModePipe
|
||||
// ModeDaemon indicates headless execution, log-only output.
|
||||
ModeDaemon
|
||||
)
|
||||
|
||||
// String returns the string representation of the Mode.
|
||||
func (m Mode) String() string {
|
||||
switch m {
|
||||
case ModeInteractive:
|
||||
return "interactive"
|
||||
case ModePipe:
|
||||
return "pipe"
|
||||
case ModeDaemon:
|
||||
return "daemon"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// DetectMode determines the execution mode based on environment.
|
||||
// Checks CORE_DAEMON env var first, then TTY status.
|
||||
func DetectMode() Mode {
|
||||
if os.Getenv("CORE_DAEMON") == "1" {
|
||||
return ModeDaemon
|
||||
}
|
||||
if !IsTTY() {
|
||||
return ModePipe
|
||||
}
|
||||
return ModeInteractive
|
||||
}
|
||||
|
||||
// IsTTY returns true if stdout is a terminal.
|
||||
func IsTTY() bool {
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
// IsStdinTTY returns true if stdin is a terminal.
|
||||
func IsStdinTTY() bool {
|
||||
return term.IsTerminal(int(os.Stdin.Fd()))
|
||||
}
|
||||
|
||||
// IsStderrTTY returns true if stderr is a terminal.
|
||||
func IsStderrTTY() bool {
|
||||
return term.IsTerminal(int(os.Stderr.Fd()))
|
||||
}
|
||||
|
||||
// --- PID File Management ---
|
||||
|
||||
// PIDFile manages a process ID file for single-instance enforcement.
|
||||
type PIDFile struct {
|
||||
medium io.Medium
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewPIDFile creates a PID file manager.
|
||||
// If medium is nil, uses io.Local (filesystem).
|
||||
func NewPIDFile(medium io.Medium, path string) *PIDFile {
|
||||
if medium == nil {
|
||||
medium = io.Local
|
||||
}
|
||||
return &PIDFile{medium: medium, path: path}
|
||||
}
|
||||
|
||||
// Acquire writes the current PID to the file.
|
||||
// Returns error if another instance is running.
|
||||
func (p *PIDFile) Acquire() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Check if PID file exists
|
||||
if data, err := p.medium.Read(p.path); err == nil {
|
||||
pid, err := strconv.Atoi(data)
|
||||
if err == nil && pid > 0 {
|
||||
// Check if process is still running
|
||||
if process, err := os.FindProcess(pid); err == nil {
|
||||
if err := process.Signal(syscall.Signal(0)); err == nil {
|
||||
return fmt.Errorf("another instance is running (PID %d)", pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stale PID file, remove it
|
||||
_ = p.medium.Delete(p.path)
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if dir := filepath.Dir(p.path); dir != "." {
|
||||
if err := p.medium.EnsureDir(dir); err != nil {
|
||||
return fmt.Errorf("failed to create PID directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write current PID
|
||||
pid := os.Getpid()
|
||||
if err := p.medium.Write(p.path, strconv.Itoa(pid)); err != nil {
|
||||
return fmt.Errorf("failed to write PID file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Release removes the PID file.
|
||||
func (p *PIDFile) Release() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.medium.Delete(p.path)
|
||||
}
|
||||
|
||||
// Path returns the PID file path.
|
||||
func (p *PIDFile) Path() string {
|
||||
return p.path
|
||||
}
|
||||
|
||||
// --- Health Check Server ---
|
||||
|
||||
// HealthServer provides a minimal HTTP health check endpoint.
|
||||
type HealthServer struct {
|
||||
addr string
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
mu sync.Mutex
|
||||
ready bool
|
||||
checks []HealthCheck
|
||||
}
|
||||
|
||||
// HealthCheck is a function that returns nil if healthy.
|
||||
type HealthCheck func() error
|
||||
|
||||
// NewHealthServer creates a health check server.
|
||||
func NewHealthServer(addr string) *HealthServer {
|
||||
return &HealthServer{
|
||||
addr: addr,
|
||||
ready: true,
|
||||
}
|
||||
}
|
||||
|
||||
// AddCheck registers a health check function.
|
||||
func (h *HealthServer) AddCheck(check HealthCheck) {
|
||||
h.mu.Lock()
|
||||
h.checks = append(h.checks, check)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetReady sets the readiness status.
|
||||
func (h *HealthServer) SetReady(ready bool) {
|
||||
h.mu.Lock()
|
||||
h.ready = ready
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// Start begins serving health check endpoints.
|
||||
// Endpoints:
|
||||
// - /health - liveness probe (always 200 if server is up)
|
||||
// - /ready - readiness probe (200 if ready, 503 if not)
|
||||
func (h *HealthServer) Start() error {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
h.mu.Lock()
|
||||
checks := h.checks
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, check := range checks {
|
||||
if err := check(); err != nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = fmt.Fprintf(w, "unhealthy: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintln(w, "ok")
|
||||
})
|
||||
|
||||
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
|
||||
h.mu.Lock()
|
||||
ready := h.ready
|
||||
h.mu.Unlock()
|
||||
|
||||
if !ready {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = fmt.Fprintln(w, "not ready")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintln(w, "ready")
|
||||
})
|
||||
|
||||
listener, err := net.Listen("tcp", h.addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on %s: %w", h.addr, err)
|
||||
}
|
||||
|
||||
h.listener = listener
|
||||
h.server = &http.Server{Handler: mux}
|
||||
|
||||
go func() {
|
||||
if err := h.server.Serve(listener); err != http.ErrServerClosed {
|
||||
LogError(fmt.Sprintf("health server error: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the health server.
|
||||
func (h *HealthServer) Stop(ctx context.Context) error {
|
||||
if h.server == nil {
|
||||
return nil
|
||||
}
|
||||
return h.server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// Addr returns the actual address the server is listening on.
|
||||
// Useful when using port 0 for dynamic port assignment.
|
||||
func (h *HealthServer) Addr() string {
|
||||
if h.listener != nil {
|
||||
return h.listener.Addr().String()
|
||||
}
|
||||
return h.addr
|
||||
}
|
||||
|
||||
// --- Daemon Runner ---
|
||||
|
||||
// DaemonOptions configures daemon mode execution.
|
||||
type DaemonOptions struct {
|
||||
// Medium is the filesystem for PID file operations.
|
||||
// If nil, uses io.Local (filesystem).
|
||||
Medium io.Medium
|
||||
|
||||
// PIDFile path for single-instance enforcement.
|
||||
// Leave empty to skip PID file management.
|
||||
PIDFile string
|
||||
|
||||
// ShutdownTimeout is the maximum time to wait for graceful shutdown.
|
||||
// Default: 30 seconds.
|
||||
ShutdownTimeout time.Duration
|
||||
|
||||
// HealthAddr is the address for health check endpoints.
|
||||
// Example: ":8080", "127.0.0.1:9000"
|
||||
// Leave empty to disable health checks.
|
||||
HealthAddr string
|
||||
|
||||
// HealthChecks are additional health check functions.
|
||||
HealthChecks []HealthCheck
|
||||
|
||||
// OnReload is called when SIGHUP is received.
|
||||
// Use for config reloading. Leave nil to ignore SIGHUP.
|
||||
OnReload func() error
|
||||
}
|
||||
|
||||
// Daemon manages daemon lifecycle.
|
||||
type Daemon struct {
|
||||
opts DaemonOptions
|
||||
pid *PIDFile
|
||||
health *HealthServer
|
||||
reload chan struct{}
|
||||
running bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewDaemon creates a daemon runner with the given options.
|
||||
func NewDaemon(opts DaemonOptions) *Daemon {
|
||||
if opts.ShutdownTimeout == 0 {
|
||||
opts.ShutdownTimeout = 30 * time.Second
|
||||
}
|
||||
|
||||
d := &Daemon{
|
||||
opts: opts,
|
||||
reload: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
if opts.PIDFile != "" {
|
||||
d.pid = NewPIDFile(opts.Medium, opts.PIDFile)
|
||||
}
|
||||
|
||||
if opts.HealthAddr != "" {
|
||||
d.health = NewHealthServer(opts.HealthAddr)
|
||||
for _, check := range opts.HealthChecks {
|
||||
d.health.AddCheck(check)
|
||||
}
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// Start initialises the daemon (PID file, health server).
|
||||
// Call this after cli.Init().
|
||||
func (d *Daemon) Start() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.running {
|
||||
return fmt.Errorf("daemon already running")
|
||||
}
|
||||
|
||||
// Acquire PID file
|
||||
if d.pid != nil {
|
||||
if err := d.pid.Acquire(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Start health server
|
||||
if d.health != nil {
|
||||
if err := d.health.Start(); err != nil {
|
||||
if d.pid != nil {
|
||||
_ = d.pid.Release()
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
d.running = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run blocks until the context is cancelled or a signal is received.
|
||||
// Handles graceful shutdown with the configured timeout.
|
||||
func (d *Daemon) Run(ctx context.Context) error {
|
||||
d.mu.Lock()
|
||||
if !d.running {
|
||||
d.mu.Unlock()
|
||||
return fmt.Errorf("daemon not started - call Start() first")
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
// Wait for context cancellation (from signal handler)
|
||||
<-ctx.Done()
|
||||
|
||||
return d.Stop()
|
||||
}
|
||||
|
||||
// Stop performs graceful shutdown.
|
||||
func (d *Daemon) Stop() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if !d.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errs []error
|
||||
|
||||
// Create shutdown context with timeout
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Stop health server
|
||||
if d.health != nil {
|
||||
d.health.SetReady(false)
|
||||
if err := d.health.Stop(shutdownCtx); err != nil {
|
||||
errs = append(errs, fmt.Errorf("health server: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Release PID file
|
||||
if d.pid != nil {
|
||||
if err := d.pid.Release(); err != nil && !os.IsNotExist(err) {
|
||||
errs = append(errs, fmt.Errorf("pid file: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
d.running = false
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("shutdown errors: %v", errs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetReady sets the daemon readiness status for health checks.
|
||||
func (d *Daemon) SetReady(ready bool) {
|
||||
if d.health != nil {
|
||||
d.health.SetReady(ready)
|
||||
}
|
||||
}
|
||||
|
||||
// HealthAddr returns the health server address, or empty if disabled.
|
||||
func (d *Daemon) HealthAddr() string {
|
||||
if d.health != nil {
|
||||
return d.health.Addr()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- Convenience Functions ---
|
||||
|
||||
// Run blocks until context is cancelled or signal received.
|
||||
// Simple helper for daemon mode without advanced features.
|
||||
//
|
||||
// cli.Init(cli.Options{AppName: "myapp"})
|
||||
// defer cli.Shutdown()
|
||||
// cli.Run(cli.Context())
|
||||
func Run(ctx context.Context) error {
|
||||
mustInit()
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// RunWithTimeout wraps Run with a graceful shutdown timeout.
|
||||
// The returned function should be deferred to replace cli.Shutdown().
|
||||
//
|
||||
// cli.Init(cli.Options{AppName: "myapp"})
|
||||
// shutdown := cli.RunWithTimeout(30 * time.Second)
|
||||
// defer shutdown()
|
||||
// cli.Run(cli.Context())
|
||||
func RunWithTimeout(timeout time.Duration) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Create done channel for shutdown completion
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
Shutdown()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Clean shutdown
|
||||
case <-ctx.Done():
|
||||
// Timeout - force exit
|
||||
LogWarn("shutdown timeout exceeded, forcing exit")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDetectMode(t *testing.T) {
|
||||
t.Run("daemon mode from env", func(t *testing.T) {
|
||||
t.Setenv("CORE_DAEMON", "1")
|
||||
assert.Equal(t, ModeDaemon, DetectMode())
|
||||
})
|
||||
|
||||
t.Run("mode string", func(t *testing.T) {
|
||||
assert.Equal(t, "interactive", ModeInteractive.String())
|
||||
assert.Equal(t, "pipe", ModePipe.String())
|
||||
assert.Equal(t, "daemon", ModeDaemon.String())
|
||||
assert.Equal(t, "unknown", Mode(99).String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestPIDFile(t *testing.T) {
|
||||
t.Run("acquire and release", func(t *testing.T) {
|
||||
m := io.NewMockMedium()
|
||||
pidPath := "/tmp/test.pid"
|
||||
|
||||
pid := NewPIDFile(m, pidPath)
|
||||
|
||||
// Acquire should succeed
|
||||
err := pid.Acquire()
|
||||
require.NoError(t, err)
|
||||
|
||||
// File should exist with our PID
|
||||
data, err := m.Read(pidPath)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, data)
|
||||
|
||||
// Release should remove file
|
||||
err = pid.Release()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, m.Exists(pidPath))
|
||||
})
|
||||
|
||||
t.Run("stale pid file", func(t *testing.T) {
|
||||
m := io.NewMockMedium()
|
||||
pidPath := "/tmp/stale.pid"
|
||||
|
||||
// Write a stale PID (non-existent process)
|
||||
err := m.Write(pidPath, "999999999")
|
||||
require.NoError(t, err)
|
||||
|
||||
pid := NewPIDFile(m, pidPath)
|
||||
|
||||
// Should acquire successfully (stale PID removed)
|
||||
err = pid.Acquire()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = pid.Release()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("creates parent directory", func(t *testing.T) {
|
||||
m := io.NewMockMedium()
|
||||
pidPath := "/tmp/subdir/nested/test.pid"
|
||||
|
||||
pid := NewPIDFile(m, pidPath)
|
||||
|
||||
err := pid.Acquire()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, m.Exists(pidPath))
|
||||
|
||||
err = pid.Release()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("path getter", func(t *testing.T) {
|
||||
m := io.NewMockMedium()
|
||||
pid := NewPIDFile(m, "/tmp/test.pid")
|
||||
assert.Equal(t, "/tmp/test.pid", pid.Path())
|
||||
})
|
||||
}
|
||||
|
||||
func TestHealthServer(t *testing.T) {
|
||||
t.Run("health and ready endpoints", func(t *testing.T) {
|
||||
hs := NewHealthServer("127.0.0.1:0") // Random port
|
||||
|
||||
err := hs.Start()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = hs.Stop(context.Background()) }()
|
||||
|
||||
addr := hs.Addr()
|
||||
require.NotEmpty(t, addr)
|
||||
|
||||
// Health should be OK
|
||||
resp, err := http.Get("http://" + addr + "/health")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Ready should be OK by default
|
||||
resp, err = http.Get("http://" + addr + "/ready")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Set not ready
|
||||
hs.SetReady(false)
|
||||
|
||||
resp, err = http.Get("http://" + addr + "/ready")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("with health checks", func(t *testing.T) {
|
||||
hs := NewHealthServer("127.0.0.1:0")
|
||||
|
||||
healthy := true
|
||||
hs.AddCheck(func() error {
|
||||
if !healthy {
|
||||
return assert.AnError
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
err := hs.Start()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = hs.Stop(context.Background()) }()
|
||||
|
||||
addr := hs.Addr()
|
||||
|
||||
// Should be healthy
|
||||
resp, err := http.Get("http://" + addr + "/health")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Make unhealthy
|
||||
healthy = false
|
||||
|
||||
resp, err = http.Get("http://" + addr + "/health")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestDaemon(t *testing.T) {
|
||||
t.Run("start and stop", func(t *testing.T) {
|
||||
m := io.NewMockMedium()
|
||||
pidPath := "/tmp/test.pid"
|
||||
|
||||
d := NewDaemon(DaemonOptions{
|
||||
Medium: m,
|
||||
PIDFile: pidPath,
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
ShutdownTimeout: 5 * time.Second,
|
||||
})
|
||||
|
||||
err := d.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Health server should be running
|
||||
addr := d.HealthAddr()
|
||||
require.NotEmpty(t, addr)
|
||||
|
||||
resp, err := http.Get("http://" + addr + "/health")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Stop should succeed
|
||||
err = d.Stop()
|
||||
require.NoError(t, err)
|
||||
|
||||
// PID file should be removed
|
||||
assert.False(t, m.Exists(pidPath))
|
||||
})
|
||||
|
||||
t.Run("double start fails", func(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
})
|
||||
|
||||
err := d.Start()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = d.Stop() }()
|
||||
|
||||
err = d.Start()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already running")
|
||||
})
|
||||
|
||||
t.Run("run without start fails", func(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := d.Run(ctx)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not started")
|
||||
})
|
||||
|
||||
t.Run("set ready", func(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
})
|
||||
|
||||
err := d.Start()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = d.Stop() }()
|
||||
|
||||
addr := d.HealthAddr()
|
||||
|
||||
// Initially ready
|
||||
resp, _ := http.Get("http://" + addr + "/ready")
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Set not ready
|
||||
d.SetReady(false)
|
||||
|
||||
resp, _ = http.Get("http://" + addr + "/ready")
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("no health addr returns empty", func(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
assert.Empty(t, d.HealthAddr())
|
||||
})
|
||||
|
||||
t.Run("default shutdown timeout", func(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunWithTimeout(t *testing.T) {
|
||||
t.Run("creates shutdown function", func(t *testing.T) {
|
||||
// Just test that it returns a function
|
||||
shutdown := RunWithTimeout(100 * time.Millisecond)
|
||||
assert.NotNil(t, shutdown)
|
||||
})
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Error Creation (replace fmt.Errorf)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Err creates a new error from a format string.
|
||||
// This is a direct replacement for fmt.Errorf.
|
||||
func Err(format string, args ...any) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
|
||||
// Wrap wraps an error with a message.
|
||||
// Returns nil if err is nil.
|
||||
//
|
||||
// return cli.Wrap(err, "load config") // "load config: <original error>"
|
||||
func Wrap(err error, msg string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s: %w", msg, err)
|
||||
}
|
||||
|
||||
// WrapVerb wraps an error using i18n grammar for "Failed to verb subject".
|
||||
// Uses the i18n.ActionFailed function for proper grammar composition.
|
||||
// Returns nil if err is nil.
|
||||
//
|
||||
// return cli.WrapVerb(err, "load", "config") // "Failed to load config: <original error>"
|
||||
func WrapVerb(err error, verb, subject string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, subject)
|
||||
return fmt.Errorf("%s: %w", msg, err)
|
||||
}
|
||||
|
||||
// WrapAction wraps an error using i18n grammar for "Failed to verb".
|
||||
// Uses the i18n.ActionFailed function for proper grammar composition.
|
||||
// Returns nil if err is nil.
|
||||
//
|
||||
// return cli.WrapAction(err, "connect") // "Failed to connect: <original error>"
|
||||
func WrapAction(err error, verb string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, "")
|
||||
return fmt.Errorf("%s: %w", msg, err)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Error Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Is reports whether any error in err's tree matches target.
|
||||
// This is a re-export of errors.Is for convenience.
|
||||
func Is(err, target error) bool {
|
||||
return errors.Is(err, target)
|
||||
}
|
||||
|
||||
// As finds the first error in err's tree that matches target.
|
||||
// This is a re-export of errors.As for convenience.
|
||||
func As(err error, target any) bool {
|
||||
return errors.As(err, target)
|
||||
}
|
||||
|
||||
// Join returns an error that wraps the given errors.
|
||||
// This is a re-export of errors.Join for convenience.
|
||||
func Join(errs ...error) error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// ExitError represents an error that should cause the CLI to exit with a specific code.
|
||||
type ExitError struct {
|
||||
Code int
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ExitError) Error() string {
|
||||
if e.Err == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *ExitError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// Exit creates a new ExitError with the given code and error.
|
||||
// Use this to return an error from a command with a specific exit code.
|
||||
func Exit(code int, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &ExitError{Code: code, Err: err}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fatal Functions (Deprecated - return error from command instead)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Fatal prints an error message to stderr, logs it, and exits with code 1.
|
||||
//
|
||||
// Deprecated: return an error from the command instead.
|
||||
func Fatal(err error) {
|
||||
if err != nil {
|
||||
LogError("Fatal error", "err", err)
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Fatalf prints a formatted error message to stderr, logs it, and exits with code 1.
|
||||
//
|
||||
// Deprecated: return an error from the command instead.
|
||||
func Fatalf(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
LogError("Fatal error", "msg", msg)
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// FatalWrap prints a wrapped error message to stderr, logs it, and exits with code 1.
|
||||
// Does nothing if err is nil.
|
||||
//
|
||||
// Deprecated: return an error from the command instead.
|
||||
//
|
||||
// cli.FatalWrap(err, "load config") // Prints "✗ load config: <error>" and exits
|
||||
func FatalWrap(err error, msg string) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
LogError("Fatal error", "msg", msg, "err", err)
|
||||
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// FatalWrapVerb prints a wrapped error using i18n grammar to stderr, logs it, and exits with code 1.
|
||||
// Does nothing if err is nil.
|
||||
//
|
||||
// Deprecated: return an error from the command instead.
|
||||
//
|
||||
// cli.FatalWrapVerb(err, "load", "config") // Prints "✗ Failed to load config: <error>" and exits
|
||||
func FatalWrapVerb(err error, verb, subject string) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, subject)
|
||||
LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject)
|
||||
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// GlyphTheme defines which symbols to use.
|
||||
type GlyphTheme int
|
||||
|
||||
const (
|
||||
// ThemeUnicode uses standard Unicode symbols.
|
||||
ThemeUnicode GlyphTheme = iota
|
||||
// ThemeEmoji uses Emoji symbols.
|
||||
ThemeEmoji
|
||||
// ThemeASCII uses ASCII fallback symbols.
|
||||
ThemeASCII
|
||||
)
|
||||
|
||||
var currentTheme = ThemeUnicode
|
||||
|
||||
// UseUnicode switches the glyph theme to Unicode.
|
||||
func UseUnicode() { currentTheme = ThemeUnicode }
|
||||
|
||||
// UseEmoji switches the glyph theme to Emoji.
|
||||
func UseEmoji() { currentTheme = ThemeEmoji }
|
||||
|
||||
// UseASCII switches the glyph theme to ASCII and disables colors.
|
||||
func UseASCII() {
|
||||
currentTheme = ThemeASCII
|
||||
SetColorEnabled(false)
|
||||
}
|
||||
|
||||
func glyphMap() map[string]string {
|
||||
switch currentTheme {
|
||||
case ThemeEmoji:
|
||||
return glyphMapEmoji
|
||||
case ThemeASCII:
|
||||
return glyphMapASCII
|
||||
default:
|
||||
return glyphMapUnicode
|
||||
}
|
||||
}
|
||||
|
||||
// Glyph converts a shortcode (e.g. ":check:") to its symbol based on the current theme.
|
||||
func Glyph(code string) string {
|
||||
if sym, ok := glyphMap()[code]; ok {
|
||||
return sym
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func compileGlyphs(x string) string {
|
||||
if x == "" {
|
||||
return ""
|
||||
}
|
||||
input := bytes.NewBufferString(x)
|
||||
output := bytes.NewBufferString("")
|
||||
|
||||
for {
|
||||
r, _, err := input.ReadRune()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if r == ':' {
|
||||
output.WriteString(replaceGlyph(input))
|
||||
} else {
|
||||
output.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func replaceGlyph(input *bytes.Buffer) string {
|
||||
code := bytes.NewBufferString(":")
|
||||
for {
|
||||
r, _, err := input.ReadRune()
|
||||
if err != nil {
|
||||
return code.String()
|
||||
}
|
||||
if r == ':' && code.Len() == 1 {
|
||||
return code.String() + replaceGlyph(input)
|
||||
}
|
||||
code.WriteRune(r)
|
||||
if unicode.IsSpace(r) {
|
||||
return code.String()
|
||||
}
|
||||
if r == ':' {
|
||||
return Glyph(code.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
package cli
|
||||
|
||||
var glyphMapUnicode = map[string]string{
|
||||
":check:": "✓", ":cross:": "✗", ":warn:": "⚠", ":info:": "ℹ",
|
||||
":question:": "?", ":skip:": "○", ":dot:": "●", ":circle:": "◯",
|
||||
":arrow_right:": "→", ":arrow_left:": "←", ":arrow_up:": "↑", ":arrow_down:": "↓",
|
||||
":pointer:": "▶", ":bullet:": "•", ":dash:": "─", ":pipe:": "│",
|
||||
":corner:": "└", ":tee:": "├", ":pending:": "…", ":spinner:": "⠋",
|
||||
}
|
||||
|
||||
var glyphMapEmoji = map[string]string{
|
||||
":check:": "✅", ":cross:": "❌", ":warn:": "⚠️", ":info:": "ℹ️",
|
||||
":question:": "❓", ":skip:": "⏭️", ":dot:": "🔵", ":circle:": "⚪",
|
||||
":arrow_right:": "➡️", ":arrow_left:": "⬅️", ":arrow_up:": "⬆️", ":arrow_down:": "⬇️",
|
||||
":pointer:": "▶️", ":bullet:": "•", ":dash:": "─", ":pipe:": "│",
|
||||
":corner:": "└", ":tee:": "├", ":pending:": "⏳", ":spinner:": "🔄",
|
||||
}
|
||||
|
||||
var glyphMapASCII = map[string]string{
|
||||
":check:": "[OK]", ":cross:": "[FAIL]", ":warn:": "[WARN]", ":info:": "[INFO]",
|
||||
":question:": "[?]", ":skip:": "[SKIP]", ":dot:": "[*]", ":circle:": "[ ]",
|
||||
":arrow_right:": "->", ":arrow_left:": "<-", ":arrow_up:": "^", ":arrow_down:": "v",
|
||||
":pointer:": ">", ":bullet:": "*", ":dash:": "-", ":pipe:": "|",
|
||||
":corner:": "`", ":tee:": "+", ":pending:": "...", ":spinner:": "-",
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGlyph(t *testing.T) {
|
||||
UseUnicode()
|
||||
if Glyph(":check:") != "✓" {
|
||||
t.Errorf("Expected ✓, got %s", Glyph(":check:"))
|
||||
}
|
||||
|
||||
UseASCII()
|
||||
if Glyph(":check:") != "[OK]" {
|
||||
t.Errorf("Expected [OK], got %s", Glyph(":check:"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGlyphs(t *testing.T) {
|
||||
UseUnicode()
|
||||
got := compileGlyphs("Status: :check:")
|
||||
if got != "Status: ✓" {
|
||||
t.Errorf("Expected Status: ✓, got %s", got)
|
||||
}
|
||||
}
|
||||
170
pkg/cli/i18n.go
170
pkg/cli/i18n.go
|
|
@ -1,170 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// I18nService wraps i18n as a Core service.
|
||||
type I18nService struct {
|
||||
*framework.ServiceRuntime[I18nOptions]
|
||||
svc *i18n.Service
|
||||
|
||||
// Collect mode state
|
||||
missingKeys []i18n.MissingKey
|
||||
missingKeysMu sync.Mutex
|
||||
}
|
||||
|
||||
// I18nOptions configures the i18n service.
|
||||
type I18nOptions struct {
|
||||
// Language overrides auto-detection (e.g., "en-GB", "de")
|
||||
Language string
|
||||
// Mode sets the translation mode (Normal, Strict, Collect)
|
||||
Mode i18n.Mode
|
||||
}
|
||||
|
||||
// NewI18nService creates an i18n service factory.
|
||||
func NewI18nService(opts I18nOptions) func(*framework.Core) (any, error) {
|
||||
return func(c *framework.Core) (any, error) {
|
||||
svc, err := i18n.New()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if opts.Language != "" {
|
||||
_ = svc.SetLanguage(opts.Language)
|
||||
}
|
||||
|
||||
// Set mode if specified
|
||||
svc.SetMode(opts.Mode)
|
||||
|
||||
// Set as global default so i18n.T() works everywhere
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
return &I18nService{
|
||||
ServiceRuntime: framework.NewServiceRuntime(c, opts),
|
||||
svc: svc,
|
||||
missingKeys: make([]i18n.MissingKey, 0),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// OnStartup initialises the i18n service.
|
||||
func (s *I18nService) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
|
||||
// Register action handler for collect mode
|
||||
if s.svc.Mode() == i18n.ModeCollect {
|
||||
i18n.OnMissingKey(s.handleMissingKey)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleMissingKey accumulates missing keys in collect mode.
|
||||
func (s *I18nService) handleMissingKey(mk i18n.MissingKey) {
|
||||
s.missingKeysMu.Lock()
|
||||
defer s.missingKeysMu.Unlock()
|
||||
s.missingKeys = append(s.missingKeys, mk)
|
||||
}
|
||||
|
||||
// MissingKeys returns all missing keys collected in collect mode.
|
||||
// Call this at the end of a QA session to report missing translations.
|
||||
func (s *I18nService) MissingKeys() []i18n.MissingKey {
|
||||
s.missingKeysMu.Lock()
|
||||
defer s.missingKeysMu.Unlock()
|
||||
result := make([]i18n.MissingKey, len(s.missingKeys))
|
||||
copy(result, s.missingKeys)
|
||||
return result
|
||||
}
|
||||
|
||||
// ClearMissingKeys resets the collected missing keys.
|
||||
func (s *I18nService) ClearMissingKeys() {
|
||||
s.missingKeysMu.Lock()
|
||||
defer s.missingKeysMu.Unlock()
|
||||
s.missingKeys = s.missingKeys[:0]
|
||||
}
|
||||
|
||||
// SetMode changes the translation mode.
|
||||
func (s *I18nService) SetMode(mode i18n.Mode) {
|
||||
s.svc.SetMode(mode)
|
||||
|
||||
// Update action handler registration
|
||||
if mode == i18n.ModeCollect {
|
||||
i18n.OnMissingKey(s.handleMissingKey)
|
||||
} else {
|
||||
i18n.OnMissingKey(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Mode returns the current translation mode.
|
||||
func (s *I18nService) Mode() i18n.Mode {
|
||||
return s.svc.Mode()
|
||||
}
|
||||
|
||||
// Queries for i18n service
|
||||
|
||||
// QueryTranslate requests a translation.
|
||||
type QueryTranslate struct {
|
||||
Key string
|
||||
Args map[string]any
|
||||
}
|
||||
|
||||
func (s *I18nService) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) {
|
||||
switch m := q.(type) {
|
||||
case QueryTranslate:
|
||||
return s.svc.T(m.Key, m.Args), true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// T translates a key with optional arguments.
|
||||
func (s *I18nService) T(key string, args ...map[string]any) string {
|
||||
if len(args) > 0 {
|
||||
return s.svc.T(key, args[0])
|
||||
}
|
||||
return s.svc.T(key)
|
||||
}
|
||||
|
||||
// SetLanguage changes the current language.
|
||||
func (s *I18nService) SetLanguage(lang string) {
|
||||
_ = s.svc.SetLanguage(lang)
|
||||
}
|
||||
|
||||
// Language returns the current language.
|
||||
func (s *I18nService) Language() string {
|
||||
return s.svc.Language()
|
||||
}
|
||||
|
||||
// AvailableLanguages returns all available languages.
|
||||
func (s *I18nService) AvailableLanguages() []string {
|
||||
return s.svc.AvailableLanguages()
|
||||
}
|
||||
|
||||
// --- Package-level convenience ---
|
||||
|
||||
// T translates a key using the CLI's i18n service.
|
||||
// Falls back to the global i18n.T if CLI not initialised.
|
||||
func T(key string, args ...map[string]any) string {
|
||||
if instance == nil {
|
||||
// CLI not initialised, use global i18n
|
||||
if len(args) > 0 {
|
||||
return i18n.T(key, args[0])
|
||||
}
|
||||
return i18n.T(key)
|
||||
}
|
||||
|
||||
svc, err := framework.ServiceFor[*I18nService](instance.core, "i18n")
|
||||
if err != nil {
|
||||
// i18n service not registered, use global
|
||||
if len(args) > 0 {
|
||||
return i18n.T(key, args[0])
|
||||
}
|
||||
return i18n.T(key)
|
||||
}
|
||||
|
||||
return svc.T(key, args...)
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Region represents one of the 5 HLCRF regions.
|
||||
type Region rune
|
||||
|
||||
const (
|
||||
// RegionHeader is the top region of the layout.
|
||||
RegionHeader Region = 'H'
|
||||
// RegionLeft is the left sidebar region.
|
||||
RegionLeft Region = 'L'
|
||||
// RegionContent is the main content region.
|
||||
RegionContent Region = 'C'
|
||||
// RegionRight is the right sidebar region.
|
||||
RegionRight Region = 'R'
|
||||
// RegionFooter is the bottom region of the layout.
|
||||
RegionFooter Region = 'F'
|
||||
)
|
||||
|
||||
// Composite represents an HLCRF layout node.
|
||||
type Composite struct {
|
||||
variant string
|
||||
path string
|
||||
regions map[Region]*Slot
|
||||
parent *Composite
|
||||
}
|
||||
|
||||
// Slot holds content for a region.
|
||||
type Slot struct {
|
||||
region Region
|
||||
path string
|
||||
blocks []Renderable
|
||||
child *Composite
|
||||
}
|
||||
|
||||
// Renderable is anything that can be rendered to terminal.
|
||||
type Renderable interface {
|
||||
Render() string
|
||||
}
|
||||
|
||||
// StringBlock is a simple string that implements Renderable.
|
||||
type StringBlock string
|
||||
|
||||
// Render returns the string content.
|
||||
func (s StringBlock) Render() string { return string(s) }
|
||||
|
||||
// Layout creates a new layout from a variant string.
|
||||
func Layout(variant string) *Composite {
|
||||
c, err := ParseVariant(variant)
|
||||
if err != nil {
|
||||
return &Composite{variant: variant, regions: make(map[Region]*Slot)}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ParseVariant parses a variant string like "H[LC]C[HCF]F".
|
||||
func ParseVariant(variant string) (*Composite, error) {
|
||||
c := &Composite{
|
||||
variant: variant,
|
||||
path: "",
|
||||
regions: make(map[Region]*Slot),
|
||||
}
|
||||
|
||||
i := 0
|
||||
for i < len(variant) {
|
||||
r := Region(variant[i])
|
||||
if !isValidRegion(r) {
|
||||
return nil, fmt.Errorf("invalid region: %c", r)
|
||||
}
|
||||
|
||||
slot := &Slot{region: r, path: string(r)}
|
||||
c.regions[r] = slot
|
||||
i++
|
||||
|
||||
if i < len(variant) && variant[i] == '[' {
|
||||
end := findMatchingBracket(variant, i)
|
||||
if end == -1 {
|
||||
return nil, fmt.Errorf("unmatched bracket at %d", i)
|
||||
}
|
||||
nested, err := ParseVariant(variant[i+1 : end])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nested.path = string(r) + "-"
|
||||
nested.parent = c
|
||||
slot.child = nested
|
||||
i = end + 1
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func isValidRegion(r Region) bool {
|
||||
return r == 'H' || r == 'L' || r == 'C' || r == 'R' || r == 'F'
|
||||
}
|
||||
|
||||
func findMatchingBracket(s string, start int) int {
|
||||
depth := 0
|
||||
for i := start; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case '[':
|
||||
depth++
|
||||
case ']':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// H adds content to Header region.
|
||||
func (c *Composite) H(items ...any) *Composite { c.addToRegion(RegionHeader, items...); return c }
|
||||
|
||||
// L adds content to Left region.
|
||||
func (c *Composite) L(items ...any) *Composite { c.addToRegion(RegionLeft, items...); return c }
|
||||
|
||||
// C adds content to Content region.
|
||||
func (c *Composite) C(items ...any) *Composite { c.addToRegion(RegionContent, items...); return c }
|
||||
|
||||
// R adds content to Right region.
|
||||
func (c *Composite) R(items ...any) *Composite { c.addToRegion(RegionRight, items...); return c }
|
||||
|
||||
// F adds content to Footer region.
|
||||
func (c *Composite) F(items ...any) *Composite { c.addToRegion(RegionFooter, items...); return c }
|
||||
|
||||
func (c *Composite) addToRegion(r Region, items ...any) {
|
||||
slot, ok := c.regions[r]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range items {
|
||||
slot.blocks = append(slot.blocks, toRenderable(item))
|
||||
}
|
||||
}
|
||||
|
||||
func toRenderable(item any) Renderable {
|
||||
switch v := item.(type) {
|
||||
case Renderable:
|
||||
return v
|
||||
case string:
|
||||
return StringBlock(v)
|
||||
default:
|
||||
return StringBlock(fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseVariant(t *testing.T) {
|
||||
c, err := ParseVariant("H[LC]F")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
if _, ok := c.regions[RegionHeader]; !ok {
|
||||
t.Error("Expected Header region")
|
||||
}
|
||||
if _, ok := c.regions[RegionFooter]; !ok {
|
||||
t.Error("Expected Footer region")
|
||||
}
|
||||
|
||||
hSlot := c.regions[RegionHeader]
|
||||
if hSlot.child == nil {
|
||||
t.Error("Header should have child layout")
|
||||
} else {
|
||||
if _, ok := hSlot.child.regions[RegionLeft]; !ok {
|
||||
t.Error("Child should have Left region")
|
||||
}
|
||||
}
|
||||
}
|
||||
144
pkg/cli/list.go
144
pkg/cli/list.go
|
|
@ -1,144 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// listModel is the internal bubbletea model for interactive list selection.
|
||||
type listModel struct {
|
||||
items []string
|
||||
cursor int
|
||||
title string
|
||||
selected bool
|
||||
quitted bool
|
||||
}
|
||||
|
||||
func newListModel(items []string, title string) *listModel {
|
||||
return &listModel{
|
||||
items: items,
|
||||
title: title,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *listModel) moveDown() {
|
||||
m.cursor++
|
||||
if m.cursor >= len(m.items) {
|
||||
m.cursor = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (m *listModel) moveUp() {
|
||||
m.cursor--
|
||||
if m.cursor < 0 {
|
||||
m.cursor = len(m.items) - 1
|
||||
}
|
||||
}
|
||||
|
||||
func (m *listModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyUp, tea.KeyShiftTab:
|
||||
m.moveUp()
|
||||
case tea.KeyDown, tea.KeyTab:
|
||||
m.moveDown()
|
||||
case tea.KeyEnter:
|
||||
m.selected = true
|
||||
return m, tea.Quit
|
||||
case tea.KeyEscape, tea.KeyCtrlC:
|
||||
m.quitted = true
|
||||
return m, tea.Quit
|
||||
case tea.KeyRunes:
|
||||
switch string(msg.Runes) {
|
||||
case "j":
|
||||
m.moveDown()
|
||||
case "k":
|
||||
m.moveUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *listModel) View() string {
|
||||
var sb strings.Builder
|
||||
|
||||
if m.title != "" {
|
||||
sb.WriteString(BoldStyle.Render(m.title) + "\n\n")
|
||||
}
|
||||
|
||||
for i, item := range m.items {
|
||||
cursor := " "
|
||||
style := DimStyle
|
||||
if i == m.cursor {
|
||||
cursor = AccentStyle.Render(Glyph(":pointer:")) + " "
|
||||
style = BoldStyle
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s%s\n", cursor, style.Render(item)))
|
||||
}
|
||||
|
||||
sb.WriteString("\n" + DimStyle.Render("↑/↓ navigate • enter select • esc cancel"))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ListOption configures InteractiveList behaviour.
|
||||
type ListOption func(*listConfig)
|
||||
|
||||
type listConfig struct {
|
||||
height int
|
||||
}
|
||||
|
||||
// WithListHeight sets the visible height of the list (number of items shown).
|
||||
func WithListHeight(n int) ListOption {
|
||||
return func(c *listConfig) {
|
||||
c.height = n
|
||||
}
|
||||
}
|
||||
|
||||
// InteractiveList presents an interactive scrollable list and returns the
|
||||
// selected item's index and value. Returns -1 and empty string if cancelled.
|
||||
//
|
||||
// Falls back to numbered Select() when stdin is not a terminal (e.g. piped input).
|
||||
//
|
||||
// idx, value := cli.InteractiveList("Pick a repo:", repos)
|
||||
func InteractiveList(title string, items []string, opts ...ListOption) (int, string) {
|
||||
if len(items) == 0 {
|
||||
return -1, ""
|
||||
}
|
||||
|
||||
// Fall back to simple Select if not a terminal
|
||||
if !term.IsTerminal(0) {
|
||||
result, err := Select(title, items)
|
||||
if err != nil {
|
||||
return -1, ""
|
||||
}
|
||||
for i, item := range items {
|
||||
if item == result {
|
||||
return i, result
|
||||
}
|
||||
}
|
||||
return -1, ""
|
||||
}
|
||||
|
||||
m := newListModel(items, title)
|
||||
p := tea.NewProgram(m)
|
||||
finalModel, err := p.Run()
|
||||
if err != nil {
|
||||
return -1, ""
|
||||
}
|
||||
|
||||
final := finalModel.(*listModel)
|
||||
if final.quitted || !final.selected {
|
||||
return -1, ""
|
||||
}
|
||||
return final.cursor, final.items[final.cursor]
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestListModel_Good_Create(t *testing.T) {
|
||||
items := []string{"alpha", "beta", "gamma"}
|
||||
m := newListModel(items, "Pick one:")
|
||||
assert.Equal(t, 3, len(m.items))
|
||||
assert.Equal(t, 0, m.cursor)
|
||||
assert.Equal(t, "Pick one:", m.title)
|
||||
}
|
||||
|
||||
func TestListModel_Good_MoveDown(t *testing.T) {
|
||||
m := newListModel([]string{"a", "b", "c"}, "")
|
||||
m.moveDown()
|
||||
assert.Equal(t, 1, m.cursor)
|
||||
m.moveDown()
|
||||
assert.Equal(t, 2, m.cursor)
|
||||
}
|
||||
|
||||
func TestListModel_Good_MoveUp(t *testing.T) {
|
||||
m := newListModel([]string{"a", "b", "c"}, "")
|
||||
m.moveDown()
|
||||
m.moveDown()
|
||||
m.moveUp()
|
||||
assert.Equal(t, 1, m.cursor)
|
||||
}
|
||||
|
||||
func TestListModel_Good_WrapAround(t *testing.T) {
|
||||
m := newListModel([]string{"a", "b", "c"}, "")
|
||||
m.moveUp() // Should wrap to bottom
|
||||
assert.Equal(t, 2, m.cursor)
|
||||
}
|
||||
|
||||
func TestListModel_Good_View(t *testing.T) {
|
||||
m := newListModel([]string{"alpha", "beta"}, "Choose:")
|
||||
view := m.View()
|
||||
assert.Contains(t, view, "Choose:")
|
||||
assert.Contains(t, view, "alpha")
|
||||
assert.Contains(t, view, "beta")
|
||||
}
|
||||
|
||||
func TestListModel_Good_Selected(t *testing.T) {
|
||||
m := newListModel([]string{"a", "b", "c"}, "")
|
||||
m.moveDown()
|
||||
m.selected = true
|
||||
assert.Equal(t, "b", m.items[m.cursor])
|
||||
}
|
||||
115
pkg/cli/log.go
115
pkg/cli/log.go
|
|
@ -1,115 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
|
||||
// LogLevel aliases for backwards compatibility.
|
||||
type LogLevel = log.Level
|
||||
|
||||
// Log level constants aliased from the log package.
|
||||
const (
|
||||
// LogLevelQuiet suppresses all output.
|
||||
LogLevelQuiet = log.LevelQuiet
|
||||
// LogLevelError shows only error messages.
|
||||
LogLevelError = log.LevelError
|
||||
// LogLevelWarn shows warnings and errors.
|
||||
LogLevelWarn = log.LevelWarn
|
||||
// LogLevelInfo shows info, warnings, and errors.
|
||||
LogLevelInfo = log.LevelInfo
|
||||
// LogLevelDebug shows all messages including debug.
|
||||
LogLevelDebug = log.LevelDebug
|
||||
)
|
||||
|
||||
// LogService wraps log.Service with CLI styling.
|
||||
type LogService struct {
|
||||
*log.Service
|
||||
}
|
||||
|
||||
// LogOptions configures the log service.
|
||||
type LogOptions = log.Options
|
||||
|
||||
// NewLogService creates a log service factory with CLI styling.
|
||||
func NewLogService(opts LogOptions) func(*framework.Core) (any, error) {
|
||||
return func(c *framework.Core) (any, error) {
|
||||
// Create the underlying service
|
||||
factory := log.NewService(opts)
|
||||
svc, err := factory(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logSvc := svc.(*log.Service)
|
||||
|
||||
// Apply CLI styles
|
||||
logSvc.StyleTimestamp = func(s string) string { return DimStyle.Render(s) }
|
||||
logSvc.StyleDebug = func(s string) string { return DimStyle.Render(s) }
|
||||
logSvc.StyleInfo = func(s string) string { return InfoStyle.Render(s) }
|
||||
logSvc.StyleWarn = func(s string) string { return WarningStyle.Render(s) }
|
||||
logSvc.StyleError = func(s string) string { return ErrorStyle.Render(s) }
|
||||
logSvc.StyleSecurity = func(s string) string { return SecurityStyle.Render(s) }
|
||||
|
||||
return &LogService{Service: logSvc}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// --- Package-level convenience ---
|
||||
|
||||
// Log returns the CLI's log service, or nil if not available.
|
||||
func Log() *LogService {
|
||||
if instance == nil {
|
||||
return nil
|
||||
}
|
||||
svc, err := framework.ServiceFor[*LogService](instance.core, "log")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// LogDebug logs a debug message with optional key-value pairs if log service is available.
|
||||
func LogDebug(msg string, keyvals ...any) {
|
||||
if l := Log(); l != nil {
|
||||
l.Debug(msg, keyvals...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogInfo logs an info message with optional key-value pairs if log service is available.
|
||||
func LogInfo(msg string, keyvals ...any) {
|
||||
if l := Log(); l != nil {
|
||||
l.Info(msg, keyvals...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogWarn logs a warning message with optional key-value pairs if log service is available.
|
||||
func LogWarn(msg string, keyvals ...any) {
|
||||
if l := Log(); l != nil {
|
||||
l.Warn(msg, keyvals...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogError logs an error message with optional key-value pairs if log service is available.
|
||||
func LogError(msg string, keyvals ...any) {
|
||||
if l := Log(); l != nil {
|
||||
l.Error(msg, keyvals...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogSecurity logs a security message if log service is available.
|
||||
func LogSecurity(msg string, keyvals ...any) {
|
||||
if l := Log(); l != nil {
|
||||
// Ensure user context is included if not already present
|
||||
hasUser := false
|
||||
for i := 0; i < len(keyvals); i += 2 {
|
||||
if keyvals[i] == "user" {
|
||||
hasUser = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasUser {
|
||||
keyvals = append(keyvals, "user", log.Username())
|
||||
}
|
||||
l.Security(msg, keyvals...)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// Blank prints an empty line.
|
||||
func Blank() {
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Echo translates a key via i18n.T and prints with newline.
|
||||
// No automatic styling - use Success/Error/Warn/Info for styled output.
|
||||
func Echo(key string, args ...any) {
|
||||
fmt.Println(i18n.T(key, args...))
|
||||
}
|
||||
|
||||
// Print outputs formatted text (no newline).
|
||||
// Glyph shortcodes like :check: are converted.
|
||||
func Print(format string, args ...any) {
|
||||
fmt.Print(compileGlyphs(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
// Println outputs formatted text with newline.
|
||||
// Glyph shortcodes like :check: are converted.
|
||||
func Println(format string, args ...any) {
|
||||
fmt.Println(compileGlyphs(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
// Text prints arguments like fmt.Println, but handling glyphs.
|
||||
func Text(args ...any) {
|
||||
fmt.Println(compileGlyphs(fmt.Sprint(args...)))
|
||||
}
|
||||
|
||||
// Success prints a success message with checkmark (green).
|
||||
func Success(msg string) {
|
||||
fmt.Println(SuccessStyle.Render(Glyph(":check:") + " " + msg))
|
||||
}
|
||||
|
||||
// Successf prints a formatted success message.
|
||||
func Successf(format string, args ...any) {
|
||||
Success(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Error prints an error message with cross (red) to stderr and logs it.
|
||||
func Error(msg string) {
|
||||
LogError(msg)
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
||||
}
|
||||
|
||||
// Errorf prints a formatted error message to stderr and logs it.
|
||||
func Errorf(format string, args ...any) {
|
||||
Error(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// ErrorWrap prints a wrapped error message to stderr and logs it.
|
||||
func ErrorWrap(err error, msg string) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
Error(fmt.Sprintf("%s: %v", msg, err))
|
||||
}
|
||||
|
||||
// ErrorWrapVerb prints a wrapped error using i18n grammar to stderr and logs it.
|
||||
func ErrorWrapVerb(err error, verb, subject string) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, subject)
|
||||
Error(fmt.Sprintf("%s: %v", msg, err))
|
||||
}
|
||||
|
||||
// ErrorWrapAction prints a wrapped error using i18n grammar to stderr and logs it.
|
||||
func ErrorWrapAction(err error, verb string) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, "")
|
||||
Error(fmt.Sprintf("%s: %v", msg, err))
|
||||
}
|
||||
|
||||
// Warn prints a warning message with warning symbol (amber) to stderr and logs it.
|
||||
func Warn(msg string) {
|
||||
LogWarn(msg)
|
||||
fmt.Fprintln(os.Stderr, WarningStyle.Render(Glyph(":warn:")+" "+msg))
|
||||
}
|
||||
|
||||
// Warnf prints a formatted warning message to stderr and logs it.
|
||||
func Warnf(format string, args ...any) {
|
||||
Warn(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Info prints an info message with info symbol (blue).
|
||||
func Info(msg string) {
|
||||
fmt.Println(InfoStyle.Render(Glyph(":info:") + " " + msg))
|
||||
}
|
||||
|
||||
// Infof prints a formatted info message.
|
||||
func Infof(format string, args ...any) {
|
||||
Info(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Dim prints dimmed text.
|
||||
func Dim(msg string) {
|
||||
fmt.Println(DimStyle.Render(msg))
|
||||
}
|
||||
|
||||
// Progress prints a progress indicator that overwrites the current line.
|
||||
// Uses i18n.Progress for gerund form ("Checking...").
|
||||
func Progress(verb string, current, total int, item ...string) {
|
||||
msg := i18n.Progress(verb)
|
||||
if len(item) > 0 && item[0] != "" {
|
||||
fmt.Printf("\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, item[0])
|
||||
} else {
|
||||
fmt.Printf("\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total)
|
||||
}
|
||||
}
|
||||
|
||||
// ProgressDone clears the progress line.
|
||||
func ProgressDone() {
|
||||
fmt.Print("\033[2K\r")
|
||||
}
|
||||
|
||||
// Label prints a "Label: value" line.
|
||||
func Label(word, value string) {
|
||||
fmt.Printf("%s %s\n", KeyStyle.Render(i18n.Label(word)), value)
|
||||
}
|
||||
|
||||
// Scanln reads from stdin.
|
||||
func Scanln(a ...any) (int, error) {
|
||||
return fmt.Scanln(a...)
|
||||
}
|
||||
|
||||
// Task prints a task header: "[label] message"
|
||||
//
|
||||
// cli.Task("php", "Running tests...") // [php] Running tests...
|
||||
// cli.Task("go", i18n.Progress("build")) // [go] Building...
|
||||
func Task(label, message string) {
|
||||
fmt.Printf("%s %s\n\n", DimStyle.Render("["+label+"]"), message)
|
||||
}
|
||||
|
||||
// Section prints a section header: "── SECTION ──"
|
||||
//
|
||||
// cli.Section("audit") // ── AUDIT ──
|
||||
func Section(name string) {
|
||||
header := "── " + strings.ToUpper(name) + " ──"
|
||||
fmt.Println(AccentStyle.Render(header))
|
||||
}
|
||||
|
||||
// Hint prints a labelled hint: "label: message"
|
||||
//
|
||||
// cli.Hint("install", "composer require vimeo/psalm")
|
||||
// cli.Hint("fix", "core php fmt --fix")
|
||||
func Hint(label, message string) {
|
||||
fmt.Printf(" %s %s\n", DimStyle.Render(label+":"), message)
|
||||
}
|
||||
|
||||
// Severity prints a severity-styled message.
|
||||
//
|
||||
// cli.Severity("critical", "SQL injection") // red, bold
|
||||
// cli.Severity("high", "XSS vulnerability") // orange
|
||||
// cli.Severity("medium", "Missing CSRF") // amber
|
||||
// cli.Severity("low", "Debug enabled") // gray
|
||||
func Severity(level, message string) {
|
||||
var style *AnsiStyle
|
||||
switch strings.ToLower(level) {
|
||||
case "critical":
|
||||
style = NewStyle().Bold().Foreground(ColourRed500)
|
||||
case "high":
|
||||
style = NewStyle().Bold().Foreground(ColourOrange500)
|
||||
case "medium":
|
||||
style = NewStyle().Foreground(ColourAmber500)
|
||||
case "low":
|
||||
style = NewStyle().Foreground(ColourGray500)
|
||||
default:
|
||||
style = DimStyle
|
||||
}
|
||||
fmt.Printf(" %s %s\n", style.Render("["+level+"]"), message)
|
||||
}
|
||||
|
||||
// Result prints a result line: "✓ message" or "✗ message"
|
||||
//
|
||||
// cli.Result(passed, "All tests passed")
|
||||
// cli.Result(false, "3 tests failed")
|
||||
func Result(passed bool, message string) {
|
||||
if passed {
|
||||
Success(message)
|
||||
} else {
|
||||
Error(message)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func captureOutput(f func()) string {
|
||||
oldOut := os.Stdout
|
||||
oldErr := os.Stderr
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
os.Stderr = w
|
||||
|
||||
f()
|
||||
|
||||
_ = w.Close()
|
||||
os.Stdout = oldOut
|
||||
os.Stderr = oldErr
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = io.Copy(&buf, r)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestSemanticOutput(t *testing.T) {
|
||||
UseASCII()
|
||||
|
||||
// Test Success
|
||||
out := captureOutput(func() {
|
||||
Success("done")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Success output empty")
|
||||
}
|
||||
|
||||
// Test Error
|
||||
out = captureOutput(func() {
|
||||
Error("fail")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Error output empty")
|
||||
}
|
||||
|
||||
// Test Warn
|
||||
out = captureOutput(func() {
|
||||
Warn("warn")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Warn output empty")
|
||||
}
|
||||
|
||||
// Test Info
|
||||
out = captureOutput(func() {
|
||||
Info("info")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Info output empty")
|
||||
}
|
||||
|
||||
// Test Task
|
||||
out = captureOutput(func() {
|
||||
Task("task", "msg")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Task output empty")
|
||||
}
|
||||
|
||||
// Test Section
|
||||
out = captureOutput(func() {
|
||||
Section("section")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Section output empty")
|
||||
}
|
||||
|
||||
// Test Hint
|
||||
out = captureOutput(func() {
|
||||
Hint("hint", "msg")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Hint output empty")
|
||||
}
|
||||
|
||||
// Test Result
|
||||
out = captureOutput(func() {
|
||||
Result(true, "pass")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Result(true) output empty")
|
||||
}
|
||||
|
||||
out = captureOutput(func() {
|
||||
Result(false, "fail")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Result(false) output empty")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ProgressHandle controls a progress bar.
|
||||
type ProgressHandle struct {
|
||||
mu sync.Mutex
|
||||
current int
|
||||
total int
|
||||
message string
|
||||
width int
|
||||
}
|
||||
|
||||
// NewProgressBar creates a new progress bar with the given total.
|
||||
func NewProgressBar(total int) *ProgressHandle {
|
||||
return &ProgressHandle{
|
||||
total: total,
|
||||
width: 30,
|
||||
}
|
||||
}
|
||||
|
||||
// Current returns the current progress value.
|
||||
func (p *ProgressHandle) Current() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.current
|
||||
}
|
||||
|
||||
// Total returns the total value.
|
||||
func (p *ProgressHandle) Total() int {
|
||||
return p.total
|
||||
}
|
||||
|
||||
// Increment advances the progress by 1.
|
||||
func (p *ProgressHandle) Increment() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.current < p.total {
|
||||
p.current++
|
||||
}
|
||||
p.render()
|
||||
}
|
||||
|
||||
// Set sets the progress to a specific value.
|
||||
func (p *ProgressHandle) Set(n int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if n > p.total {
|
||||
n = p.total
|
||||
}
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
p.current = n
|
||||
p.render()
|
||||
}
|
||||
|
||||
// SetMessage sets the message displayed alongside the bar.
|
||||
func (p *ProgressHandle) SetMessage(msg string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.message = msg
|
||||
p.render()
|
||||
}
|
||||
|
||||
// Done completes the progress bar and moves to a new line.
|
||||
func (p *ProgressHandle) Done() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.current = p.total
|
||||
p.render()
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// String returns the rendered progress bar without ANSI cursor control.
|
||||
func (p *ProgressHandle) String() string {
|
||||
pct := 0
|
||||
if p.total > 0 {
|
||||
pct = (p.current * 100) / p.total
|
||||
}
|
||||
|
||||
filled := 0
|
||||
if p.total > 0 {
|
||||
filled = (p.width * p.current) / p.total
|
||||
}
|
||||
if filled > p.width {
|
||||
filled = p.width
|
||||
}
|
||||
empty := p.width - filled
|
||||
|
||||
bar := "[" + strings.Repeat("\u2588", filled) + strings.Repeat("\u2591", empty) + "]"
|
||||
|
||||
if p.message != "" {
|
||||
return fmt.Sprintf("%s %3d%% %s", bar, pct, p.message)
|
||||
}
|
||||
return fmt.Sprintf("%s %3d%%", bar, pct)
|
||||
}
|
||||
|
||||
// render outputs the progress bar, overwriting the current line.
|
||||
func (p *ProgressHandle) render() {
|
||||
fmt.Printf("\033[2K\r%s", p.String())
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProgressBar_Good_Create(t *testing.T) {
|
||||
pb := NewProgressBar(100)
|
||||
require.NotNil(t, pb)
|
||||
assert.Equal(t, 0, pb.Current())
|
||||
assert.Equal(t, 100, pb.Total())
|
||||
}
|
||||
|
||||
func TestProgressBar_Good_Increment(t *testing.T) {
|
||||
pb := NewProgressBar(10)
|
||||
pb.Increment()
|
||||
assert.Equal(t, 1, pb.Current())
|
||||
pb.Increment()
|
||||
assert.Equal(t, 2, pb.Current())
|
||||
}
|
||||
|
||||
func TestProgressBar_Good_SetMessage(t *testing.T) {
|
||||
pb := NewProgressBar(10)
|
||||
pb.SetMessage("Processing file.go")
|
||||
assert.Equal(t, "Processing file.go", pb.message)
|
||||
}
|
||||
|
||||
func TestProgressBar_Good_Set(t *testing.T) {
|
||||
pb := NewProgressBar(100)
|
||||
pb.Set(50)
|
||||
assert.Equal(t, 50, pb.Current())
|
||||
}
|
||||
|
||||
func TestProgressBar_Good_Done(t *testing.T) {
|
||||
pb := NewProgressBar(5)
|
||||
for i := 0; i < 5; i++ {
|
||||
pb.Increment()
|
||||
}
|
||||
pb.Done()
|
||||
// After Done, Current == Total
|
||||
assert.Equal(t, 5, pb.Current())
|
||||
}
|
||||
|
||||
func TestProgressBar_Bad_ExceedsTotal(t *testing.T) {
|
||||
pb := NewProgressBar(2)
|
||||
pb.Increment()
|
||||
pb.Increment()
|
||||
pb.Increment() // Should clamp to total
|
||||
assert.Equal(t, 2, pb.Current())
|
||||
}
|
||||
|
||||
func TestProgressBar_Good_Render(t *testing.T) {
|
||||
pb := NewProgressBar(10)
|
||||
pb.Set(5)
|
||||
rendered := pb.String()
|
||||
assert.Contains(t, rendered, "50%")
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var stdin = bufio.NewReader(os.Stdin)
|
||||
|
||||
// Prompt asks for text input with a default value.
|
||||
func Prompt(label, defaultVal string) (string, error) {
|
||||
if defaultVal != "" {
|
||||
fmt.Printf("%s [%s]: ", label, defaultVal)
|
||||
} else {
|
||||
fmt.Printf("%s: ", label)
|
||||
}
|
||||
|
||||
input, err := stdin.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return defaultVal, nil
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// Select presents numbered options and returns the selected value.
|
||||
func Select(label string, options []string) (string, error) {
|
||||
fmt.Println(label)
|
||||
for i, opt := range options {
|
||||
fmt.Printf(" %d. %s\n", i+1, opt)
|
||||
}
|
||||
fmt.Printf("Choose [1-%d]: ", len(options))
|
||||
|
||||
input, err := stdin.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(strings.TrimSpace(input))
|
||||
if err != nil || n < 1 || n > len(options) {
|
||||
return "", fmt.Errorf("invalid selection")
|
||||
}
|
||||
return options[n-1], nil
|
||||
}
|
||||
|
||||
// MultiSelect presents checkboxes (space-separated numbers).
|
||||
func MultiSelect(label string, options []string) ([]string, error) {
|
||||
fmt.Println(label)
|
||||
for i, opt := range options {
|
||||
fmt.Printf(" %d. %s\n", i+1, opt)
|
||||
}
|
||||
fmt.Printf("Choose (space-separated) [1-%d]: ", len(options))
|
||||
|
||||
input, err := stdin.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var selected []string
|
||||
for _, s := range strings.Fields(input) {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 1 || n > len(options) {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, options[n-1])
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RenderStyle controls how layouts are rendered.
|
||||
type RenderStyle int
|
||||
|
||||
// Render style constants for layout output.
|
||||
const (
|
||||
// RenderFlat uses no borders or decorations.
|
||||
RenderFlat RenderStyle = iota
|
||||
// RenderSimple uses --- separators between sections.
|
||||
RenderSimple
|
||||
// RenderBoxed uses Unicode box drawing characters.
|
||||
RenderBoxed
|
||||
)
|
||||
|
||||
var currentRenderStyle = RenderFlat
|
||||
|
||||
// UseRenderFlat sets the render style to flat (no borders).
|
||||
func UseRenderFlat() { currentRenderStyle = RenderFlat }
|
||||
|
||||
// UseRenderSimple sets the render style to simple (--- separators).
|
||||
func UseRenderSimple() { currentRenderStyle = RenderSimple }
|
||||
|
||||
// UseRenderBoxed sets the render style to boxed (Unicode box drawing).
|
||||
func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
|
||||
|
||||
// Render outputs the layout to terminal.
|
||||
func (c *Composite) Render() {
|
||||
fmt.Print(c.String())
|
||||
}
|
||||
|
||||
// String returns the rendered layout.
|
||||
func (c *Composite) String() string {
|
||||
var sb strings.Builder
|
||||
c.renderTo(&sb, 0)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (c *Composite) renderTo(sb *strings.Builder, depth int) {
|
||||
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
|
||||
|
||||
var active []Region
|
||||
for _, r := range order {
|
||||
if slot, ok := c.regions[r]; ok {
|
||||
if len(slot.blocks) > 0 || slot.child != nil {
|
||||
active = append(active, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, r := range active {
|
||||
slot := c.regions[r]
|
||||
if i > 0 && currentRenderStyle != RenderFlat {
|
||||
c.renderSeparator(sb, depth)
|
||||
}
|
||||
c.renderSlot(sb, slot, depth)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Composite) renderSeparator(sb *strings.Builder, depth int) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
switch currentRenderStyle {
|
||||
case RenderBoxed:
|
||||
sb.WriteString(indent + "├" + strings.Repeat("─", 40) + "┤\n")
|
||||
case RenderSimple:
|
||||
sb.WriteString(indent + strings.Repeat("─", 40) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Composite) renderSlot(sb *strings.Builder, slot *Slot, depth int) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
for _, block := range slot.blocks {
|
||||
for _, line := range strings.Split(block.Render(), "\n") {
|
||||
if line != "" {
|
||||
sb.WriteString(indent + line + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
if slot.child != nil {
|
||||
slot.child.renderTo(sb, depth+1)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
// Package cli provides the CLI runtime and utilities.
|
||||
//
|
||||
// The CLI uses the Core framework for its own runtime. Usage is simple:
|
||||
//
|
||||
// cli.Init(cli.Options{AppName: "core"})
|
||||
// defer cli.Shutdown()
|
||||
//
|
||||
// cli.Success("Done!")
|
||||
// cli.Error("Failed")
|
||||
// if cli.Confirm("Proceed?") { ... }
|
||||
//
|
||||
// // When you need the Core instance
|
||||
// c := cli.Core()
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
instance *runtime
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// runtime is the CLI's internal Core runtime.
|
||||
type runtime struct {
|
||||
core *framework.Core
|
||||
root *cobra.Command
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// Options configures the CLI runtime.
|
||||
type Options struct {
|
||||
AppName string
|
||||
Version string
|
||||
Services []framework.Option // Additional services to register
|
||||
|
||||
// OnReload is called when SIGHUP is received (daemon mode).
|
||||
// Use for configuration reloading. Leave nil to ignore SIGHUP.
|
||||
OnReload func() error
|
||||
}
|
||||
|
||||
// Init initialises the global CLI runtime.
|
||||
// Call this once at startup (typically in main.go or cmd.Execute).
|
||||
func Init(opts Options) error {
|
||||
var initErr error
|
||||
once.Do(func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Create root command
|
||||
rootCmd := &cobra.Command{
|
||||
Use: opts.AppName,
|
||||
Version: opts.Version,
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
// Build signal service options
|
||||
var signalOpts []SignalOption
|
||||
if opts.OnReload != nil {
|
||||
signalOpts = append(signalOpts, WithReloadHandler(opts.OnReload))
|
||||
}
|
||||
|
||||
// Build options: app, signal service + any additional services
|
||||
coreOpts := []framework.Option{
|
||||
framework.WithApp(rootCmd),
|
||||
framework.WithName("signal", newSignalService(cancel, signalOpts...)),
|
||||
}
|
||||
coreOpts = append(coreOpts, opts.Services...)
|
||||
coreOpts = append(coreOpts, framework.WithServiceLock())
|
||||
|
||||
c, err := framework.New(coreOpts...)
|
||||
if err != nil {
|
||||
initErr = err
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
instance = &runtime{
|
||||
core: c,
|
||||
root: rootCmd,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
if err := c.ServiceStartup(ctx, nil); err != nil {
|
||||
initErr = err
|
||||
return
|
||||
}
|
||||
})
|
||||
return initErr
|
||||
}
|
||||
|
||||
func mustInit() {
|
||||
if instance == nil {
|
||||
panic("cli not initialised - call cli.Init() first")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Core Access ---
|
||||
|
||||
// Core returns the CLI's framework Core instance.
|
||||
func Core() *framework.Core {
|
||||
mustInit()
|
||||
return instance.core
|
||||
}
|
||||
|
||||
// RootCmd returns the CLI's root cobra command.
|
||||
func RootCmd() *cobra.Command {
|
||||
mustInit()
|
||||
return instance.root
|
||||
}
|
||||
|
||||
// Execute runs the CLI root command.
|
||||
// Returns an error if the command fails.
|
||||
func Execute() error {
|
||||
mustInit()
|
||||
return instance.root.Execute()
|
||||
}
|
||||
|
||||
// Context returns the CLI's root context.
|
||||
// Cancelled on SIGINT/SIGTERM.
|
||||
func Context() context.Context {
|
||||
mustInit()
|
||||
return instance.ctx
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the CLI.
|
||||
func Shutdown() {
|
||||
if instance == nil {
|
||||
return
|
||||
}
|
||||
instance.cancel()
|
||||
_ = instance.core.ServiceShutdown(instance.ctx)
|
||||
}
|
||||
|
||||
// --- Signal Service (internal) ---
|
||||
|
||||
type signalService struct {
|
||||
cancel context.CancelFunc
|
||||
sigChan chan os.Signal
|
||||
onReload func() error
|
||||
shutdownOnce sync.Once
|
||||
}
|
||||
|
||||
// SignalOption configures signal handling.
|
||||
type SignalOption func(*signalService)
|
||||
|
||||
// WithReloadHandler sets a callback for SIGHUP.
|
||||
func WithReloadHandler(fn func() error) SignalOption {
|
||||
return func(s *signalService) {
|
||||
s.onReload = fn
|
||||
}
|
||||
}
|
||||
|
||||
func newSignalService(cancel context.CancelFunc, opts ...SignalOption) func(*framework.Core) (any, error) {
|
||||
return func(c *framework.Core) (any, error) {
|
||||
svc := &signalService{
|
||||
cancel: cancel,
|
||||
sigChan: make(chan os.Signal, 1),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(svc)
|
||||
}
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalService) OnStartup(ctx context.Context) error {
|
||||
signals := []os.Signal{syscall.SIGINT, syscall.SIGTERM}
|
||||
if s.onReload != nil {
|
||||
signals = append(signals, syscall.SIGHUP)
|
||||
}
|
||||
signal.Notify(s.sigChan, signals...)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case sig := <-s.sigChan:
|
||||
switch sig {
|
||||
case syscall.SIGHUP:
|
||||
if s.onReload != nil {
|
||||
if err := s.onReload(); err != nil {
|
||||
LogError("reload failed", "err", err)
|
||||
} else {
|
||||
LogInfo("configuration reloaded")
|
||||
}
|
||||
}
|
||||
case syscall.SIGINT, syscall.SIGTERM:
|
||||
s.cancel()
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *signalService) OnShutdown(ctx context.Context) error {
|
||||
s.shutdownOnce.Do(func() {
|
||||
signal.Stop(s.sigChan)
|
||||
close(s.sigChan)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SpinnerHandle controls a running spinner.
|
||||
type SpinnerHandle struct {
|
||||
mu sync.Mutex
|
||||
message string
|
||||
done bool
|
||||
ticker *time.Ticker
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewSpinner starts an async spinner with the given message.
|
||||
// Call Stop(), Done(), or Fail() to stop it.
|
||||
func NewSpinner(message string) *SpinnerHandle {
|
||||
s := &SpinnerHandle{
|
||||
message: message,
|
||||
ticker: time.NewTicker(100 * time.Millisecond),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
if !ColorEnabled() {
|
||||
frames = []string{"|", "/", "-", "\\"}
|
||||
}
|
||||
|
||||
go func() {
|
||||
i := 0
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-s.ticker.C:
|
||||
s.mu.Lock()
|
||||
if !s.done {
|
||||
fmt.Printf("\033[2K\r%s %s", DimStyle.Render(frames[i%len(frames)]), s.message)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
i++
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Message returns the current spinner message.
|
||||
func (s *SpinnerHandle) Message() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.message
|
||||
}
|
||||
|
||||
// Update changes the spinner message.
|
||||
func (s *SpinnerHandle) Update(message string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.message = message
|
||||
}
|
||||
|
||||
// Stop stops the spinner silently (clears the line).
|
||||
func (s *SpinnerHandle) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.done {
|
||||
return
|
||||
}
|
||||
s.done = true
|
||||
s.ticker.Stop()
|
||||
close(s.stopCh)
|
||||
fmt.Print("\033[2K\r")
|
||||
}
|
||||
|
||||
// Done stops the spinner with a success message.
|
||||
func (s *SpinnerHandle) Done(message string) {
|
||||
s.mu.Lock()
|
||||
alreadyDone := s.done
|
||||
s.done = true
|
||||
s.mu.Unlock()
|
||||
|
||||
if alreadyDone {
|
||||
return
|
||||
}
|
||||
s.ticker.Stop()
|
||||
close(s.stopCh)
|
||||
fmt.Printf("\033[2K\r%s\n", SuccessStyle.Render(Glyph(":check:")+" "+message))
|
||||
}
|
||||
|
||||
// Fail stops the spinner with an error message.
|
||||
func (s *SpinnerHandle) Fail(message string) {
|
||||
s.mu.Lock()
|
||||
alreadyDone := s.done
|
||||
s.done = true
|
||||
s.mu.Unlock()
|
||||
|
||||
if alreadyDone {
|
||||
return
|
||||
}
|
||||
s.ticker.Stop()
|
||||
close(s.stopCh)
|
||||
fmt.Printf("\033[2K\r%s\n", ErrorStyle.Render(Glyph(":cross:")+" "+message))
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSpinner_Good_CreateAndStop(t *testing.T) {
|
||||
s := NewSpinner("Loading...")
|
||||
require.NotNil(t, s)
|
||||
assert.Equal(t, "Loading...", s.Message())
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
func TestSpinner_Good_UpdateMessage(t *testing.T) {
|
||||
s := NewSpinner("Step 1")
|
||||
s.Update("Step 2")
|
||||
assert.Equal(t, "Step 2", s.Message())
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
func TestSpinner_Good_Done(t *testing.T) {
|
||||
s := NewSpinner("Building")
|
||||
s.Done("Build complete")
|
||||
// After Done, spinner is stopped — calling Stop again is safe
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
func TestSpinner_Good_Fail(t *testing.T) {
|
||||
s := NewSpinner("Checking")
|
||||
s.Fail("Check failed")
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
func TestSpinner_Good_DoubleStop(t *testing.T) {
|
||||
s := NewSpinner("Loading")
|
||||
s.Stop()
|
||||
s.Stop() // Should not panic
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Sprintf formats a string (fmt.Sprintf wrapper).
|
||||
func Sprintf(format string, args ...any) string {
|
||||
return fmt.Sprintf(format, args...)
|
||||
}
|
||||
|
||||
// Sprint formats using default formats (fmt.Sprint wrapper).
|
||||
func Sprint(args ...any) string {
|
||||
return fmt.Sprint(args...)
|
||||
}
|
||||
|
||||
// Styled returns text with a style applied.
|
||||
func Styled(style *AnsiStyle, text string) string {
|
||||
return style.Render(text)
|
||||
}
|
||||
|
||||
// Styledf returns formatted text with a style applied.
|
||||
func Styledf(style *AnsiStyle, format string, args ...any) string {
|
||||
return style.Render(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// SuccessStr returns success-styled string.
|
||||
func SuccessStr(msg string) string {
|
||||
return SuccessStyle.Render(Glyph(":check:") + " " + msg)
|
||||
}
|
||||
|
||||
// ErrorStr returns error-styled string.
|
||||
func ErrorStr(msg string) string {
|
||||
return ErrorStyle.Render(Glyph(":cross:") + " " + msg)
|
||||
}
|
||||
|
||||
// WarnStr returns warning-styled string.
|
||||
func WarnStr(msg string) string {
|
||||
return WarningStyle.Render(Glyph(":warn:") + " " + msg)
|
||||
}
|
||||
|
||||
// InfoStr returns info-styled string.
|
||||
func InfoStr(msg string) string {
|
||||
return InfoStyle.Render(Glyph(":info:") + " " + msg)
|
||||
}
|
||||
|
||||
// DimStr returns dim-styled string.
|
||||
func DimStr(msg string) string {
|
||||
return DimStyle.Render(msg)
|
||||
}
|
||||
146
pkg/cli/stubs.go
146
pkg/cli/stubs.go
|
|
@ -1,146 +0,0 @@
|
|||
package cli
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Form (stubbed — simple fallback, will use charmbracelet/huh later)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// FieldType defines the type of a form field.
|
||||
type FieldType string
|
||||
|
||||
const (
|
||||
FieldText FieldType = "text"
|
||||
FieldPassword FieldType = "password"
|
||||
FieldConfirm FieldType = "confirm"
|
||||
FieldSelect FieldType = "select"
|
||||
)
|
||||
|
||||
// FormField describes a single field in a form.
|
||||
type FormField struct {
|
||||
Label string
|
||||
Key string
|
||||
Type FieldType
|
||||
Default string
|
||||
Placeholder string
|
||||
Options []string // For FieldSelect
|
||||
Required bool
|
||||
Validator func(string) error
|
||||
}
|
||||
|
||||
// Form presents a multi-field form and returns the values keyed by FormField.Key.
|
||||
// Currently falls back to sequential Question()/Confirm()/Select() calls.
|
||||
// Will be replaced with charmbracelet/huh interactive form later.
|
||||
//
|
||||
// results, err := cli.Form([]cli.FormField{
|
||||
// {Label: "Name", Key: "name", Type: cli.FieldText, Required: true},
|
||||
// {Label: "Password", Key: "pass", Type: cli.FieldPassword},
|
||||
// {Label: "Accept terms?", Key: "terms", Type: cli.FieldConfirm},
|
||||
// })
|
||||
func Form(fields []FormField) (map[string]string, error) {
|
||||
results := make(map[string]string, len(fields))
|
||||
|
||||
for _, f := range fields {
|
||||
switch f.Type {
|
||||
case FieldPassword:
|
||||
val := Question(f.Label+":", WithDefault(f.Default))
|
||||
results[f.Key] = val
|
||||
case FieldConfirm:
|
||||
if Confirm(f.Label) {
|
||||
results[f.Key] = "true"
|
||||
} else {
|
||||
results[f.Key] = "false"
|
||||
}
|
||||
case FieldSelect:
|
||||
val, err := Select(f.Label, f.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results[f.Key] = val
|
||||
default: // FieldText
|
||||
var opts []QuestionOption
|
||||
if f.Default != "" {
|
||||
opts = append(opts, WithDefault(f.Default))
|
||||
}
|
||||
if f.Required {
|
||||
opts = append(opts, RequiredInput())
|
||||
}
|
||||
if f.Validator != nil {
|
||||
opts = append(opts, WithValidator(f.Validator))
|
||||
}
|
||||
results[f.Key] = Question(f.Label+":", opts...)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// FilePicker (stubbed — will use charmbracelet/filepicker later)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// FilePickerOption configures FilePicker behaviour.
|
||||
type FilePickerOption func(*filePickerConfig)
|
||||
|
||||
type filePickerConfig struct {
|
||||
dir string
|
||||
extensions []string
|
||||
}
|
||||
|
||||
// InDirectory sets the starting directory for the file picker.
|
||||
func InDirectory(dir string) FilePickerOption {
|
||||
return func(c *filePickerConfig) {
|
||||
c.dir = dir
|
||||
}
|
||||
}
|
||||
|
||||
// WithExtensions filters to specific file extensions (e.g. ".go", ".yaml").
|
||||
func WithExtensions(exts ...string) FilePickerOption {
|
||||
return func(c *filePickerConfig) {
|
||||
c.extensions = exts
|
||||
}
|
||||
}
|
||||
|
||||
// FilePicker presents a file browser and returns the selected path.
|
||||
// Currently falls back to a text prompt. Will be replaced with an
|
||||
// interactive file browser later.
|
||||
//
|
||||
// path, err := cli.FilePicker(cli.InDirectory("."), cli.WithExtensions(".go"))
|
||||
func FilePicker(opts ...FilePickerOption) (string, error) {
|
||||
cfg := &filePickerConfig{dir: "."}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
hint := "File path"
|
||||
if cfg.dir != "." {
|
||||
hint += " (from " + cfg.dir + ")"
|
||||
}
|
||||
return Question(hint + ":"), nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Tabs (stubbed — will use bubbletea model later)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TabItem describes a tab with a title and content.
|
||||
type TabItem struct {
|
||||
Title string
|
||||
Content string
|
||||
}
|
||||
|
||||
// Tabs displays tabbed content. Currently prints all tabs sequentially.
|
||||
// Will be replaced with an interactive tab switcher later.
|
||||
//
|
||||
// cli.Tabs([]cli.TabItem{
|
||||
// {Title: "Overview", Content: summaryText},
|
||||
// {Title: "Details", Content: detailText},
|
||||
// })
|
||||
func Tabs(items []TabItem) error {
|
||||
for i, tab := range items {
|
||||
if i > 0 {
|
||||
Blank()
|
||||
}
|
||||
Section(tab.Title)
|
||||
Println("%s", tab.Content)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormField_Good_Types(t *testing.T) {
|
||||
fields := []FormField{
|
||||
{Label: "Name", Key: "name", Type: FieldText},
|
||||
{Label: "Password", Key: "pass", Type: FieldPassword},
|
||||
{Label: "Accept", Key: "ok", Type: FieldConfirm},
|
||||
}
|
||||
assert.Equal(t, 3, len(fields))
|
||||
assert.Equal(t, FieldText, fields[0].Type)
|
||||
assert.Equal(t, FieldPassword, fields[1].Type)
|
||||
assert.Equal(t, FieldConfirm, fields[2].Type)
|
||||
}
|
||||
|
||||
func TestFieldType_Good_Constants(t *testing.T) {
|
||||
assert.Equal(t, FieldType("text"), FieldText)
|
||||
assert.Equal(t, FieldType("password"), FieldPassword)
|
||||
assert.Equal(t, FieldType("confirm"), FieldConfirm)
|
||||
assert.Equal(t, FieldType("select"), FieldSelect)
|
||||
}
|
||||
|
||||
func TestTabItem_Good_Structure(t *testing.T) {
|
||||
tabs := []TabItem{
|
||||
{Title: "Overview", Content: "overview content"},
|
||||
{Title: "Details", Content: "detail content"},
|
||||
}
|
||||
assert.Equal(t, 2, len(tabs))
|
||||
assert.Equal(t, "Overview", tabs[0].Title)
|
||||
}
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
// Package cli provides semantic CLI output with zero external dependencies.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Tailwind colour palette (hex strings)
|
||||
const (
|
||||
ColourBlue50 = "#eff6ff"
|
||||
ColourBlue100 = "#dbeafe"
|
||||
ColourBlue200 = "#bfdbfe"
|
||||
ColourBlue300 = "#93c5fd"
|
||||
ColourBlue400 = "#60a5fa"
|
||||
ColourBlue500 = "#3b82f6"
|
||||
ColourBlue600 = "#2563eb"
|
||||
ColourBlue700 = "#1d4ed8"
|
||||
ColourGreen400 = "#4ade80"
|
||||
ColourGreen500 = "#22c55e"
|
||||
ColourGreen600 = "#16a34a"
|
||||
ColourRed400 = "#f87171"
|
||||
ColourRed500 = "#ef4444"
|
||||
ColourRed600 = "#dc2626"
|
||||
ColourAmber400 = "#fbbf24"
|
||||
ColourAmber500 = "#f59e0b"
|
||||
ColourAmber600 = "#d97706"
|
||||
ColourOrange500 = "#f97316"
|
||||
ColourYellow500 = "#eab308"
|
||||
ColourEmerald500 = "#10b981"
|
||||
ColourPurple500 = "#a855f7"
|
||||
ColourViolet400 = "#a78bfa"
|
||||
ColourViolet500 = "#8b5cf6"
|
||||
ColourIndigo500 = "#6366f1"
|
||||
ColourCyan500 = "#06b6d4"
|
||||
ColourGray50 = "#f9fafb"
|
||||
ColourGray100 = "#f3f4f6"
|
||||
ColourGray200 = "#e5e7eb"
|
||||
ColourGray300 = "#d1d5db"
|
||||
ColourGray400 = "#9ca3af"
|
||||
ColourGray500 = "#6b7280"
|
||||
ColourGray600 = "#4b5563"
|
||||
ColourGray700 = "#374151"
|
||||
ColourGray800 = "#1f2937"
|
||||
ColourGray900 = "#111827"
|
||||
)
|
||||
|
||||
// Core styles
|
||||
var (
|
||||
SuccessStyle = NewStyle().Bold().Foreground(ColourGreen500)
|
||||
ErrorStyle = NewStyle().Bold().Foreground(ColourRed500)
|
||||
WarningStyle = NewStyle().Bold().Foreground(ColourAmber500)
|
||||
InfoStyle = NewStyle().Foreground(ColourBlue400)
|
||||
SecurityStyle = NewStyle().Bold().Foreground(ColourPurple500)
|
||||
DimStyle = NewStyle().Dim().Foreground(ColourGray500)
|
||||
MutedStyle = NewStyle().Foreground(ColourGray600)
|
||||
BoldStyle = NewStyle().Bold()
|
||||
KeyStyle = NewStyle().Foreground(ColourGray400)
|
||||
ValueStyle = NewStyle().Foreground(ColourGray200)
|
||||
AccentStyle = NewStyle().Foreground(ColourCyan500)
|
||||
LinkStyle = NewStyle().Foreground(ColourBlue500).Underline()
|
||||
HeaderStyle = NewStyle().Bold().Foreground(ColourGray200)
|
||||
TitleStyle = NewStyle().Bold().Foreground(ColourBlue500)
|
||||
CodeStyle = NewStyle().Foreground(ColourGray300)
|
||||
NumberStyle = NewStyle().Foreground(ColourBlue300)
|
||||
RepoStyle = NewStyle().Bold().Foreground(ColourBlue500)
|
||||
)
|
||||
|
||||
// Truncate shortens a string to max length with ellipsis.
|
||||
func Truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
if max <= 3 {
|
||||
return s[:max]
|
||||
}
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
|
||||
// Pad right-pads a string to width.
|
||||
func Pad(s string, width int) string {
|
||||
if len(s) >= width {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", width-len(s))
|
||||
}
|
||||
|
||||
// FormatAge formats a time as human-readable age (e.g., "2h ago", "3d ago").
|
||||
func FormatAge(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
case d < 7*24*time.Hour:
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
case d < 30*24*time.Hour:
|
||||
return fmt.Sprintf("%dw ago", int(d.Hours()/(24*7)))
|
||||
default:
|
||||
return fmt.Sprintf("%dmo ago", int(d.Hours()/(24*30)))
|
||||
}
|
||||
}
|
||||
|
||||
// Table renders tabular data with aligned columns.
|
||||
// HLCRF is for layout; Table is for tabular data - they serve different purposes.
|
||||
type Table struct {
|
||||
Headers []string
|
||||
Rows [][]string
|
||||
Style TableStyle
|
||||
}
|
||||
|
||||
// TableStyle configures the appearance of table output.
|
||||
type TableStyle struct {
|
||||
HeaderStyle *AnsiStyle
|
||||
CellStyle *AnsiStyle
|
||||
Separator string
|
||||
}
|
||||
|
||||
// DefaultTableStyle returns sensible defaults.
|
||||
func DefaultTableStyle() TableStyle {
|
||||
return TableStyle{
|
||||
HeaderStyle: HeaderStyle,
|
||||
CellStyle: nil,
|
||||
Separator: " ",
|
||||
}
|
||||
}
|
||||
|
||||
// NewTable creates a table with headers.
|
||||
func NewTable(headers ...string) *Table {
|
||||
return &Table{
|
||||
Headers: headers,
|
||||
Style: DefaultTableStyle(),
|
||||
}
|
||||
}
|
||||
|
||||
// AddRow adds a row to the table.
|
||||
func (t *Table) AddRow(cells ...string) *Table {
|
||||
t.Rows = append(t.Rows, cells)
|
||||
return t
|
||||
}
|
||||
|
||||
// String renders the table.
|
||||
func (t *Table) String() string {
|
||||
if len(t.Headers) == 0 && len(t.Rows) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Calculate column widths
|
||||
cols := len(t.Headers)
|
||||
if cols == 0 && len(t.Rows) > 0 {
|
||||
cols = len(t.Rows[0])
|
||||
}
|
||||
widths := make([]int, cols)
|
||||
|
||||
for i, h := range t.Headers {
|
||||
if len(h) > widths[i] {
|
||||
widths[i] = len(h)
|
||||
}
|
||||
}
|
||||
for _, row := range t.Rows {
|
||||
for i, cell := range row {
|
||||
if i < cols && len(cell) > widths[i] {
|
||||
widths[i] = len(cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sep := t.Style.Separator
|
||||
|
||||
// Headers
|
||||
if len(t.Headers) > 0 {
|
||||
for i, h := range t.Headers {
|
||||
if i > 0 {
|
||||
sb.WriteString(sep)
|
||||
}
|
||||
styled := Pad(h, widths[i])
|
||||
if t.Style.HeaderStyle != nil {
|
||||
styled = t.Style.HeaderStyle.Render(styled)
|
||||
}
|
||||
sb.WriteString(styled)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Rows
|
||||
for _, row := range t.Rows {
|
||||
for i, cell := range row {
|
||||
if i > 0 {
|
||||
sb.WriteString(sep)
|
||||
}
|
||||
styled := Pad(cell, widths[i])
|
||||
if t.Style.CellStyle != nil {
|
||||
styled = t.Style.CellStyle.Render(styled)
|
||||
}
|
||||
sb.WriteString(styled)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Render prints the table to stdout.
|
||||
func (t *Table) Render() {
|
||||
fmt.Print(t.String())
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// textInputModel is the internal bubbletea model for text input.
|
||||
type textInputModel struct {
|
||||
title string
|
||||
placeholder string
|
||||
value string
|
||||
masked bool
|
||||
submitted bool
|
||||
cancelled bool
|
||||
cursorPos int
|
||||
validator func(string) error
|
||||
err error
|
||||
}
|
||||
|
||||
func newTextInputModel(title, placeholder string) *textInputModel {
|
||||
return &textInputModel{
|
||||
title: title,
|
||||
placeholder: placeholder,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *textInputModel) insertChar(ch rune) {
|
||||
m.value = m.value[:m.cursorPos] + string(ch) + m.value[m.cursorPos:]
|
||||
m.cursorPos++
|
||||
}
|
||||
|
||||
func (m *textInputModel) backspace() {
|
||||
if m.cursorPos > 0 {
|
||||
m.value = m.value[:m.cursorPos-1] + m.value[m.cursorPos:]
|
||||
m.cursorPos--
|
||||
}
|
||||
}
|
||||
|
||||
func (m *textInputModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *textInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEnter:
|
||||
if m.validator != nil {
|
||||
if err := m.validator(m.value); err != nil {
|
||||
m.err = err
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
if m.value == "" && m.placeholder != "" {
|
||||
m.value = m.placeholder
|
||||
}
|
||||
m.submitted = true
|
||||
return m, tea.Quit
|
||||
case tea.KeyEscape, tea.KeyCtrlC:
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
case tea.KeyBackspace:
|
||||
m.backspace()
|
||||
m.err = nil
|
||||
case tea.KeyLeft:
|
||||
if m.cursorPos > 0 {
|
||||
m.cursorPos--
|
||||
}
|
||||
case tea.KeyRight:
|
||||
if m.cursorPos < len(m.value) {
|
||||
m.cursorPos++
|
||||
}
|
||||
case tea.KeyRunes:
|
||||
for _, ch := range msg.Runes {
|
||||
m.insertChar(ch)
|
||||
}
|
||||
m.err = nil
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *textInputModel) View() string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(BoldStyle.Render(m.title) + "\n\n")
|
||||
|
||||
display := m.value
|
||||
if m.masked {
|
||||
display = strings.Repeat("*", len(m.value))
|
||||
}
|
||||
|
||||
if display == "" && m.placeholder != "" {
|
||||
sb.WriteString(DimStyle.Render(m.placeholder))
|
||||
} else {
|
||||
sb.WriteString(display)
|
||||
}
|
||||
sb.WriteString(AccentStyle.Render("\u2588")) // Cursor block
|
||||
|
||||
if m.err != nil {
|
||||
sb.WriteString("\n" + ErrorStyle.Render(fmt.Sprintf(" %s", m.err)))
|
||||
}
|
||||
|
||||
sb.WriteString("\n\n" + DimStyle.Render("enter submit \u2022 esc cancel"))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// TextInputOption configures TextInput behaviour.
|
||||
type TextInputOption func(*textInputConfig)
|
||||
|
||||
type textInputConfig struct {
|
||||
placeholder string
|
||||
masked bool
|
||||
validator func(string) error
|
||||
}
|
||||
|
||||
// WithTextPlaceholder sets placeholder text shown when input is empty.
|
||||
func WithTextPlaceholder(text string) TextInputOption {
|
||||
return func(c *textInputConfig) {
|
||||
c.placeholder = text
|
||||
}
|
||||
}
|
||||
|
||||
// WithMask hides input characters (for passwords).
|
||||
func WithMask() TextInputOption {
|
||||
return func(c *textInputConfig) {
|
||||
c.masked = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithInputValidator adds a validation function for the input.
|
||||
func WithInputValidator(fn func(string) error) TextInputOption {
|
||||
return func(c *textInputConfig) {
|
||||
c.validator = fn
|
||||
}
|
||||
}
|
||||
|
||||
// TextInput presents a styled text input prompt and returns the entered value.
|
||||
// Returns empty string if cancelled.
|
||||
//
|
||||
// Falls back to Question() when stdin is not a terminal.
|
||||
//
|
||||
// name, err := cli.TextInput("Enter your name:", cli.WithTextPlaceholder("Anonymous"))
|
||||
// pass, err := cli.TextInput("Password:", cli.WithMask())
|
||||
func TextInput(title string, opts ...TextInputOption) (string, error) {
|
||||
cfg := &textInputConfig{}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
// Fall back to simple Question if not a terminal
|
||||
if !term.IsTerminal(0) {
|
||||
var qopts []QuestionOption
|
||||
if cfg.placeholder != "" {
|
||||
qopts = append(qopts, WithDefault(cfg.placeholder))
|
||||
}
|
||||
if cfg.validator != nil {
|
||||
qopts = append(qopts, WithValidator(cfg.validator))
|
||||
}
|
||||
return Question(title, qopts...), nil
|
||||
}
|
||||
|
||||
m := newTextInputModel(title, cfg.placeholder)
|
||||
m.masked = cfg.masked
|
||||
m.validator = cfg.validator
|
||||
|
||||
p := tea.NewProgram(m)
|
||||
finalModel, err := p.Run()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
final := finalModel.(*textInputModel)
|
||||
if final.cancelled {
|
||||
return "", nil
|
||||
}
|
||||
return final.value, nil
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTextInputModel_Good_Create(t *testing.T) {
|
||||
m := newTextInputModel("Enter name:", "")
|
||||
assert.Equal(t, "Enter name:", m.title)
|
||||
assert.Equal(t, "", m.value)
|
||||
}
|
||||
|
||||
func TestTextInputModel_Good_WithPlaceholder(t *testing.T) {
|
||||
m := newTextInputModel("Name:", "John")
|
||||
assert.Equal(t, "John", m.placeholder)
|
||||
}
|
||||
|
||||
func TestTextInputModel_Good_TypeCharacters(t *testing.T) {
|
||||
m := newTextInputModel("Name:", "")
|
||||
m.insertChar('H')
|
||||
m.insertChar('i')
|
||||
assert.Equal(t, "Hi", m.value)
|
||||
}
|
||||
|
||||
func TestTextInputModel_Good_Backspace(t *testing.T) {
|
||||
m := newTextInputModel("Name:", "")
|
||||
m.insertChar('A')
|
||||
m.insertChar('B')
|
||||
m.backspace()
|
||||
assert.Equal(t, "A", m.value)
|
||||
}
|
||||
|
||||
func TestTextInputModel_Good_BackspaceEmpty(t *testing.T) {
|
||||
m := newTextInputModel("Name:", "")
|
||||
m.backspace() // Should not panic
|
||||
assert.Equal(t, "", m.value)
|
||||
}
|
||||
|
||||
func TestTextInputModel_Good_Masked(t *testing.T) {
|
||||
m := newTextInputModel("Password:", "")
|
||||
m.masked = true
|
||||
m.insertChar('s')
|
||||
m.insertChar('e')
|
||||
m.insertChar('c')
|
||||
assert.Equal(t, "sec", m.value) // Internal value is real
|
||||
view := m.View()
|
||||
assert.NotContains(t, view, "sec") // Display is masked
|
||||
assert.Contains(t, view, "***")
|
||||
}
|
||||
|
||||
func TestTextInputModel_Good_View(t *testing.T) {
|
||||
m := newTextInputModel("Enter:", "")
|
||||
m.insertChar('X')
|
||||
view := m.View()
|
||||
assert.Contains(t, view, "Enter:")
|
||||
assert.Contains(t, view, "X")
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// Model is the interface for interactive TUI applications.
|
||||
// It mirrors bubbletea's Model but uses our own types so domain
|
||||
// packages never import bubbletea directly.
|
||||
type Model interface {
|
||||
// Init returns an initial command to run.
|
||||
Init() Cmd
|
||||
|
||||
// Update handles a message and returns the updated model and command.
|
||||
Update(msg Msg) (Model, Cmd)
|
||||
|
||||
// View returns the string representation of the UI.
|
||||
View() string
|
||||
}
|
||||
|
||||
// Msg is a message passed to Update. Can be any type.
|
||||
type Msg = tea.Msg
|
||||
|
||||
// Cmd is a function that returns a message. Nil means no command.
|
||||
type Cmd = tea.Cmd
|
||||
|
||||
// Quit is a command that tells the TUI to exit.
|
||||
var Quit = tea.Quit
|
||||
|
||||
// KeyMsg represents a key press event.
|
||||
type KeyMsg = tea.KeyMsg
|
||||
|
||||
// KeyType represents the type of key pressed.
|
||||
type KeyType = tea.KeyType
|
||||
|
||||
// Key type constants.
|
||||
const (
|
||||
KeyEnter KeyType = tea.KeyEnter
|
||||
KeyEsc KeyType = tea.KeyEscape
|
||||
KeyCtrlC KeyType = tea.KeyCtrlC
|
||||
KeyUp KeyType = tea.KeyUp
|
||||
KeyDown KeyType = tea.KeyDown
|
||||
KeyLeft KeyType = tea.KeyLeft
|
||||
KeyRight KeyType = tea.KeyRight
|
||||
KeyTab KeyType = tea.KeyTab
|
||||
KeyBackspace KeyType = tea.KeyBackspace
|
||||
KeySpace KeyType = tea.KeySpace
|
||||
KeyHome KeyType = tea.KeyHome
|
||||
KeyEnd KeyType = tea.KeyEnd
|
||||
KeyPgUp KeyType = tea.KeyPgUp
|
||||
KeyPgDown KeyType = tea.KeyPgDown
|
||||
KeyDelete KeyType = tea.KeyDelete
|
||||
KeyShiftTab KeyType = tea.KeyShiftTab
|
||||
KeyRunes KeyType = tea.KeyRunes
|
||||
)
|
||||
|
||||
// adapter wraps our Model interface into a bubbletea.Model.
|
||||
type adapter struct {
|
||||
inner Model
|
||||
}
|
||||
|
||||
func (a adapter) Init() tea.Cmd {
|
||||
return a.inner.Init()
|
||||
}
|
||||
|
||||
func (a adapter) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m, cmd := a.inner.Update(msg)
|
||||
return adapter{inner: m}, cmd
|
||||
}
|
||||
|
||||
func (a adapter) View() string {
|
||||
return a.inner.View()
|
||||
}
|
||||
|
||||
// RunTUI runs an interactive TUI application using the provided Model.
|
||||
// This is the escape hatch for complex interactive UIs that need the
|
||||
// full bubbletea event loop. For simple spinners, progress bars, and
|
||||
// lists, use the dedicated helpers instead.
|
||||
//
|
||||
// err := cli.RunTUI(&myModel{items: items})
|
||||
func RunTUI(m Model) error {
|
||||
p := tea.NewProgram(adapter{inner: m})
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// testModel is a minimal Model that quits immediately.
|
||||
type testModel struct {
|
||||
initCalled bool
|
||||
updateCalled bool
|
||||
viewCalled bool
|
||||
}
|
||||
|
||||
func (m *testModel) Init() Cmd {
|
||||
m.initCalled = true
|
||||
return Quit
|
||||
}
|
||||
|
||||
func (m *testModel) Update(msg Msg) (Model, Cmd) {
|
||||
m.updateCalled = true
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *testModel) View() string {
|
||||
m.viewCalled = true
|
||||
return "test view"
|
||||
}
|
||||
|
||||
func TestModel_Good_InterfaceSatisfied(t *testing.T) {
|
||||
var m Model = &testModel{}
|
||||
assert.NotNil(t, m)
|
||||
}
|
||||
|
||||
func TestQuitCmd_Good_ReturnsQuitMsg(t *testing.T) {
|
||||
cmd := Quit
|
||||
assert.NotNil(t, cmd)
|
||||
}
|
||||
|
||||
func TestKeyMsg_Good_Type(t *testing.T) {
|
||||
// Verify our re-exported KeyType constants match bubbletea's
|
||||
assert.Equal(t, KeyEnter, KeyEnter)
|
||||
assert.Equal(t, KeyEsc, KeyEsc)
|
||||
}
|
||||
|
||||
func TestKeyTypes_Good_Constants(t *testing.T) {
|
||||
// Verify key type constants exist and are distinct
|
||||
keys := []KeyType{KeyEnter, KeyEsc, KeyCtrlC, KeyUp, KeyDown, KeyTab, KeyBackspace}
|
||||
seen := make(map[KeyType]bool)
|
||||
for _, k := range keys {
|
||||
assert.False(t, seen[k], "duplicate key type")
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
505
pkg/cli/utils.go
505
pkg/cli/utils.go
|
|
@ -1,505 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
|
||||
// GhAuthenticated checks if the GitHub CLI is authenticated.
|
||||
// Returns true if 'gh auth status' indicates a logged-in user.
|
||||
func GhAuthenticated() bool {
|
||||
cmd := exec.Command("gh", "auth", "status")
|
||||
output, _ := cmd.CombinedOutput()
|
||||
authenticated := strings.Contains(string(output), "Logged in")
|
||||
|
||||
if authenticated {
|
||||
LogSecurity("GitHub CLI authenticated", "user", log.Username())
|
||||
} else {
|
||||
LogSecurity("GitHub CLI not authenticated", "user", log.Username())
|
||||
}
|
||||
|
||||
return authenticated
|
||||
}
|
||||
|
||||
// ConfirmOption configures Confirm behaviour.
|
||||
type ConfirmOption func(*confirmConfig)
|
||||
|
||||
type confirmConfig struct {
|
||||
defaultYes bool
|
||||
required bool
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// DefaultYes sets the default response to "yes" (pressing Enter confirms).
|
||||
func DefaultYes() ConfirmOption {
|
||||
return func(c *confirmConfig) {
|
||||
c.defaultYes = true
|
||||
}
|
||||
}
|
||||
|
||||
// Required prevents empty responses; user must explicitly type y/n.
|
||||
func Required() ConfirmOption {
|
||||
return func(c *confirmConfig) {
|
||||
c.required = true
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout sets a timeout after which the default response is auto-selected.
|
||||
// If no default is set (not Required and not DefaultYes), defaults to "no".
|
||||
//
|
||||
// Confirm("Continue?", Timeout(30*time.Second)) // Auto-no after 30s
|
||||
// Confirm("Continue?", DefaultYes(), Timeout(10*time.Second)) // Auto-yes after 10s
|
||||
func Timeout(d time.Duration) ConfirmOption {
|
||||
return func(c *confirmConfig) {
|
||||
c.timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm prompts the user for yes/no confirmation.
|
||||
// Returns true if the user enters "y" or "yes" (case-insensitive).
|
||||
//
|
||||
// Basic usage:
|
||||
//
|
||||
// if Confirm("Delete file?") { ... }
|
||||
//
|
||||
// With options:
|
||||
//
|
||||
// if Confirm("Save changes?", DefaultYes()) { ... }
|
||||
// if Confirm("Dangerous!", Required()) { ... }
|
||||
// if Confirm("Auto-continue?", Timeout(30*time.Second)) { ... }
|
||||
func Confirm(prompt string, opts ...ConfirmOption) bool {
|
||||
cfg := &confirmConfig{}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
// Build the prompt suffix
|
||||
var suffix string
|
||||
if cfg.required {
|
||||
suffix = "[y/n] "
|
||||
} else if cfg.defaultYes {
|
||||
suffix = "[Y/n] "
|
||||
} else {
|
||||
suffix = "[y/N] "
|
||||
}
|
||||
|
||||
// Add timeout indicator if set
|
||||
if cfg.timeout > 0 {
|
||||
suffix = fmt.Sprintf("%s(auto in %s) ", suffix, cfg.timeout.Round(time.Second))
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
fmt.Printf("%s %s", prompt, suffix)
|
||||
|
||||
var response string
|
||||
|
||||
if cfg.timeout > 0 {
|
||||
// Use timeout-based reading
|
||||
resultChan := make(chan string, 1)
|
||||
go func() {
|
||||
line, _ := reader.ReadString('\n')
|
||||
resultChan <- line
|
||||
}()
|
||||
|
||||
select {
|
||||
case response = <-resultChan:
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
case <-time.After(cfg.timeout):
|
||||
fmt.Println() // New line after timeout
|
||||
return cfg.defaultYes
|
||||
}
|
||||
} else {
|
||||
response, _ = reader.ReadString('\n')
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
}
|
||||
|
||||
// Handle empty response
|
||||
if response == "" {
|
||||
if cfg.required {
|
||||
continue // Ask again
|
||||
}
|
||||
return cfg.defaultYes
|
||||
}
|
||||
|
||||
// Check for yes/no responses
|
||||
if response == "y" || response == "yes" {
|
||||
return true
|
||||
}
|
||||
if response == "n" || response == "no" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Invalid response
|
||||
if cfg.required {
|
||||
fmt.Println("Please enter 'y' or 'n'")
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-required: treat invalid as default
|
||||
return cfg.defaultYes
|
||||
}
|
||||
}
|
||||
|
||||
// ConfirmAction prompts for confirmation of an action using grammar composition.
|
||||
//
|
||||
// if ConfirmAction("delete", "config.yaml") { ... }
|
||||
// if ConfirmAction("save", "changes", DefaultYes()) { ... }
|
||||
func ConfirmAction(verb, subject string, opts ...ConfirmOption) bool {
|
||||
question := i18n.Title(verb) + " " + subject + "?"
|
||||
return Confirm(question, opts...)
|
||||
}
|
||||
|
||||
// ConfirmDangerousAction prompts for double confirmation of a dangerous action.
|
||||
// Shows initial question, then a "Really verb subject?" confirmation.
|
||||
//
|
||||
// if ConfirmDangerousAction("delete", "config.yaml") { ... }
|
||||
func ConfirmDangerousAction(verb, subject string) bool {
|
||||
question := i18n.Title(verb) + " " + subject + "?"
|
||||
if !Confirm(question, Required()) {
|
||||
return false
|
||||
}
|
||||
|
||||
confirm := "Really " + verb + " " + subject + "?"
|
||||
return Confirm(confirm, Required())
|
||||
}
|
||||
|
||||
// QuestionOption configures Question behaviour.
|
||||
type QuestionOption func(*questionConfig)
|
||||
|
||||
type questionConfig struct {
|
||||
defaultValue string
|
||||
required bool
|
||||
validator func(string) error
|
||||
}
|
||||
|
||||
// WithDefault sets the default value shown in brackets.
|
||||
func WithDefault(value string) QuestionOption {
|
||||
return func(c *questionConfig) {
|
||||
c.defaultValue = value
|
||||
}
|
||||
}
|
||||
|
||||
// WithValidator adds a validation function for the response.
|
||||
func WithValidator(fn func(string) error) QuestionOption {
|
||||
return func(c *questionConfig) {
|
||||
c.validator = fn
|
||||
}
|
||||
}
|
||||
|
||||
// RequiredInput prevents empty responses.
|
||||
func RequiredInput() QuestionOption {
|
||||
return func(c *questionConfig) {
|
||||
c.required = true
|
||||
}
|
||||
}
|
||||
|
||||
// Question prompts the user for text input.
|
||||
//
|
||||
// name := Question("Enter your name:")
|
||||
// name := Question("Enter your name:", WithDefault("Anonymous"))
|
||||
// name := Question("Enter your name:", RequiredInput())
|
||||
func Question(prompt string, opts ...QuestionOption) string {
|
||||
cfg := &questionConfig{}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
// Build prompt with default
|
||||
if cfg.defaultValue != "" {
|
||||
fmt.Printf("%s [%s] ", prompt, cfg.defaultValue)
|
||||
} else {
|
||||
fmt.Printf("%s ", prompt)
|
||||
}
|
||||
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
// Handle empty response
|
||||
if response == "" {
|
||||
if cfg.required {
|
||||
fmt.Println("Response required")
|
||||
continue
|
||||
}
|
||||
response = cfg.defaultValue
|
||||
}
|
||||
|
||||
// Validate if validator provided
|
||||
if cfg.validator != nil {
|
||||
if err := cfg.validator(response); err != nil {
|
||||
fmt.Printf("Invalid: %v\n", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
// QuestionAction prompts for text input using grammar composition.
|
||||
//
|
||||
// name := QuestionAction("rename", "old.txt")
|
||||
func QuestionAction(verb, subject string, opts ...QuestionOption) string {
|
||||
question := i18n.Title(verb) + " " + subject + "?"
|
||||
return Question(question, opts...)
|
||||
}
|
||||
|
||||
// ChooseOption configures Choose behaviour.
|
||||
type ChooseOption[T any] func(*chooseConfig[T])
|
||||
|
||||
type chooseConfig[T any] struct {
|
||||
displayFn func(T) string
|
||||
defaultN int // 0-based index of default selection
|
||||
filter bool // Enable fuzzy filtering
|
||||
multi bool // Allow multiple selection
|
||||
}
|
||||
|
||||
// WithDisplay sets a custom display function for items.
|
||||
func WithDisplay[T any](fn func(T) string) ChooseOption[T] {
|
||||
return func(c *chooseConfig[T]) {
|
||||
c.displayFn = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaultIndex sets the default selection index (0-based).
|
||||
func WithDefaultIndex[T any](idx int) ChooseOption[T] {
|
||||
return func(c *chooseConfig[T]) {
|
||||
c.defaultN = idx
|
||||
}
|
||||
}
|
||||
|
||||
// Filter enables type-to-filter functionality.
|
||||
// Users can type to narrow down the list of options.
|
||||
// Note: This is a hint for interactive UIs; the basic CLI Choose
|
||||
// implementation uses numbered selection which doesn't support filtering.
|
||||
func Filter[T any]() ChooseOption[T] {
|
||||
return func(c *chooseConfig[T]) {
|
||||
c.filter = true
|
||||
}
|
||||
}
|
||||
|
||||
// Multi allows multiple selections.
|
||||
// Use ChooseMulti instead of Choose when this option is needed.
|
||||
func Multi[T any]() ChooseOption[T] {
|
||||
return func(c *chooseConfig[T]) {
|
||||
c.multi = true
|
||||
}
|
||||
}
|
||||
|
||||
// Display sets a custom display function for items.
|
||||
// Alias for WithDisplay for shorter syntax.
|
||||
//
|
||||
// Choose("Select:", items, Display(func(f File) string { return f.Name }))
|
||||
func Display[T any](fn func(T) string) ChooseOption[T] {
|
||||
return WithDisplay[T](fn)
|
||||
}
|
||||
|
||||
// Choose prompts the user to select from a list of items.
|
||||
// Returns the selected item. Uses simple numbered selection for terminal compatibility.
|
||||
//
|
||||
// choice := Choose("Select a file:", files)
|
||||
// choice := Choose("Select a file:", files, WithDisplay(func(f File) string { return f.Name }))
|
||||
func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
|
||||
var zero T
|
||||
if len(items) == 0 {
|
||||
return zero
|
||||
}
|
||||
|
||||
cfg := &chooseConfig[T]{
|
||||
displayFn: func(item T) string { return fmt.Sprint(item) },
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
// Display options
|
||||
fmt.Println(prompt)
|
||||
for i, item := range items {
|
||||
marker := " "
|
||||
if i == cfg.defaultN {
|
||||
marker = "*"
|
||||
}
|
||||
fmt.Printf(" %s%d. %s\n", marker, i+1, cfg.displayFn(item))
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
fmt.Printf("Enter number [1-%d]: ", len(items))
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
// Empty response uses default
|
||||
if response == "" {
|
||||
return items[cfg.defaultN]
|
||||
}
|
||||
|
||||
// Parse number
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(response, "%d", &n); err == nil {
|
||||
if n >= 1 && n <= len(items) {
|
||||
return items[n-1]
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Please enter a number between 1 and %d\n", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
// ChooseAction prompts for selection using grammar composition.
|
||||
//
|
||||
// file := ChooseAction("select", "file", files)
|
||||
func ChooseAction[T any](verb, subject string, items []T, opts ...ChooseOption[T]) T {
|
||||
question := i18n.Title(verb) + " " + subject + ":"
|
||||
return Choose(question, items, opts...)
|
||||
}
|
||||
|
||||
// ChooseMulti prompts the user to select multiple items from a list.
|
||||
// Returns the selected items. Uses space-separated numbers or ranges.
|
||||
//
|
||||
// choices := ChooseMulti("Select files:", files)
|
||||
// choices := ChooseMulti("Select files:", files, WithDisplay(func(f File) string { return f.Name }))
|
||||
//
|
||||
// Input format:
|
||||
// - "1 3 5" - select items 1, 3, and 5
|
||||
// - "1-3" - select items 1, 2, and 3
|
||||
// - "1 3-5" - select items 1, 3, 4, and 5
|
||||
// - "" (empty) - select none
|
||||
func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := &chooseConfig[T]{
|
||||
displayFn: func(item T) string { return fmt.Sprint(item) },
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
// Display options
|
||||
fmt.Println(prompt)
|
||||
for i, item := range items {
|
||||
fmt.Printf(" %d. %s\n", i+1, cfg.displayFn(item))
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
fmt.Printf("Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
// Empty response returns no selections
|
||||
if response == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the selection
|
||||
selected, err := parseMultiSelection(response, len(items))
|
||||
if err != nil {
|
||||
fmt.Printf("Invalid selection: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Build result
|
||||
result := make([]T, 0, len(selected))
|
||||
for _, idx := range selected {
|
||||
result = append(result, items[idx])
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// parseMultiSelection parses a multi-selection string like "1 3 5" or "1-3 5".
|
||||
// Returns 0-based indices.
|
||||
func parseMultiSelection(input string, maxItems int) ([]int, error) {
|
||||
selected := make(map[int]bool)
|
||||
parts := strings.Fields(input)
|
||||
|
||||
for _, part := range parts {
|
||||
// Check for range (e.g., "1-3")
|
||||
if strings.Contains(part, "-") {
|
||||
rangeParts := strings.Split(part, "-")
|
||||
if len(rangeParts) != 2 {
|
||||
return nil, fmt.Errorf("invalid range: %s", part)
|
||||
}
|
||||
var start, end int
|
||||
if _, err := fmt.Sscanf(rangeParts[0], "%d", &start); err != nil {
|
||||
return nil, fmt.Errorf("invalid range start: %s", rangeParts[0])
|
||||
}
|
||||
if _, err := fmt.Sscanf(rangeParts[1], "%d", &end); err != nil {
|
||||
return nil, fmt.Errorf("invalid range end: %s", rangeParts[1])
|
||||
}
|
||||
if start < 1 || start > maxItems || end < 1 || end > maxItems || start > end {
|
||||
return nil, fmt.Errorf("range out of bounds: %s", part)
|
||||
}
|
||||
for i := start; i <= end; i++ {
|
||||
selected[i-1] = true // Convert to 0-based
|
||||
}
|
||||
} else {
|
||||
// Single number
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(part, "%d", &n); err != nil {
|
||||
return nil, fmt.Errorf("invalid number: %s", part)
|
||||
}
|
||||
if n < 1 || n > maxItems {
|
||||
return nil, fmt.Errorf("number out of range: %d", n)
|
||||
}
|
||||
selected[n-1] = true // Convert to 0-based
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to sorted slice
|
||||
result := make([]int, 0, len(selected))
|
||||
for i := 0; i < maxItems; i++ {
|
||||
if selected[i] {
|
||||
result = append(result, i)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ChooseMultiAction prompts for multiple selections using grammar composition.
|
||||
//
|
||||
// files := ChooseMultiAction("select", "files", files)
|
||||
func ChooseMultiAction[T any](verb, subject string, items []T, opts ...ChooseOption[T]) []T {
|
||||
question := i18n.Title(verb) + " " + subject + ":"
|
||||
return ChooseMulti(question, items, opts...)
|
||||
}
|
||||
|
||||
// GitClone clones a GitHub repository to the specified path.
|
||||
// Prefers 'gh repo clone' if authenticated, falls back to SSH.
|
||||
func GitClone(ctx context.Context, org, repo, path string) error {
|
||||
if GhAuthenticated() {
|
||||
httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo)
|
||||
cmd := exec.CommandContext(ctx, "gh", "repo", "clone", httpsURL, path)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
errStr := strings.TrimSpace(string(output))
|
||||
if strings.Contains(errStr, "already exists") {
|
||||
return fmt.Errorf("%s", errStr)
|
||||
}
|
||||
}
|
||||
// Fall back to SSH clone
|
||||
cmd := exec.CommandContext(ctx, "git", "clone", fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// viewportModel is the internal bubbletea model for scrollable content.
|
||||
type viewportModel struct {
|
||||
title string
|
||||
lines []string
|
||||
offset int
|
||||
height int
|
||||
quitted bool
|
||||
}
|
||||
|
||||
func newViewportModel(content, title string, height int) *viewportModel {
|
||||
lines := strings.Split(content, "\n")
|
||||
return &viewportModel{
|
||||
title: title,
|
||||
lines: lines,
|
||||
height: height,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *viewportModel) scrollDown() {
|
||||
maxOffset := len(m.lines) - m.height
|
||||
if maxOffset < 0 {
|
||||
maxOffset = 0
|
||||
}
|
||||
if m.offset < maxOffset {
|
||||
m.offset++
|
||||
}
|
||||
}
|
||||
|
||||
func (m *viewportModel) scrollUp() {
|
||||
if m.offset > 0 {
|
||||
m.offset--
|
||||
}
|
||||
}
|
||||
|
||||
func (m *viewportModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *viewportModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyUp:
|
||||
m.scrollUp()
|
||||
case tea.KeyDown:
|
||||
m.scrollDown()
|
||||
case tea.KeyPgUp:
|
||||
for i := 0; i < m.height; i++ {
|
||||
m.scrollUp()
|
||||
}
|
||||
case tea.KeyPgDown:
|
||||
for i := 0; i < m.height; i++ {
|
||||
m.scrollDown()
|
||||
}
|
||||
case tea.KeyHome:
|
||||
m.offset = 0
|
||||
case tea.KeyEnd:
|
||||
maxOffset := len(m.lines) - m.height
|
||||
if maxOffset < 0 {
|
||||
maxOffset = 0
|
||||
}
|
||||
m.offset = maxOffset
|
||||
case tea.KeyEscape, tea.KeyCtrlC:
|
||||
m.quitted = true
|
||||
return m, tea.Quit
|
||||
case tea.KeyRunes:
|
||||
switch string(msg.Runes) {
|
||||
case "q":
|
||||
m.quitted = true
|
||||
return m, tea.Quit
|
||||
case "j":
|
||||
m.scrollDown()
|
||||
case "k":
|
||||
m.scrollUp()
|
||||
case "g":
|
||||
m.offset = 0
|
||||
case "G":
|
||||
maxOffset := len(m.lines) - m.height
|
||||
if maxOffset < 0 {
|
||||
maxOffset = 0
|
||||
}
|
||||
m.offset = maxOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *viewportModel) View() string {
|
||||
var sb strings.Builder
|
||||
|
||||
if m.title != "" {
|
||||
sb.WriteString(BoldStyle.Render(m.title) + "\n")
|
||||
sb.WriteString(DimStyle.Render(strings.Repeat("\u2500", len(m.title))) + "\n")
|
||||
}
|
||||
|
||||
// Visible window
|
||||
end := m.offset + m.height
|
||||
if end > len(m.lines) {
|
||||
end = len(m.lines)
|
||||
}
|
||||
for _, line := range m.lines[m.offset:end] {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
|
||||
// Scroll indicator
|
||||
total := len(m.lines)
|
||||
if total > m.height {
|
||||
pct := (m.offset * 100) / (total - m.height)
|
||||
sb.WriteString(DimStyle.Render(fmt.Sprintf("\n%d%% (%d/%d lines)", pct, m.offset+m.height, total)))
|
||||
}
|
||||
|
||||
sb.WriteString("\n" + DimStyle.Render("\u2191/\u2193 scroll \u2022 PgUp/PgDn page \u2022 q quit"))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ViewportOption configures Viewport behaviour.
|
||||
type ViewportOption func(*viewportConfig)
|
||||
|
||||
type viewportConfig struct {
|
||||
title string
|
||||
height int
|
||||
}
|
||||
|
||||
// WithViewportTitle sets the title shown above the viewport.
|
||||
func WithViewportTitle(title string) ViewportOption {
|
||||
return func(c *viewportConfig) {
|
||||
c.title = title
|
||||
}
|
||||
}
|
||||
|
||||
// WithViewportHeight sets the visible height in lines.
|
||||
func WithViewportHeight(n int) ViewportOption {
|
||||
return func(c *viewportConfig) {
|
||||
c.height = n
|
||||
}
|
||||
}
|
||||
|
||||
// Viewport displays scrollable content in the terminal.
|
||||
// Falls back to printing the full content when stdin is not a terminal.
|
||||
//
|
||||
// cli.Viewport(longContent, WithViewportTitle("Build Log"), WithViewportHeight(20))
|
||||
func Viewport(content string, opts ...ViewportOption) error {
|
||||
cfg := &viewportConfig{
|
||||
height: 20,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
// Fall back to plain output if not a terminal
|
||||
if !term.IsTerminal(0) {
|
||||
if cfg.title != "" {
|
||||
fmt.Println(BoldStyle.Render(cfg.title))
|
||||
fmt.Println(DimStyle.Render(strings.Repeat("\u2500", len(cfg.title))))
|
||||
}
|
||||
fmt.Println(content)
|
||||
return nil
|
||||
}
|
||||
|
||||
m := newViewportModel(content, cfg.title, cfg.height)
|
||||
p := tea.NewProgram(m)
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestViewportModel_Good_Create(t *testing.T) {
|
||||
content := "line 1\nline 2\nline 3"
|
||||
m := newViewportModel(content, "Title", 5)
|
||||
assert.Equal(t, "Title", m.title)
|
||||
assert.Equal(t, 3, len(m.lines))
|
||||
assert.Equal(t, 0, m.offset)
|
||||
}
|
||||
|
||||
func TestViewportModel_Good_ScrollDown(t *testing.T) {
|
||||
lines := make([]string, 20)
|
||||
for i := range lines {
|
||||
lines[i] = strings.Repeat("x", 10)
|
||||
}
|
||||
m := newViewportModel(strings.Join(lines, "\n"), "", 5)
|
||||
m.scrollDown()
|
||||
assert.Equal(t, 1, m.offset)
|
||||
}
|
||||
|
||||
func TestViewportModel_Good_ScrollUp(t *testing.T) {
|
||||
lines := make([]string, 20)
|
||||
for i := range lines {
|
||||
lines[i] = strings.Repeat("x", 10)
|
||||
}
|
||||
m := newViewportModel(strings.Join(lines, "\n"), "", 5)
|
||||
m.scrollDown()
|
||||
m.scrollDown()
|
||||
m.scrollUp()
|
||||
assert.Equal(t, 1, m.offset)
|
||||
}
|
||||
|
||||
func TestViewportModel_Good_NoScrollPastTop(t *testing.T) {
|
||||
m := newViewportModel("a\nb\nc", "", 5)
|
||||
m.scrollUp() // Already at top
|
||||
assert.Equal(t, 0, m.offset)
|
||||
}
|
||||
|
||||
func TestViewportModel_Good_NoScrollPastBottom(t *testing.T) {
|
||||
m := newViewportModel("a\nb\nc", "", 5)
|
||||
for i := 0; i < 10; i++ {
|
||||
m.scrollDown()
|
||||
}
|
||||
// Should clamp -- can't scroll past content
|
||||
assert.GreaterOrEqual(t, m.offset, 0)
|
||||
}
|
||||
|
||||
func TestViewportModel_Good_View(t *testing.T) {
|
||||
m := newViewportModel("line 1\nline 2", "My Title", 10)
|
||||
view := m.View()
|
||||
assert.Contains(t, view, "My Title")
|
||||
assert.Contains(t, view, "line 1")
|
||||
assert.Contains(t, view, "line 2")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue