diff --git a/README.md b/README.md index ca98b0b..77935a9 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,13 @@ grit new myapp --triple --next # Web + Admin + API (Next.js) grit new myapp --double --vite # Web + API (TanStack Router) grit new myapp --single # Single Go binary + embedded SPA grit new myapp --api # Go API only +grit new . --triple --vite # Scaffold into current directory +grit new ./ --triple --vite # Same as above grit new-desktop myapp # Native desktop app (Wails) ``` +Tip: when using `grit new .`, Grit infers the project name from your current folder name. Use `--force` if the directory is non-empty. + ```bash cd myapp docker compose up -d # PostgreSQL, Redis, MinIO, Mailhog @@ -114,6 +118,7 @@ grit remove resource Post # Scaffolding grit new # Interactive (architecture + frontend) grit new --triple --next # Explicit flags +grit new . --triple --vite # Scaffold into current directory grit new-desktop # Desktop app (Wails) # Code generation diff --git a/cmd/grit/main.go b/cmd/grit/main.go index 2dbafb8..bb01374 100644 --- a/cmd/grit/main.go +++ b/cmd/grit/main.go @@ -67,35 +67,41 @@ func versionCmd() *cobra.Command { func newCmd() *cobra.Command { // New architecture/frontend flags var archFlag, frontendFlag, style string + var inPlace, force bool // Legacy flags (backward compatibility) var apiOnly, includeExpo, mobileOnly, full bool cmd := &cobra.Command{ - Use: "new ", + Use: "new ", Short: "Create a new Grit project", - Long: "Scaffold a new Grit project. Interactive by default — select architecture and frontend.\nUse flags to skip prompts: grit new my-app --single --vite", + Long: "Scaffold a new Grit project. Interactive by default — select architecture and frontend.\nUse flags to skip prompts: grit new my-app --single --vite\nUse `grit new .` to scaffold in the current directory.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - projectName := args[0] + projectArg := strings.TrimSpace(args[0]) + projectName := projectArg - // Support "grit new ." to scaffold into current directory - if projectName == "." { + if filepath.Clean(projectArg) == "." { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("getting current directory: %w", err) } projectName = filepath.Base(cwd) - } else { - if err := scaffold.ValidateProjectName(projectName); err != nil { - return err + inPlace = true + } + + if err := scaffold.ValidateProjectName(projectName); err != nil { + if filepath.Clean(projectArg) == "." { + return fmt.Errorf("invalid current directory name %q for project generation: %w", projectName, err) } + return err } opts := scaffold.Options{ ProjectName: projectName, - InPlace: args[0] == ".", Style: style, + InPlace: inPlace, + Force: force, // Legacy flags APIOnly: apiOnly, IncludeExpo: includeExpo, @@ -103,6 +109,13 @@ func newCmd() *cobra.Command { Full: full, } + if !opts.InPlace { + cwd, err := os.Getwd() + if err == nil && filepath.Base(cwd) == projectName { + opts.InPlace = true + } + } + // Map architecture shorthand flags switch archFlag { case "single": @@ -133,16 +146,23 @@ func newCmd() *cobra.Command { return fmt.Errorf("invalid frontend %q: must be next, vite, or tanstack", frontendFlag) } - // Apply legacy flag mappings - opts.Normalize() - - // Only show interactive prompt if NO explicit flags were set - anyFlagSet := archFlag != "" || frontendFlag != "" || - apiOnly || mobileOnly || full || includeExpo - - if !anyFlagSet { + // Show interactive selector only when the user did not provide any + // architecture/frontend shortcuts or explicit long-form flags. + anyScaffoldSelection := cmd.Flags().Changed("arch") || + cmd.Flags().Changed("frontend") || + cmd.Flags().Changed("single") || + cmd.Flags().Changed("double") || + cmd.Flags().Changed("triple") || + cmd.Flags().Changed("vite") || + cmd.Flags().Changed("next") || + cmd.Flags().Changed("api") || + cmd.Flags().Changed("mobile") || + cmd.Flags().Changed("expo") || + cmd.Flags().Changed("full") + + if !anyScaffoldSelection { printLogo() - // Reset to empty so prompt shows + // Keep empty values so the prompt can collect architecture/frontend. opts.Architecture = "" opts.Frontend = "" if err := prompt.RunNewProjectPrompt(&opts); err != nil { @@ -215,14 +235,16 @@ func newCmd() *cobra.Command { cmd.Flags().Bool("single", false, "Shorthand for --arch=single") cmd.Flags().Bool("double", false, "Shorthand for --arch=double") cmd.Flags().Bool("triple", false, "Shorthand for --arch=triple") + cmd.Flags().BoolVar(&inPlace, "here", false, "Scaffold into the current directory instead of creating a new folder") + cmd.Flags().BoolVar(&force, "force", false, "Allow scaffolding into a non-empty directory (use with --here)") return cmd } func generateCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "generate", - Short: "Generate code for your Grit project", + Use: "generate", + Short: "Generate code for your Grit project", Aliases: []string{"g"}, } @@ -233,8 +255,8 @@ func generateCmd() *cobra.Command { func removeCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "remove", - Short: "Remove components from your Grit project", + Use: "remove", + Short: "Remove components from your Grit project", Aliases: []string{"rm"}, } @@ -741,7 +763,9 @@ func printSuccess(name string, opts scaffold.Options) { white.Println(" Next steps:") fmt.Println() - cyan.Printf(" cd %s\n", name) + if !opts.InPlace { + cyan.Printf(" cd %s\n", name) + } cyan.Println(" docker compose up -d") switch opts.Architecture { @@ -1010,4 +1034,4 @@ func deployCmd() *cobra.Command { cmd.Flags().StringVar(&appPort, "app-port", "8080", "Port the app runs on") return cmd -} +} \ No newline at end of file diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index 50c0e6c..d04ed00 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -169,6 +169,63 @@ func ValidateProjectName(name string) error { return nil } +func resolveScaffoldRoot(opts Options) (string, bool, error) { + if opts.InPlace { + return ".", true, nil + } + + cwd, err := os.Getwd() + if err != nil { + return "", false, fmt.Errorf("getting current directory: %w", err) + } + + // Quality-of-life behavior: if the user is already inside a directory whose + // name matches the project name, scaffold in place instead of nesting. + if filepath.Base(cwd) == opts.ProjectName { + return ".", true, nil + } + + return opts.ProjectName, false, nil +} + +func ensureTargetDirectory(root string, inPlace bool, force bool) error { + if !inPlace { + if _, err := os.Stat(root); err == nil { + return fmt.Errorf("directory %q already exists", root) + } else if !os.IsNotExist(err) { + return fmt.Errorf("checking directory %q: %w", root, err) + } + return nil + } + + info, err := os.Stat(root) + if err != nil { + if os.IsNotExist(err) { + if err := os.MkdirAll(root, 0755); err != nil { + return fmt.Errorf("creating directory %q: %w", root, err) + } + return nil + } + return fmt.Errorf("checking directory %q: %w", root, err) + } + if !info.IsDir() { + return fmt.Errorf("target %q is not a directory", root) + } + if force { + return nil + } + + entries, err := os.ReadDir(root) + if err != nil { + return fmt.Errorf("reading directory %q: %w", root, err) + } + if len(entries) > 0 { + return fmt.Errorf("current directory is not empty; rerun with --force to scaffold in place") + } + + return nil +} + // Run executes the full scaffolding process. func Run(opts Options) error { opts.Normalize() diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go index 2ddf2dd..9c8a46c 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -45,6 +45,110 @@ func TestValidateProjectName(t *testing.T) { } } +func TestResolveScaffoldRoot(t *testing.T) { + t.Run("explicit in-place", func(t *testing.T) { + root, inPlace, err := resolveScaffoldRoot(Options{ProjectName: "my-app", InPlace: true}) + if err != nil { + t.Fatalf("resolveScaffoldRoot returned error: %v", err) + } + if root != "." { + t.Fatalf("root = %q, want .", root) + } + if !inPlace { + t.Fatal("inPlace = false, want true") + } + }) + + t.Run("auto in-place when cwd matches project name", func(t *testing.T) { + prev, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + + target := filepath.Join(t.TempDir(), "my-app") + if err := os.MkdirAll(target, 0755); err != nil { + t.Fatalf("mkdirall: %v", err) + } + if err := os.Chdir(target); err != nil { + t.Fatalf("chdir target: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(prev) + }) + + root, inPlace, err := resolveScaffoldRoot(Options{ProjectName: "my-app"}) + if err != nil { + t.Fatalf("resolveScaffoldRoot returned error: %v", err) + } + if root != "." { + t.Fatalf("root = %q, want .", root) + } + if !inPlace { + t.Fatal("inPlace = false, want true") + } + }) + + t.Run("default creates named directory", func(t *testing.T) { + root, inPlace, err := resolveScaffoldRoot(Options{ProjectName: "my-app"}) + if err != nil { + t.Fatalf("resolveScaffoldRoot returned error: %v", err) + } + if root != "my-app" { + t.Fatalf("root = %q, want my-app", root) + } + if inPlace { + t.Fatal("inPlace = true, want false") + } + }) +} + +func TestEnsureTargetDirectory(t *testing.T) { + t.Run("non-in-place rejects existing directory", func(t *testing.T) { + root := filepath.Join(t.TempDir(), "existing") + if err := os.MkdirAll(root, 0755); err != nil { + t.Fatalf("mkdirall: %v", err) + } + + err := ensureTargetDirectory(root, false, false) + if err == nil { + t.Fatal("expected error for existing directory, got nil") + } + }) + + t.Run("in-place allows empty directory", func(t *testing.T) { + root := t.TempDir() + if err := ensureTargetDirectory(root, true, false); err != nil { + t.Fatalf("ensureTargetDirectory returned error: %v", err) + } + }) + + t.Run("in-place rejects non-empty directory by default", func(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "placeholder.txt"), []byte("x"), 0644); err != nil { + t.Fatalf("writefile: %v", err) + } + + err := ensureTargetDirectory(root, true, false) + if err == nil { + t.Fatal("expected error for non-empty directory, got nil") + } + if !strings.Contains(err.Error(), "--force") { + t.Fatalf("expected --force hint in error, got: %v", err) + } + }) + + t.Run("in-place force allows non-empty directory", func(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "placeholder.txt"), []byte("x"), 0644); err != nil { + t.Fatalf("writefile: %v", err) + } + + if err := ensureTargetDirectory(root, true, true); err != nil { + t.Fatalf("ensureTargetDirectory returned error with force: %v", err) + } + }) +} + // ── ValidateStyle ───────────────────────────────────────────────────────────── func TestValidateStyle(t *testing.T) {