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';
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}