Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -114,6 +118,7 @@ grit remove resource Post
# Scaffolding
grit new <name> # Interactive (architecture + frontend)
grit new <name> --triple --next # Explicit flags
grit new . --triple --vite # Scaffold into current directory
grit new-desktop <name> # Desktop app (Wails)

# Code generation
Expand Down
72 changes: 48 additions & 24 deletions cmd/grit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,42 +67,55 @@ 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 <project-name>",
Use: "new <project-name|.>",
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,
MobileOnly: mobileOnly,
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":
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"},
}

Expand All @@ -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"},
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1010,4 +1034,4 @@ func deployCmd() *cobra.Command {
cmd.Flags().StringVar(&appPort, "app-port", "8080", "Port the app runs on")

return cmd
}
}
57 changes: 57 additions & 0 deletions internal/scaffold/scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
104 changes: 104 additions & 0 deletions internal/scaffold/scaffold_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down