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