OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / commands / releases.js

167 lines · javascript ref:
/** pgh releases — inspect the local ReleaseRegistry */

import process from '#node:process'
import fs from '#node:fs'
import path from '#node:path'
import { homedir } from '#node:os'
import Corestore from 'corestore'

import { Client } from 'hyper-svc'
import { isAbstract } from '../daemon-client.js'

export const usage = `pgh releases — inspect the local ReleaseRegistry

  pgh releases list [name]                 List releases (all or one app)
  pgh releases latest <name>               Latest release of an app
  pgh releases show <name> <version>       A specific release
  pgh releases key                         Print the registry core key
  pgh releases remove <name> [version]     Un-register an app (retire). Drops it
                                           from the local conflict index and the
                                           replicated release registry. No version
                                           = remove ALL versions of the app.`

import { ReleaseRegistry } from '../release-registry.js'
import { PublishedIndex } from '../published-index.js'

const DEFAULT_STORE = path.join(homedir(), '.config', 'pgh', 'release-registry')
const SIDECAR_SOCK = process.env.PGH_SIDECAR_SOCK || path.join(homedir(), '.pgh', 'svc.sock')

// Prefer pgh-sidecar (which owns the registry lock). Fall back to
// opening the corestore directly when the sidecar isn't running.
export async function withRegistry (fn) {
  if (isAbstract(SIDECAR_SOCK) || fs.existsSync(SIDECAR_SOCK)) {
    let client = null
    try {
      client = await Client.connectUds(SIDECAR_SOCK)
      return await fn(sidecarRegistryAdapter(client))
    } finally {
      try { if (client) await client.close() } catch {}
    }
  }

  fs.mkdirSync(DEFAULT_STORE, { recursive: true })
  const store = new Corestore(DEFAULT_STORE)
  const reg = new ReleaseRegistry(store)
  await reg.ready()
  try {
    return await fn(reg)
  } finally {
    await reg.close()
    await store.close()
  }
}

// Minimal adapter that mirrors the ReleaseRegistry methods the
// commands below call (keyHex, list, latest, get), implemented over
// the sidecar's UDS RPCs.
function sidecarRegistryAdapter (client) {
  return {
    get keyHex () { return this._keyHex },
    async _loadKey () {
      if (this._keyHex) return
      const info = await client.call('registry-info', {})
      this._keyHex = info.keyHex
    },
    async list (name) {
      return client.call('releases-list', name ? { name } : {})
    },
    async latest (name) {
      return client.call('releases-latest', { name })
    },
    async get (name, version) {
      return client.call('releases-get', { name, version })
    },
    async remove (name, version) {
      return client.call('remove-release', { name, version })
    },
    // Not a registry method: the sidecar runs release-retention gc itself,
    // because `list()` of every release overflows the UDS frame.
    async storeGc (opts) {
      return client.call('store-gc', opts)
    }
  }
}

export async function run (client, args) {
  const sub = args[0] || 'list'

  if (sub === 'help') {
    return usage
  }

  if (sub === 'key') {
    return withRegistry(async (reg) => {
      if (typeof reg._loadKey === 'function') await reg._loadKey()
      return { keyHex: reg.keyHex }
    })
  }

  if (sub === 'list' || sub === 'ls') {
    const nameFilter = args[1] || null
    return withRegistry(async (reg) => {
      const entries = await reg.list(nameFilter)
      return entries.map(e => ({
        name: e.name,
        version: e.version,
        treeHash: e.treeHash.slice(0, 12),
        publishedAt: e.publishedAt,
        signed: !!e.sig
      }))
    })
  }

  if (sub === 'latest') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh releases latest <name>')
    return withRegistry(async (reg) => {
      const entry = await reg.latest(name)
      if (!entry) throw new Error('no releases for ' + name)
      return entry
    })
  }

  if (sub === 'show') {
    const name = args[1]
    const version = args[2]
    if (!name || !version) throw new Error('usage: pgh releases show <name> <version>')
    return withRegistry(async (reg) => {
      const entry = await reg.get(name, version)
      if (!entry) throw new Error('no release ' + name + '@' + version)
      return entry
    })
  }

  if (sub === 'remove' || sub === 'retire') {
    const name = args[1]
    if (!name) throw new Error('usage: pgh releases remove <name> [version]')
    const version = args[2] || null
    const result = { name, version: version || 'all', publishedIndex: false, releaseRegistry: 0, registryNote: null }

    // 1. Local conflict index — what `pgh publish` compares against. Removing
    //    here is what actually drops the app out of conflict detection.
    const index = new PublishedIndex()
    result.publishedIndex = index.remove(name, version)

    // 2. Replicated release registry — un-advertise so it's no longer
    //    installable. Best-effort: needs the sidecar's remove-release RPC
    //    (or direct access when the sidecar is down). Degrade gracefully so
    //    the local removal above still stands.
    try {
      await withRegistry(async (reg) => {
        if (version) { await reg.remove(name, version); result.releaseRegistry = 1 }
        else {
          const entries = await reg.list(name)
          for (const e of entries) { await reg.remove(e.name, e.version); result.releaseRegistry++ }
        }
      })
    } catch (err) {
      result.registryNote = 'release-registry not updated (' + (err.message || err) +
        ') — local conflict index removal still applied. Update pgh-sidecar for full un-register.'
    }

    return result
  }

  throw new Error('unknown releases subcommand: ' + sub + '\n' + usage)
}