#!/usr/bin/env npx tsx /** * Plugin Registry Publisher * * Publishes the plugin registry to IPFS via Pinata and updates IPNS pointer. * * Setup: * 1. Create Pinata account at https://pinata.cloud * 2. Generate API keys (JWT) * 3. Set environment variables: * - PINATA_JWT: Your Pinata JWT token * - REGISTRY_PRIVATE_KEY: Ed25519 private key (hex) for signing * * Usage: * npx tsx scripts/publish-registry.ts * npx tsx scripts/publish-registry.ts --dry-run * npx tsx scripts/publish-registry.ts --registry ./custom-registry.json */ import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Types interface PluginEntry { id: string; name: string; displayName: string; description: string; version: string; cid?: string; size: number; checksum: string; author: { id: string; displayName: string; verified: boolean; }; license: string; categories: string[]; tags: string[]; downloads: number; rating: number; lastUpdated: string; minClaudeFlowVersion: string; type: string; hooks: string[]; commands: string[]; permissions: string[]; exports: string[]; verified: boolean; trustLevel: string; } interface PluginRegistry { version: string; type: 'plugins'; updatedAt: string; ipnsName: string; plugins: PluginEntry[]; categories: Array<{ id: string; name: string; description: string; pluginCount: number }>; totalPlugins: number; totalDownloads: number; featured: string[]; trending: string[]; newest: string[]; official: string[]; registrySignature?: string; registryPublicKey?: string; } interface PinataResponse { IpfsHash: string; PinSize: number; Timestamp: string; } // Configuration const PINATA_API_URL = 'https://api.pinata.cloud'; const DEFAULT_REGISTRY_PATH = path.join(__dirname, '../src/plugins/store/registry.json'); // Parse command line arguments const args = process.argv.slice(2); const isDryRun = args.includes('--dry-run'); const registryPathArg = args.find(a => a.startsWith('--registry=')); const registryPath = registryPathArg ? registryPathArg.split('=')[1] : DEFAULT_REGISTRY_PATH; /** * Fetch npm stats for a package */ async function fetchNpmStats(packageName: string): Promise<{ downloads: number; version: string } | null> { try { const downloadsUrl = `https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(packageName)}`; const downloadsRes = await fetch(downloadsUrl, { signal: AbortSignal.timeout(5000) }); if (!downloadsRes.ok) return null; const downloadsData = await downloadsRes.json() as { downloads?: number }; const packageUrl = `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`; const packageRes = await fetch(packageUrl, { signal: AbortSignal.timeout(5000) }); let version = 'unknown'; if (packageRes.ok) { const packageData = await packageRes.json() as { version?: string }; version = packageData.version || 'unknown'; } return { downloads: downloadsData.downloads || 0, version, }; } catch { return null; } } /** * Sign registry with Ed25519 */ async function signRegistry(registry: PluginRegistry, privateKeyHex: string): Promise<{ signature: string; publicKey: string; }> { const ed = await import('@noble/ed25519'); const privateKey = Buffer.from(privateKeyHex, 'hex'); const publicKey = await ed.getPublicKeyAsync(privateKey); // Create a copy without signature fields for signing const registryToSign = { ...registry }; delete registryToSign.registrySignature; delete registryToSign.registryPublicKey; const message = JSON.stringify(registryToSign); const signature = await ed.signAsync( new TextEncoder().encode(message), privateKey ); return { signature: Buffer.from(signature).toString('hex'), publicKey: `ed25519:${Buffer.from(publicKey).toString('hex')}`, }; } /** * Pin JSON to IPFS via Pinata */ async function pinToIPFS(data: unknown, name: string, jwt: string): Promise { const response = await fetch(`${PINATA_API_URL}/pinning/pinJSONToIPFS`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${jwt}`, }, body: JSON.stringify({ pinataContent: data, pinataMetadata: { name, keyvalues: { type: 'plugin-registry', publishedAt: new Date().toISOString(), }, }, pinataOptions: { cidVersion: 1, }, }), }); if (!response.ok) { const error = await response.text(); throw new Error(`Pinata error: ${response.status} - ${error}`); } return response.json() as Promise; } /** * Generate a demo registry from npm packages */ async function generateRegistry(): Promise { console.log('šŸ“¦ Fetching npm stats for plugins...'); const officialPackages = [ '@claude-flow/plugin-agentic-qe', '@claude-flow/plugin-prime-radiant', '@claude-flow/plugin-gastown-bridge', '@claude-flow/security', '@claude-flow/claims', '@claude-flow/embeddings', '@claude-flow/neural', '@claude-flow/performance', '@claude-flow/plugins', ]; const plugins: PluginEntry[] = []; const now = new Date().toISOString(); for (const pkg of officialPackages) { console.log(` Fetching ${pkg}...`); const stats = await fetchNpmStats(pkg); plugins.push({ id: pkg, name: pkg, displayName: pkg.replace('@claude-flow/plugin-', '').replace('@claude-flow/', ''), description: `Official Claude Flow plugin: ${pkg}`, version: stats?.version || '0.0.0', size: 100000, checksum: `sha256:${crypto.randomBytes(32).toString('hex')}`, author: { id: 'claude-flow-team', displayName: 'Claude Flow Team', verified: true, }, license: 'MIT', categories: ['official'], tags: [pkg.split('/').pop() || ''], downloads: stats?.downloads || 0, rating: 0, lastUpdated: now, minClaudeFlowVersion: '3.0.0', type: 'integration', hooks: [], commands: [], permissions: ['memory', 'filesystem'], exports: [], verified: true, trustLevel: 'official', }); } const totalDownloads = plugins.reduce((sum, p) => sum + p.downloads, 0); return { version: '1.0.0', type: 'plugins', updatedAt: now, ipnsName: '', // Will be set after publishing plugins, categories: [ { id: 'official', name: 'Official', description: 'Official Claude Flow plugins', pluginCount: plugins.length }, ], totalPlugins: plugins.length, totalDownloads, featured: plugins.slice(0, 3).map(p => p.id), trending: plugins.sort((a, b) => b.downloads - a.downloads).slice(0, 3).map(p => p.id), newest: plugins.slice(-3).map(p => p.id), official: plugins.map(p => p.id), }; } /** * Main publish function */ async function main() { console.log('šŸš€ Plugin Registry Publisher\n'); // Check environment const jwt = process.env.PINATA_JWT; const privateKey = process.env.REGISTRY_PRIVATE_KEY; if (!jwt) { console.error('āŒ PINATA_JWT environment variable is required'); console.log('\nGet your JWT from https://pinata.cloud/keys'); process.exit(1); } // Load or generate registry let registry: PluginRegistry; if (fs.existsSync(registryPath)) { console.log(`šŸ“„ Loading registry from ${registryPath}`); const content = fs.readFileSync(registryPath, 'utf-8'); registry = JSON.parse(content); } else { console.log('šŸ“„ Generating registry from npm packages...'); registry = await generateRegistry(); } // Update timestamp registry.updatedAt = new Date().toISOString(); console.log(`\nšŸ“Š Registry Stats:`); console.log(` Plugins: ${registry.plugins.length}`); console.log(` Total Downloads: ${registry.totalDownloads.toLocaleString()}`); console.log(` Updated: ${registry.updatedAt}`); // Sign registry if private key is available if (privateKey) { console.log('\nšŸ” Signing registry with Ed25519...'); const { signature, publicKey } = await signRegistry(registry, privateKey); registry.registrySignature = signature; registry.registryPublicKey = publicKey; console.log(` Public Key: ${publicKey.slice(0, 30)}...`); } else { console.log('\nāš ļø No REGISTRY_PRIVATE_KEY set, skipping signature'); } if (isDryRun) { console.log('\nšŸ” Dry run - would publish:'); console.log(JSON.stringify(registry, null, 2).slice(0, 1000) + '...'); return; } // Pin to IPFS console.log('\nšŸ“Œ Pinning to IPFS via Pinata...'); try { const result = await pinToIPFS(registry, 'claude-flow-plugin-registry', jwt); console.log('\nāœ… Published successfully!'); console.log(` CID: ${result.IpfsHash}`); console.log(` Size: ${(result.PinSize / 1024).toFixed(2)} KB`); console.log(`\n🌐 Gateway URLs:`); console.log(` https://gateway.pinata.cloud/ipfs/${result.IpfsHash}`); console.log(` https://ipfs.io/ipfs/${result.IpfsHash}`); console.log(` https://cloudflare-ipfs.com/ipfs/${result.IpfsHash}`); console.log(` https://dweb.link/ipfs/${result.IpfsHash}`); // Save CID for reference const cidFile = path.join(__dirname, '../.registry-cid'); fs.writeFileSync(cidFile, result.IpfsHash); console.log(`\nšŸ’¾ CID saved to ${cidFile}`); // Update discovery.ts config (manual step reminder) console.log('\nšŸ“ Next steps:'); console.log(' 1. Update DEFAULT_PLUGIN_STORE_CONFIG in discovery.ts with the new CID'); console.log(' 2. If using IPNS, update the IPNS pointer via Pinata dashboard'); console.log(' 3. Test with: npx claude-flow@latest plugins list'); } catch (error) { console.error('\nāŒ Publish failed:', error); process.exit(1); } } // Run main().catch(console.error);