This repository is public. Write everything here so an external contributor with no meshcloud access can follow it. Tag meshcloud-internal shortcuts clearly as internal, and never let understanding a rule depend on them.
A relative path like ../meshfed-release refers to a sibling checkout: meshcloud developers
clone the meshcloud org flat, so every repository in it is a sibling of this one. Write cross-repo
paths that way rather than bare, so they resolve as written.
| Belongs in | Rather than here |
|---|---|
.golangci.yml |
Which dependency may reach which package |
Taskfile.yml |
What a command does |
| A doc comment on the code | Why a package, type or command is built the way it is |
| The file that holds the setting | Why the setting has that value: flake.nix, go.mod, .goreleaser.yml, Dockerfile |
| A skill | A procedure long enough to need its own steps, loaded only when the work starts |
Restating a rule in two places is worse than leaving it in one: the copies drift, and neither one looks stale.
meshstack— the binary, so every invocation readsmeshstack login.- meshStack CLI — the product name, used in prose and docs.
github.com/meshcloud/meshstack-cli— the repository and Go module.
Everything published carries the repository name — the release archives, the checksum file and the
container image are all meshstack-cli — while the binary inside them is meshstack.
The binary gets its name from its directory, cmd/meshstack, which is what task build relies on.
Do not add a main.go at the repository root; that would name the binary after the module.
| Path | Holds |
|---|---|
cmd/meshstack/ |
package main: main() and the root command. The only main package. |
cmd/<subcommand>/ |
One package per subcommand of the cobra command tree. |
cmd/internal/ |
What the command tree shares: flags, the session it resolves, the version. |
cmd/internal/testacc/ |
The suite that runs the commands against a live meshStack. |
pkg/ |
Each package here wraps the internal/ package of the same name, and nothing else. |
client/ |
The meshStack API client, which the Terraform provider imports. |
internal/ |
Everything else. The depguard rules in .golangci.yml say which package may import which. |
pkg/ and client/ are the two import paths the Terraform provider's own depguard rule allows,
so a rename or a signature change in either breaks it. Go's internal rule closes internal/ to the
provider, which is what makes the indirection through pkg/ worth its cost.
cmd/meshstack is the one exception, and is not a subcommand: it is the binary's package main,
holding main() and the root command together.
These rules hold the tree together:
- Register a command explicitly in its parent's constructor, never from
init(). - A command with a top-level shortcut —
meshstack loginformeshstack auth login— is registered twice by calling its constructor twice.Aliasescannot do this. - A constructor keeps its own flag targets in locals captured by the closure. The persistent
flags in
cmd/internalare the exception:SettingSourcesreads their values back, so they are package-level vars. - A parent command sets
RunEas well asArgs.
The depguard rules in .golangci.yml are the policy, not only its enforcement: each rule
confines a dependency to a smaller area than the module, so widening a boundary is a deliberate edit
rather than a lint fix. Adding a dependency therefore means editing go.mod and .golangci.yml,
and the second edit is where you argue for it.
Read the API docs before extending client/:
meshstack-openapi-docs.json, or the
dev variant for what is merged to
develop but not released. The source is the controllers and meshObjects of ../meshfed-release
(meshcloud-internal).
The provider implements the client's interfaces. Its tests plug the mocks of its
internal/clientmock into client.Client, so a method added to a Mesh…Client interface stops
the provider compiling at its next bump. Put a method only the CLI calls on a new client.Client
field instead: an interface of its own, or a concrete type such as *client.RawClient when nothing
mocks it. client.New sets the field for every caller, while the provider's mock client fills the
struct by field name and leaves a new field unset.
Reading the pre-import history takes both paths, since the imported history carries the files at
the repository root and the import merge re-roots them under client/:
git log -- client/client.go client.go # a path-limited log from client/ alone stops at the merge
git blame client/client.go # traverses the merge on its ownclient/ does not log in. client.Authorization produces a bearer token and replaces one that
came back 401; resolving a credential, minting a token, caching it and refreshing it is pkg/auth.
Both front ends build their client through auth.ResolveClient, so the endpoint and the
authorization always agree with what was resolved. Do not add a login exchange anywhere else: a
second one gets a static token and starts returning 401 once it expires, and for a browser login it
would end the user's session.
client/ does not own HTTP. The client, the request options and the retry policy are
internal/http, one directory above, because internal/oidc and pkg/auth need them and Go's
internal rule closes client/internal to both. Its names carry no Http prefix — the package is
what says that — so it reads http.Client, http.Error, http.NewClient.
net/http is always imported as gohttp, which importas in .golangci.yml settles. That
leaves the plain name to internal/http, the package a meshStack call goes through, and net/http
to the status and method constants and to the loopback server. The forbidigo rule matches on the
type rather than on the written name, so it catches gohttp.Client and leaves http.Client alone.
Logging goes through slog's default logger, on which each front end installs its own handler:
cmd/meshstack a charmbracelet/log one, the Terraform provider a tflog bridge. A handler
installed that late constrains every log call, and internal/http/logging.go states how.
- Self-explanatory code. Move the fact into a name, a type, a check or a test: the compiler and
CI keep those true, while a comment goes stale in silence. A comment stays only when you could not
have written it by reading the code, and only when it changes what the reader does — the reason
for a decision, a rejected alternative, an external constraint with its source, a link to code
this must stay in step with. (meshcloud-internal: the
self-explanatory-codeskill of../meshfed-release.) - Lint and format only via
task lint, and never rungofmtorgo vetseparately — a differently built gofmt enforces different formatting. APostToolUsehook in.claude/settings.jsonformats every.gofile an agent writes, so it rarely reaches the gate. - Test against a live meshStack. A command's behaviour is tested by an acceptance test in
cmd/internal/testacc, and aclient/method by the provider's or the CLI's acceptance tests, so request plumbing gets no unit test against a fake meshStack. A unit test pins only delicate behaviour an acceptance test cannot reach reliably — concurrency, locking, token refresh, the precedence of settings or credentials, parsing edge cases, exit codes, guards — and never restates the code: many small unit tests cost more to keep than they catch. Write a test as few top-level scenarios whoset.Runsteps build on each other and share one setup. - Behaviour goes on its type. A function whose main parameter is a type of its own package is a
method of that type:
status.IsTerminal(), notisTerminal(status). A package-level function needs a reason: it is a constructor, it has no natural receiver (auth.Login), or it has type parameters, which a Go method cannot have. Inline a helper of a few lines that has one caller. A string or map that several functions pass around gets a named type with methods, asinternal.KindCommandis. - Conventional Commits for messages (
feat:,fix:,docs:,chore:). While the CLI is at 0.x, a breaking change, ofclient/included, takes no!: every minor release may break. goreleaser writes the release notes from these subjects, aschangelog:in.goreleaser.ymlsets out, so the subject is what a user reads there. Give every change ofclient/theclientscope, as inrefactor(client): …: it puts the change in the group the Terraform provider's maintainers read before they bump the module. - Stress-test a plan before writing code. For any non-trivial change, walk each branch of the
decision tree and settle every open question with a recommended answer first. (meshcloud-internal:
the
grill-meskill of../meshfed-release.)
Everything runs through the Taskfile, inside nix develop. task --list is the list.
The Go version is pinned in go.mod, in flake.nix and in the Dockerfile's base image. They
must agree, each says so at the pin, and all three are held in lock-step with the Terraform
provider's own pin.
This repository is a meshStack satellite: cmd/internal/testacc/ runs the commands against a
live backend. It builds no binary: it runs the cobra commands in process, and talks to them only
through stdin, stdout, stderr and the environment, as a person or a script uses the binary.
A whole meshStack only exists in the meshcloud-internal mono repo, so
CI here does not run the suite. .github/workflows/test-acceptance.yml asks that repository for the
run, and meshstack-satellite.gradle is everything the run reads from here. The other half of the
lane belongs to ../meshfed-release and changes without us, so read it there, in
satellite-suites.md of the acceptance-testing skill, rather than trusting a copy here.
To run the suite yourself, bring up the local stack of ../meshfed-release (its local-dev-stack
skill); ./gradlew satelliteEnv there writes ../.env-satellites-testacc. Then run the suite from
here with plain go test:
set -a; . ../.env-satellites-testacc; set +a
go test ./cmd/internal/testacc/... -run TestAccEvery setting the CLI reads is a MESHSTACK_-prefixed environment variable. grep -rn 'setting\.Setting\['
finds them all, each next to the code that uses it.
Each one is declared once, in the domain package it belongs to, as a setting.Setting[T] whose
EnvKey is both the variable name and the setting's identity. internal/setting resolves it from
the sources it is given, and each front end contributes exactly one source over its own flags or
block attributes. No front end assembles a sentence out of an imported name: every message that
has to mention a variable is produced in the package that owns the declaration. The Taskfile reads a
git-ignored .env for local runs.
Pushing a vN.N.N tag runs .github/workflows/release.yml: goreleaser publishes the archives and
checksums, and the image goes to GHCR only, as ghcr.io/meshcloud/meshstack-cli.
A build with no ldflag reports the version the go command stamped, a pseudo-version inside a
checkout, so a missed ldflag fails nothing. Check dist/*/meshstack --version after
task release:snapshot.
Pin every GitHub Action by commit SHA with the version in a trailing comment, as the existing workflows do.