UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

630 lines (629 loc) 25.1 kB
/** * FileDeployer — 统一文件部署引擎 * * 根据 FileManifest 中的策略,部署文件到用户项目。 * SetupService 和 UpgradeService 共享此引擎,消除所有重复代码。 * * 策略实现: * overwrite — mkdirSync + copyFileSync * overwrite-dir — 递归复制目录中的所有文件 * signature-safe — safeCopyFile(签名匹配才覆盖) * create-only — 仅在文件不存在时复制 * merge-json — 读取现有 JSON,合并 autosnippet 键,写回 * merge-gitignore — 增量追加缺失规则 + 迁移旧格式 * backup-overwrite — 备份旧文件再覆盖 * inject-marker — 在 <!-- autosnippet:begin/end --> 标记间注入 * generate — 调用自定义生成函数 */ import { execSync } from 'node:child_process'; import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { injectAutoApprove } from '../../external/mcp/autoApproveInjector.js'; import { checkWriteSafety, safeCopyFile } from '../../service/delivery/FileProtection.js'; import { DEFAULT_KNOWLEDGE_BASE_DIR } from '../../shared/ProjectMarkers.js'; import { PACKAGE_ROOT, TEMPLATES_DIR } from '../../shared/package-root.js'; import { buildMcpServerEntry, GITIGNORE_MIGRATIONS, GITIGNORE_RULES, GITIGNORE_SECTION_BEGIN, GITIGNORE_SECTION_END, MANIFEST, } from './FileManifest.js'; /** AutoSnippet 源码仓库根目录 */ const REPO_ROOT = PACKAGE_ROOT; export class FileDeployer { force; projectName; projectRoot; /** @param {{ projectRoot: string, force?: boolean }} options */ constructor({ projectRoot, force = false }) { this.projectRoot = resolve(projectRoot); this.projectName = this.projectRoot.split('/').pop() || ''; this.force = force; } /* ═══ 公共入口 ═══════════════════════════════════════ */ /** * 部署所有适用的文件 * @param options 可选过滤部署的 category * @returns > }} */ deployAll(mode, { filter } = {}) { const applicable = MANIFEST.filter((entry) => { if (entry.on !== 'both' && entry.on !== mode) { return false; } if (filter && !filter.includes(entry.category)) { return false; } return true; }); const deployed = []; const skipped = []; const errors = []; for (const entry of applicable) { try { const result = this._deployOne(entry, mode); if (result) { deployed.push(entry.id); } else { skipped.push(entry.id); } } catch (err) { errors.push({ id: entry.id, error: err.message }); } } return { deployed, skipped, errors }; } /** 按 category 部署 */ deployCategory(category, mode) { return this.deployAll(mode, { filter: [category] }); } /* ═══ 单文件部署路由 ═════════════════════════════════ */ /** * @param entry Manifest 条目 * @returns 是否实际写入了文件 */ _deployOne(entry, mode) { switch (entry.strategy) { case 'overwrite': return this._strategyOverwrite(entry); case 'overwrite-dir': return this._strategyOverwriteDir(entry); case 'signature-safe': return this._strategySignatureSafe(entry, mode); case 'create-only': return this._strategyCreateOnly(entry); case 'merge-json': return this._strategyMergeJson(entry); case 'merge-gitignore': return this._strategyMergeGitignore(entry); case 'backup-overwrite': return this._strategyBackupOverwrite(entry); case 'inject-marker': return this._strategyInjectMarker(entry); case 'generate': return this._strategyGenerate(entry); default: throw new Error(`Unknown deploy strategy: ${entry.strategy}`); } } /* ═══ 策略实现 ═══════════════════════════════════════ */ /** overwrite — AutoSnippet 完全拥有,始终覆盖 */ _strategyOverwrite(entry) { const src = join(TEMPLATES_DIR, entry.src); if (!existsSync(src)) { return false; } const dest = join(this.projectRoot, entry.dest); mkdirSync(dirname(dest), { recursive: true }); copyFileSync(src, dest); if (entry.chmod) { this._chmodExec(dest); } return true; } /** overwrite-dir — 递归覆盖目录 */ _strategyOverwriteDir(entry) { const srcDir = join(TEMPLATES_DIR, entry.src); if (!existsSync(srcDir)) { return false; } const destDir = join(this.projectRoot, entry.dest); const copied = this._copyDirRecursive(srcDir, destDir, entry.chmod); // 清理旧文件 if (entry.cleanup) { for (const rel of entry.cleanup) { const old = join(this.projectRoot, rel); if (existsSync(old)) { try { unlinkSync(old); } catch { /* ignore */ } } } } return copied; } /** signature-safe — 有 AutoSnippet 签名才覆盖 */ _strategySignatureSafe(entry, mode) { const src = join(TEMPLATES_DIR, entry.src); if (!existsSync(src)) { return false; } const dest = join(this.projectRoot, entry.dest); mkdirSync(dirname(dest), { recursive: true }); // setup + 不存在 → 直接复制 if (mode === 'setup' && !existsSync(dest)) { copyFileSync(src, dest); return true; } // setup + 已存在 + 非 force → 尝试签名覆盖 if (mode === 'setup' && existsSync(dest) && !this.force) { const { canWrite } = checkWriteSafety(dest); if (!canWrite) { // 签名保护失败 → 尝试 fallback 策略 if (entry.fallback === 'inject-marker') { return this._strategyInjectMarker(entry); } return false; } copyFileSync(src, dest); return true; } // upgrade 或 force → safeCopyFile const { written } = safeCopyFile(src, dest); if (!written && entry.fallback === 'inject-marker') { return this._strategyInjectMarker(entry); } return written; } /** create-only — 仅在不存在时创建 */ _strategyCreateOnly(entry) { let dest; if (entry.resolveDest) { dest = this._resolvers[entry.resolveDest]?.call(this); if (!dest) { return false; } } else { dest = join(this.projectRoot, entry.dest); } if (existsSync(dest) && !this.force) { return false; } const { canWrite } = checkWriteSafety(dest); if (!canWrite) { return false; } const src = join(TEMPLATES_DIR, entry.src); if (!existsSync(src)) { return false; } mkdirSync(dirname(dest), { recursive: true }); copyFileSync(src, dest); if (entry.chmod) { this._chmodExec(dest); } return true; } /** merge-json — 读取现有 JSON,合并 autosnippet 键 */ _strategyMergeJson(entry) { const dest = join(this.projectRoot, entry.dest); mkdirSync(dirname(dest), { recursive: true }); let config = {}; if (existsSync(dest)) { try { config = JSON.parse(readFileSync(dest, 'utf8')); } catch { /* */ } } const parentKey = entry.jsonKey; if (!config[parentKey]) { config[parentKey] = {}; } const ide = entry.id === 'vscode-mcp' ? 'vscode' : 'cursor'; config[parentKey].autosnippet = buildMcpServerEntry(this.projectRoot, ide); writeFileSync(dest, JSON.stringify(config, null, 2)); return true; } /** * merge-gitignore — section-based 管理 * * 设计:用 BEGIN/END 标记包裹 AutoSnippet 规则块,整块替换。 * - 首次:追加 section 到文件末尾 * - 升级:替换已有 section(规则变更自动生效) * - 迁移:清理旧版逐行追加的散落规则 */ _strategyMergeGitignore(_entry) { const giPath = join(this.projectRoot, '.gitignore'); let content = existsSync(giPath) ? readFileSync(giPath, 'utf8') : ''; let changed = false; // 1. 迁移旧格式(regex-based cleanup) for (const migration of GITIGNORE_MIGRATIONS) { if (migration.find.test(content)) { content = content.replace(migration.find, migration.replace); changed = true; } } // 2. 迁移:清理旧版散落的 AutoSnippet 规则(无 section marker 时代的残留) const oldPatterns = GITIGNORE_RULES.map((r) => r.pattern); const oldComments = GITIGNORE_RULES.filter((r) => r.comment).map((r) => `# ${r.comment}`); // 也清除旧版注入的通用规则 const legacyTokens = [ '.DS_Store', 'nohup.out', '*.sw[a-p]', '# macOS 元数据', '# AutoSnippet 运行时缓存(不入库)', '# AutoSnippet 环境变量(含 API Key,不入库)', '# AutoSnippet 运行日志', ]; const allOldTokens = new Set([...oldPatterns, ...oldComments, ...legacyTokens]); // 只有在 section markers 不存在时才清理散落规则(避免误删 section 内容后重复清理) if (!content.includes(GITIGNORE_SECTION_BEGIN)) { const lines = content.split('\n'); const cleaned = lines.filter((line) => !allOldTokens.has(line.trim())); const cleanedContent = cleaned .join('\n') .replace(/\n{3,}/g, '\n\n') .trimEnd(); if (cleanedContent !== content.trimEnd()) { content = cleanedContent.endsWith('\n') ? cleanedContent : `${cleanedContent}\n`; changed = true; } } // 3. 构建 AutoSnippet section block const sectionLines = [GITIGNORE_SECTION_BEGIN]; for (const rule of GITIGNORE_RULES) { if (rule.comment) { sectionLines.push(`# ${rule.comment}`); } sectionLines.push(rule.pattern); } // 确保 AutoSnippet/ 知识库不被忽略 const kbDir = DEFAULT_KNOWLEDGE_BASE_DIR; const contentLines = content.split('\n'); const hasIgnoreAS = contentLines.some((l) => { const t = l.trim(); return (t === `${kbDir}/` || t === kbDir) && !t.startsWith('#') && !t.startsWith('!'); }); if (hasIgnoreAS) { sectionLines.push(`# 知识库必须入库`); sectionLines.push(`!${kbDir}/`); } sectionLines.push(GITIGNORE_SECTION_END); const sectionBlock = sectionLines.join('\n'); // 4. 插入或替换 section const beginIdx = content.indexOf(GITIGNORE_SECTION_BEGIN); const endIdx = content.indexOf(GITIGNORE_SECTION_END); if (beginIdx !== -1 && endIdx !== -1) { // 替换已有 section const before = content.substring(0, beginIdx); const after = content.substring(endIdx + GITIGNORE_SECTION_END.length); const newContent = `${before}${sectionBlock}${after}`; if (newContent !== content) { content = newContent; changed = true; } } else { // 首次追加 const separator = content.endsWith('\n') || content.length === 0 ? '\n' : '\n\n'; content += `${separator}${sectionBlock}\n`; changed = true; } if (changed) { writeFileSync(giPath, content); } return changed; } /** backup-overwrite — 备份旧文件后覆盖 */ _strategyBackupOverwrite(entry) { const src = join(TEMPLATES_DIR, entry.src); if (!existsSync(src)) { return false; } // 需要目标目录存在 if (entry.requireDir) { const reqDir = join(this.projectRoot, entry.requireDir); if (!existsSync(reqDir)) { return false; } } const dest = join(this.projectRoot, entry.dest); if (existsSync(dest)) { const oldContent = readFileSync(dest, 'utf8'); const newContent = readFileSync(src, 'utf8'); if (oldContent === newContent) { return false; // 无变化 } copyFileSync(dest, `${dest}.bak`); // 备份 } mkdirSync(dirname(dest), { recursive: true }); copyFileSync(src, dest); return true; } /** inject-marker — 在 autosnippet:begin/end 标记间注入 */ _strategyInjectMarker(entry) { const BEGIN_MARKER = '<!-- autosnippet:begin -->'; const END_MARKER = '<!-- autosnippet:end -->'; const src = join(TEMPLATES_DIR, entry.src); if (!existsSync(src)) { return false; } const templateContent = readFileSync(src, 'utf8'); const beginIdx = templateContent.indexOf(BEGIN_MARKER); const endIdx = templateContent.indexOf(END_MARKER); if (beginIdx === -1 || endIdx === -1) { return false; } const snippet = templateContent.slice(beginIdx, endIdx + END_MARKER.length); const dest = join(this.projectRoot, entry.dest); const destDir = dirname(dest); mkdirSync(destDir, { recursive: true }); if (existsSync(dest)) { const existing = readFileSync(dest, 'utf8'); if (existing.includes(BEGIN_MARKER)) { // 替换现有段落 const updated = existing.replace(new RegExp(`${BEGIN_MARKER}[\\s\\S]*?${END_MARKER}`), snippet); writeFileSync(dest, updated); return true; } // 追加到末尾 writeFileSync(dest, `${existing}\n\n${snippet}\n`); return true; } writeFileSync(dest, `${snippet}\n`); return true; } /** generate — 自定义生成逻辑 */ _strategyGenerate(entry) { const fn = this._generators[entry.generate]; if (!fn) { throw new Error(`Unknown generator: ${entry.generate}`); } return fn.call(this); } /* ═══ 自定义生成器 ═══════════════════════════════════ */ _generators = { /** .cursor/rules/autosnippet-conventions.mdc — 读 conventions.md + YAML frontmatter */ generateConventionsMdc() { const tpl = join(TEMPLATES_DIR, 'instructions/conventions.md'); if (!existsSync(tpl)) { return false; } const body = readFileSync(tpl, 'utf8').trimEnd(); const content = [ '---', 'description: AutoSnippet conventions — behavioral rules for task tracking, knowledge guardrails, and MCP usage', 'alwaysApply: true', '---', '', '# AutoSnippet Conventions', '', body, '', ].join('\n'); const dest = join(this.projectRoot, '.cursor/rules/autosnippet-conventions.mdc'); mkdirSync(dirname(dest), { recursive: true }); writeFileSync(dest, content); return true; }, /** .github/copilot-instructions.md — 读 conventions.md + HTML markers */ generateCopilotInstructions() { const tpl = join(TEMPLATES_DIR, 'instructions/conventions.md'); if (!existsSync(tpl)) { return false; } const body = readFileSync(tpl, 'utf8').trimEnd(); const content = [ '<!-- autosnippet:begin -->', '', '# AutoSnippet Conventions', '', body, '', '<!-- autosnippet:end -->', '', ].join('\n'); const dest = join(this.projectRoot, '.github/copilot-instructions.md'); const destDir = dirname(dest); mkdirSync(destDir, { recursive: true }); // 如果文件已存在且包含 begin/end markers,仅替换标记间内容 if (existsSync(dest)) { const existing = readFileSync(dest, 'utf8'); const BEGIN = '<!-- autosnippet:begin -->'; const END = '<!-- autosnippet:end -->'; if (existing.includes(BEGIN) && existing.includes(END)) { const snippet = content.trimEnd(); const updated = existing.replace(new RegExp(`${BEGIN}[\\s\\S]*?${END}`), snippet); writeFileSync(dest, updated); return true; } // 用户文件无 markers 且无 AutoSnippet 签名 → 追加 const { canWrite } = checkWriteSafety(dest); if (!canWrite) { writeFileSync(dest, `${existing}\n\n${content}`); return true; } } writeFileSync(dest, content); return true; }, /** AGENTS.md 静态骨架 — 读 agent-static.md 模板 */ generateAgentsMd() { const claudePath = join(this.projectRoot, 'CLAUDE.md'); if (existsSync(claudePath)) { return false; // 有 CLAUDE.md 时跳过 } const agentsPath = join(this.projectRoot, 'AGENTS.md'); if (existsSync(agentsPath) && !this.force) { return false; } const { canWrite } = checkWriteSafety(agentsPath); if (!canWrite) { return false; } const tpl = join(TEMPLATES_DIR, 'instructions/agent-static.md'); if (!existsSync(tpl)) { return false; } const content = readFileSync(tpl, 'utf8').replace(/\{\{projectName\}\}/g, this.projectName); writeFileSync(agentsPath, content); return true; }, /** 安装 Cursor Skills */ installSkills() { const installScript = join(REPO_ROOT, 'scripts', 'install-cursor-skill.js'); if (!existsSync(installScript)) { return false; } try { execSync(`node "${installScript}"`, { cwd: this.projectRoot, stdio: 'pipe', env: { ...process.env, NODE_PATH: join(REPO_ROOT, 'node_modules') }, }); return true; } catch { return false; } }, /** 确保 AutoSnippet/skills/ 目录存在 */ ensureSkillsDir() { const autoDir = join(this.projectRoot, DEFAULT_KNOWLEDGE_BASE_DIR); if (!existsSync(autoDir)) { return false; } const skillsDir = join(autoDir, 'skills'); if (existsSync(skillsDir)) { return false; } mkdirSync(skillsDir, { recursive: true }); return true; }, /** 触发 Cursor Delivery Pipeline 动态生成(fire-and-forget) */ triggerCursorDelivery() { this._triggerCursorDeliveryAsync().catch(() => { }); return true; }, /** 注入 autoApprove */ injectAutoApprove() { try { injectAutoApprove(this.projectRoot); return true; } catch { return false; } }, /** 构建并安装 VSCode Extension */ installVSCodeExtension() { const extDir = join(REPO_ROOT, 'resources', 'vscode-ext'); const pkgJson = join(extDir, 'package.json'); if (!existsSync(pkgJson)) { return false; } try { // 编译 TypeScript execSync('npx tsc -p ./tsconfig.json', { cwd: extDir, stdio: 'pipe' }); // 打包 .vsix execSync('npx @vscode/vsce package --no-dependencies -o autosnippet.vsix', { cwd: extDir, stdio: 'pipe', }); const vsixPath = join(extDir, 'autosnippet.vsix'); if (!existsSync(vsixPath)) { return false; } // 探测可用 IDE CLI const cliCandidates = ['code', 'cursor', 'codex']; const installed = []; for (const cli of cliCandidates) { try { execSync(`which ${cli}`, { stdio: 'pipe' }); execSync(`${cli} --install-extension "${vsixPath}" --force`, { stdio: 'pipe' }); installed.push(cli); } catch { /* CLI 不可用 */ } } return installed.length > 0; } catch { return false; } }, }; /* ═══ Destination Resolvers ══════════════════════════ */ _resolvers = { /** 解析 pre-commit hook 的目标路径 */ resolvePreCommitDest() { const huskyDir = join(this.projectRoot, '.husky'); if (existsSync(huskyDir)) { return join(huskyDir, 'pre-commit'); } if (existsSync(join(this.projectRoot, '.git'))) { const hooksDir = join(this.projectRoot, '.git', 'hooks'); mkdirSync(hooksDir, { recursive: true }); return join(hooksDir, 'pre-commit'); } return null; }, }; /* ═══ Helpers ════════════════════════════════════════ */ /** 递归复制目录 */ _copyDirRecursive(srcDir, destDir, chmod = false) { if (!existsSync(srcDir)) { return false; } let copied = false; const entries = readdirSync(srcDir, { withFileTypes: true }); for (const entry of entries) { const srcPath = join(srcDir, entry.name); const destPath = join(destDir, entry.name); if (entry.isDirectory()) { const sub = this._copyDirRecursive(srcPath, destPath, chmod); copied = copied || sub; } else { mkdirSync(destDir, { recursive: true }); copyFileSync(srcPath, destPath); if (chmod && entry.name.endsWith('.sh')) { this._chmodExec(destPath); } copied = true; } } return copied; } /** chmod +x */ _chmodExec(filePath) { try { execSync(`chmod +x "${filePath}"`, { stdio: 'pipe' }); } catch { /* Windows — ignore */ } } /** 异步触发 Cursor Delivery Pipeline */ async _triggerCursorDeliveryAsync() { try { const { getServiceContainer } = await import('../../injection/ServiceContainer.js'); const container = getServiceContainer(); const pipeline = container.services.cursorDeliveryPipeline ? container.get('cursorDeliveryPipeline') : null; if (pipeline) { await pipeline.deliver(); } } catch { // ServiceContainer 未初始化 — 正常(upgrade 可能在无 DB 环境执行) } } } export default FileDeployer;