diff --git a/.dev/scripts/uv-sync.sh b/.dev/scripts/uv-sync.sh new file mode 100755 index 0000000..f78eaef --- /dev/null +++ b/.dev/scripts/uv-sync.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Setup Python environment using uv +set -euo pipefail + +if ! command -v uv &>/dev/null; then + echo "error: uv is required to manage Python dependencies" >&2 + echo " install it from https://docs.astral.sh/uv/" >&2 + exit 1 +fi + +uv sync --extra dev --quiet diff --git a/.envrc b/.envrc index 13de9e4..81100dc 100644 --- a/.envrc +++ b/.envrc @@ -1,37 +1,19 @@ -# Use the Nix flake in this directory +# Nix flake + direnv integration use flake -# Watch for changes in these files to trigger environment reload +# Watch for changes to trigger environment reload watch_file pyproject.toml watch_file flake.nix +watch_file flake.lock watch_file uv.lock -echo "" -echo "ETH Library Zurich - Data Archive Pipeline (DAP) - Orchestrator" -echo "───────────────────────────────────────────────────────────────" +# Environment variables +export DAGSTER_HOME="$PWD/.dagster" +export VIRTUAL_ENV="$PWD/.venv" -export DAGSTER_HOME="$PWD" +# Setup Python dependencies +.dev/scripts/uv-sync.sh || log_error "Python setup failed" +PATH_add "$PWD/.venv/bin" -# Auto-create venv and sync dependencies -if [ ! -d .venv ]; then - echo "○ creating venv..." - uv venv --quiet -fi - -source .venv/bin/activate -uv sync --extra dev --frozen --quiet - -echo "" -echo " Environment" -echo " ─────────────────────────────────────────────────────────────" -echo " nix flake $(nix flake metadata --json 2>/dev/null | jq -r '.resolved.url' | sed 's|^file://||')" -echo " python venv $VIRTUAL_ENV" -echo " python version $(python --version 2>&1 | cut -d' ' -f2)" -echo " DAGSTER_HOME $DAGSTER_HOME" -echo "" -echo " Commands" -echo " ─────────────────────────────────────────────────────────────" -echo " just show available commands" -echo " just info show tool versions" -echo " just dev start Dagster dev server" -echo "" +# Show welcome message +dap welcome diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17203cf..38a9baf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,9 @@ name: CI on: push: branches: [ main ] + paths-ignore: ['cli/**'] pull_request: + paths-ignore: ['cli/**'] permissions: contents: read diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml new file mode 100644 index 0000000..d21d682 --- /dev/null +++ b/.github/workflows/cli.yml @@ -0,0 +1,28 @@ +name: CLI + +on: + push: + branches: [main] + paths: ['cli/**'] + pull_request: + paths: ['cli/**'] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v31 + - name: Run tests + run: nix develop .#cli-dev --command dap cli test + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v31 + - name: Lint and check formatting + run: nix develop .#cli-dev --command dap cli lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8c73d4d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release CLI + +on: + push: + tags: ['cli/v*'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: cachix/install-nix-action@v31 + - name: Run tests + run: nix develop .#cli-dev --command dap cli test + - uses: actions/setup-go@v5 + with: + go-version-file: cli/go.mod + - uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index e704cc4..0e044d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ # Python virtual environment .venv/ +# Go vendor (using gomod2nix instead) +cli/vendor/ +vendor/ + +# Go build output +cli/bin/ + # Devbox environment .devbox/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..6dd454c --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,19 @@ +version: 2 +project_name: dap +builds: + - dir: cli + binary: dap + env: [CGO_ENABLED=0] + goos: [linux, darwin] + goarch: [amd64, arm64] + ldflags: + - -s -w + - -X github.com/eth-library/dap/cli/cmd.version={{ .Version }} + - -X github.com/eth-library/dap/cli/cmd.commit={{ .Commit }} + - -X github.com/eth-library/dap/cli/cmd.date={{ .Date }} +archives: + - name_template: "dap_{{ .Version }}_{{ .Os }}_{{ .Arch }}" +checksum: + name_template: checksums.txt +release: + name_template: "CLI {{ .Tag }}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2790da1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,26 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + # Go CLI hooks + - repo: local + hooks: + - id: cli-gomod2nix + name: "cli: Update gomod2nix.toml" + entry: bash -c 'cd cli && gomod2nix && git add gomod2nix.toml' + language: system + files: ^cli/go\.(mod|sum)$ + pass_filenames: false diff --git a/README.md b/README.md index a6bfea2..69bdc0d 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,16 @@ direnv allow If not using [direnv](https://direnv.net/), activate the environment manually: ```bash -nix develop +nix develop # Full environment (Python, uv, dap CLI, Go, kubectl, helm) +nix develop .#minimal # Minimal: Python, uv, dap CLI only +nix develop .#k8s # K8s: adds kubectl and helm +nix develop .#cli-dev # CLI development: Python, uv, dap CLI, Go ``` Start the Dagster development server: ```bash -just dev +dap dev ``` Open http://localhost:3000 in your browser. @@ -59,7 +62,7 @@ Ensure you have the following installed: - Python 3.12+ - [uv](https://github.com/astral-sh/uv) (Python package manager) -- [just](https://github.com/casey/just) (command runner) +- [Go 1.22+](https://go.dev/dl/) (for the dap CLI) Set up the project: @@ -69,12 +72,16 @@ cd data-assets-pipeline uv venv --python python3.12 source .venv/bin/activate uv sync --extra dev + +# Build the dap CLI +cd cli && go build -o bin/dap . && cd .. +export PATH="$PWD/cli/bin:$PATH" ``` Start the Dagster development server: ```bash -dagster dev +dap dev ``` Open http://localhost:3000 in your browser. @@ -85,9 +92,10 @@ Open http://localhost:3000 in your browser. |------|---------|--------------| | [Nix](https://nixos.org/download.html) | Development environment (optional but recommended) | [Install guide](https://nixos.org/download.html) | | [direnv](https://direnv.net/) | Automatic environment loading | [Install guide](https://direnv.net/docs/installation.html) | +| [nix-direnv](https://github.com/nix-community/nix-direnv) | Fast Nix + direnv integration | [Install guide](https://github.com/nix-community/nix-direnv#installation) | | Python 3.12+ | Runtime | [python.org](https://www.python.org/downloads/) | | [uv](https://github.com/astral-sh/uv) | Fast Python package manager | `curl -LsSf https://astral.sh/uv/install.sh \| sh` | -| [just](https://github.com/casey/just) | Command runner | `cargo install just` or via package manager | +| [Go 1.22+](https://go.dev/dl/) | dap CLI build | [go.dev](https://go.dev/dl/) | For Kubernetes development, you'll also need: @@ -120,13 +128,13 @@ The `xml_file_sensor` monitors a configured directory for new XML files and auto Start the Dagster UI with hot-reload: ```bash -just dev +dap dev ``` Run the test suite: ```bash -just test +dap test ``` ### Manual Pipeline Runs @@ -148,7 +156,7 @@ Requires Docker Desktop with Kubernetes enabled (Settings → Kubernetes → Ena Deploy to local Kubernetes: ```bash -just k8s-up +dap k8s up ``` The Dagster UI will be available at http://localhost:8080. @@ -156,13 +164,13 @@ The Dagster UI will be available at http://localhost:8080. Rebuild and restart after code changes: ```bash -just k8s-restart +dap k8s restart ``` Tear down the deployment: ```bash -just k8s-down +dap k8s down ``` ## Configuration @@ -171,7 +179,9 @@ just k8s-down | Variable | Description | Default | |----------|-------------|---------| +| `DAGSTER_HOME` | Dagster instance directory | Project root (set by `.envrc`) | | `DAGSTER_TEST_DATA_PATH` | Directory containing METS XML files for the sensor to monitor | `da_pipeline_tests/test_data` | +| `DAP_QUIET` | Set to `1` to hide the "Commands" hint in welcome banner | unset | Copy `.env.example` to `.env` and modify as needed. @@ -179,8 +189,8 @@ Copy `.env.example` to `.env` and modify as needed. | File | Purpose | |------|---------| -| `flake.nix` | Nix development environment (Python, uv, kubectl, helm, just) | -| `justfile` | Task runner commands | +| `flake.nix` | Nix development environment with multiple shells (see below) | +| `cli/` | Go CLI source code (see [cli/CONTRIBUTING.md](cli/CONTRIBUTING.md)) | | `pyproject.toml` | Python project metadata and dependencies | | `dagster.yaml` | Dagster instance configuration | | `config.yaml` | Example run configuration for manual pipeline execution | @@ -188,48 +198,74 @@ Copy `.env.example` to `.env` and modify as needed. | `helm/values-local.yaml` | Local Kubernetes overrides | | `helm/pvc.yaml` | Persistent volume claim for Dagster storage | +### Development Shells + +The Nix flake provides multiple development shells for different use cases: + +| Shell | Command | Packages | Use Case | +|-------|---------|----------|----------| +| default | `nix develop` | Python, uv, dap CLI, Go, kubectl, helm | Full development | +| minimal | `nix develop .#minimal` | Python, uv, dap CLI | Running pipeline and tests | +| k8s | `nix develop .#k8s` | Python, uv, dap CLI, kubectl, helm | Kubernetes deployment | +| cli-dev | `nix develop .#cli-dev` | Python, uv, dap CLI, Go, gomod2nix | Working on the dap CLI | + +The **default** shell is automatically loaded when using `direnv allow` (via `.envrc`) or running `nix develop` without arguments. It includes all tools needed for full development. + +The specialized shells (`minimal`, `k8s`, `cli-dev`) are useful for CI pipelines where faster startup and smaller environments are beneficial. + ## Commands Reference -Run `just` or `just --list` to see all available commands. +Run `dap --help` to see all available commands. -### General +### Development | Command | Description | |---------|-------------| -| `just setup` | Create virtual environment and install dependencies | -| `just info` | Show tool versions and available commands | -| `just versions` | Show tool versions only | +| `dap dev` | Start Dagster development server (localhost:3000) | +| `dap test` | Run pytest test suite | +| `dap check` | Run all quality checks (lint + typecheck + test) | +| `dap lint` | Check code style with Ruff (use `--fix` to auto-format) | +| `dap typecheck` | Type check with mypy | -### Local Development +### Environment | Command | Description | |---------|-------------| -| `just dev` | Start Dagster server at localhost:3000 | -| `just test` | Run pytest test suite | +| `dap versions` | Show tool versions (use `--all` for full list) | +| `dap clean` | Remove `.venv` and caches | +| `dap reset` | Clean and reinstall dependencies | -### Code Quality +### Dagster | Command | Description | |---------|-------------| -| `just lint` | Check code with Ruff (no modifications) | -| `just fmt` | Format and fix code with Ruff | +| `dap materialize` | Materialize all Dagster assets | +| `dap run` | Run the ingest_sip_job | ### Kubernetes | Command | Description | |---------|-------------| -| `just k8s-up` | Build image, deploy to Kubernetes, port-forward to localhost:8080 | -| `just k8s-down` | Tear down Kubernetes deployment | -| `just k8s-restart` | Rebuild image and restart user code pod | -| `just k8s-status` | Show pods and services | -| `just k8s-logs` | Stream logs from user code pod | -| `just k8s-ui` | Port-forward to Dagster UI (if not already running) | -| `just k8s-shell` | Open shell in user code pod | +| `dap k8s up` | Build and deploy to local Kubernetes (localhost:8080) | +| `dap k8s down` | Tear down Kubernetes deployment | +| `dap k8s restart` | Rebuild and restart user code pod | +| `dap k8s status` | Show pods and services | +| `dap k8s logs` | Stream logs from user code pod | +| `dap k8s shell` | Open shell in user code pod | + +### CLI Development + +For working on the dap CLI itself, see [cli/CONTRIBUTING.md](cli/CONTRIBUTING.md). ## Project Structure ``` -da_pipeline/ # Main package +cli/ # dap CLI (Go) - see cli/CONTRIBUTING.md +├── cmd/ # Command implementations +├── internal/ # Internal packages (ui, exec) +└── main.go # Entry point + +da_pipeline/ # Main package (Python) ├── definitions.py # Dagster entry point (Definitions) ├── assets.py # Pipeline assets ├── sensors.py # File monitoring sensor and job definition @@ -327,7 +363,9 @@ uv sync --extra dev ### Development Tools - [uv](https://github.com/astral-sh/uv) - Fast Python package manager -- [just](https://github.com/casey/just) - Command runner +- [Go](https://go.dev/) - Programming language for the dap CLI +- [Cobra](https://github.com/spf13/cobra) - Go CLI framework +- [Charmbracelet](https://charm.sh/) - Terminal UI libraries - [Nix](https://nixos.org/) - Reproducible development environments - [direnv](https://direnv.net/) - Automatic environment loading - [Ruff](https://docs.astral.sh/ruff/) - Python linter and formatter diff --git a/cli/CONTRIBUTING.md b/cli/CONTRIBUTING.md new file mode 100644 index 0000000..0014faf --- /dev/null +++ b/cli/CONTRIBUTING.md @@ -0,0 +1,293 @@ +# Contributing to the DAP CLI + +This guide is for developers who want to modify or extend the `dap` CLI tool. + +## Quick Start + +```bash +# Rebuild the CLI (go mod tidy + gomod2nix + nix build) +dap cli build + +# Run tests +dap cli test +``` + +## Prerequisites + +- Go 1.22+ (provided by `nix develop`) +- Understanding of [Cobra](https://github.com/spf13/cobra) CLI framework +- Familiarity with [Lipgloss](https://github.com/charmbracelet/lipgloss) for terminal styling + +## Project Structure + +``` +cli/ +├── main.go # Entry point +├── go.mod # Go module definition +├── go.sum # Dependency checksums +├── gomod2nix.toml # Nix dependency hashes (auto-generated) +├── package.nix # Nix build definition +├── bin/dap # Development binary +│ +├── cmd/ # Command implementations +│ ├── root.go # Root command, groups, global flags +│ │ +│ ├── dev/ # Development commands +│ │ ├── dev.go # Package entry, PythonTargets constant +│ │ ├── check.go # dap check +│ │ ├── devserver.go # dap dev +│ │ ├── fmt.go # dap fmt +│ │ ├── lint.go # dap lint +│ │ ├── test.go # dap test +│ │ └── typecheck.go # dap typecheck +│ │ +│ ├── env/ # Environment commands +│ │ ├── env.go # Package entry +│ │ ├── clean.go # dap clean +│ │ ├── reset.go # dap reset +│ │ ├── versions.go # dap versions +│ │ └── welcome.go # dap welcome +│ │ +│ ├── dagster/ # Dagster commands +│ │ ├── dagster.go # Package entry +│ │ ├── materialize.go # dap materialize +│ │ └── run.go # dap run +│ │ +│ ├── k8s/ # Kubernetes commands +│ │ ├── k8s.go # Parent command, constants +│ │ ├── up.go # dap k8s up +│ │ ├── down.go # dap k8s down +│ │ ├── restart.go # dap k8s restart +│ │ ├── status.go # dap k8s status +│ │ ├── logs.go # dap k8s logs +│ │ └── shell.go # dap k8s shell +│ │ +│ └── meta/ # Hidden CLI maintenance commands +│ ├── meta.go # Package entry +│ ├── build.go # dap build (full rebuild) +│ └── go.go # dap go build, dap go test +│ +└── internal/ # Internal packages + ├── exec/ # Command execution utilities + │ ├── run.go # Run, RunInteractive, RunPassthrough, Which + │ └── run_test.go + └── ui/ # Terminal UI components + ├── styles.go # Colors, styles, symbols + ├── output.go # Banner, Section, Success, Error, etc. + └── styles_test.go +``` + +## Adding a New Command + +### 1. Choose the package + +Commands are organized by domain: + +| Package | Group | Commands | +|---------|-------|----------| +| `cmd/dev` | Development | check, dev, fmt, lint, test, typecheck | +| `cmd/env` | Environment | clean, reset, versions, welcome | +| `cmd/dagster` | Dagster | materialize, run | +| `cmd/k8s` | Kubernetes | up, down, status, restart, logs, shell | +| `cmd/meta` | Hidden | build, go | + +### 2. Create the command file + +Create a new file in the appropriate package (e.g., `cmd/dev/mycommand.go`): + +```go +package dev + +import ( + "fmt" + + "github.com/eth-library/dap/cli/internal/exec" + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +var MyCmd = &cobra.Command{ + Use: "mycommand", + Short: "Brief description", + Long: "Detailed description of what this command does.", + GroupID: GroupID, + RunE: func(cmd *cobra.Command, args []string) error { + ui.Info("Doing something...") + + if err := exec.RunPassthrough("some-tool", "arg1"); err != nil { + ui.Error("Something failed") + return fmt.Errorf("some-tool failed: %w", err) + } + + ui.Success("Done!") + return nil + }, +} +``` + +### 3. Register the command + +Add to the `Commands()` function in the package's main file (e.g., `cmd/dev/dev.go`): + +```go +func Commands() []*cobra.Command { + return []*cobra.Command{ + CheckCmd, + DevServerCmd, + // ... + MyCmd, // Add here + } +} +``` + +### 4. Add flags (optional) + +```go +func init() { + MyCmd.Flags().BoolP("verbose", "v", false, "Verbose output") +} +``` + +## UI Guidelines + +Use the `internal/ui` package for all terminal output: + +```go +import "github.com/eth-library/dap/cli/internal/ui" + +// Status messages +ui.Info("Starting...") +ui.Success("Completed", "key", value) +ui.Warn("Something might be wrong") +ui.Error("Failed") + +// Sections and structure +ui.Section("Section Title") +ui.Divider() +ui.Newline() + +// Key-value pairs +ui.KeyValue("name", "value") +ui.KeyValueStatus("name", "value", true) // with checkmark +ui.KeyValueDim("name", "not set") // dimmed + +// Multi-step operations +ui.Step(1, 3, "First step...") +ui.StepDone(1, 3, "First step complete") +ui.StepFail(1, 3, "First step failed") + +// Command hints +ui.CommandHint("dap dev", "start the dev server") +``` + +Colors auto-disable in CI environments and with `--no-color`. + +## Running External Commands + +Use `internal/exec` instead of `os/exec` directly: + +```go +import "github.com/eth-library/dap/cli/internal/exec" + +// Capture output +output, err := exec.Run("command", "arg1", "arg2") + +// Pass through to terminal (for interactive commands) +err := exec.RunInteractive("command", "arg1") + +// Pass through stdout/stderr only +err := exec.RunPassthrough("command", "arg1") + +// Check if command exists +if exec.Which("docker") { + // docker is available +} +``` + +## Error Handling + +Always wrap errors with context: + +```go +if err := exec.RunPassthrough("ruff", "check", "."); err != nil { + ui.Error("Lint check failed") + return fmt.Errorf("ruff check failed: %w", err) +} +``` + +## Testing + +```bash +dap cli test # Run tests +dap cli test -v # Verbose output +``` + +## Linting + +```bash +dap cli lint # Check go vet and formatting +dap cli lint --fix # Auto-fix formatting, then run go vet +``` + +## Building + +```bash +dap cli build # Full rebuild (go mod tidy + gomod2nix + nix build) +``` + +`dap cli build` runs three steps: + +1. **`go mod tidy`** — syncs `go.mod` and `go.sum` with the actual imports in the code +2. **`gomod2nix`** — regenerates `gomod2nix.toml` from `go.sum`, translating Go dependency hashes into a format Nix can use (skipped if not installed) +3. **`nix build .#dap`** — builds the binary in a hermetic Nix sandbox + +The Nix build chain works as follows: + +- The root `flake.nix` delegates to `cli/flake.nix`, which loads the [gomod2nix](https://github.com/nix-community/gomod2nix) overlay +- `cli/package.nix` defines the build using `buildGoApplication`, which compiles the Go source with pinned dependencies from `gomod2nix.toml` +- Linker flags (`-s -w`) strip debug info for a smaller binary, and `-X ...cmd.version` embeds the version string +- The output binary is renamed from `cli` to `dap` and placed at `result/bin/dap` + +## Dependency Management + +After changing Go dependencies: + +```bash +dap build --deps-only # go mod tidy + gomod2nix +``` + +## Shared Constants + +### Python targets + +Use `PythonTargets` from `cmd/dev/dev.go`: + +```go +exec.RunPassthrough("ruff", append([]string{"check"}, dev.PythonTargets...)...) +``` + +### Kubernetes constants + +Use constants from `cmd/k8s/k8s.go`: + +```go +const ( + Namespace = "dagster" + DagsterUIURL = "http://localhost:8080" + K8sContext = "docker-desktop" +) +``` + +## Code Style + +- Follow standard Go conventions (`go fmt`, `go vet`) +- Keep functions small and focused +- Export commands as `FooCmd` (e.g., `CheckCmd`, `DevServerCmd`) +- Use `GroupID` constant from the package for command grouping + +## Release Checklist + +1. `dap cli test` - Run tests +2. `dap cli lint` - Check formatting and vet +3. `dap cli build` - Full rebuild +4. `./bin/dap --help` - Test manually diff --git a/cli/cmd/dagster/dagster.go b/cli/cmd/dagster/dagster.go new file mode 100644 index 0000000..8f06264 --- /dev/null +++ b/cli/cmd/dagster/dagster.go @@ -0,0 +1,15 @@ +// Package dagster contains Dagster-related commands. +package dagster + +import "github.com/spf13/cobra" + +// GroupID for dagster commands (matches root.go GroupDagster) +const GroupID = "dagster" + +// Commands returns all dagster commands to be registered with the root command. +func Commands() []*cobra.Command { + return []*cobra.Command{ + MaterializeCmd, + RunCmd, + } +} diff --git a/cli/cmd/dagster/dagster_test.go b/cli/cmd/dagster/dagster_test.go new file mode 100644 index 0000000..b411595 --- /dev/null +++ b/cli/cmd/dagster/dagster_test.go @@ -0,0 +1,40 @@ +package dagster + +import ( + "testing" +) + +func TestCommands(t *testing.T) { + cmds := Commands() + if len(cmds) != 2 { + t.Errorf("Commands() returned %d commands, want 2", len(cmds)) + } + + // Verify expected commands + names := make(map[string]bool) + for _, cmd := range cmds { + names[cmd.Use] = true + } + + if !names["materialize [flags]"] { + t.Error("missing materialize command") + } + if !names["run [flags]"] { + t.Error("missing run command") + } +} + +func TestGroupID(t *testing.T) { + if GroupID != "dagster" { + t.Errorf("GroupID = %q, want %q", GroupID, "dagster") + } +} + +func TestCommandsHaveDisabledFlagParsing(t *testing.T) { + // Dagster commands should pass flags through to dagster + for _, cmd := range Commands() { + if !cmd.DisableFlagParsing { + t.Errorf("command %q should have DisableFlagParsing=true", cmd.Use) + } + } +} diff --git a/cli/cmd/dagster/materialize.go b/cli/cmd/dagster/materialize.go new file mode 100644 index 0000000..052417d --- /dev/null +++ b/cli/cmd/dagster/materialize.go @@ -0,0 +1,25 @@ +package dagster + +import ( + "github.com/eth-library/dap/cli/internal/exec" + "github.com/spf13/cobra" +) + +// MaterializeCmd materializes all Dagster assets. +var MaterializeCmd = &cobra.Command{ + Use: "materialize [flags]", + Short: "Materialize Dagster assets", + Long: "Materialize all Dagster assets. Any additional flags are passed to dagster.", + GroupID: GroupID, + // Allow passing flags through to dagster + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + dagsterArgs := []string{ + "asset", "materialize", + "-m", "da_pipeline.definitions", + "--select", "*", + } + dagsterArgs = append(dagsterArgs, args...) + return exec.RunInteractive("dagster", dagsterArgs...) + }, +} diff --git a/cli/cmd/dagster/run.go b/cli/cmd/dagster/run.go new file mode 100644 index 0000000..234cf45 --- /dev/null +++ b/cli/cmd/dagster/run.go @@ -0,0 +1,25 @@ +package dagster + +import ( + "github.com/eth-library/dap/cli/internal/exec" + "github.com/spf13/cobra" +) + +// RunCmd runs the Dagster ingest_sip_job. +var RunCmd = &cobra.Command{ + Use: "run [flags]", + Short: "Run Dagster job", + Long: "Run the ingest_sip_job. Any additional flags are passed to dagster.", + GroupID: GroupID, + // Allow passing flags through to dagster + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + dagsterArgs := []string{ + "job", "execute", + "-m", "da_pipeline.definitions", + "-j", "ingest_sip_job", + } + dagsterArgs = append(dagsterArgs, args...) + return exec.RunInteractive("dagster", dagsterArgs...) + }, +} diff --git a/cli/cmd/dev/check.go b/cli/cmd/dev/check.go new file mode 100644 index 0000000..0c9c661 --- /dev/null +++ b/cli/cmd/dev/check.go @@ -0,0 +1,59 @@ +package dev + +import ( + "fmt" + + "github.com/eth-library/dap/cli/internal/exec" + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +// CheckCmd runs all quality checks (lint, typecheck, test). +var CheckCmd = &cobra.Command{ + Use: "check", + Short: "Run all quality checks", + Long: `Run all quality checks in sequence. Fails fast on first error. + +Steps: + 1. Lint ruff check + ruff format --check + 2. Typecheck mypy da_pipeline + 3. Test pytest da_pipeline_tests`, + GroupID: GroupID, + RunE: func(cmd *cobra.Command, args []string) error { + ui.Section("Quality Checks") + + totalSteps := 3 + + // Step 1: Lint + ui.Step(1, totalSteps, "Checking code style...") + if err := exec.RunPassthrough("ruff", append([]string{"check"}, PythonTargets...)...); err != nil { + ui.StepFail(1, totalSteps, "Lint check failed") + return fmt.Errorf("ruff check failed: %w", err) + } + if err := exec.RunPassthrough("ruff", append([]string{"format", "--check"}, PythonTargets...)...); err != nil { + ui.StepFail(1, totalSteps, "Format check failed") + return fmt.Errorf("ruff format check failed: %w", err) + } + ui.StepDone(1, totalSteps, "Lint passed") + + // Step 2: Typecheck + ui.Step(2, totalSteps, "Type checking...") + if err := exec.RunPassthrough("mypy", PythonTargets...); err != nil { + ui.StepFail(2, totalSteps, "Type check failed") + return fmt.Errorf("mypy type check failed: %w", err) + } + ui.StepDone(2, totalSteps, "Typecheck passed") + + // Step 3: Test + ui.Step(3, totalSteps, "Running tests...") + if err := exec.RunPassthrough("pytest", "da_pipeline_tests"); err != nil { + ui.StepFail(3, totalSteps, "Tests failed") + return fmt.Errorf("pytest failed: %w", err) + } + ui.StepDone(3, totalSteps, "Tests passed") + + ui.Newline() + ui.Success("All checks passed") + return nil + }, +} diff --git a/cli/cmd/dev/dev.go b/cli/cmd/dev/dev.go new file mode 100644 index 0000000..2b242c4 --- /dev/null +++ b/cli/cmd/dev/dev.go @@ -0,0 +1,21 @@ +// Package dev contains development-related commands. +package dev + +import "github.com/spf13/cobra" + +// GroupID for development commands (matches root.go GroupDevelopment) +const GroupID = "development" + +// PythonTargets defines the Python packages to check/lint/format. +var PythonTargets = []string{"da_pipeline", "da_pipeline_tests"} + +// Commands returns all development commands to be registered with the root command. +func Commands() []*cobra.Command { + return []*cobra.Command{ + CheckCmd, + DevServerCmd, + LintCmd, + TestCmd, + TypecheckCmd, + } +} diff --git a/cli/cmd/dev/dev_test.go b/cli/cmd/dev/dev_test.go new file mode 100644 index 0000000..78b5cc9 --- /dev/null +++ b/cli/cmd/dev/dev_test.go @@ -0,0 +1,163 @@ +package dev + +import ( + "testing" +) + +func TestCommands(t *testing.T) { + cmds := Commands() + if len(cmds) == 0 { + t.Error("Commands() returned empty slice") + } + + // Verify expected command count + if len(cmds) != 5 { + t.Errorf("Commands() returned %d commands, want 5", len(cmds)) + } + + // Verify all commands have GroupID set + for _, cmd := range cmds { + if cmd.GroupID != GroupID { + t.Errorf("command %q has GroupID %q, want %q", cmd.Name(), cmd.GroupID, GroupID) + } + } +} + +func TestPythonTargets(t *testing.T) { + if len(PythonTargets) == 0 { + t.Error("PythonTargets is empty") + } + + // Should contain the main packages + hasMain := false + hasTests := false + for _, target := range PythonTargets { + if target == "da_pipeline" { + hasMain = true + } + if target == "da_pipeline_tests" { + hasTests = true + } + } + + if !hasMain { + t.Error("PythonTargets missing da_pipeline") + } + if !hasTests { + t.Error("PythonTargets missing da_pipeline_tests") + } +} + +func TestGroupID(t *testing.T) { + if GroupID != "development" { + t.Errorf("GroupID = %q, want %q", GroupID, "development") + } +} + +func TestCheckCmd(t *testing.T) { + if CheckCmd.Use != "check" { + t.Errorf("CheckCmd.Use = %q, want 'check'", CheckCmd.Use) + } + if CheckCmd.Short == "" { + t.Error("CheckCmd.Short is empty") + } + if CheckCmd.Long == "" { + t.Error("CheckCmd.Long is empty") + } + if CheckCmd.GroupID != GroupID { + t.Errorf("CheckCmd.GroupID = %q, want %q", CheckCmd.GroupID, GroupID) + } + if CheckCmd.RunE == nil { + t.Error("CheckCmd.RunE is nil") + } +} + +func TestDevServerCmd(t *testing.T) { + if DevServerCmd.Use != "dev" { + t.Errorf("DevServerCmd.Use = %q, want 'dev'", DevServerCmd.Use) + } + if DevServerCmd.Short == "" { + t.Error("DevServerCmd.Short is empty") + } + if DevServerCmd.GroupID != GroupID { + t.Errorf("DevServerCmd.GroupID = %q, want %q", DevServerCmd.GroupID, GroupID) + } + if DevServerCmd.RunE == nil { + t.Error("DevServerCmd.RunE is nil") + } +} + +func TestLintCmd(t *testing.T) { + if LintCmd.Use != "lint" { + t.Errorf("LintCmd.Use = %q, want 'lint'", LintCmd.Use) + } + if LintCmd.Short == "" { + t.Error("LintCmd.Short is empty") + } + if LintCmd.GroupID != GroupID { + t.Errorf("LintCmd.GroupID = %q, want %q", LintCmd.GroupID, GroupID) + } + if LintCmd.RunE == nil { + t.Error("LintCmd.RunE is nil") + } + + // Verify --fix flag exists + flag := LintCmd.Flags().Lookup("fix") + if flag == nil { + t.Error("LintCmd should have --fix flag") + } +} + +func TestTestCmd(t *testing.T) { + if TestCmd.Use != "test [pytest-args...]" { + t.Errorf("TestCmd.Use = %q, want 'test [pytest-args...]'", TestCmd.Use) + } + if TestCmd.Short == "" { + t.Error("TestCmd.Short is empty") + } + if TestCmd.GroupID != GroupID { + t.Errorf("TestCmd.GroupID = %q, want %q", TestCmd.GroupID, GroupID) + } + if TestCmd.RunE == nil { + t.Error("TestCmd.RunE is nil") + } +} + +func TestTypecheckCmd(t *testing.T) { + if TypecheckCmd.Use != "typecheck" { + t.Errorf("TypecheckCmd.Use = %q, want 'typecheck'", TypecheckCmd.Use) + } + if TypecheckCmd.Short == "" { + t.Error("TypecheckCmd.Short is empty") + } + if TypecheckCmd.GroupID != GroupID { + t.Errorf("TypecheckCmd.GroupID = %q, want %q", TypecheckCmd.GroupID, GroupID) + } + if TypecheckCmd.RunE == nil { + t.Error("TypecheckCmd.RunE is nil") + } +} + +func TestCommandNames(t *testing.T) { + cmds := Commands() + + expectedNames := map[string]bool{ + "check": false, + "dev": false, + "lint": false, + "test": false, + "typecheck": false, + } + + for _, cmd := range cmds { + if _, ok := expectedNames[cmd.Name()]; ok { + expectedNames[cmd.Name()] = true + } + } + + for name, found := range expectedNames { + if !found { + t.Errorf("Expected command %q not found in Commands()", name) + } + } +} diff --git a/cli/cmd/dev/devserver.go b/cli/cmd/dev/devserver.go new file mode 100644 index 0000000..6679b02 --- /dev/null +++ b/cli/cmd/dev/devserver.go @@ -0,0 +1,26 @@ +package dev + +import ( + "github.com/eth-library/dap/cli/internal/exec" + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +// DevServerCmd starts the Dagster development server. +var DevServerCmd = &cobra.Command{ + Use: "dev", + Short: "Start Dagster development server", + Long: `Start the Dagster development server with hot-reload. + +Runs 'dagster dev' which starts the webserver at http://localhost:3000. +Code changes are automatically reloaded without server restart. + +Press Ctrl+C to stop the server.`, + GroupID: GroupID, + RunE: func(cmd *cobra.Command, args []string) error { + ui.TaskStart("Starting Dagster dev server...") + ui.KeyValue("url", "http://localhost:3000") + ui.Newline() + return exec.RunInteractive("dagster", "dev") + }, +} diff --git a/cli/cmd/dev/lint.go b/cli/cmd/dev/lint.go new file mode 100644 index 0000000..b50a9ba --- /dev/null +++ b/cli/cmd/dev/lint.go @@ -0,0 +1,51 @@ +package dev + +import ( + "fmt" + + "github.com/eth-library/dap/cli/internal/exec" + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +var lintFix bool + +// LintCmd checks code style and formatting. +var LintCmd = &cobra.Command{ + Use: "lint", + Short: "Check code style and formatting", + Long: "Run ruff to check code style and formatting. Use --fix to auto-fix issues.", + GroupID: GroupID, + RunE: func(cmd *cobra.Command, args []string) error { + if lintFix { + // Fix mode + ui.Info("Fixing lint issues...") + if err := exec.RunPassthrough("ruff", append([]string{"check", "--fix"}, PythonTargets...)...); err != nil { + return fmt.Errorf("ruff check --fix failed: %w", err) + } + ui.Info("Formatting code...") + if err := exec.RunPassthrough("ruff", append([]string{"format"}, PythonTargets...)...); err != nil { + return fmt.Errorf("ruff format failed: %w", err) + } + ui.Success("Code fixed and formatted") + } else { + // Check mode + ui.Info("Checking code style...") + if err := exec.RunPassthrough("ruff", append([]string{"check"}, PythonTargets...)...); err != nil { + ui.Error("Lint check failed") + return fmt.Errorf("ruff check failed: %w", err) + } + ui.Info("Checking formatting...") + if err := exec.RunPassthrough("ruff", append([]string{"format", "--check"}, PythonTargets...)...); err != nil { + ui.Error("Format check failed") + return fmt.Errorf("ruff format check failed: %w", err) + } + ui.Success("All lint checks passed") + } + return nil + }, +} + +func init() { + LintCmd.Flags().BoolVar(&lintFix, "fix", false, "Auto-fix issues") +} diff --git a/cli/cmd/dev/test.go b/cli/cmd/dev/test.go new file mode 100644 index 0000000..1044910 --- /dev/null +++ b/cli/cmd/dev/test.go @@ -0,0 +1,35 @@ +package dev + +import ( + "github.com/eth-library/dap/cli/internal/exec" + "github.com/spf13/cobra" +) + +// TestCmd runs the pytest test suite. +var TestCmd = &cobra.Command{ + Use: "test [pytest-args...]", + Short: "Run pytest tests", + Long: `Run the pytest test suite on da_pipeline_tests/. + +All arguments are passed directly to pytest. + +Examples: + dap test Run all tests + dap test -v Verbose output + dap test -k "test_foo" Run tests matching pattern + dap test --lf Re-run last failed tests`, + GroupID: GroupID, + DisableFlagParsing: true, + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + // Handle --help/-h manually since DisableFlagParsing is true + for _, arg := range args { + if arg == "--help" || arg == "-h" { + return cmd.Help() + } + } + pytestArgs := []string{"da_pipeline_tests"} + pytestArgs = append(pytestArgs, args...) + return exec.RunPassthrough("pytest", pytestArgs...) + }, +} diff --git a/cli/cmd/dev/typecheck.go b/cli/cmd/dev/typecheck.go new file mode 100644 index 0000000..ae62522 --- /dev/null +++ b/cli/cmd/dev/typecheck.go @@ -0,0 +1,26 @@ +package dev + +import ( + "fmt" + + "github.com/eth-library/dap/cli/internal/exec" + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +// TypecheckCmd runs mypy type checking. +var TypecheckCmd = &cobra.Command{ + Use: "typecheck", + Short: "Run type checking", + Long: "Run mypy to check Python type annotations.", + GroupID: GroupID, + RunE: func(cmd *cobra.Command, args []string) error { + ui.Info("Type checking with mypy...") + if err := exec.RunPassthrough("mypy", PythonTargets...); err != nil { + ui.Error("Type check failed") + return fmt.Errorf("mypy type check failed: %w", err) + } + ui.Success("No type errors") + return nil + }, +} diff --git a/cli/cmd/env/clean.go b/cli/cmd/env/clean.go new file mode 100644 index 0000000..e3226f8 --- /dev/null +++ b/cli/cmd/env/clean.go @@ -0,0 +1,70 @@ +package env + +import ( + "os" + "path/filepath" + + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +// CleanCmd removes .venv and caches. +var CleanCmd = &cobra.Command{ + Use: "clean", + Short: "Remove .venv and caches", + Long: "Remove the virtual environment and all Python cache directories.", + GroupID: GroupID, + RunE: func(cmd *cobra.Command, args []string) error { + ui.Info("Cleaning environment...") + + // Remove .venv + if err := os.RemoveAll(".venv"); err != nil { + ui.Warn("Could not remove .venv", "error", err) + } else { + ui.Success("Removed .venv") + } + + // Remove cache directories + cachePatterns := []string{ + "__pycache__", + ".pytest_cache", + ".ruff_cache", + ".mypy_cache", + "*.egg-info", + } + + for _, pattern := range cachePatterns { + matches, _ := filepath.Glob("**/" + pattern) + // Also check root level + rootMatches, _ := filepath.Glob(pattern) + matches = append(matches, rootMatches...) + + for _, match := range matches { + if err := os.RemoveAll(match); err != nil { + ui.Warn("Could not remove "+match, "error", err) + } + } + } + + // Walk directories to find nested caches + filepath.Walk(".", func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if !info.IsDir() { + return nil + } + name := info.Name() + for _, pattern := range cachePatterns { + if matched, _ := filepath.Match(pattern, name); matched { + os.RemoveAll(path) + return filepath.SkipDir + } + } + return nil + }) + + ui.Success("Environment cleaned") + return nil + }, +} diff --git a/cli/cmd/env/env.go b/cli/cmd/env/env.go new file mode 100644 index 0000000..746620e --- /dev/null +++ b/cli/cmd/env/env.go @@ -0,0 +1,17 @@ +// Package env contains environment-related commands. +package env + +import "github.com/spf13/cobra" + +// GroupID for environment commands (matches root.go GroupEnvironment) +const GroupID = "environment" + +// Commands returns all environment commands to be registered with the root command. +func Commands() []*cobra.Command { + return []*cobra.Command{ + CleanCmd, + ResetCmd, + VersionsCmd, + WelcomeCmd, + } +} diff --git a/cli/cmd/env/reset.go b/cli/cmd/env/reset.go new file mode 100644 index 0000000..49d9b35 --- /dev/null +++ b/cli/cmd/env/reset.go @@ -0,0 +1,36 @@ +package env + +import ( + "fmt" + + "github.com/eth-library/dap/cli/internal/exec" + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +// ResetCmd cleans and reinstalls dependencies. +var ResetCmd = &cobra.Command{ + Use: "reset", + Short: "Clean and reinstall dependencies", + Long: "Remove the virtual environment, caches, and reinstall all dependencies.", + GroupID: GroupID, + RunE: func(cmd *cobra.Command, args []string) error { + // Run clean + ui.Info("Cleaning environment...") + if err := CleanCmd.RunE(cmd, args); err != nil { + return fmt.Errorf("clean failed: %w", err) + } + + ui.Newline() + + // Reinstall dependencies + ui.Info("Reinstalling dependencies...") + if err := exec.RunPassthrough("uv", "sync", "--extra", "dev"); err != nil { + ui.Error("Failed to sync dependencies") + return fmt.Errorf("uv sync failed: %w", err) + } + + ui.Success("Environment reset complete") + return nil + }, +} diff --git a/cli/cmd/env/versions.go b/cli/cmd/env/versions.go new file mode 100644 index 0000000..b9d2611 --- /dev/null +++ b/cli/cmd/env/versions.go @@ -0,0 +1,131 @@ +package env + +import ( + "encoding/json" + "runtime" + "strings" + + "github.com/eth-library/dap/cli/internal/exec" + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +var versionsAll bool + +// VersionsCmd displays tool versions. +var VersionsCmd = &cobra.Command{ + Use: "versions", + Short: "Display tool versions", + Long: "Show versions of development tools. Use --all to show all Nix flake dependencies.", + GroupID: GroupID, + Run: func(cmd *cobra.Command, args []string) { + ui.Subtitle("Versions") + if versionsAll { + ShowVersionsFull() + } else { + ShowVersionsCompact() + } + }, +} + +func init() { + VersionsCmd.Flags().BoolVarP(&versionsAll, "all", "a", false, "Show all Nix flake dependencies") +} + +// ShowVersionsCompact displays essential tool versions for the welcome screen. +func ShowVersionsCompact() { + showVersion("python", getPythonVersion()) + showVersion("uv", getUVVersion()) + showVersion("dagster", getDagsterVersion()) +} + +// ShowVersionsFull displays all Nix flake dependencies. +func ShowVersionsFull() { + showVersion("python", getPythonVersion()) + showVersion("uv", getUVVersion()) + showVersion("dagster", getDagsterVersion()) + showVersion("go", runtime.Version()) + showVersion("kubectl", getKubectlVersion()) + showVersion("helm", getHelmVersion()) +} + +func showVersion(name, version string) { + if version != "" { + ui.KeyValue(name, version) + } else { + ui.KeyValue(name, ui.Styles.Dim.Render("not found")) + } +} + +func getPythonVersion() string { + out, err := exec.Run("python", "--version") + if err != nil { + return "" + } + return strings.TrimPrefix(out, "Python ") +} + +func getUVVersion() string { + out, err := exec.Run("uv", "--version") + if err != nil { + return "" + } + return strings.TrimPrefix(out, "uv ") +} + +func getDagsterVersion() string { + out, err := exec.Run("python", "-c", "import dagster; print(dagster.__version__)") + if err != nil { + return "" + } + return out +} + +func getRuffVersion() string { + out, err := exec.Run("ruff", "--version") + if err != nil { + return "" + } + return strings.TrimPrefix(out, "ruff ") +} + +func getMypyVersion() string { + out, err := exec.Run("mypy", "--version") + if err != nil { + return "" + } + // Output is "mypy X.Y.Z (compiled: yes)" + parts := strings.Fields(out) + if len(parts) >= 2 { + return parts[1] + } + return out +} + +func getKubectlVersion() string { + out, err := exec.Run("kubectl", "version", "--client", "-o", "json") + if err != nil { + return "" + } + + var v struct { + ClientVersion struct { + GitVersion string `json:"gitVersion"` + } `json:"clientVersion"` + } + if err := json.Unmarshal([]byte(out), &v); err != nil { + return "" + } + return v.ClientVersion.GitVersion +} + +func getHelmVersion() string { + out, err := exec.Run("helm", "version", "--short") + if err != nil { + return "" + } + if idx := strings.Index(out, "+"); idx > 0 { + out = out[:idx] + } + return out +} diff --git a/cli/cmd/env/versions_test.go b/cli/cmd/env/versions_test.go new file mode 100644 index 0000000..9cdd903 --- /dev/null +++ b/cli/cmd/env/versions_test.go @@ -0,0 +1,42 @@ +package env + +import ( + "testing" +) + +func TestVersionParsing(t *testing.T) { + // Test that version functions don't panic when tools are missing + // These return empty string on error, which is the expected behavior + + t.Run("getPythonVersion handles missing python gracefully", func(t *testing.T) { + // This test just ensures no panic - actual version depends on environment + _ = getPythonVersion() + }) + + t.Run("getUVVersion handles missing uv gracefully", func(t *testing.T) { + _ = getUVVersion() + }) + + t.Run("getDagsterVersion handles missing dagster gracefully", func(t *testing.T) { + _ = getDagsterVersion() + }) + + t.Run("getKubectlVersion handles missing kubectl gracefully", func(t *testing.T) { + _ = getKubectlVersion() + }) + + t.Run("getHelmVersion handles missing helm gracefully", func(t *testing.T) { + _ = getHelmVersion() + }) +} + +func TestShowVersionsFunctions(t *testing.T) { + // Ensure the show functions don't panic + t.Run("ShowVersionsCompact doesn't panic", func(t *testing.T) { + ShowVersionsCompact() + }) + + t.Run("ShowVersionsFull doesn't panic", func(t *testing.T) { + ShowVersionsFull() + }) +} diff --git a/cli/cmd/env/welcome.go b/cli/cmd/env/welcome.go new file mode 100644 index 0000000..157f116 --- /dev/null +++ b/cli/cmd/env/welcome.go @@ -0,0 +1,69 @@ +package env + +import ( + "os" + + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +// WelcomeCmd shows the welcome banner and environment info. +var WelcomeCmd = &cobra.Command{ + Use: "welcome", + Short: "Show welcome banner and environment info", + Long: "Display the project banner, environment versions, and quick command hints.", + GroupID: GroupID, + Run: func(cmd *cobra.Command, args []string) { + showWelcome() + }, +} + +func showWelcome() { + // Banner + ui.Banner("Data Archive Pipeline (DAP) - Orchestrator", "ETH Library Zurich") + + // Versions section + ui.Section("Versions") + ShowVersionsCompact() + + // Environment section with status indicators + ui.Section("Environment") + showEnvironmentPaths() + + // Command hints (unless DAP_QUIET is set) + if os.Getenv("DAP_QUIET") == "" { + ui.Section("Quick Start") + ui.CommandHint("dap dev", "Start the Dagster development server") + ui.CommandHint("dap test", "Run tests (pytest)") + ui.CommandHint("dap check", "Run all quality checks (ruff, mypy, pytest)") + ui.Newline() + ui.Hint("Run 'dap --help' for all commands") + } + + ui.Newline() +} + +// showEnvironmentPaths displays important environment paths with status. +func showEnvironmentPaths() { + // Nix flake - check if flake.nix exists + if cwd, err := os.Getwd(); err == nil { + _, flakeErr := os.Stat(cwd + "/flake.nix") + ui.KeyValueStatus("nix flake", cwd, flakeErr == nil) + } + + // Python venv path - check if it exists + if venv := os.Getenv("VIRTUAL_ENV"); venv != "" { + _, venvErr := os.Stat(venv) + ui.KeyValueStatus("python venv", venv, venvErr == nil) + } else { + ui.KeyValueDim("python venv", "not set") + } + + // DAGSTER_HOME - check if directory exists + if dagsterHome := os.Getenv("DAGSTER_HOME"); dagsterHome != "" { + _, homeErr := os.Stat(dagsterHome) + ui.KeyValueStatus("DAGSTER_HOME", dagsterHome, homeErr == nil) + } else { + ui.KeyValueDim("DAGSTER_HOME", "not set") + } +} diff --git a/cli/cmd/k8s/down.go b/cli/cmd/k8s/down.go new file mode 100644 index 0000000..834fa80 --- /dev/null +++ b/cli/cmd/k8s/down.go @@ -0,0 +1,40 @@ +package k8s + +import ( + "github.com/eth-library/dap/cli/internal/exec" + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +var downCmd = &cobra.Command{ + Use: "down", + Short: "Tear down Kubernetes deployment", + Long: "Remove the Dagster deployment from Kubernetes.", + RunE: func(cmd *cobra.Command, args []string) error { + ui.Info("Tearing down Dagster deployment...") + + // Uninstall Helm release + exec.Run("helm", "uninstall", Release, "-n", Namespace, "--wait=false") + ui.Success("Helm release uninstalled") + + // Clean up jobs + exec.Run("kubectl", "delete", "jobs", "-n", Namespace, "-l", "dagster/run-id", "--timeout=10s") + + // Clean up pods + exec.Run("kubectl", "delete", "pods", "-n", Namespace, "-l", "dagster/run-id", + "--grace-period=0", "--force", "--timeout=10s") + + // Clean up PVC + exec.Run("kubectl", "delete", "pvc", "dagster-storage", "-n", Namespace, "--timeout=10s") + + // Clean up ConfigMap + exec.Run("kubectl", "delete", "configmap", "test-data-xml", "-n", Namespace, "--timeout=10s") + + ui.Success("Teardown complete") + return nil + }, +} + +func init() { + K8sCmd.AddCommand(downCmd) +} diff --git a/cli/cmd/k8s/k8s.go b/cli/cmd/k8s/k8s.go new file mode 100644 index 0000000..91eaf86 --- /dev/null +++ b/cli/cmd/k8s/k8s.go @@ -0,0 +1,34 @@ +// Package k8s contains Kubernetes-related commands. +package k8s + +import ( + "github.com/spf13/cobra" +) + +// Configuration for Kubernetes deployment +const ( + Namespace = "dagster" + Release = "dagster" + Image = "da-pipeline:local" + HelmChart = "dagster/dagster" + HelmVersion = "1.10.14" + PGSecretName = "dagster-postgresql" + + // Network configuration + DagsterUIURL = "http://localhost:8080" + K8sContext = "docker-desktop" + + // Timeouts + RolloutTimeout = "120s" +) + +// K8sCmd is the parent command for Kubernetes operations. +var K8sCmd = &cobra.Command{ + Use: "k8s", + Short: "Kubernetes operations", + Long: "Commands for deploying and managing the pipeline on Kubernetes.", +} + +func init() { + // Subcommands are added in their respective files +} diff --git a/cli/cmd/k8s/k8s_test.go b/cli/cmd/k8s/k8s_test.go new file mode 100644 index 0000000..0ee32ba --- /dev/null +++ b/cli/cmd/k8s/k8s_test.go @@ -0,0 +1,37 @@ +package k8s + +import ( + "testing" +) + +func TestConstants(t *testing.T) { + tests := []struct { + name string + value string + want string + }{ + {"Namespace", Namespace, "dagster"}, + {"Release", Release, "dagster"}, + {"Image", Image, "da-pipeline:local"}, + {"HelmChart", HelmChart, "dagster/dagster"}, + {"K8sContext", K8sContext, "docker-desktop"}, + } + + for _, tt := range tests { + if tt.value != tt.want { + t.Errorf("%s = %q, want %q", tt.name, tt.value, tt.want) + } + } +} + +func TestK8sCmdHasSubcommands(t *testing.T) { + if !K8sCmd.HasSubCommands() { + t.Error("K8sCmd should have subcommands") + } +} + +func TestK8sCmdUse(t *testing.T) { + if K8sCmd.Use != "k8s" { + t.Errorf("K8sCmd.Use = %q, want %q", K8sCmd.Use, "k8s") + } +} diff --git a/cli/cmd/k8s/logs.go b/cli/cmd/k8s/logs.go new file mode 100644 index 0000000..41b72e5 --- /dev/null +++ b/cli/cmd/k8s/logs.go @@ -0,0 +1,27 @@ +package k8s + +import ( + "github.com/eth-library/dap/cli/internal/exec" + "github.com/spf13/cobra" +) + +var logsCmd = &cobra.Command{ + Use: "logs [flags]", + Short: "Stream logs from user code pod", + Long: "Stream logs from the Dagster user code pod. Additional flags are passed to kubectl.", + // Allow passing flags through to kubectl + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + kubectlArgs := []string{ + "logs", "-n", Namespace, + "-l", "app.kubernetes.io/name=dagster-user-deployments", + "--tail=100", "-f", + } + kubectlArgs = append(kubectlArgs, args...) + return exec.RunInteractive("kubectl", kubectlArgs...) + }, +} + +func init() { + K8sCmd.AddCommand(logsCmd) +} diff --git a/cli/cmd/k8s/restart.go b/cli/cmd/k8s/restart.go new file mode 100644 index 0000000..1f3da97 --- /dev/null +++ b/cli/cmd/k8s/restart.go @@ -0,0 +1,52 @@ +package k8s + +import ( + "fmt" + + "github.com/eth-library/dap/cli/internal/exec" + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +var restartCmd = &cobra.Command{ + Use: "restart", + Short: "Rebuild and restart user code pod", + Long: "Rebuild the Docker image and restart the user code deployment.", + RunE: func(cmd *cobra.Command, args []string) error { + // Check Kubernetes connectivity + if err := checkK8s(); err != nil { + return err + } + + // Build Docker image + ui.Info("Building Docker image...") + if err := exec.RunPassthrough("docker", "build", "-t", Image, "-q", "."); err != nil { + ui.Error("Docker build failed") + return fmt.Errorf("docker build failed: %w", err) + } + ui.Success("Image built", "tag", Image) + + // Restart deployment + ui.Info("Restarting user code deployment...") + if err := exec.RunPassthrough("kubectl", "rollout", "restart", "deployment", + "-n", Namespace, "-l", "app.kubernetes.io/name=dagster-user-deployments"); err != nil { + ui.Error("Restart failed") + return fmt.Errorf("deployment restart failed: %w", err) + } + + // Wait for rollout + ui.Info("Waiting for rollout...") + if err := exec.RunPassthrough("kubectl", "rollout", "status", "deployment", + "-n", Namespace, "-l", "app.kubernetes.io/name=dagster-user-deployments", "--timeout="+RolloutTimeout); err != nil { + ui.Error("Rollout failed") + return fmt.Errorf("rollout status check failed: %w", err) + } + + ui.Success("Restart complete") + return nil + }, +} + +func init() { + K8sCmd.AddCommand(restartCmd) +} diff --git a/cli/cmd/k8s/shell.go b/cli/cmd/k8s/shell.go new file mode 100644 index 0000000..8d91fd9 --- /dev/null +++ b/cli/cmd/k8s/shell.go @@ -0,0 +1,35 @@ +package k8s + +import ( + "fmt" + "strings" + + "github.com/eth-library/dap/cli/internal/exec" + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +var shellCmd = &cobra.Command{ + Use: "shell", + Short: "Open shell in user code pod", + Long: "Open an interactive bash shell in the Dagster user code pod.", + RunE: func(cmd *cobra.Command, args []string) error { + // Get pod name + podName, err := exec.Run("kubectl", "get", "pods", "-n", Namespace, + "-l", "app.kubernetes.io/name=dagster-user-deployments", + "-o", "jsonpath={.items[0].metadata.name}") + if err != nil || podName == "" { + ui.Error("No user code pod found. Is Dagster deployed?") + return fmt.Errorf("failed to find user code pod: %w", err) + } + + podName = strings.TrimSpace(podName) + ui.Info("Connecting to pod", "name", podName) + + return exec.RunInteractive("kubectl", "exec", "-it", "-n", Namespace, podName, "--", "/bin/bash") + }, +} + +func init() { + K8sCmd.AddCommand(shellCmd) +} diff --git a/cli/cmd/k8s/status.go b/cli/cmd/k8s/status.go new file mode 100644 index 0000000..2f43a78 --- /dev/null +++ b/cli/cmd/k8s/status.go @@ -0,0 +1,47 @@ +package k8s + +import ( + "fmt" + + "github.com/eth-library/dap/cli/internal/exec" + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show pods and services", + Long: "Display the current status of Kubernetes pods and services.", + RunE: func(cmd *cobra.Command, args []string) error { + // Check if namespace exists + if _, err := exec.Run("kubectl", "get", "namespace", Namespace); err != nil { + ui.Warn("Deployment not running") + ui.CommandHint("dap k8s up", "deploy to Kubernetes") + return nil + } + + // Show pods + ui.Section("Pods") + pods, err := exec.Run("kubectl", "get", "pods", "-n", Namespace, "-o", "wide") + if err != nil || pods == "" || pods == "No resources found in "+Namespace+" namespace." { + ui.KeyValueDim("status", "no pods running") + } else { + fmt.Println(pods) + } + + // Show services + ui.Section("Services") + svcs, err := exec.Run("kubectl", "get", "svc", "-n", Namespace) + if err != nil || svcs == "" { + ui.KeyValueDim("status", "no services running") + } else { + fmt.Println(svcs) + } + + return nil + }, +} + +func init() { + K8sCmd.AddCommand(statusCmd) +} diff --git a/cli/cmd/k8s/up.go b/cli/cmd/k8s/up.go new file mode 100644 index 0000000..ae8316b --- /dev/null +++ b/cli/cmd/k8s/up.go @@ -0,0 +1,130 @@ +package k8s + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "os/exec" + + dapexec "github.com/eth-library/dap/cli/internal/exec" + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +// generatePassword creates a cryptographically secure random password +func generatePassword() string { + bytes := make([]byte, 24) + rand.Read(bytes) + return base64.StdEncoding.EncodeToString(bytes) +} + +var upCmd = &cobra.Command{ + Use: "up", + Short: "Build and deploy to local Kubernetes", + Long: "Build the Docker image and deploy to local Kubernetes cluster (localhost:8080).", + RunE: func(cmd *cobra.Command, args []string) error { + // Check Kubernetes connectivity + if err := checkK8s(); err != nil { + return err + } + + // Build Docker image + ui.Info("Building Docker image...") + if err := dapexec.RunPassthrough("docker", "build", "-t", Image, "-q", "."); err != nil { + ui.Error("Docker build failed") + return fmt.Errorf("docker build failed: %w", err) + } + ui.Success("Image built", "tag", Image) + + // Create namespace + ui.Info("Creating namespace...") + nsCmd := exec.Command("kubectl", "create", "namespace", Namespace, "--dry-run=client", "-o", "yaml") + applyCmd := exec.Command("kubectl", "apply", "-f", "-") + applyCmd.Stdin, _ = nsCmd.StdoutPipe() + applyCmd.Stdout = nil + applyCmd.Stderr = nil + nsCmd.Start() + applyCmd.Run() + nsCmd.Wait() + ui.Success("Namespace ready", "name", Namespace) + + // Create PostgreSQL secret if it doesn't exist + if _, err := dapexec.Run("kubectl", "get", "secret", PGSecretName, "-n", Namespace); err != nil { + ui.Info("Creating PostgreSQL secret...") + password := generatePassword() + if err := dapexec.RunPassthrough("kubectl", "create", "secret", "generic", PGSecretName, + "--from-literal=postgresql-password="+password, + "-n", Namespace); err != nil { + ui.Error("Failed to create PostgreSQL secret") + return fmt.Errorf("failed to create postgresql secret: %w", err) + } + ui.Success("PostgreSQL secret created") + } + + // Add Helm repo + ui.Info("Setting up Helm repository...") + dapexec.Run("helm", "repo", "add", "dagster", "https://dagster-io.github.io/helm") + dapexec.Run("helm", "repo", "update", "dagster") + ui.Success("Helm repo ready") + + // Apply PVC + ui.Info("Applying persistent volume claim...") + if err := dapexec.RunPassthrough("kubectl", "apply", "-n", Namespace, "-f", "helm/pvc.yaml"); err != nil { + ui.Error("Failed to apply PVC") + return fmt.Errorf("failed to apply pvc: %w", err) + } + ui.Success("PVC applied") + + // Create test data ConfigMap if test data exists + if _, err := os.Stat("da_pipeline_tests/test_data"); err == nil { + ui.Info("Creating test data ConfigMap...") + configMapCmd := exec.Command("kubectl", "create", "configmap", "test-data-xml", + "--from-file=da_pipeline_tests/test_data/", + "-n", Namespace, "--dry-run=client", "-o", "yaml") + applyCmd := exec.Command("kubectl", "apply", "-f", "-") + applyCmd.Stdin, _ = configMapCmd.StdoutPipe() + configMapCmd.Start() + applyCmd.Run() + configMapCmd.Wait() + ui.Success("Test data ConfigMap created") + } + + // Deploy with Helm + ui.Info("Deploying with Helm...", "version", HelmVersion) + if err := dapexec.RunPassthrough("helm", "upgrade", "--install", Release, HelmChart, + "-f", "helm/values.yaml", + "-f", "helm/values-local.yaml", + "-n", Namespace, + "--version", HelmVersion, + "--skip-schema-validation"); err != nil { + ui.Error("Helm deployment failed") + return fmt.Errorf("helm deployment failed: %w", err) + } + ui.Success("Dagster deployed") + + // Show status + ui.Newline() + statusCmd.RunE(cmd, args) + ui.Newline() + + ui.Success("Dagster deployed") + ui.Info("UI available at", "url", DagsterUIURL) + ui.Hint("Service exposed via LoadBalancer - no port-forward needed") + return nil + }, +} + +func checkK8s() error { + if _, err := dapexec.Run("kubectl", "cluster-info"); err != nil { + ui.Error("Kubernetes not available. Enable it in Docker Desktop.") + return fmt.Errorf("kubernetes not available") + } + dapexec.Run("kubectl", "config", "use-context", K8sContext) + ui.Success("Kubernetes cluster connected") + return nil +} + +func init() { + K8sCmd.AddCommand(upCmd) +} diff --git a/cli/cmd/meta/go.go b/cli/cmd/meta/go.go new file mode 100644 index 0000000..fa9ba16 --- /dev/null +++ b/cli/cmd/meta/go.go @@ -0,0 +1,200 @@ +package meta + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/eth-library/dap/cli/internal/exec" + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +// CliCmd is the parent command for CLI development operations. +var CliCmd = &cobra.Command{ + Use: "cli", + Short: "CLI development commands", + Long: "Commands for building and testing the dap CLI.", +} + +// CliBuildCmd rebuilds the dap CLI with Nix. +var CliBuildCmd = &cobra.Command{ + Use: "build", + Short: "Rebuild the dap CLI", + Long: "Runs go mod tidy, gomod2nix, and nix build to rebuild the CLI.", + RunE: func(cmd *cobra.Command, args []string) error { + if err := inCliDir(func() error { + // Step 1: go mod tidy + ui.Info("Running go mod tidy...") + if err := exec.RunPassthrough("go", "mod", "tidy"); err != nil { + ui.Error("go mod tidy failed", "error", err) + return err + } + ui.Success("go mod tidy complete") + + // Step 2: gomod2nix + if !exec.Which("gomod2nix") { + ui.Warn("gomod2nix not found in PATH - skipping") + } else { + ui.Info("Running gomod2nix...") + if err := exec.RunPassthrough("gomod2nix"); err != nil { + ui.Error("gomod2nix failed", "error", err) + return err + } + ui.Success("gomod2nix.toml updated") + } + + return nil + }); err != nil { + return err + } + + // Step 3: nix build (must run from repo root where flake.nix lives) + root, err := repoRoot() + if err != nil { + return err + } + if err := os.Chdir(root); err != nil { + return err + } + ui.Info("Running nix build...") + if err := exec.RunPassthrough("nix", "build", ".#dap"); err != nil { + ui.Error("nix build failed", "error", err) + return err + } + ui.Success("Build complete") + ui.Newline() + ui.CommandHint("direnv reload", "to use the new binary") + + return nil + }, +} + +// CliTestCmd runs tests for the Go CLI. +var CliTestCmd = &cobra.Command{ + Use: "test", + Short: "Run Go CLI tests", + Long: "Runs go test for all CLI packages.", + RunE: func(cmd *cobra.Command, args []string) error { + verbose, _ := cmd.Flags().GetBool("verbose") + + return inCliDir(func() error { + ui.Info("Running CLI tests...") + testArgs := []string{"test", "./..."} + if verbose { + testArgs = append(testArgs, "-v") + } + if err := exec.RunPassthrough("go", testArgs...); err != nil { + ui.Error("Tests failed", "error", err) + return err + } + ui.Success("All tests passed") + return nil + }) + }, +} + +var cliLintFix bool + +// CliLintCmd runs linting and format checks for the Go CLI. +var CliLintCmd = &cobra.Command{ + Use: "lint", + Short: "Lint and check formatting", + Long: "Runs go vet and checks gofmt formatting for all CLI packages. Use --fix to auto-format.", + RunE: func(cmd *cobra.Command, args []string) error { + return inCliDir(func() error { + if cliLintFix { + return lintFixCli() + } + return lintCheckCli() + }) + }, +} + +// lintFixCli formats code and runs go vet. +func lintFixCli() error { + ui.Info("Fixing formatting...") + if err := exec.RunPassthrough("gofmt", "-w", "."); err != nil { + ui.Error("gofmt -w failed", "error", err) + return err + } + ui.Success("Formatting fixed") + + return goVet() +} + +// lintCheckCli runs go vet and checks formatting without modifying files. +func lintCheckCli() error { + if err := goVet(); err != nil { + return err + } + + ui.Info("Checking formatting...") + output, err := exec.Run("gofmt", "-l", ".") + if err != nil { + ui.Error("gofmt failed", "error", err) + return err + } + if output != "" { + ui.Error("Files not formatted:") + for _, f := range strings.Split(strings.TrimSpace(output), "\n") { + ui.ListItem(f) + } + return fmt.Errorf("gofmt check failed") + } + ui.Success("Formatting check passed") + + return nil +} + +// goVet runs go vet on all packages. +func goVet() error { + ui.Info("Running go vet...") + if err := exec.RunPassthrough("go", "vet", "./..."); err != nil { + ui.Error("go vet failed", "error", err) + return err + } + ui.Success("go vet passed") + return nil +} + +// inCliDir runs fn inside the cli directory, restoring the original directory afterwards. +func inCliDir(fn func() error) error { + cliDir, err := findCliDir() + if err != nil { + return err + } + origDir, _ := os.Getwd() + if err := os.Chdir(cliDir); err != nil { + return err + } + defer os.Chdir(origDir) + return fn() +} + +// repoRoot returns the absolute path to the git repository root. +func repoRoot() (string, error) { + root, err := exec.Run("git", "rev-parse", "--show-toplevel") + if err != nil { + return "", fmt.Errorf("not in a git repository: %w", err) + } + return strings.TrimSpace(root), nil +} + +// findCliDir returns the absolute path to the cli directory using the git repo root. +func findCliDir() (string, error) { + root, err := repoRoot() + if err != nil { + return "", err + } + return filepath.Join(root, "cli"), nil +} + +func init() { + CliTestCmd.Flags().BoolP("verbose", "v", false, "Verbose test output") + CliLintCmd.Flags().BoolVar(&cliLintFix, "fix", false, "Auto-fix formatting") + CliCmd.AddCommand(CliBuildCmd) + CliCmd.AddCommand(CliTestCmd) + CliCmd.AddCommand(CliLintCmd) +} diff --git a/cli/cmd/meta/meta.go b/cli/cmd/meta/meta.go new file mode 100644 index 0000000..904f306 --- /dev/null +++ b/cli/cmd/meta/meta.go @@ -0,0 +1,9 @@ +// Package meta contains CLI maintenance commands (hidden from regular users). +package meta + +import "github.com/spf13/cobra" + +// Commands returns hidden CLI maintenance commands. +func Commands() []*cobra.Command { + return []*cobra.Command{} +} diff --git a/cli/cmd/meta/meta_test.go b/cli/cmd/meta/meta_test.go new file mode 100644 index 0000000..a58d5e1 --- /dev/null +++ b/cli/cmd/meta/meta_test.go @@ -0,0 +1,42 @@ +package meta + +import ( + "os/exec" + "path/filepath" + "testing" +) + +func TestCommands(t *testing.T) { + cmds := Commands() + if len(cmds) != 0 { + t.Errorf("Commands() returned %d commands, want 0", len(cmds)) + } +} + +func TestCliCmdIsVisible(t *testing.T) { + if CliCmd.Hidden { + t.Error("CliCmd should be visible") + } +} + +func TestCliCmdHasSubcommands(t *testing.T) { + if !CliCmd.HasSubCommands() { + t.Error("CliCmd should have subcommands (build, test)") + } +} + +func TestFindCliDir(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available (Nix build sandbox)") + } + dir, err := findCliDir() + if err != nil { + t.Fatalf("findCliDir() failed: %v", err) + } + if filepath.Base(dir) != "cli" { + t.Errorf("findCliDir() = %q, want path ending in cli", dir) + } + if !filepath.IsAbs(dir) { + t.Errorf("findCliDir() = %q, want absolute path", dir) + } +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go new file mode 100644 index 0000000..a9d4542 --- /dev/null +++ b/cli/cmd/root.go @@ -0,0 +1,87 @@ +// Package cmd contains all CLI commands for dap. +package cmd + +import ( + "github.com/eth-library/dap/cli/cmd/dagster" + "github.com/eth-library/dap/cli/cmd/dev" + "github.com/eth-library/dap/cli/cmd/env" + "github.com/eth-library/dap/cli/cmd/k8s" + "github.com/eth-library/dap/cli/cmd/meta" + "github.com/eth-library/dap/cli/internal/ui" + "github.com/spf13/cobra" +) + +// Command group IDs for organized --help output +const ( + GroupDevelopment = "development" + GroupEnvironment = "environment" + GroupDagster = "dagster" + GroupKubernetes = "kubernetes" + GroupGoCLI = "gocli" +) + +var rootCmd = &cobra.Command{ + Use: "dap", + Version: version, + Short: "Developer tools for the Data Archive Pipeline (DAP) Orchestrator", + Long: `dap is the CLI for the Data Archive Pipeline (DAP) Orchestrator. + +A Dagster-based orchestrator for processing digital assets following +the OAIS reference model. This tool provides commands for local development, +testing, code quality, and Kubernetes deployment.`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + if noColor, _ := cmd.Flags().GetBool("no-color"); noColor { + ui.DisableColors() + } + }, +} + +func init() { + // Disable default completion command + rootCmd.CompletionOptions.DisableDefaultCmd = true + + // Define command groups for organized help output + rootCmd.AddGroup( + &cobra.Group{ID: GroupDevelopment, Title: "Development:"}, + &cobra.Group{ID: GroupEnvironment, Title: "Environment:"}, + &cobra.Group{ID: GroupDagster, Title: "Dagster:"}, + &cobra.Group{ID: GroupKubernetes, Title: "Kubernetes:"}, + &cobra.Group{ID: GroupGoCLI, Title: "CLI Development:"}, + ) + + // Global flags + rootCmd.PersistentFlags().Bool("no-color", false, "Disable colored output") + + // Register commands from dev package + for _, cmd := range dev.Commands() { + rootCmd.AddCommand(cmd) + } + + // Register commands from env package + for _, cmd := range env.Commands() { + rootCmd.AddCommand(cmd) + } + + // Register commands from dagster package + for _, cmd := range dagster.Commands() { + rootCmd.AddCommand(cmd) + } + + // Add k8s subcommand (nested, not flat) + k8s.K8sCmd.GroupID = GroupKubernetes + rootCmd.AddCommand(k8s.K8sCmd) + + // Register meta/maintenance commands + for _, cmd := range meta.Commands() { + rootCmd.AddCommand(cmd) + } + + // Add go subcommand for CLI development + meta.CliCmd.GroupID = GroupGoCLI + rootCmd.AddCommand(meta.CliCmd) +} + +// Execute runs the root command. +func Execute() error { + return rootCmd.Execute() +} diff --git a/cli/cmd/root_test.go b/cli/cmd/root_test.go new file mode 100644 index 0000000..cafbd0e --- /dev/null +++ b/cli/cmd/root_test.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "bytes" + "testing" +) + +func TestExecute(t *testing.T) { + // Execute with --help should not error + rootCmd.SetArgs([]string{"--help"}) + + // Capture output + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + + err := rootCmd.Execute() + if err != nil { + t.Errorf("Execute() with --help returned error: %v", err) + } +} + +func TestRootCommandGroups(t *testing.T) { + groups := rootCmd.Groups() + + expectedGroups := []string{ + GroupDevelopment, + GroupEnvironment, + GroupDagster, + GroupKubernetes, + GroupGoCLI, + } + + if len(groups) != len(expectedGroups) { + t.Errorf("Expected %d groups, got %d", len(expectedGroups), len(groups)) + } + + // Verify all expected groups exist + groupIDs := make(map[string]bool) + for _, g := range groups { + groupIDs[g.ID] = true + } + + for _, expected := range expectedGroups { + if !groupIDs[expected] { + t.Errorf("Expected group %q not found", expected) + } + } +} + +func TestRootCommandHasSubcommands(t *testing.T) { + commands := rootCmd.Commands() + + if len(commands) == 0 { + t.Error("Root command has no subcommands") + } + + // Verify some expected commands exist + expectedCommands := []string{"dev", "test", "lint", "check", "k8s", "cli"} + commandNames := make(map[string]bool) + for _, cmd := range commands { + commandNames[cmd.Name()] = true + } + + for _, expected := range expectedCommands { + if !commandNames[expected] { + t.Errorf("Expected command %q not found", expected) + } + } +} + +func TestNoColorFlag(t *testing.T) { + flag := rootCmd.PersistentFlags().Lookup("no-color") + if flag == nil { + t.Error("--no-color flag not defined") + } + + if flag.DefValue != "false" { + t.Errorf("--no-color default should be 'false', got %q", flag.DefValue) + } +} + +func TestGroupConstants(t *testing.T) { + // Verify group constants have expected values + tests := []struct { + name string + constant string + expected string + }{ + {"GroupDevelopment", GroupDevelopment, "development"}, + {"GroupEnvironment", GroupEnvironment, "environment"}, + {"GroupDagster", GroupDagster, "dagster"}, + {"GroupKubernetes", GroupKubernetes, "kubernetes"}, + {"GroupGoCLI", GroupGoCLI, "gocli"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.constant != tt.expected { + t.Errorf("%s = %q, want %q", tt.name, tt.constant, tt.expected) + } + }) + } +} + +func TestRootCommandUsage(t *testing.T) { + if rootCmd.Use != "dap" { + t.Errorf("Root command Use = %q, want 'dap'", rootCmd.Use) + } + + if rootCmd.Short == "" { + t.Error("Root command Short description is empty") + } + + if rootCmd.Long == "" { + t.Error("Root command Long description is empty") + } +} + +func TestCompletionDisabled(t *testing.T) { + if !rootCmd.CompletionOptions.DisableDefaultCmd { + t.Error("Completion should be disabled") + } +} + +func TestRootCommandHasVersion(t *testing.T) { + if rootCmd.Version == "" { + t.Error("Root command Version is empty") + } +} diff --git a/cli/cmd/version.go b/cli/cmd/version.go new file mode 100644 index 0000000..8117056 --- /dev/null +++ b/cli/cmd/version.go @@ -0,0 +1,7 @@ +package cmd + +var ( + version = "dev" + commit = "none" + date = "unknown" +) diff --git a/cli/flake.lock b/cli/flake.lock new file mode 100644 index 0000000..4169826 --- /dev/null +++ b/cli/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gomod2nix": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1770585520, + "narHash": "sha256-yBz9Ozd5Wb56i3e3cHZ8WcbzCQ9RlVaiW18qDYA/AzA=", + "owner": "nix-community", + "repo": "gomod2nix", + "rev": "1201ddd1279c35497754f016ef33d5e060f3da8d", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "gomod2nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1770464364, + "narHash": "sha256-z5NJPSBwsLf/OfD8WTmh79tlSU8XgIbwmk6qB1/TFzY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "23d72dabcb3b12469f57b37170fcbc1789bd7457", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "gomod2nix": "gomod2nix", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/cli/flake.nix b/cli/flake.nix new file mode 100644 index 0000000..e342424 --- /dev/null +++ b/cli/flake.nix @@ -0,0 +1,33 @@ +{ + description = "DAP CLI - Developer tools for the Data Archive Pipeline"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + gomod2nix = { + url = "github:nix-community/gomod2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, gomod2nix }: + let + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ gomod2nix.overlays.default ]; + }; + in f pkgs + ); + in + { + packages = forAllSystems (pkgs: { + dap = pkgs.callPackage ./package.nix { }; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.dap; + }); + + # Export gomod2nix for the parent flake's devShell + inherit gomod2nix; + }; +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..db99275 --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,32 @@ +module github.com/eth-library/dap/cli + +go 1.25.5 + +require ( + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/log v0.4.2 + github.com/mattn/go-isatty v0.0.20 + github.com/muesli/termenv v0.16.0 + github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.40.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.5 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // 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/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sys v0.41.0 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..b592f46 --- /dev/null +++ b/cli/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/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +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/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4= +github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +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.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.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-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/gomod2nix.toml b/cli/gomod2nix.toml new file mode 100644 index 0000000..927f8fc --- /dev/null +++ b/cli/gomod2nix.toml @@ -0,0 +1,94 @@ +schema = 3 + +[mod] + [mod.'github.com/aymanbagabas/go-osc52/v2'] + version = 'v2.0.1' + hash = 'sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=' + + [mod.'github.com/charmbracelet/colorprofile'] + version = 'v0.4.1' + hash = 'sha256-d/NjM/ybG+bGRRRMMcjbPCFGFS5noZRMaL05Ix5r/II=' + + [mod.'github.com/charmbracelet/lipgloss'] + version = 'v1.1.0' + hash = 'sha256-RHsRT2EZ1nDOElxAK+6/DC9XAaGVjDTgPvRh3pyCfY4=' + + [mod.'github.com/charmbracelet/log'] + version = 'v0.4.2' + hash = 'sha256-3w1PCM/c4JvVEh2d0sMfv4C77Xs1bPa1Ea84zdynC7I=' + + [mod.'github.com/charmbracelet/x/ansi'] + version = 'v0.11.5' + hash = 'sha256-wN1s0rHnHWqQyw0HdJ46diMxaExifyGZNdAg5Wd1Gt4=' + + [mod.'github.com/charmbracelet/x/cellbuf'] + version = 'v0.0.15' + hash = 'sha256-0S60XaWhKZG+TB3Kqe1oMn2Okwdq53nym8XayVSHHiM=' + + [mod.'github.com/charmbracelet/x/term'] + version = 'v0.2.2' + hash = 'sha256-KF7IU1Luxl/sZP6XjomWB2e3lxSUS4/5AahhapGir/4=' + + [mod.'github.com/clipperhouse/displaywidth'] + version = 'v0.9.0' + hash = 'sha256-9CNyTZPSncKQ7Y0my9DR4WYXDjtDHYNL512D691WDAM=' + + [mod.'github.com/clipperhouse/stringish'] + version = 'v0.1.1' + hash = 'sha256-Mp8M1CRbwr6dcJ4BD9tXD5I78ZgCFEm0GDxJv0GYReg=' + + [mod.'github.com/clipperhouse/uax29/v2'] + version = 'v2.5.0' + hash = 'sha256-Men4JLhiuEtAx8ZSzId5ciRWhud68o3k/B48ppwyxkM=' + + [mod.'github.com/go-logfmt/logfmt'] + version = 'v0.6.0' + hash = 'sha256-RtIG2qARd5sT10WQ7F3LR8YJhS8exs+KiuUiVf75bWg=' + + [mod.'github.com/inconshreveable/mousetrap'] + version = 'v1.1.0' + hash = 'sha256-XWlYH0c8IcxAwQTnIi6WYqq44nOKUylSWxWO/vi+8pE=' + + [mod.'github.com/lucasb-eyer/go-colorful'] + version = 'v1.3.0' + hash = 'sha256-6BKrJsfmxie+YFAWzTYVPQfrwjQEXRo+J8LY+50C1BU=' + + [mod.'github.com/mattn/go-isatty'] + version = 'v0.0.20' + hash = 'sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=' + + [mod.'github.com/mattn/go-runewidth'] + version = 'v0.0.19' + hash = 'sha256-GpnbKplhX410Q/eIdknvWbYZgdav1keN+7wNUeOSMHE=' + + [mod.'github.com/muesli/termenv'] + version = 'v0.16.0' + hash = 'sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI=' + + [mod.'github.com/rivo/uniseg'] + version = 'v0.4.7' + hash = 'sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=' + + [mod.'github.com/spf13/cobra'] + version = 'v1.10.2' + hash = 'sha256-nbRCTFiDCC2jKK7AHi79n7urYCMP5yDZnWtNVJrDi+k=' + + [mod.'github.com/spf13/pflag'] + version = 'v1.0.9' + hash = 'sha256-YAjyYpq5BXCosVJtvYLWFG1t4gma2ylzc7ILLoj/hD8=' + + [mod.'github.com/xo/terminfo'] + version = 'v0.0.0-20220910002029-abceb7e1c41e' + hash = 'sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=' + + [mod.'golang.org/x/exp'] + version = 'v0.0.0-20231006140011-7918f672742d' + hash = 'sha256-2SO1etTQ6UCUhADR5sgvDEDLHcj77pJKCIa/8mGDbAo=' + + [mod.'golang.org/x/sys'] + version = 'v0.41.0' + hash = 'sha256-owjs3/IzAKfFlIz1U1fiHSfl2+bTUhaXTyWEjL5SWHk=' + + [mod.'golang.org/x/term'] + version = 'v0.40.0' + hash = 'sha256-tu8USqDmcWfwery/+xti/BdUpNLbMNmZeghBNDsj1fQ=' diff --git a/cli/internal/exec/run.go b/cli/internal/exec/run.go new file mode 100644 index 0000000..09a6f2b --- /dev/null +++ b/cli/internal/exec/run.go @@ -0,0 +1,47 @@ +// Package exec provides utilities for running external commands. +package exec + +import ( + "bytes" + "os" + "os/exec" + "strings" +) + +// Run executes a command and returns its combined output. +// Returns empty string if the command fails. +func Run(name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + return "", err + } + return strings.TrimSpace(stdout.String()), nil +} + +// RunInteractive runs a command with stdin/stdout/stderr connected to the terminal. +func RunInteractive(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// RunPassthrough runs a command, passing through all output to the terminal. +// Returns the exit code. +func RunPassthrough(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// Which checks if a command exists in PATH. +func Which(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} diff --git a/cli/internal/exec/run_test.go b/cli/internal/exec/run_test.go new file mode 100644 index 0000000..40d9736 --- /dev/null +++ b/cli/internal/exec/run_test.go @@ -0,0 +1,64 @@ +package exec + +import ( + "testing" +) + +func TestWhich(t *testing.T) { + tests := []struct { + name string + command string + expected bool + }{ + {"go exists", "go", true}, + {"nonexistent command", "definitely-not-a-real-command-12345", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Which(tt.command) + if got != tt.expected { + t.Errorf("Which(%q) = %v, want %v", tt.command, got, tt.expected) + } + }) + } +} + +func TestRun(t *testing.T) { + tests := []struct { + name string + command string + args []string + wantErr bool + wantContain string + }{ + { + name: "echo command", + command: "echo", + args: []string{"hello"}, + wantErr: false, + wantContain: "hello", + }, + { + name: "nonexistent command", + command: "definitely-not-a-real-command-12345", + args: []string{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Run(tt.command, tt.args...) + if (err != nil) != tt.wantErr { + t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && tt.wantContain != "" { + if got != tt.wantContain { + t.Errorf("Run() = %q, want to contain %q", got, tt.wantContain) + } + } + }) + } +} diff --git a/cli/internal/ui/output.go b/cli/internal/ui/output.go new file mode 100644 index 0000000..cc447f2 --- /dev/null +++ b/cli/internal/ui/output.go @@ -0,0 +1,353 @@ +package ui + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/log" + "golang.org/x/term" +) + +// Logger is the global logger instance for dap CLI. +var Logger *log.Logger + +func init() { + // Configure the logger with a clean, minimal style + Logger = log.NewWithOptions(os.Stderr, log.Options{ + ReportTimestamp: true, + TimeFormat: time.Kitchen, + Prefix: "", + }) + + // Adjust log level based on environment + if IsCI() { + Logger.SetReportTimestamp(false) + } +} + +// Layout constants +const ( + defaultSeparatorWidth = 80 +) + +// TerminalWidth returns the current terminal width, or a default if unavailable. +func TerminalWidth() int { + if w, _, err := term.GetSize(int(os.Stderr.Fd())); err == nil && w > 0 { + return w + } + return defaultSeparatorWidth +} + +// Separator returns a horizontal line of the specified width. +// If width is 0, it uses the full terminal width. +func Separator(width int) string { + if width <= 0 { + width = TerminalWidth() + } + return strings.Repeat("─", width) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Banner & Headers +// ═══════════════════════════════════════════════════════════════════════════ + +// Banner prints a styled banner with ASCII art logo centered above text. +// In CI/CD environments, prints a simple text banner instead. +func Banner(title, subtitle string) { + // Simple banner for CI/CD + if IsCI() || !IsTTY() { + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, " DAP-O | "+title) + fmt.Fprintln(os.Stderr, " "+subtitle) + return + } + + // Fancy banner for TTY + logoStyle := lipgloss.NewStyle(). + Foreground(ETHBlue). + Bold(true) + + titleStyle := lipgloss.NewStyle(). + Foreground(ColorMuted) + + subtitleStyle := lipgloss.NewStyle(). + Foreground(ETHPetrol) + + logoLines := []string{ + "██████╗ █████╗ ██████╗ ██████╗", + "██╔══██╗██╔══██╗██╔══██╗ ██╔═══██╗", + "██║ ██║███████║██████╔╝ █████╗ ██║ ██║", + "██║ ██║██╔══██║██╔═══╝ ╚════╝ ██║ ██║", + "██████╔╝██║ ██║██║ ╚██████╔╝", + "╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═════╝", + } + + // Calculate padding to center logo above title + logoWidth := len([]rune(logoLines[0])) + titleWidth := len([]rune(title)) + padding := " " // Base padding + if titleWidth > logoWidth { + // Center logo over title + extra := (titleWidth - logoWidth) / 2 + padding = " " + strings.Repeat(" ", extra) + } + + fmt.Fprintln(os.Stderr) + for _, line := range logoLines { + fmt.Fprintln(os.Stderr, padding+logoStyle.Render(line)) + } + fmt.Fprintln(os.Stderr, " "+titleStyle.Render(title)) + fmt.Fprintln(os.Stderr, " "+subtitleStyle.Render(subtitle)) +} + +// Title prints a styled title/header. +func Title(text string) { + style := lipgloss.NewStyle(). + Bold(true). + Foreground(ETHBlue). + MarginTop(1) + fmt.Fprintln(os.Stderr, style.Render(text)) +} + +// Subtitle prints a styled subtitle. +func Subtitle(text string) { + fmt.Fprintln(os.Stderr, Styles.Subtitle.Render(text)) +} + +// Section prints a section header. +func Section(title string) { + style := lipgloss.NewStyle(). + Foreground(ETHPetrol). + Bold(true). + MarginTop(1) + + fmt.Fprintln(os.Stderr, style.Render(title)) +} + +// Divider prints a horizontal line. +func Divider() { + fmt.Fprintln(os.Stderr, Styles.Dim.Render(Separator(0))) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Status Messages +// ═══════════════════════════════════════════════════════════════════════════ + +// Success prints a success message with a checkmark. +func Success(msg string, keyvals ...interface{}) { + icon := Styles.StatusOK.Render(Symbols.Success) + text := Styles.Success.Render(msg) + printStatusLine(icon, text, keyvals...) +} + +// Error prints an error message with an X mark. +func Error(msg string, keyvals ...interface{}) { + icon := Styles.StatusFail.Render(Symbols.Error) + text := Styles.Error.Render(msg) + printStatusLine(icon, text, keyvals...) +} + +// Warn prints a warning message with an exclamation mark. +func Warn(msg string, keyvals ...interface{}) { + icon := Styles.StatusWarn.Render(Symbols.Warning) + text := Styles.Warning.Render(msg) + printStatusLine(icon, text, keyvals...) +} + +// Info prints an info message with an arrow. +func Info(msg string, keyvals ...interface{}) { + icon := Styles.StatusInfo.Render(Symbols.Info) + printStatusLine(icon, msg, keyvals...) +} + +func printStatusLine(icon, msg string, keyvals ...interface{}) { + fmt.Fprintf(os.Stderr, "%s %s", icon, msg) + if len(keyvals) > 0 { + fmt.Fprintf(os.Stderr, " ") + for i := 0; i < len(keyvals); i += 2 { + if i+1 < len(keyvals) { + fmt.Fprintf(os.Stderr, "%s=%v ", Styles.Dim.Render(fmt.Sprint(keyvals[i])), keyvals[i+1]) + } + } + } + fmt.Fprintln(os.Stderr) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Key-Value Display +// ═══════════════════════════════════════════════════════════════════════════ + +// KeyValue prints a key-value pair with consistent formatting. +func KeyValue(key, value string) { + keyStyle := Styles.Dim.Width(14) + fmt.Fprintf(os.Stderr, " %s %s\n", keyStyle.Render(key), value) +} + +// KeyValueStatus prints a key-value pair with a status indicator. +func KeyValueStatus(key, value string, ok bool) { + keyStyle := Styles.Dim.Width(14) + var status string + if ok { + status = Styles.StatusOK.Render(Symbols.Success) + } else { + status = Styles.StatusWarn.Render(Symbols.Warning) + } + fmt.Fprintf(os.Stderr, " %s %s %s\n", keyStyle.Render(key), value, status) +} + +// KeyValueDim prints a key-value pair with dimmed value (for "not set" etc). +func KeyValueDim(key, value string) { + keyStyle := Styles.Dim.Width(14) + valueStyle := Styles.Dim + fmt.Fprintf(os.Stderr, " %s %s\n", keyStyle.Render(key), valueStyle.Render(value)) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Task & Step Indicators +// ═══════════════════════════════════════════════════════════════════════════ + +// Step prints a step indicator for multi-step operations. +func Step(current, total int, description string) { + stepStyle := lipgloss.NewStyle(). + Foreground(ETHBlue). + Bold(true) + + descStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#CCCCCC")) + + step := stepStyle.Render(fmt.Sprintf("[%d/%d]", current, total)) + desc := descStyle.Render(description) + + fmt.Fprintf(os.Stderr, "%s %s\n", step, desc) +} + +// StepDone prints a completed step. +func StepDone(current, total int, description string) { + stepStyle := lipgloss.NewStyle(). + Foreground(ColorSuccess). + Bold(true) + + step := stepStyle.Render(fmt.Sprintf("[%d/%d]", current, total)) + icon := Styles.StatusOK.Render(Symbols.Success) + + fmt.Fprintf(os.Stderr, "%s %s %s\n", step, icon, description) +} + +// StepFail prints a failed step. +func StepFail(current, total int, description string) { + stepStyle := lipgloss.NewStyle(). + Foreground(ColorError). + Bold(true) + + step := stepStyle.Render(fmt.Sprintf("[%d/%d]", current, total)) + icon := Styles.StatusFail.Render(Symbols.Error) + + fmt.Fprintf(os.Stderr, "%s %s %s\n", step, icon, description) +} + +// TaskStart prints a task starting message with arrow. +func TaskStart(task string) { + icon := lipgloss.NewStyle().Foreground(ETHPetrol).Render("▸") + fmt.Fprintf(os.Stderr, "%s %s\n", icon, task) +} + +// TaskDone prints a task completed message. +func TaskDone(task string) { + icon := Styles.StatusOK.Render(Symbols.Success) + fmt.Fprintf(os.Stderr, "%s %s\n", icon, Styles.Success.Render(task)) +} + +// TaskFail prints a task failed message. +func TaskFail(task string) { + icon := Styles.StatusFail.Render(Symbols.Error) + fmt.Fprintf(os.Stderr, "%s %s\n", icon, Styles.Error.Render(task)) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Command Hints +// ═══════════════════════════════════════════════════════════════════════════ + +// CommandHint prints a command hint for the user. +func CommandHint(cmd, description string) { + cmdStyle := Styles.Command.Width(14) + fmt.Fprintf(os.Stderr, " %s %s\n", cmdStyle.Render(cmd), Styles.Description.Render(description)) +} + +// Hint prints a dimmed hint/tip message. +func Hint(text string) { + fmt.Fprintf(os.Stderr, " %s\n", Styles.Dim.Render(text)) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tables & Lists +// ═══════════════════════════════════════════════════════════════════════════ + +// ListItem prints a bulleted list item. +func ListItem(text string) { + bullet := Styles.Dim.Render("•") + fmt.Fprintf(os.Stderr, " %s %s\n", bullet, text) +} + +// ListItemStatus prints a list item with status icon. +func ListItemStatus(text string, ok bool) { + var icon string + if ok { + icon = Styles.StatusOK.Render(Symbols.Success) + } else { + icon = Styles.StatusFail.Render(Symbols.Error) + } + fmt.Fprintf(os.Stderr, " %s %s\n", icon, text) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Boxes & Panels +// ═══════════════════════════════════════════════════════════════════════════ + +// Box renders content in a bordered box. +func Box(content string) string { + style := lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(ColorMuted). + Padding(0, 1) + return style.Render(content) +} + +// SuccessBox renders a success message in a styled box. +func SuccessBox(title, message string) { + style := lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(ColorSuccess). + Padding(0, 2). + Width(50) + + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(ColorSuccess) + + content := titleStyle.Render(Symbols.Success+" "+title) + "\n" + message + fmt.Fprintln(os.Stderr, style.Render(content)) +} + +// ErrorBox renders an error message in a styled box. +func ErrorBox(title, message string) { + style := lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(ColorError). + Padding(0, 2). + Width(50) + + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(ColorError) + + content := titleStyle.Render(Symbols.Error+" "+title) + "\n" + message + fmt.Fprintln(os.Stderr, style.Render(content)) +} + +// Newline prints an empty line. +func Newline() { + fmt.Fprintln(os.Stderr) +} diff --git a/cli/internal/ui/output_test.go b/cli/internal/ui/output_test.go new file mode 100644 index 0000000..578ed61 --- /dev/null +++ b/cli/internal/ui/output_test.go @@ -0,0 +1,364 @@ +package ui + +import ( + "bytes" + "os" + "strings" + "testing" +) + +// captureStderr captures stderr output from a function +func captureStderr(fn func()) string { + // Save original stderr + oldStderr := os.Stderr + + // Create a pipe + r, w, _ := os.Pipe() + os.Stderr = w + + // Run the function + fn() + + // Close writer and restore stderr + w.Close() + os.Stderr = oldStderr + + // Read captured output + var buf bytes.Buffer + buf.ReadFrom(r) + return buf.String() +} + +func TestLogger(t *testing.T) { + // Logger should be initialized + if Logger == nil { + t.Error("Logger is nil") + } +} + +func TestBanner(t *testing.T) { + output := captureStderr(func() { + Banner("Test Title", "Test Subtitle") + }) + + if output == "" { + t.Error("Banner() produced no output") + } + // In CI mode, should contain simple text + if !strings.Contains(output, "Test Title") && !strings.Contains(output, "DAP") { + t.Error("Banner() should contain title or DAP text") + } +} + +func TestTitle(t *testing.T) { + output := captureStderr(func() { + Title("Test Title") + }) + + if !strings.Contains(output, "Test Title") { + t.Errorf("Title() output should contain 'Test Title', got: %s", output) + } +} + +func TestSubtitle(t *testing.T) { + output := captureStderr(func() { + Subtitle("Test Subtitle") + }) + + if !strings.Contains(output, "Test Subtitle") { + t.Errorf("Subtitle() output should contain 'Test Subtitle', got: %s", output) + } +} + +func TestSection(t *testing.T) { + output := captureStderr(func() { + Section("Test Section") + }) + + if !strings.Contains(output, "Test Section") { + t.Errorf("Section() output should contain 'Test Section', got: %s", output) + } +} + +func TestDivider(t *testing.T) { + output := captureStderr(func() { + Divider() + }) + + if output == "" { + t.Error("Divider() produced no output") + } +} + +func TestSuccess(t *testing.T) { + output := captureStderr(func() { + Success("Operation completed") + }) + + if !strings.Contains(output, "Operation completed") { + t.Errorf("Success() should contain message, got: %s", output) + } +} + +func TestSuccessWithKeyvals(t *testing.T) { + output := captureStderr(func() { + Success("Done", "count", 5, "status", "ok") + }) + + if !strings.Contains(output, "Done") { + t.Error("Success() should contain message") + } + if !strings.Contains(output, "count") { + t.Error("Success() should contain key 'count'") + } +} + +func TestError(t *testing.T) { + output := captureStderr(func() { + Error("Something failed") + }) + + if !strings.Contains(output, "Something failed") { + t.Errorf("Error() should contain message, got: %s", output) + } +} + +func TestWarn(t *testing.T) { + output := captureStderr(func() { + Warn("Warning message") + }) + + if !strings.Contains(output, "Warning message") { + t.Errorf("Warn() should contain message, got: %s", output) + } +} + +func TestInfo(t *testing.T) { + output := captureStderr(func() { + Info("Info message") + }) + + if !strings.Contains(output, "Info message") { + t.Errorf("Info() should contain message, got: %s", output) + } +} + +func TestKeyValue(t *testing.T) { + output := captureStderr(func() { + KeyValue("Version", "1.0.0") + }) + + if !strings.Contains(output, "Version") { + t.Error("KeyValue() should contain key") + } + if !strings.Contains(output, "1.0.0") { + t.Error("KeyValue() should contain value") + } +} + +func TestKeyValueStatus(t *testing.T) { + t.Run("ok status", func(t *testing.T) { + output := captureStderr(func() { + KeyValueStatus("Python", "3.12.0", true) + }) + + if !strings.Contains(output, "Python") { + t.Error("KeyValueStatus() should contain key") + } + if !strings.Contains(output, "3.12.0") { + t.Error("KeyValueStatus() should contain value") + } + }) + + t.Run("not ok status", func(t *testing.T) { + output := captureStderr(func() { + KeyValueStatus("Python", "not found", false) + }) + + if !strings.Contains(output, "Python") { + t.Error("KeyValueStatus() should contain key") + } + }) +} + +func TestKeyValueDim(t *testing.T) { + output := captureStderr(func() { + KeyValueDim("Optional", "not set") + }) + + if !strings.Contains(output, "Optional") { + t.Error("KeyValueDim() should contain key") + } + if !strings.Contains(output, "not set") { + t.Error("KeyValueDim() should contain value") + } +} + +func TestStep(t *testing.T) { + output := captureStderr(func() { + Step(1, 3, "Installing dependencies") + }) + + if !strings.Contains(output, "1") && !strings.Contains(output, "3") { + t.Error("Step() should contain step numbers") + } + if !strings.Contains(output, "Installing dependencies") { + t.Error("Step() should contain description") + } +} + +func TestStepDone(t *testing.T) { + output := captureStderr(func() { + StepDone(1, 3, "Installed dependencies") + }) + + if !strings.Contains(output, "Installed dependencies") { + t.Error("StepDone() should contain description") + } +} + +func TestStepFail(t *testing.T) { + output := captureStderr(func() { + StepFail(2, 3, "Failed to compile") + }) + + if !strings.Contains(output, "Failed to compile") { + t.Error("StepFail() should contain description") + } +} + +func TestTaskStart(t *testing.T) { + output := captureStderr(func() { + TaskStart("Running tests") + }) + + if !strings.Contains(output, "Running tests") { + t.Error("TaskStart() should contain task name") + } +} + +func TestTaskDone(t *testing.T) { + output := captureStderr(func() { + TaskDone("Tests passed") + }) + + if !strings.Contains(output, "Tests passed") { + t.Error("TaskDone() should contain task name") + } +} + +func TestTaskFail(t *testing.T) { + output := captureStderr(func() { + TaskFail("Tests failed") + }) + + if !strings.Contains(output, "Tests failed") { + t.Error("TaskFail() should contain task name") + } +} + +func TestCommandHint(t *testing.T) { + output := captureStderr(func() { + CommandHint("dap dev", "Start development server") + }) + + if !strings.Contains(output, "dap dev") { + t.Error("CommandHint() should contain command") + } + if !strings.Contains(output, "Start development server") { + t.Error("CommandHint() should contain description") + } +} + +func TestHint(t *testing.T) { + output := captureStderr(func() { + Hint("Run dap --help for more info") + }) + + if !strings.Contains(output, "Run dap --help") { + t.Error("Hint() should contain hint text") + } +} + +func TestListItem(t *testing.T) { + output := captureStderr(func() { + ListItem("First item") + }) + + if !strings.Contains(output, "First item") { + t.Error("ListItem() should contain item text") + } +} + +func TestListItemStatus(t *testing.T) { + t.Run("ok status", func(t *testing.T) { + output := captureStderr(func() { + ListItemStatus("Passed test", true) + }) + + if !strings.Contains(output, "Passed test") { + t.Error("ListItemStatus() should contain item text") + } + }) + + t.Run("not ok status", func(t *testing.T) { + output := captureStderr(func() { + ListItemStatus("Failed test", false) + }) + + if !strings.Contains(output, "Failed test") { + t.Error("ListItemStatus() should contain item text") + } + }) +} + +func TestSuccessBox(t *testing.T) { + output := captureStderr(func() { + SuccessBox("Success!", "Operation completed successfully") + }) + + if output == "" { + t.Error("SuccessBox() produced no output") + } + if !strings.Contains(output, "Success!") { + t.Error("SuccessBox() should contain title") + } +} + +func TestErrorBox(t *testing.T) { + output := captureStderr(func() { + ErrorBox("Error!", "Something went wrong") + }) + + if output == "" { + t.Error("ErrorBox() produced no output") + } + if !strings.Contains(output, "Error!") { + t.Error("ErrorBox() should contain title") + } +} + +func TestNewline(t *testing.T) { + output := captureStderr(func() { + Newline() + }) + + if output != "\n" { + t.Errorf("Newline() should produce a single newline, got: %q", output) + } +} + +func TestPrintStatusLineWithOddKeyvals(t *testing.T) { + // Test with odd number of keyvals (incomplete pair) + output := captureStderr(func() { + Success("Test", "key1", "value1", "orphan") + }) + + // Should still contain the message and complete pairs + if !strings.Contains(output, "Test") { + t.Error("Should contain message") + } + if !strings.Contains(output, "key1") { + t.Error("Should contain key1") + } +} diff --git a/cli/internal/ui/styles.go b/cli/internal/ui/styles.go new file mode 100644 index 0000000..0b86edc --- /dev/null +++ b/cli/internal/ui/styles.go @@ -0,0 +1,138 @@ +// Package ui provides terminal UI components and styling for dap CLI. +// +// Design Philosophy: +// - Minimal and professional aesthetic inspired by gh, gum, and docker CLI +// - ETH Zurich brand colors where they provide good terminal readability +// - Graceful degradation in CI environments (no colors, no animations) +// - Consistent visual language across all commands +package ui + +import ( + "os" + + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-isatty" + "github.com/muesli/termenv" +) + +// ETH Zurich brand colors (adjusted for terminal readability) +// See: https://ethz.ch/staffnet/en/service/communication/corporate-design/colours.html +var ( + // Primary colors + ETHBlue = lipgloss.Color("#215CAF") // Headers, primary accent + ETHPetrol = lipgloss.Color("#007894") // Secondary accent, info + ETHGreen = lipgloss.Color("#627313") // Success (may need brightening on dark bg) + ETHBronze = lipgloss.Color("#8E6713") // Warnings + ETHRed = lipgloss.Color("#B7352D") // Errors + ETHPurple = lipgloss.Color("#A7117A") // Special highlights + + // Semantic aliases for better terminal contrast + ColorSuccess = lipgloss.Color("#22c55e") // Brighter green for dark terminals + ColorWarning = ETHBronze + ColorError = ETHRed + ColorInfo = ETHPetrol + ColorAccent = ETHBlue + + // Adaptive colors for light/dark terminal accessibility + ColorMuted = lipgloss.AdaptiveColor{Light: "#555555", Dark: "#a0a0a0"} +) + +// Styles defines reusable lipgloss styles for consistent output. +var Styles = struct { + // Text styles + Bold lipgloss.Style + Dim lipgloss.Style + Italic lipgloss.Style + Underline lipgloss.Style + + // Semantic styles + Success lipgloss.Style + Warning lipgloss.Style + Error lipgloss.Style + Info lipgloss.Style + + // Component styles + Title lipgloss.Style + Subtitle lipgloss.Style + Command lipgloss.Style + Flag lipgloss.Style + Description lipgloss.Style + + // Status indicators + StatusOK lipgloss.Style + StatusFail lipgloss.Style + StatusWarn lipgloss.Style + StatusInfo lipgloss.Style +}{ + // Text styles + Bold: lipgloss.NewStyle().Bold(true), + Dim: lipgloss.NewStyle().Foreground(ColorMuted), + Italic: lipgloss.NewStyle().Italic(true), + Underline: lipgloss.NewStyle().Underline(true), + + // Semantic styles + Success: lipgloss.NewStyle().Foreground(ColorSuccess), + Warning: lipgloss.NewStyle().Foreground(ColorWarning), + Error: lipgloss.NewStyle().Foreground(ColorError), + Info: lipgloss.NewStyle().Foreground(ColorInfo), + + // Component styles + Title: lipgloss.NewStyle().Bold(true).Foreground(ETHBlue), + Subtitle: lipgloss.NewStyle().Foreground(ColorMuted), + Command: lipgloss.NewStyle().Bold(true).Foreground(ETHPetrol), + Flag: lipgloss.NewStyle().Foreground(ETHPurple), + Description: lipgloss.NewStyle().Foreground(ColorMuted), + + // Status indicators with symbols + StatusOK: lipgloss.NewStyle().Foreground(ColorSuccess), + StatusFail: lipgloss.NewStyle().Foreground(ColorError), + StatusWarn: lipgloss.NewStyle().Foreground(ColorWarning), + StatusInfo: lipgloss.NewStyle().Foreground(ColorInfo), +} + +// Symbols for status indicators +var Symbols = struct { + Success string + Error string + Warning string + Info string + Arrow string + Dot string +}{ + Success: "✓", + Error: "✗", + Warning: "!", + Info: "→", + Arrow: "→", + Dot: "•", +} + +// IsCI returns true if running in a CI environment. +func IsCI() bool { + return os.Getenv("CI") != "" || os.Getenv("GITHUB_ACTIONS") != "" +} + +// IsTTY returns true if stderr is a terminal. +// Note: CLI output goes to stderr, so we check stderr not stdout. +func IsTTY() bool { + return isatty.IsTerminal(os.Stderr.Fd()) || isatty.IsCygwinTerminal(os.Stderr.Fd()) +} + +// DisableColors disables all color output (for CI or --no-color flag). +func DisableColors() { + lipgloss.SetColorProfile(termenv.Ascii) +} + +// NoColor returns true if NO_COLOR env var is set (any value). +// See: https://no-color.org/ +func NoColor() bool { + _, exists := os.LookupEnv("NO_COLOR") + return exists +} + +func init() { + // Auto-disable colors per no-color.org standard and for CI/non-TTY + if NoColor() || IsCI() || !IsTTY() { + DisableColors() + } +} diff --git a/cli/internal/ui/styles_test.go b/cli/internal/ui/styles_test.go new file mode 100644 index 0000000..c7740fd --- /dev/null +++ b/cli/internal/ui/styles_test.go @@ -0,0 +1,249 @@ +package ui + +import ( + "os" + "testing" + + "github.com/charmbracelet/lipgloss" +) + +func TestIsCI(t *testing.T) { + // Save original env + origCI := os.Getenv("CI") + origGHA := os.Getenv("GITHUB_ACTIONS") + defer func() { + os.Setenv("CI", origCI) + os.Setenv("GITHUB_ACTIONS", origGHA) + }() + + tests := []struct { + name string + ci string + gha string + expected bool + }{ + {"no env vars", "", "", false}, + {"CI set", "true", "", true}, + {"GITHUB_ACTIONS set", "", "true", true}, + {"both set", "true", "true", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv("CI", tt.ci) + os.Setenv("GITHUB_ACTIONS", tt.gha) + + got := IsCI() + if got != tt.expected { + t.Errorf("IsCI() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestSymbolsAreDefined(t *testing.T) { + if Symbols.Success == "" { + t.Error("Symbols.Success is empty") + } + if Symbols.Error == "" { + t.Error("Symbols.Error is empty") + } + if Symbols.Warning == "" { + t.Error("Symbols.Warning is empty") + } + if Symbols.Info == "" { + t.Error("Symbols.Info is empty") + } +} + +func TestStylesAreDefined(t *testing.T) { + // Just verify styles can be rendered without panic + _ = Styles.Bold.Render("test") + _ = Styles.Dim.Render("test") + _ = Styles.Success.Render("test") + _ = Styles.Error.Render("test") + _ = Styles.Warning.Render("test") +} + +func TestDisableColors(t *testing.T) { + // Just verify it doesn't panic + DisableColors() +} + +func TestSeparator(t *testing.T) { + tests := []struct { + name string + width int + }{ + {"explicit width 10", 10}, + {"explicit width 30", 30}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Separator(tt.width) + // Count runes, not bytes (─ is multi-byte UTF-8) + runeCount := 0 + for range got { + runeCount++ + } + if runeCount != tt.width { + t.Errorf("Separator(%d) rune count = %d, want %d", tt.width, runeCount, tt.width) + } + }) + } +} + +func TestTerminalWidth(t *testing.T) { + // Just ensure it returns a positive value + w := TerminalWidth() + if w <= 0 { + t.Errorf("TerminalWidth() = %d, want > 0", w) + } +} + +func TestNoColor(t *testing.T) { + // Save original env + origNoColor, hadNoColor := os.LookupEnv("NO_COLOR") + defer func() { + if hadNoColor { + os.Setenv("NO_COLOR", origNoColor) + } else { + os.Unsetenv("NO_COLOR") + } + }() + + tests := []struct { + name string + setEnv bool + value string + expected bool + }{ + {"NO_COLOR not set", false, "", false}, + {"NO_COLOR set to empty", true, "", true}, + {"NO_COLOR set to 1", true, "1", true}, + {"NO_COLOR set to true", true, "true", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setEnv { + os.Setenv("NO_COLOR", tt.value) + } else { + os.Unsetenv("NO_COLOR") + } + + got := NoColor() + if got != tt.expected { + t.Errorf("NoColor() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestIsTTY(t *testing.T) { + // IsTTY should return a boolean without panicking + // In test environment, it may return false (not a real terminal) + _ = IsTTY() +} + +func TestBox(t *testing.T) { + content := "test content" + result := Box(content) + + // Box should contain the content + if result == "" { + t.Error("Box() returned empty string") + } + // The result should be longer than just the content (has borders) + if len(result) <= len(content) { + t.Errorf("Box() result length %d should be > content length %d", len(result), len(content)) + } +} + +func TestSeparatorZeroWidth(t *testing.T) { + // Separator(0) should use terminal width (default) + sep := Separator(0) + if sep == "" { + t.Error("Separator(0) returned empty string") + } +} + +func TestColorsAreDefined(t *testing.T) { + // Verify all color constants are usable + colors := []lipgloss.Color{ + ETHBlue, + ETHPetrol, + ETHGreen, + ETHBronze, + ETHRed, + ETHPurple, + ColorSuccess, + ColorWarning, + ColorError, + ColorInfo, + ColorAccent, + } + + for _, c := range colors { + // Colors should be non-empty strings + if string(c) == "" { + t.Errorf("Color constant is empty") + } + } +} + +func TestSymbolsComplete(t *testing.T) { + // Verify all symbols have distinct values + symbols := map[string]string{ + "Success": Symbols.Success, + "Error": Symbols.Error, + "Warning": Symbols.Warning, + "Info": Symbols.Info, + "Arrow": Symbols.Arrow, + "Dot": Symbols.Dot, + } + + for name, val := range symbols { + if val == "" { + t.Errorf("Symbol %s is empty", name) + } + } +} + +func TestAllStylesRender(t *testing.T) { + testText := "test" + + // Test all styles can render without panic + styles := []struct { + name string + style lipgloss.Style + }{ + {"Bold", Styles.Bold}, + {"Dim", Styles.Dim}, + {"Italic", Styles.Italic}, + {"Underline", Styles.Underline}, + {"Success", Styles.Success}, + {"Warning", Styles.Warning}, + {"Error", Styles.Error}, + {"Info", Styles.Info}, + {"Title", Styles.Title}, + {"Subtitle", Styles.Subtitle}, + {"Command", Styles.Command}, + {"Flag", Styles.Flag}, + {"Description", Styles.Description}, + {"StatusOK", Styles.StatusOK}, + {"StatusFail", Styles.StatusFail}, + {"StatusWarn", Styles.StatusWarn}, + {"StatusInfo", Styles.StatusInfo}, + } + + for _, s := range styles { + t.Run(s.name, func(t *testing.T) { + result := s.style.Render(testText) + if result == "" { + t.Errorf("Styles.%s.Render() returned empty", s.name) + } + }) + } +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..2acb5f2 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,15 @@ +// Package main is the entry point for the dap CLI. +// dap is a developer experience tool for the Data Archive Pipeline. +package main + +import ( + "os" + + "github.com/eth-library/dap/cli/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cli/package.nix b/cli/package.nix new file mode 100644 index 0000000..5704657 --- /dev/null +++ b/cli/package.nix @@ -0,0 +1,23 @@ +{ buildGoApplication, lib }: + +buildGoApplication { + pname = "dap"; + version = "dev"; + src = lib.cleanSource ./.; + modules = ./gomod2nix.toml; + + ldflags = [ + "-s" "-w" + ]; + + postInstall = '' + mv $out/bin/cli $out/bin/dap + ''; + + meta = with lib; { + description = "Development tooling for the DAP Orchestrator"; + homepage = "https://github.com/eth-library/data-assets-pipeline"; + license = licenses.asl20; + mainProgram = "dap"; + }; +} diff --git a/flake.lock b/flake.lock index 42349e7..cfcf96e 100644 --- a/flake.lock +++ b/flake.lock @@ -1,6 +1,77 @@ { "nodes": { + "dap-cli": { + "inputs": { + "gomod2nix": "gomod2nix", + "nixpkgs": "nixpkgs" + }, + "locked": { + "path": "./cli", + "type": "path" + }, + "original": { + "path": "./cli", + "type": "path" + }, + "parent": [] + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gomod2nix": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "dap-cli", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1770585520, + "narHash": "sha256-yBz9Ozd5Wb56i3e3cHZ8WcbzCQ9RlVaiW18qDYA/AzA=", + "owner": "nix-community", + "repo": "gomod2nix", + "rev": "1201ddd1279c35497754f016ef33d5e060f3da8d", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "gomod2nix", + "type": "github" + } + }, "nixpkgs": { + "locked": { + "lastModified": 1770464364, + "narHash": "sha256-z5NJPSBwsLf/OfD8WTmh79tlSU8XgIbwmk6qB1/TFzY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "23d72dabcb3b12469f57b37170fcbc1789bd7457", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { "locked": { "lastModified": 1769900590, "narHash": "sha256-I7Lmgj3owOTBGuauy9FL6qdpeK2umDoe07lM4V+PnyA=", @@ -18,7 +89,23 @@ }, "root": { "inputs": { - "nixpkgs": "nixpkgs" + "dap-cli": "dap-cli", + "nixpkgs": "nixpkgs_2" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index deb6bfa..675b4b0 100644 --- a/flake.nix +++ b/flake.nix @@ -1,32 +1,50 @@ { - description = "Development environment for da_pipeline using Nix, uv, and .venv"; + description = "Development environment for the Data Archive Pipeline (DAP) Orchestrator"; - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + dap-cli.url = "path:./cli"; + }; - outputs = { self, nixpkgs }: + outputs = { self, nixpkgs, dap-cli }: let systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); in { - devShells = forAllSystems (pkgs: { - default = pkgs.mkShell { - packages = [ - pkgs.python312 - pkgs.uv - pkgs.kubectl - pkgs.kubernetes-helm - pkgs.just - pkgs.jq - pkgs.curl - pkgs.openssl - ]; - - # Required for pip-installed packages with C++ extensions (e.g., grpcio) - LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ - pkgs.stdenv.cc.cc.lib - ]; - }; + packages = forAllSystems (pkgs: { + dap = dap-cli.packages.${pkgs.stdenv.hostPlatform.system}.dap; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.dap; }); + + devShells = forAllSystems (pkgs: + let + # Package groups + basePackages = [ pkgs.python312 pkgs.uv ]; + cliPackage = [ dap-cli.packages.${pkgs.stdenv.hostPlatform.system}.dap ]; + k8sPackages = [ pkgs.kubectl pkgs.kubernetes-helm ]; + goPackages = [ pkgs.go dap-cli.gomod2nix.packages.${pkgs.stdenv.hostPlatform.system}.default ]; + + # Helper to create shells with common settings + mkDevShell = packages: pkgs.mkShell { + inherit packages; + # Required for pip-installed packages with C++ extensions (e.g., grpcio) + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ]; + }; + + in { + # Default: Everything (full development environment) + default = mkDevShell (basePackages ++ cliPackage ++ k8sPackages ++ goPackages); + + # Minimal: Python + dap CLI (for running pipeline and tests) + minimal = mkDevShell (basePackages ++ cliPackage); + + # K8s: Python + dap CLI + kubectl/helm (for deployment) + k8s = mkDevShell (basePackages ++ cliPackage ++ k8sPackages); + + # CLI development: Python + dap CLI + Go (for working on the dap CLI) + cli-dev = mkDevShell (basePackages ++ cliPackage ++ goPackages); + } + ); }; } diff --git a/helm/values-local.yaml b/helm/values-local.yaml index 04b6a1e..6ebfe89 100644 --- a/helm/values-local.yaml +++ b/helm/values-local.yaml @@ -2,6 +2,14 @@ # Requires: Docker Desktop with Kubernetes enabled --- +#################################################################################################### +# Webserver - Expose via LoadBalancer for direct access (no port-forward needed) +#################################################################################################### +dagsterWebserver: + service: + type: LoadBalancer + port: 8080 + #################################################################################################### # User Code Deployments - Local overrides #################################################################################################### diff --git a/justfile b/justfile deleted file mode 100644 index 2993b0f..0000000 --- a/justfile +++ /dev/null @@ -1,213 +0,0 @@ -# Justfile for da_pipeline development -# Run `just` or `just --list` to see available commands - -set shell := ["bash", "-euo", "pipefail", "-c"] - -# Configuration -namespace := "dagster" -release := "dagster" -image := "da-pipeline:local" -helm_chart := "dagster/dagster" -helm_version := "1.10.14" -pg_secret_name := "dagster-postgresql" # K8s secret name (password auto-generated) - -# Default: show available commands -default: - @just --list - -# Show environment info (versions + commands) -info: - @echo "da_pipeline" - @echo "===========" - @just versions - @echo "" - @just --list - -# Show tool versions -versions: - @echo "python: $(python --version 2>&1 | cut -d' ' -f2)" - @echo "uv: $(uv --version 2>&1 | cut -d' ' -f2 || echo 'n/a')" - @echo "just: $(just --version 2>&1 | cut -d' ' -f2 || echo 'n/a')" - @echo "kubectl: $(kubectl version --client -o json 2>/dev/null | python -c 'import sys,json; print(json.load(sys.stdin)["clientVersion"]["gitVersion"])' 2>/dev/null || echo 'n/a')" - @echo "helm: $(helm version --short 2>/dev/null | cut -d'+' -f1 || echo 'n/a')" - @echo "dagster: $(python -c 'import dagster; print(dagster.__version__)' 2>/dev/null || echo '⚠ not installed - run: just setup')" - -# ═══════════════════════════════════════════════════════════════════════════════ -# Local Development -# ═══════════════════════════════════════════════════════════════════════════════ - -# Setup Python environment (creates venv if needed, syncs deps) -setup: - #!/usr/bin/env bash - set -euo pipefail - [ ! -d .venv ] && uv venv --python "$(which python)" - [ ! -f uv.lock ] && uv lock - source .venv/bin/activate - uv sync --extra dev --quiet - echo "Done." - -# Start Dagster development server (localhost:3000) -dev: - dagster dev - -# Run tests -test *ARGS: - pytest da_pipeline_tests {{ ARGS }} - -# Lint code (check only) -lint: - ruff check da_pipeline da_pipeline_tests - ruff format --check da_pipeline da_pipeline_tests - -# Format and fix code -fmt: - ruff check --fix da_pipeline da_pipeline_tests - ruff format da_pipeline da_pipeline_tests - -# ═══════════════════════════════════════════════════════════════════════════════ -# Kubernetes (k8s-*) -# ═══════════════════════════════════════════════════════════════════════════════ - -# Deploy to local Kubernetes (localhost:8080) -k8s-up: _check-k8s _build _helm-up - #!/usr/bin/env bash - set -euo pipefail - echo "" - just k8s-status - echo "" - pkill -f "kubectl.*port-forward.*8080" 2>/dev/null || true - # Start port-forward in background with retries (suppress all output) - (while ! kubectl port-forward svc/dagster-dagster-webserver 8080:80 -n {{ namespace }} &>/dev/null; do sleep 2; done) & - - echo "Waiting for webserver to start..." - while true; do - if curl -s -o /dev/null -w '' --connect-timeout 1 http://localhost:8080 2>/dev/null; then - echo "" - echo "✓ Dagster UI is ready!" - echo " URL: http://localhost:8080" - exit 0 - fi - echo "[$(date +%H:%M:%S)] Waiting for webserver... (retrying in 2s)" - sleep 2 - done - -# Tear down Kubernetes deployment -k8s-down: _check-k8s - #!/usr/bin/env bash - set -euo pipefail - echo "Tearing down..." - helm uninstall {{ release }} -n {{ namespace }} --wait=false 2>/dev/null || true - # Delete all dagster run jobs and pods (prevents PVC from being stuck) - kubectl delete jobs -n {{ namespace }} -l dagster/run-id --timeout=10s 2>/dev/null || true - kubectl delete pods -n {{ namespace }} -l dagster/run-id --grace-period=0 --force --timeout=10s 2>/dev/null || true - kubectl delete pvc dagster-storage -n {{ namespace }} --timeout=10s 2>/dev/null || true - kubectl delete configmap test-data-xml -n {{ namespace }} --timeout=10s 2>/dev/null || true - echo "Done." - -# Rebuild and restart user code pod -k8s-restart: _check-k8s _build - @kubectl rollout restart deployment -n {{ namespace }} -l "app.kubernetes.io/name=dagster-user-deployments" - @kubectl rollout status deployment -n {{ namespace }} -l "app.kubernetes.io/name=dagster-user-deployments" --timeout=120s - @echo "Restarted." - -# Show pods and services -k8s-status: - #!/usr/bin/env bash - set -euo pipefail - if ! kubectl get namespace {{ namespace }} &>/dev/null; then - echo "Kubernetes deployment not running. Use 'just k8s-up' to deploy." - exit 0 - fi - echo "=== Pods ===" - kubectl get pods -n {{ namespace }} -o wide 2>/dev/null || echo "No pods" - echo "" - echo "=== Services ===" - kubectl get svc -n {{ namespace }} 2>/dev/null || echo "No services" - -# Stream logs from user code pod -k8s-logs *ARGS: - kubectl logs -n {{ namespace }} -l "app.kubernetes.io/name=dagster-user-deployments" --tail=100 -f {{ ARGS }} - -# Port-forward to Dagster UI (localhost:8080) -k8s-ui: - #!/usr/bin/env bash - set -euo pipefail - if lsof -i :8080 -sTCP:LISTEN &>/dev/null; then - echo "✓ Port-forward already active: http://localhost:8080" - exit 0 - fi - - # Start port-forward in background - (kubectl port-forward svc/dagster-dagster-webserver 8080:80 -n {{ namespace }} &>/dev/null &) - - echo "Waiting for webserver to start..." - while true; do - if curl -s -o /dev/null -w '' --connect-timeout 1 http://localhost:8080 2>/dev/null; then - echo "" - echo "✓ Dagster UI is ready!" - echo " URL: http://localhost:8080" - exit 0 - fi - echo "[$(date +%H:%M:%S)] Waiting for webserver... (retrying in 2s)" - sleep 2 - done - -# Open shell in user code pod -k8s-shell: - kubectl exec -it -n {{ namespace }} $(kubectl get pods -n {{ namespace }} -l "app.kubernetes.io/name=dagster-user-deployments" -o jsonpath='{.items[0].metadata.name}') -- /bin/bash - -# ═══════════════════════════════════════════════════════════════════════════════ -# Internal recipes (prefixed with _) -# ═══════════════════════════════════════════════════════════════════════════════ - -[private] -_check-k8s: - @kubectl cluster-info &>/dev/null || (echo "Error: Kubernetes not available. Enable it in Docker Desktop." && exit 1) - @kubectl config use-context docker-desktop &>/dev/null || true - -[private] -_build: - @echo "Building {{ image }}..." - @docker build -t {{ image }} -q . - @echo "Built." - -[private] -_helm-up: - #!/usr/bin/env bash - set -euo pipefail - - # Ensure namespace - kubectl create namespace {{ namespace }} --dry-run=client -o yaml | kubectl apply -f - - - # Create K8s secret with random password (generated once, reused on subsequent runs) - if ! kubectl get secret {{ pg_secret_name }} -n {{ namespace }} &>/dev/null; then - kubectl create secret generic {{ pg_secret_name }} \ - --from-literal=postgresql-password="$(openssl rand -base64 24)" \ - -n {{ namespace }} - echo "Created K8s secret '{{ pg_secret_name }}' with random password." - fi - - # Setup helm repo (only update if not updated in last hour) - helm repo add dagster https://dagster-io.github.io/helm 2>/dev/null || true - REPO_CACHE="$HOME/.cache/helm/repository/dagster-index.yaml" - if [[ ! -f "$REPO_CACHE" ]] || [[ $(find "$REPO_CACHE" -mmin +60 2>/dev/null) ]]; then - helm repo update dagster >/dev/null - fi - - # Apply PVC from manifest (creates or updates) - kubectl apply -n {{ namespace }} -f helm/pvc.yaml - - # Create ConfigMap for test data (only if changed) - if ls da_pipeline_tests/test_data/*.xml &>/dev/null; then - kubectl create configmap test-data-xml --from-file=da_pipeline_tests/test_data/ -n {{ namespace }} --dry-run=client -o yaml | kubectl apply -f - - fi - - # Deploy with Helm (no --wait for faster return) - echo "Deploying Dagster..." - helm upgrade --install {{ release }} {{ helm_chart }} \ - -f helm/values.yaml \ - -f helm/values-local.yaml \ - -n {{ namespace }} \ - --version {{ helm_version }} \ - --skip-schema-validation - echo "Deployed (pods starting in background)."