OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / tree-install.js

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