diff --git a/src/commands/fetch.ts b/src/commands/fetch.ts index a9a9695..fdca06f 100644 --- a/src/commands/fetch.ts +++ b/src/commands/fetch.ts @@ -90,6 +90,8 @@ function getRegistryLabel(registry: Registry): string { return "PyPI"; case "crates": return "crates.io"; + case "packagist": + return "Packagist"; } } diff --git a/src/commands/list.ts b/src/commands/list.ts index 736e53d..7420d24 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -10,6 +10,7 @@ const REGISTRY_LABELS: Record = { npm: "npm", pypi: "PyPI", crates: "crates.io", + packagist: "Packagist", }; /** @@ -28,9 +29,10 @@ export async function listCommand(options: ListOptions = {}): Promise { ); console.log("Use `opensrc /` to fetch a GitHub repository."); console.log("\nSupported registries:"); - console.log(" • npm: opensrc zod, opensrc npm:react"); - console.log(" • PyPI: opensrc pypi:requests"); - console.log(" • crates: opensrc crates:serde"); + console.log(" • npm: opensrc zod, opensrc npm:react"); + console.log(" • PyPI: opensrc pypi:requests"); + console.log(" • crates: opensrc crates:serde"); + console.log(" • Packagist: opensrc packagist:laravel/framework"); return; } @@ -44,6 +46,7 @@ export async function listCommand(options: ListOptions = {}): Promise { npm: [], pypi: [], crates: [], + packagist: [], }; for (const pkg of sources.packages) { @@ -51,7 +54,7 @@ export async function listCommand(options: ListOptions = {}): Promise { } // Display packages by registry - const registries: Registry[] = ["npm", "pypi", "crates"]; + const registries: Registry[] = ["npm", "pypi", "crates", "packagist"]; let hasDisplayedPackages = false; for (const registry of registries) { diff --git a/src/commands/remove.ts b/src/commands/remove.ts index 3fa6fd6..8f10de7 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -76,7 +76,7 @@ export async function removeCommand( if (!pkgInfo) { // Try other registries if default didn't work - const registries: Registry[] = ["npm", "pypi", "crates"]; + const registries: Registry[] = ["npm", "pypi", "crates", "packagist"]; for (const reg of registries) { if (reg !== registry) { pkgInfo = await getPackageInfo(cleanSpec, cwd, reg); diff --git a/src/index.ts b/src/index.ts index 2562651..26afba4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ program program .argument( "[packages...]", - "packages or repos to fetch (e.g., zod, pypi:requests, crates:serde, owner/repo)", + "packages or repos to fetch (e.g., zod, pypi:requests, crates:serde, packagist:laravel/framework, owner/repo)", ) .option("--cwd ", "working directory (default: current directory)") .option( @@ -80,6 +80,7 @@ program .option("--npm", "only remove npm packages") .option("--pypi", "only remove PyPI packages") .option("--crates", "only remove crates.io packages") + .option("--packagist", "only remove Packagist packages") .option("--cwd ", "working directory (default: current directory)") .action( async (options: { @@ -88,6 +89,7 @@ program npm?: boolean; pypi?: boolean; crates?: boolean; + packagist?: boolean; cwd?: string; }) => { // Determine registry from flags @@ -95,6 +97,7 @@ program if (options.npm) registry = "npm"; else if (options.pypi) registry = "pypi"; else if (options.crates) registry = "crates"; + else if (options.packagist) registry = "packagist"; await cleanCommand({ packages: options.packages || !!registry, diff --git a/src/lib/agents.ts b/src/lib/agents.ts index ff13e42..d5ff943 100644 --- a/src/lib/agents.ts +++ b/src/lib/agents.ts @@ -29,10 +29,11 @@ Use this source code when you need to understand how a package works internally, To fetch source code for a package or repository you need to understand, run: \`\`\`bash -npx opensrc # npm package (e.g., npx opensrc zod) -npx opensrc pypi: # Python package (e.g., npx opensrc pypi:requests) -npx opensrc crates: # Rust crate (e.g., npx opensrc crates:serde) -npx opensrc / # GitHub repo (e.g., npx opensrc vercel/ai) +npx opensrc # npm package (e.g., npx opensrc zod) +npx opensrc pypi: # Python package (e.g., npx opensrc pypi:requests) +npx opensrc crates: # Rust crate (e.g., npx opensrc crates:serde) +npx opensrc packagist: # PHP package (e.g., npx opensrc packagist:laravel/framework) +npx opensrc / # GitHub repo (e.g., npx opensrc vercel/ai) \`\`\` ${SECTION_END_MARKER}`; diff --git a/src/lib/registries/index.test.ts b/src/lib/registries/index.test.ts index 8035182..f3fbb7a 100644 --- a/src/lib/registries/index.test.ts +++ b/src/lib/registries/index.test.ts @@ -85,6 +85,36 @@ describe("detectRegistry", () => { }); }); + describe("packagist registry", () => { + it("detects packagist: prefix", () => { + expect(detectRegistry("packagist:laravel/framework")).toEqual({ + registry: "packagist", + cleanSpec: "laravel/framework", + }); + }); + + it("detects composer: prefix", () => { + expect(detectRegistry("composer:laravel/framework")).toEqual({ + registry: "packagist", + cleanSpec: "laravel/framework", + }); + }); + + it("detects php: prefix", () => { + expect(detectRegistry("php:symfony/symfony")).toEqual({ + registry: "packagist", + cleanSpec: "symfony/symfony", + }); + }); + + it("handles case-insensitive prefixes", () => { + expect(detectRegistry("PACKAGIST:laravel/framework")).toEqual({ + registry: "packagist", + cleanSpec: "laravel/framework", + }); + }); + }); + describe("preserves version in cleanSpec", () => { it("npm with version", () => { expect(detectRegistry("npm:lodash@4.17.21")).toEqual({ @@ -106,6 +136,13 @@ describe("detectRegistry", () => { cleanSpec: "serde@1.0.0", }); }); + + it("packagist with version", () => { + expect(detectRegistry("packagist:laravel/framework@11.0.0")).toEqual({ + registry: "packagist", + cleanSpec: "laravel/framework@11.0.0", + }); + }); }); }); @@ -179,6 +216,24 @@ describe("parsePackageSpec", () => { }); }); }); + + describe("packagist packages", () => { + it("parses packagist package", () => { + expect(parsePackageSpec("packagist:laravel/framework")).toEqual({ + registry: "packagist", + name: "laravel/framework", + version: undefined, + }); + }); + + it("parses packagist package with @ version", () => { + expect(parsePackageSpec("php:laravel/framework@11.0.0")).toEqual({ + registry: "packagist", + name: "laravel/framework", + version: "11.0.0", + }); + }); + }); }); describe("detectInputType", () => { @@ -203,6 +258,18 @@ describe("detectInputType", () => { expect(detectInputType("crates:serde")).toBe("package"); }); + it("packagist package", () => { + expect(detectInputType("packagist:laravel/framework")).toBe("package"); + }); + + it("composer package", () => { + expect(detectInputType("composer:symfony/symfony")).toBe("package"); + }); + + it("php package", () => { + expect(detectInputType("php:guzzlehttp/guzzle")).toBe("package"); + }); + it("package with version", () => { expect(detectInputType("lodash@4.17.21")).toBe("package"); }); diff --git a/src/lib/registries/index.ts b/src/lib/registries/index.ts index bc465ac..1f43ce9 100644 --- a/src/lib/registries/index.ts +++ b/src/lib/registries/index.ts @@ -2,11 +2,13 @@ import type { Registry, PackageSpec, ResolvedPackage } from "../../types.js"; import { parseNpmSpec, resolveNpmPackage } from "./npm.js"; import { parsePyPISpec, resolvePyPIPackage } from "./pypi.js"; import { parseCratesSpec, resolveCrate } from "./crates.js"; +import { parsePackagistSpec, resolvePackagistPackage } from "./packagist.js"; import { isRepoSpec } from "../repo.js"; export { resolveNpmPackage } from "./npm.js"; export { resolvePyPIPackage } from "./pypi.js"; export { resolveCrate } from "./crates.js"; +export { resolvePackagistPackage } from "./packagist.js"; /** * Registry prefixes for explicit specification @@ -19,6 +21,9 @@ const REGISTRY_PREFIXES: Record = { "crates:": "crates", "cargo:": "crates", "rust:": "crates", + "packagist:": "packagist", + "composer:": "packagist", + "php:": "packagist", }; /** @@ -67,6 +72,9 @@ export function parsePackageSpec(spec: string): PackageSpec { case "crates": ({ name, version } = parseCratesSpec(cleanSpec)); break; + case "packagist": + ({ name, version } = parsePackagistSpec(cleanSpec)); + break; } return { registry, name, version }; @@ -87,6 +95,8 @@ export async function resolvePackage( return resolvePyPIPackage(name, version); case "crates": return resolveCrate(name, version); + case "packagist": + return resolvePackagistPackage(name, version); } } diff --git a/src/lib/registries/packagist.test.ts b/src/lib/registries/packagist.test.ts new file mode 100644 index 0000000..a1ca053 --- /dev/null +++ b/src/lib/registries/packagist.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { parsePackagistSpec } from "./packagist.js"; + +describe("parsePackagistSpec", () => { + describe("package name only", () => { + it("parses simple vendor/package name", () => { + expect(parsePackagistSpec("laravel/framework")).toEqual({ + name: "laravel/framework", + version: undefined, + }); + }); + + it("parses package with hyphens", () => { + expect(parsePackagistSpec("symfony/http-foundation")).toEqual({ + name: "symfony/http-foundation", + version: undefined, + }); + }); + + it("parses package with underscores", () => { + expect(parsePackagistSpec("doctrine/dbal")).toEqual({ + name: "doctrine/dbal", + version: undefined, + }); + }); + }); + + describe("@ version specifier", () => { + it("parses package@version", () => { + expect(parsePackagistSpec("laravel/framework@11.0.0")).toEqual({ + name: "laravel/framework", + version: "11.0.0", + }); + }); + + it("parses complex package name@version", () => { + expect(parsePackagistSpec("symfony/http-foundation@7.0.0")).toEqual({ + name: "symfony/http-foundation", + version: "7.0.0", + }); + }); + + it("parses with v prefix in version", () => { + expect(parsePackagistSpec("guzzlehttp/guzzle@v7.8.0")).toEqual({ + name: "guzzlehttp/guzzle", + version: "v7.8.0", + }); + }); + }); + + describe("edge cases", () => { + it("handles whitespace trimming", () => { + expect(parsePackagistSpec(" laravel/framework ")).toEqual({ + name: "laravel/framework", + version: undefined, + }); + }); + + it("handles dev versions", () => { + expect(parsePackagistSpec("laravel/framework@dev-master")).toEqual({ + name: "laravel/framework", + version: "dev-master", + }); + }); + + it("handles prerelease versions", () => { + expect(parsePackagistSpec("laravel/framework@11.0.0-alpha.1")).toEqual({ + name: "laravel/framework", + version: "11.0.0-alpha.1", + }); + }); + + it("handles beta versions", () => { + expect(parsePackagistSpec("symfony/symfony:7.0.0-beta1")).toEqual({ + name: "symfony/symfony", + version: "7.0.0-beta1", + }); + }); + + it("handles package without vendor prefix as-is", () => { + expect(parsePackagistSpec("somepackage")).toEqual({ + name: "somepackage", + version: undefined, + }); + }); + }); +}); diff --git a/src/lib/registries/packagist.ts b/src/lib/registries/packagist.ts new file mode 100644 index 0000000..7b6428e --- /dev/null +++ b/src/lib/registries/packagist.ts @@ -0,0 +1,224 @@ +import type { ResolvedPackage } from "../../types.js"; + +const PACKAGIST_API = "https://repo.packagist.org/p2"; + +interface PackagistVersion { + version: string; + version_normalized: string; + source?: { + type: string; + url: string; + reference: string; + }; + time?: string; +} + +interface PackagistResponse { + packages: { + [packageName: string]: PackagistVersion[]; + }; +} + +/** + * Parse a Packagist package specifier like "laravel/framework@11.0.0" into name and version + */ +export function parsePackagistSpec(spec: string): { + name: string; + version?: string; +} { + const trimmed = spec.trim(); + + // Packagist packages are in vendor/package format + // Handle version specifier: vendor/package@1.0.0 or vendor/package:1.0.0 + const colonIndex = trimmed.lastIndexOf(":"); + const atIndex = trimmed.lastIndexOf("@"); + + // Use whichever delimiter comes after the vendor/package part + // We need to be careful with @ since it could be part of the version (like dev-main@abc123) + // The package name always contains a /, so find that first + const slashIndex = trimmed.indexOf("/"); + if (slashIndex === -1) { + return { name: trimmed }; + } + + // Check for : version separator (Composer style: vendor/package:^1.0) + if (colonIndex > slashIndex) { + return { + name: trimmed.slice(0, colonIndex).trim(), + version: trimmed.slice(colonIndex + 1).trim(), + }; + } + + // Check for @ version separator + if (atIndex > slashIndex) { + return { + name: trimmed.slice(0, atIndex).trim(), + version: trimmed.slice(atIndex + 1).trim(), + }; + } + + return { name: trimmed }; +} + +/** + * Fetch package metadata from Packagist + */ +export async function fetchPackagistInfo( + packageName: string, +): Promise { + const url = `${PACKAGIST_API}/${packageName}.json`; + + const response = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": "opensrc-cli (https://github.com/vercel-labs/opensrc)", + }, + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error(`Package "${packageName}" not found on Packagist`); + } + throw new Error( + `Failed to fetch package info: ${response.status} ${response.statusText}`, + ); + } + + return response.json() as Promise; +} + +/** + * Extract repository URL from package version info + */ +export function extractRepoUrl(version: PackagistVersion): string | null { + if (!version.source?.url) { + return null; + } + + let url = version.source.url; + + // Normalize git URLs + url = url + .replace(/^git\+/, "") + .replace(/^git:\/\//, "https://") + .replace(/^git@github\.com:/, "https://github.com/") + .replace(/^git@gitlab\.com:/, "https://gitlab.com/") + .replace(/\.git$/, ""); + + // Only return URLs from known git hosts + if (isGitRepoUrl(url)) { + return normalizeRepoUrl(url); + } + + return null; +} + +function isGitRepoUrl(url: string): boolean { + return ( + url.includes("github.com") || + url.includes("gitlab.com") || + url.includes("bitbucket.org") + ); +} + +function normalizeRepoUrl(url: string): string { + return url + .replace(/\/+$/, "") + .replace(/\.git$/, "") + .replace(/\/tree\/.*$/, "") + .replace(/\/blob\/.*$/, ""); +} + +/** + * Get available versions sorted by time (newest first) + */ +function getAvailableVersions( + versions: PackagistVersion[], + includeDev = false, +): PackagistVersion[] { + return versions.sort((a, b) => { + // Sort by time if available, otherwise by version + if (a.time && b.time) { + return new Date(b.time).getTime() - new Date(a.time).getTime(); + } + return b.version_normalized.localeCompare(a.version_normalized); + }); +} + +export function getLatestVersion( + versions: PackagistVersion[], +): PackagistVersion { + const stableVersions = getAvailableVersions(versions); + return stableVersions[0] ?? versions[0]; +} + +/** + * Resolve a Packagist package to its repository information + */ +export async function resolvePackagistPackage( + packageName: string, + version?: string, +): Promise { + const info = await fetchPackagistInfo(packageName); + const versions = info.packages[packageName]; + + if (!versions || versions.length === 0) { + throw new Error(`No versions found for "${packageName}"`); + } + + let resolvedVersion: PackagistVersion; + + if (version) { + // Find the specific version requested + // Handle both exact match and normalized match + const normalizedRequest = version.replace(/^v/, ""); + const matchingVersion = versions.find( + (v) => + v.version === version || + v.version === `v${version}` || + v.version_normalized === normalizedRequest, + ); + + if (!matchingVersion) { + const availableVersions = getAvailableVersions(versions) + .slice(0, 5) + .map((v) => v.version) + .join(", "); + throw new Error( + `Version "${version}" not found for "${packageName}". ` + + `Recent versions: ${availableVersions}`, + ); + } + + resolvedVersion = matchingVersion; + } else { + resolvedVersion = getLatestVersion(versions); + } + + const repoUrl = extractRepoUrl(resolvedVersion); + + if (!repoUrl) { + const availableVersions = getAvailableVersions(versions) + .slice(0, 5) + .map((v) => v.version) + .join(", "); + throw new Error( + `No repository URL found for "${packageName}@${resolvedVersion.version}". ` + + `This package may not have its source published. ` + + `Recent versions: ${availableVersions}`, + ); + } + + // PHP packages commonly use v1.2.3 as tags + const gitTag = resolvedVersion.version.startsWith("v") + ? resolvedVersion.version + : `v${resolvedVersion.version}`; + + return { + registry: "packagist", + name: packageName, + version: resolvedVersion.version, + repoUrl, + gitTag, + }; +} diff --git a/src/types.ts b/src/types.ts index 8d0a42a..0ca4bd0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ /** * Supported package registries */ -export type Registry = "npm" | "pypi" | "crates"; +export type Registry = "npm" | "pypi" | "crates" | "packagist"; export interface PackageInfo { name: string;