diff --git a/Makefile b/Makefile index 511c626..5d25b44 100644 --- a/Makefile +++ b/Makefile @@ -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)'" diff --git a/README.md b/README.md index 21cedce..2cda653 100644 --- a/README.md +++ b/README.md @@ -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, `` 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 @@ -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) ``` --- @@ -259,7 +261,7 @@ ocppctl actions ocppctl validate 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. diff --git a/RELEASE.md b/RELEASE.md index 98811f0..c468fc9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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 , built , 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 `` 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 diff --git a/cmd/client/call.go b/cmd/client/call.go index 0b240ad..cc6747f 100644 --- a/cmd/client/call.go +++ b/cmd/client/call.go @@ -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 { @@ -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) @@ -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"} diff --git a/cmd/completion_coverage_test.go b/cmd/completion_coverage_test.go new file mode 100644 index 0000000..0bc069a --- /dev/null +++ b/cmd/completion_coverage_test.go @@ -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) + } +} diff --git a/cmd/offline.go b/cmd/offline.go index fc9d1c9..a22b113 100644 --- a/cmd/offline.go +++ b/cmd/offline.go @@ -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) @@ -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") diff --git a/cmd/offline_test.go b/cmd/offline_test.go index f120596..ea1fea6 100644 --- a/cmd/offline_test.go +++ b/cmd/offline_test.go @@ -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) @@ -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) + } + } + } } diff --git a/cmd/root.go b/cmd/root.go index ed8a095..c46c6d8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,7 +9,6 @@ import ( "io" "os" "os/signal" - "runtime" "strings" "syscall" @@ -17,6 +16,9 @@ import ( "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" ) @@ -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). diff --git a/cmd/server/root.go b/cmd/server/root.go index ce518a2..f928143 100644 --- a/cmd/server/root.go +++ b/cmd/server/root.go @@ -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" ) @@ -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") }) diff --git a/cmd/version_test.go b/cmd/version_test.go new file mode 100644 index 0000000..7338b88 --- /dev/null +++ b/cmd/version_test.go @@ -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) + } +} diff --git a/docs/json-output.md b/docs/json-output.md index 21c420c..2331ba6 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -132,6 +132,10 @@ An invalid payload is written as a result and exits 3. | `hash_algorithm`, `issuer_name_hash`, `issuer_key_hash`, `serial_number` | CertificateHashData, hex | | `ocpp` | the same as OCPP messages carry it: `{"hashAlgorithm", "issuerNameHash", "issuerKeyHash", "serialNumber"}`, ready for a `certificateHashData` field | +### `version --json` + +One line, keys in this order, every key always present: `name`, `version` (no leading `v`; `"dev"` for a build without one), `tag` (`""` for a build that is not a release), `commit` (`"unknown"` when not recorded; `-dirty` for a modified tree), `built` (RFC 3339 UTC, or `"unknown"`), `libraries` (the OT Fabric libraries the binary is built on, sorted: `name`, `version`; `[]` when none), `go`, `platform` (`os/arch`). `OCPPCTL_FORMAT=json` gives the same. + ### `server stations` `target` (the control socket) and `stations`: `identity`, `version`, `remote`, `connected_at`, `tls`, `compression`, `session_id`. diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 413c789..20f3b62 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -46,7 +46,7 @@ func TestVersionPrintsTheBuiltVersion(t *testing.T) { if want == "" { t.Fatal("OCPPCTL_E2E_VERSION is not set; run make e2e") } - if !strings.Contains(output, "ocppctl") || !strings.Contains(output, want) { + if !strings.HasPrefix(output, "ocppctl "+want+" (") { t.Fatalf("output does not name ocppctl %s:\n%s", want, output) } } diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..a496c37 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT + +// Package buildinfo resolves the build metadata that `version` prints: what the release +// injected at link time (-X main.version=… in the Makefile and the release workflow), +// else what the Go toolchain recorded in the binary. +package buildinfo + +import ( + "encoding/json" + "regexp" + "runtime" + "runtime/debug" + "sort" + "strings" +) + +const ( + // Dev is the version of a build that carries no version: go run, or go build in a clone. + Dev = "dev" + // Unknown stands for a commit or a build date that nothing recorded. + Unknown = "unknown" +) + +// Info is what `version` reports. The field order is the key order of the JSON output. +type Info struct { + Name string `json:"name"` + Version string `json:"version"` + // Tag is the git tag of a release build, "" for any other build. + Tag string `json:"tag"` + Commit string `json:"commit"` + Built string `json:"built"` + // Libraries are the OT Fabric libraries (github.com/otfabric/go-*) the binary is built + // on, sorted by name; [] when there are none. + Libraries []Library `json:"libraries"` + Go string `json:"go"` + Platform string `json:"platform"` +} + +// Library is one OT Fabric library the binary is built on. +type Library struct { + Name string `json:"name"` // last element of the module path: go-bacnet + Version string `json:"version"` // without the leading v +} + +// readBuildInfo is debug.ReadBuildInfo; a test replaces it. +var readBuildInfo = debug.ReadBuildInfo + +// Resolve returns the build metadata of the binary named name from the link-time values +// (empty, or the "dev" default of main, when not injected). A missing version is the +// module version the toolchain recorded for a release (go install …@vX.Y.Z), else "dev" +// (go run, and go build in a clone, which records a pseudo-version); a missing +// commit is the recorded VCS revision, with "-dirty" for a modified tree, else "unknown". +func Resolve(name, version, tag, commit, buildDate string) Info { + info := Info{ + Name: name, + Version: trimV(strings.TrimSpace(version)), + Tag: strings.TrimSpace(tag), + Commit: strings.TrimSpace(commit), + Built: strings.TrimSpace(buildDate), + Libraries: []Library{}, + Go: runtime.Version(), + Platform: runtime.GOOS + "/" + runtime.GOARCH, + } + bi, ok := readBuildInfo() + if !ok { + bi = nil + } + if info.Version == "" || info.Version == Dev { + info.Version = Dev + if bi != nil && isRelease(bi.Main.Version) { + info.Version = trimV(bi.Main.Version) + } + } + if info.Commit == "" && bi != nil { + var dirty bool + for _, s := range bi.Settings { + switch s.Key { + case "vcs.revision": + info.Commit = s.Value + case "vcs.modified": + dirty = s.Value == "true" + } + } + if info.Commit != "" && dirty { + info.Commit += "-dirty" + } + } + if bi != nil { + info.Libraries = libraries(bi) + } + if info.Commit == "" { + info.Commit = Unknown + } + if info.Built == "" { + info.Built = Unknown + } + return info +} + +// libraries returns the github.com/otfabric/go-* modules among the dependencies of bi +// (their replacements where replaced), without the main module, sorted by name. +func libraries(bi *debug.BuildInfo) []Library { + out := []Library{} + for _, m := range bi.Deps { + if m.Replace != nil { + m = m.Replace + } + if !strings.HasPrefix(m.Path, "github.com/otfabric/go-") || m.Path == bi.Main.Path { + continue + } + out = append(out, Library{Name: m.Path[strings.LastIndex(m.Path, "/")+1:], Version: trimV(m.Version)}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// notRelease matches the module versions the toolchain makes up for a build in a clone: a +// pseudo-version (v0.4.1-0.20261107212138-864e49686ecb) or a modified tree (+dirty). +var notRelease = regexp.MustCompile(`\d{14}-[0-9a-f]{12}|\+dirty$`) + +// isRelease reports whether v, a module version the toolchain recorded, is the version of +// a release: what go install …@v1.2.3 records, not (devel) or a pseudo-version. +func isRelease(v string) bool { + return v != "" && v != "(devel)" && !notRelease.MatchString(v) +} + +// trimV drops the leading v of a version: v3.5.1 is 3.5.1. +func trimV(v string) string { + if len(v) > 1 && v[0] == 'v' && v[1] >= '0' && v[1] <= '9' { + return v[1:] + } + return v +} + +// String is the one line of `version`: +// +// name 3.5.1 (tag v3.5.1, commit , built , go-lib 1.2.0, go1.26.0, linux/amd64) +// +// without the tag part when there is no tag, and without 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 one line of `version --json`, without a trailing newline. +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..62dd43b --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MIT + +package buildinfo + +import ( + "encoding/json" + "runtime" + "runtime/debug" + "strings" + "testing" +) + +const sha = "1802d0f9afe8642d1e278120b76fcdfac967cb9e" + +var platform = runtime.Version() + ", " + runtime.GOOS + "/" + runtime.GOARCH + +// withBuildInfo makes Resolve see bi as what the toolchain recorded (nil: nothing). +func withBuildInfo(t *testing.T, bi *debug.BuildInfo) { + t.Helper() + old := readBuildInfo + t.Cleanup(func() { readBuildInfo = old }) + readBuildInfo = func() (*debug.BuildInfo, bool) { return bi, bi != nil } +} + +func TestReleaseBuild(t *testing.T) { + withBuildInfo(t, &debug.BuildInfo{Main: debug.Module{Version: "v0.0.1"}}) + i := Resolve("tool", "3.5.1", "v3.5.1", sha, "2026-11-05T15:41:49Z") + if got, want := i.String(), "tool 3.5.1 (tag v3.5.1, commit "+sha+", built 2026-11-05T15:41:49Z, "+platform+")"; got != want { + t.Errorf("text:\n got %s\nwant %s", got, want) + } + want := `{"name":"tool","version":"3.5.1","tag":"v3.5.1","commit":"` + sha + `","built":"2026-11-05T15:41:49Z","libraries":[],"go":"` + + runtime.Version() + `","platform":"` + runtime.GOOS + "/" + runtime.GOARCH + `"}` + if got := i.JSON(); got != want { + t.Errorf("json:\n got %s\nwant %s", got, want) + } +} + +func TestWithoutTag(t *testing.T) { + withBuildInfo(t, nil) + i := Resolve("tool", "v3.5.1", " ", sha, "2026-11-05T15:41:49Z") + if got, want := i.String(), "tool 3.5.1 (commit "+sha+", built 2026-11-05T15:41:49Z, "+platform+")"; got != want { + t.Errorf("text:\n got %s\nwant %s", got, want) + } + var m map[string]string + if err := json.Unmarshal([]byte(strings.Replace(i.JSON(), `"libraries":[],`, "", 1)), &m); err != nil || m["tag"] != "" || len(m) != 7 { + t.Errorf("json %s: %v %v", i.JSON(), m, err) + } +} + +// Nothing injected: go run, go build, go install. +func TestDevFallback(t *testing.T) { + rev := []debug.BuildSetting{{Key: "vcs.revision", Value: sha}, {Key: "vcs.modified", Value: "true"}} + for _, c := range []struct { + name string + version string + bi *debug.BuildInfo + want string + }{ + {"nothing recorded", "dev", nil, "tool dev (commit unknown, built unknown, " + platform + ")"}, + {"empty injection", "", &debug.BuildInfo{}, "tool dev (commit unknown, built unknown, " + platform + ")"}, + {"go build in a modified clone", "dev", &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}, Settings: rev}, + "tool dev (commit " + sha + "-dirty, built unknown, " + platform + ")"}, + {"clean clone", "dev", &debug.BuildInfo{Settings: rev[:1]}, "tool dev (commit " + sha + ", built unknown, " + platform + ")"}, + {"go install of a version", "dev", &debug.BuildInfo{Main: debug.Module{Version: "v3.5.1"}}, + "tool 3.5.1 (commit unknown, built unknown, " + platform + ")"}, + {"go build in a clone after a tag", "dev", &debug.BuildInfo{Main: debug.Module{Version: "v0.4.1-0.20261107212138-864e49686ecb+dirty"}, Settings: rev}, + "tool dev (commit " + sha + "-dirty, built unknown, " + platform + ")"}, + {"go build at a tag, modified", "dev", &debug.BuildInfo{Main: debug.Module{Version: "v0.4.0+dirty"}}, + "tool dev (commit unknown, built unknown, " + platform + ")"}, + } { + t.Run(c.name, func(t *testing.T) { + withBuildInfo(t, c.bi) + i := Resolve("tool", c.version, "", "", "") + if got := i.String(); got != c.want { + t.Errorf("\n got %s\nwant %s", got, c.want) + } + if j := i.JSON(); !strings.Contains(j, `"tag":""`) || strings.Count(j, `""`) != 1 { + t.Errorf("json has an empty field other than tag: %s", j) + } + }) + } +} + +// An injected commit wins over the recorded revision. +func TestInjectedCommitWins(t *testing.T) { + withBuildInfo(t, &debug.BuildInfo{Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: "other"}}}) + if i := Resolve("tool", "1.0.0", "", sha, ""); i.Commit != sha { + t.Errorf("commit %q", i.Commit) + } +} + +func TestJSONKeyOrder(t *testing.T) { + withBuildInfo(t, nil) + j := Resolve("tool", "1.0.0", "v1.0.0", sha, "2026-01-01T00:00:00Z").JSON() + last := -1 + for _, k := range []string{"name", "version", "tag", "commit", "built", "libraries", "go", "platform"} { + p := strings.Index(j, `"`+k+`":`) + if p <= last { + t.Fatalf("key %s out of order in %s", k, j) + } + last = p + } + if strings.Contains(j, "\n") { + t.Errorf("not one compact line: %q", j) + } +} + +// The libraries, from an injected list: none, one and two. +func TestLibrariesFormatting(t *testing.T) { + base := Info{Name: "tool", Version: "1.0.0", Commit: sha, Built: "2026-01-01T00:00:00Z", Go: "go1.26.0", Platform: "linux/amd64"} + for _, c := range []struct { + libs []Library + text, js string + }{ + {nil, "built 2026-01-01T00:00:00Z, go1.26.0", `"libraries":[]`}, + {[]Library{{"go-a", "0.8.0"}}, "built 2026-01-01T00:00:00Z, go-a 0.8.0, go1.26.0", `"libraries":[{"name":"go-a","version":"0.8.0"}]`}, + {[]Library{{"go-a", "0.8.0"}, {"go-b", "0.5.1-0.20261101120000-0123456789ab"}}, + "built 2026-01-01T00:00:00Z, go-a 0.8.0, go-b 0.5.1-0.20261101120000-0123456789ab, go1.26.0", + `"libraries":[{"name":"go-a","version":"0.8.0"},{"name":"go-b","version":"0.5.1-0.20261101120000-0123456789ab"}]`}, + } { + i := base + i.Libraries = c.libs + if !strings.Contains(i.String(), c.text+", linux/amd64)") { + t.Errorf("text %s lacks %s", i.String(), c.text) + } + if !strings.Contains(i.JSON(), `"built":"2026-01-01T00:00:00Z",`+c.js+`,"go":"go1.26.0"`) { + t.Errorf("json %s lacks %s", i.JSON(), c.js) + } + } +} + +// Resolve takes the github.com/otfabric/go-* dependencies, replaced where replaced, sorted, +// without the main module and other modules. +func TestLibrariesFromBuildInfo(t *testing.T) { + withBuildInfo(t, &debug.BuildInfo{ + Main: debug.Module{Path: "github.com/otfabric/go-self"}, + Deps: []*debug.Module{ + {Path: "github.com/otfabric/go-zeta", Version: "v1.2.0"}, + {Path: "github.com/spf13/cobra", Version: "v1.10.2"}, + {Path: "github.com/otfabric/go-self", Version: "v9.0.0"}, + {Path: "github.com/otfabric/docsync", Version: "v0.3.1"}, + {Path: "github.com/otfabric/go-alpha", Version: "v0.1.0", Replace: &debug.Module{Path: "github.com/otfabric/go-alpha", Version: "v0.1.1"}}, + }, + }) + got := Resolve("tool", "1.0.0", "", sha, "").Libraries + if len(got) != 2 || got[0] != (Library{"go-alpha", "0.1.1"}) || got[1] != (Library{"go-zeta", "1.2.0"}) { + t.Errorf("libraries: %+v", got) + } +} diff --git a/internal/cli/actions.go b/internal/cli/actions.go new file mode 100644 index 0000000..6ee8fc7 --- /dev/null +++ b/internal/cli/actions.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT + +package cli + +import ( + "slices" + "strings" + + "github.com/otfabric/go-ocpp" + "github.com/otfabric/go-ocpp/ocppj" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/otfabric/ocppctl/internal/ocppx" +) + +// versionsOf returns the OCPP versions --ocpp-version of cmd names, or every version when +// it is not given (or names none that parses). +func versionsOf(cmd *cobra.Command) []ocpp.Version { + var list []string + if f := cmd.Flags().Lookup("ocpp-version"); f != nil && f.Changed { + if s, ok := f.Value.(pflag.SliceValue); ok { + list = s.GetSlice() + } else { + list = []string{f.Value.String()} + } + } + if v, err := ocppx.ParseVersions(list); err == nil { + return v + } + v, _ := ocppx.ParseVersions(ocppx.DefaultVersions) + return v +} + +// CompleteActions completes the argument with the actions of the versions +// --ocpp-version names (all versions without it) that keep accepts. +func CompleteActions(keep func(ocppx.Action) bool) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return actionNames(cmd, keep, toComplete), cobra.ShellCompDirectiveNoFileComp + } +} + +// RegisterActionFlagCompletion completes the flag name of cmd with the actions, as +// [CompleteActions] does for an argument. +func RegisterActionFlagCompletion(cmd *cobra.Command, name string, keep func(ocppx.Action) bool) { + _ = cmd.RegisterFlagCompletionFunc(name, func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return actionNames(cmd, keep, toComplete), cobra.ShellCompDirectiveNoFileComp + }) +} + +func actionNames(cmd *cobra.Command, keep func(ocppx.Action) bool, toComplete string) []string { + var out []string + for _, v := range versionsOf(cmd) { + for _, a := range ocppx.Actions(v) { + if (keep == nil || keep(a)) && strings.HasPrefix(strings.ToLower(a.Name), strings.ToLower(toComplete)) { + out = append(out, a.Name) + } + } + } + slices.Sort(out) + return slices.Compact(out) +} + +// RegisterErrorCodeCompletion completes the flag name of cmd with the CALLERROR codes of +// the versions --ocpp-version names. +func RegisterErrorCodeCompletion(cmd *cobra.Command, name string) { + _ = cmd.RegisterFlagCompletionFunc(name, func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + var out []string + for _, v := range versionsOf(cmd) { + for _, c := range ocppj.ErrorCodes(v) { + if strings.HasPrefix(strings.ToLower(string(c)), strings.ToLower(toComplete)) { + out = append(out, string(c)) + } + } + } + slices.Sort(out) + return slices.Compact(out), cobra.ShellCompDirectiveNoFileComp + }) +} diff --git a/internal/cli/actions_test.go b/internal/cli/actions_test.go new file mode 100644 index 0000000..ed7ee98 --- /dev/null +++ b/internal/cli/actions_test.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT + +package cli + +import ( + "slices" + "testing" + + "github.com/spf13/cobra" + + "github.com/otfabric/ocppctl/internal/ocppx" +) + +func TestActionAndErrorCodeCompletion(t *testing.T) { + newCmd := func(slice bool) *cobra.Command { + cmd := &cobra.Command{Use: "x"} + if slice { + cmd.Flags().StringSlice("ocpp-version", nil, "") + } else { + cmd.Flags().String("ocpp-version", "2.0.1", "") + } + cmd.Flags().String("action", "", "") + cmd.Flags().String("error-code", "", "") + RegisterActionFlagCompletion(cmd, "action", func(a ocppx.Action) bool { return a.From != ocppx.FromCSMS }) + RegisterErrorCodeCompletion(cmd, "error-code") + return cmd + } + + cmd := newCmd(true) + _ = cmd.Flags().Set("ocpp-version", "1.6") + got, dir := CompleteActions(nil)(cmd, nil, "boot") + if !slices.Equal(got, []string{"BootNotification"}) || dir != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("1.6 actions: %v %v", got, dir) + } + if got, _ := CompleteActions(nil)(cmd, []string{"Heartbeat"}, ""); got != nil { + t.Errorf("a second argument completes: %v", got) + } + fn, _ := cmd.GetFlagCompletionFunc("error-code") + if got, _ := fn(cmd, nil, "Format"); !slices.Equal(got, []string{"FormationViolation"}) { + t.Errorf("1.6 error codes: %v", got) + } + + // Without --ocpp-version, every version; a CSMS action is not offered to a station. + cmd = newCmd(false) + fn, _ = cmd.GetFlagCompletionFunc("action") + got, _ = fn(cmd, nil, "") + if !slices.Contains(got, "BootNotification") || !slices.Contains(got, "StartTransaction") || !slices.Contains(got, "TransactionEvent") || + slices.Contains(got, "RemoteStartTransaction") { + t.Errorf("station actions of every version: %v", got) + } + fn, _ = cmd.GetFlagCompletionFunc("error-code") + if got, _ := fn(cmd, nil, "Format"); !slices.Equal(got, []string{"FormatViolation", "FormationViolation"}) { + t.Errorf("error codes of every version: %v", got) + } + + // A version that does not parse falls back to every version. + _ = cmd.Flags().Set("ocpp-version", "9.9") + if got, _ := CompleteActions(nil)(cmd, nil, "NotifyPeriodicEventStream"); len(got) != 1 { + t.Errorf("bad version: %v", got) + } +} diff --git a/internal/cli/complete_rest.go b/internal/cli/complete_rest.go new file mode 100644 index 0000000..a114ab5 --- /dev/null +++ b/internal/cli/complete_rest.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT + +package cli + +import ( + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// CompleteRest finishes the shell completion of the command tree under root, after every +// command has registered the completion of its fixed value sets: +// +// - a flag that takes a value and has no completion of its own completes file names when +// files names it (only those with one of its extensions, if it lists any) and nothing +// otherwise: addresses, numbers and other free values are not file names; +// - a command that does not complete its arguments completes no file names. +// +// args names the commands, by their path without the root ("client script"), whose +// arguments are files, with the extensions to offer. +func CompleteRest(root *cobra.Command, files map[string][]string, args map[string][]string) { + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + c.LocalFlags().VisitAll(func(f *pflag.Flag) { + if t := f.Value.Type(); t == "bool" || t == "count" || HasCompletion(c, f) { + return // takes no value, or completes already + } + if exts, ok := files[f.Name]; ok { + _ = c.MarkFlagFilename(f.Name, exts...) + return + } + _ = c.RegisterFlagCompletionFunc(f.Name, cobra.NoFileCompletions) + }) + if c.ValidArgsFunction == nil && len(c.ValidArgs) == 0 { + if exts, ok := args[pathOf(c)]; ok { + c.ValidArgsFunction = fileArgs(exts) + } else { + c.ValidArgsFunction = cobra.NoFileCompletions + } + } + for _, sub := range c.Commands() { + walk(sub) + } + } + walk(root) +} + +// HasCompletion reports whether flag f of c completes: a completion function, or a +// file name or directory annotation. +func HasCompletion(c *cobra.Command, f *pflag.Flag) bool { + if _, ok := c.GetFlagCompletionFunc(f.Name); ok { + return true + } + _, files := f.Annotations[cobra.BashCompFilenameExt] + _, dirs := f.Annotations[cobra.BashCompSubdirsInDir] + return files || dirs +} + +func pathOf(c *cobra.Command) string { + if !c.HasParent() { + return "" + } + if p := pathOf(c.Parent()); p != "" { + return p + " " + c.Name() + } + return c.Name() +} + +func fileArgs(exts []string) cobra.CompletionFunc { + return func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(exts) == 0 { + return nil, cobra.ShellCompDirectiveDefault + } + return exts, cobra.ShellCompDirectiveFilterFileExt + } +} diff --git a/internal/cli/complete_rest_test.go b/internal/cli/complete_rest_test.go new file mode 100644 index 0000000..3f3aeab --- /dev/null +++ b/internal/cli/complete_rest_test.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT + +package cli + +import ( + "bytes" + "slices" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// runComplete runs the hidden __complete command of root and returns the candidates and the +// directive line. +func runComplete(t *testing.T, root *cobra.Command, args ...string) ([]string, string) { + t.Helper() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&bytes.Buffer{}) + root.SetArgs(append([]string{cobra.ShellCompRequestCmd}, args...)) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + return lines[:len(lines)-1], lines[len(lines)-1] +} + +func TestCompleteRest(t *testing.T) { + newTree := func() *cobra.Command { + root := &cobra.Command{Use: "tool"} + root.PersistentFlags().String("addr", "", "address") + run := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}} + run.Flags().String("config", "", "model file") + run.Flags().String("mode", "", "a or b") + run.Flags().Bool("yes", false, "") + run.Flags().String("cert", "", "PEM file") + _ = RegisterEnumFlagCompletion(run, "mode", []string{"a", "b"}) + _ = run.MarkFlagFilename("cert", "pem") + script := &cobra.Command{Use: "script", Run: func(*cobra.Command, []string) {}} + load := &cobra.Command{Use: "load", Run: func(*cobra.Command, []string) {}} + anyFile := &cobra.Command{Use: "any", Run: func(*cobra.Command, []string) {}} + root.AddCommand(run, script, load, anyFile) + CompleteRest(root, map[string][]string{"config": {"yaml", "json"}}, + map[string][]string{"script": {"yaml"}, "any": nil}) + return root + } + nofile := ":4" // ShellCompDirectiveNoFileComp + for _, c := range []struct { + args []string + want []string + directive string + }{ + {[]string{"run", "--addr", ""}, nil, nofile}, + {[]string{"run", "--mode", ""}, []string{"a", "b"}, nofile}, + {[]string{"run", "--config", ""}, []string{"yaml", "json"}, ":8"}, + {[]string{"run", "--cert", ""}, []string{"pem"}, ":8"}, + {[]string{"run", ""}, nil, nofile}, + {[]string{"script", ""}, []string{"yaml"}, ":8"}, + {[]string{"any", ""}, nil, ":0"}, + {[]string{"load", ""}, nil, nofile}, + } { + got, directive := runComplete(t, newTree(), c.args...) + if !slices.Equal(got, c.want) || directive != c.directive { + t.Errorf("%v: %q %s; want %q %s", c.args, got, directive, c.want, c.directive) + } + } +} diff --git a/main_test.go b/main_test.go index 1596a04..8081f9b 100644 --- a/main_test.go +++ b/main_test.go @@ -37,7 +37,7 @@ func TestMainPassesBuildMetaAndExitCode(t *testing.T) { defer func() { version = old }() version = "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, "ocppctl 9.8.7-test (") { t.Fatalf("version: exit %d, output %q", code, out) } if code, _ := runMain(t, "no-such-command"); code == 0 {