diff --git a/cmd/krci/main.go b/cmd/krci/main.go index 0fb6635..417392e 100644 --- a/cmd/krci/main.go +++ b/cmd/krci/main.go @@ -5,8 +5,9 @@ import ( "fmt" "os" - "github.com/KubeRocketCI/cli/internal/cli" + "github.com/KubeRocketCI/cli/internal/cmdutil" "github.com/KubeRocketCI/cli/internal/config" + "github.com/KubeRocketCI/cli/pkg/cmd/root" ) // Build-time variables injected via ldflags. @@ -18,9 +19,10 @@ var ( func main() { config.Init() - cli.SetVersionInfo(version, commit, date) - if err := cli.NewRootCmd().Execute(); err != nil { + f := cmdutil.New() + + if err := root.NewCmdRoot(f, version, commit, date).Execute(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } diff --git a/go.mod b/go.mod index b5ecae0..6b95425 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,8 @@ module github.com/KubeRocketCI/cli go 1.26.1 require ( - github.com/charmbracelet/lipgloss v1.1.0 + charm.land/lipgloss/v2 v2.0.2 github.com/coreos/go-oidc/v3 v3.17.0 - github.com/fatih/color v1.17.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 @@ -18,11 +17,14 @@ require ( require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -38,14 +40,12 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect - github.com/muesli/termenv v0.16.0 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -61,7 +61,8 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.9.0 // indirect diff --git a/go.sum b/go.sum index b85d5f8..63dbfea 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,29 @@ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= +charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/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.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -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/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -29,8 +35,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= -github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= 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= @@ -79,25 +83,20 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -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/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= @@ -110,7 +109,6 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -154,8 +152,8 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= @@ -164,11 +162,9 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= diff --git a/internal/cli/auth.go b/internal/cli/auth.go deleted file mode 100644 index ceb06cf..0000000 --- a/internal/cli/auth.go +++ /dev/null @@ -1,18 +0,0 @@ -package cli - -import "github.com/spf13/cobra" - -func (a *App) newAuthCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "auth", - Short: "Authentication commands", - } - - cmd.AddCommand( - a.newAuthLoginCmd(), - a.newAuthStatusCmd(), - a.newAuthLogoutCmd(), - ) - - return cmd -} diff --git a/internal/cli/auth_login.go b/internal/cli/auth_login.go deleted file mode 100644 index 05625c1..0000000 --- a/internal/cli/auth_login.go +++ /dev/null @@ -1,63 +0,0 @@ -package cli - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/util/validation" - - "github.com/KubeRocketCI/cli/internal/config" - "github.com/KubeRocketCI/cli/internal/portal" -) - -func (a *App) newAuthLoginCmd() *cobra.Command { - return &cobra.Command{ - Use: "login", - Short: "Authenticate with OIDC provider (Keycloak)", - Long: `Authenticate by opening a browser to the OIDC provider. -After successful login, credentials are stored encrypted locally -and the OIDC configuration is saved to ~/.config/krci/config.yaml. - -The OIDC issuer URL must be configured via one of: - --issuer-url flag - KRCI_ISSUER_URL environment variable - issuer-url in ~/.config/krci/config.yaml`, - RunE: func(cmd *cobra.Command, _ []string) error { - if a.cfg.IssuerURL == "" { - return fmt.Errorf( - "OIDC issuer URL required.\n\nSet it via:\n" + - " --issuer-url flag\n" + - " KRCI_ISSUER_URL env var\n" + - " issuer-url in ~/.config/krci/config.yaml", - ) - } - - if err := a.tokenProvider.Login(cmd.Context()); err != nil { - return err - } - - // Fetch portal config to auto-populate namespace. - if a.cfg.PortalURL != "" && a.cfg.Namespace == "" { - portalCfg, err := portal.FetchConfig(a.cfg.PortalURL) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: could not fetch portal config: %v\n", err) - } else if portalCfg.DefaultNamespace != "" { - if errs := validation.IsDNS1123Label(portalCfg.DefaultNamespace); len(errs) > 0 { - fmt.Fprintf(os.Stderr, "Warning: portal returned invalid namespace %q, ignoring\n", portalCfg.DefaultNamespace) - } else { - a.cfg.Namespace = portalCfg.DefaultNamespace - fmt.Fprintf(os.Stderr, "Namespace: %s (from portal)\n", a.cfg.Namespace) - } - } - } - - // Save config so subsequent commands don't need --issuer-url. - if err := config.Save(a.cfg); err != nil { - fmt.Fprintf(os.Stderr, "Warning: could not save config: %v\n", err) - } - - return nil - }, - } -} diff --git a/internal/cli/auth_logout.go b/internal/cli/auth_logout.go deleted file mode 100644 index dd40322..0000000 --- a/internal/cli/auth_logout.go +++ /dev/null @@ -1,23 +0,0 @@ -package cli - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" -) - -func (a *App) newAuthLogoutCmd() *cobra.Command { - return &cobra.Command{ - Use: "logout", - Short: "Clear stored credentials", - Long: "Remove all locally stored tokens and credentials. You will need to run 'krci auth login' again.", - RunE: func(cmd *cobra.Command, _ []string) error { - if err := a.tokenProvider.Logout(); err != nil { - return err - } - fmt.Fprintln(os.Stderr, "Logged out. Credentials removed.") - return nil - }, - } -} diff --git a/internal/cli/auth_status.go b/internal/cli/auth_status.go deleted file mode 100644 index d27940e..0000000 --- a/internal/cli/auth_status.go +++ /dev/null @@ -1,62 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "os" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/KubeRocketCI/cli/internal/auth" -) - -func (a *App) newAuthStatusCmd() *cobra.Command { - return &cobra.Command{ - Use: "status", - Short: "Show authentication status", - RunE: func(cmd *cobra.Command, _ []string) error { - _, err := a.tokenProvider.GetToken(cmd.Context()) - - info, infoErr := a.tokenProvider.UserInfo() - - if err != nil { - if errors.Is(err, auth.ErrNotAuthenticated) { - fmt.Fprintln(os.Stderr, "Not authenticated. Run: krci auth login") - return nil - } - if errors.Is(err, auth.ErrRefreshFailed) || errors.Is(err, auth.ErrTokenExpired) { - if infoErr == nil { - fmt.Fprintf(os.Stderr, "User: %s\n", info.Email) - } - fmt.Fprintln(os.Stderr, "Status: Session expired. Run: krci auth login") - return nil - } - return err - } - - if infoErr != nil { - fmt.Fprintln(os.Stderr, "Status: Authenticated (unable to read user info)") - return nil - } - - fmt.Fprintf(os.Stderr, "User: %s\n", info.Email) - if info.Name != "" { - fmt.Fprintf(os.Stderr, "Name: %s\n", info.Name) - } - fmt.Fprintf(os.Stderr, "Status: Authenticated\n") - - if expiry := info.ExpiresAt; !expiry.IsZero() { - remaining := time.Until(expiry).Round(time.Second) - fmt.Fprintf(os.Stderr, "Expires: %s (%s)\n", expiry.Local().Format(time.RFC822), remaining) - } - - if len(info.Groups) > 0 { - fmt.Fprintf(os.Stderr, "Groups: %s\n", strings.Join(info.Groups, ", ")) - } - - return nil - }, - } -} diff --git a/internal/cli/deployment.go b/internal/cli/deployment.go deleted file mode 100644 index 8dd9ef6..0000000 --- a/internal/cli/deployment.go +++ /dev/null @@ -1,18 +0,0 @@ -package cli - -import "github.com/spf13/cobra" - -func (a *App) newDeploymentCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "deployment", - Short: "Manage deployments (CDPipelines)", - Aliases: []string{"dp"}, - } - - cmd.AddCommand( - a.newDeploymentListCmd(), - a.newDeploymentGetCmd(), - ) - - return cmd -} diff --git a/internal/cli/deployment_list.go b/internal/cli/deployment_list.go deleted file mode 100644 index f9368bf..0000000 --- a/internal/cli/deployment_list.go +++ /dev/null @@ -1,79 +0,0 @@ -package cli - -import ( - "fmt" - "strings" - - "github.com/spf13/cobra" -) - -// maxInlineApps is the maximum number of applications shown inline before truncating. -const maxInlineApps = 3 - -func (a *App) newDeploymentListCmd() *cobra.Command { - var output string - - cmd := &cobra.Command{ - Use: "list", - Short: "List deployments", - Aliases: []string{"ls"}, - RunE: func(cmd *cobra.Command, _ []string) error { - if err := a.requireK8s(); err != nil { - return err - } - - deployments, err := a.k8sDeployment.List(cmd.Context()) - if err != nil { - return err - } - - w := cmd.OutOrStdout() - format := resolveFormat(output, w) - - switch format { - case outputFormatJSON: - return printJSON(w, deployments) - case outputFormatTable: - headers := []string{"NAME", "APPLICATIONS", "ENVS", "STATUS"} - rows := make([][]string, 0, len(deployments)) - - isTTY := isTerminal(w) - - for _, d := range deployments { - status := d.Status - if isTTY { - status = statusColor(d.Status) - } - - rows = append(rows, []string{ - d.Name, - formatApplications(d.Applications), - strings.Join(d.StageNames, " \u2192 "), - status, - }) - } - - if isTTY { - return printStyledTable(w, headers, rows) - } - - return printTable(w, headers, rows) - default: - return fmt.Errorf("unknown output format: %s (use 'json' or 'table')", format) - } - }, - } - - cmd.Flags().StringVarP(&output, "output", "o", "", "Output format: table, json (default: auto-detect)") - - return cmd -} - -// formatApplications joins application names, truncating if more than maxInlineApps. -func formatApplications(apps []string) string { - if len(apps) <= maxInlineApps { - return strings.Join(apps, ", ") - } - - return strings.Join(apps[:maxInlineApps], ", ") + fmt.Sprintf(" +%d more", len(apps)-maxInlineApps) -} diff --git a/internal/cli/output.go b/internal/cli/output.go deleted file mode 100644 index 9e71778..0000000 --- a/internal/cli/output.go +++ /dev/null @@ -1,191 +0,0 @@ -package cli - -import ( - "encoding/json" - "fmt" - "io" - "os" - "strings" - "text/tabwriter" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" - "github.com/fatih/color" - - "github.com/KubeRocketCI/cli/internal/k8s" -) - -const ( - outputFormatTable = "table" - outputFormatJSON = "json" -) - -// ANSI color code used for borders and header accents. -const accentColor = "99" - -// Style definitions shared across all commands. -var ( - headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(accentColor)).Padding(0, 1) - evenRowStyle = lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("252")) - oddRowStyle = lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("245")) - borderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(accentColor)) - labelStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("245")).Width(14) - valueStyle = lipgloss.NewStyle() -) - -// Status color helpers (fatih/color auto-disables when stdout is not a TTY). -var ( - greenText = color.New(color.FgGreen).SprintFunc() - yellowText = color.New(color.FgYellow).SprintFunc() - redText = color.New(color.FgRed).SprintFunc() -) - -// statusColor returns the status string with color applied based on its value. -func statusColor(status string) string { - switch strings.ToLower(status) { - case k8s.StatusCreated: - return greenText(status) - case k8s.StatusInProgress: - return yellowText(status) - case k8s.StatusFailed: - return redText(status) - default: - return status - } -} - -// availableText returns a colorized "Yes" or "No" instead of true/false. -func availableText(available bool) string { - if available { - return greenText("Yes") - } - - return redText("No") -} - -// printStyledTable renders a lipgloss table with rounded borders and styled rows. -func printStyledTable(w io.Writer, headers []string, rows [][]string) error { - t := table.New(). - Border(lipgloss.RoundedBorder()). - BorderStyle(borderStyle). - StyleFunc(func(row, col int) lipgloss.Style { - if row == table.HeaderRow { - return headerStyle - } - - if row%2 == 0 { - return evenRowStyle - } - - return oddRowStyle - }). - Headers(headers...). - Rows(rows...) - - _, err := fmt.Fprintln(w, t) - - return err -} - -// printTable renders a plain-text table for piped or non-TTY output. -func printTable(w io.Writer, headers []string, rows [][]string) error { - tw := tabwriter.NewWriter(w, 0, 0, 3, ' ', 0) - - if _, err := fmt.Fprintln(tw, strings.Join(headers, "\t")); err != nil { - return err - } - - for _, row := range rows { - if _, err := fmt.Fprintln(tw, strings.Join(row, "\t")); err != nil { - return err - } - } - - return tw.Flush() -} - -func printJSON(w io.Writer, v any) error { - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - - return enc.Encode(v) -} - -// resolveFormat returns the explicit format if provided, otherwise auto-detects -// based on whether the writer is a terminal (table) or a pipe/file (JSON). -func resolveFormat(explicit string, w io.Writer) string { - if explicit != "" { - return explicit - } - - if isTerminal(w) { - return outputFormatTable - } - - return outputFormatJSON -} - -func isTerminal(w io.Writer) bool { - f, ok := w.(*os.File) - if !ok { - return false - } - - info, err := f.Stat() - if err != nil { - return false - } - - return info.Mode()&os.ModeCharDevice != 0 -} - -// detailRenderer defines styled and plain rendering for a resource detail view. -type detailRenderer[T any] struct { - styled func(io.Writer, T) error - plain func(io.Writer, T) error -} - -// renderDetail handles the common format-resolution and output logic for detail commands. -func renderDetail[T any](w io.Writer, output string, data T, r detailRenderer[T]) error { - format := resolveFormat(output, w) - - switch format { - case outputFormatJSON: - return printJSON(w, data) - case outputFormatTable: - if isTerminal(w) { - return r.styled(w, data) - } - - return r.plain(w, data) - default: - return fmt.Errorf("unknown output format: %s (use 'json' or 'table')", format) - } -} - -// printStyledDetailLines renders detail lines with lipgloss styling. -func printStyledDetailLines(w io.Writer, lines []detailLine) error { - for _, l := range lines { - v := l.styled - if v == "" { - v = valueStyle.Render(l.value) - } - - if _, err := fmt.Fprintf(w, "%s %s\n", labelStyle.Render(l.label+":"), v); err != nil { - return err - } - } - - return nil -} - -// printPlainDetailLines renders detail lines as plain text for piped output. -func printPlainDetailLines(w io.Writer, lines []detailLine) error { - for _, l := range lines { - if _, err := fmt.Fprintf(w, "%-14s%s\n", l.label+":", l.value); err != nil { - return err - } - } - - return nil -} diff --git a/internal/cli/project.go b/internal/cli/project.go deleted file mode 100644 index 3d20626..0000000 --- a/internal/cli/project.go +++ /dev/null @@ -1,18 +0,0 @@ -package cli - -import "github.com/spf13/cobra" - -func (a *App) newProjectCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "project", - Short: "Manage projects (Codebases)", - Aliases: []string{"proj"}, - } - - cmd.AddCommand( - a.newProjectListCmd(), - a.newProjectGetCmd(), - ) - - return cmd -} diff --git a/internal/cli/project_get.go b/internal/cli/project_get.go deleted file mode 100644 index 68ef128..0000000 --- a/internal/cli/project_get.go +++ /dev/null @@ -1,91 +0,0 @@ -package cli - -import ( - "io" - "strconv" - - "github.com/spf13/cobra" - - "github.com/KubeRocketCI/cli/internal/k8s" -) - -func (a *App) newProjectGetCmd() *cobra.Command { - var output string - - cmd := &cobra.Command{ - Use: "get ", - Short: "Get project details", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - if err := a.requireK8s(); err != nil { - return err - } - - project, err := a.k8sProject.Get(cmd.Context(), args[0]) - if err != nil { - return err - } - - return renderDetail(cmd.OutOrStdout(), output, project, detailRenderer[*k8s.Project]{ - styled: printStyledProjectDetail, - plain: printPlainProjectDetail, - }) - }, - } - - cmd.Flags().StringVarP(&output, "output", "o", "", "Output format: table, json (default: auto-detect)") - - return cmd -} - -// detailLine holds one label-value pair for resource detail rendering. -// When styled is populated, the styled renderer uses it instead of value. -type detailLine struct { - label string - value string - styled string -} - -// projectDetailLines builds the ordered field list for a project. -// When styled is true, Status and Available get colorized representations. -func projectDetailLines(p *k8s.Project, styled bool) []detailLine { - lines := []detailLine{ - {label: "Name", value: p.Name}, - {label: "Namespace", value: p.Namespace}, - {label: "Type", value: p.Type}, - {label: "Language", value: p.Language}, - {label: "Build Tool", value: p.BuildTool}, - } - - if p.Framework != "" { - lines = append(lines, detailLine{label: "Framework", value: p.Framework}) - } - - lines = append(lines, detailLine{label: "Git Server", value: p.GitServer}) - - if p.GitURL != "" { - lines = append(lines, detailLine{label: "Git URL", value: p.GitURL}) - } - - statusLine := detailLine{label: "Status", value: p.Status} - availableLine := detailLine{label: "Available", value: strconv.FormatBool(p.Available)} - - if styled { - statusLine.styled = statusColor(p.Status) - availableLine.styled = availableText(p.Available) - } - - lines = append(lines, statusLine, availableLine) - - return lines -} - -// printStyledProjectDetail renders project details with lipgloss styling. -func printStyledProjectDetail(w io.Writer, p *k8s.Project) error { - return printStyledDetailLines(w, projectDetailLines(p, true)) -} - -// printPlainProjectDetail renders project details as plain text for piped output. -func printPlainProjectDetail(w io.Writer, p *k8s.Project) error { - return printPlainDetailLines(w, projectDetailLines(p, false)) -} diff --git a/internal/cli/project_list.go b/internal/cli/project_list.go deleted file mode 100644 index 4222a10..0000000 --- a/internal/cli/project_list.go +++ /dev/null @@ -1,61 +0,0 @@ -package cli - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -func (a *App) newProjectListCmd() *cobra.Command { - var output string - - cmd := &cobra.Command{ - Use: "list", - Short: "List projects", - Aliases: []string{"ls"}, - RunE: func(cmd *cobra.Command, _ []string) error { - if err := a.requireK8s(); err != nil { - return err - } - - projects, err := a.k8sProject.List(cmd.Context()) - if err != nil { - return err - } - - w := cmd.OutOrStdout() - format := resolveFormat(output, w) - - switch format { - case outputFormatJSON: - return printJSON(w, projects) - case outputFormatTable: - headers := []string{"NAME", "TYPE", "LANGUAGE", "BUILD TOOL", "STATUS"} - rows := make([][]string, 0, len(projects)) - - isTTY := isTerminal(w) - - for _, p := range projects { - status := p.Status - if isTTY { - status = statusColor(p.Status) - } - - rows = append(rows, []string{p.Name, p.Type, p.Language, p.BuildTool, status}) - } - - if isTTY { - return printStyledTable(w, headers, rows) - } - - return printTable(w, headers, rows) - default: - return fmt.Errorf("unknown output format: %s (use 'json' or 'table')", format) - } - }, - } - - cmd.Flags().StringVarP(&output, "output", "o", "", "Output format: table, json (default: auto-detect)") - - return cmd -} diff --git a/internal/cli/root.go b/internal/cli/root.go deleted file mode 100644 index c8eeb4a..0000000 --- a/internal/cli/root.go +++ /dev/null @@ -1,105 +0,0 @@ -// Package cli provides Cobra command definitions for the krci CLI. -package cli - -import ( - "fmt" - - "github.com/spf13/cobra" - "k8s.io/client-go/dynamic" - - "github.com/KubeRocketCI/cli/internal/auth" - "github.com/KubeRocketCI/cli/internal/config" - "github.com/KubeRocketCI/cli/internal/k8s" - "github.com/KubeRocketCI/cli/internal/token" -) - -// App holds wired dependencies for all command handlers. -// Config and providers are resolved lazily after Cobra parses flags. -type App struct { - cfg *config.Config - tokenProvider auth.TokenProvider - k8sDynClient dynamic.Interface - k8sProject *k8s.ProjectService - k8sDeployment *k8s.DeploymentService -} - -// initConfig resolves config after flags are parsed, then wires dependencies. -func (a *App) initConfig(cmd *cobra.Command, _ []string) error { - cfg, err := config.Resolve() - if err != nil { - return fmt.Errorf("loading config: %w", err) - } - - a.cfg = cfg - enc := token.NewAESEncryptor(cfg.KeyringService, cfg.ConfigDir) - store := token.NewEncryptedStore(cfg.TokenPath, enc) - a.tokenProvider = auth.NewTokenProvider(store, cfg) - - return nil -} - -// requireK8s creates the K8s client on first call and caches it. -// Both ProjectService and DeploymentService share the same dynamic client. -func (a *App) requireK8s() error { - if a.k8sDynClient != nil { - return nil - } - - if a.cfg.APIServer == "" { - return fmt.Errorf( - "kubernetes API server not configured\n\nSet it via:\n" + - " --api-server flag\n" + - " KRCI_API_SERVER env var\n" + - " api-server in ~/.config/krci/config.yaml", - ) - } - - if a.cfg.Namespace == "" { - return fmt.Errorf( - "kubernetes namespace not configured\n\nSet it via:\n" + - " -n/--namespace flag\n" + - " KRCI_NAMESPACE env var\n" + - " namespace in ~/.config/krci/config.yaml", - ) - } - - dynClient, err := k8s.NewDynamicClient(k8s.ClientConfig{ - APIServer: a.cfg.APIServer, - CAData: a.cfg.CAData, - TokenFunc: a.tokenProvider.GetToken, - }) - if err != nil { - return fmt.Errorf("kubernetes client initialization failed: %w", err) - } - - a.k8sDynClient = dynClient - a.k8sProject = k8s.NewProjectService(dynClient, a.cfg.Namespace) - a.k8sDeployment = k8s.NewDeploymentService(dynClient, a.cfg.Namespace) - - return nil -} - -// NewRootCmd builds the root cobra.Command with all subcommands. -func NewRootCmd() *cobra.Command { - app := &App{} - - cmd := &cobra.Command{ - Use: "krci", - Short: "KubeRocketCI CLI", - Long: "Command-line interface for the KubeRocketCI platform.", - SilenceUsage: true, - SilenceErrors: true, - PersistentPreRunE: app.initConfig, - } - - config.BindFlags(cmd) - - cmd.AddCommand( - app.newAuthCmd(), - app.newProjectCmd(), - app.newDeploymentCmd(), - app.newVersionCmd(), - ) - - return cmd -} diff --git a/internal/cli/version.go b/internal/cli/version.go deleted file mode 100644 index dad46c6..0000000 --- a/internal/cli/version.go +++ /dev/null @@ -1,35 +0,0 @@ -package cli - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var ( - buildVersion = "dev" - buildCommit = "none" - buildDate = "unknown" -) - -// SetVersionInfo sets the build-time version information. -func SetVersionInfo(version, commit, date string) { - buildVersion = version - buildCommit = commit - buildDate = date -} - -func (a *App) newVersionCmd() *cobra.Command { - return &cobra.Command{ - Use: "version", - Short: "Print krci CLI version", - RunE: func(cmd *cobra.Command, _ []string) error { - _, err := fmt.Fprintf(cmd.OutOrStdout(), - "krci version %s (commit: %s, built: %s)\n", - buildVersion, buildCommit, buildDate, - ) - - return err - }, - } -} diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go new file mode 100644 index 0000000..177fa0e --- /dev/null +++ b/internal/cmdutil/factory.go @@ -0,0 +1,136 @@ +// Package cmdutil provides shared CLI utilities, including the Factory dependency container. +package cmdutil + +import ( + "fmt" + "sync" + + "k8s.io/client-go/dynamic" + + "github.com/KubeRocketCI/cli/internal/auth" + "github.com/KubeRocketCI/cli/internal/config" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/k8s" + "github.com/KubeRocketCI/cli/internal/token" +) + +// Factory holds lazy-func dependencies shared across all CLI commands. +// Each func is memoized: the first call resolves the dependency; subsequent calls +// return the cached result instantly. +type Factory struct { + IOStreams *iostreams.IOStreams + Config func() (*config.Config, error) + TokenProvider func() (auth.TokenProvider, error) + K8sClient func() (dynamic.Interface, error) +} + +// New creates a Factory wired to real system resources. +// Config, TokenProvider, and K8sClient are lazily resolved after Cobra +// parses command-line flags (triggered by PersistentPreRunE on the root command). +func New() *Factory { + f := &Factory{ + IOStreams: iostreams.System(), + } + + var ( + muCfg sync.Mutex + cachedConfig *config.Config + ) + + f.Config = func() (*config.Config, error) { + muCfg.Lock() + defer muCfg.Unlock() + + if cachedConfig != nil { + return cachedConfig, nil + } + + cfg, err := config.Resolve() + if err != nil { + return nil, fmt.Errorf("loading config: %w", err) + } + + cachedConfig = cfg + return cachedConfig, nil + } + + var ( + onceTP sync.Once + cachedTP auth.TokenProvider + cachedTPErr error + ) + + f.TokenProvider = func() (auth.TokenProvider, error) { + onceTP.Do(func() { + cfg, err := f.Config() + if err != nil { + cachedTPErr = err + return + } + + enc := token.NewAESEncryptor(cfg.KeyringService, cfg.ConfigDir) + store := token.NewEncryptedStore(cfg.TokenPath, enc) + cachedTP = auth.NewTokenProvider(store, cfg) + }) + + return cachedTP, cachedTPErr + } + + var ( + onceK8s sync.Once + cachedK8s dynamic.Interface + cachedK8sErr error + ) + + f.K8sClient = func() (dynamic.Interface, error) { + onceK8s.Do(func() { + cfg, err := f.Config() + if err != nil { + cachedK8sErr = err + return + } + + if cfg.APIServer == "" { + cachedK8sErr = fmt.Errorf( + "kubernetes API server not configured\n\nSet it via:\n" + + " --api-server flag\n" + + " KRCI_API_SERVER env var\n" + + " api-server in ~/.config/krci/config.yaml", + ) + return + } + + if cfg.Namespace == "" { + cachedK8sErr = fmt.Errorf( + "kubernetes namespace not configured\n\nSet it via:\n" + + " -n/--namespace flag\n" + + " KRCI_NAMESPACE env var\n" + + " namespace in ~/.config/krci/config.yaml", + ) + return + } + + tp, err := f.TokenProvider() + if err != nil { + cachedK8sErr = err + return + } + + dynClient, err := k8s.NewDynamicClient(k8s.ClientConfig{ + APIServer: cfg.APIServer, + CAData: cfg.CAData, + TokenFunc: tp.GetToken, + }) + if err != nil { + cachedK8sErr = fmt.Errorf("kubernetes client initialization failed: %w", err) + return + } + + cachedK8s = dynClient + }) + + return cachedK8s, cachedK8sErr + } + + return f +} diff --git a/internal/iostreams/iostreams.go b/internal/iostreams/iostreams.go new file mode 100644 index 0000000..c3cabf1 --- /dev/null +++ b/internal/iostreams/iostreams.go @@ -0,0 +1,45 @@ +// Package iostreams provides I/O stream abstractions for CLI commands. +package iostreams + +import ( + "io" + "os" +) + +// IOStreams holds the standard I/O streams and TTY state for a command invocation. +type IOStreams struct { + In io.Reader + Out io.Writer + ErrOut io.Writer + isTTY bool +} + +// System returns an IOStreams wired to the real os.Stdin/Stdout/Stderr, +// with TTY state detected from os.Stdout. +func System() *IOStreams { + return &IOStreams{ + In: os.Stdin, + Out: os.Stdout, + ErrOut: os.Stderr, + isTTY: isTerminal(os.Stdout), + } +} + +// IsStdoutTTY reports whether Out is connected to an interactive terminal. +func (s *IOStreams) IsStdoutTTY() bool { + return s.isTTY +} + +func isTerminal(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + + info, err := f.Stat() + if err != nil { + return false + } + + return info.Mode()&os.ModeCharDevice != 0 +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..3dba1a5 --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,219 @@ +// Package output provides rendering utilities for CLI command output. +package output + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "text/tabwriter" + + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" + + "github.com/KubeRocketCI/cli/internal/iostreams" +) + +const ( + FormatTable = "table" + FormatJSON = "json" +) + +// ANSI color code used for borders and header accents. +const accentColor = "99" + +// Style definitions shared across all commands. +var ( + HeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(accentColor)).Padding(0, 1) + EvenRowStyle = lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.BrightWhite) + OddRowStyle = lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("245")) + BorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(accentColor)) + LabelStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("245")).Width(14) + ValueStyle = lipgloss.NewStyle() +) + +// Status color styles using lipgloss 4-bit color constants. +var ( + greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Green) + yellowStyle = lipgloss.NewStyle().Foreground(lipgloss.Yellow) + redStyle = lipgloss.NewStyle().Foreground(lipgloss.Red) +) + +// StatusColor returns the status string with color applied based on its value. +// Recognized values (from KubeRocketCI CRD status field): +// - "created" renders green +// - "in_progress" renders yellow +// - "failed" renders red +// +// Any other value is returned unstyled. +func StatusColor(status string) string { + switch strings.ToLower(status) { + case "created": + return greenStyle.Render(status) + case "in_progress": + return yellowStyle.Render(status) + case "failed": + return redStyle.Render(status) + default: + return status + } +} + +// AvailableText returns a colorized "Yes" or "No" instead of true/false. +func AvailableText(available bool) string { + if available { + return greenStyle.Render("Yes") + } + + return redStyle.Render("No") +} + +// GreenText returns s rendered in green. +func GreenText(s string) string { return greenStyle.Render(s) } + +// YellowText returns s rendered in yellow. +func YellowText(s string) string { return yellowStyle.Render(s) } + +// PrintStyledTable renders a lipgloss table with rounded borders and styled rows. +func PrintStyledTable(w io.Writer, headers []string, rows [][]string) error { + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(BorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return HeaderStyle + } + + if row%2 == 0 { + return EvenRowStyle + } + + return OddRowStyle + }). + Headers(headers...). + Rows(rows...) + + _, err := lipgloss.Fprintln(w, t) + + return err +} + +// PrintTable renders a plain-text table for piped or non-TTY output. +func PrintTable(w io.Writer, headers []string, rows [][]string) error { + tw := tabwriter.NewWriter(w, 0, 0, 3, ' ', 0) + + if _, err := fmt.Fprintln(tw, strings.Join(headers, "\t")); err != nil { + return err + } + + for _, row := range rows { + if _, err := fmt.Fprintln(tw, strings.Join(row, "\t")); err != nil { + return err + } + } + + return tw.Flush() +} + +// PrintJSON encodes v as indented JSON to w. +func PrintJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + + return enc.Encode(v) +} + +// ResolveFormat returns the explicit format if provided, otherwise defaults to table. +// Use -o json for JSON output. +func ResolveFormat(explicit string) string { + if explicit != "" { + return explicit + } + + return FormatTable +} + +// DetailLine holds one label-value pair for resource detail rendering. +// When Styled is populated, the styled renderer uses it instead of Value. +type DetailLine struct { + Label string + Value string + Styled string +} + +// DetailRenderer defines styled and plain rendering for a resource detail view. +type DetailRenderer[T any] struct { + Styled func(io.Writer, T) error + Plain func(io.Writer, T) error +} + +// RenderList handles the common format-resolution and output logic for list commands. +// toRows receives the isTTY flag so callers can apply color only when rendering to a terminal. +func RenderList[T any]( + ios *iostreams.IOStreams, + outputFormat string, + data T, + toRows func(isTTY bool) (headers []string, rows [][]string), +) error { + isTTY := ios.IsStdoutTTY() + format := ResolveFormat(outputFormat) + + switch format { + case FormatJSON: + return PrintJSON(ios.Out, data) + case FormatTable: + headers, rows := toRows(isTTY) + if isTTY { + return PrintStyledTable(ios.Out, headers, rows) + } + + return PrintTable(ios.Out, headers, rows) + default: + return fmt.Errorf("unknown output format: %s (use 'json' or 'table')", format) + } +} + +// RenderDetail handles the common format-resolution and output logic for detail commands. +func RenderDetail[T any](ios *iostreams.IOStreams, outputFormat string, data T, r DetailRenderer[T]) error { + format := ResolveFormat(outputFormat) + + switch format { + case FormatJSON: + return PrintJSON(ios.Out, data) + case FormatTable: + if ios.IsStdoutTTY() { + return r.Styled(ios.Out, data) + } + + return r.Plain(ios.Out, data) + default: + return fmt.Errorf("unknown output format: %s (use 'json' or 'table')", format) + } +} + +// PrintStyledDetailLines renders detail lines with lipgloss styling. +func PrintStyledDetailLines(w io.Writer, lines []DetailLine) error { + for _, l := range lines { + v := l.Styled + if v == "" { + v = ValueStyle.Render(l.Value) + } + + if _, err := lipgloss.Fprintf(w, "%s %s\n", LabelStyle.Render(l.Label+":"), v); err != nil { + return err + } + } + + return nil +} + +// PrintPlainDetailLines renders detail lines as plain text for piped output. +func PrintPlainDetailLines(w io.Writer, lines []DetailLine) error { + for _, l := range lines { + if _, err := fmt.Fprintf(w, "%-14s%s\n", l.Label+":", l.Value); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go new file mode 100644 index 0000000..73c4a59 --- /dev/null +++ b/pkg/cmd/auth/auth.go @@ -0,0 +1,27 @@ +// Package auth implements the "krci auth" command group. +package auth + +import ( + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/pkg/cmd/auth/login" + "github.com/KubeRocketCI/cli/pkg/cmd/auth/logout" + "github.com/KubeRocketCI/cli/pkg/cmd/auth/status" +) + +// NewCmdAuth returns the "auth" group cobra.Command with all subcommands attached. +func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Authentication commands", + } + + cmd.AddCommand( + login.NewCmdLogin(f, nil), + status.NewCmdStatus(f, nil), + logout.NewCmdLogout(f, nil), + ) + + return cmd +} diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go new file mode 100644 index 0000000..1bca6b7 --- /dev/null +++ b/pkg/cmd/auth/login/login.go @@ -0,0 +1,111 @@ +// Package login implements the "krci auth login" command. +package login + +import ( + "fmt" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/util/validation" + + "github.com/KubeRocketCI/cli/internal/auth" + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/config" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/portal" +) + +// LoginOptions holds all inputs for the login command. +type LoginOptions struct { + IO *iostreams.IOStreams + Config func() (*config.Config, error) + TokenProvider func() (auth.TokenProvider, error) +} + +// NewCmdLogin returns the "auth login" cobra.Command. +// runF is the business logic function; pass nil to use the default loginRun. +func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { + opts := &LoginOptions{ + IO: f.IOStreams, + Config: f.Config, + TokenProvider: f.TokenProvider, + } + + cmd := &cobra.Command{ + Use: "login", + Short: "Authenticate with OIDC provider (Keycloak)", + Long: `Authenticate by opening a browser to the OIDC provider. +After successful login, credentials are stored encrypted locally +and the OIDC configuration is saved to ~/.config/krci/config.yaml. + +The OIDC issuer URL must be configured via one of: + --issuer-url flag + KRCI_ISSUER_URL environment variable + issuer-url in ~/.config/krci/config.yaml`, + Example: ` # Log in using an issuer URL passed as a flag + krci auth login --issuer-url https://keycloak.example.com/realms/myrealm + + # Log in using an issuer URL from the environment + export KRCI_ISSUER_URL=https://keycloak.example.com/realms/myrealm + krci auth login`, + RunE: func(cmd *cobra.Command, _ []string) error { + if runF != nil { + return runF(opts) + } + + return loginRun(cmd, opts) + }, + } + + return cmd +} + +func loginRun(cmd *cobra.Command, opts *LoginOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + if cfg.IssuerURL == "" { + return fmt.Errorf( + "OIDC issuer URL required.\n\nSet it via:\n" + + " --issuer-url flag\n" + + " KRCI_ISSUER_URL env var\n" + + " issuer-url in ~/.config/krci/config.yaml", + ) + } + + tp, err := opts.TokenProvider() + if err != nil { + return err + } + + if err := tp.Login(cmd.Context()); err != nil { + return err + } + + // Clone cfg to avoid mutating the factory-cached pointer. + cfgCopy := *cfg + + // Fetch portal config to auto-populate namespace. + if cfgCopy.PortalURL != "" && cfgCopy.Namespace == "" { + portalCfg, err := portal.FetchConfig(cfgCopy.PortalURL) + if err != nil { + _, _ = fmt.Fprintf(opts.IO.ErrOut, "Warning: could not fetch portal config: %v\n", err) + } else if portalCfg.DefaultNamespace != "" { + if errs := validation.IsDNS1123Label(portalCfg.DefaultNamespace); len(errs) > 0 { + _, _ = fmt.Fprintf(opts.IO.ErrOut, + "Warning: portal returned invalid namespace %q, ignoring\n", portalCfg.DefaultNamespace) + } else { + cfgCopy.Namespace = portalCfg.DefaultNamespace + _, _ = fmt.Fprintf(opts.IO.ErrOut, "Namespace: %s (from portal)\n", cfgCopy.Namespace) + } + } + } + + // Save config so subsequent commands don't need --issuer-url. + if err := config.Save(&cfgCopy); err != nil { + _, _ = fmt.Fprintf(opts.IO.ErrOut, "Warning: could not save config: %v\n", err) + } + + return nil +} diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go new file mode 100644 index 0000000..d61a888 --- /dev/null +++ b/pkg/cmd/auth/logout/logout.go @@ -0,0 +1,56 @@ +// Package logout implements the "krci auth logout" command. +package logout + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/auth" + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/iostreams" +) + +// LogoutOptions holds all inputs for the logout command. +type LogoutOptions struct { + IO *iostreams.IOStreams + TokenProvider func() (auth.TokenProvider, error) +} + +// NewCmdLogout returns the "auth logout" cobra.Command. +// runF is the business logic function; pass nil to use the default logoutRun. +func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Command { + opts := &LogoutOptions{ + IO: f.IOStreams, + TokenProvider: f.TokenProvider, + } + + return &cobra.Command{ + Use: "logout", + Short: "Clear stored credentials", + Long: "Remove all locally stored tokens and credentials. You will need to run 'krci auth login' again.", + Example: " krci auth logout", + RunE: func(cmd *cobra.Command, _ []string) error { + if runF != nil { + return runF(opts) + } + + return logoutRun(opts) + }, + } +} + +func logoutRun(opts *LogoutOptions) error { + tp, err := opts.TokenProvider() + if err != nil { + return err + } + + if err := tp.Logout(); err != nil { + return err + } + + _, _ = fmt.Fprintln(opts.IO.ErrOut, "Logged out. Credentials removed.") + + return nil +} diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go new file mode 100644 index 0000000..f4cb0fc --- /dev/null +++ b/pkg/cmd/auth/status/status.go @@ -0,0 +1,121 @@ +// Package status implements the "krci auth status" command. +package status + +import ( + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/auth" + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/output" +) + +// StatusOptions holds all inputs for the status command. +type StatusOptions struct { + IO *iostreams.IOStreams + TokenProvider func() (auth.TokenProvider, error) +} + +// NewCmdStatus returns the "auth status" cobra.Command. +// runF is the business logic function; pass nil to use the default statusRun. +func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { + opts := &StatusOptions{ + IO: f.IOStreams, + TokenProvider: f.TokenProvider, + } + + return &cobra.Command{ + Use: "status", + Short: "Show authentication status", + Example: " krci auth status", + RunE: func(cmd *cobra.Command, _ []string) error { + if runF != nil { + return runF(opts) + } + + return statusRun(cmd, opts) + }, + } +} + +func statusRun(cmd *cobra.Command, opts *StatusOptions) error { + tp, err := opts.TokenProvider() + if err != nil { + return err + } + + // GetToken is called for its error (to classify auth state: not-authenticated, + // expired, refresh-failed, or valid) and its side-effect (refreshing and + // persisting the token if expired). The token value itself is not needed. + _, tokenErr := tp.GetToken(cmd.Context()) + + info, infoErr := tp.UserInfo() + + if tokenErr != nil { + if errors.Is(tokenErr, auth.ErrNotAuthenticated) { + _, _ = fmt.Fprintln(opts.IO.ErrOut, "Not authenticated. Run: krci auth login") + return nil + } + + if errors.Is(tokenErr, auth.ErrRefreshFailed) || errors.Is(tokenErr, auth.ErrTokenExpired) { + if infoErr == nil { + _, _ = fmt.Fprintf(opts.IO.ErrOut, "User: %s\n", info.Email) + } + + _, _ = fmt.Fprintln(opts.IO.ErrOut, "Status: Session expired. Run: krci auth login") + + return nil + } + + return tokenErr + } + + if infoErr != nil { + _, _ = fmt.Fprintln(opts.IO.Out, "Status: Authenticated (unable to read user info)") + return nil + } + + lines := []output.DetailLine{ + {Label: "User", Value: info.Email}, + } + + if info.Name != "" { + lines = append(lines, output.DetailLine{Label: "Name", Value: info.Name}) + } + + lines = append(lines, output.DetailLine{Label: "Status", Value: "Authenticated"}) + + if expiry := info.ExpiresAt; !expiry.IsZero() { + remaining := time.Until(expiry).Round(time.Second) + expiresVal := fmt.Sprintf("%s (%s)", expiry.Local().Format(time.RFC822), remaining) + lines = append(lines, output.DetailLine{Label: "Expires", Value: expiresVal}) + } + + if len(info.Groups) > 0 { + lines = append(lines, output.DetailLine{Label: "Groups", Value: strings.Join(info.Groups, ", ")}) + } + + return output.RenderDetail(opts.IO, "", lines, output.DetailRenderer[[]output.DetailLine]{ + Styled: func(w io.Writer, ls []output.DetailLine) error { + for i, l := range ls { + switch l.Label { + case "Status": + ls[i].Styled = output.GreenText(l.Value) + case "Expires": + remaining := time.Until(info.ExpiresAt).Round(time.Second) + if remaining < 5*time.Minute { + ls[i].Styled = output.YellowText(l.Value) + } + } + } + return output.PrintStyledDetailLines(w, ls) + }, + Plain: output.PrintPlainDetailLines, + }) +} diff --git a/pkg/cmd/deployment/deployment.go b/pkg/cmd/deployment/deployment.go new file mode 100644 index 0000000..76e3b98 --- /dev/null +++ b/pkg/cmd/deployment/deployment.go @@ -0,0 +1,26 @@ +// Package deployment implements the "krci deployment" command group. +package deployment + +import ( + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/pkg/cmd/deployment/get" + "github.com/KubeRocketCI/cli/pkg/cmd/deployment/list" +) + +// NewCmdDeployment returns the "deployment" group cobra.Command with all subcommands attached. +func NewCmdDeployment(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "deployment", + Short: "Manage deployments (CDPipelines)", + Aliases: []string{"dp"}, + } + + cmd.AddCommand( + list.NewCmdList(f, nil), + get.NewCmdGet(f, nil), + ) + + return cmd +} diff --git a/internal/cli/deployment_get.go b/pkg/cmd/deployment/get/get.go similarity index 50% rename from internal/cli/deployment_get.go rename to pkg/cmd/deployment/get/get.go index 65c5c60..f0b669f 100644 --- a/internal/cli/deployment_get.go +++ b/pkg/cmd/deployment/get/get.go @@ -1,63 +1,108 @@ -package cli +// Package get implements the "krci deployment get" command. +package get import ( + "context" "fmt" "io" "strconv" "strings" "github.com/spf13/cobra" + "k8s.io/client-go/dynamic" + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/config" + "github.com/KubeRocketCI/cli/internal/iostreams" "github.com/KubeRocketCI/cli/internal/k8s" + "github.com/KubeRocketCI/cli/internal/output" ) -func (a *App) newDeploymentGetCmd() *cobra.Command { - var output string +// GetOptions holds all inputs for the deployment get command. +type GetOptions struct { + IO *iostreams.IOStreams + K8sClient func() (dynamic.Interface, error) + Config func() (*config.Config, error) + Name string + OutputFormat string +} + +// NewCmdGet returns the "deployment get" cobra.Command. +// runF is the business logic function; pass nil to use the default getRun. +func NewCmdGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command { + opts := &GetOptions{ + IO: f.IOStreams, + K8sClient: f.K8sClient, + Config: f.Config, + } cmd := &cobra.Command{ Use: "get ", Short: "Get deployment details", Args: cobra.ExactArgs(1), + Example: ` # Get details for a deployment + krci deployment get my-pipeline + + # Output as JSON + krci deployment get my-pipeline -o json`, RunE: func(cmd *cobra.Command, args []string) error { - if err := a.requireK8s(); err != nil { - return err - } + opts.Name = args[0] - detail, err := a.k8sDeployment.Get(cmd.Context(), args[0]) - if err != nil { - return err + if runF != nil { + return runF(opts) } - return renderDetail(cmd.OutOrStdout(), output, detail, detailRenderer[*k8s.DeploymentDetail]{ - styled: printStyledDeploymentDetail, - plain: printPlainDeploymentDetail, - }) + return getRun(cmd.Context(), opts) }, } - cmd.Flags().StringVarP(&output, "output", "o", "", "Output format: table, json (default: auto-detect)") + cmd.Flags().StringVarP(&opts.OutputFormat, "output", "o", "", "Output format: table, json (default: auto-detect)") return cmd } +func getRun(ctx context.Context, opts *GetOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + dynClient, err := opts.K8sClient() + if err != nil { + return err + } + + svc := k8s.NewDeploymentService(dynClient, cfg.Namespace) + + detail, err := svc.Get(ctx, opts.Name) + if err != nil { + return err + } + + return output.RenderDetail(opts.IO, opts.OutputFormat, detail, output.DetailRenderer[*k8s.DeploymentDetail]{ + Styled: printStyledDeploymentDetail, + Plain: printPlainDeploymentDetail, + }) +} + // deploymentDetailLines builds the ordered field list for a deployment detail. -func deploymentDetailLines(d *k8s.DeploymentDetail, styled bool) []detailLine { - lines := []detailLine{ - {label: "Name", value: d.Name}, - {label: "Namespace", value: d.Namespace}, - {label: "Applications", value: strings.Join(d.Applications, ", ")}, +func deploymentDetailLines(d *k8s.DeploymentDetail, styled bool) []output.DetailLine { + lines := []output.DetailLine{ + {Label: "Name", Value: d.Name}, + {Label: "Namespace", Value: d.Namespace}, + {Label: "Applications", Value: strings.Join(d.Applications, ", ")}, } if d.Description != "" { - lines = append(lines, detailLine{label: "Description", value: d.Description}) + lines = append(lines, output.DetailLine{Label: "Description", Value: d.Description}) } - statusLine := detailLine{label: "Status", value: d.Status} - availableLine := detailLine{label: "Available", value: strconv.FormatBool(d.Available)} + statusLine := output.DetailLine{Label: "Status", Value: d.Status} + availableLine := output.DetailLine{Label: "Available", Value: strconv.FormatBool(d.Available)} if styled { - statusLine.styled = statusColor(d.Status) - availableLine.styled = availableText(d.Available) + statusLine.Styled = output.StatusColor(d.Status) + availableLine.Styled = output.AvailableText(d.Available) } lines = append(lines, statusLine, availableLine) @@ -67,7 +112,7 @@ func deploymentDetailLines(d *k8s.DeploymentDetail, styled bool) []detailLine { // printStyledDeploymentDetail renders deployment details with lipgloss styling. func printStyledDeploymentDetail(w io.Writer, d *k8s.DeploymentDetail) error { - if err := printStyledDetailLines(w, deploymentDetailLines(d, true)); err != nil { + if err := output.PrintStyledDetailLines(w, deploymentDetailLines(d, true)); err != nil { return err } @@ -76,7 +121,7 @@ func printStyledDeploymentDetail(w io.Writer, d *k8s.DeploymentDetail) error { // printPlainDeploymentDetail renders deployment details as plain text for piped output. func printPlainDeploymentDetail(w io.Writer, d *k8s.DeploymentDetail) error { - if err := printPlainDetailLines(w, deploymentDetailLines(d, false)); err != nil { + if err := output.PrintPlainDetailLines(w, deploymentDetailLines(d, false)); err != nil { return err } @@ -94,7 +139,7 @@ func printStageSection(w io.Writer, stages []k8s.Stage, styled bool) error { } if styled { - if _, err := fmt.Fprintln(w, labelStyle.Render("Environments:")); err != nil { + if _, err := fmt.Fprintln(w, output.LabelStyle.Render("Environments:")); err != nil { return err } } else { @@ -107,10 +152,10 @@ func printStageSection(w io.Writer, stages []k8s.Stage, styled bool) error { rows := stageRows(stages, styled) if styled { - return printStyledTable(w, headers, rows) + return output.PrintStyledTable(w, headers, rows) } - return printTable(w, headers, rows) + return output.PrintTable(w, headers, rows) } // stageRows builds table rows from stages. When styled is true, status is colorized. @@ -120,7 +165,7 @@ func stageRows(stages []k8s.Stage, styled bool) [][]string { for _, s := range stages { status := s.Status if styled { - status = statusColor(s.Status) + status = output.StatusColor(s.Status) } rows = append(rows, []string{ diff --git a/pkg/cmd/deployment/list/list.go b/pkg/cmd/deployment/list/list.go new file mode 100644 index 0000000..7fa0bfa --- /dev/null +++ b/pkg/cmd/deployment/list/list.go @@ -0,0 +1,112 @@ +// Package list implements the "krci deployment list" command. +package list + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + "k8s.io/client-go/dynamic" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/config" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/k8s" + "github.com/KubeRocketCI/cli/internal/output" +) + +// maxInlineApps is the maximum number of applications shown inline before truncating. +const maxInlineApps = 3 + +// ListOptions holds all inputs for the deployment list command. +type ListOptions struct { + IO *iostreams.IOStreams + K8sClient func() (dynamic.Interface, error) + Config func() (*config.Config, error) + OutputFormat string +} + +// NewCmdList returns the "deployment list" cobra.Command. +// runF is the business logic function; pass nil to use the default listRun. +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + K8sClient: f.K8sClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List deployments", + Aliases: []string{"ls"}, + Example: ` # List all deployments + krci deployment list + + # Output as JSON + krci deployment list -o json + + # Use the ls alias + krci deployment ls`, + RunE: func(cmd *cobra.Command, _ []string) error { + if runF != nil { + return runF(opts) + } + + return listRun(cmd.Context(), opts) + }, + } + + cmd.Flags().StringVarP(&opts.OutputFormat, "output", "o", "", "Output format: table, json (default: auto-detect)") + + return cmd +} + +func listRun(ctx context.Context, opts *ListOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + dynClient, err := opts.K8sClient() + if err != nil { + return err + } + + svc := k8s.NewDeploymentService(dynClient, cfg.Namespace) + + deployments, err := svc.List(ctx) + if err != nil { + return err + } + + return output.RenderList(opts.IO, opts.OutputFormat, deployments, func(isTTY bool) ([]string, [][]string) { + headers := []string{"NAME", "APPLICATIONS", "ENVS", "STATUS"} + rows := make([][]string, 0, len(deployments)) + + for _, d := range deployments { + status := d.Status + if isTTY { + status = output.StatusColor(d.Status) + } + + rows = append(rows, []string{ + d.Name, + formatApplications(d.Applications), + strings.Join(d.StageNames, " \u2192 "), + status, + }) + } + + return headers, rows + }) +} + +// formatApplications joins application names, truncating if more than maxInlineApps. +func formatApplications(apps []string) string { + if len(apps) <= maxInlineApps { + return strings.Join(apps, ", ") + } + + return strings.Join(apps[:maxInlineApps], ", ") + fmt.Sprintf(" +%d more", len(apps)-maxInlineApps) +} diff --git a/pkg/cmd/project/get/get.go b/pkg/cmd/project/get/get.go new file mode 100644 index 0000000..137ab11 --- /dev/null +++ b/pkg/cmd/project/get/get.go @@ -0,0 +1,128 @@ +// Package get implements the "krci project get" command. +package get + +import ( + "context" + "io" + "strconv" + + "github.com/spf13/cobra" + "k8s.io/client-go/dynamic" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/config" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/k8s" + "github.com/KubeRocketCI/cli/internal/output" +) + +// GetOptions holds all inputs for the project get command. +type GetOptions struct { + IO *iostreams.IOStreams + K8sClient func() (dynamic.Interface, error) + Config func() (*config.Config, error) + Name string + OutputFormat string +} + +// NewCmdGet returns the "project get" cobra.Command. +// runF is the business logic function; pass nil to use the default getRun. +func NewCmdGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command { + opts := &GetOptions{ + IO: f.IOStreams, + K8sClient: f.K8sClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get project details", + Args: cobra.ExactArgs(1), + Example: ` # Get details for a project + krci project get my-app + + # Output as JSON + krci project get my-app -o json`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Name = args[0] + + if runF != nil { + return runF(opts) + } + + return getRun(cmd.Context(), opts) + }, + } + + cmd.Flags().StringVarP(&opts.OutputFormat, "output", "o", "", "Output format: table, json (default: auto-detect)") + + return cmd +} + +func getRun(ctx context.Context, opts *GetOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + dynClient, err := opts.K8sClient() + if err != nil { + return err + } + + svc := k8s.NewProjectService(dynClient, cfg.Namespace) + + project, err := svc.Get(ctx, opts.Name) + if err != nil { + return err + } + + return output.RenderDetail(opts.IO, opts.OutputFormat, project, output.DetailRenderer[*k8s.Project]{ + Styled: printStyledProjectDetail, + Plain: printPlainProjectDetail, + }) +} + +// projectDetailLines builds the ordered field list for a project. +// When styled is true, Status and Available get colorized representations. +func projectDetailLines(p *k8s.Project, styled bool) []output.DetailLine { + lines := []output.DetailLine{ + {Label: "Name", Value: p.Name}, + {Label: "Namespace", Value: p.Namespace}, + {Label: "Type", Value: p.Type}, + {Label: "Language", Value: p.Language}, + {Label: "Build Tool", Value: p.BuildTool}, + } + + if p.Framework != "" { + lines = append(lines, output.DetailLine{Label: "Framework", Value: p.Framework}) + } + + lines = append(lines, output.DetailLine{Label: "Git Server", Value: p.GitServer}) + + if p.GitURL != "" { + lines = append(lines, output.DetailLine{Label: "Git URL", Value: p.GitURL}) + } + + statusLine := output.DetailLine{Label: "Status", Value: p.Status} + availableLine := output.DetailLine{Label: "Available", Value: strconv.FormatBool(p.Available)} + + if styled { + statusLine.Styled = output.StatusColor(p.Status) + availableLine.Styled = output.AvailableText(p.Available) + } + + lines = append(lines, statusLine, availableLine) + + return lines +} + +// printStyledProjectDetail renders project details with lipgloss styling. +func printStyledProjectDetail(w io.Writer, p *k8s.Project) error { + return output.PrintStyledDetailLines(w, projectDetailLines(p, true)) +} + +// printPlainProjectDetail renders project details as plain text for piped output. +func printPlainProjectDetail(w io.Writer, p *k8s.Project) error { + return output.PrintPlainDetailLines(w, projectDetailLines(p, false)) +} diff --git a/pkg/cmd/project/list/list.go b/pkg/cmd/project/list/list.go new file mode 100644 index 0000000..527648a --- /dev/null +++ b/pkg/cmd/project/list/list.go @@ -0,0 +1,93 @@ +// Package list implements the "krci project list" command. +package list + +import ( + "context" + + "github.com/spf13/cobra" + "k8s.io/client-go/dynamic" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/config" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/k8s" + "github.com/KubeRocketCI/cli/internal/output" +) + +// ListOptions holds all inputs for the project list command. +type ListOptions struct { + IO *iostreams.IOStreams + K8sClient func() (dynamic.Interface, error) + Config func() (*config.Config, error) + OutputFormat string +} + +// NewCmdList returns the "project list" cobra.Command. +// runF is the business logic function; pass nil to use the default listRun. +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + K8sClient: f.K8sClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List projects", + Aliases: []string{"ls"}, + Example: ` # List all projects + krci project list + + # Output as JSON + krci project list -o json + + # Use the ls alias + krci project ls`, + RunE: func(cmd *cobra.Command, _ []string) error { + if runF != nil { + return runF(opts) + } + + return listRun(cmd.Context(), opts) + }, + } + + cmd.Flags().StringVarP(&opts.OutputFormat, "output", "o", "", "Output format: table, json (default: auto-detect)") + + return cmd +} + +func listRun(ctx context.Context, opts *ListOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + dynClient, err := opts.K8sClient() + if err != nil { + return err + } + + svc := k8s.NewProjectService(dynClient, cfg.Namespace) + + projects, err := svc.List(ctx) + if err != nil { + return err + } + + return output.RenderList(opts.IO, opts.OutputFormat, projects, func(isTTY bool) ([]string, [][]string) { + headers := []string{"NAME", "TYPE", "LANGUAGE", "BUILD TOOL", "STATUS"} + rows := make([][]string, 0, len(projects)) + + for _, p := range projects { + status := p.Status + if isTTY { + status = output.StatusColor(p.Status) + } + + rows = append(rows, []string{p.Name, p.Type, p.Language, p.BuildTool, status}) + } + + return headers, rows + }) +} diff --git a/pkg/cmd/project/project.go b/pkg/cmd/project/project.go new file mode 100644 index 0000000..5d7c781 --- /dev/null +++ b/pkg/cmd/project/project.go @@ -0,0 +1,26 @@ +// Package project implements the "krci project" command group. +package project + +import ( + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/pkg/cmd/project/get" + "github.com/KubeRocketCI/cli/pkg/cmd/project/list" +) + +// NewCmdProject returns the "project" group cobra.Command with all subcommands attached. +func NewCmdProject(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "project", + Short: "Manage projects (Codebases)", + Aliases: []string{"proj"}, + } + + cmd.AddCommand( + list.NewCmdList(f, nil), + get.NewCmdGet(f, nil), + ) + + return cmd +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go new file mode 100644 index 0000000..e4380e3 --- /dev/null +++ b/pkg/cmd/root/root.go @@ -0,0 +1,43 @@ +// Package root assembles the top-level cobra.Command for the krci CLI. +package root + +import ( + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/config" + "github.com/KubeRocketCI/cli/pkg/cmd/auth" + "github.com/KubeRocketCI/cli/pkg/cmd/deployment" + "github.com/KubeRocketCI/cli/pkg/cmd/project" + "github.com/KubeRocketCI/cli/pkg/cmd/version" +) + +// NewCmdRoot builds the root cobra.Command with all subcommands attached. +// version, commit, and date are injected from ldflags at build time. +func NewCmdRoot(f *cmdutil.Factory, v, commit, date string) *cobra.Command { + cmd := &cobra.Command{ + Use: "krci", + Short: "KubeRocketCI CLI", + Long: "Command-line interface for the KubeRocketCI platform.", + Version: v, + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + // Warm the config cache after Cobra has parsed all flags. + // Subcommand RunE functions receive the cached result instantly. + _, err := f.Config() + return err + }, + } + + config.BindFlags(cmd) + + cmd.AddCommand( + auth.NewCmdAuth(f), + project.NewCmdProject(f), + deployment.NewCmdDeployment(f), + version.NewCmdVersion(f.IOStreams, v, commit, date), + ) + + return cmd +} diff --git a/pkg/cmd/version/version.go b/pkg/cmd/version/version.go new file mode 100644 index 0000000..8727b4e --- /dev/null +++ b/pkg/cmd/version/version.go @@ -0,0 +1,27 @@ +// Package version implements the "krci version" command. +package version + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/iostreams" +) + +// NewCmdVersion returns the "version" cobra.Command. +// Version info is provided at construction time from ldflags injected by main. +func NewCmdVersion(ios *iostreams.IOStreams, version, commit, date string) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print krci CLI version", + RunE: func(cmd *cobra.Command, _ []string) error { + _, err := fmt.Fprintf(ios.Out, + "krci version %s (commit: %s, built: %s)\n", + version, commit, date, + ) + + return err + }, + } +}