Skip to content

Commit af14998

Browse files
authored
Merge pull request #5 from mosespace/grit/cmd_features
Merging for --force flag, helper functions, tests, and README updates. Will adjust anyFlagSet logic post-merge.
2 parents ec55691 + a85c476 commit af14998

4 files changed

Lines changed: 214 additions & 24 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,13 @@ grit new myapp --triple --next # Web + Admin + API (Next.js)
4141
grit new myapp --double --vite # Web + API (TanStack Router)
4242
grit new myapp --single # Single Go binary + embedded SPA
4343
grit new myapp --api # Go API only
44+
grit new . --triple --vite # Scaffold into current directory
45+
grit new ./ --triple --vite # Same as above
4446
grit new-desktop myapp # Native desktop app (Wails)
4547
```
4648

49+
Tip: when using `grit new .`, Grit infers the project name from your current folder name. Use `--force` if the directory is non-empty.
50+
4751
```bash
4852
cd myapp
4953
docker compose up -d # PostgreSQL, Redis, MinIO, Mailhog
@@ -114,6 +118,7 @@ grit remove resource Post
114118
# Scaffolding
115119
grit new <name> # Interactive (architecture + frontend)
116120
grit new <name> --triple --next # Explicit flags
121+
grit new . --triple --vite # Scaffold into current directory
117122
grit new-desktop <name> # Desktop app (Wails)
118123

119124
# Code generation

cmd/grit/main.go

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -67,42 +67,55 @@ func versionCmd() *cobra.Command {
6767
func newCmd() *cobra.Command {
6868
// New architecture/frontend flags
6969
var archFlag, frontendFlag, style string
70+
var inPlace, force bool
7071

7172
// Legacy flags (backward compatibility)
7273
var apiOnly, includeExpo, mobileOnly, full bool
7374

7475
cmd := &cobra.Command{
75-
Use: "new <project-name>",
76+
Use: "new <project-name|.>",
7677
Short: "Create a new Grit project",
77-
Long: "Scaffold a new Grit project. Interactive by default — select architecture and frontend.\nUse flags to skip prompts: grit new my-app --single --vite",
78+
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.",
7879
Args: cobra.ExactArgs(1),
7980
RunE: func(cmd *cobra.Command, args []string) error {
80-
projectName := args[0]
81+
projectArg := strings.TrimSpace(args[0])
82+
projectName := projectArg
8183

82-
// Support "grit new ." to scaffold into current directory
83-
if projectName == "." {
84+
if filepath.Clean(projectArg) == "." {
8485
cwd, err := os.Getwd()
8586
if err != nil {
8687
return fmt.Errorf("getting current directory: %w", err)
8788
}
8889
projectName = filepath.Base(cwd)
89-
} else {
90-
if err := scaffold.ValidateProjectName(projectName); err != nil {
91-
return err
90+
inPlace = true
91+
}
92+
93+
if err := scaffold.ValidateProjectName(projectName); err != nil {
94+
if filepath.Clean(projectArg) == "." {
95+
return fmt.Errorf("invalid current directory name %q for project generation: %w", projectName, err)
9296
}
97+
return err
9398
}
9499

95100
opts := scaffold.Options{
96101
ProjectName: projectName,
97-
InPlace: args[0] == ".",
98102
Style: style,
103+
InPlace: inPlace,
104+
Force: force,
99105
// Legacy flags
100106
APIOnly: apiOnly,
101107
IncludeExpo: includeExpo,
102108
MobileOnly: mobileOnly,
103109
Full: full,
104110
}
105111

112+
if !opts.InPlace {
113+
cwd, err := os.Getwd()
114+
if err == nil && filepath.Base(cwd) == projectName {
115+
opts.InPlace = true
116+
}
117+
}
118+
106119
// Map architecture shorthand flags
107120
switch archFlag {
108121
case "single":
@@ -133,16 +146,23 @@ func newCmd() *cobra.Command {
133146
return fmt.Errorf("invalid frontend %q: must be next, vite, or tanstack", frontendFlag)
134147
}
135148

136-
// Apply legacy flag mappings
137-
opts.Normalize()
138-
139-
// Only show interactive prompt if NO explicit flags were set
140-
anyFlagSet := archFlag != "" || frontendFlag != "" ||
141-
apiOnly || mobileOnly || full || includeExpo
142-
143-
if !anyFlagSet {
149+
// Show interactive selector only when the user did not provide any
150+
// architecture/frontend shortcuts or explicit long-form flags.
151+
anyScaffoldSelection := cmd.Flags().Changed("arch") ||
152+
cmd.Flags().Changed("frontend") ||
153+
cmd.Flags().Changed("single") ||
154+
cmd.Flags().Changed("double") ||
155+
cmd.Flags().Changed("triple") ||
156+
cmd.Flags().Changed("vite") ||
157+
cmd.Flags().Changed("next") ||
158+
cmd.Flags().Changed("api") ||
159+
cmd.Flags().Changed("mobile") ||
160+
cmd.Flags().Changed("expo") ||
161+
cmd.Flags().Changed("full")
162+
163+
if !anyScaffoldSelection {
144164
printLogo()
145-
// Reset to empty so prompt shows
165+
// Keep empty values so the prompt can collect architecture/frontend.
146166
opts.Architecture = ""
147167
opts.Frontend = ""
148168
if err := prompt.RunNewProjectPrompt(&opts); err != nil {
@@ -215,14 +235,16 @@ func newCmd() *cobra.Command {
215235
cmd.Flags().Bool("single", false, "Shorthand for --arch=single")
216236
cmd.Flags().Bool("double", false, "Shorthand for --arch=double")
217237
cmd.Flags().Bool("triple", false, "Shorthand for --arch=triple")
238+
cmd.Flags().BoolVar(&inPlace, "here", false, "Scaffold into the current directory instead of creating a new folder")
239+
cmd.Flags().BoolVar(&force, "force", false, "Allow scaffolding into a non-empty directory (use with --here)")
218240

219241
return cmd
220242
}
221243

222244
func generateCmd() *cobra.Command {
223245
cmd := &cobra.Command{
224-
Use: "generate",
225-
Short: "Generate code for your Grit project",
246+
Use: "generate",
247+
Short: "Generate code for your Grit project",
226248
Aliases: []string{"g"},
227249
}
228250

@@ -233,8 +255,8 @@ func generateCmd() *cobra.Command {
233255

234256
func removeCmd() *cobra.Command {
235257
cmd := &cobra.Command{
236-
Use: "remove",
237-
Short: "Remove components from your Grit project",
258+
Use: "remove",
259+
Short: "Remove components from your Grit project",
238260
Aliases: []string{"rm"},
239261
}
240262

@@ -741,7 +763,9 @@ func printSuccess(name string, opts scaffold.Options) {
741763

742764
white.Println(" Next steps:")
743765
fmt.Println()
744-
cyan.Printf(" cd %s\n", name)
766+
if !opts.InPlace {
767+
cyan.Printf(" cd %s\n", name)
768+
}
745769
cyan.Println(" docker compose up -d")
746770

747771
switch opts.Architecture {
@@ -1010,4 +1034,4 @@ func deployCmd() *cobra.Command {
10101034
cmd.Flags().StringVar(&appPort, "app-port", "8080", "Port the app runs on")
10111035

10121036
return cmd
1013-
}
1037+
}

internal/scaffold/scaffold.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,63 @@ func ValidateProjectName(name string) error {
169169
return nil
170170
}
171171

172+
func resolveScaffoldRoot(opts Options) (string, bool, error) {
173+
if opts.InPlace {
174+
return ".", true, nil
175+
}
176+
177+
cwd, err := os.Getwd()
178+
if err != nil {
179+
return "", false, fmt.Errorf("getting current directory: %w", err)
180+
}
181+
182+
// Quality-of-life behavior: if the user is already inside a directory whose
183+
// name matches the project name, scaffold in place instead of nesting.
184+
if filepath.Base(cwd) == opts.ProjectName {
185+
return ".", true, nil
186+
}
187+
188+
return opts.ProjectName, false, nil
189+
}
190+
191+
func ensureTargetDirectory(root string, inPlace bool, force bool) error {
192+
if !inPlace {
193+
if _, err := os.Stat(root); err == nil {
194+
return fmt.Errorf("directory %q already exists", root)
195+
} else if !os.IsNotExist(err) {
196+
return fmt.Errorf("checking directory %q: %w", root, err)
197+
}
198+
return nil
199+
}
200+
201+
info, err := os.Stat(root)
202+
if err != nil {
203+
if os.IsNotExist(err) {
204+
if err := os.MkdirAll(root, 0755); err != nil {
205+
return fmt.Errorf("creating directory %q: %w", root, err)
206+
}
207+
return nil
208+
}
209+
return fmt.Errorf("checking directory %q: %w", root, err)
210+
}
211+
if !info.IsDir() {
212+
return fmt.Errorf("target %q is not a directory", root)
213+
}
214+
if force {
215+
return nil
216+
}
217+
218+
entries, err := os.ReadDir(root)
219+
if err != nil {
220+
return fmt.Errorf("reading directory %q: %w", root, err)
221+
}
222+
if len(entries) > 0 {
223+
return fmt.Errorf("current directory is not empty; rerun with --force to scaffold in place")
224+
}
225+
226+
return nil
227+
}
228+
172229
// Run executes the full scaffolding process.
173230
func Run(opts Options) error {
174231
opts.Normalize()

internal/scaffold/scaffold_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,110 @@ func TestValidateProjectName(t *testing.T) {
4545
}
4646
}
4747

48+
func TestResolveScaffoldRoot(t *testing.T) {
49+
t.Run("explicit in-place", func(t *testing.T) {
50+
root, inPlace, err := resolveScaffoldRoot(Options{ProjectName: "my-app", InPlace: true})
51+
if err != nil {
52+
t.Fatalf("resolveScaffoldRoot returned error: %v", err)
53+
}
54+
if root != "." {
55+
t.Fatalf("root = %q, want .", root)
56+
}
57+
if !inPlace {
58+
t.Fatal("inPlace = false, want true")
59+
}
60+
})
61+
62+
t.Run("auto in-place when cwd matches project name", func(t *testing.T) {
63+
prev, err := os.Getwd()
64+
if err != nil {
65+
t.Fatalf("getwd: %v", err)
66+
}
67+
68+
target := filepath.Join(t.TempDir(), "my-app")
69+
if err := os.MkdirAll(target, 0755); err != nil {
70+
t.Fatalf("mkdirall: %v", err)
71+
}
72+
if err := os.Chdir(target); err != nil {
73+
t.Fatalf("chdir target: %v", err)
74+
}
75+
t.Cleanup(func() {
76+
_ = os.Chdir(prev)
77+
})
78+
79+
root, inPlace, err := resolveScaffoldRoot(Options{ProjectName: "my-app"})
80+
if err != nil {
81+
t.Fatalf("resolveScaffoldRoot returned error: %v", err)
82+
}
83+
if root != "." {
84+
t.Fatalf("root = %q, want .", root)
85+
}
86+
if !inPlace {
87+
t.Fatal("inPlace = false, want true")
88+
}
89+
})
90+
91+
t.Run("default creates named directory", func(t *testing.T) {
92+
root, inPlace, err := resolveScaffoldRoot(Options{ProjectName: "my-app"})
93+
if err != nil {
94+
t.Fatalf("resolveScaffoldRoot returned error: %v", err)
95+
}
96+
if root != "my-app" {
97+
t.Fatalf("root = %q, want my-app", root)
98+
}
99+
if inPlace {
100+
t.Fatal("inPlace = true, want false")
101+
}
102+
})
103+
}
104+
105+
func TestEnsureTargetDirectory(t *testing.T) {
106+
t.Run("non-in-place rejects existing directory", func(t *testing.T) {
107+
root := filepath.Join(t.TempDir(), "existing")
108+
if err := os.MkdirAll(root, 0755); err != nil {
109+
t.Fatalf("mkdirall: %v", err)
110+
}
111+
112+
err := ensureTargetDirectory(root, false, false)
113+
if err == nil {
114+
t.Fatal("expected error for existing directory, got nil")
115+
}
116+
})
117+
118+
t.Run("in-place allows empty directory", func(t *testing.T) {
119+
root := t.TempDir()
120+
if err := ensureTargetDirectory(root, true, false); err != nil {
121+
t.Fatalf("ensureTargetDirectory returned error: %v", err)
122+
}
123+
})
124+
125+
t.Run("in-place rejects non-empty directory by default", func(t *testing.T) {
126+
root := t.TempDir()
127+
if err := os.WriteFile(filepath.Join(root, "placeholder.txt"), []byte("x"), 0644); err != nil {
128+
t.Fatalf("writefile: %v", err)
129+
}
130+
131+
err := ensureTargetDirectory(root, true, false)
132+
if err == nil {
133+
t.Fatal("expected error for non-empty directory, got nil")
134+
}
135+
if !strings.Contains(err.Error(), "--force") {
136+
t.Fatalf("expected --force hint in error, got: %v", err)
137+
}
138+
})
139+
140+
t.Run("in-place force allows non-empty directory", func(t *testing.T) {
141+
root := t.TempDir()
142+
if err := os.WriteFile(filepath.Join(root, "placeholder.txt"), []byte("x"), 0644); err != nil {
143+
t.Fatalf("writefile: %v", err)
144+
}
145+
146+
if err := ensureTargetDirectory(root, true, true); err != nil {
147+
t.Fatalf("ensureTargetDirectory returned error with force: %v", err)
148+
}
149+
})
150+
}
151+
48152
// ── ValidateStyle ─────────────────────────────────────────────────────────────
49153

50154
func TestValidateStyle(t *testing.T) {

0 commit comments

Comments
 (0)