From 4f531da944b565733de0303df734be36a28fdea8 Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:06:13 +0100 Subject: [PATCH 1/4] feat: add Arch Linux package registry (alpm) Official repos and AUR via PURL type alpm with namespace routing. Covers package metadata, versions, deps, maintainers, and URLs. --- src/core/purl.ts | 4 + src/registries/alpm.ts | 485 ++++++++++++++++++++++++++++++++++++++++ src/registries/index.ts | 1 + test/e2e/smoke.test.ts | 26 ++- test/unit/alpm.test.ts | 477 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 991 insertions(+), 2 deletions(-) create mode 100644 src/registries/alpm.ts create mode 100644 test/unit/alpm.test.ts diff --git a/src/core/purl.ts b/src/core/purl.ts index 6027876..2c768ee 100644 --- a/src/core/purl.ts +++ b/src/core/purl.ts @@ -81,6 +81,10 @@ export function parsePURL(purlStr: string): ParsedPURL { name = name.toLowerCase().replace(/_/g, '-') } + if (type === 'alpm') { + name = name.toLowerCase() + } + return { type, namespace, name, version, qualifiers, subpath } } diff --git a/src/registries/alpm.ts b/src/registries/alpm.ts new file mode 100644 index 0000000..dd102a8 --- /dev/null +++ b/src/registries/alpm.ts @@ -0,0 +1,485 @@ +import type { Client } from '../core/client.ts' +import type { + Dependency, + Maintainer, + Package, + Registry, + RegistryFactory, + URLBuilder, + Version, +} from '../core/types.ts' +import { register } from '../core/registry.ts' +import { HTTPError, NotFoundError } from '../core/errors.ts' +import { combineLicenses } from '../core/license.ts' + +const AUR_BASE_URL = 'https://aur.archlinux.org' + +/** Arch Linux official package search response. */ +interface ArchSearchResponse { + results: ArchPackageResult[] +} + +/** Arch Linux official package data from search/detail endpoints. */ +interface ArchPackageResult { + pkgname: string + pkgbase: string + repo: string + arch: string + pkgver: string + pkgrel: string + epoch: number + pkgdesc: string + url: string + filename: string + compressed_size: number + installed_size: number + build_date: string + last_update: string + flag_date: string | null + maintainers: string[] + packager: string + groups: string[] + licenses: string[] + conflicts: string[] + provides: string[] + replaces: string[] + depends: string[] + optdepends: string[] + makedepends: string[] + checkdepends: string[] +} + +/** AUR RPC v5 response wrapper. */ +interface AurResponse { + resultcount: number + results: AurPackageResult[] + type: string + version: number +} + +/** AUR package data. */ +interface AurPackageResult { + ID: number + Name: string + PackageBase: string + PackageBaseID: number + Version: string + Description: string + URL: string + NumVotes: number + Popularity: number + OutOfDate: number | null + Maintainer: string | null + FirstSubmitted: number + LastModified: number + URLPath: string + Depends?: string[] + OptDepends?: string[] + MakeDepends?: string[] + CheckDepends?: string[] + License?: string[] + Keywords?: string[] +} + +/** ALPM registry client — routes to Arch Linux official repos or AUR based on namespace. */ +class AlpmRegistry implements Registry { + constructor( + baseURL: string, + client: Client, + ) { + this.baseURL = baseURL + this.client = client + } + + readonly baseURL: string + readonly client: Client + + ecosystem(): string { + return 'alpm' + } + + async fetchPackage(name: string, signal?: AbortSignal): Promise { + const { namespace, pkgName } = this.parseName(name) + this.assertSupportedNamespace(namespace, name) + + if (namespace === 'aur') { + return this.fetchAurPackage(pkgName, signal) + } + + return this.fetchOfficialPackage(pkgName, signal) + } + + async fetchVersions(name: string, signal?: AbortSignal): Promise { + const { namespace, pkgName } = this.parseName(name) + this.assertSupportedNamespace(namespace, name) + + if (namespace === 'aur') { + return this.fetchAurVersions(pkgName, signal) + } + + return this.fetchOfficialVersions(pkgName, signal) + } + + async fetchDependencies( + name: string, + version: string, + signal?: AbortSignal, + ): Promise { + const { namespace, pkgName } = this.parseName(name) + this.assertSupportedNamespace(namespace, name) + + if (namespace === 'aur') { + return this.fetchAurDependencies(pkgName, version, signal) + } + + return this.fetchOfficialDependencies(pkgName, version, signal) + } + + async fetchMaintainers(name: string, signal?: AbortSignal): Promise { + const { namespace, pkgName } = this.parseName(name) + this.assertSupportedNamespace(namespace, name) + + if (namespace === 'aur') { + return this.fetchAurMaintainers(pkgName, signal) + } + + return this.fetchOfficialMaintainers(pkgName, signal) + } + + urls(): URLBuilder { + return { + registry: (name: string, _version?: string) => { + const { namespace, pkgName } = this.parseName(name) + if (namespace === 'aur') { + return `https://aur.archlinux.org/packages/${pkgName}` + } + return `https://archlinux.org/packages/?name=${pkgName}` + }, + download: (name: string, _version: string) => { + const { namespace, pkgName } = this.parseName(name) + if (namespace === 'aur') { + return `https://aur.archlinux.org/cgit/aur.git/snapshot/${pkgName}.tar.gz` + } + const letter = pkgName.charAt(0) + return `https://archive.archlinux.org/packages/${letter}/${pkgName}/` + }, + documentation: (name: string, _version?: string) => { + const { namespace, pkgName } = this.parseName(name) + if (namespace === 'aur') { + return `https://aur.archlinux.org/packages/${pkgName}` + } + return `https://wiki.archlinux.org/title/${pkgName}` + }, + readme: (_name: string, _version?: string) => { + return '' + }, + purl: (name: string, version?: string) => { + const { namespace, pkgName } = this.parseName(name) + const versionSuffix = version ? `@${version}` : '' + return `pkg:alpm/${namespace}/${pkgName}${versionSuffix}` + }, + } + } + + // --- Official Arch Linux API --- + + private async fetchOfficialPackage(name: string, signal?: AbortSignal): Promise { + const result = await this.searchOfficial(name, signal) + + return { + name: result.pkgname, + description: result.pkgdesc || '', + homepage: result.url || '', + documentation: '', + repository: '', + licenses: combineLicenses(result.licenses), + keywords: result.groups, + namespace: 'arch', + latestVersion: this.formatVersion(result), + metadata: { + repo: result.repo, + arch: result.arch, + pkgbase: result.pkgbase, + compressedSize: result.compressed_size, + installedSize: result.installed_size, + buildDate: result.build_date, + lastUpdate: result.last_update, + flagDate: result.flag_date, + packager: result.packager, + provides: result.provides, + conflicts: result.conflicts, + replaces: result.replaces, + }, + } + } + + private async fetchOfficialVersions(name: string, signal?: AbortSignal): Promise { + const result = await this.searchOfficial(name, signal) + + return [{ + number: this.formatVersion(result), + publishedAt: result.last_update ? new Date(result.last_update) : null, + licenses: combineLicenses(result.licenses), + integrity: '', + status: result.flag_date ? 'deprecated' : '', + metadata: { + repo: result.repo, + arch: result.arch, + }, + }] + } + + private async fetchOfficialDependencies(name: string, version: string, signal?: AbortSignal): Promise { + const result = await this.searchOfficial(name, signal) + const currentVersion = this.formatVersion(result) + this.assertVersionMatches(name, version, currentVersion) + + return this.buildDependencies(result.depends, result.makedepends, result.optdepends, result.checkdepends) + } + + private async fetchOfficialMaintainers(name: string, signal?: AbortSignal): Promise { + const result = await this.searchOfficial(name, signal) + + return result.maintainers.map(m => ({ + uuid: '', + login: m, + name: m, + email: '', + url: '', + role: 'maintainer', + })) + } + + /** Search official repos by exact package name. Prefers x86_64 results. */ + private async searchOfficial(name: string, signal?: AbortSignal): Promise { + const url = `${this.baseURL}/packages/search/json/?name=${encodeURIComponent(name)}` + + try { + const data = await this.client.getJSON(url, signal) + + if (!data.results || data.results.length === 0) { + throw new NotFoundError('alpm', name) + } + + // Prefer x86_64 arch, fall back to first result + return data.results.find(r => r.arch === 'x86_64') || data.results[0]! + } + catch (error) { + if (error instanceof NotFoundError) throw error + if (error instanceof HTTPError && error.isNotFound()) { + throw new NotFoundError('alpm', name) + } + throw error + } + } + + // --- AUR API --- + + private async fetchAurPackage(name: string, signal?: AbortSignal): Promise { + const result = await this.fetchAurInfo(name, signal) + + return { + name: result.Name, + description: result.Description || '', + homepage: result.URL || '', + documentation: '', + repository: '', + licenses: combineLicenses(result.License || []), + keywords: result.Keywords || [], + namespace: 'aur', + latestVersion: result.Version, + metadata: { + votes: result.NumVotes, + popularity: result.Popularity, + outOfDate: result.OutOfDate, + pkgbase: result.PackageBase, + firstSubmitted: result.FirstSubmitted, + lastModified: result.LastModified, + }, + } + } + + private async fetchAurVersions(name: string, signal?: AbortSignal): Promise { + const result = await this.fetchAurInfo(name, signal) + + return [{ + number: result.Version, + publishedAt: result.LastModified ? new Date(result.LastModified * 1000) : null, + licenses: combineLicenses(result.License || []), + integrity: '', + status: result.OutOfDate ? 'deprecated' : '', + metadata: { + votes: result.NumVotes, + popularity: result.Popularity, + }, + }] + } + + private async fetchAurDependencies(name: string, version: string, signal?: AbortSignal): Promise { + const result = await this.fetchAurInfo(name, signal) + this.assertVersionMatches(`aur/${name}`, version, result.Version) + + return this.buildDependencies( + result.Depends || [], + result.MakeDepends || [], + result.OptDepends || [], + result.CheckDepends || [], + ) + } + + private async fetchAurMaintainers(name: string, signal?: AbortSignal): Promise { + const result = await this.fetchAurInfo(name, signal) + + if (!result.Maintainer) return [] + + return [{ + uuid: '', + login: result.Maintainer, + name: result.Maintainer, + email: '', + url: '', + role: 'maintainer', + }] + } + + /** Fetch package info from AUR RPC v5. */ + private async fetchAurInfo(name: string, signal?: AbortSignal): Promise { + const url = `${AUR_BASE_URL}/rpc/v5/info/${encodeURIComponent(name)}` + + try { + const data = await this.client.getJSON(url, signal) + + if (!data.results || data.results.length === 0) { + throw new NotFoundError('alpm', `aur/${name}`) + } + + return data.results[0]! + } + catch (error) { + if (error instanceof NotFoundError) throw error + if (error instanceof HTTPError && error.isNotFound()) { + throw new NotFoundError('alpm', `aur/${name}`) + } + throw error + } + } + + // --- Shared helpers --- + + /** Parse "namespace/name" into components. Defaults to "arch" namespace. */ + private parseName(fullName: string): { namespace: string; pkgName: string } { + const normalized = fullName.trim().toLowerCase() + const slashIdx = normalized.indexOf('/') + if (slashIdx === -1) { + return { namespace: 'arch', pkgName: normalized } + } + return { + namespace: normalized.slice(0, slashIdx), + pkgName: normalized.slice(slashIdx + 1), + } + } + + private assertSupportedNamespace(namespace: string, requestedName: string): void { + if (namespace !== 'arch' && namespace !== 'aur') { + throw new NotFoundError('alpm', requestedName) + } + } + + private assertVersionMatches(name: string, requested: string, current: string): void { + if (requested && requested !== current) { + throw new NotFoundError('alpm', name, requested) + } + } + + /** Format ALPM version: epoch:pkgver-pkgrel (epoch omitted when 0). */ + private formatVersion(result: ArchPackageResult): string { + const epoch = result.epoch && result.epoch > 0 ? `${result.epoch}:` : '' + return `${epoch}${result.pkgver}-${result.pkgrel}` + } + + /** Build normalized dependency list from ALPM dependency arrays. */ + private buildDependencies( + depends: string[], + makedepends: string[], + optdepends: string[], + checkdepends: string[], + ): Dependency[] { + const dependencies: Dependency[] = [] + + for (const dep of depends) { + const parsed = this.parseDep(dep) + dependencies.push({ + name: parsed.name, + requirements: parsed.requirements, + scope: 'runtime', + optional: false, + }) + } + + for (const dep of makedepends) { + const parsed = this.parseDep(dep) + dependencies.push({ + name: parsed.name, + requirements: parsed.requirements, + scope: 'build', + optional: false, + }) + } + + for (const dep of optdepends) { + const parsed = this.parseOptDep(dep) + dependencies.push({ + name: parsed.name, + requirements: parsed.requirements, + scope: 'optional', + optional: true, + }) + } + + for (const dep of checkdepends) { + const parsed = this.parseDep(dep) + dependencies.push({ + name: parsed.name, + requirements: parsed.requirements, + scope: 'test', + optional: false, + }) + } + + return dependencies + } + + /** Parse a dependency string like "glibc>=2.33" or "bash". */ + private parseDep(dep: string): { name: string; requirements: string } { + const match = dep.match(/^([a-zA-Z0-9@._+-]+)(.*)$/) + if (!match) return { name: dep, requirements: '' } + return { + name: match[1]!, + requirements: match[2]!.trim(), + } + } + + /** Parse optional dependency like "perl-locale-gettext: translation support". */ + private parseOptDep(dep: string): { name: string; requirements: string; description: string } { + const colonIdx = dep.indexOf(':') + if (colonIdx === -1) { + const parsed = this.parseDep(dep.trim()) + return { name: parsed.name, requirements: parsed.requirements, description: '' } + } + const parsed = this.parseDep(dep.slice(0, colonIdx).trim()) + + return { + name: parsed.name, + requirements: parsed.requirements, + description: dep.slice(colonIdx + 1).trim(), + } + } +} + +/** Factory function for creating ALPM registry instances. */ +const factory: RegistryFactory = (baseURL: string, client: Client): Registry => { + return new AlpmRegistry(baseURL, client) +} + +// Self-register on import +register('alpm', 'https://archlinux.org', factory) diff --git a/src/registries/index.ts b/src/registries/index.ts index 8949f42..fd80764 100644 --- a/src/registries/index.ts +++ b/src/registries/index.ts @@ -4,3 +4,4 @@ import './cargo.ts' import './pypi.ts' import './rubygems.ts' import './packagist.ts' +import './alpm.ts' diff --git a/test/e2e/smoke.test.ts b/test/e2e/smoke.test.ts index facfc71..e394da8 100644 --- a/test/e2e/smoke.test.ts +++ b/test/e2e/smoke.test.ts @@ -8,14 +8,15 @@ import { create, ecosystems, has } from '../../src/core/registry.ts' import '../../src/registries/index.ts' describe('registry smoke tests', () => { - it('all 5 ecosystems are registered', () => { + it('all 6 ecosystems are registered', () => { const registered = ecosystems() expect(registered).toContain('npm') expect(registered).toContain('cargo') expect(registered).toContain('pypi') expect(registered).toContain('gem') expect(registered).toContain('composer') - expect(registered).toHaveLength(5) + expect(registered).toContain('alpm') + expect(registered).toHaveLength(6) }) it('has() returns correct values', () => { @@ -87,4 +88,25 @@ describe('registry smoke tests', () => { expect(pkg.namespace).toBe('laravel') }) }) + + describe('alpm — pacman (official)', { timeout: 15_000 }, () => { + it('fetchPackage', async () => { + const reg = create('alpm') + const pkg = await reg.fetchPackage('arch/pacman') + expect(pkg.name).toBe('pacman') + expect(pkg.licenses).toBeTruthy() + expect(pkg.namespace).toBe('arch') + expect(pkg.latestVersion).toBeTruthy() + }) + }) + + describe('alpm — yay (AUR)', { timeout: 15_000 }, () => { + it('fetchPackage', async () => { + const reg = create('alpm') + const pkg = await reg.fetchPackage('aur/yay') + expect(pkg.name).toBe('yay') + expect(pkg.namespace).toBe('aur') + expect(pkg.latestVersion).toBeTruthy() + }) + }) }) diff --git a/test/unit/alpm.test.ts b/test/unit/alpm.test.ts new file mode 100644 index 0000000..ddef0f7 --- /dev/null +++ b/test/unit/alpm.test.ts @@ -0,0 +1,477 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { Client } from '../../src/core/client.ts' +import { NotFoundError, HTTPError } from '../../src/core/errors.ts' +import { create } from '../../src/core/registry.ts' +import '../../src/registries/index.ts' + +/** Helper — minimal Arch official search response. */ +function archSearchResponse(overrides: Record = {}) { + return { + results: [{ + pkgname: 'pacman', + pkgbase: 'pacman', + repo: 'Core', + arch: 'x86_64', + pkgver: '6.1.0', + pkgrel: '3', + epoch: 0, + pkgdesc: 'A library-based package manager with dependency support', + url: 'https://www.archlinux.org/pacman/', + filename: 'pacman-6.1.0-3-x86_64.pkg.tar.zst', + compressed_size: 500000, + installed_size: 2000000, + build_date: '2025-11-10T10:00:00Z', + last_update: '2025-11-10T12:00:00Z', + flag_date: null, + maintainers: ['Allan'], + packager: 'Allan', + groups: ['base-devel'], + licenses: ['GPL-3.0-or-later'], + conflicts: [], + provides: ['libalpm.so=14-64'], + replaces: [], + depends: ['bash', 'glibc', 'libarchive', 'curl>=7.55'], + optdepends: ['perl-locale-gettext: translation support in makepkg-template'], + makedepends: ['meson'], + checkdepends: ['python', 'fakechroot'], + ...overrides, + }], + } +} + +/** Helper — minimal AUR info response. */ +function aurInfoResponse(overrides: Record = {}) { + return { + resultcount: 1, + type: 'multiinfo', + version: 5, + results: [{ + ID: 1234, + Name: 'yay', + PackageBase: 'yay', + PackageBaseID: 5678, + Version: '12.4.2-1', + Description: 'Yet another yogurt - An AUR Helper written in Go', + URL: 'https://github.com/Jguer/yay', + NumVotes: 2500, + Popularity: 25.5, + OutOfDate: null, + Maintainer: 'Jguer', + FirstSubmitted: 1500000000, + LastModified: 1700000000, + URLPath: '/cgit/aur.git/snapshot/yay.tar.gz', + Depends: ['pacman>=5.2', 'git'], + OptDepends: ['sudo: privilege elevation'], + MakeDepends: ['go>=1.21'], + CheckDepends: [], + License: ['GPL-3.0-or-later'], + Keywords: ['aur', 'helper'], + ...overrides, + }], + } +} + +describe('alpm registry', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('official packages', () => { + it('should fetch and normalize official package', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce(archSearchResponse()) + + const registry = create('alpm', undefined, client) + const pkg = await registry.fetchPackage('arch/pacman') + + expect(pkg.name).toBe('pacman') + expect(pkg.description).toBe('A library-based package manager with dependency support') + expect(pkg.homepage).toBe('https://www.archlinux.org/pacman/') + expect(pkg.licenses).toBe('GPL-3.0-or-later') + expect(pkg.namespace).toBe('arch') + expect(pkg.latestVersion).toBe('6.1.0-3') + expect(pkg.keywords).toContain('base-devel') + expect(pkg.metadata.repo).toBe('Core') + expect(pkg.metadata.arch).toBe('x86_64') + }) + + it('should format version with epoch when > 0', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce( + archSearchResponse({ epoch: 2, pkgver: '1.0.0', pkgrel: '1' }), + ) + + const registry = create('alpm', undefined, client) + const pkg = await registry.fetchPackage('arch/some-pkg') + + expect(pkg.latestVersion).toBe('2:1.0.0-1') + }) + + it('should prefer x86_64 result when multiple archs exist', async () => { + const client = new Client() + const response = { + results: [ + { ...archSearchResponse().results[0], arch: 'any', pkgver: '1.0.0' }, + { ...archSearchResponse().results[0], arch: 'x86_64', pkgver: '2.0.0' }, + ], + } + vi.spyOn(client, 'getJSON').mockResolvedValueOnce(response) + + const registry = create('alpm', undefined, client) + const pkg = await registry.fetchPackage('arch/pacman') + + expect(pkg.metadata.arch).toBe('x86_64') + }) + + it('should throw NotFoundError for missing official package', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce({ results: [] }) + + const registry = create('alpm', undefined, client) + + await expect(registry.fetchPackage('arch/nonexistent-pkg-xyz')).rejects.toThrow( + NotFoundError, + ) + }) + + it('should throw NotFoundError on HTTP 404', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockRejectedValueOnce( + new HTTPError(404, 'https://mock/not-found', 'Not Found'), + ) + + const registry = create('alpm', undefined, client) + + await expect(registry.fetchPackage('arch/nonexistent-pkg-xyz')).rejects.toThrow( + NotFoundError, + ) + }) + + it('should fetch versions (single current version)', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce(archSearchResponse()) + + const registry = create('alpm', undefined, client) + const versions = await registry.fetchVersions('arch/pacman') + + expect(versions).toHaveLength(1) + expect(versions[0].number).toBe('6.1.0-3') + expect(versions[0].licenses).toBe('GPL-3.0-or-later') + expect(versions[0].status).toBe('') + expect(versions[0].publishedAt).toBeInstanceOf(Date) + }) + + it('should mark flagged package version as deprecated', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce( + archSearchResponse({ flag_date: '2025-12-01T00:00:00Z' }), + ) + + const registry = create('alpm', undefined, client) + const versions = await registry.fetchVersions('arch/old-pkg') + + expect(versions[0].status).toBe('deprecated') + }) + + it('should parse dependencies with version constraints', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce(archSearchResponse()) + + const registry = create('alpm', undefined, client) + const deps = await registry.fetchDependencies('arch/pacman', '6.1.0-3') + + // Runtime deps + const bash = deps.find(d => d.name === 'bash') + expect(bash).toBeDefined() + expect(bash!.scope).toBe('runtime') + expect(bash!.optional).toBe(false) + + // Dep with version constraint + const curl = deps.find(d => d.name === 'curl') + expect(curl).toBeDefined() + expect(curl!.requirements).toBe('>=7.55') + + // Build dep + const meson = deps.find(d => d.name === 'meson') + expect(meson).toBeDefined() + expect(meson!.scope).toBe('build') + + // Optional dep (description stripped) + const perl = deps.find(d => d.name === 'perl-locale-gettext') + expect(perl).toBeDefined() + expect(perl!.scope).toBe('optional') + expect(perl!.optional).toBe(true) + expect(perl!.requirements).toBe('') + + // Check dep + const python = deps.find(d => d.name === 'python') + expect(python).toBeDefined() + expect(python!.scope).toBe('test') + }) + + it('should enforce exact version for official dependency lookups', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce(archSearchResponse()) + + const registry = create('alpm', undefined, client) + + await expect(registry.fetchDependencies('arch/pacman', '6.1.0-2')).rejects.toThrow( + NotFoundError, + ) + }) + + it('should parse optional dependency requirements', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce( + archSearchResponse({ + depends: [], + makedepends: [], + checkdepends: [], + optdepends: ['foo>=1.2: optional runtime integration'], + }), + ) + + const registry = create('alpm', undefined, client) + const deps = await registry.fetchDependencies('arch/pacman', '6.1.0-3') + + expect(deps).toHaveLength(1) + expect(deps[0].name).toBe('foo') + expect(deps[0].requirements).toBe('>=1.2') + expect(deps[0].scope).toBe('optional') + expect(deps[0].optional).toBe(true) + }) + + it('should fetch maintainers', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce(archSearchResponse()) + + const registry = create('alpm', undefined, client) + const maintainers = await registry.fetchMaintainers('arch/pacman') + + expect(maintainers).toHaveLength(1) + expect(maintainers[0].login).toBe('Allan') + expect(maintainers[0].role).toBe('maintainer') + }) + }) + + describe('AUR packages', () => { + it('should fetch and normalize AUR package', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce(aurInfoResponse()) + + const registry = create('alpm', undefined, client) + const pkg = await registry.fetchPackage('aur/yay') + + expect(pkg.name).toBe('yay') + expect(pkg.description).toContain('AUR Helper') + expect(pkg.homepage).toBe('https://github.com/Jguer/yay') + expect(pkg.licenses).toBe('GPL-3.0-or-later') + expect(pkg.namespace).toBe('aur') + expect(pkg.latestVersion).toBe('12.4.2-1') + expect(pkg.keywords).toContain('aur') + expect(pkg.metadata.votes).toBe(2500) + expect(pkg.metadata.popularity).toBe(25.5) + }) + + it('should throw NotFoundError for missing AUR package', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce({ resultcount: 0, results: [], type: 'multiinfo', version: 5 }) + + const registry = create('alpm', undefined, client) + + await expect(registry.fetchPackage('aur/nonexistent-pkg-xyz')).rejects.toThrow( + NotFoundError, + ) + }) + + it('should fetch AUR versions with timestamp conversion', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce(aurInfoResponse()) + + const registry = create('alpm', undefined, client) + const versions = await registry.fetchVersions('aur/yay') + + expect(versions).toHaveLength(1) + expect(versions[0].number).toBe('12.4.2-1') + expect(versions[0].publishedAt).toBeInstanceOf(Date) + // AUR timestamps are unix seconds — verify correct ms conversion + expect(versions[0].publishedAt!.getTime()).toBe(1700000000 * 1000) + }) + + it('should mark out-of-date AUR package as deprecated', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce( + aurInfoResponse({ OutOfDate: 1700000000 }), + ) + + const registry = create('alpm', undefined, client) + const versions = await registry.fetchVersions('aur/old-pkg') + + expect(versions[0].status).toBe('deprecated') + }) + + it('should parse AUR dependencies', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce(aurInfoResponse()) + + const registry = create('alpm', undefined, client) + const deps = await registry.fetchDependencies('aur/yay', '12.4.2-1') + + const pacman = deps.find(d => d.name === 'pacman') + expect(pacman).toBeDefined() + expect(pacman!.scope).toBe('runtime') + expect(pacman!.requirements).toBe('>=5.2') + + const go = deps.find(d => d.name === 'go') + expect(go).toBeDefined() + expect(go!.scope).toBe('build') + expect(go!.requirements).toBe('>=1.21') + + const sudo = deps.find(d => d.name === 'sudo') + expect(sudo).toBeDefined() + expect(sudo!.scope).toBe('optional') + expect(sudo!.optional).toBe(true) + }) + + it('should enforce exact version for AUR dependency lookups', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce(aurInfoResponse()) + + const registry = create('alpm', undefined, client) + + await expect(registry.fetchDependencies('aur/yay', '12.4.2-0')).rejects.toThrow( + NotFoundError, + ) + }) + + it('should parse AUR optional dependency requirements', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce( + aurInfoResponse({ + Depends: [], + MakeDepends: [], + CheckDepends: [], + OptDepends: ['bar>=2: optional helper'], + }), + ) + + const registry = create('alpm', undefined, client) + const deps = await registry.fetchDependencies('aur/yay', '12.4.2-1') + + expect(deps).toHaveLength(1) + expect(deps[0].name).toBe('bar') + expect(deps[0].requirements).toBe('>=2') + expect(deps[0].scope).toBe('optional') + expect(deps[0].optional).toBe(true) + }) + + it('should return empty maintainers for orphaned AUR package', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce( + aurInfoResponse({ Maintainer: null }), + ) + + const registry = create('alpm', undefined, client) + const maintainers = await registry.fetchMaintainers('aur/orphaned-pkg') + + expect(maintainers).toHaveLength(0) + }) + + it('should fetch AUR maintainer', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce(aurInfoResponse()) + + const registry = create('alpm', undefined, client) + const maintainers = await registry.fetchMaintainers('aur/yay') + + expect(maintainers).toHaveLength(1) + expect(maintainers[0].login).toBe('Jguer') + expect(maintainers[0].role).toBe('maintainer') + }) + }) + + describe('namespace routing', () => { + it('should reject unsupported namespaces', async () => { + const client = new Client() + const registry = create('alpm', undefined, client) + + await expect(registry.fetchPackage('manjaro/pacman')).rejects.toThrow(NotFoundError) + }) + + it('should normalize input casing and whitespace', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce(archSearchResponse()) + + const registry = create('alpm', undefined, client) + const pkg = await registry.fetchPackage(' Arch/Pacman ') + + expect(pkg.name).toBe('pacman') + expect(client.getJSON).toHaveBeenCalledWith( + expect.stringContaining('name=pacman'), + undefined, + ) + }) + + it('should default to "arch" namespace when no slash in name', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce(archSearchResponse()) + + const registry = create('alpm', undefined, client) + const pkg = await registry.fetchPackage('pacman') + + expect(pkg.namespace).toBe('arch') + expect(client.getJSON).toHaveBeenCalledWith( + expect.stringContaining('archlinux.org/packages/search/json'), + undefined, + ) + }) + + it('should route "aur/..." to AUR API', async () => { + const client = new Client() + vi.spyOn(client, 'getJSON').mockResolvedValueOnce(aurInfoResponse()) + + const registry = create('alpm', undefined, client) + await registry.fetchPackage('aur/yay') + + expect(client.getJSON).toHaveBeenCalledWith( + expect.stringContaining('aur.archlinux.org/rpc/v5/info'), + undefined, + ) + }) + }) + + describe('URL builder', () => { + it('should generate official package URLs', () => { + const registry = create('alpm') + const urls = registry.urls() + + expect(urls.registry('arch/pacman')).toContain('archlinux.org/packages') + expect(urls.download('arch/pacman', '6.1.0-3')).toBe('https://archive.archlinux.org/packages/p/pacman/') + expect(urls.documentation('arch/pacman')).toContain('wiki.archlinux.org') + expect(urls.purl('arch/pacman', '6.1.0-3')).toBe('pkg:alpm/arch/pacman@6.1.0-3') + }) + + it('should generate AUR package URLs', () => { + const registry = create('alpm') + const urls = registry.urls() + + expect(urls.registry('aur/yay')).toContain('aur.archlinux.org/packages/yay') + expect(urls.download('aur/yay', '12.4.2-1')).toContain('snapshot/yay.tar.gz') + expect(urls.purl('aur/yay', '12.4.2-1')).toBe('pkg:alpm/aur/yay@12.4.2-1') + }) + + it('should point download URL to archive directory without hardcoded arch', () => { + const registry = create('alpm') + const urls = registry.urls() + + const url = urls.download('arch/some-pkg', '2:1.0.0-1') + expect(url).toBe('https://archive.archlinux.org/packages/s/some-pkg/') + expect(url).not.toContain('x86_64') + expect(url).not.toContain('any') + }) + }) +}) From 4b1349858bb84f25f51f2b04b2e9918265663051 Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:15:04 +0100 Subject: [PATCH 2/4] docs: mention alpm in readme --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bc14816..2a29a9a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![npm downloads](https://img.shields.io/npm/dm/regxa?style=flat&colorA=130f40&colorB=474787)](https://npm.chart.dev/regxa) [![license](https://img.shields.io/github/license/oritwoen/regxa?style=flat&colorA=130f40&colorB=474787)](https://github.com/oritwoen/regxa/blob/main/LICENSE) -> Query npm, PyPI, crates.io, RubyGems, and Packagist with one API. PURL-native, typed, cached. +> Query npm, PyPI, crates.io, RubyGems, Packagist, and Arch Linux with one API. PURL-native, typed, cached. ## Why? @@ -16,7 +16,7 @@ regxa fills that gap. One `fetchPackage` call, same response shape, regardless o ## Features -- 🔍 **Single API, five registries** — npm, PyPI, crates.io, RubyGems, Packagist +- 🔍 **Single API, six registries** — npm, PyPI, crates.io, RubyGems, Packagist, Arch Linux (official + AUR) - 📦 **PURL-native** — [ECMA-427](https://github.com/package-url/purl-spec) identifiers as first-class input - 🏷️ **Normalized data model** — same `Package`, `Version`, `Dependency`, `Maintainer` types everywhere - 💾 **Storage-backed cache + lockfile** — unstorage-native, sha256 integrity checks, configurable TTL @@ -52,6 +52,7 @@ await fetchPackageFromPURL('pkg:cargo/serde') await fetchPackageFromPURL('pkg:pypi/flask') await fetchPackageFromPURL('pkg:gem/rails') await fetchPackageFromPURL('pkg:composer/laravel/framework') +await fetchPackageFromPURL('pkg:alpm/arch/pacman') ``` ### CLI @@ -63,6 +64,7 @@ regxa info npm/lodash regxa versions cargo/serde regxa deps pypi/flask@3.1.1 regxa maintainers gem/rails +regxa deps alpm/aur/yay ``` Add `--json` for machine-readable output, `--no-cache` to skip the cache. @@ -76,9 +78,12 @@ Add `--json` for machine-readable output, `--no-cache` to skip the cache. | PyPI | `pkg:pypi/...` | pypi.org | | RubyGems | `pkg:gem/...` | rubygems.org | | Packagist | `pkg:composer/...` | packagist.org | +| Arch Linux | `pkg:alpm/...` | archlinux.org, aur.archlinux.org | Scoped packages work as expected: `pkg:npm/%40vue/core` or `npm/@vue/core` in the CLI. +Arch Linux packages use a namespace prefix: `pkg:alpm/arch/pacman` for official repos, `pkg:alpm/aur/yay` for AUR. The namespace defaults to `arch` if omitted. + ## API reference ### PURL helpers From e08695c805e370cd88070d967574d629a3ada2ae Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:15:50 +0100 Subject: [PATCH 3/4] docs: use paru instead of yay in readme examples --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2a29a9a..249f5ab 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ regxa info npm/lodash regxa versions cargo/serde regxa deps pypi/flask@3.1.1 regxa maintainers gem/rails -regxa deps alpm/aur/yay +regxa deps alpm/aur/paru ``` Add `--json` for machine-readable output, `--no-cache` to skip the cache. @@ -82,7 +82,7 @@ Add `--json` for machine-readable output, `--no-cache` to skip the cache. Scoped packages work as expected: `pkg:npm/%40vue/core` or `npm/@vue/core` in the CLI. -Arch Linux packages use a namespace prefix: `pkg:alpm/arch/pacman` for official repos, `pkg:alpm/aur/yay` for AUR. The namespace defaults to `arch` if omitted. +Arch Linux packages use a namespace prefix: `pkg:alpm/arch/pacman` for official repos, `pkg:alpm/aur/paru` for AUR. The namespace defaults to `arch` if omitted. ## API reference From ca51f184b704c0a25802b6e2966abcdc663f72aa Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:22:45 +0100 Subject: [PATCH 4/4] docs: clarify alpm namespace is optional for official repos --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 249f5ab..18c6b7b 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Add `--json` for machine-readable output, `--no-cache` to skip the cache. Scoped packages work as expected: `pkg:npm/%40vue/core` or `npm/@vue/core` in the CLI. -Arch Linux packages use a namespace prefix: `pkg:alpm/arch/pacman` for official repos, `pkg:alpm/aur/paru` for AUR. The namespace defaults to `arch` if omitted. +Arch Linux packages use a namespace: `pkg:alpm/arch/pacman` (or just `pkg:alpm/pacman`) for official repos, `pkg:alpm/aur/paru` for AUR. Official packages default to `arch` when the namespace is omitted; AUR requires the explicit `aur` namespace. ## API reference