@playcraft/cli 0.0.1
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/README.md +12 -0
- package/dist/build-config.js +26 -0
- package/dist/commands/build.js +363 -0
- package/dist/commands/config.js +133 -0
- package/dist/commands/init.js +86 -0
- package/dist/commands/inspect.js +209 -0
- package/dist/commands/logs.js +121 -0
- package/dist/commands/start.js +284 -0
- package/dist/commands/status.js +106 -0
- package/dist/commands/stop.js +58 -0
- package/dist/config.js +31 -0
- package/dist/fs-handler.js +83 -0
- package/dist/index.js +200 -0
- package/dist/logger.js +122 -0
- package/dist/playable/base-builder.js +265 -0
- package/dist/playable/builder.js +1462 -0
- package/dist/playable/converter.js +150 -0
- package/dist/playable/index.js +3 -0
- package/dist/playable/platforms/base.js +12 -0
- package/dist/playable/platforms/facebook.js +37 -0
- package/dist/playable/platforms/index.js +24 -0
- package/dist/playable/platforms/snapchat.js +59 -0
- package/dist/playable/playable-builder.js +521 -0
- package/dist/playable/types.js +1 -0
- package/dist/playable/vite/config-builder.js +136 -0
- package/dist/playable/vite/platform-configs.js +102 -0
- package/dist/playable/vite/plugin-model-compression.js +63 -0
- package/dist/playable/vite/plugin-platform.js +65 -0
- package/dist/playable/vite/plugin-playcanvas.js +454 -0
- package/dist/playable/vite-builder.js +125 -0
- package/dist/port-utils.js +27 -0
- package/dist/process-manager.js +96 -0
- package/dist/server.js +128 -0
- package/dist/socket.js +117 -0
- package/dist/watcher.js +33 -0
- package/package.json +41 -0
- package/templates/playable-ad.html +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
export async function loadBuildConfigFromFile(configPath) {
|
|
4
|
+
const content = await fs.readFile(configPath, 'utf-8');
|
|
5
|
+
const parsed = JSON.parse(content);
|
|
6
|
+
if (parsed && typeof parsed === 'object' && 'build' in parsed) {
|
|
7
|
+
return parsed.build || {};
|
|
8
|
+
}
|
|
9
|
+
return parsed || {};
|
|
10
|
+
}
|
|
11
|
+
export async function loadBuildConfig(options) {
|
|
12
|
+
if (options.configPath) {
|
|
13
|
+
return loadBuildConfigFromFile(path.resolve(options.configPath));
|
|
14
|
+
}
|
|
15
|
+
if (options.projectPath) {
|
|
16
|
+
const unifiedPath = path.join(path.resolve(options.projectPath), 'playcraft.config.json');
|
|
17
|
+
try {
|
|
18
|
+
await fs.access(unifiedPath);
|
|
19
|
+
return loadBuildConfigFromFile(unifiedPath);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import { BaseBuilder, ViteBuilder, PlayableBuilder } from '@playcraft/build';
|
|
7
|
+
import { loadBuildConfig } from '../build-config.js';
|
|
8
|
+
import inquirer from 'inquirer';
|
|
9
|
+
/**
|
|
10
|
+
* 检测是否是基础构建产物(多文件版本)
|
|
11
|
+
*/
|
|
12
|
+
async function detectBaseBuild(dir) {
|
|
13
|
+
const indicators = [
|
|
14
|
+
path.join(dir, 'index.html'),
|
|
15
|
+
path.join(dir, 'config.json'),
|
|
16
|
+
path.join(dir, '__start__.js'),
|
|
17
|
+
];
|
|
18
|
+
try {
|
|
19
|
+
await fs.access(indicators[0]);
|
|
20
|
+
await fs.access(indicators[1]);
|
|
21
|
+
await fs.access(indicators[2]);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function resolveSuggestedPhysicsEngine(use3dPhysics) {
|
|
29
|
+
return use3dPhysics ? 'cannon' : 'p2';
|
|
30
|
+
}
|
|
31
|
+
async function detectAmmoUsage(baseBuildDir) {
|
|
32
|
+
const configPath = path.join(baseBuildDir, 'config.json');
|
|
33
|
+
let hasAmmo = false;
|
|
34
|
+
let use3dPhysics = false;
|
|
35
|
+
try {
|
|
36
|
+
const configContent = await fs.readFile(configPath, 'utf-8');
|
|
37
|
+
const configJson = JSON.parse(configContent);
|
|
38
|
+
use3dPhysics = Boolean(configJson.application_properties?.use3dPhysics);
|
|
39
|
+
if (configJson.assets) {
|
|
40
|
+
for (const asset of Object.values(configJson.assets)) {
|
|
41
|
+
const assetData = asset;
|
|
42
|
+
const moduleName = assetData?.data?.moduleName?.toLowerCase?.() || '';
|
|
43
|
+
const fileUrl = assetData?.file?.url?.toLowerCase?.() || '';
|
|
44
|
+
if (moduleName.includes('ammo') || fileUrl.includes('ammo')) {
|
|
45
|
+
hasAmmo = true;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
// 忽略解析失败
|
|
53
|
+
}
|
|
54
|
+
if (!hasAmmo) {
|
|
55
|
+
try {
|
|
56
|
+
const settingsContent = await fs.readFile(path.join(baseBuildDir, '__settings__.js'), 'utf-8');
|
|
57
|
+
if (settingsContent.toLowerCase().includes('ammo')) {
|
|
58
|
+
hasAmmo = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
// 忽略读取失败
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
hasAmmo,
|
|
67
|
+
suggestedEngine: resolveSuggestedPhysicsEngine(use3dPhysics),
|
|
68
|
+
use3dPhysics,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async function analyzeBaseBuild(baseBuildDir, ammoReplacement) {
|
|
72
|
+
const assetSizes = [];
|
|
73
|
+
let engineSize = 0;
|
|
74
|
+
const ammoCheck = await detectAmmoUsage(baseBuildDir);
|
|
75
|
+
const engineCandidates = [
|
|
76
|
+
'playcanvas-stable.min.js',
|
|
77
|
+
'playcanvas.min.js',
|
|
78
|
+
'__lib__.js',
|
|
79
|
+
];
|
|
80
|
+
for (const name of engineCandidates) {
|
|
81
|
+
try {
|
|
82
|
+
const stats = await fs.stat(path.join(baseBuildDir, name));
|
|
83
|
+
engineSize = stats.size;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
// ignore
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const configContent = await fs.readFile(path.join(baseBuildDir, 'config.json'), 'utf-8');
|
|
92
|
+
const configJson = JSON.parse(configContent);
|
|
93
|
+
if (configJson.assets) {
|
|
94
|
+
for (const asset of Object.values(configJson.assets)) {
|
|
95
|
+
const assetData = asset;
|
|
96
|
+
const url = assetData?.file?.url;
|
|
97
|
+
if (!url || typeof url !== 'string' || url.startsWith('data:')) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const cleanUrl = url.split('?')[0];
|
|
101
|
+
const lowerUrl = cleanUrl.toLowerCase();
|
|
102
|
+
if (ammoReplacement && lowerUrl.includes('ammo')) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const assetPath = path.join(baseBuildDir, cleanUrl);
|
|
106
|
+
try {
|
|
107
|
+
const stats = await fs.stat(assetPath);
|
|
108
|
+
assetSizes.push({ path: cleanUrl, size: stats.size });
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
// ignore missing assets
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// ignore
|
|
118
|
+
}
|
|
119
|
+
assetSizes.sort((a, b) => b.size - a.size);
|
|
120
|
+
return {
|
|
121
|
+
engineSize,
|
|
122
|
+
topAssets: assetSizes.slice(0, 10),
|
|
123
|
+
hasAmmo: ammoReplacement ? false : ammoCheck.hasAmmo,
|
|
124
|
+
suggestedEngine: ammoCheck.suggestedEngine,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export async function buildCommand(projectPath, options) {
|
|
128
|
+
const spinner = ora(pc.cyan('🔨 开始打包 Playable Ad...')).start();
|
|
129
|
+
try {
|
|
130
|
+
// 解析项目路径
|
|
131
|
+
const resolvedProjectPath = path.resolve(projectPath);
|
|
132
|
+
if (!options.baseOnly && !options.mode) {
|
|
133
|
+
spinner.stop();
|
|
134
|
+
const modeAnswer = await inquirer.prompt([
|
|
135
|
+
{
|
|
136
|
+
type: 'list',
|
|
137
|
+
name: 'mode',
|
|
138
|
+
message: '选择构建模式:',
|
|
139
|
+
choices: [
|
|
140
|
+
{ name: '完整构建(Base Build + Channel Build)', value: 'full' },
|
|
141
|
+
{ name: '仅 Base Build(多文件产物)', value: 'base' },
|
|
142
|
+
{ name: '仅 Channel Build(从多文件产物生成 Playable)', value: 'playable' },
|
|
143
|
+
],
|
|
144
|
+
default: 'full',
|
|
145
|
+
},
|
|
146
|
+
]);
|
|
147
|
+
options.mode = modeAnswer.mode;
|
|
148
|
+
spinner.start(pc.cyan('🔨 开始打包 Playable Ad...'));
|
|
149
|
+
}
|
|
150
|
+
const fileConfig = await loadBuildConfig({
|
|
151
|
+
configPath: options.config,
|
|
152
|
+
projectPath: resolvedProjectPath,
|
|
153
|
+
});
|
|
154
|
+
if (fileConfig) {
|
|
155
|
+
if (!options.platform && fileConfig.platform) {
|
|
156
|
+
options.platform = fileConfig.platform;
|
|
157
|
+
}
|
|
158
|
+
if (!options.format && fileConfig.format) {
|
|
159
|
+
options.format = fileConfig.format;
|
|
160
|
+
}
|
|
161
|
+
if (!options.output && fileConfig.outputDir) {
|
|
162
|
+
options.output = fileConfig.outputDir;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// 如果只执行基础构建
|
|
166
|
+
if (options.baseOnly || options.mode === 'base') {
|
|
167
|
+
spinner.text = pc.cyan('执行阶段1: 基础构建...');
|
|
168
|
+
const baseBuilder = new BaseBuilder(resolvedProjectPath, {
|
|
169
|
+
outputDir: path.resolve(options.output || './build'),
|
|
170
|
+
});
|
|
171
|
+
const baseBuild = await baseBuilder.build();
|
|
172
|
+
spinner.succeed(pc.green('✅ 基础构建完成!'));
|
|
173
|
+
console.log(pc.green(`输出目录: ${baseBuild.outputDir}`));
|
|
174
|
+
console.log(pc.dim('💡 提示: 可以在浏览器中打开 index.html 测试游戏'));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// 完整流程:阶段1 + 阶段2
|
|
178
|
+
const shouldRunPlayableOnly = options.mode === 'playable';
|
|
179
|
+
// 验证平台
|
|
180
|
+
const validPlatforms = [
|
|
181
|
+
'facebook',
|
|
182
|
+
'snapchat',
|
|
183
|
+
'ironsource',
|
|
184
|
+
'applovin',
|
|
185
|
+
'google',
|
|
186
|
+
'tiktok',
|
|
187
|
+
'unity',
|
|
188
|
+
'liftoff',
|
|
189
|
+
'moloco',
|
|
190
|
+
'bigo',
|
|
191
|
+
];
|
|
192
|
+
if (!options.platform || !validPlatforms.includes(options.platform)) {
|
|
193
|
+
spinner.stop();
|
|
194
|
+
const answers = await inquirer.prompt([
|
|
195
|
+
{
|
|
196
|
+
type: 'list',
|
|
197
|
+
name: 'platform',
|
|
198
|
+
message: '选择目标平台:',
|
|
199
|
+
choices: validPlatforms,
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
type: 'list',
|
|
203
|
+
name: 'format',
|
|
204
|
+
message: '选择输出格式:',
|
|
205
|
+
choices: [
|
|
206
|
+
{ name: 'HTML(单文件)', value: 'html' },
|
|
207
|
+
{ name: 'ZIP(多文件)', value: 'zip' },
|
|
208
|
+
],
|
|
209
|
+
default: 'html',
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
type: 'input',
|
|
213
|
+
name: 'output',
|
|
214
|
+
message: '输出目录:',
|
|
215
|
+
default: options.output || './dist',
|
|
216
|
+
},
|
|
217
|
+
]);
|
|
218
|
+
options.platform = answers.platform;
|
|
219
|
+
options.format = answers.format;
|
|
220
|
+
options.output = answers.output;
|
|
221
|
+
spinner.start(pc.cyan('🔨 开始打包 Playable Ad...'));
|
|
222
|
+
}
|
|
223
|
+
// 解析配置
|
|
224
|
+
const buildOptions = {
|
|
225
|
+
platform: options.platform,
|
|
226
|
+
format: options.format || 'html',
|
|
227
|
+
outputDir: path.resolve(options.output || './dist'),
|
|
228
|
+
// 如果命令行指定了 --compress,则使用命令行参数;否则让平台配置生效
|
|
229
|
+
compressEngine: options.compress,
|
|
230
|
+
analyze: options.analyze || false,
|
|
231
|
+
// Vite 构建选项
|
|
232
|
+
useVite: options.useVite !== false, // 默认使用 Vite
|
|
233
|
+
// 压缩选项
|
|
234
|
+
cssMinify: options.cssMinify,
|
|
235
|
+
jsMinify: options.jsMinify,
|
|
236
|
+
compressImages: options.compressImages,
|
|
237
|
+
imageQuality: options.imageQuality,
|
|
238
|
+
convertToWebP: options.convertToWebP,
|
|
239
|
+
compressModels: options.compressModels,
|
|
240
|
+
modelCompression: options.modelCompression,
|
|
241
|
+
};
|
|
242
|
+
if (fileConfig) {
|
|
243
|
+
Object.assign(buildOptions, fileConfig);
|
|
244
|
+
}
|
|
245
|
+
// 1. 检测输入类型
|
|
246
|
+
spinner.text = pc.cyan('检测项目类型...');
|
|
247
|
+
const isBaseBuild = await detectBaseBuild(resolvedProjectPath);
|
|
248
|
+
let baseBuildDir;
|
|
249
|
+
if (shouldRunPlayableOnly) {
|
|
250
|
+
if (!isBaseBuild) {
|
|
251
|
+
throw new Error('当前目录不是多文件构建产物,请先运行完整构建或使用 build-base 生成。');
|
|
252
|
+
}
|
|
253
|
+
spinner.text = pc.cyan('✅ 使用多文件构建产物,直接执行阶段2');
|
|
254
|
+
baseBuildDir = resolvedProjectPath;
|
|
255
|
+
}
|
|
256
|
+
else if (isBaseBuild) {
|
|
257
|
+
// 输入已经是多文件构建产物,直接使用
|
|
258
|
+
spinner.text = pc.cyan('✅ 检测到多文件构建产物,跳过阶段1');
|
|
259
|
+
baseBuildDir = resolvedProjectPath;
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
// 输入是源代码或官方构建,先执行阶段1
|
|
263
|
+
spinner.text = pc.cyan('执行阶段1: 基础构建...');
|
|
264
|
+
const tempDir = path.join(os.tmpdir(), `playcraft-base-build-${Date.now()}`);
|
|
265
|
+
const baseBuilder = new BaseBuilder(resolvedProjectPath, {
|
|
266
|
+
outputDir: tempDir,
|
|
267
|
+
});
|
|
268
|
+
const baseBuild = await baseBuilder.build();
|
|
269
|
+
baseBuildDir = baseBuild.outputDir;
|
|
270
|
+
spinner.text = pc.cyan('✅ 阶段1完成,开始阶段2...');
|
|
271
|
+
}
|
|
272
|
+
const ammoCheck = await detectAmmoUsage(baseBuildDir);
|
|
273
|
+
const requestedAmmoEngine = options.ammoEngine;
|
|
274
|
+
if (requestedAmmoEngine === 'p2' || requestedAmmoEngine === 'cannon') {
|
|
275
|
+
buildOptions.ammoReplacement = requestedAmmoEngine;
|
|
276
|
+
}
|
|
277
|
+
else if (options.replaceAmmo && ammoCheck.hasAmmo && ammoCheck.suggestedEngine) {
|
|
278
|
+
buildOptions.ammoReplacement = ammoCheck.suggestedEngine;
|
|
279
|
+
}
|
|
280
|
+
else if (ammoCheck.hasAmmo && ammoCheck.suggestedEngine && !buildOptions.ammoReplacement) {
|
|
281
|
+
spinner.stop();
|
|
282
|
+
const replaceAnswer = await inquirer.prompt([
|
|
283
|
+
{
|
|
284
|
+
type: 'confirm',
|
|
285
|
+
name: 'replaceAmmo',
|
|
286
|
+
message: `检测到 Ammo.js,是否替换为 ${ammoCheck.suggestedEngine} 并打包内置?`,
|
|
287
|
+
default: false,
|
|
288
|
+
},
|
|
289
|
+
]);
|
|
290
|
+
if (replaceAnswer.replaceAmmo) {
|
|
291
|
+
buildOptions.ammoReplacement = ammoCheck.suggestedEngine;
|
|
292
|
+
}
|
|
293
|
+
spinner.start(pc.cyan('🔨 开始打包 Playable Ad...'));
|
|
294
|
+
}
|
|
295
|
+
// 2. 执行阶段2:转换为Playable Ads
|
|
296
|
+
spinner.text = pc.cyan('执行阶段2: 转换为单HTML Playable Ads...');
|
|
297
|
+
let outputPath;
|
|
298
|
+
let sizeReport;
|
|
299
|
+
if (buildOptions.useVite !== false) {
|
|
300
|
+
// 使用 Vite 构建
|
|
301
|
+
const viteBuilder = new ViteBuilder(baseBuildDir, buildOptions);
|
|
302
|
+
outputPath = await viteBuilder.build();
|
|
303
|
+
sizeReport = viteBuilder.getSizeReport();
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
// 使用旧的 PlayableBuilder(向后兼容)
|
|
307
|
+
const playableBuilder = new PlayableBuilder(baseBuildDir, buildOptions);
|
|
308
|
+
outputPath = await playableBuilder.build();
|
|
309
|
+
sizeReport = playableBuilder.getSizeReport();
|
|
310
|
+
}
|
|
311
|
+
spinner.succeed(pc.green('📦 打包完成!'));
|
|
312
|
+
// 显示文件大小报告
|
|
313
|
+
console.log('\n' + pc.bold('文件大小报告:'));
|
|
314
|
+
for (const [name, size] of Object.entries(sizeReport.assets)) {
|
|
315
|
+
const sizeMB = (size / 1024 / 1024).toFixed(2);
|
|
316
|
+
console.log(` - ${name}: ${sizeMB} MB`);
|
|
317
|
+
}
|
|
318
|
+
const totalMB = (sizeReport.total / 1024 / 1024).toFixed(2);
|
|
319
|
+
const limitMB = (sizeReport.limit / 1024 / 1024).toFixed(2);
|
|
320
|
+
const status = sizeReport.total <= sizeReport.limit ? pc.green('✅') : pc.red('❌');
|
|
321
|
+
console.log(` - 总计: ${totalMB} MB ${status} (限制: ${limitMB} MB)`);
|
|
322
|
+
console.log('\n' + pc.green(`输出: ${outputPath}`));
|
|
323
|
+
if (buildOptions.analyze) {
|
|
324
|
+
const reportPath = buildOptions.analyzeReportPath
|
|
325
|
+
? buildOptions.analyzeReportPath
|
|
326
|
+
: path.join(buildOptions.outputDir || './dist', 'bundle-report.html');
|
|
327
|
+
const analysis = await analyzeBaseBuild(baseBuildDir, buildOptions.ammoReplacement);
|
|
328
|
+
console.log('\n' + pc.bold('分析报告:'));
|
|
329
|
+
console.log(` - 可视化报告: ${reportPath}`);
|
|
330
|
+
if (analysis.engineSize > 0) {
|
|
331
|
+
const engineMB = (analysis.engineSize / 1024 / 1024).toFixed(2);
|
|
332
|
+
console.log(` - 引擎大小: ${engineMB} MB`);
|
|
333
|
+
}
|
|
334
|
+
if (analysis.topAssets.length > 0) {
|
|
335
|
+
console.log(' - 资源 Top:');
|
|
336
|
+
analysis.topAssets.forEach((asset) => {
|
|
337
|
+
const sizeMB = (asset.size / 1024 / 1024).toFixed(2);
|
|
338
|
+
console.log(` • ${asset.path}: ${sizeMB} MB`);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
if (analysis.hasAmmo) {
|
|
342
|
+
console.log(pc.yellow(` - 检测到 Ammo.js,建议替换为 ${analysis.suggestedEngine}`));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// 清理临时目录
|
|
346
|
+
if (!isBaseBuild && baseBuildDir.startsWith(os.tmpdir())) {
|
|
347
|
+
try {
|
|
348
|
+
await fs.rm(baseBuildDir, { recursive: true, force: true });
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
// 忽略清理错误
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
spinner.fail(pc.red('打包失败'));
|
|
357
|
+
console.error(pc.red(`错误: ${error.message}`));
|
|
358
|
+
if (error.stack) {
|
|
359
|
+
console.error(pc.dim(error.stack));
|
|
360
|
+
}
|
|
361
|
+
process.exit(1);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
export async function configCommand(action, options) {
|
|
7
|
+
switch (action) {
|
|
8
|
+
case 'get':
|
|
9
|
+
await getConfig(options);
|
|
10
|
+
break;
|
|
11
|
+
case 'set':
|
|
12
|
+
await setConfig(options);
|
|
13
|
+
break;
|
|
14
|
+
case 'list':
|
|
15
|
+
await listConfig(options);
|
|
16
|
+
break;
|
|
17
|
+
default:
|
|
18
|
+
console.error(pc.red(`未知操作: ${action}`));
|
|
19
|
+
console.log(pc.yellow('可用操作: get, set, list'));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function getConfig(options) {
|
|
24
|
+
const spinner = ora('读取配置...').start();
|
|
25
|
+
try {
|
|
26
|
+
const config = await loadConfig({
|
|
27
|
+
projectId: options.project,
|
|
28
|
+
});
|
|
29
|
+
spinner.succeed('配置读取成功');
|
|
30
|
+
if (options.key) {
|
|
31
|
+
// 获取特定键的值
|
|
32
|
+
const value = config[options.key];
|
|
33
|
+
if (value === undefined) {
|
|
34
|
+
console.log(pc.yellow(`配置项 ${options.key} 不存在`));
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
console.log(pc.cyan(`\n${options.key}: ${value}\n`));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// 显示所有配置
|
|
42
|
+
console.log(pc.cyan('\n📋 当前配置\n'));
|
|
43
|
+
console.log(JSON.stringify(config, null, 2));
|
|
44
|
+
console.log();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
spinner.fail('读取配置失败');
|
|
49
|
+
console.error(pc.red(`错误: ${error.message}`));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function setConfig(options) {
|
|
54
|
+
if (!options.key || !options.value) {
|
|
55
|
+
console.error(pc.red('请指定配置键和值'));
|
|
56
|
+
console.log(pc.yellow('用法: playcraft config set --key <key> --value <value>'));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
const spinner = ora('更新配置...').start();
|
|
60
|
+
try {
|
|
61
|
+
// 查找配置文件
|
|
62
|
+
const configPath = path.join(process.cwd(), 'playcraft.agent.config.json');
|
|
63
|
+
let config = {};
|
|
64
|
+
try {
|
|
65
|
+
const content = await fs.readFile(configPath, 'utf-8');
|
|
66
|
+
config = JSON.parse(content);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (error.code !== 'ENOENT') {
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
// 文件不存在,创建新配置
|
|
73
|
+
}
|
|
74
|
+
// 更新配置值
|
|
75
|
+
if (options.key === 'port') {
|
|
76
|
+
config.port = parseInt(options.value, 10);
|
|
77
|
+
if (isNaN(config.port)) {
|
|
78
|
+
throw new Error('端口必须是数字');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
config[options.key] = options.value;
|
|
83
|
+
}
|
|
84
|
+
// 保存配置
|
|
85
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
86
|
+
spinner.succeed('配置已更新');
|
|
87
|
+
console.log(pc.green(`✅ ${options.key} = ${options.value}\n`));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
spinner.fail('更新配置失败');
|
|
91
|
+
console.error(pc.red(`错误: ${error.message}`));
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function listConfig(options) {
|
|
96
|
+
const spinner = ora('读取配置...').start();
|
|
97
|
+
try {
|
|
98
|
+
const config = await loadConfig({
|
|
99
|
+
projectId: options.project,
|
|
100
|
+
});
|
|
101
|
+
spinner.succeed('配置读取成功');
|
|
102
|
+
console.log(pc.cyan('\n📋 配置列表\n'));
|
|
103
|
+
const configPath = path.join(process.cwd(), 'playcraft.agent.config.json');
|
|
104
|
+
let fileConfig = {};
|
|
105
|
+
try {
|
|
106
|
+
const content = await fs.readFile(configPath, 'utf-8');
|
|
107
|
+
fileConfig = JSON.parse(content);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
// 忽略文件读取错误
|
|
111
|
+
}
|
|
112
|
+
// 显示配置来源
|
|
113
|
+
console.log(`${pc.bold('配置文件:')} ${configPath}`);
|
|
114
|
+
console.log(`${pc.bold('项目 ID:')} ${config.projectId || pc.yellow('未设置')}`);
|
|
115
|
+
console.log(`${pc.bold('目录:')} ${config.dir}`);
|
|
116
|
+
console.log(`${pc.bold('端口:')} ${config.port}`);
|
|
117
|
+
if (config.token) {
|
|
118
|
+
console.log(`${pc.bold('令牌:')} ${pc.dim('***')}`);
|
|
119
|
+
}
|
|
120
|
+
console.log();
|
|
121
|
+
// 显示配置来源
|
|
122
|
+
if (Object.keys(fileConfig).length > 0) {
|
|
123
|
+
console.log(pc.dim('配置文件内容:'));
|
|
124
|
+
console.log(JSON.stringify(fileConfig, null, 2));
|
|
125
|
+
console.log();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
spinner.fail('读取配置失败');
|
|
130
|
+
console.error(pc.red(`错误: ${error.message}`));
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { findAvailablePort } from '../port-utils.js';
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
export async function initCommand(dir = process.cwd()) {
|
|
7
|
+
console.log(pc.cyan('\n🚀 PlayCraft Agent 初始化\n'));
|
|
8
|
+
const answers = await inquirer.prompt([
|
|
9
|
+
{
|
|
10
|
+
type: 'input',
|
|
11
|
+
name: 'projectId',
|
|
12
|
+
message: '请输入项目 ID:',
|
|
13
|
+
validate: (input) => {
|
|
14
|
+
if (!input.trim()) {
|
|
15
|
+
return '项目 ID 不能为空';
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
type: 'input',
|
|
22
|
+
name: 'dir',
|
|
23
|
+
message: '监听目录:',
|
|
24
|
+
default: dir,
|
|
25
|
+
validate: async (input) => {
|
|
26
|
+
try {
|
|
27
|
+
const resolved = path.resolve(input);
|
|
28
|
+
const stats = await fs.stat(resolved);
|
|
29
|
+
if (!stats.isDirectory()) {
|
|
30
|
+
return '路径不是一个目录';
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code === 'ENOENT') {
|
|
36
|
+
return '目录不存在';
|
|
37
|
+
}
|
|
38
|
+
return `无法访问目录: ${error.message}`;
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: 'input',
|
|
44
|
+
name: 'port',
|
|
45
|
+
message: '服务端口:',
|
|
46
|
+
default: '2468',
|
|
47
|
+
validate: async (input) => {
|
|
48
|
+
const port = parseInt(input, 10);
|
|
49
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
50
|
+
return '端口号必须是 1-65535 之间的数字';
|
|
51
|
+
}
|
|
52
|
+
const available = await findAvailablePort(port, 1).then(() => true).catch(() => false);
|
|
53
|
+
if (!available) {
|
|
54
|
+
return `端口 ${port} 已被占用,请选择其他端口`;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
type: 'input',
|
|
61
|
+
name: 'token',
|
|
62
|
+
message: '认证令牌 (可选):',
|
|
63
|
+
default: '',
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
const config = {
|
|
67
|
+
agent: {
|
|
68
|
+
projectId: answers.projectId,
|
|
69
|
+
dir: path.resolve(answers.dir),
|
|
70
|
+
port: parseInt(answers.port, 10),
|
|
71
|
+
...(answers.token && { token: answers.token }),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
const configPath = path.join(dir, 'playcraft.config.json');
|
|
75
|
+
try {
|
|
76
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
77
|
+
console.log(pc.green(`\n✅ 配置文件已创建: ${configPath}\n`));
|
|
78
|
+
console.log(pc.dim('配置内容:'));
|
|
79
|
+
console.log(JSON.stringify(config, null, 2));
|
|
80
|
+
console.log();
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
console.error(pc.red(`\n❌ 创建配置文件失败: ${error.message}\n`));
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
}
|