diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index eb89e5f..e3ccf3f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -26,5 +26,5 @@ jobs: binary-name: sparkplugbctl main-path: . release-name-prefix: sparkplugbctl - ldflags: "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" + ldflags: "-s -w -X main.version=${VERSION} -X main.tag=${TAG} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" secrets: inherit diff --git a/Makefile b/Makefile index 037ea04..bb97740 100644 --- a/Makefile +++ b/Makefile @@ -11,10 +11,14 @@ GO_MOD_VERSION := $(shell sed -nE 's/^go ([0-9]+\.[0-9]+).*/\1/p' go.mod | head GOFMT_CI = $(shell GOTOOLCHAIN=go$(GO_MOD_VERSION).0 go env GOROOT)/bin/gofmt BIN_DIR := bin -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +# Build metadata for the version command (internal/buildinfo). TAG and VERSION +# (the tag without its v) are set only when HEAD is exactly on a tag; otherwise +# they stay empty and the binary reports dev. COMMIT is the full SHA. +TAG ?= $(shell git describe --tags --exact-match 2>/dev/null) +VERSION ?= $(patsubst v%,%,$(TAG)) +COMMIT := $(shell git rev-parse HEAD 2>/dev/null) BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) -LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(BUILD_DATE) +LDFLAGS := -s -w -X main.version=$(VERSION) -X main.tag=$(TAG) -X main.commit=$(COMMIT) -X main.date=$(BUILD_DATE) TEST_PKGS := $(shell go list ./...) COVER_PKGS := $(shell go list ./... | paste -sd, -) @@ -41,7 +45,7 @@ build-all: ## Cross-compile the release platforms into ./bin e2e: ## Build the binary and run the e2e suite against it @mkdir -p bin @go build -ldflags "$(LDFLAGS)" -o bin/sparkplugbctl . - @SPARKPLUGBCTL_E2E_BIN=$(CURDIR)/bin/sparkplugbctl SPARKPLUGBCTL_E2E_VERSION=$(VERSION) go test -count=1 -tags=e2e ./e2e/... + @SPARKPLUGBCTL_E2E_BIN=$(CURDIR)/bin/sparkplugbctl SPARKPLUGBCTL_E2E_VERSION=$(or $(VERSION),dev) go test -count=1 -tags=e2e ./e2e/... install: ## Install sparkplugbctl into GOPATH/bin @echo "Installing sparkplugbctl" diff --git a/README.md b/README.md index 40efefa..d215f5b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ From source (Go 1.27.3+): `go install github.com/otfabric/sparkplugbctl@latest` ### Shell completion +Besides the commands and flags, the type in `--metric name:type=value` completes, and the TLS flags complete `.pem`, `.crt`, `.cer` and `.key` files. + ```bash # bash (needs the bash-completion package) mkdir -p ~/.local/share/bash-completion/completions @@ -318,15 +320,25 @@ Generate shell completion scripts for bash, zsh, fish and PowerShell; how to ins ```bash sparkplugbctl version +sparkplugbctl version --json ``` +One line on stdout. A release build: + +``` +sparkplugbctl 0.4.0 (tag v0.4.0, commit 1802d0f9afe8642d1e278120b76fcdfac967cb9e, built 2026-11-05T15:41:49Z, go-sparkplugb 0.3.3, go1.27.3, darwin/arm64) ``` -sparkplugbctl 0.1.0 - commit: e38c75bdb1b909fb91ff3f735d746cf42145f169 - built: 2026-10-27T12:12:45Z - library: go-sparkplugb v0.1.2 - go: go1.27.3 - os/arch: darwin/arm64 + +A development build (`go build`, `go run`, or `make build` off a tag; `go install …@vX.Y.Z` reports that version): + +``` +sparkplugbctl dev (commit 1802d0f9afe8642d1e278120b76fcdfac967cb9e-dirty, built unknown, go-sparkplugb 0.3.3, go1.27.3, darwin/arm64) +``` + +With `--json` (or `SPARKPLUGBCTL_FORMAT=json`) it prints one JSON object; the fields are in [docs/json-output.md](docs/json-output.md#version). + +```json +{"name":"sparkplugbctl","version":"0.4.0","tag":"v0.4.0","commit":"1802d0f9afe8642d1e278120b76fcdfac967cb9e","built":"2026-11-05T15:41:49Z","libraries":[{"name":"go-sparkplugb","version":"0.3.3"}],"go":"go1.27.3","platform":"darwin/arm64"} ``` --- diff --git a/RELEASE.md b/RELEASE.md index 86ed275..ea93a1a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,22 @@ # sparkplugbctl Releases +## v0.4.0 + +**Date:** 2026-11-07 +**Previous release:** v0.3.6 + +### Added + +- `version --json` (and `version` with `SPARKPLUGBCTL_FORMAT=json`) prints one JSON object: `name`, `version`, `tag`, `commit`, `built`, `libraries`, `go`, `platform`, in that order ([docs/json-output.md](docs/json-output.md#version)). +- Shell completion for flag values: the type in `--metric name:type=value` (`publish`, `edge`, `encode`) completes the 15 metric types, `--tls-cert`/`--tls-ca` complete `.pem`/`.crt`/`.cer` files and `--tls-key` `.pem`/`.key` files; flags that take free values and commands without arguments no longer offer file names. `completion` covers bash, zsh, fish and PowerShell. + +### Changed + +- `version` prints one line, aligned with the other OT Fabric CLIs: `sparkplugbctl 0.4.0 (tag v0.4.0, commit , built , go-sparkplugb 0.3.3, go1.27.3, darwin/arm64)`. The version has no leading `v` and the tag part is left out when there is none. A `go install …@vX.Y.Z` build reports that version; a `go build` or a `make build` off a tag reports `dev` with the VCS commit (with `-dirty`). +- The release workflow injects the git tag (`-X main.tag`); the Makefile injects the full commit SHA, and the version and tag only when HEAD is exactly on a tag. + +--- + ## v0.3.6 **Date:** 2026-11-01 diff --git a/cmd/app.go b/cmd/app.go index fdf418e..e731096 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -19,8 +19,9 @@ import ( var appGroup string var appCmd = &cobra.Command{ - Use: "app", - Short: "Run a Sparkplug B application (subscriber, no STATE)", + ValidArgsFunction: cobra.NoFileCompletions, + Use: "app", + Short: "Run a Sparkplug B application (subscriber, no STATE)", Long: `App runs a Sparkplug B application that subscribes to spBv1.0/#, tracks edge nodes and devices, and displays events as they arrive. @@ -32,6 +33,7 @@ resolves aliases, and can send NCMD/DCMD commands.`, func init() { appCmd.Flags().StringVar(&appGroup, "group", "", "filter events by group ID (optional)") + completeFlags(appCmd, cobra.NoFileCompletions, "group") rootCmd.AddCommand(appCmd) } diff --git a/cmd/codec_test.go b/cmd/codec_test.go index 50605f5..cb47d14 100644 --- a/cmd/codec_test.go +++ b/cmd/codec_test.go @@ -7,6 +7,8 @@ import ( "errors" "os" "path/filepath" + "runtime" + "runtime/debug" "strings" "testing" "testing/iotest" @@ -24,15 +26,55 @@ func TestRootCommandExists(t *testing.T) { } func TestVersion(t *testing.T) { - out, _, err := run(t, nil, "version") - if err != nil { - t.Fatal(err) + defer func(v, tg, c, d string) { Version, Tag, Commit, BuildDate = v, tg, c, d }(Version, Tag, Commit, BuildDate) + Version, Tag, Commit, BuildDate = "0.4.0", "v0.4.0", "1802d0f9afe8642d1e278120b76fcdfac967cb9e", "2026-11-05T15:41:49Z" + platform := runtime.Version() + ", " + runtime.GOOS + "/" + runtime.GOARCH + lib := sparkplugbVersion(t) + wantText := "sparkplugbctl 0.4.0 (tag v0.4.0, commit 1802d0f9afe8642d1e278120b76fcdfac967cb9e, built 2026-11-05T15:41:49Z, go-sparkplugb " + lib + ", " + platform + ")\n" + wantJSON := `{"name":"sparkplugbctl","version":"0.4.0","tag":"v0.4.0","commit":"1802d0f9afe8642d1e278120b76fcdfac967cb9e",` + + `"built":"2026-11-05T15:41:49Z","libraries":[{"name":"go-sparkplugb","version":"` + lib + `"}],"go":"` + runtime.Version() + `","platform":"` + runtime.GOOS + "/" + runtime.GOARCH + `"}` + "\n" + + t.Setenv("SPARKPLUGBCTL_FORMAT", "") + t.Setenv("SPARKPLUGBCTL_OUTPUT_FORMAT", "") + cases := []struct { + name, format string + args []string + want string + }{ + {"text", "", []string{"version"}, wantText}, + {"json flag", "", []string{"version", "--json"}, wantJSON}, + {"format json", "json", []string{"version"}, wantJSON}, + {"format text", "text", []string{"version"}, wantText}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Setenv("SPARKPLUGBCTL_FORMAT", c.format) + out, _, err := run(t, nil, c.args...) + if err != nil { + t.Fatal(err) + } + if out != c.want { + t.Errorf("got %q\nwant %q", out, c.want) + } + }) } - for _, want := range []string{"sparkplugbctl " + Version, "commit:", "built:", "library: go-sparkplugb ", "go:", "os/arch:"} { - if !strings.Contains(out, want) { - t.Errorf("version output missing %q:\n%s", want, out) +} + +// sparkplugbVersion is the go-sparkplugb version of the test binary, without +// the leading v. +func sparkplugbVersion(t *testing.T) string { + t.Helper() + bi, ok := debug.ReadBuildInfo() + if !ok { + t.Fatal("no build information") + } + for _, d := range bi.Deps { + if d.Path == "github.com/otfabric/go-sparkplugb" { + return strings.TrimPrefix(d.Version, "v") } } + t.Fatal("go-sparkplugb is not a dependency") + return "" } // TestEnvironment checks that a global flag comes from its SPARKPLUGBCTL_ diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..9387ca6 --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "strings" + + "github.com/spf13/cobra" +) + +// Shell completion. Cobra's default completion command serves bash, zsh, fish +// and PowerShell; the functions here give the flags their values. + +// metricTypes are the metric types of --metric name:type=value, as +// parseDataType accepts them (case-insensitive), in their Sparkplug spelling. +var metricTypes = []string{ + "Int8", "Int16", "Int32", "Int64", + "UInt8", "UInt16", "UInt32", "UInt64", + "Float", "Double", "Boolean", "String", "Text", "UUID", "Bytes", +} + +// completeMetric completes the type of a --metric name:type=value: after +// "name:" it offers "name:=" for each type that starts with what was +// typed. Before the colon (a free name) and after "=" (a value) it offers +// nothing. +func completeMetric(_ *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + name, typed, ok := strings.Cut(toComplete, ":") + if !ok || strings.Contains(typed, "=") { + return nil, cobra.ShellCompDirectiveNoFileComp + } + var out []cobra.Completion + for _, t := range metricTypes { + if strings.HasPrefix(strings.ToLower(t), strings.ToLower(typed)) { + out = append(out, name+":"+t+"=") + } + } + return out, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp +} + +// anyFile completes file names of any kind (a binary payload has no fixed +// extension). +func anyFile(*cobra.Command, []string, string) ([]cobra.Completion, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveDefault +} + +// fileExt completes files with one of the extensions. +func fileExt(exts ...string) cobra.CompletionFunc { + return func(*cobra.Command, []string, string) ([]cobra.Completion, cobra.ShellCompDirective) { + return exts, cobra.ShellCompDirectiveFilterFileExt + } +} + +// completeFlags registers f for the flags names of c (local or persistent). +// A registration error is a typo in a flag name; TestEveryFlagCompletes +// catches it. +func completeFlags(c *cobra.Command, f cobra.CompletionFunc, names ...string) { + for _, n := range names { + _ = c.RegisterFlagCompletionFunc(n, f) + } +} diff --git a/cmd/completion_test.go b/cmd/completion_test.go new file mode 100644 index 0000000..edd0ebe --- /dev/null +++ b/cmd/completion_test.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "slices" + "strconv" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// complete runs the hidden __complete command and returns the offered +// values and the directive line. +func complete(t *testing.T, args ...string) ([]string, string) { + t.Helper() + out, _, err := run(t, nil, append([]string{cobra.ShellCompRequestCmd}, args...)...) + removeCommands(cobra.ShellCompRequestCmd) // cobra keeps it once called + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + directive := lines[len(lines)-1] + var vals []string + for _, l := range lines[:len(lines)-1] { + v, _, _ := strings.Cut(l, "\t") + vals = append(vals, v) + } + return vals, directive +} + +// removeCommands removes the commands named name that cobra adds to rootCmd +// on its first run and keeps: the completion command writes to the output +// of the run that created it, and __complete stays once called. +func removeCommands(name string) { + for _, c := range rootCmd.Commands() { + if c.Name() == name { + rootCmd.RemoveCommand(c) + } + } +} + +func TestCompletionShells(t *testing.T) { + got, _ := complete(t, "completion", "") + for _, sh := range []string{"bash", "zsh", "fish", "powershell"} { + if !slices.Contains(got, sh) { + t.Errorf("completion offers %v, missing %s", got, sh) + } + } + for _, sh := range []string{"bash", "zsh", "fish", "powershell"} { + removeCommands("completion") + out, _, err := run(t, nil, "completion", sh) + if err != nil || !strings.Contains(out, "sparkplugbctl") { + t.Errorf("completion %s: %v, %d bytes", sh, err, len(out)) + } + } +} + +func TestCompleteMetricTypes(t *testing.T) { + for _, c := range []string{"publish", "edge", "encode"} { + got, directive := complete(t, c, "--metric", "temp:") + if len(got) != len(metricTypes) || got[0] != "temp:Int8=" || !slices.Contains(got, "temp:Double=") { + t.Errorf("%s --metric temp: offers %v", c, got) + } + want := cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp + if directive != ":"+strconv.Itoa(int(want)) { + t.Errorf("%s --metric directive %s, want :%d", c, directive, want) + } + } + if got, _ := complete(t, "publish", "--metric", "t:do"); !slices.Equal(got, []string{"t:Double="}) { + t.Errorf("t:do offers %v", got) + } + for _, typed := range []string{"temp", "temp:Double=2"} { + if got, directive := complete(t, "publish", "--metric", typed); len(got) != 0 || directive != ":4" { + t.Errorf("%q offers %v %s", typed, got, directive) + } + } +} + +func TestCompleteFileFlags(t *testing.T) { + got, directive := complete(t, "listen", "--tls-key", "") + if !slices.Equal(got, []string{"pem", "key"}) || directive != ":8" { + t.Errorf("--tls-key offers %v %s", got, directive) + } + got, directive = complete(t, "app", "--tls-ca", "") + if !slices.Equal(got, []string{"pem", "crt", "cer"}) || directive != ":8" { + t.Errorf("--tls-ca offers %v %s", got, directive) + } + if _, directive := complete(t, "decode", "--file", ""); directive != ":0" { + t.Errorf("decode --file directive %s, want :0 (files)", directive) + } + if _, directive := complete(t, "host", "--host-id", ""); directive != ":4" { + t.Errorf("host --host-id directive %s, want :4 (no files)", directive) + } + if _, directive := complete(t, "version", ""); directive != ":4" { + t.Errorf("version args directive %s, want :4 (no files)", directive) + } +} + +// TestEveryFlagCompletes walks the command tree: every flag that takes a +// value has a completion function, and every command without subcommands +// completes no files for its (absent) arguments. +func TestEveryFlagCompletes(t *testing.T) { + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + check := func(f *pflag.Flag) { + if f.NoOptDefVal != "" || f.Name == "help" { + return + } + if _, ok := c.GetFlagCompletionFunc(f.Name); !ok { + t.Errorf("%s --%s has no completion function", c.CommandPath(), f.Name) + } + } + c.LocalFlags().VisitAll(check) + c.InheritedFlags().VisitAll(check) + if c.Name() != "completion" && c.Name() != "help" && !c.HasSubCommands() && c.ValidArgsFunction == nil { + t.Errorf("%s has no ValidArgsFunction", c.CommandPath()) + } + for _, sub := range c.Commands() { + walk(sub) + } + } + rootCmd.InitDefaultHelpCmd() + rootCmd.InitDefaultCompletionCmd() + walk(rootCmd) +} diff --git a/cmd/decode.go b/cmd/decode.go index c889ed1..12fe029 100644 --- a/cmd/decode.go +++ b/cmd/decode.go @@ -15,8 +15,9 @@ import ( var decodeFile string var decodeCmd = &cobra.Command{ - Use: "decode", - Short: "Decode a Sparkplug B Protobuf payload", + ValidArgsFunction: cobra.NoFileCompletions, + Use: "decode", + Short: "Decode a Sparkplug B Protobuf payload", Long: `Decode reads a binary Sparkplug B Protobuf payload from stdin or a file and pretty-prints or JSON-encodes it.`, RunE: runDecode, @@ -24,6 +25,7 @@ a file and pretty-prints or JSON-encodes it.`, func init() { decodeCmd.Flags().StringVar(&decodeFile, "file", "", "read payload from file (default: stdin)") + completeFlags(decodeCmd, anyFile, "file") rootCmd.AddCommand(decodeCmd) } diff --git a/cmd/edge.go b/cmd/edge.go index ee4b800..4c32f4c 100644 --- a/cmd/edge.go +++ b/cmd/edge.go @@ -27,8 +27,9 @@ var ( ) var edgeCmd = &cobra.Command{ - Use: "edge", - Short: "Simulate a Sparkplug B edge node", + ValidArgsFunction: cobra.NoFileCompletions, + Use: "edge", + Short: "Simulate a Sparkplug B edge node", Long: `Edge simulates a full Sparkplug B edge node lifecycle: Connect → NBIRTH → DBIRTH(s) → DATA loop → Ctrl+C → DDEATH(s) → NDEATH → Disconnect @@ -44,6 +45,8 @@ func init() { edgeCmd.Flags().StringSliceVar(&edgeMetrics, "metric", nil, "node metrics as name:type=value (repeatable)") edgeCmd.Flags().DurationVar(&edgeInterval, "interval", 5*time.Second, "data publish interval") edgeCmd.Flags().StringVar(&edgePrimaryHost, "primary-host", "", "primary host application ID") + completeFlags(edgeCmd, cobra.NoFileCompletions, "group", "node", "device", "interval", "primary-host") + completeFlags(edgeCmd, completeMetric, "metric") _ = edgeCmd.MarkFlagRequired("group") _ = edgeCmd.MarkFlagRequired("node") rootCmd.AddCommand(edgeCmd) diff --git a/cmd/encode.go b/cmd/encode.go index 7174914..fa2fb5c 100644 --- a/cmd/encode.go +++ b/cmd/encode.go @@ -17,8 +17,9 @@ var ( ) var encodeCmd = &cobra.Command{ - Use: "encode", - Short: "Encode a Sparkplug B payload to Protobuf", + ValidArgsFunction: cobra.NoFileCompletions, + Use: "encode", + Short: "Encode a Sparkplug B payload to Protobuf", Long: `Encode creates a Sparkplug B Protobuf payload from the given metrics and writes it to stdout or a file. @@ -33,6 +34,9 @@ func init() { encodeCmd.Flags().StringSliceVar(&encodeMetrics, "metric", nil, "metric in name:type=value format (repeatable)") encodeCmd.Flags().Uint64Var(&encodeSeq, "seq", 0, "sequence number") encodeCmd.Flags().StringVar(&encodeFile, "file", "", "write output to file (default: stdout)") + completeFlags(encodeCmd, cobra.NoFileCompletions, "seq") + completeFlags(encodeCmd, completeMetric, "metric") + completeFlags(encodeCmd, anyFile, "file") rootCmd.AddCommand(encodeCmd) } diff --git a/cmd/host.go b/cmd/host.go index 981f67b..73c721d 100644 --- a/cmd/host.go +++ b/cmd/host.go @@ -18,8 +18,9 @@ import ( var hostID string var hostCmd = &cobra.Command{ - Use: "host", - Short: "Simulate a Sparkplug B host application", + ValidArgsFunction: cobra.NoFileCompletions, + Use: "host", + Short: "Simulate a Sparkplug B host application", Long: `Host simulates a Sparkplug B host application: Connect → STATE ONLINE → listen for edge activity → Ctrl+C → STATE OFFLINE → Disconnect`, RunE: runHost, @@ -27,6 +28,7 @@ Connect → STATE ONLINE → listen for edge activity → Ctrl+C → STATE OFFLI func init() { hostCmd.Flags().StringVar(&hostID, "host-id", "", "host application ID (required)") + completeFlags(hostCmd, cobra.NoFileCompletions, "host-id") _ = hostCmd.MarkFlagRequired("host-id") rootCmd.AddCommand(hostCmd) } diff --git a/cmd/listen.go b/cmd/listen.go index 503d11a..4494ae2 100644 --- a/cmd/listen.go +++ b/cmd/listen.go @@ -23,8 +23,9 @@ var ( ) var listenCmd = &cobra.Command{ - Use: "listen", - Short: "Subscribe to Sparkplug B messages and print them", + ValidArgsFunction: cobra.NoFileCompletions, + Use: "listen", + Short: "Subscribe to Sparkplug B messages and print them", Long: `Listen subscribes to spBv1.0/# and decodes incoming Sparkplug B messages in real time. Use --group, --node, and --device to filter.`, RunE: runListen, @@ -34,6 +35,7 @@ func init() { listenCmd.Flags().StringVar(&filterGroup, "group", "", "filter by group ID") listenCmd.Flags().StringVar(&filterNode, "node", "", "filter by edge node ID") listenCmd.Flags().StringVar(&filterDevice, "device", "", "filter by device ID") + completeFlags(listenCmd, cobra.NoFileCompletions, "group", "node", "device") rootCmd.AddCommand(listenCmd) } diff --git a/cmd/publish.go b/cmd/publish.go index 016e7cc..abe8058 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -23,8 +23,9 @@ var ( ) var publishCmd = &cobra.Command{ - Use: "publish", - Short: "Publish a Sparkplug B payload", + ValidArgsFunction: cobra.NoFileCompletions, + Use: "publish", + Short: "Publish a Sparkplug B payload", Long: `Publish creates a Sparkplug B payload from the given metrics and publishes it to the specified topic. @@ -40,6 +41,8 @@ func init() { publishCmd.Flags().StringVar(&pubTopic, "topic", "", "MQTT topic (required)") publishCmd.Flags().StringSliceVar(&pubMetrics, "metric", nil, "metric in name:type=value format (repeatable)") publishCmd.Flags().Uint64Var(&pubSeq, "seq", 0, "sequence number") + completeFlags(publishCmd, cobra.NoFileCompletions, "topic", "seq") + completeFlags(publishCmd, completeMetric, "metric") _ = publishCmd.MarkFlagRequired("topic") rootCmd.AddCommand(publishCmd) } diff --git a/cmd/root.go b/cmd/root.go index 5bcd244..f54df63 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -72,6 +72,9 @@ func init() { rootCmd.PersistentFlags().StringVar(&tlsKey, "tls-key", "", "TLS client key file") rootCmd.PersistentFlags().StringVar(&tlsCA, "tls-ca", "", "TLS CA certificate file") rootCmd.PersistentFlags().BoolVar(&jsonOut, "json", false, "output as JSON") + completeFlags(rootCmd, cobra.NoFileCompletions, "broker", "client-id", "username", "password") + completeFlags(rootCmd, fileExt("pem", "crt", "cer"), "tls-cert", "tls-ca") + completeFlags(rootCmd, fileExt("pem", "key"), "tls-key") } func Execute() error { diff --git a/cmd/version.go b/cmd/version.go index aa00753..cbb902a 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -4,48 +4,33 @@ package cmd import ( "fmt" - "runtime" - "runtime/debug" + "github.com/otfabric/sparkplugbctl/internal/buildinfo" "github.com/spf13/cobra" ) +// Build metadata, set by main from the values injected at link time. Empty +// means not injected: the version command then falls back to the Go build +// information, then to "dev" or "unknown". var ( - Version = "dev" - Commit = "unknown" - BuildDate = "unknown" + Version string + Tag string + Commit string + BuildDate string ) var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print version information", + Use: "version", + Short: "Print version information", + ValidArgsFunction: cobra.NoFileCompletions, Run: func(cmd *cobra.Command, args []string) { - w := cmd.OutOrStdout() - _, _ = fmt.Fprintf(w, "sparkplugbctl %s\n", Version) - _, _ = fmt.Fprintf(w, " commit: %s\n", Commit) - _, _ = fmt.Fprintf(w, " built: %s\n", BuildDate) - _, _ = fmt.Fprintf(w, " library: go-sparkplugb %s\n", libraryVersion()) - _, _ = fmt.Fprintf(w, " go: %s\n", runtime.Version()) - _, _ = fmt.Fprintf(w, " os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) - }, -} - -// libraryVersion is the go-sparkplugb module version this binary was built -// with, from its build information. -func libraryVersion() string { - info, ok := debug.ReadBuildInfo() - if !ok { - return "unknown" - } - for _, dep := range info.Deps { - if dep.Path == "github.com/otfabric/go-sparkplugb" { - if dep.Replace != nil { - return dep.Replace.Version + " (replaced by " + dep.Replace.Path + ")" - } - return dep.Version + info := buildinfo.Resolve(cmd.Root().Name(), Version, Tag, Commit, BuildDate) + line := info.String() + if jsonOut { + line = info.JSON() } - } - return "unknown" + _, _ = fmt.Fprintln(cmd.OutOrStdout(), line) + }, } func init() { diff --git a/docs/json-output.md b/docs/json-output.md index c3416c1..8e974af 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -1,8 +1,8 @@ # `--json` output -`--json` (or `SPARKPLUGBCTL_FORMAT=json`) changes what `decode`, `listen` and `app` write to stdout. Every other command ignores it: `host` and `edge` always print text, `encode` writes Protobuf bytes, `publish` prints text. Errors are text on stderr, with the exit codes of [exitcodes.md](exitcodes.md); no error object is written to stdout. +`--json` (or `SPARKPLUGBCTL_FORMAT=json`) changes what `decode`, `listen`, `app` and `version` write to stdout. Every other command ignores it: `host` and `edge` always print text, `encode` writes Protobuf bytes, `publish` prints text. Errors are text on stderr, with the exit codes of [exitcodes.md](exitcodes.md); no error object is written to stdout. -Field names are a contract for scripts (`internal/output/output.go`, `appEvent` in `cmd/app.go`). A change to one is called out in the release notes. +Field names are a contract for scripts (`internal/output/output.go`, `appEvent` in `cmd/app.go`, `Info` in `internal/buildinfo`). A change to one is called out in the release notes. ## General @@ -85,3 +85,22 @@ One line per lifecycle or data event: | `node_id` | string | | | `device_id` | string | Omitted for node events | | `metrics` | number | How many metrics the message carried, not the metrics; omitted when 0 | + +## `version` + +One compact object on one line, the keys always present and in this order: + +| Field | Type | Meaning | +|---|---|---| +| `name` | string | `sparkplugbctl` | +| `version` | string | The version without a leading `v`; `dev` for a development build | +| `tag` | string | The git tag of a release build (`v0.4.0`); `""` when there is none | +| `commit` | string | The full commit SHA, with `-dirty` for a build from modified sources; `unknown` when not known | +| `built` | string | The RFC 3339 UTC build time; `unknown` when not known | +| `libraries` | array | The OT Fabric libraries (`github.com/otfabric/go-*`) the binary is built on, sorted by name, each `{"name":"go-sparkplugb","version":"0.3.3"}` with the version without its `v`; `[]` when none | +| `go` | string | The Go version the binary was built with | +| `platform` | string | `/` | + +```json +{"name":"sparkplugbctl","version":"0.4.0","tag":"v0.4.0","commit":"1802d0f9afe8642d1e278120b76fcdfac967cb9e","built":"2026-11-05T15:41:49Z","libraries":[{"name":"go-sparkplugb","version":"0.3.3"}],"go":"go1.27.3","platform":"darwin/arm64"} +``` diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 77122f7..2b102a8 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -46,8 +46,12 @@ func TestVersionPrintsTheBuiltVersion(t *testing.T) { if want == "" { t.Fatal("SPARKPLUGBCTL_E2E_VERSION is not set; run make e2e") } - if !strings.Contains(output, "sparkplugbctl") || !strings.Contains(output, want) { - t.Fatalf("output does not name sparkplugbctl %s:\n%s", want, output) + if !strings.HasPrefix(output, "sparkplugbctl "+want+" (") || strings.Count(output, "\n") != 1 { + t.Fatalf("output is not one line naming sparkplugbctl %s:\n%s", want, output) + } + output, code = run(t, "version", "--json") + if code != 0 || !strings.HasPrefix(output, `{"name":"sparkplugbctl","version":"`+want+`","tag":`) { + t.Fatalf("version --json: exit %d\n%s", code, output) } } diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..70b21bc --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MIT + +// Package buildinfo resolves the build metadata of the binary and formats the +// output of the version command: one text line or one JSON object. +// +// The values come from the linker (-X main.version=... -X main.tag=... +// -X main.commit=... -X main.date=..., set by the Makefile and the release +// workflow). What was not injected falls back to the Go build information +// (go install ...@vX.Y.Z, or the VCS stamp of go build), then to "dev" or +// "unknown". +package buildinfo + +import ( + "encoding/json" + "path" + "regexp" + "runtime" + "runtime/debug" + "sort" + "strings" +) + +// readBuildInfo is debug.ReadBuildInfo; tests replace it. +var readBuildInfo = debug.ReadBuildInfo + +// Info is the resolved build metadata. Field order is the JSON key order. +type Info struct { + Name string `json:"name"` + Version string `json:"version"` + Tag string `json:"tag"` + Commit string `json:"commit"` + Built string `json:"built"` + Libraries []Library `json:"libraries"` + Go string `json:"go"` + Platform string `json:"platform"` +} + +// Library is an OT Fabric library (github.com/otfabric/go-*) the binary is +// built on. +type Library struct { + Name string `json:"name"` // last path element, e.g. go-sparkplugb + Version string `json:"version"` // without the leading v +} + +// libraryPrefix is the module path prefix of the OT Fabric libraries. +const libraryPrefix = "github.com/otfabric/go-" + +// Resolve builds the Info of the binary name from the injected values, any of +// which may be empty. +func Resolve(name, version, tag, commit, built string) Info { + bi, ok := readBuildInfo() + if version == "" && ok && isRelease(bi.Main.Version) { + version = bi.Main.Version + } + version = strings.TrimPrefix(version, "v") + if version == "" { + version = "dev" + } + if commit == "" && ok { + commit = vcsCommit(bi) + } + if commit == "" { + commit = "unknown" + } + if built == "" { + built = "unknown" + } + libs := []Library{} + if ok { + libs = libraries(bi) + } + return Info{ + Name: name, + Version: version, + Tag: tag, + Commit: commit, + Built: built, + Libraries: libs, + Go: runtime.Version(), + Platform: runtime.GOOS + "/" + runtime.GOARCH, + } +} + +// libraries are the github.com/otfabric/go-* dependencies of the build other +// than the main module, with their replacement version when replaced, sorted +// by name. Never nil, so the JSON is [] when there are none. +func libraries(bi *debug.BuildInfo) []Library { + libs := []Library{} + for _, dep := range bi.Deps { + if !strings.HasPrefix(dep.Path, libraryPrefix) || dep.Path == bi.Main.Path { + continue + } + v := dep.Version + if dep.Replace != nil { + v = dep.Replace.Version + } + v = strings.TrimPrefix(v, "v") + if v == "" { + v = "unknown" // a replacement by a local directory has no version + } + libs = append(libs, Library{Name: path.Base(dep.Path), Version: v}) + } + sort.Slice(libs, func(i, j int) bool { return libs[i].Name < libs[j].Name }) + return libs +} + +// pseudoVersion matches the timestamp-and-commit tail of a Go pseudo-version +// (v0.3.7-0.20261107212211-a54bc2e3e6f9), which go build (Go 1.24+) stamps on +// a build from a checkout. +var pseudoVersion = regexp.MustCompile(`[-.]\d{14}-[0-9a-f]{12}$`) + +// isRelease reports whether v, the main module version of the build +// information, is a released version (go install ...@vX.Y.Z), not "(devel)", +// a pseudo-version or a +dirty build from modified sources. +func isRelease(v string) bool { + return v != "" && v != "(devel)" && !strings.Contains(v, "+") && !pseudoVersion.MatchString(v) +} + +// vcsCommit is the vcs.revision of the build, with -dirty when vcs.modified. +func vcsCommit(bi *debug.BuildInfo) string { + var rev string + var dirty bool + for _, s := range bi.Settings { + switch s.Key { + case "vcs.revision": + rev = s.Value + case "vcs.modified": + dirty = s.Value == "true" + } + } + if rev != "" && dirty { + rev += "-dirty" + } + return rev +} + +// String is the version line: +// (tag , commit , built , , …, , /); +// the tag part is left out when there is no tag, the libraries when there are +// none. +func (i Info) String() string { + var b strings.Builder + b.WriteString(i.Name + " " + i.Version + " (") + if i.Tag != "" { + b.WriteString("tag " + i.Tag + ", ") + } + b.WriteString("commit " + i.Commit + ", built " + i.Built + ", ") + for _, l := range i.Libraries { + b.WriteString(l.Name + " " + l.Version + ", ") + } + b.WriteString(i.Go + ", " + i.Platform + ")") + return b.String() +} + +// JSON is the version object on one line, keys in a fixed order; libraries +// is [] when there are none. +func (i Info) JSON() string { + if i.Libraries == nil { + i.Libraries = []Library{} + } + b, _ := json.Marshal(i) // a struct of strings always marshals + return string(b) +} diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go new file mode 100644 index 0000000..42c58fb --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MIT + +package buildinfo + +import ( + "runtime" + "runtime/debug" + "testing" +) + +const sha = "1802d0f9afe8642d1e278120b76fcdfac967cb9e" + +var platform = runtime.Version() + ", " + runtime.GOOS + "/" + runtime.GOARCH + +func fakeBuildInfo(t *testing.T, bi *debug.BuildInfo, ok bool) { + t.Helper() + old := readBuildInfo + t.Cleanup(func() { readBuildInfo = old }) + readBuildInfo = func() (*debug.BuildInfo, bool) { return bi, ok } +} + +func TestTextAllFields(t *testing.T) { + fakeBuildInfo(t, nil, false) + got := Resolve("sparkplugbctl", "0.4.0", "v0.4.0", sha, "2026-11-05T15:41:49Z").String() + want := "sparkplugbctl 0.4.0 (tag v0.4.0, commit " + sha + ", built 2026-11-05T15:41:49Z, " + platform + ")" + if got != want { + t.Fatalf("got %q\nwant %q", got, want) + } +} + +func TestTextWithoutTag(t *testing.T) { + fakeBuildInfo(t, nil, false) + got := Resolve("sparkplugbctl", "v0.4.0-3-gabc", "", sha, "2026-11-05T15:41:49Z").String() + want := "sparkplugbctl 0.4.0-3-gabc (commit " + sha + ", built 2026-11-05T15:41:49Z, " + platform + ")" + if got != want { + t.Fatalf("got %q\nwant %q", got, want) + } +} + +func TestDevFallbackNothingKnown(t *testing.T) { + fakeBuildInfo(t, nil, false) + got := Resolve("sparkplugbctl", "", "", "", "").String() + want := "sparkplugbctl dev (commit unknown, built unknown, " + platform + ")" + if got != want { + t.Fatalf("got %q\nwant %q", got, want) + } +} + +func TestDevFallbackFromVCS(t *testing.T) { + fakeBuildInfo(t, &debug.BuildInfo{ + Main: debug.Module{Version: "(devel)"}, + Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: sha}, + {Key: "vcs.modified", Value: "true"}, + }, + }, true) + got := Resolve("sparkplugbctl", "", "", "", "").String() + want := "sparkplugbctl dev (commit " + sha + "-dirty, built unknown, " + platform + ")" + if got != want { + t.Fatalf("got %q\nwant %q", got, want) + } +} + +func TestGoInstallVersion(t *testing.T) { + fakeBuildInfo(t, &debug.BuildInfo{ + Main: debug.Module{Version: "v0.4.0"}, + Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: sha}, {Key: "vcs.modified", Value: "false"}}, + }, true) + i := Resolve("sparkplugbctl", "", "", "", "") + if i.Version != "0.4.0" || i.Commit != sha || i.Tag != "" { + t.Fatalf("got %+v", i) + } +} + +func TestInjectedWinsOverBuildInfo(t *testing.T) { + fakeBuildInfo(t, &debug.BuildInfo{ + Main: debug.Module{Version: "v9.9.9"}, + Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: "other"}}, + }, true) + i := Resolve("sparkplugbctl", "0.4.0", "v0.4.0", sha, "x") + if i.Version != "0.4.0" || i.Commit != sha { + t.Fatalf("got %+v", i) + } +} + +func TestJSONKeyOrderAndValues(t *testing.T) { + fakeBuildInfo(t, nil, false) + got := Resolve("sparkplugbctl", "0.4.0", "v0.4.0", sha, "2026-11-05T15:41:49Z").JSON() + want := `{"name":"sparkplugbctl","version":"0.4.0","tag":"v0.4.0","commit":"` + sha + + `","built":"2026-11-05T15:41:49Z","libraries":[],"go":"` + runtime.Version() + `","platform":"` + runtime.GOOS + "/" + runtime.GOARCH + `"}` + if got != want { + t.Fatalf("got %s\nwant %s", got, want) + } +} + +func TestJSONDev(t *testing.T) { + fakeBuildInfo(t, nil, false) + got := Resolve("sparkplugbctl", "", "", "", "").JSON() + want := `{"name":"sparkplugbctl","version":"dev","tag":"","commit":"unknown","built":"unknown","libraries":[],"go":"` + + runtime.Version() + `","platform":"` + runtime.GOOS + "/" + runtime.GOARCH + `"}` + if got != want { + t.Fatalf("got %s\nwant %s", got, want) + } +} + +func TestPseudoAndDirtyVersionsAreDev(t *testing.T) { + for _, v := range []string{ + "(devel)", "", + "v0.3.7-0.20261107212211-a54bc2e3e6f9", + "v0.3.7-0.20261107212211-a54bc2e3e6f9+dirty", + "v0.0.0-20261107212211-a54bc2e3e6f9", + "v0.4.0-rc.1.0.20261107212211-a54bc2e3e6f9", + "v0.4.0+dirty", + } { + fakeBuildInfo(t, &debug.BuildInfo{Main: debug.Module{Version: v}}, true) + if got := Resolve("sparkplugbctl", "", "", "", "").Version; got != "dev" { + t.Errorf("%q: version %q, want dev", v, got) + } + } + fakeBuildInfo(t, &debug.BuildInfo{Main: debug.Module{Version: "v0.4.0-rc.1"}}, true) + if got := Resolve("sparkplugbctl", "", "", "", "").Version; got != "0.4.0-rc.1" { + t.Errorf("v0.4.0-rc.1: version %q", got) + } +} + +func TestLibrariesFormatting(t *testing.T) { + base := Info{Name: "sparkplugbctl", Version: "0.4.0", Tag: "v0.4.0", Commit: sha, Built: "2026-11-05T15:41:49Z", Go: "go1.27.3", Platform: "darwin/arm64"} + head := `{"name":"sparkplugbctl","version":"0.4.0","tag":"v0.4.0","commit":"` + sha + `","built":"2026-11-05T15:41:49Z",` + tail := `"go":"go1.27.3","platform":"darwin/arm64"}` + cases := []struct { + name string + libs []Library + text, json string + }{ + {"none (nil)", nil, + "sparkplugbctl 0.4.0 (tag v0.4.0, commit " + sha + ", built 2026-11-05T15:41:49Z, go1.27.3, darwin/arm64)", + head + `"libraries":[],` + tail}, + {"none (empty)", []Library{}, + "sparkplugbctl 0.4.0 (tag v0.4.0, commit " + sha + ", built 2026-11-05T15:41:49Z, go1.27.3, darwin/arm64)", + head + `"libraries":[],` + tail}, + {"one", []Library{{"go-sparkplugb", "0.3.3"}}, + "sparkplugbctl 0.4.0 (tag v0.4.0, commit " + sha + ", built 2026-11-05T15:41:49Z, go-sparkplugb 0.3.3, go1.27.3, darwin/arm64)", + head + `"libraries":[{"name":"go-sparkplugb","version":"0.3.3"}],` + tail}, + {"two", []Library{{"go-canbus", "0.8.0"}, {"go-canopen", "0.5.1"}}, + "sparkplugbctl 0.4.0 (tag v0.4.0, commit " + sha + ", built 2026-11-05T15:41:49Z, go-canbus 0.8.0, go-canopen 0.5.1, go1.27.3, darwin/arm64)", + head + `"libraries":[{"name":"go-canbus","version":"0.8.0"},{"name":"go-canopen","version":"0.5.1"}],` + tail}, + } + for _, c := range cases { + i := base + i.Libraries = c.libs + if got := i.String(); got != c.text { + t.Errorf("%s text\ngot %q\nwant %q", c.name, got, c.text) + } + if got := i.JSON(); got != c.json { + t.Errorf("%s json\ngot %s\nwant %s", c.name, got, c.json) + } + } +} + +func TestLibrariesFromBuildInfo(t *testing.T) { + fakeBuildInfo(t, &debug.BuildInfo{ + Main: debug.Module{Path: "github.com/otfabric/go-self", Version: "(devel)"}, + Deps: []*debug.Module{ + {Path: "github.com/spf13/cobra", Version: "v1.10.2"}, + {Path: "github.com/otfabric/go-sparkplugb", Version: "v0.3.3"}, + {Path: "github.com/otfabric/go-self", Version: "v1.0.0"}, + {Path: "github.com/otfabric/go-canbus", Version: "v0.1.0", Replace: &debug.Module{Path: "github.com/fork/go-canbus", Version: "v0.8.0"}}, + {Path: "github.com/otfabric/go-local", Version: "v0.1.0", Replace: &debug.Module{Path: "../go-local"}}, + {Path: "github.com/otfabric/go-pseudo", Version: "v0.0.0-20261107212211-a54bc2e3e6f9"}, + {Path: "github.com/otfabric/docsync", Version: "v0.3.1"}, + }, + }, true) + got := Resolve("sparkplugbctl", "", "", "", "").Libraries + want := []Library{{"go-canbus", "0.8.0"}, {"go-local", "unknown"}, {"go-pseudo", "0.0.0-20261107212211-a54bc2e3e6f9"}, {"go-sparkplugb", "0.3.3"}} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for k := range want { + if got[k] != want[k] { + t.Errorf("libraries[%d] = %v, want %v", k, got[k], want[k]) + } + } +} diff --git a/main.go b/main.go index 3870468..e5b48b3 100644 --- a/main.go +++ b/main.go @@ -9,17 +9,19 @@ import ( ) // Injected at link time (Makefile and the release workflow): -// -X main.version=... -X main.commit=... -X main.date=... +// -X main.version=... -X main.tag=... -X main.commit=... -X main.date=... +// Empty means not injected; see internal/buildinfo for the fallbacks. var ( - version = "dev" - commit = "unknown" - date = "unknown" + version string + tag string + commit string + date string ) // exit ends the process; a test replaces it. var exit = os.Exit func main() { - cmd.Version, cmd.Commit, cmd.BuildDate = version, commit, date + cmd.Version, cmd.Tag, cmd.Commit, cmd.BuildDate = version, tag, commit, date exit(cmd.ExitCode(cmd.Execute())) } diff --git a/main_test.go b/main_test.go index 92d391c..c953947 100644 --- a/main_test.go +++ b/main_test.go @@ -36,8 +36,10 @@ func TestMainPassesBuildMetaAndExitCode(t *testing.T) { old := version defer func() { version = old }() version = "v9.8.7-test" + defer func(o string) { tag = o }(tag) + tag = "v9.8.7-test" code, out := runMain(t, "version") - if code != 0 || !strings.Contains(out, "v9.8.7-test") { + if code != 0 || !strings.HasPrefix(out, "sparkplugbctl 9.8.7-test (tag v9.8.7-test, commit ") { t.Fatalf("version: exit %d, output %q", code, out) } if code, _ := runMain(t, "no-such-command"); code == 0 {