@playcraft/cli 0.0.23 → 0.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ export function getCliTopicsHelpText() {
4
4
  '命令分区(完整说明见仓库 docs/cli/capabilities.md):',
5
5
  '',
6
6
  ' 本地开发 init, start, stop, status, logs, config, sync, fix-ids',
7
- ' 素材 tools generate-* | image <子命令> | audio <子命令>',
7
+ ' 素材 tools generate-* | image <子命令> | audio <子命令> | prefab <子命令>',
8
8
  ' 平台 build, build-base, build-playable, build-all, analyze, inspect, upgrade',
9
9
  ' tools 除 generate-* 外(Git / 沙箱构建 / 发布 / 列表 / Prefab 等,需后端 API)',
10
10
  '',
@@ -434,6 +434,7 @@ export async function buildCommand(projectPath, options) {
434
434
  clean: shouldClean && !isSameAsInput, // 清理逻辑由 BaseBuilder 内部处理
435
435
  analyze: options.analyze, // 传递 analyze 参数
436
436
  engine: detectedEngine, // 传递引擎类型
437
+ ...(options.format ? { format: options.format } : {}),
437
438
  }, {
438
439
  usePlayableScripts: isPlayableScripts,
439
440
  playableScriptsConfig,
@@ -685,6 +686,8 @@ export async function buildCommand(projectPath, options) {
685
686
  ...(options.esmMode ? { esmMode: options.esmMode } : {}),
686
687
  // 商店跳转地址
687
688
  ...(options.storeUrls ? { storeUrls: options.storeUrls } : {}),
689
+ // Tracking 埋点(默认启用,--no-tracking 可关闭)
690
+ ...(options.tracking === false ? { tracking: { enabled: false } } : {}),
688
691
  };
689
692
  if (fileConfig) {
690
693
  Object.assign(buildOptions, fileConfig);
@@ -746,6 +749,7 @@ export async function buildCommand(projectPath, options) {
746
749
  analyze: buildOptions.analyze,
747
750
  analyzeReportPath: buildOptions.analyze ? 'base-bundle-report.html' : undefined,
748
751
  engine: detectedEngine, // 传递引擎类型
752
+ format: buildOptions.format,
749
753
  }, {
750
754
  usePlayableScripts: isPlayableScripts,
751
755
  playableScriptsConfig,
@@ -13,6 +13,10 @@ function getMaxInputBytes() {
13
13
  }
14
14
  return DEFAULT_MAX_INPUT_BYTES;
15
15
  }
16
+ /** Commander 会把上一值/默认值作为第二参数传入,不能直接用 `parseInt`(会当作 radix)。 */
17
+ function cliParseInt(value) {
18
+ return Number.parseInt(value, 10);
19
+ }
16
20
  function assertInputWithinLimit(filePath, label = 'input') {
17
21
  const max = getMaxInputBytes();
18
22
  const st = statSync(filePath);
@@ -66,8 +70,8 @@ export function registerImageCommands(program) {
66
70
  .description('缩放图片(按比例或指定宽高)')
67
71
  .requiredOption('--input <path>', '输入图片路径')
68
72
  .requiredOption('--output <path>', '输出图片路径')
69
- .option('--width <n>', '目标宽度(像素)', parseInt)
70
- .option('--height <n>', '目标高度(像素)', parseInt)
73
+ .option('--width <n>', '目标宽度(像素)', cliParseInt)
74
+ .option('--height <n>', '目标高度(像素)', cliParseInt)
71
75
  .option('--scale <factor>', '缩放比例(如 0.5 = 50%)', parseFloat)
72
76
  .option('--fit <mode>', 'contain|cover|fill|inside|outside', 'cover')
73
77
  .action(async (opts) => {
@@ -107,10 +111,10 @@ export function registerImageCommands(program) {
107
111
  .description('裁剪图片(指定区域)')
108
112
  .requiredOption('--input <path>', '输入图片路径')
109
113
  .requiredOption('--output <path>', '输出图片路径')
110
- .requiredOption('--x <n>', '左边距(像素)', parseInt)
111
- .requiredOption('--y <n>', '上边距(像素)', parseInt)
112
- .requiredOption('--width <n>', '裁剪宽度(像素)', parseInt)
113
- .requiredOption('--height <n>', '裁剪高度(像素)', parseInt)
114
+ .requiredOption('--x <n>', '左边距(像素)', cliParseInt)
115
+ .requiredOption('--y <n>', '上边距(像素)', cliParseInt)
116
+ .requiredOption('--width <n>', '裁剪宽度(像素)', cliParseInt)
117
+ .requiredOption('--height <n>', '裁剪高度(像素)', cliParseInt)
114
118
  .action(async (opts) => {
115
119
  try {
116
120
  assertInputWithinLimit(opts.input);
@@ -130,7 +134,7 @@ export function registerImageCommands(program) {
130
134
  .description('旋转图片')
131
135
  .requiredOption('--input <path>', '输入图片路径')
132
136
  .requiredOption('--output <path>', '输出图片路径')
133
- .requiredOption('--angle <n>', '旋转角度(顺时针,如 90/180/270 或任意整数)', parseInt)
137
+ .requiredOption('--angle <n>', '旋转角度(顺时针,如 90/180/270 或任意整数)', cliParseInt)
134
138
  .option('--background <color>', '旋转后露出的背景色(CSS hex,如 #000000)', '#00000000')
135
139
  .action(async (opts) => {
136
140
  try {
@@ -180,11 +184,11 @@ export function registerImageCommands(program) {
180
184
  .description('扩展画布,在图片四周添加边距(保持原图居中)')
181
185
  .requiredOption('--input <path>', '输入图片路径')
182
186
  .requiredOption('--output <path>', '输出图片路径')
183
- .option('--top <n>', '上边距(像素)', parseInt, 0)
184
- .option('--bottom <n>', '下边距(像素)', parseInt, 0)
185
- .option('--left <n>', '左边距(像素)', parseInt, 0)
186
- .option('--right <n>', '右边距(像素)', parseInt, 0)
187
- .option('--all <n>', '四周统一边距(覆盖单独方向设置)', parseInt)
187
+ .option('--top <n>', '上边距(像素)', cliParseInt, 0)
188
+ .option('--bottom <n>', '下边距(像素)', cliParseInt, 0)
189
+ .option('--left <n>', '左边距(像素)', cliParseInt, 0)
190
+ .option('--right <n>', '右边距(像素)', cliParseInt, 0)
191
+ .option('--all <n>', '四周统一边距(覆盖单独方向设置)', cliParseInt)
188
192
  .option('--background <color>', '背景色(CSS hex)', '#00000000')
189
193
  .action(async (opts) => {
190
194
  try {
@@ -210,7 +214,7 @@ export function registerImageCommands(program) {
210
214
  .requiredOption('--input <path>', '输入图片路径')
211
215
  .requiredOption('--output <path>', '输出图片路径(扩展名决定格式,或用 --format 强制指定)')
212
216
  .option('--format <fmt>', '目标格式 (png|jpg|webp|avif),默认从输出路径扩展名推断')
213
- .option('--quality <n>', '压缩质量 1-100(jpg/webp/avif 有效)', parseInt, 85)
217
+ .option('--quality <n>', '压缩质量 1-100(jpg/webp/avif 有效)', cliParseInt, 85)
214
218
  .option('--lossless', '无损压缩(webp/avif 有效)')
215
219
  .action(async (opts) => {
216
220
  try {
@@ -302,7 +306,7 @@ export function registerImageCommands(program) {
302
306
  .description('自动裁掉透明或纯色边界')
303
307
  .requiredOption('--input <path>', '输入图片路径')
304
308
  .requiredOption('--output <path>', '输出图片路径')
305
- .option('--threshold <n>', '颜色容差(0-255,默认 10)', parseInt, 10)
309
+ .option('--threshold <n>', '颜色容差(0-255,默认 10)', cliParseInt, 10)
306
310
  .action(async (opts) => {
307
311
  try {
308
312
  assertInputWithinLimit(opts.input);
@@ -341,8 +345,8 @@ export function registerImageCommands(program) {
341
345
  .requiredOption('--base <path>', '底图路径')
342
346
  .requiredOption('--overlay <path>', '叠加图路径')
343
347
  .requiredOption('--output <path>', '输出图片路径')
344
- .option('--x <n>', '叠加图 X 偏移(像素,默认居中)', parseInt)
345
- .option('--y <n>', '叠加图 Y 偏移(像素,默认居中)', parseInt)
348
+ .option('--x <n>', '叠加图 X 偏移(像素,默认居中)', cliParseInt)
349
+ .option('--y <n>', '叠加图 Y 偏移(像素,默认居中)', cliParseInt)
346
350
  .option('--gravity <g>', '对齐方向(center|north|south|east|west...,--x/--y 优先)', 'center')
347
351
  .action(async (opts) => {
348
352
  try {
@@ -371,7 +375,7 @@ export function registerImageCommands(program) {
371
375
  .description('像素化图片(像素风格效果)')
372
376
  .requiredOption('--input <path>', '输入图片路径')
373
377
  .requiredOption('--output <path>', '输出图片路径')
374
- .option('--pixel-size <n>', '像素块大小(默认 8)', parseInt, 8)
378
+ .option('--pixel-size <n>', '像素块大小(默认 8)', cliParseInt, 8)
375
379
  .action(async (opts) => {
376
380
  try {
377
381
  assertInputWithinLimit(opts.input);
@@ -397,10 +401,10 @@ export function registerImageCommands(program) {
397
401
  .description('将多张图合并为精灵图,并输出帧坐标 JSON(完整本地实现)')
398
402
  .requiredOption('--inputs <paths>', '输入图片路径列表,逗号分隔')
399
403
  .requiredOption('--output <basePath>', '输出基路径(自动生成 .png 和 .json)')
400
- .option('--columns <n>', '列数(默认自动按平方根计算)', parseInt)
401
- .option('--padding <n>', '帧间距(像素,默认 0)', parseInt, 0)
402
- .option('--cell-width <n>', '统一格子宽度(不填则用第一张图宽度)', parseInt)
403
- .option('--cell-height <n>', '统一格子高度(不填则用第一张图高度)', parseInt)
404
+ .option('--columns <n>', '列数(默认自动按平方根计算)', cliParseInt)
405
+ .option('--padding <n>', '帧间距(像素,默认 0)', cliParseInt, 0)
406
+ .option('--cell-width <n>', '统一格子宽度(不填则用第一张图宽度)', cliParseInt)
407
+ .option('--cell-height <n>', '统一格子高度(不填则用第一张图高度)', cliParseInt)
404
408
  .action(async (opts) => {
405
409
  try {
406
410
  const paths = opts.inputs.split(',').map((p) => p.trim()).filter(Boolean);
@@ -0,0 +1,502 @@
1
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
2
+ import { join, resolve } from 'path';
3
+ import JSON5 from 'json5';
4
+ import { THEME_SCHEMA_PATH, THEME_INDEX_PATH, THEME_DIR, DEFAULT_GAME_PATH, MANIFEST_PATH, ASSETS_JSON_PATH, LITE_CREATOR_EXTENSIONS_DIR, themeDataPath, scenePath, defaultValueFromJsonSchemaProperty, buildThemeIndexTs, parseThemeIndexImportPath, parseThemePrefabs, mergeThemeDataKey, deleteThemeDataKey, parseExtensionMetaData, parseGameConfig, buildExtensionPrefabs, updateGameConfigValue, updateGameConfigExtensions, findConfigKeyCaseInsensitive, parseConfigKey, listScenesFromManifest, resolveGameConfigPath, validateThemeValue, validateConfigValue, describeJsonSchemaFields, describeConfigSchemaFields, } from '@playcraft/common/prefab';
5
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
6
+ function resolveProjectDir(opts) {
7
+ return opts.projectDir ? resolve(opts.projectDir) : process.cwd();
8
+ }
9
+ function readLocalFile(projectDir, relPath) {
10
+ return readFileSync(join(projectDir, relPath), 'utf-8');
11
+ }
12
+ function fileExists(projectDir, relPath) {
13
+ return existsSync(join(projectDir, relPath));
14
+ }
15
+ function detectProjectType(projectDir) {
16
+ if (fileExists(projectDir, THEME_SCHEMA_PATH))
17
+ return 'external-theme';
18
+ if (fileExists(projectDir, DEFAULT_GAME_PATH))
19
+ return 'lite-creator';
20
+ console.error('Error: 无法识别项目类型。当前仅支持 External(需 src/theme/theme.schema.json5)' +
21
+ '和 PlayCanvas(需 assets/DefaultGame.json)两种项目类型。');
22
+ process.exit(1);
23
+ throw new Error('Project type could not be detected');
24
+ }
25
+ function outputJson(data) {
26
+ console.log(JSON.stringify(data, null, 2));
27
+ }
28
+ // ─── External Theme helpers ──────────────────────────────────────────────────
29
+ function loadThemeSchema(projectDir) {
30
+ const raw = readLocalFile(projectDir, THEME_SCHEMA_PATH);
31
+ return JSON5.parse(raw.trim());
32
+ }
33
+ function loadThemeData(projectDir, themeId) {
34
+ const path = themeDataPath(themeId);
35
+ if (!fileExists(projectDir, path))
36
+ return {};
37
+ const raw = readLocalFile(projectDir, path);
38
+ const trimmed = raw.trim();
39
+ if (!trimmed)
40
+ return {};
41
+ return JSON5.parse(trimmed);
42
+ }
43
+ function writeThemeData(projectDir, themeId, data) {
44
+ const relPath = themeDataPath(themeId);
45
+ const content = `${JSON5.stringify(data, null, 2)}\n`;
46
+ writeFileSync(join(projectDir, relPath), content, 'utf-8');
47
+ }
48
+ function listLocalThemeIds(projectDir) {
49
+ const themeDir = join(projectDir, THEME_DIR);
50
+ if (!existsSync(themeDir))
51
+ return [];
52
+ return readdirSync(themeDir)
53
+ .filter((name) => {
54
+ if (name.startsWith('.') || name === 'node_modules')
55
+ return false;
56
+ return statSync(join(themeDir, name)).isDirectory();
57
+ })
58
+ .sort();
59
+ }
60
+ function resolveActiveThemeId(projectDir) {
61
+ if (!fileExists(projectDir, THEME_INDEX_PATH))
62
+ return null;
63
+ const content = readLocalFile(projectDir, THEME_INDEX_PATH);
64
+ return parseThemeIndexImportPath(content);
65
+ }
66
+ function resolveThemeId(projectDir, opts) {
67
+ if (opts.variant)
68
+ return opts.variant;
69
+ const active = resolveActiveThemeId(projectDir);
70
+ if (active)
71
+ return active;
72
+ const themes = listLocalThemeIds(projectDir);
73
+ if (themes.length > 0)
74
+ return themes[0];
75
+ console.error('Error: 没有找到可用的主题。请使用 --variant 指定主题 ID。');
76
+ process.exit(1);
77
+ }
78
+ // ─── PlayCanvas / LiteCreator helpers ────────────────────────────────────────
79
+ function loadGameConfig(projectDir, configPath) {
80
+ const raw = readLocalFile(projectDir, configPath);
81
+ return JSON.parse(raw);
82
+ }
83
+ function writeGameConfig(projectDir, configPath, data) {
84
+ writeFileSync(join(projectDir, configPath), JSON.stringify(data, null, 2), 'utf-8');
85
+ }
86
+ function scanExtensionMetaData(projectDir) {
87
+ const map = new Map();
88
+ const extDir = join(projectDir, LITE_CREATOR_EXTENSIONS_DIR);
89
+ if (!existsSync(extDir))
90
+ return map;
91
+ for (const folder of readdirSync(extDir)) {
92
+ const metaPath = join(extDir, folder, 'MetaData.json');
93
+ if (!existsSync(metaPath))
94
+ continue;
95
+ try {
96
+ const raw = readFileSync(metaPath, 'utf-8');
97
+ const meta = parseExtensionMetaData(JSON.parse(raw));
98
+ if (meta)
99
+ map.set(meta.name, meta);
100
+ }
101
+ catch { /* skip malformed */ }
102
+ }
103
+ return map;
104
+ }
105
+ function resolveGameConfigPathForVariant(projectDir, variantId) {
106
+ if (!variantId)
107
+ return DEFAULT_GAME_PATH;
108
+ if (!fileExists(projectDir, scenePath(variantId)))
109
+ return DEFAULT_GAME_PATH;
110
+ const sceneJson = JSON.parse(readLocalFile(projectDir, scenePath(variantId)));
111
+ if (!fileExists(projectDir, ASSETS_JSON_PATH))
112
+ return DEFAULT_GAME_PATH;
113
+ const assetsJson = JSON.parse(readLocalFile(projectDir, ASSETS_JSON_PATH));
114
+ const resolved = resolveGameConfigPath(sceneJson, assetsJson);
115
+ return resolved ?? DEFAULT_GAME_PATH;
116
+ }
117
+ function listLocalScenes(projectDir) {
118
+ if (!fileExists(projectDir, MANIFEST_PATH))
119
+ return [];
120
+ const manifestJson = JSON.parse(readLocalFile(projectDir, MANIFEST_PATH));
121
+ return listScenesFromManifest(manifestJson);
122
+ }
123
+ // ─── Unified prefab loading ──────────────────────────────────────────────────
124
+ function loadPrefabs(projectDir, systemType, opts) {
125
+ if (systemType === 'external-theme') {
126
+ const themeId = resolveThemeId(projectDir, opts);
127
+ const schema = loadThemeSchema(projectDir);
128
+ const data = loadThemeData(projectDir, themeId);
129
+ return parseThemePrefabs(schema, data, themeId);
130
+ }
131
+ const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
132
+ const gameConfigJson = loadGameConfig(projectDir, configPath);
133
+ const { extensions, config } = parseGameConfig(gameConfigJson);
134
+ const metaMap = scanExtensionMetaData(projectDir);
135
+ return buildExtensionPrefabs(metaMap, { extensions, config }, configPath);
136
+ }
137
+ // ─── dotpath helper ──────────────────────────────────────────────────────────
138
+ function getByDotPath(obj, path) {
139
+ const parts = path.split('.');
140
+ let cur = obj;
141
+ for (const p of parts) {
142
+ if (!cur || typeof cur !== 'object')
143
+ return undefined;
144
+ cur = cur[p];
145
+ }
146
+ return cur;
147
+ }
148
+ function setByDotPath(obj, path, value) {
149
+ const parts = path.split('.');
150
+ if (parts.length === 1) {
151
+ return { ...obj, [path]: value };
152
+ }
153
+ const [head, ...rest] = parts;
154
+ const sub = (obj[head] && typeof obj[head] === 'object' && !Array.isArray(obj[head]))
155
+ ? { ...obj[head] }
156
+ : {};
157
+ return { ...obj, [head]: setByDotPath(sub, rest.join('.'), value) };
158
+ }
159
+ function coerceValue(rawValue, fieldType) {
160
+ const t = fieldType.toLowerCase();
161
+ if (t === 'boolean' || t === 'switch') {
162
+ return rawValue === 'true' || rawValue === '1';
163
+ }
164
+ if (t === 'integer' || t === 'int') {
165
+ const n = parseInt(rawValue, 10);
166
+ if (Number.isNaN(n))
167
+ throw new Error(`Cannot parse "${rawValue}" as integer`);
168
+ return n;
169
+ }
170
+ if (t === 'number' || t === 'float' || t === 'range') {
171
+ const n = parseFloat(rawValue);
172
+ if (Number.isNaN(n))
173
+ throw new Error(`Cannot parse "${rawValue}" as number`);
174
+ return n;
175
+ }
176
+ try {
177
+ return JSON.parse(rawValue);
178
+ }
179
+ catch {
180
+ return rawValue;
181
+ }
182
+ }
183
+ // ─── Command registration ────────────────────────────────────────────────────
184
+ export function registerPrefabCommands(program) {
185
+ const prefab = program
186
+ .command('prefab')
187
+ .description('Remix prefab 配置管理(自动检测 External Theme / PlayCanvas 项目类型)');
188
+ // ─── variants / themes / scenes (aliases) ────────────────────
189
+ const variantsHandler = (opts) => {
190
+ const projectDir = resolveProjectDir(opts);
191
+ const systemType = detectProjectType(projectDir);
192
+ if (systemType === 'external-theme') {
193
+ const themes = listLocalThemeIds(projectDir);
194
+ const activeId = resolveActiveThemeId(projectDir);
195
+ const variants = themes.map((id) => ({
196
+ id,
197
+ name: id,
198
+ isActive: id === activeId,
199
+ }));
200
+ outputJson({ projectType: 'external', variants });
201
+ }
202
+ else {
203
+ const scenes = listLocalScenes(projectDir);
204
+ outputJson({ projectType: 'playcanvas', variants: scenes });
205
+ }
206
+ };
207
+ prefab.command('variants')
208
+ .description('列出可用变体(External: 主题列表;PlayCanvas: 场景列表)')
209
+ .option('--project-dir <path>', '项目根目录(默认 cwd)')
210
+ .action(variantsHandler);
211
+ prefab.command('themes')
212
+ .description('variants 别名(对齐前端「主题列表」术语)')
213
+ .option('--project-dir <path>', '项目根目录(默认 cwd)')
214
+ .action(variantsHandler);
215
+ prefab.command('scenes')
216
+ .description('variants 别名(对齐前端「场景列表」术语)')
217
+ .option('--project-dir <path>', '项目根目录(默认 cwd)')
218
+ .action(variantsHandler);
219
+ // ─── list ────────────────────────────────────────────────────
220
+ prefab.command('list')
221
+ .description('列出所有 prefab 及状态')
222
+ .option('--variant <id>', '指定变体(External: themeId;PlayCanvas: sceneId)')
223
+ .option('--project-dir <path>', '项目根目录(默认 cwd)')
224
+ .action((opts) => {
225
+ const projectDir = resolveProjectDir(opts);
226
+ const systemType = detectProjectType(projectDir);
227
+ const prefabs = loadPrefabs(projectDir, systemType, opts);
228
+ outputJson({
229
+ projectType: systemType === 'external-theme' ? 'external' : 'playcanvas',
230
+ variant: opts.variant ?? null,
231
+ total: prefabs.length,
232
+ enabled: prefabs.filter((p) => p.isUsed).length,
233
+ prefabs: prefabs.map((p) => ({
234
+ key: p.key,
235
+ name: p.name,
236
+ description: p.description,
237
+ isUsed: p.isUsed,
238
+ type: p.systemType,
239
+ })),
240
+ });
241
+ });
242
+ // ─── describe ────────────────────────────────────────────────
243
+ prefab.command('describe <key>')
244
+ .description('显示 prefab 完整字段信息')
245
+ .option('--variant <id>', '指定变体')
246
+ .option('--project-dir <path>', '项目根目录(默认 cwd)')
247
+ .action((key, opts) => {
248
+ const projectDir = resolveProjectDir(opts);
249
+ const systemType = detectProjectType(projectDir);
250
+ const prefabs = loadPrefabs(projectDir, systemType, opts);
251
+ const found = prefabs.find((p) => p.key === key);
252
+ if (!found) {
253
+ console.error(`Error: prefab "${key}" 不存在。可用的 key: ${prefabs.map((p) => p.key).join(', ')}`);
254
+ process.exit(1);
255
+ }
256
+ if (found.systemType === 'external-theme' && found.jsonSchema) {
257
+ const fields = describeJsonSchemaFields(found.jsonSchema, found.currentValue);
258
+ outputJson({
259
+ key: found.key,
260
+ type: found.systemType,
261
+ isUsed: found.isUsed,
262
+ description: found.description,
263
+ fields,
264
+ });
265
+ }
266
+ else if (found.configSchema) {
267
+ const currentValues = (found.currentValue && typeof found.currentValue === 'object')
268
+ ? found.currentValue
269
+ : {};
270
+ const fields = describeConfigSchemaFields(found.configSchema, currentValues);
271
+ outputJson({
272
+ key: found.key,
273
+ type: found.systemType,
274
+ isUsed: found.isUsed,
275
+ description: found.description,
276
+ configPath: found.configPath,
277
+ fields,
278
+ });
279
+ }
280
+ else {
281
+ outputJson({
282
+ key: found.key,
283
+ type: found.systemType,
284
+ isUsed: found.isUsed,
285
+ description: found.description,
286
+ currentValue: found.currentValue,
287
+ });
288
+ }
289
+ });
290
+ // ─── get ─────────────────────────────────────────────────────
291
+ prefab.command('get <key> [field]')
292
+ .description('获取 prefab 或子字段的当前值')
293
+ .option('--variant <id>', '指定变体')
294
+ .option('--project-dir <path>', '项目根目录(默认 cwd)')
295
+ .action((key, field, opts) => {
296
+ const projectDir = resolveProjectDir(opts);
297
+ const systemType = detectProjectType(projectDir);
298
+ const prefabs = loadPrefabs(projectDir, systemType, opts);
299
+ const found = prefabs.find((p) => p.key === key);
300
+ if (!found) {
301
+ console.error(`Error: prefab "${key}" 不存在。`);
302
+ process.exit(1);
303
+ }
304
+ if (!found.isUsed) {
305
+ console.error(`Error: prefab "${key}" 当前未启用,没有值。使用 "prefab enable ${key}" 启用。`);
306
+ process.exit(1);
307
+ }
308
+ let value = found.currentValue;
309
+ if (field) {
310
+ value = getByDotPath(value, field);
311
+ }
312
+ outputJson({ key, field: field ?? null, value });
313
+ });
314
+ // ─── set ─────────────────────────────────────────────────────
315
+ prefab.command('set <key> <field> <value>')
316
+ .description('修改 prefab 子字段值(带校验)')
317
+ .option('--variant <id>', '指定变体')
318
+ .option('--project-dir <path>', '项目根目录(默认 cwd)')
319
+ .action((key, field, rawValue, opts) => {
320
+ const projectDir = resolveProjectDir(opts);
321
+ const systemType = detectProjectType(projectDir);
322
+ if (systemType === 'external-theme') {
323
+ const themeId = resolveThemeId(projectDir, opts);
324
+ const schema = loadThemeSchema(projectDir);
325
+ const data = loadThemeData(projectDir, themeId);
326
+ const prefabs = parseThemePrefabs(schema, data, themeId);
327
+ const found = prefabs.find((p) => p.key === key);
328
+ if (!found) {
329
+ console.error(`Error: prefab "${key}" 不存在。`);
330
+ process.exit(1);
331
+ }
332
+ const fieldSchema = resolveJsonSchemaField(found.jsonSchema, field);
333
+ const fieldType = fieldSchema?.type ?? 'string';
334
+ const coerced = coerceValue(rawValue, fieldType);
335
+ if (fieldSchema) {
336
+ const result = validateThemeValue(fieldSchema, coerced);
337
+ if (!result.valid) {
338
+ console.error(`Validation error: ${result.errors.join('; ')}`);
339
+ process.exit(1);
340
+ }
341
+ }
342
+ const currentValue = data[key];
343
+ let newValue;
344
+ if (found.jsonSchema?.type === 'object') {
345
+ const obj = (currentValue && typeof currentValue === 'object' && !Array.isArray(currentValue))
346
+ ? { ...currentValue }
347
+ : {};
348
+ newValue = setByDotPath(obj, field, coerced);
349
+ }
350
+ else {
351
+ newValue = coerced;
352
+ }
353
+ const newData = mergeThemeDataKey(data, key, newValue);
354
+ writeThemeData(projectDir, themeId, newData);
355
+ outputJson({ success: true, key, field, value: coerced });
356
+ }
357
+ else {
358
+ const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
359
+ const gameConfigJson = loadGameConfig(projectDir, configPath);
360
+ const { config } = parseGameConfig(gameConfigJson);
361
+ const metaMap = scanExtensionMetaData(projectDir);
362
+ const meta = metaMap.get(key);
363
+ if (!meta) {
364
+ console.error(`Error: extension "${key}" 不存在。`);
365
+ process.exit(1);
366
+ }
367
+ const configKey = meta.configPath ? parseConfigKey(meta.configPath) : meta.name;
368
+ const existingKey = findConfigKeyCaseInsensitive(config, configKey);
369
+ const currentValues = (existingKey ? config[existingKey] : {}) ?? {};
370
+ const fieldDef = meta.configSchema?.[field];
371
+ const fieldType = fieldDef?.type ?? 'string';
372
+ const coerced = coerceValue(rawValue, fieldType);
373
+ if (fieldDef) {
374
+ const result = validateConfigValue(fieldDef, coerced);
375
+ if (!result.valid) {
376
+ console.error(`Validation error: ${result.errors.join('; ')}`);
377
+ process.exit(1);
378
+ }
379
+ }
380
+ const newValues = { ...currentValues, [field]: coerced };
381
+ const updated = updateGameConfigValue(gameConfigJson, configKey, newValues);
382
+ writeGameConfig(projectDir, configPath, updated);
383
+ outputJson({ success: true, key, field, value: coerced });
384
+ }
385
+ });
386
+ // ─── enable ──────────────────────────────────────────────────
387
+ prefab.command('enable <key>')
388
+ .description('启用 prefab')
389
+ .option('--variant <id>', '指定变体')
390
+ .option('--project-dir <path>', '项目根目录(默认 cwd)')
391
+ .action((key, opts) => {
392
+ const projectDir = resolveProjectDir(opts);
393
+ const systemType = detectProjectType(projectDir);
394
+ if (systemType === 'external-theme') {
395
+ const themeId = resolveThemeId(projectDir, opts);
396
+ const schema = loadThemeSchema(projectDir);
397
+ const data = loadThemeData(projectDir, themeId);
398
+ const props = (schema.properties ?? {});
399
+ if (!props[key]) {
400
+ console.error(`Error: prefab "${key}" 不存在于 schema 中。`);
401
+ process.exit(1);
402
+ }
403
+ const defaultValue = defaultValueFromJsonSchemaProperty(props[key]);
404
+ const newData = mergeThemeDataKey(data, key, defaultValue);
405
+ writeThemeData(projectDir, themeId, newData);
406
+ outputJson({ success: true, key, enabled: true, defaultValue });
407
+ }
408
+ else {
409
+ const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
410
+ const gameConfigJson = loadGameConfig(projectDir, configPath);
411
+ const { extensions } = parseGameConfig(gameConfigJson);
412
+ if (extensions.includes(key)) {
413
+ outputJson({ success: true, key, enabled: true, message: 'Already enabled' });
414
+ return;
415
+ }
416
+ const updated = updateGameConfigExtensions(gameConfigJson, [...extensions, key]);
417
+ writeGameConfig(projectDir, configPath, updated);
418
+ outputJson({ success: true, key, enabled: true });
419
+ }
420
+ });
421
+ // ─── disable ─────────────────────────────────────────────────
422
+ prefab.command('disable <key>')
423
+ .description('禁用 prefab')
424
+ .option('--variant <id>', '指定变体')
425
+ .option('--project-dir <path>', '项目根目录(默认 cwd)')
426
+ .action((key, opts) => {
427
+ const projectDir = resolveProjectDir(opts);
428
+ const systemType = detectProjectType(projectDir);
429
+ if (systemType === 'external-theme') {
430
+ const themeId = resolveThemeId(projectDir, opts);
431
+ const data = loadThemeData(projectDir, themeId);
432
+ if (!Object.prototype.hasOwnProperty.call(data, key)) {
433
+ outputJson({ success: true, key, enabled: false, message: 'Already disabled' });
434
+ return;
435
+ }
436
+ const newData = deleteThemeDataKey(data, key);
437
+ writeThemeData(projectDir, themeId, newData);
438
+ outputJson({ success: true, key, enabled: false });
439
+ }
440
+ else {
441
+ const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
442
+ const gameConfigJson = loadGameConfig(projectDir, configPath);
443
+ const { extensions } = parseGameConfig(gameConfigJson);
444
+ const filtered = extensions.filter((n) => n !== key);
445
+ if (filtered.length === extensions.length) {
446
+ outputJson({ success: true, key, enabled: false, message: 'Already disabled' });
447
+ return;
448
+ }
449
+ const updated = updateGameConfigExtensions(gameConfigJson, filtered);
450
+ writeGameConfig(projectDir, configPath, updated);
451
+ outputJson({ success: true, key, enabled: false });
452
+ }
453
+ });
454
+ // ─── switch ──────────────────────────────────────────────────
455
+ prefab.command('switch <variant>')
456
+ .description('切换活跃变体(External: 修改 index.ts;PlayCanvas: 指定场景上下文)')
457
+ .option('--project-dir <path>', '项目根目录(默认 cwd)')
458
+ .action((variant, opts) => {
459
+ const projectDir = resolveProjectDir(opts);
460
+ const systemType = detectProjectType(projectDir);
461
+ if (systemType === 'external-theme') {
462
+ const themes = listLocalThemeIds(projectDir);
463
+ if (!themes.includes(variant)) {
464
+ console.error(`Error: 主题 "${variant}" 不存在。可用: ${themes.join(', ')}`);
465
+ process.exit(1);
466
+ }
467
+ const content = buildThemeIndexTs(variant);
468
+ writeFileSync(join(projectDir, THEME_INDEX_PATH), content, 'utf-8');
469
+ outputJson({ success: true, activeVariant: variant, message: `Switched to theme "${variant}"` });
470
+ }
471
+ else {
472
+ const scenes = listLocalScenes(projectDir);
473
+ const found = scenes.find((s) => s.id === variant || s.name === variant);
474
+ if (!found) {
475
+ console.error(`Error: 场景 "${variant}" 不存在。可用: ${scenes.map((s) => `${s.name}(${s.id})`).join(', ')}`);
476
+ process.exit(1);
477
+ }
478
+ outputJson({
479
+ success: true,
480
+ activeVariant: found.id,
481
+ message: `PlayCanvas 场景上下文已设为 "${found.name}"。后续命令使用 --variant ${found.id} 来操作此场景的配置。`,
482
+ });
483
+ }
484
+ });
485
+ }
486
+ // ─── JSON Schema field resolver ──────────────────────────────────────────────
487
+ function resolveJsonSchemaField(schema, dotPath) {
488
+ const parts = dotPath.split('.');
489
+ let cur = schema;
490
+ for (const p of parts) {
491
+ if (cur.type === 'object' && cur.properties?.[p]) {
492
+ cur = cur.properties[p];
493
+ }
494
+ else if (cur.properties?.[p]) {
495
+ cur = cur.properties[p];
496
+ }
497
+ else {
498
+ return null;
499
+ }
500
+ }
501
+ return cur;
502
+ }
@@ -1,5 +1,5 @@
1
1
  import { writeFileSync, mkdirSync, readFileSync } from 'fs';
2
- import { dirname, join } from 'path';
2
+ import { dirname, join, parse } from 'path';
3
3
  import { tmpdir } from 'os';
4
4
  import { AgentApiClient } from '../utils/agent-api-client.js';
5
5
  const TMP_DIR = join(tmpdir(), 'playcraft');
@@ -20,6 +20,77 @@ function handleError(err) {
20
20
  console.error(`Error: ${msg}`);
21
21
  process.exit(1);
22
22
  }
23
+ /** Fallback extension from API mime when magic-byte sniff fails. */
24
+ function extensionForImageMime(mimeType) {
25
+ const base = mimeType.toLowerCase().split(';')[0]?.trim() ?? '';
26
+ switch (base) {
27
+ case 'image/png':
28
+ return '.png';
29
+ case 'image/jpeg':
30
+ case 'image/jpg':
31
+ return '.jpg';
32
+ case 'image/webp':
33
+ return '.webp';
34
+ case 'image/gif':
35
+ return '.gif';
36
+ case 'image/bmp':
37
+ return '.bmp';
38
+ default:
39
+ return '.png';
40
+ }
41
+ }
42
+ /**
43
+ * Detect container from decoded bytes (magic). Prefer this over MIME alone.
44
+ */
45
+ function sniffImageExtension(buf) {
46
+ // PNG (8-byte signature)
47
+ if (buf.length >= 8 &&
48
+ buf[0] === 0x89 &&
49
+ buf[1] === 0x50 &&
50
+ buf[2] === 0x4e &&
51
+ buf[3] === 0x47) {
52
+ return '.png';
53
+ }
54
+ // JPEG (SOI marker)
55
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
56
+ return '.jpg';
57
+ }
58
+ // GIF87a / GIF89a
59
+ if (buf.length >= 6 &&
60
+ buf[0] === 0x47 &&
61
+ buf[1] === 0x49 &&
62
+ buf[2] === 0x46 &&
63
+ buf[3] === 0x38 &&
64
+ (buf[4] === 0x37 || buf[4] === 0x39) &&
65
+ buf[5] === 0x61) {
66
+ return '.gif';
67
+ }
68
+ // WebP: RIFF .... WEBP
69
+ if (buf.length >= 12 &&
70
+ buf[0] === 0x52 &&
71
+ buf[1] === 0x49 &&
72
+ buf[2] === 0x46 &&
73
+ buf[3] === 0x46 &&
74
+ buf[8] === 0x57 &&
75
+ buf[9] === 0x45 &&
76
+ buf[10] === 0x42 &&
77
+ buf[11] === 0x50) {
78
+ return '.webp';
79
+ }
80
+ // BMP
81
+ if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d) {
82
+ return '.bmp';
83
+ }
84
+ return null;
85
+ }
86
+ /** Replace output extension when it does not match the chosen format extension. */
87
+ function resolveImageOutputPath(outputPath, wantExt) {
88
+ const { dir, name, ext } = parse(outputPath);
89
+ if (ext.toLowerCase() === wantExt) {
90
+ return outputPath;
91
+ }
92
+ return join(dir, `${name}${wantExt}`);
93
+ }
23
94
  export function registerToolsCommands(program) {
24
95
  const tools = program
25
96
  .command('tools')
@@ -51,11 +122,25 @@ export function registerToolsCommands(program) {
51
122
  referenceImageBase64,
52
123
  referenceImageMimeType,
53
124
  });
54
- mkdirSync(dirname(opts.output), { recursive: true });
55
125
  const buf = Buffer.from(result.imageBase64, 'base64');
56
- writeFileSync(opts.output, buf);
126
+ const sniffed = sniffImageExtension(buf);
127
+ const fromMime = extensionForImageMime(result.mimeType);
128
+ const wantExt = sniffed ?? fromMime;
129
+ if (!sniffed) {
130
+ console.warn('Could not detect image format from file signature; using Content-Type from API for extension.');
131
+ }
132
+ else if (sniffed !== fromMime) {
133
+ console.warn(`Image bytes look like ${sniffed.slice(1).toUpperCase()} but API reported ${result.mimeType}; ` +
134
+ 'extension follows file signature.');
135
+ }
136
+ const outputPath = resolveImageOutputPath(opts.output, wantExt);
137
+ if (outputPath !== opts.output) {
138
+ console.log(`Output path adjusted to ${wantExt} payload: ${outputPath}`);
139
+ }
140
+ mkdirSync(dirname(outputPath), { recursive: true });
141
+ writeFileSync(outputPath, buf);
57
142
  const sizeKB = Math.round(buf.length / 1024);
58
- console.log(`Image saved to ${opts.output} (${sizeKB}KB, ${result.mimeType})`);
143
+ console.log(`Image saved to ${outputPath} (${sizeKB}KB, ${wantExt.slice(1)})`);
59
144
  }
60
145
  catch (e) {
61
146
  handleError(e);
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import { fixIdsCommand } from './commands/fix-ids.js';
18
18
  import { registerToolsCommands } from './commands/tools.js';
19
19
  import { registerImageCommands } from './commands/image.js';
20
20
  import { registerAudioCommands } from './commands/audio.js';
21
+ import { registerPrefabCommands } from './commands/prefab.js';
21
22
  import { CLI_ROOT_DESCRIPTION, getCliTopicsHelpText, registerRootProgramHelp, } from './cli-root-help.js';
22
23
  const __filename = fileURLToPath(import.meta.url);
23
24
  const __dirname = dirname(__filename);
@@ -162,6 +163,7 @@ program
162
163
  .option('--no-compress-models', '禁用模型压缩')
163
164
  .option('--model-compression <method>', '模型压缩方法 (draco|meshopt)', 'draco')
164
165
  .option('--use-playable-scripts', '使用 @playcraft/devkit 构建工具打包(可玩广告项目专用)', false)
166
+ .option('--no-tracking', '禁用埋点 tracking adapter 注入(默认启用)')
165
167
  .action(async (projectPath, options) => {
166
168
  await buildCommand(projectPath, options);
167
169
  });
@@ -211,6 +213,7 @@ program
211
213
  .option('--no-compress-models', '禁用模型压缩')
212
214
  .option('--model-compression <method>', '模型压缩方法 (draco|meshopt)', 'draco')
213
215
  .option('--esm-mode <mode>', 'ESM 模块处理模式 (auto|enabled|disabled)', 'auto')
216
+ .option('--no-tracking', '禁用埋点 tracking adapter 注入(默认启用)')
214
217
  .action(async (projectPath, options) => {
215
218
  await buildCommand(projectPath, {
216
219
  ...options,
@@ -248,6 +251,7 @@ program
248
251
  .option('--no-compress-models', '禁用模型压缩')
249
252
  .option('--model-compression <method>', '模型压缩方法 (draco|meshopt)', 'draco')
250
253
  .option('--esm-mode <mode>', 'ESM 模块处理模式 (auto|enabled|disabled)', 'auto')
254
+ .option('--no-tracking', '禁用埋点 tracking adapter 注入(默认启用)')
251
255
  .action(async (baseBuildDir, options) => {
252
256
  await buildCommand(baseBuildDir, {
253
257
  ...options,
@@ -289,6 +293,7 @@ program
289
293
  .option('--esm-mode <mode>', 'ESM 模块处理模式 (auto|enabled|disabled)', 'auto')
290
294
  .option('--use-playable-scripts', '使用 @playcraft/devkit 构建工具打包(可玩广告项目专用)', false)
291
295
  .option('--theme <theme>', '指定要构建的主题(devkit 项目专用)')
296
+ .option('--no-tracking', '禁用埋点 tracking adapter 注入(默认启用)')
292
297
  .action(async (projectPath, options) => {
293
298
  await buildAllCommand(projectPath, options);
294
299
  });
@@ -322,4 +327,6 @@ registerToolsCommands(program);
322
327
  registerImageCommands(program);
323
328
  // Local audio processing (playcraft audio <command>)
324
329
  registerAudioCommands(program);
330
+ // Remix prefab management (playcraft prefab <command>)
331
+ registerPrefabCommands(program);
325
332
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.0.23",
3
+ "version": "0.0.24",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,8 +22,8 @@
22
22
  "release": "node scripts/release.js"
23
23
  },
24
24
  "dependencies": {
25
- "@playcraft/build": "^0.0.23",
26
- "@playcraft/common": "^0.0.12",
25
+ "@playcraft/build": "^0.0.24",
26
+ "@playcraft/common": "^0.0.13",
27
27
  "chokidar": "^4.0.3",
28
28
  "commander": "^13.1.0",
29
29
  "cors": "^2.8.6",
@@ -34,6 +34,7 @@
34
34
  "express": "^5.2.1",
35
35
  "fluent-ffmpeg": "^2.1.3",
36
36
  "inquirer": "^9.3.8",
37
+ "json5": "^2.2.3",
37
38
  "latest-version": "^7.0.0",
38
39
  "music-metadata": "^11.12.3",
39
40
  "ora": "^8.2.0",