diff --git a/README.md b/README.md index e2f11cc..dfc102c 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ fiber migrate --to 3.0.0 -f, --force Force migration even if already on the version -s, --skip_go_mod Skip running go mod tidy, download and vendor --hash string Commit hash for Fiber version + --third-party strings Refresh third-party modules (contrib,storage,template). Provide a comma-separated list and optionally append @ to pin a commit -v, --verbose Enable verbose output -h, --help help for migrate ``` diff --git a/cmd/migrate.go b/cmd/migrate.go index 773889d..bfa33a3 100644 --- a/cmd/migrate.go +++ b/cmd/migrate.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" "golang.org/x/mod/module" + "github.com/gofiber/cli/cmd/internal" "github.com/gofiber/cli/cmd/internal/migrations" ) @@ -25,6 +26,7 @@ func newMigrateCmd() *cobra.Command { var force bool var skipGoMod bool var verbose bool + var thirdParty []string cmd := &cobra.Command{ Use: "migrate", @@ -36,8 +38,21 @@ func newMigrateCmd() *cobra.Command { cmd.Flags().BoolVarP(&skipGoMod, "skip_go_mod", "s", false, "Skip running go mod tidy, download and vendor") cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output") cmd.Flags().StringVar(&targetHash, "hash", "", "Commit hash for Fiber version") + cmd.Flags().StringSliceVar(&thirdParty, "third-party", nil, "Refresh third-party modules (contrib,storage,template). Use a comma-separated list like --third-party=contrib,storage and append @ to pin a commit") cmd.RunE = func(cmd *cobra.Command, _ []string) error { + tps := make([]ThirdPartyParam, 0, len(thirdParty)) + for _, tp := range thirdParty { + parts := strings.SplitN(tp, "@", 2) + p := ThirdPartyParam{Name: parts[0]} + if len(parts) == 2 { + p.Hash = parts[1] + } + if p.Name != "" { + tps = append(tps, p) + } + } + return migrateRunE(cmd, MigrateOptions{ CurrentVersionFile: currentVersionFile, TargetVersionS: targetVersionS, @@ -45,6 +60,7 @@ func newMigrateCmd() *cobra.Command { Force: force, SkipGoMod: skipGoMod, Verbose: verbose, + ThirdParty: tps, }) } @@ -57,11 +73,17 @@ type MigrateOptions struct { CurrentVersionFile string TargetVersionS string TargetHash string + ThirdParty []ThirdPartyParam Force bool SkipGoMod bool Verbose bool } +type ThirdPartyParam struct { + Name string + Hash string +} + func migrateRunE(cmd *cobra.Command, opts MigrateOptions) error { currentVersionS, err := currentVersionFromFile(opts.CurrentVersionFile) if err != nil { @@ -84,7 +106,7 @@ func migrateRunE(cmd *cobra.Command, opts MigrateOptions) error { targetVersion := baseVersion if opts.TargetHash != "" { - pv, err := pseudoVersionFromHash(baseVersion, opts.TargetHash) + pv, err := pseudoVersionFromHash("gofiber/fiber", baseVersion, opts.TargetHash) if err != nil { return fmt.Errorf("pseudo version: %w", err) } @@ -121,6 +143,33 @@ func migrateRunE(cmd *cobra.Command, opts MigrateOptions) error { return fmt.Errorf("migration failed %w", err) } + tpChanged := false + for _, tp := range opts.ThirdParty { + var ( + changed bool + err error + ) + switch tp.Name { + case "contrib": + changed, err = refreshContrib(cmd, wd, tp.Hash) + case "storage": + changed, err = refreshStorage(cmd, wd, tp.Hash) + case "template", "templates": + changed, err = refreshTemplates(cmd, wd, tp.Hash) + } + if err != nil { + return fmt.Errorf("refresh %s packages: %w", tp.Name, err) + } + if changed { + tpChanged = true + } + } + if tpChanged && !opts.SkipGoMod { + if err := internal.RunGoMod(wd); err != nil { + return fmt.Errorf("go mod: %w", err) + } + } + msg := fmt.Sprintf("Migration from Fiber %s to %s", migrateFromS, opts.TargetVersionS) cmd.Println(termenv.String(msg). Foreground(termenv.ANSIBrightBlue)) @@ -128,8 +177,8 @@ func migrateRunE(cmd *cobra.Command, opts MigrateOptions) error { return nil } -func pseudoVersionFromHash(base *semver.Version, hash string) (string, error) { - url := "https://api.github.com/repos/gofiber/fiber/commits/" + hash +func pseudoVersionFromHash(repo string, base *semver.Version, hash string) (string, error) { + url := "https://api.github.com/repos/" + repo + "/commits/" + hash ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/cmd/third_party.go b/cmd/third_party.go new file mode 100644 index 0000000..8e28a03 --- /dev/null +++ b/cmd/third_party.go @@ -0,0 +1,339 @@ +package cmd + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/spf13/cobra" + + "github.com/gofiber/cli/cmd/internal" +) + +var ( + latestContribVersionFn = latestContribVersion + latestStorageVersionFn = func(module, major string) string { return latestThirdPartyVersion("storage", module, major) } + latestTemplateVersionFn = func(module, major string) string { return latestThirdPartyVersion("template", module, major) } +) + +const vendorDir = "vendor" + +func refreshContrib(cmd *cobra.Command, cwd, hash string) (bool, error) { + modules, err := findContribModules(cwd) + if err != nil { + return false, fmt.Errorf("find modules: %w", err) + } + if len(modules) == 0 { + return false, nil + } + + versions := make(map[string]string, len(modules)) + if hash == "" { + reader := bufio.NewReader(cmd.InOrStdin()) + for _, m := range modules { + latest := latestContribVersionFn(m) + prompt := fmt.Sprintf("Version for github.com/gofiber/contrib/%s (default %s): ", m, latest) + cmd.Print(prompt) + line, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return false, fmt.Errorf("read input: %w", err) + } + v := strings.TrimSpace(line) + if v == "" { + v = latest + } + if v != "" { + versions[m] = v + } + } + } else { + for _, m := range modules { + latest := latestContribVersionFn(m) + if latest == "" { + continue + } + base, err := semver.NewVersion(strings.TrimPrefix(latest, "v")) + if err != nil { + return false, fmt.Errorf("parse version: %w", err) + } + pv, err := pseudoVersionFromHash("gofiber/contrib", base, hash) + if err != nil { + return false, fmt.Errorf("pseudo version: %w", err) + } + versions[m] = pv + } + } + if len(versions) == 0 { + return false, nil + } + + re := regexp.MustCompile(`"github\.com/gofiber/contrib(?:/v\d+)?/([a-zA-Z0-9_]+)(?:/v\d+)?([^\"]*)"`) + changed, err := internal.ChangeFileContent(cwd, func(content string) string { + return re.ReplaceAllStringFunc(content, func(s string) string { + sub := re.FindStringSubmatch(s) + mod := sub[1] + rest := sub[2] + ver, ok := versions[mod] + if !ok { + return s + } + major := majorFromVersion(ver) + return fmt.Sprintf("\"github.com/gofiber/contrib/%s%s%s\"", mod, majorPath(major), rest) + }) + }) + if err != nil { + return false, fmt.Errorf("refresh imports: %w", err) + } + + modFile := filepath.Join(cwd, "go.mod") + b, err := os.ReadFile(modFile) // #nosec G304 + if err != nil && !os.IsNotExist(err) { + return false, fmt.Errorf("read go.mod: %w", err) + } + changedMod := false + if err == nil { + content := string(b) + for mod, ver := range versions { + major := majorFromVersion(ver) + re := regexp.MustCompile(fmt.Sprintf(`(?m)^(\s*(?:require\s+)?)github.com/gofiber/contrib/(?:v\d+/)?%s(?:/v\d+)?\s+v[\w\.-]+`, regexp.QuoteMeta(mod))) + newLine := fmt.Sprintf(`${1}github.com/gofiber/contrib/%s%s %s`, mod, majorPath(major), ver) + replaced := re.ReplaceAllString(content, newLine) + if replaced != content { + content = replaced + changedMod = true + } + } + if changedMod { + if err := os.WriteFile(modFile, []byte(content), 0o600); err != nil { + return false, fmt.Errorf("write go.mod: %w", err) + } + } + } + + if changed || changedMod { + cmd.Println("Refreshing contrib packages") + } + return changed || changedMod, nil +} + +func findContribModules(cwd string) ([]string, error) { + modules := make(map[string]struct{}) + re := regexp.MustCompile(`(?:^|[^\w])github\.com/gofiber/contrib/(?:v\d+/)?([a-zA-Z0-9_]+)\b`) // capture module name, anchored to avoid partial matches + err := filepath.WalkDir(cwd, func(path string, d os.DirEntry, err error) error { + if err != nil { + return fmt.Errorf("walk path: %w", err) + } + if d.IsDir() { + if d.Name() == vendorDir { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(d.Name(), ".go") { + return nil + } + b, err := os.ReadFile(path) // #nosec G304 + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + matches := re.FindAllStringSubmatch(string(b), -1) + for _, m := range matches { + modules[m[1]] = struct{}{} + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("walk %s: %w", cwd, err) + } + res := make([]string, 0, len(modules)) + for m := range modules { + res = append(res, m) + } + sort.Strings(res) + return res, nil +} + +func latestContribVersion(module string) string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + url := fmt.Sprintf("https://proxy.golang.org/github.com/gofiber/contrib/%s/@latest", module) + b, status, err := cachedGET(ctx, url, nil) + if err != nil || status != 200 { + return "" + } + var data struct { + Version string `json:"Version"` //nolint:tagliatelle // field name defined by proxy + } + if err := json.Unmarshal(b, &data); err != nil { + return "" + } + return data.Version +} + +func majorFromVersion(v string) string { + v = strings.TrimPrefix(v, "v") + idx := strings.IndexAny(v, ".-") + if idx >= 0 { + v = v[:idx] + } + return "v" + v +} + +func majorPath(major string) string { + if major == "" || major == "v0" || major == "v1" { + return "" + } + return "/" + major +} + +func refreshThirdParty(cmd *cobra.Command, cwd, hash, repo, label string, latestFn func(string, string) string) (bool, error) { + modules, err := findThirdPartyModules(cwd, repo) + if err != nil { + return false, fmt.Errorf("find modules: %w", err) + } + if len(modules) == 0 { + return false, nil + } + + versions := make(map[string]string, len(modules)) + for mod, curMajor := range modules { + latest := latestFn(mod, curMajor) + if latest == "" { + continue + } + ver := latest + if hash != "" { + base, err := semver.NewVersion(strings.TrimPrefix(latest, "v")) + if err != nil { + return false, fmt.Errorf("parse version: %w", err) + } + pv, err := pseudoVersionFromHash("gofiber/"+repo, base, hash) + if err != nil { + return false, fmt.Errorf("pseudo version: %w", err) + } + ver = pv + } + versions[mod] = ver + } + if len(versions) == 0 { + return false, nil + } + + re := regexp.MustCompile(fmt.Sprintf(`"github\.com/gofiber/%s/([a-zA-Z0-9_]+)(?:/v\d+)?([^\"]*)"`, regexp.QuoteMeta(repo))) + changed, err := internal.ChangeFileContent(cwd, func(content string) string { + return re.ReplaceAllStringFunc(content, func(s string) string { + sub := re.FindStringSubmatch(s) + mod := sub[1] + rest := sub[2] + ver, ok := versions[mod] + if !ok { + return s + } + major := majorFromVersion(ver) + return fmt.Sprintf("\"github.com/gofiber/%s/%s%s%s\"", repo, mod, majorPath(major), rest) + }) + }) + if err != nil { + return false, fmt.Errorf("refresh imports: %w", err) + } + + modFile := filepath.Join(cwd, "go.mod") + b, err := os.ReadFile(modFile) // #nosec G304 + if err != nil && !os.IsNotExist(err) { + return false, fmt.Errorf("read go.mod: %w", err) + } + changedMod := false + if err == nil { + content := string(b) + for mod, ver := range versions { + major := majorFromVersion(ver) + re := regexp.MustCompile(fmt.Sprintf(`(?m)^(\s*(?:require\s+)?)github.com/gofiber/%s/%s(?:/v\d+)?\s+v[\w\.-]+`, regexp.QuoteMeta(repo), regexp.QuoteMeta(mod))) + newLine := fmt.Sprintf(`${1}github.com/gofiber/%s/%s%s %s`, repo, mod, majorPath(major), ver) + replaced := re.ReplaceAllString(content, newLine) + if replaced != content { + content = replaced + changedMod = true + } + } + if changedMod { + if err := os.WriteFile(modFile, []byte(content), 0o600); err != nil { + return false, fmt.Errorf("write go.mod: %w", err) + } + } + } + + if changed || changedMod { + cmd.Printf("Refreshing %s packages\n", label) + } + return changed || changedMod, nil +} + +func refreshStorage(cmd *cobra.Command, cwd, hash string) (bool, error) { + return refreshThirdParty(cmd, cwd, hash, "storage", "storage", latestStorageVersionFn) +} + +func refreshTemplates(cmd *cobra.Command, cwd, hash string) (bool, error) { + return refreshThirdParty(cmd, cwd, hash, "template", "template", latestTemplateVersionFn) +} + +func findThirdPartyModules(cwd, repo string) (map[string]string, error) { + modules := make(map[string]string) + re := regexp.MustCompile(fmt.Sprintf(`(?:^|[^\w])github\.com/gofiber/%s/([a-zA-Z0-9_]+)(?:/(v\d+))?\b`, regexp.QuoteMeta(repo))) + err := filepath.WalkDir(cwd, func(path string, d os.DirEntry, err error) error { + if err != nil { + return fmt.Errorf("walk path: %w", err) + } + if d.IsDir() { + if d.Name() == vendorDir { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(d.Name(), ".go") { + return nil + } + b, err := os.ReadFile(path) // #nosec G304 + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + matches := re.FindAllStringSubmatch(string(b), -1) + for _, m := range matches { + modules[m[1]] = m[2] + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("walk %s: %w", cwd, err) + } + return modules, nil +} + +func latestThirdPartyVersion(repo, module, major string) string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + url := fmt.Sprintf("https://proxy.golang.org/github.com/gofiber/%s/%s", repo, module) + if major != "" { + url += "/" + major + } + url += "/@latest" + b, status, err := cachedGET(ctx, url, nil) + if err != nil || status != 200 { + return "" + } + var data struct { + Version string `json:"Version"` //nolint:tagliatelle // field name defined by proxy + } + if err := json.Unmarshal(b, &data); err != nil { + return "" + } + return data.Version +} diff --git a/cmd/third_party_test.go b/cmd/third_party_test.go new file mode 100644 index 0000000..e67a391 --- /dev/null +++ b/cmd/third_party_test.go @@ -0,0 +1,118 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_refreshContrib(t *testing.T) { + dir := t.TempDir() + mainSrc := `package main + +import _ "github.com/gofiber/contrib/monitor" + +func main(){}` + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainSrc), 0o600)) + modSrc := `module test + +require github.com/gofiber/contrib/monitor v1.0.0 +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(modSrc), 0o600)) + + old := latestContribVersionFn + latestContribVersionFn = func(string) string { return "v1.2.3" } + defer func() { latestContribVersionFn = old }() + + c := &cobra.Command{} + c.SetIn(bytes.NewBufferString("\n")) + var buf bytes.Buffer + c.SetOut(&buf) + + changed, err := refreshContrib(c, dir, "") + require.NoError(t, err) + assert.True(t, changed) + + content, err := os.ReadFile(filepath.Join(dir, "main.go")) // #nosec G304 + require.NoError(t, err) + assert.Contains(t, string(content), "github.com/gofiber/contrib/monitor") + + gm, err := os.ReadFile(filepath.Join(dir, "go.mod")) // #nosec G304 + require.NoError(t, err) + assert.Contains(t, string(gm), "github.com/gofiber/contrib/monitor v1.2.3") +} + +func Test_refreshStorage(t *testing.T) { + dir := t.TempDir() + mainSrc := `package main + +import _ "github.com/gofiber/storage/redis" + +func main(){}` + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainSrc), 0o600)) + modSrc := `module test + +require github.com/gofiber/storage/redis v1.0.0 +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(modSrc), 0o600)) + + old := latestStorageVersionFn + latestStorageVersionFn = func(string, string) string { return "v2.3.4" } + defer func() { latestStorageVersionFn = old }() + + c := &cobra.Command{} + var buf bytes.Buffer + c.SetOut(&buf) + + changed, err := refreshStorage(c, dir, "") + require.NoError(t, err) + assert.True(t, changed) + + content, err := os.ReadFile(filepath.Join(dir, "main.go")) // #nosec G304 + require.NoError(t, err) + assert.Contains(t, string(content), "github.com/gofiber/storage/redis/v2") + + gm, err := os.ReadFile(filepath.Join(dir, "go.mod")) // #nosec G304 + require.NoError(t, err) + assert.Contains(t, string(gm), "github.com/gofiber/storage/redis/v2 v2.3.4") +} + +func Test_refreshTemplates(t *testing.T) { + dir := t.TempDir() + mainSrc := `package main + +import _ "github.com/gofiber/template/html/v2" + +func main(){}` + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainSrc), 0o600)) + modSrc := `module test + +require github.com/gofiber/template/html/v2 v2.0.0 +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(modSrc), 0o600)) + + old := latestTemplateVersionFn + latestTemplateVersionFn = func(string, string) string { return "v3.2.1" } + defer func() { latestTemplateVersionFn = old }() + + c := &cobra.Command{} + var buf bytes.Buffer + c.SetOut(&buf) + + changed, err := refreshTemplates(c, dir, "") + require.NoError(t, err) + assert.True(t, changed) + + content, err := os.ReadFile(filepath.Join(dir, "main.go")) // #nosec G304 + require.NoError(t, err) + assert.Contains(t, string(content), "github.com/gofiber/template/html/v3") + + gm, err := os.ReadFile(filepath.Join(dir, "go.mod")) // #nosec G304 + require.NoError(t, err) + assert.Contains(t, string(gm), "github.com/gofiber/template/html/v3 v3.2.1") +} diff --git a/docs/guide/migrate.md b/docs/guide/migrate.md index e461272..4fc1b5c 100644 --- a/docs/guide/migrate.md +++ b/docs/guide/migrate.md @@ -19,6 +19,7 @@ fiber migrate --to 3.0.0 - `-t`, `--to` – Target version to migrate to. Defaults to the latest release - `--hash` – Commit hash for the Fiber version when migrating to a pseudo version +- `--third-party` – Refresh third-party modules like `contrib`, `storage`, or `template`. Provide a comma-separated list (e.g. `--third-party=contrib,storage`) and append `@` to pin to a specific commit - `-f`, `--force` – Force migration even if already on the target version - `-s`, `--skip_go_mod` – Skip running `go mod tidy`, `go mod download`, and `go mod vendor` - `-v`, `--verbose` – Enable verbose output during migration @@ -43,6 +44,24 @@ Use a commit hash for unreleased versions: fiber migrate --to 3.0.0 --hash abcdef123456 ``` +Refresh contrib packages to their latest versions: + +```bash +fiber migrate --third-party=contrib +``` + +Refresh template packages to a specific commit: + +```bash +fiber migrate --third-party=template@abcdef123456 +``` + +Refresh both contrib and storage packages at once: + +```bash +fiber migrate --third-party=contrib,storage +``` + Force re-running migrations even if the version matches: ```bash