diff --git a/go.mod b/go.mod index 7b03e1c..4dfbddb 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/golang/snappy v0.0.4 // indirect github.com/klauspost/compress v1.16.7 // indirect github.com/montanaflynn/stats v0.7.1 // indirect + github.com/spf13/afero v1.15.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect @@ -35,5 +36,5 @@ require ( golang.org/x/sync v0.17.0 golang.org/x/sys v0.23.0 // indirect golang.org/x/term v0.23.0 // indirect - golang.org/x/text v0.17.0 // indirect + golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 7a93120..ccaaac6 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -98,6 +100,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/cli/root.go b/internal/cli/root.go index 92c2d9d..d5abf16 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -14,7 +14,6 @@ import ( "github.com/b-jonathan/taco/internal/logx" "github.com/b-jonathan/taco/internal/prompt" "github.com/b-jonathan/taco/internal/stacks" - github "github.com/google/go-github/v55/github" "github.com/joho/godotenv" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" @@ -77,52 +76,6 @@ func gatherInitParams(cmd *cobra.Command, args []string) (InitParams, error) { params.Name = name } - if f := cmd.Flags().Lookup("private"); f != nil && f.Changed { - b, _ := strconv.ParseBool(f.Value.String()) - params.Private = b - } else { - b, err := prompt.CreateSurveyConfirm("Make repository private?", prompt.AskOpts{ - Default: false, - }) - if err != nil && prompt.IsTTY() { - return params, err - } - if err == nil { - params.Private = b - } - } - - if v, _ := cmd.Flags().GetString("remote"); v != "" { - params.Remote = v - } else { - if prompt.IsTTY() { - r, err := prompt.CreateSurveySelect("Remote URL type", []string{"ssh", "https"}, prompt.AskOpts{ - Default: "ssh", - PageSize: 2, - }) - if err != nil { - return params, err - } - params.Remote = r - } - } - - if v, _ := cmd.Flags().GetString("description"); v != "" { - params.Description = v - } else { - // optional field; allow empty in non-TTY - if prompt.IsTTY() { - desc, err := prompt.CreateSurveyInput("Repository description", prompt.AskOpts{ - Default: "", - Help: "you can leave this empty", - }) - if err != nil { - return params, err - } - params.Description = desc - } - } - if f := cmd.Flags().Lookup("github"); f != nil && f.Changed { b, _ := strconv.ParseBool(f.Value.String()) params.UseGitHub = b @@ -142,6 +95,54 @@ func gatherInitParams(cmd *cobra.Command, args []string) (InitParams, error) { } } + if params.UseGitHub { + if f := cmd.Flags().Lookup("private"); f != nil && f.Changed { + b, _ := strconv.ParseBool(f.Value.String()) + params.Private = b + } else { + b, err := prompt.CreateSurveyConfirm("Make repository private?", prompt.AskOpts{ + Default: false, + }) + if err != nil && prompt.IsTTY() { + return params, err + } + if err == nil { + params.Private = b + } + } + + if v, _ := cmd.Flags().GetString("remote"); v != "" { + params.Remote = v + } else { + if prompt.IsTTY() { + r, err := prompt.CreateSurveySelect("Remote URL type", []string{"ssh", "https"}, prompt.AskOpts{ + Default: "ssh", + PageSize: 2, + }) + if err != nil { + return params, err + } + params.Remote = r + } + } + + if v, _ := cmd.Flags().GetString("description"); v != "" { + params.Description = v + } else { + // optional field; allow empty in non-TTY + if prompt.IsTTY() { + desc, err := prompt.CreateSurveyInput("Repository description", prompt.AskOpts{ + Default: "", + Help: "you can leave this empty", + }) + if err != nil { + return params, err + } + params.Description = desc + } + } + } + return params, nil } @@ -251,65 +252,33 @@ func initCmd() *cobra.Command { // This is additional templates if params.UseGitHub { - fmt.Println("Starting gh command") - // client, err := gh.FromContext(cmd.Context()) - // if err != nil { - // return err - // } - client, err := gh.EnsureClient(cmd.Context()) - if err != nil { - return err - } - cmd.SetContext(gh.WithContext(cmd.Context(), client)) - fmt.Println("GitHub client initialized") - ghCtx := context.Background() - ghCtx, cancel := context.WithTimeout(ghCtx, 100*time.Second) - defer cancel() - - newRepo := &github.Repository{ - Name: github.String(params.Name), - Private: github.Bool(params.Private), - Description: github.String(params.Description), - } + fmt.Println("Creating GitHub repository...") - repo, _, err := client.Repositories.Create(ghCtx, "", newRepo) + repo, err := gh.CreateRepo(cmd.Context(), gh.CreateRepoOptions{ + Name: params.Name, + Private: params.Private, + Description: params.Description, + }) if err != nil { - return fmt.Errorf("create repo: %w", err) + return err } fmt.Println("Created:", repo.GetHTMLURL()) + remoteURL := repo.GetSSHURL() if params.Remote == "https" { remoteURL = repo.GetCloneURL() } - fmt.Println("Committing and Pushing to Github...") - if err := git.InitAndPush(ghCtx, projectRoot, remoteURL, "initial-commit"); err != nil { - owner := "" - if repo.GetOwner() != nil { - owner = repo.GetOwner().GetLogin() - } - - // Fallback - if owner == "" { - parts := strings.Split(repo.GetFullName(), "/") - if len(parts) == 2 { - owner = parts[0] - } - } - - if owner != "" { - if _, delErr := client.Repositories.Delete(ghCtx, owner, repo.GetName()); delErr != nil { - logx.Warnf("failed to delete repo after push failure: %v", delErr) - } - } else { - logx.Warnf("could not determine owner for cleanup of repo %q", repo.GetFullName()) - } - - return fmt.Errorf("git init/push failed: %w", err) + + fmt.Println("Committing and pushing...") + + if err := git.InitAndPush(cmd.Context(), projectRoot, remoteURL, "initial-commit"); err != nil { + // cleanup + _ = gh.DeleteRepo(cmd.Context(), repo) + return fmt.Errorf("git push failed: %w", err) } + fmt.Println("Pushed:", repo.GetHTMLURL()) - } else { - fmt.Println("Skipping GitHub repo creation") } fmt.Println("Time Taken:", time.Since(start)) diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go index 740734f..545e92c 100644 --- a/internal/fsutil/fsutil.go +++ b/internal/fsutil/fsutil.go @@ -12,18 +12,21 @@ import ( "text/template" "github.com/b-jonathan/taco/internal/stacks/templates" + "github.com/spf13/afero" ) +var Fs = afero.NewOsFs() + // TODO: Some of these were completely vibe coded, just need to refactor a bit to make more consistent func EnsureFile(path string) error { // Create parent directories if needed. - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := Fs.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } // Create the file if missing. O_EXCL prevents clobbering if a race happens. - f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL, 0o644) + f, err := Fs.OpenFile(path, os.O_CREATE|os.O_EXCL, 0o644) if err != nil { - // If it already exists, that’s fine. + // If it already exists, that's fine. if os.IsExist(err) { return nil } @@ -40,7 +43,7 @@ func WriteFile(file FileInfo) error { } // log.Println("Ensuring file complete") // log.Printf("Writing File: %s", path) - if err := os.WriteFile(file.Path, file.Content, 0o644); err != nil { + if err := afero.WriteFile(Fs, file.Path, file.Content, 0o644); err != nil { return fmt.Errorf("write %s file: %w", filename, err) } // log.Println("Writing file complete") @@ -57,7 +60,7 @@ func WriteMultipleFiles(files []FileInfo) error { } func AppendUniqueLines(path string, lines []string) error { - buf, _ := os.ReadFile(path) + buf, _ := afero.ReadFile(Fs, path) for _, line := range lines { if !bytes.Contains(buf, []byte(line+"\n")) && !bytes.Equal(bytes.TrimSpace(buf), []byte(line)) { if len(buf) > 0 && buf[len(buf)-1] != '\n' { @@ -66,7 +69,7 @@ func AppendUniqueLines(path string, lines []string) error { buf = append(buf, []byte(line+"\n")...) } } - return os.WriteFile(path, buf, 0o644) + return afero.WriteFile(Fs, path, buf, 0o644) } // in a shared package or file @@ -164,10 +167,10 @@ func GenerateFromTemplateDir(templateRoot, outputRoot string) error { return fmt.Errorf("render template %s: %w", path, err) } - if err := os.MkdirAll(filepath.Dir(finalPath), 0755); err != nil { + if err := Fs.MkdirAll(filepath.Dir(finalPath), 0755); err != nil { return err } - return os.WriteFile(finalPath, content, 0644) + return afero.WriteFile(Fs, finalPath, content, 0644) }) } diff --git a/internal/gh/repo.go b/internal/gh/repo.go new file mode 100644 index 0000000..7c2f2d6 --- /dev/null +++ b/internal/gh/repo.go @@ -0,0 +1,80 @@ +package gh + +import ( + "context" + "fmt" + "strings" + "time" + + github "github.com/google/go-github/v55/github" +) + +type CreateRepoOptions struct { + Name string + Private bool + Description string + Timeout time.Duration +} + +// Create Repo +func CreateRepo(ctx context.Context, opts CreateRepoOptions) (*github.Repository, error) { + client, err := EnsureClient(ctx) + if err != nil { + return nil, err + } + + timeout := opts.Timeout + if timeout <= 0 { + timeout = 100 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + newRepo := &github.Repository{ + Name: github.String(opts.Name), + Private: github.Bool(opts.Private), + Description: github.String(opts.Description), + } + + repo, _, err := client.Repositories.Create(ctx, "", newRepo) + if err != nil { + return nil, fmt.Errorf("create repo: %w", err) + } + + return repo, nil +} + +// For cleanup +func DeleteRepo(ctx context.Context, repo *github.Repository) error { + client, err := EnsureClient(ctx) + if err != nil { + return err + } + + if repo == nil { + return nil + } + + owner := "" + if repo.GetOwner() != nil { + owner = repo.GetOwner().GetLogin() + } + + if owner == "" { + parts := strings.Split(repo.GetFullName(), "/") + if len(parts) == 2 { + owner = parts[0] + } + } + + if owner == "" { + return fmt.Errorf("cannot determine repo owner") + } + + _, err = client.Repositories.Delete(ctx, owner, repo.GetName()) + if err != nil { + return fmt.Errorf("delete repo: %w", err) + } + + return nil +} diff --git a/internal/stacks/nextjs/nextjs.go b/internal/stacks/nextjs/nextjs.go index 5b05a81..9aafb79 100644 --- a/internal/stacks/nextjs/nextjs.go +++ b/internal/stacks/nextjs/nextjs.go @@ -3,7 +3,6 @@ package nextjs import ( "context" "fmt" - "os" "path/filepath" "strings" @@ -11,6 +10,7 @@ import ( "github.com/b-jonathan/taco/internal/fsutil" "github.com/b-jonathan/taco/internal/nodepkg" "github.com/b-jonathan/taco/internal/stacks" + "github.com/spf13/afero" ) type Stack = stacks.Stack @@ -25,7 +25,7 @@ func (nextjs) Type() string { return "frontend" } func (nextjs) Name() string { return "nextjs" } func (nextjs) Init(ctx context.Context, opts *Options) error { - if err := os.MkdirAll(opts.ProjectRoot, 0o755); err != nil { + if err := fsutil.Fs.MkdirAll(opts.ProjectRoot, 0o755); err != nil { return fmt.Errorf("mkdir: %w", err) } // 1) Scaffold Next.js in TS, without ESLint, noninteractive @@ -104,11 +104,11 @@ func (nextjs) Post(ctx context.Context, opts *Options) error { } dir := filepath.Dir(envPath) - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := fsutil.Fs.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("mkdir %s: %w", dir, err) } content := `NEXT_PUBLIC_BACKEND_URL=http://localhost:4000` - if err := os.WriteFile(envPath, []byte(content), 0o644); err != nil { + if err := afero.WriteFile(fsutil.Fs, envPath, []byte(content), 0o644); err != nil { return fmt.Errorf("write %s: %w", envPath, err) }