Files
mingyuansi b65a4f22c3 fix: Windows spawn, prefab path, preview API, asset deps, error messages; add execute-context resource, tool descriptions, scene/script/asset tools
Bug fixes (verified against Cocos Creator 3.8.8):
- diagnostics: shell:true for .cmd/.bat on Windows (T125/T126)
- prefabs: duplicatePrefab target resolves under assets/ (T420)
- cocos-project: add preview.open candidate for 3.8.8 (T444)
- assets-advanced: detect directory assets in inspectAssetDependencies (T110)
- scene: improve component-not-found errors with compilation hint (T429)

Documentation improvements:
- tool-registry: add enum and injected vars to execute_javascript, path format examples, asset ref limitation note
- resources: add cocos://mcp/execute-context resource with variables, patterns, pitfalls

New tools (core 37->38, full 101->110):
- scene-management: create_scene, query_scene_state (core), copy_paste_node, rename_node, reparent_node
- scripts: create_script with component/plain templates
- prefabs: create_prefab
- assets-advanced: batch_asset_ops, find_unused_assets
2026-06-30 21:53:06 +08:00

296 lines
9.3 KiB
JavaScript

'use strict';
const fs = require('fs');
const path = require('path');
const { listAssets, queryAssetData, queryAssetInfo, queryAssetMeta } = require('./assets');
const { resolveProjectPath } = require('./path-safety');
function requestEditorMessage(channel, method, ...args) {
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
throw new Error('Editor.Message.request is unavailable in the Cocos extension host.');
}
return Editor.Message.request(channel, method, ...args);
}
function assetUrlToPath(projectPath, url) {
if (!url || !String(url).startsWith('db://assets/')) {
return '';
}
return path.join(projectPath, String(url).slice('db://'.length));
}
function assetFilePath(projectPath, info) {
const candidates = [
info && info.file,
info && info.path,
info && info.source,
info && info.url ? assetUrlToPath(projectPath, info.url) : '',
].filter(Boolean);
for (const candidate of candidates) {
const fullPath = path.isAbsolute(candidate)
? resolveProjectPath(projectPath, candidate)
: resolveProjectPath(projectPath, candidate);
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
return fullPath;
}
}
return '';
}
function collectUuidReferences(value, refs = [], pointer = '') {
if (!value || typeof value !== 'object') {
return refs;
}
if (Array.isArray(value)) {
value.forEach((item, index) => collectUuidReferences(item, refs, `${pointer}/${index}`));
return refs;
}
for (const [key, child] of Object.entries(value)) {
const childPointer = `${pointer}/${key}`;
if (
typeof child === 'string' &&
(key.toLowerCase().includes('uuid') || key === '__uuid__' || key === 'assetUuid' || key === 'prefabUuid')
) {
refs.push({ uuid: child, path: childPointer, key });
} else {
collectUuidReferences(child, refs, childPointer);
}
}
return refs;
}
function getByJsonPath(target, jsonPath) {
const segments = String(jsonPath || '')
.replace(/^\//, '')
.split(/[/.]/)
.map((segment) => segment.trim())
.filter(Boolean);
let current = target;
for (const segment of segments) {
if (current == null) {
return undefined;
}
current = current[segment];
}
return current;
}
function setByJsonPath(target, jsonPath, value) {
const segments = String(jsonPath || '')
.replace(/^\//, '')
.split(/[/.]/)
.map((segment) => segment.trim())
.filter(Boolean);
if (!segments.length) {
throw new Error('jsonPath is required.');
}
let current = target;
for (let index = 0; index < segments.length - 1; index += 1) {
const segment = segments[index];
if (current[segment] == null || typeof current[segment] !== 'object') {
current[segment] = {};
}
current = current[segment];
}
current[segments[segments.length - 1]] = value;
}
async function inspectPrefab(projectPath, target) {
const info = await queryAssetInfo(target);
const meta = await queryAssetMeta(target).catch(() => null);
const data = await queryAssetData(target).catch(() => null);
const filePath = assetFilePath(projectPath, info);
const content = filePath ? fs.readFileSync(filePath, 'utf8') : '';
const parsed = content ? JSON.parse(content) : data;
const references = collectUuidReferences(parsed).slice(0, 500);
return {
info,
meta,
filePath: filePath ? path.relative(projectPath, filePath).replace(/\\/g, '/') : '',
referenceCount: references.length,
references,
};
}
async function validatePrefabReferences(projectPath, options = {}) {
const targets = options.target
? [options.target]
: (await listAssets({ pattern: options.pattern || 'db://assets/**', ccType: 'cc.Prefab' }))
.slice(0, Number.isFinite(options.limit) ? Math.max(1, Math.min(200, options.limit)) : 50)
.map((asset) => asset.uuid || asset.url)
.filter(Boolean);
const prefabs = [];
for (const target of targets) {
const prefab = await inspectPrefab(projectPath, target);
const checked = [];
const missing = [];
for (const ref of prefab.references) {
try {
const info = await queryAssetInfo(ref.uuid);
checked.push({ ...ref, exists: true, asset: { uuid: info.uuid, url: info.url, type: info.type } });
} catch (error) {
missing.push({ ...ref, exists: false, error: error.message });
}
}
prefabs.push({
target,
filePath: prefab.filePath,
referenceCount: prefab.referenceCount,
checkedCount: checked.length + missing.length,
missingCount: missing.length,
missing,
});
}
const missingCount = prefabs.reduce((sum, prefab) => sum + prefab.missingCount, 0);
return {
ok: missingCount === 0,
prefabCount: prefabs.length,
missingCount,
prefabs,
};
}
async function duplicatePrefab(projectPath, options = {}) {
const source = String(options.source || '').trim();
const target = String(options.target || '').trim();
if (!source || !target) {
throw new Error('source and target are required.');
}
const info = await queryAssetInfo(source);
const sourcePath = assetFilePath(projectPath, info);
if (!sourcePath) {
throw new Error(`Prefab source file was not found: ${source}`);
}
const rawTarget = target.endsWith('.prefab') ? target : `${target}.prefab`;
const targetUnderAssets = rawTarget.replace(/^assets[\\/]/, '');
const targetPath = resolveProjectPath(projectPath, path.join('assets', targetUnderAssets));
const assetsRoot = path.join(projectPath, 'assets');
const relativeToAssets = path.relative(assetsRoot, targetPath);
if (relativeToAssets.startsWith('..') || path.isAbsolute(relativeToAssets)) {
throw new Error('target must be inside the Cocos assets directory.');
}
if (fs.existsSync(targetPath) && options.overwrite !== true) {
throw new Error(`Target prefab already exists: ${target}`);
}
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.copyFileSync(sourcePath, targetPath);
return {
duplicated: true,
source: path.relative(projectPath, sourcePath).replace(/\\/g, '/'),
target: path.relative(projectPath, targetPath).replace(/\\/g, '/'),
};
}
async function editPrefabJson(projectPath, options = {}) {
const target = String(options.target || '').trim();
if (!target) {
throw new Error('target is required.');
}
const info = await queryAssetInfo(target);
const filePath = assetFilePath(projectPath, info);
if (!filePath) {
throw new Error(`Prefab file was not found: ${target}`);
}
const original = fs.readFileSync(filePath, 'utf8');
let updated = original;
if (options.search !== undefined) {
const search = String(options.search);
if (!search) {
throw new Error('search must not be empty.');
}
if (!original.includes(search)) {
throw new Error('search text was not found in prefab file.');
}
updated = options.replaceAll
? original.split(search).join(String(options.replace || ''))
: original.replace(search, String(options.replace || ''));
} else {
const json = JSON.parse(original);
const value = JSON.parse(String(options.valueJson || 'null'));
setByJsonPath(json, options.jsonPath, value);
updated = JSON.stringify(json, null, 2) + '\n';
}
JSON.parse(updated);
if (options.createBackup) {
fs.writeFileSync(`${filePath}.bak`, original, 'utf8');
}
fs.writeFileSync(filePath, updated, 'utf8');
return {
edited: true,
path: path.relative(projectPath, filePath).replace(/\\/g, '/'),
oldValue: options.jsonPath ? getByJsonPath(JSON.parse(original), options.jsonPath) : undefined,
validation: await validatePrefabReferences(projectPath, { target }),
};
}
async function applyPrefabInstance(nodeUuid) {
const uuid = String(nodeUuid || '').trim();
if (!uuid) {
throw new Error('node uuid is required.');
}
const result = await requestEditorMessage('scene', 'apply-prefab', uuid);
return { applied: true, uuid, result };
}
async function revertPrefabInstance(nodeUuid) {
const uuid = String(nodeUuid || '').trim();
if (!uuid) {
throw new Error('node uuid is required.');
}
const candidates = ['revert-prefab', 'restore-prefab'];
let lastError = null;
for (const method of candidates) {
try {
const result = await requestEditorMessage('scene', method, uuid);
return { reverted: true, uuid, method, result };
} catch (error) {
lastError = error;
}
}
throw lastError || new Error('No prefab revert editor message was available.');
}
async function createPrefab(options = {}) {
const nodeUuid = String(options.nodeUuid || '').trim();
const savePath = String(options.savePath || '').trim();
if (!nodeUuid) {
throw new Error('nodeUuid is required.');
}
if (!savePath) {
throw new Error('savePath is required.');
}
if (!savePath.endsWith('.prefab')) {
throw new Error('savePath must end with .prefab');
}
// Note: 'create-prefab' is not a public message in Cocos Creator 3.8.8
// (Public: No in editor-messages-3.8.8.md). It works but may change in future versions.
const result = await requestEditorMessage('scene', 'create-prefab', nodeUuid, savePath);
return {
created: true,
nodeUuid,
savePath,
result,
};
}
module.exports = {
applyPrefabInstance,
createPrefab,
duplicatePrefab,
editPrefabJson,
inspectPrefab,
revertPrefabInstance,
validatePrefabReferences,
};