/**
* 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
}
}