/** * island:publish * * Publish a discovered connected component as an org.latha.island record * on the user's PDS. Reads the SQLite cache from island:discover. * * The canonical island ID is the first 12 hex chars of the SHA-256 of the * lexmin vertex (lexicographically smallest URL in the component). This is * stable across runs — the same component always gets the same ID. * * Usage: * bun island:publish # list islands with their IDs * bun island:publish a1b2c3d4e5f6 # publish by canonical ID * bun island:publish a1b2c3d4e5f6 --dry-run # preview without writing * bun island:publish a1b2c3d4e5f6 --json # JSON output */ import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { AtpAgent } from '@atproto/api'; import { loadDotEnv } from './cli-utils.js'; import { openDb, findComponents, fetchComponentEdges, domainFromUrl, type Component, type EdgeRow } from './island-shared.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); await loadDotEnv(resolve(__dirname, '..', '.env')); await loadDotEnv('/home/nandi/code/swarm/.env'); // fallback for shared credentials const BLUESKY_HANDLE = process.env.BLUESKY_IDENTIFIER ?? ''; const BLUESKY_APP_PASSWORD = process.env.BLUESKY_APP_PASSWORD ?? ''; const PDS_IDENTIFIER = process.env.PDS_IDENTIFIER ?? BLUESKY_HANDLE; const PDS_APP_PASSWORD = process.env.PDS_APP_PASSWORD ?? BLUESKY_APP_PASSWORD; const PDS_SERVICE = process.env.PDS_SERVICE ?? 'https://amanita.us-east.host.bsky.network'; // --- Build island record --- interface AnalysisRow { island_id: string; title: string | null; themes: string | null; highlights: string | null; tensions: string | null; open_questions: string | null; synthesis: string | null; new_connections: string | null; analyzed_at: string; } function loadAnalysis(db: ReturnType, islandId: string): AnalysisRow | null { try { return db.query('SELECT * FROM analyses WHERE island_id = ?').get(islandId) as AnalysisRow | null; } catch { return null; } } function buildIslandRecord(component: Component, edges: EdgeRow[], analysis: AnalysisRow | null) { const lexmin = component.lexmin; const domain = domainFromUrl(lexmin); const nodeCount = component.nodes.size; const edgeCount = edges.length; const contributorCount = component.dids.size; const connections = edges.map((e) => ({ uri: e.atUri ?? '', source: e.source, target: e.target, ...(e.relation ? { connectionType: e.relation } : {}), })); // Merge new connections from analysis if (analysis?.new_connections) { try { const newConns = JSON.parse(analysis.new_connections) as Array<{ source: string; target: string; connectionType: string; note: string }>; for (const nc of newConns) { connections.push({ uri: '', source: nc.source, target: nc.target, connectionType: nc.connectionType, note: nc.note, }); } } catch {} } // Use analysis data if available, otherwise fall back to generic const title = analysis?.title ?? `Island: ${domain} (${nodeCount} nodes, ${edgeCount} edges)`; const themes = analysis?.themes ? JSON.parse(analysis.themes) : ['discovered-island']; const synthesis = analysis?.synthesis ?? [ `Discovered island centered on ${domain}.`, `${nodeCount} nodes connected by ${edgeCount} edges, contributed by ${contributorCount} DIDs.`, `Lexmin vertex: ${lexmin}`, ].join('\n\n'); const record: Record = { $type: 'org.latha.island', source: { uri: lexmin, collection: 'network.cosmik.connection', }, connections, analysis: { title, themes, synthesis, }, createdAt: new Date().toISOString(), }; // Add analysis fields if present if (analysis) { const a = record.analysis as Record; if (analysis.highlights) a.highlights = JSON.parse(analysis.highlights); if (analysis.tensions) a.tensions = JSON.parse(analysis.tensions); if (analysis.open_questions) a.openQuestions = JSON.parse(analysis.open_questions); } return record; } // --- Main --- async function main(): Promise { const args = process.argv.slice(2); const wantJson = args.includes('--json'); const dryRun = args.includes('--dry-run'); const targetId = args.find((a) => !a.startsWith('--')); // Open DB let db; try { db = openDb(); const count = (db.query('SELECT COUNT(*) as c FROM edges').get() as { c: number } | null)?.c ?? 0; if (count === 0) { console.error('No edges found. Run `bun island:discover` first.'); process.exit(1); } } catch { console.error('Database not found. Run `bun island:discover` first.'); process.exit(1); } console.error('Finding components...'); const components = findComponents(db); // No positional arg: list all islands if (!targetId) { if (wantJson) { console.log(JSON.stringify(components.map((c) => ({ id: c.islandId, lexmin: c.lexmin, nodeCount: c.nodes.size, didCount: c.dids.size, })), null, 2)); } else { console.log(`\n${components.length} islands:\n`); for (const c of components.slice(0, 50)) { console.log(` ${c.islandId} ${c.nodes.size} nodes ${domainFromUrl(c.lexmin)}`); } if (components.length > 50) console.log(` ... and ${components.length - 50} more`); console.log(`\nUsage: bun island:publish [--dry-run] [--json]`); } db.close(); return; } const component = components.find((c) => c.islandId === targetId); if (!component) { console.error(`Island ${targetId} not found. Run without args to list available islands.`); process.exit(1); } console.error(`Island ${component.islandId}: ${component.nodes.size} nodes, ${component.dids.size} DIDs (lexmin: ${component.lexmin})`); const edges = fetchComponentEdges(db, component); const analysis = loadAnalysis(db, component.islandId); const record = buildIslandRecord(component, edges, analysis); if (dryRun) { console.error('[dry-run] Would publish:'); if (wantJson) { console.log(JSON.stringify({ islandId: component.islandId, record }, null, 2)); } else { const a = record.analysis as Record; console.log(`Title: ${a.title}`); console.log(`Source: ${(record.source as Record).uri}`); console.log(`Connections: ${(record.connections as unknown[]).length}`); console.log(`Themes: ${(a.themes as string[]).join(', ')}`); console.log(`\nSynthesis:\n${a.synthesis}`); if (analysis) { if (analysis.highlights) console.log(`Highlights: ${JSON.parse(analysis.highlights).length}`); if (analysis.tensions) console.log(`Tensions: ${JSON.parse(analysis.tensions).length}`); if (analysis.open_questions) console.log(`Open questions: ${JSON.parse(analysis.open_questions).length}`); } console.log(`\nConnections:`); for (const c of record.connections as Array>) { console.log(` ${c.source} -> ${c.target} ${c.connectionType ?? ''} (${c.note})`); } } db.close(); return; } // Publish to PDS if (!PDS_IDENTIFIER || !PDS_APP_PASSWORD) { console.error('PDS_IDENTIFIER and PDS_APP_PASSWORD (or BLUESKY_IDENTIFIER/BLUESKY_APP_PASSWORD) must be set'); process.exit(1); } const APPVIEW = process.env.STIGMERGIC_APPVIEW ?? 'https://stigmergic.latha.org'; const agent = new AtpAgent({ service: PDS_SERVICE }); await agent.login({ identifier: PDS_IDENTIFIER, password: PDS_APP_PASSWORD }); const result = await agent.api.com.atproto.repo.putRecord({ repo: agent.session!.did, collection: 'org.latha.island', rkey: component.islandId, record, }); const atUri = result.data.uri; console.error(`Published: ${atUri}`); // Notify appview to crawl the new record try { const did = agent.session!.did; const notifyResp = await fetch(`${APPVIEW}/xrpc/org.latha.island.notifyOfUpdate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ did }), }); if (notifyResp.ok) { const notifyData = await notifyResp.json() as { ingested?: number }; console.error(`Appview ingested: ${notifyData.ingested ?? 0} record(s)`); } else { console.error(`Appview notify failed: ${notifyResp.status} (non-fatal, cron will catch up)`); } } catch (e: any) { console.error(`Appview notify error: ${e.message} (non-fatal, cron will catch up)`); } if (wantJson) { console.log(JSON.stringify({ islandId: component.islandId, atUri, record }, null, 2)); } else { console.log(atUri); } db.close(); } main().catch((err) => { console.error('Fatal:', err instanceof Error ? err.message : String(err)); process.exit(1); });