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