@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.
- package/dist/agent/agent.js +202 -0
- package/dist/agent/api-proxy.js +68 -0
- package/dist/agent/cloud-connection.js +233 -0
- package/dist/agent/cloud-connection.test.js +67 -0
- package/dist/agent/fs-backend.js +158 -0
- package/dist/agent/local-backend.js +359 -0
- package/dist/agent/local-backend.test.js +52 -0
- package/dist/commands/build.js +167 -46
- package/dist/commands/fix-ids.js +43 -0
- package/dist/commands/start.js +14 -129
- package/dist/commands/sync.js +40 -0
- package/dist/commands/upgrade.js +71 -0
- package/dist/config.js +2 -0
- package/dist/fs-handler.js +21 -0
- package/dist/index.js +51 -0
- package/dist/server.js +28 -0
- package/dist/socket.js +80 -21
- package/dist/sync/sync-engine.js +213 -0
- package/dist/sync/sync-manager.js +62 -0
- package/dist/sync/sync-manager.test.js +80 -0
- package/dist/utils/package-manager.js +37 -0
- package/dist/utils/updater.js +89 -0
- package/dist/utils/version-checker.js +84 -0
- package/package.json +11 -3
package/dist/commands/build.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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 =
|
|
364
|
-
options.format =
|
|
365
|
-
options.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
|
-
|
|
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
|
-
|
|
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
|
|
514
|
-
|
|
629
|
+
catch {
|
|
630
|
+
// 报告文件不存在(例如从官方构建产物直接复制时),这是正常的
|
|
515
631
|
}
|
|
516
632
|
const analysis = await analyzeBaseBuild(baseBuildDir, buildOptions.ammoReplacement);
|
|
517
633
|
console.log('\n' + pc.bold('分析报告:'));
|
|
518
|
-
|
|
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.
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
+
}
|
package/dist/commands/start.js
CHANGED
|
@@ -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
|
|
130
|
-
await
|
|
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
|
-
|
|
168
|
-
|
|
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
|
|
221
|
-
await
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
+
}
|