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
404 lines
13 KiB
JavaScript
404 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { listAssets, queryAssetInfo } = require('../assets');
|
|
const { resolveProjectPath } = require('../path-safety');
|
|
|
|
const UUID_KEY_PATTERN = /uuid|assetUuid|prefabUuid|sceneUuid|__uuid__/i;
|
|
const UUID_LITERAL_PATTERN = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z0-9+/=-]{20,32}/g;
|
|
|
|
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 = resolveProjectPath(projectPath, candidate);
|
|
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
|
|
return fullPath;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function collectStructuredUuidReferences(value, refs = [], pointer = '') {
|
|
if (value == null) {
|
|
return refs;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
value.forEach((item, index) => collectStructuredUuidReferences(item, refs, `${pointer}/${index}`));
|
|
return refs;
|
|
}
|
|
if (typeof value !== 'object') {
|
|
return refs;
|
|
}
|
|
|
|
for (const [key, child] of Object.entries(value)) {
|
|
const childPointer = `${pointer}/${key}`;
|
|
if (typeof child === 'string' && UUID_KEY_PATTERN.test(key)) {
|
|
refs.push({ uuid: child, path: childPointer, key, source: 'structured' });
|
|
continue;
|
|
}
|
|
collectStructuredUuidReferences(child, refs, childPointer);
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
function collectTextUuidReferences(text) {
|
|
const refs = [];
|
|
const seen = new Set();
|
|
let match;
|
|
while ((match = UUID_LITERAL_PATTERN.exec(String(text || '')))) {
|
|
const uuid = match[0];
|
|
if (seen.has(uuid)) {
|
|
continue;
|
|
}
|
|
seen.add(uuid);
|
|
refs.push({ uuid, path: `@${match.index}`, key: '', source: 'text' });
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
function dedupeReferences(refs) {
|
|
const seen = new Set();
|
|
const result = [];
|
|
for (const ref of refs) {
|
|
const key = `${ref.uuid}:${ref.path}`;
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
result.push(ref);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function collectUuidReferences(content) {
|
|
const refs = [];
|
|
try {
|
|
refs.push(...collectStructuredUuidReferences(JSON.parse(content)));
|
|
} catch (error) {
|
|
// Non-JSON assets still get a literal reference scan below.
|
|
}
|
|
refs.push(...collectTextUuidReferences(content));
|
|
return dedupeReferences(refs);
|
|
}
|
|
|
|
async function inspectAssetDependencies(projectPath, target, options = {}) {
|
|
const info = await queryAssetInfo(target);
|
|
const isDirectory = info && (info.isDirectory || info.type === 'folder' || (info.url && !path.extname(info.url)));
|
|
const filePath = assetFilePath(projectPath, info);
|
|
if (!filePath) {
|
|
if (isDirectory) {
|
|
throw new Error(`Cannot inspect dependencies of a directory asset: ${target}. Please specify a file asset (e.g. a .prefab, .scene, or .mat file).`);
|
|
}
|
|
throw new Error(`Asset file was not found: ${target}`);
|
|
}
|
|
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(500, options.limit)) : 200;
|
|
const references = collectUuidReferences(content).slice(0, limit);
|
|
const dependencies = [];
|
|
const missing = [];
|
|
|
|
for (const ref of references) {
|
|
try {
|
|
const asset = await queryAssetInfo(ref.uuid);
|
|
dependencies.push({
|
|
...ref,
|
|
exists: true,
|
|
asset: {
|
|
uuid: asset.uuid,
|
|
url: asset.url,
|
|
type: asset.type,
|
|
importer: asset.importer,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
missing.push({ ...ref, exists: false, error: error.message });
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: missing.length === 0,
|
|
target,
|
|
asset: {
|
|
uuid: info.uuid,
|
|
url: info.url,
|
|
type: info.type,
|
|
},
|
|
filePath: path.relative(projectPath, filePath).replace(/\\/g, '/'),
|
|
referenceCount: references.length,
|
|
dependencyCount: dependencies.length,
|
|
missingCount: missing.length,
|
|
dependencies,
|
|
missing,
|
|
};
|
|
}
|
|
|
|
async function validateAssetDependencies(projectPath, options = {}) {
|
|
const targets = options.target
|
|
? [options.target]
|
|
: (await listAssets({ pattern: options.pattern || 'db://assets/**', ccType: options.ccType }))
|
|
.slice(0, Number.isFinite(options.limit) ? Math.max(1, Math.min(200, options.limit)) : 50)
|
|
.map((asset) => asset.uuid || asset.url)
|
|
.filter(Boolean);
|
|
|
|
const assets = [];
|
|
for (const target of targets) {
|
|
try {
|
|
assets.push(await inspectAssetDependencies(projectPath, target, options));
|
|
} catch (error) {
|
|
assets.push({
|
|
ok: false,
|
|
target,
|
|
error: error.message,
|
|
missingCount: 1,
|
|
});
|
|
}
|
|
}
|
|
|
|
const missingCount = assets.reduce((sum, asset) => sum + (Number(asset.missingCount) || 0), 0);
|
|
return {
|
|
ok: missingCount === 0,
|
|
assetCount: assets.length,
|
|
missingCount,
|
|
assets,
|
|
};
|
|
}
|
|
|
|
function createAssetsAdvancedTools({ createSchema, getRuntimeContext }) {
|
|
return [
|
|
{
|
|
name: 'inspect_asset_dependencies',
|
|
profile: 'core',
|
|
description: '[specialist] Inspect UUID-style dependencies referenced by a serialized Cocos asset.',
|
|
inputSchema: createSchema(
|
|
{
|
|
target: { type: 'string', description: 'Asset uuid, db url, or project path.' },
|
|
limit: { type: 'number', description: 'Maximum dependency references to inspect.' },
|
|
},
|
|
['target']
|
|
),
|
|
handler: async (args) => {
|
|
const { projectPath } = getRuntimeContext();
|
|
return await inspectAssetDependencies(projectPath, args.target, args);
|
|
},
|
|
},
|
|
{
|
|
name: 'validate_asset_dependencies',
|
|
profile: 'core',
|
|
description: '[specialist] Validate UUID-style dependencies for one asset or a project asset query.',
|
|
inputSchema: createSchema(
|
|
{
|
|
target: { type: 'string', description: 'Optional asset uuid, db url, or project path.' },
|
|
pattern: { type: 'string', description: 'Asset-db pattern used when target is omitted.' },
|
|
ccType: { type: 'string', description: 'Optional Cocos asset type filter.' },
|
|
limit: { type: 'number', description: 'Maximum assets to scan when target is omitted.' },
|
|
},
|
|
[]
|
|
),
|
|
handler: async (args) => {
|
|
const { projectPath } = getRuntimeContext();
|
|
return await validateAssetDependencies(projectPath, args);
|
|
},
|
|
},
|
|
{
|
|
name: 'batch_asset_ops',
|
|
profile: 'full',
|
|
description: '[core] Batch import or delete assets in the Cocos asset database.',
|
|
inputSchema: createSchema(
|
|
{
|
|
action: {
|
|
type: 'string',
|
|
enum: ['import', 'delete'],
|
|
description: 'Batch operation.',
|
|
},
|
|
sourceDirectory: {
|
|
type: 'string',
|
|
description: 'Local filesystem source path (import only).',
|
|
},
|
|
targetDirectory: {
|
|
type: 'string',
|
|
description: 'Asset-db target URL, e.g. db://assets/textures (import only).',
|
|
},
|
|
urls: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
description: 'Asset URLs to delete (delete only).',
|
|
},
|
|
},
|
|
['action']
|
|
),
|
|
handler: async (args) => {
|
|
const action = String(args.action || '').trim();
|
|
|
|
if (action === 'import') {
|
|
const sourceDirectory = String(args.sourceDirectory || '').trim();
|
|
const targetDirectory = String(args.targetDirectory || '').trim();
|
|
if (!sourceDirectory) {
|
|
throw new Error('sourceDirectory is required for action: import.');
|
|
}
|
|
if (!targetDirectory) {
|
|
throw new Error('targetDirectory is required for action: import.');
|
|
}
|
|
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
|
|
throw new Error('Editor.Message.request is unavailable in the Cocos extension host.');
|
|
}
|
|
const result = await Editor.Message.request('asset-db', 'import-asset', sourceDirectory, targetDirectory);
|
|
return { action, sourceDirectory, targetDirectory, result };
|
|
}
|
|
|
|
if (action === 'delete') {
|
|
const urls = Array.isArray(args.urls) ? args.urls.filter(Boolean) : [];
|
|
if (urls.length === 0) {
|
|
throw new Error('urls is required for action: delete.');
|
|
}
|
|
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
|
|
throw new Error('Editor.Message.request is unavailable in the Cocos extension host.');
|
|
}
|
|
const results = [];
|
|
for (const url of urls) {
|
|
try {
|
|
await Editor.Message.request('asset-db', 'delete-asset', url);
|
|
results.push({ url, deleted: true });
|
|
} catch (error) {
|
|
results.push({ url, deleted: false, error: error.message });
|
|
}
|
|
}
|
|
return { action, urls, results };
|
|
}
|
|
|
|
throw new Error(`Unknown action: ${action}. Must be one of: import, delete.`);
|
|
},
|
|
},
|
|
{
|
|
name: 'find_unused_assets',
|
|
profile: 'full',
|
|
description: '[core] Find assets not referenced by any scene, prefab, or animation in the project.',
|
|
inputSchema: createSchema(
|
|
{
|
|
directory: {
|
|
type: 'string',
|
|
description: 'Asset-db directory to scan. Default: db://assets.',
|
|
},
|
|
excludeDirectories: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
description: 'Directories to exclude from the reference scan.',
|
|
},
|
|
assetTypes: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
description: 'Filter by Cocos asset type, e.g. ["cc.Texture2D", "cc.AudioClip"].',
|
|
},
|
|
limit: { type: 'number', description: 'Max results. Default 200.' },
|
|
},
|
|
[]
|
|
),
|
|
handler: async (args) => {
|
|
const { projectPath } = getRuntimeContext();
|
|
return await findUnusedAssets(projectPath, args);
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
const SERIALIZED_EXTENSIONS = ['.scene', '.prefab', '.anim', '.mat'];
|
|
const SKIP_UNUSED_EXTENSIONS = ['.ts', '.scene'];
|
|
|
|
async function findUnusedAssets(projectPath, options = {}, listAssetsFn) {
|
|
const listAssetsRef = typeof listAssetsFn === 'function' ? listAssetsFn : listAssets;
|
|
const directory = options.directory || 'db://assets';
|
|
const excludeDirs = Array.isArray(options.excludeDirectories) ? options.excludeDirectories : [];
|
|
const assetTypes = Array.isArray(options.assetTypes) ? options.assetTypes : undefined;
|
|
const limit = Number.isFinite(options.limit) ? Math.max(1, options.limit) : 200;
|
|
|
|
const allAssets = await listAssetsRef({
|
|
pattern: directory + '/**',
|
|
ccType: assetTypes,
|
|
});
|
|
|
|
const referencedUuids = new Set();
|
|
const assetsDir = path.join(projectPath, 'assets');
|
|
|
|
function scanDir(dir) {
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (entry.name === 'node_modules' || entry.name === 'temp' || entry.name === 'library') {
|
|
continue;
|
|
}
|
|
const relativePath = path.relative(assetsDir, fullPath).replace(/\\/g, '/');
|
|
if (excludeDirs.some((excl) => relativePath.startsWith(String(excl)))) {
|
|
continue;
|
|
}
|
|
scanDir(fullPath);
|
|
} else {
|
|
const ext = path.extname(entry.name).toLowerCase();
|
|
if (!SERIALIZED_EXTENSIONS.includes(ext)) {
|
|
continue;
|
|
}
|
|
try {
|
|
const content = fs.readFileSync(fullPath, 'utf8');
|
|
const refs = collectUuidReferences(content);
|
|
for (const ref of refs) {
|
|
referencedUuids.add(ref.uuid);
|
|
}
|
|
} catch (_) {
|
|
// Skip unreadable files
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
scanDir(assetsDir);
|
|
|
|
const unused = allAssets.filter((asset) => {
|
|
if (!asset || !asset.uuid) {
|
|
return false;
|
|
}
|
|
if (asset.isDirectory) {
|
|
return false;
|
|
}
|
|
const url = asset.url || asset.source || '';
|
|
const ext = path.extname(url).toLowerCase();
|
|
if (SKIP_UNUSED_EXTENSIONS.includes(ext)) {
|
|
return false;
|
|
}
|
|
return !referencedUuids.has(asset.uuid);
|
|
});
|
|
|
|
return {
|
|
totalScanned: allAssets.length,
|
|
unusedCount: unused.length,
|
|
referencedCount: referencedUuids.size,
|
|
unused: unused.slice(0, limit),
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
collectUuidReferences,
|
|
createAssetsAdvancedTools,
|
|
findUnusedAssets,
|
|
inspectAssetDependencies,
|
|
validateAssetDependencies,
|
|
};
|