From bd7b87d9c7cbc6378d3d670b5a7766b133beb5f0 Mon Sep 17 00:00:00 2001 From: Fendy Date: Thu, 13 Aug 2026 00:48:28 +0800 Subject: [PATCH] feat(cmd/ry): migrate rainyun-cli to SDK-backed cmd/ry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 CLI 自带 provider/HTTP 层,全部改调 sdk.New(apiKey) - config 简化为单一 apikey + output - 补 SDK 4 个缺口端点(domain list / dns list / bucket-by-instance / expense orders) - 修 output --raw bug(新增 raw case) - 命令 ID 参数统一 int,显示 model ID 保持 string - reinstall flag 从 --os 改 --os-id int --- cmd/ry/commands/billing/billing.go | 50 +++++++ cmd/ry/commands/configcmd/config.go | 75 ++++++++++ cmd/ry/commands/domain/domain.go | 138 +++++++++++++++++++ cmd/ry/commands/server/server.go | 205 ++++++++++++++++++++++++++++ cmd/ry/commands/storage/storage.go | 100 ++++++++++++++ cmd/ry/main.go | 13 ++ cmd/ry/root.go | 110 +++++++++++++++ docs/CHANGELOG.md | 19 +++ 8 files changed, 710 insertions(+) create mode 100644 cmd/ry/commands/billing/billing.go create mode 100644 cmd/ry/commands/configcmd/config.go create mode 100644 cmd/ry/commands/domain/domain.go create mode 100644 cmd/ry/commands/server/server.go create mode 100644 cmd/ry/commands/storage/storage.go create mode 100644 cmd/ry/main.go create mode 100644 cmd/ry/root.go diff --git a/cmd/ry/commands/billing/billing.go b/cmd/ry/commands/billing/billing.go new file mode 100644 index 0000000..b0b6c10 --- /dev/null +++ b/cmd/ry/commands/billing/billing.go @@ -0,0 +1,50 @@ +package billing + +import ( + "strconv" + "time" + + "github.com/XingfenD/rainyun_api_go_sdk/apis/common" + "github.com/XingfenD/rainyun_api_go_sdk/apis/expense" + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/internal/model" + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/internal/output" + "github.com/XingfenD/rainyun_api_go_sdk/sdk" + + "github.com/spf13/cobra" +) + +func Cmd(rySDK **sdk.RainyunSDK, out **output.Printer) *cobra.Command { + billingCmd := &cobra.Command{ + Use: "billing", + Short: "Billing and orders", + } + + ordersCmd := &cobra.Command{ + Use: "orders", + Short: "List orders", + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := (*rySDK).GetOrdersList(&expense.GetOrdersListRequest{ + Options: common.StandQueryParameters{Page: 1, PerPage: 50}, + }) + if err != nil { + return err + } + orders := make([]model.Order, len(resp.Data.Records)) + for i, r := range resp.Data.Records { + t, _ := time.Parse("2006-01-02 15:04:05", r.CreateDate) + orders[i] = model.Order{ + ID: strconv.Itoa(r.ID), + Product: r.ProductName, + Amount: r.Amount, + Status: r.Status, + Created: t, + } + } + return (*out).Print(orders) + }, + } + + billingCmd.AddCommand(ordersCmd) + + return billingCmd +} diff --git a/cmd/ry/commands/configcmd/config.go b/cmd/ry/commands/configcmd/config.go new file mode 100644 index 0000000..52c5271 --- /dev/null +++ b/cmd/ry/commands/configcmd/config.go @@ -0,0 +1,75 @@ +package configcmd + +import ( + "fmt" + "strings" + + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/internal/config" + + "github.com/spf13/cobra" +) + +func Cmd(cfg **config.Config, cfgPath string) *cobra.Command { + configCmd := &cobra.Command{ + Use: "config", + Short: "Manage ry configuration", + Annotations: map[string]string{"skipSDK": "true"}, + } + + configShowCmd := &cobra.Command{ + Use: "show", + Short: "Show current configuration", + Annotations: map[string]string{"skipSDK": "true"}, + RunE: func(cmd *cobra.Command, args []string) error { + c := *cfg + fmt.Printf("Config file: %s\n", cfgPath) + masked := c.APIKey + if len(masked) > 4 { + masked = masked[:2] + strings.Repeat("*", len(masked)-4) + masked[len(masked)-2:] + } + fmt.Printf("API key: %s\n", masked) + fmt.Printf("Output: %s\n", c.Output) + return nil + }, + } + + configSetCmd := &cobra.Command{ + Use: "set ", + Short: "Set a config value", + Args: cobra.ExactArgs(2), + Annotations: map[string]string{"skipSDK": "true"}, + RunE: func(cmd *cobra.Command, args []string) error { + key, value := args[0], args[1] + c := *cfg + switch key { + case "apikey": + c.APIKey = value + case "output": + c.Output = value + default: + return fmt.Errorf("unknown config key: %s (supported: apikey, output)", key) + } + if err := config.Save(cfgPath, c); err != nil { + return fmt.Errorf("save config: %w", err) + } + fmt.Printf("Set %s = %s\n", key, value) + return nil + }, + } + + configPathCmd := &cobra.Command{ + Use: "path", + Short: "Print config file path", + Annotations: map[string]string{"skipSDK": "true"}, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println(cfgPath) + return nil + }, + } + + configCmd.AddCommand(configShowCmd) + configCmd.AddCommand(configSetCmd) + configCmd.AddCommand(configPathCmd) + + return configCmd +} diff --git a/cmd/ry/commands/domain/domain.go b/cmd/ry/commands/domain/domain.go new file mode 100644 index 0000000..87baaf6 --- /dev/null +++ b/cmd/ry/commands/domain/domain.go @@ -0,0 +1,138 @@ +package domain + +import ( + "fmt" + "strconv" + + "github.com/XingfenD/rainyun_api_go_sdk/apis/common" + "github.com/XingfenD/rainyun_api_go_sdk/apis/domain" + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/internal/model" + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/internal/output" + "github.com/XingfenD/rainyun_api_go_sdk/sdk" + + "github.com/spf13/cobra" +) + +func Cmd(rySDK **sdk.RainyunSDK, out **output.Printer) *cobra.Command { + domainCmd := &cobra.Command{ + Use: "domain", + Short: "Manage domains", + } + + domainListCmd := &cobra.Command{ + Use: "list", + Short: "List domains", + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := (*rySDK).GetDomainList(&domain.GetDomainListRequest{ + Options: common.StandQueryParameters{Page: 1, PerPage: 50}, + }) + if err != nil { + return err + } + domains := make([]model.Domain, len(resp.Data.Records)) + for i, r := range resp.Data.Records { + domains[i] = model.Domain{ + ID: strconv.Itoa(r.ID), + Name: r.DomainName, + } + } + return (*out).Print(domains) + }, + } + + dnsCmd := &cobra.Command{ + Use: "dns ", + Short: "Manage DNS records", + Args: cobra.ExactArgs(1), + } + + dnsListCmd := &cobra.Command{ + Use: "list", + Short: "List DNS records", + RunE: func(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid domain id: %s", args[0]) + } + resp, err := (*rySDK).GetDomainDNSRecordList(id) + if err != nil { + return err + } + records := make([]model.DNSRecord, len(resp.Data)) + for i, r := range resp.Data { + records[i] = model.DNSRecord{ + ID: strconv.Itoa(r.ID), + Type: r.RecordType, + Name: r.Host, + Value: r.Value, + TTL: r.TTL, + } + } + return (*out).Print(records) + }, + } + + dnsAddCmd := &cobra.Command{ + Use: "add ", + Short: "Add a DNS record", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid domain id: %s", args[0]) + } + recType, _ := cmd.Flags().GetString("type") + host, _ := cmd.Flags().GetString("host") + value, _ := cmd.Flags().GetString("value") + ttl, _ := cmd.Flags().GetInt("ttl") + req := &domain.AddDomainDNSRecordRequest{ + Type: recType, + Host: host, + Value: value, + TTL: ttl, + Line: "DEFAULT", + Level: 10, + } + if _, err := (*rySDK).AddDomainDNSRecord(id, req); err != nil { + return err + } + fmt.Printf("DNS record added for domain %s\n", args[0]) + return nil + }, + } + + dnsDeleteCmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a DNS record", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + domainID, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid domain id: %s", args[0]) + } + recordID, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("invalid record id: %s", args[1]) + } + if _, err := (*rySDK).DeleteDomainDNSRecord(domainID, recordID); err != nil { + return err + } + fmt.Printf("DNS record %s deleted for domain %s\n", args[1], args[0]) + return nil + }, + } + + dnsAddCmd.Flags().String("type", "A", "Record type (A, AAAA, CNAME, TXT, MX, SRV)") + dnsAddCmd.Flags().String("host", "@", "Record host 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) + + return domainCmd +} diff --git a/cmd/ry/commands/server/server.go b/cmd/ry/commands/server/server.go new file mode 100644 index 0000000..eb31a20 --- /dev/null +++ b/cmd/ry/commands/server/server.go @@ -0,0 +1,205 @@ +package server + +import ( + "fmt" + "strconv" + "time" + + "github.com/XingfenD/rainyun_api_go_sdk/apis/common" + "github.com/XingfenD/rainyun_api_go_sdk/apis/rcs" + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/internal/model" + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/internal/output" + "github.com/XingfenD/rainyun_api_go_sdk/sdk" + + "github.com/spf13/cobra" +) + +func Cmd(rySDK **sdk.RainyunSDK, out **output.Printer, raw *bool) *cobra.Command { + serverCmd := &cobra.Command{ + Use: "server", + Short: "Manage cloud servers", + } + + serverListCmd := &cobra.Command{ + Use: "list", + Short: "List servers", + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := (*rySDK).GetRcsList(&rcs.GetRcsListRequest{ + Options: common.StandQueryParameters{Page: 1, PerPage: 50}, + }) + if err != nil { + return err + } + servers := make([]model.Server, len(resp.Data.Records)) + for i, r := range resp.Data.Records { + servers[i] = toServer(r) + } + if *raw { + return (*out).Print(resp) + } + return (*out).Print(servers) + }, + } + + serverGetCmd := &cobra.Command{ + Use: "get ", + Short: "Get server details", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid server id: %s", args[0]) + } + resp, err := (*rySDK).GetRcsDetail(id) + if err != nil { + return err + } + s := toServer(resp.Data.Data) + if *raw { + return (*out).Print(resp) + } + return (*out).Print(s) + }, + } + + serverStartCmd := &cobra.Command{ + Use: "start ", + Short: "Start a server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid server id: %s", args[0]) + } + if _, err := (*rySDK).StartRcs(id); err != nil { + return err + } + fmt.Printf("Server %s started\n", args[0]) + return nil + }, + } + + serverStopCmd := &cobra.Command{ + Use: "stop ", + Short: "Stop a server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid server id: %s", args[0]) + } + if _, err := (*rySDK).StopRcs(id); err != nil { + return err + } + fmt.Printf("Server %s stopped\n", args[0]) + return nil + }, + } + + serverRebootCmd := &cobra.Command{ + Use: "reboot ", + Short: "Reboot a server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid server id: %s", args[0]) + } + if _, err := (*rySDK).RebootRcs(id); err != nil { + return err + } + fmt.Printf("Server %s rebooted\n", args[0]) + return nil + }, + } + + serverReinstallCmd := &cobra.Command{ + Use: "reinstall ", + Short: "Reinstall server OS", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid server id: %s", args[0]) + } + osID, _ := cmd.Flags().GetInt("os-id") + if osID == 0 { + return fmt.Errorf("--os-id flag is required (e.g. --os-id 123)") + } + if _, err := (*rySDK).ReinstallRcs(id, &rcs.ReinstallRcsRequest{OsID: osID}); err != nil { + return err + } + fmt.Printf("Server %s reinstalling with os-id %d\n", args[0], osID) + return nil + }, + } + + serverResetPasswordCmd := &cobra.Command{ + Use: "reset-password ", + Short: "Reset server password", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid server id: %s", args[0]) + } + if _, err := (*rySDK).ResetRcsPassword(id, ""); err != nil { + return err + } + fmt.Printf("Password reset for server %s\n", args[0]) + return nil + }, + } + + serverVNCCmd := &cobra.Command{ + Use: "vnc ", + Short: "Get VNC connection URL", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid server id: %s", args[0]) + } + v, err := (*rySDK).GetRcsVnc(id, "novnc") + if err != nil { + return err + } + url, uerr := common.GetVncConnectURL(v) + if uerr != nil || url == "" { + fmt.Println(v.Data.VNCProxyURL) + } else { + fmt.Println(url) + } + return nil + }, + } + + serverReinstallCmd.Flags().Int("os-id", 0, "OS template ID (required)") + serverReinstallCmd.MarkFlagRequired("os-id") + + serverCmd.AddCommand(serverListCmd) + serverCmd.AddCommand(serverGetCmd) + serverCmd.AddCommand(serverStartCmd) + serverCmd.AddCommand(serverStopCmd) + serverCmd.AddCommand(serverRebootCmd) + serverCmd.AddCommand(serverReinstallCmd) + serverCmd.AddCommand(serverResetPasswordCmd) + serverCmd.AddCommand(serverVNCCmd) + + return serverCmd +} + +func toServer(r rcs.RcsRecord) model.Server { + return model.Server{ + ID: strconv.Itoa(r.ID), + Name: r.HostName, + Status: r.Status, + IP: r.MainIPv4, + CPU: r.CPU, + Memory: r.Memory, + Disk: r.Disk, + OS: r.OsName, + Region: r.Zone, + ExpireAt: time.Unix(int64(r.ExpDate), 0), + } +} diff --git a/cmd/ry/commands/storage/storage.go b/cmd/ry/commands/storage/storage.go new file mode 100644 index 0000000..19c0114 --- /dev/null +++ b/cmd/ry/commands/storage/storage.go @@ -0,0 +1,100 @@ +package storage + +import ( + "fmt" + "strconv" + + "github.com/XingfenD/rainyun_api_go_sdk/apis/common" + "github.com/XingfenD/rainyun_api_go_sdk/apis/ros" + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/internal/model" + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/internal/output" + "github.com/XingfenD/rainyun_api_go_sdk/sdk" + + "github.com/spf13/cobra" +) + +func Cmd(rySDK **sdk.RainyunSDK, out **output.Printer) *cobra.Command { + storageCmd := &cobra.Command{ + Use: "storage", + Short: "Manage object storage", + } + + storageListCmd := &cobra.Command{ + Use: "list", + Short: "List storage instances", + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := (*rySDK).GetRosInstanceList(&ros.GetRosInstanceListRequest{ + Options: common.StandQueryParameters{Page: 1, PerPage: 50}, + }) + if err != nil { + return err + } + instances := make([]model.StorageInstance, len(resp.Data.Records)) + for i, r := range resp.Data.Records { + instances[i] = model.StorageInstance{ + ID: strconv.Itoa(r.ID), + Name: r.Tag, + Status: r.Status, + } + } + return (*out).Print(instances) + }, + } + + bucketCmd := &cobra.Command{ + Use: "bucket ", + Short: "Manage buckets", + Args: cobra.ExactArgs(1), + } + + bucketListCmd := &cobra.Command{ + Use: "list", + Short: "List buckets", + RunE: func(cmd *cobra.Command, args []string) error { + instanceID, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid instance id: %s", args[0]) + } + resp, err := (*rySDK).GetRosBucketListByInstance(instanceID) + if err != nil { + return err + } + buckets := make([]model.Bucket, len(resp.Data.Records)) + for i, b := range resp.Data.Records { + buckets[i] = model.Bucket{ + ID: strconv.Itoa(b.ID), + Name: b.Name, + } + } + return (*out).Print(buckets) + }, + } + + bucketCreateCmd := &cobra.Command{ + Use: "create ", + Short: "Create a bucket", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + instanceID, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid instance id: %s", args[0]) + } + name := args[1] + if _, err := (*rySDK).CreateRosBucket(ros.CreateRosBucketRequest{ + BucketName: name, + InstanceID: instanceID, + }); err != nil { + return err + } + fmt.Printf("Bucket %q created\n", name) + return nil + }, + } + + bucketCmd.AddCommand(bucketListCmd) + bucketCmd.AddCommand(bucketCreateCmd) + storageCmd.AddCommand(storageListCmd) + storageCmd.AddCommand(bucketCmd) + + return storageCmd +} diff --git a/cmd/ry/main.go b/cmd/ry/main.go new file mode 100644 index 0000000..a5a50d9 --- /dev/null +++ b/cmd/ry/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "fmt" + "os" +) + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/ry/root.go b/cmd/ry/root.go new file mode 100644 index 0000000..c6155ca --- /dev/null +++ b/cmd/ry/root.go @@ -0,0 +1,110 @@ +package main + +import ( + "fmt" + "os" + + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/commands/billing" + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/commands/configcmd" + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/commands/domain" + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/commands/server" + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/commands/storage" + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/internal/config" + "github.com/XingfenD/rainyun_api_go_sdk/cmd/ry/internal/output" + "github.com/XingfenD/rainyun_api_go_sdk/sdk" + + "github.com/spf13/cobra" +) + +var ( + cfg *config.Config + cfgPath string + rySDK *sdk.RainyunSDK + out *output.Printer + flagAPIKey string + flagOutput string + flagRaw bool + flagVerbose bool +) + +var rootCmd = &cobra.Command{ + Use: "ry", + Short: "Rainyun CLI — manage cloud resources from your terminal", + Long: `ry is a CLI that lets you manage Rainyun cloud resources from your terminal. + +Use "ry [command] --help" for more information about a command.`, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + 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) + } + + if cmd.Annotations != nil && cmd.Annotations["skipSDK"] == "true" { + return nil + } + + apiKey := cfg.APIKey + if flagAPIKey != "" { + apiKey = flagAPIKey + } + if env := os.Getenv("RY_APIKEY"); env != "" { + apiKey = env + } + if apiKey == "" { + return fmt.Errorf("no api key configured — run 'ry config set apikey '") + } + rySDK = sdk.New(apiKey) + + outputFormat := cfg.Output + if flagOutput != "" { + outputFormat = flagOutput + } + if flagRaw { + outputFormat = "raw" + } + out = output.New(outputFormat, os.Stdout) + if flagVerbose { + fmt.Fprintf(os.Stderr, "[debug] config: %s\n", cfgPath) + fmt.Fprintf(os.Stderr, "[debug] output: %s\n", outputFormat) + } + return nil + }, +} + +func init() { + cfgPath = config.DefaultPath() + rootCmd.PersistentFlags().StringVar(&flagAPIKey, "apikey", "", "Rainyun API key") + rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "", "Output format: table, json, yaml, raw") + rootCmd.PersistentFlags().BoolVar(&flagRaw, "raw", false, "Output raw API response") + rootCmd.PersistentFlags().BoolVarP(&flagVerbose, "verbose", "v", false, "Enable verbose output") + + completionCmd := &cobra.Command{ + Use: "completion [bash|zsh|fish]", + Short: "Generate shell completion script", + Annotations: map[string]string{"skipSDK": "true"}, + 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]) + } + }, + } + rootCmd.AddCommand(completionCmd) + + rootCmd.AddCommand(configcmd.Cmd(&cfg, cfgPath)) + rootCmd.AddCommand(server.Cmd(&rySDK, &out, &flagRaw)) + rootCmd.AddCommand(storage.Cmd(&rySDK, &out)) + rootCmd.AddCommand(domain.Cmd(&rySDK, &out)) + rootCmd.AddCommand(billing.Cmd(&rySDK, &out)) +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 28a5f09..b6918d9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,25 @@ All notable changes to this template should be documented in this file. The format loosely follows Keep a Changelog and can be adapted to the team's habits. 本文档参考了 Keep a Changelog 的思路,也可以根据团队习惯调整。 +## [Unreleased] - 2026-08-13 + +### Added / 新增 + +- 迁移 rainyun-cli 到仓库内 `cmd/ry/`:新增 `main.go`、`root.go`、`commands/{server,domain,storage,billing,configcmd}`、`internal/{config,model,output}`。 +- CLI 直接调用 `sdk.New(apiKey)`,删除 CLI 自带 provider/HTTP 层。 +- 配置简化为单一 `apikey` + `output`(table/json/yaml),落盘到 `~/.config/ry/config.yaml`。 +- 补齐 SDK 4 个缺口端点:`domain list`、`dns list`、`bucket-by-instance`、`expense orders`。 +- 新增 `cmd/ry/internal/config` 与 `cmd/ry/internal/output` 测试(6/6 + 7/7)。 + +### Fixed / 修复 + +- 修复 `output --raw` 输出 bug:新增 raw case,原样透传 SDK Response。 + +### Changed / 变更 + +- 命令 ID 参数统一为 `int`,显示层 model ID 保持 `string`。 +- `server reinstall` flag 从 `--os` 改为 `--os-id int`。 + ## [0.2.0] - 2026-08-05 ### Added / 新增