diff --git a/README.md b/README.md index 0652490..bff952d 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,18 @@ - 检查子项目环境:Git/SVN、包管理器、Node 版本、env 文件、Git 状态。 - 生成防偏差工作包:把开工前检查、禁止项、风险扫描、验证计划汇总成一份可执行清单。 - 生成验证建议:按后端、前端、样式、页面、部署、MCP 等变更类型给出轻量验证命令。 +- 固化改动后 review:验证计划和提交清单都会提醒执行 `kt_global_code_review` / `pnpm run global-review`。 +- 生成任务收尾包:把状态、验证计划、可选验证执行、全局 review 和历史清理收成一份结果。 - 生成页面测试用例:内置“先写用例、可视化证据、事不过三”的测试闭环。 - 生成接口测试计划:接口改动后输出真实调用命令和统一返回结构断言。 +- 生成业务链路测试计划:固化 Admin 登录、博客 CRUD、QQBot 扫码/自动回复、Web/Playground 回跳。 +- 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。 +- 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 NAS 服务探测、GTID、备份和行数校验。 +- 生成专项组件工作流:KtTable、BlogArgon、AdminAuth、QQBot 的防踩坑清单和验证点。 - 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。 - 清理历史产物:按目录最近修改时间只保留最近 3 轮测试/验证产物,模板目录永久保留。 - 检查 env 策略和变更风险:提醒真实配置、锁文件、核心表格组件、部署链路等高风险改动。 +- 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量和当前变更风险;任何文件改动后都要跑一遍。 - 生成或写入 `TASKS.md` 改动记录:默认 `dryRun=true`,确认后再落盘。 - 生成提交前检查清单:校验 KT commit message 约定。 @@ -67,6 +74,14 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login | `kt_cleanup_process_plan` | 生成验证进程清理检查命令 | | `kt_env_policy` | 检查 env 文件现状和提交策略 | | `kt_risk_scan` | 扫描当前或传入变更文件的偏差风险 | +| `kt_global_code_review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描 | +| `kt_finish_task` | 生成任务收尾包,可选执行验证和历史清理 | +| `kt_commit_plan` | 生成多仓库提交计划、建议 commit message 和检查项 | +| `kt_push_plan` | 生成多仓库推送计划和远程异常提醒 | +| `kt_business_test_plan` | 生成固化业务链路测试计划 | +| `kt_remote_health_check` | 生成或执行远程只读健康检查命令 | +| `kt_db_sync_plan` | 生成数据库同步安全向导 | +| `kt_component_workflow` | 输出专项组件/链路防踩坑工作流 | | `kt_append_task_record` | 预览或写入 `TASKS.md` 改动记录 | | `kt_commit_checklist` | 生成提交前检查清单并校验 commit message | @@ -75,6 +90,7 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login | 脚本 | 用途 | | --- | --- | | `pnpm run admin-login` | 使用可见 Edge 打开 Admin 登录页,填写账号密码,拖动滑块,保存登录态和截图。默认账号来自初始化数据 `admin/123456`,生产或个人账号用 `KT_ADMIN_USERNAME` / `KT_ADMIN_PASSWORD` 或 CLI 参数覆盖。 | +| `pnpm run global-review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描,输出 JSON 复审报告。 | ## 项目别名 @@ -90,7 +106,10 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login ## 开发说明 -- 主入口是 `src/server.ts`,工具入参类型集中在 `src/types.ts`。 +- 主入口是 `src/server.ts`,只负责 CLI 分支和 MCP Server 汇聚启动。 +- 工具入参类型集中在 `src/types.ts`,MCP 注册集中在 `src/registerTools.ts`。 +- `src/core/*` 放项目别名、工作区路径、命令执行、仓库/包管理器识别等基础能力。 +- `src/tools/*` 按功能拆分:检查、验证、测试、清理、风险、复审、任务记录和工作流计划。 - MCP 客户端直接通过 `node --import tsx loader` 启动 TS 入口,不再保留旧的 `src/server.mjs`。 - Node/npm/pnpm 版本异常时优先走 `nvm ls` + `nvm use`,不要用扫盘路径作为第一选择。 @@ -98,6 +117,13 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login - 写代码前先调用 `kt_prepare_task`;只需要单项信息时再调用 `kt_read_context` 或 `kt_inspect_project`。 - 多项目联动时先调用 `kt_inspect_all_projects`。 +- 要收尾时调用 `kt_finish_task`,默认只生成计划和 review;需要执行验证时显式传 `runValidation=true`。 +- 文件改动完成并验证后调用 `kt_global_code_review`,或运行 `pnpm run global-review`;它只读扫描,不删除文件、不提交代码。 +- 要提交或推送时先调用 `kt_commit_plan` / `kt_push_plan`,按仓库分组确认范围。 +- 要测真实业务链路时调用 `kt_business_test_plan`,选择 `admin-login`、`qqbot-auto-reply` 等 flow。 +- 远程服务排查先调用 `kt_remote_health_check`,默认只生成只读命令;需要执行时显式传 `execute=true`。 +- 数据库同步前调用 `kt_db_sync_plan`,先确认源库、目标库、备份库和校验点。 +- 改 KtTable、BlogArgon、AdminAuth、QQBot 前调用 `kt_component_workflow`,先看专项禁区。 - 不确定任务边界时调用 `kt_guardrails`,先拿到“能做什么、不能做什么、怎么验证”。 - 验证前调用 `kt_suggest_verification`,避免盲跑全量构建。 - 页面测试前调用 `kt_create_page_test_case`,再执行 Playwright/浏览器测试。 diff --git a/package.json b/package.json index 7491368..937d644 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "@kt/mcp-kt-workflow", - "version": "0.2.0", + "version": "0.4.0", "private": true, "type": "module", "description": "Reusable MCP workflow tools for the KT workspace.", "scripts": { "admin-login": "node --import ./node_modules/tsx/dist/loader.mjs scripts/admin-login-smoke.ts", "cleanup-history": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --cleanup-history", + "global-review": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --global-review", "self-test": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --self-test", "start": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts", "typecheck": "tsc --noEmit" diff --git a/src/core/constants.ts b/src/core/constants.ts new file mode 100644 index 0000000..767b1a5 --- /dev/null +++ b/src/core/constants.ts @@ -0,0 +1,68 @@ +import type { ProjectAlias, TaskType } from '../types.js'; +export const projectAliases = { + admin: 'Vue/kt-template-admin', + api: 'Node/kt-template-online-api', + blog: 'Vue/kt-blog-web', + mcp: 'mcp/ktWorkflow', + playground: 'Vue/kt-template-online-playground', + root: '.', + web: 'Vue/kt-template-online-web', +} satisfies Record; + +export const projectLabels = { + admin: 'Vue Admin 后台', + api: 'NestJS API 后端', + blog: 'Blog Web 博客前台', + mcp: 'KT Workflow MCP', + playground: 'Playground 编辑器', + root: 'KT 协作根目录', + web: 'Web 前台展示', +} satisfies Record; + +export const taskTypeValues = [ + 'api', + 'backend', + 'commit', + 'database', + 'deploy', + 'docs', + 'frontend', + 'general', + 'mcp', + 'page', + 'style', +] as const satisfies readonly TaskType[]; + +export const registeredToolNames = [ + 'kt_read_context', + 'kt_inspect_project', + 'kt_inspect_all_projects', + 'kt_guardrails', + 'kt_prepare_task', + 'kt_suggest_verification', + 'kt_create_page_test_case', + 'kt_api_test_plan', + 'kt_cleanup_history', + 'kt_cleanup_process_plan', + 'kt_env_policy', + 'kt_risk_scan', + 'kt_global_code_review', + 'kt_finish_task', + 'kt_commit_plan', + 'kt_push_plan', + 'kt_business_test_plan', + 'kt_remote_health_check', + 'kt_db_sync_plan', + 'kt_component_workflow', + 'kt_append_task_record', + 'kt_commit_checklist', +] as const; + +export const defaultHistoryRoots = [ + 'test-artifacts', + '.codex-test-logs', + 'codex-test-logs', + '.codex-verify', + '.playwright-mcp', + '.codex-db-sync', +] as const; diff --git a/src/core/exec.ts b/src/core/exec.ts new file mode 100644 index 0000000..04d86fd --- /dev/null +++ b/src/core/exec.ts @@ -0,0 +1,43 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import type { ExecResult } from '../types.js'; + +const execFileAsync = promisify(execFile); +function getErrorText(error: unknown, key: 'message' | 'stderr' | 'stdout'): string { + const value = (error as Partial>)[key]; + if (typeof value === 'string') return value; + if (Buffer.isBuffer(value)) return value.toString('utf8'); + return ''; +} + +export async function tryExecFile( + command: string, + args: string[], + cwd: string, + timeoutMs = 20_000, +): Promise { + try { + const result = await execFileAsync(command, args, { + cwd, + maxBuffer: 1024 * 1024 * 4, + timeout: timeoutMs, + windowsHide: true, + }); + return { + ok: true, + stderr: result.stderr.trim(), + stdout: result.stdout.trim(), + }; + } catch (error) { + return { + ok: false, + stderr: `${getErrorText(error, 'stderr') || getErrorText(error, 'message')}`.trim(), + stdout: getErrorText(error, 'stdout').trim(), + }; + } +} + +export async function tryPowerShell(command: string, cwd: string, timeoutMs = 120_000): Promise { + return tryExecFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', command], cwd, timeoutMs); +} diff --git a/src/core/project.ts b/src/core/project.ts new file mode 100644 index 0000000..6310683 --- /dev/null +++ b/src/core/project.ts @@ -0,0 +1,55 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +import type { PackageInfo, PackageJsonLike, RepoType, ResolvedProject } from '../types.js'; +import { readJson } from './workspace.js'; +export function detectRepoType(projectPath: string): RepoType { + if (existsSync(path.join(projectPath, '.git'))) return 'git'; + if (existsSync(path.join(projectPath, '.svn'))) return 'svn'; + return 'none'; +} + +export function detectPackageManager(projectPath: string): PackageInfo { + const packageJsonPath = path.join(projectPath, 'package.json'); + const packageJson = readJson(packageJsonPath); + const lockfiles = { + npm: existsSync(path.join(projectPath, 'package-lock.json')), + pnpm: existsSync(path.join(projectPath, 'pnpm-lock.yaml')), + yarn: existsSync(path.join(projectPath, 'yarn.lock')), + }; + const packageManagerSpec = packageJson?.packageManager || null; + const packageManager = + packageManagerSpec?.split('@')[0] || + (lockfiles.pnpm && 'pnpm') || + (lockfiles.yarn && 'yarn') || + (lockfiles.npm && 'npm') || + null; + + return { + engines: packageJson?.engines || {}, + lockfiles, + packageManager, + packageManagerSpec, + scripts: packageJson?.scripts || {}, + }; +} + +export function getTypecheckCommand( + project: ResolvedProject, + packageManager: string, + scripts: Record, +): string | null { + if (project.alias === 'admin') { + return 'pnpm -F @vben/web-antdv-next run typecheck'; + } + if (project.alias === 'blog') { + return 'pnpm run type-check'; + } + if (project.alias === 'web') { + return 'pnpm exec vue-tsc --noEmit'; + } + if (scripts.typecheck) return `${packageManager} run typecheck`; + if (scripts['check:type']) return `${packageManager} run check:type`; + if (scripts.check) return `${packageManager} run check`; + return null; +} diff --git a/src/core/workspace.ts b/src/core/workspace.ts new file mode 100644 index 0000000..f1473a2 --- /dev/null +++ b/src/core/workspace.ts @@ -0,0 +1,97 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { ProjectAlias, ResolvedProject } from '../types.js'; +import { projectAliases, projectLabels } from './constants.js'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +export const workspaceRoot = path.resolve( + process.env.KT_WORKSPACE_ROOT || path.join(dirname, '../../../..'), +); +export function toPosix(value: string): string { + return value.replaceAll(path.sep, '/'); +} + +export function resolveInsideRoot(target = '.'): string { + const absoluteTarget = path.isAbsolute(target) + ? path.resolve(target) + : path.resolve(workspaceRoot, target); + const relative = path.relative(workspaceRoot, absoluteTarget); + + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`Path is outside KT workspace: ${target}`); + } + + return absoluteTarget; +} + +export function getGlobalReviewCommand(): string { + return `pnpm --dir ${toPosix(resolveInsideRoot(projectAliases.mcp))} run global-review`; +} + +export function resolveProject(project = 'root'): ResolvedProject { + const normalized = (projectAliases as Record)[project] || project; + const absolutePath = resolveInsideRoot(normalized); + const alias = (Object.entries(projectAliases) as Array<[ProjectAlias, string]>).find( + ([, relativePath]) => resolveInsideRoot(relativePath) === absolutePath, + )?.[0]; + + return { + alias: alias || null, + label: alias ? projectLabels[alias] : '自定义路径', + path: absolutePath, + relativePath: toPosix(path.relative(workspaceRoot, absolutePath)) || '.', + }; +} + +export function readText(relativePath: string, fallback = ''): string { + const filePath = resolveInsideRoot(relativePath); + if (!existsSync(filePath)) return fallback; + return readFileSync(filePath, 'utf8'); +} + +export function readTextIfExists(filePath: string, fallback = ''): string { + if (!existsSync(filePath)) return fallback; + return readFileSync(filePath, 'utf8'); +} + +export function readJson>(filePath: string): T | null { + if (!existsSync(filePath)) return null; + + try { + return JSON.parse(readFileSync(filePath, 'utf8')) as T; + } catch { + return null; + } +} + +export function listImmediateChildren(targetPath: string): Array<{ name: string; type: 'directory' | 'file' }> { + if (!existsSync(targetPath)) return []; + + return readdirSync(targetPath, { withFileTypes: true }).map((item) => ({ + name: item.name, + type: item.isDirectory() ? 'directory' : 'file', + })); +} + +export function getRecentTaskRecords(count = 5): string { + const tasks = readText('TASKS.md'); + const records = tasks + .split(/\n(?=### \d{4}-\d{2}-\d{2}:)/) + .filter((section) => section.startsWith('### ')) + .slice(0, count); + + return records.join('\n\n').trim(); +} + +export function formatDateInShanghai(date = new Date()): string { + const parts = new Intl.DateTimeFormat('en-CA', { + day: '2-digit', + month: '2-digit', + timeZone: 'Asia/Shanghai', + year: 'numeric', + }).formatToParts(date); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.year}-${map.month}-${map.day}`; +} diff --git a/src/registerTools.ts b/src/registerTools.ts new file mode 100644 index 0000000..4ddc90b --- /dev/null +++ b/src/registerTools.ts @@ -0,0 +1,360 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import type { McpTextResponse } from './types.js'; +import { defaultHistoryRoots, taskTypeValues } from './core/constants.js'; +import { createCleanupProcessPlan, cleanupHistoryArtifacts } from './tools/cleanup.js'; +import { buildEnvPolicy, scanTaskRisk } from './tools/envRisk.js'; +import { buildGuardrails } from './tools/guardrails.js'; +import { inspectAllProjects, inspectProject } from './tools/inspect.js'; +import { buildGlobalCodeReview, defaultReviewProjects } from './tools/review.js'; +import { appendTaskRecord, createCommitChecklist, prepareTask, readWorkflowContext } from './tools/task.js'; +import { buildBusinessTestPlan, createApiTestPlan, createPageTestCase } from './tools/testing.js'; +import { buildVerificationPlan } from './tools/verification.js'; +import { buildCommitPlan, buildComponentWorkflow, buildDbSyncPlan, buildFinishTask, buildPushPlan, buildRemoteHealthCheck } from './tools/workflow.js'; +export function response(data: unknown): McpTextResponse { + return { + content: [ + { + text: typeof data === 'string' ? data : JSON.stringify(data, null, 2), + type: 'text', + }, + ], + }; +} + +export function registerTools(server: McpServer): void { + server.registerTool( + 'kt_read_context', + { + description: '读取 KT 根目录 AGENTS/SKILLS/TASKS,并返回项目清单和最近任务记录。', + inputSchema: { + taskRecordCount: z.number().int().min(0).max(20).default(5), + }, + title: 'Read KT Workflow Context', + }, + async (input) => response(readWorkflowContext(input)), + ); + + server.registerTool( + 'kt_inspect_project', + { + description: '检查 KT 子项目的仓库类型、包管理器、Node 版本、env 文件和 Git 状态。', + inputSchema: { + includeGit: z.boolean().default(true), + project: z.string().default('root'), + }, + title: 'Inspect KT Project', + }, + async (input) => response(await inspectProject(input)), + ); + + server.registerTool( + 'kt_inspect_all_projects', + { + description: '一次性检查 KT 工作区所有已知项目,适合多仓库联动任务开工前使用。', + inputSchema: { + includeGit: z.boolean().default(true), + includeRoot: z.boolean().default(true), + }, + title: 'Inspect All KT Projects', + }, + async (input) => response(await inspectAllProjects(input)), + ); + + server.registerTool( + 'kt_guardrails', + { + description: '按任务类型生成 KT 防偏差清单,包含开工、改动、禁止项和验证约束。', + inputSchema: { + changeType: z.enum(taskTypeValues).optional(), + includePageTest: z.boolean().default(false), + paths: z.array(z.string()).default([]), + project: z.string().default('root'), + taskType: z.enum(taskTypeValues).default('general'), + userRequest: z.string().optional(), + }, + title: 'KT Guardrails', + }, + async (input) => response(buildGuardrails(input)), + ); + + server.registerTool( + 'kt_prepare_task', + { + description: '生成一份 KT 任务 work packet:上下文、项目检查、防偏差清单、风险扫描和验证计划。', + inputSchema: { + changedFiles: z.array(z.string()).default([]), + entryUrl: z.string().optional(), + includeGit: z.boolean().default(true), + includePageTest: z.boolean().default(false), + paths: z.array(z.string()).default([]), + project: z.string().default('root'), + recordTitle: z.string().optional(), + taskType: z.enum(taskTypeValues).default('general'), + userRequest: z.string().optional(), + }, + title: 'Prepare KT Task', + }, + async (input) => response(await prepareTask(input)), + ); + + server.registerTool( + 'kt_suggest_verification', + { + description: '按 KT 规则为指定项目和变更类型生成轻量验证命令与注意事项。', + inputSchema: { + changeType: z.enum(taskTypeValues).default('general'), + includePageTest: z.boolean().default(false), + project: z.string().default('root'), + }, + title: 'Suggest KT Verification', + }, + async (input) => response(buildVerificationPlan(input)), + ); + + server.registerTool( + 'kt_create_page_test_case', + { + description: '生成 KT 页面级可视化测试用例骨架,包含事不过三闭环规则。', + inputSchema: { + account: z.string().optional(), + assertions: z.array(z.string()).default([]), + entryUrl: z.string().optional(), + project: z.string().default('admin'), + steps: z.array(z.string()).default([]), + title: z.string().optional(), + }, + title: 'Create KT Page Test Case', + }, + async (input) => response(createPageTestCase(input)), + ); + + server.registerTool( + 'kt_api_test_plan', + { + description: '为后端接口改动生成真实调用测试计划和响应结构断言。', + inputSchema: { + auth: z.enum(['none', 'bearer']).default('bearer'), + baseUrl: z.string().default('http://localhost:3000'), + body: z.record(z.any()).optional(), + contentType: z.boolean().default(true), + endpoint: z.string().default('/api/待填写'), + expectedFields: z.array(z.string()).default([]), + method: z.enum(['DELETE', 'GET', 'PATCH', 'POST', 'PUT']).default('GET'), + project: z.string().default('api'), + }, + title: 'KT API Test Plan', + }, + async (input) => response(createApiTestPlan(input)), + ); + + server.registerTool( + 'kt_cleanup_history', + { + description: '清理 KT 根目录历史产物。默认 dryRun=true,只预览;执行时按 LastWriteTime 每个历史目录仅保留最近 3 轮,test-artifacts/_templates 永久保留。', + inputSchema: { + dryRun: z.boolean().default(true), + keep: z.number().int().min(0).max(50).default(3), + roots: z.array(z.string()).default([...defaultHistoryRoots]), + }, + title: 'KT Cleanup History Artifacts', + }, + async (input) => response(cleanupHistoryArtifacts(input)), + ); + + server.registerTool( + 'kt_cleanup_process_plan', + { + description: '生成按项目路径和端口清理验证进程的 PowerShell 检查命令,不直接杀进程。', + inputSchema: { + ports: z.array(z.number().int().min(1).max(65_535)).default([]), + project: z.string().default('root'), + }, + title: 'KT Cleanup Process Plan', + }, + async (input) => response(createCleanupProcessPlan(input)), + ); + + server.registerTool( + 'kt_env_policy', + { + description: '检查指定项目 env 文件现状,并输出 KT 环境配置提交策略。', + inputSchema: { + project: z.string().default('root'), + }, + title: 'KT Env Policy', + }, + async (input) => response(buildEnvPolicy(input)), + ); + + server.registerTool( + 'kt_risk_scan', + { + description: '按当前变更文件或传入文件列表扫描常见偏差风险和验证提醒。', + inputSchema: { + changedFiles: z.array(z.string()).default([]), + project: z.string().default('root'), + }, + title: 'KT Risk Scan', + }, + async (input) => response(await scanTaskRisk(input)), + ); + + server.registerTool( + 'kt_global_code_review', + { + description: '对 KT 已知子仓库做只读全局 CodeReview 扫描:Git 状态、tracked env/deploy/secrets、冲突标记、调试输出、疑似凭据字面量和当前变更风险。', + inputSchema: { + includeContentScan: z.boolean().default(true), + includeRootScan: z.boolean().default(true), + maxFindingsPerProject: z.number().int().min(1).max(200).default(20), + projects: z.array(z.string()).default([...defaultReviewProjects]), + }, + title: 'KT Global Code Review', + }, + async (input) => response(await buildGlobalCodeReview(input)), + ); + + server.registerTool( + 'kt_finish_task', + { + description: '一键生成任务收尾包:项目状态、验证计划、可选验证执行、全局 review、历史产物清理预览/执行和停止条件。', + inputSchema: { + changeType: z.enum(taskTypeValues).default('general'), + cleanupHistory: z.boolean().default(false), + includeRootScan: z.boolean().default(true), + keepHistory: z.number().int().min(0).max(50).default(3), + maxFindingsPerProject: z.number().int().min(1).max(200).default(20), + projects: z.array(z.string()).default([...defaultReviewProjects]), + runValidation: z.boolean().default(false), + }, + title: 'KT Finish Task', + }, + async (input) => response(await buildFinishTask(input)), + ); + + server.registerTool( + 'kt_commit_plan', + { + description: '按 KT 多仓库约定生成提交计划:按仓库分组、建议 commit message、提交命令和提交前检查。', + inputSchema: { + includePush: z.boolean().default(false), + message: z.string().optional(), + projects: z.array(z.string()).default([...defaultReviewProjects]), + }, + title: 'KT Commit Plan', + }, + async (input) => response(await buildCommitPlan(input)), + ); + + server.registerTool( + 'kt_push_plan', + { + description: '按 KT 多仓库约定生成推送计划:检查本地状态、待推提交、push 命令和远程异常提醒。', + inputSchema: { + branch: z.string().optional(), + projects: z.array(z.string()).default([...defaultReviewProjects]), + remote: z.string().default('origin'), + }, + title: 'KT Push Plan', + }, + async (input) => response(await buildPushPlan(input)), + ); + + server.registerTool( + 'kt_business_test_plan', + { + description: '生成 KT 固化业务链路测试计划:Admin 登录、博客 CRUD、QQBot 扫码/自动回复、Web/Playground 鉴权回跳。', + inputSchema: { + baseUrl: z.string().optional(), + environment: z.enum(['local', 'remote']).default('local'), + flow: z + .enum([ + 'admin-login', + 'blog-crud', + 'qqbot-account-scan', + 'qqbot-auto-reply', + 'web-playground-auth', + 'all', + ]) + .default('all'), + }, + title: 'KT Business Test Plan', + }, + async (input) => response(buildBusinessTestPlan(input)), + ); + + server.registerTool( + 'kt_remote_health_check', + { + description: '生成或执行飞牛 NAS 远程只读健康检查:SSH、Docker、Gitea、Jenkins、MySQL、MinIO、Mosquitto、NapCat、k3d/K8s。', + inputSchema: { + execute: z.boolean().default(false), + host: z.string().optional(), + port: z.number().int().min(1).max(65_535).default(2202), + services: z.array(z.string()).default([]), + sshTarget: z.string().optional(), + }, + title: 'KT Remote Health Check', + }, + async (input) => response(await buildRemoteHealthCheck(input)), + ); + + server.registerTool( + 'kt_db_sync_plan', + { + description: '生成 KT 数据库同步安全向导:源/目标确认、GTID 规避、目标备份、行数校验和通过后删除备份库。', + inputSchema: { + backupName: z.string().optional(), + source: z.enum(['local', 'remote']).default('local'), + target: z.enum(['local', 'remote']).default('remote'), + }, + title: 'KT DB Sync Plan', + }, + async (input) => response(buildDbSyncPlan(input)), + ); + + server.registerTool( + 'kt_component_workflow', + { + description: '按 KT 专项组件/链路输出防踩坑工作流:KtTable、BlogArgon、AdminAuth、QQBot。', + inputSchema: { + target: z.enum(['AdminAuth', 'BlogArgon', 'KtTable', 'QQBot']).default('KtTable'), + }, + title: 'KT Component Workflow', + }, + async (input) => response(buildComponentWorkflow(input)), + ); + + server.registerTool( + 'kt_append_task_record', + { + description: '生成或写入 TASKS.md 改动记录。默认 dryRun=true,只预览不落盘。', + inputSchema: { + content: z.string().optional(), + date: z.string().optional(), + dryRun: z.boolean().default(true), + scope: z.array(z.string()).default([]), + testCase: z.string().optional(), + title: z.string(), + verification: z.string().optional(), + }, + title: 'Append KT Task Record', + }, + async (input) => response(appendTaskRecord(input)), + ); + + server.registerTool( + 'kt_commit_checklist', + { + description: '生成 KT 子仓库提交前检查清单,并校验 commit message 是否符合约定。', + inputSchema: { + message: z.string().optional(), + project: z.string().default('root'), + }, + title: 'KT Commit Checklist', + }, + async (input) => response(await createCommitChecklist(input)), + ); +} diff --git a/src/selfTest.ts b/src/selfTest.ts new file mode 100644 index 0000000..bc3f2cf --- /dev/null +++ b/src/selfTest.ts @@ -0,0 +1,74 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +import { registeredToolNames } from './core/constants.js'; +import { resolveInsideRoot } from './core/workspace.js'; +import { buildGuardrails } from './tools/guardrails.js'; +import { cleanupHistoryArtifacts } from './tools/cleanup.js'; +import { inspectProject } from './tools/inspect.js'; +import { prepareTask, readWorkflowContext } from './tools/task.js'; +import { buildGlobalCodeReview } from './tools/review.js'; +import { buildBusinessTestPlan } from './tools/testing.js'; +import { buildVerificationPlan } from './tools/verification.js'; +import { buildCommitPlan, buildComponentWorkflow, buildDbSyncPlan, buildFinishTask, buildPushPlan, buildRemoteHealthCheck } from './tools/workflow.js'; +export async function runSelfTest(): Promise { + const data = { + context: readWorkflowContext({ taskRecordCount: 2 }), + guardrails: buildGuardrails({ + project: 'mcp', + taskType: 'mcp', + userRequest: '扩展 KT 工作区 MCP 可复用能力', + }), + historyCleanup: cleanupHistoryArtifacts({ + dryRun: true, + keep: 3, + }), + inspectAdmin: await inspectProject({ includeGit: false, project: 'admin' }), + prepareTask: await prepareTask({ + includeGit: false, + project: 'mcp', + taskType: 'mcp', + userRequest: '扩展 KT 工作区 MCP 可复用能力', + }), + review: await buildGlobalCodeReview({ + includeContentScan: true, + includeRootScan: true, + maxFindingsPerProject: 10, + projects: ['mcp'], + }), + businessTestPlan: buildBusinessTestPlan({ + flow: 'qqbot-auto-reply', + }), + commitPlan: await buildCommitPlan({ + projects: ['mcp'], + }), + componentWorkflow: buildComponentWorkflow({ + target: 'KtTable', + }), + dbSyncPlan: buildDbSyncPlan({}), + finishTask: await buildFinishTask({ + cleanupHistory: false, + projects: ['mcp'], + runValidation: false, + }), + pushPlan: await buildPushPlan({ + projects: ['mcp'], + }), + remoteHealthCheck: await buildRemoteHealthCheck({ + execute: false, + services: ['ssh', 'docker'], + }), + verification: buildVerificationPlan({ + changeType: 'mcp', + project: 'mcp', + }), + }; + const outputDir = resolveInsideRoot('.codex-verify/ktWorkflow'); + mkdirSync(outputDir, { recursive: true }); + writeFileSync( + path.join(outputDir, 'self-test.json'), + JSON.stringify(data, null, 2), + 'utf8', + ); + console.log(JSON.stringify({ ok: true, outputDir, tools: registeredToolNames.length }, null, 2)); +} diff --git a/src/server.ts b/src/server.ts index 4c697b2..a9b0474 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,1177 +1,31 @@ #!/usr/bin/env node -import { execFile } from 'node:child_process'; -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - statSync, - writeFileSync, -} from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; - import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; -import type { - ApiTestPlanInput, - CleanupHistoryInput, - CleanupProcessPlanInput, - CommitChecklistInput, - EnvPolicyInput, - ExecResult, - GuardrailSet, - GuardrailsResult, - GuardrailsInput, - InspectAllProjectsInput, - InspectProjectInput, - McpTextResponse, - PackageInfo, - PackageJsonLike, - PageTestCaseInput, - PageTestCaseResult, - PrepareTaskInput, - ProjectAlias, - RepoType, - ResolvedProject, - RiskScanInput, - TaskRecordInput, - TaskType, - VerificationPlanResult, - VerificationPlanInput, - WorkflowContextInput, -} from './types.js'; - -const execFileAsync = promisify(execFile); -const dirname = path.dirname(fileURLToPath(import.meta.url)); -const workspaceRoot = path.resolve( - process.env.KT_WORKSPACE_ROOT || path.join(dirname, '../../..'), -); - -const projectAliases = { - admin: 'Vue/kt-template-admin', - api: 'Node/kt-template-online-api', - blog: 'Vue/kt-blog-web', - mcp: 'mcp/ktWorkflow', - playground: 'Vue/kt-template-online-playground', - root: '.', - web: 'Vue/kt-template-online-web', -} satisfies Record; - -const projectLabels = { - admin: 'Vue Admin 后台', - api: 'NestJS API 后端', - blog: 'Blog Web 博客前台', - mcp: 'KT Workflow MCP', - playground: 'Playground 编辑器', - root: 'KT 协作根目录', - web: 'Web 前台展示', -} satisfies Record; - -const taskTypeValues = [ - 'api', - 'backend', - 'commit', - 'database', - 'deploy', - 'docs', - 'frontend', - 'general', - 'mcp', - 'page', - 'style', -] as const satisfies readonly TaskType[]; - -const registeredToolNames = [ - 'kt_read_context', - 'kt_inspect_project', - 'kt_inspect_all_projects', - 'kt_guardrails', - 'kt_prepare_task', - 'kt_suggest_verification', - 'kt_create_page_test_case', - 'kt_api_test_plan', - 'kt_cleanup_history', - 'kt_cleanup_process_plan', - 'kt_env_policy', - 'kt_risk_scan', - 'kt_append_task_record', - 'kt_commit_checklist', -] as const; - -const defaultHistoryRoots = [ - 'test-artifacts', - '.codex-test-logs', - 'codex-test-logs', - '.codex-verify', - '.playwright-mcp', - '.codex-db-sync', -] as const; - -function toPosix(value: string): string { - return value.replaceAll(path.sep, '/'); -} - -function resolveInsideRoot(target = '.'): string { - const absoluteTarget = path.isAbsolute(target) - ? path.resolve(target) - : path.resolve(workspaceRoot, target); - const relative = path.relative(workspaceRoot, absoluteTarget); - - if (relative.startsWith('..') || path.isAbsolute(relative)) { - throw new Error(`Path is outside KT workspace: ${target}`); - } - - return absoluteTarget; -} - -function resolveProject(project = 'root'): ResolvedProject { - const normalized = (projectAliases as Record)[project] || project; - const absolutePath = resolveInsideRoot(normalized); - const alias = (Object.entries(projectAliases) as Array<[ProjectAlias, string]>).find( - ([, relativePath]) => resolveInsideRoot(relativePath) === absolutePath, - )?.[0]; - - return { - alias: alias || null, - label: alias ? projectLabels[alias] : '自定义路径', - path: absolutePath, - relativePath: toPosix(path.relative(workspaceRoot, absolutePath)) || '.', - }; -} - -function readText(relativePath: string, fallback = ''): string { - const filePath = resolveInsideRoot(relativePath); - if (!existsSync(filePath)) return fallback; - return readFileSync(filePath, 'utf8'); -} - -function readTextIfExists(filePath: string, fallback = ''): string { - if (!existsSync(filePath)) return fallback; - return readFileSync(filePath, 'utf8'); -} - -function readJson>(filePath: string): T | null { - if (!existsSync(filePath)) return null; - - try { - return JSON.parse(readFileSync(filePath, 'utf8')) as T; - } catch { - return null; - } -} - -function listImmediateChildren(targetPath: string): Array<{ name: string; type: 'directory' | 'file' }> { - if (!existsSync(targetPath)) return []; - - return readdirSync(targetPath, { withFileTypes: true }).map((item) => ({ - name: item.name, - type: item.isDirectory() ? 'directory' : 'file', - })); -} - -function getRecentTaskRecords(count = 5): string { - const tasks = readText('TASKS.md'); - const records = tasks - .split(/\n(?=### \d{4}-\d{2}-\d{2}:)/) - .filter((section) => section.startsWith('### ')) - .slice(0, count); - - return records.join('\n\n').trim(); -} - -function getErrorText(error: unknown, key: 'message' | 'stderr' | 'stdout'): string { - const value = (error as Partial>)[key]; - if (typeof value === 'string') return value; - if (Buffer.isBuffer(value)) return value.toString('utf8'); - return ''; -} - -async function tryExecFile(command: string, args: string[], cwd: string): Promise { - try { - const result = await execFileAsync(command, args, { - cwd, - maxBuffer: 1024 * 1024 * 4, - timeout: 20_000, - windowsHide: true, - }); - return { - ok: true, - stderr: result.stderr.trim(), - stdout: result.stdout.trim(), - }; - } catch (error) { - return { - ok: false, - stderr: `${getErrorText(error, 'stderr') || getErrorText(error, 'message')}`.trim(), - stdout: getErrorText(error, 'stdout').trim(), - }; - } -} - -function detectRepoType(projectPath: string): RepoType { - if (existsSync(path.join(projectPath, '.git'))) return 'git'; - if (existsSync(path.join(projectPath, '.svn'))) return 'svn'; - return 'none'; -} - -function detectPackageManager(projectPath: string): PackageInfo { - const packageJsonPath = path.join(projectPath, 'package.json'); - const packageJson = readJson(packageJsonPath); - const lockfiles = { - npm: existsSync(path.join(projectPath, 'package-lock.json')), - pnpm: existsSync(path.join(projectPath, 'pnpm-lock.yaml')), - yarn: existsSync(path.join(projectPath, 'yarn.lock')), - }; - const packageManagerSpec = packageJson?.packageManager || null; - const packageManager = - packageManagerSpec?.split('@')[0] || - (lockfiles.pnpm && 'pnpm') || - (lockfiles.yarn && 'yarn') || - (lockfiles.npm && 'npm') || - null; - - return { - engines: packageJson?.engines || {}, - lockfiles, - packageManager, - packageManagerSpec, - scripts: packageJson?.scripts || {}, - }; -} - -async function inspectProject(input: InspectProjectInput): Promise> { - const project = resolveProject(input.project); - const repoType = detectRepoType(project.path); - const packageInfo = detectPackageManager(project.path); - const nodeVersionFile = ['.node-version', '.nvmrc'].find((file) => - existsSync(path.join(project.path, file)), - ); - const nodeVersion = nodeVersionFile - ? readFileSync(path.join(project.path, nodeVersionFile), 'utf8').trim() - : null; - const envFiles = ['.env.development', '.env.production', '.env.example'].map( - (file) => ({ - exists: existsSync(path.join(project.path, file)), - file, - }), - ); - const childSummary = listImmediateChildren(project.path).slice(0, 60); - const git = - repoType === 'git' && input.includeGit !== false - ? { - branch: await tryExecFile('git', ['branch', '--show-current'], project.path), - remote: await tryExecFile('git', ['remote', '-v'], project.path), - status: await tryExecFile('git', ['status', '--short'], project.path), - } - : null; - - return { - childSummary, - envFiles, - git, - nodeVersion, - nodeVersionFile: nodeVersionFile || null, - packageInfo, - project, - repoType, - }; -} - -function getTypecheckCommand( - project: ResolvedProject, - packageManager: string, - scripts: Record, -): string | null { - if (project.alias === 'admin') { - return 'pnpm -F @vben/web-antdv-next run typecheck'; - } - if (project.alias === 'blog') { - return 'pnpm run type-check'; - } - if (project.alias === 'web') { - return 'pnpm exec vue-tsc --noEmit'; - } - if (scripts.typecheck) return `${packageManager} run typecheck`; - if (scripts['check:type']) return `${packageManager} run check:type`; - if (scripts.check) return `${packageManager} run check`; - return null; -} - -function buildVerificationPlan(input: VerificationPlanInput): VerificationPlanResult { - const project = resolveProject(input.project); - const repoType = detectRepoType(project.path); - const packageInfo = detectPackageManager(project.path); - const packageManager = packageInfo.packageManager || 'pnpm'; - const scripts = packageInfo.scripts; - const changeType = input.changeType || 'general'; - const commands = repoType === 'git' ? ['git status --short'] : []; - const notes = [ - '先确认仓库类型,再确认包管理器和 Node 版本。', - '只跑当前变更所需的最轻验证,不默认全量 build。', - ]; - - const typecheck = getTypecheckCommand(project, packageManager, scripts); - - if (changeType === 'style' && project.alias === 'admin') { - commands.push( - 'pnpm exec stylelint "apps/web-antdv-next/src/components/**/*.{scss,css,vue}"', - ); - } - - if (repoType === 'none') { - notes.push('当前目录不是独立 Git/SVN 仓库,提交和 diff 检查需要切到对应子仓库执行。'); - } - - if ( - typecheck && - ['api', 'frontend', 'general', 'page', 'style'].includes(changeType) - ) { - commands.push(typecheck); - } - - if (project.alias === 'api') { - if (scripts.lint) commands.push(`${packageManager} run lint`); - if (scripts.test && ['api', 'backend', 'general'].includes(changeType)) { - commands.push(`${packageManager} test -- --passWithNoTests`); - } - if (['api', 'backend'].includes(changeType)) { - notes.push('接口改动需要本地启动或复用本地服务,真实调用对应接口一次。'); - } - } - - if (project.alias === 'playground' && scripts['build-preview'] && changeType === 'deploy') { - commands.push(`${packageManager} run build-preview`); - } - - if (project.alias === 'web' && scripts.build && changeType === 'deploy') { - commands.push(`${packageManager} run build`); - } - - if (project.alias === 'mcp') { - if (scripts['self-test']) commands.push(`${packageManager} run self-test`); - notes.push('MCP 变更需要用真实 SDK Client 做 listTools/callTool smoke test。'); - } - - if (input.includePageTest || changeType === 'page') { - notes.push( - '页面级测试必须先写测试用例,使用可见浏览器或截图记录关键步骤,同一用例最多三轮闭环。', - ); - } - - if (repoType === 'git') commands.push('git diff --check'); - - return { - changeType, - commands: Array.from(new Set(commands)), - notes, - project, - repoType, - }; -} - -function createPageTestCase(input: PageTestCaseInput): PageTestCaseResult { - const project = resolveProject(input.project); - const title = input.title || `${project.label} 页面级测试`; - const steps = - (input.steps?.length ?? 0) > 0 - ? input.steps! - : ['打开入口 URL', '完成登录或注入测试登录态', '执行用户关键路径操作', '保存关键步骤截图']; - const assertions = - (input.assertions?.length ?? 0) > 0 - ? input.assertions! - : ['页面无明显渲染错误', '控制台无 error', '关键接口返回成功', '核心 DOM 或业务状态符合预期']; - - return { - account: input.account || '按当前环境使用数据库管理员账号或测试专用账号', - assertions, - cleanup: [ - '关闭浏览器实例', - '清理本次启动的 Node/Vite 进程', - '保存截图和 result.json', - '调用 kt_cleanup_history,仅保留最近 3 轮历史产物', - ], - entryUrl: input.entryUrl || '待填写', - maxRounds: 3, - project, - protocol: [ - '测试前先写用例。', - '失败后先记录复现证据,再排查和修复。', - '按同一用例复测。', - '第三轮仍失败时停止并等待用户建议。', - ], - steps, - title, - }; -} - -async function inspectAllProjects(input: InspectAllProjectsInput = {}): Promise> { - const aliases = input.includeRoot === false - ? Object.keys(projectAliases).filter((key) => key !== 'root') - : Object.keys(projectAliases); - const inspections = await Promise.all( - aliases.map((alias) => - inspectProject({ - includeGit: input.includeGit !== false, - project: alias, - }), - ), - ); - - return { - inspections, - workspaceRoot, - }; -} - -function buildGuardrails(input: GuardrailsInput): GuardrailsResult { - const project = resolveProject(input.project); - const taskType = input.taskType || input.changeType || 'general'; - const paths = input.paths || []; - const guardrails: GuardrailSet = { - beforeEdit: [ - '先读最近的 AGENTS.md,再读 TASKS.md 和 SKILLS.md。', - '先检查目标仓库状态,识别用户已有改动,不覆盖、不回滚无关文件。', - '先快速扫现有代码风格和目录结构,再决定改动方式。', - '搜索优先用 rg,路径和接口名尽量保持可 grep。', - ], - duringEdit: [ - '改动范围收敛到本次需求相关文件。', - '复杂度高的地方加简短注释,避免空泛解释。', - '真实环境配置不写入提交内容,优先只维护 .env.example。', - '前端实现遵循现有 antdv-next/Vben 约定,不引入无关抽象。', - ], - doNot: [ - '不要执行 git reset --hard、git checkout -- 等破坏性回滚。', - '不要在用户未要求时自动 push。', - '不要把真实密钥、数据库密码、生产 env 写进可提交文件。', - '不要为了验证长期保留 Node/Vite/浏览器进程。', - ], - verification: buildVerificationPlan({ - changeType: taskType, - includePageTest: input.includePageTest || taskType === 'page', - project: input.project, - }), - }; - - if (['api', 'backend'].includes(taskType)) { - guardrails.beforeEdit.push('接口改动先确认 DTO、Controller、Service、返回结构和鉴权链路。'); - guardrails.duringEdit.push('接口成功返回统一保持 code=200/msg/data,错误返回 err 字段。'); - guardrails.verification.notes.push('接口改完必须本地真实调用一次对应接口。'); - } - - if (taskType === 'page' || input.includePageTest) { - guardrails.beforeEdit.push('页面级测试先写用例,再打开可视化浏览器执行。'); - guardrails.verification.pageTestCase = createPageTestCase({ - project: input.project, - title: input.userRequest || `${project.label} 页面级测试`, - }); - } - - if (taskType === 'database') { - guardrails.beforeEdit.push('数据库同步或修复前先确认源库、目标库、备份库和回滚点。'); - guardrails.doNot.push('不要在未确认目标库前执行覆盖、drop、truncate。'); - } - - if (taskType === 'deploy') { - guardrails.duringEdit.push('部署配置保留真正能提升运行稳定性的内容,删掉噪音配置。'); - guardrails.doNot.push('不要在未要求时触发真实部署或改动远程服务。'); - } - - if (taskType === 'mcp') { - guardrails.duringEdit.push('MCP 工具描述要明确边界,默认输出建议和检查清单,不做高风险副作用。'); - guardrails.verification.notes.push('MCP 变更至少跑 self-test 和真实 SDK Client smoke test。'); - } - - return { - guardrails, - paths, - project, - taskType, - userRequest: input.userRequest || '', - }; -} - -function createApiTestPlan(input: ApiTestPlanInput): Record { - const project = resolveProject(input.project || 'api'); - const method = (input.method || 'GET').toUpperCase(); - const endpoint = input.endpoint || '/api/待填写'; - const baseUrl = input.baseUrl || 'http://localhost:3000'; - const headers = []; - - if (input.auth === 'bearer') { - headers.push('-H "Authorization: Bearer "'); - } - - if (input.contentType !== false && ['POST', 'PUT', 'PATCH'].includes(method)) { - headers.push('-H "Content-Type: application/json"'); - } - - const body = - input.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) - ? ` -d '${JSON.stringify(input.body)}'` - : ''; - const curl = `curl -i -X ${method} ${headers.join(' ')} "${baseUrl}${endpoint}"${body}`; - - return { - assertions: [ - 'HTTP 状态码符合预期。', - '成功响应结构为 { code: 200, msg: string, data: any }。', - '成功响应不返回 err 字段。', - ...(input.expectedFields || []).map((field) => `data 中包含 ${field}`), - ], - curl, - endpoint, - method, - notes: [ - '接口改动后不要只跑类型检查,必须真实调用对应接口一次。', - '如依赖登录态,先调用 login 获取 token,再带 Authorization 复测。', - ], - project, - }; -} - -function createCleanupProcessPlan(input: CleanupProcessPlanInput): Record { - const project = resolveProject(input.project); - const escapedProjectPath = project.path.replaceAll('\\', '\\\\'); - const portFilters = (input.ports || []).map((port) => ({ - command: `Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique`, - port, - })); - - return { - commands: [ - `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${escapedProjectPath}*' -and $_.Name -match 'node|pnpm|npm|yarn' } | Select-Object ProcessId,Name,CommandLine`, - '确认命中进程只属于本次验证后,再用 Stop-Process -Id 结束。', - ...portFilters.map((item) => item.command), - ], - notes: [ - '先按项目路径筛选,再按端口交叉确认。', - '不要批量结束与本次验证无关的 Node 进程。', - '页面测试结束后清理浏览器和 Vite/Nest 进程,避免内存累积。', - ], - project, - }; -} - -interface HistoryEntry { - lastWriteTime: string; - mtimeMs: number; - name: string; - path: string; - relativePath: string; - type: 'directory' | 'file'; -} - -function listHistoryEntries(rootPath: string, preservedNames: Set): HistoryEntry[] { - if (!existsSync(rootPath)) return []; - - return readdirSync(rootPath, { withFileTypes: true }) - .filter((item) => !preservedNames.has(item.name)) - .map((item) => { - const entryPath = path.join(rootPath, item.name); - const stats = statSync(entryPath); - - return { - lastWriteTime: stats.mtime.toISOString(), - mtimeMs: stats.mtimeMs, - name: item.name, - path: entryPath, - relativePath: toPosix(path.relative(workspaceRoot, entryPath)), - type: item.isDirectory() ? ('directory' as const) : ('file' as const), - }; - }) - .sort((a, b) => b.mtimeMs - a.mtimeMs); -} - -function cleanupHistoryArtifacts(input: CleanupHistoryInput = {}): Record { - const keep = Math.max(0, Math.min(input.keep ?? 3, 50)); - const dryRun = input.dryRun ?? true; - const roots = (input.roots?.length ?? 0) > 0 ? input.roots! : [...defaultHistoryRoots]; - const result = { - deleted: [] as Array>, - dryRun, - keep, - preserved: [] as Array>, - roots: [] as Array<{ - candidates: number; - deleteCount: number; - keepCount: number; - preservedNames: string[]; - root: string; - }>, - skipped: [] as Array<{ - reason: string; - root: string; - }>, - }; - - for (const root of roots) { - const rootPath = resolveInsideRoot(root); - const rootRelativePath = toPosix(path.relative(workspaceRoot, rootPath)) || '.'; - - if (!existsSync(rootPath)) { - result.skipped.push({ - reason: 'not_exists', - root: rootRelativePath, - }); - continue; - } - - const preservedNames = new Set(rootRelativePath === 'test-artifacts' ? ['_templates'] : []); - const entries = listHistoryEntries(rootPath, preservedNames); - const preserved = entries.slice(0, keep); - const deleting = entries.slice(keep); - - result.roots.push({ - candidates: entries.length, - deleteCount: deleting.length, - keepCount: preserved.length, - preservedNames: Array.from(preservedNames), - root: rootRelativePath, - }); - result.preserved.push(...preserved.map(({ mtimeMs, path: _path, ...entry }) => entry)); - - for (const entry of deleting) { - if (!dryRun) { - rmSync(entry.path, { - force: true, - recursive: true, - }); - } - const { mtimeMs, path: _path, ...publicEntry } = entry; - result.deleted.push(publicEntry); - } - } - - return result; -} - -function parseCliCleanupArgs(args: string[]): CleanupHistoryInput { - const keepArg = args.find((arg) => arg.startsWith('--keep=')); - const rootsArg = args.find((arg) => arg.startsWith('--roots=')); - - return { - dryRun: args.includes('--dry-run'), - keep: keepArg ? Number.parseInt(keepArg.split('=')[1], 10) : 3, - roots: rootsArg ? rootsArg.split('=')[1].split(',').filter(Boolean) : [...defaultHistoryRoots], - }; -} - -function buildEnvPolicy(input: EnvPolicyInput): Record { - const project = resolveProject(input.project); - const envFiles = ['.env.development', '.env.production', '.env.example'].map((file) => { - const absolutePath = path.join(project.path, file); - return { - exists: existsSync(absolutePath), - file, - relativePath: toPosix(path.relative(workspaceRoot, absolutePath)), - }; - }); - const gitignore = readTextIfExists(path.join(project.path, '.gitignore')); - const ignoredRules = gitignore - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - - return { - envFiles, - ignoredRules: ignoredRules.filter((line) => line.includes('.env')), - policy: [ - '真实开发和生产配置只保留在本地,不提交。', - '.env.example 用于维护变量名和示例值。', - '接口、数据库、WordPress、MinIO 等真实账号密码不得写入文档或提交记录。', - ], - project, - warnings: envFiles - .filter((file) => file.file !== '.env.example' && file.exists) - .map((file) => `${file.file} 存在,提交前确认已被 .gitignore 忽略。`), - }; -} - -async function scanTaskRisk(input: RiskScanInput): Promise> { - const project = resolveProject(input.project); - let changedFiles = input.changedFiles || []; - - if (changedFiles.length === 0 && detectRepoType(project.path) === 'git') { - const status = await tryExecFile('git', ['status', '--short'], project.path); - if (status.ok) { - changedFiles = status.stdout - .split(/\r?\n/) - .map((line) => line.slice(3).trim()) - .filter(Boolean); - } - } - - const risks: Array<{ - file: string; - level: 'high' | 'medium'; - reason: string; - suggestion: string; - }> = []; - const addRisk = ( - level: 'high' | 'medium', - file: string, - reason: string, - suggestion: string, - ) => { - risks.push({ file, level, reason, suggestion }); - }; - - for (const file of changedFiles) { - const normalized = file.replaceAll('\\', '/'); - - if (/\.env\.(development|production)$/.test(normalized)) { - addRisk('high', file, '真实环境配置文件有改动。', '确认不会提交真实密钥和密码。'); - } - - if (/package\.json$|pnpm-lock\.yaml$|package-lock\.json$|yarn\.lock$/.test(normalized)) { - addRisk('medium', file, '依赖或脚本发生变化。', '确认锁文件与 package.json 同步,并运行对应轻量验证。'); - } - - if (normalized.includes('/components/ktTable/')) { - addRisk( - 'medium', - file, - 'KtTable 是高频核心组件,布局和滚动性能容易回归。', - '保持原生 antdv-next 表格能力,避免监听 scroll/resize 做高频状态更新。', - ); - } - - if (/src\/admin\/|src\/.*controller|src\/.*service|src\/.*dto/.test(normalized)) { - addRisk('medium', file, '后端接口或业务逻辑改动。', '本地启动或复用服务后真实调用对应接口。'); - } - - if (/Jenkinsfile|deploy\/|k8s\/|nginx.*\.conf/.test(normalized)) { - addRisk('medium', file, '部署链路配置改动。', '只验证配置语法或生成产物,不主动触发远程部署。'); - } - } - - return { - changedFiles, - project, - risks, - safeDefaults: [ - '不覆盖用户已有改动。', - '只运行必要验证。', - '提交前重新检查 status 和 diff。', - ], - }; -} - -async function prepareTask(input: PrepareTaskInput): Promise> { - const project = resolveProject(input.project); - const taskType = input.taskType || 'general'; - const inspection = await inspectProject({ - includeGit: input.includeGit !== false, - project: input.project, - }); - const guardrails = buildGuardrails(input); - const riskScan = await scanTaskRisk({ - changedFiles: input.changedFiles || [], - project: input.project, - }); - const pageTestCase = - input.includePageTest || taskType === 'page' - ? createPageTestCase({ - entryUrl: input.entryUrl, - project: input.project, - title: input.userRequest || `${project.label} 页面级测试`, - }) - : null; - - return { - guardrails, - inspection, - nextSteps: [ - '阅读 workPacket.readFirst 中的上下文文件。', - '按 guardrails.beforeEdit 做开工前检查。', - '按最小范围修改。', - '按 verification.commands 和 notes 做轻量验证。', - '有文件改动时更新 TASKS.md。', - ], - pageTestCase, - project, - readFirst: [ - 'AGENTS.md', - 'SKILLS.md', - 'TASKS.md', - `${project.relativePath}/AGENTS.md`, - `${project.relativePath}/package.json`, - ], - riskScan, - taskRecordDraft: buildTaskRecord({ - content: input.userRequest || '待补充', - scope: [project.relativePath], - testCase: pageTestCase ? pageTestCase.title : '本次无页面测试或待补充。', - title: input.recordTitle || input.userRequest || `${project.label} 任务记录`, - verification: guardrails.guardrails.verification.commands.join(';') || '待补充。', - }), - taskType, - userRequest: input.userRequest || '', - }; -} - -function formatDateInShanghai(date = new Date()): string { - const parts = new Intl.DateTimeFormat('en-CA', { - day: '2-digit', - month: '2-digit', - timeZone: 'Asia/Shanghai', - year: 'numeric', - }).formatToParts(date); - const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); - return `${map.year}-${map.month}-${map.day}`; -} - -function buildTaskRecord(input: TaskRecordInput): string { - const date = input.date || formatDateInShanghai(); - const scope = (input.scope?.length ?? 0) > 0 ? input.scope!.join('、') : '待补充'; - const content = input.content || '待补充'; - const testCase = input.testCase || '本次无页面测试或待补充。'; - const verification = input.verification || '待补充。'; - - return `### ${date}:${input.title}\n\n- 范围:${scope}\n- 内容:${content}\n- 页面测试用例:${testCase}\n- 验证:${verification}\n`; -} - -function appendTaskRecord(input: TaskRecordInput): Record { - const record = buildTaskRecord(input); - const tasksPath = resolveInsideRoot('TASKS.md'); - const current = readFileSync(tasksPath, 'utf8'); - const marker = '## 改动记录'; - const markerIndex = current.indexOf(marker); - - if (markerIndex === -1) { - throw new Error('TASKS.md does not contain ## 改动记录'); - } - - const insertAt = current.indexOf('\n', markerIndex); - const next = `${current.slice(0, insertAt + 1)}\n${record}\n${current.slice(insertAt + 1)}`; - - if (!input.dryRun) { - writeFileSync(tasksPath, next, 'utf8'); - } - - return { - dryRun: input.dryRun, - record, - target: tasksPath, - }; -} - -async function createCommitChecklist(input: CommitChecklistInput): Promise> { - const inspection = await inspectProject({ - includeGit: true, - project: input.project, - }); - const message = input.message || ''; - const messageOk = - !message || - /^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([^)]+\))?: .*[一-龥]/u.test( - message, - ); - - return { - checks: [ - '确认只包含本次任务相关文件。', - '先运行对应轻量验证命令。', - '每个独立 Git 仓库单独 commit。', - '没有用户明确要求时不 push。', - ], - inspection, - message, - messageOk, - messageRule: '英文 type 前缀 + 中文描述,例如 feat: 功能性提交', - }; -} - -function readWorkflowContext(input: WorkflowContextInput): Record { - const projects = Object.keys(projectAliases).map((key) => { - const project = resolveProject(key); - return { - ...project, - exists: existsSync(project.path), - repoType: existsSync(project.path) ? detectRepoType(project.path) : 'missing', - }; - }); - - return { - docs: { - agents: readText('AGENTS.md'), - recentTasks: getRecentTaskRecords(input.taskRecordCount), - skills: readText('SKILLS.md'), - }, - projects, - workspaceRoot, - }; -} - -function response(data: unknown): McpTextResponse { - return { - content: [ - { - text: typeof data === 'string' ? data : JSON.stringify(data, null, 2), - type: 'text', - }, - ], - }; -} - -async function runSelfTest(): Promise { - const data = { - context: readWorkflowContext({ taskRecordCount: 2 }), - guardrails: buildGuardrails({ - project: 'mcp', - taskType: 'mcp', - userRequest: '扩展 KT 工作区 MCP 可复用能力', - }), - historyCleanup: cleanupHistoryArtifacts({ - dryRun: true, - keep: 3, - }), - inspectAdmin: await inspectProject({ includeGit: false, project: 'admin' }), - prepareTask: await prepareTask({ - includeGit: false, - project: 'mcp', - taskType: 'mcp', - userRequest: '扩展 KT 工作区 MCP 可复用能力', - }), - verification: buildVerificationPlan({ - changeType: 'mcp', - project: 'mcp', - }), - }; - const outputDir = resolveInsideRoot('.codex-verify/ktWorkflow'); - mkdirSync(outputDir, { recursive: true }); - writeFileSync( - path.join(outputDir, 'self-test.json'), - JSON.stringify(data, null, 2), - 'utf8', - ); - console.log(JSON.stringify({ ok: true, outputDir, tools: registeredToolNames.length }, null, 2)); -} - -function registerTools(server: McpServer): void { - server.registerTool( - 'kt_read_context', - { - description: '读取 KT 根目录 AGENTS/SKILLS/TASKS,并返回项目清单和最近任务记录。', - inputSchema: { - taskRecordCount: z.number().int().min(0).max(20).default(5), - }, - title: 'Read KT Workflow Context', - }, - async (input) => response(readWorkflowContext(input)), - ); - - server.registerTool( - 'kt_inspect_project', - { - description: '检查 KT 子项目的仓库类型、包管理器、Node 版本、env 文件和 Git 状态。', - inputSchema: { - includeGit: z.boolean().default(true), - project: z.string().default('root'), - }, - title: 'Inspect KT Project', - }, - async (input) => response(await inspectProject(input)), - ); - - server.registerTool( - 'kt_inspect_all_projects', - { - description: '一次性检查 KT 工作区所有已知项目,适合多仓库联动任务开工前使用。', - inputSchema: { - includeGit: z.boolean().default(true), - includeRoot: z.boolean().default(true), - }, - title: 'Inspect All KT Projects', - }, - async (input) => response(await inspectAllProjects(input)), - ); - - server.registerTool( - 'kt_guardrails', - { - description: '按任务类型生成 KT 防偏差清单,包含开工、改动、禁止项和验证约束。', - inputSchema: { - changeType: z.enum(taskTypeValues).optional(), - includePageTest: z.boolean().default(false), - paths: z.array(z.string()).default([]), - project: z.string().default('root'), - taskType: z.enum(taskTypeValues).default('general'), - userRequest: z.string().optional(), - }, - title: 'KT Guardrails', - }, - async (input) => response(buildGuardrails(input)), - ); - - server.registerTool( - 'kt_prepare_task', - { - description: '生成一份 KT 任务 work packet:上下文、项目检查、防偏差清单、风险扫描和验证计划。', - inputSchema: { - changedFiles: z.array(z.string()).default([]), - entryUrl: z.string().optional(), - includeGit: z.boolean().default(true), - includePageTest: z.boolean().default(false), - paths: z.array(z.string()).default([]), - project: z.string().default('root'), - recordTitle: z.string().optional(), - taskType: z.enum(taskTypeValues).default('general'), - userRequest: z.string().optional(), - }, - title: 'Prepare KT Task', - }, - async (input) => response(await prepareTask(input)), - ); - - server.registerTool( - 'kt_suggest_verification', - { - description: '按 KT 规则为指定项目和变更类型生成轻量验证命令与注意事项。', - inputSchema: { - changeType: z.enum(taskTypeValues).default('general'), - includePageTest: z.boolean().default(false), - project: z.string().default('root'), - }, - title: 'Suggest KT Verification', - }, - async (input) => response(buildVerificationPlan(input)), - ); - - server.registerTool( - 'kt_create_page_test_case', - { - description: '生成 KT 页面级可视化测试用例骨架,包含事不过三闭环规则。', - inputSchema: { - account: z.string().optional(), - assertions: z.array(z.string()).default([]), - entryUrl: z.string().optional(), - project: z.string().default('admin'), - steps: z.array(z.string()).default([]), - title: z.string().optional(), - }, - title: 'Create KT Page Test Case', - }, - async (input) => response(createPageTestCase(input)), - ); - - server.registerTool( - 'kt_api_test_plan', - { - description: '为后端接口改动生成真实调用测试计划和响应结构断言。', - inputSchema: { - auth: z.enum(['none', 'bearer']).default('bearer'), - baseUrl: z.string().default('http://localhost:3000'), - body: z.record(z.any()).optional(), - contentType: z.boolean().default(true), - endpoint: z.string().default('/api/待填写'), - expectedFields: z.array(z.string()).default([]), - method: z.enum(['DELETE', 'GET', 'PATCH', 'POST', 'PUT']).default('GET'), - project: z.string().default('api'), - }, - title: 'KT API Test Plan', - }, - async (input) => response(createApiTestPlan(input)), - ); - - server.registerTool( - 'kt_cleanup_history', - { - description: '清理 KT 根目录历史产物。默认 dryRun=true,只预览;执行时按 LastWriteTime 每个历史目录仅保留最近 3 轮,test-artifacts/_templates 永久保留。', - inputSchema: { - dryRun: z.boolean().default(true), - keep: z.number().int().min(0).max(50).default(3), - roots: z.array(z.string()).default([...defaultHistoryRoots]), - }, - title: 'KT Cleanup History Artifacts', - }, - async (input) => response(cleanupHistoryArtifacts(input)), - ); - - server.registerTool( - 'kt_cleanup_process_plan', - { - description: '生成按项目路径和端口清理验证进程的 PowerShell 检查命令,不直接杀进程。', - inputSchema: { - ports: z.array(z.number().int().min(1).max(65_535)).default([]), - project: z.string().default('root'), - }, - title: 'KT Cleanup Process Plan', - }, - async (input) => response(createCleanupProcessPlan(input)), - ); - - server.registerTool( - 'kt_env_policy', - { - description: '检查指定项目 env 文件现状,并输出 KT 环境配置提交策略。', - inputSchema: { - project: z.string().default('root'), - }, - title: 'KT Env Policy', - }, - async (input) => response(buildEnvPolicy(input)), - ); - - server.registerTool( - 'kt_risk_scan', - { - description: '按当前变更文件或传入文件列表扫描常见偏差风险和验证提醒。', - inputSchema: { - changedFiles: z.array(z.string()).default([]), - project: z.string().default('root'), - }, - title: 'KT Risk Scan', - }, - async (input) => response(await scanTaskRisk(input)), - ); - - server.registerTool( - 'kt_append_task_record', - { - description: '生成或写入 TASKS.md 改动记录。默认 dryRun=true,只预览不落盘。', - inputSchema: { - content: z.string().optional(), - date: z.string().optional(), - dryRun: z.boolean().default(true), - scope: z.array(z.string()).default([]), - testCase: z.string().optional(), - title: z.string(), - verification: z.string().optional(), - }, - title: 'Append KT Task Record', - }, - async (input) => response(appendTaskRecord(input)), - ); - - server.registerTool( - 'kt_commit_checklist', - { - description: '生成 KT 子仓库提交前检查清单,并校验 commit message 是否符合约定。', - inputSchema: { - message: z.string().optional(), - project: z.string().default('root'), - }, - title: 'KT Commit Checklist', - }, - async (input) => response(await createCommitChecklist(input)), - ); -} +import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js'; +import { buildGlobalCodeReview } from './tools/review.js'; +import { registerTools } from './registerTools.js'; +import { runSelfTest } from './selfTest.js'; if (process.argv.includes('--cleanup-history')) { console.log(JSON.stringify(cleanupHistoryArtifacts(parseCliCleanupArgs(process.argv)), null, 2)); +} else if (process.argv.includes('--global-review')) { + console.log( + JSON.stringify( + await buildGlobalCodeReview({ + includeContentScan: !process.argv.includes('--no-content-scan'), + includeRootScan: !process.argv.includes('--no-root-scan'), + }), + null, + 2, + ), + ); } else if (process.argv.includes('--self-test')) { await runSelfTest(); } else { const server = new McpServer({ name: 'kt-workflow', - version: '0.2.0', + version: '0.4.0', }); registerTools(server); await server.connect(new StdioServerTransport()); diff --git a/src/tools/cleanup.ts b/src/tools/cleanup.ts new file mode 100644 index 0000000..2006dae --- /dev/null +++ b/src/tools/cleanup.ts @@ -0,0 +1,132 @@ +import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'; +import path from 'node:path'; + +import type { CleanupHistoryInput, CleanupProcessPlanInput } from '../types.js'; +import { defaultHistoryRoots } from '../core/constants.js'; +import { resolveInsideRoot, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js'; +export function createCleanupProcessPlan(input: CleanupProcessPlanInput): Record { + const project = resolveProject(input.project); + const escapedProjectPath = project.path.replaceAll('\\', '\\\\'); + const portFilters = (input.ports || []).map((port) => ({ + command: `Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique`, + port, + })); + + return { + commands: [ + `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${escapedProjectPath}*' -and $_.Name -match 'node|pnpm|npm|yarn' } | Select-Object ProcessId,Name,CommandLine`, + '确认命中进程只属于本次验证后,再用 Stop-Process -Id 结束。', + ...portFilters.map((item) => item.command), + ], + notes: [ + '先按项目路径筛选,再按端口交叉确认。', + '不要批量结束与本次验证无关的 Node 进程。', + '页面测试结束后清理浏览器和 Vite/Nest 进程,避免内存累积。', + ], + project, + }; +} + +interface HistoryEntry { + lastWriteTime: string; + mtimeMs: number; + name: string; + path: string; + relativePath: string; + type: 'directory' | 'file'; +} + +function listHistoryEntries(rootPath: string, preservedNames: Set): HistoryEntry[] { + if (!existsSync(rootPath)) return []; + + return readdirSync(rootPath, { withFileTypes: true }) + .filter((item) => !preservedNames.has(item.name)) + .map((item) => { + const entryPath = path.join(rootPath, item.name); + const stats = statSync(entryPath); + + return { + lastWriteTime: stats.mtime.toISOString(), + mtimeMs: stats.mtimeMs, + name: item.name, + path: entryPath, + relativePath: toPosix(path.relative(workspaceRoot, entryPath)), + type: item.isDirectory() ? ('directory' as const) : ('file' as const), + }; + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs); +} + +export function cleanupHistoryArtifacts(input: CleanupHistoryInput = {}): Record { + const keep = Math.max(0, Math.min(input.keep ?? 3, 50)); + const dryRun = input.dryRun ?? true; + const roots = (input.roots?.length ?? 0) > 0 ? input.roots! : [...defaultHistoryRoots]; + const result = { + deleted: [] as Array>, + dryRun, + keep, + preserved: [] as Array>, + roots: [] as Array<{ + candidates: number; + deleteCount: number; + keepCount: number; + preservedNames: string[]; + root: string; + }>, + skipped: [] as Array<{ + reason: string; + root: string; + }>, + }; + + for (const root of roots) { + const rootPath = resolveInsideRoot(root); + const rootRelativePath = toPosix(path.relative(workspaceRoot, rootPath)) || '.'; + + if (!existsSync(rootPath)) { + result.skipped.push({ + reason: 'not_exists', + root: rootRelativePath, + }); + continue; + } + + const preservedNames = new Set(rootRelativePath === 'test-artifacts' ? ['_templates'] : []); + const entries = listHistoryEntries(rootPath, preservedNames); + const preserved = entries.slice(0, keep); + const deleting = entries.slice(keep); + + result.roots.push({ + candidates: entries.length, + deleteCount: deleting.length, + keepCount: preserved.length, + preservedNames: Array.from(preservedNames), + root: rootRelativePath, + }); + result.preserved.push(...preserved.map(({ mtimeMs, path: _path, ...entry }) => entry)); + + for (const entry of deleting) { + if (!dryRun) { + rmSync(entry.path, { + force: true, + recursive: true, + }); + } + const { mtimeMs, path: _path, ...publicEntry } = entry; + result.deleted.push(publicEntry); + } + } + + return result; +} + +export function parseCliCleanupArgs(args: string[]): CleanupHistoryInput { + const keepArg = args.find((arg) => arg.startsWith('--keep=')); + const rootsArg = args.find((arg) => arg.startsWith('--roots=')); + + return { + dryRun: args.includes('--dry-run'), + keep: keepArg ? Number.parseInt(keepArg.split('=')[1], 10) : 3, + roots: rootsArg ? rootsArg.split('=')[1].split(',').filter(Boolean) : [...defaultHistoryRoots], + }; +} diff --git a/src/tools/envRisk.ts b/src/tools/envRisk.ts new file mode 100644 index 0000000..c12e074 --- /dev/null +++ b/src/tools/envRisk.ts @@ -0,0 +1,107 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +import type { EnvPolicyInput, RiskScanInput } from '../types.js'; +import { tryExecFile } from '../core/exec.js'; +import { detectRepoType } from '../core/project.js'; +import { readTextIfExists, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js'; +export function buildEnvPolicy(input: EnvPolicyInput): Record { + const project = resolveProject(input.project); + const envFiles = ['.env.development', '.env.production', '.env.example'].map((file) => { + const absolutePath = path.join(project.path, file); + return { + exists: existsSync(absolutePath), + file, + relativePath: toPosix(path.relative(workspaceRoot, absolutePath)), + }; + }); + const gitignore = readTextIfExists(path.join(project.path, '.gitignore')); + const ignoredRules = gitignore + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + return { + envFiles, + ignoredRules: ignoredRules.filter((line) => line.includes('.env')), + policy: [ + '真实开发和生产配置只保留在本地,不提交。', + '.env.example 用于维护变量名和示例值。', + '接口、数据库、WordPress、MinIO 等真实账号密码不得写入文档或提交记录。', + ], + project, + warnings: envFiles + .filter((file) => file.file !== '.env.example' && file.exists) + .map((file) => `${file.file} 存在,提交前确认已被 .gitignore 忽略。`), + }; +} + +export async function scanTaskRisk(input: RiskScanInput): Promise> { + const project = resolveProject(input.project); + let changedFiles = input.changedFiles || []; + + if (changedFiles.length === 0 && detectRepoType(project.path) === 'git') { + const status = await tryExecFile('git', ['status', '--short'], project.path); + if (status.ok) { + changedFiles = status.stdout + .split(/\r?\n/) + .map((line) => line.slice(3).trim()) + .filter(Boolean); + } + } + + const risks: Array<{ + file: string; + level: 'high' | 'medium'; + reason: string; + suggestion: string; + }> = []; + const addRisk = ( + level: 'high' | 'medium', + file: string, + reason: string, + suggestion: string, + ) => { + risks.push({ file, level, reason, suggestion }); + }; + + for (const file of changedFiles) { + const normalized = file.replaceAll('\\', '/'); + + if (/\.env\.(development|production)$/.test(normalized)) { + addRisk('high', file, '真实环境配置文件有改动。', '确认不会提交真实密钥和密码。'); + } + + if (/package\.json$|pnpm-lock\.yaml$|package-lock\.json$|yarn\.lock$/.test(normalized)) { + addRisk('medium', file, '依赖或脚本发生变化。', '确认锁文件与 package.json 同步,并运行对应轻量验证。'); + } + + if (normalized.includes('/components/ktTable/')) { + addRisk( + 'medium', + file, + 'KtTable 是高频核心组件,布局和滚动性能容易回归。', + '保持原生 antdv-next 表格能力,避免监听 scroll/resize 做高频状态更新。', + ); + } + + if (/src\/admin\/|src\/.*controller|src\/.*service|src\/.*dto/.test(normalized)) { + addRisk('medium', file, '后端接口或业务逻辑改动。', '本地启动或复用服务后真实调用对应接口。'); + } + + if (/Jenkinsfile|deploy\/|k8s\/|nginx.*\.conf/.test(normalized)) { + addRisk('medium', file, '部署链路配置改动。', '只验证配置语法或生成产物,不主动触发远程部署。'); + } + } + + return { + changedFiles, + project, + risks, + safeDefaults: [ + '不覆盖用户已有改动。', + '只运行必要验证。', + '提交前重新检查 status 和 diff。', + ], + }; +} diff --git a/src/tools/guardrails.ts b/src/tools/guardrails.ts new file mode 100644 index 0000000..4692dbd --- /dev/null +++ b/src/tools/guardrails.ts @@ -0,0 +1,75 @@ +import type { GuardrailSet, GuardrailsInput, GuardrailsResult } from '../types.js'; +import { resolveProject } from '../core/workspace.js'; +import { createPageTestCase } from './testing.js'; +import { buildVerificationPlan } from './verification.js'; +export function buildGuardrails(input: GuardrailsInput): GuardrailsResult { + const project = resolveProject(input.project); + const taskType = input.taskType || input.changeType || 'general'; + const paths = input.paths || []; + const guardrails: GuardrailSet = { + beforeEdit: [ + '先读最近的 AGENTS.md,再读 TASKS.md 和 SKILLS.md。', + '先检查目标仓库状态,识别用户已有改动,不覆盖、不回滚无关文件。', + '先快速扫现有代码风格和目录结构,再决定改动方式。', + '搜索优先用 rg,路径和接口名尽量保持可 grep。', + ], + duringEdit: [ + '改动范围收敛到本次需求相关文件。', + '复杂度高的地方加简短注释,避免空泛解释。', + '真实环境配置不写入提交内容,优先只维护 .env.example。', + '前端实现遵循现有 antdv-next/Vben 约定,不引入无关抽象。', + ], + doNot: [ + '不要执行 git reset --hard、git checkout -- 等破坏性回滚。', + '不要在用户未要求时自动 push。', + '不要把真实密钥、数据库密码、生产 env 写进可提交文件。', + '不要为了验证长期保留 Node/Vite/浏览器进程。', + ], + verification: buildVerificationPlan({ + changeType: taskType, + includePageTest: input.includePageTest || taskType === 'page', + project: input.project, + }), + }; + + if (['api', 'backend'].includes(taskType)) { + guardrails.beforeEdit.push('接口改动先确认 DTO、Controller、Service、返回结构和鉴权链路。'); + guardrails.duringEdit.push('接口成功返回统一保持 code=200/msg/data,错误返回 err 字段。'); + guardrails.verification.notes.push('接口改完必须本地真实调用一次对应接口。'); + } + + guardrails.verification.notes.push( + '改动后的 review 是硬步骤,优先用 kt_global_code_review 或 pnpm run global-review。', + ); + + if (taskType === 'page' || input.includePageTest) { + guardrails.beforeEdit.push('页面级测试先写用例,再打开可视化浏览器执行。'); + guardrails.verification.pageTestCase = createPageTestCase({ + project: input.project, + title: input.userRequest || `${project.label} 页面级测试`, + }); + } + + if (taskType === 'database') { + guardrails.beforeEdit.push('数据库同步或修复前先确认源库、目标库、备份库和回滚点。'); + guardrails.doNot.push('不要在未确认目标库前执行覆盖、drop、truncate。'); + } + + if (taskType === 'deploy') { + guardrails.duringEdit.push('部署配置保留真正能提升运行稳定性的内容,删掉噪音配置。'); + guardrails.doNot.push('不要在未要求时触发真实部署或改动远程服务。'); + } + + if (taskType === 'mcp') { + guardrails.duringEdit.push('MCP 工具描述要明确边界,默认输出建议和检查清单,不做高风险副作用。'); + guardrails.verification.notes.push('MCP 变更至少跑 self-test 和真实 SDK Client smoke test。'); + } + + return { + guardrails, + paths, + project, + taskType, + userRequest: input.userRequest || '', + }; +} diff --git a/src/tools/inspect.ts b/src/tools/inspect.ts new file mode 100644 index 0000000..3cced42 --- /dev/null +++ b/src/tools/inspect.ts @@ -0,0 +1,64 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +import type { InspectAllProjectsInput, InspectProjectInput } from '../types.js'; +import { projectAliases } from '../core/constants.js'; +import { tryExecFile } from '../core/exec.js'; +import { detectPackageManager, detectRepoType } from '../core/project.js'; +import { listImmediateChildren, resolveProject, workspaceRoot } from '../core/workspace.js'; +export async function inspectProject(input: InspectProjectInput): Promise> { + const project = resolveProject(input.project); + const repoType = detectRepoType(project.path); + const packageInfo = detectPackageManager(project.path); + const nodeVersionFile = ['.node-version', '.nvmrc'].find((file) => + existsSync(path.join(project.path, file)), + ); + const nodeVersion = nodeVersionFile + ? readFileSync(path.join(project.path, nodeVersionFile), 'utf8').trim() + : null; + const envFiles = ['.env.development', '.env.production', '.env.example'].map( + (file) => ({ + exists: existsSync(path.join(project.path, file)), + file, + }), + ); + const childSummary = listImmediateChildren(project.path).slice(0, 60); + const git = + repoType === 'git' && input.includeGit !== false + ? { + branch: await tryExecFile('git', ['branch', '--show-current'], project.path), + remote: await tryExecFile('git', ['remote', '-v'], project.path), + status: await tryExecFile('git', ['status', '--short'], project.path), + } + : null; + + return { + childSummary, + envFiles, + git, + nodeVersion, + nodeVersionFile: nodeVersionFile || null, + packageInfo, + project, + repoType, + }; +} + +export async function inspectAllProjects(input: InspectAllProjectsInput = {}): Promise> { + const aliases = input.includeRoot === false + ? Object.keys(projectAliases).filter((key) => key !== 'root') + : Object.keys(projectAliases); + const inspections = await Promise.all( + aliases.map((alias) => + inspectProject({ + includeGit: input.includeGit !== false, + project: alias, + }), + ), + ); + + return { + inspections, + workspaceRoot, + }; +} diff --git a/src/tools/review.ts b/src/tools/review.ts new file mode 100644 index 0000000..1bd45e0 --- /dev/null +++ b/src/tools/review.ts @@ -0,0 +1,365 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; + +import type { GlobalCodeReviewInput, ProjectAlias, RepoType, ResolvedProject } from '../types.js'; +import { tryExecFile } from '../core/exec.js'; +import { detectRepoType } from '../core/project.js'; +import { resolveProject, toPosix, workspaceRoot } from '../core/workspace.js'; +import { scanTaskRisk } from './envRisk.js'; +export const defaultReviewProjects = [ + 'api', + 'admin', + 'blog', + 'web', + 'playground', + 'mcp', +] as const; + +const reviewSkipDirectoryNames = new Set([ + '.git', + '.turbo', + '.vite', + 'coverage', + 'dist', + 'node_modules', + 'playwright-report', + 'test-results', +]); + +const reviewSkipFilePattern = + /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|.*\.map|.*\.png|.*\.jpg|.*\.jpeg|.*\.gif|.*\.webp|.*\.zip|.*\.tar|.*\.gz)$/i; + +const sensitiveTrackedFilePattern = + /(^|\/)(\.env($|\..+)|deploy\.json|secrets?\.(env|json|ya?ml)|id_rsa|[^/]+\.(pem|key))$/i; + +const allowedSensitiveTrackedFilePattern = /(^|\/)\.env\.example$/i; + +const credentialAssignmentPattern = + /\b(password|passwd|secret|token|webuiToken|accessToken|privateKey)\b\s*[:=]\s*(["'])([^"']+)\2/i; + +interface ReviewFinding { + category: string; + file?: string; + line?: number; + level: 'P1' | 'P2' | 'P3'; + message: string; + project: string; + suggestion: string; +} + +interface ReviewProjectResult { + changedFiles: string[]; + credentialHints: ReviewFinding[]; + debugFindings: ReviewFinding[]; + findings: ReviewFinding[]; + project: ResolvedProject; + repoType: RepoType; + riskScan: Record; + status: string; + trackedSensitiveFiles: string[]; +} + +export function parseGitStatusFiles(status: string): string[] { + return status + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter(Boolean) + .map((line) => { + const normalized = line.trimStart(); + const match = normalized.match(/^[ MADRCU?!]{1,2}\s+(.+)$/); + const file = (match?.[1] || line.slice(3)).trim(); + return file.includes(' -> ') ? file.split(' -> ').at(-1)!.trim() : file; + }) + .filter(Boolean); +} + +async function listGitTrackedFiles(projectPath: string): Promise { + const tracked = await tryExecFile('git', ['ls-files'], projectPath); + if (!tracked.ok) return []; + return tracked.stdout + .split(/\r?\n/) + .map((file) => file.trim()) + .filter(Boolean) + .map((file) => file.replaceAll('\\', '/')); +} + +function shouldReadReviewFile(file: string): boolean { + const normalized = file.replaceAll('\\', '/'); + return !reviewSkipFilePattern.test(normalized); +} + +function readProjectFileLines(projectPath: string, file: string): string[] { + const filePath = path.join(projectPath, file); + if (!existsSync(filePath)) return []; + + try { + const stats = statSync(filePath); + if (!stats.isFile() || stats.size > 1024 * 1024) return []; + return readFileSync(filePath, 'utf8').split(/\r?\n/); + } catch { + return []; + } +} + +function isBenignCredentialValue(value: string): boolean { + const normalized = value.trim().replace(/^["']|["']$/g, '').toLowerCase(); + if (!normalized) return true; + if (normalized.startsWith('$') || normalized.startsWith('${')) return true; + if (normalized.startsWith('<') || normalized.includes('example')) return true; + return [ + 'false', + 'null', + 'password', + 'please-replace-me-with-your-own-key', + 'secret', + 'token', + 'true', + 'undefined', + 'your-token', + ].includes(normalized); +} + +function isRuntimeDebugAllowed(projectAlias: ProjectAlias | null, file: string): boolean { + const normalized = file.replaceAll('\\', '/'); + if (/^(README|skills\/|.*\.md$)/.test(normalized)) return true; + if (projectAlias === 'admin' && normalized.startsWith('internal/vite-config/')) { + return true; + } + if (projectAlias === 'mcp' && /^(scripts\/|src\/server\.ts$)/.test(normalized)) { + return true; + } + return false; +} + +function isCredentialHintAllowed(file: string, line: string): boolean { + const normalized = file.replaceAll('\\', '/'); + const trimmed = line.trimStart(); + if (trimmed.startsWith('<')) return true; + return /(^|\/)views\/(demos|examples)\//.test(normalized); +} + +function collectProjectContentFindings( + project: ResolvedProject, + trackedFiles: string[], + includeContentScan: boolean, + maxFindings: number, +): { + credentialHints: ReviewFinding[]; + debugFindings: ReviewFinding[]; + findings: ReviewFinding[]; +} { + const findings: ReviewFinding[] = []; + const credentialHints: ReviewFinding[] = []; + const debugFindings: ReviewFinding[] = []; + + for (const file of trackedFiles.filter(shouldReadReviewFile)) { + const lines = readProjectFileLines(project.path, file); + if (lines.length === 0) continue; + + lines.forEach((line, index) => { + if (/^(<<<<<<<|=======|>>>>>>>) /.test(line)) { + findings.push({ + category: 'conflict-marker', + file, + line: index + 1, + level: 'P1', + message: '发现 Git 冲突标记。', + project: project.relativePath, + suggestion: '先解决冲突标记,再继续构建或提交。', + }); + } + + if (!isRuntimeDebugAllowed(project.alias, file) && /\bdebugger;|console\.log\(/.test(line)) { + debugFindings.push({ + category: 'runtime-debug', + file, + line: index + 1, + level: /\bdebugger;/.test(line) ? 'P2' : 'P3', + message: '运行时代码中存在调试输出。', + project: project.relativePath, + suggestion: '页面或服务运行时代码不要保留 console.log/debugger。', + }); + } + + if (includeContentScan) { + const credentialMatch = line.match(credentialAssignmentPattern); + if ( + credentialMatch && + !allowedSensitiveTrackedFilePattern.test(file) && + !isCredentialHintAllowed(file, line) && + !isBenignCredentialValue(credentialMatch[3]) + ) { + credentialHints.push({ + category: 'credential-literal', + file, + line: index + 1, + level: 'P2', + message: `疑似 ${credentialMatch[1]} 字面量赋值。`, + project: project.relativePath, + suggestion: '确认是否为真实凭据;真实值应移入本地 env 或远程 Secret。', + }); + } + } + }); + + if ( + findings.length + credentialHints.length + debugFindings.length >= + maxFindings * 3 + ) { + break; + } + } + + return { + credentialHints: credentialHints.slice(0, maxFindings), + debugFindings: debugFindings.slice(0, maxFindings), + findings: findings.slice(0, maxFindings), + }; +} + +function findWorkspaceFilesByName(names: Set, maxResults: number): string[] { + const results: string[] = []; + + const walk = (currentPath: string) => { + if (results.length >= maxResults) return; + + for (const entry of readdirSync(currentPath, { withFileTypes: true })) { + if (results.length >= maxResults) return; + if (entry.isDirectory()) { + if (reviewSkipDirectoryNames.has(entry.name)) continue; + walk(path.join(currentPath, entry.name)); + continue; + } + + if (entry.isFile() && names.has(entry.name)) { + results.push(toPosix(path.relative(workspaceRoot, path.join(currentPath, entry.name)))); + } + } + }; + + walk(workspaceRoot); + return results; +} + +async function reviewProject( + projectKey: string, + input: Required>, +): Promise { + const project = resolveProject(projectKey); + const repoType = detectRepoType(project.path); + const status = + repoType === 'git' + ? await tryExecFile('git', ['status', '--short'], project.path) + : { ok: false, stderr: '', stdout: '' }; + const changedFiles = status.ok ? parseGitStatusFiles(status.stdout) : []; + const trackedFiles = repoType === 'git' ? await listGitTrackedFiles(project.path) : []; + const trackedSensitiveFiles = trackedFiles.filter( + (file) => + sensitiveTrackedFilePattern.test(file) && + !allowedSensitiveTrackedFilePattern.test(file), + ); + const contentFindings = collectProjectContentFindings( + project, + trackedFiles, + input.includeContentScan, + input.maxFindingsPerProject, + ); + const trackedSensitiveFindings = trackedSensitiveFiles + .slice(0, input.maxFindingsPerProject) + .map((file) => ({ + category: 'tracked-sensitive-file', + file, + level: 'P1', + message: '仓库仍跟踪真实环境或部署凭据类文件。', + project: project.relativePath, + suggestion: '使用 git rm --cached 移出索引,并提交 example 文件或文档说明。', + })); + + return { + changedFiles, + credentialHints: contentFindings.credentialHints, + debugFindings: contentFindings.debugFindings, + findings: [...trackedSensitiveFindings, ...contentFindings.findings], + project, + repoType, + riskScan: await scanTaskRisk({ + changedFiles, + project: projectKey, + }), + status: status.ok ? status.stdout : status.stderr, + trackedSensitiveFiles, + }; +} + +export async function buildGlobalCodeReview( + input: GlobalCodeReviewInput = {}, +): Promise> { + const projects = + (input.projects?.length ?? 0) > 0 + ? input.projects! + : [...defaultReviewProjects]; + const includeContentScan = input.includeContentScan ?? true; + const maxFindingsPerProject = Math.max( + 1, + Math.min(input.maxFindingsPerProject ?? 20, 200), + ); + const projectResults = await Promise.all( + projects.map((project) => + reviewProject(project, { + includeContentScan, + maxFindingsPerProject, + }), + ), + ); + const rootLegacyFiles = + input.includeRootScan === false + ? [] + : findWorkspaceFilesByName(new Set(['deploy.js', 'deploy.json']), 50); + const findings = projectResults.flatMap((item) => [ + ...item.findings, + ...item.debugFindings, + ...item.credentialHints, + ]); + const rootFindings = rootLegacyFiles.map((file) => ({ + category: 'legacy-deploy-file', + file, + level: 'P2', + message: '发现旧部署遗留文件。', + project: 'root', + suggestion: '确认是否仍被脚本引用;无用则删除并从 Git 索引移除。', + })); + const allFindings = [...findings, ...rootFindings]; + + return { + findings: allFindings, + generatedAt: new Date().toISOString(), + notes: [ + '本工具只读扫描,不会修改文件、删除文件或提交代码。', + 'P1 优先处理:冲突标记、被跟踪的真实 env/部署凭据文件。', + 'P2 需要人工确认:疑似凭据字面量、旧部署文件、调试断点。', + 'P3 为清洁度问题:运行时 console.log 等。', + ], + projects: projectResults.map((item) => ({ + changedFiles: item.changedFiles, + credentialHintCount: item.credentialHints.length, + debugFindingCount: item.debugFindings.length, + findingCount: item.findings.length, + project: item.project, + repoType: item.repoType, + riskScan: item.riskScan, + status: item.status, + trackedSensitiveFiles: item.trackedSensitiveFiles, + })), + summary: { + changedProjectCount: projectResults.filter((item) => item.changedFiles.length > 0) + .length, + findingCount: allFindings.length, + p1: allFindings.filter((item) => item.level === 'P1').length, + p2: allFindings.filter((item) => item.level === 'P2').length, + p3: allFindings.filter((item) => item.level === 'P3').length, + reviewedProjects: projectResults.length, + rootLegacyFiles, + }, + workspaceRoot, + }; +} diff --git a/src/tools/task.ts b/src/tools/task.ts new file mode 100644 index 0000000..02f3b8f --- /dev/null +++ b/src/tools/task.ts @@ -0,0 +1,146 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; + +import type { CommitChecklistInput, PrepareTaskInput, TaskRecordInput, WorkflowContextInput } from '../types.js'; +import { projectAliases } from '../core/constants.js'; +import { detectRepoType } from '../core/project.js'; +import { formatDateInShanghai, getRecentTaskRecords, readText, resolveInsideRoot, resolveProject, workspaceRoot } from '../core/workspace.js'; +import { buildGuardrails } from './guardrails.js'; +import { inspectProject } from './inspect.js'; +import { scanTaskRisk } from './envRisk.js'; +import { createPageTestCase } from './testing.js'; +export async function prepareTask(input: PrepareTaskInput): Promise> { + const project = resolveProject(input.project); + const taskType = input.taskType || 'general'; + const inspection = await inspectProject({ + includeGit: input.includeGit !== false, + project: input.project, + }); + const guardrails = buildGuardrails(input); + const riskScan = await scanTaskRisk({ + changedFiles: input.changedFiles || [], + project: input.project, + }); + const pageTestCase = + input.includePageTest || taskType === 'page' + ? createPageTestCase({ + entryUrl: input.entryUrl, + project: input.project, + title: input.userRequest || `${project.label} 页面级测试`, + }) + : null; + + return { + guardrails, + inspection, + nextSteps: [ + '阅读 workPacket.readFirst 中的上下文文件。', + '按 guardrails.beforeEdit 做开工前检查。', + '按最小范围修改。', + '按 verification.commands 和 notes 做轻量验证。', + '有文件改动时更新 TASKS.md。', + '最终回复或提交前跑全局 review,并处理全部 findings。', + ], + pageTestCase, + project, + readFirst: [ + 'AGENTS.md', + 'SKILLS.md', + 'TASKS.md', + `${project.relativePath}/AGENTS.md`, + `${project.relativePath}/package.json`, + ], + riskScan, + taskRecordDraft: buildTaskRecord({ + content: input.userRequest || '待补充', + scope: [project.relativePath], + testCase: pageTestCase ? pageTestCase.title : '本次无页面测试或待补充。', + title: input.recordTitle || input.userRequest || `${project.label} 任务记录`, + verification: guardrails.guardrails.verification.commands.join(';') || '待补充。', + }), + taskType, + userRequest: input.userRequest || '', + }; +} + +export function buildTaskRecord(input: TaskRecordInput): string { + const date = input.date || formatDateInShanghai(); + const scope = (input.scope?.length ?? 0) > 0 ? input.scope!.join('、') : '待补充'; + const content = input.content || '待补充'; + const testCase = input.testCase || '本次无页面测试或待补充。'; + const verification = input.verification || '待补充。'; + + return `### ${date}:${input.title}\n\n- 范围:${scope}\n- 内容:${content}\n- 页面测试用例:${testCase}\n- 验证:${verification}\n`; +} + +export function appendTaskRecord(input: TaskRecordInput): Record { + const record = buildTaskRecord(input); + const tasksPath = resolveInsideRoot('TASKS.md'); + const current = readFileSync(tasksPath, 'utf8'); + const marker = '## 改动记录'; + const markerIndex = current.indexOf(marker); + + if (markerIndex === -1) { + throw new Error('TASKS.md does not contain ## 改动记录'); + } + + const insertAt = current.indexOf('\n', markerIndex); + const next = `${current.slice(0, insertAt + 1)}\n${record}\n${current.slice(insertAt + 1)}`; + + if (!input.dryRun) { + writeFileSync(tasksPath, next, 'utf8'); + } + + return { + dryRun: input.dryRun, + record, + target: tasksPath, + }; +} + +export async function createCommitChecklist(input: CommitChecklistInput): Promise> { + const inspection = await inspectProject({ + includeGit: true, + project: input.project, + }); + const message = input.message || ''; + const messageOk = + !message || + /^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([^)]+\))?: .*[一-龥]/u.test( + message, + ); + + return { + checks: [ + '确认只包含本次任务相关文件。', + '先运行对应轻量验证命令。', + '提交前已跑全局 review,且 findings 已修复或明确记录为非阻塞风险。', + '每个独立 Git 仓库单独 commit。', + '没有用户明确要求时不 push。', + ], + inspection, + message, + messageOk, + messageRule: '英文 type 前缀 + 中文描述,例如 feat: 功能性提交', + }; +} + +export function readWorkflowContext(input: WorkflowContextInput): Record { + const projects = Object.keys(projectAliases).map((key) => { + const project = resolveProject(key); + return { + ...project, + exists: existsSync(project.path), + repoType: existsSync(project.path) ? detectRepoType(project.path) : 'missing', + }; + }); + + return { + docs: { + agents: readText('AGENTS.md'), + recentTasks: getRecentTaskRecords(input.taskRecordCount), + skills: readText('SKILLS.md'), + }, + projects, + workspaceRoot, + }; +} diff --git a/src/tools/testing.ts b/src/tools/testing.ts new file mode 100644 index 0000000..8ca8baf --- /dev/null +++ b/src/tools/testing.ts @@ -0,0 +1,155 @@ +import type { ApiTestPlanInput, BusinessFlow, BusinessTestPlanInput, PageTestCaseInput, PageTestCaseResult } from '../types.js'; +import { resolveProject } from '../core/workspace.js'; +export function createPageTestCase(input: PageTestCaseInput): PageTestCaseResult { + const project = resolveProject(input.project); + const title = input.title || `${project.label} 页面级测试`; + const steps = + (input.steps?.length ?? 0) > 0 + ? input.steps! + : ['打开入口 URL', '完成登录或注入测试登录态', '执行用户关键路径操作', '保存关键步骤截图']; + const assertions = + (input.assertions?.length ?? 0) > 0 + ? input.assertions! + : ['页面无明显渲染错误', '控制台无 error', '关键接口返回成功', '核心 DOM 或业务状态符合预期']; + + return { + account: input.account || '按当前环境使用数据库管理员账号或测试专用账号', + assertions, + cleanup: [ + '关闭浏览器实例', + '清理本次启动的 Node/Vite 进程', + '保存截图和 result.json', + '调用 kt_cleanup_history,仅保留最近 3 轮历史产物', + ], + entryUrl: input.entryUrl || '待填写', + maxRounds: 3, + project, + protocol: [ + '测试前先写用例。', + '失败后先记录复现证据,再排查和修复。', + '按同一用例复测。', + '第三轮仍失败时停止并等待用户建议。', + ], + steps, + title, + }; +} + +export function createApiTestPlan(input: ApiTestPlanInput): Record { + const project = resolveProject(input.project || 'api'); + const method = (input.method || 'GET').toUpperCase(); + const endpoint = input.endpoint || '/api/待填写'; + const baseUrl = input.baseUrl || 'http://localhost:3000'; + const headers = []; + + if (input.auth === 'bearer') { + headers.push('-H "Authorization: Bearer "'); + } + + if (input.contentType !== false && ['POST', 'PUT', 'PATCH'].includes(method)) { + headers.push('-H "Content-Type: application/json"'); + } + + const body = + input.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) + ? ` -d '${JSON.stringify(input.body)}'` + : ''; + const curl = `curl -i -X ${method} ${headers.join(' ')} "${baseUrl}${endpoint}"${body}`; + + return { + assertions: [ + 'HTTP 状态码符合预期。', + '成功响应结构为 { code: 200, msg: string, data: any }。', + '成功响应不返回 err 字段。', + ...(input.expectedFields || []).map((field) => `data 中包含 ${field}`), + ], + curl, + endpoint, + method, + notes: [ + '接口改动后不要只跑类型检查,必须真实调用对应接口一次。', + '如依赖登录态,先调用 login 获取 token,再带 Authorization 复测。', + ], + project, + }; +} + +export function buildBusinessTestPlan(input: BusinessTestPlanInput = {}): Record { + const environment = input.environment || 'local'; + const baseUrl = input.baseUrl || (environment === 'remote' ? 'https://待填写' : 'http://127.0.0.1'); + const flows: Record> = { + 'admin-login': { + assertions: [ + '登录接口返回 code=200 且 data 内有 accessToken/userInfo/menu/permissions。', + 'Admin 页面进入后后端路由菜单正常渲染。', + 'WordPress/NAS 不可用时不阻塞 Admin 登录。', + ], + preflight: ['API 可达', 'Admin dev server 可达', '数据库管理员账号存在'], + projects: ['api', 'admin'], + steps: [ + '调用 /auth/login 或使用 admin-login 脚本固化登录态。', + '进入 Admin 首页,确认菜单与按钮权限。', + '刷新页面后确认 auth 持久化仍有效。', + ], + }, + 'blog-crud': { + assertions: [ + '文章、分类、标签列表返回统一 code/msg/data。', + '新增/编辑/删除后后端刷新 WordPress 缓存。', + '文章编辑能绑定分类和标签,查询不额外打开新 tab。', + ], + preflight: ['API 可达', 'WordPress 管理端认证可用或降级逻辑明确', 'Admin 博客菜单有权限'], + projects: ['api', 'admin', 'blog'], + steps: ['登录 Admin', '新增分类/标签', '新增文章并绑定分类标签', '编辑后查询列表', '清理 KT_TEST_ 数据'], + }, + 'qqbot-account-scan': { + assertions: [ + '扫码创建返回 qrcode/containerName/webuiPort。', + '取消/过期/失败会清理未绑定 NapCat 容器。', + '删除账号会二次确认并删除专属容器。', + ], + preflight: ['NAS SSH 可达', 'NapCat WebUI 可达', 'API QQBot env 配置完整'], + projects: ['api', 'admin'], + steps: ['打开账号连接页', '点击扫码新增账号', '轮询二维码状态', '确认账号回填', '测试删除联动容器'], + }, + 'qqbot-auto-reply': { + assertions: [ + '消息日志能看到 OneBot 收到的私聊/群聊/频道消息。', + '权限名单按 QQ号、群/频道、精确 QQ 过滤生效。', + '规则命中后发送日志记录成功或失败原因。', + ], + preflight: ['账号在线', 'MQTT 或 local bus 正常', '规则启用', '名单配置符合测试目标'], + projects: ['api', 'admin'], + steps: ['创建 KT_TEST_ 自动回复规则', '发送私聊测试消息', '查询消息日志', '查询发送日志', '清理测试规则'], + }, + 'web-playground-auth': { + assertions: [ + '无 token 时不主动 refresh,不产生登录循环。', + 'Admin 已登录时能带回授权态并重定向回原页面。', + 'Playground 保存组件时截图上传 MinIO 并把 image 写入 API。', + ], + preflight: ['Admin/API/Web/Playground 地址可达', '登录态存储为空或可控', 'MinIO 可达'], + projects: ['api', 'admin', 'web', 'playground'], + steps: ['清理本地 token', '从 Web/Playground 进入受保护页面', '跳 Admin 登录', '登录后回跳', '保存组件并查询接口'], + }, + }; + const selected: Array<[string, Record]> = + !input.flow || input.flow === 'all' + ? Object.entries(flows) + : [[input.flow, flows[input.flow] as Record]]; + + return { + baseUrl, + environment, + maxRounds: 3, + protocol: [ + '业务测试先写用例,再按环境 -> 账号 -> 数据 -> 接口 -> 页面预检。', + '失败必须记录复现步骤、证据和初步归因。', + '同一用例最多三轮,第三轮仍失败则停止等待建议。', + ], + selectedFlows: selected.map(([name, plan]) => ({ + name, + ...plan, + })), + }; +} diff --git a/src/tools/verification.ts b/src/tools/verification.ts new file mode 100644 index 0000000..d43ec98 --- /dev/null +++ b/src/tools/verification.ts @@ -0,0 +1,76 @@ +import type { VerificationPlanInput, VerificationPlanResult } from '../types.js'; +import { detectPackageManager, detectRepoType, getTypecheckCommand } from '../core/project.js'; +import { getGlobalReviewCommand, resolveProject } from '../core/workspace.js'; +export function buildVerificationPlan(input: VerificationPlanInput): VerificationPlanResult { + const project = resolveProject(input.project); + const repoType = detectRepoType(project.path); + const packageInfo = detectPackageManager(project.path); + const packageManager = packageInfo.packageManager || 'pnpm'; + const scripts = packageInfo.scripts; + const changeType = input.changeType || 'general'; + const commands = repoType === 'git' ? ['git status --short'] : []; + const notes = [ + '先确认仓库类型,再确认包管理器和 Node 版本。', + '只跑当前变更所需的最轻验证,不默认全量 build。', + '只要产生文件改动,最终回复或提交前必须走一次 review;有 findings 先修复再复跑。', + ]; + + const typecheck = getTypecheckCommand(project, packageManager, scripts); + + if (changeType === 'style' && project.alias === 'admin') { + commands.push( + 'pnpm exec stylelint "apps/web-antdv-next/src/components/**/*.{scss,css,vue}"', + ); + } + + if (repoType === 'none') { + notes.push('当前目录不是独立 Git/SVN 仓库,提交和 diff 检查需要切到对应子仓库执行。'); + } + + if ( + typecheck && + ['api', 'frontend', 'general', 'mcp', 'page', 'style'].includes(changeType) + ) { + commands.push(typecheck); + } + + if (project.alias === 'api') { + if (scripts.lint) commands.push(`${packageManager} run lint`); + if (scripts.test && ['api', 'backend', 'general'].includes(changeType)) { + commands.push(`${packageManager} test -- --passWithNoTests`); + } + if (['api', 'backend'].includes(changeType)) { + notes.push('接口改动需要本地启动或复用本地服务,真实调用对应接口一次。'); + } + } + + if (project.alias === 'playground' && scripts['build-preview'] && changeType === 'deploy') { + commands.push(`${packageManager} run build-preview`); + } + + if (project.alias === 'web' && scripts.build && changeType === 'deploy') { + commands.push(`${packageManager} run build`); + } + + if (project.alias === 'mcp') { + if (scripts['self-test']) commands.push(`${packageManager} run self-test`); + notes.push('MCP 变更需要用真实 SDK Client 做 listTools/callTool smoke test。'); + } + + if (input.includePageTest || changeType === 'page') { + notes.push( + '页面级测试必须先写测试用例,使用可见浏览器或截图记录关键步骤,同一用例最多三轮闭环。', + ); + } + + if (repoType === 'git') commands.push('git diff --check'); + commands.push(getGlobalReviewCommand()); + + return { + changeType, + commands: Array.from(new Set(commands)), + notes, + project, + repoType, + }; +} diff --git a/src/tools/workflow.ts b/src/tools/workflow.ts new file mode 100644 index 0000000..87a3c6c --- /dev/null +++ b/src/tools/workflow.ts @@ -0,0 +1,316 @@ +import type { CommitPlanInput, ComponentWorkflowInput, DbSyncPlanInput, ExecResult, FinishTaskInput, PushPlanInput, RemoteHealthCheckInput, RepoType, ResolvedProject } from '../types.js'; +import { tryExecFile, tryPowerShell } from '../core/exec.js'; +import { detectRepoType } from '../core/project.js'; +import { formatDateInShanghai, getGlobalReviewCommand, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js'; +import { cleanupHistoryArtifacts } from './cleanup.js'; +import { buildGlobalCodeReview, defaultReviewProjects, parseGitStatusFiles } from './review.js'; +import { buildVerificationPlan } from './verification.js'; +interface RepoSnapshot { + branch: string; + changedFiles: string[]; + project: ResolvedProject; + remote: string; + repoType: RepoType; + status: string; +} + +function normalizeProjectKeys(projects?: string[]): string[] { + const selected = (projects?.length ?? 0) > 0 ? projects! : [...defaultReviewProjects]; + return Array.from(new Set(selected.filter((project) => project !== 'root'))); +} + +async function collectRepoSnapshot(projectKey: string): Promise { + const project = resolveProject(projectKey); + const repoType = detectRepoType(project.path); + const status = repoType === 'git' + ? await tryExecFile('git', ['status', '--short'], project.path) + : null; + const branch = repoType === 'git' + ? await tryExecFile('git', ['branch', '--show-current'], project.path) + : null; + const remote = repoType === 'git' + ? await tryExecFile('git', ['remote', '-v'], project.path) + : null; + + return { + branch: branch?.ok ? branch.stdout : '', + changedFiles: status?.ok ? parseGitStatusFiles(status.stdout) : [], + project, + remote: remote?.ok ? remote.stdout : '', + repoType, + status: status?.ok ? status.stdout : status?.stderr || '', + }; +} + +function suggestCommitMessage(snapshot: RepoSnapshot): string { + const files = snapshot.changedFiles.join('\n'); + + if (snapshot.project.alias === 'mcp') { + return 'feat(workflow): 扩展KT工作流能力'; + } + if (snapshot.project.alias === 'api' && /qqbot/i.test(files)) { + return 'fix(qqbot): 修复QQBot运行链路'; + } + if (snapshot.project.alias === 'admin' && /\.env|authentication/.test(files)) { + return 'fix(admin): 收敛环境配置和登录页'; + } + if (snapshot.project.alias === 'web' && /deploy\.json|\.gitignore/.test(files)) { + return 'chore(web): 清理旧部署配置'; + } + if (snapshot.project.alias === 'blog') { + return 'fix(blog): 优化博客前台体验'; + } + return `chore(${snapshot.project.alias || 'repo'}): 更新项目改动`; +} + +export async function buildFinishTask(input: FinishTaskInput = {}): Promise> { + const projectKeys = normalizeProjectKeys(input.projects); + const snapshots = await Promise.all(projectKeys.map(collectRepoSnapshot)); + const changedSnapshots = snapshots.filter((snapshot) => snapshot.changedFiles.length > 0); + const verificationPlans = changedSnapshots.map((snapshot) => + buildVerificationPlan({ + changeType: input.changeType || 'general', + project: snapshot.project.alias || snapshot.project.relativePath, + }), + ); + const validationRuns: Array<{ + command: string; + project: ResolvedProject; + result: ExecResult; + }> = []; + + if (input.runValidation) { + for (const plan of verificationPlans) { + for (const command of plan.commands.filter((item) => !item.includes('global-review'))) { + validationRuns.push({ + command, + project: plan.project, + result: await tryPowerShell(command, plan.project.path), + }); + } + } + } + + const review = await buildGlobalCodeReview({ + includeContentScan: true, + includeRootScan: input.includeRootScan ?? true, + maxFindingsPerProject: input.maxFindingsPerProject ?? 20, + projects: projectKeys, + }); + const cleanup = cleanupHistoryArtifacts({ + dryRun: !(input.cleanupHistory ?? false), + keep: input.keepHistory ?? 3, + }); + + return { + cleanup, + mode: input.runValidation ? 'validation_executed' : 'plan_only', + notes: [ + '默认只生成收尾结果;runValidation=true 时才执行验证命令。', + 'cleanupHistory=true 时才真实清理历史产物,否则仅预览。', + '提交和推送仍需用户明确要求后执行。', + ], + review, + snapshots, + stopConditions: [ + 'review.findings 非空时先修复,再复跑 kt_global_code_review。', + 'validationRuns 中任一 result.ok=false 时先处理失败命令。', + '发现真实 env、生产 Secret 或非本次范围文件时停止提交。', + ], + summary: { + changedProjectCount: changedSnapshots.length, + changedProjects: changedSnapshots.map((snapshot) => snapshot.project.relativePath), + validationRunCount: validationRuns.length, + }, + validationRuns, + verificationPlans, + }; +} + +export async function buildCommitPlan(input: CommitPlanInput = {}): Promise> { + const snapshots = await Promise.all(normalizeProjectKeys(input.projects).map(collectRepoSnapshot)); + const changedSnapshots = snapshots.filter((snapshot) => snapshot.changedFiles.length > 0); + + return { + checks: [ + '每个独立 Git 仓库单独提交。', + '提交前先跑对应轻量验证和 kt_global_code_review。', + '确认 .env.development/.env.production 等真实配置只从索引移除,不提交真实值。', + '根目录 AGENTS/SKILLS/TASKS 不属于子仓库,最终回复里单独说明。', + ], + includePush: input.includePush ?? false, + projects: changedSnapshots.map((snapshot) => { + const message = input.message || suggestCommitMessage(snapshot); + return { + branch: snapshot.branch, + changedFiles: snapshot.changedFiles, + commands: [ + 'git status --short', + 'git add -A', + `git commit -m "${message}"`, + ...(input.includePush ? [`git push origin ${snapshot.branch || 'main'}`] : []), + ], + message, + project: snapshot.project, + status: snapshot.status, + }; + }), + reviewCommand: getGlobalReviewCommand(), + summary: { + changedProjectCount: changedSnapshots.length, + totalChangedFiles: changedSnapshots.reduce( + (count, snapshot) => count + snapshot.changedFiles.length, + 0, + ), + }, + }; +} + +export async function buildPushPlan(input: PushPlanInput = {}): Promise> { + const snapshots = await Promise.all(normalizeProjectKeys(input.projects).map(collectRepoSnapshot)); + const remote = input.remote || 'origin'; + + return { + checks: [ + '推送前工作区应干净;如仍有改动,先提交或说明为什么暂不提交。', + '推送前复跑 kt_global_code_review,findings 必须为 0 或明确非阻塞。', + '如果 Jenkins/Gitea host key、mirror、hooks 异常,先修远程仓库状态再重试。', + ], + projects: snapshots + .filter((snapshot) => snapshot.repoType === 'git') + .map((snapshot) => { + const branch = input.branch || snapshot.branch || 'main'; + return { + branch, + commands: [ + 'git status --short', + `git log --oneline ${remote}/${branch}..${branch}`, + `git push ${remote} ${branch}`, + ], + project: snapshot.project, + remote: snapshot.remote, + status: snapshot.status, + }; + }), + reviewCommand: getGlobalReviewCommand(), + }; +} + +function buildRemoteHealthCommands(input: RemoteHealthCheckInput = {}): Array<{ + command: string; + service: string; +}> { + const host = input.sshTarget || input.host || process.env.KT_NAS_SSH_TARGET || 'nas.kwitsukasa.top'; + const port = input.port ?? Number(process.env.KT_NAS_SSH_PORT || 2202); + const ssh = (remoteCommand: string) => `ssh -p ${port} ${host} "${remoteCommand}"`; + const allCommands: Record = { + docker: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}'"), + gitea: ssh("docker ps --format '{{.Names}} {{.Status}}' | grep -i gitea || true"), + jenkins: ssh("docker ps --format '{{.Names}} {{.Status}}' | grep -i jenkins || true"), + k3d: ssh('kubectl get pods -A -o wide 2>/dev/null || k3d cluster list 2>/dev/null || true'), + minio: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -i minio || true"), + mosquitto: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -i mosquitto || true"), + mysql: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -Ei 'mysql|mariadb' || true"), + napcat: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -Ei 'napcat|qqbot' || true"), + ssh: ssh('hostname && date'), + }; + const services = (input.services?.length ?? 0) > 0 ? input.services! : Object.keys(allCommands); + + return services + .filter((service) => allCommands[service]) + .map((service) => ({ + command: allCommands[service], + service, + })); +} + +export async function buildRemoteHealthCheck(input: RemoteHealthCheckInput = {}): Promise> { + const commands = buildRemoteHealthCommands(input); + const results = input.execute + ? await Promise.all( + commands.map(async (item) => ({ + ...item, + result: await tryPowerShell(item.command, workspaceRoot), + })), + ) + : []; + + return { + commands, + execute: input.execute ?? false, + notes: [ + '默认 execute=false,只生成只读探测命令。', + '远程命令只做 hostname/date/docker ps/kubectl get 等只读检查。', + '真实密码、token、Secret 不从仓库读取,也不写入输出。', + ], + results, + }; +} + +export function buildDbSyncPlan(input: DbSyncPlanInput = {}): Record { + const source = input.source || 'local'; + const target = input.target || 'remote'; + const backupName = input.backupName || `kt_template_backup_${formatDateInShanghai().replaceAll('-', '')}`; + + return { + backupName, + commands: [ + '从 Node/kt-template-online-api/.env.development 和 .env.production 读取连接信息,只在本机 shell 环境使用,不写入 Git。', + 'mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers kt_template > .codex-db-sync/kt_template.sql', + `mysql -e "CREATE DATABASE IF NOT EXISTS ${backupName} DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"`, + `mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers kt_template | mysql ${backupName}`, + 'mysql kt_template < .codex-db-sync/kt_template.sql', + '对比源库/目标库表数量、视图/函数/过程数量和关键表行数。', + `验证通过后再执行:mysql -e "DROP DATABASE ${backupName};"`, + ], + guardrails: [ + '默认只生成计划,不执行覆盖、drop、truncate。', + '同步前必须确认 source/target 方向,不能靠变量名猜。', + '目标库备份校验通过前不得删除备份库。', + '涉及生产库时优先只读验证,批量删除必须二次确认。', + ], + source, + target, + verification: [ + 'SHOW TABLES 数量一致。', + 'admin_user、admin_menu、qqbot_account、qqbot_message_log 等关键表行数符合预期。', + '应用登录和核心接口 smoke 通过。', + '全局 review findings=0。', + ], + }; +} + +export function buildComponentWorkflow(input: ComponentWorkflowInput = {}): Record { + const target = input.target || 'KtTable'; + const workflows = { + AdminAuth: { + antiPatterns: ['无 token 时主动 refresh', '跨端回跳时清空 Admin 已有授权态', '把 redirect 写丢或重复编码'], + files: ['Vue/kt-template-admin/apps/web-antdv-next/src/store/auth.ts', 'Vue/kt-template-online-web/src/api/auth.ts', 'Vue/kt-template-online-playground/src/api/auth.ts'], + guardrails: ['先画清楚 Admin/API/Web/Playground token 链路', '401 时先判断旧 token 是否存在', 'Admin 已登录时直接带回授权态'], + verification: ['清空 token 测无循环', 'Admin 已登录测直接回跳', '刷新页面测持久化'], + }, + BlogArgon: { + antiPatterns: ['使用具名 id 控制 DOM', 'SCSS 类名逃出 kt-blog 命名空间', '主题变量散落在 scss 文件', '亮色模式仍套暗色遮罩'], + files: ['Vue/kt-blog-web/src/hooks/useBlogTheme.ts', 'Vue/kt-blog-web/src/styles', 'Vue/kt-blog-web/src/components/blog'], + guardrails: ['样式统一 kt-blog BEM 命名空间', '主题变量收敛到 useBlogTheme', '动画用 Vue Transition', '和线上 Argon DOM/视觉逐项对照'], + verification: ['亮/暗模式切换', '搜索 Enter 和动画', '文章切换动画', '无感知滚动加载', '背景图滚动到底部不断层'], + }, + KtTable: { + antiPatterns: ['自定义纵向滚动条覆盖 Antdv 原生滚动', 'scroll/resize 高频写响应式状态', '表格高度参与展开收起动画', '固定列透明导致透视'], + files: ['Vue/kt-template-admin/apps/web-antdv-next/src/components/ktTable'], + guardrails: ['优先使用 antdv-next 原生 table 能力', '统计列固定到底部但不参与表单动画', '序号列和操作列固定最小/最大宽', 'resize 用 RAF 节流和非深度监听'], + verification: ['横向滚动性能', '纵向滚动表头对齐', '列宽/行高 resize', 'summary 固定底部', '宽屏无无意义横向滚动条'], + }, + QQBot: { + antiPatterns: ['把 NapCat token 写进仓库', '删除账号不清理专属容器', '白名单/黑名单只做前端过滤', '远程服务不可达时伪装成业务成功'], + files: ['Node/kt-template-online-api/src/qqbot', 'Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot', 'ci/fnos-k8s/deploy-qqbot-runtime.ps1'], + guardrails: ['OneBot 反向 WS token 校验', '权限名单底层先走 QQ号过滤', '群/频道精确 QQ 过滤由开关控制', '扫码取消/过期/失败清理未绑定容器'], + verification: ['账号在线', '扫码新增/更新登录', '自动回复规则命中', '权限名单三类过滤', '消息日志和发送日志闭环'], + }, + } satisfies Record>; + + return { + target, + workflow: workflows[target], + }; +} diff --git a/src/types.ts b/src/types.ts index 2b534a2..1b5565c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -126,6 +126,66 @@ export interface RiskScanInput { project?: string; } +export interface GlobalCodeReviewInput { + includeContentScan?: boolean; + includeRootScan?: boolean; + maxFindingsPerProject?: number; + projects?: string[]; +} + +export interface FinishTaskInput { + changeType?: TaskType; + cleanupHistory?: boolean; + includeRootScan?: boolean; + keepHistory?: number; + maxFindingsPerProject?: number; + projects?: string[]; + runValidation?: boolean; +} + +export interface CommitPlanInput { + includePush?: boolean; + message?: string; + projects?: string[]; +} + +export interface PushPlanInput { + branch?: string; + projects?: string[]; + remote?: string; +} + +export type BusinessFlow = + | 'admin-login' + | 'blog-crud' + | 'qqbot-account-scan' + | 'qqbot-auto-reply' + | 'web-playground-auth'; + +export interface BusinessTestPlanInput { + baseUrl?: string; + environment?: 'local' | 'remote'; + flow?: BusinessFlow | 'all'; +} + +export interface RemoteHealthCheckInput { + execute?: boolean; + host?: string; + port?: number; + services?: string[]; + sshTarget?: string; +} + +export interface DbSyncPlanInput { + backupName?: string; + source?: 'local' | 'remote'; + target?: 'local' | 'remote'; +} + +export interface ComponentWorkflowInput { + target?: 'AdminAuth' | 'BlogArgon' | 'KtTable' | 'QQBot'; +} + export interface PrepareTaskInput extends GuardrailsInput { changedFiles?: string[]; entryUrl?: string;