diff --git a/.golangci.yml b/.golangci.yml index 53bd464..f96ed80 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -14,8 +14,7 @@ linters: linters-settings: govet: - check-shadowing: true - enable-all: true + check-shadowing: false disable: ["fieldalignment"] errcheck: exclude-functions: diff --git a/cmd/main.go b/cmd/main.go index fa55b70..0ebb37e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -2,6 +2,9 @@ package main import ( + "os" + "time" + "github.com/zeabur/cli/internal/cmd/root" "github.com/zeabur/cli/internal/cmdutil" "github.com/zeabur/cli/pkg/auth" @@ -9,7 +12,6 @@ import ( "github.com/zeabur/cli/pkg/log" "github.com/zeabur/cli/pkg/printer" "github.com/zeabur/cli/pkg/prompt" - "time" ) var ( @@ -26,6 +28,10 @@ func main() { panic(err) } + if len(os.Args) <= 1 { + os.Args = append([]string{os.Args[0], "deploy"}, os.Args[1:]...) + } + // log errors if err := rootCmd.Execute(); err != nil { // when some errors occur(such as args dis-match), the log may not be initialized diff --git a/internal/cmd/deploy/deploy.go b/internal/cmd/deploy/deploy.go index fc8cb12..82eb960 100644 --- a/internal/cmd/deploy/deploy.go +++ b/internal/cmd/deploy/deploy.go @@ -2,12 +2,13 @@ package deploy import ( "context" + "fmt" "github.com/briandowns/spinner" "github.com/spf13/cobra" "github.com/zeabur/cli/internal/cmdutil" "github.com/zeabur/cli/internal/util" - "golang.org/x/sync/errgroup" + "github.com/zeabur/cli/pkg/zcontext" ) type Options struct { @@ -19,7 +20,7 @@ func NewCmdDeploy(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "deploy", - Short: "Deploy a local Git Service", + Short: "Deploy local project to Zeabur with one command", PreRunE: util.NeedProjectContextWhenNonInteractive(f), RunE: func(cmd *cobra.Command, args []string) error { return runDeploy(f, opts) @@ -32,97 +33,80 @@ func NewCmdDeploy(f *cmdutil.Factory) *cobra.Command { } func runDeploy(f *cmdutil.Factory, opts *Options) error { - if f.Interactive { - return runDeployInteractive(f, opts) - } else { - return runDeployNonInteractive(f, opts) - } -} - -func runDeployNonInteractive(f *cmdutil.Factory, opts *Options) error { - repoOwner, repoName, err := f.ApiClient.GetRepoInfo() + s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, + spinner.WithColor(cmdutil.SpinnerColor), + spinner.WithSuffix(" Fetching projects ..."), + ) + s.Start() + projects, err := f.ApiClient.ListAllProjects(context.Background()) if err != nil { return err } + s.Stop() + + if len(projects) == 0 { + confirm, err := f.Prompter.Confirm("No projects found, would you like to create one now?", true) + if err != nil { + return err + } + if confirm { + project, err := f.ApiClient.CreateProject(context.Background(), "default", nil) + if err != nil { + f.Log.Error("Failed to create project: ", err) + return err + } + f.Log.Infof("Project %s created", project.Name) + f.Config.GetContext().SetProject(zcontext.NewBasicInfo(project.ID, project.Name)) + + return nil + } + } + + f.Log.Info("Select one project to deploy your service.") - repoID, err := f.ApiClient.GetRepoID(repoOwner, repoName) + _, project, err := f.Selector.SelectProject() if err != nil { return err } - f.Log.Debugf("repoID: %d", repoID) + f.Config.GetContext().SetProject(zcontext.NewBasicInfo(project.ID, project.Name)) - //TODO: Deploy Local Git Service NonInteractive - - return nil -} + _, environment, err := f.Selector.SelectEnvironment(project.ID) + if err != nil { + return err + } -func runDeployInteractive(f *cmdutil.Factory, opts *Options) error { - s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, + s = spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, spinner.WithColor(cmdutil.SpinnerColor), - spinner.WithSuffix(" Fetching repository information..."), + spinner.WithSuffix(" Creating new service ..."), ) s.Start() - var repoOwner string - var repoName string - var err error - - repoOwner, repoName, err = f.ApiClient.GetRepoInfo() + bytes, fileName, err := util.PackZip() if err != nil { return err } - // Use repo name as default service name - if opts.name == "" { - opts.name = repoName - } - - var eg errgroup.Group - var repoID int - var branches []string - - eg.Go(func() error { - repoID, err = f.ApiClient.GetRepoID(repoOwner, repoName) - return err - }) - - eg.Go(func() error { - branches, err = f.ApiClient.GetRepoBranches(context.Background(), repoOwner, repoName) - return err - }) - - if err = eg.Wait(); err != nil { + service, err := f.ApiClient.CreateEmptyService(context.Background(), project.ID, fileName) + if err != nil { return err } s.Stop() - // If repo has only one branch, use it as default branch - // Otherwise, ask user to select a branch - var branch string - - if len(branches) == 1 { - branch = branches[0] - } else { - _, err = f.Prompter.Select("Select branch", branch, branches) - if err != nil { - return err - } - } - s = spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, spinner.WithColor(cmdutil.SpinnerColor), - spinner.WithSuffix(" Creating service..."), - spinner.WithFinalMSG(cmdutil.SuccessIcon+" Service created 🥂\n"), + spinner.WithSuffix(" Uploading codes to Zeabur ..."), ) s.Start() - _, err = f.ApiClient.CreateService(context.Background(), f.Config.GetContext().GetProject().GetID(), opts.name, repoID, branch) + _, err = f.ApiClient.UploadZipToService(context.Background(), project.ID, service.ID, environment.ID, bytes) if err != nil { return err } s.Stop() + fmt.Println("Service created successfully, you can access it at: ", "https://dash.zeabur.com/projects/"+project.ID+"/services/"+service.ID+"?environmentID="+environment.ID) + return nil } diff --git a/internal/cmd/project/create/create.go b/internal/cmd/project/create/create.go index 25f78ac..067b09d 100644 --- a/internal/cmd/project/create/create.go +++ b/internal/cmd/project/create/create.go @@ -3,6 +3,7 @@ package create import ( "context" "fmt" + "github.com/zeabur/cli/pkg/zcontext" "github.com/briandowns/spinner" @@ -55,6 +56,8 @@ func runCreateInteractive(f *cmdutil.Factory, opts *Options) error { if err != nil { return err } + + regions = regions[1:] s.Stop() regionIDs := make([]string, 0, len(regions)) diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index af5528b..1447285 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -2,12 +2,12 @@ package root import ( - "errors" "fmt" "time" "github.com/zeabur/cli/pkg/fill" "github.com/zeabur/cli/pkg/selector" + "golang.org/x/oauth2" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" @@ -31,7 +31,7 @@ import ( // NewCmdRoot creates the root command func NewCmdRoot(f *cmdutil.Factory, version, commit, date string) (*cobra.Command, error) { cmd := &cobra.Command{ - Use: "zeabur [flags]", + Use: "zeabur", Short: "Zeabur CLI", Long: `Zeabur CLI is the official command line tool for Zeabur.`, Example: heredoc.Doc(` @@ -49,9 +49,23 @@ func NewCmdRoot(f *cmdutil.Factory, version, commit, date string) (*cobra.Comman // require that the user is authenticated before running most commands if cmdutil.IsAuthCheckEnabled(cmd) { - f.Log.Debug("Checking authentication") + // do not return error, guide user to login instead if !f.LoggedIn() { - return errors.New("not authenticated") + f.Log.Info("A browser window will be opened for you to login, please confirm") + + var ( + tokenString string + token *oauth2.Token + err error + ) + + token, err = f.AuthClient.Login() + if err != nil { + return fmt.Errorf("failed to login: %w", err) + } + tokenString = token.AccessToken + f.Config.SetToken(token) + f.Config.SetTokenString(tokenString) } // set up the client f.ApiClient = api.New(f.Config.GetTokenString()) diff --git a/internal/util/pack.go b/internal/util/pack.go new file mode 100644 index 0000000..03aa669 --- /dev/null +++ b/internal/util/pack.go @@ -0,0 +1,113 @@ +package util + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +func PackZip() ([]byte, string, error) { + zipBytes, err := wrapNodeFunction(os.Getenv("PWD"), map[string]string{}) + + // turn pwd to a valid file name + fileName := filepath.Base(os.Getenv("PWD")) + + if err != nil { + return nil, "", fmt.Errorf("wrap node function: %w", err) + } + + return zipBytes, fileName, nil +} + +func wrapNodeFunction(baseFolder string, envVars map[string]string) ([]byte, error) { + buf := new(bytes.Buffer) + w := zip.NewWriter(buf) + + err := filepath.Walk(baseFolder, func(path string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("walking to %s: %w", path, err) + } + + if info.IsDir() { + return nil + } + + // This will ensure only the content inside baseFolder is included at the root of the ZIP. + relativePath, err := filepath.Rel(baseFolder, path) + if err != nil { + return fmt.Errorf("getting relative path: %w", err) + } + + lstat, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("lstat: %w", err) + } + + if lstat.Mode()&os.ModeSymlink == os.ModeSymlink { + + zipFile, err := w.Create(relativePath + ".link") + if err != nil { + return fmt.Errorf("creating zip file: %w", err) + } + + target, err := os.Readlink(path) + if err != nil { + return fmt.Errorf("read symlink: %w", err) + } + + _, err = zipFile.Write([]byte(target)) + if err != nil { + return fmt.Errorf("writing zip file: %w", err) + } + + } else { + + zipFile, err := w.Create(relativePath) + if err != nil { + return fmt.Errorf("creating zip file: %w", err) + } + + fileContent, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("reading file: %w", err) + } + + _, err = zipFile.Write(fileContent) + if err != nil { + return fmt.Errorf("writing zip file: %w", err) + } + + } + + zipFile, err := w.Create(".zeabur-env.json") + if err != nil { + return fmt.Errorf("creating zip file: %w", err) + } + + envJsonStr, err := json.Marshal(envVars) + if err != nil { + return fmt.Errorf("marshaling env vars: %w", err) + } + + _, err = zipFile.Write(envJsonStr) + if err != nil { + return fmt.Errorf("writing zip file: %w", err) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("walking function directory: %w", err) + } + + err = w.Close() + if err != nil { + return nil, fmt.Errorf("closing zip writer: %w", err) + } + + return buf.Bytes(), nil +} diff --git a/pkg/api/deploy.go b/pkg/api/deploy.go new file mode 100644 index 0000000..778f64e --- /dev/null +++ b/pkg/api/deploy.go @@ -0,0 +1 @@ +package api diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 96c6be6..a12433c 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -56,6 +56,8 @@ type ( ExposeService(ctx context.Context, id string, environmentID string, projectID string, name string) (*model.TempTCPPort, error) CreateServiceFromMarketplace(ctx context.Context, projectID string, name string, itemCode string) (*model.Service, error) CreateService(ctx context.Context, projectID string, name string, repoID int, branchName string) (*model.Service, error) + CreateEmptyService(ctx context.Context, projectID string, name string) (*model.Service, error) + UploadZipToService(ctx context.Context, projectID string, serviceID string, environmentID string, zipBytes []byte) (*model.Service, error) } DeploymentAPI interface { diff --git a/pkg/api/service.go b/pkg/api/service.go index 62d9d70..3a1ac29 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -1,10 +1,16 @@ package api import ( + "bytes" "context" "errors" + "fmt" + "io" + "mime/multipart" + "net/http" "time" + "github.com/spf13/viper" "github.com/zeabur/cli/pkg/model" ) @@ -305,3 +311,75 @@ func (c *client) CreateService(ctx context.Context, projectID string, name strin return &mutation.CreateService, nil } + +func (c *client) CreateEmptyService(ctx context.Context, projectID string, name string) (*model.Service, error) { + var mutation struct { + CreateService model.Service `graphql:"createService(projectID: $projectID, template: $template, name: $name)"` + } + + err := c.Mutate(ctx, &mutation, V{ + "projectID": ObjectID(projectID), + "template": ServiceTemplate("GIT"), + "name": name, + }) + + if err != nil { + return nil, err + } + + return &mutation.CreateService, nil +} + +func (c *client) UploadZipToService(ctx context.Context, projectID string, serviceID string, environmentID string, zipBytes []byte) (*model.Service, error) { + url := "https://gateway.zeabur.com/projects/" + projectID + "/services/" + serviceID + "/deploy" + + method := "POST" + + var requestBody bytes.Buffer + multipartWriter := multipart.NewWriter(&requestBody) + + err := multipartWriter.WriteField("environment", environmentID) + if err != nil { + fmt.Println(err) + return nil, err + } + + fileWriter, err := multipartWriter.CreateFormFile("code", "zeabur.zip") + if err != nil { + fmt.Println(err) + return nil, err + } + _, err = io.Copy(fileWriter, bytes.NewReader(zipBytes)) + if err != nil { + fmt.Println(err) + return nil, err + } + + err = multipartWriter.Close() + if err != nil { + fmt.Println(err) + return nil, err + } + + client := &http.Client{} + + req, err := http.NewRequest(method, url, &requestBody) + if err != nil { + fmt.Println(err) + return nil, err + } + + token := viper.GetString("token") + + req.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + req.Header.Set("Cookie", "token="+token) + + res, err := client.Do(req) + if err != nil { + fmt.Println(err) + return nil, err + } + defer res.Body.Close() + + return nil, nil +} diff --git a/pkg/selector/selector.go b/pkg/selector/selector.go index 7665ba9..470874d 100644 --- a/pkg/selector/selector.go +++ b/pkg/selector/selector.go @@ -64,10 +64,40 @@ func (s *selector) SelectProject() (zcontext.BasicInfo, *model.Project, error) { for i, project := range projects { projectsName[i] = project.Name } + projectsName = append(projectsName, "Create a new project") index, err := s.prompter.Select("Select project", projectsName[0], projectsName) if err != nil { return nil, nil, fmt.Errorf("select project failed: %w", err) } + + if index == len(projects) { + + regions, err := s.client.GetRegions(context.Background()) + if err != nil { + return nil, nil, fmt.Errorf("get regions failed: %w", err) + } + regions = regions[1:] + + regionIDs := make([]string, 0, len(regions)) + for _, region := range regions { + regionIDs = append(regionIDs, region.ID) + } + + projectRegionIndex, err := s.prompter.Select("Select project region", "", regionIDs) + if err != nil { + return nil, nil, fmt.Errorf("select project region failed: %w", err) + } + + projectRegion := regions[projectRegionIndex].ID + + project, err := s.client.CreateProject(context.Background(), projectRegion, nil) + if err != nil { + return nil, nil, fmt.Errorf("create project failed: %w", err) + } + + return zcontext.NewBasicInfo(project.ID, project.Name), project, nil + } + project := projects[index] return zcontext.NewBasicInfo(project.ID, project.Name), project, nil