OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore / pear-app / index.js

2098 lines · javascript ref:
#!/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![](${imageHash})`
          }

          // 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)
})