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
28 changes: 20 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,7 @@ jobs:
go-version-file: go.mod
cache: true

- name: Add Go bin to PATH
run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH

- name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0

# golangci-lint version is pinned in the Makefile (GOLANGCI_LINT_VERSION)
- name: Run linters
run: make lint

Expand Down Expand Up @@ -119,6 +114,23 @@ jobs:
exit 1
fi

# go fix modernizers: fail if any fix is pending (go fix -diff exits non-zero)
go-fix:
name: Go Fix Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true

- name: Check for pending go fix changes
run: make fix.check

vuln-scan:
name: Vulnerability Scan
runs-on: ubuntu-latest
Expand All @@ -145,11 +157,11 @@ jobs:
name: All CI Checks Passed
runs-on: ubuntu-latest
if: always()
needs: [lint, test, build, format, mod-tidy, vuln-scan]
needs: [lint, test, build, format, mod-tidy, go-fix, vuln-scan]
steps:
- name: Check results
run: |
for result in "${{ needs.lint.result }}" "${{ needs.test.result }}" "${{ needs.build.result }}" "${{ needs.format.result }}" "${{ needs.mod-tidy.result }}" "${{ needs.vuln-scan.result }}"; do
for result in "${{ needs.lint.result }}" "${{ needs.test.result }}" "${{ needs.build.result }}" "${{ needs.format.result }}" "${{ needs.mod-tidy.result }}" "${{ needs.go-fix.result }}" "${{ needs.vuln-scan.result }}"; do
if [ "$result" != "success" ]; then
echo "One or more CI checks failed"
exit 1
Expand Down
18 changes: 6 additions & 12 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,13 @@ jobs:
go-version-file: go.mod
cache: true

- name: Add Go bin to PATH
run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH

- name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0

# golangci-lint version is pinned in the Makefile (GOLANGCI_LINT_VERSION).
# make security passes --default=none + -E gosec, scoping this step to gosec
# findings only. Without --default=none, golangci-lint's "standard" linter set
# (errcheck, govet, staticcheck, ...) also runs, which duplicates `make lint`
# and bypasses the project's test-file exclusions in .golangci.yaml.
- name: Run gosec via golangci-lint
# --default=none + -E gosec scopes this step to gosec findings only.
# Without --default=none, golangci-lint's "standard" linter set
# (errcheck, govet, staticcheck, ...) also runs, which duplicates
# `make lint` and bypasses the project's test-file exclusions in
# .golangci.yaml — producing noise unrelated to security.
run: golangci-lint run --no-config --default=none -E gosec ./...
run: make security

gitleaks:
name: Gitleaks (secret scanning)
Expand Down
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ repos:

- repo: local
hooks:
- id: go-fix
name: Modernize Go code (go fix)
entry: make fix
language: system
types: [go]
pass_filenames: false
- id: golangci-lint
name: Lint Go code (golangci-lint)
entry: make lint
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Skipping these steps leads to pattern violations, broken dual-mode, and pricing
- [ ] `make build` passes
- [ ] `make lint` passes with zero issues (do not rely on pre-commit to surface these)
- [ ] `make test` passes (unit tests with -race; lint is the separate `make lint` item above)
- [ ] `make fix.check` passes (run `make fix` to apply pending `go fix` modernizations)
- [ ] `--help` renders correctly for changed commands
- [ ] Interactive and non-interactive modes both work
- [ ] Interactive Selects pass `tui.WithShowHints(true)` so the hint bar renders
Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ Go CLI for Verda Cloud. Cobra commands + Bubble Tea TUI + lipgloss styling.
make build # Build binary to ./bin/verda
make test # Run all tests (go test -race)
make lint # Lint only (golangci-lint); also run by pre-commit hooks
make fix # Apply go fix modernizers; CI runs `make fix.check`
make pre-commit # Full pre-commit suite
```

The `go` directive in `go.mod` is the single source of the Go version: CI reads it via `go-version-file`, and the Makefile pins `GOTOOLCHAIN` to it for golangci-lint (version pinned in the Makefile) and `go fix`. After bumping `go` in `go.mod`, run `make fix` — new Go releases add fixers, and `fix.check` will fail until they're applied.

Never use raw `go test ./...` — always `make test` (go test -race). Lint is separate: `make lint`; the pre-commit hooks run both.

## Architecture
Expand Down Expand Up @@ -140,7 +143,7 @@ The repo lints with `golangci-lint` via `make lint` (also enforced by the pre-co
3. Read `.ai/skills/new-command.md` for the full checklist when adding/modifying commands
4. If touching pricing, auth, or agent-mode: plan first, don't code immediately

Per-command docs are auto-maintained by `/update-command-knowledge` skill.
Per-command docs are maintained by the `/update-command-knowledge` skill. The `githooks/pre-commit` hook runs it only when `VERDA_DOC_UPDATE=1` is set.
Manual update: `claude -p "/update-command-knowledge --all" --model sonnet --dangerously-skip-permissions`

## Thinking Depth
Expand All @@ -163,6 +166,7 @@ Before considering any change complete:
make build # Must compile
make test # Must pass (go test -race)
make lint # Must pass (golangci-lint; also run by pre-commit hooks)
make fix.check # Must pass (go fix modernizers; `make fix` applies them)
```

**Never** report work as complete with lint failures outstanding. Fix them before the "done" message; don't defer to the pre-commit hook. See the "Go House Style" section above for the patterns that prevent the common hits.
Expand Down
26 changes: 21 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
OUTPUT_DIR ?= bin

.PHONY: all build clean run.sandbox lint lint.fix security test test.integration test-s3-integration fmt changelog changelog.unreleased hooks.install pre-commit help
# Single source of truth for the Go version: the go directive in go.mod (CI reads it via go-version-file).
GO_VERSION := $(shell sed -n 's/^go //p' go.mod)
GOLANGCI_LINT_VERSION := v2.5.0
GOLANGCI_LINT := GOTOOLCHAIN=go$(GO_VERSION) go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
# Toolchain pinned because each Go release adds or removes fixers; the integration tag brings tests/integration into scope.
GOFIX := GOTOOLCHAIN=go$(GO_VERSION) go fix -tags=integration

.PHONY: all build clean run.sandbox lint lint.fix security fix fix.check test test.integration test-s3-integration fmt changelog changelog.unreleased hooks.install pre-commit help

## Build -------------------------------------------------------------------

Expand All @@ -23,13 +30,22 @@ run.sandbox: build ## Run the binary against a throwaway config dir, e.g. make r
## Quality -----------------------------------------------------------------

lint: ## Run golangci-lint on all packages
@golangci-lint run ./...
@$(GOLANGCI_LINT) run ./...

lint.fix: ## Run golangci-lint with auto-fix
@golangci-lint run --fix ./...
@$(GOLANGCI_LINT) run --fix ./...

# --default=none keeps golangci-lint's "standard" set out, so only gosec findings are reported.
security: ## Run gosec-only scan, as CI does (ignores .golangci.yaml, so test files are scanned too)
@$(GOLANGCI_LINT) run --no-config --default=none -E gosec ./...

# Two passes: applying one modernization can enable another (https://go.dev/blog/gofix).
fix: ## Apply go fix modernizers
@$(GOFIX) ./...
@$(GOFIX) ./...

security: ## Run gosec-only scan mirroring CI (ignores .golangci.yaml, so test files are scanned too)
@golangci-lint run --no-config -E gosec ./...
fix.check: ## Fail if go fix has pending modernizations (CI)
@$(GOFIX) -diff ./... || { echo "Pending go fix changes. Run 'make fix'"; exit 1; }

test: ## Run all tests (with race detector)
@go test -race -count=1 ./...
Expand Down
18 changes: 10 additions & 8 deletions githooks/pre-commit
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
# make hooks.install
# # or: git config core.hooksPath githooks
#
# Requires bash. Optional: golangci-lint on PATH for extra checks.
# Requires bash and Go (make lint and make fix.check fetch their pinned tools).
# VERDA_DOC_UPDATE=1 also regenerates command docs with claude (off by default).
set -euo pipefail

STAGED_GO=$(git diff --cached --name-only --diff-filter=ACM -- '*.go' || true)
Expand All @@ -21,10 +22,12 @@ go build ./...
printf " go vet ./...\n"
go vet ./...

if command -v golangci-lint &>/dev/null; then
printf " golangci-lint (staged files)\n"
echo "$STAGED_GO" | xargs golangci-lint run --new-from-rev=HEAD
fi
# Same pinned targets as CI: the Makefile pins golangci-lint and the go fix toolchain.
printf " make fix.check\n"
make --no-print-directory fix.check

printf " make lint\n"
make --no-print-directory lint

PKGS=$(echo "$STAGED_GO" | xargs -I{} dirname {} | sort -u | sed 's|^|./|')
if [ -n "$PKGS" ]; then
Expand All @@ -33,9 +36,8 @@ if [ -n "$PKGS" ]; then
fi

# --- Auto-update per-subcommand knowledge docs ----------------------------
if [ "${SKIP_DOC_UPDATE:-}" = "1" ]; then
printf " ⏭️ skipping command knowledge doc update (SKIP_DOC_UPDATE=1)\n"
else
# Opt-in: runs headless claude with skipped permissions and stages its doc edits.
if [ "${VERDA_DOC_UPDATE:-}" = "1" ]; then
CHANGED_CMD_DIRS=$(git diff --cached --name-only -- 'internal/verda-cli/cmd/' \
| grep -v 'cmd/util/' | grep -v 'cmd/cmd\.go' | grep -v 'cmd/helper\.go' \
| sed 's|internal/verda-cli/cmd/\([^/]*\)/.*|\1|' | sort -u || true)
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
module github.com/verda-cloud/verda-cli

go 1.26.9
go 1.26.10

require (
charm.land/lipgloss/v2 v2.0.2
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/verda-cloud/verdacloud-sdk-go v1.4.2
github.com/verda-cloud/verdacloud-sdk-go v1.7.0
go.yaml.in/yaml/v3 v3.0.4
)

Expand Down Expand Up @@ -65,7 +65,7 @@ require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.4.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
Expand Down
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
Expand Down Expand Up @@ -107,8 +107,8 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-ozzo/ozzo-validation/v4 v4.4.1 h1:AQ3X8zHnXEuNE04pyc1H/nmIlroNjgZ7hcY7Xv/IgH8=
github.com/go-ozzo/ozzo-validation/v4 v4.4.1/go.mod h1:4ZtPNefSnNq39wjL+2We8y2ysqEX/S4D5mPybufHd7Y=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
Expand Down Expand Up @@ -187,8 +187,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
github.com/verda-cloud/verdacloud-sdk-go v1.4.2 h1:oVb8fHVQOY+YPuuMYMee9gYCkPTwAw01LmkqxM21T/Y=
github.com/verda-cloud/verdacloud-sdk-go v1.4.2/go.mod h1:pmlpiCL9fTSikZ3qWLJPpHOG0E8PKkQVUX5s4Z+SktY=
github.com/verda-cloud/verdacloud-sdk-go v1.7.0 h1:ioe12/i1IDYmOwwZje2T7jCYPfToE1jMBDHXY6ZXSHU=
github.com/verda-cloud/verdacloud-sdk-go v1.7.0/go.mod h1:1APG3X5TXhXpUp1dhgemElaj/6bw+OaKeQ+Ze/0/Cyc=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
Expand Down
2 changes: 1 addition & 1 deletion internal/verda-cli/cmd/cost/running.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func runRunning(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStrea
sp, _ = status.Spinner(ctx, "Loading instances and volumes...")
}

instances, err := client.Instances.Get(ctx, "")
instances, err := client.Instances.List(ctx, nil)
if err != nil {
if sp != nil {
sp.Stop("")
Expand Down
4 changes: 1 addition & 3 deletions internal/verda-cli/cmd/cost/running_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,11 @@ import (
cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util"
)

func strPtr(s string) *string { return &s }

func TestUniqueVolumeIDs(t *testing.T) {
t.Parallel()

inst := &verda.Instance{
OSVolumeID: strPtr("vol-os"),
OSVolumeID: new("vol-os"),
VolumeIDs: []string{"vol-os", "vol-data", "vol-data"},
}

Expand Down
2 changes: 1 addition & 1 deletion internal/verda-cli/cmd/images/images_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func TestOutputShowsImageTypeNotID(t *testing.T) {
mockServer := testutil.NewMockServer()
defer mockServer.Close()

client := verda.NewTestClient(mockServer)
client := verda.NewTestClient(t, mockServer)

var buf bytes.Buffer
ioStreams := cmdutil.IOStreams{Out: &buf, ErrOut: &buf}
Expand Down
8 changes: 3 additions & 5 deletions internal/verda-cli/cmd/mcp/lazy_init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,8 @@ func TestLazyClientInitConcurrent(t *testing.T) {
const workers = 16
var wg sync.WaitGroup
errs := make(chan error, workers)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for range workers {
wg.Go(func() {
c, err := s.verdaClient()
if err != nil {
errs <- err
Expand All @@ -63,7 +61,7 @@ func TestLazyClientInitConcurrent(t *testing.T) {
if c != client {
errs <- errors.New("verdaClient returned the wrong client instance")
}
}()
})
}
wg.Wait()
close(errs)
Expand Down
7 changes: 3 additions & 4 deletions internal/verda-cli/cmd/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"encoding/json"
"fmt"
"os"
"slices"
"strings"
"sync"

Expand Down Expand Up @@ -288,10 +289,8 @@ func optionalEnum(a map[string]any, name string, allowed ...string) (string, err
if err != nil || s == "" {
return "", err
}
for _, allow := range allowed {
if s == allow {
return s, nil
}
if slices.Contains(allowed, s) {
return s, nil
}
return "", invalidArgError(name, fmt.Sprintf("invalid value %q (valid: %s)", s, strings.Join(allowed, ", ")))
}
Expand Down
2 changes: 1 addition & 1 deletion internal/verda-cli/cmd/mcp/tools_cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ func (s *Server) handleGetRunningCosts(ctx context.Context, _ mcp.CallToolReques
return mcp.NewToolResultError(err.Error()), nil
}

instances, err := client.Instances.Get(ctx, "running")
instances, err := client.Instances.List(ctx, &verda.ListInstancesOptions{Status: "running"})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
Expand Down
2 changes: 1 addition & 1 deletion internal/verda-cli/cmd/mcp/tools_ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ func (s *Server) resolveInstance(ctx context.Context, idOrHostname string) (*ver
}

// Fall back to searching by hostname.
instances, err := client.Instances.Get(ctx, "")
instances, err := client.Instances.List(ctx, nil)
if err != nil {
return nil, err
}
Expand Down
6 changes: 3 additions & 3 deletions internal/verda-cli/cmd/mcp/tools_vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ func (s *Server) handleListVMs(ctx context.Context, req mcp.CallToolRequest) (*m
return mcp.NewToolResultError(err.Error()), nil
}

instances, err := client.Instances.Get(ctx, status)
instances, err := client.Instances.List(ctx, &verda.ListInstancesOptions{Status: status})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
Expand Down Expand Up @@ -434,10 +434,10 @@ var vmActions = map[string]vmAction{
destructive: true,
exec: func(ctx context.Context, c *verda.Client, id string) error { return c.Instances.ForceShutdown(ctx, id) },
},
verda.ActionHibernate: {
verda.ActionHibernate: { //nolint:staticcheck // SA1019: hibernate is deprecated but still supported
expectStatus: verda.StatusOffline,
destructive: true,
exec: func(ctx context.Context, c *verda.Client, id string) error { return c.Instances.Hibernate(ctx, id) },
exec: func(ctx context.Context, c *verda.Client, id string) error { return c.Instances.Hibernate(ctx, id) }, //nolint:staticcheck // SA1019: hibernate is deprecated but still supported
},
verda.ActionDelete: {
destructive: true,
Expand Down
Loading
Loading