Skip to content
Open
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: 7 additions & 0 deletions .changeset/spotty-carrots-invent.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions packages/registry/src/definition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
32 changes: 14 additions & 18 deletions packages/registry/src/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
66 changes: 66 additions & 0 deletions packages/registry/src/version-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
46 changes: 45 additions & 1 deletion packages/registry/src/version-check.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -33,6 +33,7 @@ const registryFetchers: Record<string, RegistryFetcher> = {
pip: fetchPipVersions,
maven: fetchMavenVersions,
hex: fetchHexVersions,
go: fetchGoVersions,
};

/**
Expand Down Expand Up @@ -220,6 +221,49 @@ async function fetchHexVersions(packageName: string): Promise<VersionInfo[]> {
}));
}

/**
* 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/<version>.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<VersionInfo[]> {
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.
Expand Down
11 changes: 11 additions & 0 deletions registry/go/github.com/spf13/cobra.yaml
Original file line number Diff line number Diff line change
@@ -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