diff --git a/docs/2026-07-28-ry-cli-plan.md b/docs/2026-07-28-ry-cli-plan.md new file mode 100644 index 0000000..dfe956e --- /dev/null +++ b/docs/2026-07-28-ry-cli-plan.md @@ -0,0 +1,2591 @@ +# 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** + +```bash +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:创建目录结构** + +```bash +mkdir -p cmd/ry \ + internal/config \ + internal/output \ + internal/provider/rainyun \ + internal/cmd +``` + +- [ ] **步骤 3:写入入口 main.go** + +创建 `cmd/ry/main.go`: + +```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): + +```go +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:拉依赖并验证编译** + +```bash +cd /root/.openclaw/workspace/project/rainyun-cli +go mod tidy +go build ./cmd/ry/ +./ry --help +``` + +预期:输出 cobra 默认 help,无报错 + +- [ ] **步骤 5:Commit** + +```bash +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`: + +```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:运行测试确认失败** + +```bash +cd /root/.openclaw/workspace/project/rainyun-cli +go test ./internal/config/ +``` + +预期:FAIL — 类型未定义 + +- [ ] **步骤 3:实现 config.go** + +创建 `internal/config/config.go`: + +```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:运行测试验证通过** + +```bash +go test ./internal/config/ -v +``` + +预期:4 个测试全部 PASS + +- [ ] **步骤 5:拉依赖 + Commit** + +```bash +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`: + +```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:运行测试确认失败** + +```bash +go test ./internal/output/ -v +``` + +预期:FAIL — 类型未定义 + +- [ ] **步骤 3:实现 Printer 接口 + table.go** + +创建 `internal/output/output.go`: + +```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`: + +```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`: + +```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`: + +```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:运行测试验证通过** + +```bash +go test ./internal/output/ -v +``` + +预期:6 个测试全部 PASS + +- [ ] **步骤 5:Commit** + +```bash +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`: + +```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:运行测试确认失败** + +```bash +go test ./internal/provider/ -v +``` + +预期:FAIL — 类型未定义 + +- [ ] **步骤 3:实现 provider.go + registry.go** + +创建 `internal/provider/provider.go`: + +```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`: + +```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:运行测试验证通过** + +```bash +go test ./internal/provider/ -v +``` + +预期:3 个测试 PASS。注意 mock 注册可能在 `TestRegistryList` 中有残留——没问题,Go 测试隔离运行。 + +- [ ] **步骤 5:Commit** + +```bash +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`: + +```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:运行测试确认失败** + +```bash +go test ./internal/provider/rainyun/ -v +``` + +预期:FAIL + +- [ ] **步骤 3:实现 client.go** + +创建 `internal/provider/rainyun/client.go`: + +```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:运行测试验证通过** + +```bash +go test ./internal/provider/rainyun/ -v +``` + +预期:2 个测试 PASS + +- [ ] **步骤 5:Commit** + +```bash +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`: + +```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:运行测试确认失败** + +```bash +go test ./internal/provider/rainyun/ -v +``` + +预期:FAIL — Rainyun 类型 / Server() 未定义 + +- [ ] **步骤 3:实现 rainyun.go** + +创建 `internal/provider/rainyun/rainyun.go`: + +```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`: + +```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:运行测试验证通过** + +```bash +go test ./internal/provider/rainyun/ -v +``` + +预期:5 个测试 PASS(client 2 + server 3) + +- [ ] **步骤 6:Commit** + +```bash +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`: + +```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 ' 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:编译验证** + +```bash +go build ./cmd/ry/ && ./ry --help +``` + +预期:输出 help 信息,包含全局 flag + +- [ ] **步骤 3:Commit** + +```bash +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`: + +```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 ", + 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 ", + 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:编译验证** + +```bash +go build ./cmd/ry/ && ./ry provider --help && ./ry provider list +``` + +预期:子命令 help 正常输出 + +- [ ] **步骤 3:Commit** + +```bash +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`: + +```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 ", + 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 ", + 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 ", + 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 ", + 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 ", + 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 ", + 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 ", + 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:编译验证** + +```bash +go build ./cmd/ry/ && ./ry server --help +``` + +预期:server 子命令 help 正常 + +- [ ] **步骤 3:Commit** + +```bash +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`: + +```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 ", + 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 ", + 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`: + +```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 ", + 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 ", + 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`: + +```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`: + +```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 ", + 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 添加: + +```go +import ( + "fmt" + + "git.yoresee.cc/nightstar/ry/internal/provider" + + "github.com/spf13/cobra" +) +``` + +并将 `DNSRecord` 改为 `provider.DNSRecord`。 + +- [ ] **步骤 6:编译验证** + +```bash +go build ./cmd/ry/ && ./ry --help && ./ry config --help && ./ry domain --help && ./ry storage --help && ./ry billing --help +``` + +预期:所有子命令 help 正常输出 + +- [ ] **步骤 7:Commit** + +```bash +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`: + +```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`: + +```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`: + +```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`: + +```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:编译 + 测试验证** + +```bash +go build ./cmd/ry/ && go test ./internal/provider/rainyun/ -v && go vet ./... +``` + +预期:编译通过 + 已有测试 PASS + +- [ ] **步骤 6:Commit** + +```bash +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()` 中添加: + +```go +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`: + +```markdown +# 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 + +```bash +ry provider add rainyun +# Enter your API key when prompted + +ry server list +ry server start +ry server vnc + +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** + +```bash +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`。