diff --git a/.changeset/spotty-carrots-invent.md b/.changeset/spotty-carrots-invent.md new file mode 100644 index 0000000..af367f4 --- /dev/null +++ b/.changeset/spotty-carrots-invent.md @@ -0,0 +1,7 @@ +--- +"@neuledge/registry": minor +--- + +Add `go` as a registry: version discovery through `proxy.golang.org`, with the uppercase-to-`!` module path escaping the proxy requires and the `v` prefix stripped so the shared `isPrerelease` and `compareSemver` keep working. `/@v/list` carries no publish dates, so `--since` filtering is unavailable for Go rather than merely slow. + +Also make `listDefinitions` recurse into any subdirectory, not only `@scope` ones. Every Go module path contains slashes, so `registry/go/github.com/spf13/cobra.yaml` was never loaded — exit code 0, no warning. Scoped npm packages continue to resolve unchanged. diff --git a/packages/registry/src/definition.test.ts b/packages/registry/src/definition.test.ts index 9f53de0..be7960f 100644 --- a/packages/registry/src/definition.test.ts +++ b/packages/registry/src/definition.test.ts @@ -220,6 +220,22 @@ describe("listDefinitions", () => { expect(defs[1].registry).toBe("pip"); }); + it("discovers a definition nested several directories deep", () => { + // Every Go module path contains slashes, so its definition file lands at + // go/github.com/spf13/cobra.yaml. Before the walk became recursive, this + // file was never loaded and the build exited 0 with no warning. + mkdirSync(join(tempDir, "go", "github.com", "spf13"), { recursive: true }); + writeFileSync( + join(tempDir, "go", "github.com", "spf13", "cobra.yaml"), + 'name: github.com/spf13/cobra\nversions:\n - min_version: "1.8.0"\n tag_pattern: "v{version}"\n source:\n type: git\n url: https://github.com/spf13/cobra\n', + ); + + const defs = listDefinitions(tempDir); + expect(defs).toHaveLength(1); + expect(defs[0].name).toBe("github.com/spf13/cobra"); + expect(defs[0].registry).toBe("go"); + }); + it("discovers scoped packages in @scope subdirectories", () => { mkdirSync(join(tempDir, "npm")); mkdirSync(join(tempDir, "npm", "@trpc"), { recursive: true }); diff --git a/packages/registry/src/definition.ts b/packages/registry/src/definition.ts index 370f1d5..1317829 100644 --- a/packages/registry/src/definition.ts +++ b/packages/registry/src/definition.ts @@ -187,31 +187,27 @@ export function loadDefinition( /** * Scan the registry/ directory and load all definitions. - * Supports scoped packages via @scope subdirectories (e.g., npm/@trpc/server.yaml). + * Recurses into subdirectories, so a package name may contain slashes: + * npm/@trpc/server.yaml and go/github.com/spf13/cobra.yaml both work. */ export function listDefinitions(registryDir: string): PackageDefinition[] { const definitions: PackageDefinition[] = []; + const walk = (dir: string, managerDir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, managerDir); + } else if (entry.isFile() && entry.name.endsWith(".yaml")) { + definitions.push(loadDefinition(full, managerDir)); + } + } + }; + for (const manager of readdirSync(registryDir, { withFileTypes: true })) { if (!manager.isDirectory()) continue; - const managerDir = join(registryDir, manager.name); - for (const entry of readdirSync(managerDir, { withFileTypes: true })) { - if (entry.isFile() && entry.name.endsWith(".yaml")) { - definitions.push( - loadDefinition(join(managerDir, entry.name), managerDir), - ); - } else if (entry.isDirectory() && entry.name.startsWith("@")) { - // Scoped package directory (e.g., @trpc/) - const scopeDir = join(managerDir, entry.name); - for (const file of readdirSync(scopeDir, { withFileTypes: true })) { - if (!file.isFile() || !file.name.endsWith(".yaml")) continue; - definitions.push( - loadDefinition(join(scopeDir, file.name), managerDir), - ); - } - } - } + walk(managerDir, managerDir); } return definitions.sort((a, b) => diff --git a/packages/registry/src/version-check.test.ts b/packages/registry/src/version-check.test.ts index 0312bd7..ebb445a 100644 --- a/packages/registry/src/version-check.test.ts +++ b/packages/registry/src/version-check.test.ts @@ -164,6 +164,72 @@ describe("discoverVersions", () => { ); }); + it("fetches go versions from the module proxy and strips the v prefix", async () => { + const goDef: VersionedDefinition = { + ...mockDefinition, + name: "github.com/spf13/cobra", + registry: "go", + }; + + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => "v1.10.2\nv1.9.1\nv1.8.1\n", + } as Response); + + const versions = await discoverVersions(goDef); + + // The shared isPrerelease() reads a leading "v" as a prerelease tag and + // discards the version, and compareSemver() returns NaN for it, so every + // Go version would vanish if the prefix survived this far. + expect(versions.map((v) => v.version)).toEqual([ + "1.10.2", + "1.9.1", + "1.8.1", + ]); + expect(mockFetch).toHaveBeenCalledWith( + "https://proxy.golang.org/github.com/spf13/cobra/@v/list", + ); + }); + + it("escapes uppercase letters in a go module path, and only those", async () => { + const goDef: VersionedDefinition = { + ...mockDefinition, + name: "github.com/BurntSushi/toml", + registry: "go", + }; + + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => "v1.5.0\n", + } as Response); + + await discoverVersions(goDef); + + // Measured against the live proxy: the unescaped path 404s, the escaped one + // returns 200. Slashes are path separators and must survive intact, which + // is why encodeURIComponent is the wrong tool here. + expect(mockFetch).toHaveBeenCalledWith( + "https://proxy.golang.org/github.com/!burnt!sushi/toml/@v/list", + ); + }); + + it("returns nothing for a go module with no tagged releases", async () => { + const goDef: VersionedDefinition = { + ...mockDefinition, + name: "github.com/test/untagged", + registry: "go", + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + text: async () => "", + } as Response); + + await expect(discoverVersions(goDef)).resolves.toEqual([]); + }); + it("throws for unsupported registry", async () => { const def = { ...mockDefinition, registry: "cargo" }; await expect(discoverVersions(def)).rejects.toThrow( diff --git a/packages/registry/src/version-check.ts b/packages/registry/src/version-check.ts index ace0b17..dd8d376 100644 --- a/packages/registry/src/version-check.ts +++ b/packages/registry/src/version-check.ts @@ -1,5 +1,5 @@ /** - * Version discovery from package registry APIs (npm, pip, maven, hex). + * Version discovery from package registry APIs (npm, pip, maven, hex, go). * * Queries public registry APIs to find available versions, * filters to defined ranges, and deduplicates to latest-patch-per-minor. @@ -33,6 +33,7 @@ const registryFetchers: Record = { pip: fetchPipVersions, maven: fetchMavenVersions, hex: fetchHexVersions, + go: fetchGoVersions, }; /** @@ -220,6 +221,49 @@ async function fetchHexVersions(packageName: string): Promise { })); } +/** + * Go modules, via the module proxy (https://proxy.golang.org). + * + * `packageName` is the full module path (e.g. "github.com/spf13/cobra"), which + * is also the definition's `name`. Three things differ from the other fetchers: + * + * - **Case escaping.** The proxy requires uppercase letters to be written as + * "!" + lowercase, so "github.com/BurntSushi/toml" is requested as + * "github.com/!burnt!sushi/toml". The unescaped path 404s. Slashes are path + * separators and must NOT be percent-encoded, so encodeURIComponent is wrong + * here. + * - **The "v" prefix is stripped.** Go tags are "v1.10.2"; this returns + * "1.10.2" so the shared `isPrerelease` and `compareSemver` keep working + * (both misread a leading "v" — isPrerelease sees a letter and discards the + * version, compareSemver returns NaN). Definitions restore it with the + * default tag_pattern "v{version}". + * - **No publish dates.** /@v/list returns bare versions. Dates need one + * /@v/.info request each, so `--since` filtering is unavailable + * rather than expensive. publishedAt is left undefined. + * + * The response is plain text, one version per line, in no particular order, + * and is empty for a module with no tagged releases. + */ +function escapeGoModulePath(modulePath: string): string { + return modulePath.replace(/[A-Z]/g, (c) => `!${c.toLowerCase()}`); +} + +async function fetchGoVersions(packageName: string): Promise { + const res = await fetchWithRetry( + `https://proxy.golang.org/${escapeGoModulePath(packageName)}/@v/list`, + `Go module proxy`, + packageName, + ); + + const body = await res.text(); + + return body + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("v")) + .map((version) => ({ version: version.slice(1) })); +} + /** * Public registries occasionally return 504/503 under load. Retry 5xx and * network errors with exponential backoff; 4xx aborts immediately. diff --git a/registry/go/github.com/spf13/cobra.yaml b/registry/go/github.com/spf13/cobra.yaml new file mode 100644 index 0000000..7d481eb --- /dev/null +++ b/registry/go/github.com/spf13/cobra.yaml @@ -0,0 +1,11 @@ +name: github.com/spf13/cobra +description: "A Commander for modern Go CLI interactions" +repository: https://github.com/spf13/cobra + +versions: + - min_version: "1.8.0" + tag_pattern: "v{version}" + source: + type: git + url: https://github.com/spf13/cobra + docs_path: site/content