UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

687 lines (686 loc) 29.2 kB
/** * SetupService — 项目初始化服务(V2 重构版) * * 一键初始化 AutoSnippet V2 工作空间,5 步完成: * * Step 1 .autosnippet/ 运行时目录 + config.json + .gitignore * Step 2 AutoSnippet/ 知识库目录结构 + AutoSnippet/recipes/(有 --repo 则 clone,无则为普通目录) * Step 3 IDE 集成(VSCode MCP + Cursor MCP + copilot-instructions + cursor-rules * + skills-template + cursor-workflow + claude-hooks + guard-ci + pre-commit-hook) * Step 4 SQLite 数据库 + V1 数据迁移 * Step 5 平台相关初始化(macOS → Xcode Snippets) * * ═══════════════════════════════════════════════════════════ * * 数据架构(核心数据在子仓库,受 git 权限保护) * ───────────────────────────────────────────── * AutoSnippet/ (知识库根目录) * ├─ constitution.yaml 权限宪法:角色 + 权限矩阵 + 治理规则 + 能力探测 * ├─ boxspec.json 项目规格定义 * ├─ recipes/ Git 子仓库 = 唯一真实来源 Source of Truth * │ └─ *.md 统一知识实体(代码规范/模式/架构/调用链/数据流/...) * ├─ candidates/ 候选知识(待审批) * ├─ skills/ Project Skills(冷启动自动生成 + 手动创建) * └─ README.md * * .autosnippet/ (运行时缓存,gitignored) * ├─ config.json 项目配置(含 core.subRepoDir 子仓库路径) * ├─ autosnippet.db SQLite 运行时缓存(从子仓库同步 + candidates/snippets/audit) * ├─ context/ 向量索引缓存 * └─ logs/ 运行日志 * * 数据流 * ───── * 写入:编辑子仓库文件 → git push(需权限)→ asd sync → 更新 DB 缓存 * 读取:查询 SQLite(快速索引) * 核心数据(统一 Recipe 实体)修改必须经过 git,普通用户无法绕过 * * 权限模型(三层架构) * ────────────────── * ① 能力层 WriteGuard — git push --dry-run:探测子仓库写权限(物理信号) * ② 角色层 Permission — constitution.yaml 角色权限矩阵(逻辑裁决) * ③ 治理层 Constitution — constitution.yaml 优先级规则引擎(业务裁决) * * 子仓库 git 权限只是"一种能力(capability)",最终裁决权在 Constitution YAML。 */ import { execSync } from 'node:child_process'; import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, rmSync, writeFileSync, } from 'node:fs'; import { join, resolve } from 'node:path'; import { isExcludedProject } from '../shared/isOwnDevRepo.js'; import { DEFAULT_KNOWLEDGE_BASE_DIR, DEFAULT_SUB_REPO_DIR, isGitRepo, } from '../shared/ProjectMarkers.js'; import { PACKAGE_ROOT } from '../shared/package-root.js'; import { FileDeployer } from './deploy/FileDeployer.js'; /** AutoSnippet 源码仓库根目录(定位 templates/ 等资源) */ const REPO_ROOT = PACKAGE_ROOT; // ───────────────────────────────────────────────────── export class SetupService { force; projectName; projectRoot; _results = null; candidatesDir; coreDir; dbPath; recipesDir; runtimeDir; seed; skillsDir; /** 子仓库相对路径(相对于 projectRoot),如 'AutoSnippet/recipes' */ subRepoDir; /** 子仓库绝对路径 */ subRepoPath; /** 子仓库远程仓库 URL(为空则 recipes/ 作为普通目录随主仓库提交) */ subRepoUrl; /** * @param options */ constructor(options) { this.projectRoot = resolve(options.projectRoot); this.projectName = this.projectRoot.split('/').pop() || ''; this.force = options.force || false; this.seed = options.seed || false; this.subRepoDir = options.subRepoDir || DEFAULT_SUB_REPO_DIR; this.subRepoUrl = options.subRepoUrl; // ── 排除项目保护 ────────────────────────────────── const exclusion = isExcludedProject(this.projectRoot); if (exclusion.excluded) { throw new Error(`[SetupService] 检测到当前目录是排除项目(${exclusion.reason}),` + '拒绝执行 setup 以避免创建 .autosnippet/ 和 AutoSnippet/ 运行时数据。' + '\n提示: 请在用户项目目录中运行 asd setup。'); } // 运行时目录(gitignored) this.runtimeDir = join(this.projectRoot, '.autosnippet'); this.dbPath = join(this.runtimeDir, 'autosnippet.db'); // 知识库根目录 this.coreDir = join(this.projectRoot, DEFAULT_KNOWLEDGE_BASE_DIR); this.recipesDir = join(this.coreDir, 'recipes'); this.candidatesDir = join(this.coreDir, 'candidates'); this.skillsDir = join(this.coreDir, 'skills'); // 子仓库绝对路径 this.subRepoPath = join(this.projectRoot, this.subRepoDir); } /* ═══ 公共入口 ═══════════════════════════════════════ */ getSteps() { return [ { label: '创建运行时目录与配置', fn: () => this.stepRuntime() }, { label: '初始化知识库与 recipes 子仓库', fn: () => this.stepCoreRepo() }, { label: '配置 IDE 集成', fn: () => this.stepIDE() }, { label: '初始化数据库', fn: () => this.stepDatabase() }, { label: '平台相关初始化', fn: () => this.stepPlatform() }, { label: '初始化向量索引', fn: () => this.stepVectorIndex() }, ]; } async run() { const steps = this.getSteps(); const results = []; const total = steps.length; for (let i = 0; i < total; i++) { const { label, fn } = steps[i]; const tag = `[${i + 1}/${total}]`; process.stdout.write(` ${tag} ${label}...`); try { const r = await fn(); const _detail = this._formatStepDetail(r); results.push({ step: i + 1, label, ok: true, ...(r || {}) }); } catch (err) { console.error(` ${err.message}`); results.push({ step: i + 1, label, ok: false, error: err.message }); } } this._results = results; return results; } /** 格式化步骤结果的简要信息 */ _formatStepDetail(r) { if (!r) { return ''; } const parts = []; if (r.configured) { parts.push(r.configured.join(', ')); } if (r.entries !== undefined) { parts.push(`${r.entries} entries`); } if (r.migrated !== undefined) { parts.push(`migrated ${r.migrated}`); } return parts.length > 0 ? ` (${parts.join('; ')})` : ''; } printSummary() { const results = this._results || []; const ok = results.filter((r) => r.ok).length; const fail = results.filter((r) => !r.ok).length; console.log(''); if (fail === 0) { console.log(` ✅ Setup 完成(${ok} 步骤全部成功)`); } else { console.log(` ⚠️ Setup 完成(${ok} 成功,${fail} 失败)`); } console.log(''); console.log(' 下一步:'); console.log(' 1. 运行 asd ui 启动后台服务'); console.log(' 2. 打开 IDE Agent Mode,告诉它「帮我冷启动」'); console.log(' 3. 所有分析和知识提取都通过 IDE 完成,无需额外配置'); console.log(''); } /* ═══ Step 1: 运行时目录与配置 ═══════════════════════ */ stepRuntime() { mkdirSync(this.runtimeDir, { recursive: true }); // config.json const configPath = join(this.runtimeDir, 'config.json'); if (existsSync(configPath) && !this.force) { } else { const config = { version: 2, projectName: this.projectName, database: this.dbPath, core: { dir: DEFAULT_KNOWLEDGE_BASE_DIR, constitution: `${DEFAULT_KNOWLEDGE_BASE_DIR}/constitution.yaml`, subRepoDir: this.subRepoDir, ...(this.subRepoUrl ? { subRepoUrl: this.subRepoUrl } : {}), }, ai: { provider: process.env.ASD_AI_PROVIDER || 'auto' }, guard: { enabled: true }, watch: { enabled: false, paths: ['Sources', 'src'], extensions: ['.swift', '.m', '.h'], }, }; writeFileSync(configPath, JSON.stringify(config, null, 2)); } // .env — AI 配置模板 this._ensureEnvFile(); return { created: 'runtime' }; } /* ═══ Step 2: 知识库目录 + recipes 子仓库 ═════════════ */ stepCoreRepo() { const alreadyRepo = isGitRepo(this.subRepoPath); // 创建目录结构 for (const d of [this.coreDir, this.recipesDir, this.candidatesDir, this.skillsDir]) { mkdirSync(d, { recursive: true }); } // ── 子仓库处理:有 URL → clone 模式;无 URL → 普通目录 ── if (this.subRepoUrl) { if (alreadyRepo) { // 幂等:已是 git 仓库,确保 remote 一致 this._ensureRemote(this.subRepoUrl); } else if (this._hasFiles(this.subRepoPath)) { // 有文件但不是 git 仓库 → 备份 + clone + 合并 this._cloneWithMerge(this.subRepoUrl); } else { // 空目录 → 直接 clone(先移除空目录,git clone 需要目标不存在) try { rmdirSync(this.subRepoPath); } catch { /* 目录可能不存在或不为空,忽略 */ } this._git(['clone', this.subRepoUrl, this.subRepoPath], this.projectRoot); } } // else: 无 URL → recipes/ 是普通目录,随主仓库提交,不执行 git init // constitution.yaml — 权限宪法 this._writeConstitution(); // boxspec.json — 项目规格 this._writeBoxspec(); // recipes/_template.md — Recipe 格式参考 this._copyRecipeTemplate(); // seed recipes — 冷启动示例 if (this.seed) { this._copySeedRecipes(); } // README.md this._writeCoreReadme(); // .gitignore(子仓库自身,仅在有 URL 即子仓库模式时写入) if (this.subRepoUrl) { const giPath = join(this.subRepoPath, '.gitignore'); if (!existsSync(giPath)) { writeFileSync(giPath, '.DS_Store\n*.swp\n'); } } // clone 后可能写入了模板文件,提交它们(仅新 clone 时) if (this.subRepoUrl && !alreadyRepo && isGitRepo(this.subRepoPath)) { try { const status = this._git(['status', '--porcelain'], this.subRepoPath); if (status.trim().length > 0) { this._git(['add', '.'], this.subRepoPath); this._git(['commit', '-m', 'Add AutoSnippet template files'], this.subRepoPath); } } catch { /* clone 的空仓库首次 commit 可能无变更,忽略 */ } } return { coreInit: true, alreadyRepo, subRepoPath: this.subRepoDir, hasUrl: Boolean(this.subRepoUrl), }; } /** 写入 constitution.yaml(优先从模板复制) */ _writeConstitution() { const dest = join(this.coreDir, 'constitution.yaml'); if (existsSync(dest) && !this.force) { return; } const tmpl = join(REPO_ROOT, 'templates', 'constitution.yaml'); if (existsSync(tmpl)) { copyFileSync(tmpl, dest); } else { // 内联生成最小宪法(模板文件不可用时的 fallback) writeFileSync(dest, [ '# AutoSnippet Constitution', 'version: "2.0"', '', 'capabilities:', ' git_write:', ' description: "recipes 子仓库 git push 权限"', ' probe: "git push --dry-run"', ' no_subrepo: "allow"', ' no_remote: "allow"', ' cache_ttl: 86400', '', 'rules:', ' - id: destructive_confirm', ' check: "删除操作必须有 confirmed: true"', ' - id: content_required', ' check: "创建 candidate/recipe 必须提供 code 或 content"', ' - id: ai_no_direct_recipe', ' check: "AI actor 不能直接创建或批准 Recipe"', ' - id: batch_authorized', ' check: "批量操作必须有 authorized: true"', '', 'roles:', ' - id: "developer"', ' name: "Developer"', ' permissions: ["*"]', ' requires_capability: ["git_write"]', ' - id: "contributor"', ' name: "Contributor"', ' permissions: ["read:recipes", "read:candidates", "read:guard_rules", "read:audit_logs:self"]', ' - id: "visitor"', ' name: "Visitor"', ' permissions: ["read:recipes", "read:guard_rules"]', ' - id: "external_agent"', ' name: "External Agent"', ' permissions: ["read:recipes", "read:guard_rules", "create:candidates", "submit:knowledge"]', ' - id: "chat_agent"', ' name: "AgentRuntime"', ' permissions: ["read:recipes", "read:candidates", "create:candidates", "read:guard_rules"]', '', ].join('\n')); } } /** 写入 boxspec.json */ _writeBoxspec() { const dest = join(this.coreDir, 'boxspec.json'); if (existsSync(dest) && !this.force) { return; } writeFileSync(dest, JSON.stringify({ name: this.projectName, schemaVersion: 2, kind: 'root', root: true, knowledgeBase: { dir: DEFAULT_KNOWLEDGE_BASE_DIR }, subRepo: { dir: this.subRepoDir }, module: { rootDir: DEFAULT_KNOWLEDGE_BASE_DIR }, }, null, 2)); } /** 复制 _template.md 到 recipes/ */ _copyRecipeTemplate() { const src = join(REPO_ROOT, 'templates', 'recipes-setup', '_template.md'); if (!existsSync(src)) { return; } const dest = join(this.recipesDir, '_template.md'); if (existsSync(dest) && !this.force) { return; } copyFileSync(src, dest); } /** 复制示例 Recipe(冷启动推荐) */ _copySeedRecipes() { const seedDir = join(REPO_ROOT, 'templates', 'recipes-setup'); if (!existsSync(seedDir)) { return; } // 匹配 seed-*.md 文件 let files; try { files = readdirSync(seedDir).filter((f) => f.startsWith('seed-') && f.endsWith('.md')); } catch { return; } let count = 0; for (const file of files) { const dest = join(this.recipesDir, file.replace('seed-', '')); if (existsSync(dest) && !this.force) { continue; } copyFileSync(join(seedDir, file), dest); count++; } if (count > 0) { } } /** 写入核心目录 README */ _writeCoreReadme() { const dest = join(this.coreDir, 'README.md'); if (existsSync(dest) && !this.force) { return; } writeFileSync(dest, [ `# ${this.projectName} — AutoSnippet Knowledge Base`, '', '此目录是项目的 **核心知识库**,`recipes/` 目录存放核心知识数据。', '', '## 目录结构', '', '```', 'AutoSnippet/', '├── constitution.yaml 权限宪法(角色 + 权限 + 治理规则 + 能力探测)', '├── boxspec.json 项目规格', ...(this.subRepoUrl ? [ '├── recipes/ ★ 独立 Git 子仓库 — 统一知识实体(Source of Truth)', '│ ├── .git/ 独立 git 仓库', ] : ['├── recipes/ ★ 知识目录 — 统一知识实体(Source of Truth)']), '│ ├── _template.md 格式参考', '│ └── ... 代码模式/调用链/数据流/约束/风格/...', '├── candidates/ 候选知识(待审批)', '├── skills/ Project Skills(冷启动自动生成 + 手动创建)', '│ └── <name>/SKILL.md AI Agent 知识增强文档', '└── README.md', '```', '', '## 统一知识模型', '', '所有知识统一为 **Recipe** 实体,由 `knowledgeType` 区分维度:', '', '| knowledgeType | 说明 |', '|---------------|------|', '| code-standard | 代码规范 |', '| code-pattern | 代码模式 |', '| code-relation | 代码关联 |', '| inheritance | 继承与接口 |', '| call-chain | 调用链路 |', '| data-flow | 数据流向 |', '| module-dependency | 模块与依赖 |', '| architecture | 模式与架构 |', '| best-practice | 最佳实践 |', '| boundary-constraint | 边界约束(含 Guard 规则) |', '| code-style | 代码风格 |', '| solution | 问题解决方案 |', '', '## 权限模型', '', 'AutoSnippet 使用 **三层权限架构**:', '', '| 层级 | 机制 | 职责 |', '|------|------|------|', '| ① 能力层 | `git push --dry-run` | 探测 recipes 子仓库物理写权限 |', '| ② 角色层 | `constitution.yaml` roles | 角色权限矩阵 (action:resource) |', '| ③ 治理层 | `constitution.yaml` priorities | 业务规则引擎 |', '', 'git 权限只是"能力信号",**最终裁决权在 Constitution YAML**。', '', ...(this.subRepoUrl ? [ '## 团队协作', '', '团队成员克隆主仓库后,需额外获取 recipes 子仓库:', '', '```bash', '# 方式 A:git submodule(推荐,自动关联)', `git submodule add ${this.subRepoUrl} ${this.subRepoDir}`, '', '# 方式 B:独立 clone', `git clone ${this.subRepoUrl} ${this.subRepoDir}`, '```', ] : [ '## Recipes 知识库', '', '`recipes/` 目录随主仓库提交。如需独立管理(团队权限控制),运行:', '', '```bash', 'asd remote <your-recipes-repo-url>', '```', ]), '', '> 运行时缓存(DB 索引、Candidates、Snippets、审计日志)在 `.autosnippet/autosnippet.db`。', '> **核心数据的唯一真实来源是 `recipes/` 目录中的文件**,DB 仅做缓存。', '', ].join('\n')); } /* ═══ Step 3: IDE 集成 ═══════════════════════════════ */ stepIDE() { const deployer = new FileDeployer({ projectRoot: this.projectRoot, force: this.force, }); const { deployed, skipped: _skipped, errors } = deployer.deployAll('setup'); if (errors.length > 0) { for (const { id, error } of errors) { console.error(` ⚠ ${id}: ${error}`); } } return { configured: deployed }; } /* ═══ Step 4: 数据库初始化 ═══════════════════════════ */ async stepDatabase() { const ConfigLoader = (await import('../infrastructure/config/ConfigLoader.js')).default; const Bootstrap = (await import('../bootstrap.js')).default; const env = process.env.NODE_ENV || 'development'; ConfigLoader.load(env); ConfigLoader.set('database.path', this.dbPath); const bootstrap = new Bootstrap({ env }); await bootstrap.initialize(); const db = bootstrap.components?.db?.getDb?.(); if (db) { // 从子仓库文件同步核心数据到 DB 缓存(统一 Recipe 模型) await this._syncRecipesToDB(db); } await bootstrap.shutdown(); ConfigLoader.config = null; // 重置静态状态 return { dbPath: this.dbPath }; } /** * 从 AutoSnippet/recipes/*.md + candidates/*.md 同步到 DB 缓存 * 委托 KnowledgeSyncService 执行全字段同步(setup 场景跳过违规记录) */ async _syncRecipesToDB(db) { const { KnowledgeSyncService } = await import('./KnowledgeSyncService.js'); const syncService = new KnowledgeSyncService(this.projectRoot); const report = syncService.sync(db, { skipViolations: true, }); if (report.synced > 0) { } else { } if (report.orphaned.length > 0) { } } /* ═══ Step 5: Snippet 初始化 (已移除 — AI-first 迁移) ═════ */ async stepPlatform() { return { skipped: true }; } /* ═══ Helpers ════════════════════════════════════════ */ /** * 在项目根目录创建 .env 文件(从 .env.example 复制) * 如果 .env 已存在则跳过并提示用户手动配置。 */ _ensureEnvFile() { const envPath = join(this.projectRoot, '.env'); if (existsSync(envPath)) { return; } const examplePath = join(REPO_ROOT, '.env.example'); if (existsSync(examplePath)) { copyFileSync(examplePath, envPath); } else { // fallback: .env.example 缺失时写入最小模板 writeFileSync(envPath, [ '# AutoSnippet AI 配置(由 asd setup 自动生成)', '# 完整配置说明见 .env.example', '', 'ASD_AI_PROVIDER=google', 'ASD_AI_MODEL=gemini-3-flash-preview', '# ASD_GOOGLE_API_KEY=', '', ].join('\n')); } } /** 在指定目录执行 git 命令 */ _git(args, cwd) { try { return execSync(`git ${args.join(' ')}`, { cwd, stdio: 'pipe', encoding: 'utf8', }).trim(); } catch (e) { if (args[0] === 'commit' && e.status === 1) { return ''; } throw e; } } /** 检查目录中是否有文件(排除 . 和 ..) */ _hasFiles(dirPath) { try { const entries = readdirSync(dirPath); return entries.length > 0; } catch { return false; } } /** 确保子仓库的 remote origin 与给定 URL 一致 */ _ensureRemote(url) { try { const currentUrl = this._git(['remote', 'get-url', 'origin'], this.subRepoPath); if (currentUrl !== url) { this._git(['remote', 'set-url', 'origin', url], this.subRepoPath); } } catch { // 没有 origin remote → 添加 this._git(['remote', 'add', 'origin', url], this.subRepoPath); } } /** * 备份已有文件 → clone → 合并回来(不覆盖远端文件) * 适用于 recipes/ 有模板文件但还不是 git 仓库的场景 */ _cloneWithMerge(url) { const backupDir = `${this.subRepoPath}-backup-${Date.now()}`; // 1. 备份 renameSync(this.subRepoPath, backupDir); // 2. clone try { this._git(['clone', url, this.subRepoPath], this.projectRoot); } catch (err) { // clone 失败 → 恢复备份 try { renameSync(backupDir, this.subRepoPath); } catch { /* 尽力恢复 */ } throw err; } // 3. 合并备份文件到 clone 结果(不覆盖已有文件) try { const files = readdirSync(backupDir); for (const file of files) { const dest = join(this.subRepoPath, file); if (!existsSync(dest)) { cpSync(join(backupDir, file), dest, { recursive: true }); } } } catch { /* 合并阶段出错不影响 clone 结果 */ } // 4. 清理备份 try { rmSync(backupDir, { recursive: true, force: true }); } catch { /* 清理失败不影响主流程 */ } } /* ═══ Step 6: 向量索引初始化 ═══════════════════════════ */ /** * 尝试初始化向量索引: 检查 embedding provider 可用性, * 若可用则自动构建初始索引;否则提示用户手动运行 asd embed。 * * 此步骤为 best-effort: 失败不阻塞 setup 流程。 */ async stepVectorIndex() { try { const { getServiceContainer } = await import('../injection/ServiceContainer.js'); const container = getServiceContainer(); // 检查 VectorService 是否已注册 if (!container.services.vectorService) { return { status: 'skipped', reason: 'vectorService 未注册(AI Provider 未配置或容器未完全初始化)', hint: '运行 `asd embed` 构建语义向量索引', }; } const vectorService = container.get('vectorService'); const stats = await vectorService.getStats(); // 如果 embedding provider 不可用,提示用户 if (!stats.embedProviderAvailable) { return { status: 'skipped', reason: '未配置 AI API Key', hint: '配置 API Key 后运行 `asd embed` 启用语义搜索', }; } // 如果索引已有数据,跳过 if (stats.count > 0 && !this.force) { return { status: 'skipped', reason: `向量索引已存在 (${stats.count} entries)`, }; } // 构建初始索引 const result = await vectorService.fullBuild({ force: this.force }); return { status: 'done', indexed: result.upserted ?? 0, skipped: result.skipped ?? 0, errors: result.errors ?? 0, }; } catch (err) { // 向量初始化失败不阻塞 setup 流程 return { status: 'warning', error: err instanceof Error ? err.message : String(err), hint: '运行 `asd embed` 手动构建向量索引', }; } } } export default SetupService;