diff --git a/.goreleaser.yaml b/.goreleaser.yaml index cbc6b75c9..1b0bbf1a9 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -46,6 +46,25 @@ builds: ignore: - goos: windows goarch: arm64 + - id: proboctl + main: ./cmd/proboctl/main.go + binary: proboctl + ldflags: + - -s -w + - -X 'main.version={{.Version}}' + gcflags: + - -e + env: + - CGO_ENABLED=0 + goos: + - windows + - darwin + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 # Docker-specific builds for Linux only - id: probod-docker main: ./cmd/probod/main.go @@ -77,11 +96,27 @@ builds: goarch: - amd64 - arm64 + - id: proboctl-docker + main: ./cmd/proboctl/main.go + binary: proboctl + ldflags: + - -s -w + - -X 'main.version={{.Version}}' + gcflags: + - -e + env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 archives: - builds: - probod - probod-bootstrap + - proboctl name_template: >- {{ .ProjectName }}_ {{- title .Os }}_ diff --git a/GNUmakefile b/GNUmakefile index ee34c96b9..d32476cf2 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -46,6 +46,9 @@ PROBOD_BIN_EXTRA_DEPS= PROBOD_BIN= bin/probod PROBOD_SRC= cmd/probod/main.go +PROBOCTL_BIN= bin/proboctl +PROBOCTL_SRC= cmd/proboctl/main.go + ifndef SKIP_APPS PROBOD_BIN_EXTRA_DEPS += \ @probo/console \ @@ -119,7 +122,7 @@ coverage-combined: coverage-report test-e2e-coverage ## Generate combined covera $(GO) tool cover -html=coverage-combined.out -o=coverage-combined.html .PHONY: build -build: bin/probod +build: bin/probod bin/proboctl .PHONY: sbom-docker sbom-docker: docker-build @@ -176,6 +179,10 @@ bin/probod: pkg/server/api/connect/v1/schema/schema.go \ @probo/emails $(GO_BUILD) -o $(PROBOD_BIN) $(PROBOD_SRC) +.PHONY: bin/proboctl +bin/proboctl: + $(GO_BUILD) -o $(PROBOCTL_BIN) $(PROBOCTL_SRC) + .PHONY: @probo/emails @probo/emails: $(NPM) --workspace $@ run build diff --git a/cmd/proboctl/main.go b/cmd/proboctl/main.go new file mode 100644 index 000000000..b52a3644c --- /dev/null +++ b/cmd/proboctl/main.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "fmt" + "os" + + "go.probo.inc/probo/pkg/cli/config" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/iostreams" + "go.probo.inc/probo/pkg/cmd/root" +) + +var version string = "unknown" + +func main() { + ios := iostreams.System() + if isNonInteractiveEnv() { + ios.ForceNonInteractive = true + } + if isNoColorEnv() { + ios.ForceNoColor = true + } + ios.ApplyColorProfile() + + f := &cmdutil.Factory{ + IOStreams: ios, + Version: version, + Config: func() (*config.Config, error) { + return config.Load() + }, + } + + cmd := root.NewCmdRoot(f) + + if err := cmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + os.Exit(1) + } +} + +func isNonInteractiveEnv() bool { + if v := os.Getenv("PROBO_NO_INTERACTIVE"); v == "1" || v == "true" { + return true + } + if v := os.Getenv("CI"); v == "true" || v == "1" { + return true + } + if os.Getenv("DEBIAN_FRONTEND") == "noninteractive" { + return true + } + if os.Getenv("TERM") == "dumb" { + return true + } + return false +} + +func isNoColorEnv() bool { + if _, ok := os.LookupEnv("NO_COLOR"); ok { + return true + } + if os.Getenv("TERM") == "dumb" { + return true + } + return false +} diff --git a/go.mod b/go.mod index e98a13026..427ebed59 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.1 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 github.com/brianvoe/gofakeit/v7 v7.14.1 + github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d github.com/chromedp/chromedp v0.14.2 github.com/crewjam/saml v0.5.1 @@ -44,7 +46,29 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // 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/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect +) require ( cloud.google.com/go/auth v0.18.2 // indirect @@ -120,7 +144,7 @@ require ( github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.3 // indirect github.com/sosodev/duration v1.3.1 // indirect - github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/tidwall/gjson v1.18.0 // indirect @@ -141,7 +165,7 @@ require ( golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect + golang.org/x/term v0.41.0 golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.43.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect diff --git a/go.sum b/go.sum index df1ecdecd..d6c4e9146 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ codeberg.org/miekg/dns v0.6.65 h1:a925lDuqKzqC2YZWizNTwXGzKhX5DdKjbURMf0mMY4w= codeberg.org/miekg/dns v0.6.65/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk= github.com/99designs/gqlgen v0.17.87 h1:pSnCIMhBQezAE8bc1GNmfdLXFmnWtWl1GRDFEE/nHP8= github.com/99designs/gqlgen v0.17.87/go.mod h1:fK05f1RqSNfQpd4CfW5qk/810Tqi4/56Wf6Nem0khAg= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= @@ -16,6 +18,8 @@ github.com/anthropics/anthropic-sdk-go v1.25.1 h1:pI8ls4kuiDCUq6Hw+fB7eJx/nfgetL github.com/anthropics/anthropic-sdk-go v1.25.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA= github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ= @@ -44,6 +48,10 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 h1:M1A9AjcFwlxTLuf0Faj88L8Iqw0n/ github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2/go.mod h1:KsdTV6Q9WKUZm2mNJnUFmIoXfZux91M3sr/a4REX8e0= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +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.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -52,12 +60,42 @@ github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CD github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= github.com/brianvoe/gofakeit/v7 v7.14.1 h1:a7fe3fonbj0cW3wgl5VwIKfZtiH9C3cLnwcIXWT7sow= github.com/brianvoe/gofakeit/v7 v7.14.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +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/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +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.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +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/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU= github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM= @@ -69,6 +107,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/crewjam/saml v0.5.1 h1:g+mfp0CrLuLRZCK793PgJcZeg5dS/0CDwoeAX2zcwNI= github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhyEolQ= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -87,6 +127,10 @@ github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53 github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= +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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -178,17 +222,29 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +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/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.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.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 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/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= +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/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/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= @@ -213,6 +269,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +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/russellhaering/goxmldsig v1.5.0 h1:AU2UkkYIUOTyZRbe08XMThaOCelArgvNfYapcmSjBNw= @@ -258,6 +316,8 @@ github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc= github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= @@ -302,6 +362,8 @@ 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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= @@ -312,6 +374,7 @@ golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/pkg/cli/api/client.go b/pkg/cli/api/client.go new file mode 100644 index 000000000..5c3c0b59d --- /dev/null +++ b/pkg/cli/api/client.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "go.probo.inc/probo/pkg/version" +) + +type ( + Client struct { + host string + token string + endpoint string + httpClient *http.Client + } + + graphQLRequest struct { + Query string `json:"query"` + Variables map[string]any `json:"variables,omitempty"` + } + + graphQLResponse struct { + Data json.RawMessage `json:"data"` + Errors []graphQLError `json:"errors"` + } + + graphQLError struct { + Message string `json:"message"` + } +) + +func NewClient(host string, token string, endpoint string, timeout time.Duration) *Client { + return &Client{ + host: host, + token: token, + endpoint: endpoint, + httpClient: &http.Client{Timeout: timeout}, + } +} + +func (c *Client) Do( + query string, + variables map[string]any, +) (json.RawMessage, error) { + raw, err := c.DoRaw(query, variables) + if err != nil { + return nil, err + } + + var resp graphQLResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("cannot parse GraphQL response: %w", err) + } + + if len(resp.Errors) > 0 { + msg := resp.Errors[0].Message + for _, e := range resp.Errors[1:] { + msg += "; " + e.Message + } + return nil, fmt.Errorf("GraphQL error: %s", msg) + } + + return resp.Data, nil +} + +func (c *Client) DoRaw( + query string, + variables map[string]any, +) ([]byte, error) { + reqBody := graphQLRequest{ + Query: query, + Variables: variables, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("cannot marshal GraphQL request: %w", err) + } + + url := fmt.Sprintf("https://%s%s", c.host, c.endpoint) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("cannot create HTTP request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("User-Agent", version.UserAgent("proboctl")) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot send HTTP request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("cannot read HTTP response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + switch resp.StatusCode { + case http.StatusUnauthorized: + return nil, fmt.Errorf("authentication failed (HTTP 401): token may be invalid or expired, try 'proboctl auth login'") + case http.StatusForbidden: + return nil, fmt.Errorf("access denied (HTTP 403): you do not have permission to perform this action") + default: + return nil, fmt.Errorf( + "HTTP %d: %s", + resp.StatusCode, + string(respBody), + ) + } + } + + return respBody, nil +} diff --git a/pkg/cli/api/pagination.go b/pkg/cli/api/pagination.go new file mode 100644 index 000000000..4c6c95d6a --- /dev/null +++ b/pkg/cli/api/pagination.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package api + +import ( + "encoding/json" + "fmt" + "maps" +) + +type ( + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor *string `json:"endCursor"` + } + + Edge[T any] struct { + Node T `json:"node"` + } + + Connection[T any] struct { + TotalCount int `json:"totalCount"` + Edges []Edge[T] `json:"edges"` + PageInfo PageInfo `json:"pageInfo"` + } +) + +// Paginate fetches all pages of a connection up to limit items. The extract +// function pulls the Connection out of the raw GraphQL response data. +func Paginate[T any]( + client *Client, + query string, + variables map[string]any, + limit int, + extract func(json.RawMessage) (*Connection[T], error), +) ([]T, int, error) { + vars := maps.Clone(variables) + + var ( + nodes = make([]T, 0) + totalCount int + ) + + for { + remaining := limit - len(nodes) + if remaining <= 0 { + break + } + vars["first"] = remaining + + data, err := client.Do(query, vars) + if err != nil { + return nil, 0, err + } + + conn, err := extract(data) + if err != nil { + return nil, 0, fmt.Errorf("cannot parse response: %w", err) + } + + totalCount = conn.TotalCount + for _, edge := range conn.Edges { + nodes = append(nodes, edge.Node) + } + + if !conn.PageInfo.HasNextPage || conn.PageInfo.EndCursor == nil { + break + } + + vars["after"] = *conn.PageInfo.EndCursor + } + + return nodes, totalCount, nil +} diff --git a/pkg/cli/config/config.go b/pkg/cli/config/config.go new file mode 100644 index 000000000..7502ee287 --- /dev/null +++ b/pkg/cli/config/config.go @@ -0,0 +1,217 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package config + +import ( + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "time" + + "gopkg.in/yaml.v3" +) + +const DefaultHTTPTimeout = 30 * time.Second + +type ( + Config struct { + Editor string `yaml:"editor,omitempty"` + Browser string `yaml:"browser,omitempty"` + Pager string `yaml:"pager,omitempty"` + Prompt string `yaml:"prompt,omitempty"` + HTTPTimeout string `yaml:"http_timeout,omitempty"` + ActiveHost string `yaml:"active_host,omitempty"` + Hosts map[string]*HostConfig `yaml:"hosts"` + } + + HostConfig struct { + Token string `yaml:"token"` + Organization string `yaml:"organization"` + } +) + +var ValidKeys = []string{ + "editor", + "browser", + "pager", + "prompt", + "http_timeout", +} + +func (c *Config) Get(key string) (string, error) { + switch key { + case "editor": + return c.Editor, nil + case "browser": + return c.Browser, nil + case "pager": + return c.Pager, nil + case "prompt": + return c.Prompt, nil + case "http_timeout": + return c.HTTPTimeout, nil + default: + return "", fmt.Errorf("unknown configuration key: %s", key) + } +} + +func (c *Config) Set(key, value string) error { + switch key { + case "editor": + c.Editor = value + case "browser": + c.Browser = value + case "pager": + c.Pager = value + case "prompt": + if value != "enabled" && value != "disabled" { + return fmt.Errorf("valid values for prompt are 'enabled' or 'disabled'") + } + c.Prompt = value + case "http_timeout": + if _, err := time.ParseDuration(value); err != nil { + return fmt.Errorf("invalid duration for http_timeout: %w", err) + } + c.HTTPTimeout = value + default: + return fmt.Errorf("unknown configuration key: %s", key) + } + return nil +} + +func (c *Config) HTTPTimeoutDuration() time.Duration { + if c.HTTPTimeout == "" { + return DefaultHTTPTimeout + } + + d, err := time.ParseDuration(c.HTTPTimeout) + if err != nil { + return DefaultHTTPTimeout + } + + return d +} + +func configDir() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("cannot determine config directory: %w", err) + } + return filepath.Join(dir, "proboctl"), nil +} + +func configPath() (string, error) { + dir, err := configDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.yaml"), nil +} + +func Load() (*Config, error) { + path, err := configPath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &Config{Hosts: make(map[string]*HostConfig)}, nil + } + return nil, fmt.Errorf("cannot read config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("cannot parse config file: %w", err) + } + + if cfg.Hosts == nil { + cfg.Hosts = make(map[string]*HostConfig) + } + + return &cfg, nil +} + +func (c *Config) Save() error { + path, err := configPath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("cannot create config directory: %w", err) + } + + data, err := yaml.Marshal(c) + if err != nil { + return fmt.Errorf("cannot marshal config: %w", err) + } + + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("cannot write config file: %w", err) + } + + return nil +} + +func (c *Config) DefaultHost() (string, *HostConfig, error) { + if host := os.Getenv("PROBO_HOST"); host != "" { + hc := &HostConfig{} + if saved, ok := c.Hosts[host]; ok { + *hc = *saved + } + if token := os.Getenv("PROBO_TOKEN"); token != "" { + hc.Token = token + } + return host, hc, nil + } + + hosts := slices.Sorted(maps.Keys(c.Hosts)) + + if token := os.Getenv("PROBO_TOKEN"); token != "" { + if len(hosts) == 0 { + return "", nil, fmt.Errorf("PROBO_TOKEN is set but no host configured; run 'proboctl auth login' first") + } + + host := hosts[0] + if c.ActiveHost != "" { + if _, ok := c.Hosts[c.ActiveHost]; ok { + host = c.ActiveHost + } + } + + return host, &HostConfig{ + Token: token, + Organization: c.Hosts[host].Organization, + }, nil + } + + if c.ActiveHost != "" { + if hc, ok := c.Hosts[c.ActiveHost]; ok { + return c.ActiveHost, hc, nil + } + } + + if len(hosts) > 0 { + host := hosts[0] + return host, c.Hosts[host], nil + } + + return "", nil, fmt.Errorf("not logged in; run 'proboctl auth login' first") +} diff --git a/pkg/cli/config/config_test.go b/pkg/cli/config/config_test.go new file mode 100644 index 000000000..efe0f0fc0 --- /dev/null +++ b/pkg/cli/config/config_test.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/cli/config" +) + +func TestDefaultHost(t *testing.T) { + t.Run( + "PROBO_TOKEN uses active host instead of first alphabetically", + func(t *testing.T) { + t.Setenv("PROBO_TOKEN", "tok_test") + t.Setenv("PROBO_HOST", "") + + cfg := &config.Config{ + ActiveHost: "beta.probo.inc", + Hosts: map[string]*config.HostConfig{ + "alpha.probo.inc": { + Token: "old-alpha", + Organization: "org-alpha", + }, + "beta.probo.inc": { + Token: "old-beta", + Organization: "org-beta", + }, + }, + } + + host, hc, err := cfg.DefaultHost() + require.NoError(t, err) + assert.Equal(t, "beta.probo.inc", host) + assert.Equal(t, "tok_test", hc.Token) + assert.Equal(t, "org-beta", hc.Organization) + }, + ) + + t.Run( + "PROBO_TOKEN falls back to first host when no active host", + func(t *testing.T) { + t.Setenv("PROBO_TOKEN", "tok_test") + t.Setenv("PROBO_HOST", "") + + cfg := &config.Config{ + Hosts: map[string]*config.HostConfig{ + "alpha.probo.inc": { + Token: "old", + Organization: "org-alpha", + }, + "beta.probo.inc": { + Token: "old", + Organization: "org-beta", + }, + }, + } + + host, hc, err := cfg.DefaultHost() + require.NoError(t, err) + assert.Equal(t, "alpha.probo.inc", host) + assert.Equal(t, "tok_test", hc.Token) + assert.Equal(t, "org-alpha", hc.Organization) + }, + ) + + t.Run( + "PROBO_HOST takes precedence over everything", + func(t *testing.T) { + t.Setenv("PROBO_HOST", "custom.probo.inc") + t.Setenv("PROBO_TOKEN", "tok_env") + + cfg := &config.Config{ + ActiveHost: "beta.probo.inc", + Hosts: map[string]*config.HostConfig{ + "beta.probo.inc": { + Token: "old", + Organization: "org-beta", + }, + }, + } + + host, hc, err := cfg.DefaultHost() + require.NoError(t, err) + assert.Equal(t, "custom.probo.inc", host) + assert.Equal(t, "tok_env", hc.Token) + }, + ) + + t.Run( + "active host is used when no env vars set", + func(t *testing.T) { + t.Setenv("PROBO_HOST", "") + t.Setenv("PROBO_TOKEN", "") + + cfg := &config.Config{ + ActiveHost: "beta.probo.inc", + Hosts: map[string]*config.HostConfig{ + "alpha.probo.inc": { + Token: "tok-alpha", + Organization: "org-alpha", + }, + "beta.probo.inc": { + Token: "tok-beta", + Organization: "org-beta", + }, + }, + } + + host, hc, err := cfg.DefaultHost() + require.NoError(t, err) + assert.Equal(t, "beta.probo.inc", host) + assert.Equal(t, "tok-beta", hc.Token) + assert.Equal(t, "org-beta", hc.Organization) + }, + ) +} diff --git a/pkg/cmd/cmdutil/factory.go b/pkg/cmd/cmdutil/factory.go new file mode 100644 index 000000000..663c6e93d --- /dev/null +++ b/pkg/cmd/cmdutil/factory.go @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cmdutil + +import ( + "go.probo.inc/probo/pkg/cli/config" + "go.probo.inc/probo/pkg/cmd/iostreams" +) + +type Factory struct { + IOStreams *iostreams.IOStreams + Version string + Config func() (*config.Config, error) +} diff --git a/pkg/cmd/cmdutil/flags.go b/pkg/cmd/cmdutil/flags.go new file mode 100644 index 000000000..f5f1c5d30 --- /dev/null +++ b/pkg/cmd/cmdutil/flags.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cmdutil + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +const ( + OutputJSON = "json" + OutputTable = "table" +) + +// AddOutputFlag registers --output / -o on cmd and returns a pointer to the +// value. The default is "table". Callers should call ValidateOutputFlag early +// in RunE, then branch on *p. +func AddOutputFlag(cmd *cobra.Command) *string { + var output string + cmd.Flags().StringVarP( + &output, + "output", + "o", + "", + "Output format: json, table (default)", + ) + return &output +} + +// ValidateOutputFlag checks that value is a supported output format. An empty +// string is treated as table (the default). +func ValidateOutputFlag(value *string) error { + switch *value { + case "": + *value = OutputTable + return nil + case OutputJSON, OutputTable: + return nil + default: + return fmt.Errorf( + "invalid --output value %q: valid values are json, table", + *value, + ) + } +} + +// ValidateEnum checks that value is one of the allowed values. It returns a +// user-friendly error mentioning the flag name and the valid choices. +func ValidateEnum(flag string, value string, allowed []string) error { + for _, v := range allowed { + if value == v { + return nil + } + } + return fmt.Errorf( + "invalid --%s value %q: valid values are %s", + flag, + value, + strings.Join(allowed, ", "), + ) +} diff --git a/pkg/cmd/cmdutil/json.go b/pkg/cmd/cmdutil/json.go new file mode 100644 index 000000000..e051d1269 --- /dev/null +++ b/pkg/cmd/cmdutil/json.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cmdutil + +import ( + "encoding/json" + "fmt" + "io" +) + +// PrintJSON writes v as indented JSON followed by a newline. +func PrintJSON(out io.Writer, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("cannot marshal JSON: %w", err) + } + _, err = fmt.Fprintln(out, string(data)) + return err +} diff --git a/pkg/cmd/cmdutil/table.go b/pkg/cmd/cmdutil/table.go new file mode 100644 index 000000000..ef1409c05 --- /dev/null +++ b/pkg/cmd/cmdutil/table.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cmdutil + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +// NewTable returns a pre-styled lipgloss table with the given headers. +func NewTable(headers ...string) *table.Table { + headerStyle := lipgloss.NewStyle().Bold(true).Padding(0, 1) + cellStyle := lipgloss.NewStyle().Padding(0, 1) + + return table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("238"))). + Headers(headers...). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return headerStyle + } + return cellStyle + }) +} diff --git a/pkg/cmd/cmdutil/time.go b/pkg/cmd/cmdutil/time.go new file mode 100644 index 000000000..75f1b8699 --- /dev/null +++ b/pkg/cmd/cmdutil/time.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cmdutil + +import "time" + +// FormatTime parses an RFC3339 timestamp and returns a human-friendly +// local representation. If parsing fails it returns the raw string. +func FormatTime(raw string) string { + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + return raw + } + return t.Local().Format("Jan 02, 2006 15:04 MST") +} diff --git a/pkg/cmd/completion/completion.go b/pkg/cmd/completion/completion.go new file mode 100644 index 000000000..4258243c7 --- /dev/null +++ b/pkg/cmd/completion/completion.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package completion + +import ( + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "completion ", + Short: "Generate shell completion scripts", + Long: `Generate shell completion scripts for proboctl. + +To load completions: + +Bash: + $ source <(proboctl completion bash) + # To load completions for each session, execute once: + # Linux: + $ proboctl completion bash > /etc/bash_completion.d/proboctl + # macOS: + $ proboctl completion bash > $(brew --prefix)/etc/bash_completion.d/proboctl + +Zsh: + $ source <(proboctl completion zsh) + # To load completions for each session, execute once: + $ proboctl completion zsh > "${fpath[1]}/_proboctl" + +Fish: + $ proboctl completion fish | source + # To load completions for each session, execute once: + $ proboctl completion fish > ~/.config/fish/completions/proboctl.fish + +PowerShell: + PS> proboctl completion powershell | Out-String | Invoke-Expression + # To load completions for each session, execute once: + PS> proboctl completion powershell > proboctl.ps1`, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + out := f.IOStreams.Out + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletionV2(out, true) + case "zsh": + return cmd.Root().GenZshCompletion(out) + case "fish": + return cmd.Root().GenFishCompletion(out, true) + case "powershell": + return cmd.Root().GenPowerShellCompletionWithDesc(out) + default: + return fmt.Errorf("unsupported shell: %s", args[0]) + } + }, + } + + return cmd +} diff --git a/pkg/cmd/iostreams/iostreams.go b/pkg/cmd/iostreams/iostreams.go new file mode 100644 index 000000000..7983dcf54 --- /dev/null +++ b/pkg/cmd/iostreams/iostreams.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package iostreams + +import ( + "bytes" + "io" + "os" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "golang.org/x/term" +) + +type IOStreams struct { + In io.ReadCloser + Out io.Writer + ErrOut io.Writer + + // ForceNonInteractive disables all interactive prompts. Set by the + // --no-interactive global flag or the PROBO_NO_INTERACTIVE env var. + ForceNonInteractive bool + + // ForceNoColor disables ANSI color output. Set by the --no-color + // global flag, the NO_COLOR env var, or TERM=dumb. + ForceNoColor bool +} + +func (s *IOStreams) IsInteractive() bool { + if s.ForceNonInteractive { + return false + } + return s.isStdinTTY() && s.isStdoutTTY() +} + +func (s *IOStreams) isStdinTTY() bool { + if f, ok := s.In.(*os.File); ok { + return term.IsTerminal(int(f.Fd())) + } + return false +} + +func (s *IOStreams) isStdoutTTY() bool { + if f, ok := s.Out.(*os.File); ok { + return term.IsTerminal(int(f.Fd())) + } + return false +} + +func (s *IOStreams) IsStdinTTY() bool { + return s.isStdinTTY() +} + +func (s *IOStreams) IsStdoutTTY() bool { + if s.ForceNonInteractive { + return false + } + return s.isStdoutTTY() +} + +func (s *IOStreams) ColorEnabled() bool { + if s.ForceNoColor { + return false + } + return s.isStdoutTTY() +} + +// ApplyColorProfile configures the lipgloss default renderer based on +// the current color settings. Call this after ForceNoColor has been set. +func (s *IOStreams) ApplyColorProfile() { + if s.ForceNoColor { + lipgloss.SetColorProfile(termenv.Ascii) + } +} + +func System() *IOStreams { + return &IOStreams{ + In: os.Stdin, + Out: os.Stdout, + ErrOut: os.Stderr, + } +} + +func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer) { + out := new(bytes.Buffer) + errOut := new(bytes.Buffer) + return &IOStreams{ + In: io.NopCloser(new(bytes.Buffer)), + Out: out, + ErrOut: errOut, + }, out, errOut +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go new file mode 100644 index 000000000..ee15fe2fd --- /dev/null +++ b/pkg/cmd/root/root.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package root + +import ( + "github.com/spf13/cobra" + cmdapi "go.probo.inc/probo/pkg/cmd/api" + "go.probo.inc/probo/pkg/cmd/auth" + "go.probo.inc/probo/pkg/cmd/browse" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/completion" + cmdconfig "go.probo.inc/probo/pkg/cmd/config" + "go.probo.inc/probo/pkg/cmd/control" + "go.probo.inc/probo/pkg/cmd/framework" + "go.probo.inc/probo/pkg/cmd/org" + "go.probo.inc/probo/pkg/cmd/risk" + "go.probo.inc/probo/pkg/cmd/soa" + "go.probo.inc/probo/pkg/cmd/user" + "go.probo.inc/probo/pkg/cmd/version" + "go.probo.inc/probo/pkg/cmd/webhook" +) + +func NewCmdRoot(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "proboctl [flags]", + Short: "Probo CLI", + Long: "proboctl is a command-line tool for interacting with the Probo platform.", + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + if noInteractive, _ := cmd.Flags().GetBool("no-interactive"); noInteractive { + f.IOStreams.ForceNonInteractive = true + } + if noColor, _ := cmd.Flags().GetBool("no-color"); noColor { + f.IOStreams.ForceNoColor = true + } + f.IOStreams.ApplyColorProfile() + }, + } + + cmd.PersistentFlags().Bool( + "no-interactive", + false, + "Disable interactive prompts (also set via PROBO_NO_INTERACTIVE=1, CI=true, or TERM=dumb)", + ) + + cmd.PersistentFlags().Bool( + "no-color", + false, + "Disable ANSI color output (also set via NO_COLOR or TERM=dumb)", + ) + + cmd.AddCommand(cmdapi.NewCmdAPI(f)) + cmd.AddCommand(auth.NewCmdAuth(f)) + cmd.AddCommand(browse.NewCmdBrowse(f)) + cmd.AddCommand(completion.NewCmdCompletion(f)) + cmd.AddCommand(cmdconfig.NewCmdConfig(f)) + cmd.AddCommand(control.NewCmdControl(f)) + cmd.AddCommand(framework.NewCmdFramework(f)) + cmd.AddCommand(org.NewCmdOrg(f)) + cmd.AddCommand(risk.NewCmdRisk(f)) + cmd.AddCommand(soa.NewCmdSoa(f)) + cmd.AddCommand(user.NewCmdUser(f)) + cmd.AddCommand(version.NewCmdVersion(f)) + cmd.AddCommand(webhook.NewCmdWebhook(f)) + + return cmd +} diff --git a/pkg/cmd/version/version.go b/pkg/cmd/version/version.go new file mode 100644 index 000000000..d9fc881ca --- /dev/null +++ b/pkg/cmd/version/version.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package version + +import ( + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/version" +) + +func NewCmdVersion(f *cmdutil.Factory) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the version of proboctl", + RunE: func(cmd *cobra.Command, args []string) error { + info := version.GetBuildInfo() + + v := f.Version + if v == "" || v == "unknown" { + v = info.Version + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "proboctl version %s (commit: %s, built: %s, go: %s)\n", + v, + info.Commit, + info.BuildDate, + info.GoVersion, + ) + + return nil + }, + } +}