OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / packages / pear-git-remote / index.js

293 lines · javascript ref:
#!/usr/bin/env pear
/**
 * pear-git-remote - Pear app for git pear:// URL support
 *
 * Install:
 *   pear run pear://<key>/pear-git-remote install
 *
 * Then use:
 *   git clone pear://<server-key>/<repo>
 */

// Load Bare runtime compatibility
await import('bare-node-runtime/global')

import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import process from 'bare-process'
import { PetnameManager } from './lib/petnames.js'
import { TopicConnector } from './lib/topic-manager.js'
import { GitP2PClient } from './lib/git-p2p-handler.js'

// Pear is available as a global when running under Pear runtime
const Pear = globalThis.Pear

const HOME = os.homedir()
const BIN_DIR = path.join(HOME, '.local', 'bin')
const HELPER_PATH = path.join(BIN_DIR, 'git-remote-pear')

// Get our pear key for the wrapper script
const pearKey = Pear?.config?.key?.toString('hex') || 'unknown'

// Use Pear.config.args when running under Pear, otherwise process.argv
const isPear = typeof Pear !== 'undefined'
const args = isPear ? (Pear.config.args || []) : process.argv.slice(2)
const command = args[0]

async function main() {
  // Check if we're being called as git remote helper
  // Git passes: git-remote-pear <remote-name> <url>
  if (command && command.startsWith('pear://')) {
    // Called by git with URL as first arg
    await runGitHelper(command, command)
    return
  }

  if (args.length >= 2 && args[1]?.startsWith('pear://')) {
    // Called by git with remote name and URL
    await runGitHelper(args[0], args[1])
    return
  }

  // Otherwise handle as CLI command
  switch (command) {
    case 'install':
      await install()
      break
    case 'uninstall':
      await uninstall()
      break
    case 'status':
      await status()
      break
    case 'helper':
      // Explicit helper mode: pear run ... helper <remote> <url>
      await runGitHelper(args[1], args[2] || args[1])
      break
    case 'help':
    case '--help':
    case '-h':
    case undefined:
      showHelp()
      break
    default:
      console.error(`Unknown command: ${command}`)
      showHelp()
      process.exit(1)
  }
}

function showHelp() {
  console.log(`
pear-git-remote - Git remote helper for pear:// URLs

Usage:
  pear run pear://${pearKey.slice(0, 8)}... install     Install git helper
  pear run pear://${pearKey.slice(0, 8)}... uninstall   Remove git helper
  pear run pear://${pearKey.slice(0, 8)}... status      Check installation

After installing:
  git clone pear://<server-key>/<repo>

Example:
  git clone pear://3cf3266a57b3ef069b724a4330200d72b0ad81daea7311b7d2a2369b0469f199/wirecore
`)
}

async function install() {
  console.log('Installing git-remote-pear...')

  // Create bin directory
  try {
    await fs.promises.mkdir(BIN_DIR, { recursive: true })
  } catch (err) {
    if (err.code !== 'EEXIST') throw err
  }

  // Create wrapper script that calls pear run
  const wrapper = `#!/bin/bash
# git-remote-pear - installed by pear-git-remote
# Calls the Pear app to handle pear:// git URLs
exec pear run pear://${pearKey} helper "$@"
`

  await fs.promises.writeFile(HELPER_PATH, wrapper, { mode: 0o755 })
  console.log(`✓ Installed ${HELPER_PATH}`)

  // Check PATH
  const pathEnv = process.env.PATH || ''
  if (!pathEnv.includes('.local/bin')) {
    console.log(`
Add ~/.local/bin to PATH:
  echo 'export PATH="\$HOME/.local/bin:\$PATH"' >> ~/.bashrc
  source ~/.bashrc
`)
  } else {
    console.log('✓ ~/.local/bin is in PATH')
  }

  console.log(`
Ready! Clone repos with:
  git clone pear://<server-key>/<repo>
`)
}

async function uninstall() {
  try {
    await fs.promises.unlink(HELPER_PATH)
    console.log(`✓ Removed ${HELPER_PATH}`)
  } catch (err) {
    if (err.code === 'ENOENT') {
      console.log('git-remote-pear is not installed')
    } else {
      throw err
    }
  }
}

async function status() {
  try {
    await fs.promises.access(HELPER_PATH, fs.constants.X_OK)
    console.log(`✓ Installed at ${HELPER_PATH}`)

    // Read and show the pear key it points to
    const content = await fs.promises.readFile(HELPER_PATH, 'utf-8')
    const match = content.match(/pear:\/\/([a-f0-9]+)\//)
    if (match) {
      console.log(`  Points to: pear://${match[1].slice(0, 16)}...`)
    }

    const pathEnv = process.env.PATH || ''
    if (pathEnv.includes('.local/bin')) {
      console.log('✓ ~/.local/bin is in PATH')
    } else {
      console.log('⚠ ~/.local/bin is NOT in PATH')
    }
  } catch {
    console.log('✗ Not installed')
    console.log(`  Run: pear run pear://${pearKey.slice(0, 8)}... install`)
  }
}

async function runGitHelper(remoteName, remoteUrl) {
  // Initialize petname manager
  const petnames = new PetnameManager()
  await petnames.init()

  // Parse pear:// URL
  let parsed
  try {
    parsed = petnames.parseUrl(remoteUrl)
  } catch (err) {
    console.error(`error: ${err.message}`)
    process.exit(128)
  }

  const { key, repo, isPetname, petname } = parsed

  if (isPetname) {
    console.error(`[pear] Resolved ${petname} -> ${key.slice(0, 8)}...`)
  }

  // Read commands from git
  const stdin = process.stdin
  const stdout = process.stdout
  const stderr = process.stderr

  let buffer = Buffer.alloc(0)
  let commandMode = true

  stdin.on('data', async (chunk) => {
    if (!commandMode) return

    const chunkBuf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
    buffer = Buffer.concat([buffer, chunkBuf])

    let newlineIdx
    while ((newlineIdx = buffer.indexOf(0x0a)) !== -1) {
      const line = buffer.slice(0, newlineIdx).toString('utf-8').trim()
      buffer = buffer.slice(newlineIdx + 1)

      if (line === '') continue

      const shouldPipe = await handleCommand(line, key, repo, stdout, stderr)
      if (shouldPipe) {
        commandMode = false
        return
      }
    }
  })

  stdin.on('end', () => {
    process.exit(0)
  })
}

async function handleCommand(command, serverKey, repoName, stdout, stderr) {
  const [cmd, ...cmdArgs] = command.split(' ')

  switch (cmd) {
    case 'capabilities':
      stdout.write('connect\n')
      stdout.write('\n')
      return false

    case 'connect':
      const service = cmdArgs[0]
      await handleConnect(service, serverKey, repoName, stdout, stderr)
      return true

    case 'list':
      stderr.write('[pear] list not implemented, use connect\n')
      stdout.write('\n')
      return false

    default:
      stderr.write(`[pear] Unknown command: ${cmd}\n`)
      process.exit(1)
      return false
  }
}

async function handleConnect(service, serverKey, repoName, stdout, stderr) {
  stderr.write(`[pear] Connecting to ${serverKey.slice(0, 8)}.../${repoName}\n`)

  const connector = new TopicConnector({ timeout: 30000 })
  await connector.init()

  try {
    stderr.write('[pear] Searching for peers...\n')
    const conn = await connector.connect(serverKey, repoName)
    stderr.write('[pear] Connected!\n')

    const client = new GitP2PClient()
    await client.initiate(conn, service, repoName)

    stdout.write('\n')

    process.stdin.pipe(conn)
    conn.pipe(stdout)

    conn.on('close', () => {
      stderr.write('[pear] Connection closed\n')
      process.exit(0)
    })

    conn.on('error', (err) => {
      stderr.write(`[pear] Error: ${err.message}\n`)
      process.exit(128)
    })

  } catch (err) {
    stderr.write(`[pear] Failed: ${err.message}\n`)
    await connector.destroy()
    process.exit(128)
  }
}

main().catch(err => {
  console.error(`Error: ${err.message}`)
  process.exit(1)
})