Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 8 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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, -)
Expand All @@ -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"
Expand Down
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}
```

---
Expand Down
17 changes: 17 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -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 <full SHA>, built <RFC 3339>, 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
Expand Down
6 changes: 4 additions & 2 deletions cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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)
}

Expand Down
54 changes: 48 additions & 6 deletions cmd/codec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"errors"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
"testing"
"testing/iotest"
Expand All @@ -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_
Expand Down
60 changes: 60 additions & 0 deletions cmd/completion.go
Original file line number Diff line number Diff line change
@@ -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:<type>=" 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)
}
}
128 changes: 128 additions & 0 deletions cmd/completion_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 4 additions & 2 deletions cmd/decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@ 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,
}

func init() {
decodeCmd.Flags().StringVar(&decodeFile, "file", "", "read payload from file (default: stdin)")
completeFlags(decodeCmd, anyFile, "file")
rootCmd.AddCommand(decodeCmd)
}

Expand Down
Loading
Loading