/** * Node adapters for @beehexa/hexasync-template-assets (AD-4: adapters, not core). * * These are the ONLY place in the install path allowed to touch the filesystem, * `node:crypto`, `node:path` or `node:url`. The core package may not. * * Everything that is a *policy* decision — which names are assets, how a bundle * version parses, what an asset path may contain — lives in core and is imported * here. An adapter that decides policy for itself is how the CLI and the editor * ended up installing different file sets from the same bundle. */ import { createHash } from 'node:crypto'; import { readFile, mkdir, rm, writeFile, readdir, stat, } from 'node:fs/promises'; import { dirname } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { BUNDLE_VERSION_FILENAME, isAssetFileName, parseBundleVersion, type AssetSource, type Clock, type FileReader, type FileSink, type Hasher, } from '@beehexa/hexasync-template-assets'; /** Serves assets from a directory on disk — the `--assets ` adapter (decision 0002). */ export function directoryAssetSource( rootDir: string, bundleVersionFile = BUNDLE_VERSION_FILENAME, ): AssetSource { const walk = async (dir: string, prefix: string): Promise => { const entries = await readdir(dir, { withFileTypes: true }); const out: string[] = []; for (const entry of entries) { if (!isAssetFileName(entry.name, prefix === '')) continue; const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`; const full = `${dir}/${entry.name}`; // `Dirent.isDirectory()` is false for a symlink even when it points at a // directory, so a symlinked folder used to be reported as an asset and // then read as a file (EISDIR). `stat` follows the link; `isFile()` is // then the deciding question, so anything that is neither a file nor a // directory (socket, fifo, device) is skipped rather than half-installed. const info = await stat(full); if (info.isDirectory()) { out.push(...(await walk(full, rel))); } else if (info.isFile()) { out.push(rel); } } return out; }; return { list: async () => walk(rootDir, ''), read: async (relativePath) => new Uint8Array(await readFile(`${rootDir}/${relativePath}`)), bundleVersion: async () => { let raw: string; try { raw = await readFile(`${rootDir}/${bundleVersionFile}`, 'utf8'); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'ENOENT') { throw new Error( `No ${bundleVersionFile} in ${rootDir}. The asset bundle is versioned by a monotonic integer (AD-16).`, ); } if (code === 'EISDIR') { throw new Error( `${bundleVersionFile} in ${rootDir} is a directory, not a version file.`, ); } throw err; } return parseBundleVersion(raw, `${rootDir}/${bundleVersionFile}`); }, }; } /** Reads back an installed file, so an update can classify before overwriting. */ export function nodeFileReader(): FileReader { return { read: async (uri) => { const filePath = uri.startsWith('file://') ? fileURLToPath(uri) : uri; try { return new Uint8Array(await readFile(filePath)); } catch (err) { // Absent is a normal answer here — it means "not installed" — so it is reported as // undefined rather than thrown. Anything else is a real failure and propagates. if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw err; } }, }; } /** Writes through to disk, creating parent directories as needed. */ export function nodeFileSink(): FileSink { return { write: async (uri, bytes) => { const filePath = uri.startsWith('file://') ? fileURLToPath(uri) : uri; // `node:path.dirname` rather than `lastIndexOf('/')`: on Windows // `fileURLToPath` returns `C:\...`, so searching for a forward slash found // nothing, no parent directory was created, and every install failed // ENOENT on its first file. const parent = dirname(filePath); if (parent !== '' && parent !== filePath) await mkdir(parent, { recursive: true }); await writeFile(filePath, bytes); }, // Lets core unwind a partially written install, so a failure leaves the // target as it was rather than half populated. remove: async (uri) => { const filePath = uri.startsWith('file://') ? fileURLToPath(uri) : uri; await rm(filePath, { force: true }); }, }; } export const nodeHasher: Hasher = { hash: async (bytes) => `sha256-${createHash('sha256').update(bytes).digest('hex')}`, }; export const systemClock: Clock = { now: async () => new Date().toISOString() }; /** Converts a CLI directory argument into the `file://` URI the core expects. */ export async function targetUriFor(dir: string): Promise { // Inspect before creating. `mkdir` first meant a file target threw a raw // `EEXIST` and the readable "--target must be a directory" message below was // unreachable — and a typo'd path silently created a tree of directories // before anything had been validated. try { const info = await stat(dir); if (!info.isDirectory()) throw new Error(`--target must be a directory: ${dir}`); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'ENOTDIR') throw new Error(`--target must be a directory: ${dir}`); if (code !== 'ENOENT') throw err; await mkdir(dir, { recursive: true }); } return pathToFileURL(dir).href; }