/** pgh publish — produce a lockfile, check conflicts, append to ReleaseRegistry */
import process from '#node:process'
import fs from '#node:fs'
import path from '#node:path'
import { homedir, tmpdir } from '#node:os'
import Corestore from 'corestore'
import { Client } from 'hyper-svc'
import { isAbstract } from '../daemon-client.js'
import { computeLockfile, writeLockfile, buildPublishIgnore } from '../publish.js'
import { PublishedIndex } from '../published-index.js'
import { detectConflicts, formatConflictsDetailed } from '../conflict.js'
import { classifyConflicts, summarize, bumpRecommendations } from '../conflict-detail.js'
import { loadPolicy } from '../conflict-policy.js'
import { validatePghSection, summarizePghSection, formatValidation } from '../schema.js'
import { ReleaseRegistry, signEntry } from '../release-registry.js'
import { loadOrCreateIdentity } from '../identity.js'
import { TreeStore } from '../store.js'
import { installTree } from '../tree-install.js'
import { localFetcher } from '../local-fetcher.js'
import { getStrategy } from '../walk-strategy.js'
import { walkTree } from 'hyper-overlay/tree'
import { fileURLToPath } from '#node:url'
import { execSync } from '../_compat-exec.js'
const RELEASE_REGISTRY_STORE = path.join(homedir(), '.config', 'pgh', 'release-registry')
const HYPERIDENT_HOME = path.join(homedir(), '.hyperident')
const SIDECAR_SOCK = process.env.PGH_SIDECAR_SOCK || path.join(homedir(), '.pgh', 'svc.sock')
export const usage = `pgh publish — compute lockfile, check conflicts, record a release
pgh publish [dir] [--accept-conflict <reason>] [--dry-run]
Publishes the project at [dir] (default: cwd): hashes its tree + dep
closure into the store and appends a release to the local ReleaseRegistry.
--accept-conflict <reason> overrides a blocked publish (records the reason);
--dry-run reports what would happen without writing.`
export async function run (client, args) {
// Parse flags first, marking the indices they consume, so we can
// exclude their values from the positional args. The previous
// implementation only filtered tokens starting with '--' and left
// value tokens (like the reason string after --accept-conflict)
// sitting in the positional slot, which then got resolved as the
// app dir and produced ENOENT.
const consumed = new Set()
let acceptReason = null
const acceptIdx = args.indexOf('--accept-conflict')
if (acceptIdx !== -1) {
consumed.add(acceptIdx)
if (args[acceptIdx + 1] !== undefined) {
acceptReason = args[acceptIdx + 1]
consumed.add(acceptIdx + 1)
}
}
const dryRun = args.includes('--dry-run')
const noBootstrap = args.includes('--no-bootstrap')
const allowLibrary = args.includes('--allow-library')
const positional = args.filter((a, i) => !consumed.has(i) && !a.startsWith('--'))
const appDir = path.resolve(positional[0] || '.')
// Validate pgh section of package.json before we go further.
const pkgPath = path.join(appDir, 'package.json')
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
const validation = validatePghSection(pkg.pgh)
if (!validation.valid) {
// Throw instead of process.exit so this is safe to call from an
// in-process daemon handler. The CLI entry wraps and exits itself.
const msg = formatValidation(validation, appDir + '/package.json')
throw Object.assign(new Error('pgh section validation failed:\n' + msg), { code: 'EVALIDATION', validation })
}
if (validation.warnings.length > 0) {
process.stderr.write(formatValidation(validation, appDir + '/package.json'))
}
if (pkg.pgh) {
console.log('pgh section:')
for (const line of summarizePghSection(pkg.pgh).split('\n')) {
console.log(' ' + line)
}
}
// Library guard: an app needs a launcher — either a `bin` or a `pgh`
// section (pgh.bin / pgh.entrypoint / runtime). A package with neither
// is a library, not an installable app; publishing it registers a
// phantom "app" in the index that then shows up as a cross-app conflict
// for every consumer (this is exactly how the stray hyper-overlay@0.2.2
// app entry got created). Refuse unless explicitly overridden.
if (!pkg.bin && !pkg.pgh && !allowLibrary) {
throw Object.assign(
new Error('"' + (pkg.name || appDir) + '" looks like a LIBRARY, not an app ' +
'(no "bin" and no "pgh" section in package.json). Publishing it would register ' +
'a phantom app in the index. If you really mean to publish it as an installable ' +
'app, add a pgh section or pass --allow-library.'),
{ code: 'ELIBRARY' }
)
}
}
// When the app opts in via pgh.bootstrap, publish the SELF-CONTAINED
// bootstrap-bare stage (bundled runtime/bare + sh-wrapper + bin/<name>.bare.js
// + pruned node_modules) AS the registry tree — built from the SAME stage as
// the curl-install tarball so the two artifacts can never diverge. Otherwise
// publish the raw source dir. Skipped on --dry-run.
const { stageDir } = dryRun
? { stageDir: null }
: stageBootstrapForPublish({ appDir, noBootstrap })
const publishDir = stageDir || appDir
const cleanupStage = () => { if (stageDir) { try { fs.rmSync(stageDir, { recursive: true, force: true }) } catch {} } }
console.log('Computing lockfile for ' + publishDir + '...')
const lockfile = await computeLockfile(publishDir)
console.log(' ' + lockfile.name + '@' + lockfile.version + ' root ' + lockfile.rootTreeHash.slice(0, 12))
console.log(' ' + lockfile.deps.length + ' top-level deps')
const index = new PublishedIndex()
const policy = loadPolicy()
const myCategory = policy.categoryOf(lockfile.name, lockfile)
const conflicts = detectConflicts(lockfile, index, { categoryOf: policy.categoryOf })
if (conflicts.length || myCategory !== 'production') {
console.log('Conflict category: ' + myCategory + ' (only comparing against ' + myCategory + ' apps)')
}
let summary = null
if (conflicts.length > 0) {
// Classify each BUILD_SKEW by WHAT differs (code vs docs vs packaging
// noise) so the list is triageable. Rival trees live in the local
// tree-store; resolve by hash. Best-effort: a tree that isn't local
// is reported 'unknown' rather than guessed.
const detailStore = new TreeStore()
const resolveTree = (h) => (h && detailStore.hasTree(h)) ? detailStore.treePath(h) : null
classifyConflicts(conflicts, resolveTree)
summary = summarize(conflicts)
// Print the FULL classified list to stdout. (The old code wrote
// formatConflicts to stderr right before throwing — bare flushes
// stderr async, so the throw/exit truncated the list and you only
// ever saw the final "blocked by N" line.)
console.log(formatConflictsDetailed(conflicts, lockfile.name, summary))
// B — self-skew detector: name the workspace packages whose runtime
// content drifted under an unchanged version (these survive the normalized
// comparison, so they're REAL). The pre-commit guard normally prevents
// this; this is the catch-at-publish backstop with an exact fix.
const bumps = bumpRecommendations(conflicts, lockfile)
if (bumps.length) {
console.log('↑ BUMP NEEDED — workspace package(s) changed content under an unchanged version:')
for (const b of bumps) {
console.log(' ' + b.pkg + '@' + b.version + (b.sourcePath ? ' (' + b.sourcePath + ')' : ''))
}
console.log(' Bump each (npm version patch --no-git-tag-version) so consumers stop skewing,')
console.log(' then republish. (The pre-commit version-guard prevents this going forward.)')
console.log('')
}
if (!acceptReason) {
cleanupStage()
// Throw — safe for in-process daemon use. CLI catches + exits.
throw Object.assign(
new Error('publish blocked by ' + conflicts.length + ' conflict(s) (' +
summary.codeDiffers + ' code-differs, ' + summary.unknown + ' unclassified). ' +
'Review the list above, then pass --accept-conflict <reason> to override.'),
{ code: 'ECONFLICT', conflicts }
)
}
lockfile.acceptedConflicts = conflicts.map(c => ({
type: c.type,
dep: c.dep,
newVersion: c.newVersion,
newTree: c.newTree,
existing: c.existing,
verdict: c.detail ? c.detail.verdict : undefined,
reason: acceptReason,
acceptedAt: new Date().toISOString()
}))
console.log('')
console.log('--accept-conflict used. Recording ' + conflicts.length + ' override(s) in lockfile with reason:')
console.log(' "' + acceptReason + '"')
if (summary.codeDiffers > 0) {
console.log(' ⚠ NOTE: ' + summary.codeDiffers + ' of these are code-differs — overriding a possible real build skew.')
}
}
if (dryRun) {
return {
dryRun: true,
lockfile: {
name: lockfile.name,
version: lockfile.version,
rootTreeHash: lockfile.rootTreeHash,
depCount: lockfile.deps.length,
conflicts: conflicts.length,
summary,
details: conflicts.map(c => ({
type: c.type,
dep: c.dep,
newVersion: c.newVersion,
verdict: c.detail ? c.detail.verdict : undefined,
existing: c.existing.map(e => ({ appName: e.appName, version: e.version })),
diffs: c.detail ? c.detail.perRival.map(r => ({ appName: r.appName, verdict: r.verdict, files: r.files })) : undefined
}))
}
}
}
const lockPath = writeLockfile(appDir, lockfile)
console.log('Wrote ' + lockPath)
index.add(lockfile)
console.log('Indexed in ' + index.path)
// Populate the publisher's local `dependencies/` store with every
// walked tree: the rootTree, each dep's base tree, AND each dep's
// per-arch prebuild trees. Without this step the published entry
// would point at hashes no one has on disk.
const strategy = getStrategy(lockfile.runtime || 'node')
// Build the list of source dirs the localFetcher will walk. For deps
// that have prebuilds, walk the base (with prebuilds/ excluded) AND
// each platform dir separately — the tree hashes already recorded in
// the lockfile correspond exactly to these walks.
//
// publishIgnore matcher applies ONLY to the rootTree walk (excluded
// paths under the app's own source). Dep trees come from npm packages
// and never see the app's publishIgnore.
let rootMatcher = null
try {
const pkgForMatcher = JSON.parse(fs.readFileSync(path.join(publishDir, 'package.json'), 'utf8'))
rootMatcher = buildPublishIgnore(pkgForMatcher)
} catch {}
const fetcherDirs = [{ rootPath: publishDir, skip: strategy.rootSkip, matcher: rootMatcher }]
for (const dep of lockfile.deps) {
if (!dep.tree) continue
// dep._sourcePath is the actual fs location where the dep was
// found — top-level for hoisted packages, nested for packages npm
// chose to keep inside an intermediate's node_modules/. Fall back
// to the old top-level assumption if _sourcePath is absent.
const depPath = dep._sourcePath || path.join(publishDir, strategy.depDir, dep.name)
if (dep.prebuilds) {
const baseSkip = new Set([...strategy.depSkip, 'prebuilds'])
fetcherDirs.push({ rootPath: depPath, skip: baseSkip })
for (const platform of Object.keys(dep.prebuilds)) {
fetcherDirs.push({
rootPath: path.join(depPath, 'prebuilds', platform),
skip: strategy.depSkip
})
}
} else {
fetcherDirs.push({ rootPath: depPath, skip: strategy.depSkip })
}
}
const fetcher = localFetcher(fetcherDirs)
const depStore = new TreeStore()
depStore.init()
let matRoot = 0
let matDeps = 0
let matPrebuilds = 0
const rootR = await installTree(depStore, lockfile.rootTreeHash, fetcher, {
source: 'pgh publish ' + lockfile.name + '@' + lockfile.version
})
matRoot = rootR.materialized
for (const dep of lockfile.deps) {
if (!dep.tree) continue
const r = await installTree(depStore, dep.tree, fetcher, {
source: 'pgh publish ' + lockfile.name + '@' + lockfile.version + '#' + dep.name
})
matDeps += r.materialized
if (dep.prebuilds) {
for (const [platform, hash] of Object.entries(dep.prebuilds)) {
const p = await installTree(depStore, hash, fetcher, {
source: 'pgh publish ' + lockfile.name + '@' + lockfile.version + '#' + dep.name + '/' + platform
})
matPrebuilds += p.materialized
}
}
}
console.log('Staged ' + matRoot + ' root / ' + matDeps + ' dep / ' + matPrebuilds + ' prebuild tree(s) into ' + depStore.dependenciesDir)
// Closure-completeness guard. A published release MUST have every tree in
// its closure (root + each dep's base tree + each per-arch prebuild) present
// AND round-tripping in the local store. Otherwise a peer fetches the entry,
// asks the deploy server for a tree it never staged (or staged under a
// different hash because a blob was corrupt), and hangs with "tree not found
// on any peer" — the 2026-06-04 failure mode.
//
// `installTree` skips on `hasTree` (dir + schema), which does NOT catch a
// dir that no longer round-trips (a corrupt/removed blob). So re-verify the
// whole closure here and force a re-materialize for anything missing or
// wrong. If a tree still can't be made valid (its source bytes are gone),
// ABORT loudly instead of registering a broken release.
const closureHashes = [lockfile.rootTreeHash]
for (const dep of lockfile.deps) {
if (dep.tree) closureHashes.push(dep.tree)
if (dep.prebuilds) for (const ph of Object.values(dep.prebuilds)) if (ph) closureHashes.push(ph)
}
const verifyTree = (h) => {
try { return walkTree(depStore.treePath(h)).get('').hash === h } catch { return false }
}
let restaged = 0
const stillBroken = []
for (const h of closureHashes) {
if (verifyTree(h)) continue
// Drop a present-but-corrupt dir + meta so materialize re-runs.
try { fs.rmSync(depStore.treePath(h), { recursive: true, force: true }) } catch {}
try { fs.unlinkSync(depStore.metaPath(h)) } catch {}
try {
const r = await installTree(depStore, h, fetcher, { source: 'pgh publish ' + lockfile.name + '@' + lockfile.version + ' (closure re-stage)' })
if (r && r.materialized) restaged++
} catch (err) {
stillBroken.push({ hash: h, reason: err.message })
continue
}
if (!verifyTree(h)) stillBroken.push({ hash: h, reason: 'still does not round-trip after re-stage' })
}
if (restaged) console.log('Re-staged ' + restaged + ' missing/corrupt closure tree(s)')
if (stillBroken.length) {
cleanupStage()
throw Object.assign(new Error(
'publish aborted — ' + stillBroken.length + ' closure tree(s) cannot be staged or do not round-trip: ' +
stillBroken.map(b => b.hash.slice(0, 12) + '… (' + b.reason + ')').join('; ') +
'. The local store lacks the correct source bytes for these trees (e.g. after `pgh store repair` removed a corrupt blob). ' +
'Ensure node_modules provides the exact content, or bump the affected dependency, then re-publish.'),
{ code: 'EINCOMPLETECLOSURE', broken: stillBroken })
}
// Build the entry. Publisher's hyperident device pubkey is the
// Hyperswarm peer key clients target when fetching content for
// this entry.
let registryKeyHex = null
let signed = false
let pgh = null
try {
const pkg = JSON.parse(fs.readFileSync(path.join(publishDir, 'package.json'), 'utf8'))
pgh = pkg.pgh || null
} catch {}
const identity = await loadOrCreateIdentity(HYPERIDENT_HOME)
let entry = {
name: lockfile.name,
version: lockfile.version,
runtime: lockfile.runtime || 'node',
treeHash: lockfile.rootTreeHash,
upgrade: pgh && pgh.upgrade ? pgh.upgrade : null,
serverKey: identity.publicKeyHex,
deps: lockfile.deps.map(d => {
const e = { name: d.name, version: d.version, tree: d.tree }
if (d.prebuilds) e.prebuilds = d.prebuilds
return e
}),
publishedAt: lockfile.publishedAt,
publisherDevicePubkey: identity.publicKeyHex,
// The attestation chain for publisherDevicePubkey, so a verifier can
// resolve this device back to the identity ROOT that owns the app name
// rather than trusting the device key on its own. null for raw/legacy
// identities, which have no chain to offer.
publisherChain: identity.chain || null,
acceptedConflicts: lockfile.acceptedConflicts || null
}
// Sign with the identity we already loaded. This used to be gated on
// PGH_SIGNING_KEY, a variable that appeared exactly once in the whole stack
// — this read — and was never set anywhere, so `sig` was null on every
// release ever published while the verifier sat uncalled (org #144).
//
// A raw/legacy identity with no chain can still sign; it simply cannot be
// resolved past its device key. An identity with no secret key at all
// publishes unsigned, as before.
if (identity.secretKey) {
entry = signEntry(entry, identity.secretKey)
signed = true
}
// Prefer pgh-sidecar over direct corestore: sidecar owns the
// registry lock, so talking to it over UDS avoids contention with
// `pgh seed` / another pgh process. Fall back to direct only when
// sidecar isn't running.
const sidecarAvailable = isAbstract(SIDECAR_SOCK) || fs.existsSync(SIDECAR_SOCK)
if (sidecarAvailable) {
try {
const client = await Client.connectUds(SIDECAR_SOCK)
try {
const info = await client.call('registry-info', {})
registryKeyHex = info.keyHex
await client.call('publish-release', { entry })
console.log('Appended to ReleaseRegistry via pgh-sidecar (' + registryKeyHex.slice(0, 12) + '...)' + (signed ? ' [signed]' : ' [unsigned]'))
} finally {
await client.close()
}
} catch (err) {
console.error('Warning: sidecar publish failed (' + err.message + ') — falling back to direct corestore append')
}
}
if (!registryKeyHex) {
// Direct corestore append fallback (sidecar unreachable / not running)
try {
fs.mkdirSync(RELEASE_REGISTRY_STORE, { recursive: true })
const store = new Corestore(RELEASE_REGISTRY_STORE)
const registry = new ReleaseRegistry(store)
await registry.ready()
registryKeyHex = registry.keyHex
await registry.add(entry)
console.log('Appended to ReleaseRegistry direct (' + registry.keyHex.slice(0, 12) + '...)' + (signed ? ' [signed]' : ' [unsigned]'))
await registry.close()
await store.close()
} catch (err) {
console.error('Warning: could not append to ReleaseRegistry: ' + err.message)
}
}
// #66 NOTE: /bootstrap distribution is NOT handled here. Publishing a
// pgh.bootstrap app simply registers its SELF-CONTAINED tree in the store
// (above) — that tree IS the bootstrap app, and the normal hyper-deploy/OTA
// install path already brings it to every forge. Each forge then TARS its
// local copy on demand (pear-git-v3 services/bootstrap-links.js) to serve
// /bootstrap. No separate artifact, no overlay, no `pgh bootstrap` step.
// The self-contained tree was already staged + published above, and the
// matching curl-install tarball deployed from the same stage. Clean up.
cleanupStage()
return {
published: {
name: lockfile.name,
version: lockfile.version,
rootTreeHash: lockfile.rootTreeHash,
depCount: lockfile.deps.length,
conflicts: conflicts.length,
overridden: !!acceptReason,
signed
},
lockfile: lockPath,
index: index.path,
registry: registryKeyHex ? { store: RELEASE_REGISTRY_STORE, keyHex: registryKeyHex } : null
}
}
// Build the SELF-CONTAINED bootstrap-bare stage for an app that opts in via
// `pgh.bootstrap`, and return the staged dir so publish registers it AS the
// registry tree. The SAME build also writes + deploys the curl-install tarball,
// so the tarball and the registry tree come from ONE stage and can never
// diverge (the root cause of the two-shapes-of-one-version bug). Returns null
// when not applicable (no pgh.bootstrap / --no-bootstrap / build script absent),
// in which case publish falls back to the raw source dir.
//
// Multi-arch: extra arches are staged under `by-arch/<label>/` and the
// install-side already prefers `by-arch/<host>/runtime/bare`
// (lib/runtime-factory.js) while skipping foreign subtrees when materialising
// (lib/store.js), so a remote host fetches only its own.
//
// Host-arch-only publishing meant an x64 publisher could ONLY produce
// x64-runnable installs: both the runtime binary AND the native prebuilds were
// pruned to the build host. An aarch64 device could fetch pgh-sidecar and end
// up with an x86-64 runtime/bare it could not execute — which is why celltron
// had to be bootstrapped from an arch-specific tarball instead of `pgh app
// install` like every other host.
//
// PGH_PUBLISH_EXTRA_ARCH overrides the default set. Requires
// bare-runtime-linux-<arch> vendored in the app's node_modules; the builder
// hard-errors rather than silently emitting a tree that only looks multi-arch.
function stageBootstrapForPublish ({ appDir, noBootstrap }) {
const none = { stageDir: null }
let pgh = null
try { pgh = JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')).pgh || null } catch { return none }
if (!pgh || !pgh.bootstrap) return none
if (noBootstrap) { console.log('Skipping bootstrap stage (--no-bootstrap) — publishing raw source.'); return none }
// Locate build-bootstrap-bare.js. Prefer the app's OWN scripts/ (every
// bootstrap app ships one, and it builds appDir whether via --root or its
// hardcoded parent). Fall back to the pgh runtime's copy (the shared
// --root-aware tool). CRITICAL: the deployed pgh bundle excludes scripts/,
// so the publish.js-relative path is absent when publishing via the INSTALLED
// CLI — that previously made publish silently emit RAW SOURCE for a bootstrap
// app (no runtime/bare, no tarball deploy), e.g. pgh-sidecar 0.2.27.
const appScript = path.join(appDir, 'scripts', 'build-bootstrap-bare.js')
const sharedScript = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts', 'build-bootstrap-bare.js')
const buildScript = fs.existsSync(appScript) ? appScript : sharedScript
if (!fs.existsSync(buildScript)) {
// Hard error — NEVER silently publish a bootstrap-flagged app as raw
// source (it produces a runtime-less registry tree + no curl tarball).
throw Object.assign(
new Error('pgh.bootstrap is set but no build-bootstrap-bare.js was found (looked in ' +
appScript + ' and ' + sharedScript + '). Refusing to publish a bootstrap app as raw source; ' +
'run from a source checkout that ships scripts/, or pass --no-bootstrap to override.'),
{ code: 'ENOBOOTSTRAPSCRIPT' }
)
}
const outDir = path.join(appDir, 'dist')
const stageOut = path.join(tmpdir(), 'pgh-publish-stage-' + path.basename(appDir))
const parts = ['node', JSON.stringify(buildScript), '--root', JSON.stringify(appDir),
'--out', JSON.stringify(outDir), '--stage-out', JSON.stringify(stageOut)]
// Only ask for arches whose runtime package is actually vendored — an
// unvendored arch is a hard error in the builder, and publish must not start
// failing for every app that has not vendored one.
const wanted = (process.env.PGH_PUBLISH_EXTRA_ARCH || 'arm64')
.split(',').map((a) => a.trim()).filter(Boolean)
const have = wanted.filter((a) =>
fs.existsSync(path.join(appDir, 'node_modules', 'bare-runtime-linux-' + a, 'bin', 'bare')))
if (have.length) {
parts.push('--extra-arch', JSON.stringify(have.join(',')))
console.log(' multi-arch: also staging ' + have.join(', '))
} else if (wanted.length) {
console.log(' multi-arch: skipped (' + wanted.join(', ') +
' runtime not vendored in node_modules — install will be host-arch only)')
}
console.log('pgh.bootstrap — staging self-contained tree:')
execSync(parts.join(' '), { stdio: 'inherit' })
if (!fs.existsSync(path.join(stageOut, 'package.json'))) {
throw new Error('bootstrap stage did not produce ' + path.join(stageOut, 'package.json'))
}
return { stageDir: stageOut }
}