Skip to content
Draft
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
19 changes: 10 additions & 9 deletions .claude/skills/development/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
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.
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/, client/http or pkg/, an HTTP request, a new setting or dependency, or a depguard or forbidigo finding.
---

# Developing the meshStack CLI
Expand Down Expand Up @@ -87,32 +87,33 @@ git log -- client/client.go client.go # a path-limited log from client/ alone
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`.
**`client/` does not log in.** `http.Authorization` produces a credential 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`.
**HTTP is `client/http`.** The client, the request options and the retry policy are there, because
`internal/oidc` and `pkg/auth` need them as well, and another module calls `client.New` with them.
No exported name of `client/` may use an `internal/` type, which `TestPublicApiNamesNoInternalType`
checks. 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`.
keeps `http.UserAgent` to those options, and the transport to `client/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.
to `client/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.
installed that late constrains every log call, and `client/http/logging.go` states how.
</rules>

## Settings
Expand Down
28 changes: 11 additions & 17 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,12 @@ linters:
- gosec
text: G101
path: _test\.go
- path: internal/http/
- path: client/http/
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)
- path: (client/http/|internal/auth/session\.go|internal/testutil/|_test\.go)
linters:
- forbidigo
text: user agent of the front end
Expand Down Expand Up @@ -125,17 +125,17 @@ linters:
- gosec

settings:
# The process has one HTTP client, built in internal/http.
# The process has one HTTP client, built in client/http.
forbidigo:
analyze-types: true
forbid:
# Anchored to the package, as net/http has more methods named Get, such as Header.Get.
- 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
msg: the process has one HTTP client, built in client/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$
pkg: ^github\.com/meshcloud/meshstack-cli/client/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.
Expand Down Expand Up @@ -290,9 +290,6 @@ linters:
- encoding/json/v2
- github.com/meshcloud/meshstack-cli/cmd/internal
- github.com/meshcloud/meshstack-cli/client
# the request options client.Raw.DoRequest takes, which pkg/ does not expose: they are for the
# CLI alone
- github.com/meshcloud/meshstack-cli/internal/http
# the API docs choose the version of a request
- github.com/meshcloud/meshstack-cli/internal/apidocs
# a full URL picks the stored profile whose endpoint holds it, and has to fit an endpoint
Expand All @@ -318,8 +315,7 @@ linters:
allow:
- $gostd
- log/slog
# 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.
# the proxy that serves the state to tofu
- github.com/meshcloud/meshstack-cli/internal/tfstate
- github.com/meshcloud/meshstack-cli/cmd/internal
- github.com/meshcloud/meshstack-cli/pkg/setting
Expand Down Expand Up @@ -423,7 +419,7 @@ linters:
- github.com/charmbracelet/colorprofile

# The package the Terraform provider consumes most directly, so it reaches nothing
# beyond the standard library and the three packages below.
# beyond the standard library and the packages below.
client:
files:
# Both patterns are needed: '**/dir/**/*.go' only matches files in
Expand All @@ -443,9 +439,7 @@ linters:
- encoding/json/jsontext
- log/slog
- github.com/meshcloud/meshstack-cli/client
- github.com/meshcloud/meshstack-cli/internal/http
- github.com/meshcloud/meshstack-cli/internal/json
- github.com/meshcloud/meshstack-cli/internal/version

pkg:
files:
Expand All @@ -465,9 +459,9 @@ linters:
- github.com/meshcloud/meshstack-cli/internal

# client reaches these two, so whatever they may import, client gets.
internal-http:
client-http:
files:
- "**/internal/http/*.go"
- "**/client/http/*.go"
- "**/internal/json/*.go"
- "!$test"
list-mode: strict
Expand All @@ -480,9 +474,9 @@ linters:
- encoding/json/jsontext
- github.com/meshcloud/meshstack-cli/internal/json

internal-version:
client-types-version:
files:
- "**/internal/version/*.go"
- "**/client/types/version/*.go"
- "!$test"
list-mode: strict
deny:
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ The binary gets its name from its directory, `cmd/meshstack`, which is what `tas

**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.
tree, the dependency policy, and what `client/`, `client/http` and the settings must keep.

[`skills/meshstack-cli`](skills/meshstack-cli/SKILL.md) is the skill for those who **use** the CLI;
other repositories link to it. A change to what a command does, prints or needs updates it in the
Expand Down
105 changes: 105 additions & 0 deletions client/api_surface_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package client

import (
"go/importer"
"go/token"
"go/types"
"io/fs"
"path"
"path/filepath"
"strings"
"testing"
)

// TestPublicApiNamesNoInternalType keeps client callable from another module, which Go's internal
// rule forbids to name an internal type.
func TestPublicApiNamesNoInternalType(t *testing.T) {

Check failure on line 16 in client/api_surface_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 23 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=meshcloud_meshstack-cli&issues=AaFoQ16AXQBWtK8uEg1S&open=AaFoQ16AXQBWtK8uEg1S&pullRequest=33
imp := importer.ForCompiler(token.NewFileSet(), "source", nil).(types.ImporterFrom) //nolint:forcetypeassert // the source importer is one
err := filepath.WalkDir(".", func(dir string, d fs.DirEntry, err error) error {
if err != nil || !d.IsDir() {
return err
}
if d.Name() == "internal" || d.Name() == "testdata" {
return filepath.SkipDir
}
pkg, err := imp.ImportFrom(path.Join("github.com/meshcloud/meshstack-cli/client", filepath.ToSlash(dir)), ".", 0)
if err != nil {
return err
}
for _, name := range pkg.Scope().Names() {
obj := pkg.Scope().Lookup(name)
if !obj.Exported() {
continue
}
check := surfaceCheck{t: t, seen: map[types.Type]bool{}}
check.visit(obj.Pkg().Path()+"."+name, obj.Type())
if named, ok := obj.Type().(*types.Named); ok {
for method := range named.Methods() {
if method.Exported() {
check.visit(obj.Pkg().Path()+"."+name+"."+method.Name(), method.Type())
}
}
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
}

type surfaceCheck struct {
t *testing.T
seen map[types.Type]bool
}

func (c surfaceCheck) visit(where string, typ types.Type) {

Check failure on line 56 in client/api_surface_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=meshcloud_meshstack-cli&issues=AaFoQ16AXQBWtK8uEg1T&open=AaFoQ16AXQBWtK8uEg1T&pullRequest=33
if c.seen[typ] {
return
}
c.seen[typ] = true
switch typ := typ.(type) {
case *types.Alias:
c.visit(where, types.Unalias(typ))
case *types.Named:
if pkg := typ.Obj().Pkg(); pkg != nil && strings.Contains(pkg.Path()+"/", "/internal/") {
c.t.Errorf("%s uses %s", where, typ)
}
for arg := range typ.TypeArgs().Types() {
c.visit(where, arg)
}
if pkg := typ.Obj().Pkg(); pkg != nil && strings.HasPrefix(pkg.Path(), "github.com/meshcloud/meshstack-cli/client") {
c.visit(where, typ.Underlying())
}
case *types.Pointer:
c.visit(where, typ.Elem())
case *types.Slice:
c.visit(where, typ.Elem())
case *types.Array:
c.visit(where, typ.Elem())
case *types.Chan:
c.visit(where, typ.Elem())
case *types.Map:
c.visit(where, typ.Key())
c.visit(where, typ.Elem())
case *types.Signature:
for param := range typ.Params().Variables() {
c.visit(where, param.Type())
}
for result := range typ.Results().Variables() {
c.visit(where, result.Type())
}
case *types.Struct:
for field := range typ.Fields() {
if field.Exported() {
c.visit(where+"."+field.Name(), field.Type())
}
}
case *types.Interface:
for method := range typ.Methods() {
if method.Exported() {
c.visit(where+"."+method.Name(), method.Type())
}
}
}
}
2 changes: 1 addition & 1 deletion client/building_block_definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ package client
import (
"context"

"github.com/meshcloud/meshstack-cli/client/http"
"github.com/meshcloud/meshstack-cli/client/internal"
"github.com/meshcloud/meshstack-cli/client/types"
"github.com/meshcloud/meshstack-cli/client/types/enum"
"github.com/meshcloud/meshstack-cli/internal/http"
)

type MeshBuildingBlockType string
Expand Down
2 changes: 1 addition & 1 deletion client/building_block_definition_version.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import (
"fmt"
"uuid"

"github.com/meshcloud/meshstack-cli/client/http"
"github.com/meshcloud/meshstack-cli/client/internal"
"github.com/meshcloud/meshstack-cli/client/types"
"github.com/meshcloud/meshstack-cli/client/types/enum"
"github.com/meshcloud/meshstack-cli/internal/http"
)

type MeshBuildingBlockDefinitionVersionState string
Expand Down
7 changes: 3 additions & 4 deletions client/building_block_run_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ package client
import (
"context"
"fmt"
gohttp "net/http"
"net/url"

"github.com/meshcloud/meshstack-cli/client/http"
"github.com/meshcloud/meshstack-cli/client/internal"
"github.com/meshcloud/meshstack-cli/internal/http"
)

// Link is a HAL link. meshStack sends the link of an action only while the caller may take it.
Expand Down Expand Up @@ -57,7 +56,7 @@ func (c *MeshBuildingBlockRunActionClient) ApproveRun(ctx context.Context, appro

func (c *MeshBuildingBlockRunActionClient) AbortRun(ctx context.Context, abort Link) error {
_, err := c.doAtLink[[]byte](ctx, http.MethodPost, abort,
http.WithHeaders(gohttp.Header{"Content-Type": {c.run.MeshObjectMimeType()}}))
http.WithHeader("Content-Type", c.run.MeshObjectMimeType()))
return err
}

Expand All @@ -76,5 +75,5 @@ func (c *MeshBuildingBlockRunActionClient) doAtLink[R any](ctx context.Context,
}
below := c.httpClient.EndpointUrl.JoinPath(path)
below.RawQuery = target.RawQuery
return c.httpClient.DoRequest[R](ctx, method, below, append(options, http.WithAccept(c.run.MeshObjectMimeType()))...)
return c.httpClient.DoRequest[R](ctx, method, below, append(options, http.WithHeader("Accept", c.run.MeshObjectMimeType()))...)
}
2 changes: 1 addition & 1 deletion client/building_block_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import (
"fmt"
"slices"

"github.com/meshcloud/meshstack-cli/client/http"
"github.com/meshcloud/meshstack-cli/client/internal"
"github.com/meshcloud/meshstack-cli/client/types"
"github.com/meshcloud/meshstack-cli/client/types/enum"
"github.com/meshcloud/meshstack-cli/internal/http"
)

type BuildingBlockLifecycleState string
Expand Down
12 changes: 5 additions & 7 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,18 @@ package client
import (
"context"

"github.com/meshcloud/meshstack-cli/client/http"
"github.com/meshcloud/meshstack-cli/client/internal"
"github.com/meshcloud/meshstack-cli/client/types/version"
"github.com/meshcloud/meshstack-cli/client/types/xurl"
"github.com/meshcloud/meshstack-cli/internal/http"
"github.com/meshcloud/meshstack-cli/internal/version"
)

var MinMeshStackVersion = version.MustParse("2026.36.0")

// Version is re-exported because MinMeshStackVersion is one and internal/version is closed to
// another module.
type Version = version.Version

type HttpError = http.Error

type Authorization = http.Authorization

type Client struct {
ApiKey MeshApiKeyClient
BuildingBlock MeshBuildingBlockClient
Expand All @@ -45,12 +41,13 @@ type Client struct {
WorkspaceGroupBinding MeshWorkspaceGroupBindingClient
WorkspaceUserBinding MeshWorkspaceUserBindingClient
Raw *RawClient
Runner MeshRunnerClient

// Endpoint is read by the Terraform provider's meshstack_instance data source.
Endpoint xurl.URL
}

func New(ctx context.Context, endpoint xurl.URL, client http.Client, auth Authorization) Client {
func New(ctx context.Context, endpoint xurl.URL, client http.Client, auth http.Authorization) Client {
authorizedClient := internal.HttpClient{
AuthorizedClient: client.WithAuthorization(auth),
EndpointUrl: endpoint,
Expand Down Expand Up @@ -92,6 +89,7 @@ func New(ctx context.Context, endpoint xurl.URL, client http.Client, auth Author
with(buildingBlockDefinition.meshObject).
withOwnOrder(buildingBlockDefinitionVersion.meshObject).
with(newEventLogMeshObject(ctx, authorizedClient)),
Runner: newRunnerClient(authorizedClient),

Endpoint: endpoint,
}
Expand Down
Loading
Loading