Skip to content

feat!: rewrite the CLI in Go, with the client generated from the OpenAPI spec - #58

Merged
achoimet merged 10 commits into
mainfrom
spike/go-cli
Sep 25, 2026
Merged

achoimet merged 10 commits into
mainfrom
spike/go-cli

Conversation

@achoimet

@achoimet achoimet commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Replaces the TypeScript CLI with a Go CLI (part of Kanbanize #14235). The goal was a rewrite users don't notice: the same commands, flags, messages, exit codes, ~/.steadybit profiles, STEADYBIT_* variables and install channels, and files written byte for byte as before. What changes is a single static binary, no Node.js runtime, and an API client generated from the platform spec.

What's in it

  • Every command of the TypeScript CLI:
    • advice validate-status
    • config show
    • config profile add|list|ls|select|remove
    • experiment run|get|apply|delete|dump, including the --template variants
    • template list|get
    • execution get|cancel|property set/add|artifact list/download
    • schedule list|get|apply|create|update|enable|disable|delete
    • service list|get|apply|delete|risk|experiment list/provide/link/unlink|variable get/set
    • service-profile list|get|apply|delete
  • New: shell completion (steadybit completion bash|zsh|fish|powershell).
  • Generated client: api/platform.gen.go is generated with oapi-codegen from openapi/platform-api.json (go generate ./api). It needed no workaround for the spec's polymorphism. Files users keep in Git (experiments, schedules, services, profiles) pass through as documents, not generated structs, so fields the spec doesn't know yet are never dropped.
  • internal/jsyaml: a port of the parts of js-yaml's dump the CLI used, plus a JSON.stringify-compatible writer. It covers quoting, folding at 80 columns, JavaScript key order and number formatting, and timestamps as Dates, so YAML and JSON output are byte-identical.
  • Transport (internal/platform), behaving as the TypeScript client did:
    • the same token-bucket pacing (STEADYBIT_RATE_LIMIT_*);
    • 429s retried for any method, transport failures only for idempotent ones;
    • a per-attempt deadline that starts once the rate limiter admits the request;
    • the token only ever sent to the configured host;
    • the problem body appended to errors;
    • redacted -v logs.
  • Commander compatibility: flags that took space-separated values (--team A B, -f a.yml b.yml) still do. The help layout is kept, with every command showing examples.

Distribution and CI

  • Build: goreleaser produces binaries for linux, darwin and windows on amd64 and arm64. Archives have version-less names, so releases/latest/download/steadybit_linux_amd64.tar.gz is a stable URL.
  • npm: npm install -g steadybit keeps working. It installs @steadybit/cli-<os>-<cpu> as an optional dependency, and a small launcher starts it. npm 12 no longer runs install scripts, so downloading on install isn't an option. The launcher passes Ctrl-C and exit codes through (130 on Ctrl-C, checked with the e2e expect scripts).
  • Docker: a multi-arch image of 18 MB instead of 249 MB, cross-compiled in buildx.
  • CI:
    • tests on Linux, macOS and Windows;
    • gofmt and a check that the generated client matches the committed spec;
    • the unchanged container e2e suite against the Go image;
    • api-compatibility, which builds against the live spec daily and before each release and warns when the generated client would change;
    • on tags, goreleaser, the npm publish and the image push.

Compatibility evidence

  • Output: 527 real experiments from dev render byte-identically to the TypeScript CLI in YAML, pretty JSON and compact JSON (JSYAML_CORPUS test). A committed fixture of 123 edge cases generated from js-yaml 5.4.1 covers quoting, folding, Unicode, numbers and key order.
  • experiment dump: for three teams (284 files: 14 experiments and 270 runs), the YAML and JSON trees are identical to the TypeScript CLI's.
  • Commands: 27 read-only commands against dev give identical stdout and exit codes. The only differences come from the platform itself returning lists in a varying order.
  • Live write tests on dev with the Go binary, on resources prefixed cli-e2e-go-, all deleted afterwards:
    • template create, update and run;
    • the whole schedule lifecycle; the written files are byte-identical to the TypeScript CLI's, including a YAML timestamp written back as .000Z;
    • profile, service, variables, provided and custom experiments, and the --delete-experiments guard;
    • run properties and cancel;
    • artifact downloads, byte-identical.
  • Tests: 197 Go tests, including command tests against a fake platform (internal/platformtest). The container e2e suite passes 20/20.

Behaviour changes, on purpose

  • Key write-back: writing a new experiment's key into a YAML file now prepends one line instead of re-dumping the file, so comments, anchors and formatting survive. JSON files are unchanged.
  • Error text: errors from -k without a value or a missing required flag use cobra's wording (required flag(s) "key" not set) instead of commander's. The exit code is still 1.

Release

This ships as v6.0.0. v5.0.0 was released from main as the last TypeScript version, so users have a fallback.

Needs setup before the first release

  • npm: the six @steadybit/cli-* packages must be allowed under the @steadybit scope, with trusted publishing configured for each, as it is for steadybit today.
  • Snyk: the npm scan becomes snyk/actions/golang.
  • Spec (platform team): duplicate operationIds (getExperimentExecutions_1/2/3) generate names like GetExperimentExecutions1, and the Slack webhook example should be checked or rotated.

A proof of concept for rewriting the CLI in Go: oapi-codegen generates the
client from openapi/platform-api.json, cobra provides the commands, and
config, experiment get/apply/run (by key, file and template) and config
profile add/list/select/remove are ported with the TypeScript CLI's
messages, flags, exit codes and profile files.

Experiment files pass through as order-preserving documents so that
output stays byte-compatible with the TypeScript CLI where possible.
…ment dump

internal/jsyaml ports the parts of js-yaml's dump the CLI used, and a
JSON.stringify-compatible writer, so files written by the Go CLI are
byte-identical to the TypeScript CLI's. Checked against a recorded corpus
of 527 platform experiments (YAML, JSON and compact JSON) and a fixture of
123 edge cases generated from js-yaml itself.

JSON is now read with a JSON decoder: yaml.v3 rejects characters JSON
allows unescaped, such as DEL.

Requests are paced by the same token bucket as before, honouring the
STEADYBIT_RATE_LIMIT_* overrides, and each attempt gets its own deadline
once admitted, so waiting for the limiter no longer counts as a timeout.

experiment dump is ported; on dev it writes trees identical to the
TypeScript CLI's. Flags that took space-separated values in commander
(--team A B, -f a b) accept them again.
…vice-profile

Every command of the TypeScript CLI now exists in the Go CLI with the same
flags, messages and output: console-table-printer's table layout, the
problem body appended to errors, ids written back into applied files,
read-only fields left out of files written by get.

Shell completion is new, from cobra, with examples like every other
command. The unchanged container e2e suite passes against the Go image.
internal/platformtest runs commands against an httptest server that
records every request. Covers the transport (429s for any method,
transport retries only for idempotent ones, the problem body in errors),
every command group, the help examples and the SPDX headers.

Fixes a crash on a placeholders file listing plain strings.
The CLI is now a single Go binary. Commands, flags, messages, exit codes,
profiles, environment variables and file formats are unchanged.

- CI tests on Linux, macOS and Windows, checks the generated client
  against the committed spec, builds against the live spec daily, and runs
  the unchanged container e2e suite against the Go image.
- Releases build the binaries with goreleaser, create the GitHub release,
  publish npm packages (one per platform, and steadybit, whose launcher
  starts the right one: npm 12 no longer runs install scripts) and push a
  multi-arch image of 18 MB instead of 249 MB.
- The spec fetcher is now Go and keeps masking the Slack webhook example.
- The js-yaml fixture generator stands alone with a pinned js-yaml.
@achoimet achoimet changed the title spike: Go CLI with a client generated from the OpenAPI spec feat!: rewrite the CLI in Go, with the client generated from the OpenAPI spec Sep 25, 2026
@achoimet
achoimet marked this pull request as ready for review September 25, 2026 12:04
It had been left out of the port. A test now pins every command of the
TypeScript CLI, so none can go missing again.
v5.0.0 is the last TypeScript release; its changelog entry is kept as it
was released.
# Conflicts:
#	CONTRIBUTING.md
#	package-lock.json
#	package.json
@achoimet
achoimet merged commit 308badc into main Sep 25, 2026
7 checks passed
@achoimet
achoimet deleted the spike/go-cli branch September 25, 2026 13:33
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant