import { randomUUID } from "node:crypto"; import { access, link, mkdir, rename, unlink } from "node:fs/promises"; import { join } from "node:path"; import { writeTextFileAtomic } from "../utils/atomic-write.ts"; import { getUserTemplatesDir, isValidTemplateName } from "./load-template.ts"; export interface SaveUserTemplateOptions { /** 是否允许替换现有同名模板。 */ overwrite?: boolean; } export interface StagedTemplateRemoval { /** 用户看到的原模板路径。 */ path: string; /** 暂存删除期间保存原内容的隐藏路径。 */ stagedPath: string; /** 永久删除已暂存的模板。 */ commit(): Promise; /** 将已暂存的模板恢复到原路径。 */ rollback(): Promise; } /** 判断未知错误是否具有指定 Node.js 文件系统错误码。 */ function hasErrorCode(error: unknown, code: string): boolean { return error instanceof Error && (error as NodeJS.ErrnoException).code === code; } /** 返回用户模板文件的完整路径。 */ function getUserTemplatePath(name: string): string { return join(getUserTemplatesDir(), `${name}.md`); } /** 判断用户模板目录中是否已存在同名模板。 */ export async function userTemplateExists(name: string): Promise { try { await access(getUserTemplatePath(name)); return true; } catch (error) { if (hasErrorCode(error, "ENOENT")) { return false; } const detail = error instanceof Error ? error.message : String(error); throw new Error(`Failed to inspect template "${name}": ${detail}`); } } /** 将模板内容原子写入用户模板目录,必要时创建目录。 */ export async function saveUserTemplate( name: string, content: string, options: SaveUserTemplateOptions = {}, ): Promise { if (!isValidTemplateName(name)) { throw new Error( `Template name "${name}" is invalid. Use letters, digits, dots, hyphens, or underscores, starting with a letter or digit.`, ); } if (!content.trim()) { throw new Error("Template content must not be empty."); } const path = getUserTemplatePath(name); await mkdir(getUserTemplatesDir(), { recursive: true }); try { await writeTextFileAtomic(path, content.endsWith("\n") ? content : `${content}\n`, { overwrite: options.overwrite ?? true, mode: 0o600, }); } catch (error) { if (hasErrorCode(error, "EEXIST")) { throw new Error( `Template "${name}" was created by another process. Review it before choosing whether to overwrite it.`, ); } const detail = error instanceof Error ? error.message : String(error); throw new Error(`Failed to save template file ${path}: ${detail}`); } return path; } /** * 通过同目录原子重命名暂存模板删除;调用方完成配置更新后再 commit,失败时 rollback。 */ export async function stageUserTemplateRemoval(name: string): Promise { if (!isValidTemplateName(name)) { throw new Error(`Template name "${name}" is invalid.`); } const path = getUserTemplatePath(name); const stagedPath = `${path}.${process.pid}.${randomUUID()}.pending-delete`; try { await rename(path, stagedPath); } catch (error) { if (hasErrorCode(error, "ENOENT")) { throw new Error(`Template "${name}" was not found in your templates directory.`); } const detail = error instanceof Error ? error.message : String(error); throw new Error(`Failed to stage template file ${path} for removal: ${detail}`); } let active = true; return { path, stagedPath, /** 永久删除暂存文件。 */ async commit(): Promise { if (!active) { return; } await unlink(stagedPath); active = false; }, /** 通过独占硬链接恢复暂存文件,不覆盖并发创建的同名模板。 */ async rollback(): Promise { if (!active) { return; } try { await link(stagedPath, path); } catch (error) { if (hasErrorCode(error, "EEXIST")) { throw new Error( `Template "${name}" was recreated by another process, so rollback did not overwrite it. The staged content remains at ${stagedPath}.`, ); } throw error; } active = false; try { await unlink(stagedPath); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new Error( `Template "${name}" was restored to ${path}, but the staged file ${stagedPath} could not be removed: ${detail}`, ); } }, }; } /** 删除用户模板;内部使用可回滚的重命名,提交失败时恢复原文件。 */ export async function removeUserTemplate(name: string): Promise { const removal = await stageUserTemplateRemoval(name); try { await removal.commit(); } catch (error) { try { await removal.rollback(); } catch (rollbackError) { const detail = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); throw new Error( `Failed to remove template file ${removal.path}; rollback also failed: ${detail}`, { cause: error, }, ); } const detail = error instanceof Error ? error.message : String(error); throw new Error(`Failed to remove template file ${removal.path}: ${detail}`); } return removal.path; }