Files
rainyun-cli/docs/plan/2026-07-28-ry-cli-plan.md

59 KiB
Raw Permalink Blame History

ry CLI 实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 构建 ry——多提供商云服务 CLI 工具,首期实现雨云 Provider,支持服务器/域名/存储/计费管理。

架构: Go + cobra + Provider 接口 + 工厂/策略/适配器模式。命令层 → Provider 接口 → 雨云 HTTP 客户端 → API。

技术栈: Go 1.21+, cobra, viper, BurntSushi/toml, tablewriter, yaml.v3


文件结构总览

文件 职责
go.mod / go.sum Go 模块定义
cmd/ry/main.go 入口,初始化 cobra + 注册子命令
internal/config/config.go Config 结构体 + TOML 读写
internal/config/config_test.go 配置读写测试
internal/output/output.go Output 接口 + 调度
internal/output/table.go 表格渲染
internal/output/json.go JSON 输出
internal/output/yaml.go YAML 输出
internal/output/output_test.go 输出格式测试
internal/provider/provider.go Provider/ServerService 等接口 + Error 类型 + 通用模型
internal/provider/registry.go 工厂注册表
internal/provider/rainyun/client.go 雨云 HTTP 客户端(适配器)
internal/provider/rainyun/rainyun.go 雨云 Provider 实现
internal/provider/rainyun/server.go 雨云 ServerService 实现
internal/provider/rainyun/domain.go 雨云 DomainService 实现
internal/provider/rainyun/storage.go 雨云 StorageService 实现
internal/provider/rainyun/billing.go 雨云 BillingService 实现
internal/provider/rainyun/server_test.go 雨云服务器操作测试
internal/cmd/root.go cobra 根命令 + 全局 flag
internal/cmd/provider.go ry provider 子命令
internal/cmd/server.go ry server 子命令
internal/cmd/domain.go ry domain 子命令
internal/cmd/storage.go ry storage 子命令
internal/cmd/billing.go ry billing 子命令
internal/cmd/config_cmd.go ry config 子命令
internal/cmd/server_test.go server 命令集成测试

任务 1:项目脚手架

文件:

  • 创建:go.mod

  • 步骤 1:初始化 Go module

cd /root/.openclaw/workspace/project/rainyun-cli
go mod init git.yoresee.cc/nightstar/ry

运行:go mod init git.yoresee.cc/nightstar/ry 预期:创建 go.mod 文件,module 路径为 git.yoresee.cc/nightstar/ry

  • 步骤 2:创建目录结构
mkdir -p cmd/ry \
  internal/config \
  internal/output \
  internal/provider/rainyun \
  internal/cmd
  • 步骤 3:写入入口 main.go

创建 cmd/ry/main.go:

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)
	}
}

创建 internal/cmd/root.go(最小 stub):

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()
}
  • 步骤 4:拉依赖并验证编译
cd /root/.openclaw/workspace/project/rainyun-cli
go mod tidy
go build ./cmd/ry/
./ry --help

预期:输出 cobra 默认 help,无报错

  • 步骤 5:Commit
git add -A
git commit -m "feat: project scaffold — go module, cobra root, dir layout"

任务 2:配置系统

文件:

  • 创建:internal/config/config.go

  • 创建:internal/config/config_test.go

  • 步骤 1:编写配置测试(失败先行)

创建 internal/config/config_test.go:

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)
	}
}
  • 步骤 2:运行测试确认失败
cd /root/.openclaw/workspace/project/rainyun-cli
go test ./internal/config/

预期:FAIL — 类型未定义

  • 步骤 3:实现 config.go

创建 internal/config/config.go:

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")
}
  • 步骤 4:运行测试验证通过
go test ./internal/config/ -v

预期:4 个测试全部 PASS

  • 步骤 5:拉依赖 + Commit
go mod tidy
git add -A
git commit -m "feat: config system — TOML load/save with provider auth map"

任务 3:输出系统

文件:

  • 创建:internal/output/output.go

  • 创建:internal/output/table.go

  • 创建:internal/output/json.go

  • 创建:internal/output/yaml.go

  • 创建:internal/output/output_test.go

  • 步骤 1:编写输出测试(失败先行)

创建 internal/output/output_test.go:

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")
	}
}
  • 步骤 2:运行测试确认失败
go test ./internal/output/ -v

预期:FAIL — 类型未定义

  • 步骤 3:实现 Printer 接口 + table.go

创建 internal/output/output.go:

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
}

创建 internal/output/table.go:

package output

import (
	"fmt"
	"io"
	"reflect"
	"strings"

	"github.com/olekukoneko/tablewriter"
)

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)
	table := tablewriter.NewWriter(w)
	table.SetHeader(headers)
	table.SetBorder(false)
	table.SetColumnSeparator("  ")
	for i := 0; i < v.Len(); i++ {
		row := extractRow(v.Index(i), cols)
		table.Append(row)
	}
	table.Render()
	return nil
}

func printTableSingle(w io.Writer, v reflect.Value) error {
	if v.Kind() == reflect.Ptr {
		v = v.Elem()
	}
	t := v.Type()
	table := tablewriter.NewWriter(w)
	table.SetBorder(false)
	table.SetColumnSeparator("  ")
	table.SetAutoFormatHeaders(false)
	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())
		table.Append([]string{tag, val})
	}
	table.Render()
	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
}

创建 internal/output/json.go:

package output

import (
	"encoding/json"
	"io"
)

func printJSON(w io.Writer, data any) error {
	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")
	return enc.Encode(data)
}

创建 internal/output/yaml.go:

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)
}
  • 步骤 4:运行测试验证通过
go test ./internal/output/ -v

预期:6 个测试全部 PASS

  • 步骤 5:Commit
go mod tidy
git add -A
git commit -m "feat: output system — table/json/yaml/raw printers with struct tag driven tables"

任务 4:Provider 接口 + Registry + 通用模型

文件:

  • 创建:internal/provider/provider.go

  • 创建:internal/provider/registry.go

  • 步骤 1:编写 registry 测试(失败先行)

创建 internal/provider/registry_test.go:

package provider

import (
	"context"
	"testing"
)

type mockProvider struct {
	name string
}

func (m *mockProvider) Name() string                        { return m.name }
func (m *mockProvider) DisplayName() string                 { return "Mock" }
func (m *mockProvider) ValidateAuth(ctx context.Context) error { return nil }
func (m *mockProvider) Server() ServerService               { return nil }
func (m *mockProvider) Domain() DomainService               { return nil }
func (m *mockProvider) Storage() StorageService             { return nil }
func (m *mockProvider) CDN() CDNService                     { return nil }
func (m *mockProvider) Billing() BillingService             { return nil }

func TestRegistryRegisterAndNew(t *testing.T) {
	Register("mock", func(cfg ProviderConfig) (Provider, error) {
		return &mockProvider{name: "mock"}, nil
	})

	p, err := New("mock", ProviderConfig{})
	if err != nil {
		t.Fatalf("New() error: %v", err)
	}
	if p.Name() != "mock" {
		t.Errorf("Name() = %q, want %q", p.Name(), "mock")
	}
}

func TestRegistryUnknown(t *testing.T) {
	_, err := New("unknown", ProviderConfig{})
	if err == nil {
		t.Error("expected error for unknown provider")
	}
}

func TestRegistryList(t *testing.T) {
	Register("list-test", func(cfg ProviderConfig) (Provider, error) {
		return &mockProvider{name: "list-test"}, nil
	})
	names := List()
	found := false
	for _, n := range names {
		if n == "list-test" {
			found = true
			break
		}
	}
	if !found {
		t.Errorf("List() should contain 'list-test', got %v", names)
	}
}
  • 步骤 2:运行测试确认失败
go test ./internal/provider/ -v

预期:FAIL — 类型未定义

  • 步骤 3:实现 provider.go + registry.go

创建 internal/provider/provider.go:

package provider

import (
	"context"
	"fmt"
	"time"
)

type ProviderConfig struct {
	APIKey         string
	AccessKeyID    string
	AccessKeySecret string
	Endpoint       string
}

type Provider interface {
	Name() string
	DisplayName() string
	ValidateAuth(ctx context.Context) error
	Server() ServerService
	Domain() DomainService
	Storage() StorageService
	CDN() CDNService
	Billing() BillingService
}

type ServerService interface {
	List(ctx context.Context) ([]Server, error)
	Get(ctx context.Context, id string) (*Server, error)
	Start(ctx context.Context, id string) error
	Stop(ctx context.Context, id string) error
	Reboot(ctx context.Context, id string) error
	Reinstall(ctx context.Context, id, osTemplate string) error
	ResetPassword(ctx context.Context, id string) error
	GetVNC(ctx context.Context, id string) (string, error)
	Upgrade(ctx context.Context, id, plan string) error
}

type DomainService interface {
	List(ctx context.Context) ([]Domain, error)
	DNSList(ctx context.Context, domain string) ([]DNSRecord, error)
	DNSAdd(ctx context.Context, domain string, record DNSRecord) error
	DNSDelete(ctx context.Context, domain string, recordID string) error
}

type StorageService interface {
	List(ctx context.Context) ([]StorageInstance, error)
	BucketList(ctx context.Context, instanceID string) ([]Bucket, error)
	BucketCreate(ctx context.Context, instanceID, name string) error
}

type CDNService interface {
	List(ctx context.Context) ([]CDNInstance, error)
}

type BillingService interface {
	Orders(ctx context.Context) ([]Order, error)
}

type Server struct {
	ID       string    `json:"id"       table:"ID"`
	Name     string    `json:"name"     table:"NAME"`
	Status   string    `json:"status"   table:"STATUS"`
	IP       string    `json:"ip"       table:"IP"`
	CPU      int       `json:"cpu"      table:"CPU"`
	Memory   int       `json:"memory"   table:"MEM"`
	Disk     int       `json:"disk"     table:"DISK"`
	OS       string    `json:"os"       table:"OS"`
	Region   string    `json:"region"   table:"REGION"`
	ExpireAt time.Time `json:"expires"  table:"EXPIRES"`
	Raw      any       `json:"-"`
}

type Domain struct {
	ID       string    `json:"id"       table:"ID"`
	Name     string    `json:"name"     table:"DOMAIN"`
	Status   string    `json:"status"   table:"STATUS"`
	ExpireAt time.Time `json:"expires"  table:"EXPIRES"`
	Raw      any       `json:"-"`
}

type DNSRecord struct {
	ID    string `json:"id"    table:"ID"`
	Type  string `json:"type"  table:"TYPE"`
	Name  string `json:"name"  table:"NAME"`
	Value string `json:"value" table:"VALUE"`
	TTL   int    `json:"ttl"   table:"TTL"`
}

type StorageInstance struct {
	ID     string `json:"id"     table:"ID"`
	Name   string `json:"name"   table:"NAME"`
	Status string `json:"status" table:"STATUS"`
	Raw    any    `json:"-"`
}

type Bucket struct {
	ID     string `json:"id"     table:"ID"`
	Name   string `json:"name"   table:"NAME"`
	Region string `json:"region" table:"REGION"`
}

type CDNInstance struct {
	ID     string `json:"id"     table:"ID"`
	Domain string `json:"domain" table:"DOMAIN"`
	Status string `json:"status" table:"STATUS"`
	Raw    any    `json:"-"`
}

type Order struct {
	ID      string    `json:"id"      table:"ID"`
	Product string    `json:"product" table:"PRODUCT"`
	Amount  float64   `json:"amount"  table:"AMOUNT"`
	Status  string    `json:"status"  table:"STATUS"`
	Created time.Time `json:"created" table:"CREATED"`
	Raw     any       `json:"-"`
}

type Error struct {
	Code    int
	Message string
	Detail  string
	Raw     []byte
}

func (e *Error) Error() string {
	return e.Message
}

func (e *Error) Debug() string {
	return fmt.Sprintf("%s\n%s", e.Detail, string(e.Raw))
}

创建 internal/provider/registry.go:

package provider

import (
	"fmt"
	"sort"
	"sync"
)

type Factory func(cfg ProviderConfig) (Provider, error)

var (
	registry   = map[string]Factory{}
	registryMu sync.RWMutex
)

func Register(name string, factory Factory) {
	registryMu.Lock()
	defer registryMu.Unlock()
	registry[name] = factory
}

func New(name string, cfg ProviderConfig) (Provider, error) {
	registryMu.RLock()
	factory, ok := registry[name]
	registryMu.RUnlock()
	if !ok {
		return nil, fmt.Errorf("unknown provider: %s (run 'ry provider add %s' first)", name, name)
	}
	return factory(cfg)
}

func List() []string {
	registryMu.RLock()
	defer registryMu.RUnlock()
	names := make([]string, 0, len(registry))
	for name := range registry {
		names = append(names, name)
	}
	sort.Strings(names)
	return names
}
  • 步骤 4:运行测试验证通过
go test ./internal/provider/ -v

预期:3 个测试 PASS。注意 mock 注册可能在 TestRegistryList 中有残留——没问题,Go 测试隔离运行。

  • 步骤 5:Commit
go mod tidy
git add -A
git commit -m "feat: provider interface + registry — factory pattern for multi-cloud"

任务 5:雨云 HTTP 客户端(适配器模式)

文件:

  • 创建:internal/provider/rainyun/client.go

  • 创建:internal/provider/rainyun/client_test.go

  • 步骤 1:编写 client 测试(失败先行)

创建 internal/provider/rainyun/client_test.go:

package rainyun

import (
	"context"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestClientAuthHeader(t *testing.T) {
	var gotKey string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotKey = r.Header.Get("X-Api-Key")
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"code":0,"data":{"id":1,"name":"test"}}`))
	}))
	defer srv.Close()

	c := newClient(srv.URL, "sk-test")
	var result struct {
		Code int `json:"code"`
		Data struct {
			ID   int    `json:"id"`
			Name string `json:"name"`
		} `json:"data"`
	}
	if err := c.get(context.Background(), "/test", &result); err != nil {
		t.Fatalf("get() error: %v", err)
	}
	if gotKey != "sk-test" {
		t.Errorf("X-Api-Key = %q, want %q", gotKey, "sk-test")
	}
	if result.Data.Name != "test" {
		t.Errorf("Data.Name = %q", result.Data.Name)
	}
}

func TestClientAPIError(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(404)
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"code":1002,"msg":"not found"}`))
	}))
	defer srv.Close()

	c := newClient(srv.URL, "sk-test")
	var result any
	err := c.get(context.Background(), "/error", &result)
	if err == nil {
		t.Fatal("expected error for 404")
	}
	apiErr, ok := err.(*APIError)
	if !ok {
		t.Fatalf("expected *APIError, got %T", err)
	}
	if apiErr.Code != 404 {
		t.Errorf("error code = %d, want 404", apiErr.Code)
	}
}
  • 步骤 2:运行测试确认失败
go test ./internal/provider/rainyun/ -v

预期:FAIL

  • 步骤 3:实现 client.go

创建 internal/provider/rainyun/client.go:

package rainyun

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"time"
)

const defaultEndpoint = "https://api.v2.rainyun.com"

type client struct {
	httpClient *http.Client
	baseURL    string
	apiKey     string
}

func newClient(endpoint, apiKey string) *client {
	if endpoint == "" {
		endpoint = defaultEndpoint
	}
	return &client{
		httpClient: &http.Client{Timeout: 30 * time.Second},
		baseURL:    endpoint,
		apiKey:     apiKey,
	}
}

type apiResponse struct {
	Code int             `json:"code"`
	Msg  string          `json:"msg"`
	Data json.RawMessage `json:"data"`
}

func (c *client) get(ctx context.Context, path string, result any) error {
	return c.do(ctx, "GET", path, nil, result)
}

func (c *client) post(ctx context.Context, path string, body, result any) error {
	return c.do(ctx, "POST", path, body, result)
}

func (c *client) patch(ctx context.Context, path string, body, result any) error {
	return c.do(ctx, "PATCH", path, body, result)
}

func (c *client) delete(ctx context.Context, path string, result any) error {
	return c.do(ctx, "DELETE", path, nil, result)
}

func (c *client) do(ctx context.Context, method, path string, body, result any) error {
	url := c.baseURL + path

	var bodyReader io.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			return fmt.Errorf("marshal request: %w", err)
		}
		bodyReader = bytes.NewReader(b)
	}

	req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
	if err != nil {
		return fmt.Errorf("create request: %w", err)
	}
	req.Header.Set("X-Api-Key", c.apiKey)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("request failed: %w", err)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return fmt.Errorf("read response: %w", err)
	}

	if resp.StatusCode >= 400 {
		return &APIError{
			StatusCode: resp.StatusCode,
			Method:     method,
			URL:        url,
			Body:       respBody,
		}
	}

	var apiResp apiResponse
	if err := json.Unmarshal(respBody, &apiResp); err != nil {
		return fmt.Errorf("unmarshal response: %w", err)
	}
	if apiResp.Code != 0 {
		return &APIError{
			StatusCode: resp.StatusCode,
			Method:     method,
			URL:        url,
			Body:       respBody,
			APICode:    apiResp.Code,
			APIMsg:     apiResp.Msg,
		}
	}

	if result != nil && apiResp.Data != nil {
		if err := json.Unmarshal(apiResp.Data, result); err != nil {
			return fmt.Errorf("unmarshal data: %w", err)
		}
	}
	return nil
}

type APIError struct {
	StatusCode int
	Method     string
	URL        string
	Body       []byte
	APICode    int
	APIMsg     string
}

func (e *APIError) Error() string {
	msg := e.APIMsg
	if msg == "" {
		msg = http.StatusText(e.StatusCode)
	}
	return fmt.Sprintf("%s", msg)
}
  • 步骤 4:运行测试验证通过
go test ./internal/provider/rainyun/ -v

预期:2 个测试 PASS

  • 步骤 5:Commit
go mod tidy
git add -A
git commit -m "feat: rainyun http client — adapter pattern with auth injection"

任务 6:雨云 ServerService 实现

文件:

  • 创建:internal/provider/rainyun/rainyun.go

  • 创建:internal/provider/rainyun/server.go

  • 创建:internal/provider/rainyun/server_test.go

  • 步骤 1:编写 server 测试(失败先行)

创建 internal/provider/rainyun/server_test.go:

package rainyun

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestServerList(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != "GET" || r.URL.Path != "/product/rcs/" {
			t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
		}
		resp := map[string]any{
			"code": 0,
			"data": []map[string]any{
				{
					"id":     12001,
					"name":   "web-server",
					"status": "running",
					"ip":     "1.2.3.4",
					"vcpu":   2,
					"memory": 4096,
					"disk":   50,
					"os":     "Ubuntu 22.04",
					"expire_date": "2026-08-15",
				},
			},
		}
		json.NewEncoder(w).Encode(resp)
	}))
	defer srv.Close()

	rain := &Rainyun{client: newClient(srv.URL, "sk-test")}
	svc := rain.Server()
	servers, err := svc.List(context.Background())
	if err != nil {
		t.Fatalf("List() error: %v", err)
	}
	if len(servers) != 1 {
		t.Fatalf("len = %d, want 1", len(servers))
	}
	s := servers[0]
	if s.Name != "web-server" {
		t.Errorf("Name = %q", s.Name)
	}
	if s.Status != "running" {
		t.Errorf("Status = %q", s.Status)
	}
	if s.CPU != 2 {
		t.Errorf("CPU = %d", s.CPU)
	}
}

func TestServerStart(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != "POST" || r.URL.Path != "/product/rcs/12001/start" {
			t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
		}
		json.NewEncoder(w).Encode(map[string]any{"code": 0})
	}))
	defer srv.Close()

	rain := &Rainyun{client: newClient(srv.URL, "sk-test")}
	err := rain.Server().Start(context.Background(), "12001")
	if err != nil {
		t.Fatalf("Start() error: %v", err)
	}
}

func TestServerVNC(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		resp := map[string]any{
			"code": 0,
			"data": map[string]any{"vnc_url": "https://vnc.example.com/abc"},
		}
		json.NewEncoder(w).Encode(resp)
	}))
	defer srv.Close()

	rain := &Rainyun{client: newClient(srv.URL, "sk-test")}
	url, err := rain.Server().GetVNC(context.Background(), "12001")
	if err != nil {
		t.Fatalf("GetVNC() error: %v", err)
	}
	if url != "https://vnc.example.com/abc" {
		t.Errorf("VNC url = %q", url)
	}
}
  • 步骤 2:运行测试确认失败
go test ./internal/provider/rainyun/ -v

预期:FAIL — Rainyun 类型 / Server() 未定义

  • 步骤 3:实现 rainyun.go

创建 internal/provider/rainyun/rainyun.go:

package rainyun

import (
	"context"

	"git.yoresee.cc/nightstar/ry/internal/provider"
)

func init() {
	provider.Register("rainyun", New)
}

type Rainyun struct {
	client *client
	server *ServerService
}

func New(cfg provider.ProviderConfig) (provider.Provider, error) {
	c := newClient(cfg.Endpoint, cfg.APIKey)
	r := &Rainyun{client: c}
	r.server = &ServerService{client: c}
	return r, nil
}

func (r *Rainyun) Name() string        { return "rainyun" }
func (r *Rainyun) DisplayName() string { return "雨云" }

func (r *Rainyun) ValidateAuth(ctx context.Context) error {
	var data any
	return r.client.get(ctx, "/user/", &data)
}

func (r *Rainyun) Server() provider.ServerService   { return r.server }
func (r *Rainyun) Domain() provider.DomainService   { return nil }
func (r *Rainyun) Storage() provider.StorageService { return nil }
func (r *Rainyun) CDN() provider.CDNService         { return nil }
func (r *Rainyun) Billing() provider.BillingService { return nil }
  • 步骤 4:实现 server.go

创建 internal/provider/rainyun/server.go:

package rainyun

import (
	"context"
	"fmt"
	"time"

	"git.yoresee.cc/nightstar/ry/internal/provider"
)

type ServerService struct {
	client *client
}

type rainyunServer struct {
	ID         int    `json:"id"`
	Name       string `json:"name"`
	Status     string `json:"status"`
	IP         string `json:"ip"`
	VCPU       int    `json:"vcpu"`
	Memory     int    `json:"memory"`
	Disk       int    `json:"disk"`
	OS         string `json:"os"`
	Region     string `json:"zone_name"`
	ExpireDate string `json:"expire_date"`
}

func (s *ServerService) List(ctx context.Context) ([]provider.Server, error) {
	var raw []rainyunServer
	if err := s.client.get(ctx, "/product/rcs/", &raw); err != nil {
		return nil, err
	}
	servers := make([]provider.Server, len(raw))
	for i, r := range raw {
		servers[i] = toServer(r)
	}
	return servers, nil
}

func (s *ServerService) Get(ctx context.Context, id string) (*provider.Server, error) {
	var raw rainyunServer
	path := fmt.Sprintf("/product/rcs/%s/", id)
	if err := s.client.get(ctx, path, &raw); err != nil {
		return nil, err
	}
	sv := toServer(raw)
	return &sv, nil
}

func (s *ServerService) Start(ctx context.Context, id string) error {
	return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/start", id), nil, nil)
}

func (s *ServerService) Stop(ctx context.Context, id string) error {
	return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/stop", id), nil, nil)
}

func (s *ServerService) Reboot(ctx context.Context, id string) error {
	return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/reboot", id), nil, nil)
}

func (s *ServerService) Reinstall(ctx context.Context, id, osTemplate string) error {
	body := map[string]string{"os": osTemplate}
	return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/changeos", id), body, nil)
}

func (s *ServerService) ResetPassword(ctx context.Context, id string) error {
	return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/reset-password", id), nil, nil)
}

func (s *ServerService) GetVNC(ctx context.Context, id string) (string, error) {
	var result struct {
		VNCURL string `json:"vnc_url"`
	}
	if err := s.client.get(ctx, fmt.Sprintf("/product/rcs/%s/vnc", id), &result); err != nil {
		return "", err
	}
	return result.VNCURL, nil
}

func (s *ServerService) Upgrade(ctx context.Context, id, plan string) error {
	body := map[string]string{"plan": plan}
	return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/upgrade", id), body, nil)
}

func toServer(r rainyunServer) provider.Server {
	expire, _ := time.Parse("2006-01-02", r.ExpireDate)
	return provider.Server{
		ID:       fmt.Sprintf("%d", r.ID),
		Name:     r.Name,
		Status:   r.Status,
		IP:       r.IP,
		CPU:      r.VCPU,
		Memory:   r.Memory,
		Disk:     r.Disk,
		OS:       r.OS,
		Region:   r.Region,
		ExpireAt: expire,
		Raw:      r,
	}
}
  • 步骤 5:运行测试验证通过
go test ./internal/provider/rainyun/ -v

预期:5 个测试 PASS(client 2 + server 3)

  • 步骤 6:Commit
go mod tidy
git add -A
git commit -m "feat: rainyun server service — list/get/start/stop/reboot/vnc/upgrade"

任务 7:Cobra 命令层 — 根命令 + 全局 flag + 配置集成

文件:

  • 修改:internal/cmd/root.go

  • 创建:internal/provider/provider.go 中补充 ProviderConfig(已有)

  • 修改:internal/provider/registry.go(已有)

  • 步骤 1:重写 root.go(集成 viper + 配置 + 全局 flag)

重写 internal/cmd/root.go:

package cmd

import (
	"fmt"
	"os"

	"git.yoresee.cc/nightstar/ry/internal/config"
	"git.yoresee.cc/nightstar/ry/internal/output"
	"git.yoresee.cc/nightstar/ry/internal/provider"

	"github.com/spf13/cobra"
)

var (
	cfg        *config.Config
	cfgPath    string
	prov       provider.Provider
	out        *output.Printer
	flagProvider string
	flagOutput   string
	flagRaw      bool
	flagDebug    bool
)

var rootCmd = &cobra.Command{
	Use:   "ry",
	Short: "Multi-cloud CLI — manage cloud resources from your terminal",
	PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
		var err error
		cfg, err = config.Load(cfgPath)
		if err != nil {
			return fmt.Errorf("load config: %w", err)
		}

		providerName := cfg.CurrentProvider
		if flagProvider != "" {
			providerName = flagProvider
		}
		if env := os.Getenv("RY_PROVIDER"); env != "" {
			providerName = env
		}

		if providerName == "" {
			return fmt.Errorf("no provider configured — run 'ry provider add <name>' first")
		}

		pc := cfg.Providers[providerName]
		prov, err = provider.New(providerName, provider.ProviderConfig{
			APIKey:         pc.APIKey,
			AccessKeyID:    pc.AccessKeyID,
			AccessKeySecret: pc.AccessKeySecret,
			Endpoint:       pc.Endpoint,
		})
		if err != nil {
			return err
		}

		outputFormat := cfg.Output
		if flagOutput != "" {
			outputFormat = flagOutput
		}
		if flagRaw {
			outputFormat = "raw"
		}
		out = output.New(outputFormat, os.Stdout)
		return nil
	},
}

func Execute() error {
	return rootCmd.Execute()
}

func init() {
	cfgPath = config.DefaultPath()
	rootCmd.PersistentFlags().StringVarP(&flagProvider, "provider", "p", "", "Cloud provider to use")
	rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "", "Output format: table, json, yaml, raw")
	rootCmd.PersistentFlags().BoolVar(&flagRaw, "raw", false, "Output raw API response")
	rootCmd.PersistentFlags().BoolVar(&flagDebug, "debug", false, "Enable debug output")
}
  • 步骤 2:编译验证
go build ./cmd/ry/ && ./ry --help

预期:输出 help 信息,包含全局 flag

  • 步骤 3:Commit
git add -A
git commit -m "feat: cobra root — config integration, global flags, provider resolution"

任务 8:Cobra 命令层 — provider 子命令

文件:

  • 创建:internal/cmd/provider.go

  • 步骤 1:实现 provider 命令

创建 internal/cmd/provider.go:

package cmd

import (
	"fmt"

	"git.yoresee.cc/nightstar/ry/internal/config"
	"git.yoresee.cc/nightstar/ry/internal/provider"

	"github.com/spf13/cobra"
)

var providerCmd = &cobra.Command{
	Use:   "provider",
	Short: "Manage cloud providers",
}

var providerListCmd = &cobra.Command{
	Use:   "list",
	Short: "List configured providers",
	RunE: func(cmd *cobra.Command, args []string) error {
		current := cfg.CurrentProvider
		for _, name := range provider.List() {
			marker := " "
			if name == current {
				marker = "*"
			}
			fmt.Printf("%s %s\n", marker, name)
		}
		return nil
	},
}

var providerUseCmd = &cobra.Command{
	Use:   "use <name>",
	Short: "Switch the current cloud provider",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		name := args[0]
		if _, ok := cfg.Providers[name]; !ok {
			return fmt.Errorf("provider %q not configured — run 'ry provider add %s' first", name, name)
		}
		cfg.CurrentProvider = name
		if err := config.Save(cfgPath, cfg); err != nil {
			return fmt.Errorf("save config: %w", err)
		}
		fmt.Printf("Switched to provider %q\n", name)
		return nil
	},
}

var providerAddCmd = &cobra.Command{
	Use:   "add <name>",
	Short: "Add a new cloud provider",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		name := args[0]
		fmt.Printf("Adding provider %q...\n", name)
		fmt.Print("Enter API Key: ")
		var apiKey string
		fmt.Scanln(&apiKey)
		if apiKey == "" {
			return fmt.Errorf("API Key is required")
		}
		if cfg.Providers == nil {
			cfg.Providers = make(map[string]config.ProviderConfig)
		}
		cfg.Providers[name] = config.ProviderConfig{APIKey: apiKey}
		if cfg.CurrentProvider == "" {
			cfg.CurrentProvider = name
		}
		if err := config.Save(cfgPath, cfg); err != nil {
			return fmt.Errorf("save config: %w", err)
		}
		fmt.Printf("Provider %q added successfully\n", name)
		if cfg.CurrentProvider == name {
			fmt.Printf("Set as current provider\n")
		}
		return nil
	},
}

func init() {
	providerCmd.AddCommand(providerListCmd)
	providerCmd.AddCommand(providerUseCmd)
	providerCmd.AddCommand(providerAddCmd)
	rootCmd.AddCommand(providerCmd)
}
  • 步骤 2:编译验证
go build ./cmd/ry/ && ./ry provider --help && ./ry provider list

预期:子命令 help 正常输出

  • 步骤 3:Commit
git add -A
git commit -m "feat: provider cmd — list, use, add with interactive key input"

任务 9:Cobra 命令层 — server 子命令

文件:

  • 创建:internal/cmd/server.go

  • 步骤 1:实现 server 命令

创建 internal/cmd/server.go:

package cmd

import (
	"fmt"

	"github.com/spf13/cobra"
)

var serverCmd = &cobra.Command{
	Use:   "server",
	Short: "Manage cloud servers",
}

var serverListCmd = &cobra.Command{
	Use:   "list",
	Short: "List servers",
	RunE: func(cmd *cobra.Command, args []string) error {
		svc := prov.Server()
		if svc == nil {
			return fmt.Errorf("server management not supported by current provider")
		}
		servers, err := svc.List(cmd.Context())
		if err != nil {
			return err
		}
		if flagRaw {
			var raw []any
			for _, s := range servers {
				raw = append(raw, s.Raw)
			}
			return out.Print(raw)
		}
		return out.Print(servers)
	},
}

var serverGetCmd = &cobra.Command{
	Use:   "get <id>",
	Short: "Get server details",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		svc := prov.Server()
		if svc == nil {
			return fmt.Errorf("server management not supported")
		}
		s, err := svc.Get(cmd.Context(), args[0])
		if err != nil {
			return err
		}
		if flagRaw {
			return out.Print(s.Raw)
		}
		return out.Print(s)
	},
}

var serverStartCmd = &cobra.Command{
	Use:   "start <id>",
	Short: "Start a server",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		svc := prov.Server()
		if svc == nil {
			return fmt.Errorf("server management not supported")
		}
		if err := svc.Start(cmd.Context(), args[0]); err != nil {
			return err
		}
		fmt.Printf("Server %s started\n", args[0])
		return nil
	},
}

var serverStopCmd = &cobra.Command{
	Use:   "stop <id>",
	Short: "Stop a server",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		svc := prov.Server()
		if svc == nil {
			return fmt.Errorf("server management not supported")
		}
		if err := svc.Stop(cmd.Context(), args[0]); err != nil {
			return err
		}
		fmt.Printf("Server %s stopped\n", args[0])
		return nil
	},
}

var serverRebootCmd = &cobra.Command{
	Use:   "reboot <id>",
	Short: "Reboot a server",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		svc := prov.Server()
		if svc == nil {
			return fmt.Errorf("server management not supported")
		}
		if err := svc.Reboot(cmd.Context(), args[0]); err != nil {
			return err
		}
		fmt.Printf("Server %s rebooted\n", args[0])
		return nil
	},
}

var serverReinstallCmd = &cobra.Command{
	Use:   "reinstall <id>",
	Short: "Reinstall server OS",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		svc := prov.Server()
		if svc == nil {
			return fmt.Errorf("server management not supported")
		}
		osTemplate, _ := cmd.Flags().GetString("os")
		if osTemplate == "" {
			return fmt.Errorf("--os flag is required (e.g. --os ubuntu-22.04)")
		}
		if err := svc.Reinstall(cmd.Context(), args[0], osTemplate); err != nil {
			return err
		}
		fmt.Printf("Server %s reinstalling with %s\n", args[0], osTemplate)
		return nil
	},
}

var serverResetPasswordCmd = &cobra.Command{
	Use:   "reset-password <id>",
	Short: "Reset server password",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		svc := prov.Server()
		if svc == nil {
			return fmt.Errorf("server management not supported")
		}
		if err := svc.ResetPassword(cmd.Context(), args[0]); err != nil {
			return err
		}
		fmt.Printf("Password reset for server %s\n", args[0])
		return nil
	},
}

var serverVNCCmd = &cobra.Command{
	Use:   "vnc <id>",
	Short: "Get VNC connection URL",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		svc := prov.Server()
		if svc == nil {
			return fmt.Errorf("server management not supported")
		}
		url, err := svc.GetVNC(cmd.Context(), args[0])
		if err != nil {
			return err
		}
		fmt.Println(url)
		return nil
	},
}

func init() {
	serverReinstallCmd.Flags().String("os", "", "OS template name (required)")
	serverReinstallCmd.MarkFlagRequired("os")

	serverCmd.AddCommand(serverListCmd)
	serverCmd.AddCommand(serverGetCmd)
	serverCmd.AddCommand(serverStartCmd)
	serverCmd.AddCommand(serverStopCmd)
	serverCmd.AddCommand(serverRebootCmd)
	serverCmd.AddCommand(serverReinstallCmd)
	serverCmd.AddCommand(serverResetPasswordCmd)
	serverCmd.AddCommand(serverVNCCmd)
	rootCmd.AddCommand(serverCmd)
}
  • 步骤 2:编译验证
go build ./cmd/ry/ && ./ry server --help

预期:server 子命令 help 正常

  • 步骤 3:Commit
git add -A
git commit -m "feat: server cmd — list/get/start/stop/reboot/reinstall/reset-password/vnc"

任务 10:剩余命令层 — domain / storage / billing / config

文件:

  • 创建:internal/cmd/domain.go

  • 创建:internal/cmd/storage.go

  • 创建:internal/cmd/billing.go

  • 创建:internal/cmd/config_cmd.go

  • 步骤 1:实现 domain 命令

创建 internal/cmd/domain.go:

package cmd

import (
	"fmt"

	"github.com/spf13/cobra"
)

var domainCmd = &cobra.Command{
	Use:   "domain",
	Short: "Manage domains",
}

var domainListCmd = &cobra.Command{
	Use:   "list",
	Short: "List domains",
	RunE: func(cmd *cobra.Command, args []string) error {
		svc := prov.Domain()
		if svc == nil {
			return fmt.Errorf("domain management not supported by current provider")
		}
		domains, err := svc.List(cmd.Context())
		if err != nil {
			return err
		}
		return out.Print(domains)
	},
}

var dnsCmd = &cobra.Command{
	Use:   "dns <domain>",
	Short: "Manage DNS records",
	Args:  cobra.ExactArgs(1),
}

var dnsListCmd = &cobra.Command{
	Use:   "list",
	Short: "List DNS records",
	RunE: func(cmd *cobra.Command, args []string) error {
		domain := args[0]
		svc := prov.Domain()
		if svc == nil {
			return fmt.Errorf("domain management not supported")
		}
		records, err := svc.DNSList(cmd.Context(), domain)
		if err != nil {
			return err
		}
		return out.Print(records)
	},
}

var dnsAddCmd = &cobra.Command{
	Use:   "add",
	Short: "Add a DNS record",
	RunE: func(cmd *cobra.Command, args []string) error {
		domain := args[0]
		svc := prov.Domain()
		if svc == nil {
			return fmt.Errorf("domain management not supported")
		}
		recType, _ := cmd.Flags().GetString("type")
		name, _ := cmd.Flags().GetString("name")
		value, _ := cmd.Flags().GetString("value")
		ttl, _ := cmd.Flags().GetInt("ttl")
		return svc.DNSAdd(cmd.Context(), domain,
			DNSRecord{Type: recType, Name: name, Value: value, TTL: ttl})
	},
}

var dnsDeleteCmd = &cobra.Command{
	Use:   "delete <record-id>",
	Short: "Delete a DNS record",
	Args:  cobra.ExactArgs(2),
	RunE: func(cmd *cobra.Command, args []string) error {
		domain := args[0]
		recordID := args[1]
		svc := prov.Domain()
		if svc == nil {
			return fmt.Errorf("domain management not supported")
		}
		return svc.DNSDelete(cmd.Context(), domain, recordID)
	},
}

func init() {
	dnsAddCmd.Flags().String("type", "A", "Record type (A, CNAME, TXT, etc.)")
	dnsAddCmd.Flags().String("name", "@", "Record name")
	dnsAddCmd.Flags().String("value", "", "Record value")
	dnsAddCmd.Flags().Int("ttl", 600, "TTL in seconds")
	dnsAddCmd.MarkFlagRequired("value")

	dnsCmd.AddCommand(dnsListCmd)
	dnsCmd.AddCommand(dnsAddCmd)
	dnsCmd.AddCommand(dnsDeleteCmd)
	domainCmd.AddCommand(domainListCmd)
	domainCmd.AddCommand(dnsCmd)
	rootCmd.AddCommand(domainCmd)
}
  • 步骤 2:实现 storage 命令

创建 internal/cmd/storage.go:

package cmd

import (
	"fmt"

	"github.com/spf13/cobra"
)

var storageCmd = &cobra.Command{
	Use:   "storage",
	Short: "Manage object storage",
}

var storageListCmd = &cobra.Command{
	Use:   "list",
	Short: "List storage instances",
	RunE: func(cmd *cobra.Command, args []string) error {
		svc := prov.Storage()
		if svc == nil {
			return fmt.Errorf("storage management not supported by current provider")
		}
		instances, err := svc.List(cmd.Context())
		if err != nil {
			return err
		}
		return out.Print(instances)
	},
}

var bucketCmd = &cobra.Command{
	Use:   "bucket <instance-id>",
	Short: "Manage buckets",
	Args:  cobra.ExactArgs(1),
}

var bucketListCmd = &cobra.Command{
	Use:   "list",
	Short: "List buckets",
	RunE: func(cmd *cobra.Command, args []string) error {
		instanceID := args[0]
		svc := prov.Storage()
		if svc == nil {
			return fmt.Errorf("storage management not supported")
		}
		buckets, err := svc.BucketList(cmd.Context(), instanceID)
		if err != nil {
			return err
		}
		return out.Print(buckets)
	},
}

var bucketCreateCmd = &cobra.Command{
	Use:   "create <name>",
	Short: "Create a bucket",
	Args:  cobra.ExactArgs(2),
	RunE: func(cmd *cobra.Command, args []string) error {
		instanceID := args[0]
		name := args[1]
		svc := prov.Storage()
		if svc == nil {
			return fmt.Errorf("storage management not supported")
		}
		if err := svc.BucketCreate(cmd.Context(), instanceID, name); err != nil {
			return err
		}
		fmt.Printf("Bucket %q created\n", name)
		return nil
	},
}

func init() {
	bucketCmd.AddCommand(bucketListCmd)
	bucketCmd.AddCommand(bucketCreateCmd)
	storageCmd.AddCommand(storageListCmd)
	storageCmd.AddCommand(bucketCmd)
	rootCmd.AddCommand(storageCmd)
}
  • 步骤 3:实现 billing 命令

创建 internal/cmd/billing.go:

package cmd

import (
	"fmt"

	"github.com/spf13/cobra"
)

var billingCmd = &cobra.Command{
	Use:   "billing",
	Short: "Billing and orders",
}

var ordersCmd = &cobra.Command{
	Use:   "orders",
	Short: "List orders",
	RunE: func(cmd *cobra.Command, args []string) error {
		svc := prov.Billing()
		if svc == nil {
			return fmt.Errorf("billing not supported by current provider")
		}
		orders, err := svc.Orders(cmd.Context())
		if err != nil {
			return err
		}
		return out.Print(orders)
	},
}

func init() {
	billingCmd.AddCommand(ordersCmd)
	rootCmd.AddCommand(billingCmd)
}
  • 步骤 4:实现 config 命令

创建 internal/cmd/config_cmd.go:

package cmd

import (
	"fmt"
	"strings"

	"git.yoresee.cc/nightstar/ry/internal/config"

	"github.com/spf13/cobra"
)

var configCmd = &cobra.Command{
	Use:   "config",
	Short: "Manage ry configuration",
}

var configShowCmd = &cobra.Command{
	Use:   "show",
	Short: "Show current configuration",
	RunE: func(cmd *cobra.Command, args []string) error {
		fmt.Printf("Config file: %s\n", cfgPath)
		fmt.Printf("Current provider: %s\n", cfg.CurrentProvider)
		fmt.Printf("Output format: %s\n", cfg.Output)
		fmt.Println("Providers:")
		for name, pc := range cfg.Providers {
			masked := pc.APIKey
			if len(masked) > 4 {
				masked = masked[:2] + strings.Repeat("*", len(masked)-4) + masked[len(masked)-2:]
			}
			marker := " "
			if name == cfg.CurrentProvider {
				marker = "*"
			}
			fmt.Printf("  %s %s: api_key=%s\n", marker, name, masked)
		}
		return nil
	},
}

var configSetCmd = &cobra.Command{
	Use:   "set <key> <value>",
	Short: "Set a config value",
	Args:  cobra.ExactArgs(2),
	RunE: func(cmd *cobra.Command, args []string) error {
		key, value := args[0], args[1]
		switch key {
		case "output":
			cfg.Output = value
		default:
			return fmt.Errorf("unknown config key: %s", key)
		}
		if err := config.Save(cfgPath, cfg); err != nil {
			return fmt.Errorf("save config: %w", err)
		}
		fmt.Printf("Set %s = %s\n", key, value)
		return nil
	},
}

var configPathCmd = &cobra.Command{
	Use:   "path",
	Short: "Print config file path",
	RunE: func(cmd *cobra.Command, args []string) error {
		fmt.Println(cfgPath)
		return nil
	},
}

func init() {
	configCmd.AddCommand(configShowCmd)
	configCmd.AddCommand(configSetCmd)
	configCmd.AddCommand(configPathCmd)
	rootCmd.AddCommand(configCmd)
}
  • 步骤 5:注意 domain.go 中引用 DNSRecord 的方式

domain.go 中的 dnsAddCmd 直接用了 DNSRecord,需要 import provider 包或定义类型别名。修复:

在 internal/cmd/domain.go 顶部 import 添加:

import (
	"fmt"

	"git.yoresee.cc/nightstar/ry/internal/provider"

	"github.com/spf13/cobra"
)

并将 DNSRecord 改为 provider.DNSRecord。

  • 步骤 6:编译验证
go build ./cmd/ry/ && ./ry --help && ./ry config --help && ./ry domain --help && ./ry storage --help && ./ry billing --help

预期:所有子命令 help 正常输出

  • 步骤 7:Commit
git add -A
git commit -m "feat: domain/storage/billing/config commands — full CLI surface"

任务 11:雨云剩余 Service 实现(domain/storage/billing)

文件:

  • 创建:internal/provider/rainyun/domain.go

  • 创建:internal/provider/rainyun/storage.go

  • 创建:internal/provider/rainyun/billing.go

  • 修改:internal/provider/rainyun/rainyun.go(挂载新 service)

  • 步骤 1:实现 domain.go

创建 internal/provider/rainyun/domain.go:

package rainyun

import (
	"context"
	"fmt"

	"git.yoresee.cc/nightstar/ry/internal/provider"
)

type DomainService struct {
	client *client
}

type rainyunDomain struct {
	ID         int    `json:"id"`
	DomainName string `json:"domain_name"`
	Status     string `json:"status"`
	ExpireDate string `json:"expire_date"`
}

func (d *DomainService) List(ctx context.Context) ([]provider.Domain, error) {
	var raw []rainyunDomain
	if err := d.client.get(ctx, "/product/domain/", &raw); err != nil {
		return nil, err
	}
	domains := make([]provider.Domain, len(raw))
	for i, r := range raw {
		domains[i] = provider.Domain{
			ID:   fmt.Sprintf("%d", r.ID),
			Name: r.DomainName,
		}
	}
	return domains, nil
}

func (d *DomainService) DNSList(ctx context.Context, domain string) ([]provider.DNSRecord, error) {
	var raw []struct {
		ID    int    `json:"id"`
		Type  string `json:"record_type"`
		Name  string `json:"host"`
		Value string `json:"value"`
		TTL   int    `json:"ttl"`
	}
	path := fmt.Sprintf("/product/domain/%s/dns/", domain)
	if err := d.client.get(ctx, path, &raw); err != nil {
		return nil, err
	}
	records := make([]provider.DNSRecord, len(raw))
	for i, r := range raw {
		records[i] = provider.DNSRecord{
			ID:    fmt.Sprintf("%d", r.ID),
			Type:  r.Type,
			Name:  r.Name,
			Value: r.Value,
			TTL:   r.TTL,
		}
	}
	return records, nil
}

func (d *DomainService) DNSAdd(ctx context.Context, domain string, record provider.DNSRecord) error {
	body := map[string]any{
		"record_type": record.Type,
		"host":        record.Name,
		"value":       record.Value,
		"ttl":         record.TTL,
	}
	return d.client.post(ctx, fmt.Sprintf("/product/domain/%s/dns", domain), body, nil)
}

func (d *DomainService) DNSDelete(ctx context.Context, domain string, recordID string) error {
	return d.client.delete(ctx, fmt.Sprintf("/product/domain/%s/dns/%s", domain, recordID), nil)
}
  • 步骤 2:实现 storage.go

创建 internal/provider/rainyun/storage.go:

package rainyun

import (
	"context"
	"fmt"

	"git.yoresee.cc/nightstar/ry/internal/provider"
)

type StorageService struct {
	client *client
}

func (s *StorageService) List(ctx context.Context) ([]provider.StorageInstance, error) {
	var raw []struct {
		ID     int    `json:"id"`
		Name   string `json:"name"`
		Status string `json:"status"`
	}
	if err := s.client.get(ctx, "/product/ros/instance", &raw); err != nil {
		return nil, err
	}
	instances := make([]provider.StorageInstance, len(raw))
	for i, r := range raw {
		instances[i] = provider.StorageInstance{
			ID:   fmt.Sprintf("%d", r.ID),
			Name: r.Name,
			Raw:  r,
		}
	}
	return instances, nil
}

func (s *StorageService) BucketList(ctx context.Context, instanceID string) ([]provider.Bucket, error) {
	var raw []struct {
		ID     int    `json:"id"`
		Name   string `json:"name"`
		Region string `json:"region"`
	}
	path := fmt.Sprintf("/product/ros/bucket/%s", instanceID)
	if err := s.client.get(ctx, path, &raw); err != nil {
		return nil, err
	}
	buckets := make([]provider.Bucket, len(raw))
	for i, r := range raw {
		buckets[i] = provider.Bucket{
			ID:   fmt.Sprintf("%d", r.ID),
			Name: r.Name,
		}
	}
	return buckets, nil
}

func (s *StorageService) BucketCreate(ctx context.Context, instanceID, name string) error {
	body := map[string]string{"name": name}
	return s.client.post(ctx, fmt.Sprintf("/product/ros/bucket/%s", instanceID), body, nil)
}
  • 步骤 3:实现 billing.go

创建 internal/provider/rainyun/billing.go:

package rainyun

import (
	"context"
	"fmt"
	"time"

	"git.yoresee.cc/nightstar/ry/internal/provider"
)

type BillingService struct {
	client *client
}

func (b *BillingService) Orders(ctx context.Context) ([]provider.Order, error) {
	var raw []struct {
		ID        int     `json:"id"`
		Product   string  `json:"product_name"`
		Amount    float64 `json:"amount"`
		Status    string  `json:"status"`
		CreatedAt string  `json:"create_date"`
	}
	if err := b.client.get(ctx, "/expense/orders/list", &raw); err != nil {
		return nil, err
	}
	orders := make([]provider.Order, len(raw))
	for i, r := range raw {
		t, _ := time.Parse("2006-01-02 15:04:05", r.CreatedAt)
		orders[i] = provider.Order{
			ID:      fmt.Sprintf("%d", r.ID),
			Product: r.Product,
			Amount:  r.Amount,
			Status:  r.Status,
			Created: t,
			Raw:     r,
		}
	}
	return orders, nil
}
  • 步骤 4:更新 rainyun.go 挂载新 service

修改 internal/provider/rainyun/rainyun.go:

type Rainyun struct {
	client  *client
	server  *ServerService
	domain  *DomainService
	storage *StorageService
	billing *BillingService
}

func New(cfg provider.ProviderConfig) (provider.Provider, error) {
	c := newClient(cfg.Endpoint, cfg.APIKey)
	r := &Rainyun{client: c}
	r.server = &ServerService{client: c}
	r.domain = &DomainService{client: c}
	r.storage = &StorageService{client: c}
	r.billing = &BillingService{client: c}
	return r, nil
}

func (r *Rainyun) Domain() provider.DomainService   { return r.domain }
func (r *Rainyun) Storage() provider.StorageService { return r.storage }
func (r *Rainyun) Billing() provider.BillingService { return r.billing }
  • 步骤 5:编译 + 测试验证
go build ./cmd/ry/ && go test ./internal/provider/rainyun/ -v && go vet ./...

预期:编译通过 + 已有测试 PASS

  • 步骤 6:Commit
git add -A
git commit -m "feat: rainyun domain/storage/billing services — full provider implementation"

任务 12:补全命令 + README

文件:

  • 创建:README.md

  • 步骤 1:添加 completion 命令

在 internal/cmd/root.go 的 init() 中添加:

rootCmd.AddCommand(&cobra.Command{
	Use:   "completion [bash|zsh|fish]",
	Short: "Generate shell completion script",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		switch args[0] {
		case "bash":
			return rootCmd.GenBashCompletion(os.Stdout)
		case "zsh":
			return rootCmd.GenZshCompletion(os.Stdout)
		case "fish":
			return rootCmd.GenFishCompletion(os.Stdout, true)
		default:
			return fmt.Errorf("unsupported shell: %s", args[0])
		}
	},
})
  • 步骤 2:编写 README.md

创建 README.md:

# ry — Multi-Cloud CLI

A `gh`-style CLI for managing cloud resources across providers.

## Install

```bash
go install git.yoresee.cc/nightstar/ry/cmd/ry@latest

Quick Start

ry provider add rainyun
# Enter your API key when prompted

ry server list
ry server start <id>
ry server vnc <id>

ry provider use aliyun  # switch provider

Commands

  • ry provider — manage cloud providers
  • ry server — manage cloud servers (list, get, start, stop, reboot, reinstall, vnc)
  • ry domain — manage domains and DNS
  • ry storage — manage object storage
  • ry billing — view orders
  • ry config — manage configuration
  • ry completion — shell completion

Global Flags

Flag Description
-p, --provider Override cloud provider
-o, --output Output format: table, json, yaml
--raw Raw API response
--debug Debug output

Supported Providers

  • 雨云 (Rainyun) — full support
  • More coming soon

Config

~/.config/ry/config.toml


- [ ] **步骤 3:最终编译 + 全量测试**

```bash
cd /root/.openclaw/workspace/project/rainyun-cli
go build ./cmd/ry/ && go test ./... -v && go vet ./...

预期:全部编译/测试通过

  • 步骤 4:Commit
git add -A
git commit -m "feat: completion + readme — polish and docs"

自检

  1. 规格覆盖度:设计规格中所有章节均有对应任务——项目结构(任务1)、配置系统(任务2)、输出系统(任务3)、Provider接口(任务4)、雨云客户端(任务5)、ServerService(任务6)、命令层(任务7-10)、剩余Service(任务11)、补全+README(任务12)。错误处理在任务5的 APIError 中已覆盖。

  2. 占位符扫描:无 "TODO"、"待定"、"后续实现"。所有代码步骤均有实际代码块。

  3. 类型一致性:provider.Server、provider.DNSRecord 等在接口定义和实现中一致。domain.go 命令层明确引用 provider.DNSRecord。