Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions odgcli/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# IDE configuration folders
.idea

# secrets
.env

.task
bin/
58 changes: 58 additions & 0 deletions odgcli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# odgcli

CVE compliance tooling for Open Delivery Gear.

## Installation

```bash
go install github.com/open-component-model/community/odgcli@latest
```

## Configuration

odgcli uses a YAML config file at `~/.config/odgcli/config.yaml`. Values can be overridden by environment variables or CLI flags.

```yaml
base_url: "https://delivery-service.demo.ci.gardener.cloud"
github_url: "https://api.github.com"
root_component: ocm.software/ocmcli
```

| Key | Env Variable | Flag | Description |
|------------------|----------------|----------|------------------------------|
| `base_url` | `BASE_URL` | — | Delivery Service base URL |
| `github_url` | `GITHUB_URL` | — | GitHub API base URL |
| `access_token` | `ACCESS_TOKEN` | — | GitHub personal access token |
| `root_component` | `ODG_ROOT` | `--root` | Root component to browse |

## Authentication

The access token can be provided via environment variable, config file, or the system keychain. The recommended approach is the system keychain:

```bash
# Store token in system keychain (interactive prompt)
odgcli auth login
```

## Usage

Start the interactive TUI (default when no subcommand is given):

```bash
odgcli

# Override the root component
odgcli --root ocm.software/ocmcli
```

## Development

This project uses [Task](https://taskfile.dev) as a task runner.

```bash
task # Build the binary
task test # Run tests
task lint # Run golangci-lint
task check # Run all checks (fmt, vet, lint, test)
task clean # Remove build artifacts
```
52 changes: 52 additions & 0 deletions odgcli/Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
version: '3'

vars:
BINARY_NAME: odgcli
BUILD_DIR: bin

tasks:
default:
desc: Build the binary
aliases: [build]
cmds:
- go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}} .
sources:
- '**/*.go'
- go.mod
- go.sum
- exclude: '**/*_test.go'
generates:
- '{{.BUILD_DIR}}/{{.BINARY_NAME}}'

test:
desc: Run tests
cmds:
- go test ./...

lint:
desc: Run golangci-lint
cmds:
- golangci-lint run ./...

fmt:
desc: Format code
cmds:
- gofmt -w .

vet:
desc: Run go vet
cmds:
- go vet ./...

clean:
desc: Remove build artifacts
cmds:
- rm -rf {{.BUILD_DIR}}

check:
desc: Run all checks (fmt, vet, lint, test)
cmds:
- task: fmt
- task: vet
- task: lint
- task: test
98 changes: 98 additions & 0 deletions odgcli/cmd/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package cmd

import (
"fmt"
"os"
"strings"

"github.com/spf13/cobra"
"github.com/zalando/go-keyring"
"golang.org/x/term"

"github.com/open-component-model/community/odgcli/internal/config"
)

var authCmd = &cobra.Command{
Use: "auth",
Short: "Manage authentication",
Long: "Store, remove, or check the status of your access token in the system keychain.",
// Override root's PersistentPreRunE so auth commands work without a valid config.
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return nil
},
}

var authLoginCmd = &cobra.Command{
Use: "login",
Short: "Store an access token in the system keychain",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Print("Paste your access token: ")
tokenBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println() // newline after hidden input
if err != nil {
return fmt.Errorf("failed to read token: %w", err)
}
token := strings.TrimSpace(string(tokenBytes))

if token == "" {
return fmt.Errorf("token cannot be empty")
}

if err := config.StoreAccessToken(token); err != nil {
return fmt.Errorf("failed to store token in keychain: %w", err)
}

fmt.Println("Token stored in system keychain.")
return nil
},
}

var authLogoutCmd = &cobra.Command{
Use: "logout",
Short: "Remove the access token from the system keychain",
RunE: func(cmd *cobra.Command, args []string) error {
if err := config.DeleteAccessToken(); err != nil {
return fmt.Errorf("failed to remove token from keychain: %w", err)
}

fmt.Println("Token removed from system keychain.")
return nil
},
}

var authStatusCmd = &cobra.Command{
Use: "status",
Short: "Show where the access token is sourced from",
Run: func(cmd *cobra.Command, args []string) {
// Check env var.
if os.Getenv("ACCESS_TOKEN") != "" {
fmt.Println("Token source: ACCESS_TOKEN environment variable")
return
}

// Check keychain.
if token, err := keyring.Get(config.KeyringService, config.KeyringAccessTokenKey); err == nil && token != "" {
fmt.Println("Token source: system keychain")
return
}

// Check config file.
config.SetConfigFile(configPath)
loadedCfg, err := config.Load()
if err == nil && loadedCfg.AccessToken != "" {
fmt.Println("Token source: config file (" + config.DefaultConfigPath() + ")")
return
}

fmt.Println("No access token found.")
fmt.Println("Run 'odgcli auth login' to store a token in the system keychain,")
fmt.Println("or set the ACCESS_TOKEN environment variable.")
},
}

func init() {
authCmd.AddCommand(authLoginCmd)
authCmd.AddCommand(authLogoutCmd)
authCmd.AddCommand(authStatusCmd)
rootCmd.AddCommand(authCmd)
}
68 changes: 68 additions & 0 deletions odgcli/cmd/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package cmd

import (
"context"
"fmt"
"os"

"github.com/spf13/cobra"

"github.com/open-component-model/community/odgcli/internal/config"
"github.com/open-component-model/community/odgcli/internal/tui"
"github.com/open-component-model/community/odgcli/pkg/github"
"github.com/open-component-model/community/odgcli/pkg/odg"
)

// cfg holds shared configuration populated in PersistentPreRunE and
// available to all subcommands.
var cfg *config.Config

// configPath allows overriding the config file location via --config flag.
var configPath string

var rootCmd = &cobra.Command{
Use: "odgcli",
Short: "CVE compliance tooling for Open Delivery Gear",
Long: "odgcli provides a TUI for browsing CVE compliance data from Open Delivery Gear.",
// When invoked without a subcommand, start the interactive TUI.
RunE: func(cmd *cobra.Command, args []string) error {
ghClient := github.NewClient(cfg.GithubURL, cfg.AccessToken)
odgClient, err := odg.NewClient(context.Background(), cfg.BaseURL, ghClient)
if err != nil {
return fmt.Errorf("failed to create ODG client: %w", err)
}
return tui.Run(odgClient, ghClient, cfg.RootComponent)
},
// Silence cobra's default usage/error printing so the TUI stays clean.
SilenceUsage: true,
SilenceErrors: true,
}

func init() {
// Define flags.
rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "config file path (default: ~/.config/odgcli/config.yaml)")
rootCmd.PersistentFlags().String("root", "", "Root component name (overrides config file and ODG_ROOT env var)")

// Bind flags and env vars to viper keys.
config.BindFlags(rootCmd)

// Load config before each command that needs it.
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
config.SetConfigFile(configPath)

var err error
cfg, err = config.Load()
if err != nil {
return err
}
return nil
}
}

// Execute is the main entry point called from main.go.
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
56 changes: 56 additions & 0 deletions odgcli/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
module github.com/open-component-model/community/odgcli

go 1.25.0

require (
github.com/allegro/bigcache/v3 v3.1.0
github.com/eko/gocache/lib/v4 v4.2.3
github.com/eko/gocache/store/bigcache/v4 v4.2.4
github.com/gdamore/tcell/v2 v2.8.1
github.com/rivo/tview v0.42.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/zalando/go-keyring v0.2.8
golang.org/x/term v0.28.0
)

require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/rivo/uniseg v0.4.7 // 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/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Loading