OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / materializers / godot.js

120 lines · javascript ref:
/**
 * Godot materializer — install layout for Godot projects.
 *
 * Layout produced:
 *
 *   apps/<name>/
 *     project.godot    → dependencies/<rootHash>/project.godot
 *     scenes/          → dependencies/<rootHash>/scenes
 *     scripts/         → dependencies/<rootHash>/scripts
 *     ... (every top-level entry of the rootTree)
 *     addons/
 *       <plugin>       → ../../dependencies/<pluginHash>/
 *       <plugin2>      → ../../dependencies/<pluginHash>/
 *       ...
 *
 * Godot plugins don't resolve each other via filesystem walks the way
 * Node's `require()` does. Each addon is a self-contained dir under
 * `addons/`, referenced by `res://addons/<name>/...` paths. Plain
 * symlinks work — Godot's VFS follows them transparently.
 *
 * No hardlinks needed, no scratch dir nesting. Simpler than Node.
 */

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

export function materialize ({ store, appName, rootTreeHash, deps }) {
  if (!store.hasTree(rootTreeHash)) {
    throw new Error('rootTree not in store: ' + rootTreeHash)
  }

  const installPath = store.appPath(appName)

  if (safeLstat(installPath)) {
    throw new Error(installPath + ' already exists — remove before re-install')
  }

  fs.mkdirSync(installPath, { recursive: true })

  // Top-level rootTree contents as symlinks, excluding addons/ — that
  // gets constructed below from the dep list.
  const rootTarget = store.treePath(rootTreeHash)
  for (const entry of fs.readdirSync(rootTarget)) {
    if (entry === 'addons') continue
    fs.symlinkSync(path.join(rootTarget, entry), path.join(installPath, entry))
  }

  // Build addons/ dir with one symlink per plugin dep.
  const addonsPath = path.join(installPath, 'addons')
  fs.mkdirSync(addonsPath, { recursive: true })

  // Collect refs for atomic per-app swap at the end. See node.js for
  // why setAppRefs is preferred over per-dep addRef.
  const referenced = new Set()

  let depCount = 0
  for (const dep of deps) {
    if (!dep.tree) continue
    if (!store.hasTree(dep.tree)) {
      throw new Error('plugin tree missing from store: ' + dep.name + ' (' + dep.tree.slice(0, 12) + ')')
    }
    const pluginTarget = store.treePath(dep.tree)
    const linkPath = path.join(addonsPath, dep.name)
    fs.symlinkSync(pluginTarget, linkPath)
    referenced.add(dep.tree)
    depCount++
  }

  referenced.add(rootTreeHash)
  store.setAppRefs(appName, referenced)

  return { installPath, rootTarget, depCount }
}

export function unmaterialize ({ store, appName }) {
  const installPath = store.appPath(appName)
  if (!fs.existsSync(installPath)) return { removed: false }

  const referenced = new Set()

  try {
    for (const entry of fs.readdirSync(installPath)) {
      if (entry === 'addons') continue
      try {
        const target = fs.readlinkSync(path.join(installPath, entry))
        const hash = hashFromTreePath(store, target)
        if (hash) referenced.add(hash)
      } catch {}
    }
  } catch {}

  const addonsPath = path.join(installPath, 'addons')
  try {
    for (const entry of fs.readdirSync(addonsPath)) {
      try {
        const target = fs.readlinkSync(path.join(addonsPath, entry))
        const hash = hashFromTreePath(store, target)
        if (hash) referenced.add(hash)
      } catch {}
    }
  } catch {}

  fs.rmSync(installPath, { recursive: true, force: true })
  // Full uninstall — drop ALL refs held by this app, not just the ones
  // we could enumerate from the install dir (defensive: legacy installs
  // may have held refs we can't infer from the on-disk layout).
  store.clearAppRefs(appName)

  return { removed: true, referenced: [...referenced] }
}

function hashFromTreePath (store, target) {
  if (!target || !target.includes(store.dependenciesDir)) return null
  return path.basename(target)
}

function safeLstat (p) {
  try { fs.lstatSync(p); return true } catch { return false }
}