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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions cmd/openrelik/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module github.com/openrelik/openrelik-go-client/cmd/cli

go 1.24.2

require (
github.com/openrelik/openrelik-go-client v0.0.0-00010101000000-000000000000
github.com/spf13/cobra v1.10.2
golang.org/x/term v0.30.0
)

require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.31.0 // indirect
)

replace github.com/openrelik/openrelik-go-client => ../../
14 changes: 14 additions & 0 deletions cmd/openrelik/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
71 changes: 71 additions & 0 deletions cmd/openrelik/internal/cli/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package cli

import (
"bufio"
"fmt"
"strings"
"syscall"

"github.com/openrelik/openrelik-go-client/cmd/cli/internal/config"
"github.com/spf13/cobra"
"golang.org/x/term"
)

var passwordReader = func(fd int) ([]byte, error) {
return term.ReadPassword(fd)
}

func newAuthCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Manage authentication",
}

cmd.AddCommand(newLoginCmd())
return cmd
}

func newLoginCmd() *cobra.Command {
return &cobra.Command{
Use: "login",
Short: "Login to OpenRelik",
RunE: func(cmd *cobra.Command, args []string) error {
var server, key string

fmt.Fprint(cmd.OutOrStdout(), "OpenRelik Server URL (e.g., http://localhost:8710): ")
scanner := bufio.NewScanner(cmd.InOrStdin())
if scanner.Scan() {
server = strings.TrimSpace(scanner.Text())
}
if server == "" {
return fmt.Errorf("server URL is required")
}

fmt.Fprint(cmd.OutOrStdout(), "OpenRelik API Key (refresh token): ")
byteKey, err := passwordReader(int(syscall.Stdin))
fmt.Fprintln(cmd.OutOrStdout()) // Print a newline after reading the password
if err != nil {
return fmt.Errorf("error reading API key: %w", err)
}
key = string(byteKey)

if key == "" {
return fmt.Errorf("API key is required")
}

err = config.SaveSettings(&config.Settings{ServerURL: server})
if err != nil {
return fmt.Errorf("error saving settings: %w", err)
}

err = config.SaveCredentials(&config.Credentials{APIKey: key})
if err != nil {
return fmt.Errorf("error saving credentials: %w", err)
}

fmt.Fprintln(cmd.OutOrStdout(), "Successfully logged in!")
return nil
},
}
}

93 changes: 93 additions & 0 deletions cmd/openrelik/internal/cli/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package cli

import (
"bytes"
"testing"

"github.com/openrelik/openrelik-go-client/cmd/cli/internal/config"
)

func TestLoginCmd(t *testing.T) {
// Setup temp config dir
tmpDir := t.TempDir()
config.SetBaseDir(tmpDir)
defer config.SetBaseDir("")

// Mock password reader
originalPasswordReader := passwordReader
passwordReader = func(fd int) ([]byte, error) {
return []byte("test-api-key"), nil
}
defer func() { passwordReader = originalPasswordReader }()

t.Run("SuccessfulLogin", func(t *testing.T) {
root := NewRootCmd()
out := new(bytes.Buffer)
in := new(bytes.Buffer)
root.SetOut(out)
root.SetIn(in)

// Provide input for server URL
in.WriteString("http://test-server\n")

root.SetArgs([]string{"auth", "login"})

if err := root.Execute(); err != nil {
t.Fatalf("Execute() failed: %v", err)
}

if !bytes.Contains(out.Bytes(), []byte("Successfully logged in!")) {
t.Errorf("expected output to contain success message, got %q", out.String())
}

// Verify config was saved
s, _ := config.LoadSettings()
if s.ServerURL != "http://test-server" {
t.Errorf("expected server URL %q, got %q", "http://test-server", s.ServerURL)
}
c, _ := config.LoadCredentials()
if c.APIKey != "test-api-key" {
t.Errorf("expected API key %q, got %q", "test-api-key", c.APIKey)
}
})

t.Run("MissingServerInput", func(t *testing.T) {
root := NewRootCmd()
in := new(bytes.Buffer)
root.SetIn(in)
// Empty input for server
in.WriteString("\n")

root.SetArgs([]string{"auth", "login"})

err := root.Execute()
if err == nil {
t.Fatal("expected error for missing server input, got nil")
}
if err.Error() != "server URL is required" {
t.Errorf("expected error %q, got %q", "server URL is required", err.Error())
}
})

t.Run("MissingAPIKeyInput", func(t *testing.T) {
// Mock empty password
passwordReader = func(fd int) ([]byte, error) {
return []byte(""), nil
}

root := NewRootCmd()
in := new(bytes.Buffer)
root.SetIn(in)
in.WriteString("http://test-server\n")

root.SetArgs([]string{"auth", "login"})

err := root.Execute()
if err == nil {
t.Fatal("expected error for missing API key, got nil")
}
if err.Error() != "API key is required" {
t.Errorf("expected error %q, got %q", "API key is required", err.Error())
}
})
}
89 changes: 89 additions & 0 deletions cmd/openrelik/internal/cli/cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package cli

import (
"fmt"
"os"

"github.com/openrelik/openrelik-go-client"
"github.com/openrelik/openrelik-go-client/cmd/cli/internal/config"
"github.com/openrelik/openrelik-go-client/cmd/cli/internal/util"
"github.com/spf13/cobra"
)

var (
serverURL string
apiKey string
outputFormat string
quiet bool
)

func NewRootCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "openrelik",
Short: "OpenRelik CLI client",
Long: `A command line tool to interact with the OpenRelik API`,
TraverseChildren: true,
}

cmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "text", "Output format (text, json)")
cmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "Suppress all output")

cmd.AddCommand(newAuthCmd())
cmd.AddCommand(newUsersCmd())

return cmd
}

func newClient() (*openrelik.Client, error) {
s := serverURL
if s == "" {
s = os.Getenv("OPENRELIK_SERVER_URL")
}
if s == "" {
if settings, err := config.LoadSettings(); err == nil {
s = settings.ServerURL
}
}

k := apiKey
if k == "" {
k = os.Getenv("OPENRELIK_API_KEY")
}
if k == "" {
if creds, err := config.LoadCredentials(); err == nil {
k = creds.APIKey
}
}

if s == "" {
return nil, fmt.Errorf("server URL is required (use OPENRELIK_SERVER_URL env var, or run 'openrelik auth login')")
}
if k == "" {
return nil, fmt.Errorf("API key is required (use OPENRELIK_API_KEY env var, or run 'openrelik auth login')")
}

return openrelik.NewClient(s, k)
}

// formatAndPrint outputs the result in the requested format.
func formatAndPrint(cmd *cobra.Command, result interface{}) error {
if quiet {
return nil
}
switch outputFormat {
case "json":
return util.FprintJSON(cmd.OutOrStdout(), result)
default:
util.FprintStruct(cmd.OutOrStdout(), result)
return nil
}
}

// Execute adds all child commands to the root command and sets flags appropriately.
func Execute() {
if err := NewRootCmd().Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

97 changes: 97 additions & 0 deletions cmd/openrelik/internal/cli/cli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package cli

import (
"os"
"testing"

"github.com/openrelik/openrelik-go-client/cmd/cli/internal/config"
)

func TestNewClient(t *testing.T) {
// Setup temp config dir
tmpDir := t.TempDir()
config.SetBaseDir(tmpDir)
defer config.SetBaseDir("")

// Helper to reset globals
resetGlobals := func() {
serverURL = ""
apiKey = ""
os.Unsetenv("OPENRELIK_SERVER_URL")
os.Unsetenv("OPENRELIK_API_KEY")
}

t.Run("Flags", func(t *testing.T) {
resetGlobals()
defer resetGlobals()
serverURL = "http://flag-server"
apiKey = "flag-key"

client, err := newClient()
if err != nil {
t.Fatalf("newClient failed: %v", err)
}
if client == nil {
t.Fatal("expected client, got nil")
}
})

t.Run("EnvVars", func(t *testing.T) {
resetGlobals()
defer resetGlobals()
os.Setenv("OPENRELIK_SERVER_URL", "http://env-server")
os.Setenv("OPENRELIK_API_KEY", "env-key")

client, err := newClient()
if err != nil {
t.Fatalf("newClient failed: %v", err)
}
if client == nil {
t.Fatal("expected client, got nil")
}
})

t.Run("ConfigFallback", func(t *testing.T) {
resetGlobals()
defer resetGlobals()

config.SaveSettings(&config.Settings{ServerURL: "http://config-server"})
config.SaveCredentials(&config.Credentials{APIKey: "config-key"})

client, err := newClient()
if err != nil {
t.Fatalf("newClient failed: %v", err)
}
if client == nil {
t.Fatal("expected client, got nil")
}
})

t.Run("MissingServer", func(t *testing.T) {
resetGlobals()
defer resetGlobals()
apiKey = "some-key"
// Remove config files
dir, _ := config.GetConfigDir()
os.RemoveAll(dir)

_, err := newClient()
if err == nil {
t.Error("expected error for missing server URL, got nil")
}
})

t.Run("MissingKey", func(t *testing.T) {
resetGlobals()
defer resetGlobals()
serverURL = "http://some-server"
// Remove config files
dir, _ := config.GetConfigDir()
os.RemoveAll(dir)

_, err := newClient()
if err == nil {
t.Error("expected error for missing API key, got nil")
}
})
}
Loading
Loading