diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a7347f1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,150 @@ +name: CI + +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + +jobs: + test: + name: Test & Coverage + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run tests with coverage + id: test + run: | + go vet ./... + + set +e + go test ./... -coverprofile=coverage.out -covermode=atomic > test-output.txt 2>&1 + exit_code=$? + set -e + + total=$(go tool cover -func=coverage.out | grep total | awk '{print $NF}') + echo "coverage=$total" >> $GITHUB_OUTPUT + echo "exit_code=$exit_code" >> $GITHUB_OUTPUT + + go tool cover -func=coverage.out > coverage_detail.txt + + EXIT_CODE=$exit_code TOTAL=$total python3 << 'PYEOF' + import os, re + + exit_code = int(os.environ.get("EXIT_CODE", "0")) + total = os.environ.get("TOTAL", "0.0%") + + # Parse go tool cover -func output + with open("coverage_detail.txt") as f: + lines = f.readlines() + + pkg_data = {} + for line in lines: + if not line.strip() or "total" in line.lower(): + continue + # Format: path/file.go:line: func pct% + parts = line.split() + if len(parts) < 2: + continue + file_func = parts[0] + pct_str = parts[-1].rstrip('%') + try: + pct = float(pct_str) + except ValueError: + continue + + # Extract package path + if ':' in file_func: + file_path = file_func.split(':')[0] + else: + continue + + # Normalize package + if file_path.startswith("github.com/"): + segments = file_path.split("/") + if "internal" in segments: + idx = segments.index("internal") + # Keep internal/subpackage structure + pkg = "/".join(segments[:idx+2]) if idx+1 < len(segments) else file_path + else: + pkg = "/".join(segments[:-1]) + if not pkg: + pkg = "github.com/PyratLabs/ugo" + else: + pkg = file_path + + if pkg not in pkg_data: + pkg_data[pkg] = [] + pkg_data[pkg].append(pct) + + # Write report + with open("report.md", "w") as f: + f.write("## Test Results\n\n") + + if exit_code == 0: + f.write("✅ **All tests passed**\n\n") + else: + f.write("❌ **Some tests failed**\n\n") + f.write("
Test output\n\n") + f.write("```\n") + with open("test-output.txt") as tf: + f.write(tf.read()) + f.write("```\n\n") + f.write("
\n\n") + + f.write(f"📊 **Total Coverage**: `{total}`\n\n") + f.write("### Coverage by Package\n\n") + f.write("| Package | Coverage |\n") + f.write("|---------|----------|\n") + + for pkg in sorted(pkg_data.keys()): + pcts = pkg_data[pkg] + avg = sum(pcts) / len(pcts) + f.write(f"| `{pkg}` | `{avg:.1f}%` |\n") + PYEOF + + - name: Post/Update Coverage Comment + if: github.event_name == 'pull_request' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + REPO="${{ github.repository }}" + PR_NUM="${{ github.event.pull_request.number }}" + + COMMENT_ID=$(gh api repos/$REPO/issues/$PR_NUM/comments \ + --jq '.[] | select(.body | contains("")) | .id' | head -1) + + python3 << 'PYEOF' + import json + + with open("report.md") as f: + report = f.read() + + body = "\n" + report + payload = json.dumps({"body": body}) + + with open("payload.json", "w") as f: + f.write(payload) + PYEOF + + if [ -n "$COMMENT_ID" ]; then + gh api repos/$REPO/issues/comments/$COMMENT_ID \ + -X PATCH --input payload.json + else + gh api repos/$REPO/issues/$PR_NUM/comments \ + -X POST --input payload.json + fi + + - name: Fail if tests failed + if: steps.test.outputs.exit_code != '0' + run: exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ecf7c94 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: Release + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build binaries + run: | + PLATFORMS="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64" + for platform in $PLATFORMS; do + os="${platform%/*}" + arch="${platform#*/}" + bin="ugo-${os}-${arch}" + [ "$os" = "windows" ] && bin="${bin}.exe" + GOOS=$os GOARCH=$arch go build -o "$bin" . + tar czf "${bin}.tar.gz" "$bin" + done + ls -la ugo-*.tar.gz + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: ugo-*.tar.gz + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98c2d0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Built binary +ugo + +# Local project config (global config lives in ~/.config//) +ugo.yaml +*.yaml + +# IDE / editor +.idea/ +.vscode/ +*.swp +*~ + +# OS +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1382eb7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# AGENTS.md + +## What is this repo? + +- uGo — a Go CLI using Cobra that executes project-specific commands defined in YAML config. +- Binary is renameable; config file name and help text follow the binary name automatically. +- Config loading: global (`~/.config//config.yaml`) merged with local (`./.yaml`), local overrides. + +## Commands + +```bash +go build -o ugo . # build +go test ./... # run all tests +go test ./... -cover # run tests with coverage +go vet ./... # vet +``` + +## Structure + +- `main.go` — entry point, calls `cmd.RootCmd().Execute()` +- `cmd/root.go` — Cobra root command; dynamically creates subcommands from YAML config +- `internal/config/config.go` — loads global + local config, merges them +- `internal/checker/checker.go` — pre-flight tool dependency validation +- `internal/version/version.go` — semver extraction and comparison +- `internal/output/output.go` — colored/emoji output with `--no-color` flag support +- `internal/output/output_test.go` — ANSI stripping verification +- `internal/args/args.go` — argument validation (enum, glob, regex) + +Tests: `cmd/root_test.go`, `internal/{config,checker,version,output,args}/*_test.go` + +## Working conventions + +- Verbs are defined in YAML, not hardcoded. Adding a new verb means editing config, not code. +- Config schema: + - `commands..{cmd, description, arguments[]}` — arguments are objects with `name`, optional `values` (enum), optional `match` (glob or regex) + - `tools..{min_version, max_version, version_cmd, download_url}` — pre-flight checks run before every verb +- Arguments after the verb are mapped positionally to `arguments` entries and expanded into `${name}` placeholders in `cmd` +- `match` is auto-detected: contains `*` or `?` → glob (checks files on disk); otherwise → regex (full string match, auto-anchored) +- Glob matching accepts full path, basename, or basename without extension +- `ugo check` runs tool checks and prints status for each tool +- Version comparison uses `golang.org/x/mod/semver`; `version_cmd` output is scanned for a semver pattern +- Running a verb without required arguments (or with invalid args) prints the error then the help, then exits diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fe03510 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Xan Manning + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d0b4353..df88a5e 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,186 @@ # uGo -## The Ubiquitous "Go script". +The ubiquitous `./go` script — as a Go binary. -### Description +## Overview -The public release of my "go script" that I use to abstract away common -operations for personal projects. The aim is to provide a common vocabulary for -each project, providing the verbs dependent on the context of the current -working directory. +uGo is a CLI tool that executes project-specific commands defined in YAML configuration. +It follows the ThoughtWorks `./go` script pattern: provide a common vocabulary for each +project, with verbs that are context-aware based on the current working directory. -### Further Reading +The binary name is flexible — rename it and the help text and config file name follow +automatically, allowing multiple instances for different projects. - - In Praise of the `./go` Script: - - [Part I](https://www.thoughtworks.com/insights/blog/praise-go-script-part-i) - - [Part II](https://www.thoughtworks.com/insights/blog/praise-go-script-part-ii) +## Quick Start -### Author +### Install + +```bash +go install github.com/xanmanning/ugo@latest +``` + +Or build from source: + +```bash +go build -o ugo . +``` + +### Create a config + +Create `.yaml` in your project root: + +```yaml +tools: + ansible-playbook: + min_version: "2.10.0" + version_cmd: "ansible-playbook --version" + download_url: "https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html" + go: + min_version: "1.25.0" + version_cmd: "go version" + kubectl: + download_url: "https://kubernetes.io/docs/tasks/tools/" + +commands: + plan: + cmd: ansible-playbook --check environments/${environment}.yaml playbooks/${playbook}.yaml + description: "Run an ansible playbook in check mode." + arguments: + - name: environment + values: [dev, staging, prod] + - name: playbook + match: "playbooks/*.yaml" + lint: + cmd: go test ./... + description: "Run test suite" + deploy: + cmd: kubectl apply -f ./deployments/${service} --context ${region} + description: "Deploy a service" + arguments: + - name: service + match: "[a-z][a-z0-9-]+" + - name: region + values: [us-east-1, eu-west-1, ap-southeast-1] +``` + +### Run commands + +```bash +ugo plan dev ensure-ssh # runs: ansible-playbook --check environments/dev.yaml playbooks/ensure-ssh.yaml +ugo lint # runs: go test ./... +ugo check # verify tool dependencies +ugo plan --help # shows argument validation rules +ugo --no-color # disable colored output +``` + +## Configuration + +uGo loads configuration from two locations and merges them (local overrides global): + +| Scope | Path | +|----------|--------------------------------------------| +| Global | `~/.config//config.yaml` | +| Local | `/.yaml` | + +### Config format + +#### Tools + +```yaml +tools: + : + min_version: "" # optional minimum version + max_version: "" # optional maximum version + version_cmd: "" # how to get the version string (defaults to " --version") + download_url: "" # shown if tool is missing (optional) +``` + +#### Commands + +```yaml +commands: + : + cmd: "" + description: "" + arguments: # positional argument definitions (optional) + - name: + values: [val1, val2] # optional: restrict to enum values + match: "" # optional: validate with file glob or regex +``` + +#### Argument validation + +Arguments support three validation modes: + +| Mode | Config | Behavior | +|------|--------|----------| +| **Enum** | `values: [dev, staging, prod]` | Value must be in the list | +| **Glob** | `match: "playbooks/*.yaml"` | Checks files on disk; accepts full path, basename, or basename without extension | +| **Regex** | `match: "[a-z]+"` | Full-string regex match (auto-anchored) | + +Glob vs regex is auto-detected: if the pattern contains `*` or `?` it's treated as a file glob. + +### Example + +```bash +# config: cmd: ansible-playbook environments/${environment}.yaml +# args: environment with values [dev, prod] + +ugo plan dev → ansible-playbook environments/dev.yaml +ugo plan test → ❌ argument 'environment': value "test" not in allowed values: [dev, prod] +``` + +Running a verb without required arguments shows the error followed by argument validation rules: + +```bash +$ ugo plan + +❌ expected 2 argument(s) for 'plan': environment, playbook + +Arguments: + environment dev, staging, prod + playbook ensure-ssh, setup-db + +Usage: + ugo plan [flags] +``` + +## Tool Dependency Checks + +Before executing any verb, uGo checks configured tools: + +- Verifies each binary exists in `$PATH` +- Extracts version from `version_cmd` output +- Compares against `min_version` and `max_version` using semver + +Run `ugo check` manually to inspect all tool status. + +```bash +$ ugo check + ✅ ansible-playbook (v2.20.5) + ✅ docker (v28.5.1) + ✅ All tool dependencies satisfied + +$ ugo check + ❌ nonexistent-tool is not installed, download at: https://example.com + ✅ docker (v28.5.1) + + ❌ Tool checks failed +``` + +## Colored Output + +uGo uses UTF-8 icons and colors for status output. Use `--no-color` to disable: + +```bash +ugo --no-color plan dev ensure-ssh +``` + +## Further Reading + +- [In Praise of the `./go` Script — Part I](https://www.thoughtworks.com/insights/blog/praise-go-script-part-i) +- [In Praise of the `./go` Script — Part II](https://www.thoughtworks.com/insights/blog/praise-go-script-part-ii) + +## Author Xan Manning, 2020 diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..bdc60e5 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,259 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/PyratLabs/ugo/internal/args" + "github.com/PyratLabs/ugo/internal/checker" + "github.com/PyratLabs/ugo/internal/config" + "github.com/PyratLabs/ugo/internal/output" +) + +var ( + binaryName string + appCfg *config.Config + noColor bool +) + +func RootCmd() *cobra.Command { + binaryName = config.BinaryName() + + var err error + appCfg, err = config.Load(binaryName) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) + os.Exit(1) + } + + root := &cobra.Command{ + Use: binaryName, + Short: fmt.Sprintf("%s — context-aware project verbs", binaryName), + Long: fmt.Sprintf(`%s executes project-specific commands defined in YAML configuration. + +Global config: %s +Local config: %s + +Local config overrides global config for the same verb names.`, + binaryName, + func() string { g, _ := config.ConfigPaths(binaryName); return g }(), + func() string { _, l := config.ConfigPaths(binaryName); return l }(), + ), + CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + output.SetNoColor(noColor) + if cmd.Name() == "check" || cmd.Name() == "help" { + return nil + } + return runToolChecks() + }, + } + + root.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable color output") + root.SetFlagErrorFunc(func(c *cobra.Command, err error) error { + return fmt.Errorf("unknown flag: %s\nRun '%s help' for usage", err.Error(), binaryName) + }) + + // Build subcommands from config + for name, cmdDef := range appCfg.Commands { + root.AddCommand(buildCommand(name, cmdDef)) + } + + root.AddCommand(checkCmd()) + + return root +} + +func checkCmd() *cobra.Command { + return &cobra.Command{ + Use: "check", + Short: "Check required tool dependencies", + Run: func(cmd *cobra.Command, args []string) { + output.SetNoColor(noColor) + if len(appCfg.Tools) == 0 { + output.Info("No tool dependencies configured.") + return + } + + output.Bold("Checking tool dependencies...\n") + + issues := checker.CheckTools(appCfg.Tools) + printToolStatus(appCfg.Tools, issues) + + if checker.HasErrors(issues) { + fmt.Fprintln(os.Stderr) + output.CheckFail("Tool checks failed") + os.Exit(1) + } + + fmt.Fprintln(os.Stderr) + output.CheckPass("All tool dependencies satisfied") + }, + } +} + +func printToolStatus(tools map[string]config.Tool, issues []checker.Issue) { + issueMap := make(map[string]checker.Issue) + for _, i := range issues { + issueMap[i.Tool] = i + } + + for name := range tools { + if issue, ok := issueMap[name]; ok { + for _, e := range issue.Errors { + if strings.HasPrefix(e, "version:") { + output.CheckPass(fmt.Sprintf("%s (%s)", name, strings.TrimPrefix(e, "version: "))) + } else { + output.CheckFail(e) + } + } + } else { + output.CheckPass(name) + } + } +} + +func runToolChecks() error { + if len(appCfg.Tools) == 0 { + return nil + } + + issues := checker.CheckTools(appCfg.Tools) + if !checker.HasErrors(issues) { + return nil + } + + output.CheckFail("Tool dependency errors:") + for _, issue := range issues { + for _, e := range issue.Errors { + if strings.HasPrefix(e, "version:") { + continue + } + output.CheckFail(fmt.Sprintf("%s: %s", issue.Tool, e)) + } + } + os.Exit(1) + return nil +} + +func buildCommand(name string, def config.Command) *cobra.Command { + c := &cobra.Command{ + Use: buildUse(name, def.Arguments), + Short: def.Description, + Long: buildLong(def.Arguments), + RunE: func(cmd *cobra.Command, args []string) error { + return executeCommand(cmd, name, def, args) + }, + } + + return c +} + +func buildLong(arguments []config.Argument) string { + if len(arguments) == 0 { + return "" + } + + var b strings.Builder + b.WriteString("\nArguments:\n") + for _, arg := range arguments { + b.WriteString(fmt.Sprintf(" %-20s", arg.Name)) + switch { + case len(arg.Values) > 0: + b.WriteString(strings.Join(arg.Values, ", ")) + case arg.Match != "": + if args.IsGlob(arg.Match) { + matches, _ := filepath.Glob(arg.Match) + if len(matches) > 0 { + names := make([]string, len(matches)) + for i, m := range matches { + base := filepath.Base(m) + names[i] = strings.TrimSuffix(base, filepath.Ext(base)) + } + b.WriteString(strings.Join(names, ", ")) + } else { + b.WriteString("(no files found)") + } + } else { + b.WriteString(fmt.Sprintf("^%s$", arg.Match)) + } + default: + b.WriteString("(no validation)") + } + b.WriteString("\n") + } + + return b.String() +} + +func buildUse(name string, arguments []config.Argument) string { + if len(arguments) == 0 { + return name + } + parts := make([]string, len(arguments)) + for i, arg := range arguments { + parts[i] = fmt.Sprintf("<%s>", arg.Name) + } + return fmt.Sprintf("%s %s", name, strings.Join(parts, " ")) +} + +func executeCommand(cmd *cobra.Command, name string, def config.Command, values []string) error { + if len(values) != len(def.Arguments) { + argNames := args.ArgNames(def.Arguments) + output.CheckFail(fmt.Sprintf("expected %d argument(s) for '%s': %s", + len(def.Arguments), name, strings.Join(argNames, ", "))) + fmt.Fprintln(os.Stderr) + cmd.Help() + os.Exit(1) + } + + if errs := args.ValidateArgs(def.Arguments, values); len(errs) > 0 { + for _, e := range errs { + output.CheckFail(e.Error()) + } + fmt.Fprintln(os.Stderr) + cmd.Help() + os.Exit(1) + } + + vars := args.ArgMap(def.Arguments, values) + + expanded := os.Expand(def.Cmd, func(key string) string { + val, ok := vars[key] + if !ok { + return fmt.Sprintf("${%s}", key) + } + return val + }) + + parts := strings.Fields(expanded) + if len(parts) == 0 { + output.CheckFail(fmt.Sprintf("empty command configured for '%s'", name)) + os.Exit(1) + } + + bin := parts[0] + cmdArgs := parts[1:] + + output.CommandRunning(name, expanded) + + command := exec.Command(bin, cmdArgs...) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + command.Stdin = os.Stdin + + if err := command.Run(); err != nil { + output.CommandFail(name) + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + os.Exit(1) + } + + output.CommandSuccess(name) + return nil +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..bbf32e0 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,284 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/PyratLabs/ugo/internal/config" +) + +func TestBuildUse(t *testing.T) { + tests := []struct { + name string + verb string + arguments []config.Argument + want string + }{ + { + name: "no arguments", + verb: "lint", + arguments: nil, + want: "lint", + }, + { + name: "single argument", + verb: "plan", + arguments: []config.Argument{{Name: "environment"}}, + want: "plan ", + }, + { + name: "multiple arguments", + verb: "deploy", + arguments: []config.Argument{{Name: "environment"}, {Name: "service"}, {Name: "region"}}, + want: "deploy ", + }, + { + name: "empty arguments slice", + verb: "test", + arguments: []config.Argument{}, + want: "test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildUse(tt.verb, tt.arguments) + if got != tt.want { + t.Errorf("buildUse(%q, %v) = %q, want %q", tt.verb, tt.arguments, got, tt.want) + } + }) + } +} + +func TestRootCmd(t *testing.T) { + // Save and restore os.Args + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + dir := t.TempDir() + configPath := filepath.Join(dir, "testgo.yaml") + if err := os.WriteFile(configPath, []byte(` +commands: + plan: + cmd: echo plan + description: "Plan the environment" + arguments: + - name: environment + - name: playbook + lint: + cmd: echo lint + description: "Run linter" +`), 0644); err != nil { + t.Fatal(err) + } + + os.Args = []string{"testgo"} + os.Chdir(dir) + + root := RootCmd() + + // Verify root command properties + if root.Use != "testgo" { + t.Errorf("root.Use = %q, want %q", root.Use, "testgo") + } + if !strings.Contains(root.Short, "testgo") { + t.Errorf("root.Short = %q, want to contain %q", root.Short, "testgo") + } + + // Verify subcommands were created + sub := root.Commands() + found := map[string]bool{} + for _, c := range sub { + found[c.Name()] = true + } + + if !found["plan"] { + t.Error("expected subcommand 'plan'") + } + if !found["lint"] { + t.Error("expected subcommand 'lint'") + } + if !found["check"] { + t.Error("expected built-in subcommand 'check'") + } + + // Verify help is available (Cobra adds it implicitly) + _, _, err := root.Find([]string{"help"}) + if err != nil { + t.Logf("help command lookup: %v (may be implicit)", err) + } +} + +func TestRootCmdNoConfig(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + dir := t.TempDir() + os.Args = []string{"noconfig"} + os.Chdir(dir) + + // Should not panic when no config exists + root := RootCmd() + if root.Use != "noconfig" { + t.Errorf("root.Use = %q, want %q", root.Use, "noconfig") + } +} + +func TestRootCmdNoColorFlag(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + dir := t.TempDir() + os.Args = []string{"testgo"} + os.Chdir(dir) + + root := RootCmd() + + // Verify --no-color flag exists + flag := root.PersistentFlags().Lookup("no-color") + if flag == nil { + t.Fatal("expected --no-color flag") + } + if flag.DefValue != "false" { + t.Errorf("no-color default = %q, want %q", flag.DefValue, "false") + } +} + +func TestBuildCommand(t *testing.T) { + tests := []struct { + name string + cmdName string + def config.Command + wantUse string + wantDesc string + }{ + { + name: "with arguments", + cmdName: "plan", + def: config.Command{ + Cmd: "echo plan", + Description: "Plan the env", + Arguments: []config.Argument{{Name: "environment"}}, + }, + wantUse: "plan ", + wantDesc: "Plan the env", + }, + { + name: "without arguments", + cmdName: "lint", + def: config.Command{ + Cmd: "echo lint", + Description: "Run linter", + }, + wantUse: "lint", + wantDesc: "Run linter", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := buildCommand(tt.cmdName, tt.def) + if cmd.Use != tt.wantUse { + t.Errorf("command.Use = %q, want %q", cmd.Use, tt.wantUse) + } + if cmd.Short != tt.wantDesc { + t.Errorf("command.Short = %q, want %q", cmd.Short, tt.wantDesc) + } + }) + } +} + +func TestRootCmdExecute(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + dir := t.TempDir() + configPath := filepath.Join(dir, "execgo.yaml") + if err := os.WriteFile(configPath, []byte(` +commands: + hello: + cmd: echo hello + description: "Say hello" +`), 0644); err != nil { + t.Fatal(err) + } + + os.Args = []string{"execgo"} + os.Chdir(dir) + + root := RootCmd() + + // Capture stdout since the command writes to os.Stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + root.SetArgs([]string{"hello"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + w.Close() + var out bytes.Buffer + out.ReadFrom(r) + os.Stdout = oldStdout + + if !strings.Contains(out.String(), "hello") { + t.Errorf("output = %q, want to contain %q", out.String(), "hello") + } +} + +func TestBuildLong(t *testing.T) { + tests := []struct { + name string + arguments []config.Argument + want []string + }{ + { + name: "no arguments", + arguments: nil, + want: nil, + }, + { + name: "enum values", + arguments: []config.Argument{ + {Name: "env", Values: []string{"dev", "staging", "prod"}}, + }, + want: []string{"dev, staging, prod"}, + }, + { + name: "regex match", + arguments: []config.Argument{ + {Name: "service", Match: "[a-z][a-z0-9-]+"}, + }, + want: []string{"^[a-z][a-z0-9-]+$"}, + }, + { + name: "no validation", + arguments: []config.Argument{ + {Name: "freeform"}, + }, + want: []string{"(no validation)"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildLong(tt.arguments) + if len(tt.want) == 0 { + if got != "" { + t.Errorf("buildLong() = %q, want empty", got) + } + return + } + for _, w := range tt.want { + if !strings.Contains(got, w) { + t.Errorf("buildLong() = %q, want to contain %q", got, w) + } + } + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f142cc5 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module github.com/PyratLabs/ugo + +go 1.25.4 + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ac65af3 --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +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/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +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/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/args/args.go b/internal/args/args.go new file mode 100644 index 0000000..c39a2ef --- /dev/null +++ b/internal/args/args.go @@ -0,0 +1,113 @@ +package args + +import ( + "fmt" + "path/filepath" + "regexp" + + "github.com/PyratLabs/ugo/internal/config" +) + +// Validate checks a single argument value against its validation rules. +// Returns nil if the argument is valid or has no rules. +func Validate(arg config.Argument, value string) error { + if len(arg.Values) > 0 { + if err := validateEnum(arg.Name, arg.Values, value); err != nil { + return err + } + } + + if arg.Match != "" { + if err := validateMatch(arg.Name, arg.Match, value); err != nil { + return err + } + } + + return nil +} + +func validateEnum(name string, values []string, actual string) error { + for _, v := range values { + if v == actual { + return nil + } + } + return fmt.Errorf("argument '%s': value %q not in allowed values: %s", name, actual, values) +} + +func validateMatch(name string, pattern string, actual string) error { + if IsGlob(pattern) { + matches, err := filepath.Glob(pattern) + if err != nil { + return fmt.Errorf("argument '%s': invalid glob pattern %q: %w", name, pattern, err) + } + for _, m := range matches { + if m == actual || filepath.Base(m) == actual || stripExt(filepath.Base(m)) == actual { + return nil + } + } + return fmt.Errorf("argument '%s': no file matching pattern %q found for value %q", name, pattern, actual) + } + + pattern = "^" + pattern + "$" + re, err := regexp.Compile(pattern) + if err != nil { + return fmt.Errorf("argument '%s': invalid regex pattern %q: %w", name, pattern, err) + } + if !re.MatchString(actual) { + return fmt.Errorf("argument '%s': value %q does not match pattern %q", name, actual, pattern) + } + + return nil +} + +func IsGlob(pattern string) bool { + for _, c := range pattern { + if c == '*' || c == '?' { + return true + } + } + return false +} + +func stripExt(name string) string { + ext := filepath.Ext(name) + if ext != "" { + return name[:len(name)-len(ext)] + } + return name +} + +// ArgNames extracts argument names for usage display and error messages +func ArgNames(args []config.Argument) []string { + names := make([]string, len(args)) + for i, a := range args { + names[i] = a.Name + } + return names +} + +// ValidateArgs validates all arguments in order +func ValidateArgs(arguments []config.Argument, values []string) []error { + var errs []error + for i, arg := range arguments { + if i >= len(values) { + break + } + if err := Validate(arg, values[i]); err != nil { + errs = append(errs, err) + } + } + return errs +} + +// ArgMap builds a name-to-value map for template expansion +func ArgMap(arguments []config.Argument, values []string) map[string]string { + m := make(map[string]string) + for i, arg := range arguments { + if i < len(values) { + m[arg.Name] = values[i] + } + } + return m +} diff --git a/internal/args/args_test.go b/internal/args/args_test.go new file mode 100644 index 0000000..6e86026 --- /dev/null +++ b/internal/args/args_test.go @@ -0,0 +1,277 @@ +package args + +import ( + "os" + "path/filepath" + "testing" + + "github.com/PyratLabs/ugo/internal/config" +) + +func TestValidate(t *testing.T) { + t.Run("no rules", func(t *testing.T) { + arg := config.Argument{Name: "foo"} + if err := Validate(arg, "anything"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("enum pass", func(t *testing.T) { + arg := config.Argument{ + Name: "env", + Values: []string{"dev", "staging", "prod"}, + } + for _, v := range arg.Values { + if err := Validate(arg, v); err != nil { + t.Errorf("Validate(%q) = %v, want nil", v, err) + } + } + }) + + t.Run("enum fail", func(t *testing.T) { + arg := config.Argument{ + Name: "env", + Values: []string{"dev", "staging", "prod"}, + } + err := Validate(arg, "invalid") + if err == nil { + t.Error("expected error for invalid enum value") + } + }) + + t.Run("regex pass", func(t *testing.T) { + arg := config.Argument{ + Name: "service", + Match: "[a-z][a-z0-9-]+", + } + for _, v := range []string{"my-service", "api", "web-app-2"} { + if err := Validate(arg, v); err != nil { + t.Errorf("Validate(%q) = %v, want nil", v, err) + } + } + }) + + t.Run("regex fail", func(t *testing.T) { + arg := config.Argument{ + Name: "service", + Match: "[a-z][a-z0-9-]+", + } + for _, v := range []string{"InvalidService", "123start", ""} { + err := Validate(arg, v) + if err == nil { + t.Errorf("Validate(%q) = nil, want error", v) + } + } + }) + + t.Run("invalid regex", func(t *testing.T) { + arg := config.Argument{ + Name: "foo", + Match: "[invalid", + } + err := Validate(arg, "anything") + if err == nil { + t.Error("expected error for invalid regex") + } + }) + + t.Run("glob pass - full path", func(t *testing.T) { + dir := t.TempDir() + for _, f := range []string{"a.yaml", "b.yaml"} { + if err := os.WriteFile(filepath.Join(dir, f), nil, 0644); err != nil { + t.Fatal(err) + } + } + arg := config.Argument{ + Name: "file", + Match: filepath.Join(dir, "*.yaml"), + } + if err := Validate(arg, filepath.Join(dir, "a.yaml")); err != nil { + t.Errorf("Validate(full path) = %v, want nil", err) + } + }) + + t.Run("glob pass - basename", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), nil, 0644); err != nil { + t.Fatal(err) + } + arg := config.Argument{ + Name: "file", + Match: filepath.Join(dir, "*.yaml"), + } + if err := Validate(arg, "config.yaml"); err != nil { + t.Errorf("Validate(basename) = %v, want nil", err) + } + }) + + t.Run("glob pass - basename without ext", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "playbook.yaml"), nil, 0644); err != nil { + t.Fatal(err) + } + arg := config.Argument{ + Name: "file", + Match: filepath.Join(dir, "*.yaml"), + } + if err := Validate(arg, "playbook"); err != nil { + t.Errorf("Validate(basename without ext) = %v, want nil", err) + } + }) + + t.Run("glob fail", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "only.yaml"), nil, 0644); err != nil { + t.Fatal(err) + } + arg := config.Argument{ + Name: "file", + Match: filepath.Join(dir, "*.yaml"), + } + err := Validate(arg, "nonexistent") + if err == nil { + t.Error("expected error for nonexistent file") + } + }) + + t.Run("glob no matches", func(t *testing.T) { + dir := t.TempDir() + arg := config.Argument{ + Name: "file", + Match: filepath.Join(dir, "*.yaml"), + } + err := Validate(arg, "anything") + if err == nil { + t.Error("expected error when no files match glob") + } + }) + + t.Run("both enum and regex fail enum", func(t *testing.T) { + arg := config.Argument{ + Name: "env", + Values: []string{"dev", "prod"}, + Match: "[a-z]+", + } + err := Validate(arg, "invalid") + if err == nil { + t.Error("expected error") + } + }) +} + +func TestValidateArgs(t *testing.T) { + argDefs := []config.Argument{ + {Name: "env", Values: []string{"dev", "prod"}}, + {Name: "service", Match: "[a-z]+"}, + } + + t.Run("all valid", func(t *testing.T) { + errs := ValidateArgs(argDefs, []string{"dev", "api"}) + if len(errs) != 0 { + t.Errorf("expected no errors, got %v", errs) + } + }) + + t.Run("first invalid", func(t *testing.T) { + errs := ValidateArgs(argDefs, []string{"staging", "api"}) + if len(errs) != 1 { + t.Fatalf("expected 1 error, got %d", len(errs)) + } + }) + + t.Run("both invalid", func(t *testing.T) { + errs := ValidateArgs(argDefs, []string{"staging", "123"}) + if len(errs) != 2 { + t.Errorf("expected 2 errors, got %d: %v", len(errs), errs) + } + }) +} + +func TestArgNames(t *testing.T) { + args := []config.Argument{ + {Name: "env"}, + {Name: "service"}, + } + names := ArgNames(args) + if len(names) != 2 { + t.Fatalf("expected 2 names, got %d", len(names)) + } + if names[0] != "env" || names[1] != "service" { + t.Errorf("names = %v, want [env, service]", names) + } +} + +func TestArgMap(t *testing.T) { + argDefs := []config.Argument{ + {Name: "env"}, + {Name: "service"}, + } + m := ArgMap(argDefs, []string{"dev", "api"}) + if m["env"] != "dev" { + t.Errorf("m[env] = %q, want %q", m["env"], "dev") + } + if m["service"] != "api" { + t.Errorf("m[service] = %q, want %q", m["service"], "api") + } +} + +func TestIsGlob(t *testing.T) { + tests := []struct { + pattern string + want bool + }{ + {"*.yaml", true}, + {"playbooks/*", true}, + {"?.txt", true}, + {"[a-z]+", false}, + {"^[a-z]+$", false}, + {"exact.yaml", false}, + {"simple", false}, + } + + for _, tt := range tests { + t.Run(tt.pattern, func(t *testing.T) { + got := IsGlob(tt.pattern) + if got != tt.want { + t.Errorf("isGlob(%q) = %v, want %v", tt.pattern, got, tt.want) + } + }) + } +} + +func TestStripExt(t *testing.T) { + tests := []struct { + name string + want string + }{ + {"playbook.yaml", "playbook"}, + {"config.json", "config"}, + {"noext", "noext"}, + {".hidden", ""}, // filepath.Ext treats .hidden as extension + {"file.tar.gz", "file.tar"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripExt(tt.name) + if got != tt.want { + t.Errorf("stripExt(%q) = %q, want %q", tt.name, got, tt.want) + } + }) + } +} + +func TestRegexFullMatch(t *testing.T) { + arg := config.Argument{ + Name: "id", + Match: "[a-z][0-9]", + } + + if err := Validate(arg, "a1"); err != nil { + t.Errorf("Validate(a1) = %v, want nil", err) + } + + if err := Validate(arg, "xa1x"); err == nil { + t.Error("Validate(xa1x) = nil, want error (should not partial match)") + } +} diff --git a/internal/checker/checker.go b/internal/checker/checker.go new file mode 100644 index 0000000..712a3bb --- /dev/null +++ b/internal/checker/checker.go @@ -0,0 +1,81 @@ +package checker + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/PyratLabs/ugo/internal/config" + "github.com/PyratLabs/ugo/internal/version" +) + +// Issue represents a problem found during tool checking +type Issue struct { + Tool string + Errors []string +} + +// CheckTools validates all required tools are available with correct versions +func CheckTools(tools map[string]config.Tool) []Issue { + var issues []Issue + + for name, tool := range tools { + var errs []string + + if _, err := exec.LookPath(name); err != nil { + msg := fmt.Sprintf("%s is not installed", name) + if tool.DownloadURL != "" { + msg += fmt.Sprintf(", download at: %s", tool.DownloadURL) + } + errs = append(errs, msg) + issues = append(issues, Issue{Tool: name, Errors: errs}) + continue + } + + if tool.MinVersion != "" || tool.MaxVersion != "" { + cmd := tool.VersionCmd + if cmd == "" { + cmd = fmt.Sprintf("%s --version", name) + } + + foundVer, err := version.Check(name, cmd, tool.MinVersion, tool.MaxVersion) + if err != nil { + errs = append(errs, err.Error()) + } else if tool.MinVersion != "" || tool.MaxVersion != "" { + errs = append(errs, fmt.Sprintf("version: %s", foundVer)) + } + } + + if len(errs) > 0 { + issues = append(issues, Issue{Tool: name, Errors: errs}) + } + } + + return issues +} + +// FormatErrors renders issues as user-friendly error messages +func FormatErrors(issues []Issue) string { + var b strings.Builder + for _, issue := range issues { + for _, err := range issue.Errors { + if strings.HasPrefix(err, "version:") { + continue // version info, not an error + } + b.WriteString(fmt.Sprintf(" - %s: %s\n", issue.Tool, err)) + } + } + return strings.TrimSuffix(b.String(), "\n") +} + +// HasErrors returns true if any issues contain actual errors (not just version info) +func HasErrors(issues []Issue) bool { + for _, issue := range issues { + for _, err := range issue.Errors { + if !strings.HasPrefix(err, "version:") { + return true + } + } + } + return false +} diff --git a/internal/checker/checker_test.go b/internal/checker/checker_test.go new file mode 100644 index 0000000..24dcd6a --- /dev/null +++ b/internal/checker/checker_test.go @@ -0,0 +1,174 @@ +package checker + +import ( + "os" + "path/filepath" + "testing" + + "github.com/PyratLabs/ugo/internal/config" +) + +func TestCheckTools(t *testing.T) { + tmp := t.TempDir() + scriptPath := filepath.Join(tmp, "fake-tool") + if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho \"fake-tool v1.5.0\"\n"), 0755); err != nil { + t.Fatal(err) + } + + badScriptPath := filepath.Join(tmp, "bad-version") + if err := os.WriteFile(badScriptPath, []byte("#!/bin/sh\necho \"no version here\"\n"), 0755); err != nil { + t.Fatal(err) + } + + t.Run("installed tool with passing version", func(t *testing.T) { + tools := map[string]config.Tool{ + scriptPath: { + MinVersion: "1.0.0", + VersionCmd: scriptPath, + }, + } + issues := CheckTools(tools) + if HasErrors(issues) { + t.Errorf("expected no errors, got: %v", FormatErrors(issues)) + } + }) + + t.Run("installed tool with failing version", func(t *testing.T) { + tools := map[string]config.Tool{ + scriptPath: { + MinVersion: "2.0.0", + VersionCmd: scriptPath, + }, + } + issues := CheckTools(tools) + if !HasErrors(issues) { + t.Error("expected errors for version below minimum") + } + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(issues)) + } + }) + + t.Run("missing tool without download URL", func(t *testing.T) { + tools := map[string]config.Tool{ + "nonexistent-tool-xyz": {}, + } + issues := CheckTools(tools) + if !HasErrors(issues) { + t.Error("expected errors for missing tool") + } + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(issues)) + } + if issues[0].Tool != "nonexistent-tool-xyz" { + t.Errorf("issue.Tool = %q, want %q", issues[0].Tool, "nonexistent-tool-xyz") + } + }) + + t.Run("missing tool with download URL", func(t *testing.T) { + tools := map[string]config.Tool{ + "nonexistent-tool-xyz": { + DownloadURL: "https://example.com/download", + }, + } + issues := CheckTools(tools) + if !HasErrors(issues) { + t.Error("expected errors for missing tool") + } + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(issues)) + } + found := false + for _, e := range issues[0].Errors { + if e == "nonexistent-tool-xyz is not installed, download at: https://example.com/download" { + found = true + break + } + } + if !found { + t.Errorf("expected download URL in error, got: %v", issues[0].Errors) + } + }) + + t.Run("tool with unparseable version", func(t *testing.T) { + tools := map[string]config.Tool{ + badScriptPath: { + MinVersion: "1.0.0", + VersionCmd: badScriptPath, + }, + } + issues := CheckTools(tools) + if !HasErrors(issues) { + t.Error("expected errors for unparseable version") + } + }) + + t.Run("empty tools map", func(t *testing.T) { + tools := map[string]config.Tool{} + issues := CheckTools(tools) + if len(issues) != 0 { + t.Errorf("expected no issues, got %d", len(issues)) + } + }) +} + +func TestHasErrors(t *testing.T) { + tests := []struct { + name string + issues []Issue + wantErr bool + }{ + { + name: "no issues", + issues: nil, + wantErr: false, + }, + { + name: "only version info", + issues: []Issue{ + {Tool: "docker", Errors: []string{"version: v24.0.7"}}, + }, + wantErr: false, + }, + { + name: "with error", + issues: []Issue{ + {Tool: "missing", Errors: []string{"missing is not installed"}}, + }, + wantErr: true, + }, + { + name: "mixed", + issues: []Issue{ + {Tool: "docker", Errors: []string{"version: v24.0.7"}}, + {Tool: "missing", Errors: []string{"missing is not installed"}}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HasErrors(tt.issues) + if got != tt.wantErr { + t.Errorf("HasErrors() = %v, want %v", got, tt.wantErr) + } + }) + } +} + +func TestFormatErrors(t *testing.T) { + issues := []Issue{ + {Tool: "missing", Errors: []string{"missing is not installed"}}, + {Tool: "docker", Errors: []string{"version: v24.0.7"}}, + } + + got := FormatErrors(issues) + if got == "" { + t.Error("expected non-empty output") + } + // Should not include version info + if len(got) > 0 && got[0:2] != " " { + t.Errorf("expected indented output, got: %q", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..7092c5e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,135 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/viper" +) + +// Tool defines a required tool dependency +type Tool struct { + MinVersion string `mapstructure:"min_version"` + MaxVersion string `mapstructure:"max_version"` + VersionCmd string `mapstructure:"version_cmd"` + DownloadURL string `mapstructure:"download_url"` +} + +// Argument defines a single command argument with optional validation +type Argument struct { + Name string `mapstructure:"name"` + Values []string `mapstructure:"values"` + Match string `mapstructure:"match"` +} + +// Command defines a single verb's configuration +type Command struct { + Name string `mapstructure:"name"` + Cmd string `mapstructure:"cmd"` + Description string `mapstructure:"description"` + Arguments []Argument `mapstructure:"arguments"` +} + +// Config represents the full YAML configuration +type Config struct { + Commands map[string]Command `mapstructure:"commands"` + Tools map[string]Tool `mapstructure:"tools"` +} + +// Load merges global and local configs. Local overrides global. +// binaryName is used to locate both config locations. +func Load(binaryName string) (*Config, error) { + global, err := loadGlobalConfig(binaryName) + if err != nil { + return nil, fmt.Errorf("loading global config: %w", err) + } + + local, err := loadLocalConfig(binaryName) + if err != nil { + return nil, fmt.Errorf("loading local config: %w", err) + } + + merged := mergeConfigs(global, local) + return merged, nil +} + +func loadGlobalConfig(binaryName string) (*Config, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("getting home directory: %w", err) + } + + configPath := filepath.Join(home, ".config", binaryName, "config.yaml") + return loadConfigFile(configPath) +} + +func loadLocalConfig(binaryName string) (*Config, error) { + configPath := filepath.Join(".", binaryName+".yaml") + return loadConfigFile(configPath) +} + +func loadConfigFile(path string) (*Config, error) { + cfg := &Config{ + Commands: make(map[string]Command), + Tools: make(map[string]Tool), + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + return cfg, nil + } + + v := viper.New() + v.SetConfigFile(path) + v.SetConfigType("yaml") + + if err := v.ReadInConfig(); err != nil { + return nil, err + } + + if err := v.Unmarshal(cfg); err != nil { + return nil, err + } + + return cfg, nil +} + +func mergeConfigs(global, local *Config) *Config { + merged := &Config{ + Commands: make(map[string]Command), + Tools: make(map[string]Tool), + } + + for name, cmd := range global.Commands { + merged.Commands[name] = cmd + } + + for name, cmd := range local.Commands { + merged.Commands[name] = cmd + } + + for name, tool := range global.Tools { + merged.Tools[name] = tool + } + + for name, tool := range local.Tools { + merged.Tools[name] = tool + } + + return merged +} + +// ConfigPaths returns the human-readable paths checked during load +func ConfigPaths(binaryName string) (global, local string) { + home, _ := os.UserHomeDir() + global = filepath.Join(home, ".config", binaryName, "config.yaml") + local = filepath.Join(".", binaryName+".yaml") + return +} + +// BinaryName extracts the base name from os.Args[0], stripping path and extension +func BinaryName() string { + base := filepath.Base(os.Args[0]) + return strings.TrimSuffix(base, filepath.Ext(base)) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..9cf2f46 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,174 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfigFile(t *testing.T) { + t.Run("missing file returns empty config", func(t *testing.T) { + cfg, err := loadConfigFile("/nonexistent/path/config.yaml") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg.Commands) != 0 { + t.Error("expected empty commands") + } + if len(cfg.Tools) != 0 { + t.Error("expected empty tools") + } + }) + + t.Run("valid config file", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(` +commands: + plan: + cmd: echo plan + description: "Plan" + arguments: + - name: env + values: [dev, prod] +tools: + docker: + min_version: "20.0.0" +`), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := loadConfigFile(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if _, ok := cfg.Commands["plan"]; !ok { + t.Error("expected command 'plan'") + } + if cfg.Commands["plan"].Cmd != "echo plan" { + t.Errorf("plan.cmd = %q, want %q", cfg.Commands["plan"].Cmd, "echo plan") + } + if len(cfg.Commands["plan"].Arguments) != 1 || cfg.Commands["plan"].Arguments[0].Name != "env" { + t.Errorf("plan.arguments = %v, want [{env}]", cfg.Commands["plan"].Arguments) + } + if len(cfg.Commands["plan"].Arguments[0].Values) != 2 { + t.Errorf("plan.arguments[0].values = %v, want [dev, prod]", cfg.Commands["plan"].Arguments[0].Values) + } + if _, ok := cfg.Tools["docker"]; !ok { + t.Error("expected tool 'docker'") + } + if cfg.Tools["docker"].MinVersion != "20.0.0" { + t.Errorf("docker.min_version = %q, want %q", cfg.Tools["docker"].MinVersion, "20.0.0") + } + }) + + t.Run("invalid yaml", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(":\n - invalid yaml{{"), 0644); err != nil { + t.Fatal(err) + } + + _, err := loadConfigFile(path) + if err == nil { + t.Error("expected error for invalid yaml") + } + }) +} + +func TestMergeConfigs(t *testing.T) { + t.Run("empty configs", func(t *testing.T) { + global := &Config{ + Commands: make(map[string]Command), + Tools: make(map[string]Tool), + } + local := &Config{ + Commands: make(map[string]Command), + Tools: make(map[string]Tool), + } + + merged := mergeConfigs(global, local) + if len(merged.Commands) != 0 { + t.Error("expected empty commands") + } + if len(merged.Tools) != 0 { + t.Error("expected empty tools") + } + }) + + t.Run("local overrides global", func(t *testing.T) { + global := &Config{ + Commands: map[string]Command{ + "plan": {Cmd: "echo global"}, + "lint": {Cmd: "echo global lint"}, + }, + Tools: map[string]Tool{ + "docker": {MinVersion: "1.0.0"}, + "go": {MinVersion: "1.20"}, + }, + } + local := &Config{ + Commands: map[string]Command{ + "plan": {Cmd: "echo local"}, + "test": {Cmd: "echo test"}, + }, + Tools: map[string]Tool{ + "docker": {MinVersion: "2.0.0"}, + "make": {MinVersion: "4.0"}, + }, + } + + merged := mergeConfigs(global, local) + + // Local overrides + if merged.Commands["plan"].Cmd != "echo local" { + t.Errorf("plan.cmd = %q, want %q", merged.Commands["plan"].Cmd, "echo local") + } + if merged.Tools["docker"].MinVersion != "2.0.0" { + t.Errorf("docker.min_version = %q, want %q", merged.Tools["docker"].MinVersion, "2.0.0") + } + + // Global preserved + if merged.Commands["lint"].Cmd != "echo global lint" { + t.Errorf("lint.cmd = %q, want %q", merged.Commands["lint"].Cmd, "echo global lint") + } + if merged.Tools["go"].MinVersion != "1.20" { + t.Errorf("go.min_version = %q, want %q", merged.Tools["go"].MinVersion, "1.20") + } + + // Local additions + if _, ok := merged.Commands["test"]; !ok { + t.Error("expected command 'test' from local") + } + if _, ok := merged.Tools["make"]; !ok { + t.Error("expected tool 'make' from local") + } + }) +} + +func TestConfigPaths(t *testing.T) { + global, local := ConfigPaths("ugo") + if global == "" { + t.Error("expected non-empty global path") + } + if local != "ugo.yaml" { + t.Errorf("local = %q, want %q", local, "ugo.yaml") + } + + global2, local2 := ConfigPaths("myproject") + if global2 == "" { + t.Error("expected non-empty global path") + } + if local2 != "myproject.yaml" { + t.Errorf("local = %q, want %q", local2, "myproject.yaml") + } +} + +func TestBinaryName(t *testing.T) { + // BinaryName uses os.Args[0], which in tests is usually the test binary path + name := BinaryName() + if name == "" { + t.Error("expected non-empty binary name") + } +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..1fa6f6a --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,93 @@ +package output + +import ( + "fmt" + "os" + + "github.com/mgutz/ansi" +) + +var noColor bool + +// SetNoColor enables or disables color output +func SetNoColor(v bool) { + noColor = v + ansi.DisableColors(v) +} + +const ( + checkPass = "✅" + checkFail = "❌" + info = "💡" + rocket = "🚀" +) + +// CheckPass prints a passing check with green checkmark +func CheckPass(msg string) { + fmt.Fprintf(os.Stdout, " %s %s\n", green(checkPass), msg) +} + +// CheckFail prints a failing check with red cross +func CheckFail(msg string) { + fmt.Fprintf(os.Stderr, " %s %s\n", red(checkFail), msg) +} + +// Info prints an info message with blue icon +func Info(msg string) { + fmt.Fprintf(os.Stdout, " %s %s\n", blue(info), msg) +} + +// CommandRunning prints a command about to execute with rocket +func CommandRunning(verb string, cmd string) { + fmt.Fprintf(os.Stdout, "%s %s: %s\n\n", yellow(rocket), yellow(verb), cmd) +} + +// CommandSuccess prints a success after command execution +func CommandSuccess(verb string) { + fmt.Fprintf(os.Stdout, "\n %s %s completed successfully\n", green(checkPass), green(verb)) +} + +// CommandFail prints a failure after command execution +func CommandFail(verb string) { + fmt.Fprintf(os.Stderr, "\n %s %s failed\n", red(checkFail), red(verb)) +} + +func green(s string) string { + if noColor { + return s + } + return ansi.Color(s, "green") +} + +func red(s string) string { + if noColor { + return s + } + return ansi.Color(s, "red") +} + +func yellow(s string) string { + if noColor { + return s + } + return ansi.Color(s, "yellow+b") +} + +func blue(s string) string { + if noColor { + return s + } + return ansi.Color(s, "blue") +} + +func bold(s string) string { + if noColor { + return s + } + return ansi.Color(s, "default+b") +} + +// Bold returns a bold string +func Bold(s string) string { + return bold(s) +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go new file mode 100644 index 0000000..4e2af76 --- /dev/null +++ b/internal/output/output_test.go @@ -0,0 +1,94 @@ +package output + +import ( + "bytes" + "os" + "testing" +) + +func TestSetNoColor(t *testing.T) { + SetNoColor(true) + if !noColor { + t.Error("expected noColor to be true") + } + + SetNoColor(false) + if noColor { + t.Error("expected noColor to be false") + } +} + +func TestOutputWithColor(t *testing.T) { + SetNoColor(false) + + tests := []struct { + name string + fn func() + }{ + {"CheckPass", func() { CheckPass("test passed") }}, + {"CheckFail", func() { CheckFail("test failed") }}, + {"Info", func() { Info("some info") }}, + {"CommandRunning", func() { CommandRunning("plan", "echo plan") }}, + {"CommandSuccess", func() { CommandSuccess("plan") }}, + {"CommandFail", func() { CommandFail("plan") }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Just verify these don't panic + tt.fn() + }) + } +} + +func TestOutputNoColor(t *testing.T) { + SetNoColor(true) + defer SetNoColor(false) + + oldStdout := os.Stdout + oldStderr := os.Stderr + rOut, wOut, _ := os.Pipe() + rErr, wErr, _ := os.Pipe() + os.Stdout = wOut + os.Stderr = wErr + + CheckPass("test passed") + CheckFail("test failed") + + wOut.Close() + wErr.Close() + + var bufOut, bufErr bytes.Buffer + bufOut.ReadFrom(rOut) + bufErr.ReadFrom(rErr) + + os.Stdout = oldStdout + os.Stderr = oldStderr + + out := bufOut.String() + err := bufErr.String() + + if out == "" { + t.Error("expected output on stdout") + } + if err == "" { + t.Error("expected output on stderr") + } + + // Verify no ANSI escape codes when noColor is true + if containsANSI(out) { + t.Errorf("stdout contains ANSI codes: %q", out) + } + if containsANSI(err) { + t.Errorf("stderr contains ANSI codes: %q", err) + } +} + +func containsANSI(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] == '\x1b' { + return true + } + } + return false +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..9fb2f62 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,65 @@ +package version + +import ( + "fmt" + "os/exec" + "regexp" + "strings" + + "golang.org/x/mod/semver" +) + +var versionRe = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`) + +// ExtractVersion finds the first semver-compatible version string in output +func ExtractVersion(output string) string { + match := versionRe.FindStringSubmatch(output) + if match == nil { + return "" + } + v := match[1] + if !strings.HasPrefix(v, "v") { + v = "v" + v + } + return v +} + +// Check verifies a tool's version against min/max constraints +// versionCmd is the command to run (e.g. "ansible-playbook --version"). +// Returns the found version and any error. +func Check(name string, versionCmd string, minVersion string, maxVersion string) (string, error) { + parts := strings.Fields(versionCmd) + if len(parts) == 0 { + return "", fmt.Errorf("empty version command for %s", name) + } + + out, err := exec.Command(parts[0], parts[1:]...).Output() + if err != nil { + return "", fmt.Errorf("%s: failed to run version command: %w", name, err) + } + + found := ExtractVersion(string(out)) + if found == "" { + return "", fmt.Errorf("%s: could not parse version from output", name) + } + + if minVersion != "" { + if !strings.HasPrefix(minVersion, "v") { + minVersion = "v" + minVersion + } + if semver.Compare(found, minVersion) < 0 { + return found, fmt.Errorf("%s: version %s is below minimum %s", name, found, minVersion) + } + } + + if maxVersion != "" { + if !strings.HasPrefix(maxVersion, "v") { + maxVersion = "v" + maxVersion + } + if semver.Compare(found, maxVersion) > 0 { + return found, fmt.Errorf("%s: version %s exceeds maximum %s", name, found, maxVersion) + } + } + + return found, nil +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..c1e8bf9 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,125 @@ +package version + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExtractVersion(t *testing.T) { + tests := []struct { + name string + input string + expect string + }{ + {"bare semver", "2.10.5", "v2.10.5"}, + {"with v prefix", "v1.2.3", "v1.2.3"}, + {"embedded in text", "ansible-playbook 2.14.0", "v2.14.0"}, + {"with v in text", "version v3.0.1-beta", "v3.0.1"}, + {"docker style", "Docker version 24.0.7, build afdd53b", "v24.0.7"}, + {"terraform style", "Terraform v1.6.2\non linux_amd64", "v1.6.2"}, + {"no version", "some random output", ""}, + {"empty", "", ""}, + {"partial version", "v1.2", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractVersion(tt.input) + if got != tt.expect { + t.Errorf("ExtractVersion(%q) = %q, want %q", tt.input, got, tt.expect) + } + }) + } +} + +func TestCheck(t *testing.T) { + tmp := t.TempDir() + scriptPath := filepath.Join(tmp, "fake-version") + if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho \"fake-tool v1.5.0\"\n"), 0755); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + versionCmd string + minVersion string + maxVersion string + wantVer string + wantErr bool + errContains string + }{ + { + name: "version within range", + versionCmd: scriptPath, + minVersion: "1.0.0", + maxVersion: "2.0.0", + wantVer: "v1.5.0", + }, + { + name: "below minimum", + versionCmd: scriptPath, + minVersion: "2.0.0", + wantErr: true, + errContains: "below minimum", + }, + { + name: "above maximum", + versionCmd: scriptPath, + maxVersion: "1.4.0", + wantErr: true, + errContains: "exceeds maximum", + }, + { + name: "no constraints", + versionCmd: scriptPath, + wantVer: "v1.5.0", + }, + { + name: "min only", + versionCmd: scriptPath, + minVersion: "1.5.0", + wantVer: "v1.5.0", + }, + { + name: "max only", + versionCmd: scriptPath, + maxVersion: "1.5.0", + wantVer: "v1.5.0", + }, + { + name: "command not found", + versionCmd: "nonexistent-command-xyz --version", + wantErr: true, + errContains: "failed to run version command", + }, + { + name: "empty version command", + versionCmd: "", + wantErr: true, + errContains: "empty version command", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotVer, err := Check("test-tool", tt.versionCmd, tt.minVersion, tt.maxVersion) + if (err != nil) != tt.wantErr { + t.Errorf("Check() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr && tt.errContains != "" { + if err == nil { + t.Errorf("Check() expected error containing %q, got nil", tt.errContains) + } else if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("Check() error = %q, want to contain %q", err.Error(), tt.errContains) + } + return + } + if gotVer != tt.wantVer { + t.Errorf("Check() version = %q, want %q", gotVer, tt.wantVer) + } + }) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9e1709a --- /dev/null +++ b/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/PyratLabs/ugo/cmd" +) + +func main() { + cmd.RootCmd().Execute() +}