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
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ BINARY = $(BIN_DIR)/$(APP_NAME)
ARCHS = linux/amd64 linux/arm64 linux/arm/v7 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64

# Override on the command line or in CI (matches reusable release workflow ldflags).
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
# TAG and VERSION only when HEAD is exactly on a tag; any other build reports "dev".
# VERSION has no leading v (the tag has it), as the release workflow passes it.
TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo "")
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
VERSION ?= $(if $(TAG),$(patsubst v%,%,$(TAG)),dev)
COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)

LDFLAGS := -ldflags "-s -w -X 'main.version=$(VERSION)' -X 'main.tag=$(TAG)' -X 'main.commit=$(COMMIT)' -X 'main.buildDate=$(BUILD_DATE)'"
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ From source (Go 1.26+): `go install github.com/otfabric/ocppctl@latest`

### Shell completion

Flags with a fixed set of values complete to those values, `<Action>` and `--action` to the actions of the OCPP version, and file flags to files of their type.

```bash
# bash (needs the bash-completion package)
mkdir -p ~/.local/share/bash-completion/completions
Expand Down Expand Up @@ -208,7 +210,7 @@ ocppctl
├── cert
│ └── hash CertificateHashData of a certificate
├── completion shell completion script (bash|zsh|fish|powershell)
└── version print version
└── version print the version line (--json: one JSON line)
```

---
Expand Down Expand Up @@ -259,7 +261,7 @@ ocppctl actions
ocppctl validate <Action>
ocppctl frame decode | encode
ocppctl cert hash
ocppctl version
ocppctl version [--json]
```

Every command has `--help`, with a description and examples. Action names are case-sensitive, as in OCPP (`BootNotification`); a near miss is suggested.
Expand Down
27 changes: 27 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
# ocppctl Releases

## v0.4.0

**Date:** 2026-11-07
**Previous release:** v0.3.0

### Summary

`version` prints one line, the same in every OT Fabric CLI, and `version --json` one JSON object; shell completion now covers every flag and argument.

### Changed

- **`version` output:** one line, `ocppctl 0.4.0 (tag v0.4.0, commit <full sha>, built <date>, go-ocpp 0.2.0, go1.26.x, linux/amd64)`; a build without a tag leaves out the tag part, and a build without injected metadata (`go build`, `go install`) reports the module version of a release or `dev`, and the VCS revision (with `-dirty`) the toolchain recorded. Was a multi-line `ocppctl version: …` block.
- **`version --json`** (new): one JSON line with the keys `name`, `version`, `tag`, `commit`, `built`, `libraries` (`name`, `version` of each OT Fabric library), `go`, `platform`, in that order and always present (docs/json-output.md). `OCPPCTL_FORMAT=json` prints the same.
- **Shell completion** (bash, zsh, fish, PowerShell) of every flag that takes a value: new completion of the `<Action>` argument of `client call`, `client send`, `server call` and `validate` and of `frame encode --action` (the actions of `--ocpp-version` the command can send, every version without it), `frame encode --error-code` (the CALLERROR codes of the version), and `.yaml` scenarios for `client script`; files of their type for the file flags, and no file names for addresses, numbers and other free values. The arguments of commands that take none complete no file names either.
- **Makefile:** injects the version and tag only when HEAD is exactly on a tag (any other build reports `dev`), the version without the leading `v`, and the full commit SHA, as the release workflow does.

### Upgrade notes

- A script that parsed the old multi-line `version` output should read `version --json` instead.

### Tests

- Version line with and without a tag, development fallback, JSON key order and values, `--json` and `OCPPCTL_FORMAT`.
- `__complete` for the four shells and for flags of every command group; a walk of the command tree fails for a flag that takes a value without completion, and for one whose usage lists values that completes none.

---

## v0.3.0

**Date:** 2026-11-05
Expand Down
8 changes: 6 additions & 2 deletions cmd/client/call.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (

"github.com/spf13/cobra"

"github.com/otfabric/ocppctl/internal/cli"
"github.com/otfabric/ocppctl/internal/clientops"
"github.com/otfabric/ocppctl/internal/ocppx"
)

func newCallCmd() *cobra.Command {
Expand Down Expand Up @@ -43,7 +45,8 @@ may have acted on it, so do not blindly resend it.`,
# A deliberately invalid message
ocppctl client call Heartbeat --url ws://localhost:9000/CS001 --insecure-transport --raw --payload '{"bogus":1}'
`,
Args: cobra.ExactArgs(1),
Args: cobra.ExactArgs(1),
ValidArgsFunction: cli.CompleteActions(func(a ocppx.Action) bool { return a.Kind == "call" && a.From != ocppx.FromCSMS }),
}
cfg := clientops.CallConfig{Common: clientops.NewCommon()}
wire(cmd, &cfg)
Expand All @@ -67,7 +70,8 @@ result says only that it was sent. The CSMS must choose OCPP 2.1.`,
ocppctl client send NotifyPeriodicEventStream --url wss://csms.example.com/ocpp/CS001 --ocpp-version 2.1 \
--payload '{"id":1,"pending":0,"basetime":"2026-01-01T00:00:00Z","data":[{"t":0,"v":"230"}]}'
`,
Args: cobra.ExactArgs(1),
Args: cobra.ExactArgs(1),
ValidArgsFunction: cli.CompleteActions(func(a ocppx.Action) bool { return a.Kind == "send" && a.From != ocppx.FromCSMS }),
}
cfg := clientops.SendConfig{Common: clientops.NewCommon()}
cfg.Versions = []string{"2.1"}
Expand Down
68 changes: 68 additions & 0 deletions cmd/completion_coverage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: MIT

package cmd

import (
"regexp"
"strings"
"testing"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

// valueSet matches a flag usage that lists the values the flag takes: "text|json",
// "one of …", or "Output format: text, json, or table".
var valueSet = regexp.MustCompile(`(?i)\b[\w.-]+\|[\w.-]+\b|\bone of\b|: [\w.-]+, [\w.-]+,? or [\w.-]+`)

// Every flag that takes a value completes: to its values, to file names, or to nothing
// (but not to the file names a shell offers by default); a flag whose usage lists its
// values completes to them; and every command completes its arguments.
func TestEveryFlagCompletes(t *testing.T) {
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
if c.Name() == "help" || strings.HasPrefix(c.Name(), "__") {
return
}
if c.ValidArgsFunction == nil && len(c.ValidArgs) == 0 {
t.Errorf("%s: the arguments do not complete", c.CommandPath())
}
c.LocalFlags().VisitAll(func(f *pflag.Flag) {
if typ := f.Value.Type(); typ == "bool" || typ == "count" {
return
}
_, fn := c.GetFlagCompletionFunc(f.Name)
_, files := f.Annotations[cobra.BashCompFilenameExt]
if !fn && !files {
t.Errorf("%s --%s does not complete", c.CommandPath(), f.Name)
return
}
if !valueSet.MatchString(f.Usage) {
return
}
args := append(strings.Fields(c.CommandPath())[1:], "--"+f.Name, "")
r := run(t, append([]string{cobra.ShellCompNoDescRequestCmd}, args...)...)
if lines := strings.Split(strings.TrimSpace(r.stdout), "\n"); len(lines) < 2 {
t.Errorf("%s --%s lists values (%q) but completes none", c.CommandPath(), f.Name, f.Usage)
}
})
for _, sub := range c.Commands() {
walk(sub)
}
}
walk(NewRootCmd())
}

// The completion command offers the four shells, and a free value completes no file names.
func TestCompletionShellsAndFreeValues(t *testing.T) {
r := run(t, cobra.ShellCompNoDescRequestCmd, "completion", "")
for _, sh := range []string{"bash", "zsh", "fish", "powershell"} {
if !strings.Contains("\n"+r.stdout, "\n"+sh+"\n") {
t.Errorf("completion offers no %s:\n%s", sh, r.stdout)
}
}
r = run(t, cobra.ShellCompNoDescRequestCmd, "version", "")
if !strings.HasSuffix(strings.TrimSpace(r.stdout), ":4") {
t.Errorf("version arguments complete file names:\n%s", r.stdout)
}
}
5 changes: 4 additions & 1 deletion cmd/offline.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ the CALLERROR code a peer would answer with) and exits 3.`,
# In a pipeline
ocppctl client boot --ocpp-version 2.1 --payload-only | ocppctl validate BootNotification --ocpp-version 2.1 --payload -
`,
Args: cobra.ExactArgs(1),
Args: cobra.ExactArgs(1),
ValidArgsFunction: cli.CompleteActions(nil),
}
cfg := offline.ValidateConfig{Version: "2.0.1", OutputFormat: "text"}
runner.RegisterCommandCfg(cmd, &cfg, versionCompletion)
Expand Down Expand Up @@ -163,6 +164,8 @@ the rules of --ocpp-version; the payload is not validated against a schema (use
sort.Strings(types)
runner.RegisterCommandCfg(cmd, &cfg, versionCompletion, func(c *cobra.Command) {
_ = cli.RegisterEnumFlagCompletion(c, "type", types)
cli.RegisterActionFlagCompletion(c, "action", nil)
cli.RegisterErrorCodeCompletion(c, "error-code")
})
cli.MustMarkFlagRequired(cmd, "type")
cli.MustMarkFlagRequired(cmd, "id")
Expand Down
33 changes: 31 additions & 2 deletions cmd/offline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,6 @@ func TestCertHash(t *testing.T) {
}

func TestRootCommands(t *testing.T) {
SetBuildMeta("v0.1.0", "v0.1.0", "abc", "2026-10-18")
run(t, "version").want(t, errs.ExitOK).has(t, "ocppctl version: v0.1.0", "commit: abc", "build date:", "go version:")
for _, sh := range []string{"bash", "zsh", "fish", "powershell"} {
if r := run(t, "completion", sh).want(t, errs.ExitOK); len(r.stdout) < 100 {
t.Fatalf("%s completion: %q", sh, r.stdout)
Expand Down Expand Up @@ -178,4 +176,35 @@ func TestRunCompletes(t *testing.T) {
if !strings.Contains(r.stdout, "Available") {
t.Fatalf("completion: %s", r.stdout)
}
// Positional actions, by sender and kind, and the flags of every command group.
for _, c := range []struct {
args []string
want, not []string
}{
{[]string{"client", "call", "--ocpp-version", "1.6", "Bo"}, []string{"BootNotification"}, nil},
{[]string{"client", "call", "Re"}, nil, []string{"RemoteStartTransaction", "Reset"}},
{[]string{"client", "send", ""}, []string{"NotifyPeriodicEventStream"}, []string{"BootNotification"}},
{[]string{"server", "call", "--station", "CS1", "Re"}, []string{"RemoteStartTransaction", "Reset"}, []string{"BootNotification"}},
{[]string{"validate", "Heart"}, []string{"Heartbeat"}, nil},
{[]string{"validate", "Heartbeat", ""}, nil, []string{"Heartbeat"}},
{[]string{"frame", "encode", "--action", "Heart"}, []string{"Heartbeat"}, nil},
{[]string{"frame", "encode", "--ocpp-version", "1.6", "--error-code", ""}, []string{"FormationViolation"}, []string{"FormatViolation"}},
{[]string{"server", "start", "--ocpp-version", ""}, []string{"1.6", "2.0.1", "2.1"}, nil},
{[]string{"cert", "hash", "--hash", ""}, []string{"sha256", "sha512"}, nil},
{[]string{"client", "script", ""}, []string{"yaml", "yml", ":8"}, nil},
{[]string{"server", "start", "--config", ""}, []string{"yaml", "json", ":8"}, nil},
{[]string{"client", "boot", "--url", ""}, []string{":4"}, nil},
} {
r := run(t, append([]string{"__completeNoDesc"}, c.args...)...)
for _, w := range c.want {
if !strings.Contains("\n"+r.stdout, "\n"+w+"\n") {
t.Errorf("completion of %v lacks %q:\n%s", c.args, w, r.stdout)
}
}
for _, n := range c.not {
if strings.Contains("\n"+r.stdout, "\n"+n+"\n") {
t.Errorf("completion of %v offers %q:\n%s", c.args, n, r.stdout)
}
}
}
}
46 changes: 26 additions & 20 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ import (
"io"
"os"
"os/signal"
"runtime"
"strings"
"syscall"

"github.com/spf13/cobra"

"github.com/otfabric/ocppctl/cmd/client"
"github.com/otfabric/ocppctl/cmd/server"
"github.com/otfabric/ocppctl/internal/buildinfo"
clihelp "github.com/otfabric/ocppctl/internal/cli"
"github.com/otfabric/ocppctl/internal/config"
"github.com/otfabric/ocppctl/internal/errs"
"github.com/otfabric/ocppctl/internal/runner"
)
Expand Down Expand Up @@ -64,34 +66,38 @@ stderr. Exit codes are stable: see docs/exitcodes.md.`,
newCompletionCmd(),
newVersionCmd(),
)
// Every other flag and argument completes no file names, except these files.
clihelp.CompleteRest(root, map[string][]string{
"config": {"yaml", "yml", "json"}, "output": nil, "password-file": nil, "passwords-file": nil, "control-socket": nil,
"tls-ca": pemFiles, "tls-cert": pemFiles, "tls-key": pemFiles, "tls-client-ca": pemFiles,
}, map[string][]string{"client script": {"yaml", "yml"}})
return root
}

// pemFiles are the extensions of the certificate and key files the TLS flags take.
var pemFiles = []string{"pem", "crt", "cer", "key"}

func newVersionCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "version",
Short: "Print the version of ocppctl",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, _ []string) {
out := cmd.OutOrStdout()
ver := strings.TrimSpace(buildMeta.version)
if ver == "" {
ver = "(unknown)"
}
_, _ = fmt.Fprintf(out, "ocppctl version: %s\n", ver)
if s := strings.TrimSpace(buildMeta.tag); s != "" {
_, _ = fmt.Fprintf(out, "tag: %s\n", s)
}
if s := strings.TrimSpace(buildMeta.commit); s != "" {
_, _ = fmt.Fprintf(out, "commit: %s\n", s)
}
if s := strings.TrimSpace(buildMeta.buildDate); s != "" {
_, _ = fmt.Fprintf(out, "build date: %s\n", s)
Long: `Print the version of ocppctl on one line: the version, the git tag and commit it
was built from, the build date, the Go version and the platform. --json (or
OCPPCTL_FORMAT=json) prints the same as one JSON object.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
info := buildinfo.Resolve("ocppctl", buildMeta.version, buildMeta.tag, buildMeta.commit, buildMeta.buildDate)
line := info.String()
asJSON, _ := cmd.Flags().GetBool("json")
if asJSON || strings.EqualFold(strings.TrimSpace(config.Getenv("OCPPCTL_FORMAT")), "json") {
line = info.JSON()
}
_, _ = fmt.Fprintf(out, "go version: %s\n", runtime.Version())
_, _ = fmt.Fprintf(out, "platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
_, err := fmt.Fprintln(cmd.OutOrStdout(), line)
return err
},
}
cmd.Flags().Bool("json", false, "Print the version as one JSON object")
return cmd
}

// Execute runs the root command and returns the process exit code (main should call os.Exit).
Expand Down
4 changes: 3 additions & 1 deletion cmd/server/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/otfabric/ocppctl/internal/csms"
"github.com/otfabric/ocppctl/internal/errs"
"github.com/otfabric/ocppctl/internal/format"
"github.com/otfabric/ocppctl/internal/ocppx"
"github.com/otfabric/ocppctl/internal/runner"
"github.com/otfabric/ocppctl/internal/types"
)
Expand Down Expand Up @@ -177,7 +178,8 @@ response that fails validation.`,
OCPPCTL_CONTROL_SOCKET=/srv/csms/ocppctl.sock ocppctl server call TriggerMessage --station CS001 --yes \
--payload '{"requestedMessage":"StatusNotification"}' --format json
`,
Args: cobra.ExactArgs(1),
Args: cobra.ExactArgs(1),
ValidArgsFunction: cli.CompleteActions(func(a ocppx.Action) bool { return a.Kind == "call" && a.From != ocppx.FromStation }),
}
cfg := csms.NewCallConfig()
runner.RegisterCommandCfg(cmd, &cfg, controlCompletion, func(c *cobra.Command) { _ = c.MarkFlagFilename("payload", "json") })
Expand Down
66 changes: 66 additions & 0 deletions cmd/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: MIT

package cmd

import (
"encoding/json"
"runtime"
"strings"
"testing"

"github.com/otfabric/ocppctl/internal/buildinfo"
)

// libText and libJSON are how the OT Fabric libraries of this binary appear in the version
// line and in its JSON; their formatting is tested in internal/buildinfo.
var libText, libJSON = func() (string, string) {
libs := buildinfo.Resolve("", "", "", "", "").Libraries
var text string
for _, l := range libs {
text += l.Name + " " + l.Version + ", "
}
js, _ := json.Marshal(libs)
return text, string(js)
}()

const sha = "1802d0f9afe8642d1e278120b76fcdfac967cb9e"

// version prints one line: with the tag of a release build, without it otherwise, and the
// development fallback when nothing was injected.
func TestVersionLine(t *testing.T) {
t.Cleanup(func() { SetBuildMeta("dev", "", "", "") })
platform := runtime.Version() + ", " + runtime.GOOS + "/" + runtime.GOARCH
SetBuildMeta("1.2.3", "v1.2.3", sha, "2026-01-31T12:00:00Z")
if r := run(t, "version").want(t, 0); r.stdout != "ocppctl 1.2.3 (tag v1.2.3, commit "+sha+", built 2026-01-31T12:00:00Z, "+libText+platform+")\n" {
t.Errorf("release build: %q", r.stdout)
}
SetBuildMeta("v1.2.3", " ", sha, "2026-01-31T12:00:00Z")
if r := run(t, "version").want(t, 0); r.stdout != "ocppctl 1.2.3 (commit "+sha+", built 2026-01-31T12:00:00Z, "+libText+platform+")\n" {
t.Errorf("without a tag: %q", r.stdout)
}
SetBuildMeta("dev", "", "", "")
if r := run(t, "version").want(t, 0); !strings.HasPrefix(r.stdout, "ocppctl dev (commit ") || !strings.HasSuffix(r.stdout, ", built unknown, "+libText+platform+")\n") {
t.Errorf("development build: %q", r.stdout)
}
run(t, "version", "extra").want(t, 2)
}

// --json and OCPPCTL_FORMAT=json print the same JSON line; other formats the text line.
func TestVersionJSON(t *testing.T) {
SetBuildMeta("1.2.3", "v1.2.3", sha, "2026-01-31T12:00:00Z")
t.Cleanup(func() { SetBuildMeta("dev", "", "", "") })
want := `{"name":"ocppctl","version":"1.2.3","tag":"v1.2.3","commit":"` + sha + `","built":"2026-01-31T12:00:00Z","libraries":` + libJSON + `,"go":"` +
runtime.Version() + `","platform":"` + runtime.GOOS + "/" + runtime.GOARCH + `"}` + "\n"
if r := run(t, "version", "--json").want(t, 0); r.stdout != want {
t.Errorf("version --json:\n got %s\nwant %s", r.stdout, want)
}
text := run(t, "version").want(t, 0).stdout
t.Setenv("OCPPCTL_FORMAT", "json")
if r := run(t, "version").want(t, 0); r.stdout != want {
t.Errorf("OCPPCTL_FORMAT=json:\n got %s\nwant %s", r.stdout, want)
}
t.Setenv("OCPPCTL_FORMAT", "table")
if r := run(t, "version").want(t, 0); r.stdout != text {
t.Errorf("OCPPCTL_FORMAT=table: %q", r.stdout)
}
}
Loading
Loading