open source is social v-it.org
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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