diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4674eda --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: latest + args: --timeout=5m + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... + + - name: Upload coverage + uses: codecov/codecov-action@v5 + with: + files: coverage.out + fail_ci_if_error: false + + build: + name: Build + runs-on: ubuntu-latest + strategy: + matrix: + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + exclude: + - goos: windows + goarch: arm64 + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + go build -ldflags="-s -w" -o azswitch-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }} ./cmd/azswitch + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: azswitch-${{ matrix.goos }}-${{ matrix.goarch }} + path: azswitch-${{ matrix.goos }}-${{ matrix.goarch }}* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ada1b5d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + packages: write + +jobs: + goreleaser: + name: GoReleaser + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + cache: true + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..052b3c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Build output +/bin/ +/dist/ + +# Test binary +*.test + +# Coverage reports +/coverage/ + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Debug +debug.log diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..4e5bd56 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,75 @@ +version: "2" +run: + issues-exit-code: 1 +linters: + enable: + - gocritic + - misspell + - revive + - unconvert + settings: + gocritic: + disabled-checks: + - hugeParam + enabled-tags: + - diagnostic + - style + - performance + misspell: + locale: US + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: if-return + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - errcheck + - gocritic + path: _test\.go + paths: + - third_party$ + - builtin$ + - examples$ +issues: + max-issues-per-linter: 0 + max-same-issues: 0 +formatters: + enable: + - gofmt + - goimports + settings: + gofmt: + simplify: true + goimports: + local-prefixes: + - github.com/l2D/azswitch + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..7390603 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,127 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - id: azswitch + main: ./cmd/azswitch + binary: azswitch + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w + - -X github.com/l2D/azswitch/internal/version.Version={{.Version}} + - -X github.com/l2D/azswitch/internal/version.CommitSHA={{.Commit}} + - -X github.com/l2D/azswitch/internal/version.BuildTime={{.Date}} + +archives: + - id: default + name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' + format: tar.gz + format_overrides: + - goos: windows + format: zip + files: + - LICENSE + - README.md + +checksum: + name_template: 'checksums.txt' + +snapshot: + name_template: '{{ incpatch .Version }}-next' + +changelog: + sort: desc + use: github + groups: + - title: Features + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' + order: 0 + - title: Bug Fixes + regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' + order: 1 + - title: Performance Improvements + regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$' + order: 2 + - title: Documentation + regexp: '^.*?docs(\([[:word:]]+\))??!?:.+$' + order: 3 + - title: Others + order: 999 + filters: + exclude: + - '^style:' + - '^chore:' + - '^ci:' + +release: + github: + owner: l2D + name: azswitch + draft: false + prerelease: auto + footer: | + ## Docker Image + ``` + docker pull ghcr.io/l2d/azswitch:{{ .Tag }} + ``` + + ## Quick Install + ```bash + # Go install + go install github.com/l2D/azswitch/cmd/azswitch@{{ .Tag }} + ``` + +dockers: + - image_templates: + - 'ghcr.io/l2d/azswitch:{{ .Tag }}-amd64' + dockerfile: Dockerfile + use: buildx + build_flag_templates: + - '--pull' + - '--platform=linux/amd64' + - '--label=org.opencontainers.image.created={{.Date}}' + - '--label=org.opencontainers.image.title={{.ProjectName}}' + - '--label=org.opencontainers.image.revision={{.FullCommit}}' + - '--label=org.opencontainers.image.version={{.Version}}' + - '--label=org.opencontainers.image.source={{.GitURL}}' + goarch: amd64 + + - image_templates: + - 'ghcr.io/l2d/azswitch:{{ .Tag }}-arm64' + dockerfile: Dockerfile + use: buildx + build_flag_templates: + - '--pull' + - '--platform=linux/arm64' + - '--label=org.opencontainers.image.created={{.Date}}' + - '--label=org.opencontainers.image.title={{.ProjectName}}' + - '--label=org.opencontainers.image.revision={{.FullCommit}}' + - '--label=org.opencontainers.image.version={{.Version}}' + - '--label=org.opencontainers.image.source={{.GitURL}}' + goarch: arm64 + +docker_manifests: + - name_template: 'ghcr.io/l2d/azswitch:{{ .Tag }}' + image_templates: + - 'ghcr.io/l2d/azswitch:{{ .Tag }}-amd64' + - 'ghcr.io/l2d/azswitch:{{ .Tag }}-arm64' + + - name_template: 'ghcr.io/l2d/azswitch:latest' + image_templates: + - 'ghcr.io/l2d/azswitch:{{ .Tag }}-amd64' + - 'ghcr.io/l2d/azswitch:{{ .Tag }}-arm64' diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..939f37e --- /dev/null +++ b/.mise.toml @@ -0,0 +1,10 @@ +[tools] +act = "0.2.84" +actionlint = "1.7.10" +azure-cli = "2.82.0" +gitleaks = "8.24.3" +go = "1.25.6" +golangci-lint = "2.8.0" +pre-commit = "4.5.1" +trivy = "0.68.2" +yamlfmt = "0.21.0" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..789e4b1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + # Go formatting + - repo: https://github.com/tekwizely/pre-commit-golang + rev: v1.0.0-rc.4 + hooks: + - id: go-fmt + args: [-w] + + # Go linting with golangci-lint v2 (includes formatters: gofmt, goimports) + - repo: https://github.com/golangci/golangci-lint + rev: v2.8.0 + hooks: + - id: golangci-lint + args: [--config=.golangci.yml, --fix] + + # Gitleaks - Secret scanning + - repo: https://github.com/gitleaks/gitleaks + rev: v8.30.0 + hooks: + - id: gitleaks + + # Trivy - Security scanning for filesystem + - repo: local + hooks: + - id: trivy-fs + name: Trivy filesystem scan + entry: trivy fs --exit-code 1 --severity HIGH,CRITICAL --scanners vuln,misconfig,secret . + language: system + pass_filenames: false + stages: [pre-commit] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d750354 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,110 @@ +# Contributing to azswitch + +Thank you for your interest in contributing to azswitch! This document provides guidelines and information about contributing. + +## Development Setup + +### Prerequisites + +- Go 1.25 or later +- Azure CLI (for testing) +- golangci-lint (for linting) +- Docker (optional, for container builds) + +### Getting Started + +1. Fork the repository +2. Clone your fork: + + ```bash + git clone https://github.com/YOUR_USERNAME/azswitch.git + cd azswitch + ``` + +3. Install dependencies: + + ```bash + go mod download + ``` + +4. Build: + + ```bash + make build + ``` + +5. Run tests: + + ```bash + make test + ``` + +## Making Changes + +### Code Style + +- Follow standard Go conventions +- Run `make fmt` before committing +- Run `make lint` to check for issues +- Write tests for new functionality + +### Commit Messages + +This project follows [Conventional Commits](https://www.conventionalcommits.org/): + +- `feat:` - New features +- `fix:` - Bug fixes +- `docs:` - Documentation changes +- `refactor:` - Code refactoring +- `test:` - Test changes +- `chore:` - Maintenance tasks + +Example: + +```sh +feat(tui): add search filtering for subscriptions + +- Add fuzzy search to subscription list +- Highlight matching characters +``` + +### Pull Requests + +1. Create a feature branch from `main` +2. Make your changes +3. Run tests and linting +4. Push your branch +5. Open a Pull Request + +## Project Structure + +```sh +azswitch/ +├── cmd/azswitch/ # Application entry point +├── internal/ +│ ├── azure/ # Azure CLI wrapper +│ ├── tui/ # Bubble Tea TUI +│ └── version/ # Version info +├── .github/workflows/ # CI/CD +└── Makefile # Build automation +``` + +## Testing + +### Running Tests + +```bash +# Run all tests +make test + +# Run with coverage +make coverage +``` + +### Mock Client + +Use `azure.NewMockClient()` for testing TUI components without Azure CLI. + +## Questions? + +Feel free to open an issue for questions or discussions. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..212fd67 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git ca-certificates + +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o azswitch ./cmd/azswitch + +# Runtime stage with Azure CLI +FROM mcr.microsoft.com/azure-cli:2.82.0 + +LABEL org.opencontainers.image.title="azswitch" +LABEL org.opencontainers.image.description="TUI for switching Azure tenants and subscriptions" +LABEL org.opencontainers.image.source="https://github.com/l2D/azswitch" +LABEL org.opencontainers.image.licenses="MIT" + +# Create non-root user +RUN tdnf install -y shadow-utils && \ + groupadd -g 1000 azswitch && \ + useradd -u 1000 -g azswitch -d /home/azswitch -s /bin/sh -m azswitch && \ + tdnf clean all + +# Copy binary from builder +COPY --from=builder /app/azswitch /usr/local/bin/azswitch + +# Set up Azure CLI cache directory with proper ownership +RUN mkdir -p /home/azswitch/.azure && \ + chown -R azswitch:azswitch /home/azswitch/.azure + +USER azswitch +WORKDIR /home/azswitch + +ENTRYPOINT ["/usr/local/bin/azswitch"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b3bbf45 --- /dev/null +++ b/Makefile @@ -0,0 +1,98 @@ +.PHONY: build test lint clean install run fmt tidy coverage setup pre-commit + +# Build variables +BINARY_NAME=azswitch +VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +COMMIT_SHA?=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_TIME=$(shell date -u '+%Y-%m-%dT%H:%M:%SZ') +LDFLAGS=-ldflags "-s -w -X github.com/l2D/azswitch/internal/version.Version=$(VERSION) -X github.com/l2D/azswitch/internal/version.CommitSHA=$(COMMIT_SHA) -X github.com/l2D/azswitch/internal/version.BuildTime=$(BUILD_TIME)" + +# Go commands +GOCMD=go +GOBUILD=$(GOCMD) build +GOTEST=$(GOCMD) test +GOGET=$(GOCMD) get +GOFMT=$(GOCMD) fmt +GOMOD=$(GOCMD) mod + +# Default target +all: lint test build + +# Build the binary +build: + @mkdir -p bin + $(GOBUILD) $(LDFLAGS) -o bin/$(BINARY_NAME) ./cmd/azswitch + +# Build for all platforms +build-all: + @mkdir -p dist + GOOS=darwin GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o dist/$(BINARY_NAME)-darwin-amd64 ./cmd/azswitch + GOOS=darwin GOARCH=arm64 $(GOBUILD) $(LDFLAGS) -o dist/$(BINARY_NAME)-darwin-arm64 ./cmd/azswitch + GOOS=linux GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o dist/$(BINARY_NAME)-linux-amd64 ./cmd/azswitch + GOOS=linux GOARCH=arm64 $(GOBUILD) $(LDFLAGS) -o dist/$(BINARY_NAME)-linux-arm64 ./cmd/azswitch + GOOS=windows GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o dist/$(BINARY_NAME)-windows-amd64.exe ./cmd/azswitch + +# Run tests +test: + $(GOTEST) -v -race ./... + +# Run tests with coverage +coverage: + @mkdir -p coverage + $(GOTEST) -v -race -coverprofile=coverage/coverage.out -covermode=atomic ./... + $(GOCMD) tool cover -html=coverage/coverage.out -o coverage/coverage.html + +# Run linter +lint: + golangci-lint run ./... + +# Format code +fmt: + $(GOFMT) ./... + +# Tidy dependencies +tidy: + $(GOMOD) tidy + +# Install the binary +install: build + cp bin/$(BINARY_NAME) $(GOPATH)/bin/ + +# Run the application +run: build + ./bin/$(BINARY_NAME) + +# Clean build artifacts +clean: + rm -rf bin/ + rm -rf dist/ + rm -rf coverage/ + +# Development: run with live reload (requires air) +dev: + air + +# Setup pre-commit hooks +setup: + pre-commit install + +# Run pre-commit on all files +pre-commit: + pre-commit run --all-files + +# Help +help: + @echo "Available targets:" + @echo " build - Build the binary" + @echo " build-all - Build for all platforms" + @echo " test - Run tests" + @echo " coverage - Run tests with coverage report" + @echo " lint - Run linter" + @echo " fmt - Format code" + @echo " tidy - Tidy dependencies" + @echo " install - Install binary to GOPATH/bin" + @echo " run - Build and run" + @echo " clean - Remove build artifacts" + @echo " dev - Run with live reload (requires air)" + @echo " setup - Install pre-commit hooks" + @echo " pre-commit - Run pre-commit on all files" diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d8b12a --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# azswitch + +A TUI application for switching Azure tenants, directories, and subscriptions. + +![Demo](docs/demo.gif) + +## Features + +- **Interactive TUI** - Navigate with keyboard (vim-style j/k or arrows) +- **View Current Account** - See active user, tenant, and subscription +- **Switch Subscriptions** - Quick selection from available subscriptions +- **Switch Tenants** - Re-authenticate to a different Azure AD tenant +- **CLI Mode** - Non-interactive flags for scripting + +## Installation + +### Homebrew (macOS/Linux) + +```bash +brew install l2D/tap/azswitch +``` + +### Go Install + +```bash +go install github.com/l2D/azswitch/cmd/azswitch@latest +``` + +### Binary Download + +Download the latest release from [GitHub Releases](https://github.com/l2D/azswitch/releases). + +### Docker + +```bash +docker pull ghcr.io/l2d/azswitch +``` + +#### Running with existing Azure credentials + +Mount your local Azure CLI configuration: + +```bash +docker run --rm -it -v ~/.azure:/home/azswitch/.azure ghcr.io/l2d/azswitch +``` + +#### Running with CLI flags + +```bash +# Show current account +docker run --rm -v ~/.azure:/home/azswitch/.azure ghcr.io/l2d/azswitch --current + +# List subscriptions +docker run --rm -v ~/.azure:/home/azswitch/.azure ghcr.io/l2d/azswitch --list +``` + +#### Interactive shell with Azure CLI + +To access the container shell with Azure CLI: + +```bash +docker run --rm -it -v ~/.azure:/home/azswitch/.azure --entrypoint /bin/sh ghcr.io/l2d/azswitch +``` + +#### Building locally + +```bash +docker build -t azswitch . +docker run --rm -it -v ~/.azure:/home/azswitch/.azure azswitch +``` + +## Prerequisites + +- [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) must be installed +- Must be logged in (`az login`) + +## Usage + +### Interactive Mode (Default) + +```bash +azswitch +``` + +### CLI Flags + +```bash +# Show current account +azswitch --current + +# List all subscriptions +azswitch --list + +# Switch to subscription by name or ID +azswitch --subscription "My Subscription" +azswitch --subscription xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# Switch to a different tenant +azswitch --tenant xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +## Key Bindings + +| Key | Action | +|-----|--------| +| `j` / `Down` | Move cursor down | +| `k` / `Up` | Move cursor up | +| `Enter` | Select item | +| `Tab` | Switch between subscriptions/tenants view | +| `?` | Toggle help | +| `q` / `Ctrl+C` | Quit | + +## Development + +### Build + +```bash +make build +``` + +### Test + +```bash +make test +``` + +### Lint + +```bash +make lint +``` + +## License + +[MIT](LICENSE) diff --git a/cmd/azswitch/main.go b/cmd/azswitch/main.go new file mode 100644 index 0000000..dd28091 --- /dev/null +++ b/cmd/azswitch/main.go @@ -0,0 +1,157 @@ +// Package main is the entry point for azswitch. +package main + +import ( + "context" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + + "github.com/l2D/azswitch/internal/azure" + "github.com/l2D/azswitch/internal/tui" + "github.com/l2D/azswitch/internal/version" +) + +var ( + // Flags + flagList bool + flagCurrent bool + flagSubscription string + flagTenant string +) + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var rootCmd = &cobra.Command{ + Use: "azswitch", + Short: "Switch Azure tenants, directories, and subscriptions", + Long: `azswitch is a TUI application for switching Azure tenants, +directories, and subscriptions. + +Run without flags to enter interactive mode.`, + Version: version.Short(), + RunE: run, +} + +func init() { + rootCmd.Flags().BoolVarP(&flagList, "list", "l", false, "List all subscriptions") + rootCmd.Flags().BoolVarP(&flagCurrent, "current", "c", false, "Show current account") + rootCmd.Flags().StringVarP(&flagSubscription, "subscription", "s", "", "Switch to subscription by ID or name") + rootCmd.Flags().StringVarP(&flagTenant, "tenant", "t", "", "Switch to tenant by ID") + + rootCmd.SetVersionTemplate("{{.Version}}\n") +} + +func run(_ *cobra.Command, _ []string) error { + client := azure.NewCLIClient() + ctx := context.Background() + + // Check if Azure CLI is installed + if err := client.CheckCLI(ctx); err != nil { + //nolint:staticcheck // ST1005: Azure CLI is a proper noun + return fmt.Errorf("Azure CLI is not installed. Install from: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli") + } + + // Check if logged in + if err := client.CheckLogin(ctx); err != nil { + return fmt.Errorf("not logged in to Azure CLI. Run: az login") + } + + // Handle non-interactive flags + if flagCurrent { + return showCurrent(ctx, client) + } + + if flagList { + return listSubscriptions(ctx, client) + } + + if flagSubscription != "" { + return switchSubscription(ctx, client, flagSubscription) + } + + if flagTenant != "" { + return switchTenant(ctx, client, flagTenant) + } + + // Interactive mode + return runInteractive(client) +} + +func showCurrent(ctx context.Context, client azure.Client) error { + account, err := client.GetCurrentAccount(ctx) + if err != nil { + return err + } + + fmt.Println("Current Azure Account:") + fmt.Printf(" User: %s\n", account.User.Name) + fmt.Printf(" Tenant: %s (%s)\n", account.TenantDisplayName, account.TenantID) + fmt.Printf(" Subscription: %s\n", account.Name) + fmt.Printf(" ID: %s\n", account.ID) + fmt.Printf(" State: %s\n", account.State) + + return nil +} + +func listSubscriptions(ctx context.Context, client azure.Client) error { + subs, err := client.ListSubscriptions(ctx) + if err != nil { + return err + } + + fmt.Println("Available Subscriptions:") + for i := range subs { + sub := &subs[i] + indicator := " " + if sub.IsDefault { + indicator = "* " + } + fmt.Printf("%s%s\n", indicator, sub.Name) + fmt.Printf(" ID: %s\n", sub.ID) + fmt.Printf(" State: %s\n", sub.State) + } + + return nil +} + +func switchSubscription(ctx context.Context, client azure.Client, subscription string) error { + fmt.Printf("Switching to subscription: %s\n", subscription) + + if err := client.SetSubscription(ctx, subscription); err != nil { + return fmt.Errorf("failed to switch subscription: %w", err) + } + + fmt.Println("Successfully switched subscription") + return showCurrent(ctx, client) +} + +func switchTenant(ctx context.Context, client azure.Client, tenant string) error { + fmt.Printf("Switching to tenant: %s\n", tenant) + fmt.Println("This will open a browser for authentication...") + + if err := client.LoginToTenant(ctx, tenant); err != nil { + return fmt.Errorf("failed to switch tenant: %w", err) + } + + fmt.Println("Successfully switched tenant") + return showCurrent(ctx, client) +} + +func runInteractive(client azure.Client) error { + model := tui.NewModel(client) + + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + return fmt.Errorf("error running TUI: %w", err) + } + + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..42d712c --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module github.com/l2D/azswitch + +go 1.25.6 + +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e3b12dd --- /dev/null +++ b/go.sum @@ -0,0 +1,59 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/azure/client.go b/internal/azure/client.go new file mode 100644 index 0000000..b2a0d82 --- /dev/null +++ b/internal/azure/client.go @@ -0,0 +1,153 @@ +package azure + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" +) + +// Common errors. +var ( + ErrAzureCLINotInstalled = errors.New("azure CLI is not installed") + ErrNotLoggedIn = errors.New("not logged in to Azure CLI") + ErrCommandFailed = errors.New("azure CLI command failed") +) + +// Client defines the interface for Azure CLI operations. +type Client interface { + // CheckCLI verifies that Azure CLI is installed. + CheckCLI(ctx context.Context) error + + // CheckLogin verifies that the user is logged in. + CheckLogin(ctx context.Context) error + + // GetCurrentAccount returns the current Azure account information. + GetCurrentAccount(ctx context.Context) (*Account, error) + + // ListSubscriptions returns all available subscriptions. + ListSubscriptions(ctx context.Context) ([]Subscription, error) + + // ListTenants returns all available tenants. + ListTenants(ctx context.Context) ([]Tenant, error) + + // SetSubscription switches to the specified subscription. + SetSubscription(ctx context.Context, subscriptionIDOrName string) error + + // LoginToTenant logs in to a specific tenant. + LoginToTenant(ctx context.Context, tenantID string) error +} + +// CLIClient implements Client using the Azure CLI. +type CLIClient struct { + // azPath is the path to the az CLI binary. + azPath string +} + +// NewCLIClient creates a new Azure CLI client. +func NewCLIClient() *CLIClient { + return &CLIClient{ + azPath: "az", + } +} + +// CheckCLI verifies that Azure CLI is installed. +func (c *CLIClient) CheckCLI(ctx context.Context) error { + _, err := exec.LookPath(c.azPath) + if err != nil { + return ErrAzureCLINotInstalled + } + return nil +} + +// CheckLogin verifies that the user is logged in. +func (c *CLIClient) CheckLogin(ctx context.Context) error { + _, err := c.GetCurrentAccount(ctx) + if err != nil { + if strings.Contains(err.Error(), "Please run 'az login'") || + strings.Contains(err.Error(), "not logged in") { + return ErrNotLoggedIn + } + return err + } + return nil +} + +// GetCurrentAccount returns the current Azure account information. +func (c *CLIClient) GetCurrentAccount(ctx context.Context) (*Account, error) { + output, err := c.runCommand(ctx, "account", "show", "--output", "json") + if err != nil { + return nil, err + } + + var account Account + if err := json.Unmarshal(output, &account); err != nil { + return nil, fmt.Errorf("failed to parse account: %w", err) + } + + return &account, nil +} + +// ListSubscriptions returns all available subscriptions. +func (c *CLIClient) ListSubscriptions(ctx context.Context) ([]Subscription, error) { + output, err := c.runCommand(ctx, "account", "list", "--output", "json") + if err != nil { + return nil, err + } + + var subscriptions []Subscription + if err := json.Unmarshal(output, &subscriptions); err != nil { + return nil, fmt.Errorf("failed to parse subscriptions: %w", err) + } + + return subscriptions, nil +} + +// ListTenants returns all available tenants. +func (c *CLIClient) ListTenants(ctx context.Context) ([]Tenant, error) { + output, err := c.runCommand(ctx, "account", "tenant", "list", "--output", "json") + if err != nil { + return nil, err + } + + var tenants []Tenant + if err := json.Unmarshal(output, &tenants); err != nil { + return nil, fmt.Errorf("failed to parse tenants: %w", err) + } + + return tenants, nil +} + +// SetSubscription switches to the specified subscription. +func (c *CLIClient) SetSubscription(ctx context.Context, subscriptionIDOrName string) error { + _, err := c.runCommand(ctx, "account", "set", "--subscription", subscriptionIDOrName) + return err +} + +// LoginToTenant logs in to a specific tenant. +func (c *CLIClient) LoginToTenant(ctx context.Context, tenantID string) error { + _, err := c.runCommand(ctx, "login", "--tenant", tenantID, "--output", "none") + return err +} + +// runCommand executes an Azure CLI command and returns the output. +func (c *CLIClient) runCommand(ctx context.Context, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, c.azPath, args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errMsg := stderr.String() + if errMsg == "" { + errMsg = err.Error() + } + return nil, fmt.Errorf("%w: %s", ErrCommandFailed, strings.TrimSpace(errMsg)) + } + + return stdout.Bytes(), nil +} diff --git a/internal/azure/client_test.go b/internal/azure/client_test.go new file mode 100644 index 0000000..3860aba --- /dev/null +++ b/internal/azure/client_test.go @@ -0,0 +1,180 @@ +package azure + +import ( + "context" + "testing" +) + +func TestMockClient_GetCurrentAccount(t *testing.T) { + client := NewMockClient() + ctx := context.Background() + + account, err := client.GetCurrentAccount(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if account.Name != "Test Subscription" { + t.Errorf("expected name 'Test Subscription', got '%s'", account.Name) + } + + if account.User.Name != "test@example.com" { + t.Errorf("expected user 'test@example.com', got '%s'", account.User.Name) + } + + if client.Calls.GetCurrentAccount != 1 { + t.Errorf("expected 1 call, got %d", client.Calls.GetCurrentAccount) + } +} + +func TestMockClient_ListSubscriptions(t *testing.T) { + client := NewMockClient() + ctx := context.Background() + + subs, err := client.ListSubscriptions(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(subs) != 2 { + t.Errorf("expected 2 subscriptions, got %d", len(subs)) + } + + if subs[0].Name != "Test Subscription 1" { + t.Errorf("expected 'Test Subscription 1', got '%s'", subs[0].Name) + } + + if !subs[0].IsDefault { + t.Error("expected first subscription to be default") + } +} + +func TestMockClient_ListTenants(t *testing.T) { + client := NewMockClient() + ctx := context.Background() + + tenants, err := client.ListTenants(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(tenants) != 2 { + t.Errorf("expected 2 tenants, got %d", len(tenants)) + } + + if tenants[0].DisplayName != "Test Tenant" { + t.Errorf("expected 'Test Tenant', got '%s'", tenants[0].DisplayName) + } +} + +func TestMockClient_SetSubscription(t *testing.T) { + client := NewMockClient() + ctx := context.Background() + + err := client.SetSubscription(ctx, "test-subscription-id") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(client.Calls.SetSubscription) != 1 { + t.Errorf("expected 1 call, got %d", len(client.Calls.SetSubscription)) + } + + if client.Calls.SetSubscription[0] != "test-subscription-id" { + t.Errorf("expected 'test-subscription-id', got '%s'", client.Calls.SetSubscription[0]) + } +} + +func TestMockClient_LoginToTenant(t *testing.T) { + client := NewMockClient() + ctx := context.Background() + + err := client.LoginToTenant(ctx, "test-tenant-id") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(client.Calls.LoginToTenant) != 1 { + t.Errorf("expected 1 call, got %d", len(client.Calls.LoginToTenant)) + } + + if client.Calls.LoginToTenant[0] != "test-tenant-id" { + t.Errorf("expected 'test-tenant-id', got '%s'", client.Calls.LoginToTenant[0]) + } +} + +func TestSubscription_Methods(t *testing.T) { + sub := Subscription{ + Name: "My Subscription", + ID: "00000000-0000-0000-0000-000000000001", + } + + if sub.Title() != "My Subscription" { + t.Errorf("expected Title() to return 'My Subscription', got '%s'", sub.Title()) + } + + if sub.Description() != "00000000-0000-0000-0000-000000000001" { + t.Errorf("expected Description() to return subscription ID") + } + + if sub.FilterValue() != "My Subscription" { + t.Errorf("expected FilterValue() to return name") + } +} + +func TestTenant_Methods(t *testing.T) { + t.Run("with display name", func(t *testing.T) { + tenant := Tenant{ + DisplayName: "My Tenant", + DefaultDomain: "mytenant.onmicrosoft.com", + TenantID: "00000000-0000-0000-0000-000000000002", + } + + if tenant.Title() != "My Tenant" { + t.Errorf("expected Title() to return 'My Tenant', got '%s'", tenant.Title()) + } + + if tenant.FilterValue() != "My Tenant" { + t.Errorf("expected FilterValue() to return display name") + } + }) + + t.Run("without display name", func(t *testing.T) { + tenant := Tenant{ + DefaultDomain: "mytenant.onmicrosoft.com", + TenantID: "00000000-0000-0000-0000-000000000002", + } + + if tenant.Title() != "mytenant.onmicrosoft.com" { + t.Errorf("expected Title() to return domain, got '%s'", tenant.Title()) + } + + if tenant.FilterValue() != "mytenant.onmicrosoft.com" { + t.Errorf("expected FilterValue() to return domain") + } + }) + + t.Run("description returns tenant ID", func(t *testing.T) { + tenant := Tenant{ + TenantID: "00000000-0000-0000-0000-000000000002", + } + + if tenant.Description() != "00000000-0000-0000-0000-000000000002" { + t.Errorf("expected Description() to return tenant ID") + } + }) +} + +func TestMockClient_CustomBehavior(t *testing.T) { + client := NewMockClient() + + // Override default behavior + client.CheckCLIFunc = func(_ context.Context) error { + return ErrAzureCLINotInstalled + } + + err := client.CheckCLI(context.Background()) + if err != ErrAzureCLINotInstalled { + t.Errorf("expected ErrAzureCLINotInstalled, got %v", err) + } +} diff --git a/internal/azure/mock.go b/internal/azure/mock.go new file mode 100644 index 0000000..e56c114 --- /dev/null +++ b/internal/azure/mock.go @@ -0,0 +1,151 @@ +package azure + +import ( + "context" +) + +// MockClient is a mock implementation of the Azure Client interface for testing. +type MockClient struct { + // CheckCLIFunc is called when CheckCLI is invoked. + CheckCLIFunc func(ctx context.Context) error + + // CheckLoginFunc is called when CheckLogin is invoked. + CheckLoginFunc func(ctx context.Context) error + + // GetCurrentAccountFunc is called when GetCurrentAccount is invoked. + GetCurrentAccountFunc func(ctx context.Context) (*Account, error) + + // ListSubscriptionsFunc is called when ListSubscriptions is invoked. + ListSubscriptionsFunc func(ctx context.Context) ([]Subscription, error) + + // ListTenantsFunc is called when ListTenants is invoked. + ListTenantsFunc func(ctx context.Context) ([]Tenant, error) + + // SetSubscriptionFunc is called when SetSubscription is invoked. + SetSubscriptionFunc func(ctx context.Context, subscriptionIDOrName string) error + + // LoginToTenantFunc is called when LoginToTenant is invoked. + LoginToTenantFunc func(ctx context.Context, tenantID string) error + + // Calls tracks function call history. + Calls struct { + CheckCLI int + CheckLogin int + GetCurrentAccount int + ListSubscriptions int + ListTenants int + SetSubscription []string + LoginToTenant []string + } +} + +// NewMockClient creates a new mock client with default implementations. +func NewMockClient() *MockClient { + return &MockClient{ + CheckCLIFunc: func(_ context.Context) error { + return nil + }, + CheckLoginFunc: func(_ context.Context) error { + return nil + }, + GetCurrentAccountFunc: func(_ context.Context) (*Account, error) { + return &Account{ + Name: "Test Subscription", + ID: "00000000-0000-0000-0000-000000000001", + TenantID: "00000000-0000-0000-0000-000000000002", + TenantDisplayName: "Test Tenant", + IsDefault: true, + State: "Enabled", + User: User{ + Name: "test@example.com", + Type: "user", + }, + }, nil + }, + ListSubscriptionsFunc: func(_ context.Context) ([]Subscription, error) { + return []Subscription{ + { + Name: "Test Subscription 1", + ID: "00000000-0000-0000-0000-000000000001", + TenantID: "00000000-0000-0000-0000-000000000002", + TenantDisplayName: "Test Tenant", + IsDefault: true, + State: "Enabled", + }, + { + Name: "Test Subscription 2", + ID: "00000000-0000-0000-0000-000000000003", + TenantID: "00000000-0000-0000-0000-000000000002", + TenantDisplayName: "Test Tenant", + IsDefault: false, + State: "Enabled", + }, + }, nil + }, + ListTenantsFunc: func(_ context.Context) ([]Tenant, error) { + return []Tenant{ + { + DisplayName: "Test Tenant", + TenantID: "00000000-0000-0000-0000-000000000002", + DefaultDomain: "test.onmicrosoft.com", + }, + { + DisplayName: "Another Tenant", + TenantID: "00000000-0000-0000-0000-000000000004", + DefaultDomain: "another.onmicrosoft.com", + }, + }, nil + }, + SetSubscriptionFunc: func(_ context.Context, _ string) error { + return nil + }, + LoginToTenantFunc: func(_ context.Context, _ string) error { + return nil + }, + } +} + +// CheckCLI implements Client. +func (m *MockClient) CheckCLI(ctx context.Context) error { + m.Calls.CheckCLI++ + return m.CheckCLIFunc(ctx) +} + +// CheckLogin implements Client. +func (m *MockClient) CheckLogin(ctx context.Context) error { + m.Calls.CheckLogin++ + return m.CheckLoginFunc(ctx) +} + +// GetCurrentAccount implements Client. +func (m *MockClient) GetCurrentAccount(ctx context.Context) (*Account, error) { + m.Calls.GetCurrentAccount++ + return m.GetCurrentAccountFunc(ctx) +} + +// ListSubscriptions implements Client. +func (m *MockClient) ListSubscriptions(ctx context.Context) ([]Subscription, error) { + m.Calls.ListSubscriptions++ + return m.ListSubscriptionsFunc(ctx) +} + +// ListTenants implements Client. +func (m *MockClient) ListTenants(ctx context.Context) ([]Tenant, error) { + m.Calls.ListTenants++ + return m.ListTenantsFunc(ctx) +} + +// SetSubscription implements Client. +func (m *MockClient) SetSubscription(ctx context.Context, subscriptionIDOrName string) error { + m.Calls.SetSubscription = append(m.Calls.SetSubscription, subscriptionIDOrName) + return m.SetSubscriptionFunc(ctx, subscriptionIDOrName) +} + +// LoginToTenant implements Client. +func (m *MockClient) LoginToTenant(ctx context.Context, tenantID string) error { + m.Calls.LoginToTenant = append(m.Calls.LoginToTenant, tenantID) + return m.LoginToTenantFunc(ctx, tenantID) +} + +// Ensure MockClient implements Client. +var _ Client = (*MockClient)(nil) diff --git a/internal/azure/types.go b/internal/azure/types.go new file mode 100644 index 0000000..3712f81 --- /dev/null +++ b/internal/azure/types.go @@ -0,0 +1,91 @@ +// Package azure provides Azure CLI wrapper functionality. +package azure + +// Account represents the current Azure account information. +type Account struct { + EnvironmentName string `json:"environmentName"` + HomeTenantID string `json:"homeTenantId"` + ID string `json:"id"` + IsDefault bool `json:"isDefault"` + ManagedByTenants []any `json:"managedByTenants"` + Name string `json:"name"` + State string `json:"state"` + TenantDisplayName string `json:"tenantDisplayName"` + TenantID string `json:"tenantId"` + User User `json:"user"` +} + +// User represents the user information within an account. +type User struct { + Name string `json:"name"` + Type string `json:"type"` +} + +// Subscription represents an Azure subscription. +type Subscription struct { + CloudName string `json:"cloudName"` + HomeTenantID string `json:"homeTenantId"` + ID string `json:"id"` + IsDefault bool `json:"isDefault"` + ManagedByTenants []any `json:"managedByTenants"` + Name string `json:"name"` + State string `json:"state"` + TenantDisplayName string `json:"tenantDisplayName"` + TenantID string `json:"tenantId"` + User User `json:"user"` +} + +// Tenant represents an Azure AD tenant/directory. +type Tenant struct { + DefaultDomain string `json:"defaultDomain"` + DisplayName string `json:"displayName"` + ID string `json:"id"` + TenantID string `json:"tenantId"` + TenantCategory string `json:"tenantCategory"` + TenantType string `json:"tenantType"` + Domains []string `json:"domains,omitempty"` + CountryCode string `json:"countryCode,omitempty"` + TenantBrandName string `json:"tenantBrandingLogoUrl,omitempty"` +} + +// Title returns a display title for the subscription. +func (s Subscription) Title() string { + return s.Name +} + +// Description returns a description for the subscription. +func (s Subscription) Description() string { + return s.ID +} + +// FilterValue returns the value used for filtering. +func (s Subscription) FilterValue() string { + return s.Name +} + +// Title returns a display title for the tenant. +func (t Tenant) Title() string { + if t.DisplayName != "" { + return t.DisplayName + } + if t.DefaultDomain != "" { + return t.DefaultDomain + } + return t.TenantID +} + +// Description returns a description for the tenant. +func (t Tenant) Description() string { + return t.TenantID +} + +// FilterValue returns the value used for filtering. +func (t Tenant) FilterValue() string { + if t.DisplayName != "" { + return t.DisplayName + } + if t.DefaultDomain != "" { + return t.DefaultDomain + } + return t.TenantID +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..c83dacc --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,67 @@ +package tui + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap defines the key bindings for the application. +type KeyMap struct { + Up key.Binding + Down key.Binding + Select key.Binding + Tab key.Binding + Help key.Binding + Quit key.Binding + Refresh key.Binding + Back key.Binding +} + +// DefaultKeyMap returns the default key bindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch view"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + Refresh: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "refresh"), + ), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + } +} + +// ShortHelp returns a short help text. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Select, k.Tab, k.Quit} +} + +// FullHelp returns the full help text. +func (k KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Select}, + {k.Tab, k.Refresh, k.Back}, + {k.Help, k.Quit}, + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..3069c32 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,477 @@ +package tui + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + + "github.com/l2D/azswitch/internal/azure" +) + +// ViewType represents the current view. +type ViewType int + +const ( + ViewSubscriptions ViewType = iota + ViewDirectories +) + +// State represents the application state. +type State int + +const ( + StateLoading State = iota + StateReady + StateError + StateSwitching + StateSuccess +) + +// Model represents the TUI model. +type Model struct { + // Azure client + client azure.Client + + // Current state + state State + + // Current view + view ViewType + + // Data + account *azure.Account + subscriptions []azure.Subscription + tenants []azure.Tenant + + // UI state + cursor int + tenantCursor int + err error + message string + + // Components + spinner spinner.Model + help help.Model + keys KeyMap + + // Window size + width int + height int + + // Show help + showHelp bool + + // Quit flag + quitting bool +} + +// Messages +type ( + // errMsg is sent when an error occurs. + errMsg struct{ err error } + + // dataLoadedMsg is sent when data is loaded. + dataLoadedMsg struct { + account *azure.Account + subscriptions []azure.Subscription + tenants []azure.Tenant + } + + // switchedMsg is sent when a switch operation completes. + switchedMsg struct { + message string + } +) + +// NewModel creates a new TUI model. +func NewModel(client azure.Client) Model { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = SpinnerStyle + + h := help.New() + h.ShowAll = false + + return Model{ + client: client, + state: StateLoading, + view: ViewSubscriptions, + spinner: s, + help: h, + keys: DefaultKeyMap(), + } +} + +// Init initializes the model. +func (m Model) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + m.loadData(), + ) +} + +// loadData loads the Azure data. +func (m Model) loadData() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + + account, err := m.client.GetCurrentAccount(ctx) + if err != nil { + return errMsg{err} + } + + subs, err := m.client.ListSubscriptions(ctx) + if err != nil { + return errMsg{err} + } + + tenants, err := m.client.ListTenants(ctx) + if err != nil { + return errMsg{err} + } + + return dataLoadedMsg{ + account: account, + subscriptions: subs, + tenants: tenants, + } + } +} + +// Update handles messages and updates the model. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKeyMsg(msg) + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.help.Width = msg.Width + return m, nil + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case errMsg: + m.state = StateError + m.err = msg.err + return m, nil + + case dataLoadedMsg: + m.state = StateReady + m.account = msg.account + m.subscriptions = msg.subscriptions + m.tenants = msg.tenants + // Set cursor to current subscription + for i := range m.subscriptions { + if m.subscriptions[i].IsDefault { + m.cursor = i + break + } + } + return m, nil + + case switchedMsg: + m.state = StateSuccess + m.message = msg.message + return m, m.loadData() + } + + return m, nil +} + +// handleKeyMsg handles keyboard input. +func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Always allow quit + if msg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + + // Don't handle keys while loading or switching + if m.state == StateLoading || m.state == StateSwitching { + return m, nil + } + + switch { + case key.Matches(msg, m.keys.Quit): + m.quitting = true + return m, tea.Quit + + case key.Matches(msg, m.keys.Help): + m.showHelp = !m.showHelp + m.help.ShowAll = m.showHelp + return m, nil + + case key.Matches(msg, m.keys.Tab): + if m.view == ViewSubscriptions { + m.view = ViewDirectories + } else { + m.view = ViewSubscriptions + } + return m, nil + + case key.Matches(msg, m.keys.Up): + if m.view == ViewSubscriptions { + if m.cursor > 0 { + m.cursor-- + } + } else { + if m.tenantCursor > 0 { + m.tenantCursor-- + } + } + return m, nil + + case key.Matches(msg, m.keys.Down): + if m.view == ViewSubscriptions { + if m.cursor < len(m.subscriptions)-1 { + m.cursor++ + } + } else { + if m.tenantCursor < len(m.tenants)-1 { + m.tenantCursor++ + } + } + return m, nil + + case key.Matches(msg, m.keys.Select): + return m.handleSelect() + + case key.Matches(msg, m.keys.Refresh): + m.state = StateLoading + return m, tea.Batch(m.spinner.Tick, m.loadData()) + } + + return m, nil +} + +// handleSelect handles the selection. +func (m Model) handleSelect() (tea.Model, tea.Cmd) { + if m.view == ViewSubscriptions && len(m.subscriptions) > 0 { + sub := m.subscriptions[m.cursor] + if sub.IsDefault { + // Already selected + return m, nil + } + m.state = StateSwitching + return m, tea.Batch( + m.spinner.Tick, + m.switchSubscription(sub.ID), + ) + } else if m.view == ViewDirectories && len(m.tenants) > 0 { + tenant := m.tenants[m.tenantCursor] + m.state = StateSwitching + return m, tea.Batch( + m.spinner.Tick, + m.switchTenant(tenant.TenantID), + ) + } + return m, nil +} + +// switchSubscription switches to the specified subscription. +func (m Model) switchSubscription(id string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + if err := m.client.SetSubscription(ctx, id); err != nil { + return errMsg{err} + } + return switchedMsg{message: "Subscription switched successfully"} + } +} + +// switchTenant switches to the specified tenant using interactive login. +func (m Model) switchTenant(id string) tea.Cmd { + cmd := exec.Command("az", "login", "--tenant", id) + return tea.ExecProcess(cmd, func(err error) tea.Msg { + if err != nil { + return errMsg{err} + } + return switchedMsg{message: "Directory switched successfully"} + }) +} + +// View renders the UI. +func (m Model) View() string { + if m.quitting { + return "" + } + + var s strings.Builder + + // Header with current account + s.WriteString(m.renderHeader()) + s.WriteString("\n") + + // Main content + switch m.state { + case StateLoading: + s.WriteString(m.renderLoading()) + case StateError: + s.WriteString(m.renderError()) + case StateSwitching: + s.WriteString(m.renderSwitching()) + default: + s.WriteString(m.renderTabs()) + s.WriteString("\n") + if m.view == ViewSubscriptions { + s.WriteString(m.renderSubscriptions()) + } else { + s.WriteString(m.renderDirectories()) + } + } + + // Help + s.WriteString("\n") + s.WriteString(HelpStyle.Render(m.help.View(m.keys))) + + return s.String() +} + +// renderHeader renders the header section. +func (m Model) renderHeader() string { + if m.account == nil { + return TitleStyle.Render("Azure Account Switcher") + } + + var content strings.Builder + content.WriteString(TitleStyle.Render("Azure Account Switcher")) + content.WriteString("\n") + content.WriteString(fmt.Sprintf(" %s %s\n", MutedStyle.Render("User:"), m.account.User.Name)) + content.WriteString(fmt.Sprintf(" %s %s\n", MutedStyle.Render("Tenant:"), m.account.TenantDisplayName)) + content.WriteString(fmt.Sprintf(" %s %s", MutedStyle.Render("Subscription:"), CurrentStyle.Render(m.account.Name))) + + return HeaderBoxStyle.Render(content.String()) +} + +// renderTabs renders the tab bar. +func (m Model) renderTabs() string { + subsTab := "Subscriptions" + dirsTab := "Directories" + + if m.view == ViewSubscriptions { + subsTab = ActiveTabStyle.Render(subsTab) + dirsTab = InactiveTabStyle.Render(dirsTab) + } else { + subsTab = InactiveTabStyle.Render(subsTab) + dirsTab = ActiveTabStyle.Render(dirsTab) + } + + return fmt.Sprintf(" %s | %s", subsTab, dirsTab) +} + +// renderLoading renders the loading state. +func (m Model) renderLoading() string { + return fmt.Sprintf("\n %s Loading...", m.spinner.View()) +} + +// renderSwitching renders the switching state. +func (m Model) renderSwitching() string { + return fmt.Sprintf("\n %s Switching...", m.spinner.View()) +} + +// renderError renders the error state. +func (m Model) renderError() string { + return fmt.Sprintf("\n %s %s", ErrorStyle.Render("Error:"), m.err.Error()) +} + +// renderSubscriptions renders the subscriptions list. +func (m Model) renderSubscriptions() string { + if len(m.subscriptions) == 0 { + return MutedStyle.Render("\n No subscriptions found") + } + + var s strings.Builder + s.WriteString("\n") + + for i := range m.subscriptions { + sub := &m.subscriptions[i] + cursor := " " + if i == m.cursor { + cursor = CursorStyle.Render("> ") + } + + name := sub.Name + switch { + case sub.IsDefault: + name = CurrentStyle.Render(name + " ✓") + case i == m.cursor: + name = SelectedStyle.Render(name) + default: + name = NormalStyle.Render(name) + } + + s.WriteString(fmt.Sprintf("%s%s\n", cursor, name)) + s.WriteString(fmt.Sprintf(" %s\n", MutedStyle.Render(sub.ID))) + } + + return s.String() +} + +// renderDirectories renders the directories (tenants) list with their subscriptions. +func (m Model) renderDirectories() string { + if len(m.tenants) == 0 { + return MutedStyle.Render("\n No directories found") + } + + // Group subscriptions by tenant ID + subsByTenant := make(map[string][]azure.Subscription) + for i := range m.subscriptions { + sub := &m.subscriptions[i] + subsByTenant[sub.TenantID] = append(subsByTenant[sub.TenantID], *sub) + } + + var s strings.Builder + s.WriteString("\n") + s.WriteString(WarningStyle.Render(" ⚠ Switching directories will open browser for re-authentication")) + s.WriteString("\n\n") + + for i := range m.tenants { + tenant := &m.tenants[i] + cursor := " " + if i == m.tenantCursor { + cursor = CursorStyle.Render("> ") + } + + name := tenant.Title() + isCurrent := m.account != nil && tenant.TenantID == m.account.TenantID + + switch { + case isCurrent: + name = CurrentStyle.Render(name + " ✓") + case i == m.tenantCursor: + name = SelectedStyle.Render(name) + default: + name = NormalStyle.Render(name) + } + + s.WriteString(fmt.Sprintf("%s%s\n", cursor, name)) + + // Show subscriptions for this directory + if subs, ok := subsByTenant[tenant.TenantID]; ok && len(subs) > 0 { + for j := range subs { + subName := subs[j].Name + if subs[j].IsDefault { + subName = CurrentStyle.Render("• " + subName) + } else { + subName = MutedStyle.Render("• " + subName) + } + s.WriteString(fmt.Sprintf(" %s\n", subName)) + } + } else { + s.WriteString(fmt.Sprintf(" %s\n", MutedStyle.Render("(no subscriptions)"))) + } + } + + return s.String() +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go new file mode 100644 index 0000000..6e16f7d --- /dev/null +++ b/internal/tui/model_test.go @@ -0,0 +1,254 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/l2D/azswitch/internal/azure" +) + +func TestNewModel(t *testing.T) { + client := azure.NewMockClient() + model := NewModel(client) + + if model.state != StateLoading { + t.Errorf("expected state to be StateLoading, got %v", model.state) + } + + if model.view != ViewSubscriptions { + t.Errorf("expected view to be ViewSubscriptions, got %v", model.view) + } +} + +func TestModel_Update_DataLoaded(t *testing.T) { + client := azure.NewMockClient() + model := NewModel(client) + + msg := dataLoadedMsg{ + account: &azure.Account{ + Name: "Test Sub", + TenantDisplayName: "Test Tenant", + User: azure.User{Name: "test@test.com"}, + }, + subscriptions: []azure.Subscription{ + {Name: "Sub 1", ID: "id-1", IsDefault: false}, + {Name: "Sub 2", ID: "id-2", IsDefault: true}, + }, + tenants: []azure.Tenant{ + {DisplayName: "Tenant 1", TenantID: "tid-1"}, + }, + } + + newModel, _ := model.Update(msg) + m := newModel.(Model) + + if m.state != StateReady { + t.Errorf("expected state to be StateReady, got %v", m.state) + } + + if m.account == nil { + t.Fatal("expected account to be set") + } + + if len(m.subscriptions) != 2 { + t.Errorf("expected 2 subscriptions, got %d", len(m.subscriptions)) + } + + // Cursor should be on the default subscription (index 1) + if m.cursor != 1 { + t.Errorf("expected cursor to be 1 (default sub), got %d", m.cursor) + } +} + +func TestModel_Update_KeyNavigation(t *testing.T) { + client := azure.NewMockClient() + model := NewModel(client) + + // Load data first + model.state = StateReady + model.subscriptions = []azure.Subscription{ + {Name: "Sub 1", ID: "id-1"}, + {Name: "Sub 2", ID: "id-2"}, + {Name: "Sub 3", ID: "id-3"}, + } + model.cursor = 0 + + // Test down navigation + msg := tea.KeyMsg{Type: tea.KeyDown} + newModel, _ := model.Update(msg) + m := newModel.(Model) + + if m.cursor != 1 { + t.Errorf("expected cursor to be 1 after down, got %d", m.cursor) + } + + // Test up navigation + msg = tea.KeyMsg{Type: tea.KeyUp} + newModel, _ = m.Update(msg) + m = newModel.(Model) + + if m.cursor != 0 { + t.Errorf("expected cursor to be 0 after up, got %d", m.cursor) + } +} + +func TestModel_Update_TabSwitch(t *testing.T) { + client := azure.NewMockClient() + model := NewModel(client) + model.state = StateReady + + if model.view != ViewSubscriptions { + t.Errorf("expected initial view to be ViewSubscriptions") + } + + // Press tab + msg := tea.KeyMsg{Type: tea.KeyTab} + newModel, _ := model.Update(msg) + m := newModel.(Model) + + if m.view != ViewDirectories { + t.Errorf("expected view to be ViewDirectories after tab, got %v", m.view) + } + + // Press tab again + newModel, _ = m.Update(msg) + m = newModel.(Model) + + if m.view != ViewSubscriptions { + t.Errorf("expected view to be ViewSubscriptions after second tab, got %v", m.view) + } +} + +func TestModel_Update_ErrorMsg(t *testing.T) { + client := azure.NewMockClient() + model := NewModel(client) + + msg := errMsg{err: azure.ErrNotLoggedIn} + newModel, _ := model.Update(msg) + m := newModel.(Model) + + if m.state != StateError { + t.Errorf("expected state to be StateError, got %v", m.state) + } + + if m.err != azure.ErrNotLoggedIn { + t.Errorf("expected error to be ErrNotLoggedIn") + } +} + +func TestModel_Update_WindowSize(t *testing.T) { + client := azure.NewMockClient() + model := NewModel(client) + + msg := tea.WindowSizeMsg{Width: 100, Height: 50} + newModel, _ := model.Update(msg) + m := newModel.(Model) + + if m.width != 100 { + t.Errorf("expected width to be 100, got %d", m.width) + } + + if m.height != 50 { + t.Errorf("expected height to be 50, got %d", m.height) + } +} + +func TestModel_View_Loading(t *testing.T) { + client := azure.NewMockClient() + model := NewModel(client) + model.state = StateLoading + + view := model.View() + + if view == "" { + t.Error("expected non-empty view") + } +} + +func TestModel_View_Ready(t *testing.T) { + client := azure.NewMockClient() + model := NewModel(client) + model.state = StateReady + model.account = &azure.Account{ + Name: "Test Sub", + TenantDisplayName: "Test Tenant", + User: azure.User{Name: "test@test.com"}, + } + model.subscriptions = []azure.Subscription{ + {Name: "Sub 1", ID: "id-1", IsDefault: true}, + } + + view := model.View() + + if view == "" { + t.Error("expected non-empty view") + } +} + +func TestModel_View_Quitting(t *testing.T) { + client := azure.NewMockClient() + model := NewModel(client) + model.quitting = true + + view := model.View() + + if view != "" { + t.Error("expected empty view when quitting") + } +} + +func TestModel_CursorBounds(t *testing.T) { + client := azure.NewMockClient() + model := NewModel(client) + model.state = StateReady + model.subscriptions = []azure.Subscription{ + {Name: "Sub 1", ID: "id-1"}, + } + model.cursor = 0 + + // Try to go up when at top + msg := tea.KeyMsg{Type: tea.KeyUp} + newModel, _ := model.Update(msg) + m := newModel.(Model) + + if m.cursor != 0 { + t.Errorf("cursor should stay at 0, got %d", m.cursor) + } + + // Try to go down when at bottom + msg = tea.KeyMsg{Type: tea.KeyDown} + newModel, _ = m.Update(msg) + m = newModel.(Model) + + if m.cursor != 0 { + t.Errorf("cursor should stay at 0 (only 1 item), got %d", m.cursor) + } +} + +func TestModel_HelpToggle(t *testing.T) { + client := azure.NewMockClient() + model := NewModel(client) + model.state = StateReady + + if model.showHelp { + t.Error("expected showHelp to be false initially") + } + + // Press ? + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}} + newModel, _ := model.Update(msg) + m := newModel.(Model) + + if !m.showHelp { + t.Error("expected showHelp to be true after pressing ?") + } + + // Press ? again + newModel, _ = m.Update(msg) + m = newModel.(Model) + + if m.showHelp { + t.Error("expected showHelp to be false after pressing ? again") + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..0f2d4bf --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,102 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +// Color palette. +var ( + primaryColor = lipgloss.Color("39") // Azure blue + secondaryColor = lipgloss.Color("208") // Orange + successColor = lipgloss.Color("82") // Green + errorColor = lipgloss.Color("196") // Red + mutedColor = lipgloss.Color("241") // Gray + highlightColor = lipgloss.Color("212") // Pink +) + +// Styles for the TUI. +var ( + // Title style for headers. + TitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(primaryColor). + MarginBottom(1) + + // Subtitle style. + SubtitleStyle = lipgloss.NewStyle(). + Foreground(mutedColor). + MarginBottom(1) + + // Selected item style. + SelectedStyle = lipgloss.NewStyle(). + Foreground(highlightColor). + Bold(true) + + // Current item indicator style. + CurrentStyle = lipgloss.NewStyle(). + Foreground(successColor). + Bold(true) + + // Normal item style. + NormalStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + // Muted style for secondary text. + MutedStyle = lipgloss.NewStyle(). + Foreground(mutedColor) + + // Error style. + ErrorStyle = lipgloss.NewStyle(). + Foreground(errorColor). + Bold(true) + + // Warning style. + WarningStyle = lipgloss.NewStyle(). + Foreground(secondaryColor). + Italic(true) + + // Success style. + SuccessStyle = lipgloss.NewStyle(). + Foreground(successColor) + + // Help style. + HelpStyle = lipgloss.NewStyle(). + Foreground(mutedColor). + MarginTop(1) + + // Box style for sections. + BoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(primaryColor). + Padding(0, 1) + + // Header box style. + HeaderBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(primaryColor). + Padding(0, 1). + MarginBottom(1) + + // Cursor style. + CursorStyle = lipgloss.NewStyle(). + Foreground(secondaryColor). + Bold(true) + + // Tab style. + ActiveTabStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(primaryColor). + Underline(true) + + // Inactive tab style. + InactiveTabStyle = lipgloss.NewStyle(). + Foreground(mutedColor) + + // Spinner style. + SpinnerStyle = lipgloss.NewStyle(). + Foreground(secondaryColor) + + // Status bar style. + StatusBarStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("235")). + Foreground(lipgloss.Color("252")). + Padding(0, 1) +) diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..9c7fba7 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,28 @@ +// Package version provides build version information. +package version + +import ( + "fmt" + "runtime" +) + +// Build information. Populated at build-time via ldflags. +var ( + Version = "dev" + CommitSHA = "unknown" + BuildTime = "unknown" +) + +// Info returns formatted version information. +func Info() string { + return fmt.Sprintf("azswitch %s (%s) built %s with %s", + Version, CommitSHA, BuildTime, runtime.Version()) +} + +// Short returns a short version string. +func Short() string { + if len(CommitSHA) > 7 { + return fmt.Sprintf("%s (%s)", Version, CommitSHA[:7]) + } + return fmt.Sprintf("%s (%s)", Version, CommitSHA) +}