-
Notifications
You must be signed in to change notification settings - Fork 37
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(cli): project template scaffold cli command (#533)
- Loading branch information
1 parent
005d888
commit c4cc1ff
Showing
5 changed files
with
376 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
package project | ||
|
||
import ( | ||
"errors" | ||
"fmt" | ||
"os" | ||
|
||
"github.com/spf13/cobra" | ||
) | ||
|
||
func CreateProjectCmd() *cobra.Command { | ||
var ( | ||
name string | ||
template string | ||
) | ||
|
||
exampleCMD := ` | ||
resonate project create --name my-app --template py | ||
resonate project create -n my-app -t 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(template, name); err != nil { | ||
return err | ||
} | ||
|
||
if err := scaffold(template, 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(&template, "template", "t", "", "name of the template, run 'resonate project list' to view available templates") | ||
|
||
_ = cmd.MarkFlagRequired("name") | ||
_ = cmd.MarkFlagRequired("template") | ||
|
||
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters