@playcraft/cli 0.0.11 → 0.0.13

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;
@@ -209,10 +216,15 @@ async function analyzeBaseBuild(baseBuildDir, ammoReplacement) {
209
216
  };
210
217
  }
211
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()}`);
212
224
  const spinner = ora(pc.cyan('🔨 开始打包 Playable Ad...')).start();
225
+ // 清理选项(默认为 false,使用覆盖模式)
226
+ const shouldClean = options.clean === true;
213
227
  try {
214
- // 解析项目路径
215
- const resolvedProjectPath = path.resolve(projectPath);
216
228
  if (!options.baseOnly && !options.mode) {
217
229
  spinner.stop();
218
230
  const modeAnswer = await inquirer.prompt([
@@ -300,10 +312,22 @@ export async function buildCommand(projectPath, options) {
300
312
  }
301
313
  // 如果只执行基础构建
302
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
+ }
303
326
  spinner.text = pc.cyan('执行阶段1: 基础构建...');
304
327
  const baseBuilder = new BaseBuilder(resolvedProjectPath, {
305
- outputDir: path.resolve(options.output || './build'),
328
+ outputDir: baseBuildOutputDir,
306
329
  selectedScenes,
330
+ clean: shouldClean && !isSameAsInput, // 清理逻辑由 BaseBuilder 内部处理
307
331
  });
308
332
  const baseBuild = await baseBuilder.build();
309
333
  spinner.succeed(pc.green('✅ 基础构建完成!'));
@@ -311,7 +335,7 @@ export async function buildCommand(projectPath, options) {
311
335
  console.log(pc.dim('💡 提示: 可以在浏览器中打开 index.html 测试游戏'));
312
336
  return;
313
337
  }
314
- // 完整流程:阶段1 + 阶段2
338
+ // 完整流程:阶段1 + 阶段2(Playable Ads)
315
339
  const shouldRunPlayableOnly = options.mode === 'playable';
316
340
  // 验证平台
317
341
  const validPlatforms = [
@@ -329,30 +353,80 @@ export async function buildCommand(projectPath, options) {
329
353
  'adikteev',
330
354
  'remerge',
331
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
+ };
332
383
  if (!options.platform || !validPlatforms.includes(options.platform)) {
333
384
  spinner.stop();
334
- const answers = await inquirer.prompt([
385
+ // 先选择平台
386
+ const platformAnswer = await inquirer.prompt([
335
387
  {
336
388
  type: 'list',
337
389
  name: 'platform',
338
390
  message: '选择目标平台:',
339
391
  choices: validPlatforms,
340
392
  },
341
- {
342
- type: 'list',
343
- name: 'format',
344
- message: (answers) => answers.platform === 'facebook' ? '选择输出格式(Facebook 支持两种格式):' : '选择输出格式:',
345
- choices: (answers) => answers.platform === 'facebook'
346
- ? [
347
- { name: 'HTML(单文件,最大 5MB,所有资源内联)', value: 'html' },
348
- { name: 'ZIP(多文件,最大 5MB,HTML 文件需 < 2MB)', value: 'zip' },
349
- ]
350
- : [
351
- { name: 'HTML(单文件)', value: 'html' },
352
- { name: 'ZIP(多文件)', value: 'zip' },
353
- ],
354
- default: 'html',
355
- },
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([
356
430
  {
357
431
  type: 'input',
358
432
  name: 'output',
@@ -360,11 +434,24 @@ export async function buildCommand(projectPath, options) {
360
434
  default: options.output || './dist',
361
435
  },
362
436
  ]);
363
- options.platform = answers.platform;
364
- options.format = answers.format;
365
- options.output = answers.output;
437
+ options.platform = selectedPlatform;
438
+ options.format = selectedFormat;
439
+ options.output = outputAnswer.output;
366
440
  spinner.start(pc.cyan('🔨 开始打包 Playable Ad...'));
367
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
+ }
368
455
  // 解析配置
369
456
  const buildOptions = {
370
457
  platform: options.platform,
@@ -385,18 +472,44 @@ export async function buildCommand(projectPath, options) {
385
472
  modelCompression: options.modelCompression,
386
473
  // 场景选择
387
474
  selectedScenes,
475
+ // ESM 选项
476
+ ...(options.esmMode ? { esmMode: options.esmMode } : {}),
388
477
  };
389
478
  if (fileConfig) {
390
479
  Object.assign(buildOptions, fileConfig);
391
480
  }
392
- // 1. 检测输入类型
481
+ // 1. 先检测输入类型(在清理目录之前!)
393
482
  spinner.text = pc.cyan('检测项目类型...');
394
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
395
510
  let baseBuildDir;
396
511
  if (shouldRunPlayableOnly) {
397
- if (!isBaseBuild) {
398
- throw new Error('当前目录不是多文件构建产物,请先运行完整构建或使用 build-base 生成。');
399
- }
512
+ // 已经在上面验证过 isBaseBuild === true
400
513
  spinner.text = pc.cyan('✅ 使用多文件构建产物,直接执行阶段2');
401
514
  baseBuildDir = resolvedProjectPath;
402
515
  }
@@ -408,7 +521,8 @@ export async function buildCommand(projectPath, options) {
408
521
  else {
409
522
  // 输入是源代码或官方构建,先执行阶段1
410
523
  spinner.text = pc.cyan('执行阶段1: 基础构建...');
411
- 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()}`);
412
526
  const baseBuilder = new BaseBuilder(resolvedProjectPath, {
413
527
  outputDir: tempDir,
414
528
  selectedScenes,
@@ -506,16 +620,20 @@ export async function buildCommand(projectPath, options) {
506
620
  const finalReportPath = buildOptions.analyzeReportPath
507
621
  ? buildOptions.analyzeReportPath
508
622
  : path.join(buildOptions.outputDir || './dist', 'bundle-report.html');
623
+ let reportExists = false;
509
624
  try {
510
625
  await fs.access(baseReportPath);
511
626
  await fs.copyFile(baseReportPath, finalReportPath);
627
+ reportExists = true;
512
628
  }
513
- catch (error) {
514
- console.warn(pc.yellow(' ⚠️ 无法找到或复制分析报告'));
629
+ catch {
630
+ // 报告文件不存在(例如从官方构建产物直接复制时),这是正常的
515
631
  }
516
632
  const analysis = await analyzeBaseBuild(baseBuildDir, buildOptions.ammoReplacement);
517
633
  console.log('\n' + pc.bold('分析报告:'));
518
- console.log(` - 可视化报告: ${finalReportPath}`);
634
+ if (reportExists) {
635
+ console.log(` - 可视化报告: ${finalReportPath}`);
636
+ }
519
637
  if (analysis.engineSize > 0) {
520
638
  const engineMB = (analysis.engineSize / 1024 / 1024).toFixed(2);
521
639
  console.log(` - 引擎大小: ${engineMB} MB`);
@@ -536,14 +654,17 @@ export async function buildCommand(projectPath, options) {
536
654
  console.log(pc.yellow(` - 检测到 Ammo.js,建议替换为 ${analysis.suggestedEngine}`));
537
655
  }
538
656
  }
539
- // 清理临时目录
540
- if (!isBaseBuild && baseBuildDir.startsWith(os.tmpdir())) {
541
- try {
542
- await fs.rm(baseBuildDir, { recursive: true, force: true });
543
- }
544
- catch (error) {
545
- // 忽略清理错误
546
- }
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
+ // }
547
668
  }
548
669
  }
549
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
+ }
@@ -4,15 +4,10 @@ import { fileURLToPath } from 'url';
4
4
  import { loadConfig } from '../config.js';
5
5
  import { ProcessManager } from '../process-manager.js';
6
6
  import { isPortAvailable, findAvailablePort } from '../port-utils.js';
7
- import { Logger } from '../logger.js';
8
- import { createServer } from '../server.js';
9
- import { SocketServer } from '../socket.js';
10
- import { Watcher } from '../watcher.js';
11
- import { FSHandler } from '../fs-handler.js';
12
- import http from 'http';
13
7
  import pc from 'picocolors';
14
8
  import ora from 'ora';
15
9
  import inquirer from 'inquirer';
10
+ import { PlayCraftAgent } from '../agent/agent.js';
16
11
  const __filename = fileURLToPath(import.meta.url);
17
12
  const __dirname = path.dirname(__filename);
18
13
  export async function startCommand(options) {
@@ -23,6 +18,7 @@ export async function startCommand(options) {
23
18
  token: options.token,
24
19
  dir: options.dir,
25
20
  port: options.port ? parseInt(options.port) : undefined,
21
+ mode: options.mode || undefined,
26
22
  });
27
23
  if (!config.projectId) {
28
24
  spinner.fail('项目 ID 未设置');
@@ -101,6 +97,7 @@ async function startDaemon(config) {
101
97
  PLAYCRAFT_PORT: config.port.toString(),
102
98
  PLAYCRAFT_DIR: config.dir,
103
99
  PLAYCRAFT_DAEMON: 'true',
100
+ PLAYCRAFT_MODE: config.mode || 'full-local',
104
101
  },
105
102
  });
106
103
  child.unref();
@@ -126,76 +123,11 @@ async function startDaemon(config) {
126
123
  }
127
124
  }
128
125
  async function startForeground(config) {
129
- const logger = new Logger(config.projectId || 'default', false);
130
- await logger.initialize();
131
- console.log(pc.cyan(`\n🚀 PlayCraft Agent 启动中...\n`));
132
- console.log(`${pc.bold('项目 ID:')} ${config.projectId || pc.yellow('未设置')}`);
133
- console.log(`${pc.bold('目录:')} ${config.dir}`);
134
- console.log(`${pc.bold('端口:')} ${config.port}\n`);
135
- const fsHandler = new FSHandler(config);
136
- const app = createServer(config, fsHandler);
137
- const server = http.createServer(app);
138
- // Connection status indicator
139
- let lastConnectionCount = 0;
140
- const socketServer = new SocketServer(server, config, async (count) => {
141
- if (count > lastConnectionCount) {
142
- const message = `✅ 编辑器已连接 (共 ${count} 个连接)`;
143
- await logger.info(message);
144
- console.log(pc.green(message));
145
- }
146
- else if (count < lastConnectionCount) {
147
- const message = count === 0
148
- ? '⚠️ 编辑器已断开连接,等待重新连接...'
149
- : `⚠️ 连接数减少 (剩余 ${count} 个连接)`;
150
- await logger.info(message);
151
- console.log(pc.yellow(message));
152
- }
153
- lastConnectionCount = count;
154
- });
155
- const watcher = new Watcher(config, async (filePath, type) => {
156
- const message = `[${type.toUpperCase()}] ${filePath}`;
157
- await logger.info(message);
158
- socketServer.notifyFileChange(filePath, type);
159
- });
160
- server.listen(config.port, async () => {
161
- await logger.info(`Local server running at http://localhost:${config.port}`);
162
- console.log(pc.green(`✅ 本地服务运行在 http://localhost:${config.port}`));
163
- console.log(pc.dim('等待编辑器连接...\n'));
164
- });
165
- // 优雅关闭
126
+ const agent = new PlayCraftAgent(config, false);
127
+ await agent.start();
166
128
  const shutdown = async () => {
167
- console.log(pc.yellow('\n正在关闭 agent...'));
168
- // Set a timeout to force exit if graceful shutdown fails
169
- const forceExitTimeout = setTimeout(() => {
170
- console.log(pc.red('强制退出...'));
171
- process.exit(1);
172
- }, 3000); // 3 seconds timeout
173
- try {
174
- // Close watcher
175
- await watcher.close();
176
- // Close WebSocket connections
177
- socketServer.destroy();
178
- // Close HTTP server
179
- await new Promise((resolve) => {
180
- server.close(() => {
181
- resolve();
182
- });
183
- // Force close if it takes too long
184
- setTimeout(() => {
185
- resolve();
186
- }, 1000);
187
- });
188
- // Close logger
189
- await logger.close();
190
- clearTimeout(forceExitTimeout);
191
- console.log(pc.green('✅ Agent 已关闭'));
192
- process.exit(0);
193
- }
194
- catch (error) {
195
- clearTimeout(forceExitTimeout);
196
- console.error(pc.red('关闭时出错:'), error);
197
- process.exit(1);
198
- }
129
+ await agent.stop();
130
+ process.exit(0);
199
131
  };
200
132
  process.on('SIGINT', shutdown);
201
133
  process.on('SIGTERM', shutdown);
@@ -207,6 +139,7 @@ export async function startInternal() {
207
139
  const port = parseInt(process.env.PLAYCRAFT_PORT || '2468');
208
140
  const dir = process.env.PLAYCRAFT_DIR || process.cwd();
209
141
  const isDaemon = process.env.PLAYCRAFT_DAEMON === 'true';
142
+ const mode = process.env.PLAYCRAFT_MODE || 'full-local';
210
143
  if (!projectId) {
211
144
  console.error('PLAYCRAFT_PROJECT_ID 环境变量未设置');
212
145
  process.exit(1);
@@ -216,68 +149,20 @@ export async function startInternal() {
216
149
  token,
217
150
  dir,
218
151
  port,
152
+ mode,
219
153
  };
220
- const logger = new Logger(config.projectId || 'default', isDaemon);
221
- await logger.initialize();
222
- await logger.info(`Starting PlayCraft Agent for project: ${config.projectId || 'default'}`);
223
- await logger.info(`Directory: ${config.dir}`);
224
- await logger.info(`Port: ${config.port}`);
225
- const fsHandler = new FSHandler(config);
226
- const app = createServer(config, fsHandler);
227
- const server = http.createServer(app);
228
- // Connection status tracking for daemon mode
229
- let lastConnectionCount = 0;
230
- const socketServer = new SocketServer(server, config, async (count) => {
231
- if (count > lastConnectionCount) {
232
- await logger.info(`Editor connected (${count} connection(s))`);
233
- }
234
- else if (count < lastConnectionCount) {
235
- await logger.info(count === 0 ? 'Editor disconnected' : `Connection decreased (${count} remaining)`);
236
- }
237
- lastConnectionCount = count;
238
- });
239
- const watcher = new Watcher(config, async (filePath, type) => {
240
- await logger.info(`[${type.toUpperCase()}] ${filePath}`);
241
- socketServer.notifyFileChange(filePath, type);
242
- });
243
- server.listen(config.port, async () => {
244
- await logger.info(`Local server running at http://localhost:${config.port}`);
245
- });
154
+ const agent = new PlayCraftAgent(config, isDaemon);
155
+ await agent.start();
246
156
  // 保存 PID
247
157
  await ProcessManager.savePid(config.projectId || 'default', process.pid);
248
- // 优雅关闭
249
158
  const shutdown = async () => {
250
- await logger.info('Shutting down agent...');
251
- // Set a timeout to force exit
252
- const forceExitTimeout = setTimeout(() => {
253
- process.exit(1);
254
- }, 3000);
255
159
  try {
256
- // Close watcher
257
- await watcher.close();
258
- // Close WebSocket connections
259
- socketServer.destroy();
260
- // Close HTTP server
261
- await new Promise((resolve) => {
262
- server.close(() => {
263
- resolve();
264
- });
265
- setTimeout(() => {
266
- resolve();
267
- }, 1000);
268
- });
269
- // Remove PID file
160
+ await agent.stop();
161
+ }
162
+ finally {
270
163
  await ProcessManager.removePid(config.projectId || 'default');
271
- // Close logger
272
- await logger.close();
273
- clearTimeout(forceExitTimeout);
274
164
  process.exit(0);
275
165
  }
276
- catch (error) {
277
- clearTimeout(forceExitTimeout);
278
- await logger.error(`Error during shutdown: ${error}`);
279
- process.exit(1);
280
- }
281
166
  };
282
167
  process.on('SIGINT', shutdown);
283
168
  process.on('SIGTERM', shutdown);
@@ -0,0 +1,40 @@
1
+ import { loadConfig } from '../config.js';
2
+ import { createSyncEngineFromConfig } from '../sync/sync-engine.js';
3
+ import pc from 'picocolors';
4
+ export async function syncCommand(action, options) {
5
+ const config = await loadConfig({});
6
+ if (config.mode !== 'hybrid') {
7
+ console.log(pc.yellow('Sync 命令仅在 hybrid 模式下可用'));
8
+ console.log(pc.dim('当前模式: ' + (config.mode || 'full-local')));
9
+ console.log(pc.dim('请设置 mode: "hybrid" 并配置 url 和 token'));
10
+ process.exit(1);
11
+ }
12
+ if (!config.url || !config.token || !config.projectId) {
13
+ console.error(pc.red('hybrid 模式需要配置 url、token 和 projectId'));
14
+ console.log(pc.dim('请在 playcraft.agent.config.json 或环境变量中设置'));
15
+ process.exit(1);
16
+ }
17
+ try {
18
+ const syncEngine = await createSyncEngineFromConfig();
19
+ switch (action) {
20
+ case 'push':
21
+ await syncEngine.push(options);
22
+ break;
23
+ case 'pull':
24
+ await syncEngine.pull(options);
25
+ break;
26
+ case 'status':
27
+ await syncEngine.status();
28
+ break;
29
+ default:
30
+ console.error(pc.red(`未知操作: ${action}`));
31
+ console.log(pc.yellow('可用: push, pull, status'));
32
+ process.exit(1);
33
+ }
34
+ }
35
+ catch (err) {
36
+ const message = err instanceof Error ? err.message : String(err);
37
+ console.error(pc.red(message));
38
+ process.exit(1);
39
+ }
40
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * upgrade 命令
3
+ * 检查并更新 CLI 到最新版本
4
+ */
5
+ import { readFileSync } from 'fs';
6
+ import { fileURLToPath } from 'url';
7
+ import { dirname, join } from 'path';
8
+ import pc from 'picocolors';
9
+ import ora from 'ora';
10
+ import { getLatestVersion } from '../utils/version-checker.js';
11
+ import { promptForUpdate } from '../utils/updater.js';
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+ /**
15
+ * 获取 package.json
16
+ */
17
+ function getPackageJson() {
18
+ return JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8'));
19
+ }
20
+ /**
21
+ * upgrade 命令处理函数
22
+ * @param options 命令选项
23
+ */
24
+ export async function upgradeCommand(options) {
25
+ const packageJson = getPackageJson();
26
+ const currentVersion = packageJson.version;
27
+ console.log(pc.bold('\n📦 PlayCraft CLI 版本检查\n'));
28
+ console.log(`当前版本: ${pc.cyan(currentVersion)}`);
29
+ try {
30
+ const spinner = ora('正在检查最新版本...').start();
31
+ const latestVersion = await getLatestVersion(packageJson);
32
+ spinner.stop();
33
+ console.log(`最新版本: ${pc.green(latestVersion)}`);
34
+ if (currentVersion === latestVersion) {
35
+ console.log(pc.green('\n✓ 已是最新版本!\n'));
36
+ return;
37
+ }
38
+ // 比较版本号
39
+ const [currentMajor, currentMinor, currentPatch] = currentVersion.split('.').map(Number);
40
+ const [latestMajor, latestMinor, latestPatch] = latestVersion.split('.').map(Number);
41
+ let updateType = 'patch';
42
+ if (latestMajor > currentMajor) {
43
+ updateType = 'major';
44
+ }
45
+ else if (latestMinor > currentMinor) {
46
+ updateType = 'minor';
47
+ }
48
+ const typeLabels = {
49
+ major: '主版本',
50
+ minor: '次版本',
51
+ patch: '补丁版本',
52
+ };
53
+ console.log(pc.yellow(`\n⚠ 有新版本可用 (${typeLabels[updateType]}更新)\n`));
54
+ if (options.checkOnly) {
55
+ console.log(pc.yellow(`运行 ${pc.bold('playcraft upgrade')} 来更新到最新版本\n`));
56
+ return;
57
+ }
58
+ // 触发交互式更新
59
+ await promptForUpdate({
60
+ latest: latestVersion,
61
+ current: currentVersion,
62
+ type: updateType,
63
+ name: packageJson.name,
64
+ }, currentVersion);
65
+ }
66
+ catch (error) {
67
+ console.error(pc.red(`\n✗ 检查版本失败: ${error instanceof Error ? error.message : String(error)}\n`));
68
+ console.error(pc.gray('提示: 请检查网络连接或稍后重试\n'));
69
+ process.exit(1);
70
+ }
71
+ }