/**
* tree-install — materialize a full tree hierarchy into the pgh store
* and wire up an app's install directory with symlinks.
*
* P.10 Phase 0.5c. Recursively fetches subtrees and content blobs
* referenced by a root tree hash, drops them into the TreeStore, then
* creates a single symlink from the app's install directory to the
* root tree's store path.
*
* The fetcher abstraction lets callers wire this to either:
* - a local DeployServer (testing, in-process)
* - a remote DeployClient over Hyperswarm (real installs — landing 0.5d)
*
* A fetcher is any object with:
* async getTree(hash) → { entries: [...], size }
* async getContent(hash) → Buffer
*/
import fs from '#node:fs'
import path from '#node:path'
import { KIND_FILE, KIND_DIR, KIND_SYMLINK, computeTreeHash } from 'hyper-overlay/tree'
/**
* Recursively materialize a tree and all its subtrees into the store.
*
* @param {TreeStore} store
* @param {string} rootHash
* @param {{ getTree: Function, getContent: Function }} fetcher
* @param {object} [opts]
* @param {string} [opts.source] - recorded in the meta for provenance
* @param {Function} [opts.skipPath] - (relPath) => boolean. Forwarded to
* `store.materialize` to skip foreign subtrees (e.g. by-arch/<other-host>/).
* @returns {Promise<{materialized: number, skipped: number}>} counts
*/
export async function installTree (store, rootHash, fetcher, opts = {}) {
store.init()
if (store.hasTree(rootHash)) {
return { materialized: 0, skipped: 1 }
}
const tree = await fetcher.getTree(rootHash)
if (!tree || !Array.isArray(tree.entries)) {
throw new Error('fetcher returned invalid tree for ' + rootHash)
}
// Content-address verification: entries MUST hash to rootHash. Peers
// in a P2P overlay may respond with empty/wrong entries to signal
// "I don't have this tree". Empty-for-non-empty-tree would silently
// materialize an empty dir — exactly the stortron-class bug. Verify
// here and let the caller decide whether to retry on another peer.
const actualRootHash = computeTreeHash(tree.entries)
if (actualRootHash !== rootHash) {
throw Object.assign(
new Error('root tree-hash mismatch: expected ' + rootHash.slice(0, 12) + '…, got ' + actualRootHash.slice(0, 12) + '… (peer may not have this tree)'),
{ code: 'EHASHMISMATCH', expected: rootHash, actual: actualRootHash }
)
}
// Materialize the tree as a single self-contained dir. `store.materialize`
// recursively inlines subtree content into the top-level dir via
// fetchTree/fetchContent callbacks. Subtree hashes do NOT get their own
// `dependencies/<subhash>/` dirs — that would duplicate every file N
// times across levels. Dedup happens at the TOP-LEVEL tree layer (same
// dep version → same top-level hash → one dir shared across apps).
await store.materialize(rootHash, tree.entries, {
fetchContent: fetcher.getContent.bind(fetcher),
fetchTree: async (h) => {
const t = await fetcher.getTree(h)
return t.entries
},
source: opts.source,
skipPath: opts.skipPath
})
return { materialized: 1, skipped: 0 }
}
/**
* Install an app by pointing its directory at a materialized tree.
*
* Strategy: the app's install directory (e.g. ~/.local/share/pear-git-v3)
* is itself a symlink to store/trees/<rootHash>/. Uninstall is then just
* removing the symlink; refcount tracks the underlying store tree.
*
* @param {TreeStore} store
* @param {string} appName
* @param {string} rootHash - tree already materialized in the store
* @param {string} appsDir - e.g. path.join(homedir(), '.local', 'share')
* @returns {{ installPath: string, target: string }}
*/
export function linkApp (store, appName, rootHash, appsDir) {
if (!store.hasTree(rootHash)) {
throw new Error('cannot link app to absent tree: ' + rootHash)
}
const installPath = path.join(appsDir, appName)
const target = store.treePath(rootHash)
fs.mkdirSync(appsDir, { recursive: true })
// If the install path already exists, refuse — caller should remove first.
let exists = false
try { fs.lstatSync(installPath); exists = true } catch {}
if (exists) {
throw new Error(installPath + ' already exists — remove it before installing')
}
fs.symlinkSync(target, installPath)
// Single-root symlink install — appName references exactly this one
// tree. setAppRefs cleans up any stale refs from a prior install of
// the same appName (the legacy addRef leak across versions).
store.setAppRefs(appName, [rootHash])
return { installPath, target }
}
/**
* Uninstall an app — removes the symlink and decrements refcount.
* Leaves the underlying tree in the store (GC handles cleanup separately).
*
* @param {TreeStore} store
* @param {string} appName
* @param {string} appsDir
* @returns {{ removed: boolean, rootHash?: string }}
*/
export function unlinkApp (store, appName, appsDir) {
const installPath = path.join(appsDir, appName)
let linkTarget = null
try {
const stat = fs.lstatSync(installPath)
if (stat.isSymbolicLink()) {
linkTarget = fs.readlinkSync(installPath)
}
} catch {
return { removed: false }
}
// If linked into the store, derive the tree hash from the target path
let rootHash = null
if (linkTarget && linkTarget.includes(store.dependenciesDir)) {
rootHash = path.basename(linkTarget)
}
try { fs.unlinkSync(installPath) } catch {}
// Full uninstall of a linkApp-installed app — clear ALL refs held by
// appName. Defensive: a legacy mixed install path may have left more
// refs than just rootHash, and we want a clean tear-down.
store.clearAppRefs(appName)
return { removed: true, rootHash }
}
/**
* Swap an app's symlink to a different tree hash. Atomic on POSIX via
* fs.renameSync of a sibling symlink. Used by `pgh app update` and
* `pgh app rollback`.
*
* @param {TreeStore} store
* @param {string} appName
* @param {string} newRootHash
* @param {string} appsDir
* @returns {{ previous: string|null, current: string }}
*/
export function swapApp (store, appName, newRootHash, appsDir) {
if (!store.hasTree(newRootHash)) {
throw new Error('cannot swap app to absent tree: ' + newRootHash)
}
const installPath = path.join(appsDir, appName)
const target = store.treePath(newRootHash)
// Guard: swapApp is for single-root SYMLINK installs only (linkApp /
// install-tree). A HYDRATED install — a real directory whose node_modules
// was built by the materializer (`pgh app install` / `pgh app adopt`) —
// must NEVER be symlink-swapped: that replaces a working tree with a
// symlink to a bare root tree that has NO node_modules, bricking the app
// (and silently deleting the hydrated install). Re-materialize via
// `pgh app install` / `pgh app update` instead. (Caught a real bug: an
// `update-tree` on the hydrated pgh-sidecar destroyed its install.)
let existing = null
try { existing = fs.lstatSync(installPath) } catch (err) { if (err.code !== 'ENOENT') throw err }
if (existing && !existing.isSymbolicLink()) {
throw Object.assign(
new Error(appName + ' is a materialized (hydrated) install at ' + installPath +
' — update-tree/rollback only support single-root symlink installs. ' +
'Re-materialize with `pgh app install` / `pgh app update` instead.'),
{ code: 'EHYDRATED' }
)
}
// Read previous target (for refcount decrement) before swap
let previous = null
if (existing && existing.isSymbolicLink()) {
try {
const linkTarget = fs.readlinkSync(installPath)
if (linkTarget.includes(store.dependenciesDir)) {
previous = path.basename(linkTarget)
}
} catch {}
}
// Atomic swap: new symlink at a sibling path, then rename over the existing
const tmpLink = installPath + '.new-' + Date.now()
fs.symlinkSync(target, tmpLink)
fs.renameSync(tmpLink, installPath)
// swapApp targets the linkApp install path (single-root symlink).
// The new ref set is exactly {newRootHash}; setAppRefs replaces the
// old {previous} entry atomically — no stale ref left behind even
// if `previous` couldn't be derived from the on-disk symlink.
store.setAppRefs(appName, [newRootHash])
return { previous, current: newRootHash }
}