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>
305 lines
8.9 KiB
JavaScript
305 lines
8.9 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
export const REQUIRED_PACKAGE_MANAGER = 'npm@10.9.7';
|
|
|
|
export const REQUIRED_WORKSPACES = Object.freeze([
|
|
'apps/admin-web',
|
|
'apps/ai-game-creator-shell',
|
|
'apps/desktop-shell',
|
|
'apps/mobile-shell',
|
|
'apps/preview-deployer-web',
|
|
'packages/image-canvas-core',
|
|
'packages/image-canvas-react',
|
|
'packages/agc-plugin-sdk',
|
|
'packages/shared',
|
|
'plugins/agc-cocos-editor',
|
|
'tools/spine-json-export-validator',
|
|
]);
|
|
|
|
const WORKSPACE_NAMES = Object.freeze({
|
|
'apps/admin-web': '@genarrative/admin-web',
|
|
'apps/ai-game-creator-shell': '@genarrative/ai-game-creator-shell',
|
|
'apps/desktop-shell': '@genarrative/desktop-shell',
|
|
'apps/mobile-shell': '@genarrative/mobile-shell',
|
|
'apps/preview-deployer-web': '@genarrative/preview-deployer-web',
|
|
'packages/image-canvas-core': '@genarrative/image-canvas-core',
|
|
'packages/image-canvas-react': '@genarrative/image-canvas-react',
|
|
'packages/agc-plugin-sdk': '@genarrative/agc-plugin-sdk',
|
|
'packages/shared': '@genarrative/shared',
|
|
'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor',
|
|
'tools/spine-json-export-validator':
|
|
'@genarrative/spine-json-export-validator',
|
|
});
|
|
|
|
const REQUIRED_LOCAL_DEPENDENCIES = Object.freeze({
|
|
'package.json': [
|
|
'@genarrative/image-canvas-core',
|
|
'@genarrative/image-canvas-react',
|
|
'@genarrative/shared',
|
|
],
|
|
'apps/admin-web/package.json': ['@genarrative/shared'],
|
|
'apps/ai-game-creator-shell/package.json': [
|
|
'@genarrative/image-canvas-core',
|
|
'@genarrative/image-canvas-react',
|
|
'@genarrative/shared',
|
|
],
|
|
'apps/mobile-shell/package.json': ['@genarrative/shared'],
|
|
'packages/image-canvas-react/package.json': [
|
|
'@genarrative/image-canvas-core',
|
|
],
|
|
'plugins/agc-cocos-editor/package.json': ['@genarrative/agc-plugin-sdk'],
|
|
});
|
|
|
|
const DEPENDENCY_FIELDS = Object.freeze([
|
|
'dependencies',
|
|
'devDependencies',
|
|
'optionalDependencies',
|
|
'peerDependencies',
|
|
]);
|
|
|
|
const IGNORED_NESTED_DIRECTORIES = new Set([
|
|
'.git',
|
|
'.vite',
|
|
'build',
|
|
'dist',
|
|
'node_modules',
|
|
'target',
|
|
]);
|
|
|
|
const HOIST_SENSITIVE_CONFIGS = Object.freeze([
|
|
'vite.config.ts',
|
|
'vitest.config.ts',
|
|
'apps/ai-game-creator-shell/vite.config.ts',
|
|
]);
|
|
|
|
const FORBIDDEN_WORKSPACE_NODE_MODULES_PATH =
|
|
'apps/ai-game-creator-shell/node_modules/';
|
|
|
|
function readJson(rootDir, relativePath, errors) {
|
|
const absolutePath = path.join(rootDir, relativePath);
|
|
try {
|
|
return JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
|
|
} catch (error) {
|
|
errors.push(`${relativePath}: cannot read valid JSON (${error.message})`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeWorkspaceLink(value) {
|
|
return typeof value === 'string'
|
|
? value.replace(/^\.\//u, '').replaceAll('\\', '/')
|
|
: value;
|
|
}
|
|
|
|
function findNestedLockfiles(rootDir, workspacePath) {
|
|
const matches = [];
|
|
const pending = [path.join(rootDir, workspacePath)];
|
|
|
|
while (pending.length > 0) {
|
|
const directory = pending.pop();
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (entry.isSymbolicLink()) {
|
|
continue;
|
|
}
|
|
const entryPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (!IGNORED_NESTED_DIRECTORIES.has(entry.name)) {
|
|
pending.push(entryPath);
|
|
}
|
|
continue;
|
|
}
|
|
if (
|
|
entry.name === 'package-lock.json' ||
|
|
entry.name === 'npm-shrinkwrap.json'
|
|
) {
|
|
matches.push(path.relative(rootDir, entryPath).replaceAll('\\', '/'));
|
|
}
|
|
}
|
|
}
|
|
|
|
return matches.sort();
|
|
}
|
|
|
|
export function collectNpmWorkspaceErrors(rootDir) {
|
|
const errors = [];
|
|
const rootPackage = readJson(rootDir, 'package.json', errors);
|
|
const lockfile = readJson(rootDir, 'package-lock.json', errors);
|
|
if (!rootPackage || !lockfile) {
|
|
return errors;
|
|
}
|
|
|
|
for (const configPath of HOIST_SENSITIVE_CONFIGS) {
|
|
const absolutePath = path.join(rootDir, configPath);
|
|
if (
|
|
fs.existsSync(absolutePath) &&
|
|
fs
|
|
.readFileSync(absolutePath, 'utf8')
|
|
.includes(FORBIDDEN_WORKSPACE_NODE_MODULES_PATH)
|
|
) {
|
|
errors.push(
|
|
`${configPath}: resolve dependencies from the workspace manifest instead of hard-coding ${FORBIDDEN_WORKSPACE_NODE_MODULES_PATH}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (rootPackage.packageManager !== REQUIRED_PACKAGE_MANAGER) {
|
|
errors.push(
|
|
`package.json: packageManager must be ${REQUIRED_PACKAGE_MANAGER}, received ${String(rootPackage.packageManager)}`,
|
|
);
|
|
}
|
|
if (
|
|
JSON.stringify(rootPackage.workspaces) !==
|
|
JSON.stringify(REQUIRED_WORKSPACES)
|
|
) {
|
|
errors.push(
|
|
`package.json: workspaces must be the explicit ordered list ${JSON.stringify(REQUIRED_WORKSPACES)}`,
|
|
);
|
|
}
|
|
|
|
const manifests = new Map([['package.json', rootPackage]]);
|
|
for (const workspacePath of REQUIRED_WORKSPACES) {
|
|
const manifestPath = `${workspacePath}/package.json`;
|
|
const manifest = readJson(rootDir, manifestPath, errors);
|
|
if (!manifest) {
|
|
continue;
|
|
}
|
|
manifests.set(manifestPath, manifest);
|
|
if (manifest.name !== WORKSPACE_NAMES[workspacePath]) {
|
|
errors.push(
|
|
`${manifestPath}: name must be ${WORKSPACE_NAMES[workspacePath]}, received ${String(manifest.name)}`,
|
|
);
|
|
}
|
|
if (workspacePath === 'apps/ai-game-creator-shell') {
|
|
if (!/^\d+\.\d+\.\d+$/u.test(manifest.version ?? '')) {
|
|
errors.push(
|
|
`${manifestPath}: workspace version must be a three-part semver`,
|
|
);
|
|
}
|
|
} else if (manifest.version !== '0.1.0') {
|
|
errors.push(`${manifestPath}: workspace version must be 0.1.0`);
|
|
}
|
|
|
|
for (const nestedLockfile of findNestedLockfiles(rootDir, workspacePath)) {
|
|
errors.push(
|
|
`${nestedLockfile}: nested npm lockfiles are forbidden inside workspaces`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const localPackageNames = new Set(Object.values(WORKSPACE_NAMES));
|
|
for (const [manifestPath, manifest] of manifests) {
|
|
for (const dependencyField of DEPENDENCY_FIELDS) {
|
|
for (const [dependencyName, dependencySpec] of Object.entries(
|
|
manifest[dependencyField] ?? {},
|
|
)) {
|
|
if (
|
|
typeof dependencySpec === 'string' &&
|
|
dependencySpec.startsWith('workspace:')
|
|
) {
|
|
errors.push(
|
|
`${manifestPath}: ${dependencyField}.${dependencyName} must not use the unsupported workspace: protocol`,
|
|
);
|
|
}
|
|
if (
|
|
localPackageNames.has(dependencyName) &&
|
|
dependencySpec !== '0.1.0'
|
|
) {
|
|
errors.push(
|
|
`${manifestPath}: ${dependencyField}.${dependencyName} must use exact version 0.1.0`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [manifestPath, requiredDependencies] of Object.entries(
|
|
REQUIRED_LOCAL_DEPENDENCIES,
|
|
)) {
|
|
const manifest = manifests.get(manifestPath);
|
|
if (!manifest) {
|
|
continue;
|
|
}
|
|
for (const dependencyName of requiredDependencies) {
|
|
if (manifest.dependencies?.[dependencyName] !== '0.1.0') {
|
|
errors.push(
|
|
`${manifestPath}: dependencies.${dependencyName} must be declared as exact version 0.1.0`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const lockPackages = lockfile.packages;
|
|
if (!lockPackages || typeof lockPackages !== 'object') {
|
|
errors.push('package-lock.json: packages map is required');
|
|
return errors;
|
|
}
|
|
if (
|
|
JSON.stringify(lockPackages['']?.workspaces) !==
|
|
JSON.stringify(REQUIRED_WORKSPACES)
|
|
) {
|
|
errors.push(
|
|
'package-lock.json: root package entry must contain the explicit workspace list',
|
|
);
|
|
}
|
|
|
|
for (const workspacePath of REQUIRED_WORKSPACES) {
|
|
const expectedName = WORKSPACE_NAMES[workspacePath];
|
|
const workspaceEntry = lockPackages[workspacePath];
|
|
if (!workspaceEntry || workspaceEntry.name !== expectedName) {
|
|
errors.push(
|
|
`package-lock.json: packages[${JSON.stringify(workspacePath)}] must describe ${expectedName}`,
|
|
);
|
|
}
|
|
|
|
const linkKey = `node_modules/${expectedName}`;
|
|
const linkEntry = lockPackages[linkKey];
|
|
if (
|
|
!linkEntry ||
|
|
linkEntry.link !== true ||
|
|
normalizeWorkspaceLink(linkEntry.resolved) !== workspacePath
|
|
) {
|
|
errors.push(
|
|
`package-lock.json: packages[${JSON.stringify(linkKey)}] must link to ${workspacePath}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
export function checkNpmWorkspaces(rootDir) {
|
|
const errors = collectNpmWorkspaceErrors(rootDir);
|
|
if (errors.length > 0) {
|
|
throw new Error(
|
|
`npm workspace validation failed:\n- ${errors.join('\n- ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const isMainModule =
|
|
process.argv[1] &&
|
|
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
|
|
|
|
if (isMainModule) {
|
|
const rootDir = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
);
|
|
try {
|
|
checkNpmWorkspaces(rootDir);
|
|
console.log('[check:npm-workspaces] OK');
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|