Move pkg/cli from core/go to core/cli. Includes Frame AppShell, Stream, TaskTracker, Tree, Rich Table. Update imports to v0.0.1 tagged deps and fix openpgp import path for go-crypt split. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7303ba6f23
commit
6a8bd92189
59 changed files with 6073 additions and 80 deletions
|
|
@ -1,6 +1,6 @@
|
|||
package config
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/cli"
|
||||
import "forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
||||
// AddConfigCommands registers the 'config' command group and all subcommands.
|
||||
func AddConfigCommands(root *cli.Command) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package config
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/config"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package config
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package config
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
func addPathCommand(parent *cli.Command) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
func addSetCommand(parent *cli.Command) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ package doctor
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package help
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/help"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/marketplace"
|
||||
"forge.lthn.ai/core/go/pkg/store"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/marketplace"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package module
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package module
|
||||
|
||||
import (
|
||||
"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 (
|
|||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"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 (
|
|||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/plugin"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/plugin"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package plugin
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/plugin"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/plugin"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/plugin"
|
||||
|
|
|
|||
|
|
@ -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/session"
|
||||
)
|
||||
|
||||
|
|
|
|||
25
go.mod
25
go.mod
|
|
@ -3,55 +3,42 @@ module forge.lthn.ai/core/cli
|
|||
go 1.26.0
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go v0.0.0-20260221220640-2a90ae65b7c7
|
||||
forge.lthn.ai/core/go-crypt v0.0.0-20260221193816-fde12e1539b2 // indirect
|
||||
forge.lthn.ai/core/go v0.0.1
|
||||
forge.lthn.ai/core/go-crypt v0.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/term v0.40.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
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/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.21.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
modernc.org/libc v1.67.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
|
|
|||
44
go.sum
44
go.sum
|
|
@ -1,25 +1,9 @@
|
|||
forge.lthn.ai/core/go v0.0.0-20260221191103-d091fa62023f h1:CcSh/FFY93K5m0vADHLxwxKn2pTIM8HzYX1eGa4WZf4=
|
||||
forge.lthn.ai/core/go v0.0.0-20260221191103-d091fa62023f/go.mod h1:WCPJVEZm/6mTcJimHV0uX8ZhnKEF3dN0rQp13ByaSPg=
|
||||
forge.lthn.ai/core/go v0.0.0-20260221220640-2a90ae65b7c7 h1:UhlJo4QeqKD0IM0wKjOh8H3OaDnvdl5m7psozRXJdO8=
|
||||
forge.lthn.ai/core/go v0.0.0-20260221220640-2a90ae65b7c7/go.mod h1:WCPJVEZm/6mTcJimHV0uX8ZhnKEF3dN0rQp13ByaSPg=
|
||||
forge.lthn.ai/core/go-crypt v0.0.0-20260221193816-fde12e1539b2 h1:2eXqQXF+1AyitPJox9Yjewb6w8fO0JHFw7gPqk8WqIM=
|
||||
forge.lthn.ai/core/go-crypt v0.0.0-20260221193816-fde12e1539b2/go.mod h1:o4vkJgoT9u+r7DR42LIJHW6L5vMS3Au8gaaCA5Cved0=
|
||||
forge.lthn.ai/core/go v0.0.1 h1:ubk4nmkA3treOUNgPS28wKd1jB6cUlEQUV7jDdGa3zM=
|
||||
forge.lthn.ai/core/go v0.0.1/go.mod h1:59YsnuMaAGQUxIhX68oK2/HnhQJEPWL1iEZhDTrNCbY=
|
||||
forge.lthn.ai/core/go-crypt v0.0.1 h1:i8CFFbpda528HL9uUcGvvRHsXSbX/j8FezGRKHBg2dA=
|
||||
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/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
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=
|
||||
|
|
@ -27,8 +11,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
|||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
|
|
@ -49,20 +31,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
|
|
@ -71,9 +41,6 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
|
|
@ -94,8 +61,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
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=
|
||||
|
|
@ -106,7 +71,6 @@ golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
|||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
|
|
|
|||
2
main.go
2
main.go
|
|
@ -8,7 +8,7 @@ import (
|
|||
"forge.lthn.ai/core/cli/cmd/pkgcmd"
|
||||
"forge.lthn.ai/core/cli/cmd/plugin"
|
||||
"forge.lthn.ai/core/cli/cmd/session"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
|
|||
163
pkg/cli/ansi.go
Normal file
163
pkg/cli/ansi.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
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)
|
||||
}
|
||||
97
pkg/cli/ansi_test.go
Normal file
97
pkg/cli/ansi_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
163
pkg/cli/app.go
Normal file
163
pkg/cli/app.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"forge.lthn.ai/core/go-crypt/crypt/openpgp"
|
||||
"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"
|
||||
)
|
||||
|
||||
// AppName is the default CLI application name.
|
||||
// Override with WithAppName before calling Main.
|
||||
var AppName = "core"
|
||||
|
||||
// Build-time variables set via ldflags (SemVer 2.0.0):
|
||||
//
|
||||
// go build -ldflags="-X forge.lthn.ai/core/cli/pkg/cli.AppVersion=1.2.0 \
|
||||
// -X forge.lthn.ai/core/cli/pkg/cli.BuildCommit=df94c24 \
|
||||
// -X forge.lthn.ai/core/cli/pkg/cli.BuildDate=2026-02-06 \
|
||||
// -X forge.lthn.ai/core/cli/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
|
||||
}
|
||||
|
||||
// WithAppName sets the application name used in help text and shell completion.
|
||||
// Call before Main for variant binaries (e.g. "lem", "devops").
|
||||
//
|
||||
// cli.WithAppName("lem")
|
||||
// cli.Main()
|
||||
func WithAppName(name string) {
|
||||
AppName = name
|
||||
}
|
||||
|
||||
// Main initialises and runs the CLI application.
|
||||
// This is the main entry point for the CLI.
|
||||
// Exits with code 1 on error or panic.
|
||||
func Main() {
|
||||
// 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))
|
||||
}
|
||||
}()
|
||||
|
||||
// Initialise CLI runtime with services
|
||||
if err := Init(Options{
|
||||
AppName: AppName,
|
||||
Version: SemVer(),
|
||||
Services: []framework.Option{
|
||||
framework.WithName("i18n", NewI18nService(I18nOptions{})),
|
||||
framework.WithName("log", NewLogService(log.Options{
|
||||
Level: log.LevelInfo,
|
||||
})),
|
||||
framework.WithName("crypt", openpgp.New),
|
||||
framework.WithName("workspace", workspace.New),
|
||||
},
|
||||
}); err != nil {
|
||||
Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
defer Shutdown()
|
||||
|
||||
// Add completion command to the CLI's root
|
||||
RootCmd().AddCommand(newCompletionCmd())
|
||||
|
||||
if err := Execute(); err != nil {
|
||||
code := 1
|
||||
var exitErr *ExitError
|
||||
if As(err, &exitErr) {
|
||||
code = exitErr.Code
|
||||
}
|
||||
Error(err.Error())
|
||||
os.Exit(code)
|
||||
}
|
||||
}
|
||||
|
||||
// newCompletionCmd creates the shell completion command using the current AppName.
|
||||
func newCompletionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
Short: "Generate shell completion script",
|
||||
Long: fmt.Sprintf(`Generate shell completion script for the specified shell.
|
||||
|
||||
To load completions:
|
||||
|
||||
Bash:
|
||||
$ source <(%s completion bash)
|
||||
|
||||
# To load completions for each session, execute once:
|
||||
# Linux:
|
||||
$ %s completion bash > /etc/bash_completion.d/%s
|
||||
# macOS:
|
||||
$ %s completion bash > $(brew --prefix)/etc/bash_completion.d/%s
|
||||
|
||||
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:
|
||||
$ %s completion zsh > "${fpath[1]}/_%s"
|
||||
|
||||
# You will need to start a new shell for this setup to take effect.
|
||||
|
||||
Fish:
|
||||
$ %s completion fish | source
|
||||
|
||||
# To load completions for each session, execute once:
|
||||
$ %s completion fish > ~/.config/fish/completions/%s.fish
|
||||
|
||||
PowerShell:
|
||||
PS> %s completion powershell | Out-String | Invoke-Expression
|
||||
|
||||
# To load completions for every new session, run:
|
||||
PS> %s completion powershell > %s.ps1
|
||||
# and source this file from your PowerShell profile.
|
||||
`, AppName, AppName, AppName, AppName, AppName,
|
||||
AppName, AppName, AppName, AppName, AppName,
|
||||
AppName, AppName, AppName),
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
164
pkg/cli/app_test.go
Normal file
164
pkg/cli/app_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
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())
|
||||
})
|
||||
}
|
||||
91
pkg/cli/check.go
Normal file
91
pkg/cli/check.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
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())
|
||||
}
|
||||
49
pkg/cli/check_test.go
Normal file
49
pkg/cli/check_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
210
pkg/cli/command.go
Normal file
210
pkg/cli/command.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
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
|
||||
}
|
||||
|
||||
// NewPassthrough creates a command that passes all arguments (including flags)
|
||||
// to the given function. Used for commands that do their own flag parsing
|
||||
// (e.g. incremental migration from flag.FlagSet to cobra).
|
||||
//
|
||||
// cmd := cli.NewPassthrough("train", "Train a model", func(args []string) {
|
||||
// // args includes all flags: ["--model", "gemma-3-1b", "--epochs", "10"]
|
||||
// fs := flag.NewFlagSet("train", flag.ExitOnError)
|
||||
// // ...
|
||||
// })
|
||||
func NewPassthrough(use, short string, fn func(args []string)) *Command {
|
||||
cmd := NewRun(use, short, "", func(_ *Command, args []string) {
|
||||
fn(args)
|
||||
})
|
||||
cmd.DisableFlagParsing = true
|
||||
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
|
||||
}
|
||||
50
pkg/cli/commands.go
Normal file
50
pkg/cli/commands.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// Package cli provides the CLI runtime and utilities.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// CommandRegistration is a function that adds commands to the root.
|
||||
type CommandRegistration func(root *cobra.Command)
|
||||
|
||||
var (
|
||||
registeredCommands []CommandRegistration
|
||||
registeredCommandsMu sync.Mutex
|
||||
commandsAttached bool
|
||||
)
|
||||
|
||||
// RegisterCommands registers a function that adds commands to the CLI.
|
||||
// Call this in your package's init() to register commands.
|
||||
//
|
||||
// func init() {
|
||||
// cli.RegisterCommands(AddCommands)
|
||||
// }
|
||||
//
|
||||
// func AddCommands(root *cobra.Command) {
|
||||
// root.AddCommand(myCmd)
|
||||
// }
|
||||
func RegisterCommands(fn CommandRegistration) {
|
||||
registeredCommandsMu.Lock()
|
||||
defer registeredCommandsMu.Unlock()
|
||||
registeredCommands = append(registeredCommands, fn)
|
||||
|
||||
// If commands already attached (CLI already running), attach immediately
|
||||
if commandsAttached && instance != nil && instance.root != nil {
|
||||
fn(instance.root)
|
||||
}
|
||||
}
|
||||
|
||||
// attachRegisteredCommands calls all registered command functions.
|
||||
// Called by Init() after creating the root command.
|
||||
func attachRegisteredCommands(root *cobra.Command) {
|
||||
registeredCommandsMu.Lock()
|
||||
defer registeredCommandsMu.Unlock()
|
||||
|
||||
for _, fn := range registeredCommands {
|
||||
fn(root)
|
||||
}
|
||||
commandsAttached = true
|
||||
}
|
||||
185
pkg/cli/commands_test.go
Normal file
185
pkg/cli/commands_test.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// resetGlobals clears the CLI singleton and command registry for test isolation.
|
||||
func resetGlobals(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
// Restore clean state after each test.
|
||||
registeredCommandsMu.Lock()
|
||||
registeredCommands = nil
|
||||
commandsAttached = false
|
||||
registeredCommandsMu.Unlock()
|
||||
if instance != nil {
|
||||
Shutdown()
|
||||
}
|
||||
instance = nil
|
||||
once = sync.Once{}
|
||||
})
|
||||
|
||||
registeredCommandsMu.Lock()
|
||||
registeredCommands = nil
|
||||
commandsAttached = false
|
||||
registeredCommandsMu.Unlock()
|
||||
if instance != nil {
|
||||
Shutdown()
|
||||
}
|
||||
instance = nil
|
||||
once = sync.Once{}
|
||||
}
|
||||
|
||||
// TestRegisterCommands_Good tests the happy path for command registration.
|
||||
func TestRegisterCommands_Good(t *testing.T) {
|
||||
t.Run("registers on startup", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
RegisterCommands(func(root *cobra.Command) {
|
||||
root.AddCommand(&cobra.Command{Use: "hello", Short: "Say hello"})
|
||||
})
|
||||
|
||||
err := Init(Options{AppName: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The "hello" command should be on the root.
|
||||
cmd, _, err := RootCmd().Find([]string{"hello"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello", cmd.Use)
|
||||
})
|
||||
|
||||
t.Run("multiple groups compose", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
RegisterCommands(func(root *cobra.Command) {
|
||||
root.AddCommand(&cobra.Command{Use: "alpha", Short: "Alpha"})
|
||||
})
|
||||
RegisterCommands(func(root *cobra.Command) {
|
||||
root.AddCommand(&cobra.Command{Use: "beta", Short: "Beta"})
|
||||
})
|
||||
|
||||
err := Init(Options{AppName: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, name := range []string{"alpha", "beta"} {
|
||||
cmd, _, err := RootCmd().Find([]string{name})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, name, cmd.Use)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("group with subcommands", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
RegisterCommands(func(root *cobra.Command) {
|
||||
grp := &cobra.Command{Use: "ml", Short: "ML commands"}
|
||||
grp.AddCommand(&cobra.Command{Use: "train", Short: "Train a model"})
|
||||
grp.AddCommand(&cobra.Command{Use: "serve", Short: "Serve a model"})
|
||||
root.AddCommand(grp)
|
||||
})
|
||||
|
||||
err := Init(Options{AppName: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd, _, err := RootCmd().Find([]string{"ml", "train"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "train", cmd.Use)
|
||||
|
||||
cmd, _, err = RootCmd().Find([]string{"ml", "serve"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "serve", cmd.Use)
|
||||
})
|
||||
|
||||
t.Run("executes registered command", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
executed := false
|
||||
RegisterCommands(func(root *cobra.Command) {
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "ping",
|
||||
Short: "Ping",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
executed = true
|
||||
return nil
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
err := Init(Options{AppName: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
RootCmd().SetArgs([]string{"ping"})
|
||||
err = Execute()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, executed, "registered command should have been executed")
|
||||
})
|
||||
}
|
||||
|
||||
// TestRegisterCommands_Bad tests expected error conditions.
|
||||
func TestRegisterCommands_Bad(t *testing.T) {
|
||||
t.Run("late registration attaches immediately", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
err := Init(Options{AppName: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Register after Init — should attach immediately.
|
||||
RegisterCommands(func(root *cobra.Command) {
|
||||
root.AddCommand(&cobra.Command{Use: "late", Short: "Late arrival"})
|
||||
})
|
||||
|
||||
cmd, _, err := RootCmd().Find([]string{"late"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "late", cmd.Use)
|
||||
})
|
||||
}
|
||||
|
||||
// TestWithAppName_Good tests the app name override.
|
||||
func TestWithAppName_Good(t *testing.T) {
|
||||
t.Run("overrides root command use", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
WithAppName("lem")
|
||||
defer WithAppName("core") // restore
|
||||
|
||||
err := Init(Options{AppName: AppName})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "lem", RootCmd().Use)
|
||||
})
|
||||
|
||||
t.Run("default is core", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
err := Init(Options{AppName: AppName})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "core", RootCmd().Use)
|
||||
})
|
||||
}
|
||||
|
||||
// TestNewPassthrough_Good tests the passthrough command builder.
|
||||
func TestNewPassthrough_Good(t *testing.T) {
|
||||
t.Run("passes all args including flags", func(t *testing.T) {
|
||||
var received []string
|
||||
cmd := NewPassthrough("train", "Train", func(args []string) {
|
||||
received = args
|
||||
})
|
||||
|
||||
cmd.SetArgs([]string{"--model", "gemma", "--epochs", "10"})
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"--model", "gemma", "--epochs", "10"}, received)
|
||||
})
|
||||
|
||||
t.Run("flag parsing is disabled", func(t *testing.T) {
|
||||
cmd := NewPassthrough("run", "Run", func(_ []string) {})
|
||||
assert.True(t, cmd.DisableFlagParsing)
|
||||
})
|
||||
}
|
||||
446
pkg/cli/daemon.go
Normal file
446
pkg/cli/daemon.go
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
// 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 {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewPIDFile creates a PID file manager.
|
||||
func NewPIDFile(path string) *PIDFile {
|
||||
return &PIDFile{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 := io.Local.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
|
||||
_ = io.Local.Delete(p.path)
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if dir := filepath.Dir(p.path); dir != "." {
|
||||
if err := io.Local.EnsureDir(dir); err != nil {
|
||||
return fmt.Errorf("failed to create PID directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write current PID
|
||||
pid := os.Getpid()
|
||||
if err := io.Local.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 io.Local.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 {
|
||||
// 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.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")
|
||||
}
|
||||
}
|
||||
}
|
||||
234
pkg/cli/daemon_test.go
Normal file
234
pkg/cli/daemon_test.go
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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) {
|
||||
pidPath := filepath.Join(t.TempDir(), "test.pid")
|
||||
|
||||
pid := NewPIDFile(pidPath)
|
||||
|
||||
err := pid.Acquire()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = pid.Release()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("stale pid file", func(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "stale.pid")
|
||||
|
||||
// Write a stale PID (non-existent process).
|
||||
require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644))
|
||||
|
||||
pid := NewPIDFile(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) {
|
||||
pidPath := filepath.Join(t.TempDir(), "subdir", "nested", "test.pid")
|
||||
|
||||
pid := NewPIDFile(pidPath)
|
||||
|
||||
err := pid.Acquire()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = pid.Release()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("path getter", func(t *testing.T) {
|
||||
pid := NewPIDFile("/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) {
|
||||
pidPath := filepath.Join(t.TempDir(), "test.pid")
|
||||
|
||||
d := NewDaemon(DaemonOptions{
|
||||
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)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
162
pkg/cli/errors.go
Normal file
162
pkg/cli/errors.go
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
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)
|
||||
}
|
||||
358
pkg/cli/frame.go
Normal file
358
pkg/cli/frame.go
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Model is the interface for components that slot into Frame regions.
|
||||
// View receives the allocated width and height and returns rendered text.
|
||||
type Model interface {
|
||||
View(width, height int) string
|
||||
}
|
||||
|
||||
// ModelFunc is a convenience adapter for using a function as a Model.
|
||||
type ModelFunc func(width, height int) string
|
||||
|
||||
// View implements Model.
|
||||
func (f ModelFunc) View(width, height int) string { return f(width, height) }
|
||||
|
||||
// Frame is a live compositional AppShell for TUI.
|
||||
// Uses HLCRF variant strings for region layout — same as the static Layout system,
|
||||
// but with live-updating Model components instead of static strings.
|
||||
//
|
||||
// frame := cli.NewFrame("HCF")
|
||||
// frame.Header(cli.StatusLine("core dev", "18 repos", "main"))
|
||||
// frame.Content(myTableModel)
|
||||
// frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit"))
|
||||
// frame.Run()
|
||||
type Frame struct {
|
||||
variant string
|
||||
layout *Composite
|
||||
models map[Region]Model
|
||||
history []Model // content region stack for Navigate/Back
|
||||
out io.Writer
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewFrame creates a new Frame with the given HLCRF variant string.
|
||||
//
|
||||
// frame := cli.NewFrame("HCF") // header, content, footer
|
||||
// frame := cli.NewFrame("H[LC]F") // header, [left + content], footer
|
||||
func NewFrame(variant string) *Frame {
|
||||
return &Frame{
|
||||
variant: variant,
|
||||
layout: Layout(variant),
|
||||
models: make(map[Region]Model),
|
||||
out: os.Stdout,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Header sets the Header region model.
|
||||
func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f }
|
||||
|
||||
// Left sets the Left sidebar region model.
|
||||
func (f *Frame) Left(m Model) *Frame { f.setModel(RegionLeft, m); return f }
|
||||
|
||||
// Content sets the Content region model.
|
||||
func (f *Frame) Content(m Model) *Frame { f.setModel(RegionContent, m); return f }
|
||||
|
||||
// Right sets the Right sidebar region model.
|
||||
func (f *Frame) Right(m Model) *Frame { f.setModel(RegionRight, m); return f }
|
||||
|
||||
// Footer sets the Footer region model.
|
||||
func (f *Frame) Footer(m Model) *Frame { f.setModel(RegionFooter, m); return f }
|
||||
|
||||
func (f *Frame) setModel(r Region, m Model) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.models[r] = m
|
||||
}
|
||||
|
||||
// Navigate replaces the Content region with a new model, pushing the current one
|
||||
// onto the history stack for Back().
|
||||
func (f *Frame) Navigate(m Model) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if current, ok := f.models[RegionContent]; ok {
|
||||
f.history = append(f.history, current)
|
||||
}
|
||||
f.models[RegionContent] = m
|
||||
}
|
||||
|
||||
// Back pops the content history stack, restoring the previous Content model.
|
||||
// Returns false if the history is empty.
|
||||
func (f *Frame) Back() bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.history) == 0 {
|
||||
return false
|
||||
}
|
||||
f.models[RegionContent] = f.history[len(f.history)-1]
|
||||
f.history = f.history[:len(f.history)-1]
|
||||
return true
|
||||
}
|
||||
|
||||
// Stop signals the Frame to exit its Run loop.
|
||||
func (f *Frame) Stop() {
|
||||
select {
|
||||
case <-f.done:
|
||||
default:
|
||||
close(f.done)
|
||||
}
|
||||
}
|
||||
|
||||
// Run renders the frame and blocks. In TTY mode, it live-refreshes at ~12fps.
|
||||
// In non-TTY mode, it renders once and returns immediately.
|
||||
func (f *Frame) Run() {
|
||||
if !f.isTTY() {
|
||||
fmt.Fprint(f.out, f.String())
|
||||
return
|
||||
}
|
||||
f.runLive()
|
||||
}
|
||||
|
||||
// RunFor runs the frame for a fixed duration, then stops.
|
||||
// Useful for dashboards that refresh periodically.
|
||||
func (f *Frame) RunFor(d time.Duration) {
|
||||
go func() {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
f.Stop()
|
||||
case <-f.done:
|
||||
}
|
||||
}()
|
||||
f.Run()
|
||||
}
|
||||
|
||||
// String renders the frame as a static string (no ANSI, no live updates).
|
||||
// This is the non-TTY fallback path.
|
||||
func (f *Frame) String() string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
w, h := f.termSize()
|
||||
var sb strings.Builder
|
||||
|
||||
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
|
||||
for _, r := range order {
|
||||
if _, exists := f.layout.regions[r]; !exists {
|
||||
continue
|
||||
}
|
||||
m, ok := f.models[r]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rw, rh := f.regionSize(r, w, h)
|
||||
view := m.View(rw, rh)
|
||||
if view != "" {
|
||||
sb.WriteString(view)
|
||||
if !strings.HasSuffix(view, "\n") {
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (f *Frame) isTTY() bool {
|
||||
if file, ok := f.out.(*os.File); ok {
|
||||
return term.IsTerminal(int(file.Fd()))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *Frame) termSize() (int, int) {
|
||||
if file, ok := f.out.(*os.File); ok {
|
||||
w, h, err := term.GetSize(int(file.Fd()))
|
||||
if err == nil {
|
||||
return w, h
|
||||
}
|
||||
}
|
||||
return 80, 24 // sensible default
|
||||
}
|
||||
|
||||
func (f *Frame) regionSize(r Region, totalW, totalH int) (int, int) {
|
||||
// Simple allocation: Header/Footer get 1 line, sidebars get 1/4 width,
|
||||
// Content gets the rest.
|
||||
switch r {
|
||||
case RegionHeader, RegionFooter:
|
||||
return totalW, 1
|
||||
case RegionLeft, RegionRight:
|
||||
return totalW / 4, totalH - 2 // minus header + footer
|
||||
case RegionContent:
|
||||
sideW := 0
|
||||
if _, ok := f.models[RegionLeft]; ok {
|
||||
sideW += totalW / 4
|
||||
}
|
||||
if _, ok := f.models[RegionRight]; ok {
|
||||
sideW += totalW / 4
|
||||
}
|
||||
return totalW - sideW, totalH - 2
|
||||
}
|
||||
return totalW, totalH
|
||||
}
|
||||
|
||||
func (f *Frame) runLive() {
|
||||
// Enter alt-screen.
|
||||
fmt.Fprint(f.out, "\033[?1049h")
|
||||
// Hide cursor.
|
||||
fmt.Fprint(f.out, "\033[?25l")
|
||||
|
||||
defer func() {
|
||||
// Show cursor.
|
||||
fmt.Fprint(f.out, "\033[?25h")
|
||||
// Leave alt-screen.
|
||||
fmt.Fprint(f.out, "\033[?1049l")
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(80 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
f.renderFrame()
|
||||
|
||||
select {
|
||||
case <-f.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Frame) renderFrame() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
w, h := f.termSize()
|
||||
|
||||
// Move to top-left.
|
||||
fmt.Fprint(f.out, "\033[H")
|
||||
// Clear screen.
|
||||
fmt.Fprint(f.out, "\033[2J")
|
||||
|
||||
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
|
||||
for _, r := range order {
|
||||
if _, exists := f.layout.regions[r]; !exists {
|
||||
continue
|
||||
}
|
||||
m, ok := f.models[r]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rw, rh := f.regionSize(r, w, h)
|
||||
view := m.View(rw, rh)
|
||||
if view != "" {
|
||||
fmt.Fprint(f.out, view)
|
||||
if !strings.HasSuffix(view, "\n") {
|
||||
fmt.Fprintln(f.out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Built-in Region Components
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// statusLineModel renders a "title key:value key:value" bar.
|
||||
type statusLineModel struct {
|
||||
title string
|
||||
pairs []string
|
||||
}
|
||||
|
||||
// StatusLine creates a header/footer bar with a title and key:value pairs.
|
||||
//
|
||||
// frame.Header(cli.StatusLine("core dev", "18 repos", "main"))
|
||||
func StatusLine(title string, pairs ...string) Model {
|
||||
return &statusLineModel{title: title, pairs: pairs}
|
||||
}
|
||||
|
||||
func (s *statusLineModel) View(width, _ int) string {
|
||||
parts := []string{BoldStyle.Render(s.title)}
|
||||
for _, p := range s.pairs {
|
||||
parts = append(parts, DimStyle.Render(p))
|
||||
}
|
||||
line := strings.Join(parts, " ")
|
||||
if width > 0 {
|
||||
line = Truncate(line, width)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// keyHintsModel renders keyboard shortcut hints.
|
||||
type keyHintsModel struct {
|
||||
hints []string
|
||||
}
|
||||
|
||||
// KeyHints creates a footer showing keyboard shortcuts.
|
||||
//
|
||||
// frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit"))
|
||||
func KeyHints(hints ...string) Model {
|
||||
return &keyHintsModel{hints: hints}
|
||||
}
|
||||
|
||||
func (k *keyHintsModel) View(width, _ int) string {
|
||||
parts := make([]string, len(k.hints))
|
||||
for i, h := range k.hints {
|
||||
parts[i] = DimStyle.Render(h)
|
||||
}
|
||||
line := strings.Join(parts, " ")
|
||||
if width > 0 {
|
||||
line = Truncate(line, width)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// breadcrumbModel renders a navigation path.
|
||||
type breadcrumbModel struct {
|
||||
parts []string
|
||||
}
|
||||
|
||||
// Breadcrumb creates a navigation breadcrumb bar.
|
||||
//
|
||||
// frame.Header(cli.Breadcrumb("core", "dev", "health"))
|
||||
func Breadcrumb(parts ...string) Model {
|
||||
return &breadcrumbModel{parts: parts}
|
||||
}
|
||||
|
||||
func (b *breadcrumbModel) View(width, _ int) string {
|
||||
styled := make([]string, len(b.parts))
|
||||
for i, p := range b.parts {
|
||||
if i == len(b.parts)-1 {
|
||||
styled[i] = BoldStyle.Render(p)
|
||||
} else {
|
||||
styled[i] = DimStyle.Render(p)
|
||||
}
|
||||
}
|
||||
line := strings.Join(styled, DimStyle.Render(" > "))
|
||||
if width > 0 {
|
||||
line = Truncate(line, width)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// staticModel wraps a plain string as a Model.
|
||||
type staticModel struct {
|
||||
text string
|
||||
}
|
||||
|
||||
// StaticModel wraps a static string as a Model, for use in Frame regions.
|
||||
func StaticModel(text string) Model {
|
||||
return &staticModel{text: text}
|
||||
}
|
||||
|
||||
func (s *staticModel) View(_, _ int) string {
|
||||
return s.text
|
||||
}
|
||||
207
pkg/cli/frame_test.go
Normal file
207
pkg/cli/frame_test.go
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFrame_Good(t *testing.T) {
|
||||
t.Run("static render HCF", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
f := NewFrame("HCF")
|
||||
f.out = &bytes.Buffer{}
|
||||
f.Header(StaticModel("header"))
|
||||
f.Content(StaticModel("content"))
|
||||
f.Footer(StaticModel("footer"))
|
||||
|
||||
out := f.String()
|
||||
assert.Contains(t, out, "header")
|
||||
assert.Contains(t, out, "content")
|
||||
assert.Contains(t, out, "footer")
|
||||
})
|
||||
|
||||
t.Run("region order preserved", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
f := NewFrame("HCF")
|
||||
f.out = &bytes.Buffer{}
|
||||
f.Header(StaticModel("AAA"))
|
||||
f.Content(StaticModel("BBB"))
|
||||
f.Footer(StaticModel("CCC"))
|
||||
|
||||
out := f.String()
|
||||
posA := indexOf(out, "AAA")
|
||||
posB := indexOf(out, "BBB")
|
||||
posC := indexOf(out, "CCC")
|
||||
assert.Less(t, posA, posB, "header before content")
|
||||
assert.Less(t, posB, posC, "content before footer")
|
||||
})
|
||||
|
||||
t.Run("navigate and back", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
f := NewFrame("HCF")
|
||||
f.out = &bytes.Buffer{}
|
||||
f.Header(StaticModel("nav"))
|
||||
f.Content(StaticModel("page-1"))
|
||||
f.Footer(StaticModel("hints"))
|
||||
|
||||
assert.Contains(t, f.String(), "page-1")
|
||||
|
||||
// Navigate to page 2
|
||||
f.Navigate(StaticModel("page-2"))
|
||||
assert.Contains(t, f.String(), "page-2")
|
||||
assert.NotContains(t, f.String(), "page-1")
|
||||
|
||||
// Navigate to page 3
|
||||
f.Navigate(StaticModel("page-3"))
|
||||
assert.Contains(t, f.String(), "page-3")
|
||||
|
||||
// Back to page 2
|
||||
ok := f.Back()
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, f.String(), "page-2")
|
||||
|
||||
// Back to page 1
|
||||
ok = f.Back()
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, f.String(), "page-1")
|
||||
|
||||
// No more history
|
||||
ok = f.Back()
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("empty regions skipped", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
f := NewFrame("HCF")
|
||||
f.out = &bytes.Buffer{}
|
||||
f.Content(StaticModel("only content"))
|
||||
|
||||
out := f.String()
|
||||
assert.Equal(t, "only content\n", out)
|
||||
})
|
||||
|
||||
t.Run("non-TTY run renders once", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
var buf bytes.Buffer
|
||||
f := NewFrame("HCF")
|
||||
f.out = &buf
|
||||
f.Header(StaticModel("h"))
|
||||
f.Content(StaticModel("c"))
|
||||
f.Footer(StaticModel("f"))
|
||||
|
||||
f.Run() // non-TTY, should return immediately
|
||||
assert.Contains(t, buf.String(), "h")
|
||||
assert.Contains(t, buf.String(), "c")
|
||||
assert.Contains(t, buf.String(), "f")
|
||||
})
|
||||
|
||||
t.Run("ModelFunc adapter", func(t *testing.T) {
|
||||
called := false
|
||||
m := ModelFunc(func(w, h int) string {
|
||||
called = true
|
||||
return "dynamic"
|
||||
})
|
||||
|
||||
out := m.View(80, 24)
|
||||
assert.True(t, called)
|
||||
assert.Equal(t, "dynamic", out)
|
||||
})
|
||||
|
||||
t.Run("RunFor exits after duration", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
f := NewFrame("C")
|
||||
f.out = &buf // non-TTY → RunFor renders once and returns
|
||||
f.Content(StaticModel("timed"))
|
||||
|
||||
start := time.Now()
|
||||
f.RunFor(50 * time.Millisecond)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
assert.Less(t, elapsed, 200*time.Millisecond)
|
||||
assert.Contains(t, buf.String(), "timed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrame_Bad(t *testing.T) {
|
||||
t.Run("empty frame", func(t *testing.T) {
|
||||
f := NewFrame("HCF")
|
||||
f.out = &bytes.Buffer{}
|
||||
assert.Equal(t, "", f.String())
|
||||
})
|
||||
|
||||
t.Run("back on empty history", func(t *testing.T) {
|
||||
f := NewFrame("C")
|
||||
f.out = &bytes.Buffer{}
|
||||
f.Content(StaticModel("x"))
|
||||
assert.False(t, f.Back())
|
||||
})
|
||||
|
||||
t.Run("invalid variant degrades gracefully", func(t *testing.T) {
|
||||
f := NewFrame("XYZ")
|
||||
f.out = &bytes.Buffer{}
|
||||
// No valid regions, so nothing renders
|
||||
assert.Equal(t, "", f.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestStatusLine_Good(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
m := StatusLine("core dev", "18 repos", "main")
|
||||
out := m.View(80, 1)
|
||||
assert.Contains(t, out, "core dev")
|
||||
assert.Contains(t, out, "18 repos")
|
||||
assert.Contains(t, out, "main")
|
||||
}
|
||||
|
||||
func TestKeyHints_Good(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
m := KeyHints("↑/↓ navigate", "q quit")
|
||||
out := m.View(80, 1)
|
||||
assert.Contains(t, out, "navigate")
|
||||
assert.Contains(t, out, "quit")
|
||||
}
|
||||
|
||||
func TestBreadcrumb_Good(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
m := Breadcrumb("core", "dev", "health")
|
||||
out := m.View(80, 1)
|
||||
assert.Contains(t, out, "core")
|
||||
assert.Contains(t, out, "dev")
|
||||
assert.Contains(t, out, "health")
|
||||
assert.Contains(t, out, ">")
|
||||
}
|
||||
|
||||
func TestStaticModel_Good(t *testing.T) {
|
||||
m := StaticModel("hello")
|
||||
assert.Equal(t, "hello", m.View(80, 24))
|
||||
}
|
||||
|
||||
// indexOf returns the position of substr in s, or -1 if not found.
|
||||
func indexOf(s, substr string) int {
|
||||
for i := range len(s) - len(substr) + 1 {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
92
pkg/cli/glyph.go
Normal file
92
pkg/cli/glyph.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
25
pkg/cli/glyph_maps.go
Normal file
25
pkg/cli/glyph_maps.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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:": "-",
|
||||
}
|
||||
23
pkg/cli/glyph_test.go
Normal file
23
pkg/cli/glyph_test.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
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
Normal file
170
pkg/cli/i18n.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
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...)
|
||||
}
|
||||
148
pkg/cli/layout.go
Normal file
148
pkg/cli/layout.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
25
pkg/cli/layout_test.go
Normal file
25
pkg/cli/layout_test.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
115
pkg/cli/log.go
Normal file
115
pkg/cli/log.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
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...)
|
||||
}
|
||||
}
|
||||
195
pkg/cli/output.go
Normal file
195
pkg/cli/output.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
101
pkg/cli/output_test.go
Normal file
101
pkg/cli/output_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
75
pkg/cli/prompt.go
Normal file
75
pkg/cli/prompt.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
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
|
||||
}
|
||||
87
pkg/cli/render.go
Normal file
87
pkg/cli/render.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
219
pkg/cli/runtime.go
Normal file
219
pkg/cli/runtime.go
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
// 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,
|
||||
}
|
||||
|
||||
// Attach all registered commands
|
||||
attachRegisteredCommands(rootCmd)
|
||||
|
||||
// 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
|
||||
}
|
||||
140
pkg/cli/stream.go
Normal file
140
pkg/cli/stream.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// StreamOption configures a Stream.
|
||||
type StreamOption func(*Stream)
|
||||
|
||||
// WithWordWrap sets the word-wrap column width.
|
||||
func WithWordWrap(cols int) StreamOption {
|
||||
return func(s *Stream) { s.wrap = cols }
|
||||
}
|
||||
|
||||
// WithStreamOutput sets the output writer (default: os.Stdout).
|
||||
func WithStreamOutput(w io.Writer) StreamOption {
|
||||
return func(s *Stream) { s.out = w }
|
||||
}
|
||||
|
||||
// Stream renders growing text as tokens arrive, with optional word-wrap.
|
||||
// Safe for concurrent writes from a single producer goroutine.
|
||||
//
|
||||
// stream := cli.NewStream(cli.WithWordWrap(80))
|
||||
// go func() {
|
||||
// for token := range tokens {
|
||||
// stream.Write(token)
|
||||
// }
|
||||
// stream.Done()
|
||||
// }()
|
||||
// stream.Wait()
|
||||
type Stream struct {
|
||||
out io.Writer
|
||||
wrap int
|
||||
col int // current column position (visible characters)
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewStream creates a streaming text renderer.
|
||||
func NewStream(opts ...StreamOption) *Stream {
|
||||
s := &Stream{
|
||||
out: os.Stdout,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Write appends text to the stream. Handles word-wrap if configured.
|
||||
func (s *Stream) Write(text string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.wrap <= 0 {
|
||||
fmt.Fprint(s.out, text)
|
||||
// Track column across newlines for Done() trailing-newline logic.
|
||||
if idx := strings.LastIndex(text, "\n"); idx >= 0 {
|
||||
s.col = utf8.RuneCountInString(text[idx+1:])
|
||||
} else {
|
||||
s.col += utf8.RuneCountInString(text)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for _, r := range text {
|
||||
if r == '\n' {
|
||||
fmt.Fprintln(s.out)
|
||||
s.col = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if s.col >= s.wrap {
|
||||
fmt.Fprintln(s.out)
|
||||
s.col = 0
|
||||
}
|
||||
|
||||
fmt.Fprint(s.out, string(r))
|
||||
s.col++
|
||||
}
|
||||
}
|
||||
|
||||
// WriteFrom reads from r and streams all content until EOF.
|
||||
func (s *Stream) WriteFrom(r io.Reader) error {
|
||||
buf := make([]byte, 256)
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
s.Write(string(buf[:n]))
|
||||
}
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Done signals that no more text will arrive.
|
||||
func (s *Stream) Done() {
|
||||
s.mu.Lock()
|
||||
if s.col > 0 {
|
||||
fmt.Fprintln(s.out) // ensure trailing newline
|
||||
}
|
||||
s.mu.Unlock()
|
||||
close(s.done)
|
||||
}
|
||||
|
||||
// Wait blocks until Done is called.
|
||||
func (s *Stream) Wait() {
|
||||
<-s.done
|
||||
}
|
||||
|
||||
// Content returns the current column position (for testing).
|
||||
func (s *Stream) Column() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.col
|
||||
}
|
||||
|
||||
// Captured returns the stream output as a string when using a bytes.Buffer.
|
||||
// Panics if the output writer is not a *strings.Builder or fmt.Stringer.
|
||||
func (s *Stream) Captured() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if sb, ok := s.out.(*strings.Builder); ok {
|
||||
return sb.String()
|
||||
}
|
||||
if st, ok := s.out.(fmt.Stringer); ok {
|
||||
return st.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
159
pkg/cli/stream_test.go
Normal file
159
pkg/cli/stream_test.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStream_Good(t *testing.T) {
|
||||
t.Run("basic write", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := NewStream(WithStreamOutput(&buf))
|
||||
|
||||
s.Write("hello ")
|
||||
s.Write("world")
|
||||
s.Done()
|
||||
s.Wait()
|
||||
|
||||
assert.Equal(t, "hello world\n", buf.String())
|
||||
})
|
||||
|
||||
t.Run("write with newlines", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := NewStream(WithStreamOutput(&buf))
|
||||
|
||||
s.Write("line1\nline2\n")
|
||||
s.Done()
|
||||
s.Wait()
|
||||
|
||||
assert.Equal(t, "line1\nline2\n", buf.String())
|
||||
})
|
||||
|
||||
t.Run("word wrap", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := NewStream(WithWordWrap(10), WithStreamOutput(&buf))
|
||||
|
||||
s.Write("1234567890ABCDE")
|
||||
s.Done()
|
||||
s.Wait()
|
||||
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
assert.Equal(t, 2, len(lines))
|
||||
assert.Equal(t, "1234567890", lines[0])
|
||||
assert.Equal(t, "ABCDE", lines[1])
|
||||
})
|
||||
|
||||
t.Run("word wrap preserves explicit newlines", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := NewStream(WithWordWrap(20), WithStreamOutput(&buf))
|
||||
|
||||
s.Write("short\nanother")
|
||||
s.Done()
|
||||
s.Wait()
|
||||
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
assert.Equal(t, 2, len(lines))
|
||||
assert.Equal(t, "short", lines[0])
|
||||
assert.Equal(t, "another", lines[1])
|
||||
})
|
||||
|
||||
t.Run("word wrap resets column on newline", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := NewStream(WithWordWrap(5), WithStreamOutput(&buf))
|
||||
|
||||
s.Write("12345\n67890ABCDE")
|
||||
s.Done()
|
||||
s.Wait()
|
||||
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
assert.Equal(t, 3, len(lines))
|
||||
assert.Equal(t, "12345", lines[0])
|
||||
assert.Equal(t, "67890", lines[1])
|
||||
assert.Equal(t, "ABCDE", lines[2])
|
||||
})
|
||||
|
||||
t.Run("no wrap when disabled", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := NewStream(WithStreamOutput(&buf))
|
||||
|
||||
s.Write(strings.Repeat("x", 200))
|
||||
s.Done()
|
||||
s.Wait()
|
||||
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
assert.Equal(t, 1, len(lines))
|
||||
assert.Equal(t, 200, len(lines[0]))
|
||||
})
|
||||
|
||||
t.Run("column tracking", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := NewStream(WithStreamOutput(&buf))
|
||||
|
||||
s.Write("hello")
|
||||
assert.Equal(t, 5, s.Column())
|
||||
|
||||
s.Write(" world")
|
||||
assert.Equal(t, 11, s.Column())
|
||||
})
|
||||
|
||||
t.Run("WriteFrom io.Reader", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := NewStream(WithStreamOutput(&buf))
|
||||
|
||||
r := strings.NewReader("streamed content")
|
||||
err := s.WriteFrom(r)
|
||||
assert.NoError(t, err)
|
||||
|
||||
s.Done()
|
||||
s.Wait()
|
||||
|
||||
assert.Equal(t, "streamed content\n", buf.String())
|
||||
})
|
||||
|
||||
t.Run("channel pattern", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := NewStream(WithStreamOutput(&buf))
|
||||
|
||||
tokens := make(chan string, 3)
|
||||
tokens <- "one "
|
||||
tokens <- "two "
|
||||
tokens <- "three"
|
||||
close(tokens)
|
||||
|
||||
go func() {
|
||||
for tok := range tokens {
|
||||
s.Write(tok)
|
||||
}
|
||||
s.Done()
|
||||
}()
|
||||
|
||||
s.Wait()
|
||||
assert.Equal(t, "one two three\n", buf.String())
|
||||
})
|
||||
|
||||
t.Run("Done adds trailing newline only if needed", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := NewStream(WithStreamOutput(&buf))
|
||||
|
||||
s.Write("text\n") // ends with newline, col=0
|
||||
s.Done()
|
||||
s.Wait()
|
||||
|
||||
assert.Equal(t, "text\n", buf.String()) // no double newline
|
||||
})
|
||||
}
|
||||
|
||||
func TestStream_Bad(t *testing.T) {
|
||||
t.Run("empty stream", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := NewStream(WithStreamOutput(&buf))
|
||||
|
||||
s.Done()
|
||||
s.Wait()
|
||||
|
||||
assert.Equal(t, "", buf.String())
|
||||
})
|
||||
}
|
||||
48
pkg/cli/strings.go
Normal file
48
pkg/cli/strings.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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)
|
||||
}
|
||||
440
pkg/cli/styles.go
Normal file
440
pkg/cli/styles.go
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
// 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)))
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Border Styles
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// BorderStyle selects the box-drawing character set for table borders.
|
||||
type BorderStyle int
|
||||
|
||||
const (
|
||||
// BorderNone disables borders (default).
|
||||
BorderNone BorderStyle = iota
|
||||
// BorderNormal uses standard box-drawing: ┌─┬┐ │ ├─┼┤ └─┴┘
|
||||
BorderNormal
|
||||
// BorderRounded uses rounded corners: ╭─┬╮ │ ├─┼┤ ╰─┴╯
|
||||
BorderRounded
|
||||
// BorderHeavy uses heavy box-drawing: ┏━┳┓ ┃ ┣━╋┫ ┗━┻┛
|
||||
BorderHeavy
|
||||
// BorderDouble uses double-line box-drawing: ╔═╦╗ ║ ╠═╬╣ ╚═╩╝
|
||||
BorderDouble
|
||||
)
|
||||
|
||||
type borderSet struct {
|
||||
tl, tr, bl, br string // corners
|
||||
h, v string // horizontal, vertical
|
||||
tt, bt, lt, rt string // tees (top, bottom, left, right)
|
||||
x string // cross
|
||||
}
|
||||
|
||||
var borderSets = map[BorderStyle]borderSet{
|
||||
BorderNormal: {"┌", "┐", "└", "┘", "─", "│", "┬", "┴", "├", "┤", "┼"},
|
||||
BorderRounded: {"╭", "╮", "╰", "╯", "─", "│", "┬", "┴", "├", "┤", "┼"},
|
||||
BorderHeavy: {"┏", "┓", "┗", "┛", "━", "┃", "┳", "┻", "┣", "┫", "╋"},
|
||||
BorderDouble: {"╔", "╗", "╚", "╝", "═", "║", "╦", "╩", "╠", "╣", "╬"},
|
||||
}
|
||||
|
||||
// CellStyleFn returns a style based on the cell's raw value.
|
||||
// Return nil to use the table's default CellStyle.
|
||||
type CellStyleFn func(value string) *AnsiStyle
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Table
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Table renders tabular data with aligned columns.
|
||||
// Supports optional box-drawing borders and per-column cell styling.
|
||||
//
|
||||
// t := cli.NewTable("REPO", "STATUS", "BRANCH").
|
||||
// WithBorders(cli.BorderRounded).
|
||||
// WithCellStyle(1, func(val string) *cli.AnsiStyle {
|
||||
// if val == "clean" { return cli.SuccessStyle }
|
||||
// return cli.WarningStyle
|
||||
// })
|
||||
// t.AddRow("core-php", "clean", "main")
|
||||
// t.Render()
|
||||
type Table struct {
|
||||
Headers []string
|
||||
Rows [][]string
|
||||
Style TableStyle
|
||||
borders BorderStyle
|
||||
cellStyleFns map[int]CellStyleFn
|
||||
maxWidth int
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// WithBorders enables box-drawing borders on the table.
|
||||
func (t *Table) WithBorders(style BorderStyle) *Table {
|
||||
t.borders = style
|
||||
return t
|
||||
}
|
||||
|
||||
// WithCellStyle sets a per-column style function.
|
||||
// The function receives the raw cell value and returns a style.
|
||||
func (t *Table) WithCellStyle(col int, fn CellStyleFn) *Table {
|
||||
if t.cellStyleFns == nil {
|
||||
t.cellStyleFns = make(map[int]CellStyleFn)
|
||||
}
|
||||
t.cellStyleFns[col] = fn
|
||||
return t
|
||||
}
|
||||
|
||||
// WithMaxWidth sets the maximum table width, truncating columns to fit.
|
||||
func (t *Table) WithMaxWidth(w int) *Table {
|
||||
t.maxWidth = w
|
||||
return t
|
||||
}
|
||||
|
||||
// String renders the table.
|
||||
func (t *Table) String() string {
|
||||
if len(t.Headers) == 0 && len(t.Rows) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if t.borders != BorderNone {
|
||||
return t.renderBordered()
|
||||
}
|
||||
return t.renderPlain()
|
||||
}
|
||||
|
||||
// Render prints the table to stdout.
|
||||
func (t *Table) Render() {
|
||||
fmt.Print(t.String())
|
||||
}
|
||||
|
||||
func (t *Table) colCount() int {
|
||||
cols := len(t.Headers)
|
||||
if cols == 0 && len(t.Rows) > 0 {
|
||||
cols = len(t.Rows[0])
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
func (t *Table) columnWidths() []int {
|
||||
cols := t.colCount()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if t.maxWidth > 0 {
|
||||
t.constrainWidths(widths)
|
||||
}
|
||||
return widths
|
||||
}
|
||||
|
||||
func (t *Table) constrainWidths(widths []int) {
|
||||
cols := len(widths)
|
||||
overhead := 0
|
||||
if t.borders != BorderNone {
|
||||
// │ cell │ cell │ = (cols+1) verticals + 2*cols padding spaces
|
||||
overhead = (cols + 1) + (cols * 2)
|
||||
} else {
|
||||
// separator between columns
|
||||
overhead = (cols - 1) * len(t.Style.Separator)
|
||||
}
|
||||
|
||||
total := overhead
|
||||
for _, w := range widths {
|
||||
total += w
|
||||
}
|
||||
|
||||
if total <= t.maxWidth {
|
||||
return
|
||||
}
|
||||
|
||||
// Shrink widest columns first until we fit.
|
||||
budget := t.maxWidth - overhead
|
||||
if budget < cols {
|
||||
budget = cols
|
||||
}
|
||||
for total-overhead > budget {
|
||||
maxIdx, maxW := 0, 0
|
||||
for i, w := range widths {
|
||||
if w > maxW {
|
||||
maxIdx, maxW = i, w
|
||||
}
|
||||
}
|
||||
widths[maxIdx]--
|
||||
total--
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) resolveStyle(col int, value string) *AnsiStyle {
|
||||
if t.cellStyleFns != nil {
|
||||
if fn, ok := t.cellStyleFns[col]; ok {
|
||||
if s := fn(value); s != nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return t.Style.CellStyle
|
||||
}
|
||||
|
||||
func (t *Table) renderPlain() string {
|
||||
widths := t.columnWidths()
|
||||
|
||||
var sb strings.Builder
|
||||
sep := t.Style.Separator
|
||||
|
||||
if len(t.Headers) > 0 {
|
||||
for i, h := range t.Headers {
|
||||
if i > 0 {
|
||||
sb.WriteString(sep)
|
||||
}
|
||||
cell := Pad(Truncate(h, widths[i]), widths[i])
|
||||
if t.Style.HeaderStyle != nil {
|
||||
cell = t.Style.HeaderStyle.Render(cell)
|
||||
}
|
||||
sb.WriteString(cell)
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
for _, row := range t.Rows {
|
||||
for i := range t.colCount() {
|
||||
if i > 0 {
|
||||
sb.WriteString(sep)
|
||||
}
|
||||
val := ""
|
||||
if i < len(row) {
|
||||
val = row[i]
|
||||
}
|
||||
cell := Pad(Truncate(val, widths[i]), widths[i])
|
||||
if style := t.resolveStyle(i, val); style != nil {
|
||||
cell = style.Render(cell)
|
||||
}
|
||||
sb.WriteString(cell)
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (t *Table) renderBordered() string {
|
||||
b := borderSets[t.borders]
|
||||
widths := t.columnWidths()
|
||||
cols := t.colCount()
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Top border: ╭──────┬──────╮
|
||||
sb.WriteString(b.tl)
|
||||
for i := range cols {
|
||||
sb.WriteString(strings.Repeat(b.h, widths[i]+2))
|
||||
if i < cols-1 {
|
||||
sb.WriteString(b.tt)
|
||||
}
|
||||
}
|
||||
sb.WriteString(b.tr)
|
||||
sb.WriteByte('\n')
|
||||
|
||||
// Header row
|
||||
if len(t.Headers) > 0 {
|
||||
sb.WriteString(b.v)
|
||||
for i := range cols {
|
||||
h := ""
|
||||
if i < len(t.Headers) {
|
||||
h = t.Headers[i]
|
||||
}
|
||||
cell := Pad(Truncate(h, widths[i]), widths[i])
|
||||
if t.Style.HeaderStyle != nil {
|
||||
cell = t.Style.HeaderStyle.Render(cell)
|
||||
}
|
||||
sb.WriteByte(' ')
|
||||
sb.WriteString(cell)
|
||||
sb.WriteByte(' ')
|
||||
sb.WriteString(b.v)
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
|
||||
// Header separator: ├──────┼──────┤
|
||||
sb.WriteString(b.lt)
|
||||
for i := range cols {
|
||||
sb.WriteString(strings.Repeat(b.h, widths[i]+2))
|
||||
if i < cols-1 {
|
||||
sb.WriteString(b.x)
|
||||
}
|
||||
}
|
||||
sb.WriteString(b.rt)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
// Data rows
|
||||
for _, row := range t.Rows {
|
||||
sb.WriteString(b.v)
|
||||
for i := range cols {
|
||||
val := ""
|
||||
if i < len(row) {
|
||||
val = row[i]
|
||||
}
|
||||
cell := Pad(Truncate(val, widths[i]), widths[i])
|
||||
if style := t.resolveStyle(i, val); style != nil {
|
||||
cell = style.Render(cell)
|
||||
}
|
||||
sb.WriteByte(' ')
|
||||
sb.WriteString(cell)
|
||||
sb.WriteByte(' ')
|
||||
sb.WriteString(b.v)
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
// Bottom border: ╰──────┴──────╯
|
||||
sb.WriteString(b.bl)
|
||||
for i := range cols {
|
||||
sb.WriteString(strings.Repeat(b.h, widths[i]+2))
|
||||
if i < cols-1 {
|
||||
sb.WriteString(b.bt)
|
||||
}
|
||||
}
|
||||
sb.WriteString(b.br)
|
||||
sb.WriteByte('\n')
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
206
pkg/cli/styles_test.go
Normal file
206
pkg/cli/styles_test.go
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTable_Good(t *testing.T) {
|
||||
t.Run("plain table unchanged", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
tbl := NewTable("NAME", "AGE")
|
||||
tbl.AddRow("Alice", "30")
|
||||
tbl.AddRow("Bob", "25")
|
||||
|
||||
out := tbl.String()
|
||||
assert.Contains(t, out, "NAME")
|
||||
assert.Contains(t, out, "Alice")
|
||||
assert.Contains(t, out, "Bob")
|
||||
})
|
||||
|
||||
t.Run("bordered normal", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
tbl := NewTable("A", "B").WithBorders(BorderNormal)
|
||||
tbl.AddRow("x", "y")
|
||||
|
||||
out := tbl.String()
|
||||
assert.True(t, strings.HasPrefix(out, "┌"))
|
||||
assert.Contains(t, out, "┐")
|
||||
assert.Contains(t, out, "│")
|
||||
assert.Contains(t, out, "├")
|
||||
assert.Contains(t, out, "┤")
|
||||
assert.Contains(t, out, "└")
|
||||
assert.Contains(t, out, "┘")
|
||||
})
|
||||
|
||||
t.Run("bordered rounded", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
tbl := NewTable("REPO", "STATUS").WithBorders(BorderRounded)
|
||||
tbl.AddRow("core", "clean")
|
||||
|
||||
out := tbl.String()
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
assert.True(t, strings.HasPrefix(lines[0], "╭"))
|
||||
assert.True(t, strings.HasSuffix(lines[0], "╮"))
|
||||
assert.True(t, strings.HasPrefix(lines[len(lines)-1], "╰"))
|
||||
assert.True(t, strings.HasSuffix(lines[len(lines)-1], "╯"))
|
||||
})
|
||||
|
||||
t.Run("bordered heavy", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
tbl := NewTable("X").WithBorders(BorderHeavy)
|
||||
tbl.AddRow("v")
|
||||
|
||||
out := tbl.String()
|
||||
assert.Contains(t, out, "┏")
|
||||
assert.Contains(t, out, "┓")
|
||||
assert.Contains(t, out, "┃")
|
||||
})
|
||||
|
||||
t.Run("bordered double", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
tbl := NewTable("X").WithBorders(BorderDouble)
|
||||
tbl.AddRow("v")
|
||||
|
||||
out := tbl.String()
|
||||
assert.Contains(t, out, "╔")
|
||||
assert.Contains(t, out, "╗")
|
||||
assert.Contains(t, out, "║")
|
||||
})
|
||||
|
||||
t.Run("bordered structure", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
tbl := NewTable("A", "B").WithBorders(BorderRounded)
|
||||
tbl.AddRow("x", "y")
|
||||
tbl.AddRow("1", "2")
|
||||
|
||||
lines := strings.Split(strings.TrimRight(tbl.String(), "\n"), "\n")
|
||||
// Top border, header, separator, 2 data rows, bottom border = 6 lines
|
||||
assert.Equal(t, 6, len(lines), "expected 6 lines: border, header, sep, 2 rows, border")
|
||||
})
|
||||
|
||||
t.Run("cell style function", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
called := false
|
||||
tbl := NewTable("STATUS").
|
||||
WithCellStyle(0, func(val string) *AnsiStyle {
|
||||
called = true
|
||||
if val == "ok" {
|
||||
return SuccessStyle
|
||||
}
|
||||
return ErrorStyle
|
||||
})
|
||||
tbl.AddRow("ok")
|
||||
tbl.AddRow("fail")
|
||||
|
||||
_ = tbl.String()
|
||||
assert.True(t, called, "cell style function should be called")
|
||||
})
|
||||
|
||||
t.Run("cell style with borders", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
tbl := NewTable("NAME", "STATUS").
|
||||
WithBorders(BorderRounded).
|
||||
WithCellStyle(1, func(val string) *AnsiStyle {
|
||||
return nil // fallback to default
|
||||
})
|
||||
tbl.AddRow("core", "ok")
|
||||
|
||||
out := tbl.String()
|
||||
assert.Contains(t, out, "core")
|
||||
assert.Contains(t, out, "ok")
|
||||
})
|
||||
|
||||
t.Run("max width truncates", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
tbl := NewTable("LONG_HEADER", "SHORT").WithMaxWidth(25)
|
||||
tbl.AddRow("very_long_value_here", "x")
|
||||
|
||||
out := tbl.String()
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
for _, line := range lines {
|
||||
w := utf8.RuneCountInString(line)
|
||||
assert.LessOrEqual(t, w, 25, "line should not exceed max width: %q", line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("max width with borders", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
tbl := NewTable("A", "B").WithBorders(BorderNormal).WithMaxWidth(20)
|
||||
tbl.AddRow("hello", "world")
|
||||
|
||||
out := tbl.String()
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
for _, line := range lines {
|
||||
w := utf8.RuneCountInString(line)
|
||||
assert.LessOrEqual(t, w, 20, "bordered line should not exceed max width: %q", line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty table returns empty", func(t *testing.T) {
|
||||
tbl := NewTable()
|
||||
assert.Equal(t, "", tbl.String())
|
||||
})
|
||||
|
||||
t.Run("no headers with borders", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
tbl := NewTable().WithBorders(BorderNormal)
|
||||
tbl.Rows = [][]string{{"a", "b"}, {"c", "d"}}
|
||||
|
||||
out := tbl.String()
|
||||
assert.Contains(t, out, "┌")
|
||||
// No header separator since no headers
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
// Top border, 2 data rows, bottom border = 4 lines (no header separator)
|
||||
assert.Equal(t, 4, len(lines))
|
||||
})
|
||||
}
|
||||
|
||||
func TestTable_Bad(t *testing.T) {
|
||||
t.Run("short rows padded", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
tbl := NewTable("A", "B", "C")
|
||||
tbl.AddRow("x") // only 1 cell, 3 columns
|
||||
|
||||
out := tbl.String()
|
||||
assert.Contains(t, out, "x")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTruncate_Good(t *testing.T) {
|
||||
assert.Equal(t, "hel...", Truncate("hello world", 6))
|
||||
assert.Equal(t, "hi", Truncate("hi", 6))
|
||||
assert.Equal(t, "he", Truncate("hello", 2))
|
||||
}
|
||||
|
||||
func TestPad_Good(t *testing.T) {
|
||||
assert.Equal(t, "hi ", Pad("hi", 5))
|
||||
assert.Equal(t, "hello", Pad("hello", 3))
|
||||
}
|
||||
291
pkg/cli/tracker.go
Normal file
291
pkg/cli/tracker.go
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Spinner frames (braille pattern).
|
||||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
// taskState tracks the lifecycle of a tracked task.
|
||||
type taskState int
|
||||
|
||||
const (
|
||||
taskPending taskState = iota
|
||||
taskRunning
|
||||
taskDone
|
||||
taskFailed
|
||||
)
|
||||
|
||||
// TrackedTask represents a single task in a TaskTracker.
|
||||
// Safe for concurrent use — call Update, Done, or Fail from any goroutine.
|
||||
type TrackedTask struct {
|
||||
name string
|
||||
status string
|
||||
state taskState
|
||||
tracker *TaskTracker
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Update sets the task status message and marks it as running.
|
||||
func (t *TrackedTask) Update(status string) {
|
||||
t.mu.Lock()
|
||||
t.status = status
|
||||
t.state = taskRunning
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// Done marks the task as successfully completed with a final message.
|
||||
func (t *TrackedTask) Done(message string) {
|
||||
t.mu.Lock()
|
||||
t.status = message
|
||||
t.state = taskDone
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// Fail marks the task as failed with an error message.
|
||||
func (t *TrackedTask) Fail(message string) {
|
||||
t.mu.Lock()
|
||||
t.status = message
|
||||
t.state = taskFailed
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *TrackedTask) snapshot() (string, string, taskState) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.name, t.status, t.state
|
||||
}
|
||||
|
||||
// TaskTracker displays multiple concurrent tasks with individual spinners.
|
||||
//
|
||||
// tracker := cli.NewTaskTracker()
|
||||
// for _, repo := range repos {
|
||||
// t := tracker.Add(repo.Name)
|
||||
// go func(t *cli.TrackedTask) {
|
||||
// t.Update("pulling...")
|
||||
// // ...
|
||||
// t.Done("up to date")
|
||||
// }(t)
|
||||
// }
|
||||
// tracker.Wait()
|
||||
type TaskTracker struct {
|
||||
tasks []*TrackedTask
|
||||
out io.Writer
|
||||
mu sync.Mutex
|
||||
started bool
|
||||
}
|
||||
|
||||
// NewTaskTracker creates a new parallel task tracker.
|
||||
func NewTaskTracker() *TaskTracker {
|
||||
return &TaskTracker{out: os.Stdout}
|
||||
}
|
||||
|
||||
// Add registers a task and returns it for goroutine use.
|
||||
func (tr *TaskTracker) Add(name string) *TrackedTask {
|
||||
t := &TrackedTask{
|
||||
name: name,
|
||||
status: "waiting",
|
||||
state: taskPending,
|
||||
tracker: tr,
|
||||
}
|
||||
tr.mu.Lock()
|
||||
tr.tasks = append(tr.tasks, t)
|
||||
tr.mu.Unlock()
|
||||
return t
|
||||
}
|
||||
|
||||
// Wait renders the task display and blocks until all tasks complete.
|
||||
// Uses ANSI cursor manipulation for live updates when connected to a terminal.
|
||||
// Falls back to line-by-line output for non-TTY.
|
||||
func (tr *TaskTracker) Wait() {
|
||||
if !tr.isTTY() {
|
||||
tr.waitStatic()
|
||||
return
|
||||
}
|
||||
tr.waitLive()
|
||||
}
|
||||
|
||||
func (tr *TaskTracker) isTTY() bool {
|
||||
if f, ok := tr.out.(*os.File); ok {
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (tr *TaskTracker) waitStatic() {
|
||||
// Non-TTY: print final state of each task when it completes.
|
||||
reported := make(map[int]bool)
|
||||
for {
|
||||
tr.mu.Lock()
|
||||
tasks := tr.tasks
|
||||
tr.mu.Unlock()
|
||||
|
||||
allDone := true
|
||||
for i, t := range tasks {
|
||||
name, status, state := t.snapshot()
|
||||
if state != taskDone && state != taskFailed {
|
||||
allDone = false
|
||||
continue
|
||||
}
|
||||
if reported[i] {
|
||||
continue
|
||||
}
|
||||
reported[i] = true
|
||||
icon := Glyph(":check:")
|
||||
if state == taskFailed {
|
||||
icon = Glyph(":cross:")
|
||||
}
|
||||
fmt.Fprintf(tr.out, "%s %-20s %s\n", icon, name, status)
|
||||
}
|
||||
if allDone {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (tr *TaskTracker) waitLive() {
|
||||
tr.mu.Lock()
|
||||
n := len(tr.tasks)
|
||||
tr.mu.Unlock()
|
||||
|
||||
// Print initial lines.
|
||||
frame := 0
|
||||
for i := range n {
|
||||
tr.renderLine(i, frame)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(80 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
<-ticker.C
|
||||
frame++
|
||||
|
||||
tr.mu.Lock()
|
||||
count := len(tr.tasks)
|
||||
tr.mu.Unlock()
|
||||
|
||||
// Move cursor up to redraw all lines.
|
||||
fmt.Fprintf(tr.out, "\033[%dA", count)
|
||||
for i := range count {
|
||||
tr.renderLine(i, frame)
|
||||
}
|
||||
|
||||
if tr.allDone() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (tr *TaskTracker) renderLine(idx, frame int) {
|
||||
tr.mu.Lock()
|
||||
t := tr.tasks[idx]
|
||||
tr.mu.Unlock()
|
||||
|
||||
name, status, state := t.snapshot()
|
||||
nameW := tr.nameWidth()
|
||||
|
||||
var icon string
|
||||
switch state {
|
||||
case taskPending:
|
||||
icon = DimStyle.Render(Glyph(":pending:"))
|
||||
case taskRunning:
|
||||
icon = InfoStyle.Render(spinnerFrames[frame%len(spinnerFrames)])
|
||||
case taskDone:
|
||||
icon = SuccessStyle.Render(Glyph(":check:"))
|
||||
case taskFailed:
|
||||
icon = ErrorStyle.Render(Glyph(":cross:"))
|
||||
}
|
||||
|
||||
var styledStatus string
|
||||
switch state {
|
||||
case taskDone:
|
||||
styledStatus = SuccessStyle.Render(status)
|
||||
case taskFailed:
|
||||
styledStatus = ErrorStyle.Render(status)
|
||||
default:
|
||||
styledStatus = DimStyle.Render(status)
|
||||
}
|
||||
|
||||
fmt.Fprintf(tr.out, "\033[2K%s %-*s %s\n", icon, nameW, name, styledStatus)
|
||||
}
|
||||
|
||||
func (tr *TaskTracker) nameWidth() int {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
w := 0
|
||||
for _, t := range tr.tasks {
|
||||
if len(t.name) > w {
|
||||
w = len(t.name)
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (tr *TaskTracker) allDone() bool {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
for _, t := range tr.tasks {
|
||||
_, _, state := t.snapshot()
|
||||
if state != taskDone && state != taskFailed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Summary returns a one-line summary of task results.
|
||||
func (tr *TaskTracker) Summary() string {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
|
||||
var passed, failed int
|
||||
for _, t := range tr.tasks {
|
||||
_, _, state := t.snapshot()
|
||||
switch state {
|
||||
case taskDone:
|
||||
passed++
|
||||
case taskFailed:
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
total := len(tr.tasks)
|
||||
if failed > 0 {
|
||||
return fmt.Sprintf("%d/%d passed, %d failed", passed, total, failed)
|
||||
}
|
||||
return fmt.Sprintf("%d/%d passed", passed, total)
|
||||
}
|
||||
|
||||
// String returns the current state of all tasks as plain text (no ANSI).
|
||||
func (tr *TaskTracker) String() string {
|
||||
tr.mu.Lock()
|
||||
tasks := tr.tasks
|
||||
tr.mu.Unlock()
|
||||
|
||||
nameW := tr.nameWidth()
|
||||
var sb strings.Builder
|
||||
for _, t := range tasks {
|
||||
name, status, state := t.snapshot()
|
||||
icon := "…"
|
||||
switch state {
|
||||
case taskDone:
|
||||
icon = "✓"
|
||||
case taskFailed:
|
||||
icon = "✗"
|
||||
case taskRunning:
|
||||
icon = "⠋"
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s %-*s %s\n", icon, nameW, name, status)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
188
pkg/cli/tracker_test.go
Normal file
188
pkg/cli/tracker_test.go
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTaskTracker_Good(t *testing.T) {
|
||||
t.Run("add and complete tasks", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{} // non-TTY
|
||||
|
||||
t1 := tr.Add("repo-a")
|
||||
t2 := tr.Add("repo-b")
|
||||
|
||||
t1.Update("pulling...")
|
||||
t2.Update("pulling...")
|
||||
|
||||
t1.Done("up to date")
|
||||
t2.Done("3 commits behind")
|
||||
|
||||
out := tr.String()
|
||||
assert.Contains(t, out, "repo-a")
|
||||
assert.Contains(t, out, "repo-b")
|
||||
assert.Contains(t, out, "up to date")
|
||||
assert.Contains(t, out, "3 commits behind")
|
||||
})
|
||||
|
||||
t.Run("task states", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
|
||||
task := tr.Add("test")
|
||||
|
||||
// Pending
|
||||
_, _, state := task.snapshot()
|
||||
assert.Equal(t, taskPending, state)
|
||||
|
||||
// Running
|
||||
task.Update("working")
|
||||
_, status, state := task.snapshot()
|
||||
assert.Equal(t, taskRunning, state)
|
||||
assert.Equal(t, "working", status)
|
||||
|
||||
// Done
|
||||
task.Done("finished")
|
||||
_, status, state = task.snapshot()
|
||||
assert.Equal(t, taskDone, state)
|
||||
assert.Equal(t, "finished", status)
|
||||
})
|
||||
|
||||
t.Run("task fail", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
|
||||
task := tr.Add("bad-repo")
|
||||
task.Fail("connection refused")
|
||||
|
||||
_, status, state := task.snapshot()
|
||||
assert.Equal(t, taskFailed, state)
|
||||
assert.Equal(t, "connection refused", status)
|
||||
})
|
||||
|
||||
t.Run("concurrent updates", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range 10 {
|
||||
task := tr.Add("task-" + string(rune('a'+i)))
|
||||
wg.Add(1)
|
||||
go func(t *TrackedTask) {
|
||||
defer wg.Done()
|
||||
t.Update("running")
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
t.Done("ok")
|
||||
}(task)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
assert.True(t, tr.allDone())
|
||||
})
|
||||
|
||||
t.Run("summary all passed", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
|
||||
tr.Add("a").Done("ok")
|
||||
tr.Add("b").Done("ok")
|
||||
tr.Add("c").Done("ok")
|
||||
|
||||
assert.Equal(t, "3/3 passed", tr.Summary())
|
||||
})
|
||||
|
||||
t.Run("summary with failures", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
|
||||
tr.Add("a").Done("ok")
|
||||
tr.Add("b").Fail("error")
|
||||
tr.Add("c").Done("ok")
|
||||
|
||||
assert.Equal(t, "2/3 passed, 1 failed", tr.Summary())
|
||||
})
|
||||
|
||||
t.Run("wait completes for non-TTY", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &buf
|
||||
|
||||
task := tr.Add("quick")
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
task.Done("done")
|
||||
}()
|
||||
|
||||
tr.Wait()
|
||||
assert.Contains(t, buf.String(), "quick")
|
||||
assert.Contains(t, buf.String(), "done")
|
||||
})
|
||||
|
||||
t.Run("name width alignment", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
|
||||
tr.Add("short")
|
||||
tr.Add("very-long-repo-name")
|
||||
|
||||
w := tr.nameWidth()
|
||||
assert.Equal(t, 19, w)
|
||||
})
|
||||
|
||||
t.Run("String output format", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
|
||||
tr.Add("repo-a").Done("clean")
|
||||
tr.Add("repo-b").Fail("dirty")
|
||||
tr.Add("repo-c").Update("pulling")
|
||||
|
||||
out := tr.String()
|
||||
assert.Contains(t, out, "✓")
|
||||
assert.Contains(t, out, "✗")
|
||||
assert.Contains(t, out, "⠋")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTaskTracker_Bad(t *testing.T) {
|
||||
t.Run("allDone with no tasks", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
assert.True(t, tr.allDone())
|
||||
})
|
||||
|
||||
t.Run("allDone incomplete", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
tr.Add("pending")
|
||||
assert.False(t, tr.allDone())
|
||||
})
|
||||
}
|
||||
|
||||
func TestTrackedTask_Good(t *testing.T) {
|
||||
t.Run("thread safety", func(t *testing.T) {
|
||||
tr := NewTaskTracker()
|
||||
tr.out = &bytes.Buffer{}
|
||||
task := tr.Add("concurrent")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 100 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
task.Update("running")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
_, status, state := task.snapshot()
|
||||
require.Equal(t, taskRunning, state)
|
||||
require.Equal(t, "running", status)
|
||||
})
|
||||
}
|
||||
98
pkg/cli/tree.go
Normal file
98
pkg/cli/tree.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TreeNode represents a node in a displayable tree structure.
|
||||
// Use NewTree to create a root, then Add children.
|
||||
//
|
||||
// tree := cli.NewTree("core-php")
|
||||
// tree.Add("core-tenant").Add("core-bio")
|
||||
// tree.Add("core-admin")
|
||||
// tree.Add("core-api")
|
||||
// fmt.Print(tree)
|
||||
// // core-php
|
||||
// // ├── core-tenant
|
||||
// // │ └── core-bio
|
||||
// // ├── core-admin
|
||||
// // └── core-api
|
||||
type TreeNode struct {
|
||||
label string
|
||||
style *AnsiStyle
|
||||
children []*TreeNode
|
||||
}
|
||||
|
||||
// NewTree creates a new tree with the given root label.
|
||||
func NewTree(label string) *TreeNode {
|
||||
return &TreeNode{label: label}
|
||||
}
|
||||
|
||||
// Add appends a child node and returns the child for chaining.
|
||||
func (n *TreeNode) Add(label string) *TreeNode {
|
||||
child := &TreeNode{label: label}
|
||||
n.children = append(n.children, child)
|
||||
return child
|
||||
}
|
||||
|
||||
// AddStyled appends a styled child node and returns the child for chaining.
|
||||
func (n *TreeNode) AddStyled(label string, style *AnsiStyle) *TreeNode {
|
||||
child := &TreeNode{label: label, style: style}
|
||||
n.children = append(n.children, child)
|
||||
return child
|
||||
}
|
||||
|
||||
// AddTree appends an existing tree as a child and returns the parent for chaining.
|
||||
func (n *TreeNode) AddTree(child *TreeNode) *TreeNode {
|
||||
n.children = append(n.children, child)
|
||||
return n
|
||||
}
|
||||
|
||||
// WithStyle sets the style on this node and returns it for chaining.
|
||||
func (n *TreeNode) WithStyle(style *AnsiStyle) *TreeNode {
|
||||
n.style = style
|
||||
return n
|
||||
}
|
||||
|
||||
// String renders the tree with box-drawing characters.
|
||||
// Implements fmt.Stringer.
|
||||
func (n *TreeNode) String() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(n.renderLabel())
|
||||
sb.WriteByte('\n')
|
||||
n.writeChildren(&sb, "")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Render prints the tree to stdout.
|
||||
func (n *TreeNode) Render() {
|
||||
fmt.Print(n.String())
|
||||
}
|
||||
|
||||
func (n *TreeNode) renderLabel() string {
|
||||
if n.style != nil {
|
||||
return n.style.Render(n.label)
|
||||
}
|
||||
return n.label
|
||||
}
|
||||
|
||||
func (n *TreeNode) writeChildren(sb *strings.Builder, prefix string) {
|
||||
for i, child := range n.children {
|
||||
last := i == len(n.children)-1
|
||||
|
||||
connector := "├── "
|
||||
next := "│ "
|
||||
if last {
|
||||
connector = "└── "
|
||||
next = " "
|
||||
}
|
||||
|
||||
sb.WriteString(prefix)
|
||||
sb.WriteString(connector)
|
||||
sb.WriteString(child.renderLabel())
|
||||
sb.WriteByte('\n')
|
||||
|
||||
child.writeChildren(sb, prefix+next)
|
||||
}
|
||||
}
|
||||
113
pkg/cli/tree_test.go
Normal file
113
pkg/cli/tree_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTree_Good(t *testing.T) {
|
||||
t.Run("single root", func(t *testing.T) {
|
||||
tree := NewTree("root")
|
||||
assert.Equal(t, "root\n", tree.String())
|
||||
})
|
||||
|
||||
t.Run("flat children", func(t *testing.T) {
|
||||
tree := NewTree("root")
|
||||
tree.Add("alpha")
|
||||
tree.Add("beta")
|
||||
tree.Add("gamma")
|
||||
|
||||
expected := "root\n" +
|
||||
"├── alpha\n" +
|
||||
"├── beta\n" +
|
||||
"└── gamma\n"
|
||||
assert.Equal(t, expected, tree.String())
|
||||
})
|
||||
|
||||
t.Run("nested children", func(t *testing.T) {
|
||||
tree := NewTree("core-php")
|
||||
tree.Add("core-tenant").Add("core-bio")
|
||||
tree.Add("core-admin")
|
||||
tree.Add("core-api")
|
||||
|
||||
expected := "core-php\n" +
|
||||
"├── core-tenant\n" +
|
||||
"│ └── core-bio\n" +
|
||||
"├── core-admin\n" +
|
||||
"└── core-api\n"
|
||||
assert.Equal(t, expected, tree.String())
|
||||
})
|
||||
|
||||
t.Run("deep nesting", func(t *testing.T) {
|
||||
tree := NewTree("a")
|
||||
tree.Add("b").Add("c").Add("d")
|
||||
|
||||
expected := "a\n" +
|
||||
"└── b\n" +
|
||||
" └── c\n" +
|
||||
" └── d\n"
|
||||
assert.Equal(t, expected, tree.String())
|
||||
})
|
||||
|
||||
t.Run("mixed depth", func(t *testing.T) {
|
||||
tree := NewTree("root")
|
||||
a := tree.Add("a")
|
||||
a.Add("a1")
|
||||
a.Add("a2")
|
||||
tree.Add("b")
|
||||
|
||||
expected := "root\n" +
|
||||
"├── a\n" +
|
||||
"│ ├── a1\n" +
|
||||
"│ └── a2\n" +
|
||||
"└── b\n"
|
||||
assert.Equal(t, expected, tree.String())
|
||||
})
|
||||
|
||||
t.Run("AddTree composes subtrees", func(t *testing.T) {
|
||||
sub := NewTree("sub-root")
|
||||
sub.Add("child")
|
||||
|
||||
tree := NewTree("main")
|
||||
tree.AddTree(sub)
|
||||
|
||||
expected := "main\n" +
|
||||
"└── sub-root\n" +
|
||||
" └── child\n"
|
||||
assert.Equal(t, expected, tree.String())
|
||||
})
|
||||
|
||||
t.Run("styled nodes", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
tree := NewTree("root")
|
||||
tree.AddStyled("green", SuccessStyle)
|
||||
tree.Add("plain")
|
||||
|
||||
expected := "root\n" +
|
||||
"├── green\n" +
|
||||
"└── plain\n"
|
||||
assert.Equal(t, expected, tree.String())
|
||||
})
|
||||
|
||||
t.Run("WithStyle on root", func(t *testing.T) {
|
||||
SetColorEnabled(false)
|
||||
defer SetColorEnabled(true)
|
||||
|
||||
tree := NewTree("root").WithStyle(ErrorStyle)
|
||||
tree.Add("child")
|
||||
|
||||
expected := "root\n" +
|
||||
"└── child\n"
|
||||
assert.Equal(t, expected, tree.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestTree_Bad(t *testing.T) {
|
||||
t.Run("empty label", func(t *testing.T) {
|
||||
tree := NewTree("")
|
||||
assert.Equal(t, "\n", tree.String())
|
||||
})
|
||||
}
|
||||
505
pkg/cli/utils.go
Normal file
505
pkg/cli/utils.go
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue