OverviewFilesCommitsIssuesDocsReleasesPeers

pgh-legacy / bin / pgh.js

270 lines · javascript ref:
#!/usr/bin/env bare

/**
 * pgh — CLI for pear-git
 *
 * Talks to pear-git's JSON API over HTTP.
 * Config: ~/.config/pgh/config.json { server, token }
 */

import { PghClient } from '../lib/client.js'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'

const argv = typeof Bare !== 'undefined' ? Bare.argv : process.argv
const args = argv.slice(2)
const cmd = args[0]
const sub = args[1]

// Load config
const configDir = path.join(os.homedir(), '.config', 'pgh')
const configPath = path.join(configDir, 'config.json')
let config = { server: 'http://localhost:5005' }
try { config = { ...config, ...JSON.parse(fs.readFileSync(configPath, 'utf-8')) } } catch {}

// Server override from flag
const serverFlag = args.indexOf('--server')
if (serverFlag >= 0) config.server = args[serverFlag + 1]

// Token from env takes priority
const envToken = typeof Bare !== 'undefined' ? Bare.env?.PGH_TOKEN : process.env.PGH_TOKEN
if (envToken) config.token = envToken

const client = new PghClient(config)

async function run () {
  switch (cmd) {
    case 'status':
      return log(await client.status())

    case 'repos':
      return logList(await client.repos(), r => `${r.name}  ${r.lastCommit ? ago(r.lastCommit) : ''}`)

    case 'repo':
      if (!sub) return usage('pgh repo <name>')
      return log(await client.repo(sub))

    case 'issues': {
      if (!sub) return usage('pgh issues <repo>')
      const issues = await client.issues(sub)
      return logList(issues, i => `#${i.id} [${i.status}] ${i.title}`)
    }

    case 'issue': {
      if (sub === 'create') {
        const repo = args[2]; const title = args[3]
        if (!repo || !title) return usage('pgh issue create <repo> <title> [body]')
        return log(await client.createIssue(repo, { title, body: args[4] || '', author: 'pgh' }))
      }
      if (sub === 'close') {
        const repo = args[2]; const id = args[3]
        if (!repo || !id) return usage('pgh issue close <repo> <id>')
        return log(await client.updateIssue(repo, id, { status: 'closed' }))
      }
      if (sub === 'reopen') {
        const repo = args[2]; const id = args[3]
        if (!repo || !id) return usage('pgh issue reopen <repo> <id>')
        return log(await client.updateIssue(repo, id, { status: 'open' }))
      }
      if (sub === 'comment') {
        const repo = args[2]; const id = args[3]; const text = args[4]
        if (!repo || !id || !text) return usage('pgh issue comment <repo> <id> <text>')
        return log(await client.addComment(repo, id, { body: text, author: 'pgh' }))
      }
      if (!sub) return usage('pgh issue <repo> <id>')
      const id = args[2]
      if (!id) return usage('pgh issue <repo> <id>')
      return log(await client.issue(sub, id))
    }

    case 'search': {
      if (!sub) return usage('pgh search <query>')
      const result = await client.search(sub)
      console.log(`${result.total} results for "${result.query}"`)
      for (const r of result.results) {
        console.log(`  ${r.repo}/${r.path}:${r.line}  ${r.content}`)
      }
      return
    }

    case 'deploy': {
      if (sub === 'status') return log(await client.deployStatus())
      if (sub === 'config') {
        const repo = args[2]
        if (!repo) return usage('pgh deploy config <repo>')
        return log(await client.deployConfig(repo))
      }
      if (sub === 'log') {
        const repo = args[2]
        if (!repo) return usage('pgh deploy log <repo>')
        const entries = await client.deployLog(repo)
        return logList(entries, e => `[${e.status}] ${e.commitHash?.slice(0, 8) || ''} ${e.timestamp ? ago(e.timestamp) : ''}`)
      }
      if (!sub) return usage('pgh deploy <repo>')
      console.log(`Deploying ${sub}...`)
      return log(await client.deploy(sub))
    }

    case 'orgs':
      return logList(await client.orgs(), o => `${o.name}  ${o.repos?.length || 0} repos`)

    case 'users':
      return logList(await client.users(), u => `${u.username || u.userId?.slice(0, 12)}  ${u.name || ''}`)

    case 'auth': {
      const { challengeId, challenge } = await client.challenge()
      console.log('Challenge:', challenge)
      console.log('Sign this challenge with your key and call:')
      console.log(`  pgh verify <signature>`)
      // TODO: auto-sign with local keypair
      return
    }

    case 'config':
      console.log('Server:', config.server)
      console.log('Config:', configPath)
      if (config.token) console.log('Token:', config.token.slice(0, 16) + '...')
      else console.log('Token: not set')
      return

    case 'config-set': {
      if (!sub) return usage('pgh config-set server <url>')
      const val = args[2]
      if (!val) return usage('pgh config-set <key> <value>')
      config[sub] = val
      try { fs.mkdirSync(configDir, { recursive: true }) } catch {}
      fs.writeFileSync(configPath, JSON.stringify(config, null, 2))
      console.log(`Set ${sub} = ${val}`)
      return
    }

    case 'app': {
      if (sub === 'install') {
        const repo = args[2]
        const nameFlag = args.indexOf('--name')
        const name = nameFlag >= 0 ? args[nameFlag + 1] : (args[3] || repo)
        if (!repo) return usage('pgh app install <repo> [--name name]')

        const installDir = path.join(os.homedir(), '.local', 'share', name)
        const binDir = path.join(os.homedir(), '.local', 'bin')
        const binPath = path.join(binDir, name)

        console.log(`Installing ${repo} → ${installDir}`)

        // Determine clone URL (punch:// key or server-based)
        const cloneUrl = repo.startsWith('punch://') ? repo : `${config.server}/${repo}.git`

        const { spawn: spawnChild } = await import('bare-subprocess')
        try {
          await execCmd(spawnChild, ['git', 'clone', '--depth', '1', '-b', 'release', cloneUrl, installDir])
        } catch (err) {
          // If dir exists, do a pull instead
          if (err.message.includes('already exists')) {
            console.log('Directory exists, pulling latest...')
            await execCmd(spawnChild, ['git', '-C', installDir, 'fetch', 'origin', 'release'])
            await execCmd(spawnChild, ['git', '-C', installDir, 'reset', '--hard', 'FETCH_HEAD'])
          } else {
            throw err
          }
        }

        // Create launcher script
        try { fs.mkdirSync(binDir, { recursive: true }) } catch {}
        fs.writeFileSync(binPath, `#!/bin/sh\nexec bare "${installDir}/index.js" "$@"\n`)
        fs.chmodSync(binPath, 0o755)

        console.log(`Installed. Run: ${name}`)
        return
      }

      if (sub === 'list') {
        const shareDir = path.join(os.homedir(), '.local', 'share')
        const binDir = path.join(os.homedir(), '.local', 'bin')
        try {
          const bins = fs.readdirSync(binDir)
          for (const b of bins) {
            try {
              const content = fs.readFileSync(path.join(binDir, b), 'utf-8')
              if (content.includes('.local/share/') && content.includes('bare')) {
                console.log(b)
              }
            } catch {}
          }
        } catch { console.log('No apps installed') }
        return
      }

      if (sub === 'remove') {
        const name = args[2]
        if (!name) return usage('pgh app remove <name>')
        const installDir = path.join(os.homedir(), '.local', 'share', name)
        const binPath = path.join(os.homedir(), '.local', 'bin', name)
        try { fs.rmSync(binPath) } catch {}
        try { fs.rmSync(installDir, { recursive: true }) } catch {}
        console.log(`Removed ${name}`)
        return
      }

      return usage('pgh app install|list|remove')
    }

    default:
      console.log(`pgh — CLI for pear-git

Commands:
  pgh status                    Server status
  pgh repos                     List repositories
  pgh repo <name>               Repo details
  pgh issues <repo>             List issues
  pgh issue <repo> <id>         Issue details
  pgh issue create <repo> <title> [body]  Create issue
  pgh issue close <repo> <id>   Close issue
  pgh issue reopen <repo> <id>  Reopen issue
  pgh issue comment <repo> <id> <text>  Add comment
  pgh search <query>            Search code across repos
  pgh deploy <repo>             Trigger deploy
  pgh deploy status             Deploy status
  pgh deploy config <repo>      Deploy config
  pgh deploy log <repo>         Deploy history
  pgh orgs                      List organizations
  pgh users                     List users
  pgh auth                      Start auth flow
  pgh config                    Show config
  pgh config-set <key> <value>  Set config
  pgh app install <repo> [--name n] Install app from release branch
  pgh app list                     List installed apps
  pgh app remove <name>            Uninstall app

Options:
  --server <url>    Override server URL

Environment:
  PGH_TOKEN         Auth token (overrides config)
`)
  }
}

function execCmd (spawnChild, [cmd, ...cmdArgs]) {
  return new Promise((resolve, reject) => {
    const child = spawnChild(cmd, cmdArgs, { stdio: ['ignore', 'pipe', 'pipe'] })
    const out = []; const err = []
    child.stdout.on('data', d => out.push(d))
    child.stderr.on('data', d => err.push(d))
    child.on('exit', code => code === 0 ? resolve(Buffer.concat(out).toString()) : reject(new Error(Buffer.concat(err).toString() || `exit ${code}`)))
    child.on('error', reject)
  })
}

function log (data) { console.log(JSON.stringify(data, null, 2)) }
function logList (arr, fmt) { arr.forEach(item => console.log(fmt(item))) }
function usage (msg) { console.log('Usage:', msg) }
function ago (ts) {
  const d = Date.now() - new Date(ts).getTime()
  if (d < 3600000) return Math.floor(d / 60000) + 'm ago'
  if (d < 86400000) return Math.floor(d / 3600000) + 'h ago'
  return Math.floor(d / 86400000) + 'd ago'
}

run().catch(err => console.error('Error:', err.message))