feat: Phase1 完整交付——Web 看板(frontend-design) + MCP server(12 工具) + CLI/导入器

- web/: phosphor 调度台风格纯静态看板,审批闸内联 accept/reject(拒绝必填意见),WS 实时
- src/api/static.ts: 手写静态服务(路径穿越防护),daemon 接入
- src/mcp/: stdio MCP server,@modelcontextprotocol/sdk 1.29.0,12 工具薄封装 REST
- src/cli/: 零依赖 CLI(project/task/next/approvals/import-todo),旧 todo.json 导入器
- 实测: pangolin 18 条旧任务导入 45 条;端到端验证通过

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-12 21:23:08 +08:00
parent 3172718a94
commit c6e66baa19
11 changed files with 3219 additions and 8 deletions
+69
View File
@@ -0,0 +1,69 @@
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, extname, join, resolve, sep } from 'node:path';
import type { FastifyInstance, FastifyReply } from 'fastify';
/**
* Web 看板静态文件服务(零依赖,手写)。
* web/ 不参与 tsc 构建,直接相对仓库根定位:
* dev 时本文件在 src/api/build 后在 dist/api/,两者到仓库根都是 ../..(与 store/db.ts 同思路)。
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const WEB_ROOT = resolve(HERE, '..', '..', 'web');
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.map': 'application/json',
'.txt': 'text/plain; charset=utf-8',
};
async function serveFile(reply: FastifyReply, rawPath: string): Promise<FastifyReply> {
let rel: string;
try {
rel = decodeURIComponent(rawPath);
} catch {
return reply.code(400).type('text/plain; charset=utf-8').send('Bad Request');
}
if (rel.includes('\0')) {
return reply.code(400).type('text/plain; charset=utf-8').send('Bad Request');
}
// 路径穿越防护:解析后必须仍落在 WEB_ROOT 之内
const abs = resolve(WEB_ROOT, '.' + sep + rel);
if (abs !== WEB_ROOT && !abs.startsWith(WEB_ROOT + sep)) {
return reply.code(403).type('text/plain; charset=utf-8').send('Forbidden');
}
const file = abs === WEB_ROOT ? join(WEB_ROOT, 'index.html') : abs;
try {
const buf = await readFile(file);
const type = MIME[extname(file).toLowerCase()] ?? 'application/octet-stream';
return reply.type(type).send(buf);
} catch {
return reply.code(404).type('text/plain; charset=utf-8').send('Not Found');
}
}
/**
* 在 Fastify 上注册看板静态路由。
* `/` → web/index.html`/*` → web/ 下对应文件(/api/**、/ws 等更具体路由优先匹配,不受影响)。
*/
export function registerStatic(app: FastifyInstance): void {
app.get('/', (_req, reply) => serveFile(reply, 'index.html'));
app.get('/*', (req, reply) => {
const params = req.params as { '*': string };
return serveFile(reply, params['*'] ?? '');
});
}