open source is social v-it.org
at main 209 lines 8.0 kB view raw
1// SPDX-License-Identifier: MIT 2// Copyright (c) 2026 sol pbc 3 4import { CAP_COLLECTION, SKILL_COLLECTION, DEFAULT_JETSTREAM_URL } from '../lib/constants.js'; 5import { resolveRef } from '../lib/cap-ref.js'; 6import { resolveHandleFromDid } from '../lib/pds.js'; 7import { brand } from '../lib/brand.js'; 8import { jsonOk, jsonError } from '../lib/json-output.js'; 9import { readBeaconSet } from '../lib/vit-dir.js'; 10 11export default function register(program) { 12 program 13 .command('scan') 14 .description('Discover cap and skill publishers across the network via Jetstream replay') 15 .option('--days <n>', 'Number of days to replay', '7') 16 .option('--beacon <beacon>', 'Filter by beacon (caps only)') 17 .option('--skills', 'Show only skill publishers') 18 .option('--caps', 'Show only cap publishers') 19 .option('--tag <tag>', 'Filter skills by tag') 20 .option('-v, --verbose', 'Show each event as it arrives') 21 .option('--json', 'Output as JSON') 22 .option('--jetstream <url>', 'Jetstream WebSocket URL (default: VIT_JETSTREAM_URL env or built-in)') 23 .action(async (opts) => { 24 try { 25 const vlog = opts.json ? (...a) => console.error(...a) : console.log; 26 const days = parseInt(opts.days, 10); 27 if (isNaN(days) || days < 1) { 28 if (opts.json) { 29 jsonError('--days must be a positive integer'); 30 return; 31 } 32 console.error('error: --days must be a positive integer'); 33 process.exitCode = 1; 34 return; 35 } 36 37 const jetstreamUrl = opts.jetstream || process.env.VIT_JETSTREAM_URL || DEFAULT_JETSTREAM_URL; 38 39 const wantCaps = !opts.skills; 40 const wantSkills = !opts.caps; 41 let beaconSet = null; 42 if (opts.beacon) { 43 if (opts.beacon === '.') { 44 beaconSet = readBeaconSet(); 45 if (beaconSet.size === 0) { 46 if (opts.json) { 47 jsonError("no beacon set — run 'vit init' first"); 48 return; 49 } 50 console.error("no beacon set — run 'vit init' first"); 51 process.exitCode = 1; 52 return; 53 } 54 } else { 55 beaconSet = new Set([opts.beacon]); 56 } 57 } 58 59 const cursor = (Date.now() - days * 86400000) * 1000; 60 const timeout = Math.max(120000, Math.min(600000, days * 60000)); 61 62 // Build wanted collections 63 const collections = []; 64 if (wantCaps) collections.push(CAP_COLLECTION); 65 if (wantSkills) collections.push(SKILL_COLLECTION); 66 67 const url = new URL(jetstreamUrl); 68 for (const col of collections) { 69 url.searchParams.append('wantedCollections', col); 70 } 71 url.searchParams.set('cursor', String(cursor)); 72 73 const scanType = wantCaps && wantSkills ? 'cap + skill' : wantSkills ? 'skill' : 'cap'; 74 if (!opts.json) { 75 console.log(`${brand} scan`); 76 console.log(` Replaying ${days} day${days === 1 ? '' : 's'} of ${scanType} events...`); 77 if (beaconSet) console.log(` Beacon filter: ${[...beaconSet].join(', ')}`); 78 if (opts.tag) console.log(` Tag filter: ${opts.tag}`); 79 console.log(` Timeout: ${Math.round(timeout / 1000)}s`); 80 console.log(''); 81 } 82 83 const publishers = new Map(); 84 85 await new Promise((resolve, reject) => { 86 const ws = new WebSocket(url.toString()); 87 const timer = setTimeout(() => { 88 ws.close(); 89 resolve(); 90 }, timeout); 91 92 ws.onmessage = (event) => { 93 let msg; 94 try { msg = JSON.parse(event.data); } catch { return; } 95 96 if (msg.kind !== 'commit' || msg.commit?.operation !== 'create') return; 97 98 const record = msg.commit?.record; 99 if (!record) return; 100 101 const collection = msg.commit?.collection; 102 const isCapEvent = collection === CAP_COLLECTION; 103 const isSkillEvent = collection === SKILL_COLLECTION; 104 105 if (!isCapEvent && !isSkillEvent) return; 106 107 // Apply filters 108 if (isCapEvent && beaconSet && !beaconSet.has(record.beacon)) return; 109 if (isSkillEvent && opts.tag) { 110 const tags = record.tags || []; 111 if (!tags.some(t => t.toLowerCase() === opts.tag.toLowerCase())) return; 112 } 113 114 const did = msg.did; 115 const ref = isCapEvent && msg.commit?.cid ? resolveRef(record, msg.commit.cid) : null; 116 117 if (opts.verbose) { 118 const didShort = did.slice(-12); 119 if (isCapEvent) { 120 const title = record.title || ''; 121 const refPart = ref ? ` (${ref})` : ''; 122 vlog(` ${didShort}: [cap] ${title}${refPart} [${record.beacon || 'no beacon'}]`); 123 } else { 124 const skillName = record.name || ''; 125 const tags = record.tags ? ` [${record.tags.join(', ')}]` : ''; 126 vlog(` ${didShort}: [skill] ${skillName}${tags}`); 127 } 128 } 129 130 if (!publishers.has(did)) { 131 publishers.set(did, { capCount: 0, skillCount: 0, beacons: new Set(), tags: new Set(), lastActive: '' }); 132 } 133 const entry = publishers.get(did); 134 if (isCapEvent) { 135 entry.capCount++; 136 if (record.beacon) entry.beacons.add(record.beacon); 137 } else { 138 entry.skillCount++; 139 if (record.tags) { 140 for (const t of record.tags) entry.tags.add(t); 141 } 142 } 143 if (record.createdAt && record.createdAt > entry.lastActive) { 144 entry.lastActive = record.createdAt; 145 } 146 }; 147 148 ws.onerror = (err) => { 149 clearTimeout(timer); 150 reject(new Error(`WebSocket error: ${err?.message ?? 'unknown'}`)); 151 }; 152 153 ws.onclose = () => { 154 clearTimeout(timer); 155 resolve(); 156 }; 157 }); 158 159 if (publishers.size === 0) { 160 if (opts.json) { 161 jsonOk({ publishers: [] }); 162 return; 163 } 164 console.log(`no ${scanType} publishers found in this time window.`); 165 console.log('the network is young — be an early publisher.'); 166 console.log("ship a cap with 'vit ship' or a skill with 'vit ship --skill' to get things started."); 167 return; 168 } 169 170 const entries = []; 171 for (const [did, stats] of publishers) { 172 const handle = await resolveHandleFromDid(did); 173 entries.push({ handle, did, ...stats, beacons: [...stats.beacons], tags: [...stats.tags] }); 174 } 175 176 const totalCount = (e) => e.capCount + e.skillCount; 177 entries.sort((a, b) => totalCount(b) - totalCount(a)); 178 179 if (opts.json) { 180 jsonOk({ publishers: entries }); 181 return; 182 } 183 console.log(`found ${entries.length} publisher${entries.length === 1 ? '' : 's'}:\n`); 184 for (const e of entries) { 185 console.log(` @${e.handle}`); 186 const parts = []; 187 if (wantCaps && e.capCount > 0) { 188 const beaconStr = e.beacons.length > 0 ? e.beacons.join(', ') : '(none)'; 189 parts.push(`caps: ${e.capCount} beacons: ${beaconStr}`); 190 } 191 if (wantSkills && e.skillCount > 0) { 192 const tagStr = e.tags.length > 0 ? e.tags.join(', ') : '(none)'; 193 parts.push(`skills: ${e.skillCount} tags: ${tagStr}`); 194 } 195 const lastActive = e.lastActive ? e.lastActive.split('T')[0] : 'unknown'; 196 parts.push(`last active: ${lastActive}`); 197 console.log(` ${parts.join(' ')}`); 198 } 199 } catch (err) { 200 const msg = err instanceof Error ? err.message : String(err); 201 if (opts.json) { 202 jsonError(msg); 203 return; 204 } 205 console.error(msg); 206 process.exitCode = 1; 207 } 208 }); 209}