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
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ stackctl is a Go CLI tool (Cobra) for managing Kubernetes stack deployments. It
## Architecture

- All code lives under `cli/` — the Go module root (`cli/go.mod`). Run Go commands from `cli/`.
- Go version: see `cli/go.mod` (1.26.9 or later). CI uses `go-version-file: cli/go.mod`.
- Go version: see `cli/go.mod` (1.26.10 or later). CI uses `go-version-file: cli/go.mod`.
- Commands: `cli/cmd/` (one file per command group, subcommands registered in `init()`)
- HTTP client: `cli/pkg/client/client.go` (REST), `cli/pkg/client/auth.go` (session renewal), `cli/pkg/client/websocket.go` (deployment log streaming)
- Types: `cli/pkg/types/types.go` (client-side structs matching API responses)
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Go CLI tool (Cobra + yaml.v3) for managing Kubernetes stack deployments. Pure API client — talks to the [k8s-stack-manager](https://github.com/omattsson/k8s-stack-manager) backend. No backend logic, no frontend, no database, no direct K8s interaction.

Go version: see `cli/go.mod` (1.26.9 or later); the Dockerfile builder image may be newer.
Go version: see `cli/go.mod` (1.26.10 or later); the Dockerfile builder image may be newer.

## Project Structure

Expand Down Expand Up @@ -125,6 +125,8 @@ cli/ # Go module root (github.com/omattsson/stackctl/cl

**Warning headers**: `send()` writes each `Warning` response header (for example a `299` deprecation notice) to `Client.WarnWriter` (stderr when nil) as `Warning: <warn-text>`, for every command and also on error responses. The quoted warn-text is unescaped and sanitized like server error messages; one header can hold several comma-separated warnings (warn-dates are skipped). The same text is written once per client (retries do not repeat it; the dedupe set holds at most 64 texts), and writes are serialized under `warnMu`. `sanitizeServerMessage` replaces control characters (`unicode.IsControl`: C0, DEL, C1) with a space and drops bidi controls (U+200E, U+200F, U+202A–U+202E, U+2066–U+2069).

**User-Agent**: every request sends `User-Agent: stackctl/<version> (<GOOS>/<GOARCH>)` (`pkg/client/useragent.go`). `cmd.SetVersionInfo` calls `client.SetVersion`, so it matches `stackctl version` (empty: `dev`). `SetVersion` builds the header value once; `UserAgent()` only loads it. Set it where the request is built (`setUserAgent` in `send()` and `sendSession()`; `baseWSHeader()` for both WebSocket dials, also the `?token=` fallback), not in a transport: `stack.go` replaces `HTTPClient`.

**Plugins** (`cmd/plugins.go`): for a plugin, `Execute` sets `TraverseChildren`, so the global flags before the plugin name are parsed by stackctl and not passed to the plugin; `pluginEnv` exports them (`STACKCTL_OUTPUT`, `STACKCTL_QUIET`, `STACKCTL_NO_COLOR` + `NO_COLOR`, `STACKCTL_DEBUG`, `STACKCTL_INSECURE`, `STACKCTL_CONFIG_DIR`, plus the context values). Arguments after the plugin name pass unchanged. Contract in `EXTENDING.md`.

**Config-free commands**: `version` and `completion` skip config file loading and work even if the config is missing or corrupted.
Expand Down Expand Up @@ -154,7 +156,7 @@ contexts:
## Testing Conventions

- `testify/assert`, table-driven tests
- `pkg/` tests call `t.Parallel()` on the parent test and on subtests
- `pkg/` tests call `t.Parallel()` on the parent test and on subtests. Exception: a test that changes package-global state (for example `client.SetVersion`, see `TestUserAgent_Requests`) must not call `t.Parallel()`, nor its subtests; it restores the state with `t.Cleanup`. Go runs the parallel top-level tests only after the serial ones
- `cmd/` tests that touch package globals (`cfg`, `printer`, `flagAPIURL` and other flag variables) must not call `t.Parallel()`. Pure-function tests in `cmd/` (for example `resolve_test.go`) may
- No `tt := tt` needed (Go 1.22+ loop variable semantics)
- Test helpers: `setupStackTestCmd` (`cmd/stack_test.go`), `setupTestCmd` (`cmd/config_test.go`)
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,8 @@ With `--id`, a connection that ends for another reason before every listed insta

stackctl writes any `Warning` header from the API (for example a deprecation notice) to stderr as `Warning: <text>`, for every command.

Every request, also the WebSocket upgrade, sends `User-Agent: stackctl/<version> (<os>/<arch>)`, for example `stackctl/1.4.0 (linux/amd64)`. The version is the one of `stackctl version`.

### Templates

```bash
Expand Down Expand Up @@ -706,7 +708,7 @@ stackctl completion powershell | Out-String | Invoke-Expression

### Prerequisites

- Go version: see `cli/go.mod` (1.26.9 or later)
- Go version: see `cli/go.mod` (1.26.10 or later)
- A running [k8s-stack-manager](https://github.com/omattsson/k8s-stack-manager) backend for integration/e2e tests (`make dev` in that repo)

### Getting Started
Expand Down
2 changes: 2 additions & 0 deletions cli/cmd/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"fmt"

"github.com/omattsson/stackctl/cli/pkg/client"
"github.com/omattsson/stackctl/cli/pkg/output"
"github.com/spf13/cobra"
)
Expand All @@ -18,6 +19,7 @@ func SetVersionInfo(version, commit, date string) {
buildVersion = version
buildCommit = commit
buildDate = date
client.SetVersion(version)
}

var versionCmd = &cobra.Command{
Expand Down
22 changes: 22 additions & 0 deletions cli/cmd/version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,27 @@ package cmd
import (
"bytes"
"encoding/json"
"runtime"
"testing"

"github.com/omattsson/stackctl/cli/pkg/client"
"github.com/omattsson/stackctl/cli/pkg/output"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// Version tests mutate package-level globals, so they cannot run in parallel.

// restoreVersionInfo restores the build globals and the client User-Agent
// version when t ends. Every test that changes the build globals calls it.
func restoreVersionInfo(t *testing.T) {
t.Helper()
prevVersion, prevCommit, prevDate := buildVersion, buildCommit, buildDate
t.Cleanup(func() { SetVersionInfo(prevVersion, prevCommit, prevDate) })
}

func TestVersionCmd_Table(t *testing.T) {
restoreVersionInfo(t)
buildVersion = "1.2.3"
buildCommit = "abc123"
buildDate = "2025-01-01"
Expand All @@ -33,6 +44,7 @@ func TestVersionCmd_Table(t *testing.T) {
}

func TestVersionCmd_JSON(t *testing.T) {
restoreVersionInfo(t)
buildVersion = "2.0.0"
buildCommit = "def456"
buildDate = "2025-06-15"
Expand All @@ -54,8 +66,18 @@ func TestVersionCmd_JSON(t *testing.T) {
}

func TestSetVersionInfo(t *testing.T) {
restoreVersionInfo(t)

SetVersionInfo("3.0.0", "xyz789", "2025-12-25")
assert.Equal(t, "3.0.0", buildVersion)
assert.Equal(t, "xyz789", buildCommit)
assert.Equal(t, "2025-12-25", buildDate)
}

func TestSetVersionInfo_SetsUserAgent(t *testing.T) {
restoreVersionInfo(t)

SetVersionInfo("3.1.4", "abc", "2026-01-01")
assert.Equal(t, "3.1.4", buildVersion)
assert.Equal(t, "stackctl/3.1.4 ("+runtime.GOOS+"/"+runtime.GOARCH+")", client.UserAgent())
}
2 changes: 1 addition & 1 deletion cli/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/omattsson/stackctl/cli

go 1.26.9
go 1.26.10

require (
github.com/gorilla/websocket v1.5.3
Expand Down
1 change: 1 addition & 0 deletions cli/pkg/client/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ func (c *Client) sendSession(method, path, bearer, refreshToken string) (*http.R
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Accept", "application/json")
setUserAgent(req.Header)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
Expand Down
1 change: 1 addition & 0 deletions cli/pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ func (c *Client) send(method, path string, body interface{}) (*http.Response, er
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
setUserAgent(req.Header)

// API key takes precedence over JWT token
if c.APIKey != "" {
Expand Down
63 changes: 63 additions & 0 deletions cli/pkg/client/useragent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package client

import (
"fmt"
"net/http"
"runtime"
"strings"
"sync/atomic"
)

// currentUserAgent is the User-Agent header value of every request.
// SetVersion builds it once; every request only loads it. main sets it at
// startup through cmd.SetVersionInfo; nil means "dev".
var currentUserAgent atomic.Pointer[string]

// devUserAgent is the User-Agent when SetVersion was never called.
var devUserAgent = formatUserAgent("")

// SetVersion sets the stackctl version that every request reports in its
// User-Agent header. An empty version means "dev".
func SetVersion(v string) {
ua := formatUserAgent(v)
currentUserAgent.Store(&ua)
}

// UserAgent returns the User-Agent header value of every request:
// "stackctl/<version> (<os>/<arch>)".
func UserAgent() string {
if ua := currentUserAgent.Load(); ua != nil {
return *ua
}
return devUserAgent
}

// formatUserAgent builds the header value. Every byte of v that is not an
// RFC 9110 tchar becomes "_", so a bad version (a space, a newline) cannot
// break the request or split the product token.
func formatUserAgent(v string) string {
if v == "" {
v = "dev"
}
b := []byte(v)
for i, c := range b {
if !isTchar(c) {
b[i] = '_'
}
}
return fmt.Sprintf("stackctl/%s (%s/%s)", b, runtime.GOOS, runtime.GOARCH)
}

// isTchar reports whether c is an RFC 9110 token character.
func isTchar(c byte) bool {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
return true
}
return strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0
}

// setUserAgent sets the User-Agent header on h.
func setUserAgent(h http.Header) {
h.Set("User-Agent", UserAgent())
}
Loading
Loading