4a46f89c9b
客户端新增随包提供的插件宿主和 Cocos Creator 集成:识别并导入 Cocos 项目,通过内置桥接操作已打开的编辑器,无需安装项目 MCP 扩展。DirectProject 现在公开 36 个独立 cocos_* 工具,保留通用 JavaScript 执行入口。 - 通用插件 SDK、命令/能力/面板注册、编辑器适配器和跨进程内置插件开关。 - Cocos 场景、节点、组件、Prefab、UI、Layout/Widget、资源、保存、撤销、日志与预览调试;目录和实现由 JS/native 共用。 - 编辑事务回读、失败回滚、后续手动修改保护及不确定结果禁止重放;预览截图通过 MCP image 返回。 - DirectProject 跳过无关专业 Agent 历史,将项目打开和历史读取中的同步 I/O 移出窗口线程,消除 Cocos 执行与项目文件锁的错误耦合。 验证: - 合并 master 后:类型/配置检查、编码检查、Rust 格式检查和提交钩子通过。 - 合并 master 后:Cocos 项目打开、插件面板和开发启动定向测试 10 通过、2 跳过;DirectProject MCP 测试 17 通过、1 项真实 Creator opt-in 忽略;插件宿主测试 9/9。 - 插件行为测试 17/17;native 测试 20/20,4 项 opt-in 测试默认忽略。 - 真实 Creator 3.8.8 的 36/36 操作 smoke,以及客户端 MCP tools/list、tools/call、UI/撤销和预览截图,在功能实现阶段已验证通过;本次 master 合并后未重复真实 GUI smoke。 验证边界:发行安装包和远端 CI 尚未验收。 Reviewed-on: #338 Co-authored-by: kdletters <kdletters@qq.com> Co-committed-by: kdletters <kdletters@qq.com>
140 lines
4.3 KiB
JavaScript
140 lines
4.3 KiB
JavaScript
'use strict';
|
|
|
|
module.exports = function projectPaths(root, require) {
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
root = fs.realpathSync(root);
|
|
function resolve(relative) {
|
|
if (
|
|
typeof relative !== 'string' ||
|
|
!relative ||
|
|
relative.includes('\0') ||
|
|
path.isAbsolute(relative) ||
|
|
relative.includes(':')
|
|
)
|
|
throw new Error('必须使用项目相对路径');
|
|
const parts = relative.replaceAll('\\', '/').split('/');
|
|
if (
|
|
parts.some((p) => p === '..' || !p) ||
|
|
['.agent', '.git', '.codex', '.env'].some((p) => parts[0] === p)
|
|
)
|
|
throw new Error('路径超出允许范围');
|
|
const target = path.resolve(root, ...parts);
|
|
const rel = path.relative(root, target);
|
|
if (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel))
|
|
throw new Error('路径不属于项目');
|
|
let cursor = root;
|
|
for (const part of parts) {
|
|
cursor = path.join(cursor, part);
|
|
try {
|
|
if (fs.lstatSync(cursor).isSymbolicLink())
|
|
throw new Error('不允许符号链接或 junction');
|
|
} catch (e) {
|
|
if (e.code === 'ENOENT') break;
|
|
throw e;
|
|
}
|
|
}
|
|
return target;
|
|
}
|
|
function assetUrl(relative) {
|
|
if (!relative?.startsWith('assets/'))
|
|
throw new Error('资源路径必须位于 assets/');
|
|
resolve(relative);
|
|
return 'db://' + relative;
|
|
}
|
|
async function tail(relative, maxLines = 200) {
|
|
if (
|
|
!/^(temp\/logs\/|temp\/builder\/|build\/)/.test(relative) ||
|
|
!/\.(log|txt)$/i.test(relative)
|
|
)
|
|
throw new Error('只读取 temp/logs、temp/builder 或 build 下日志');
|
|
const file = resolve(relative);
|
|
let handle;
|
|
try {
|
|
handle = await fs.promises.open(file, 'r');
|
|
} catch (e) {
|
|
if (e.code === 'ENOENT')
|
|
return { path: relative, missing: true, lines: [] };
|
|
throw e;
|
|
}
|
|
try {
|
|
const stat = await handle.stat();
|
|
if (!stat.isFile()) throw new Error('日志不是普通文件');
|
|
const size = Math.min(stat.size, 256 * 1024);
|
|
const buffer = Buffer.alloc(size);
|
|
await handle.read(buffer, 0, size, stat.size - size);
|
|
const lines = buffer.toString('utf8').split(/\r?\n/);
|
|
if (stat.size > size) lines.shift();
|
|
return {
|
|
path: relative,
|
|
missing: false,
|
|
truncated: stat.size > size || lines.length > maxLines,
|
|
lines: lines.slice(-maxLines),
|
|
};
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
async function buildLogs(maxFiles, maxLines) {
|
|
const files = [];
|
|
let visited = 0;
|
|
async function scan(relative, depth = 0) {
|
|
if (depth > 5 || visited >= 3000) return;
|
|
let entries;
|
|
try {
|
|
entries = await fs.promises.readdir(resolve(relative), {
|
|
withFileTypes: true,
|
|
});
|
|
} catch (e) {
|
|
if (e.code === 'ENOENT') return;
|
|
throw e;
|
|
}
|
|
for (const entry of entries) {
|
|
if (++visited > 3000) break;
|
|
if (entry.isSymbolicLink()) continue;
|
|
const next = relative + '/' + entry.name;
|
|
if (entry.isDirectory()) await scan(next, depth + 1);
|
|
else if (entry.isFile() && /\.(log|txt)$/i.test(entry.name)) {
|
|
const stat = await fs.promises.stat(resolve(next));
|
|
files.push({ path: next, mtime: stat.mtimeMs });
|
|
}
|
|
}
|
|
}
|
|
await scan('temp/logs');
|
|
await scan('temp/builder');
|
|
await scan('build');
|
|
files.sort((a, b) => b.mtime - a.mtime);
|
|
const logs = [];
|
|
let remaining = 384 * 1024;
|
|
for (const file of files.slice(0, maxFiles)) {
|
|
if (remaining <= 0) break;
|
|
const log = await tail(file.path, maxLines);
|
|
const selected = [];
|
|
for (const line of log.lines) {
|
|
const bytes = Buffer.byteLength(line);
|
|
if (bytes > remaining) {
|
|
log.truncated = true;
|
|
break;
|
|
}
|
|
remaining -= bytes;
|
|
selected.push(line);
|
|
}
|
|
log.lines = selected;
|
|
logs.push(log);
|
|
}
|
|
return {
|
|
files: logs,
|
|
scannedEntries: visited,
|
|
truncated: visited >= 3000 || files.length > maxFiles,
|
|
diagnostics: logs
|
|
.flatMap((f) =>
|
|
f.lines
|
|
.filter((l) => /error|exception|failed|warning/i.test(l))
|
|
.map((message) => ({ path: f.path, message })),
|
|
)
|
|
.slice(0, 500),
|
|
};
|
|
}
|
|
return { resolve, assetUrl, tail, buildLogs };
|
|
};
|