import { randomUUID } from 'node:crypto'; import { link, open, rename, unlink } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; import { CliError } from './error-handler'; interface ExportFileHandle { writeFile(content: string): Promise; sync(): Promise; close(): Promise; } export interface ExportFileOperations { open(path: string): Promise; link(source: string, target: string): Promise; rename(source: string, target: string): Promise; unlink(path: string): Promise; syncDirectory(path: string): Promise; randomSuffix(): string; } const DEFAULT_OPERATIONS: ExportFileOperations = { open: async (path) => open(path, 'wx'), link, rename, unlink, async syncDirectory(path) { const directory = await open(path, 'r'); try { await directory.sync(); } finally { await directory.close(); } }, randomSuffix: randomUUID, }; function hasErrorCode(error: unknown, code: string): boolean { return error !== null && typeof error === 'object' && 'code' in error && error.code === code; } const UNSUPPORTED_ATOMIC_LINK_CODES = new Set(['EMLINK', 'ENOTSUP', 'EOPNOTSUPP', 'EPERM', 'EXDEV']); function errorCode(error: unknown): string | undefined { return error !== null && typeof error === 'object' && 'code' in error && typeof error.code === 'string' ? error.code : undefined; } async function ignoreMissingUnlink(path: string, operations: ExportFileOperations): Promise { try { await operations.unlink(path); } catch (error) { if (!hasErrorCode(error, 'ENOENT')) throw error; } } async function syncDirectoryBestEffort(path: string, operations: ExportFileOperations): Promise { try { await operations.syncDirectory(dirname(path)); } catch { // The complete file has already been published atomically. Some platforms // do not support syncing directory handles, so this durability step is // best-effort after the required file fsync. } } /** * Publish a complete export without exposing a partially-written target. * * The staged file lives beside the target so hard-link/rename publication * stays on one filesystem. Default publication uses a hard link for atomic * no-clobber semantics; --force uses atomic rename replacement. */ export async function publishExportFile( path: string, content: string, force: boolean | undefined, operations: ExportFileOperations = DEFAULT_OPERATIONS, ): Promise { const tempPath = join(dirname(path), `.${basename(path)}.${process.pid}.${operations.randomSuffix()}.tmp`); let tempCreated = false; try { const file = await operations.open(tempPath); tempCreated = true; try { await file.writeFile(content); await file.sync(); } finally { await file.close(); } if (force) { await operations.rename(tempPath, path); tempCreated = false; await syncDirectoryBestEffort(path, operations); return; } try { await operations.link(tempPath, path); } catch (error) { if (hasErrorCode(error, 'EEXIST')) { throw new CliError( `Refusing to overwrite existing export file: ${path}. Re-run with --force to replace it.`, 'EXPORT_EXISTS', ); } const code = errorCode(error); if (code && UNSUPPORTED_ATOMIC_LINK_CODES.has(code)) { throw new CliError( `The filesystem cannot atomically create a no-overwrite export at ${path}. Choose a local filesystem, write to stdout, or use --force only when replacement semantics are acceptable.`, 'EXPORT_ATOMIC_UNSUPPORTED', ); } throw error; } // The hard link is now the published target. Remove the staging name. If // cleanup fails, roll back the target before surfacing the error so callers // never observe a failed command with a newly-published file. try { await operations.unlink(tempPath); tempCreated = false; await syncDirectoryBestEffort(path, operations); } catch (error) { await ignoreMissingUnlink(path, operations); throw error; } } catch (error) { if (tempCreated) { try { await ignoreMissingUnlink(tempPath, operations); } catch { // Preserve the publication/write error. Cleanup is best-effort after a // filesystem failure, but every normal failure path removes the stage. } } throw error; } }