Files
Genarrative/plugins/agc-cocos-editor/src/operations/paths.cjs
T
kdletters 4a46f89c9b
Project CI / Repository checks (push) Successful in 2m45s
Project CI / Frontend tests (push) Successful in 3m27s
Project CI / Backend tests (push) Successful in 6m18s
Project CI / Native shell tests (push) Failing after 13m52s
接入 AGC 内置插件宿主并补齐 Cocos 编辑器能力 (#338)
客户端新增随包提供的插件宿主和 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>
2026-09-13 14:48:55 +08:00

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 };
};