#!/usr/bin/env node
// Wirecore - P2P Social Network Terminal Client
//
// Architecture:
// - Each user has their own writable Hypercore feed
// - Following = joining their Hyperswarm topic and replicating read-only
// - Timeline = merge all followed feeds by timestamp
// - Sparse replication = only followed users, not entire network
//
// pgh provides P2P OTA updates (content-addressed) when installed via pgh.
//
// Usage: bare index.js <command> [options]
import Corestore from 'corestore'
import Hyperswarm from 'hyperswarm'
import Hyperbee from 'hyperbee'
import Hyperdrive from 'hyperdrive'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import goodbye from 'graceful-goodbye'
import IdentityKey from 'keet-identity-key'
import http from 'bare-http1'
import https from 'bare-https'
import bareWs from 'bare-ws'
import fs from 'bare-fs'
import path from 'bare-path'
import process from 'bare-process'
import os from 'bare-os'
import subprocess from 'bare-subprocess'
import { ANProto } from './lib/anproto.js'
import { YAMLHelper } from './lib/yaml-helper.js'
const { Server: WebSocketServer } = bareWs
// Check if running in Pear or Node
const isPear = typeof Pear !== 'undefined'
function getAppDir () {
return path.dirname(new URL(import.meta.url).pathname)
}
function getStorageDir () {
const p = os.platform()
const name = 'wirecore'
if (p === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', name)
if (p === 'win32') return path.join(os.homedir(), 'AppData', 'Roaming', name)
return path.join(os.homedir(), '.config', name)
}
// Configuration
const STORAGE_PATH = isPear && Pear.config.storage ? Pear.config.storage : getStorageDir()
// Global state
let store = null
let swarm = null
let myFeed = null
let metaBee = null
let contentCache = null // Hyperbee for content-by-hash storage (Phase 3)
let userDrive = null // Hyperdrive for user files (images, etc.)
let identity = null // Legacy IdentityKey (will be removed in Phase 2)
let anprotoKeypair = null // ANProto keypair { publicKey: Buffer, secretKey: Buffer }
let follows = new Map() // discoveryKey -> { feed, publicKey, name }
let bridgeInstance = null // Bridge daemon instance (WirecoreBridge, for web UI management)
let bridgeConfig = { relayUrl: 'https://7rnx.net/relay', autoStart: false } // Bridge configuration
/**
* Initialize Corestore and load state (without starting swarm)
*/
async function loadState() {
if (store) return true // Already loaded
// Try to open Corestore - if it fails, storage doesn't exist yet
try {
store = new Corestore(STORAGE_PATH)
await store.ready()
// Load metadata first (needed for both systems)
metaBee = new Hyperbee(store.get({ name: 'metadata' }), {
keyEncoding: 'utf-8',
valueEncoding: 'json'
})
await metaBee.ready()
// Load content cache (Phase 3: content-by-hash storage)
contentCache = new Hyperbee(store.get({ name: 'content-cache' }), {
keyEncoding: 'utf-8',
valueEncoding: 'utf-8'
})
await contentCache.ready()
// Check for ANProto keypair (new system) FIRST
const keypairNode = await metaBee.get('anproto-keypair')
if (keypairNode && keypairNode.value) {
// New ANProto system found
const keypairStr = keypairNode.value
const { publicKey, secretKey } = ANProto.loadKeypair(keypairStr)
anprotoKeypair = { publicKey, secretKey }
// Load feed using ANProto keypair
myFeed = store.get({
publicKey: anprotoKeypair.publicKey,
secretKey: anprotoKeypair.secretKey
})
await myFeed.ready()
// User Hyperdrive will be created lazily when first image is uploaded
return true
}
// Fallback: try old IdentityKey system
const identityCore = store.get({ name: 'identity' })
await identityCore.ready()
if (identityCore.length === 0) {
// Not initialized at all
await store.close()
store = null
metaBee = null
return null
}
// Old system exists
const identitySeed = await identityCore.get(0)
identity = await IdentityKey.from({ seed: identitySeed })
// Load personal feed (old system uses named feed)
myFeed = store.get({ name: 'my-feed' })
await myFeed.ready()
// User Hyperdrive will be created lazily when first image is uploaded
return true
} catch (err) {
// Storage doesn't exist or is corrupted
if (store) {
try { await store.close() } catch {}
}
// Reset all globals
store = null
myFeed = null
metaBee = null
contentCache = null
userDrive = null
identity = null
return null
}
}
/**
* Start Hyperswarm networking
*/
async function startSwarm() {
if (swarm) return
swarm = new Hyperswarm()
let connectionCount = 0
swarm.on('connection', (socket, peerInfo) => {
connectionCount++
console.log(`🔗 Connected to peer ${connectionCount}`)
store.replicate(socket)
})
// Announce our feed
const discovery = swarm.join(myFeed.discoveryKey, { server: true, client: false })
await discovery.flushed()
console.log(` 📢 Announced feed: ${b4a.toString(myFeed.discoveryKey, 'hex').substring(0, 16)}...`)
// Load and join followed feeds
await loadFollows()
console.log(` 📥 Joined ${follows.size} follow topics`)
// Graceful shutdown
goodbye(() => cleanup())
}
/**
* Load existing follows from metadata and join swarm topics
*/
async function loadFollows() {
const node = await metaBee.get('follows')
if (!node) return
const followList = node.value
for (const { publicKey, name } of followList) {
const pubKeyBuf = b4a.from(publicKey, 'hex')
const feedKey = crypto.discoveryKey(pubKeyBuf)
const feed = store.get(pubKeyBuf)
await feed.ready()
if (swarm) {
const discovery = swarm.join(feedKey, { server: false, client: true })
await discovery.flushed()
}
// Listen for new posts from followed feeds
feed.on('download', (index) => {
console.log(`📥 Downloaded block ${index} from ${name || publicKey.substring(0, 16)}`)
broadcast({ type: 'new_post', feed: 'followed', publicKey, name: name || publicKey.substring(0, 16) })
})
// Continuously check for new blocks (every 5 seconds)
const updateInterval = setInterval(async () => {
try {
const oldLength = feed.length
await feed.update()
if (feed.length > oldLength) {
console.log(`🔄 Feed updated for ${name}, new length: ${feed.length}`)
}
} catch (err) {
console.error(`Failed to update feed for ${name}:`, err.message)
}
}, 5000)
follows.set(b4a.toString(feedKey, 'hex'), {
feed,
publicKey,
name: name || publicKey.substring(0, 16),
updateInterval
})
}
}
/**
* Save follows to metadata
*/
async function saveFollows() {
const followList = []
for (const [discoveryKey, { publicKey, name }] of follows.entries()) {
followList.push({ publicKey, name })
}
await metaBee.put('follows', followList)
}
/**
* Ensure user Hyperdrive is initialized (lazy loading)
*/
async function ensureUserDrive() {
if (userDrive) return userDrive
userDrive = new Hyperdrive(store, { name: 'user-files' })
await userDrive.ready()
return userDrive
}
/**
* Cleanup on exit
*/
async function cleanup() {
if (swarm) {
await swarm.destroy()
swarm = null
}
if (userDrive) {
await userDrive.close()
userDrive = null
}
if (store) {
await store.close()
store = null
}
}
/**
* Command: Initialize new identity
*/
async function cmdInit() {
const exists = await loadState()
if (exists) {
console.log('⚠️ Already initialized')
if (anprotoKeypair) {
// New system (ANProto)
const publicKey = b4a.toString(anprotoKeypair.publicKey, 'base64')
console.log('ANProto public key:', publicKey)
console.log('Feed key:', b4a.toString(myFeed.key, 'hex'))
} else if (identity) {
// Old system (IdentityKey)
const publicKey = b4a.toString(identity.identityPublicKey, 'hex')
console.log('Legacy public key:', publicKey)
}
return
}
console.log('📝 Initializing Wirecore with ANProto...')
// Create Corestore
store = new Corestore(STORAGE_PATH)
await store.ready()
// Generate ANProto keypair
const { keypair, publicKey, secretKey } = ANProto.gen()
// Note: publicKey and secretKey from gen() are Buffers
anprotoKeypair = { publicKey, secretKey }
// Create personal feed using ANProto keypair
// This unifies identity: ANProto public key = Hypercore feed key
myFeed = store.get({
publicKey: anprotoKeypair.publicKey,
secretKey: anprotoKeypair.secretKey
})
await myFeed.ready()
// Create metadata
metaBee = new Hyperbee(store.get({ name: 'metadata' }), {
keyEncoding: 'utf-8',
valueEncoding: 'json'
})
await metaBee.ready()
// Store ANProto keypair and other metadata
await metaBee.put('anproto-keypair', keypair)
await metaBee.put('follows', [])
await metaBee.put('profile', { username: null })
console.log('✅ Wirecore initialized with ANProto!')
console.log('ANProto public key:', b4a.toString(publicKey, 'base64'))
console.log('Feed key (same as ANProto key):', b4a.toString(myFeed.key, 'hex'))
console.log('')
console.log('💡 Set your username with: pear run . cli setname "YourName"')
}
/**
* Command: Post a message
*/
async function cmdPost(message) {
await loadState()
if (!myFeed) {
console.error('❌ Not initialized. Run: init')
return
}
// Get username from profile
const profileNode = await metaBee.get('profile')
const username = profileNode && profileNode.value && profileNode.value.username
const timestamp = Date.now()
// Get previous post hash for chain
let previousHash = null
if (myFeed.length > 0) {
const prevBlock = await myFeed.get(myFeed.length - 1)
previousHash = b4a.toString(crypto.hash(prevBlock), 'base64')
}
// Create YAML format for relay compatibility
const metadata = {
name: username || null,
previous: previousHash
}
const yaml = YAMLHelper.create(metadata, message)
// Phase 3: Hash content and store in contentCache
const contentHash = ANProto.hash(yaml)
await contentCache.put(contentHash, yaml)
// Store in feed with both full content (transition) and hash
const entry = {
yaml,
contentHash, // Phase 3: Add hash for Phase 4 transition
timestamp,
username: username || null
}
await myFeed.append(Buffer.from(JSON.stringify(entry)))
console.log('✅ Posted:', message)
console.log(' Hash:', contentHash.substring(0, 16) + '...')
}
/**
* Command: Set username
*/
async function cmdSetName(username) {
await loadState()
if (!metaBee) {
console.error('❌ Not initialized. Run: init')
return
}
if (!username || username.trim().length === 0) {
console.error('❌ Username cannot be empty')
return
}
// Get existing profile or create new one
const profileNode = await metaBee.get('profile')
const profile = (profileNode && profileNode.value) || {}
// Update username
profile.username = username.trim()
await metaBee.put('profile', profile)
console.log('✅ Username set to:', profile.username)
}
/**
* Command: Follow a user
*/
async function cmdFollow(publicKeyHex, name) {
await loadState()
if (!metaBee) {
console.error('❌ Not initialized. Run: init')
return
}
const pubKeyBuf = b4a.from(publicKeyHex, 'hex')
const feedKey = crypto.discoveryKey(pubKeyBuf)
const discoveryKeyHex = b4a.toString(feedKey, 'hex')
if (follows.has(discoveryKeyHex)) {
console.log('⚠️ Already following this user')
return
}
const feed = store.get(pubKeyBuf)
await feed.ready()
// Listen for new posts from this feed
feed.on('download', (index) => {
console.log(`📥 Downloaded block ${index} from ${name || publicKeyHex.substring(0, 16)}`)
broadcast({ type: 'new_post', feed: 'followed', publicKey: publicKeyHex, name: name || publicKeyHex.substring(0, 16) })
})
// Continuously check for new blocks (every 5 seconds)
setInterval(async () => {
try {
const oldLength = feed.length
await feed.update()
if (feed.length > oldLength) {
console.log(`🔄 Feed updated for ${name}, new length: ${feed.length}`)
}
} catch (err) {
console.error(`Failed to update feed for ${name}:`, err.message)
}
}, 5000)
follows.set(discoveryKeyHex, {
feed,
publicKey: publicKeyHex,
name: name || publicKeyHex.substring(0, 16)
})
await saveFollows()
console.log(`✅ Following: ${name || publicKeyHex.substring(0, 16)}`)
console.log(` Feed key: ${publicKeyHex}`)
}
/**
* Command: Unfollow a user
*/
async function cmdUnfollow(publicKeyHex) {
await loadState()
if (!metaBee) {
console.error('❌ Not initialized. Run: init')
return
}
const pubKeyBuf = b4a.from(publicKeyHex, 'hex')
const feedKey = crypto.discoveryKey(pubKeyBuf)
const discoveryKeyHex = b4a.toString(feedKey, 'hex')
if (!follows.has(discoveryKeyHex)) {
console.log('⚠️ Not following this user')
return
}
const followData = follows.get(discoveryKeyHex)
// Clear the update interval to prevent memory leak
if (followData && followData.updateInterval) {
clearInterval(followData.updateInterval)
console.log(`🧹 Cleared update interval for ${followData.name}`)
}
follows.delete(discoveryKeyHex)
await saveFollows()
console.log(`✅ Unfollowed: ${publicKeyHex.substring(0, 16)}...`)
}
/**
* Command: View timeline
*/
async function cmdTimeline(limit = 20) {
await loadState()
if (!myFeed) {
console.error('❌ Not initialized. Run: init')
return
}
// Start swarm to fetch remote data
console.log('📡 Connecting to network...')
await startSwarm()
console.log(` Following ${follows.size} users`)
// Wait for DHT discovery and connections
console.log('⏳ Waiting for peer connections (8 seconds)...')
await new Promise(resolve => setTimeout(resolve, 8000))
// Download latest blocks from followed feeds
console.log('📥 Syncing feeds...')
for (const [discoveryKey, { feed, name }] of follows.entries()) {
try {
// Update feed length (fetch remote length)
await feed.update()
console.log(` ${name}: ${feed.length} blocks`)
} catch (err) {
console.log(` ${name}: sync failed`)
}
}
const messages = []
// Read own feed
for (let i = Math.max(0, myFeed.length - limit); i < myFeed.length; i++) {
try {
const block = await myFeed.get(i, { wait: false })
if (!block) continue
const entry = JSON.parse(block.toString())
messages.push({ ...entry, isOwn: true })
} catch (err) {
// Skip
}
}
// Read followed feeds
for (const [discoveryKey, { feed, name, publicKey }] of follows.entries()) {
const feedLimit = Math.min(limit, feed.length)
for (let i = Math.max(0, feed.length - feedLimit); i < feed.length; i++) {
try {
const block = await feed.get(i, { wait: true, timeout: 5000 })
if (!block) continue
const entry = JSON.parse(block.toString())
messages.push({ ...entry, author: name || publicKey.substring(0, 16), isOwn: false })
} catch (err) {
// Skip blocks that can't be fetched
console.log(` ⚠️ Couldn't fetch block ${i} from ${name}: ${err.message}`)
continue
}
}
}
// Sort by timestamp
messages.sort((a, b) => b.timestamp - a.timestamp)
// Display
console.log(`\n📰 Timeline (${messages.slice(0, limit).length} messages):\n`)
for (const msg of messages.slice(0, limit)) {
const time = new Date(msg.timestamp).toLocaleString()
const prefix = msg.isOwn ? '💬 You' : `👤 ${msg.author}`
console.log(`${prefix} (${time}):`)
console.log(` ${msg.text}`)
console.log('')
}
}
/**
* Command: Show own public key
*/
async function cmdWhoami() {
await loadState()
if (!myFeed) {
console.error('❌ Not initialized. Run: init')
return
}
if (anprotoKeypair) {
// New system (ANProto)
console.log('ANProto public key:', b4a.toString(anprotoKeypair.publicKey, 'base64'))
console.log('Feed key (hex):', b4a.toString(myFeed.key, 'hex'))
console.log('Discovery key:', b4a.toString(myFeed.discoveryKey, 'hex'))
} else if (identity) {
// Old system (IdentityKey)
console.log('Legacy public key:', b4a.toString(identity.identityPublicKey, 'hex'))
console.log('Feed key:', b4a.toString(myFeed.key, 'hex'))
console.log('Discovery key:', b4a.toString(myFeed.discoveryKey, 'hex'))
}
}
/**
* Command: List followed users
*/
async function cmdFollows() {
await loadState()
if (!metaBee) {
console.error('❌ Not initialized. Run: init')
return
}
await loadFollows()
if (follows.size === 0) {
console.log('📭 Not following anyone yet')
return
}
console.log(`\n👥 Following ${follows.size} users:\n`)
for (const [discoveryKey, { publicKey, name, feed }] of follows.entries()) {
console.log(` ${name || publicKey.substring(0, 16)}`)
console.log(` Feed key: ${publicKey}`)
console.log(` Messages: ${feed.length}`)
console.log('')
}
}
async function cmdDirectory(query = '') {
const endpoint = query ? `/directory/search?q=${encodeURIComponent(query)}` : '/directory'
return new Promise((resolve) => {
const req = https.request(`https://7rnx.net/relay${endpoint}`, (res) => {
if (res.statusCode !== 200) {
console.error(`❌ Failed to fetch directory: HTTP ${res.statusCode}`)
resolve()
return
}
let data = ''
res.on('data', chunk => { data += chunk })
res.on('end', () => {
try {
const result = JSON.parse(data)
if (result.total === 0) {
console.log(query ? `\n🔍 No users found matching "${query}"` : '\n📭 No users in directory')
resolve()
return
}
console.log(query ? `\n🔍 Found ${result.total} users matching "${query}":\n` : `\n👥 Directory (${result.total} users):\n`)
for (const user of result.users) {
console.log(` ${user.name}`)
console.log(` Pubkey: ${user.pubkey}`)
console.log(` Last seen: ${new Date(parseInt(user.lastSeen)).toLocaleString()}`)
console.log('')
}
resolve()
} catch (err) {
console.error('❌ Error parsing directory response:', err.message)
resolve()
}
})
res.on('error', (err) => {
console.error('❌ Error fetching directory:', err.message)
resolve()
})
})
req.on('error', (err) => {
console.error('❌ Network error:', err.message)
resolve()
})
req.end()
})
}
async function cmdProfile(pubkey) {
if (!pubkey) {
console.error('❌ Usage: profile <pubkey>')
return
}
return new Promise((resolve) => {
const req = https.request(`https://7rnx.net/relay/directory/${encodeURIComponent(pubkey)}`, (res) => {
if (res.statusCode === 404) {
console.error(`❌ User not found: ${pubkey}`)
resolve()
return
}
if (res.statusCode !== 200) {
console.error(`❌ Failed to fetch profile: HTTP ${res.statusCode}`)
resolve()
return
}
let data = ''
res.on('data', chunk => { data += chunk })
res.on('end', () => {
try {
const profile = JSON.parse(data)
console.log(`\n👤 ${profile.name}\n`)
console.log(`Pubkey: ${profile.pubkey}`)
console.log(`Messages: ${profile.messageCount}`)
console.log(`Last seen: ${new Date(parseInt(profile.lastSeen)).toLocaleString()}`)
console.log(`\n📝 Recent posts:\n`)
for (const post of profile.recentPosts) {
// Remove YAML frontmatter for cleaner display
const text = post.text.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, '')
const date = new Date(parseInt(post.timestamp)).toLocaleString()
console.log(` [${date}]`)
console.log(` ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`)
console.log('')
}
resolve()
} catch (err) {
console.error('❌ Error parsing profile response:', err.message)
resolve()
}
})
res.on('error', (err) => {
console.error('❌ Error fetching profile:', err.message)
resolve()
})
})
req.on('error', (err) => {
console.error('❌ Network error:', err.message)
resolve()
})
req.end()
})
}
/**
* Migrate storage from local directory to production path
*/
async function cmdMigrateStorage(sourcePath, destPath) {
if (!sourcePath) {
console.error('❌ Usage: cli migrate-storage <source-path> [dest-path]')
console.log('\nExamples:')
console.log(' cli migrate-storage ./wirecore-storage')
console.log(' cli migrate-storage ./wirecore-storage ~/.config/pear/app-storage/by-dkey/...')
console.log('')
console.log('If dest-path is not specified, uses current storage path:', STORAGE_PATH)
return
}
// Resolve paths
const source = path.resolve(sourcePath)
const dest = destPath ? path.resolve(destPath) : path.resolve(STORAGE_PATH)
console.log('📦 Storage Migration')
console.log(' Source:', source)
console.log(' Destination:', dest)
console.log('')
// Check if source exists
try {
const stats = fs.statSync(source)
if (!stats.isDirectory()) {
console.error('❌ Source is not a directory:', source)
return
}
} catch (err) {
console.error('❌ Source path does not exist:', source)
return
}
// Check if source has valid Corestore structure
const primaryKeyPath = path.join(source, 'primary-key')
const coresPath = path.join(source, 'cores')
try {
fs.statSync(primaryKeyPath)
const coresStats = fs.statSync(coresPath)
if (!coresStats.isDirectory()) {
console.error('❌ Source does not have valid Corestore structure (cores/ not a directory)')
return
}
} catch (err) {
console.error('❌ Source does not have valid Corestore structure (missing primary-key or cores/)')
return
}
// Check if destination already exists
let destExists = false
try {
fs.statSync(dest)
destExists = true
} catch (err) {
// Destination doesn't exist - that's fine
}
if (destExists) {
console.log('⚠️ Destination already exists. Migration will OVERWRITE existing data.')
console.log(' If you want to keep existing data, cancel now and backup first.')
console.log('')
// In CLI mode, we'll proceed - user can Ctrl+C to cancel
// In a future version, we could add confirmation prompts
}
// Close current Corestore if open
if (store) {
console.log('🔄 Closing current Corestore...')
try {
await swarm.destroy()
swarm = null
} catch (err) {
console.log(' (swarm already closed)')
}
try {
await store.close()
store = null
myFeed = null
metaBee = null
userDrive = null
} catch (err) {
console.error('⚠️ Error closing store:', err.message)
}
}
// Create destination directory if needed
try {
const destParent = path.dirname(dest)
fs.mkdirSync(destParent, { recursive: true })
} catch (err) {
// Already exists
}
// Copy the data
console.log('📋 Copying storage data...')
try {
// Copy primary-key
const primaryKeySrc = path.join(source, 'primary-key')
const primaryKeyDest = path.join(dest, 'primary-key')
const primaryKeyData = fs.readFileSync(primaryKeySrc)
fs.writeFileSync(primaryKeyDest, primaryKeyData)
console.log(' ✅ Copied primary-key')
// Copy cores directory recursively
copyDirectoryRecursive(path.join(source, 'cores'), path.join(dest, 'cores'))
console.log(' ✅ Copied cores/')
console.log('')
console.log('✅ Storage copied successfully!')
console.log('')
// Validate the migration by trying to load the data
console.log('🔍 Validating migrated data...')
try {
const testStore = new Corestore(dest)
await testStore.ready()
// Check if identity can be loaded
const testIdentityCore = testStore.get({ name: 'identity' })
await testIdentityCore.ready()
if (testIdentityCore.length === 0) {
throw new Error('Identity core is empty')
}
const testIdentitySeed = await testIdentityCore.get(0)
const testIdentity = await IdentityKey.from({ seed: testIdentitySeed })
const publicKey = b4a.toString(testIdentity.identityPublicKey, 'hex')
console.log(' ✅ Identity verified:', publicKey.substring(0, 16) + '...')
// Check if feed exists
const testFeed = testStore.get({ name: 'my-feed' })
await testFeed.ready()
console.log(' ✅ Feed verified:', testFeed.length, 'posts')
// Check if metadata exists
const testMetaBee = new Hyperbee(testStore.get({ name: 'metadata' }), {
keyEncoding: 'utf-8',
valueEncoding: 'json'
})
await testMetaBee.ready()
const profileNode = await testMetaBee.get('profile')
const username = profileNode && profileNode.value && profileNode.value.username
if (username) {
console.log(' ✅ Username verified:', username)
}
// Count follows
let followCount = 0
for await (const node of testMetaBee.createReadStream({ gte: 'follow:', lt: 'follow;\xff' })) {
followCount++
}
console.log(' ✅ Follows verified:', followCount)
// Check if Hyperdrive exists and has files
try {
const testDrive = new Hyperdrive(testStore, { name: 'user-files' })
await testDrive.ready()
let imageCount = 0
for await (const entry of testDrive.list('/images')) {
imageCount++
}
if (imageCount > 0) {
console.log(' ✅ Hyperdrive verified:', imageCount, 'images')
} else {
console.log(' ✅ Hyperdrive verified (no images yet)')
}
await testDrive.close()
} catch (driveErr) {
console.log(' ⚠️ Hyperdrive check skipped:', driveErr.message)
}
await testStore.close()
console.log('')
console.log('✅ Migration complete and validated!')
console.log('')
console.log('Your identity, posts, and follows have been successfully migrated.')
console.log('Storage location:', dest)
console.log('')
console.log('You can now restart the app and it will use the migrated data.')
} catch (validationErr) {
console.error('⚠️ Migration copied files but validation failed:', validationErr.message)
console.log('')
console.log('The data was copied, but there may be an issue.')
console.log('You can try restarting the app to see if it works.')
}
} catch (err) {
console.error('❌ Migration failed:', err.message)
console.error(err.stack)
}
}
/**
* Recursively copy a directory
*/
function copyDirectoryRecursive(src, dest) {
// Create destination directory
try {
fs.mkdirSync(dest, { recursive: true })
} catch (err) {
if (err.code !== 'EEXIST') throw err
}
// Read source directory
const entries = fs.readdirSync(src)
for (const entry of entries) {
const srcPath = path.join(src, entry)
const destPath = path.join(dest, entry)
const stats = fs.statSync(srcPath)
if (stats.isDirectory()) {
copyDirectoryRecursive(srcPath, destPath)
} else {
const data = fs.readFileSync(srcPath)
fs.writeFileSync(destPath, data)
}
}
}
/**
* Web Server - HTTP + WebSocket
*/
let wss = null
const wsClients = new Set()
async function startWebServer(port = 3003) {
await loadState()
if (!anprotoKeypair && !identity) {
console.error('❌ Not initialized. Run: pear run . cli init')
return
}
// Start P2P networking
await startSwarm()
await loadFollows()
// Load bridge configuration
await loadBridgeConfig()
// Auto-start bridge if configured
if (bridgeConfig.autoStart) {
try {
await startBridge()
console.log('✅ Bridge auto-started')
} catch (err) {
console.error('Failed to auto-start bridge:', err.message)
}
}
// Create HTTP server
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost')
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
if (req.method === 'OPTIONS') {
res.writeHead(200)
res.end()
return
}
// API routes
if (url.pathname.startsWith('/api/')) {
await handleAPI(req, res, url)
return
}
// Serve static files from public/
await serveStatic(req, res, url)
})
// Create WebSocket server
wss = new WebSocketServer({ server })
wss.on('connection', (socket) => {
wsClients.add(socket)
console.log('🔌 Browser connected, total:', wsClients.size)
socket.on('close', () => {
wsClients.delete(socket)
console.log('👋 Browser disconnected, total:', wsClients.size)
})
socket.on('error', (err) => {
console.error('WebSocket error:', err.message)
wsClients.delete(socket)
})
})
// Listen for feed events and broadcast to browsers
myFeed.on('append', () => {
broadcast({ type: 'new_post', feed: 'own' })
})
// Start server
server.listen(port, '0.0.0.0', () => {
console.log('🚀 Wirecore Web Server running!')
console.log('')
console.log(`📍 Local: http://localhost:${port}`)
console.log(`🔌 WebSocket: ws://localhost:${port}`)
if (anprotoKeypair) {
console.log(`👤 ANProto public key: ${b4a.toString(anprotoKeypair.publicKey, 'base64')}`)
} else if (identity) {
console.log(`👤 Legacy public key: ${b4a.toString(identity.identityPublicKey, 'hex')}`)
}
console.log(`📡 Following: ${follows.size} users`)
console.log(`📂 Public files: ./public (via import.meta.asset)`)
console.log('')
console.log('')
console.log('✨ Open in your browser and start posting!')
})
}
// Broadcast message to all connected browsers
function broadcast(message) {
const data = JSON.stringify(message)
const buffer = Buffer.from(data, 'utf8')
console.log(`[BROADCAST] Sending to ${wsClients.size} clients:`, message)
let sent = 0
for (const socket of wsClients) {
console.log(`[BROADCAST] Socket state: writable=${socket.writable}, destroyed=${socket.destroyed}, readable=${socket.readable}`)
if (socket.writable && !socket.destroyed) {
try {
const result = socket.write(buffer)
console.log(`[BROADCAST] Write returned: ${result}, buffer length: ${buffer.length}`)
sent++
} catch (err) {
console.error('Failed to send to client:', err.message, err.stack)
}
} else {
console.log('[BROADCAST] Socket not writable or destroyed, skipping')
}
}
console.log(`[BROADCAST] Successfully sent to ${sent} clients`)
}
// Handle API requests
async function handleAPI(req, res, url) {
const route = url.pathname
try {
switch (route) {
case '/api/whoami':
res.writeHead(200, { 'Content-Type': 'application/json' })
if (anprotoKeypair) {
// New system (ANProto)
res.end(JSON.stringify({
pubkey: b4a.toString(anprotoKeypair.publicKey, 'base64'),
feedKey: b4a.toString(myFeed.key, 'hex'),
discoveryKey: b4a.toString(myFeed.discoveryKey, 'hex'),
format: 'anproto' // Indicates base64 format
}))
} else if (identity) {
// Old system (IdentityKey)
res.end(JSON.stringify({
pubkey: b4a.toString(identity.identityPublicKey, 'hex'),
feedKey: b4a.toString(myFeed.key, 'hex'),
discoveryKey: b4a.toString(myFeed.discoveryKey, 'hex'),
format: 'legacy' // Indicates hex format
}))
}
break
case '/api/profile':
if (req.method === 'GET') {
const profileNode = await metaBee.get('profile')
const profile = (profileNode && profileNode.value) || {}
let pubkey
if (anprotoKeypair) {
pubkey = b4a.toString(anprotoKeypair.publicKey, 'base64')
} else if (identity) {
pubkey = b4a.toString(identity.identityPublicKey, 'hex')
}
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
pubkey,
username: profile.username || null
}))
} else if (req.method === 'POST') {
const body = await readBody(req)
const { username } = JSON.parse(body)
if (!username || username.trim().length === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Username cannot be empty' }))
return
}
const profileNode = await metaBee.get('profile')
const profile = (profileNode && profileNode.value) || {}
profile.username = username.trim()
await metaBee.put('profile', profile)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ success: true, username: profile.username }))
}
break
case '/api/timeline':
const posts = await getTimelineMessages(50)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(posts))
break
case '/api/post':
if (req.method === 'POST') {
const body = await readBody(req)
const { message, imageData, imageMimeType } = JSON.parse(body)
// Get username from profile
const profileNode = await metaBee.get('profile')
const username = profileNode && profileNode.value && profileNode.value.username
const timestamp = Date.now()
// Get previous post hash for chain
let previousHash = null
if (myFeed.length > 0) {
const prevBlock = await myFeed.get(myFeed.length - 1)
previousHash = b4a.toString(crypto.hash(prevBlock), 'base64')
}
// Handle image if provided
let imageHash = null
let dataUrl = null
if (imageData) {
// Create data URL like wiredove-cli does
const mimeType = imageMimeType || 'image/jpeg'
dataUrl = `data:${mimeType};base64,${imageData}`
// Hash the data URL (not raw bytes) to match wiredove protocol
imageHash = ANProto.hash(dataUrl)
}
// Add image markdown reference to message if image present
let finalMessage = message
if (imageHash) {
finalMessage = message + `\n`
}
// Create YAML format for relay compatibility
const metadata = {
name: username || null,
image: imageHash,
previous: previousHash
}
const yaml = YAMLHelper.create(metadata, finalMessage)
// Phase 3: Hash content and store in contentCache
const contentHash = ANProto.hash(yaml)
await contentCache.put(contentHash, yaml)
// Store in feed with both full content (transition) and hash
const entry = {
yaml,
contentHash, // Phase 3: Add hash for Phase 4 transition
timestamp,
username: username || null
}
// Store image inline for P2P replication
// Store as data URL to match wiredove protocol
if (dataUrl) {
entry.imageData = dataUrl // Store full data URL
}
await myFeed.append(Buffer.from(JSON.stringify(entry)))
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ success: true }))
}
break
case '/api/follow':
if (req.method === 'POST') {
const body = await readBody(req)
const { publicKey, name } = JSON.parse(body)
await followUser(publicKey, name)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ success: true }))
}
break
case '/api/unfollow':
if (req.method === 'POST') {
const body = await readBody(req)
const { publicKey } = JSON.parse(body)
const pubKeyBuf = b4a.from(publicKey, 'hex')
const feedKey = crypto.discoveryKey(pubKeyBuf)
const discoveryKeyHex = b4a.toString(feedKey, 'hex')
const followData = follows.get(discoveryKeyHex)
// Clear the update interval to prevent memory leak
if (followData && followData.updateInterval) {
clearInterval(followData.updateInterval)
console.log(`🧹 Cleared update interval for ${followData.name}`)
}
follows.delete(discoveryKeyHex)
await saveFollows()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ success: true }))
}
break
case '/api/follows':
const followsList = Array.from(follows.entries()).map(([dk, { publicKey, name, feed }]) => ({
publicKey,
name,
messageCount: feed.length
}))
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(followsList))
break
case '/api/directory':
// Proxy directory search requests to relay
{
const query = url.searchParams.get('q') || ''
const relayUrl = query
? `https://7rnx.net/relay/directory/search?q=${encodeURIComponent(query)}`
: 'https://7rnx.net/relay/directory'
https.request(relayUrl, (relayRes) => {
res.writeHead(relayRes.statusCode, {
'Content-Type': 'application/json'
})
relayRes.pipe(res)
}).on('error', (err) => {
console.error('Relay request failed:', err)
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Failed to fetch directory' }))
}).end()
}
break
case '/api/upload':
if (req.method === 'POST') {
const body = await readBody(req)
const { image, filename, mimeType } = JSON.parse(body)
// Store images inline in posts as base64 (for P2P replication)
// Also store in Hyperdrive for backward compatibility with old posts
const imageData = b4a.from(image, 'base64')
const hash = b4a.toString(crypto.hash(imageData), 'hex').substring(0, 16)
const ext = filename ? filename.split('.').pop() : 'jpg'
const imagePath = `/images/${hash}.${ext}`
// Lazy-load userDrive and store (for backward compatibility)
try {
await ensureUserDrive()
await userDrive.put(imagePath, imageData)
} catch (err) {
console.error('Failed to store in Hyperdrive:', err)
}
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ success: true, imageData: image, mimeType }))
}
break
case '/api/bridge/status':
{
const status = await getBridgeStatus()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(status))
}
break
case '/api/bridge/start':
if (req.method === 'POST') {
try {
await startBridge()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ success: true, message: 'Bridge started' }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
}
break
case '/api/bridge/stop':
if (req.method === 'POST') {
try {
await stopBridge()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ success: true, message: 'Bridge stopped' }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
}
break
case '/api/bridge/sync':
if (req.method === 'POST') {
try {
const result = await syncBridge()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ success: true, ...result }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
}
break
case '/api/bridge/config':
if (req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(bridgeConfig))
} else if (req.method === 'POST') {
try {
const body = await readBody(req)
const newConfig = JSON.parse(body)
bridgeConfig = { ...bridgeConfig, ...newConfig }
await saveBridgeConfig()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ success: true, config: bridgeConfig }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
}
break
case '/api/bridge/reset-sync':
if (req.method === 'POST') {
try {
if (!bridge) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Bridge not initialized' }))
break
}
// Parse query parameter for index (e.g., ?index=11)
const indexParam = url.searchParams.get('index')
const newIndex = indexParam !== null ? parseInt(indexParam, 10) : 0
if (isNaN(newIndex) || newIndex < 0) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Invalid index parameter' }))
break
}
const oldIndex = await bridge.getLastSyncedIndex()
await bridge.setLastSyncedIndex(newIndex)
console.log(`🔄 Admin: Reset sync index from ${oldIndex} to ${newIndex}`)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
success: true,
oldIndex,
newIndex,
message: `Sync index reset. Next sync will push posts from index ${newIndex} onwards.`
}))
} catch (err) {
console.error('❌ Failed to reset sync index:', err)
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
}
break
default:
// Check if it's a directory profile request
if (url.pathname.startsWith('/api/directory/')) {
const pubkey = url.pathname.substring('/api/directory/'.length)
const relayUrl = `https://7rnx.net/relay/directory/${encodeURIComponent(pubkey)}`
const proxyReq = https.request(relayUrl, (relayRes) => {
res.writeHead(relayRes.statusCode, {
'Content-Type': 'application/json'
})
relayRes.pipe(res)
})
proxyReq.on('error', (err) => {
console.error('Relay request failed:', err)
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Failed to fetch profile' }))
})
proxyReq.end()
return
}
// Check if it's an image request (for backward compatibility with old posts)
if (url.pathname.startsWith('/api/image/')) {
const imagePath = url.pathname.replace('/api/image', '')
try {
// Lazy-load userDrive if needed
await ensureUserDrive()
const imageData = await userDrive.get(imagePath)
if (!imageData) {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Image not found')
return
}
// Determine content type from extension
const ext = imagePath.split('.').pop().toLowerCase()
const contentType = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml'
}[ext] || 'application/octet-stream'
res.writeHead(200, { 'Content-Type': contentType })
res.end(imageData)
} catch (err) {
console.error('Error loading image:', err)
res.writeHead(500, { 'Content-Type': 'text/plain' })
res.end('Error loading image')
}
return
}
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found' }))
}
} catch (err) {
console.error('API error:', err)
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
}
// Read request body
function readBody(req) {
return new Promise((resolve, reject) => {
let body = ''
req.on('data', chunk => { body += chunk.toString() })
req.on('end', () => resolve(body))
req.on('error', reject)
})
}
// Serve static files from local filesystem
async function serveStatic(req, res, url) {
// Remove leading slash and default to index.html
let fileName = url.pathname === '/' ? 'index.html' : url.pathname.substring(1)
try {
const localPath = path.join(getAppDir(), 'public', fileName)
const data = fs.readFileSync(localPath)
const ext = path.extname(fileName)
const contentType = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.svg': 'image/svg+xml'
}[ext] || 'text/plain'
res.writeHead(200, { 'Content-Type': contentType })
res.end(data)
} catch (err) {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not found')
}
}
// Get timeline messages (helper for API)
async function getTimelineMessages(limit) {
const messages = []
// Read own feed
for (let i = Math.max(0, myFeed.length - limit); i < myFeed.length; i++) {
try {
const block = await myFeed.get(i, { wait: false })
if (!block) continue
const entry = JSON.parse(block.toString())
// Phase 3: Try contentCache first if hash available
let yaml = entry.yaml
if (entry.contentHash && !yaml) {
const cached = await contentCache.get(entry.contentHash)
if (cached) {
yaml = cached.value
}
}
// If we have yaml from feed but not in cache, store it
if (entry.yaml && entry.contentHash) {
const cached = await contentCache.get(entry.contentHash)
if (!cached) {
await contentCache.put(entry.contentHash, entry.yaml)
}
}
// Parse YAML if present (new format), otherwise use text directly (old format)
let text = entry.text || ''
if (yaml) {
const parsed = YAMLHelper.parse(yaml)
text = parsed.content
}
// Use username from post if available, otherwise "You"
const displayName = entry.username || 'You'
let pubkey
if (anprotoKeypair) {
pubkey = b4a.toString(anprotoKeypair.publicKey, 'base64')
} else if (identity) {
pubkey = b4a.toString(identity.identityPublicKey, 'hex')
}
messages.push({
text,
timestamp: entry.timestamp,
author: displayName,
publicKey: pubkey,
isOwn: true,
imageData: entry.imageData,
imageMimeType: entry.imageMimeType
})
} catch (err) {
// Skip
}
}
// Read followed feeds
for (const [discoveryKey, { feed, name, publicKey }] of follows.entries()) {
try {
await feed.update()
const feedLimit = Math.min(limit, feed.length)
for (let i = Math.max(0, feed.length - feedLimit); i < feed.length; i++) {
try {
const block = await feed.get(i, { wait: true, timeout: 2000 })
if (!block) continue
const entry = JSON.parse(block.toString())
// Phase 3: Try contentCache first if hash available
let yaml = entry.yaml
if (entry.contentHash && !yaml) {
const cached = await contentCache.get(entry.contentHash)
if (cached) {
yaml = cached.value
}
}
// If we have yaml from feed but not in cache, store it
if (entry.yaml && entry.contentHash) {
const cached = await contentCache.get(entry.contentHash)
if (!cached) {
await contentCache.put(entry.contentHash, entry.yaml)
}
}
// Parse YAML if present (new format), otherwise use text directly (old format)
let text = entry.text || ''
if (yaml) {
const parsed = YAMLHelper.parse(yaml)
text = parsed.content
}
// Use username from post if available, otherwise follow name or pubkey
const displayName = entry.username || name || publicKey.substring(0, 16)
messages.push({
text,
timestamp: entry.timestamp,
author: displayName,
publicKey,
isOwn: false,
imageData: entry.imageData,
imageMimeType: entry.imageMimeType
})
} catch (err) {
continue
}
}
} catch (err) {
continue
}
}
// Sort by timestamp descending
messages.sort((a, b) => b.timestamp - a.timestamp)
return messages.slice(0, limit)
}
// Helper function to follow a user (extracted for API use)
async function followUser(publicKeyInput, name) {
// Auto-detect format: base64 (from directory/wiredove) or hex (wirecore native)
let pubKeyBuf
let publicKeyHex
if (publicKeyInput.includes('=') || publicKeyInput.includes('+') || publicKeyInput.includes('/')) {
// Base64 format (from directory)
pubKeyBuf = b4a.from(publicKeyInput, 'base64')
publicKeyHex = b4a.toString(pubKeyBuf, 'hex')
} else {
// Hex format (wirecore native)
publicKeyHex = publicKeyInput
pubKeyBuf = b4a.from(publicKeyHex, 'hex')
}
const feedKey = crypto.discoveryKey(pubKeyBuf)
const discoveryKeyHex = b4a.toString(feedKey, 'hex')
if (follows.has(discoveryKeyHex)) {
throw new Error('Already following this user')
}
const discovery = swarm.join(feedKey, { server: false, client: true })
await discovery.flushed()
const feed = store.get(pubKeyBuf)
await feed.ready()
// Listen for new posts from followed feed
feed.on('download', (index) => {
console.log(`📥 Downloaded block ${index} from ${name || publicKeyHex.substring(0, 16)}`)
broadcast({ type: 'new_post', feed: 'followed', publicKey: publicKeyHex, name: name || publicKeyHex.substring(0, 16) })
})
// Continuously check for new blocks (every 5 seconds)
const updateInterval = setInterval(async () => {
try {
const oldLength = feed.length
await feed.update()
if (feed.length > oldLength) {
console.log(`🔄 Feed updated for ${name || publicKeyHex.substring(0, 16)}, new length: ${feed.length}`)
}
} catch (err) {
console.error(`Failed to update feed for ${name || publicKeyHex.substring(0, 16)}:`, err.message)
}
}, 5000)
follows.set(discoveryKeyHex, {
feed,
publicKey: publicKeyHex,
name: name || publicKeyHex.substring(0, 16),
updateInterval
})
await saveFollows()
broadcast({ type: 'new_follow', publicKey: publicKeyHex, name })
}
// Bridge management functions
/**
* Get bridge status (running, stats, config)
*/
async function getBridgeStatus() {
try {
// If bridge is running, get its status
if (bridgeInstance) {
const status = await bridgeInstance.status()
return {
running: true,
config: bridgeConfig,
status
}
}
// Bridge not running - return basic status without accessing storage
// (accessing storage would cause ELOCKED if web server already has it open)
return {
running: false,
config: bridgeConfig,
status: {
relay: { online: null }, // Unknown when bridge not running
sync: { pendingPosts: 0 },
stats: { totalPushed: 0, totalPulled: 0 }
}
}
} catch (err) {
return {
running: false,
config: bridgeConfig,
error: err.message
}
}
}
/**
* Start bridge daemon in-process
*/
async function startBridge() {
if (bridgeInstance) {
throw new Error('Bridge already running')
}
console.log('🌉 Starting bridge daemon...')
console.log(' Relay:', bridgeConfig.relayUrl)
const { WirecoreBridge } = await import('./bridge/wirecore-bridge.js')
// Pass existing Corestore instance to avoid ELOCKED error
bridgeInstance = new WirecoreBridge(bridgeConfig.relayUrl, store, STORAGE_PATH)
await bridgeInstance.init()
// Start in daemon mode (watch + periodic pull)
const pullInterval = (bridgeConfig.pullInterval || 300) * 1000 // Convert seconds to ms
await bridgeInstance.start(pullInterval)
console.log('✅ Bridge daemon started')
}
/**
* Stop bridge daemon
*/
async function stopBridge() {
if (!bridgeInstance) {
throw new Error('Bridge not running')
}
console.log('🌉 Stopping bridge daemon...')
await bridgeInstance.stop()
await bridgeInstance.close()
bridgeInstance = null
console.log('✅ Bridge daemon stopped')
}
/**
* One-time sync (push + pull)
*/
async function syncBridge() {
const { WirecoreBridge } = await import('./bridge/wirecore-bridge.js')
// Pass existing Corestore instance to avoid ELOCKED error
const bridge = new WirecoreBridge(bridgeConfig.relayUrl, store, STORAGE_PATH)
await bridge.init()
const result = await bridge.sync()
await bridge.close()
return result
}
/**
* Save bridge config to metadata
*/
async function saveBridgeConfig() {
if (!metaBee) return
await metaBee.put('bridge-config', bridgeConfig)
}
/**
* Load bridge config from metadata
*/
async function loadBridgeConfig() {
if (!metaBee) return
const node = await metaBee.get('bridge-config')
if (node && node.value) {
bridgeConfig = { ...bridgeConfig, ...node.value }
}
}
/**
* Show usage information
*/
function showUsage() {
console.log('🕊️ Wirecore - P2P Social Network')
console.log('')
console.log('Usage:')
console.log(' pear run . cli Start in CLI mode (interactive commands)')
console.log(' pear run . web [port] Start web server (default port: 3003)')
console.log(' pear run . bridge <command> Relay bridge commands')
console.log('')
console.log('CLI Commands:')
console.log(' pear run . cli init Initialize new identity')
console.log(' pear run . cli post <text> Post a message')
console.log(' pear run . cli setname <name> Set your username')
console.log(' pear run . cli follow <pubkey> [name] Follow a user')
console.log(' pear run . cli unfollow <pubkey> Unfollow a user')
console.log(' pear run . cli timeline [limit] View timeline')
console.log(' pear run . cli whoami Show your public key')
console.log(' pear run . cli follows List followed users')
console.log(' pear run . cli directory [query] Search user directory')
console.log(' pear run . cli profile <pubkey> View user profile')
console.log(' pear run . cli storage-path Show current storage location')
console.log(' pear run . cli migrate-storage <source> [dest] Migrate storage data')
console.log('')
console.log('Examples:')
console.log(' pear run . web 3003')
console.log(' pear run . cli init')
console.log(' pear run . cli post "Hello, P2P world!"')
console.log(' pear run . cli timeline 50')
console.log(' pear run . cli directory ev')
console.log(' pear run . cli profile 5Skd4SFHZkY5Syf6RiiSEvBlEHiJQvrffBnw0VKacB4=')
console.log('')
console.log('Migration:')
console.log(' # Check current storage location')
console.log(' pear run . cli storage-path')
console.log('')
console.log(' # Migrate from local storage to specific destination')
console.log(' pear run . cli migrate-storage ./wirecore-storage ~/.config/pear/app-storage/by-dkey/...')
}
/**
* Main CLI handler
*/
async function main() {
const args = isPear ? (Pear.config.args || []) : (typeof Bare !== 'undefined' ? Bare.argv.slice(2) : process.argv.slice(2))
if (args.length === 0) {
showUsage()
return
}
const [mode, ...params] = args
// Long-running commands that should NOT cleanup
const longRunningCommands = ['web']
const shouldCleanup = !longRunningCommands.includes(mode)
try {
switch (mode) {
case 'web':
// Start web server with optional custom port: pear run . web 8080
const customPort = params[0] ? parseInt(params[0]) : 3003
await startWebServer(customPort)
return // Keep server running - cleanup handled by graceful-goodbye
case 'bridge':
// Bridge mode - run bridge commands directly
const { WirecoreBridge } = await import('./bridge/wirecore-bridge.js')
const RELAY_URL = process.env.RELAY_URL || 'https://7rnx.net/relay'
const bridge = new WirecoreBridge(STORAGE_PATH, RELAY_URL)
await bridge.init()
const bridgeCommand = params[0]
switch (bridgeCommand) {
case 'start':
// Parse pull interval
let pullInterval = 5 * 60 * 1000
const intervalIndex = params.indexOf('--interval')
if (intervalIndex !== -1 && params[intervalIndex + 1]) {
pullInterval = parseInt(params[intervalIndex + 1]) * 1000
}
await bridge.start(pullInterval)
return // Keep running
case 'push':
await bridge.push()
break
case 'pull':
await bridge.pull()
break
case 'watch':
await bridge.watch()
return // Keep running
case 'sync':
await bridge.sync()
break
case 'status':
await bridge.status()
break
case 'help':
case '--help':
case '-h':
case undefined:
console.log('🌉 Wirecore Bridge - Relay Integration\n')
console.log('Usage:')
console.log(' pear run . bridge start Start daemon (watch + periodic pull)')
console.log(' pear run . bridge push Push all local posts to relay')
console.log(' pear run . bridge pull Pull posts from relay')
console.log(' pear run . bridge watch Watch for new posts and auto-push')
console.log(' pear run . bridge sync One-time sync (push + pull)')
console.log(' pear run . bridge status Show bridge status')
break
default:
console.error(`❌ Unknown bridge command: ${bridgeCommand}`)
console.log('Run: pear run . bridge help')
}
await bridge.close()
break
case 'cli':
// CLI mode - execute command
if (params.length === 0) {
showUsage()
return
}
const [command, ...cmdParams] = params
switch (command) {
case 'init':
await cmdInit()
break
case 'post':
await cmdPost(cmdParams.join(' '))
break
case 'setname':
await cmdSetName(cmdParams.join(' '))
break
case 'follow':
await cmdFollow(cmdParams[0], cmdParams[1])
break
case 'unfollow':
await cmdUnfollow(cmdParams[0])
break
case 'timeline':
await cmdTimeline(parseInt(cmdParams[0]) || 20)
break
case 'whoami':
await cmdWhoami()
break
case 'follows':
await cmdFollows()
break
case 'directory':
await cmdDirectory(cmdParams.join(' '))
break
case 'profile':
await cmdProfile(cmdParams[0])
break
case 'storage-path':
console.log('Storage path:', STORAGE_PATH)
break
case 'migrate-storage':
await cmdMigrateStorage(cmdParams[0], cmdParams[1])
break
default:
console.log('Unknown command:', command)
showUsage()
}
break
// Legacy support for old commands without 'cli' prefix
case 'init':
await cmdInit()
break
case 'post':
await cmdPost(params.join(' '))
break
case 'setname':
await cmdSetName(params.join(' '))
break
case 'follow':
await cmdFollow(params[0], params[1])
break
case 'unfollow':
await cmdUnfollow(params[0])
break
case 'timeline':
await cmdTimeline(parseInt(params[0]) || 20)
break
case 'whoami':
await cmdWhoami()
break
case 'follows':
await cmdFollows()
break
default:
console.log('Unknown mode:', mode)
showUsage()
}
} catch (err) {
console.error('❌ Error:', err.message)
console.error(err.stack)
} finally {
// Only cleanup for short-lived CLI commands
if (shouldCleanup) {
await cleanup()
}
}
}
// Background OTA via pgh — content-addressed store, NO Hyperdrive/blobs.
// `pgh app update <name>` resolves the latest release from package.json's
// pgh.upgrade and ATOMICALLY materializes the new tree (next launch uses it).
// Replaces the former pear-runtime Hyperdrive updater, which kept an unbounded
// append-only blob corestore under the app storage dir.
function startBackgroundUpdate () {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(getAppDir(), 'package.json'), 'utf-8'))
const upgrade = pkg.pgh && pkg.pgh.upgrade
if (!upgrade) return
const appName = (pkg.pgh && pkg.pgh.bin) || pkg.name || 'wirecore'
// Resolve the pgh CLI: prefer ~/.local/bin/pgh, else the store bare+entry.
let argv = null
const launcher = path.join(os.homedir(), '.local', 'bin', 'pgh')
if (fs.existsSync(launcher)) argv = [launcher]
else {
const storeRoot = process.env.PGH_STORE_ROOT || path.join(os.homedir(), '.local', 'share', 'store')
const bareBin = path.join(storeRoot, 'apps', 'pgh', 'runtime', 'bare')
const entry = path.join(storeRoot, 'apps', 'pgh', 'bin', 'pgh.bare.js')
if (fs.existsSync(bareBin) && fs.existsSync(entry)) argv = [bareBin, entry]
}
if (!argv) return
// Detached + best-effort: never blocks the command.
const proc = subprocess.spawn(argv[0], [...argv.slice(1), 'app', 'update', appName], {
stdio: 'ignore',
detached: true
})
proc.on('error', () => {})
if (proc.unref) proc.unref()
} catch {}
}
// Run main
const _argv = typeof Bare !== 'undefined' ? Bare.argv : process.argv
if (!isPear && !_argv.includes('--no-updates')) {
startBackgroundUpdate()
}
main().catch(err => {
console.error('Fatal error:', err)
if (typeof Bare !== 'undefined') Bare.exit(1)
else process.exit(1)
})