diff --git a/Makefile b/Makefile index 6995fe6..1f7e40e 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,12 @@ -.PHONY: test testjs all +.PHONY: test testjs testjspatch all -all: test testjs +all: test testjs testjspatch test: go test ./... testjs: cd test_js && go run .. --dry-run --verbose + +testjspatch: + cd test_js && go run .. --dry-run --verbose --patch-only \ No newline at end of file diff --git a/ctx.go b/ctx.go index 15cb96c..7c9f263 100644 --- a/ctx.go +++ b/ctx.go @@ -22,8 +22,9 @@ type Context struct { Logger *log.Logger // Flags - DryRun bool - Verbose bool + DryRun bool + PatchOnly bool + Verbose bool } var Ctx = Context{ @@ -32,5 +33,6 @@ var Ctx = Context{ HTTPClient: &http.Client{Timeout: 5 * time.Second}, Logger: log.Default(), DryRun: false, + PatchOnly: false, Verbose: false, } diff --git a/main.go b/main.go index 04fec4f..22a2a78 100644 --- a/main.go +++ b/main.go @@ -59,6 +59,8 @@ func main() { flag.BoolVar(&Ctx.DryRun, "dry-run", false, "Perform a dry run without making any changes") flag.BoolVar(&Ctx.DryRun, "d", false, "Perform a dry run without making any changes (shorthand)") + flag.BoolVar(&Ctx.PatchOnly, "patch-only", false, "Only update patch versions") + flag.BoolVar(&Ctx.PatchOnly, "po", false, "Only update patch versions (shorthand)") flag.BoolVar(&Ctx.Verbose, "verbose", false, "Enable verbose logging") flag.BoolVar(&Ctx.Verbose, "v", false, "Enable verbose logging (shorthand)") diff --git a/npm.go b/npm.go index 538153e..86ab0c4 100644 --- a/npm.go +++ b/npm.go @@ -122,45 +122,37 @@ func getNPMPackageLatestVersion(packageName string) (string, error) { return result.Version, nil } -func outputLogger() *log.Logger { - if Ctx.Logger != nil { - return Ctx.Logger +func getOtherNPMPackageVersions(packageName string) ([]string, error) { + registryURL := "https://registry.npmjs.org/" + url.PathEscape(packageName) + client := Ctx.HTTPClient + if client == nil { + client = http.DefaultClient } - return log.Default() -} - -func formatDependencyDiff(before DependencyVersion, after DependencyVersion) string { - if before.HasSemver && after.HasSemver { - if before.Prefix == "" && after.Prefix == "" { - return before.Semver.Diff(after.Semver) - } - - return fmt.Sprintf("%s -> %s (%s)", before.String(), after.String(), before.Semver.ChangeType(after.Semver)) + resp, err := client.Get(registryURL) + if err != nil { + return []string{}, fmt.Errorf("request error: %w", err) } + defer resp.Body.Close() - return fmt.Sprintf("%s -> %s", before.String(), after.String()) -} - -func (update DependencyUpdate) String() string { - return fmt.Sprintf("%s: %s", update.Name, formatDependencyDiff(update.Before, update.After)) -} + if resp.StatusCode != http.StatusOK { + return []string{}, fmt.Errorf("unexpected status: %d", resp.StatusCode) + } -func printDependencyUpdates(packagePath string, section string, updates []DependencyUpdate) { - if len(updates) == 0 { - return + var result struct { + Versions map[string]json.RawMessage `json:"versions"` } - action := "Updated" - if Ctx.DryRun { - action = "Would update" + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return []string{}, fmt.Errorf("JSON decode error: %w", err) } - logger := outputLogger() - logger.Print(action + " " + section + " in " + packagePath + ":") - for _, update := range updates { - logger.Print("- " + update.String()) + versions := make([]string, 0, len(result.Versions)) + for version := range result.Versions { + versions = append(versions, version) } + + return versions, nil } func updateDependencies(deps []DependencyJSON) ([]DependencyUpdate, error) { @@ -188,8 +180,21 @@ func updateDependencies(deps []DependencyJSON) ([]DependencyUpdate, error) { } if !shouldUpdate { - log.Debugf("Dependency %s already up to date (%s)", dep.Name, changeType) - continue + if !Ctx.PatchOnly || !dep.Version.HasSemver { + log.Debugf("Dependency %s won't update (%s)", dep.Name, changeType) + continue + } + + patchVersion, ok, err := getLatestPatchNPMPackageVersion(dep.Name, dep.Version) + if err != nil { + return nil, fmt.Errorf("failed to fetch other versions for %s: %w", dep.Name, err) + } + if !ok { + log.Debugf("Dependency %s has no available patch update", dep.Name) + continue + } + + latestVersion = patchVersion } updatedVersion := mergeDependencyVersion(dep.Version, latestVersion) @@ -200,9 +205,36 @@ func updateDependencies(deps []DependencyJSON) ([]DependencyUpdate, error) { return updates, nil } +func getLatestPatchNPMPackageVersion(packageName string, currentVersion DependencyVersion) (DependencyVersion, bool, error) { + versions, err := getOtherNPMPackageVersions(packageName) + if err != nil { + return DependencyVersion{}, false, err + } + + var latestPatch DependencyVersion + foundPatch := false + for _, version := range versions { + versionToCheck := parseDependencyVersion(version) + changeTypeForVersionToCheck, shouldUpdateForVersionToCheck := classifyDependencyUpdate(currentVersion, versionToCheck) + if !shouldUpdateForVersionToCheck || changeTypeForVersionToCheck != SemverChangePatch { + continue + } + + if !foundPatch || latestPatch.Semver.LessThan(versionToCheck.Semver) { + latestPatch = versionToCheck + foundPatch = true + } + } + + return latestPatch, foundPatch, nil +} + func classifyDependencyUpdate(currentVersion DependencyVersion, latestVersion DependencyVersion) (SemverChange, bool) { if currentVersion.HasSemver && latestVersion.HasSemver { changeType := currentVersion.Semver.ChangeType(latestVersion.Semver) + if Ctx.PatchOnly && changeType != SemverChangePatch { + return changeType, false + } return changeType, changeType != SemverChangeNone && changeType != SemverChangeDowngrade } @@ -210,6 +242,11 @@ func classifyDependencyUpdate(currentVersion DependencyVersion, latestVersion De return SemverChangeNone, false } + if Ctx.PatchOnly { + log.Warnf("Cannot determine change type for non-semver dependency version '%s' -> '%s', skipping update due to patch-only mode", currentVersion.String(), latestVersion.String()) + return SemverChangeNone, false + } + return SemverChangeInvalid, true } @@ -241,6 +278,47 @@ func depsToMap(deps []DependencyJSON) map[string]string { return depsMap } +func outputLogger() *log.Logger { + if Ctx.Logger != nil { + return Ctx.Logger + } + + return log.Default() +} + +func formatDependencyDiff(before DependencyVersion, after DependencyVersion) string { + if before.HasSemver && after.HasSemver { + if before.Prefix == "" && after.Prefix == "" { + return before.Semver.Diff(after.Semver) + } + + return fmt.Sprintf("%s -> %s (%s)", before.String(), after.String(), before.Semver.ChangeType(after.Semver)) + } + + return fmt.Sprintf("%s -> %s", before.String(), after.String()) +} + +func (update DependencyUpdate) String() string { + return fmt.Sprintf("%s: %s", update.Name, formatDependencyDiff(update.Before, update.After)) +} + +func printDependencyUpdates(packagePath string, section string, updates []DependencyUpdate) { + if len(updates) == 0 { + return + } + + action := "Updated" + if Ctx.DryRun { + action = "Would update" + } + + logger := outputLogger() + logger.Print(action + " " + section + " in " + packagePath + ":") + for _, update := range updates { + logger.Print("- " + update.String()) + } +} + func processNPMPackage(packagePath string) error { packageJSONFile, err := os.ReadFile(packagePath) if err != nil { diff --git a/npm_test.go b/npm_test.go index 1a20bf5..ef104b2 100644 --- a/npm_test.go +++ b/npm_test.go @@ -334,6 +334,54 @@ func TestGetNPMPackageLatestVersion(t *testing.T) { }) } +func TestGetOtherNPMPackageVersions(t *testing.T) { + t.Run("uses versions metadata only", func(t *testing.T) { + client := newRegistryClient(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{ + "time":{"created":"2020-01-01T00:00:00.000Z","modified":"2020-01-02T00:00:00.000Z","1.0.0":"2020-01-01T00:00:00.000Z"}, + "versions":{"1.0.0":{},"1.0.1":{}} + }`) + }) + + withTestContext(t, Context{HTTPClient: client}) + + versions, err := getOtherNPMPackageVersions("react") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + got := map[string]bool{} + for _, version := range versions { + got[version] = true + } + want := map[string]bool{"1.0.0": true, "1.0.1": true} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected versions %v, got %v", want, got) + } + }) + + t.Run("falls back to default client", func(t *testing.T) { + client := newRegistryClient(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"versions":{"2.0.0":{}}}`) + }) + previousDefaultClient := http.DefaultClient + http.DefaultClient = client + t.Cleanup(func() { + http.DefaultClient = previousDefaultClient + }) + + withTestContext(t, Context{}) + + versions, err := getOtherNPMPackageVersions("react") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !reflect.DeepEqual(versions, []string{"2.0.0"}) { + t.Fatalf("expected default client version, got %v", versions) + } + }) +} + func TestProcessNPMPackage(t *testing.T) { t.Run("updates dependencies and preserves document fields", func(t *testing.T) { tempDir := t.TempDir() diff --git a/test_js/package.json b/test_js/package.json index 25449cb..09e8724 100644 --- a/test_js/package.json +++ b/test_js/package.json @@ -10,6 +10,9 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { - "adb-wifi": "^0.1.1" + "adb-wifi": "^0.1.1", + "left-pad": "1.1.2", + "@docusaurus/core": "3.6.2-canary-6154", + "react": "18.3.0" } }