OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-inspector / bin / cli.js

715 lines · javascript ref:
#!/usr/bin/env node

import { openDB, listApps, getCorestoreStats, formatBytes } from '../lib/rocksdb-inspector.js'
import { findCorestores, getDirSize, getLastModified, isLocked, getLockHolder } from '../lib/scanner.js'
import { openDrive, getDriveStats, discoverDrives } from '../lib/drive-inspector.js'
import { enumerateCores } from '../lib/core-enumerator.js'
import { analyzeCoreDiskUsage, formatAnalysis } from '../lib/deep-analyzer.js'
import { walkHyperbeeBlocks, decodeNode, decodeDriveEntry, stripSubEncoder, decodeHeader } from '../lib/block-reader.js'
import { listDriveFromDB, buildTree, formatTree, formatFlat } from '../lib/drive-ls.js'
import { extractDrive } from '../lib/drive-extract.js'
import { corestoreDU, formatDU } from '../lib/du.js'
import b4a from 'b4a'
import { existsSync } from 'fs'
import { join, resolve } from 'path'
import { homedir } from 'os'

const args = process.argv.slice(2)
const command = args[0]

const HELP = `
hyper-inspector - Inspect, audit, and manage Hypercore corestores

Commands:
  scan [dir]            Scan a directory for all corestores (default: ~/)
  audit [dir]           Full audit with sizes, lock status, and staleness
  du [dir]              Full Hyperdrive disk usage across all corestores
  inspect <db-path>     Inspect a corestore's RocksDB (path to db/ dir)
  apps <db-path>        List pear:// apps in a platform corestore
  cores <storage-path>  Enumerate all cores in a corestore (path with CORESTORE file)
  drives <storage-path> Discover all Hyperdrives in a corestore
  drive <storage-path>  Open and traverse a Hyperdrive, list all files
  drive <storage-path> --key <hex>  Open a specific drive by key
  ls <db-path> <dataPtr> List all files in a Hyperdrive (by RocksDB dataPointer)
  usage <db-path>       Deep analysis: which cores consume how many bytes on disk
  blocks <storage-path> --key <hex>  Read raw Hyperbee blocks, decode file entries
  extract <storage-path> --data-ptr <n> --out <dir>  Copy files OUT of a Hyperdrive (read-only)

Options:
  --readonly          Open databases in read-only mode (for locked DBs)
  --limit <n>         Max keys to scan (default: 100000)
  --key <hex>         Specify a Hypercore key
  --data-ptr <n>      Metadata core dataPointer (from "cores"/"usage") for extract
  --out <dir>         Destination directory for extract
  --filter <substr>   Only extract paths containing this string
  --max-bytes <n>     Skip files larger than this (e.g. 5M)
  --dry-run           List what would be extracted without writing
  --help              Show this help

Examples:
  hyper-inspector scan
  hyper-inspector audit
  hyper-inspector cores ~/.config/hypernote/server-data
  hyper-inspector drive ~/.config/hypernote/server-data
  hyper-inspector ls ~/.config/hypernote/server-data/db 3 --readonly
  hyper-inspector extract ~/.config/hypernote/server-data --data-ptr 3 --out /tmp/dump --readonly
  hyper-inspector inspect ~/.config/pear/corestores/platform/db --readonly
  hyper-inspector apps ~/.config/pear/corestores/platform/db --readonly
`

if (!command || command === '--help' || command === 'help') {
  console.log(HELP)
  process.exit(0)
}

if (command === 'scan') {
  const dir = args[1] || homedir()
  console.log(`\nScanning ${dir} for Hypercore storage...\n`)

  const stores = findCorestores(dir)
  let totalSize = 0

  for (const store of stores) {
    totalSize += store.size
    const modified = getLastModified(store.path)
    const modStr = modified ? modified.toISOString().slice(0, 10) : 'unknown'
    console.log(`  ${formatBytes(store.size).padStart(10)}  ${modStr}  ${store.path}`)
  }

  console.log(`\n  Total: ${formatBytes(totalSize)} across ${stores.length} locations\n`)
}

if (command === 'inspect') {
  const dbPath = resolve(args[1] || '')
  const readOnly = args.includes('--readonly')
  const limit = parseInt(args[args.indexOf('--limit') + 1]) || 100000

  if (!dbPath || !existsSync(dbPath)) {
    console.error('Error: provide a valid path to a db/ directory')
    process.exit(1)
  }

  const locked = isLocked(dbPath)
  if (locked && !readOnly) {
    const holder = getLockHolder(dbPath)
    console.error(`Database is locked by ${holder?.command} (PID ${holder?.pid})`)
    console.error('Use --readonly to open in read-only mode')
    process.exit(1)
  }

  console.log(`\nInspecting: ${dbPath}`)
  console.log(`Mode: ${readOnly ? 'read-only' : 'read-write'}`)

  const db = await openDB(dbPath, { readOnly })
  const stats = await getCorestoreStats(db)

  console.log(`\nCorestore column family:`)
  console.log(`  Keys scanned: ${stats.totalKeys}`)
  console.log(`  Value bytes: ${formatBytes(stats.totalBytes)}`)

  console.log(`\nKey type breakdown:`)
  for (const t of stats.typeBreakdown) {
    console.log(`  ${t.type.padEnd(16)} ${t.count.toString().padStart(8)} keys  ${t.mb.padStart(10)} MB`)
  }

  if (stats.readable.length > 0) {
    console.log(`\nSample readable values (${stats.readable.length}):`)
    for (const r of stats.readable.slice(0, 30)) {
      const label = KEY_TYPES_LABEL[r.type] || `0x${r.type.toString(16)}`
      try {
        const obj = JSON.parse(r.value)
        if (obj.slug || obj.name || obj.title || obj.id) {
          console.log(`  [${label}] ${obj.title || obj.name || obj.slug || obj.id}`)
          continue
        }
      } catch {}
      console.log(`  [${label}] ${r.value.slice(0, 100)}`)
    }
  }

  const parentPath = dbPath.replace(/\/db\/?$/, '')
  const diskSize = getDirSize(parentPath)
  console.log(`\nDisk usage: ${formatBytes(diskSize)}`)

  await db.close()
}

if (command === 'apps') {
  const dbPath = resolve(args[1] || '')
  const readOnly = args.includes('--readonly')

  if (!dbPath || !existsSync(dbPath)) {
    console.error('Error: provide a valid path to a platform db/ directory')
    process.exit(1)
  }

  const db = await openDB(dbPath, { readOnly })
  const { pearApps, fileSources } = await listApps(db)

  const appRegistrations = pearApps.filter(a => a.prefix === 0x03)
  const appVersions = pearApps.filter(a => a.prefix === 0x06)

  console.log(`\n=== Pear Apps (${appRegistrations.length} registered) ===\n`)
  for (const app of appRegistrations) {
    const versionEntry = appVersions.find(v => v.url === app.url)
    const hasVersion = versionEntry ? 'active' : 'no-version'
    const dkeyMatch = app.storage.match(/by-dkey\/([a-f0-9]+)/)
    const randomMatch = app.storage.match(/by-random\/([a-f0-9]+)/)
    const storageId = dkeyMatch ? `dkey:${dkeyMatch[1].slice(0, 12)}...` : randomMatch ? `random:${randomMatch[1].slice(0, 12)}...` : ''

    const storagePath = app.storage.match(/\/home\/[^\s|]+/)?.[0]
    let sizeStr = ''
    if (storagePath && existsSync(storagePath)) {
      sizeStr = formatBytes(getDirSize(storagePath))
    }

    console.log(`  ${app.url}`)
    console.log(`    status: ${hasVersion}  storage: ${storageId}  size: ${sizeStr || 'n/a'}`)
  }

  console.log(`\n=== Staging Sources (${fileSources.length}) ===\n`)
  for (const f of fileSources) {
    console.log(`  ${f.path}`)
  }

  await db.close()
}

if (command === 'cores') {
  const storagePath = resolve(args[1] || '')
  const readOnly = args.includes('--readonly')

  if (!storagePath || !existsSync(storagePath)) {
    console.error('Error: provide a valid corestore path (directory with CORESTORE file)')
    process.exit(1)
  }

  const dbPath = join(storagePath, 'db')
  const locked = isLocked(dbPath)

  if (locked && !readOnly) {
    const holder = getLockHolder(dbPath)
    console.error(`Database is locked by ${holder?.command} (PID ${holder?.pid})`)
    console.error('Use --readonly to open in read-only mode')
    process.exit(1)
  }

  console.log(`\nEnumerating cores in: ${storagePath}`)
  console.log(`Mode: ${readOnly ? 'read-only' : 'read-write'}\n`)

  const cores = await enumerateCores(dbPath, { readOnly })

  console.log(`Found ${cores.length} cores:\n`)
  for (const core of cores) {
    const alias = core.alias
      ? `${core.alias.name} (ns: ${core.alias.namespace.slice(0, 8)}...)`
      : '(unnamed)'
    const key = core.publicKey ? `key: ${core.publicKey.slice(0, 16)}...` : ''
    const len = core.length != null ? `len: ${core.length}` : ''
    console.log(`  #${String(core.corePointer).padEnd(4)} ${alias.padEnd(30)} ${len.padEnd(12)} ${key}`)
    console.log(`         dkey: ${core.discoveryKey.slice(0, 32)}...`)
  }
}

if (command === 'drives') {
  const storagePath = resolve(args[1] || '')
  const readOnly = args.includes('--readonly')

  if (!storagePath || !existsSync(storagePath)) {
    console.error('Error: provide a valid corestore path')
    process.exit(1)
  }

  console.log(`\nDiscovering Hyperdrives in: ${storagePath}\n`)

  const drives = await discoverDrives(storagePath, { readOnly })

  if (drives.length === 0) {
    console.log('No Hyperdrives found (data may be Autobase/Hyperbee, not Hyperdrive)')
  } else {
    for (const d of drives) {
      const alias = d.alias ? d.alias.name : '(unnamed)'
      const rw = d.writable ? 'writable' : 'read-only'
      console.log(`  Hyperdrive: ${alias}`)
      console.log(`    key:     ${d.key}`)
      console.log(`    content: ${d.contentKey || 'n/a'}`)
      console.log(`    version: ${d.version}  bytes: ${formatBytes(d.byteLength)}  ${rw}`)

      if (d.filePaths.length > 0) {
        console.log(`    files (${d.filePaths.length} found):`)
        for (const f of d.filePaths.slice(0, 15)) {
          console.log(`      ${f}`)
        }
        if (d.filePaths.length > 15) {
          console.log(`      ... and ${d.filePaths.length - 15} more`)
        }
      }
      console.log()
    }
  }
}

if (command === 'drive') {
  const storagePath = resolve(args[1] || '')
  const keyIdx = args.indexOf('--key')
  const key = keyIdx >= 0 ? args[keyIdx + 1] : null

  if (!storagePath || !existsSync(storagePath)) {
    console.error('Error: provide a valid corestore path')
    process.exit(1)
  }

  const dbPath = join(storagePath, 'db')
  const locked = isLocked(dbPath)
  if (locked) {
    const holder = getLockHolder(dbPath)
    console.error(`Corestore is locked by ${holder?.command} (PID ${holder?.pid})`)
    console.error('Cannot open a Hyperdrive while another process holds the lock.')
    console.error('Stop the process first, or use "cores" / "inspect" with --readonly.')
    process.exit(1)
  }

  console.log(`\nOpening Hyperdrive from: ${storagePath}`)
  if (key) console.log(`Key: ${key}`)

  const { store, drive } = await openDrive(storagePath, { key })

  console.log(`\nDrive key: ${drive.key.toString('hex')}`)
  console.log(`Version: ${drive.version}`)
  console.log(`Writable: ${drive.writable}`)

  const stats = await getDriveStats(drive)

  console.log(`\nFiles: ${stats.fileCount}`)
  console.log(`Total content: ${formatBytes(stats.totalBytes)}`)
  console.log(`Blob blocks: ${stats.blobBlocks}`)

  if (stats.topDirs.length > 0) {
    console.log(`\n--- By directory ---`)
    for (const d of stats.topDirs.slice(0, 15)) {
      console.log(`  ${formatBytes(d.bytes).padStart(10)}  ${d.count.toString().padStart(5)} files  ${d.dir}`)
    }
  }

  if (stats.extensions.length > 0) {
    console.log(`\n--- By extension ---`)
    for (const e of stats.extensions.slice(0, 15)) {
      console.log(`  ${formatBytes(e.bytes).padStart(10)}  ${e.count.toString().padStart(5)} files  .${e.ext}`)
    }
  }

  // Show first 50 files
  console.log(`\n--- Files (first 50 of ${stats.files.length}) ---`)
  for (const f of stats.files.slice(0, 50)) {
    const size = f.byteLength ? formatBytes(f.byteLength) : '0 B'
    const link = f.linkname ? ` -> ${f.linkname}` : ''
    const exec = f.executable ? ' *' : ''
    console.log(`  ${size.padStart(10)}  ${f.path}${exec}${link}`)
  }

  if (stats.files.length > 50) {
    console.log(`  ... and ${stats.files.length - 50} more files`)
  }

  await drive.close()
  await store.close()
}

if (command === 'ls') {
  const dbPath = resolve(args[1] || '')
  const dataPtr = parseInt(args[2])
  const readOnly = args.includes('--readonly')
  const flat = args.includes('--flat')
  const filterIdx = args.indexOf('--filter')
  const filter = filterIdx >= 0 ? args[filterIdx + 1] : null
  const depthIdx = args.indexOf('--depth')
  const maxDepth = depthIdx >= 0 ? parseInt(args[depthIdx + 1]) : 3
  const minIdx = args.indexOf('--min')
  const minBytes = minIdx >= 0 ? parseSize(args[minIdx + 1]) : 0

  if (!dbPath || isNaN(dataPtr)) {
    console.error('Usage: hyper-inspector ls <db-path> <dataPointer> [--readonly] [--flat] [--filter <path>] [--depth <n>] [--min <size>]')
    console.error('  dataPointer: the data pointer number from "cores" output')
    console.error('  --flat: flat file list sorted by size')
    console.error('  --filter: only show paths containing this string')
    console.error('  --depth: max tree depth (default: 3)')
    console.error('  --min: minimum file size to show (e.g., 1M, 100K)')
    process.exit(1)
  }

  console.log(`\nListing Hyperdrive at dataPointer ${dataPtr} in ${dbPath}\n`)

  const { header, files, blocksRead } = await listDriveFromDB(dbPath, dataPtr, { readOnly })

  if (header) {
    console.log(`Hyperbee: protocol="${header.protocol}"`)
    if (header.contentFeed) {
      console.log(`Content core: ${b4a.toString(header.contentFeed, 'hex')}`)
    }
  }

  console.log(`Scanned ${blocksRead} blocks, found ${files.length} files`)

  let totalBytes = 0
  for (const f of files) totalBytes += f.bytes
  console.log(`Total content: ${formatBytes(totalBytes)}\n`)

  if (files.length === 0) {
    console.log('No Hyperdrive file entries found.')
  } else if (flat) {
    console.log(formatFlat(files, { filter, limit: 200 }))
  } else {
    const tree = buildTree(files)
    console.log(formatTree(tree, { maxDepth, minBytes, showFiles: maxDepth <= 2 }))
  }
}

function parseSize (str) {
  if (!str) return 0
  const num = parseFloat(str)
  if (str.endsWith('G') || str.endsWith('g')) return num * 1024 * 1024 * 1024
  if (str.endsWith('M') || str.endsWith('m')) return num * 1024 * 1024
  if (str.endsWith('K') || str.endsWith('k')) return num * 1024
  return num
}

if (command === 'usage') {
  const dbPath = resolve(args[1] || '')
  const readOnly = args.includes('--readonly')
  const sampleIdx = args.indexOf('--sample')
  const sampleSize = sampleIdx >= 0 ? parseInt(args[sampleIdx + 1]) : 500000

  if (!dbPath || !existsSync(dbPath)) {
    console.error('Error: provide a valid path to a db/ directory')
    process.exit(1)
  }

  console.log(`\nDeep disk usage analysis: ${dbPath}`)
  console.log(`Sampling up to ${sampleSize} entries...\n`)

  const analysis = await analyzeCoreDiskUsage(dbPath, { readOnly, sampleSize })
  console.log(formatAnalysis(analysis))
}

if (command === 'blocks') {
  const storagePath = resolve(args[1] || '')
  const keyIdx = args.indexOf('--key')
  const keyHex = keyIdx >= 0 ? args[keyIdx + 1] : null
  const maxIdx = args.indexOf('--max')
  const maxBlocks = maxIdx >= 0 ? parseInt(args[maxIdx + 1]) : 500

  if (!storagePath || !keyHex) {
    console.error('Usage: hyper-inspector blocks <storage-path> --key <hex> [--max <n>]')
    process.exit(1)
  }

  const dbPath = join(storagePath, 'db')
  const locked = isLocked(dbPath)
  if (locked) {
    const holder = getLockHolder(dbPath)
    console.error(`Locked by ${holder?.command} (PID ${holder?.pid}). Cannot open core.`)
    process.exit(1)
  }

  const Corestore = (await import('corestore')).default
  const store = new Corestore(storagePath)
  await store.ready()

  const core = store.get({ key: Buffer.from(keyHex, 'hex') })
  await core.ready()

  console.log(`\nCore: len=${core.length} bytes=${formatBytes(core.byteLength)} writable=${core.writable}`)

  const { header, entries } = await walkHyperbeeBlocks(core, { maxBlocks })

  if (header) {
    console.log(`\nHyperbee header: protocol="${header.protocol}"`)
    if (header.contentFeed) {
      console.log(`Content feed: ${b4a.toString(header.contentFeed, 'hex')}`)
    }
  }

  console.log(`\nDecoded ${entries.length} Hyperbee entries from ${Math.min(core.length, maxBlocks)} blocks\n`)

  // Try to decode as Hyperdrive file entries
  let driveEntries = 0
  let totalBlobBytes = 0
  const filesByExt = new Map()
  const filesByDir = new Map()
  const allFiles = []

  for (const entry of entries) {
    const rawKey = stripSubEncoder(entry.key)
    let filePath = ''
    try {
      filePath = b4a.toString(rawKey, 'utf8')
    } catch {
      continue
    }

    // Skip non-path entries
    if (!filePath.startsWith('/')) continue

    let driveEntry = null
    try {
      driveEntry = decodeDriveEntry(entry.value)
    } catch {}

    if (driveEntry) {
      driveEntries++
      const blob = driveEntry.blob
      const bytes = blob?.byteLength || 0
      totalBlobBytes += bytes

      // By extension
      const ext = filePath.includes('.') ? filePath.split('.').pop() : '(none)'
      const extInfo = filesByExt.get(ext) || { count: 0, bytes: 0 }
      extInfo.count++
      extInfo.bytes += bytes
      filesByExt.set(ext, extInfo)

      // By top-level directory
      const parts = filePath.split('/')
      const topDir = parts.length > 2 ? '/' + parts[1] : '/'
      const dirInfo = filesByDir.get(topDir) || { count: 0, bytes: 0 }
      dirInfo.count++
      dirInfo.bytes += bytes
      filesByDir.set(topDir, dirInfo)

      allFiles.push({
        path: filePath,
        blob,
        executable: driveEntry.executable,
        linkname: driveEntry.linkname,
        bytes
      })
    }
  }

  if (driveEntries > 0) {
    console.log(`=== Hyperdrive File Analysis ===`)
    console.log(`Files: ${driveEntries}`)
    console.log(`Total blob content: ${formatBytes(totalBlobBytes)}\n`)

    // By directory
    const sortedDirs = [...filesByDir.entries()].sort((a, b) => b[1].bytes - a[1].bytes)
    console.log('--- By directory ---')
    for (const [dir, info] of sortedDirs.slice(0, 20)) {
      console.log(`  ${formatBytes(info.bytes).padStart(10)}  ${info.count.toString().padStart(5)} files  ${dir}`)
    }

    // By extension
    const sortedExts = [...filesByExt.entries()].sort((a, b) => b[1].bytes - a[1].bytes)
    console.log('\n--- By extension ---')
    for (const [ext, info] of sortedExts.slice(0, 20)) {
      console.log(`  ${formatBytes(info.bytes).padStart(10)}  ${info.count.toString().padStart(5)} files  .${ext}`)
    }

    // Largest files
    allFiles.sort((a, b) => b.bytes - a.bytes)
    console.log('\n--- Largest files ---')
    for (const f of allFiles.slice(0, 20)) {
      const blockInfo = f.blob ? `blocks[${f.blob.blockOffset}..${f.blob.blockOffset + f.blob.blockLength}]` : ''
      console.log(`  ${formatBytes(f.bytes).padStart(10)}  ${f.path}  ${blockInfo}`)
    }

    // Show blob block range summary
    if (allFiles.length > 0 && allFiles[0].blob) {
      let minBlock = Infinity
      let maxBlock = 0
      for (const f of allFiles) {
        if (f.blob) {
          minBlock = Math.min(minBlock, f.blob.blockOffset)
          maxBlock = Math.max(maxBlock, f.blob.blockOffset + f.blob.blockLength)
        }
      }
      console.log(`\nBlob block range: ${minBlock} - ${maxBlock} (${maxBlock - minBlock} blocks in content core)`)
    }
  } else {
    // Not a Hyperdrive - show raw entries
    console.log('Not a Hyperdrive (no file path entries found).')
    console.log('Raw Hyperbee entries:')
    for (const entry of entries.slice(0, 30)) {
      let keyStr = ''
      try {
        keyStr = b4a.toString(entry.key, 'utf8')
        if (!/^[\x20-\x7e\n\r\t]+$/.test(keyStr)) {
          keyStr = `[hex: ${b4a.toString(entry.key, 'hex').slice(0, 40)}]`
        }
      } catch {
        keyStr = `[hex: ${b4a.toString(entry.key, 'hex').slice(0, 40)}]`
      }
      const valLen = entry.value ? entry.value.length : 0
      console.log(`  seq=${entry.seq}  ${keyStr}  (val: ${valLen}B)`)
    }
  }

  await core.close()
  await store.close()
}

if (command === 'du') {
  const dir = args[1] || homedir()
  const depthIdx = args.indexOf('--depth')
  const dirDepth = depthIdx >= 0 ? parseInt(args[depthIdx + 1]) : 2
  const minIdx = args.indexOf('--min')
  const minSize = minIdx >= 0 ? parseSize(args[minIdx + 1]) : 1024 * 1024

  console.log(`\n=== Hyperdrive Disk Usage: ${dir} ===\n`)

  const stores = findCorestores(dir)

  for (const store of stores) {
    const dbPath = join(store.path, 'db')
    if (!existsSync(dbPath)) {
      // Try nested corestore/db
      const nested = join(store.path, 'corestore', 'db')
      if (!existsSync(nested)) continue
    }

    const actualDbPath = existsSync(join(store.path, 'db'))
      ? join(store.path, 'db')
      : join(store.path, 'corestore', 'db')

    const diskSize = getDirSize(store.path)
    const locked = isLocked(actualDbPath)
    const lockInfo = locked ? getLockHolder(actualDbPath) : null

    console.log(`--- ${store.path} (${formatBytes(diskSize)} on disk)${locked ? ` [LOCKED by ${lockInfo?.command}]` : ''} ---\n`)

    try {
      const result = await corestoreDU(actualDbPath, { readOnly: true, dirDepth })
      console.log(formatDU(result, { dirDepth, minSize }))
    } catch (err) {
      if (err.message.includes('Column families not opened')) {
        console.log('  (not a corestore RocksDB — legacy or different format)')
      } else {
        console.log(`  Error: ${err.message.slice(0, 100)}`)
      }
    }

    console.log('')
  }
}

if (command === 'audit') {
  const dir = args[1] || homedir()
  console.log(`\n=== Hypercore Storage Audit: ${dir} ===\n`)

  const stores = findCorestores(dir)
  let totalSize = 0
  const categories = { active: [], stale: [], junk: [], unknown: [] }

  for (const store of stores) {
    totalSize += store.size
    const modified = getLastModified(store.path)
    const now = new Date()
    const ageMs = modified ? now - modified : Infinity
    const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24))

    const dbPath = join(store.path, 'db')
    const parentDb = existsSync(dbPath) ? dbPath : join(store.path, 'corestore', 'db')
    const locked = existsSync(parentDb) ? isLocked(parentDb) : false
    const holder = locked ? getLockHolder(parentDb) : null

    const entry = {
      ...store,
      modified,
      ageDays,
      locked,
      holder,
      sizeFormatted: formatBytes(store.size)
    }

    if (locked) {
      categories.active.push(entry)
    } else if (ageDays > 180) {
      categories.stale.push(entry)
    } else if (store.path.includes('backup') || store.path.includes('broken') || store.path.includes('test') || store.path.includes('Videos')) {
      categories.junk.push(entry)
    } else {
      categories.unknown.push(entry)
    }
  }

  const printEntry = (e) => {
    const lockStr = e.locked ? ` [LOCKED by ${e.holder?.command} PID ${e.holder?.pid}]` : ''
    const ageStr = e.ageDays < 365 ? `${e.ageDays}d ago` : `${Math.floor(e.ageDays / 365)}y ago`
    console.log(`  ${e.sizeFormatted.padStart(10)}  ${ageStr.padStart(8)}  ${e.path}${lockStr}`)
  }

  if (categories.active.length > 0) {
    console.log('ACTIVE (locked by running processes):')
    categories.active.forEach(printEntry)
    console.log()
  }

  if (categories.unknown.length > 0) {
    console.log('NEEDS REVIEW (unlocked, relatively recent):')
    categories.unknown.forEach(printEntry)
    console.log()
  }

  if (categories.stale.length > 0) {
    console.log('STALE (>6 months old, unlocked):')
    categories.stale.forEach(printEntry)
    console.log()
  }

  if (categories.junk.length > 0) {
    console.log('LIKELY JUNK (backups, test data, broken):')
    categories.junk.forEach(printEntry)
    console.log()
  }

  console.log(`Total: ${formatBytes(totalSize)} across ${stores.length} locations`)
  const staleSize = categories.stale.reduce((s, e) => s + e.size, 0)
  const junkSize = categories.junk.reduce((s, e) => s + e.size, 0)
  console.log(`Potentially reclaimable: ${formatBytes(staleSize + junkSize)} (stale + junk)`)
}

const KEY_TYPES_LABEL = {
  0x00: 'seed',
  0x01: 'core-key',
  0x02: 'dk',
  0x03: 'info',
  0x04: 'data'
}

if (command === 'extract') {
  const storagePath = resolve(args[1] || '')
  const readOnly = args.includes('--readonly')
  const dryRun = args.includes('--dry-run')
  const dpIdx = args.indexOf('--data-ptr')
  const dataPtr = dpIdx >= 0 ? parseInt(args[dpIdx + 1]) : NaN
  const outIdx = args.indexOf('--out')
  const outDir = outIdx >= 0 ? resolve(args[outIdx + 1]) : null
  const filterIdx = args.indexOf('--filter')
  const filter = filterIdx >= 0 ? args[filterIdx + 1] : null
  const maxIdx = args.indexOf('--max-bytes')
  const maxBytes = maxIdx >= 0 ? parseSize(args[maxIdx + 1]) : Infinity
  const limitIdx = args.indexOf('--limit')
  const limit = limitIdx >= 0 ? parseInt(args[limitIdx + 1]) : Infinity
  const blobIdx = args.indexOf('--blob-ptr')
  const blobDataPointer = blobIdx >= 0 ? parseInt(args[blobIdx + 1]) : null

  if (!storagePath || isNaN(dataPtr) || (!outDir && !dryRun)) {
    console.error('Usage: hyper-inspector extract <storage-path> --data-ptr <n> --out <dir> [--readonly] [--filter <s>] [--max-bytes <n>] [--dry-run]')
    console.error('  --data-ptr: the metadata core dataPointer from "cores"/"usage" output')
    process.exit(1)
  }

  console.log(`\nExtracting drive (dataPointer ${dataPtr}) from ${storagePath}`)
  if (dryRun) console.log('(dry run — no files written)')

  const res = await extractDrive(storagePath, dataPtr, outDir, { readOnly, filter, maxBytes, limit, dryRun, blobDataPointer })

  if (res.note) console.log('Note: ' + res.note)
  console.log(`Blobs core dataPointer: ${res.blobsDataPointer}`)
  for (const f of res.files.slice(0, 200)) {
    console.log(`  ${String(f.status || '').padEnd(13)} ${formatBytes(f.bytes).padStart(10)}  ${f.path}`)
  }
  console.log(`\n${dryRun ? 'Would write' : 'Wrote'} ${res.written} file(s), ${formatBytes(res.totalBytes)}${res.skipped ? `, skipped ${res.skipped}` : ''}`)
  if (!dryRun && outDir) console.log(`Output: ${outDir}`)
}