OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / bin / hn

446 lines · text ref:
#!/usr/bin/env bare
await import('bare-node-runtime/global')

/**
 * hn - Hypernote CLI
 *
 * Usage:
 *   hn <command> [args] [--flags]
 *
 * Commands:
 *   init      Generate device identity (keypair + encrypted mnemonic)
 *   restore   Restore identity from mnemonic
 *   whoami    Show current identity
 *   config    Get/set/list configuration
 *   help      Show help
 *   version   Show version
 */

import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import { HnClient } from '../lib/cli/client.js'
import { loadConfig, saveConfig, hasDeviceIdentity, getServer } from '../lib/cli/config.js'
import { log, error } from '../lib/cli/output.js'
import { IdentityManager } from '../lib/identity/identity-manager.js'
import { notebookCommand } from '../lib/cli/commands/notebook.js'
import { noteCommand } from '../lib/cli/commands/note.js'
import { searchCommand } from '../lib/cli/commands/search.js'
import { shareCommand } from '../lib/cli/commands/share.js'
import { linksCommand, backlinksCommand, graphCommand, mergeCommand, splitCommand } from '../lib/cli/commands/links.js'
import { syncCommand } from '../lib/cli/commands/sync.js'
import { linkCommand } from '../lib/cli/commands/link.js'

const VERSION = '0.1.0'
const CONFIG_DIR = path.join(os.homedir(), '.config', 'hypernote')

// Parse args
const rawArgs = process.argv.slice(2)
const flags = {}
const positional = []

const booleanFlags = new Set([
  '--json', '--help', '-h', '--version', '-v', '--verbose', '--offline', '--force', '--encrypted'
])

for (let i = 0; i < rawArgs.length; i++) {
  const arg = rawArgs[i]
  if (booleanFlags.has(arg)) {
    const key = arg.startsWith('--') ? arg.slice(2) : arg.slice(1)
    flags[key] = true
  } else if (arg.startsWith('--') && rawArgs[i + 1] && !rawArgs[i + 1].startsWith('--')) {
    flags[arg.slice(2)] = rawArgs[++i]
  } else if (arg.startsWith('-') && arg.length === 2 && rawArgs[i + 1] && !rawArgs[i + 1].startsWith('-')) {
    flags[arg.slice(1)] = rawArgs[++i]
  } else if (!arg.startsWith('-')) {
    positional.push(arg)
  }
}

const command = positional[0]
const subcommand = positional[1]
const cmdArgs = positional.slice(2)

if (flags.version || flags.v) {
  log(`hn ${VERSION}`)
  process.exit(0)
}

if (!command || flags.help || flags.h) {
  showHelp()
  process.exit(0)
}

// First-run guard — require device identity for most commands
const skipGuardCommands = new Set(['init', 'restore', 'config', 'help', 'version'])
if (command && !skipGuardCommands.has(command) && !hasDeviceIdentity()) {
  log('Hypernote is not configured. Run: hn init')
  process.exit(1)
}

try {
  switch (command) {
    case 'init':
      await initCommand()
      break
    case 'restore':
      await restoreCommand()
      break
    case 'whoami':
      await whoamiCommand()
      break
    case 'config':
      await configCommand()
      break
    case 'notebook':
    case 'nb':
      await notebookCommand(subcommand, cmdArgs, flags)
      break
    case 'note':
      await noteCommand(subcommand, cmdArgs, flags)
      break
    case 'share':
      await shareCommand(subcommand, cmdArgs, flags)
      break
    case 'links':
      await linksCommand(positional.slice(1), flags)
      break
    case 'backlinks':
      await backlinksCommand(positional.slice(1), flags)
      break
    case 'graph':
      await graphCommand(positional.slice(1), flags)
      break
    case 'merge':
      await mergeCommand(positional.slice(1), flags)
      break
    case 'split':
      await splitCommand(positional.slice(1), flags)
      break
    case 'search':
      await searchCommand(positional.slice(1), flags)
      break
    case 'sync':
      await syncCommand(subcommand, cmdArgs, flags)
      break
    case 'link':
      await linkCommand(subcommand, cmdArgs, flags)
      break
    case 'help':
      showHelp()
      break
    case 'version':
      log(`hn ${VERSION}`)
      break
    default:
      error(`Unknown command: ${command}\nRun 'hn help' for available commands`)
  }
} catch (err) {
  error(err.message)
}

// ==================== COMMANDS ====================

async function initCommand() {
  if (hasDeviceIdentity()) {
    log('Device identity already exists.')
    log('Use "hn restore" to restore from mnemonic on a new device.')
    return
  }

  // Ensure config dir
  await fs.promises.mkdir(CONFIG_DIR, { recursive: true })

  const mgr = new IdentityManager({
    storagePath: CONFIG_DIR,
    identityFile: path.join(CONFIG_DIR, 'device-identity.json')
  })

  // Generate keypair
  await mgr.ensureKeypair()

  // Prompt for password
  let securePrompt
  try {
    securePrompt = (await import('secure-prompt')).default
  } catch {
    error('secure-prompt not available. Install with: npm install secure-prompt')
    return
  }

  log('Creating device identity...')
  log('')

  const password = await securePrompt('Enter encryption password: ')
  if (!password || password.length === 0) {
    error('Password cannot be empty')
    return
  }

  const confirm = await securePrompt('Confirm password: ')
  if (password.toString() !== confirm.toString()) {
    error('Passwords do not match')
    return
  }

  // Generate mnemonic + proof + encrypt vault
  const result = await mgr.setup(password)

  log('')
  log('=== BACKUP YOUR MNEMONIC ===')
  log('')
  log(result.mnemonic)
  log('')
  log('Write these words down and store them safely.')
  log('You will need them to restore your identity on another device.')
  log('')
  log(`Identity public key: ${result.identityPublicKey.substring(0, 16)}...`)
  log('')
  log('Device identity created successfully!')

  // Prompt for display name
  let name = 'Anonymous'
  const readline = await import('bare-readline')
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
  try {
    name = await new Promise((resolve) => {
      rl.question('Display name (optional): ', (answer) => {
        resolve(answer || 'Anonymous')
      })
    })
  } finally {
    rl.close()
  }

  // Save config with name
  const cfg = loadConfig()
  cfg.name = name
  cfg.identityPublicKey = result.identityPublicKey
  saveConfig(cfg)

  log(`Welcome, ${name}!`)
}

async function restoreCommand() {
  // Ensure config dir
  await fs.promises.mkdir(CONFIG_DIR, { recursive: true })

  const mgr = new IdentityManager({
    storagePath: CONFIG_DIR,
    identityFile: path.join(CONFIG_DIR, 'device-identity.json')
  })

  // Ensure a device keypair exists (new key for this device)
  await mgr.ensureKeypair()

  let securePrompt
  try {
    securePrompt = (await import('secure-prompt')).default
  } catch {
    error('secure-prompt not available. Install with: npm install secure-prompt')
    return
  }

  log('Restoring identity from mnemonic...')
  log('')

  const readline = await import('bare-readline')
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout })

  let mnemonic
  try {
    mnemonic = await new Promise((resolve) => {
      rl.question('Enter mnemonic (12 or 24 words): ', resolve)
    })
  } finally {
    rl.close()
  }

  if (!mnemonic || mnemonic.trim().split(/\s+/).length < 12) {
    error('Invalid mnemonic (must be 12 or 24 words)')
    return
  }

  const password = await securePrompt('Enter encryption password for this device: ')
  if (!password || password.length === 0) {
    error('Password cannot be empty')
    return
  }

  const confirm = await securePrompt('Confirm password: ')
  if (password.toString() !== confirm.toString()) {
    error('Passwords do not match')
    return
  }

  const result = await mgr.setupFromMnemonic(mnemonic.trim(), password)

  log('')
  log(`Identity restored! Public key: ${result.identityPublicKey.substring(0, 16)}...`)

  // Save to config
  const cfg = loadConfig()
  cfg.identityPublicKey = result.identityPublicKey
  saveConfig(cfg)

  log('Identity restored successfully!')
}

async function whoamiCommand() {
  const cfg = loadConfig()

  if (flags.offline) {
    // Offline mode — read from config
    log(`Name: ${cfg.name || 'Not set'}`)
    log(`Server: ${cfg.server}`)
    if (cfg.identityPublicKey) {
      log(`Identity: ${cfg.identityPublicKey.substring(0, 16)}...`)
    }
    if (cfg.token) {
      log(`Token: ${cfg.token.substring(0, 8)}...`)
    }
    return
  }

  // Try server
  try {
    const client = new HnClient(flags)
    const data = await client.get('/api/whoami')
    if (data.authenticated) {
      log(`Authenticated: yes`)
      log(`Method: ${data.method}`)
      if (data.username) log(`Username: ${data.username}`)
      if (data.userId) log(`User ID: ${data.userId}`)
      log(`Permissions: ${JSON.stringify(data.permissions)}`)
    } else {
      log('Not authenticated with server')
    }
  } catch {
    // Fall back to offline
    log(`Name: ${cfg.name || 'Not set'}`)
    log(`Server: ${cfg.server} (unreachable)`)
    if (cfg.identityPublicKey) {
      log(`Identity: ${cfg.identityPublicKey.substring(0, 16)}...`)
    }
  }
}

async function configCommand() {
  const cfg = loadConfig()

  if (!subcommand || subcommand === 'list') {
    // List all config
    for (const [key, value] of Object.entries(cfg)) {
      log(`${key} = ${value !== null ? value : '(not set)'}`)
    }
    return
  }

  if (subcommand === 'get') {
    const key = cmdArgs[0]
    if (!key) {
      error('Usage: hn config get <key>')
      return
    }
    const value = cfg[key]
    log(value !== undefined && value !== null ? String(value) : '(not set)')
    return
  }

  if (subcommand === 'set') {
    const key = cmdArgs[0]
    const value = cmdArgs[1]
    if (!key || value === undefined) {
      error('Usage: hn config set <key> <value>')
      return
    }
    cfg[key] = value
    saveConfig(cfg)
    log(`Set ${key} = ${value}`)
    return
  }

  error(`Unknown config subcommand: ${subcommand}`)
}

function showHelp() {
  log(`hn ${VERSION} — Hypernote CLI

Usage:
  hn <command> [args] [--flags]

Commands:
  init        Generate device identity (keypair + encrypted mnemonic)
  restore     Restore identity from mnemonic backup
  whoami      Show current identity (--offline for local only)
  config      Manage configuration
    list      List all config values
    get <key> Get a config value
    set <k> <v> Set a config value

  notebook (nb)  Manage notebooks
    list         List all notebooks
    create <slug> [--name "..."] [--description "..."] [--encrypted --password "..."]
    info <slug>  Show notebook details
    rename <old> <new>  Rename a notebook
    delete <slug> [--force]  Delete notebook + all notes

  note         Manage notes
    list <notebook>  List notes in a notebook
    create <notebook> --title "..." [--content "..." | --file path] [--tags "a,b"]
    show <notebook> <id>  Show note content
    edit <notebook> <id> [--title | --content | --file | --tags]
    delete <notebook> <id>

  search <query> [--notebook <slug>]  Search notes

  links <notebook> <id>      Show outgoing [[wiki-links]] from a note
  backlinks <notebook> <id>  Show notes that link TO this note
  graph [notebook]           Show link graph (all or one notebook)
  merge <notebook> <id1> <id2> [--title "..."]  Merge two notes
  split <notebook> <id> [--delimiter "## "]     Split note at headings

  share        Manage notebook shares
    create <notebook> [--permissions read|write] [--label "..."]
    list <notebook>                List active shares
    revoke <notebook> <tokenHash>  Revoke a share

  sync         P2P replication control
    status                         Show peers + announced notebooks
    announce <slug>                Share notebook on swarm
    unannounce <slug>              Stop sharing
    add <driveKey> [--slug name]   Add remote notebook by drive key
    remove <slug>                  Remove remote notebook
    info <slug>                    Show drive key + sync status

  link         Multi-writer device linking
    create                         Create link invite
    join <invite-hex>              Join with invite from another device
    status                         Show multiwriter status
    sync                           Trigger manual mirror sync
    add-writer <key>               Manual writer key add (fallback)

  help        Show this help
  version     Show version

Flags:
  --server <url>  Override server URL
  --token <tok>   Override auth token
  --offline       Don't contact server
  --json          Output as JSON
  --force         Skip confirmation prompts
  -h, --help      Show help
  -v, --version   Show version

Examples:
  hn init                          # First-time setup
  hn config set server http://localhost:4040
  hn config set token <admin-token>
  hn whoami                        # Check identity
  hn notebook create work --name "Work Notes"
  hn note create work --title "Meeting" --content "# Notes\\n..."
  hn note list work
  hn search "meeting"
`)
}