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
128 changes: 128 additions & 0 deletions .claude/skills/development/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
---
name: development
description: How the meshStack CLI's Go code is laid out and built - where a command, its logic and an HTTP call go, and what the client and settings must keep. Use when writing, changing or reviewing Go code here - a new command or package, a change to client/, pkg/ or internal/http, an HTTP request, a new setting or dependency, or a depguard or forbidigo finding.
---

# Developing the meshStack CLI

For Go idioms newer than your training, follow the `use-modern-go` skill of the
`modern-go-guidelines` plugin, which `.claude/settings.json` enables. `go.mod` pins the Go version
it applies to.

## 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, nested as the tree is: `cmd/buildingblock/tfstate/` holds `meshstack buildingblock tfstate`. |
| `cmd/internal/` | What several commands share as a front end: the global and shared flags, the client and session they resolve, output, prompts, 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.

<rules id="front-end-adapters">
**A command is a front end over an `internal/` package.** The command owns its flags, arguments,
prompts and output, and the hints that name a flag, such as "run again with --force". What it does
is the `internal/` package's, such as which workspace holds a state, or whether a run of the
building block blocks a write. `cmd/buildingblock/tfstate` over `internal/tfstate` is the pattern. So
the logic stays testable without cobra, and a second front end does not copy it.

`cmd/internal` is no place for that logic either: it holds only the front-end parts that several
commands share.
</rules>

<rules id="command-tree">
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: it is the binary's `package main`, with `main()` and the root
command. For a command over a meshObject kind, the `meshobject-command` skill has the rest.

- 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`**.
</rules>

## Dependencies

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

<rules id="client-package">
**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 requires this module at a released version. A
change here reaches the provider when it bumps its `meshstack-cli` requirement, so a break surfaces
there, later, and not in this repository's CI.

**Read the API docs before extending `client/`**:
[meshstack-openapi-docs.json](https://docs.meshcloud.io/api/meshstack-openapi-docs.json), or the
[dev variant](https://docs.dev.meshcloud.io/api/meshstack-openapi-docs.json) 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 import merge re-roots the files under
`client/`:

```shell
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`, because `internal/oidc` and `pkg/auth` need them and Go's internal rule closes
`client/internal` to both. Its names carry no `Http` prefix: `http.Client`, `http.Error`.

**Every request names the front end and version that sent it.** `http.NewClient` takes an
`http.UserAgent`, which `auth.ResolveSessionOptions` embeds: the front end sets its `GitHubRepo` and
`Version` once, the CLI in `cmd/internal.ResolveClientOptions`, and the release check reads the same
two. So code below `cmd/` takes its client from its caller, and a command that needs one for
anything but a meshStack client calls `internal.ResolveClientOptions().HttpClient()`. `forbidigo`
keeps `http.UserAgent` to those options, and the transport to `internal/http`.

**`net/http` is always imported as `gohttp`**, which `importas` settles. That leaves the plain name
to `internal/http`, and `net/http` to the status and method constants and to the loopback server.
The `forbidigo` rule matches on the type rather than 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.
</rules>

## Settings

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.
4 changes: 2 additions & 2 deletions .claude/skills/meshobject-command/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ description: Use when adding or changing a `meshstack` command for a meshObject
# A command for a meshObject kind

The tree's structure, one package per subcommand and one file per leaf, is the `command-tree`
rule of `CLAUDE.md`. Before you extend `client/`, follow the API-docs rule in its `client-package`
section.
rule of the `development` skill. Before you extend `client/`, follow the API-docs rule in its
`client-package` section.

## Name

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ unit.txt

.vscode/
.idea/
/scratch/

# Per-developer Claude Code settings; .claude/settings.json is shared and committed
.claude/settings.local.json
Expand Down
22 changes: 16 additions & 6 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ linters:
linters:
- forbidigo
text: one HTTP client
# The options of a session carry the user agent the front end sets.
- path: (internal/http/|internal/auth/session\.go|internal/testutil/|_test\.go)
linters:
- forbidigo
text: user agent of the front end
- path: internal/tfstate/apiproxy/
linters:
- forbidigo
text: one HTTP client
# The fake clock is a test's; production code waits on the real one.
- path-except: _test\.go
linters:
Expand Down Expand Up @@ -124,6 +133,10 @@ linters:
- pattern: ^http\.(Client|DefaultClient|Transport|DefaultTransport|Get|Head|Post|PostForm)$
pkg: ^net/http$
msg: the process has one HTTP client, built in internal/http; take it from there rather than making another
# A user agent made anywhere else drifts from the one the front end names itself by.
- pattern: ^http\.UserAgent$
pkg: ^github\.com/meshcloud/meshstack-cli/internal/http$
msg: a client carries the user agent of the front end; take the client from your caller, or from auth.ResolveSessionOptions.HttpClient
# A test that sleeps for real is slow, and flaky once the machine is slower than the
# sleep assumed. synctest.Sleep panics outside a bubble, so it cannot be used the same way.
- pattern: ^time\.Sleep$
Expand Down Expand Up @@ -214,7 +227,7 @@ linters:
- "!**/cmd/meshstack/*.go"
- "!**/cmd/api/*.go"
- "!**/cmd/auth/*.go"
- "!**/cmd/buildingblocktfstate/*.go"
- "!**/cmd/buildingblock/tfstate/*.go"
- "!**/cmd/profile/*.go"
- "!**/cmd/internal/*.go"
- "!**/cmd/internal/**/*.go"
Expand Down Expand Up @@ -292,9 +305,9 @@ linters:
# api-docs renders the flags of a failed request back into a command line
- github.com/spf13/pflag

cmd-buildingblocktfstate:
cmd-buildingblock-tfstate:
files:
- "**/cmd/buildingblocktfstate/*.go"
- "**/cmd/buildingblock/tfstate/*.go"
- "!$test"
list-mode: strict
deny:
Expand All @@ -305,9 +318,6 @@ linters:
allow:
- $gostd
- log/slog
# the deny on encoding/json matches its subpackages too; a building block names the
# workspace its state is stored under
- encoding/json/v2
# the proxy that serves the state to tofu. It needs the request options of
# client.Raw.DoRequest, and pkg/ does not expose them, because they are for the CLI alone.
- github.com/meshcloud/meshstack-cli/internal/tfstate
Expand Down
107 changes: 4 additions & 103 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,98 +43,11 @@ container image are all `meshstack-cli` — while the binary inside them is `mes
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
## Development

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

<rules id="command-tree">
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`**.
</rules>

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

<rules id="client-package">
**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](https://docs.meshcloud.io/api/meshstack-openapi-docs.json), or the
[dev variant](https://docs.dev.meshcloud.io/api/meshstack-openapi-docs.json) 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/`:

```shell
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.
</rules>
**Load the `development` skill before you write or review Go code here.** It holds the package
layout, the split of a command into a front end in `cmd/` and its logic in `internal/`, the command
tree, the dependency policy, and what `client/`, `internal/http` and the settings must keep.

## Always-on rules

Expand Down Expand Up @@ -202,18 +115,6 @@ 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
Expand Down
3 changes: 1 addition & 2 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@ type Client struct {
Endpoint xurl.URL
}

func New(ctx context.Context, endpoint xurl.URL, userAgent string, auth Authorization) Client {
client := http.NewClient(userAgent)
func New(ctx context.Context, endpoint xurl.URL, client http.Client, auth Authorization) Client {
authorizedClient := internal.HttpClient{
AuthorizedClient: client.WithAuthorization(auth),
EndpointUrl: endpoint,
Expand Down
2 changes: 1 addition & 1 deletion client/raw_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (

func newTestHttpClient(server *fakemeshstack.Server) internal.HttpClient {
return internal.HttpClient{
AuthorizedClient: http.NewClient("test-agent").WithAuthorization(http.BearerToken(fakemeshstack.Token)),
AuthorizedClient: http.NewClient(fakemeshstack.UserAgent).WithAuthorization(http.BearerToken(fakemeshstack.Token)),
EndpointUrl: xurl.MustParsef("%s", server.URL),
}
}
Expand Down
Loading
Loading