/**
* pgh-tree RPC handlers — daemon-mediated wrappers around the pgh
* package's content-addressed publish/install/registry primitives.
*
* Phase 1 of the pgh/pear-git-v3 convergence (see
* ../../pgh/NEXT-STEPS.md). When a pear-git-v3 daemon is running on a
* machine, every tree publish/install/registry operation routes
* through these handlers instead of each user's CLI doing its own
* thing. Net effect: ONE store + ONE registry per machine, shared
* across all pgh CLI invocations on that host.
*
* These handlers deliberately do NOT re-implement pgh's logic — they
* import + call it directly. The daemon's process owns the same
* ~/.local/share/store/ + ~/.config/pgh/release-registry/ the
* standalone pgh would own. Auth: mutating handlers require a writer
* (trust map), reads are open. Mirror mode refuses mutations.
*
* Not yet: channel-promote / channel-unpin / registry subscription.
* Those land once pgh itself grows channel-aware commands.
*/
import { run as runPublish } from 'pgh/commands/publish'
import { run as runApp } from 'pgh/commands/app'
import { TreeStore } from 'pgh/store'
import { Client } from 'hyper-svc'
import { requireWriter } from '../auth/check.js'
import { sidecarSockPath } from '../sidecar-wait.js'
/**
* Mark each registry entry with whether THIS machine holds its tree.
*
* The ReleaseRegistry is append-only with no retention; the tree store is
* content-addressed and garbage-collected. They diverge by construction, so a
* registry row routinely names a tree that was swept long ago. Measured on
* tabertron 2026-08-29 for hyper-tps: 382 advertised tree hashes, 10 present,
* 372 absent -- every one of those 372 rendering an install command that
* resolves to nothing here.
*
* `heldLocally: false` means NOT HELD HERE, never "gone". The registry is
* replicated and another peer may well hold the tree; only the local answer is
* cheap enough to compute per page render. Callers must word it that way.
*
* Pure and defensive: input objects are not mutated, a missing hash is never
* claimed as held, and a probe that throws (permissions, absent store) degrades
* to not-held rather than asserting something it did not verify.
*
* @param {Array<object>} entries
* @param {(treeHash: string) => boolean} hasTree
* @returns {Array<object>} copies, each with heldLocally
*/
export function markHeldLocally (entries, hasTree) {
if (!Array.isArray(entries)) return []
return entries.map((e) => {
let held = false
if (e && e.treeHash) {
try { held = !!hasTree(e.treeHash) } catch { held = false }
}
return { ...e, heldLocally: held }
})
}
export function pghTreeHandlers (svc) {
const registry = () => svc.identityRegistry
return [
{
name: 'tree-publish',
fn: async (ctx, params = {}) => {
await requireWriter(ctx, registry())
const { appDir, acceptConflict, dryRun } = params
if (!appDir || typeof appDir !== 'string') {
throw new Error('tree-publish requires { appDir: string }')
}
const args = [appDir]
if (acceptConflict) args.push('--accept-conflict', acceptConflict)
if (dryRun) args.push('--dry-run')
return runPublish(null, args)
}
},
{
name: 'tree-install',
fn: async (ctx, params = {}) => {
await requireWriter(ctx, registry())
const { registryKeyHex, appName, version, installName } = params
if (!registryKeyHex || registryKeyHex.length !== 64) {
throw new Error('tree-install requires { registryKeyHex: 64-char hex }')
}
if (!appName) throw new Error('tree-install requires { appName }')
const url = 'pgh://' + registryKeyHex + '/' + appName + (version ? '@' + version : '')
const args = ['install', url]
if (installName) args.push('--name', installName)
return runApp(null, args)
}
},
{
// Ask the SIDECAR for the registry. Do NOT open the store here.
//
// This used to `new Corestore(~/.config/pgh/release-registry)` directly.
// pgh-sidecar owns that store and holds its RocksDB lock for its whole
// lifetime, so a second opener always failed with "File descriptor could
// not be locked" — verified against the live daemon 2026-08-07. The
// handler could therefore never succeed in the deployed configuration,
// which is why nothing was ever built on it.
//
// Uses `releases-summary`, not `releases-list`: every registry entry
// embeds its full dep closure (~120 KB for one pear-git-v3 entry), so
// listing via the fat RPC does not return. Requires pgh-sidecar >= 0.2.51.
name: 'tree-registry-list',
fn: async (ctx, params = {}) => {
// Opted into ANONYMOUS_METHODS so the SSR Releases tab can render for a
// logged-out visitor. The registry itself is public by construction — a
// replicated Autobase whose key ships in every published package.json as
// `pgh.upgrade` — so gating reads adds friction, not secrecy.
//
// BUT an unfiltered call would enumerate every app on this machine,
// including ones whose repo is private, bypassing the repo read gate. So
// an anonymous caller must name a repo, and that name goes through
// repoAccess exactly like the release-download route (router.js:513).
// A trusted caller (UDS / writer) keeps the unfiltered listing.
const anon = !ctx || !ctx.identity
if (anon) {
if (!params.name) {
return { registryKey: null, count: 0, entries: [], unavailable: 'name required for anonymous callers' }
}
if (svc.repoAccess) {
let d = null
try {
d = await svc.repoAccess.async(params.name, { transport: 'http', identity: null })
} catch { d = null }
if (d && d.wouldDeny && d.enforced) {
return { registryKey: null, count: 0, entries: [] }
}
}
}
const sock = sidecarSockPath()
let c = null
try {
c = await Client.connectUds(sock)
const info = await c.call('registry-info', {})
const entries = await c.call('releases-summary',
params.name ? { name: params.name } : {})
// Annotate with local resolvability. This reads the TREE store, not
// the sidecar's registry corestore -- the lock the comment above warns
// about is RocksDB on ~/.config/pgh/release-registry. The tree store
// is a plain hardlink directory, so hasTree() is an existsSync plus a
// small meta read: no lock, no init, no side effects.
let probe = () => false
try {
const ts = new TreeStore()
probe = (h) => ts.hasTree(h)
} catch { /* no store on this machine -- everything reads as not held */ }
return {
registryKey: info && info.keyHex ? info.keyHex : null,
count: entries.length,
entries: markHeldLocally(entries, probe)
}
} catch (err) {
// A forge whose sidecar is down should render an empty, explained
// section rather than 500 the page it is embedded in.
return {
registryKey: null,
count: 0,
entries: [],
unavailable: err.message || String(err)
}
} finally {
if (c) { try { await c.close() } catch {} }
}
}
},
{
name: 'tree-store-stats',
fn: async () => {
const ts = new TreeStore()
ts.init()
return ts.stats()
}
},
{
name: 'tree-seed-status',
fn: async () => {
// Reports whether the daemon's DeployServer is active + serving
// tree content. The seed is wired at process startup; this is
// pure status reporting.
const deploy = svc._deploy
if (!deploy) return { active: false, reason: 'no DeployServer running' }
return {
active: true,
topic: deploy.topicHex,
connections: deploy.connections,
fileCount: deploy.manifest ? deploy.manifest.files.length : 0,
totalSize: deploy.manifest ? deploy.manifest.totalSize : 0
}
}
}
]
}