OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / publish.js

359 lines · javascript ref:
/**
 * publish — resolve an app into a deterministic lockfile.
 *
 * P.10 Phase 0.5d. Walks `node_modules/` of an app dir, computes a
 * tree hash per top-level dep, and produces a lockfile shape suitable
 * for pgh's content-addressable install flow.
 *
 * Lockfile schema (.pgh-lock.json):
 * {
 *   name, version,                          — from package.json
 *   rootTreeHash,                           — whole-app tree hash
 *   publishedAt,                            — ISO timestamp
 *   deps: [
 *     { name, version, tree, dev?, ... }    — from node_modules/<name>/package.json
 *   ],
 *   acceptedConflicts?: [{ dep, reason }]   — override record if --accept-conflict
 * }
 */

import fs from '#node:fs'
import path from '#node:path'

import { walkTree } from 'hyper-overlay/tree'
import { normalizedTreeHash } from './conflict-detail.js'
import { createMatcher } from 'hyper-overlay/ignore'

import { detectRuntime, getStrategy } from './walk-strategy.js'

/**
 * Build a publishIgnore matcher from a package.json's pgh section.
 * Returns null if no patterns are configured (publishers without
 * publishIgnore see identical behavior to pre-NI-4).
 *
 * Patterns are gitignore-style (passed through to hyper-overlay/ignore's
 * matcher with noDenyList: true so we don't double-block stuff
 * rootSkip already handles).
 */
export function buildPublishIgnore (pkg) {
  const patterns = (pkg && pkg.pgh && Array.isArray(pkg.pgh.publishIgnore))
    ? pkg.pgh.publishIgnore
    : []
  if (patterns.length === 0) return null
  return createMatcher(patterns, { noDenyList: true })
}

/**
 * Compute a lockfile for an app directory.
 *
 * Publish invariant (the key one): no filesystem-local deps survive into
 * the root tree. We walk the app WITHOUT the runtime's dep-dir
 * (node_modules/ for Node, addons/ for Godot), so the root hash is a
 * pure function of the app's source. Each dep is walked independently
 * and recorded in lockfile.deps with its own tree hash. The installer
 * reconstructs the dep layout at install time from that list.
 *
 * This is what makes cross-app dedup work: two apps depending on the
 * same hyper-overlay@1.0.0 produce the same dep tree hash, and both
 * point at one directory in `dependencies/`.
 *
 * @param {string} appDir - absolute path to the app source
 * @param {object} [opts]
 * @param {Set<string>} [opts.skip] - additional names to skip
 * @param {string} [opts.runtime] - override runtime detection
 * @returns {Promise<object>} lockfile
 */
export async function computeLockfile (appDir, opts = {}) {
  const pkgPath = path.join(appDir, 'package.json')
  let pkg = null
  if (fs.existsSync(pkgPath)) {
    pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
  }

  const runtime = opts.runtime || detectRuntime(pkg, appDir, fs, path)
  const strategy = getStrategy(runtime)

  // Root tree = app source ONLY. Dep-dir excluded so its contents don't
  // leak into the hash (otherwise a single dep change would invalidate
  // the app's root hash and break dedup).
  const rootSkip = new Set([...strategy.rootSkip, ...(opts.skip || [])])
  const matcher = opts.matcher || buildPublishIgnore(pkg)
  const allTrees = walkTree(appDir, { skip: rootSkip, matcher })
  const rootTreeHash = allTrees.get('').hash

  // Derive app name/version — prefer package.json, fall back to dir name
  // (Godot projects may not have a package.json).
  const name = (pkg && pkg.name) || path.basename(appDir)
  const version = (pkg && pkg.version) || '0.0.0'
  // Conflict compartment. Apps self-declare via pgh.category; absent ⇒ left
  // undefined here and resolved at conflict-check time (policy override map,
  // then default 'production'). Recorded in the lockfile so future publishes
  // and the index carry it forward.
  const category = (pkg && pkg.pgh && typeof pkg.pgh.category === 'string') ? pkg.pgh.category : undefined

  // Walk the dep-dir RECURSIVELY — including nested node_modules under
  // each package — so the lockfile carries the COMPLETE transitive
  // closure regardless of how npm chose to hoist vs nest. Before this,
  // the walk was top-level only, which missed transitives that npm
  // nested instead of flattening (common with file: deps). Symptom:
  // runtime `Cannot find package '<pkg>'` errors for packages that
  // are declared by some intermediate dep but not hoisted to the app's
  // top-level node_modules.
  //
  // Dedup key: package name. Same name appearing in multiple nested
  // locations is normal (npm caches identical trees). We keep the
  // first occurrence seen (deterministic since we sort sibling entries).
  const depDir = path.join(appDir, strategy.depDir)
  const deps = []
  const seen = new Set()
  if (fs.existsSync(depDir)) collectDepsRecursive(depDir, deps, seen, strategy)

  // Guard: every declared file: (workspace) dependency MUST resolve into the
  // closure. collectDepsRecursive() silently skips broken symlinks (it has to,
  // to survive npm's hoisting leftovers), so a workspace dep whose file: target
  // is absent — e.g. `pgh publish` from an isolated CI git-archive checkout
  // where ../<sibling> doesn't exist — vanishes with no error and ships a tree
  // that crashes at import. This was the root cause of the 2026-07-15
  // pear-git-v3 0.1.147 broken publish (all 13 workspace deps dropped). Refuse
  // to publish a tree that would crash at import; fail loudly instead.
  if (pkg && pkg.dependencies) {
    const dropped = []
    for (const [depName, spec] of Object.entries(pkg.dependencies)) {
      if (typeof spec !== 'string' || !spec.startsWith('file:')) continue
      const entry = path.join(depDir, ...depName.split('/'))
      let why = null
      try {
        const st = fs.statSync(entry) // follows the symlink; throws if broken/missing
        if (!st.isDirectory()) why = 'not a directory'
        else if (!fs.existsSync(path.join(entry, 'package.json'))) why = 'no package.json'
      } catch {
        try {
          const lst = fs.lstatSync(entry)
          why = lst.isSymbolicLink()
            ? 'broken symlink -> ' + fs.readlinkSync(entry)
            : 'unreadable'
        } catch { why = 'missing from node_modules' }
      }
      if (why) dropped.push('  ' + depName + ' (' + spec + '): ' + why)
    }
    if (dropped.length) {
      throw new Error(
        'publish: ' + dropped.length + ' declared file: dependenc' +
        (dropped.length === 1 ? 'y' : 'ies') + ' did not resolve into the closure — ' +
        'refusing to publish a tree that would crash at import:\n' + dropped.join('\n') +
        '\n\nThis usually means publish ran from an isolated checkout (e.g. a CI ' +
        'git-archive) where the workspace siblings are absent. Vendor them into ' +
        'node_modules before publishing, or publish from a tree where the file: ' +
        'targets resolve.'
      )
    }
  }

  return {
    name,
    version,
    runtime,
    category,
    rootTreeHash,
    publishedAt: new Date().toISOString(),
    deps
  }
}

/**
 * Walk a node_modules/ directory, pushing every dep entry into `deps`
 * and recursing into each dep's own node_modules/ to capture nested
 * transitives. Dedups by package name via `seen`.
 *
 * The individual dep's tree hash still EXCLUDES its own node_modules/
 * (depSkip contains 'node_modules') — so nested deps don't leak into
 * the parent's tree. They get their own independent entries.
 */
function collectDepsRecursive (nmDir, deps, seen, strategy) {
  let entries
  try { entries = fs.readdirSync(nmDir).sort() } catch { return }

  for (const entry of entries) {
    if (entry.startsWith('.')) continue

    const entryPath = path.join(nmDir, entry)

    // Scoped packages: @scope/name lives one level deeper
    if (strategy.scopedPrefix && entry.startsWith(strategy.scopedPrefix)) {
      let subs
      try { subs = fs.readdirSync(entryPath).filter(n => !n.startsWith('.')).sort() }
      catch { continue }
      for (const sub of subs) {
        const name = entry + '/' + sub
        const depPath = path.join(entryPath, sub)
        // Dedup by name@version, NOT name. npm hoisting leaves multiple
        // versions of a package across nested node_modules (e.g. bare-net
        // 1.0.1 top-level + 2.3.1 under bare-tls). Recording only the
        // first-walked copy made the lockfile a lossy, walk-order-dependent
        // summary — two apps with identical trees published different
        // representatives and looked like they conflicted. Record every
        // distinct version so the closure (and conflict detection) is exact.
        const key = name + '@' + readDepVersion(depPath)
        if (seen.has(key)) continue
        seen.add(key)
        deps.push(depEntry(depPath, name, strategy.depSkip))
        // Recurse into this scoped dep's own node_modules
        const nested = path.join(depPath, strategy.depDir)
        if (fs.existsSync(nested)) collectDepsRecursive(nested, deps, seen, strategy)
      }
      continue
    }

    // Resolve real path; skip anything that's a broken symlink, a file,
    // or otherwise not a readable directory. Survives the messy states
    // npm leaves behind when file: deps + hoisting + nested installs
    // don't align (e.g. an 'up-reference' link to a hoisted parent-level
    // package that got removed).
    let stat
    try { stat = fs.statSync(entryPath) } catch { continue }
    if (!stat.isDirectory()) continue
    let pkgExists
    try { pkgExists = fs.existsSync(path.join(entryPath, 'package.json')) } catch { continue }
    if (!pkgExists) continue

    // Dedup by name@version (see scoped branch above) so every distinct
    // version is recorded, not just the first one walked.
    const key = entry + '@' + readDepVersion(entryPath)
    if (seen.has(key)) continue
    seen.add(key)
    try {
      deps.push(depEntry(entryPath, entry, strategy.depSkip))
    } catch {
      // depEntry already returns { error } on failure; it throws only
      // for genuinely unreadable dirs. Log-and-continue rather than
      // killing the whole publish.
      continue
    }

    // Recurse into this dep's own node_modules
    const nested = path.join(entryPath, strategy.depDir)
    try {
      if (fs.existsSync(nested)) collectDepsRecursive(nested, deps, seen, strategy)
    } catch {
      // Broken symlink inside nested → skip this branch, keep going.
    }
  }
}

// Cheap version read for the dedup key — avoids walking a dep's tree
// (depEntry's expensive part) just to discover it's a version already seen.
function readDepVersion (depDir) {
  try {
    return JSON.parse(fs.readFileSync(path.join(depDir, 'package.json'), 'utf8')).version || null
  } catch { return null }
}

function depEntry (depDir, name, skip) {
  const pkgPath = path.join(depDir, 'package.json')
  let version = null
  if (fs.existsSync(pkgPath)) {
    try {
      const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
      version = pkg.version
    } catch {}
  }

  // Per-arch prebuilds split. Packages that ship native addons carry a
  // `prebuilds/<platform>-<arch>/` directory per supported host. We
  // publish each platform as its OWN tree so clients only fetch their
  // own arch — that's what cuts ~248 MB / 85% of the bundle.
  //
  // The base tree EXCLUDES prebuilds/ entirely. The per-arch tree is
  // the content of one platform dir (just <pkg>.node + <pkg>.bare in
  // practice). Materializer overlays arch tree onto base under
  // `prebuilds/<platform>/`.
  const prebuildDir = path.join(depDir, 'prebuilds')
  let prebuilds = null
  let depSkip = skip
  if (fs.existsSync(prebuildDir)) {
    try {
      const platforms = fs.readdirSync(prebuildDir)
        .filter(p => {
          try { return fs.statSync(path.join(prebuildDir, p)).isDirectory() }
          catch { return false }
        })
      if (platforms.length > 0) {
        prebuilds = {}
        for (const platform of platforms.sort()) {
          try {
            const trees = walkTree(path.join(prebuildDir, platform), { skip })
            prebuilds[platform] = trees.get('').hash
          } catch {
            // Unrepresentable platform dir — skip silently
          }
        }
        // Exclude prebuilds/ from the base tree walk
        depSkip = new Set([...skip, 'prebuilds'])
      }
    } catch {}
  }

  // Walk this dep's base tree (excluding its own node_modules + prebuilds).
  let tree = null
  let normHash = null
  try {
    const trees = walkTree(depDir, { skip: depSkip })
    tree = trees.get('').hash
    // Normalized hash for conflict comparison: runtime content only, mode-
    // independent. Two copies of a dep that differ solely in dev/doc/meta
    // files or file modes share this hash, so they don't false-skew. Derived
    // from the same walk — no extra reads.
    normHash = normalizedTreeHash(trees)
  } catch (err) {
    return { name, version, tree: null, error: err.message }
  }

  const entry = { name, version, tree, normHash }
  // Is this one of OUR editable workspace packages (a file: symlink to source
  // outside any node_modules), as opposed to an installed npm dep? Used by the
  // self-skew detector (B) to name exactly which package to bump when its
  // content drifted under an unchanged version. realpath outside node_modules
  // ⇒ workspace source. (In a copied bootstrap stage this reads false; the
  // signal is exact in a source-dir publish / --dry-run.)
  try {
    entry.workspace = !fs.realpathSync(depDir).includes('/node_modules/')
  } catch { entry.workspace = false }
  if (prebuilds) entry.prebuilds = prebuilds
  // Absolute fs path this dep was read from. Needed by the staging step
  // because nested deps don't live at appDir/node_modules/<name> — they
  // may be several levels deep under intermediate packages' node_modules/.
  entry._sourcePath = depDir
  return entry
}

/**
 * Write a lockfile alongside the app's package.json.
 *
 * @param {string} appDir
 * @param {object} lockfile
 * @param {string} [filename='.pgh-lock.json']
 */
export function writeLockfile (appDir, lockfile, filename = '.pgh-lock.json') {
  // Strip publisher-local metadata (_sourcePath) from the persisted
  // lockfile — consumers only need name/version/tree/prebuilds.
  const serializable = {
    ...lockfile,
    deps: (lockfile.deps || []).map(d => {
      const { _sourcePath, ...rest } = d
      return rest
    })
  }
  const p = path.join(appDir, filename)
  fs.writeFileSync(p, JSON.stringify(serializable, null, 2))
  return p
}

/**
 * Read an existing lockfile (or return null if absent).
 */
export function readLockfile (appDir, filename = '.pgh-lock.json') {
  const p = path.join(appDir, filename)
  if (!fs.existsSync(p)) return null
  return JSON.parse(fs.readFileSync(p, 'utf8'))
}