open source is social v-it.org
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}