OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / commands / publish.js

524 lines · javascript ref:
/** 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 }
}