/** Filesystem preflight.
*
* pgh requires a POSIX filesystem that supports **unix domain sockets** and
* **symlinks**, and benefits from **hardlinks** (content-addressed dedup).
*
* - unix sockets: the sidecar + every daemon bind a UDS (~/.pgh/svc.sock).
* No fallback — if the fs can't host an AF_UNIX socket, nothing runs.
* - symlinks: the node materializer wires node_modules with symlinks
* (lib/materializers/node.js). No fallback — materialize throws.
* - hardlinks: the store dedups blobs by hardlinking one inode into every
* app tree (lib/store.js). SOFT — degrades to copy on EXDEV/EPERM/etc.,
* so a hardlink-less fs still works, it just uses more disk.
*
* Classic failure surfaces, and why each fails (this probe is EMPIRICAL — it
* tests the capabilities, it does NOT match on filesystem name):
* - FAT / vfat / exFAT: none of the three (no symlinks/hardlinks/special
* files) — fails symlink AND socket.
* - NTFS (ntfs3/ntfs-3g): HAS symlinks + hardlinks, but can't host a
* unix-socket special file — fails on the socket alone.
* - WSL Windows-drive bridge (`/mnt/c` via drvfs/9p): no unix sockets,
* unreliable symlinks.
* Catch it here, before init gets deep.
*/
import fs from '#node:fs'
import path from '#node:path'
import net from '#node:net'
/**
* Probe `dir`'s filesystem. Returns a report; never throws for a merely
* unsupported capability (only for an unwritable dir).
*
* @param {string} dir a path on the filesystem to test (created if absent)
* @returns {Promise<{ok:boolean, hardlink:boolean, symlink:boolean,
* unixSocket:boolean, onWindowsDrive:boolean, errors:string[]}>}
*/
export async function checkFilesystem (dir) {
const errors = []
const result = {
ok: false,
hardlink: false,
symlink: false,
unixSocket: false,
onWindowsDrive: false,
errors
}
fs.mkdirSync(dir, { recursive: true })
const scratch = fs.mkdtempSync(path.join(dir, '.pgh-fscheck-'))
try {
// WSL Windows-drive detection: /mnt/<letter>/ is drvfs/9p, not a real
// Linux fs. Resolve symlinks first so a symlinked store is caught too.
let real = scratch
try { real = fs.realpathSync(scratch) } catch {}
if (/^\/mnt\/[a-z](\/|$)/i.test(real)) result.onWindowsDrive = true
// hardlink — SOFT (copy fallback exists; dedup lost without it)
try {
const a = path.join(scratch, 'a')
fs.writeFileSync(a, 'x')
fs.linkSync(a, path.join(scratch, 'b'))
result.hardlink = true
} catch {
result.hardlink = false
}
// symlink — HARD (node_modules wiring has no fallback)
try {
fs.symlinkSync('a', path.join(scratch, 's'))
result.symlink = true
} catch (err) {
errors.push('symlinks not supported (' + (err.code || err.message) + ')')
}
// unix domain socket — HARD (sidecar + daemon UDS)
result.unixSocket = await canBindUnixSocket(path.join(scratch, 'x.sock'))
if (!result.unixSocket) errors.push('unix domain sockets not supported')
} finally {
try { fs.rmSync(scratch, { recursive: true, force: true }) } catch {}
}
result.ok = result.symlink && result.unixSocket
return result
}
/** Try to bind an AF_UNIX server at `sockPath`. Resolves true/false, never
* rejects. Guarded by a timeout so a wedged fs can't hang init. */
function canBindUnixSocket (sockPath) {
return new Promise((resolve) => {
let done = false
let server = null
const finish = (ok) => {
if (done) return
done = true
if (server) { try { server.close() } catch {} }
resolve(ok)
}
try {
server = net.createServer()
} catch {
return finish(false)
}
server.on('error', () => finish(false))
try {
server.listen(sockPath, () => finish(true))
} catch {
return finish(false)
}
const t = setTimeout(() => finish(false), 3000)
if (t && typeof t.unref === 'function') t.unref()
})
}
/** Human-readable one-liner block for the preflight report. */
export function formatReport (r, dir) {
const yn = (b) => (b ? 'ok' : 'NO')
const lines = [
' filesystem preflight (' + dir + '):',
' unix sockets: ' + yn(r.unixSocket) + (r.unixSocket ? '' : ' <- required'),
' symlinks: ' + yn(r.symlink) + (r.symlink ? '' : ' <- required'),
' hardlinks: ' + yn(r.hardlink) + (r.hardlink ? '' : ' (dedup disabled — copies instead)')
]
if (r.onWindowsDrive) {
lines.push(' WARNING: store is on a Windows drive (/mnt) — move it to $HOME on the Linux filesystem (WSL2).')
}
return lines.join('\n')
}