@playcraft/cli 0.0.27 → 0.0.28

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.
@@ -48,6 +48,21 @@ function handleError(err) {
48
48
  console.error(`Error: ${msg}`);
49
49
  process.exit(1);
50
50
  }
51
+ /** 多圈边缘羽化默认 alpha;若 edgeLayers 更大则自最后一档按衰减补齐。 */
52
+ const DEFAULT_EDGE_LAYER_ALPHAS = [200, 120, 60];
53
+ function buildEdgeLayerAlphas(edgeLayers) {
54
+ const base = DEFAULT_EDGE_LAYER_ALPHAS;
55
+ if (edgeLayers <= base.length) {
56
+ return Array.from(base.slice(0, edgeLayers));
57
+ }
58
+ const out = Array.from(base);
59
+ let last = base[base.length - 1];
60
+ while (out.length < edgeLayers) {
61
+ last = Math.max(8, Math.round(last * 0.62));
62
+ out.push(last);
63
+ }
64
+ return out;
65
+ }
51
66
  /**
52
67
  * 从图片四条边向内 BFS flood-fill,自动检测边缘主色并移除连通的同色背景区域。
53
68
  * 适用于 AI 生成的素材(纯色、灰色、棋盘格背景),前景纹理零损失。
@@ -143,7 +158,7 @@ async function floodFillRemoveBg(inputPath, opts) {
143
158
  }
144
159
  // ── 3. Edge smoothing (multi-layer alpha gradient) ──
145
160
  if (opts.edgeLayers > 0) {
146
- const layerAlphas = [200, 120, 60];
161
+ const layerAlphas = buildEdgeLayerAlphas(opts.edgeLayers);
147
162
  let borderSet = new Set();
148
163
  for (let y = 1; y < h - 1; y++) {
149
164
  for (let x = 1; x < w - 1; x++) {
@@ -155,12 +170,13 @@ async function floodFillRemoveBg(inputPath, opts) {
155
170
  borderSet.add(pos);
156
171
  }
157
172
  }
158
- for (let layer = 0; layer < opts.edgeLayers && layer < layerAlphas.length; layer++) {
173
+ for (let layer = 0; layer < layerAlphas.length; layer++) {
159
174
  const alpha = layerAlphas[layer];
160
175
  for (const pos of borderSet) {
161
176
  result[pos * 4 + 3] = Math.min(result[pos * 4 + 3], alpha);
162
177
  }
163
- if (layer < opts.edgeLayers - 1) {
178
+ if (layer < layerAlphas.length - 1) {
179
+ const nextAlpha = layerAlphas[layer + 1];
164
180
  const nextBorder = new Set();
165
181
  for (const pos of borderSet) {
166
182
  const x = pos % w;
@@ -172,7 +188,7 @@ async function floodFillRemoveBg(inputPath, opts) {
172
188
  continue;
173
189
  const npos = ny * w + nx;
174
190
  if (result[npos * 4 + 3] === 0 && !borderSet.has(npos)) {
175
- result[npos * 4 + 3] = Math.min(60, layerAlphas[layer + 1] ?? 40);
191
+ result[npos * 4 + 3] = Math.min(60, nextAlpha);
176
192
  nextBorder.add(npos);
177
193
  }
178
194
  }
@@ -2,7 +2,19 @@ import { inspect } from 'node:util';
2
2
  import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
3
3
  import { join, resolve } from 'path';
4
4
  import JSON5 from 'json5';
5
- 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
+ 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, getPrefabFieldDescriptors, getPrefabFieldDiffs, prefabHasSchemaDiff, } from '@playcraft/common/prefab';
6
+ const DEFAULT_PAGE_LIMIT = 50;
7
+ /** 无法识别 External / PlayCanvas 项目类型(供测试在 mock process.exit 后通过 instanceof 识别)。 */
8
+ export class ProjectDetectError extends Error {
9
+ name = 'ProjectDetectError';
10
+ constructor(message = 'Project type could not be detected') {
11
+ super(message);
12
+ Error.captureStackTrace?.(this, ProjectDetectError);
13
+ }
14
+ }
15
+ function isProjectDetectError(e) {
16
+ return e instanceof ProjectDetectError;
17
+ }
6
18
  // ─── Helpers ─────────────────────────────────────────────────────────────────
7
19
  function resolveProjectDir(opts) {
8
20
  return opts.projectDir ? resolve(opts.projectDir) : process.cwd();
@@ -21,11 +33,10 @@ function detectProjectType(projectDir) {
21
33
  console.error('Error: 无法识别项目类型。当前仅支持 External(需 src/theme/theme.schema.json5)' +
22
34
  '和 PlayCanvas(需 assets/DefaultGame.json)两种项目类型。');
23
35
  process.exit(1);
24
- throw new Error('Project type could not be detected');
36
+ throw new ProjectDetectError();
25
37
  }
26
38
  const HUMAN_INSPECT = { colors: false, depth: 10, maxArrayLength: 200 };
27
39
  const JSON_OPT_DESC = '以 JSON 输出(便于脚本解析;默认为人可读文本)';
28
- /** 为子命令追加 `--json`(默认人类可读) */
29
40
  function withJsonOption(cmd) {
30
41
  return cmd.option('--json', JSON_OPT_DESC);
31
42
  }
@@ -67,6 +78,116 @@ function describeFieldLine(f) {
67
78
  parts.push(`默认: ${formatHumanValue(f.default)}`);
68
79
  return parts.join(' | ');
69
80
  }
81
+ function isFieldDescriptor(v) {
82
+ if (v === null || typeof v !== 'object')
83
+ return false;
84
+ const o = v;
85
+ return typeof o.path === 'string' && typeof o.type === 'string';
86
+ }
87
+ /** 规范化 describe 的 fields,剔除不符合 FieldDescriptor 形态的项(避免 JSON/终端输出依赖不安全断言)。 */
88
+ function normalizeDescribeFields(raw) {
89
+ if (!Array.isArray(raw))
90
+ return [];
91
+ return raw.filter(isFieldDescriptor);
92
+ }
93
+ function isDescribeJsonWithFields(b) {
94
+ return 'fields' in b && Array.isArray(b.fields);
95
+ }
96
+ function slicePage(arr, limit, offset) {
97
+ const total = arr.length;
98
+ const slice = arr.slice(offset, offset + limit);
99
+ const hasMore = offset + slice.length < total;
100
+ return { slice, page: { limit, offset, total, hasMore } };
101
+ }
102
+ function tryCompileRegex(pattern, matchCase) {
103
+ try {
104
+ return new RegExp(pattern, matchCase ? '' : 'i');
105
+ }
106
+ catch (e) {
107
+ const msg = e instanceof Error ? e.message : String(e);
108
+ throw new Error(`Invalid regex pattern "${pattern}": ${msg}`);
109
+ }
110
+ }
111
+ function prefabMetaMatches(p, re) {
112
+ if (re.test(p.key))
113
+ return true;
114
+ if (re.test(p.name))
115
+ return true;
116
+ if (p.description && re.test(p.description))
117
+ return true;
118
+ return false;
119
+ }
120
+ function filterPrefabsList(all, o) {
121
+ let list = all;
122
+ if (o.usedOnly)
123
+ list = list.filter((p) => p.isUsed);
124
+ if (o.unusedOnly)
125
+ list = list.filter((p) => !p.isUsed);
126
+ if (o.changedOnly)
127
+ list = list.filter((p) => prefabHasSchemaDiff(p));
128
+ if (o.match) {
129
+ const re = tryCompileRegex(o.match, Boolean(o.matchCase));
130
+ list = list.filter((p) => prefabMetaMatches(p, re));
131
+ }
132
+ return list;
133
+ }
134
+ function buildSummary(systemType, variantResolved, all) {
135
+ const enabled = all.filter((p) => p.isUsed).length;
136
+ const changed = all.filter((p) => prefabHasSchemaDiff(p)).length;
137
+ return {
138
+ projectType: systemType === 'external-theme' ? 'external' : 'playcanvas',
139
+ variant: variantResolved,
140
+ total: all.length,
141
+ enabled,
142
+ disabled: all.length - enabled,
143
+ changed,
144
+ };
145
+ }
146
+ function buildDescribeGuide(prefabKey, isUsed) {
147
+ return {
148
+ workflow: [
149
+ 'playcraft prefab list --json --limit 50 --offset 0',
150
+ `playcraft prefab diff --json --limit ${DEFAULT_PAGE_LIMIT} --offset 0`,
151
+ `playcraft prefab get ${prefabKey} --json`,
152
+ ],
153
+ setExample: `playcraft prefab set ${prefabKey} <field> <value>`,
154
+ whenDisabled: isUsed ? undefined : `playcraft prefab enable ${prefabKey}`,
155
+ };
156
+ }
157
+ function valuePreview(p, maxLen = 120) {
158
+ if (!p.isUsed)
159
+ return null;
160
+ const s = formatHumanValue(p.currentValue);
161
+ if (s.length <= maxLen)
162
+ return s;
163
+ return `${s.slice(0, maxLen)}…`;
164
+ }
165
+ function withPagingOptions(cmd) {
166
+ return cmd
167
+ .option('--limit <n>', '分页条数', (v) => parseInt(v, 10), DEFAULT_PAGE_LIMIT)
168
+ .option('--offset <n>', '分页偏移', (v) => parseInt(v, 10), 0);
169
+ }
170
+ function withFilterOptions(cmd) {
171
+ return cmd
172
+ .option('--match <regex>', 'prefab 级正则过滤(key/name/description)')
173
+ .option('--match-case', '正则区分大小写(默认忽略大小写)')
174
+ .option('--used-only', '仅已启用')
175
+ .option('--unused-only', '仅未启用')
176
+ .option('--changed-only', '相对 schema 默认值有差异的 prefab');
177
+ }
178
+ function parsePaging(opts) {
179
+ const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? opts.limit : DEFAULT_PAGE_LIMIT;
180
+ const offset = Number.isFinite(opts.offset) && opts.offset >= 0 ? opts.offset : 0;
181
+ return { limit, offset };
182
+ }
183
+ function resolveVariantResolved(projectDir, systemType, opts) {
184
+ if (opts.variant)
185
+ return opts.variant;
186
+ if (systemType === 'external-theme') {
187
+ return resolveThemeId(projectDir, opts);
188
+ }
189
+ return null;
190
+ }
70
191
  // ─── External Theme helpers ──────────────────────────────────────────────────
71
192
  function loadThemeSchema(projectDir) {
72
193
  const raw = readLocalFile(projectDir, THEME_SCHEMA_PATH);
@@ -116,6 +237,7 @@ function resolveThemeId(projectDir, opts) {
116
237
  return themes[0];
117
238
  console.error('Error: 没有找到可用的主题。请使用 --variant 指定主题 ID。');
118
239
  process.exit(1);
240
+ throw new Error('unreachable');
119
241
  }
120
242
  // ─── PlayCanvas / LiteCreator helpers ────────────────────────────────────────
121
243
  function loadGameConfig(projectDir, configPath) {
@@ -162,7 +284,6 @@ function listLocalScenes(projectDir) {
162
284
  const manifestJson = JSON.parse(readLocalFile(projectDir, MANIFEST_PATH));
163
285
  return listScenesFromManifest(manifestJson);
164
286
  }
165
- // ─── Unified prefab loading ──────────────────────────────────────────────────
166
287
  function loadPrefabs(projectDir, systemType, opts) {
167
288
  if (systemType === 'external-theme') {
168
289
  const themeId = resolveThemeId(projectDir, opts);
@@ -222,12 +343,58 @@ function coerceValue(rawValue, fieldType) {
222
343
  return rawValue;
223
344
  }
224
345
  }
346
+ /** Coerce batch JSON value: strings go through coerceValue; other types used as-is. */
347
+ function coerceBatchLeaf(raw, fieldType) {
348
+ if (typeof raw === 'string')
349
+ return coerceValue(raw, fieldType);
350
+ return raw;
351
+ }
352
+ function readBatchJson(filePath) {
353
+ if (filePath)
354
+ return readFileSync(filePath, 'utf-8');
355
+ return readFileSync(0, 'utf-8');
356
+ }
357
+ function buildSingleDescribeJson(found) {
358
+ if (found.systemType === 'external-theme' && found.jsonSchema) {
359
+ const fields = normalizeDescribeFields(describeJsonSchemaFields(found.jsonSchema, found.currentValue));
360
+ return {
361
+ key: found.key,
362
+ type: found.systemType,
363
+ isUsed: found.isUsed,
364
+ description: found.description,
365
+ fields,
366
+ guide: buildDescribeGuide(found.key, found.isUsed),
367
+ };
368
+ }
369
+ if (found.configSchema) {
370
+ const currentValues = (found.currentValue && typeof found.currentValue === 'object')
371
+ ? found.currentValue
372
+ : {};
373
+ const fields = normalizeDescribeFields(describeConfigSchemaFields(found.configSchema, currentValues));
374
+ return {
375
+ key: found.key,
376
+ type: found.systemType,
377
+ isUsed: found.isUsed,
378
+ description: found.description,
379
+ configPath: found.configPath,
380
+ fields,
381
+ guide: buildDescribeGuide(found.key, found.isUsed),
382
+ };
383
+ }
384
+ return {
385
+ key: found.key,
386
+ type: found.systemType,
387
+ isUsed: found.isUsed,
388
+ description: found.description,
389
+ currentValue: found.currentValue,
390
+ guide: buildDescribeGuide(found.key, found.isUsed),
391
+ };
392
+ }
225
393
  // ─── Command registration ────────────────────────────────────────────────────
226
394
  export function registerPrefabCommands(program) {
227
395
  const prefab = program
228
396
  .command('prefab')
229
397
  .description('Remix prefab 配置管理(自动检测 External Theme / PlayCanvas 项目类型)');
230
- // ─── variants / themes / scenes (aliases) ────────────────────
231
398
  const variantsHandler = (opts) => {
232
399
  const projectDir = resolveProjectDir(opts);
233
400
  const systemType = detectProjectType(projectDir);
@@ -283,144 +450,294 @@ export function registerPrefabCommands(program) {
283
450
  .description('variants 别名(对齐前端「场景列表」术语)')
284
451
  .option('--project-dir <path>', '项目根目录(默认 cwd)')
285
452
  .action(variantsHandler);
286
- // ─── list ────────────────────────────────────────────────────
287
- withJsonOption(prefab.command('list'))
288
- .description('列出所有 prefab 及状态')
453
+ withPagingOptions(withFilterOptions(withJsonOption(prefab.command('list'))))
454
+ .description('列出 prefab 及状态(支持分页与正则过滤)')
289
455
  .option('--variant <id>', '指定变体(External: themeId;PlayCanvas: sceneId)')
290
456
  .option('--project-dir <path>', '项目根目录(默认 cwd)')
457
+ .option('--with-values', '附带当前值摘要(已启用项)')
291
458
  .action((opts) => {
292
- const projectDir = resolveProjectDir(opts);
293
- const systemType = detectProjectType(projectDir);
294
- const prefabs = loadPrefabs(projectDir, systemType, opts);
295
- const asJson = Boolean(opts.json);
296
- let variantNote = opts.variant ?? null;
297
- if (systemType === 'external-theme' && !opts.variant) {
298
- variantNote = resolveThemeId(projectDir, opts);
299
- }
300
- const payload = {
301
- projectType: systemType === 'external-theme' ? 'external' : 'playcanvas',
302
- variant: opts.variant ?? null,
303
- total: prefabs.length,
304
- enabled: prefabs.filter((p) => p.isUsed).length,
305
- prefabs: prefabs.map((p) => ({
306
- key: p.key,
307
- name: p.name,
308
- description: p.description,
309
- isUsed: p.isUsed,
310
- type: p.systemType,
311
- })),
312
- };
313
- outputResult(asJson, payload, () => {
314
- console.log(`项目类型: ${projectTypeLabel(systemType)}`);
315
- console.log(`变体上下文: ${variantNote ?? '(默认)'}`);
316
- console.log(`共 ${prefabs.length} 个 prefab,已启用 ${prefabs.filter((p) => p.isUsed).length} 个。`);
317
- console.log('');
318
- const keyW = Math.min(28, Math.max(8, ...prefabs.map((p) => p.key.length)));
319
- for (let i = 0; i < prefabs.length; i++) {
320
- const p = prefabs[i];
321
- const status = p.isUsed ? '已启用' : '未启用';
322
- const label = p.key.padEnd(keyW);
323
- console.log(` ${label} ${status}`);
324
- const detailIndent = ' ';
325
- if (p.name && p.name !== p.key) {
326
- console.log(`${detailIndent}名称: ${p.name}`);
327
- }
328
- const desc = p.description?.trim();
329
- if (desc) {
330
- console.log(`${detailIndent}说明: ${desc}`);
459
+ try {
460
+ const projectDir = resolveProjectDir(opts);
461
+ const systemType = detectProjectType(projectDir);
462
+ const all = loadPrefabs(projectDir, systemType, opts);
463
+ const asJson = Boolean(opts.json);
464
+ const variantResolved = resolveVariantResolved(projectDir, systemType, opts);
465
+ let variantNote = opts.variant ?? null;
466
+ if (systemType === 'external-theme' && !opts.variant) {
467
+ variantNote = resolveThemeId(projectDir, opts);
468
+ }
469
+ const summary = buildSummary(systemType, variantResolved, all);
470
+ const filtered = filterPrefabsList(all, {
471
+ match: opts.match,
472
+ matchCase: Boolean(opts.matchCase),
473
+ usedOnly: Boolean(opts.usedOnly),
474
+ unusedOnly: Boolean(opts.unusedOnly),
475
+ changedOnly: Boolean(opts.changedOnly),
476
+ });
477
+ const { limit, offset } = parsePaging(opts);
478
+ const { slice, page } = slicePage(filtered, limit, offset);
479
+ const prefabRows = slice.map((p) => {
480
+ const row = {
481
+ key: p.key,
482
+ name: p.name,
483
+ description: p.description,
484
+ isUsed: p.isUsed,
485
+ type: p.systemType,
486
+ };
487
+ if (opts.withValues) {
488
+ row.valuePreview = valuePreview(p);
331
489
  }
332
- if (i < prefabs.length - 1) {
333
- console.log('');
490
+ return row;
491
+ });
492
+ const payload = {
493
+ summary,
494
+ page,
495
+ prefabs: prefabRows,
496
+ };
497
+ outputResult(asJson, payload, () => {
498
+ console.log(`项目类型: ${projectTypeLabel(systemType)}`);
499
+ console.log(`变体上下文: ${variantNote ?? '(默认)'}`);
500
+ console.log(`概况: 共 ${summary.total} 个 prefab,已启用 ${summary.enabled},未启用 ${summary.disabled},` +
501
+ `相对默认值有改动 ${summary.changed} 个。`);
502
+ console.log(`本页: ${filtered.length === 0 ? 0 : offset + 1}-${offset + slice.length} / 过滤后 ${page.total} 条` +
503
+ (page.hasMore ? `(尚有更多,使用 --offset ${offset + limit})` : ''));
504
+ console.log('');
505
+ const maxKeyLen = slice.reduce((m, p) => Math.max(m, p.key.length), 0);
506
+ const keyW = Math.min(28, Math.max(8, maxKeyLen));
507
+ for (let i = 0; i < slice.length; i++) {
508
+ const p = slice[i];
509
+ const status = p.isUsed ? '已启用' : '未启用';
510
+ const label = p.key.padEnd(keyW);
511
+ console.log(` ${label} ${status}`);
512
+ const detailIndent = ' ';
513
+ if (opts.withValues && p.isUsed) {
514
+ const pv = valuePreview(p, 200);
515
+ if (pv)
516
+ console.log(`${detailIndent}值摘要: ${pv}`);
517
+ }
518
+ if (p.name && p.name !== p.key) {
519
+ console.log(`${detailIndent}名称: ${p.name}`);
520
+ }
521
+ const desc = p.description?.trim();
522
+ if (desc) {
523
+ console.log(`${detailIndent}说明: ${desc}`);
524
+ }
525
+ if (i < slice.length - 1)
526
+ console.log('');
334
527
  }
335
- }
336
- });
528
+ });
529
+ }
530
+ catch (e) {
531
+ if (isProjectDetectError(e))
532
+ throw e;
533
+ const msg = e instanceof Error ? e.message : String(e);
534
+ console.error(`Error: ${msg}`);
535
+ process.exit(1);
536
+ }
337
537
  });
338
- // ─── describe ────────────────────────────────────────────────
339
- withJsonOption(prefab.command('describe <key>'))
340
- .description('显示 prefab 完整字段信息')
538
+ withPagingOptions(withFilterOptions(withJsonOption(prefab.command('diff'))))
539
+ .description('仅显示相对 schema 默认值有差异的字段')
341
540
  .option('--variant <id>', '指定变体')
342
541
  .option('--project-dir <path>', '项目根目录(默认 cwd)')
343
- .action((key, opts) => {
344
- const projectDir = resolveProjectDir(opts);
345
- const systemType = detectProjectType(projectDir);
346
- const prefabs = loadPrefabs(projectDir, systemType, opts);
347
- const found = prefabs.find((p) => p.key === key);
348
- if (!found) {
349
- console.error(`Error: prefab "${key}" 不存在。可用的 key: ${prefabs.map((p) => p.key).join(', ')}`);
350
- process.exit(1);
351
- }
352
- const asJson = Boolean(opts.json);
353
- if (found.systemType === 'external-theme' && found.jsonSchema) {
354
- const fields = describeJsonSchemaFields(found.jsonSchema, found.currentValue);
355
- const payload = {
356
- key: found.key,
357
- type: found.systemType,
358
- isUsed: found.isUsed,
359
- description: found.description,
360
- fields,
361
- };
542
+ .action((opts) => {
543
+ try {
544
+ const projectDir = resolveProjectDir(opts);
545
+ const systemType = detectProjectType(projectDir);
546
+ const all = loadPrefabs(projectDir, systemType, opts);
547
+ const asJson = Boolean(opts.json);
548
+ const variantResolved = resolveVariantResolved(projectDir, systemType, opts);
549
+ const summary = buildSummary(systemType, variantResolved, all);
550
+ const withDiff = all.filter((p) => getPrefabFieldDiffs(p).length > 0);
551
+ const filtered = filterPrefabsList(withDiff, {
552
+ match: opts.match,
553
+ matchCase: Boolean(opts.matchCase),
554
+ usedOnly: Boolean(opts.usedOnly),
555
+ unusedOnly: Boolean(opts.unusedOnly),
556
+ changedOnly: Boolean(opts.changedOnly),
557
+ });
558
+ const { limit, offset } = parsePaging(opts);
559
+ const { slice, page } = slicePage(filtered, limit, offset);
560
+ const items = slice.map((p) => ({
561
+ key: p.key,
562
+ isUsed: p.isUsed,
563
+ diffs: getPrefabFieldDiffs(p),
564
+ }));
565
+ const payload = { summary, page, prefabs: items };
362
566
  outputResult(asJson, payload, () => {
363
- console.log(`Prefab: ${found.key}`);
364
- console.log(`类型: ${projectTypeLabel(found.systemType)}`);
365
- console.log(`状态: ${found.isUsed ? '已启用' : '未启用'}`);
366
- if (found.description)
367
- console.log(`说明: ${found.description}`);
567
+ console.log(`概况: ${summary.total} 个 prefab,其中 ${withDiff.length} 个有字段级差异。`);
568
+ console.log(`本页: ${page.total === 0 ? 0 : offset + 1}-${offset + slice.length} / ${page.total}`);
368
569
  console.log('');
369
- console.log('字段:');
370
- for (const f of fields) {
371
- console.log(` • ${describeFieldLine(f)}`);
570
+ for (const it of items) {
571
+ console.log(` ${it.key}:`);
572
+ for (const d of it.diffs) {
573
+ console.log(` ${d.path}: 当前 ${formatHumanValue(d.current)} | 默认 ${formatHumanValue(d.default)}`);
574
+ }
575
+ console.log('');
372
576
  }
373
577
  });
374
578
  }
375
- else if (found.configSchema) {
376
- const currentValues = (found.currentValue && typeof found.currentValue === 'object')
377
- ? found.currentValue
378
- : {};
379
- const fields = describeConfigSchemaFields(found.configSchema, currentValues);
579
+ catch (e) {
580
+ if (isProjectDetectError(e))
581
+ throw e;
582
+ const msg = e instanceof Error ? e.message : String(e);
583
+ console.error(`Error: ${msg}`);
584
+ process.exit(1);
585
+ }
586
+ });
587
+ withPagingOptions(withFilterOptions(withJsonOption(prefab.command('describe [key]'))))
588
+ .description('显示 prefab 字段信息;省略 key 时需 --all')
589
+ .option('--all', '列出(过滤后)全部 prefab 的字段详情,分页输出')
590
+ .option('--variant <id>', '指定变体')
591
+ .option('--project-dir <path>', '项目根目录(默认 cwd)')
592
+ .action((key, opts) => {
593
+ try {
594
+ const projectDir = resolveProjectDir(opts);
595
+ const systemType = detectProjectType(projectDir);
596
+ const prefabs = loadPrefabs(projectDir, systemType, opts);
597
+ const asJson = Boolean(opts.json);
598
+ const variantResolved = resolveVariantResolved(projectDir, systemType, opts);
599
+ const summary = buildSummary(systemType, variantResolved, prefabs);
600
+ const allMode = Boolean(opts.all);
601
+ if (allMode && key) {
602
+ console.error('Error: 不能同时使用位置参数 key 与 --all。');
603
+ process.exit(1);
604
+ }
605
+ if (!allMode && !key) {
606
+ console.error('Error: 请指定 <key> 或传入 --all。');
607
+ process.exit(1);
608
+ }
609
+ if (!allMode && key) {
610
+ const found = prefabs.find((p) => p.key === key);
611
+ if (!found) {
612
+ console.error(`Error: prefab "${key}" 不存在。可用的 key: ${prefabs.map((p) => p.key).join(', ')}`);
613
+ process.exit(1);
614
+ }
615
+ const payload = buildSingleDescribeJson(found);
616
+ outputResult(asJson, payload, () => {
617
+ console.log(`Prefab: ${found.key}`);
618
+ console.log(`类型: ${projectTypeLabel(found.systemType)}`);
619
+ console.log(`状态: ${found.isUsed ? '已启用' : '未启用'}`);
620
+ if (found.description)
621
+ console.log(`说明: ${found.description}`);
622
+ console.log('');
623
+ if (isDescribeJsonWithFields(payload)) {
624
+ console.log('字段:');
625
+ for (const f of payload.fields) {
626
+ console.log(` • ${describeFieldLine(f)}`);
627
+ }
628
+ }
629
+ else {
630
+ console.log('当前值:');
631
+ console.log(formatHumanValue(payload.currentValue));
632
+ }
633
+ });
634
+ return;
635
+ }
636
+ const filtered = filterPrefabsList(prefabs, {
637
+ match: opts.match,
638
+ matchCase: Boolean(opts.matchCase),
639
+ usedOnly: Boolean(opts.usedOnly),
640
+ unusedOnly: Boolean(opts.unusedOnly),
641
+ changedOnly: Boolean(opts.changedOnly),
642
+ });
643
+ const { limit, offset } = parsePaging(opts);
644
+ const { slice, page } = slicePage(filtered, limit, offset);
645
+ const blocks = slice.map((p) => buildSingleDescribeJson(p));
380
646
  const payload = {
381
- key: found.key,
382
- type: found.systemType,
383
- isUsed: found.isUsed,
384
- description: found.description,
385
- configPath: found.configPath,
386
- fields,
647
+ summary,
648
+ page,
649
+ guide: {
650
+ workflow: [
651
+ 'playcraft prefab list --json --limit 20 --offset 0',
652
+ 'playcraft prefab describe --all --json --match "<regex>" --limit 20 --offset 0',
653
+ ],
654
+ },
655
+ prefabs: blocks,
387
656
  };
388
657
  outputResult(asJson, payload, () => {
389
- console.log(`Prefab: ${found.key}`);
390
- console.log(`类型: ${projectTypeLabel(found.systemType)}`);
391
- console.log(`状态: ${found.isUsed ? '已启用' : '未启用'}`);
392
- if (found.configPath)
393
- console.log(`配置: ${found.configPath}`);
394
- if (found.description)
395
- console.log(`说明: ${found.description}`);
396
- console.log('');
397
- console.log('字段:');
398
- for (const f of fields) {
399
- console.log(` • ${describeFieldLine(f)}`);
658
+ console.log(`概况: ${summary.total} 个 prefab;本页描述 ${blocks.length} 个(过滤后共 ${page.total})。`);
659
+ for (const b of blocks) {
660
+ console.log('');
661
+ console.log(`=== ${b.key} ===`);
662
+ if (isDescribeJsonWithFields(b)) {
663
+ for (const f of b.fields) {
664
+ console.log(` • ${describeFieldLine(f)}`);
665
+ }
666
+ }
667
+ else {
668
+ console.log(formatHumanValue(b.currentValue));
669
+ }
670
+ }
671
+ if (page.hasMore) {
672
+ console.log('');
673
+ console.log(`下一页: --offset ${offset + limit}`);
400
674
  }
401
675
  });
402
676
  }
403
- else {
404
- const payload = {
405
- key: found.key,
406
- type: found.systemType,
407
- isUsed: found.isUsed,
408
- description: found.description,
409
- currentValue: found.currentValue,
410
- };
677
+ catch (e) {
678
+ if (isProjectDetectError(e))
679
+ throw e;
680
+ const msg = e instanceof Error ? e.message : String(e);
681
+ console.error(`Error: ${msg}`);
682
+ process.exit(1);
683
+ }
684
+ });
685
+ withPagingOptions(withJsonOption(prefab.command('search <pattern>')))
686
+ .description('按正则搜索字段 path /说明 / 枚举(所有 prefab)')
687
+ .option('--match-case', '区分大小写')
688
+ .option('--variant <id>', '指定变体')
689
+ .option('--project-dir <path>', '项目根目录(默认 cwd)')
690
+ .action((pattern, opts) => {
691
+ try {
692
+ const projectDir = resolveProjectDir(opts);
693
+ const systemType = detectProjectType(projectDir);
694
+ const prefabs = loadPrefabs(projectDir, systemType, opts);
695
+ const asJson = Boolean(opts.json);
696
+ const variantResolved = resolveVariantResolved(projectDir, systemType, opts);
697
+ const summary = buildSummary(systemType, variantResolved, prefabs);
698
+ const re = tryCompileRegex(pattern, Boolean(opts.matchCase));
699
+ const hits = [];
700
+ for (const p of prefabs) {
701
+ for (const f of getPrefabFieldDescriptors(p)) {
702
+ const hay = [
703
+ f.path,
704
+ f.description ?? '',
705
+ ...(f.enumOptions ?? []),
706
+ ].join('\n');
707
+ if (re.test(hay)) {
708
+ hits.push({
709
+ prefabKey: p.key,
710
+ fieldPath: f.path,
711
+ type: f.type,
712
+ description: f.description,
713
+ currentValue: f.currentValue,
714
+ default: f.default,
715
+ });
716
+ }
717
+ }
718
+ }
719
+ const { limit, offset } = parsePaging(opts);
720
+ const { slice, page } = slicePage(hits, limit, offset);
721
+ const payload = { summary, page, hits: slice };
411
722
  outputResult(asJson, payload, () => {
412
- console.log(`Prefab: ${found.key}`);
413
- console.log(`类型: ${projectTypeLabel(found.systemType)}`);
414
- console.log(`状态: ${found.isUsed ? '已启用' : '未启用'}`);
415
- if (found.description)
416
- console.log(`说明: ${found.description}`);
417
- console.log('');
418
- console.log('当前值:');
419
- console.log(formatHumanValue(found.currentValue));
723
+ console.log(`命中 ${hits.length} 条字段(本页 ${slice.length} 条)`);
724
+ for (const h of slice) {
725
+ console.log(` ${h.prefabKey}.${h.fieldPath} (${h.type})`);
726
+ if (h.description)
727
+ console.log(` ${h.description}`);
728
+ }
729
+ if (page.hasMore)
730
+ console.log(`下一页: --offset ${offset + limit}`);
420
731
  });
421
732
  }
733
+ catch (e) {
734
+ if (isProjectDetectError(e))
735
+ throw e;
736
+ const msg = e instanceof Error ? e.message : String(e);
737
+ console.error(`Error: ${msg}`);
738
+ process.exit(1);
739
+ }
422
740
  });
423
- // ─── get ─────────────────────────────────────────────────────
424
741
  withJsonOption(prefab.command('get <key> [field]'))
425
742
  .description('获取 prefab 或子字段的当前值')
426
743
  .option('--variant <id>', '指定变体')
@@ -454,7 +771,6 @@ export function registerPrefabCommands(program) {
454
771
  }
455
772
  });
456
773
  });
457
- // ─── set ─────────────────────────────────────────────────────
458
774
  withJsonOption(prefab.command('set <key> <field> <value>'))
459
775
  .description('修改 prefab 子字段值(带校验)')
460
776
  .option('--variant <id>', '指定变体')
@@ -533,7 +849,141 @@ export function registerPrefabCommands(program) {
533
849
  });
534
850
  }
535
851
  });
536
- // ─── enable ──────────────────────────────────────────────────
852
+ withJsonOption(prefab.command('set-batch'))
853
+ .description('从文件或 stdin 读取 JSON,一次修改多个 prefab 字段(校验全部通过后写盘)')
854
+ .option('--file <path>', 'JSON 文件路径(默认读 stdin)')
855
+ .option('--variant <id>', '指定变体')
856
+ .option('--project-dir <path>', '项目根目录(默认 cwd)')
857
+ .action((opts) => {
858
+ const projectDir = resolveProjectDir(opts);
859
+ const systemType = detectProjectType(projectDir);
860
+ const asJson = Boolean(opts.json);
861
+ let raw;
862
+ try {
863
+ raw = readBatchJson(opts.file);
864
+ }
865
+ catch (e) {
866
+ console.error(`Error: 无法读取 batch输入: ${e instanceof Error ? e.message : e}`);
867
+ process.exit(1);
868
+ }
869
+ const trimmed = raw.trim();
870
+ if (!trimmed) {
871
+ console.error('Error: batch 输入为空。请使用 --file 或管道传入 JSON。');
872
+ process.exit(1);
873
+ }
874
+ let batch;
875
+ try {
876
+ batch = JSON.parse(trimmed);
877
+ }
878
+ catch (e) {
879
+ console.error(`Error: batch JSON 解析失败: ${e instanceof Error ? e.message : e}`);
880
+ process.exit(1);
881
+ }
882
+ if (!batch || typeof batch !== 'object' || Array.isArray(batch)) {
883
+ console.error('Error: batch 必须是形如 { "prefabKey": { "field": value } } 的对象。');
884
+ process.exit(1);
885
+ }
886
+ try {
887
+ if (systemType === 'external-theme') {
888
+ const themeId = resolveThemeId(projectDir, opts);
889
+ const schema = loadThemeSchema(projectDir);
890
+ let data = loadThemeData(projectDir, themeId);
891
+ const prefabs = parseThemePrefabs(schema, data, themeId);
892
+ for (const [prefabKey, fieldMap] of Object.entries(batch)) {
893
+ if (!fieldMap || typeof fieldMap !== 'object' || Array.isArray(fieldMap)) {
894
+ throw new Error(`prefab "${prefabKey}" 的值必须是对象(字段 map)`);
895
+ }
896
+ const found = prefabs.find((p) => p.key === prefabKey);
897
+ if (!found) {
898
+ throw new Error(`prefab "${prefabKey}" 不存在`);
899
+ }
900
+ const currentValue = data[prefabKey];
901
+ let newValue;
902
+ if (found.jsonSchema?.type === 'object') {
903
+ let obj = (currentValue && typeof currentValue === 'object' && !Array.isArray(currentValue))
904
+ ? { ...currentValue }
905
+ : {};
906
+ for (const [field, rawVal] of Object.entries(fieldMap)) {
907
+ const fieldSchema = resolveJsonSchemaField(found.jsonSchema, field);
908
+ const fieldType = fieldSchema?.type ?? 'string';
909
+ const coerced = coerceBatchLeaf(rawVal, fieldType);
910
+ if (fieldSchema) {
911
+ const result = validateThemeValue(fieldSchema, coerced);
912
+ if (!result.valid) {
913
+ throw new Error(`${prefabKey}.${field}: ${result.errors.join('; ')}`);
914
+ }
915
+ }
916
+ obj = setByDotPath(obj, field, coerced);
917
+ }
918
+ newValue = obj;
919
+ }
920
+ else {
921
+ const entries = Object.entries(fieldMap);
922
+ if (entries.length !== 1) {
923
+ throw new Error(`prefab "${prefabKey}" 非 object schema,batch 内只能包含一个字段`);
924
+ }
925
+ const [field, rawVal] = entries[0];
926
+ const fieldSchema = resolveJsonSchemaField(found.jsonSchema, field);
927
+ const fieldType = fieldSchema?.type ?? 'string';
928
+ const coerced = coerceBatchLeaf(rawVal, fieldType);
929
+ if (fieldSchema) {
930
+ const result = validateThemeValue(fieldSchema, coerced);
931
+ if (!result.valid) {
932
+ throw new Error(`${prefabKey}.${field}: ${result.errors.join('; ')}`);
933
+ }
934
+ }
935
+ newValue = coerced;
936
+ }
937
+ data = mergeThemeDataKey(data, prefabKey, newValue);
938
+ }
939
+ writeThemeData(projectDir, themeId, data);
940
+ }
941
+ else {
942
+ const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
943
+ let gameConfigJson = loadGameConfig(projectDir, configPath);
944
+ const metaMap = scanExtensionMetaData(projectDir);
945
+ for (const [extKey, fieldMap] of Object.entries(batch)) {
946
+ if (!fieldMap || typeof fieldMap !== 'object' || Array.isArray(fieldMap)) {
947
+ throw new Error(`extension "${extKey}" 的值必须是对象(字段 map)`);
948
+ }
949
+ const meta = metaMap.get(extKey);
950
+ if (!meta) {
951
+ throw new Error(`extension "${extKey}" 不存在`);
952
+ }
953
+ const configKey = meta.configPath ? parseConfigKey(meta.configPath) : meta.name;
954
+ const { config } = parseGameConfig(gameConfigJson);
955
+ const existingKey = findConfigKeyCaseInsensitive(config, configKey);
956
+ let currentValues = (existingKey ? config[existingKey] : {}) ?? {};
957
+ currentValues = typeof currentValues === 'object' && !Array.isArray(currentValues)
958
+ ? { ...currentValues }
959
+ : {};
960
+ for (const [field, rawVal] of Object.entries(fieldMap)) {
961
+ const fieldDef = meta.configSchema?.[field];
962
+ const fieldType = fieldDef?.type ?? 'string';
963
+ const coerced = coerceBatchLeaf(rawVal, fieldType);
964
+ if (fieldDef) {
965
+ const result = validateConfigValue(fieldDef, coerced);
966
+ if (!result.valid) {
967
+ throw new Error(`${extKey}.${field}: ${result.errors.join('; ')}`);
968
+ }
969
+ }
970
+ currentValues = setByDotPath(currentValues, field, coerced);
971
+ }
972
+ gameConfigJson = updateGameConfigValue(gameConfigJson, configKey, currentValues);
973
+ }
974
+ writeGameConfig(projectDir, configPath, gameConfigJson);
975
+ }
976
+ const payload = { success: true, updatedKeys: Object.keys(batch) };
977
+ outputResult(asJson, payload, () => {
978
+ console.log(`已批量更新: ${Object.keys(batch).join(', ')}`);
979
+ });
980
+ }
981
+ catch (e) {
982
+ const msg = e instanceof Error ? e.message : String(e);
983
+ console.error(`Error: ${msg}`);
984
+ process.exit(1);
985
+ }
986
+ });
537
987
  withJsonOption(prefab.command('enable <key>'))
538
988
  .description('启用 prefab')
539
989
  .option('--variant <id>', '指定变体')
@@ -579,7 +1029,6 @@ export function registerPrefabCommands(program) {
579
1029
  });
580
1030
  }
581
1031
  });
582
- // ─── disable ─────────────────────────────────────────────────
583
1032
  withJsonOption(prefab.command('disable <key>'))
584
1033
  .description('禁用 prefab')
585
1034
  .option('--variant <id>', '指定变体')
@@ -625,7 +1074,6 @@ export function registerPrefabCommands(program) {
625
1074
  });
626
1075
  }
627
1076
  });
628
- // ─── switch ──────────────────────────────────────────────────
629
1077
  withJsonOption(prefab.command('switch <variant>'))
630
1078
  .description('切换活跃变体(External: 修改 index.ts;PlayCanvas: 指定场景上下文)')
631
1079
  .option('--project-dir <path>', '项目根目录(默认 cwd)')
@@ -669,7 +1117,6 @@ export function registerPrefabCommands(program) {
669
1117
  }
670
1118
  });
671
1119
  }
672
- // ─── JSON Schema field resolver ──────────────────────────────────────────────
673
1120
  function resolveJsonSchemaField(schema, dotPath) {
674
1121
  const parts = dotPath.split('.');
675
1122
  let cur = schema;
@@ -102,6 +102,20 @@ function mimeTypeForImagePath(filePath) {
102
102
  return 'image/webp';
103
103
  return 'image/jpeg';
104
104
  }
105
+ function readReferenceImagePayload(filePath) {
106
+ try {
107
+ return {
108
+ base64: readFileSync(filePath).toString('base64'),
109
+ mimeType: mimeTypeForImagePath(filePath),
110
+ };
111
+ }
112
+ catch (e) {
113
+ const detail = e instanceof Error ? e.message : String(e);
114
+ const code = e && typeof e === 'object' && 'code' in e ? String(e.code) : '';
115
+ const hint = code ? ` (${code})` : '';
116
+ throw new Error(`Failed to read reference image: ${filePath}${hint}\n ${detail}`);
117
+ }
118
+ }
105
119
  export function registerToolsCommands(program) {
106
120
  const tools = program
107
121
  .command('tools')
@@ -117,12 +131,7 @@ export function registerToolsCommands(program) {
117
131
  .action(async (opts) => {
118
132
  try {
119
133
  const paths = opts.referenceImage ?? [];
120
- const referenceImages = paths.length > 0
121
- ? paths.map((p) => ({
122
- base64: readFileSync(p).toString('base64'),
123
- mimeType: mimeTypeForImagePath(p),
124
- }))
125
- : undefined;
134
+ const referenceImages = paths.length > 0 ? paths.map((p) => readReferenceImagePayload(p)) : undefined;
126
135
  const client = new AgentApiClient();
127
136
  const result = await client.post('/generate-image', {
128
137
  prompt: opts.prompt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.0.27",
3
+ "version": "0.0.28",
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.27",
26
- "@playcraft/common": "^0.0.16",
25
+ "@playcraft/build": "^0.0.28",
26
+ "@playcraft/common": "^0.0.17",
27
27
  "chokidar": "^4.0.3",
28
28
  "commander": "^13.1.0",
29
29
  "cors": "^2.8.6",