@playcraft/cli 0.0.10 → 0.0.12

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.
@@ -1,6 +1,5 @@
1
1
  import path from 'path';
2
2
  import fs from 'fs/promises';
3
- import os from 'os';
4
3
  import pc from 'picocolors';
5
4
  import ora from 'ora';
6
5
  import { BaseBuilder, ViteBuilder, PlayableBuilder } from '@playcraft/build';
@@ -10,16 +9,24 @@ import inquirer from 'inquirer';
10
9
  * 检测是否是基础构建产物(多文件版本)
11
10
  */
12
11
  async function detectBaseBuild(dir) {
13
- const indicators = [
12
+ // 基础文件:所有格式都需要
13
+ const baseIndicators = [
14
14
  path.join(dir, 'index.html'),
15
15
  path.join(dir, 'config.json'),
16
- path.join(dir, '__start__.js'),
17
16
  ];
17
+ // Classic 格式特有
18
+ const classicIndicator = path.join(dir, '__start__.js');
19
+ // ESM 格式特有
20
+ const esmIndicator = path.join(dir, 'js/index.mjs');
18
21
  try {
19
- await fs.access(indicators[0]);
20
- await fs.access(indicators[1]);
21
- await fs.access(indicators[2]);
22
- return true;
22
+ // 检查基础文件
23
+ for (const indicator of baseIndicators) {
24
+ await fs.access(indicator);
25
+ }
26
+ // 检查格式特有文件(满足其一即可)
27
+ const isClassic = await fs.access(classicIndicator).then(() => true).catch(() => false);
28
+ const isESM = await fs.access(esmIndicator).then(() => true).catch(() => false);
29
+ return isClassic || isESM;
23
30
  }
24
31
  catch (error) {
25
32
  return false;
@@ -84,7 +91,36 @@ async function detectAmmoUsage(baseBuildDir) {
84
91
  try {
85
92
  const configContent = await fs.readFile(configPath, 'utf-8');
86
93
  const configJson = JSON.parse(configContent);
94
+ // 检查 application_properties.use3dPhysics
87
95
  use3dPhysics = Boolean(configJson.application_properties?.use3dPhysics);
96
+ // 如果 use3dPhysics 未设置,通过检查场景内容来推断
97
+ // 检查场景是否包含 3D 物理组件(rigidbody, collision 等)
98
+ if (!use3dPhysics) {
99
+ const scenesDir = baseBuildDir;
100
+ try {
101
+ const files = await fs.readdir(scenesDir);
102
+ for (const file of files) {
103
+ if (file.endsWith('.json') && !['config.json', 'manifest.json'].includes(file)) {
104
+ try {
105
+ const sceneContent = await fs.readFile(path.join(scenesDir, file), 'utf-8');
106
+ // 检查场景是否包含 3D 物理组件
107
+ if (sceneContent.includes('"rigidbody"') ||
108
+ sceneContent.includes('"collision"') ||
109
+ sceneContent.includes('"joint"')) {
110
+ use3dPhysics = true;
111
+ break;
112
+ }
113
+ }
114
+ catch {
115
+ // 忽略单个场景读取失败
116
+ }
117
+ }
118
+ }
119
+ }
120
+ catch {
121
+ // 忽略目录读取失败
122
+ }
123
+ }
88
124
  if (configJson.assets) {
89
125
  for (const asset of Object.values(configJson.assets)) {
90
126
  const assetData = asset;
@@ -119,6 +155,7 @@ async function detectAmmoUsage(baseBuildDir) {
119
155
  }
120
156
  async function analyzeBaseBuild(baseBuildDir, ammoReplacement) {
121
157
  const assetSizes = [];
158
+ const assetPaths = new Set(); // 用于去重
122
159
  let engineSize = 0;
123
160
  const ammoCheck = await detectAmmoUsage(baseBuildDir);
124
161
  const engineCandidates = [
@@ -151,10 +188,15 @@ async function analyzeBaseBuild(baseBuildDir, ammoReplacement) {
151
188
  if (ammoReplacement && lowerUrl.includes('ammo')) {
152
189
  continue;
153
190
  }
191
+ // 跳过已经处理过的资源
192
+ if (assetPaths.has(cleanUrl)) {
193
+ continue;
194
+ }
154
195
  const assetPath = path.join(baseBuildDir, cleanUrl);
155
196
  try {
156
197
  const stats = await fs.stat(assetPath);
157
198
  assetSizes.push({ path: cleanUrl, size: stats.size });
199
+ assetPaths.add(cleanUrl); // 标记为已处理
158
200
  }
159
201
  catch (error) {
160
202
  // ignore missing assets
@@ -174,10 +216,15 @@ async function analyzeBaseBuild(baseBuildDir, ammoReplacement) {
174
216
  };
175
217
  }
176
218
  export async function buildCommand(projectPath, options) {
219
+ // 解析项目路径 - 在 spinner 之前执行以便调试
220
+ const resolvedProjectPath = path.resolve(projectPath);
221
+ console.log(`[DEBUG] projectPath: ${projectPath}`);
222
+ console.log(`[DEBUG] resolvedProjectPath: ${resolvedProjectPath}`);
223
+ console.log(`[DEBUG] cwd: ${process.cwd()}`);
177
224
  const spinner = ora(pc.cyan('🔨 开始打包 Playable Ad...')).start();
225
+ // 清理选项(默认为 false,使用覆盖模式)
226
+ const shouldClean = options.clean === true;
178
227
  try {
179
- // 解析项目路径
180
- const resolvedProjectPath = path.resolve(projectPath);
181
228
  if (!options.baseOnly && !options.mode) {
182
229
  spinner.stop();
183
230
  const modeAnswer = await inquirer.prompt([
@@ -265,10 +312,22 @@ export async function buildCommand(projectPath, options) {
265
312
  }
266
313
  // 如果只执行基础构建
267
314
  if (options.baseOnly || options.mode === 'base') {
315
+ // 确定输出目录(Base Build 默认 ./build)
316
+ const baseBuildOutputDir = path.resolve(options.output || './build');
317
+ // 安全检查:防止输出目录与输入目录相同时被清理
318
+ const isSameAsInput = path.resolve(baseBuildOutputDir) === path.resolve(resolvedProjectPath);
319
+ // 如果输出目录与输入目录相同,警告用户并禁用清理
320
+ if (shouldClean && isSameAsInput) {
321
+ console.log(pc.yellow(`\n⚠️ 输出目录与输入目录相同,跳过清理以保护源文件`));
322
+ }
323
+ else if (!shouldClean) {
324
+ console.log(pc.dim(`\nℹ️ 使用覆盖模式,新文件将覆盖旧文件(使用 --clean 可清理旧目录)`));
325
+ }
268
326
  spinner.text = pc.cyan('执行阶段1: 基础构建...');
269
327
  const baseBuilder = new BaseBuilder(resolvedProjectPath, {
270
- outputDir: path.resolve(options.output || './build'),
328
+ outputDir: baseBuildOutputDir,
271
329
  selectedScenes,
330
+ clean: shouldClean && !isSameAsInput, // 清理逻辑由 BaseBuilder 内部处理
272
331
  });
273
332
  const baseBuild = await baseBuilder.build();
274
333
  spinner.succeed(pc.green('✅ 基础构建完成!'));
@@ -276,7 +335,7 @@ export async function buildCommand(projectPath, options) {
276
335
  console.log(pc.dim('💡 提示: 可以在浏览器中打开 index.html 测试游戏'));
277
336
  return;
278
337
  }
279
- // 完整流程:阶段1 + 阶段2
338
+ // 完整流程:阶段1 + 阶段2(Playable Ads)
280
339
  const shouldRunPlayableOnly = options.mode === 'playable';
281
340
  // 验证平台
282
341
  const validPlatforms = [
@@ -294,30 +353,80 @@ export async function buildCommand(projectPath, options) {
294
353
  'adikteev',
295
354
  'remerge',
296
355
  ];
356
+ // 渠道输出格式配置(根据各渠道Playable规格对照表)
357
+ // 只支持 HTML: applovin, ironsource, unity, moloco, adikteev, remerge
358
+ // 只支持 ZIP: google, tiktok, liftoff, bigo, snapchat, inmobi
359
+ // 支持两种: facebook
360
+ const platformFormatConfig = {
361
+ facebook: {
362
+ formats: ['html', 'zip'],
363
+ default: 'html',
364
+ description: 'Facebook 支持两种格式'
365
+ },
366
+ applovin: { formats: ['html'], default: 'html' },
367
+ ironsource: { formats: ['html'], default: 'html' },
368
+ unity: { formats: ['html'], default: 'html' },
369
+ moloco: { formats: ['html'], default: 'html' },
370
+ adikteev: { formats: ['html'], default: 'html' },
371
+ remerge: { formats: ['html'], default: 'html' },
372
+ google: { formats: ['zip'], default: 'zip' },
373
+ tiktok: { formats: ['zip'], default: 'zip' },
374
+ liftoff: { formats: ['zip'], default: 'zip' },
375
+ bigo: { formats: ['zip'], default: 'zip' },
376
+ snapchat: { formats: ['zip'], default: 'zip' },
377
+ inmobi: { formats: ['zip'], default: 'zip' },
378
+ };
379
+ // 获取渠道支持的格式
380
+ const getPlatformFormats = (platform) => {
381
+ return platformFormatConfig[platform] || { formats: ['html', 'zip'], default: 'html' };
382
+ };
297
383
  if (!options.platform || !validPlatforms.includes(options.platform)) {
298
384
  spinner.stop();
299
- const answers = await inquirer.prompt([
385
+ // 先选择平台
386
+ const platformAnswer = await inquirer.prompt([
300
387
  {
301
388
  type: 'list',
302
389
  name: 'platform',
303
390
  message: '选择目标平台:',
304
391
  choices: validPlatforms,
305
392
  },
306
- {
307
- type: 'list',
308
- name: 'format',
309
- message: (answers) => answers.platform === 'facebook' ? '选择输出格式(Facebook 支持两种格式):' : '选择输出格式:',
310
- choices: (answers) => answers.platform === 'facebook'
311
- ? [
312
- { name: 'HTML(单文件,最大 5MB,所有资源内联)', value: 'html' },
313
- { name: 'ZIP(多文件,最大 5MB,HTML 文件需 < 2MB)', value: 'zip' },
314
- ]
315
- : [
316
- { name: 'HTML(单文件)', value: 'html' },
317
- { name: 'ZIP(多文件)', value: 'zip' },
318
- ],
319
- default: 'html',
320
- },
393
+ ]);
394
+ const selectedPlatform = platformAnswer.platform;
395
+ const formatConfig = getPlatformFormats(selectedPlatform);
396
+ // 根据平台支持的格式决定是否需要选择
397
+ let selectedFormat;
398
+ if (formatConfig.formats.length === 1) {
399
+ // 只支持一种格式,直接使用
400
+ selectedFormat = formatConfig.formats[0];
401
+ const formatName = selectedFormat === 'html' ? 'HTML(单文件)' : 'ZIP(多文件)';
402
+ console.log(pc.dim(`ℹ️ ${selectedPlatform} 渠道仅支持 ${formatName} 格式`));
403
+ }
404
+ else {
405
+ // 支持多种格式,让用户选择
406
+ const formatAnswer = await inquirer.prompt([
407
+ {
408
+ type: 'list',
409
+ name: 'format',
410
+ message: formatConfig.description
411
+ ? `选择输出格式(${formatConfig.description}):`
412
+ : '选择输出格式:',
413
+ choices: formatConfig.formats.map(f => ({
414
+ name: f === 'html'
415
+ ? (selectedPlatform === 'facebook'
416
+ ? 'HTML(单文件,最大 5MB,所有资源内联)'
417
+ : 'HTML(单文件)')
418
+ : (selectedPlatform === 'facebook'
419
+ ? 'ZIP(多文件,最大 5MB,HTML 文件需 < 2MB)'
420
+ : 'ZIP(多文件)'),
421
+ value: f,
422
+ })),
423
+ default: formatConfig.default,
424
+ },
425
+ ]);
426
+ selectedFormat = formatAnswer.format;
427
+ }
428
+ // 选择输出目录
429
+ const outputAnswer = await inquirer.prompt([
321
430
  {
322
431
  type: 'input',
323
432
  name: 'output',
@@ -325,11 +434,24 @@ export async function buildCommand(projectPath, options) {
325
434
  default: options.output || './dist',
326
435
  },
327
436
  ]);
328
- options.platform = answers.platform;
329
- options.format = answers.format;
330
- options.output = answers.output;
437
+ options.platform = selectedPlatform;
438
+ options.format = selectedFormat;
439
+ options.output = outputAnswer.output;
331
440
  spinner.start(pc.cyan('🔨 开始打包 Playable Ad...'));
332
441
  }
442
+ else {
443
+ // 命令行已指定平台,检查格式是否符合要求
444
+ const formatConfig = getPlatformFormats(options.platform);
445
+ if (options.format && !formatConfig.formats.includes(options.format)) {
446
+ const supportedFormats = formatConfig.formats.join(' 或 ');
447
+ console.log(pc.yellow(`\n⚠️ ${options.platform} 渠道仅支持 ${supportedFormats} 格式,已自动切换为 ${formatConfig.default}`));
448
+ options.format = formatConfig.default;
449
+ }
450
+ else if (!options.format) {
451
+ // 未指定格式,使用默认值
452
+ options.format = formatConfig.default;
453
+ }
454
+ }
333
455
  // 解析配置
334
456
  const buildOptions = {
335
457
  platform: options.platform,
@@ -350,18 +472,44 @@ export async function buildCommand(projectPath, options) {
350
472
  modelCompression: options.modelCompression,
351
473
  // 场景选择
352
474
  selectedScenes,
475
+ // ESM 选项
476
+ ...(options.esmMode ? { esmMode: options.esmMode } : {}),
353
477
  };
354
478
  if (fileConfig) {
355
479
  Object.assign(buildOptions, fileConfig);
356
480
  }
357
- // 1. 检测输入类型
481
+ // 1. 先检测输入类型(在清理目录之前!)
358
482
  spinner.text = pc.cyan('检测项目类型...');
359
483
  const isBaseBuild = await detectBaseBuild(resolvedProjectPath);
484
+ // 2. 验证 playable-only 模式的前置条件
485
+ if (shouldRunPlayableOnly && !isBaseBuild) {
486
+ // 检查是否是原始 PlayCanvas 编辑器项目
487
+ const isEditorProject = await fs.access(path.join(resolvedProjectPath, 'scenes.json')).then(() => true).catch(() => false);
488
+ if (isEditorProject) {
489
+ throw new Error('检测到这是 PlayCanvas 编辑器项目,不是多文件构建产物。\n\n' +
490
+ '请使用以下命令之一:\n' +
491
+ ' 1. playcraft build <项目路径> # 完整构建(推荐)\n' +
492
+ ' 2. playcraft build-base <项目路径> # 先生成多文件产物\n' +
493
+ ' playcraft build-playable <产物路径> # 再转换为 Playable\n\n' +
494
+ '💡 提示: build-playable 命令仅用于已有的多文件构建产物');
495
+ }
496
+ throw new Error('当前目录不是多文件构建产物,请先运行完整构建或使用 build-base 生成。');
497
+ }
498
+ // 3. 验证通过后,显示清理/覆盖模式提示(实际清理由 ViteBuilder 内部处理)
499
+ const playableOutputDir = buildOptions.outputDir || path.resolve(options.output || './dist');
500
+ // 安全检查:防止输出目录与输入目录相同时被清理
501
+ const isSameAsInput = path.resolve(playableOutputDir) === path.resolve(resolvedProjectPath);
502
+ if (shouldClean && isSameAsInput) {
503
+ console.log(pc.yellow(`\n⚠️ 输出目录与输入目录相同,跳过清理以保护源文件`));
504
+ }
505
+ else if (!shouldClean) {
506
+ console.log(pc.dim(`\nℹ️ 使用覆盖模式,新文件将覆盖旧文件(使用 --clean 可清理旧目录)`));
507
+ }
508
+ // 注意:ViteBuilder 内部会通过 emptyOutDir: true 自动清空输出目录
509
+ // 4. 确定 baseBuildDir
360
510
  let baseBuildDir;
361
511
  if (shouldRunPlayableOnly) {
362
- if (!isBaseBuild) {
363
- throw new Error('当前目录不是多文件构建产物,请先运行完整构建或使用 build-base 生成。');
364
- }
512
+ // 已经在上面验证过 isBaseBuild === true
365
513
  spinner.text = pc.cyan('✅ 使用多文件构建产物,直接执行阶段2');
366
514
  baseBuildDir = resolvedProjectPath;
367
515
  }
@@ -373,10 +521,13 @@ export async function buildCommand(projectPath, options) {
373
521
  else {
374
522
  // 输入是源代码或官方构建,先执行阶段1
375
523
  spinner.text = pc.cyan('执行阶段1: 基础构建...');
376
- const tempDir = path.join(os.tmpdir(), `playcraft-base-build-${Date.now()}`);
524
+ // 将临时目录放在项目所在目录下,避免跨盘符路径问题(Windows C:\Temp 和 D:\project 会导致 Vite 路径计算错误)
525
+ const tempDir = path.join(resolvedProjectPath, '.playcraft-temp', `base-build-${Date.now()}`);
377
526
  const baseBuilder = new BaseBuilder(resolvedProjectPath, {
378
527
  outputDir: tempDir,
379
528
  selectedScenes,
529
+ analyze: buildOptions.analyze,
530
+ analyzeReportPath: buildOptions.analyze ? 'base-bundle-report.html' : undefined,
380
531
  });
381
532
  const baseBuild = await baseBuilder.build();
382
533
  baseBuildDir = baseBuild.outputDir;
@@ -390,18 +541,48 @@ export async function buildCommand(projectPath, options) {
390
541
  else if (options.replaceAmmo && ammoCheck.hasAmmo && ammoCheck.suggestedEngine) {
391
542
  buildOptions.ammoReplacement = ammoCheck.suggestedEngine;
392
543
  }
393
- else if (ammoCheck.hasAmmo && ammoCheck.suggestedEngine && !buildOptions.ammoReplacement) {
544
+ else if (ammoCheck.hasAmmo && !buildOptions.ammoReplacement) {
394
545
  spinner.stop();
395
- const replaceAnswer = await inquirer.prompt([
396
- {
397
- type: 'confirm',
398
- name: 'replaceAmmo',
399
- message: `检测到 Ammo.js,是否替换为 ${ammoCheck.suggestedEngine} 并打包内置?`,
400
- default: false,
401
- },
402
- ]);
403
- if (replaceAnswer.replaceAmmo) {
404
- buildOptions.ammoReplacement = ammoCheck.suggestedEngine;
546
+ // 根据是否使用 3D 物理提供不同的选项
547
+ const physicsType = ammoCheck.use3dPhysics ? '3D' : '2D';
548
+ if (ammoCheck.use3dPhysics) {
549
+ // 3D 项目 - 可以使用 Cannon.js 替换
550
+ console.log(pc.yellow(`⚠️ 检测到 3D 物理项目,Ammo.js 约 1.8MB`));
551
+ console.log(pc.gray(` 提示: Cannon.js (~400KB) 可以减小 75% 的体积`));
552
+ const replaceAnswer = await inquirer.prompt([
553
+ {
554
+ type: 'list',
555
+ name: 'ammoAction',
556
+ message: `如何处理 Ammo.js?`,
557
+ choices: [
558
+ { name: '保留 Ammo.js(完整物理功能,文件较大)', value: 'keep' },
559
+ { name: '替换为 Cannon.js(推荐 3D 项目,~400KB,自动适配)', value: 'cannon' },
560
+ ],
561
+ default: 'cannon',
562
+ },
563
+ ]);
564
+ if (replaceAnswer.ammoAction === 'cannon') {
565
+ buildOptions.ammoReplacement = 'cannon';
566
+ console.log(pc.green(' ✓ 将使用 Cannon.js 替换 Ammo.js(自动兼容 rigidbody API)'));
567
+ }
568
+ }
569
+ else {
570
+ // 2D 项目 - 可以使用 p2.js 替换
571
+ const replaceAnswer = await inquirer.prompt([
572
+ {
573
+ type: 'list',
574
+ name: 'ammoAction',
575
+ message: `检测到 Ammo.js(项目使用 ${physicsType} 物理),如何处理?`,
576
+ choices: [
577
+ { name: '不替换(保留 Ammo.js,文件较大)', value: 'keep' },
578
+ { name: '替换为 p2.js(推荐 2D 项目,~60KB)', value: 'p2' },
579
+ ],
580
+ default: 'p2',
581
+ },
582
+ ]);
583
+ if (replaceAnswer.ammoAction === 'p2') {
584
+ buildOptions.ammoReplacement = 'p2';
585
+ }
405
586
  }
406
587
  spinner.start(pc.cyan('🔨 开始打包 Playable Ad...'));
407
588
  }
@@ -434,12 +615,25 @@ export async function buildCommand(projectPath, options) {
434
615
  console.log(` - 总计: ${totalMB} MB ${status} (限制: ${limitMB} MB)`);
435
616
  console.log('\n' + pc.green(`输出: ${outputPath}`));
436
617
  if (buildOptions.analyze) {
437
- const reportPath = buildOptions.analyzeReportPath
618
+ // Base Build 复制分析报告到最终输出目录
619
+ const baseReportPath = path.join(baseBuildDir, 'base-bundle-report.html');
620
+ const finalReportPath = buildOptions.analyzeReportPath
438
621
  ? buildOptions.analyzeReportPath
439
622
  : path.join(buildOptions.outputDir || './dist', 'bundle-report.html');
623
+ let reportExists = false;
624
+ try {
625
+ await fs.access(baseReportPath);
626
+ await fs.copyFile(baseReportPath, finalReportPath);
627
+ reportExists = true;
628
+ }
629
+ catch {
630
+ // 报告文件不存在(例如从官方构建产物直接复制时),这是正常的
631
+ }
440
632
  const analysis = await analyzeBaseBuild(baseBuildDir, buildOptions.ammoReplacement);
441
633
  console.log('\n' + pc.bold('分析报告:'));
442
- console.log(` - 可视化报告: ${reportPath}`);
634
+ if (reportExists) {
635
+ console.log(` - 可视化报告: ${finalReportPath}`);
636
+ }
443
637
  if (analysis.engineSize > 0) {
444
638
  const engineMB = (analysis.engineSize / 1024 / 1024).toFixed(2);
445
639
  console.log(` - 引擎大小: ${engineMB} MB`);
@@ -447,22 +641,30 @@ export async function buildCommand(projectPath, options) {
447
641
  if (analysis.topAssets.length > 0) {
448
642
  console.log(' - 资源 Top:');
449
643
  analysis.topAssets.forEach((asset) => {
450
- const sizeMB = (asset.size / 1024 / 1024).toFixed(2);
451
- console.log(` • ${asset.path}: ${sizeMB} MB`);
644
+ const sizeKB = asset.size / 1024;
645
+ const sizeMB = sizeKB / 1024;
646
+ // 自动选择合适的单位
647
+ const sizeStr = sizeMB >= 0.01
648
+ ? `${sizeMB.toFixed(2)} MB`
649
+ : `${sizeKB.toFixed(2)} KB`;
650
+ console.log(` • ${asset.path}: ${sizeStr}`);
452
651
  });
453
652
  }
454
653
  if (analysis.hasAmmo) {
455
654
  console.log(pc.yellow(` - 检测到 Ammo.js,建议替换为 ${analysis.suggestedEngine}`));
456
655
  }
457
656
  }
458
- // 清理临时目录
459
- if (!isBaseBuild && baseBuildDir.startsWith(os.tmpdir())) {
460
- try {
461
- await fs.rm(baseBuildDir, { recursive: true, force: true });
462
- }
463
- catch (error) {
464
- // 忽略清理错误
465
- }
657
+ // 清理临时目录(调试时注释掉)
658
+ if (!isBaseBuild && baseBuildDir.includes('.playcraft-temp')) {
659
+ // 调试:暂时保留临时目录
660
+ console.log(pc.yellow(`[DEBUG] 临时目录保留在: ${baseBuildDir}`));
661
+ // try {
662
+ // // 清理整个 .playcraft-temp 目录
663
+ // const tempBaseDir = path.join(resolvedProjectPath, '.playcraft-temp');
664
+ // await fs.rm(tempBaseDir, { recursive: true, force: true });
665
+ // } catch (error) {
666
+ // // 忽略清理错误
667
+ // }
466
668
  }
467
669
  }
468
670
  catch (error) {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * fix-ids 命令:修复已导入项目的 ID 映射(将 Sandbox 中的 PlayCanvas ID 转换为 PlayCraft numericId)
3
+ */
4
+ import pc from 'picocolors';
5
+ export async function fixIdsCommand(options) {
6
+ const { projectId, apiUrl, token } = options;
7
+ const baseUrl = (apiUrl || process.env.BACKEND_API_URL || process.env.PLAYCRAFT_URL || 'http://localhost:3001').replace(/\/+$/, '');
8
+ const url = `${baseUrl}/api/projects/${encodeURIComponent(projectId)}/fix-ids`;
9
+ const headers = {
10
+ 'Content-Type': 'application/json',
11
+ };
12
+ if (token || process.env.PLAYCRAFT_TOKEN) {
13
+ headers['Authorization'] = `Bearer ${token || process.env.PLAYCRAFT_TOKEN}`;
14
+ }
15
+ if (process.env.NEXT_PUBLIC_LOCAL_AUTH_BYPASS === 'true' && process.env.NEXT_PUBLIC_LOCAL_USER) {
16
+ headers['X-Local-User'] = process.env.NEXT_PUBLIC_LOCAL_USER;
17
+ }
18
+ console.log(pc.dim(`Fixing ID mapping for project ${projectId}...`));
19
+ console.log(pc.dim(`POST ${url}`));
20
+ try {
21
+ const res = await fetch(url, {
22
+ method: 'POST',
23
+ headers,
24
+ });
25
+ const body = await res.json().catch(() => ({}));
26
+ if (!res.ok) {
27
+ const msg = body.message || res.statusText || String(res.status);
28
+ console.error(pc.red(`Failed to fix ID mapping: ${msg}`));
29
+ process.exit(1);
30
+ }
31
+ if (body.success !== true) {
32
+ console.error(pc.red('Unexpected response from server'));
33
+ process.exit(1);
34
+ }
35
+ console.log(pc.green('ID mapping fixed successfully'));
36
+ console.log(pc.dim('Please reload the editor to see changes.'));
37
+ }
38
+ catch (err) {
39
+ const message = err instanceof Error ? err.message : String(err);
40
+ console.error(pc.red(`Request failed: ${message}`));
41
+ process.exit(1);
42
+ }
43
+ }