diff --git a/README.md b/README.md index c30e81b..0e20ddd 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,13 @@ Top-level non-resource commands: | 6 | server error (5xx) | | 7 | network / transport failure | +## Version information + +`twoctl version` and `twoctl --version` print the same version, Go runtime, +and platform information. `twoctl version --json` emits a JSON object with +`version`, `go_version`, `os`, and `arch` fields for scripts. Version queries +do not read credentials, check for upgrades, or make network requests. + ## Self-upgrade `twoctl` checks for new releases at most once every 24 hours and prompts when one is available. The prompt offers: diff --git a/cmd/twoctl/cli/explain.go b/cmd/twoctl/cli/explain.go index 8bd9d78..e7f0398 100644 --- a/cmd/twoctl/cli/explain.go +++ b/cmd/twoctl/cli/explain.go @@ -1,29 +1,17 @@ package cli -// kubectl-flavored top-level helpers: `version`, `explain`, `api-resources`. +// kubectl-flavored top-level helpers: `explain`, `api-resources`. import ( "encoding/json" "fmt" - "runtime" "sort" "github.com/getkin/kin-openapi/openapi3" "github.com/spf13/cobra" - - "github.com/two-inc/twoctl-cli/internal/httpx" ) func init() { - register(&cobra.Command{ - Use: "version", - Short: "Print twoctl version, commit, and Go runtime", - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Fprintf(cmd.OutOrStdout(), "twoctl version %s\ngo %s\nplatform %s/%s\n", - httpx.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH) - return nil - }, - }) register(&cobra.Command{ Use: "explain ", diff --git a/cmd/twoctl/cli/root.go b/cmd/twoctl/cli/root.go index d5da2ab..edb3d6e 100644 --- a/cmd/twoctl/cli/root.go +++ b/cmd/twoctl/cli/root.go @@ -54,6 +54,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&flagURL, "url", "", "raw API base URL (overrides the context's URL)") rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "auto", "output format: table, json, yaml (auto = table on TTY, json when piped)") rootCmd.Version = httpx.Version + rootCmd.SetVersionTemplate(versionText("{{.Version}}")) } // Root returns the configured root cobra.Command. diff --git a/cmd/twoctl/cli/version.go b/cmd/twoctl/cli/version.go new file mode 100644 index 0000000..b35a572 --- /dev/null +++ b/cmd/twoctl/cli/version.go @@ -0,0 +1,46 @@ +package cli + +import ( + "encoding/json" + "fmt" + "runtime" + + "github.com/spf13/cobra" +) + +func init() { + version := &cobra.Command{ + Use: "version", + Short: "Print twoctl version, Go runtime, and platform", + Args: cobra.NoArgs, + // Local version information must not invoke the inherited upgrade + // check, especially when a script requests machine-readable output. + PersistentPreRunE: func(*cobra.Command, []string) error { return nil }, + RunE: runVersion, + } + version.Flags().Bool("json", false, "print version information as JSON") + register(version) +} + +type versionInfo struct { + Version string `json:"version"` + GoVersion string `json:"go_version"` + OS string `json:"os"` + Arch string `json:"arch"` +} + +func versionText(version string) string { + return fmt.Sprintf("twoctl version %s\ngo %s\nplatform %s/%s\n", + version, runtime.Version(), runtime.GOOS, runtime.GOARCH) +} + +func runVersion(cmd *cobra.Command, _ []string) error { + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(versionInfo{ + Version: rootCmd.Version, GoVersion: runtime.Version(), OS: runtime.GOOS, Arch: runtime.GOARCH, + }) + } + _, err := fmt.Fprint(cmd.OutOrStdout(), versionText(rootCmd.Version)) + return err +} diff --git a/cmd/twoctl/cli/version_test.go b/cmd/twoctl/cli/version_test.go new file mode 100644 index 0000000..8d4cd25 --- /dev/null +++ b/cmd/twoctl/cli/version_test.go @@ -0,0 +1,67 @@ +package cli + +import ( + "encoding/json" + "errors" + "runtime" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestVersionFormsMatch(t *testing.T) { + resetGlobals(t) + original := Root().Version + Root().Version = "v1.2.3-test" + t.Cleanup(func() { Root().Version = original }) + text, stderr, err := runRoot(t, "version") + if err != nil || stderr != "" { + t.Fatalf("version failed: %v, stderr=%q", err, stderr) + } + resetCobraFlags(Root()) + flag, stderr, err := runRoot(t, "--version") + if err != nil || stderr != "" { + t.Fatalf("--version failed: %v, stderr=%q", err, stderr) + } + if flag != text { + t.Fatalf("version forms disagree:\ncommand: %q\nflag: %q", text, flag) + } + for _, want := range []string{"v1.2.3-test", runtime.Version(), runtime.GOOS + "/" + runtime.GOARCH} { + if !strings.Contains(text, want) { + t.Errorf("version output omitted %q: %s", want, text) + } + } +} + +func TestVersionJSONIsOfflineAndMachineReadable(t *testing.T) { + resetGlobals(t) + original := Root().PersistentPreRunE + Root().PersistentPreRunE = func(*cobra.Command, []string) error { + return errors.New("version must not invoke the inherited upgrade hook") + } + t.Cleanup(func() { Root().PersistentPreRunE = original }) + stdout, stderr, err := runRoot(t, "version", "--json") + if err != nil || stderr != "" { + t.Fatalf("version --json failed: %v, stderr=%q", err, stderr) + } + var info map[string]string + if err := json.Unmarshal([]byte(stdout), &info); err != nil { + t.Fatalf("output is not a single JSON object: %v\n%s", err, stdout) + } + want := map[string]string{ + "version": Root().Version, "go_version": runtime.Version(), "os": runtime.GOOS, "arch": runtime.GOARCH, + } + for key, value := range want { + if info[key] != value { + t.Errorf("%s = %q, want %q", key, info[key], value) + } + } +} + +func TestVersionRejectsUnexpectedArguments(t *testing.T) { + resetGlobals(t) + if _, _, err := runRoot(t, "version", "unexpected"); err == nil { + t.Fatal("version accepted an unexpected positional argument") + } +}