diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..761e773 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,44 @@ +name: golangci-lint + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + schedule: + - cron: "23 6 * * 1" + +permissions: + contents: read + pull-requests: read + +jobs: + lint: + name: lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + path: cli + # Sibling checkouts (proto/common) for repos with replace directives. + # No-op for repos that do not need them. + - uses: actions/checkout@v4 + if: ${{ hashFiles('cli/go.mod') != '' }} + with: + repository: InstaNode-dev/common + path: common + continue-on-error: true + - uses: actions/checkout@v4 + with: + repository: InstaNode-dev/proto + path: proto + continue-on-error: true + - uses: actions/setup-go@v5 + with: + go-version-file: cli/go.mod + - uses: golangci/golangci-lint-action@v8 + with: + version: latest + working-directory: cli + args: --timeout=5m diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..b836fbc --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,35 @@ +# golangci-lint v2 config — start conservative, expand once baseline is clean +version: "2" + +run: + timeout: 5m + tests: true + +linters: + # Default linter set is govet+errcheck+ineffassign+staticcheck+unused. + # We explicitly add misspell + gocyclo on top. gosimple folded into staticcheck in v2. + enable: + - errcheck # checks unchecked errors + - govet # standard vet + - ineffassign # ineffective assignments + - staticcheck # bug detection (subsumes gosimple in v2) + - unused # unused code + - misspell # spelling + - gocyclo # cyclomatic complexity + settings: + gocyclo: + # Baseline lift to clear the two pre-existing top offenders + # (runUp=31, runResourceDetail=21) without a behavior-changing + # refactor. Set just above the highest (31) so any NEW function + # crossing this threshold still fails the gate. + min-complexity: 32 + exclusions: + rules: + - path: _test\.go + linters: + - errcheck + - gocyclo + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/cmd/deploy_stub.go b/cmd/deploy_stub.go index 23a532b..6fd6f22 100644 --- a/cmd/deploy_stub.go +++ b/cmd/deploy_stub.go @@ -89,7 +89,7 @@ func newDeployStub(verb, extra string) *cobra.Command { Short: short, Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { - fmt.Fprintf(cmd.ErrOrStderr(), + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "`instant deploy %s` is not yet implemented in the CLI.\n"+ "Use one of:\n"+ " - MCP tool (Claude Code / Cursor: %s)\n"+ diff --git a/cmd/discover.go b/cmd/discover.go index 632757b..b15f64e 100644 --- a/cmd/discover.go +++ b/cmd/discover.go @@ -89,7 +89,7 @@ func runResources(cmd *cobra.Command) error { if err != nil { return fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() // T16 P1-2 — uniform 401 handling. if resp.StatusCode == http.StatusUnauthorized { @@ -171,7 +171,7 @@ func runResources(cmd *cobra.Command) error { } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "TOKEN\tTYPE\tNAME\tTIER\tSTATUS") + _, _ = fmt.Fprintln(w, "TOKEN\tTYPE\tNAME\tTIER\tSTATUS") for _, r := range result.Items { shortToken := r.Token if len(shortToken) > 12 { @@ -181,10 +181,10 @@ func runResources(cmd *cobra.Command) error { if name == "" { name = "-" } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", shortToken, r.ResourceType, name, r.Tier, r.Status) } - w.Flush() + _ = w.Flush() return nil } diff --git a/cmd/extras.go b/cmd/extras.go index c7c4cdf..1d1bffa 100644 --- a/cmd/extras.go +++ b/cmd/extras.go @@ -117,7 +117,7 @@ func runResourceDetail(cmd *cobra.Command, token string) error { if err != nil { return fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusUnauthorized { if haveAuth() { @@ -169,36 +169,36 @@ func runResourceDetail(cmd *cobra.Command, token string) error { } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintf(w, "TOKEN\t%s\n", detail.Token) + _, _ = fmt.Fprintf(w, "TOKEN\t%s\n", detail.Token) if detail.ID != "" { - fmt.Fprintf(w, "ID\t%s\n", detail.ID) + _, _ = fmt.Fprintf(w, "ID\t%s\n", detail.ID) } if detail.ResourceType != "" { - fmt.Fprintf(w, "TYPE\t%s\n", detail.ResourceType) + _, _ = fmt.Fprintf(w, "TYPE\t%s\n", detail.ResourceType) } if detail.Name != "" { - fmt.Fprintf(w, "NAME\t%s\n", detail.Name) + _, _ = fmt.Fprintf(w, "NAME\t%s\n", detail.Name) } if detail.Env != "" { - fmt.Fprintf(w, "ENV\t%s\n", detail.Env) + _, _ = fmt.Fprintf(w, "ENV\t%s\n", detail.Env) } if detail.Tier != "" { - fmt.Fprintf(w, "TIER\t%s\n", detail.Tier) + _, _ = fmt.Fprintf(w, "TIER\t%s\n", detail.Tier) } if detail.Status != "" { - fmt.Fprintf(w, "STATUS\t%s\n", detail.Status) + _, _ = fmt.Fprintf(w, "STATUS\t%s\n", detail.Status) } if detail.ConnectionURL != "" { - fmt.Fprintf(w, "URL\t%s\n", detail.ConnectionURL) + _, _ = fmt.Fprintf(w, "URL\t%s\n", detail.ConnectionURL) } if detail.ReceiveURL != "" { - fmt.Fprintf(w, "RECEIVE_URL\t%s\n", detail.ReceiveURL) + _, _ = fmt.Fprintf(w, "RECEIVE_URL\t%s\n", detail.ReceiveURL) } if detail.CreatedAt != "" { - fmt.Fprintf(w, "CREATED\t%s\n", detail.CreatedAt) + _, _ = fmt.Fprintf(w, "CREATED\t%s\n", detail.CreatedAt) } if detail.ExpiresAt != "" { - fmt.Fprintf(w, "EXPIRES\t%s\n", detail.ExpiresAt) + _, _ = fmt.Fprintf(w, "EXPIRES\t%s\n", detail.ExpiresAt) } return w.Flush() } @@ -235,7 +235,7 @@ func runResourceDelete(cmd *cobra.Command, token string) error { if err != nil { return fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode == http.StatusUnauthorized { if haveAuth() { diff --git a/cmd/login.go b/cmd/login.go index 32e788b..39cebed 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -184,7 +184,7 @@ func createCLISession(anonTokens []string) (*cliSession, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { @@ -216,7 +216,7 @@ func pollForAuthCompletion(sessionID string) (*authResult, error) { } raw, _ := io.ReadAll(resp.Body) - resp.Body.Close() + _ = resp.Body.Close() if resp.StatusCode == http.StatusAccepted { // Still pending — print a progress dot and wait. @@ -242,7 +242,7 @@ func pollForAuthCompletion(sessionID string) (*authResult, error) { return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, raw) } - return nil, fmt.Errorf("timed out waiting for login (%.0f minutes). Try again.", pollTimeout.Minutes()) + return nil, fmt.Errorf("timed out waiting for login after %.0f minutes; try again", pollTimeout.Minutes()) } // pollForTierUpgrade polls GET /auth/me until the tier changes, up to 5 minutes. @@ -267,7 +267,7 @@ func pollForTierUpgrade(cfg *cliconfig.Config) error { TeamName string `json:"team_name"` } raw, _ := io.ReadAll(resp.Body) - resp.Body.Close() + _ = resp.Body.Close() if err := json.Unmarshal(raw, &result); err != nil { time.Sleep(pollInterval) continue diff --git a/cmd/login_poll_test.go b/cmd/login_poll_test.go index 45733b4..2f56ceb 100644 --- a/cmd/login_poll_test.go +++ b/cmd/login_poll_test.go @@ -286,7 +286,9 @@ func TestLoadAnonymousTokens_WithEntries(t *testing.T) { if len(out) != 2 { t.Fatalf("expected 2 anon tokens, got %d", len(out)) } - if !((out[0] == "tok-1" && out[1] == "tok-2") || (out[0] == "tok-2" && out[1] == "tok-1")) { + order1 := out[0] == "tok-1" && out[1] == "tok-2" + order2 := out[0] == "tok-2" && out[1] == "tok-1" + if !order1 && !order2 { t.Errorf("unexpected token slice: %v", out) } } diff --git a/cmd/monitor.go b/cmd/monitor.go index ae0da3b..8f57d33 100644 --- a/cmd/monitor.go +++ b/cmd/monitor.go @@ -209,7 +209,7 @@ func provisionResource(endpoint, name string) (*provisionResponse, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode == http.StatusUnauthorized && haveAuth() { @@ -282,17 +282,17 @@ With --json, output is a machine-readable JSON array of token entries } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "TOKEN\tNAME\tSOURCE\tCREATED") + _, _ = fmt.Fprintln(w, "TOKEN\tNAME\tSOURCE\tCREATED") for _, e := range store.Entries { shortToken := e.Token if len(shortToken) > 12 { shortToken = shortToken[:12] + "…" } created := e.CreatedAt.Format("2006-01-02") - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", shortToken, e.Name, e.Source, created) } - w.Flush() + _ = w.Flush() return nil }, } diff --git a/cmd/up.go b/cmd/up.go index a97b15c..363b5ad 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -389,7 +389,7 @@ func fetchExistingResources(env string) ([]resourceListItem, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() // 401: unauthenticated. For anonymous callers this is expected (no // resources to reuse, no error); for callers with a token it means the @@ -460,7 +460,7 @@ func provisionForUp(decl manifestRsrc, env string) (*provisionResponse, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode == http.StatusUnauthorized && haveAuth() { return nil, errSessionExpiredSentinel @@ -539,7 +539,7 @@ func fetchCredentials(token string) (string, error) { if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("server %d: %s", resp.StatusCode, truncate(string(raw), 120)) diff --git a/internal/secretstore/keychain_fake_test.go b/internal/secretstore/keychain_fake_test.go index 786c9e7..48b2257 100644 --- a/internal/secretstore/keychain_fake_test.go +++ b/internal/secretstore/keychain_fake_test.go @@ -10,11 +10,10 @@ import ( // It also lets each test inject specific errors on Get/Set/Delete to drive // every branch of the wrapper. type fakeKeyring struct { - store map[string]string - getErr error - setErr error - deleteErr error - notFoundFn func(err error) bool + store map[string]string + getErr error + setErr error + deleteErr error } func newFakeKeyring() *fakeKeyring { diff --git a/main.go b/main.go index f55df5e..7b29df3 100644 --- a/main.go +++ b/main.go @@ -41,7 +41,7 @@ func run(args []string, stderr io.Writer) int { err := cmd.ExecuteWithArgs(args) if err != nil { - fmt.Fprintln(stderr, err) + _, _ = fmt.Fprintln(stderr, err) } // Translate any error returned by the cobra tree into the documented // exit-code contract. A nil error exits 0; an *ExitCodeError carries its