Skip to content

Commit 216026f

Browse files
committed
v0.3.0
introduce --force flag add staged project generation and overwrite protection add project testing correct graphQL handler update CI update README update Makefile fix duplicate code
1 parent ec16ee1 commit 216026f

16 files changed

Lines changed: 571 additions & 70 deletions

File tree

.github/workflows/goforge-ci.yml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,8 @@ jobs:
1717
with:
1818
go-version: "1.24.5"
1919

20-
- name: Verify dependencies
21-
run: go mod verify
22-
23-
- name: Build
24-
run: make build
20+
- name: Verify deps, run tests, vet, lint, and build
21+
run: make check
2522

2623
- name: Version Check
2724
run: ./bin/goforge version

Makefile

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: default build run clean
1+
.PHONY: default build test verify vet lint check run clean
22

33
default:
44
echo "Opinionated Go CLI for generating backend service boilerplate"
@@ -7,8 +7,22 @@ build:
77
go build -o bin/goforge main.go
88
chmod +x bin/goforge
99

10-
run:
11-
bash -c "./bin/goforge"
10+
test:
11+
go test ./...
12+
13+
verify:
14+
go mod verify
15+
16+
vet:
17+
go vet ./...
18+
19+
lint:
20+
golangci-lint run ./...
21+
22+
check: verify test vet lint build
23+
24+
run: build
25+
./bin/goforge
1226

1327
clean:
1428
rm -rf ./bin/**

README.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,27 @@ const (
6868
)
6969

7070
type Form struct {
71-
Name string
72-
ServerTypeFlag ServerTypeFlag
73-
DatabaseFlag bool
74-
MakefileFlag bool
71+
Name string
72+
ServerTypeFlag ServerTypeFlag
73+
DatabaseFlag bool
74+
MakefileFlag bool
75+
DockerFlag bool
7576
}
7677
```
7778

78-
If all required flags are provided (including `--database`), the TUI is skipped and the server is generated immediately.
79+
For non-interactive use (without opening the TUI options), explicitly provide each boolean option, including false values:
80+
81+
```bash
82+
goforge generate \
83+
--path rest-server \
84+
--name my-server \
85+
--server rest \
86+
--database=false \
87+
--makefile=true \
88+
--docker=false
89+
```
90+
91+
Docker generation requires database generation. If a generated file already exists, `goforge` preserves it and returns an error; pass `--force` to explicitly replace generated files.
7992

8093
For example, running with all required flags will produce:
8194
![goforge all flags](images/goforge-all-flags.png)
@@ -85,10 +98,10 @@ Here is an example where you can trigger the TUI:\
8598

8699
### Roadmap
87100

88-
- [X] Allow different server type boilerplates
101+
- [x] Allow different server type boilerplates
89102
- [x] REST (`go-chi`)
90103
- [x] gRPC (`grpc-go`)
91-
- [X] GraphQL (`gqlgen`)
104+
- [x] GraphQL (`gqlgen`)
92105
- [x] Add Makefile support flag
93106
- [x] Add SQLc/database support flag
94107
- [x] Potentially add a docker-compose file to spin up a PostgreSQL instance

cmd/generate.go

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
1-
/*
2-
Copyright © 2026 NAME HERE <EMAIL ADDRESS>
3-
*/
41
package cmd
52

63
import (
7-
"fmt"
8-
"os"
9-
"regexp"
104
"strings"
115

126
"github.com/andrearcaina/goforge/internal/config"
@@ -19,26 +13,21 @@ var cfg config.Config
1913
// generateCmd represents the generate command
2014
var generateCmd = &cobra.Command{
2115
Use: "generate",
22-
Short: "Generate a hello world message",
23-
Long: `Generates a simple hello world message using the provided flag.
24-
For example:
16+
Short: "Generate a Go backend service",
17+
Long: `Generate a REST, gRPC, or GraphQL Go backend service.
2518
26-
goforge generate --some-flag "Developer"`,
19+
Missing options are collected through an interactive form.`,
20+
Args: cobra.NoArgs,
2721
RunE: func(cmd *cobra.Command, args []string) error {
28-
if err := goforge.Forge(&cfg); err != nil {
29-
return err
30-
}
31-
32-
return nil
22+
return goforge.Forge(&cfg)
3323
},
3424
}
3525

3626
func init() {
37-
rootCmd.AddCommand(generateCmd)
38-
3927
// general flags
4028
generateCmd.Flags().StringVarP(&cfg.OutputPath, "path", "p", ".", "The directory to write the generated file to")
4129
generateCmd.Flags().BoolVarP(&cfg.Default, "default", "d", false, "Use default configuration values")
30+
generateCmd.Flags().BoolVarP(&cfg.Force, "force", "f", false, "Overwrite generated files that already exist")
4231

4332
// flags for form fields
4433
generateCmd.Flags().StringVarP(&cfg.Form.Name, "name", "n", "", "The name for the go.mod module")
@@ -48,16 +37,13 @@ func init() {
4837
generateCmd.Flags().BoolVarP(&cfg.Form.DockerFlag, "docker", "D", false, "Generate Docker Compose (for DB) and .env file (if flag is set, set to true)")
4938

5039
// normalize server type flag to lowercase
51-
generateCmd.PreRun = func(cmd *cobra.Command, args []string) {
52-
// first convert to string, then lowercase the string, then convert back to ServerTypeFlag
53-
cfg.Form.ServerTypeFlag = config.ServerTypeFlag(strings.ToLower(string(cfg.Form.ServerTypeFlag)))
54-
55-
// validate go.mod names
56-
if cfg.Form.Name != "" {
57-
if matched, _ := regexp.MatchString(`[^a-zA-Z0-9_\-]`, cfg.Form.Name); matched {
58-
fmt.Println("Error: project name can only contain letters, numbers, underscores, or dashes")
59-
os.Exit(1)
60-
}
61-
}
40+
generateCmd.PreRunE = func(cmd *cobra.Command, args []string) error {
41+
cfg.Form.ServerTypeFlag = config.ServerTypeFlag(strings.ToLower(strings.TrimSpace(string(cfg.Form.ServerTypeFlag))))
42+
cfg.Form.Name = strings.TrimSpace(cfg.Form.Name)
43+
cfg.OutputPath = strings.TrimSpace(cfg.OutputPath)
44+
cfg.DatabaseFlagSet = cmd.Flags().Changed("database")
45+
cfg.MakefileFlagSet = cmd.Flags().Changed("makefile")
46+
cfg.DockerFlagSet = cmd.Flags().Changed("docker")
47+
return nil
6248
}
6349
}

cmd/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@ Cobra is a CLI library for Go that empowers applications.
1717
This application is a tool to generate the needed files
1818
to quickly create a Cobra application.`,
1919
Run: func(cmd *cobra.Command, args []string) {
20-
fmt.Println("v0.2.0")
20+
fmt.Println("v0.3.0")
2121
},
2222
}

internal/config/config.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@ const (
1010
)
1111

1212
type Config struct {
13-
OutputPath string
14-
Default bool
15-
Form Form
13+
OutputPath string
14+
Default bool
15+
Force bool
16+
DatabaseFlagSet bool
17+
MakefileFlagSet bool
18+
DockerFlagSet bool
19+
Form Form
1620
}
1721

1822
type Form struct {

internal/goforge/forge.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@ import (
55

66
"github.com/andrearcaina/goforge/internal/config"
77
"github.com/andrearcaina/goforge/internal/ui"
8+
"github.com/andrearcaina/goforge/internal/utils"
89
"github.com/charmbracelet/huh/spinner"
910
)
1011

1112
func Forge(cfg *config.Config) error {
13+
if cfg == nil {
14+
return fmt.Errorf("invalid configuration: configuration cannot be nil")
15+
}
16+
1217
if cfg.Default {
1318
cfg.Form = config.Form{
1419
Name: "example-server",
@@ -21,6 +26,10 @@ func Forge(cfg *config.Config) error {
2126
}
2227
}
2328

29+
if err := utils.Validate(cfg); err != nil {
30+
return fmt.Errorf("invalid configuration: %w", err)
31+
}
32+
2433
var err error
2534
_ = spinner.New().
2635
Title("Forging your project...").

internal/goforge/generator.go

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,61 @@ package goforge
22

33
import (
44
"fmt"
5+
"io/fs"
56
"os"
67
"path/filepath"
78
"text/template"
89
"time"
910

1011
"github.com/andrearcaina/goforge/internal/config"
1112
"github.com/andrearcaina/goforge/internal/templates"
13+
"github.com/andrearcaina/goforge/internal/utils"
1214
)
1315

1416
func Generate(cfg *config.Config) error {
17+
if err := utils.Validate(cfg); err != nil {
18+
return fmt.Errorf("invalid configuration: %w", err)
19+
}
20+
21+
outputPath := filepath.Clean(cfg.OutputPath)
22+
parentPath := filepath.Dir(outputPath)
23+
if err := os.MkdirAll(parentPath, 0755); err != nil {
24+
return fmt.Errorf("create output parent directory: %w", err)
25+
}
26+
27+
if info, err := os.Lstat(outputPath); err == nil {
28+
if info.Mode()&os.ModeSymlink != 0 {
29+
return fmt.Errorf("output path %q must not be a symbolic link", outputPath)
30+
}
31+
if !info.IsDir() {
32+
return fmt.Errorf("output path %q is not a directory", outputPath)
33+
}
34+
} else if !os.IsNotExist(err) {
35+
return fmt.Errorf("inspect output path: %w", err)
36+
}
37+
38+
stagingPath, err := os.MkdirTemp(parentPath, ".goforge-stage-*")
39+
if err != nil {
40+
return fmt.Errorf("create staging directory: %w", err)
41+
}
42+
defer func() {
43+
_ = os.RemoveAll(stagingPath)
44+
}()
45+
46+
stagingCfg := *cfg
47+
stagingCfg.OutputPath = stagingPath
48+
if err := generateProject(&stagingCfg); err != nil {
49+
return fmt.Errorf("render project: %w", err)
50+
}
51+
52+
if err := commitGeneratedProject(stagingPath, outputPath, cfg.Force); err != nil {
53+
return err
54+
}
55+
56+
return nil
57+
}
58+
59+
func generateProject(cfg *config.Config) error {
1560
if err := generateBaseFiles(cfg); err != nil {
1661
return err
1762
}
@@ -75,6 +120,90 @@ func Generate(cfg *config.Config) error {
75120
return nil
76121
}
77122

123+
func commitGeneratedProject(stagingPath, outputPath string, force bool) error {
124+
if _, err := os.Lstat(outputPath); os.IsNotExist(err) {
125+
if err := os.Rename(stagingPath, outputPath); err != nil {
126+
return fmt.Errorf("commit generated project: %w", err)
127+
}
128+
return nil
129+
} else if err != nil {
130+
return fmt.Errorf("inspect output path: %w", err)
131+
}
132+
133+
var dirs []string
134+
var files []string
135+
err := filepath.WalkDir(stagingPath, func(path string, entry fs.DirEntry, walkErr error) error {
136+
if walkErr != nil {
137+
return walkErr
138+
}
139+
if path == stagingPath {
140+
return nil
141+
}
142+
143+
relativePath, err := filepath.Rel(stagingPath, path)
144+
if err != nil {
145+
return err
146+
}
147+
if entry.IsDir() {
148+
dirs = append(dirs, relativePath)
149+
} else {
150+
files = append(files, relativePath)
151+
}
152+
return nil
153+
})
154+
if err != nil {
155+
return fmt.Errorf("inspect staged project: %w", err)
156+
}
157+
158+
for _, relativePath := range dirs {
159+
targetPath := filepath.Join(outputPath, relativePath)
160+
if info, err := os.Lstat(targetPath); err == nil && !info.IsDir() {
161+
return fmt.Errorf("cannot create directory %q because a file already exists there", targetPath)
162+
} else if err != nil && !os.IsNotExist(err) {
163+
return fmt.Errorf("inspect destination %q: %w", targetPath, err)
164+
}
165+
}
166+
167+
for _, relativePath := range files {
168+
targetPath := filepath.Join(outputPath, relativePath)
169+
if info, err := os.Lstat(targetPath); err == nil {
170+
if info.IsDir() {
171+
return fmt.Errorf("cannot generate file %q because a directory already exists there", targetPath)
172+
}
173+
if !force {
174+
return fmt.Errorf("refusing to overwrite existing file %q; use --force to overwrite generated files", targetPath)
175+
}
176+
} else if !os.IsNotExist(err) {
177+
return fmt.Errorf("inspect destination %q: %w", targetPath, err)
178+
}
179+
}
180+
181+
for _, relativePath := range dirs {
182+
targetPath := filepath.Join(outputPath, relativePath)
183+
if err := os.MkdirAll(targetPath, 0755); err != nil {
184+
return fmt.Errorf("create destination directory %q: %w", targetPath, err)
185+
}
186+
}
187+
188+
for _, relativePath := range files {
189+
sourcePath := filepath.Join(stagingPath, relativePath)
190+
targetPath := filepath.Join(outputPath, relativePath)
191+
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
192+
return fmt.Errorf("create destination directory for %q: %w", targetPath, err)
193+
}
194+
if force {
195+
if err := os.Remove(targetPath); err != nil && !os.IsNotExist(err) {
196+
return fmt.Errorf("replace existing file %q: %w", targetPath, err)
197+
}
198+
}
199+
if err := os.Rename(sourcePath, targetPath); err != nil {
200+
return fmt.Errorf("commit generated file %q: %w", targetPath, err)
201+
}
202+
}
203+
204+
return nil
205+
}
206+
78207
func generateDirs(cfg *config.Config, dirs []string) error {
79208
for _, dir := range dirs {
80209
if err := os.MkdirAll(filepath.Join(cfg.OutputPath, dir), 0755); err != nil {
@@ -153,11 +282,11 @@ func generateSpecificFile(tmplPath []string, outputPath string, data interface{}
153282
if err != nil {
154283
return err
155284
}
156-
defer out.Close()
157285

158286
if err := tmpl.Execute(out, data); err != nil {
287+
_ = out.Close()
159288
return err
160289
}
161290

162-
return nil
291+
return out.Close()
163292
}

0 commit comments

Comments
 (0)