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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Skipping these steps leads to pattern violations, broken dual-mode, and pricing
- **Interactive hint bar** — every direct `prompter.Select(...)` outside the wizard engine must pass `tui.WithShowHints(true)` (and the equivalent option on `MultiSelect`) so the prompt renders its key hints below the choices. Wizard steps are exempt — the composite already renders the hint bar
- **Ctrl+C exits immediately, no confirmation** — use `cmdutil.IsPromptCancel(err)` to detect either Esc or Ctrl+C and return cleanly. When a flow needs different behavior per key (e.g. a "Back to list / Exit" gate where Esc means back), split with `IsPromptInterrupt(err)` (Ctrl+C) and `IsPromptBack(err)` (Esc). Never show an "Exit?" confirmation dialog — Unix users expect Ctrl+C to be terminal
- **`pkg/` is in-tree** — the TUI core (`pkg/tui*`), `pkg/log`, `pkg/version` are part of this repo; edit them directly
- **Never run the binary against the real config dir** — every manual, scripted, or pty-driven `./bin/verda` run sets `VERDA_HOME=$(mktemp -d)` (or uses `make run.sandbox`). `VERDA_SHARED_CREDENTIALS_FILE` is not enough; it leaves `config.yaml` and `EnsureVerdaDir` pointing at the real `~/.verda`. Driving `auth login` to completion once overwrote a developer's real credentials, and a clobbered client secret cannot be recovered from the API. See CLAUDE.md § "NEVER run the binary against the real config dir"
- **Commit only when asked** — don't auto-commit

## Risky Areas — Slow Down
Expand All @@ -44,6 +45,7 @@ Skipping these steps leads to pattern violations, broken dual-mode, and pricing
| `options/credentials.go` | Break auth = break everything | Test all profiles, expired tokens |
| Agent mode (`--agent`) | JSON contract change = break downstream | Check structured error format |
| Wizard steps | Step ordering, cache invalidation | Map dependencies before coding |
| Running `auth login` / any binary run | Overwrites the real `~/.verda`; lost secrets are unrecoverable | Set `VERDA_HOME=$(mktemp -d)` first, always |

## Done Checklist

Expand All @@ -54,5 +56,6 @@ Skipping these steps leads to pattern violations, broken dual-mode, and pricing
- [ ] Interactive and non-interactive modes both work
- [ ] Interactive Selects pass `tui.WithShowHints(true)` so the hint bar renders
- [ ] No leftover debug code, TODOs, or commented-out blocks
- [ ] Every manual/pty run of the binary set `VERDA_HOME` to a temp dir — the real `~/.verda` is untouched

If `make lint` reports issues, fix them *before* announcing completion. See `CLAUDE.md` § "Go House Style" for the patterns that prevent the common hits (http.NoBody, American spelling, reused constants, rangeValCopy, nilerr annotations, etc.).
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,31 @@ If you modified a command, also verify:
- `--agent -o json` mode works (structured output, no TUI)
- `--debug` shows request/response payloads

### NEVER run the binary against the real config dir

Any manual, scripted, or pty-driven run of `./bin/verda` MUST set `VERDA_HOME` to a
throwaway directory:

```bash
VERDA_HOME=$(mktemp -d) ./bin/verda <command> # or: make run.sandbox ARGS="<command>"
```

`VERDA_HOME` (see `options.VerdaDir`) redirects the whole config dir — credentials *and*
`config.yaml`. `VERDA_SHARED_CREDENTIALS_FILE` covers only the credentials file, so
`auth use`, `settings`, and `EnsureVerdaDir` still hit the real `~/.verda`. Use
`VERDA_HOME`.

This is not hypothetical: driving the `auth login` wizard to completion to verify a TUI
fix overwrote a developer's real `~/.verda/credentials` with test values.
`auth login` replaces an existing profile with no warning — the documented re-auth
behavior — and **a client secret cannot be read back from the API, so a clobber is
unrecoverable**. Assume any command may write to the config dir, not just the obviously
auth-shaped ones.

The repo's own suites already do this — copy them, don't hand-roll a harness:
`tests/contract/main_test.go` (`cliEnv` strips every inherited `VERDA_*`, then sets
`VERDA_HOME=t.TempDir()`) and `options/registry_credentials_test.go:168`.

## Other Agents

This repo targets Claude Code and OpenAI Codex. Claude auto-loads this file; Codex auto-loads `AGENTS.md` (execution contract). A `.cursor/rules/main.mdc` pointer exists for Cursor users but is not a primary target — if Cursor drops out of the stack, delete it rather than letting it drift.
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
OUTPUT_DIR ?= bin

.PHONY: all build clean lint lint.fix security test test.integration test-s3-integration fmt changelog changelog.unreleased hooks.install pre-commit help
.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

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

Expand All @@ -14,6 +14,12 @@ build: ## Build the binary into bin/
clean: ## Remove build artifacts
@rm -rf $(OUTPUT_DIR)

# Never drive the binary against the real ~/.verda: auth login replaces a profile
# with no warning, and a clobbered client secret cannot be read back from the API.
# VERDA_HOME redirects the whole config dir; VERDA_SHARED_CREDENTIALS_FILE does not.
run.sandbox: build ## Run the binary against a throwaway config dir, e.g. make run.sandbox ARGS="auth login"
@dir=$$(mktemp -d) && echo "VERDA_HOME=$$dir" && VERDA_HOME=$$dir $(OUTPUT_DIR)/verda $(ARGS)

## Quality -----------------------------------------------------------------

lint: ## Run golangci-lint on all packages
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/verda-cloud/verda-cli

go 1.25.12
go 1.25.13

require (
charm.land/lipgloss/v2 v2.0.2
Expand Down
13 changes: 13 additions & 0 deletions internal/verda-cli/cmd/auth/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- `path.go` -- Helpers: `resolveCredentialsFile`, `defaultConfigFilePath`
- `auth_test.go` -- Tests for `writeActiveProfile`, `resolveCredentialsFile`
- `wizard_test.go` -- Wizard flow tests with mock prompter
- `login_test.go` -- Flag-driven write path: new/named profile, merge, re-auth overwrite, 0600, flag-over-env

## Domain-Specific Logic
- Credentials file resolution order: explicit flag > `VERDA_SHARED_CREDENTIALS_FILE` env var > `options.DefaultCredentialsFilePath()`
Expand All @@ -28,6 +29,18 @@
- The `selectThemeWizard` pattern of returning `nil` on wizard error (user cancel) is NOT used here -- login returns the wizard error directly.
- `writeActiveProfile` in `use.go` merges into existing config YAML rather than overwriting the whole file.
- `login` creates the `~/.verda/` directory via `options.EnsureVerdaDir()` before saving.
- **Never run `auth login` against the real config dir.** Set `VERDA_HOME` to a temp dir for
any manual or pty-driven run (`make run.sandbox ARGS="auth login"`). Re-running login
replaces an existing profile with no warning -- intentional, it is the re-auth path --
and a client secret cannot be read back from the API, so a clobber is unrecoverable.
`VERDA_SHARED_CREDENTIALS_FILE` alone is insufficient: `EnsureVerdaDir()` resolves
through `VerdaDir()` and would still mkdir the real `~/.verda`. Tests must set
`VERDA_HOME` for the same reason.
- `login_test.go` covers only the flag-driven path. Supplying both `--client-id` and
`--client-secret` is what skips the wizard, so the post-wizard validation gate is
unreachable from a test -- the engine is constructed inline in `RunE`, and a wizard in a
test would start a real `tea.Program` against the developer's stdin. Covering that gate
means injecting the engine.

## Relationships
- `cmdutil.Factory` / `cmdutil.IOStreams` -- standard dependency injection
Expand Down
216 changes: 216 additions & 0 deletions internal/verda-cli/cmd/auth/login_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// Copyright 2026 Verda Cloud Oy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package auth

import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util"
"github.com/verda-cloud/verda-cli/internal/verda-cli/options"
)

// These tests cover the flag-driven write path only. Supplying both
// --client-id and --client-secret is what skips the wizard (login.go checks
// them with OR), and a wizard here would start a real tea.Program against the
// developer's stdin — a hang when `go test` runs from a terminal. The
// post-wizard "still empty" validation gate is therefore unreachable from a
// test; covering it needs the engine injected rather than constructed inline.

// sandboxHome points the whole config dir at a temp tree. VERDA_HOME, not
// VERDA_SHARED_CREDENTIALS_FILE: login calls options.EnsureVerdaDir, which
// resolves through VerdaDir and would mkdir the developer's real ~/.verda even
// with the credentials path redirected elsewhere.
func sandboxHome(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("VERDA_HOME", dir)
t.Setenv("VERDA_SHARED_CREDENTIALS_FILE", filepath.Join(dir, "credentials"))
return dir
}

func runAuthLoginForTest(t *testing.T, args ...string) error {
t.Helper()
streams := cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}
cmd := NewCmdLogin(cmdutil.NewTestFactory(nil), streams)
cmd.SetArgs(args)
cmd.SetOut(streams.Out)
cmd.SetErr(streams.ErrOut)
cmd.SilenceUsage = true
cmd.SilenceErrors = true
return cmd.Execute()
}

func loadProfile(t *testing.T, path, profile string) *options.SharedCredentials {
t.Helper()
creds, err := options.LoadSharedCredentialsForProfile(path, profile)
if err != nil {
t.Fatalf("LoadSharedCredentialsForProfile(%q, %q): %v", path, profile, err)
}
return creds
}

func TestLoginWritesNewProfile(t *testing.T) {
dir := sandboxHome(t)
path := filepath.Join(dir, "credentials")

if err := runAuthLoginForTest(t, "--client-id", "id-1", "--client-secret", "secret-1"); err != nil {
t.Fatalf("login: %v", err)
}

got := loadProfile(t, path, "default")
if got.ClientID != "id-1" {
t.Errorf("ClientID = %q, want id-1", got.ClientID)
}
if got.ClientSecret != "secret-1" {
t.Errorf("ClientSecret = %q, want secret-1", got.ClientSecret)
}
if got.BaseURL != defaultBaseURL {
t.Errorf("BaseURL = %q, want %q", got.BaseURL, defaultBaseURL)
}
}

// A leaked secret is not recoverable, so the 0600 is load-bearing rather than
// cosmetic. Windows has no mode bits to assert.
func TestLoginRestrictsFilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("no Unix mode bits on Windows")
}
dir := sandboxHome(t)
path := filepath.Join(dir, "credentials")

if err := runAuthLoginForTest(t, "--client-id", "id", "--client-secret", "secret"); err != nil {
t.Fatalf("login: %v", err)
}

info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("mode = %#o, want 0600", perm)
}
}

func TestLoginWritesNamedProfileAndBaseURL(t *testing.T) {
dir := sandboxHome(t)
path := filepath.Join(dir, "credentials")

err := runAuthLoginForTest(t,
"--profile", "staging",
"--base-url", "https://staging-api.verda.com/v1",
"--client-id", "stg-id",
"--client-secret", "stg-secret",
)
if err != nil {
t.Fatalf("login: %v", err)
}

got := loadProfile(t, path, "staging")
if got.BaseURL != "https://staging-api.verda.com/v1" {
t.Errorf("BaseURL = %q", got.BaseURL)
}
if got.ClientID != "stg-id" {
t.Errorf("ClientID = %q, want stg-id", got.ClientID)
}

if _, err := options.LoadSharedCredentialsForProfile(path, "default"); err == nil {
t.Error("a [default] section appeared; --profile must write only the named section")
}
}

// The writer merges into the existing INI. Dropping unrelated profiles would
// destroy credentials the user cannot recover from the API.
func TestLoginPreservesOtherProfiles(t *testing.T) {
dir := sandboxHome(t)
path := filepath.Join(dir, "credentials")

seed := "[other]\n" +
"verda_base_url = https://other.verda.com/v1\n" +
"verda_client_id = other-id\n" +
"verda_client_secret = other-secret\n"
if err := os.WriteFile(path, []byte(seed), 0o600); err != nil {
t.Fatalf("seed: %v", err)
}

if err := runAuthLoginForTest(t, "--client-id", "new-id", "--client-secret", "new-secret"); err != nil {
t.Fatalf("login: %v", err)
}

other := loadProfile(t, path, "other")
if other.ClientID != "other-id" || other.ClientSecret != "other-secret" {
t.Errorf("[other] was modified: %+v", other)
}
if added := loadProfile(t, path, "default"); added.ClientID != "new-id" {
t.Errorf("[default] ClientID = %q, want new-id", added.ClientID)
}
}

// Re-running login against a profile is the documented re-auth path: rotating a
// secret must replace the stored one, not append or refuse.
func TestLoginOverwritesSameProfile(t *testing.T) {
dir := sandboxHome(t)
path := filepath.Join(dir, "credentials")

if err := runAuthLoginForTest(t, "--client-id", "old", "--client-secret", "old-secret"); err != nil {
t.Fatalf("first login: %v", err)
}
if err := runAuthLoginForTest(t, "--client-id", "new", "--client-secret", "new-secret"); err != nil {
t.Fatalf("second login: %v", err)
}

got := loadProfile(t, path, "default")
if got.ClientID != "new" || got.ClientSecret != "new-secret" {
t.Errorf("re-login did not replace credentials: %+v", got)
}

data, err := os.ReadFile(path) //nolint:gosec // test-owned temp file
if err != nil {
t.Fatalf("read: %v", err)
}
if n := strings.Count(string(data), "[default]"); n != 1 {
t.Errorf("found %d [default] sections, want 1", n)
}
if strings.Contains(string(data), "old-secret") {
t.Error("the replaced secret is still present in the file")
}
}

// --credentials-file outranks VERDA_SHARED_CREDENTIALS_FILE; sandboxHome sets
// the env var, so a write landing at the flag path proves the precedence.
func TestLoginCredentialsFileFlagWinsOverEnv(t *testing.T) {
dir := sandboxHome(t)
flagPath := filepath.Join(dir, "explicit-credentials")

err := runAuthLoginForTest(t,
"--credentials-file", flagPath,
"--client-id", "flag-id",
"--client-secret", "flag-secret",
)
if err != nil {
t.Fatalf("login: %v", err)
}

if got := loadProfile(t, flagPath, "default"); got.ClientID != "flag-id" {
t.Errorf("ClientID = %q, want flag-id", got.ClientID)
}
if _, err := os.Stat(filepath.Join(dir, "credentials")); !os.IsNotExist(err) {
t.Error("the env-var path was written despite --credentials-file")
}
}
32 changes: 30 additions & 2 deletions internal/verda-cli/cmd/util/iostreams.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,34 @@ type IOStreams struct {
ErrOut io.Writer
}

// terminalWriter is a colorprofile writer that still answers Fd().
//
// The fd must stay reachable through the wrapper: bubbletea and this repo's own
// rendersToTerminal both identify a terminal by asserting the writer to
// term.File and asking for its descriptor. A bare colorprofile.Writer hides it,
// which costs bubbletea term.GetSize — leaving every prompt rendering into a
// 0x0 viewport (a blank screen that looks like a hang) and silencing every
// spinner, progress bar and pager.
//
// Write is promoted from the embedded colorprofile.Writer, so ANSI is still
// downsampled or stripped to suit the destination.
type terminalWriter struct {
*colorprofile.Writer
file *os.File
}

func newTerminalWriter(f *os.File) *terminalWriter {
return &terminalWriter{Writer: colorprofile.NewWriter(f, os.Environ()), file: f}
}

func (w *terminalWriter) Fd() uintptr { return w.file.Fd() }

// Read and Close exist only to satisfy term.File; nothing in the stack calls
// either on an output stream. Close is a deliberate no-op — closing the
// process's own stdout or stderr is never what a caller wants.
func (w *terminalWriter) Read(p []byte) (int, error) { return w.file.Read(p) }
func (w *terminalWriter) Close() error { return nil }

// NewStdIOStreams returns an IOStreams wired to os.Stdin, os.Stdout, and os.Stderr.
//
// Both writers are wrapped in a colorprofile writer, which detects what the
Expand All @@ -43,8 +71,8 @@ type IOStreams struct {
func NewStdIOStreams() IOStreams {
return IOStreams{
In: os.Stdin,
Out: colorprofile.NewWriter(os.Stdout, os.Environ()),
ErrOut: colorprofile.NewWriter(os.Stderr, os.Environ()),
Out: newTerminalWriter(os.Stdout),
ErrOut: newTerminalWriter(os.Stderr),
}
}

Expand Down
Loading