OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / cli / client.js

158 lines · javascript ref:
/**
 * pgh HTTP client
 *
 * Wraps fetch() with auth headers and error handling.
 * On 401, falls back to challenge-response using device keypair.
 */

import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import { getServer, getToken } from './config.js'

const PEER_IDENTITY_FILE = path.join(os.homedir(), '.config', 'pear-git', 'peer-identity.json')

export class PghClient {
  constructor (flags = {}) {
    this.server = getServer(flags)
    this.token = getToken(flags)
    this._peerIdentity = null
  }

  _loadPeerIdentity () {
    if (this._peerIdentity !== null) return this._peerIdentity
    try {
      const data = JSON.parse(fs.readFileSync(PEER_IDENTITY_FILE, 'utf-8'))
      if (data.publicKey && data.secretKey) {
        this._peerIdentity = data
        return data
      }
    } catch {}
    this._peerIdentity = false
    return false
  }

  _headers (extra = {}) {
    const headers = { 'Content-Type': 'application/json', ...extra }
    if (this.token) {
      headers.Authorization = `Bearer ${this.token}`
    }
    return headers
  }

  async _challengeAuth (method, url, body) {
    const identity = this._loadPeerIdentity()
    if (!identity) return null

    // Get challenge from server
    let challengeData
    try {
      const cRes = await fetch(this.server + '/api/auth/challenge', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ context: 'cli-fallback' })
      })
      if (!cRes.ok) return null
      challengeData = await cRes.json()
    } catch {
      return null
    }

    // Sign challenge with device keypair
    let signature
    try {
      const { ANProto } = await import('../anproto.js')
      const keypair = ANProto.loadKeypair(identity.publicKey + identity.secretKey)
      signature = ANProto.sign(challengeData.challenge, keypair)
    } catch {
      return null
    }

    // Retry request with challenge-response headers
    const headers = {
      'Content-Type': 'application/json',
      'X-Wirecore-PublicKey': identity.publicKey,
      'X-Wirecore-Signature': signature,
      'X-Wirecore-Challenge': challengeData.challengeId
    }
    const opts = { method, headers }
    if (body) opts.body = JSON.stringify(body)

    return fetch(url, opts)
  }

  async _request (method, path, body = null, extra = {}) {
    const url = this.server + path
    const opts = { method, headers: this._headers(extra) }
    if (body) opts.body = JSON.stringify(body)

    let res
    try {
      res = await fetch(url, opts)
    } catch (err) {
      const isConnect = err.message.includes('ECONNREFUSED') || err.message.includes('connect')
      if (isConnect) {
        console.error(`Error: Could not connect to ${this.server}`)
        console.error(`  Is the server running? Check with: pgh serve status`)
        console.error(`  Or set server URL: pgh config set server http://<host>:<port>/git`)
      } else {
        console.error(`Error: Network request failed — ${err.message}`)
      }
      process.exit(1)
    }

    // On 401, try challenge-response with device keypair
    if (res.status === 401) {
      const retryRes = await this._challengeAuth(method, url, body)
      if (retryRes && retryRes.ok) return retryRes
    }

    if (!res.ok) {
      let msg = res.statusText
      try {
        const data = await res.json()
        if (data.error) msg = data.error
      } catch {}
      if (res.status === 401) {
        console.error(`Error: Authentication required`)
        console.error(`  Set a token: pgh config set token <your-token>`)
        console.error(`  Or create one: pgh token create`)
      } else if (res.status === 403) {
        console.error(`Error: Permission denied — ${msg}`)
      } else if (res.status === 404) {
        console.error(`Error: Not found — ${msg}`)
      } else {
        console.error(`Error (${res.status}): ${msg}`)
      }
      process.exit(1)
    }

    return res
  }

  async get (path, extra = {}) {
    const res = await this._request('GET', path, null, extra)
    return res.json()
  }

  async post (path, body, extra = {}) {
    const res = await this._request('POST', path, body, extra)
    return res.json()
  }

  async patch (path, body, extra = {}) {
    const res = await this._request('PATCH', path, body, extra)
    return res.json()
  }

  async del (path, extra = {}) {
    const res = await this._request('DELETE', path, null, extra)
    return res.json()
  }

  async stream (path) {
    const res = await this._request('GET', path)
    return res.body
  }
}