Files
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

286 lines
9.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
defaultEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
const releaseTarget =
process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget;
const bundleRoot = path.join(
appRoot,
'src-tauri',
'target',
releaseTarget,
'release',
'bundle',
);
const packageJsonPath = path.join(appRoot, 'package.json');
const rootPackageLockPath = path.resolve(appRoot, '../..', 'package-lock.json');
const tauriConfigPath = path.join(appRoot, 'src-tauri', 'tauri.conf.json');
const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml');
const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
const defaultOssBaseUrl =
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
const updateManifestUrl =
process.env.VITE_AGC_UPDATE_MANIFEST_URL?.trim() ||
`${process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl}/latest.json`;
function readPackageJson() {
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
}
export function compareVersions(left, right) {
const leftParts = left.split('.').map(Number);
const rightParts = right.split('.').map(Number);
for (let index = 0; index < 3; index += 1) {
if (leftParts[index] !== rightParts[index]) {
return leftParts[index] > rightParts[index] ? 1 : -1;
}
}
return 0;
}
function parseVersion(value, label) {
if (typeof value !== 'string' || !/^\d+\.\d+\.\d+$/u.test(value)) {
throw new Error(`${label} 不是有效的三段版本号:${String(value)}`);
}
return value;
}
export function nextPatchVersion(localVersion, remoteVersion) {
const local = parseVersion(localVersion, '本地版本');
const remote =
remoteVersion == null ? null : parseVersion(remoteVersion, 'OSS版本');
const base = remote && compareVersions(remote, local) > 0 ? remote : local;
const [major, minor, patch] = base.split('.').map(Number);
if (patch === Number.MAX_SAFE_INTEGER) {
throw new Error(`版本号 patch 已达到上限:${base}`);
}
return `${major}.${minor}.${patch + 1}`;
}
async function readRemoteVersion() {
let response;
try {
response = await fetch(updateManifestUrl, {
headers: { Accept: 'application/json' },
});
} catch (error) {
throw new Error(`读取 OSS 版本清单失败:${error.message}`);
}
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`读取 OSS 版本清单失败:HTTP ${response.status}`);
}
let manifest;
try {
manifest = await response.json();
} catch (error) {
throw new Error(`OSS 版本清单不是有效 JSON${error.message}`);
}
return parseVersion(manifest?.version, 'OSS版本清单 version');
}
function replaceVersionLine(source, version, pattern, label) {
if (!pattern.test(source)) throw new Error(`未找到${label}版本字段`);
return source.replace(pattern, `$1${version}$3`);
}
export async function prepareReleaseVersion() {
const localVersion = parseVersion(readPackageJson().version, '本地版本');
const remoteVersion = await readRemoteVersion();
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
const nextVersion = requestedVersion
? parseVersion(requestedVersion, '指定版本')
: nextPatchVersion(localVersion, remoteVersion);
const packageSource = fs.readFileSync(packageJsonPath, 'utf8');
fs.writeFileSync(
packageJsonPath,
replaceVersionLine(
packageSource,
nextVersion,
/("version"\s*:\s*")([^"]+)(")/u,
'package.json',
),
);
const rootPackageLockSource = fs.readFileSync(rootPackageLockPath, 'utf8');
fs.writeFileSync(
rootPackageLockPath,
replaceVersionLine(
rootPackageLockSource,
nextVersion,
/("apps\/ai-game-creator-shell"\s*:\s*\{\s*\n\s*"name"\s*:\s*"@genarrative\/ai-game-creator-shell"\s*,\s*\n\s*"version"\s*:\s*")([^"]+)(")/u,
'root package-lock.json',
),
);
const tauriSource = fs.readFileSync(tauriConfigPath, 'utf8');
fs.writeFileSync(
tauriConfigPath,
replaceVersionLine(
tauriSource,
nextVersion,
/("version"\s*:\s*")([^"]+)(")/u,
'tauri.conf.json',
),
);
const cargoSource = fs.readFileSync(cargoManifestPath, 'utf8');
fs.writeFileSync(
cargoManifestPath,
replaceVersionLine(
cargoSource,
nextVersion,
/(^\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/mu,
'Cargo.toml',
),
);
const cargoLockSource = fs.readFileSync(cargoLockPath, 'utf8');
fs.writeFileSync(
cargoLockPath,
replaceVersionLine(
cargoLockSource,
nextVersion,
/(^name\s*=\s*"genarrative-ai-game-creator-shell"\s*\nversion\s*=\s*")([^"]+)(")/mu,
'Cargo.lock',
),
);
console.log(
requestedVersion
? `[ai-game-creator-shell] 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'}`
: `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
);
return nextVersion;
}
export function buildTauriBuildArguments(
args = [],
target = releaseTarget,
platform = process.platform,
) {
const noBundle = args.includes('--no-bundle');
const targetIndex = args.indexOf('--target');
const explicitTarget =
targetIndex >= 0
? args[targetIndex + 1]
: args
.find((value) => value.startsWith('--target='))
?.slice('--target='.length);
const targetArgs = noBundle || explicitTarget ? [] : ['--target', target];
const features = defaultEditorFeatures(
explicitTarget || (noBundle ? platform : target),
);
return [
'build',
...withDefaultCargoFeatures([...targetArgs, ...args], features),
];
}
export function runTauriBuild(args = []) {
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const result = spawnSync(
npmCommand,
[
'--prefix',
'../..',
'exec',
'tauri',
'--',
...buildTauriBuildArguments(args),
],
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
);
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
function listFiles(root) {
if (!fs.existsSync(root)) return [];
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
const fullPath = path.join(root, entry.name);
return entry.isDirectory() ? listFiles(fullPath) : [fullPath];
});
}
function artifactPriority(filePath) {
const name = path.basename(filePath).toLowerCase();
if (releaseTarget.includes('windows')) return name.endsWith('.exe') ? 0 : 99;
if (process.platform === 'darwin') return name.endsWith('.dmg') ? 0 : 99;
if (name.endsWith('.appimage')) return 0;
if (name.endsWith('.deb')) return 1;
if (name.endsWith('.rpm')) return 2;
return 99;
}
export function selectReleaseArtifact(files) {
const explicit = process.env.AGC_UPDATE_ARTIFACT?.trim();
if (explicit) {
const resolved = path.resolve(explicit);
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
throw new Error(`AGC_UPDATE_ARTIFACT 不是有效文件:${resolved}`);
}
return resolved;
}
return (
[...files]
.filter((filePath) => artifactPriority(filePath) < 99)
.sort((left, right) => {
const priority = artifactPriority(left) - artifactPriority(right);
return priority || left.localeCompare(right);
})[0] ?? null
);
}
export function createUpdateManifest(artifactPath) {
const bytes = fs.readFileSync(artifactPath);
const version = readPackageJson().version;
const fileName = path.basename(artifactPath);
const baseUrl = (
process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl
).replace(/\/+$/u, '');
const encodedFileName = encodeURIComponent(fileName).replace(/%2F/giu, '/');
return {
version,
downloadUrl: `${baseUrl}/${encodeURIComponent(version)}/${encodedFileName}`,
sha256: createHash('sha256').update(bytes).digest('hex'),
size: bytes.length,
...(process.env.AGC_UPDATE_RELEASE_NOTES?.trim()
? { releaseNotes: process.env.AGC_UPDATE_RELEASE_NOTES.trim() }
: {}),
};
}
export function generateUpdateManifest() {
const artifact = selectReleaseArtifact(listFiles(bundleRoot));
if (!artifact) {
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
}
const manifest = createUpdateManifest(artifact);
const manifestPath = path.join(bundleRoot, 'latest.json');
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
console.log(`[ai-game-creator-shell] 已生成 ${manifestPath}`);
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
return { artifact, manifestPath, manifest };
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
const args = process.argv.slice(2);
if (!args.includes('--no-bundle')) await prepareReleaseVersion();
runTauriBuild(args);
if (!args.includes('--no-bundle')) generateUpdateManifest();
}