diff --git a/internal/cmd/project/clone/clone.go b/internal/cmd/project/clone/clone.go new file mode 100644 index 0000000..b8d3337 --- /dev/null +++ b/internal/cmd/project/clone/clone.go @@ -0,0 +1,226 @@ +package clone + +import ( + "context" + "fmt" + "time" + + "github.com/briandowns/spinner" + "github.com/spf13/cobra" + + "github.com/zeabur/cli/internal/cmdutil" + "github.com/zeabur/cli/internal/util" + "github.com/zeabur/cli/pkg/model" +) + +type Options struct { + ProjectID string + ProjectName string + EnvironmentID string + Region string + Suspend bool +} + +func NewCmdClone(f *cmdutil.Factory) *cobra.Command { + opts := &Options{} + + cmd := &cobra.Command{ + Use: "clone", + Short: "Clone a project to another region", + RunE: func(cmd *cobra.Command, args []string) error { + return runClone(f, opts) + }, + } + + util.AddProjectParam(cmd, &opts.ProjectID, &opts.ProjectName) + cmd.Flags().StringVar(&opts.EnvironmentID, "env-id", "", "Source environment ID (auto-resolved if omitted)") + cmd.Flags().StringVarP(&opts.Region, "region", "r", "", "Target region") + cmd.Flags().BoolVar(&opts.Suspend, "suspend", false, "Suspend old project after cloning") + + return cmd +} + +func runClone(f *cmdutil.Factory, opts *Options) error { + if err := paramCheck(opts); err == nil { + return runCloneNonInteractive(f, opts) + } + + if f.Interactive { + return runCloneInteractive(f, opts) + } + + return runCloneNonInteractive(f, opts) +} + +func paramCheck(opts *Options) error { + if opts.ProjectID == "" && opts.ProjectName == "" { + return fmt.Errorf("please specify project with --id or --name") + } + if opts.Region == "" { + return fmt.Errorf("please specify target region with --region") + } + return nil +} + +func runCloneInteractive(f *cmdutil.Factory, opts *Options) error { + // Select project if not provided + if opts.ProjectID == "" && opts.ProjectName == "" { + projectInfo, _, err := f.Selector.SelectProject() + if err != nil { + return fmt.Errorf("select project: %w", err) + } + opts.ProjectID = projectInfo.GetID() + } else if opts.ProjectID == "" && opts.ProjectName != "" { + project, err := util.GetProjectByName(f.Config, f.ApiClient, opts.ProjectName) + if err != nil { + return err + } + opts.ProjectID = project.ID + } + + // Resolve environment ID if not provided + if opts.EnvironmentID == "" { + envID, err := util.ResolveEnvironmentID(f.ApiClient, opts.ProjectID) + if err != nil { + return err + } + opts.EnvironmentID = envID + } + + // Select region if not provided + if opts.Region == "" { + s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, + spinner.WithColor(cmdutil.SpinnerColor), + spinner.WithSuffix(" Fetching available regions..."), + ) + s.Start() + regions, err := f.ApiClient.GetGenericRegions(context.Background()) + if err != nil { + s.Stop() + return err + } + s.Stop() + + availableRegions := make([]model.GenericRegion, 0, len(regions)) + regionOptions := make([]string, 0, len(regions)) + for _, region := range regions { + if region.IsAvailable() { + availableRegions = append(availableRegions, region) + regionOptions = append(regionOptions, region.String()) + } + } + + if len(availableRegions) == 0 { + return fmt.Errorf("no available regions to clone to") + } + + regionIndex, err := f.Prompter.Select("Select target region", "", regionOptions) + if err != nil { + return err + } + + opts.Region = availableRegions[regionIndex].GetID() + } + + return doClone(f, opts) +} + +func runCloneNonInteractive(f *cmdutil.Factory, opts *Options) error { + if err := paramCheck(opts); err != nil { + return err + } + + // Resolve project ID from name if needed + if opts.ProjectID == "" && opts.ProjectName != "" { + project, err := util.GetProjectByName(f.Config, f.ApiClient, opts.ProjectName) + if err != nil { + return err + } + opts.ProjectID = project.ID + } + + // Resolve environment ID if not provided + if opts.EnvironmentID == "" { + envID, err := util.ResolveEnvironmentID(f.ApiClient, opts.ProjectID) + if err != nil { + return err + } + opts.EnvironmentID = envID + } + + return doClone(f, opts) +} + +func doClone(f *cmdutil.Factory, opts *Options) error { + s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, + spinner.WithColor(cmdutil.SpinnerColor), + spinner.WithSuffix(" Cloning project..."), + ) + s.Start() + + result, err := f.ApiClient.CloneProject( + context.Background(), + opts.ProjectID, + opts.EnvironmentID, + opts.Region, + opts.Suspend, + ) + if err != nil { + s.Stop() + return fmt.Errorf("clone project failed: %w", err) + } + + s.Stop() + f.Log.Infof("Clone started, new project ID: %s", result.NewProjectID) + + // Poll for status with 10-minute timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + + seenEvents := 0 + for { + select { + case <-ctx.Done(): + return fmt.Errorf("clone timed out after 10 minutes (new project ID: %s)", result.NewProjectID) + case <-ticker.C: + } + + status, err := f.ApiClient.CloneProjectStatus(ctx, result.NewProjectID) + if err != nil { + return fmt.Errorf("query clone status failed: %w", err) + } + + // Print new events and check for terminal states + completed := false + var failMsg string + for i := seenEvents; i < len(status.Events); i++ { + ev := status.Events[i] + f.Log.Infof("[%s] %s", ev.Type, ev.Message) + if ev.Type == "CloneProjectCompleted" { + completed = true + } + if ev.Type == "CloneProjectFailed" { + failMsg = ev.Message + } + } + seenEvents = len(status.Events) + + if failMsg != "" { + return fmt.Errorf("clone failed: %s", failMsg) + } + + if status.Error != nil && *status.Error != "" { + return fmt.Errorf("clone failed: %s", *status.Error) + } + + if completed { + f.Log.Infof("Project cloned successfully!") + f.Log.Infof("New project ID: %s", result.NewProjectID) + f.Log.Infof("Dashboard: https://dash.zeabur.com/projects/%s", result.NewProjectID) + return nil + } + } +} diff --git a/internal/cmd/project/project.go b/internal/cmd/project/project.go index 4a281da..0541adf 100644 --- a/internal/cmd/project/project.go +++ b/internal/cmd/project/project.go @@ -6,6 +6,7 @@ import ( "github.com/zeabur/cli/internal/cmdutil" + projectCloneCmd "github.com/zeabur/cli/internal/cmd/project/clone" projectCreateCmd "github.com/zeabur/cli/internal/cmd/project/create" projectDeleteCmd "github.com/zeabur/cli/internal/cmd/project/delete" projectExportCmd "github.com/zeabur/cli/internal/cmd/project/export" @@ -23,6 +24,7 @@ func NewCmdProject(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(projectGetCmd.NewCmdGet(f)) cmd.AddCommand(projectListCmd.NewCmdList(f)) cmd.AddCommand(projectCreateCmd.NewCmdCreate(f)) + cmd.AddCommand(projectCloneCmd.NewCmdClone(f)) cmd.AddCommand(projectDeleteCmd.NewCmdDelete(f)) cmd.AddCommand(projectExportCmd.NewCmdExport(f)) diff --git a/pkg/api/interface.go b/pkg/api/interface.go index eacff24..a4f1683 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -37,6 +37,9 @@ type ( GetRegions(ctx context.Context) ([]model.Region, error) GetServers(ctx context.Context) ([]model.Server, error) GetGenericRegions(ctx context.Context) ([]model.GenericRegion, error) + + CloneProject(ctx context.Context, projectID, environmentID, targetRegion string, suspendOldProject bool) (*model.CloneProjectResult, error) + CloneProjectStatus(ctx context.Context, newProjectID string) (*model.CloneProjectStatusResult, error) } EnvironmentAPI interface { diff --git a/pkg/api/project.go b/pkg/api/project.go index 659bfd4..34c85f4 100644 --- a/pkg/api/project.go +++ b/pkg/api/project.go @@ -189,3 +189,38 @@ func (c *client) GetGenericRegions(ctx context.Context) ([]model.GenericRegion, return genericRegions, nil } + +// CloneProject clones a project to a target region. +func (c *client) CloneProject(ctx context.Context, projectID, environmentID, targetRegion string, suspendOldProject bool) (*model.CloneProjectResult, error) { + var mutation struct { + CloneProject model.CloneProjectResult `graphql:"cloneProject(projectId: $projectId, environmentId: $environmentId, targetRegion: $targetRegion, suspendOldProject: $suspendOldProject, preserveGroupsOrder: true)"` + } + + err := c.Mutate(ctx, &mutation, V{ + "projectId": ObjectID(projectID), + "environmentId": ObjectID(environmentID), + "targetRegion": targetRegion, + "suspendOldProject": suspendOldProject, + }) + if err != nil { + return nil, err + } + + return &mutation.CloneProject, nil +} + +// CloneProjectStatus queries the status of a project clone operation. +func (c *client) CloneProjectStatus(ctx context.Context, newProjectID string) (*model.CloneProjectStatusResult, error) { + var query struct { + CloneProjectStatus model.CloneProjectStatusResult `graphql:"cloneProjectStatus(newProjectId: $newProjectId)"` + } + + err := c.Query(ctx, &query, V{ + "newProjectId": ObjectID(newProjectID), + }) + if err != nil { + return nil, err + } + + return &query.CloneProjectStatus, nil +} diff --git a/pkg/model/clone.go b/pkg/model/clone.go new file mode 100644 index 0000000..4f533c6 --- /dev/null +++ b/pkg/model/clone.go @@ -0,0 +1,22 @@ +package model + +import "time" + +// CloneProjectResult is the result of the cloneProject mutation. +type CloneProjectResult struct { + NewProjectID string `graphql:"newProjectId"` +} + +// CloneProjectEvent is a single event emitted during project cloning. +type CloneProjectEvent struct { + Type string `graphql:"type"` + CreatedAt time.Time `graphql:"createdAt"` + Message string `graphql:"message"` +} + +// CloneProjectStatusResult is the result of the cloneProjectStatus query. +type CloneProjectStatusResult struct { + NewProjectID *string `graphql:"newProjectId"` + Events []CloneProjectEvent `graphql:"events"` + Error *string `graphql:"error"` +}