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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:

- uses: actions/setup-go@v5
with:
go-version: "1.25.8"
go-version: "1.25.0"

- name: Lint
uses: golangci/golangci-lint-action@v9
Expand All @@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ["1.25.8"]
go-version: ["1.25.0"]

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -76,7 +76,7 @@ jobs:

- uses: actions/setup-go@v5
with:
go-version: "1.25.8"
go-version: "1.25.0"

- name: Build CLI
working-directory: cli
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:

- uses: actions/setup-go@v5
with:
go-version: "1.25.8"
go-version: "1.25.0"

- name: Run tests
run: go test -race ./...
Expand All @@ -37,7 +37,7 @@ jobs:

- uses: actions/setup-go@v5
with:
go-version: "1.25.8"
go-version: "1.25.0"

- name: Build binary
env:
Expand Down
2 changes: 1 addition & 1 deletion .go-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.25.8
1.25.0
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Thank you for your interest in contributing to the Gofasta CLI! This document ex
cd cli
```

2. **Verify Go version** (1.25.8 or later required):
2. **Verify Go version** (1.25.0 or later required):

```bash
go version
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The command-line tool for [Gofasta](https://github.com/gofastadev/gofasta), a Go

The CLI lives in its own Go module (`github.com/gofastadev/cli`) with `main.go` at `cmd/gofasta/`. It is not the same as the `github.com/gofastadev/gofasta` library, which your generated projects import as a dependency. You install one, you import the other.

**Option A — `go install` (recommended for Go developers, requires Go 1.25.8+):**
**Option A — `go install` (recommended for Go developers, requires Go 1.25.0+):**

```bash
go install github.com/gofastadev/cli/cmd/gofasta@latest
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/gofastadev/cli

go 1.25.8
go 1.25.0

require (
github.com/knadh/koanf/parsers/yaml v1.1.0
Expand Down
29 changes: 15 additions & 14 deletions internal/commands/commands_exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -513,14 +513,15 @@ func TestRunNew_GoModInitFails(t *testing.T) {
// branches we need the first two exec calls to succeed.
func TestRunNew_WarningBranches(t *testing.T) {
chdirTemp(t)
stagedFakeExec(t, 0, 0, 1) // mod init ok, gofasta install ok, everything else fails
// mod init ok, mod edit -go ok, gofasta install ok, everything else fails
stagedFakeExec(t, 0, 0, 0, 1)
err := runNew("warnapp", false)
assert.NoError(t, err)
}

func TestRunNew_WarningBranches_GraphQL(t *testing.T) {
chdirTemp(t)
stagedFakeExec(t, 0, 0, 1)
stagedFakeExec(t, 0, 0, 0, 1)
err := runNew("warnapp", true)
assert.NoError(t, err)
}
Expand Down Expand Up @@ -549,7 +550,7 @@ func TestRunNew_GofastaReplaceHappyPath(t *testing.T) {
// Fake framework checkout — absolute path with a go.mod inside.
fakeFramework := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(fakeFramework, "go.mod"),
[]byte("module github.com/gofastadev/gofasta\n\ngo 1.25.8\n"), 0o644))
[]byte("module github.com/gofastadev/gofasta\n\ngo 1.25.0\n"), 0o644))
t.Setenv("GOFASTA_REPLACE", fakeFramework)
withFakeExec(t, 0)

Expand All @@ -567,21 +568,21 @@ func TestRunRoutes_SampleProject(t *testing.T) {
routesDir := "app/rest/routes"
require.NoError(t, os.MkdirAll(routesDir, 0755))

// Index file includes a .PathPrefix("...").Subrouter() call so the
// Index file includes a chi r.Mount("...", ...) call so the
// prefix extraction regex matches and apiPrefix gets set to "/api/v1".
index := `package routes
func InitApi(r *mux.Router) {
api := r.PathPrefix("/api/v1").Subrouter()
r.HandleFunc("/health", httputil.Handle(c.Ok)).Methods("GET")
r.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler)
_ = api
func InitApi(r *chi.Mux) {
api := chi.NewRouter()
r.Get("/health", httputil.Handle(c.Ok))
r.Handle("/swagger/*", httpSwagger.WrapHandler)
r.Mount("/api/v1", api)
}`
require.NoError(t, os.WriteFile(routesDir+"/index.routes.go", []byte(index), 0644))

user := `package routes
func UserRoutes(r *mux.Router) {
r.HandleFunc("/users", httputil.Handle(c.List)).Methods("GET")
r.HandleFunc("/users/{id}", httputil.Handle(c.Get)).Methods("GET")
func UserRoutes(r chi.Router) {
r.Get("/users", httputil.Handle(c.List))
r.Get("/users/{id}", httputil.Handle(c.Get))
}`
require.NoError(t, os.WriteFile(routesDir+"/user.routes.go", []byte(user), 0644))

Expand All @@ -599,7 +600,7 @@ func TestRunRoutes_NoIndexFile(t *testing.T) {
routesDir := "app/rest/routes"
require.NoError(t, os.MkdirAll(routesDir, 0755))
// Only a non-index file — apiPrefix will stay empty
user := `r.HandleFunc("/a", x).Methods("GET")`
user := `r.Get("/a", x)`
require.NoError(t, os.WriteFile(routesDir+"/a.routes.go", []byte(user), 0644))
assert.NoError(t, runRoutes())
}
Expand Down Expand Up @@ -631,7 +632,7 @@ func TestRunRoutes_UnreadableRouteFile(t *testing.T) {
// list the file (only needs execute on the parent dir), but ReadFile
// fails with EACCES. The file should be silently skipped.
require.NoError(t, os.WriteFile(routesDir+"/blocked.routes.go",
[]byte(`r.HandleFunc("/x", h).Methods("GET")`), 0o000))
[]byte(`r.Get("/x", h)`), 0o000))
t.Cleanup(func() { _ = os.Chmod(routesDir+"/blocked.routes.go", 0o644) })

// Should not error — unreadable files are skipped (continue branch).
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/configutil/configutil_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,6 @@ func TestEnvPrefixes_DeDupesGofasta(t *testing.T) {
// (which setupConfigDir has already set to a fresh tempdir).
func writeGoMod(t *testing.T, modulePath string) {
t.Helper()
content := "module " + modulePath + "\n\ngo 1.25.8\n"
content := "module " + modulePath + "\n\ngo 1.25.0\n"
require.NoError(t, os.WriteFile("go.mod", []byte(content), 0o644))
}
14 changes: 12 additions & 2 deletions internal/commands/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,7 @@ func runNew(nameOrPath string, includeGraphQL bool) error {
ProjectName: projectName,
ProjectNameLower: strings.ToLower(projectName),
// Upper variant is used as an env-var prefix in compose.yaml,
// .env.example, k8s deployment.yaml, CI workflows, and the
// generated LoadConfig wrapper. Shell variable names only allow
// .env.example, CI workflows, and the generated LoadConfig wrapper. Shell variable names only allow
// [A-Z0-9_], so we strip anything else (dashes, dots, etc.) —
// otherwise a project named "my-app" would produce invalid env
// vars like "MY-APP_DATABASE_HOST" and the framework would never
Expand Down Expand Up @@ -153,6 +152,16 @@ func runNew(nameOrPath string, includeGraphQL bool) error {
if err := runCmdSilent("go", "mod", "init", modulePath); err != nil {
return fmt.Errorf("go mod init failed: %w", err)
}
// `go mod init` writes the current toolchain version as the `go` directive
// — so a developer running Go 1.27 would get `go 1.27` in their scaffold
// even though we only require 1.25.0. Normalise to the declared minimum so
// generated projects match our stated support floor regardless of the
// developer's local toolchain. Best-effort: if this fails, the scaffold
// still works, it just ships with the developer's toolchain version
// instead of the declared minimum.
if err := runCmdSilent("go", "mod", "edit", "-go=1.25.0"); err != nil {
termcolor.PrintWarn("Could not normalise go directive to 1.25.0 (generated go.mod may pin a higher version): %v", err)
}

// Walk embedded skeleton and generate files
termcolor.PrintStep("🏗 Creating project structure...")
Expand Down Expand Up @@ -302,6 +311,7 @@ func runNew(nameOrPath string, includeGraphQL bool) error {
_ = runCmdSilent("go", "get", "github.com/air-verse/air@latest")
_ = runCmdSilent("go", "get", "github.com/swaggo/swag/cmd/swag@latest")
_ = runCmdSilent("go", "get", "github.com/swaggo/http-swagger/v2@latest")
_ = runCmdSilent("go", "get", "github.com/go-chi/chi/v5@latest")
// Register as Go tools
if includeGraphQL {
_ = runCmdSilent("go", "mod", "edit", "-tool", "github.com/99designs/gqlgen")
Expand Down
8 changes: 4 additions & 4 deletions internal/commands/new_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ func TestInstallGofastaFromLocal_HappyPath(t *testing.T) {
// Create a fake "gofasta checkout" — a directory with a go.mod inside.
fakeFramework := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(fakeFramework, "go.mod"),
[]byte("module github.com/gofastadev/gofasta\n\ngo 1.25.8\n"), 0o644))
[]byte("module github.com/gofastadev/gofasta\n\ngo 1.25.0\n"), 0o644))
// Mock execCommand so the `go mod edit` calls "succeed" without
// actually hitting the real go binary.
withFakeExec(t, 0)
Expand All @@ -211,7 +211,7 @@ func TestInstallGofastaFromLocal_EditRequireFails(t *testing.T) {
setupGoMod(t)
fakeFramework := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(fakeFramework, "go.mod"),
[]byte("module github.com/gofastadev/gofasta\n\ngo 1.25.8\n"), 0o644))
[]byte("module github.com/gofastadev/gofasta\n\ngo 1.25.0\n"), 0o644))
withFakeExec(t, 1) // every exec fails

err := installGofastaFromLocal(fakeFramework)
Expand All @@ -226,7 +226,7 @@ func TestInstallGofastaFromLocal_EditReplaceFails(t *testing.T) {
setupGoMod(t)
fakeFramework := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(fakeFramework, "go.mod"),
[]byte("module github.com/gofastadev/gofasta\n\ngo 1.25.8\n"), 0o644))
[]byte("module github.com/gofastadev/gofasta\n\ngo 1.25.0\n"), 0o644))
stagedFakeExec(t, 0, 1) // require ok, replace fails

err := installGofastaFromLocal(fakeFramework)
Expand All @@ -253,7 +253,7 @@ func TestRunNew_GofastaReplaceBadPath(t *testing.T) {
func setupGoMod(t *testing.T) {
t.Helper()
require.NoError(t, os.WriteFile("go.mod",
[]byte("module testproject\n\ngo 1.25.8\n"), 0o644))
[]byte("module testproject\n\ngo 1.25.0\n"), 0o644))
}

// chdirTemp is a lightweight helper that pins the test to a fresh temp
Expand Down
51 changes: 25 additions & 26 deletions internal/commands/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import (
var routesCmd = &cobra.Command{
Use: "routes",
Short: "List every registered REST route in a table (method, path, source)",
Long: `Statically parse every file under app/rest/routes/ for ` + "`router.GET`" + ` /
` + "`router.POST`" + ` / ` + "`router.PUT`" + ` / ` + "`router.DELETE`" + ` / ` + "`router.PATCH`" + ` calls and print
a formatted table showing HTTP method, full path (including router group
prefixes), and source file. Does not import or run your project code —
purely a grep-and-format pass.
Long: `Statically parse every file under app/rest/routes/ for chi router
method calls (` + "`r.Get`" + `, ` + "`r.Post`" + `, ` + "`r.Put`" + `, ` + "`r.Delete`" + `, ` + "`r.Patch`" + `) and print
a formatted table showing HTTP method, full path (including mounted
subrouter prefixes), and source file. Does not import or run your project
code — purely a grep-and-format pass.

Useful for debugging route conflicts, documenting the public API, and
spotting unregistered handlers. For GraphQL schema introspection use
Expand All @@ -38,9 +38,9 @@ type routeEntry struct {
}

var (
handleFuncRe = regexp.MustCompile(`\.HandleFunc\("([^"]+)",.+\.Methods\("([^"]+)"\)`)
prefixRe = regexp.MustCompile(`\.PathPrefix\("([^"]+)"\)\.Subrouter\(\)`)
pathHandlerRe = regexp.MustCompile(`\.PathPrefix\("([^"]+)"\)\.Handler\(`)
routeMethodRe = regexp.MustCompile(`\.(Get|Post|Put|Delete|Patch|Head|Options)\("([^"]+)",`)
mountRe = regexp.MustCompile(`\.Mount\("([^"]+)",`)
wildcardRe = regexp.MustCompile(`\.Handle\("([^"]+\*)",`)
)

func runRoutes() error {
Expand All @@ -54,11 +54,11 @@ func runRoutes() error {
return fmt.Errorf("failed to read routes directory: %w", err)
}

// Extract API prefix from index file
// Extract API prefix from index file via the chi Mount call.
apiPrefix := ""
indexPath := routesDir + "/index.routes.go"
if indexContent, err := os.ReadFile(indexPath); err == nil {
if matches := prefixRe.FindSubmatch(indexContent); len(matches) > 1 {
if matches := mountRe.FindSubmatch(indexContent); len(matches) > 1 {
apiPrefix = string(matches[1])
}
}
Expand Down Expand Up @@ -98,29 +98,28 @@ func runRoutes() error {
}

func extractRoutes(content, prefix, filename string) []routeEntry {
var routes []routeEntry

// Match .HandleFunc("path", ...).Methods("METHOD")
for _, m := range handleFuncRe.FindAllStringSubmatch(content, -1) {
methodMatches := routeMethodRe.FindAllStringSubmatch(content, -1)
wildcardMatches := wildcardRe.FindAllStringSubmatch(content, -1)
routes := make([]routeEntry, 0, len(methodMatches)+len(wildcardMatches))

// Match r.Get("path", ...) / r.Post / r.Put / r.Delete / r.Patch — chi's
// method-based API. The captured method name is already uppercase-first,
// so convert to HTTP verb with ToUpper.
for _, m := range methodMatches {
routes = append(routes, routeEntry{
method: m[2],
path: prefix + m[1],
method: strings.ToUpper(m[1]),
path: prefix + m[2],
filename: filename,
})
}

// Match .PathPrefix("path").Handler(...) — used by swagger UI and
// other prefix-mounted handlers. Shown as GET since they serve content.
for _, m := range pathHandlerRe.FindAllStringSubmatch(content, -1) {
path := m[1]
// Don't pick up the API subrouter prefix (e.g. /api/v1) — that's
// already handled by prefixRe and used as a prefix for child routes.
if path == prefix || path == prefix+"/" {
continue
}
// Match r.Handle("path/*", ...) — used by swagger UI and other
// wildcard-mounted handlers. Shown as GET since they serve content.
// chi patterns already include the trailing wildcard, so display as-is.
for _, m := range wildcardMatches {
routes = append(routes, routeEntry{
method: "GET",
path: path + "*",
path: m[1],
filename: filename,
})
}
Expand Down
Loading
Loading