OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / cli / commands / app.js

952 lines · javascript ref:
/**
 * pgh app commands — install and manage Pear apps locally
 *
 * Subcommands: install, update, list, remove
 *
 * Apps are downloaded via smart Hyperdrive access (arch-aware, prebuild-filtered),
 * installed to ~/.local/share/<name>/, and a launcher script is created at
 * ~/.local/bin/<name> that runs `bare <entry>`. pear-runtime handles OTA updates.
 */

import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import subprocess from 'bare-subprocess'
import Corestore from 'corestore'
import Hyperdrive from 'hyperdrive'
import Hyperswarm from 'hyperswarm'
import Localdrive from 'localdrive'
import idEncoding from 'hypercore-id-encoding'
import { table, jsonOut, log, error } from '../output.js'

const HOME = os.homedir()
const SHARE_DIR = path.join(HOME, '.local', 'share')
const BIN_DIR = path.join(HOME, '.local', 'bin')

function detectHost () {
  return Bare.platform + '-' + Bare.arch // e.g. linux-x64, darwin-arm64
}

/**
 * Smart download from a Hyperdrive — arch-aware with prebuild filtering.
 *
 * 1. Decode pear:// key, open Hyperdrive via temp Corestore + Hyperswarm
 * 2. Read root /package.json for app name
 * 3. Check for by-arch/{host}/app/{name}/ layout
 * 4. Mirror with prefix + prebuild filter (only host platform prebuilds)
 * 5. Cleanup temp corestore storage
 *
 * Returns { pkg, byArch } where pkg is parsed root package.json.
 */
async function smartDownload (key, destDir, opts = {}) {
  const verbose = opts.verbose || false
  const host = detectHost()
  const appName = opts.name || null

  // Decode pear:// key
  const driveKey = idEncoding.decode(key)

  // Temp corestore for download
  const tmpStorage = path.join(os.tmpdir(), 'pgh-store-' + Date.now())
  const store = new Corestore(tmpStorage)
  const swarm = new Hyperswarm()
  swarm.on('connection', (conn) => store.replicate(conn))

  const drive = new Hyperdrive(store, driveKey)
  await drive.ready()

  if (verbose) log('Drive key: ' + idEncoding.encode(drive.key))
  if (verbose) log('Discovery key: ' + idEncoding.encode(drive.discoveryKey))

  // Join swarm as client (download only)
  const discovery = swarm.join(drive.discoveryKey, { client: true, server: false })
  await discovery.flushed()

  // Wait for drive content to be available
  if (drive.core.length === 0) {
    if (verbose) log('Waiting for drive metadata...')
    await drive.update({ wait: true })
  }

  if (drive.blobs) await drive.blobs.core.update({ wait: true })

  // Read root package.json
  let pkg = {}
  try {
    const buf = await drive.get('/package.json')
    if (buf) pkg = JSON.parse(buf.toString())
  } catch {
    // Will fail gracefully — name required via --name flag
  }

  const name = appName || pkg.name

  // Prebuild filter — skip foreign platform prebuilds
  const prebuildFilter = (key) => {
    const m = key.match(/\/prebuilds\/([^/]+)\//)
    if (!m) return true
    return m[1] === host
  }

  // Check for by-arch layout
  const archPrefix = '/by-arch/' + host + '/app/' + name
  let byArch = false
  let prefix = '/'

  if (name) {
    try {
      const entry = await drive.entry(archPrefix + '/package.json')
      if (entry) {
        byArch = true
        prefix = archPrefix
        if (verbose) log('Detected by-arch layout (' + host + ')')
      }
    } catch {}
  }

  if (verbose) log('Mirroring with prefix: ' + prefix)

  // Mirror to local destination
  const local = new Localdrive(destDir)
  const mirror = drive.mirror(local, { prefix, filter: prebuildFilter })

  for await (const diff of mirror) {
    if (verbose && diff.op === 'add') log('  + ' + diff.key)
  }

  // If by-arch, also copy root package.json (needed for upgrade key / version)
  if (byArch) {
    try {
      const rootPkg = await drive.get('/package.json')
      if (rootPkg) {
        fs.writeFileSync(path.join(destDir, 'package.json'), rootPkg)
      }
    } catch {}
  }

  // Cleanup
  await swarm.destroy()
  await drive.close()
  await store.close()
  cleanup(tmpStorage)

  return { pkg, byArch }
}

export async function appCommand (client, sub, args, flags) {
  switch (sub) {
    case 'install':
      return appInstall(args[0], flags)
    case 'update':
      return appUpdate(args[0], flags)
    case 'list':
      return appList(flags)
    case 'remove':
      return appRemove(args[0], flags)
    default:
      appHelp()
  }
}

async function appInstall (keyOrRepo, flags) {
  // Git-based install: --app REPO_NAME
  if (flags.app) {
    return appInstallGit(flags.app, flags)
  }

  // Legacy Hyperdrive install: pear://KEY
  if (!keyOrRepo || !keyOrRepo.startsWith('pear://')) {
    error('Usage: pgh app install <pear://key> [--name <name>]\n       pgh app install --app <repo> [--name <name>]')
  }

  return appInstallHyperdrive(keyOrRepo, flags)
}

/**
 * Git-based install: clone release branch from pear-git server.
 * Tries HTTP archive download first (fast, no git/P2P needed), falls back to git clone.
 */
async function appInstallGit (repoName, flags) {
  const name = flags.name || repoName
  const installDir = path.join(SHARE_DIR, name)

  // Smart install: detect existing install and auto-update
  if (existsSync(installDir) && existsSync(path.join(installDir, 'package.json')) && !flags.force) {
    let pkg = {}
    try { pkg = JSON.parse(fs.readFileSync(path.join(installDir, 'package.json'), 'utf-8')) } catch {}
    const oldVersion = pkg.version || 'unknown'

    if (existsSync(path.join(installDir, '.git'))) {
      // Git-cloned install — update from local bare repo (synced via federation)
      const localBareRepo = path.join(HOME, 'git-repos', repoName + '.git')
      const fetchSource = existsSync(localBareRepo) ? localBareRepo : 'origin'
      log(name + ' v' + oldVersion + ' already installed, updating from ' + (fetchSource === 'origin' ? 'P2P origin' : 'local bare repo') + '...')
      try {
        await execGitInDir(installDir, ['fetch', fetchSource, 'release', '--depth', '1'])
        await execGitInDir(installDir, ['reset', '--hard', 'FETCH_HEAD'])
      } catch (err) {
        error('Git update failed: ' + err.message)
      }
    } else {
      // Archive install — re-download archive over existing dir
      log(name + ' v' + oldVersion + ' already installed, updating via archive...')
      const httpServer = resolveHttpServer(flags)
      if (httpServer) {
        const archiveUrl = buildArchiveUrl(httpServer, repoName)
        try {
          await downloadAndExtract(archiveUrl, installDir, { token: flags.token || readTokenFromConfig() })
        } catch (err) {
          error('Archive update failed: ' + err.message)
        }
      } else {
        error('No HTTP server configured for archive update. Use --force to reinstall via git clone.')
      }
    }

    return finishInstall(repoName, name, installDir, flags, existsSync(path.join(installDir, '.git')) ? 'git' : 'archive')
  }

  // Fresh install: resolve server config
  let cloneUrl = null
  let httpServer = null

  try {
    const configPath = path.join(HOME, '.config', 'pear-git', 'pgh.json')
    const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
    if (flags.server || config.server) {
      httpServer = (flags.server || config.server).replace(/\/$/, '')
    }
    if (config.serverKey) {
      cloneUrl = `punch://${config.serverKey}/${repoName}`
    } else if (httpServer) {
      cloneUrl = `${httpServer}/${repoName}.git`
    }
  } catch {}

  // P2P git clone first (gives OTA updates via PearGitUpdater)
  if (cloneUrl) {
    // Connectivity check: ls-remote is lightweight (~1KB) and fast.
    // If the P2P transport can't reach the server, skip the expensive clone.
    let p2pReachable = false
    try {
      log('Checking P2P connectivity to ' + cloneUrl + '...')
      await execGitInDir(os.tmpdir(), ['ls-remote', '--exit-code', cloneUrl, 'refs/heads/release'], { timeout: 20000 })
      p2pReachable = true
      log('P2P connection OK')
    } catch (err) {
      log('P2P unreachable (' + err.message.split('\n')[0] + '), skipping clone')
    }

    if (p2pReachable) {
      if (flags.force) cleanup(installDir)

      const host = detectHost()
      log('Cloning ' + repoName + ' (release branch, partial clone) from ' + cloneUrl + '...')

      try {
        fs.mkdirSync(path.dirname(installDir), { recursive: true })

        // Partial clone: download tree metadata only (~10 MB), no blobs
        await execGitInDir(path.dirname(installDir), [
          'clone', '--filter=blob:none', '--sparse', '--depth', '1',
          '-b', 'release', cloneUrl, name
        ])

        // Sparse checkout: fetch only this platform's blobs
        const byArchPath = `by-arch/${host}/app/${repoName}`
        try {
          await execGitInDir(installDir, ['sparse-checkout', 'init', '--cone'])
          await execGitInDir(installDir, ['sparse-checkout', 'set', byArchPath])
          log('Sparse checkout: ' + byArchPath)
        } catch {
          // No by-arch layout — fetch all blobs (small repos like hyper-overlay)
          await execGitInDir(installDir, ['sparse-checkout', 'disable'])
          log('No by-arch layout, full checkout')
        }

        return finishInstall(repoName, name, installDir, flags, 'git')
      } catch (err) {
        log('P2P git clone failed (' + err.message + '), falling back to HTTP archive...')
        cleanup(installDir)
      }
    }
  }

  // Fall back to HTTP archive download
  if (httpServer && !flags.noArchive) {
    const archiveUrl = buildArchiveUrl(httpServer, repoName)
    try {
      log('Downloading ' + repoName + ' archive from ' + archiveUrl + '...')
      if (flags.force) cleanup(installDir)
      fs.mkdirSync(installDir, { recursive: true })
      await downloadAndExtract(archiveUrl, installDir, { token: flags.token || readTokenFromConfig() })
      return finishInstall(repoName, name, installDir, flags, 'archive')
    } catch (err) {
      log('Archive download failed: ' + err.message)
      cleanup(installDir)
    }
  }

  if (!cloneUrl && !httpServer) {
    error('No pear-git server configured. Set serverKey in ~/.config/pear-git/pgh.json or use --server URL')
  }

  error('Install failed: both P2P clone and HTTP archive failed for ' + repoName)
}

/**
 * Legacy Hyperdrive install: download from pear://KEY
 */
async function appInstallHyperdrive (key, flags) {
  log('Downloading app from ' + key + '...')

  // First pass: download to temp dir to read package.json for name
  const tmpDir = path.join(os.tmpdir(), 'pgh-install-' + Date.now())
  fs.mkdirSync(tmpDir, { recursive: true })

  let result
  try {
    result = await smartDownload(key, tmpDir, { verbose: flags.verbose, name: flags.name })
  } catch (err) {
    cleanup(tmpDir)
    error('Download failed: ' + err.message)
  }

  const name = flags.name || result.pkg.name
  if (!name) {
    cleanup(tmpDir)
    error('Could not determine app name. Use --name <name>')
  }

  const installDir = path.join(SHARE_DIR, name)

  // Clean previous install to remove stale files
  cleanup(installDir)

  // Move temp download to install dir
  fs.renameSync(tmpDir, installDir)

  // chmod executables in build/
  const buildDir = path.join(installDir, 'build')
  if (existsSync(buildDir)) {
    chmodExecutables(buildDir)
  }

  // Read package.json from install dir
  let pkg = result.pkg
  try {
    pkg = JSON.parse(fs.readFileSync(path.join(installDir, 'package.json'), 'utf-8'))
  } catch {}
  const resolvedEntry = pkg.main || 'index.js'

  // Create launcher script
  fs.mkdirSync(BIN_DIR, { recursive: true })
  const launcherPath = path.join(BIN_DIR, name)
  const launcherContent = `#!/bin/sh
exec bare ${path.join(installDir, resolvedEntry)} "$@"
`
  fs.writeFileSync(launcherPath, launcherContent)
  fs.chmodSync(launcherPath, 0o755)

  if (flags.json) {
    jsonOut({ name, key, entry: resolvedEntry, installDir, launcher: launcherPath, method: 'hyperdrive' })
    return
  }

  log('')
  log('Installed ' + name)
  log('  Location: ' + installDir)
  log('  Launcher: ' + launcherPath)
  log('  Entry:    ' + resolvedEntry)
  log('')
  log('Run: ' + name)
}

async function appUpdate (name, flags) {
  if (!name) error('Usage: pgh app update <name> [--verbose]')

  const installDir = path.join(SHARE_DIR, name)
  if (!existsSync(installDir)) {
    error('App not found: ' + name + '. Run: pgh app list')
  }

  // Check if this is a git-installed app
  const updaterConfigPath = path.join(installDir, '.pear-git-updater.json')
  const isGitInstall = existsSync(path.join(installDir, '.git')) || existsSync(updaterConfigPath)

  if (isGitInstall) {
    return appUpdateGit(name, installDir, flags)
  }

  return appUpdateHyperdrive(name, installDir, flags)
}

async function appUpdateGit (name, installDir, flags) {
  log('Updating ' + name + ' via git pull...')

  try {
    // Fetch and reset to latest release
    await execGitInDir(installDir, ['fetch', 'origin', 'release', '--depth', '1'])
    const oldHash = await execGitInDir(installDir, ['rev-parse', 'HEAD'])
    await execGitInDir(installDir, ['reset', '--hard', 'origin/release'])
    const newHash = await execGitInDir(installDir, ['rev-parse', 'HEAD'])

    // chmod executables in build/
    const buildDir = path.join(installDir, 'build')
    if (existsSync(buildDir)) {
      chmodExecutables(buildDir)
    }

    // Recreate launcher (entry point might have changed)
    let pkg = {}
    try {
      pkg = JSON.parse(fs.readFileSync(path.join(installDir, 'package.json'), 'utf-8'))
    } catch {}
    const entry = pkg.main || 'index.js'
    const launcherPath = path.join(BIN_DIR, name)
    const launcherContent = `#!/bin/sh
exec bare ${path.join(installDir, entry)} "$@"
`
    fs.writeFileSync(launcherPath, launcherContent)
    fs.chmodSync(launcherPath, 0o755)

    // Update config
    try {
      let config = {}
      try {
        config = JSON.parse(fs.readFileSync(path.join(installDir, '.pear-git-updater.json'), 'utf-8'))
      } catch {}
      config.lastCommitHash = newHash.trim()
      config.lastUpdated = Date.now()
      fs.writeFileSync(
        path.join(installDir, '.pear-git-updater.json'),
        JSON.stringify(config, null, 2) + '\n'
      )
    } catch {}

    if (flags.json) {
      jsonOut({ name, method: 'git', oldHash: oldHash.trim(), newHash: newHash.trim(), installDir })
      return
    }

    if (oldHash.trim() === newHash.trim()) {
      log('Already up to date: ' + name + ' (' + newHash.trim().slice(0, 8) + ')')
    } else {
      log('')
      log('Updated ' + name + ' (git)')
      log('  Commit: ' + oldHash.trim().slice(0, 8) + ' -> ' + newHash.trim().slice(0, 8))
      log('  Location: ' + installDir)
      log('')
    }
  } catch (err) {
    error('Git update failed: ' + err.message)
  }
}

async function appUpdateHyperdrive (name, installDir, flags) {
  // Read existing package.json to get upgrade key
  const pkgPath = path.join(installDir, 'package.json')
  let pkg = {}
  try {
    pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
  } catch {
    error('No package.json in ' + installDir)
  }

  const key = typeof pkg.upgrade === 'string' ? pkg.upgrade : (pkg.upgrade && pkg.upgrade.key)
  if (!key) {
    error('No upgrade key in package.json. Cannot update ' + name)
  }

  const oldVersion = pkg.version || 'unknown'

  log('Updating ' + name + ' from ' + key + '...')

  // Download to temp dir
  const tmpDir = path.join(os.tmpdir(), 'pgh-update-' + Date.now())
  fs.mkdirSync(tmpDir, { recursive: true })

  let result
  try {
    result = await smartDownload(key, tmpDir, { verbose: flags.verbose, name })
  } catch (err) {
    cleanup(tmpDir)
    error('Download failed: ' + err.message)
  }

  // Read new version
  let newPkg = {}
  try {
    newPkg = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf-8'))
  } catch {}
  const newVersion = newPkg.version || result.pkg.version || 'unknown'

  // Replace install dir
  cleanup(installDir)
  fs.renameSync(tmpDir, installDir)

  // chmod executables in build/
  const buildDir = path.join(installDir, 'build')
  if (existsSync(buildDir)) {
    chmodExecutables(buildDir)
  }

  // Re-read package.json from installDir
  try {
    newPkg = JSON.parse(fs.readFileSync(path.join(installDir, 'package.json'), 'utf-8'))
  } catch {}

  // Recreate launcher (entry point might have changed)
  const entry = newPkg.main || 'index.js'
  const launcherPath = path.join(BIN_DIR, name)
  const launcherContent = `#!/bin/sh
exec bare ${path.join(installDir, entry)} "$@"
`
  fs.writeFileSync(launcherPath, launcherContent)
  fs.chmodSync(launcherPath, 0o755)

  if (flags.json) {
    jsonOut({ name, key, method: 'hyperdrive', oldVersion, newVersion, installDir })
    return
  }

  log('')
  log('Updated ' + name)
  log('  Version: ' + oldVersion + ' -> ' + newVersion)
  log('  Location: ' + installDir)
  log('')
}

async function appList (flags) {
  if (!existsSync(SHARE_DIR)) {
    if (flags.json) { jsonOut([]); return }
    log('No apps installed.')
    return
  }

  const entries = fs.readdirSync(SHARE_DIR)
  const apps = []

  for (const name of entries) {
    const pkgPath = path.join(SHARE_DIR, name, 'package.json')
    if (!existsSync(pkgPath)) continue

    let pkg = {}
    try {
      pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
    } catch {
      continue
    }

    // Include apps installed via pgh (pear-runtime or git)
    const updaterConfigPath = path.join(SHARE_DIR, name, '.pear-git-updater.json')
    const isGitInstall = existsSync(updaterConfigPath)
    if (!pkg.pear && !pkg.upgrade && !isGitInstall) continue

    apps.push({
      name,
      version: pkg.version || '-',
      method: isGitInstall ? 'git' : 'hyperdrive',
      key: (pkg.pear && pkg.pear.name) || (pkg.upgrade && pkg.upgrade.key) || '-',
      entry: pkg.main || 'index.js'
    })
  }

  if (flags.json) {
    jsonOut(apps)
    return
  }

  if (apps.length === 0) {
    log('No apps installed.')
    log('Run: pgh app install <pear://key> --name <name>')
    return
  }

  table(apps, [
    { key: 'name', label: 'NAME', width: 25 },
    { key: 'version', label: 'VERSION', width: 15 },
    { key: 'method', label: 'METHOD', width: 12 },
    { key: 'key', label: 'KEY', width: 35 },
    { key: 'entry', label: 'ENTRY', width: 20 }
  ])
}

async function appRemove (name, flags) {
  if (!name) error('Usage: pgh app remove <name>')

  const installDir = path.join(SHARE_DIR, name)
  const launcherPath = path.join(BIN_DIR, name)

  if (!existsSync(installDir)) {
    error('App not found: ' + name)
  }

  cleanup(installDir)

  if (existsSync(launcherPath)) {
    fs.unlinkSync(launcherPath)
  }

  if (flags.json) {
    jsonOut({ removed: name })
    return
  }

  log('Removed ' + name)
}

// --- Archive Install Helpers ---

/**
 * Build the HTTP archive URL for a given repo on a pear-git server.
 * @param {string} serverUrl - The server base URL (e.g. 'http://localhost:5005/git')
 * @param {string} repoName - The repository name
 * @returns {string} Full archive URL
 */
export function buildArchiveUrl (serverUrl, repoName, opts = {}) {
  const base = serverUrl.replace(/\/$/, '')
  const arch = opts.arch || detectHost()
  return `${base}/tabertron/${repoName}/archive/release.tar.gz?arch=${arch}`
}

/**
 * Download a tar.gz archive via curl and extract it into destDir.
 * Pipes curl stdout directly into tar stdin.
 * @param {string} url - Archive URL
 * @param {string} destDir - Destination directory (must exist)
 * @param {object} [opts] - Options
 * @param {string} [opts.token] - Bearer token for Authorization header
 */
function downloadAndExtract (url, destDir, opts = {}) {
  return new Promise((resolve, reject) => {
    const curlArgs = ['-f', '--progress-bar', '--max-time', '600']
    if (opts.token) {
      curlArgs.push('-H', 'Authorization: Bearer ' + opts.token)
    }
    curlArgs.push(url)

    const curlProc = subprocess.spawn('curl', curlArgs, {
      stdio: ['ignore', 'pipe', 'pipe']
    })

    const tarProc = subprocess.spawn('tar', ['xz', '--strip-components=1', '-C', destDir], {
      stdio: ['pipe', 'pipe', 'pipe']
    })

    // bare-subprocess only accepts strings in stdio — pipe manually
    curlProc.stdout.pipe(tarProc.stdin)
    tarProc.stdin.on('error', (err) => {
      if (err.code !== 'EPIPE') console.error('[Archive] stdin error:', err.message)
    })

    const errChunks = []
    curlProc.stderr.on('data', (c) => errChunks.push(c))
    tarProc.stderr.on('data', (c) => errChunks.push(c))

    const timeout = setTimeout(() => {
      curlProc.kill('SIGTERM')
      tarProc.kill('SIGTERM')
      reject(new Error('Archive download timed out after 300s'))
    }, 300000)

    curlProc.on('error', (err) => {
      clearTimeout(timeout)
      tarProc.kill('SIGTERM')
      reject(err)
    })

    tarProc.on('exit', (code) => {
      clearTimeout(timeout)
      if (code === 0) {
        resolve()
      } else {
        const errMsg = Buffer.concat(errChunks).toString('utf-8').trim()
        reject(new Error(errMsg || 'Archive extraction failed with code ' + code))
      }
    })

    tarProc.on('error', (err) => {
      clearTimeout(timeout)
      reject(err)
    })
  })
}

/**
 * Read bearer token from pgh config file.
 * @returns {string|null}
 */
function readTokenFromConfig () {
  try {
    const configPath = path.join(HOME, '.config', 'pear-git', 'pgh.json')
    const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
    return config.token || null
  } catch { return null }
}

function resolveHttpServer (flags) {
  try {
    const configPath = path.join(HOME, '.config', 'pear-git', 'pgh.json')
    const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
    const server = flags.server || config.server
    return server ? server.replace(/\/$/, '') : null
  } catch { return flags.server ? flags.server.replace(/\/$/, '') : null }
}

/**
 * Shared post-install logic for both git and archive installs.
 * Handles: npm install, chmod, package.json read, updater config, launcher, output.
 */
async function finishInstall (repoName, name, installDir, flags, method) {
  // Install dependencies if no node_modules
  if (!existsSync(path.join(installDir, 'node_modules'))) {
    log('Installing dependencies...')
    try {
      await execInDir(installDir, 'npm', ['install', '--production'])
    } catch (err) {
      error('npm install failed: ' + err.message)
    }
  }

  // chmod executables in build/
  const buildDir = path.join(installDir, 'build')
  if (existsSync(buildDir)) {
    chmodExecutables(buildDir)
  }

  // Read package.json
  let pkg = {}
  try {
    pkg = JSON.parse(fs.readFileSync(path.join(installDir, 'package.json'), 'utf-8'))
  } catch {}
  // Resolve entry point — prefer by-arch layout if present
  const host = detectHost()
  const byArchEntry = path.join('by-arch', host, 'app', repoName, pkg.main || 'index.js')
  const rootEntry = pkg.main || 'index.js'
  const resolvedEntry = existsSync(path.join(installDir, byArchEntry)) ? byArchEntry : rootEntry

  // Store deploy info for self-update
  try {
    const updaterConfig = {
      repoName,
      installMethod: method,
      installedAt: Date.now(),
      serverUrl: flags.server || null
    }
    fs.writeFileSync(
      path.join(installDir, '.pear-git-updater.json'),
      JSON.stringify(updaterConfig, null, 2) + '\n'
    )
  } catch {}

  // Ensure bare repo exists in ~/git-repos/ for sync system
  // The sync system (Protomux manifest exchange) only tracks repos in ~/git-repos/.
  // Without this, the app won't receive updates via federation sync.
  const defaultReposDir = path.join(HOME, 'git-repos')
  const bareRepoPath = path.join(defaultReposDir, repoName + '.git')
  if (!existsSync(bareRepoPath)) {
    try {
      fs.mkdirSync(defaultReposDir, { recursive: true })
      await execGitInDir(defaultReposDir, ['init', '--bare', repoName + '.git'])
      // Push current release branch to the bare repo so sync has a baseline
      if (existsSync(path.join(installDir, '.git'))) {
        await execGitInDir(installDir, ['push', bareRepoPath, 'HEAD:refs/heads/release'])
      }
      log('  Created bare repo: ' + bareRepoPath)
    } catch (err) {
      // Non-fatal — sync will still work via direct P2P, just won't have local cache
      log('  Note: Could not create bare repo (' + err.message + ')')
    }
  }

  // Create launcher script
  fs.mkdirSync(BIN_DIR, { recursive: true })
  const launcherPath = path.join(BIN_DIR, name)
  const entryAbsolute = path.join(installDir, resolvedEntry)
  const entryDir = path.dirname(entryAbsolute)
  const entryFile = path.basename(entryAbsolute)
  const launcherContent = `#!/bin/sh\ncd ${entryDir}\nexec bare ${entryFile} "$@"\n`
  fs.writeFileSync(launcherPath, launcherContent)
  fs.chmodSync(launcherPath, 0o755)

  // Create helper launchers for CLI tools (pgh, git-remote-punch, etc.)
  // Points to the installed copy so they stay current with updates.
  const binDir = path.join(installDir, 'bin')
  if (existsSync(binDir)) {
    try {
      const bins = fs.readdirSync(binDir)
      for (const bin of bins) {
        if (bin === name) continue
        const binPath = path.join(BIN_DIR, bin)
        const content = `#!/bin/sh\nexec bare ${path.join(binDir, bin)} "$@"\n`
        fs.writeFileSync(binPath, content)
        fs.chmodSync(binPath, 0o755)
      }
    } catch {}
  }

  if (flags.json) {
    jsonOut({ name, repoName, entry: resolvedEntry, installDir, launcher: launcherPath, method })
    return
  }

  log('')
  log('Installed ' + name + ' (' + method + ')')
  log('  Location: ' + installDir)
  log('  Launcher: ' + launcherPath)
  log('  Entry:    ' + resolvedEntry)
  log('')
  log('Run: ' + name)
}

// --- Helpers ---

function execGitInDir (dir, args) {
  return new Promise((resolve, reject) => {
    const proc = subprocess.spawn('git', args, {
      cwd: dir,
      stdio: ['ignore', 'pipe', 'pipe']
    })

    const chunks = []
    const errChunks = []

    const timeout = setTimeout(() => {
      proc.kill('SIGTERM')
      reject(new Error('Git command timed out after 120s'))
    }, 120000)

    proc.stdout.on('data', (c) => chunks.push(c))
    proc.stderr.on('data', (c) => errChunks.push(c))

    proc.on('exit', (code) => {
      clearTimeout(timeout)
      if (code === 0) {
        resolve(Buffer.concat(chunks).toString('utf-8'))
      } else {
        reject(new Error(Buffer.concat(errChunks).toString('utf-8') || `git exited with code ${code}`))
      }
    })
    proc.on('error', (err) => {
      clearTimeout(timeout)
      reject(err)
    })
  })
}

function execInDir (dir, cmd, args) {
  return new Promise((resolve, reject) => {
    const proc = subprocess.spawn(cmd, args, {
      cwd: dir,
      stdio: ['ignore', 'pipe', 'pipe']
    })

    const chunks = []
    const errChunks = []

    const timeout = setTimeout(() => {
      proc.kill('SIGTERM')
      reject(new Error(`${cmd} timed out after 300s`))
    }, 300000)

    proc.stdout.on('data', (c) => chunks.push(c))
    proc.stderr.on('data', (c) => errChunks.push(c))

    proc.on('exit', (code) => {
      clearTimeout(timeout)
      if (code === 0) {
        resolve(Buffer.concat(chunks).toString('utf-8'))
      } else {
        reject(new Error(Buffer.concat(errChunks).toString('utf-8') || `${cmd} exited with code ${code}`))
      }
    })
    proc.on('error', (err) => {
      clearTimeout(timeout)
      reject(err)
    })
  })
}

function existsSync (p) {
  try {
    fs.statSync(p)
    return true
  } catch {
    return false
  }
}

function chmodExecutables (dir) {
  const entries = fs.readdirSync(dir)
  for (const entry of entries) {
    const fullPath = path.join(dir, entry)
    const stat = fs.statSync(fullPath)
    if (stat.isDirectory()) {
      chmodExecutables(fullPath)
    } else {
      const ext = path.extname(entry)
      if (!ext || ext === '.so' || ext === '.dylib' || ext === '.node') {
        fs.chmodSync(fullPath, 0o755)
      }
    }
  }
}

function cleanup (dir) {
  try {
    fs.rmSync(dir, { recursive: true })
  } catch {
    // Best effort cleanup
  }
}

export function appHelp () {
  log(`Usage: pgh app <subcommand> [args] [flags]

Subcommands:
  install                Install an app (git or Hyperdrive)
  update <name>          Update an installed app to latest version
  list                   List installed apps
  remove <name>          Uninstall an app

Install Methods:
  pgh app install --app <repo> [--name <name>]   Git clone (release branch)
  pgh app install <pear://key> [--name <name>]   Legacy Hyperdrive download

Install Flags:
  --app REPO             Install by repo name (archive or git clone)
  --name NAME            App name (default: repo name or from package.json)
  --server URL           Pear-git server URL (default: from pgh.json)
  --force                Force reinstall (remove existing)
  --no-archive           Skip HTTP archive download, go straight to git clone
  --token TOKEN          Bearer token for archive download auth
  --verbose              Show download progress

Other Flags:
  --json                 Output as JSON

Git Install Flow:
  1. Try HTTP archive download (curl|tar, fast, no git needed)
  2. Fall back to git clone --depth 1 -b release if archive unavailable
  3. Smart update: existing install → git pull (no full reclone)
  4. npm install if no node_modules
  5. chmod executables in build/
  6. Create launcher at ~/.local/bin/<name>
  7. Store deploy info in .pear-git-updater.json

Hyperdrive Install Flow (legacy):
  1. Connect to Hyperdrive via Hyperswarm (P2P)
  2. Detect by-arch/ layout → download only this platform's files
  3. Filter out foreign platform prebuilds (saves ~660MB)
  4. chmod executables in build/
  5. Create launcher at ~/.local/bin/<name>

Examples:
  pgh app install --app hyper-note --name hypernote
  pgh app install pear://6d5asjamx... --name godot-keet
  pgh app update hypernote
  pgh app list
  pgh app remove hypernote`)
}