diff --git a/cmd/project/create.go b/cmd/project/create.go new file mode 100644 index 00000000..41c0291a --- /dev/null +++ b/cmd/project/create.go @@ -0,0 +1,82 @@ +package project + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func CreateProjectCmd() *cobra.Command { + var ( + name string + project string + ) + + exampleCMD := ` + resonate project create --name my-app --project py + resonate project create -n my-app -p py + ` + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new resonate application node project", + Example: exampleCMD, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validate(project, name); err != nil { + return err + } + + if err := scaffold(project, name); err != nil { + return err + } + + fmt.Printf("\nproject successfully created in folder %s\n", name) + return nil + }, + } + + cmd.Flags().StringVarP(&name, "name", "n", "", "name of the project") + cmd.Flags().StringVarP(&project, "project", "p", "", "name of the project, run 'resonate project list' to view available projects") + + _ = cmd.MarkFlagRequired("name") + _ = cmd.MarkFlagRequired("project") + + return cmd +} + +func validate(project, name string) error { + if name == "" { + return errors.New("a folder name is required") + } + + if project == "" { + return errors.New("project name is required") + } + + err := checkFolderExists(name) + if err != nil { + return err + } + + return nil +} + +func checkFolderExists(name string) error { + info, err := os.Stat(name) + + if err != nil { + if os.IsNotExist(err) { + return nil + } + + return err + } + + if info.IsDir() { + return fmt.Errorf("a folder named '%s' already exists", name) + } + + return nil +} diff --git a/cmd/project/list.go b/cmd/project/list.go new file mode 100644 index 00000000..1025b654 --- /dev/null +++ b/cmd/project/list.go @@ -0,0 +1,32 @@ +package project + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func ListProjectCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List the available application node projects", + Example: "resonate project list", + RunE: func(cmd *cobra.Command, args []string) error { + templates, err := GetProjects() + if err != nil { + return err + } + + display(templates) + return nil + }, + } + + return cmd +} + +func display(templates Projects) { + for name, t := range templates { + fmt.Printf("\n%s\n\t%s\n", name, t.Desc) + } +} diff --git a/cmd/project/project.go b/cmd/project/project.go new file mode 100644 index 00000000..f50c1c15 --- /dev/null +++ b/cmd/project/project.go @@ -0,0 +1,80 @@ +package project + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/spf13/cobra" +) + +type ( + Project struct { + Href string `json:"href"` + Desc string `json:"desc"` + } + + Projects map[string]Project +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "project", + Aliases: []string{"project"}, + Short: "Resonate application node projects", + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, + } + + // Add subcommands + cmd.AddCommand(ListProjectCmd()) // list available projects + cmd.AddCommand(CreateProjectCmd()) // create a project + + return cmd +} + +func GetProjects() (Projects, error) { + const url = "https://raw.githubusercontent.com/resonatehq/templates/refs/heads/main/templates.json" + + res, err := http.Get(url) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if err := checkstatus(res); err != nil { + return nil, err + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + projects, err := parse(body) + if err != nil { + return nil, err + } + + return projects, nil +} + +func parse(body []byte) (Projects, error) { + projects := Projects{} + if err := json.Unmarshal(body, &projects); err != nil { + return nil, err + } + + return projects, nil +} + +func GetProjectKeys(projects Projects) []string { + keys := make([]string, 0) + + for name := range projects { + keys = append(keys, name) + } + + return keys +} diff --git a/cmd/project/scaffold.go b/cmd/project/scaffold.go new file mode 100644 index 00000000..eee0b532 --- /dev/null +++ b/cmd/project/scaffold.go @@ -0,0 +1,180 @@ +package project + +import ( + "archive/zip" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +// scaffold orchestrates the setup of the project from source to destination. +func scaffold(tmpl, name string) error { + projects, err := GetProjects() + if err != nil { + return err + } + + // find the project based on project (key) + project, exists := projects[tmpl] + if !exists { + return fmt.Errorf("unknown project '%s', available projects are: %v", tmpl, GetProjectKeys(projects)) + } + + if err := setup(project.Href, name); err != nil { + return err + } + + return nil +} + +// setup downloads and unzips the project to the destination folder. +func setup(url, dest string) error { + tmp := dest + ".zip" + if err := download(url, tmp); err != nil { + return err + } + defer os.Remove(tmp) + + if err := unzip(tmp, dest); err != nil { + return err + } + + return nil +} + +// download fetches a file from the URL and stores it locally. +func download(url, file string) error { + res, err := http.Get(url) + if err != nil { + return err + } + defer res.Body.Close() + + if err := checkstatus(res); err != nil { + return err + } + + out, err := os.Create(file) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, res.Body) + return err +} + +// checkstatus verifies the HTTP response for a successful status. +func checkstatus(res *http.Response) error { + if res.StatusCode != http.StatusOK { + return fmt.Errorf("failed to fetch project: %s", res.Status) + } + + return nil +} + +// unzip extracts the contents of a zip file to the destination folder. +func unzip(src, dest string) error { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer r.Close() + + root, err := extract(r, dest) + if err != nil { + return err + } + + if root != "" { + path := filepath.Join(dest, root) + return restructure(path, dest) + } + + return nil +} + +// extract unzips the contents and returns the root folder name. +func extract(r *zip.ReadCloser, dest string) (string, error) { + var root string + for _, f := range r.File { + rel := strings.TrimPrefix(f.Name, root) + file := filepath.Join(dest, rel) + + if root == "" { + root = base(f.Name) + } + + if f.FileInfo().IsDir() { + if err := os.MkdirAll(file, os.ModePerm); err != nil { + return "", err + } + continue + } + + if err := os.MkdirAll(filepath.Dir(file), os.ModePerm); err != nil { + return "", err + } + + if err := write(f, file); err != nil { + return "", err + } + } + + return root, nil +} + +// base returns the root directory name from a path. +func base(name string) string { + parts := strings.Split(name, "/") + if len(parts) > 0 { + return parts[0] + } + + return "" +} + +// write writes a file from a zip entry to the destination path. +func write(f *zip.File, path string) error { + out, err := os.Create(path) + if err != nil { + return err + } + defer out.Close() + + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + _, err = io.Copy(out, rc) + return err +} + +// restructure moves extracted contents from a root directory to destination. +func restructure(src, dest string) error { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + for _, entry := range entries { + if err := move(src, dest, entry); err != nil { + return err + } + } + + return os.Remove(src) +} + +// move moves a file or directory from the source to the destination +func move(src, dest string, entry os.DirEntry) error { + old := filepath.Join(src, entry.Name()) + new := filepath.Join(dest, entry.Name()) + + return os.Rename(old, new) +} diff --git a/cmd/root.go b/cmd/root.go index c21b701d..69075b1d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,6 +7,7 @@ import ( "github.com/resonatehq/resonate/cmd/callbacks" "github.com/resonatehq/resonate/cmd/dst" + "github.com/resonatehq/resonate/cmd/project" "github.com/resonatehq/resonate/cmd/promises" "github.com/resonatehq/resonate/cmd/quickstart" "github.com/resonatehq/resonate/cmd/schedules" @@ -42,6 +43,7 @@ func init() { rootCmd.AddCommand(quickstart.NewCmd()) rootCmd.AddCommand(tasks.NewCmd()) rootCmd.AddCommand(callbacks.NewCmd()) + rootCmd.AddCommand(project.NewCmd()) rootCmd.AddCommand(subscriptions.NewCmd()) // Set default output