Skip to content

Latest commit

 

History

History
234 lines (187 loc) · 13.5 KB

File metadata and controls

234 lines (187 loc) · 13.5 KB

AGENTS.md — meshStack CLI

You are an expert Go engineer working on the meshStack CLI: the `meshstack` binary, and the Go client for the meshStack API that the [meshStack Terraform provider](https://github.com/meshcloud/terraform-provider-meshstack) imports as a library. This file is the always-on source of truth for both AI agents and humans.

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.

**This file is loaded into every session, so keep it short.** A rule earns a place here only if it has no closer home. Everything else belongs next to what it governs:
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.

Naming

  • meshstack — the binary, so every invocation reads meshstack 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.

Package layout

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.

To add a command, put it in `cmd/`, where **the package name is the subcommand and the file name is the leaf command**: `cmd/auth/login.go` holds `meshstack auth login`. The package exports a `New` function returning its `*cobra.Command`, and the parent's constructor wires it in with `AddCommand`.

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 login for meshstack auth login — is registered twice by calling its constructor twice. Aliases cannot do this.
  • A constructor keeps its own flag targets in locals captured by the closure. The persistent flags in cmd/internal are the exception: SettingSources reads their values back, so they are package-level vars.
  • A parent command sets RunE as well as Args.

Dependency policy

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.

**This repository is the client's only home.** `client/` moved here from [terraform-provider-meshstack](https://github.com/meshcloud/terraform-provider-meshstack) as a one-time `git subtree` import, and the provider deleted its copy and requires this module at a released version instead. Change the client here; the provider picks the change up when it bumps its `meshstack-cli` requirement, so a break surfaces there, later, and not in this repository's CI. There is nothing to pull or push.

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 own

client/ 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.

Always-on rules

  • 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-code skill of ../meshfed-release.)
  • Lint and format only via task lint, and never run gofmt or go vet separately — a differently built gofmt enforces different formatting. A PostToolUse hook in .claude/settings.json formats every .go file 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 a client/ 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 whose t.Run steps 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(), not isTerminal(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, as internal.KindCommand is.
  • Conventional Commits for messages (feat:, fix:, docs:, chore:). While the CLI is at 0.x, a breaking change, of client/ included, takes no !: every minor release may break. goreleaser writes the release notes from these subjects, as changelog: in .goreleaser.yml sets out, so the subject is what a user reads there. Give every change of client/ the client scope, as in refactor(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-me skill of ../meshfed-release.)

Commands

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.

Acceptance tests

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 TestAcc

Authentication

Every 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.

Releasing

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.

The version reaches the binary through an ldflag on `github.com/meshcloud/meshstack-cli/cmd/internal.Version`, set in `.goreleaser.yml`, in the `Dockerfile` and in `flake.nix`. **They must agree**, and all three say so at the ldflag. The linker ignores an `-X` whose path does not resolve and warns about nothing, so a stale path is silent.

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.