Compare commits

...
4 Commits
12 changed files with 514 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# Binaries
ry
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of go coverage
*.out
# IDE
.idea/
.vscode/
*.swp
*.swo
+15
View File
@@ -0,0 +1,15 @@
package main
import (
"fmt"
"os"
"git.yoresee.cc/nightstar/ry/internal/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+14
View File
@@ -0,0 +1,14 @@
module git.yoresee.cc/nightstar/ry
go 1.18
require (
github.com/BurntSushi/toml v1.6.0
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
+15
View File
@@ -0,0 +1,15 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
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=
+14
View File
@@ -0,0 +1,14 @@
package cmd
import (
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "ry",
Short: "Multi-cloud CLI, like gh for cloud providers",
}
func Execute() error {
return rootCmd.Execute()
}
+67
View File
@@ -0,0 +1,67 @@
package config
import (
"os"
"path/filepath"
"github.com/BurntSushi/toml"
)
type ProviderConfig struct {
APIKey string `toml:"api_key,omitempty"`
AccessKeyID string `toml:"access_key_id,omitempty"`
AccessKeySecret string `toml:"access_key_secret,omitempty"`
Endpoint string `toml:"endpoint,omitempty"`
}
type Config struct {
CurrentProvider string `toml:"current_provider"`
Output string `toml:"output"`
Providers map[string]ProviderConfig `toml:"providers"`
}
func Load(path string) (*Config, error) {
cfg := &Config{
Output: "table",
Providers: make(map[string]ProviderConfig),
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return nil, err
}
if err := toml.Unmarshal(data, cfg); err != nil {
return nil, err
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderConfig)
}
if cfg.Output == "" {
cfg.Output = "table"
}
return cfg, nil
}
func Save(path string, cfg *Config) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
enc := toml.NewEncoder(f)
return enc.Encode(cfg)
}
func DefaultPath() string {
home, err := os.UserHomeDir()
if err != nil {
return "config.toml"
}
return filepath.Join(home, ".config", "ry", "config.toml")
}
+96
View File
@@ -0,0 +1,96 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadTOML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
content := `
current_provider = "rainyun"
output = "table"
[providers.rainyun]
api_key = "sk-test123"
[providers.aliyun]
access_key_id = "ak-xxx"
access_key_secret = "sk-xxx"
`
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.CurrentProvider != "rainyun" {
t.Errorf("CurrentProvider = %q, want %q", cfg.CurrentProvider, "rainyun")
}
if cfg.Output != "table" {
t.Errorf("Output = %q, want %q", cfg.Output, "table")
}
if cfg.Providers["rainyun"].APIKey != "sk-test123" {
t.Errorf("rainyun api_key = %q, want %q", cfg.Providers["rainyun"].APIKey, "sk-test123")
}
if cfg.Providers["aliyun"].AccessKeyID != "ak-xxx" {
t.Errorf("aliyun access_key_id = %q, want %q", cfg.Providers["aliyun"].AccessKeyID, "ak-xxx")
}
}
func TestLoadDefaults(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.toml")
if err := os.WriteFile(path, []byte(""), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Output != "table" {
t.Errorf("default Output = %q, want %q", cfg.Output, "table")
}
}
func TestSave(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "out.toml")
cfg := &Config{
CurrentProvider: "test",
Output: "json",
Providers: map[string]ProviderConfig{
"test": {APIKey: "secret"},
},
}
if err := Save(path, cfg); err != nil {
t.Fatalf("Save() error: %v", err)
}
loaded, err := Load(path)
if err != nil {
t.Fatalf("re-Load() error: %v", err)
}
if loaded.CurrentProvider != "test" {
t.Errorf("roundtrip CurrentProvider = %q", loaded.CurrentProvider)
}
}
func TestDefaultPath(t *testing.T) {
path := DefaultPath()
if path == "" {
t.Error("DefaultPath() returned empty")
}
}
func TestEnsureDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "sub", "dir")
path := filepath.Join(dir, "config.toml")
cfg := &Config{Output: "table"}
if err := Save(path, cfg); err != nil {
t.Fatalf("Save() to deep dir error: %v", err)
}
}
+12
View File
@@ -0,0 +1,12 @@
package output
import (
"encoding/json"
"io"
)
func printJSON(w io.Writer, data any) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(data)
}
+33
View File
@@ -0,0 +1,33 @@
package output
import (
"fmt"
"io"
)
type Printer struct {
format string
writer io.Writer
}
func New(format string, w io.Writer) *Printer {
return &Printer{format: format, writer: w}
}
func (p *Printer) Print(data any) error {
switch p.format {
case "table":
return printTable(p.writer, data)
case "json":
return printJSON(p.writer, data)
case "yaml":
return printYAML(p.writer, data)
default:
return fmt.Errorf("unknown output format: %s", p.format)
}
}
func (p *Printer) PrintRaw(raw []byte) error {
_, err := fmt.Fprintln(p.writer, string(raw))
return err
}
+96
View File
@@ -0,0 +1,96 @@
package output
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
type testItem struct {
ID string `json:"id" table:"ID"`
Name string `json:"name" table:"NAME"`
Age int `json:"age" table:"AGE"`
}
func TestTableFormat(t *testing.T) {
items := []testItem{
{ID: "1", Name: "Alice", Age: 30},
{ID: "2", Name: "Bob", Age: 25},
}
var buf bytes.Buffer
printer := New("table", &buf)
if err := printer.Print(items); err != nil {
t.Fatalf("Print table error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "ID") || !strings.Contains(out, "Alice") {
t.Errorf("table output missing headers or data:\n%s", out)
}
}
func TestJSONFormat(t *testing.T) {
items := []testItem{
{ID: "1", Name: "Alice", Age: 30},
}
var buf bytes.Buffer
printer := New("json", &buf)
if err := printer.Print(items); err != nil {
t.Fatalf("Print json error: %v", err)
}
var parsed []testItem
if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
if len(parsed) != 1 || parsed[0].ID != "1" {
t.Errorf("JSON content mismatch: %+v", parsed)
}
}
func TestYAMLFormat(t *testing.T) {
items := []testItem{
{ID: "1", Name: "Alice", Age: 30},
}
var buf bytes.Buffer
printer := New("yaml", &buf)
if err := printer.Print(items); err != nil {
t.Fatalf("Print yaml error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "id:") || !strings.Contains(out, "Alice") {
t.Errorf("yaml output missing data:\n%s", out)
}
}
func TestRawFormat(t *testing.T) {
raw := []byte(`{"raw":"data"}`)
var buf bytes.Buffer
printer := New("raw", &buf)
if err := printer.PrintRaw(raw); err != nil {
t.Fatalf("PrintRaw error: %v", err)
}
if buf.String() != `{"raw":"data"}`+"\n" {
t.Errorf("raw output = %q", buf.String())
}
}
func TestSingleItem(t *testing.T) {
item := testItem{ID: "1", Name: "Solo", Age: 99}
var buf bytes.Buffer
printer := New("json", &buf)
if err := printer.Print(item); err != nil {
t.Fatalf("Print single item error: %v", err)
}
if !strings.Contains(buf.String(), "Solo") {
t.Errorf("single item output missing data:\n%s", buf.String())
}
}
func TestUnknownFormat(t *testing.T) {
var buf bytes.Buffer
printer := New("unknown", &buf)
err := printer.Print([]testItem{})
if err == nil {
t.Error("expected error for unknown format")
}
}
+120
View File
@@ -0,0 +1,120 @@
package output
import (
"fmt"
"io"
"reflect"
"strings"
)
func printTable(w io.Writer, data any) error {
v := reflect.ValueOf(data)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() == reflect.Slice {
return printTableSlice(w, v)
}
return printTableSingle(w, v)
}
func printTableSlice(w io.Writer, v reflect.Value) error {
if v.Len() == 0 {
fmt.Fprintln(w, "(empty)")
return nil
}
elemType := v.Index(0).Type()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
headers, cols := extractTableTags(elemType)
if len(headers) == 0 {
return fmt.Errorf("no table tags found on type %s", elemType.Name())
}
// Collect all rows
rows := make([][]string, v.Len())
for i := 0; i < v.Len(); i++ {
rows[i] = extractRow(v.Index(i), cols)
}
// Calculate column widths
widths := make([]int, len(headers))
for i, h := range headers {
widths[i] = len(h)
}
for _, row := range rows {
for i, cell := range row {
if len(cell) > widths[i] {
widths[i] = len(cell)
}
}
}
// Print headers
sep := " "
renderRow(w, headers, widths, sep)
// Print separator line
separator := make([]string, len(headers))
for i, ww := range widths {
separator[i] = strings.Repeat("-", ww)
}
renderRow(w, separator, widths, sep)
// Print data rows
for _, row := range rows {
renderRow(w, row, widths, sep)
}
return nil
}
func renderRow(w io.Writer, cells []string, widths []int, sep string) {
parts := make([]string, len(cells))
for i, cell := range cells {
parts[i] = fmt.Sprintf("%-*s", widths[i], cell)
}
fmt.Fprintln(w, strings.Join(parts, sep))
}
func printTableSingle(w io.Writer, v reflect.Value) error {
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
t := v.Type()
for i := 0; i < t.NumField(); i++ {
tag := t.Field(i).Tag.Get("table")
if tag == "" {
continue
}
val := fmt.Sprintf("%v", v.Field(i).Interface())
fmt.Fprintf(w, "%-10s %s\n", tag+":", val)
}
return nil
}
func extractTableTags(t reflect.Type) ([]string, []int) {
var headers []string
var cols []int
for i := 0; i < t.NumField(); i++ {
tag := t.Field(i).Tag.Get("table")
if tag == "" {
continue
}
headers = append(headers, strings.ToUpper(tag))
cols = append(cols, i)
}
return headers, cols
}
func extractRow(v reflect.Value, cols []int) []string {
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
row := make([]string, len(cols))
for i, idx := range cols {
row[i] = fmt.Sprintf("%v", v.Field(idx).Interface())
}
return row
}
+13
View File
@@ -0,0 +1,13 @@
package output
import (
"io"
"gopkg.in/yaml.v3"
)
func printYAML(w io.Writer, data any) error {
enc := yaml.NewEncoder(w)
enc.SetIndent(2)
return enc.Encode(data)
}