/**
* 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'))
}