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
7 changes: 5 additions & 2 deletions internal/cli/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,15 @@ func TestChangelogPrintsPublishedReleaseNotes(t *testing.T) {
output := &bytes.Buffer{}
app := testApp(output, &memoryStore{}, &fakeDevice{}, &fakeService{})
app.FetchReleases = func(context.Context) ([]github.Release, error) {
return []github.Release{{Tag: "v3.0.0", Name: "Launch", Body: "Everything you need.", PublishedAt: time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC)}}, nil
return []github.Release{{
Tag: "v3.0.0-beta.1", Name: "Beta", Body: "Everything\x1b[31m you need.\x07",
PublishedAt: time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC), Prerelease: true,
}}, nil
}
if err := app.Run(context.Background(), []string{"changelog"}); err != nil {
t.Fatal(err)
}
if got := output.String(); !strings.Contains(got, "v3.0.0: Launch") || !strings.Contains(got, "2026-08-21") || !strings.Contains(got, "Everything you need.") {
if got := output.String(); !strings.Contains(got, "v3.0.0-beta.1: Beta (prerelease)") || !strings.Contains(got, "2026-08-21") || !strings.Contains(got, "Everything[31m you need.") || !strings.Contains(got, "Showing up to 10 latest releases") || strings.ContainsRune(got, '\x1b') || strings.ContainsRune(got, '\x07') {
t.Fatalf("output = %q", got)
}
}
Expand Down
20 changes: 19 additions & 1 deletion internal/cli/changelog.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"strings"

"github.com/axadrn/goilerplate/v3/internal/github"
)

func (a *App) changelog(ctx context.Context, arguments []string) error {
Expand Down Expand Up @@ -32,13 +34,29 @@ func (a *App) changelog(ctx context.Context, arguments []string) error {
} else {
name = release.Tag + ": " + name
}
if release.Prerelease {
name += " (prerelease)"
}
fmt.Fprintln(a.Out, name)
if !release.PublishedAt.IsZero() {
fmt.Fprintln(a.Out, release.PublishedAt.Format("2006-01-02"))
}
if body := strings.TrimSpace(release.Body); body != "" {
if body := strings.TrimSpace(safeTerminalText(release.Body)); body != "" {
fmt.Fprintln(a.Out, body)
}
}
fmt.Fprintf(a.Out, "\nShowing up to %d latest releases. View all: %s\n", github.ReleaseLimit, github.ReleasesPageURL)
return nil
}

func safeTerminalText(value string) string {
return strings.Map(func(character rune) rune {
if character == '\n' || character == '\t' {
return character
}
if character < 0x20 || character >= 0x7f && character <= 0x9f {
return -1
}
return character
}, value)
}
88 changes: 59 additions & 29 deletions internal/doctor/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
Expand All @@ -17,6 +18,8 @@ import (

type Level int

const maxEnvironmentSize = 1 << 20

const (
LevelOK Level = iota
LevelWarning
Expand Down Expand Up @@ -65,16 +68,20 @@ func (i Inspector) Inspect(ctx context.Context, directory string) Report {
module, requiredGo, err := readModule(filepath.Join(root, "go.mod"))
if err != nil {
report = report.add("go.mod", err.Error(), LevelError)
} else if module != lock.Answers.ModulePath {
report = report.add("go.mod", fmt.Sprintf("module is %s, lock expects %s", module, lock.Answers.ModulePath), LevelError)
} else {
report = report.add("go.mod", module, LevelOK)
if module != lock.Answers.ModulePath {
report = report.add("go.mod", fmt.Sprintf("module is %s, lock expects %s", module, lock.Answers.ModulePath), LevelError)
} else {
report = report.add("go.mod", module, LevelOK)
}
if requiredGo != "" {
report = i.checkVersion(ctx, report, "go", []string{"env", "GOVERSION"}, requiredGo, "Install Go "+requiredGo+" or newer")
}
}

report = i.checkVersion(ctx, report, "go", []string{"env", "GOVERSION"}, requiredGo, "Install Go "+requiredGo+" or newer")
report = i.checkVersion(ctx, report, "git", []string{"--version"}, "2.38.0", "Install Git 2.38 or newer for goilerplate update")
report = i.checkTool(report, "task", "Install Task from https://taskfile.dev/installation/")
report = i.checkTool(report, "tailwindcss", "Install the Tailwind CSS CLI from https://tailwindcss.com/docs/installation/tailwind-cli")
report = i.checkTool(report, "task", "Optional. Install Task from https://taskfile.dev/installation/", LevelWarning)
report = i.checkTool(report, "tailwindcss", "Optional. Install the Tailwind CSS CLI from https://tailwindcss.com/docs/installation/tailwind-cli", LevelWarning)

environmentPath := filepath.Join(root, ".env")
if _, err := os.Stat(environmentPath); err == nil {
Expand All @@ -90,12 +97,12 @@ func (i Inspector) Inspect(ctx context.Context, directory string) Report {
}
}
} else if errors.Is(err, os.ErrNotExist) {
report = report.add(".env", "missing, run: cp .env.example .env", LevelWarning)
report = report.add(".env", "missing. Create .env from .env.example", LevelWarning)
} else {
report = report.add(".env", err.Error(), LevelWarning)
}
if lock.Answers.Database == "postgres" || lock.Answers.Mail == "smtp" {
report = i.checkTool(report, "docker", "Install Docker or provide the selected services yourself")
report = i.checkTool(report, "docker", "Optional. Install Docker or provide the selected services yourself", LevelWarning)
}
return report
}
Expand All @@ -106,8 +113,16 @@ func readEnvironment(name string) (map[string]string, error) {
return nil, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, err
}
if info.Size() > maxEnvironmentSize {
return nil, fmt.Errorf(".env is larger than %d bytes", maxEnvironmentSize)
}
values := map[string]string{}
scanner := bufio.NewScanner(file)
scanner := bufio.NewScanner(io.LimitReader(file, maxEnvironmentSize+1))
scanner.Buffer(make([]byte, 64*1024), maxEnvironmentSize)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
Expand Down Expand Up @@ -178,10 +193,10 @@ func (r Report) add(name, message string, level Level) Report {
return r
}

func (i Inspector) checkTool(report Report, name, fix string) Report {
func (i Inspector) checkTool(report Report, name, fix string, missingLevel Level) Report {
path, err := i.LookPath(name)
if err != nil {
return report.add(name, fix, LevelError)
return report.add(name, fix, missingLevel)
}
return report.add(name, path, LevelOK)
}
Expand Down Expand Up @@ -230,7 +245,8 @@ func readModule(name string) (string, string, error) {
var module, goVersion string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
line, _, _ := strings.Cut(scanner.Text(), "//")
fields := strings.Fields(line)
if len(fields) == 2 && fields[0] == "module" {
module = fields[1]
}
Expand All @@ -241,29 +257,27 @@ func readModule(name string) (string, string, error) {
if err := scanner.Err(); err != nil {
return "", "", err
}
if module == "" || goVersion == "" {
return "", "", errors.New("go.mod must contain module and go directives")
if module == "" {
return "", "", errors.New("go.mod must contain a module directive")
}
return module, goVersion, nil
}

func firstVersion(value string) (string, bool) {
value = strings.TrimSpace(strings.TrimPrefix(value, "go"))
for _, field := range strings.Fields(value) {
candidate := strings.TrimPrefix(field, "go")
parts := strings.Split(candidate, ".")
if len(parts) >= 2 {
if _, err := strconv.Atoi(parts[0]); err == nil {
return candidate, true
}
candidate := strings.TrimLeftFunc(field, func(character rune) bool {
return character < '0' || character > '9'
})
if _, count := numericVersion(candidate); count >= 2 {
return candidate, true
}
}
return "", false
}

func compareVersions(left, right string) int {
leftParts := numericVersion(left)
rightParts := numericVersion(right)
leftParts, _ := numericVersion(left)
rightParts, _ := numericVersion(right)
for index := 0; index < 3; index++ {
if leftParts[index] < rightParts[index] {
return -1
Expand All @@ -275,12 +289,28 @@ func compareVersions(left, right string) int {
return 0
}

func numericVersion(value string) [3]int {
func numericVersion(value string) ([3]int, int) {
var result [3]int
for index, part := range strings.SplitN(value, ".", 3) {
part = strings.TrimLeftFunc(part, func(character rune) bool { return character < '0' || character > '9' })
part = strings.TrimRightFunc(part, func(character rune) bool { return character < '0' || character > '9' })
result[index], _ = strconv.Atoi(part)
count := 0
for _, part := range strings.Split(value, ".") {
if count == len(result) {
break
}
digits := part
if end := strings.IndexFunc(part, func(character rune) bool {
return character < '0' || character > '9'
}); end >= 0 {
digits = part[:end]
}
if digits == "" {
break
}
number, err := strconv.Atoi(digits)
if err != nil {
break
}
result[count] = number
count++
}
return result
return result, count
}
61 changes: 56 additions & 5 deletions internal/doctor/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,35 @@ func TestInspectReportsModuleVersionsAndMissingTools(t *testing.T) {
inspector := fakeInspector(map[string]string{"go": "go1.24.0", "git": "git version 2.37.0"})

report := inspector.Inspect(context.Background(), root)
if report.Errors != 5 {
if report.Errors != 3 {
t.Fatalf("errors = %d, checks = %#v", report.Errors, report.Checks)
}
for _, name := range []string{"go.mod", "go", "git", "task", "tailwindcss"} {
for _, name := range []string{"go.mod", "go", "git"} {
if !hasCheck(report, name, LevelError) {
t.Fatalf("missing failed check %q in %#v", name, report.Checks)
}
}
for _, name := range []string{"task", "tailwindcss"} {
if !hasCheck(report, name, LevelWarning) {
t.Fatalf("missing optional warning %q in %#v", name, report.Checks)
}
}
}

func TestInspectAllowsCommentsAndNoGoVersionGate(t *testing.T) {
root := t.TempDir()
writeProject(t, root, api.ProjectLock{
SchemaVersion: api.LockSchemaVersion,
TemplateVersion: "v3.0.0",
Answers: api.GenerationAnswers{ModulePath: "example.com/acme", Edition: "free", Database: "sqlite", Mail: "resend"},
})
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/acme // generated project\n"), 0o644); err != nil {
t.Fatal(err)
}
report := fakeInspector(map[string]string{"git": "git version 2.50.1"}).Inspect(context.Background(), root)
if report.Errors != 0 || hasCheckName(report, "go") {
t.Fatalf("report = %#v", report)
}
}

func TestInspectValidatesSelectedEnvironment(t *testing.T) {
Expand Down Expand Up @@ -116,9 +137,12 @@ func TestCompareVersions(t *testing.T) {
left, right string
want int
}{
"older": {"2.37.9", "2.38.0", -1},
"equal": {"1.25", "1.25.0", 0},
"newer": {"1.25.7", "1.25.0", 1},
"older": {"2.37.9", "2.38.0", -1},
"equal": {"1.25", "1.25.0", 0},
"newer": {"1.25.7", "1.25.0", 1},
"four components": {"2.38.0.1", "2.38.0", 0},
"windows suffix": {"2.38.1.windows.1", "2.38.1", 0},
"release candidate": {"1.26rc1", "1.26.0", 0},
} {
t.Run(name, func(t *testing.T) {
if got := compareVersions(test.left, test.right); got != test.want {
Expand All @@ -128,6 +152,24 @@ func TestCompareVersions(t *testing.T) {
}
}

func TestFirstVersionReadsToolOutputVariants(t *testing.T) {
for _, value := range []string{"go1.25.7", "go version go1.26rc1 windows/amd64", "git version 2.50.1.windows.1", "git version 2.50.1.2"} {
if _, ok := firstVersion(value); !ok {
t.Fatalf("firstVersion(%q) did not find a version", value)
}
}
}

func TestReadEnvironmentRejectsOversizedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(strings.Repeat("A", maxEnvironmentSize+1)), 0o600); err != nil {
t.Fatal(err)
}
if _, err := readEnvironment(path); err == nil || !strings.Contains(err.Error(), "larger") {
t.Fatalf("readEnvironment() error = %v", err)
}
}

func fakeInspector(outputs map[string]string) Inspector {
return Inspector{
LookPath: func(name string) (string, error) {
Expand Down Expand Up @@ -164,3 +206,12 @@ func hasCheck(report Report, name string, level Level) bool {
}
return false
}

func hasCheckName(report Report, name string) bool {
for _, check := range report.Checks {
if check.Name == name {
return true
}
}
return false
}
25 changes: 25 additions & 0 deletions internal/github/releases.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"time"
)

const ReleaseLimit = 10
const ReleasesPageURL = "https://github.com/axadrn/goilerplate/releases"
const releasesURL = "https://api.github.com/repos/axadrn/goilerplate/releases?per_page=10"
const maxReleasesResponseSize = 1 << 20

Expand All @@ -19,6 +22,7 @@ type Release struct {
Body string `json:"body"`
PublishedAt time.Time `json:"published_at"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}

func ListReleases(ctx context.Context, client *http.Client) ([]Release, error) {
Expand All @@ -37,6 +41,11 @@ func ListReleases(ctx context.Context, client *http.Client) ([]Release, error) {
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
if response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusTooManyRequests {
if reset, ok := rateLimitReset(response.Header, time.Now().UTC()); ok {
return nil, fmt.Errorf("load release notes: GitHub rate limit reached. Try again after %s", reset.Format(time.RFC3339))
}
}
return nil, fmt.Errorf("load release notes: GitHub returned %s", response.Status)
}
content, err := io.ReadAll(io.LimitReader(response.Body, maxReleasesResponseSize+1))
Expand All @@ -58,3 +67,19 @@ func ListReleases(ctx context.Context, client *http.Client) ([]Release, error) {
}
return published, nil
}

func rateLimitReset(header http.Header, now time.Time) (time.Time, bool) {
if value := header.Get("Retry-After"); value != "" {
seconds, err := strconv.ParseInt(value, 10, 64)
if err == nil && seconds >= 0 {
return now.Add(time.Duration(seconds) * time.Second), true
}
}
if value := header.Get("X-RateLimit-Reset"); value != "" {
seconds, err := strconv.ParseInt(value, 10, 64)
if err == nil && seconds > 0 {
return time.Unix(seconds, 0).UTC(), true
}
}
return time.Time{}, false
}
Loading