From 64f3d66d98cbb3a7a3bd82c36e49da41c24c7218 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 13 May 2026 14:31:05 +0200 Subject: [PATCH 1/2] fix registry metadata on republish --- .../src/routes/__tests__/packages.test.ts | 364 +++++++++++++++++- packages/registry/src/routes/packages.ts | 31 +- 2 files changed, 381 insertions(+), 14 deletions(-) diff --git a/packages/registry/src/routes/__tests__/packages.test.ts b/packages/registry/src/routes/__tests__/packages.test.ts index e51090e3..f7b0db21 100644 --- a/packages/registry/src/routes/__tests__/packages.test.ts +++ b/packages/registry/src/routes/__tests__/packages.test.ts @@ -2,9 +2,32 @@ * Package routes tests */ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; -import Fastify, { FastifyInstance } from 'fastify'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { FastifyInstance, FastifyRequest } from 'fastify'; import { packageRoutes } from '../packages'; +import { gzipSync } from 'zlib'; + +vi.mock('../../cache/redis.js', () => ({ + cacheGet: vi.fn().mockResolvedValue(null), + cacheSet: vi.fn().mockResolvedValue(undefined), + cacheDelete: vi.fn().mockResolvedValue(undefined), + cacheDeletePattern: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../storage/s3.js', () => ({ + uploadPackage: vi.fn().mockImplementation( + async ( + _server: FastifyInstance, + packageName: string, + version: string, + tarball: Buffer, + ) => ({ + url: `https://storage.example.test/${encodeURIComponent(packageName)}/${version}.tar.gz`, + hash: `sha256-${version}`, + size: tarball.length, + }), + ), +})); describe('Package Routes', () => { let server: FastifyInstance; @@ -13,7 +36,9 @@ describe('Package Routes', () => { server = Fastify(); // Mock authenticate decorator - server.decorate('authenticate', async () => {}); + server.decorate('authenticate', async (request: FastifyRequest) => { + request.user = { user_id: 'test-user-id', username: 'test-user' }; + }); // Create mock query function const mockQuery = async (sql: string, params?: unknown[]) => { @@ -291,10 +316,6 @@ describe('Package Routes', () => { return originalMockQuery(sql, params); }; - // Mock user authentication - (server as any).decorate('authenticate', async (request: any) => { - request.user = { user_id: 'test-user-id', username: 'test-user' }; - }, { decorateRequest: true }); }); it('should star a package', async () => { @@ -393,10 +414,6 @@ describe('Package Routes', () => { return originalMockQuery(sql, params); }; - // Mock user authentication - (server as any).decorate('authenticate', async (request: any) => { - request.user = { user_id: 'test-user-id', username: 'test-user' }; - }, { decorateRequest: true }); }); it('should return starred packages', async () => { @@ -431,3 +448,328 @@ describe('Package Routes', () => { }); }); }); + +interface TestPackageRow { + id: string; + name: string; + display_name: string | null; + description: string; + author_id: string | null; + org_id: string | null; + format: string; + subtype: string; + license: string | null; + tags: string[]; + keywords: string[]; + language: string | null; + framework: string | null; + visibility: string; + eager: boolean; + created_at: Date; + updated_at: Date; + last_published_at: Date | null; + ai_enrichment_needed: boolean; + full_content: string | null; +} + +interface TestPackageVersionRow { + package_id: string; + version: string; + tarball_url: string; + content_hash: string; + file_size: number; + published_at: Date; + metadata: unknown; + eager: boolean; +} + +function createRedisMock() { + const store = new Map(); + + return { + get: vi.fn(async (key: string) => store.get(key) ?? null), + set: vi.fn(async (key: string, value: string) => { + store.set(key, value); + return 'OK'; + }), + setex: vi.fn(async (key: string, _seconds: number, value: string) => { + store.set(key, value); + return 'OK'; + }), + incr: vi.fn(async (key: string) => { + const next = Number(store.get(key) ?? '0') + 1; + store.set(key, String(next)); + return next; + }), + decr: vi.fn(async (key: string) => { + const next = Number(store.get(key) ?? '0') - 1; + store.set(key, String(next)); + return next; + }), + expire: vi.fn(async () => 1), + ttl: vi.fn(async () => 60), + del: vi.fn(async (...keys: string[]) => { + keys.forEach((key) => store.delete(key)); + return keys.length; + }), + keys: vi.fn(async () => [] as string[]), + }; +} + +function buildPublishManifest(version: string, format: string) { + return { + name: '@ci-test/metadata-drift', + version, + displayName: `Metadata Drift ${version}`, + description: `Manifest metadata for ${format}`, + format, + subtype: format === 'claude' ? 'agent' : 'skill', + license: format === 'claude' ? 'Apache-2.0' : 'MIT', + tags: [format, 'metadata'], + keywords: [format, 'republish'], + language: format === 'claude' ? 'typescript' : 'markdown', + framework: format === 'claude' ? 'node' : undefined, + eager: format === 'claude', + }; +} + +function writeTarString(buffer: Buffer, offset: number, length: number, value: string) { + buffer.write(value.slice(0, length), offset, length, 'utf8'); +} + +function writeTarOctal(buffer: Buffer, offset: number, length: number, value: number) { + const octal = value.toString(8).padStart(length - 1, '0'); + buffer.write(`${octal}\0`, offset, length, 'ascii'); +} + +function createPrpmJsonTarballBase64(manifest: Record) { + const fileContent = Buffer.from(JSON.stringify(manifest), 'utf8'); + const header = Buffer.alloc(512, 0); + + writeTarString(header, 0, 100, 'prpm.json'); + writeTarOctal(header, 100, 8, 0o644); + writeTarOctal(header, 108, 8, 0); + writeTarOctal(header, 116, 8, 0); + writeTarOctal(header, 124, 12, fileContent.length); + writeTarOctal(header, 136, 12, 0); + header.fill(' ', 148, 156); + writeTarString(header, 156, 1, '0'); + writeTarString(header, 257, 6, 'ustar'); + writeTarString(header, 263, 2, '00'); + + const checksum = header.reduce((sum, byte) => sum + byte, 0); + const checksumValue = checksum.toString(8).padStart(6, '0'); + header.write(`${checksumValue}\0 `, 148, 8, 'ascii'); + + const paddingLength = (512 - (fileContent.length % 512)) % 512; + const tarball = Buffer.concat([ + header, + fileContent, + Buffer.alloc(paddingLength, 0), + Buffer.alloc(1024, 0), + ]); + + return gzipSync(tarball).toString('base64'); +} + +describe('Package publish metadata republish', () => { + let server: FastifyInstance; + let originalCiMode: string | undefined; + let packageRow: TestPackageRow | null; + let versionRows: TestPackageVersionRow[]; + + beforeAll(async () => { + originalCiMode = process.env.CI_MODE; + process.env.CI_MODE = 'true'; + + packageRow = null; + versionRows = []; + server = Fastify(); + server.decorate('authenticate', async () => {}); + server.decorate('redis', createRedisMock() as never); + + const rowResult = (rows: T[]) => ({ + rows, + rowCount: rows.length, + command: 'SELECT', + oid: 0, + fields: [], + }); + + const queryMock = vi.fn(async (sql: string, params: unknown[] = []) => { + const normalizedSql = sql.replace(/\s+/g, ' ').trim(); + + if (normalizedSql.startsWith('SELECT * FROM packages WHERE name = $1')) { + return rowResult( + packageRow && packageRow.name === params[0] ? [packageRow] : [], + ); + } + + if (normalizedSql.startsWith('SELECT version FROM package_versions')) { + return rowResult( + versionRows + .filter((row) => row.package_id === params[0] && row.version === params[1]) + .map((row) => ({ version: row.version })), + ); + } + + if (normalizedSql.startsWith('INSERT INTO packages')) { + const now = new Date(); + packageRow = { + id: 'pkg-metadata-drift', + name: params[0] as string, + display_name: params[1] as string | null, + description: params[2] as string, + author_id: params[3] as string | null, + org_id: params[4] as string | null, + format: params[5] as string, + subtype: params[6] as string, + license: params[7] as string | null, + tags: params[8] as string[], + keywords: params[9] as string[], + language: params[10] as string | null, + framework: params[11] as string | null, + visibility: params[12] as string, + eager: params[13] as boolean, + created_at: now, + updated_at: now, + last_published_at: now, + ai_enrichment_needed: true, + full_content: null, + }; + + return rowResult([packageRow]); + } + + if (normalizedSql.startsWith('UPDATE packages SET display_name = $1')) { + if (!packageRow) { + return rowResult([]); + } + + packageRow = { + ...packageRow, + display_name: params[0] as string | null, + description: params[1] as string, + format: params[2] as string, + subtype: params[3] as string, + license: params[4] as string | null, + tags: params[5] as string[], + keywords: params[6] as string[], + language: params[7] as string | null, + framework: params[8] as string | null, + eager: params[9] as boolean, + ai_enrichment_needed: true, + updated_at: new Date(), + }; + + return rowResult([packageRow]); + } + + if (normalizedSql.startsWith('INSERT INTO package_versions')) { + const row: TestPackageVersionRow = { + package_id: params[0] as string, + version: params[1] as string, + tarball_url: params[2] as string, + content_hash: params[3] as string, + file_size: params[4] as number, + published_at: new Date(), + metadata: params[5], + eager: params[6] as boolean, + }; + versionRows.push(row); + return rowResult([row]); + } + + if (normalizedSql.startsWith('UPDATE packages SET last_published_at')) { + if (packageRow) { + packageRow = { + ...packageRow, + last_published_at: new Date(), + updated_at: new Date(), + full_content: params[1] as string | null, + ai_enrichment_needed: true, + }; + } + return rowResult(packageRow ? [packageRow] : []); + } + + if (normalizedSql.startsWith('SELECT * FROM package_versions')) { + return rowResult( + versionRows + .filter((row) => row.package_id === params[0]) + .sort((a, b) => b.published_at.getTime() - a.published_at.getTime()), + ); + } + + return rowResult([]); + }); + + server.decorate('pg', { + query: queryMock, + connect: async () => ({ + query: queryMock, + release: () => {}, + }), + } as never); + + await server.register(packageRoutes, { prefix: '/api/v1/packages' }); + await server.ready(); + }); + + afterAll(async () => { + if (originalCiMode === undefined) { + delete process.env.CI_MODE; + } else { + process.env.CI_MODE = originalCiMode; + } + await server.close(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + async function publish(version: string, format: string) { + const manifest = buildPublishManifest(version, format); + + return server.inject({ + method: 'POST', + url: '/api/v1/packages', + payload: { + manifest, + tarball: createPrpmJsonTarballBase64(manifest), + }, + }); + } + + it('updates mutable package metadata from the latest manifest when republishing', async () => { + const firstPublish = await publish('1.0.0', 'generic'); + expect(firstPublish.statusCode).toBe(200); + + const secondPublish = await publish('1.0.1', 'claude'); + expect(secondPublish.statusCode).toBe(200); + + const response = await server.inject({ + method: 'GET', + url: `/api/v1/packages/${encodeURIComponent('@ci-test/metadata-drift')}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body) as TestPackageRow & { + versions: TestPackageVersionRow[]; + }; + + expect(body.format).toBe('claude'); + expect(body.subtype).toBe('agent'); + expect(body.description).toBe('Manifest metadata for claude'); + expect(body.display_name).toBe('Metadata Drift 1.0.1'); + expect(body.license).toBe('Apache-2.0'); + expect(body.tags).toEqual(['claude', 'metadata']); + expect(body.keywords).toEqual(['claude', 'republish']); + expect(body.language).toBe('typescript'); + expect(body.framework).toBe('node'); + expect(body.eager).toBe(true); + expect(body.ai_enrichment_needed).toBe(true); + expect(body.versions.map((version) => version.version)).toEqual(['1.0.1', '1.0.0']); + }); +}); diff --git a/packages/registry/src/routes/packages.ts b/packages/registry/src/routes/packages.ts index 57a5689b..85de1efd 100644 --- a/packages/registry/src/routes/packages.ts +++ b/packages/registry/src/routes/packages.ts @@ -1131,11 +1131,36 @@ export async function packageRoutes(server: FastifyInstance) { }); } - // Update eager field for existing package if it changed in the manifest + // Keep mutable package metadata aligned with the latest published manifest. await query( server, - "UPDATE packages SET eager = $1 WHERE id = $2", - [eager, pkg.id], + `UPDATE packages SET + display_name = $1, + description = $2, + format = $3, + subtype = $4, + license = $5, + tags = $6, + keywords = $7, + language = $8, + framework = $9, + eager = $10, + ai_enrichment_needed = TRUE, + updated_at = NOW() + WHERE id = $11`, + [ + displayName || null, + description, + format, + subtype, + license || null, + tags, + keywords, + language || null, + framework || null, + eager, + pkg.id, + ], ); } else { // New package - create it From 0d5a6dd1b7732fd2d169121aea80faca900e459a Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 13 May 2026 14:35:22 +0200 Subject: [PATCH 2/2] fix README package count typo --- README.md | 2 +- packages/cli/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ea0dc61b..f9944b5a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ prpm install collections/nextjs-pro # Entire Next.js setup in one command ``` Installs 20 packages: backend-architect, cloud-architect, database-architect, and more -7,5000+ cross platform packages +7,500+ cross platform packages [Docs](https://docs.prpm.dev) | [Search Packages](https://prpm.dev/search) | [Search Collections](https://prpm.dev/search?tab=collections) diff --git a/packages/cli/README.md b/packages/cli/README.md index 14c76941..2376b370 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14,7 +14,7 @@ prpm install collections/nextjs-pro # Entire Next.js setup in one command ``` Installs 20 packages: backend-architect, cloud-architect, database-architect, and more -7,5000+ cross platform packages +7,500+ cross platform packages [Docs](https://docs.prpm.dev) | [Search Packages](https://prpm.dev/search) | [Search Collections](https://prpm.dev/search?tab=collections)