diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index b898ca3..678ef64 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -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) } } diff --git a/internal/cli/changelog.go b/internal/cli/changelog.go index 6b8bb7a..6a82ae9 100644 --- a/internal/cli/changelog.go +++ b/internal/cli/changelog.go @@ -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 { @@ -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) +} diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 0ab75d3..4f30981 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -17,6 +18,8 @@ import ( type Level int +const maxEnvironmentSize = 1 << 20 + const ( LevelOK Level = iota LevelWarning @@ -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 { @@ -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 } @@ -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, "#") { @@ -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) } @@ -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] } @@ -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 @@ -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 } diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index 669c226..d40ea9e 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -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) { @@ -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 { @@ -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) { @@ -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 +} diff --git a/internal/github/releases.go b/internal/github/releases.go index 8535ed8..4a34416 100644 --- a/internal/github/releases.go +++ b/internal/github/releases.go @@ -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 @@ -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) { @@ -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)) @@ -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 +} diff --git a/internal/github/releases_test.go b/internal/github/releases_test.go index 73faccb..c04d122 100644 --- a/internal/github/releases_test.go +++ b/internal/github/releases_test.go @@ -4,8 +4,10 @@ import ( "context" "io" "net/http" + "strconv" "strings" "testing" + "time" ) type roundTripFunc func(*http.Request) (*http.Response, error) @@ -20,7 +22,7 @@ func TestListReleasesReturnsPublishedReleases(t *testing.T) { t.Fatalf("request = %s, headers = %#v", request.URL, request.Header) } body := `[ - {"tag_name":"v3.0.0","name":"Launch","body":"Ready","published_at":"2026-08-21T10:00:00Z"}, + {"tag_name":"v3.0.0-beta.1","name":"Beta","body":"Ready","published_at":"2026-08-21T10:00:00Z","prerelease":true}, {"tag_name":"v3.1.0","draft":true} ]` return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil @@ -30,11 +32,27 @@ func TestListReleasesReturnsPublishedReleases(t *testing.T) { if err != nil { t.Fatal(err) } - if len(releases) != 1 || releases[0].Tag != "v3.0.0" || releases[0].Body != "Ready" { + if len(releases) != 1 || releases[0].Tag != "v3.0.0-beta.1" || releases[0].Body != "Ready" || !releases[0].Prerelease { t.Fatalf("releases = %#v", releases) } } +func TestListReleasesExplainsRateLimitReset(t *testing.T) { + reset := time.Date(2026, 8, 21, 12, 0, 0, 0, time.UTC) + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusForbidden, + Status: "403 Forbidden", + Header: http.Header{"X-Ratelimit-Reset": []string{strconv.FormatInt(reset.Unix(), 10)}}, + Body: io.NopCloser(strings.NewReader("rate limited")), + }, nil + })} + _, err := ListReleases(context.Background(), client) + if err == nil || !strings.Contains(err.Error(), reset.Format(time.RFC3339)) { + t.Fatalf("ListReleases() error = %v", err) + } +} + func TestListReleasesRejectsFailedAndOversizedResponses(t *testing.T) { for name, response := range map[string]*http.Response{ "status": {StatusCode: http.StatusForbidden, Status: "403 Forbidden", Body: io.NopCloser(strings.NewReader("no"))},