@playcraft/cli 0.0.51 → 0.0.52
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/REMIX.md +8 -5
- package/dist/commands/remix.js +362 -35
- package/dist/utils/playcraft-http-client.js +76 -36
- package/package.json +4 -3
package/REMIX.md
CHANGED
|
@@ -486,11 +486,14 @@ playcraft remix run
|
|
|
486
486
|
交互顺序:
|
|
487
487
|
|
|
488
488
|
1. 选择团队。
|
|
489
|
-
2.
|
|
490
|
-
3.
|
|
491
|
-
4.
|
|
492
|
-
5.
|
|
493
|
-
6.
|
|
489
|
+
2. 加载模板来源:有模板页权限时显示团队模板和当前团队的系统推荐;无权限时只显示当前团队的系统推荐。系统推荐与 Remix 画廊一致,由推荐 Prompt 模板和推荐 Remix 两部分组成。
|
|
490
|
+
3. 选择模板;输入 `back` 可返回团队列表。
|
|
491
|
+
4. 选择团队模板后加载它的分支,可选择 `main` 或已有 Remix;选择系统推荐时直接使用该推荐配置的基础模板和分支。输入 `back` 可返回模板列表。
|
|
492
|
+
5. CLI 按平台规则自动生成 Remix 名称并创建真实 Remix 分支。
|
|
493
|
+
6. 输入需求描述。
|
|
494
|
+
7. 有需求时启动沙箱并运行 Agent;直接回车或输入 `exit` 时只创建 Remix。
|
|
495
|
+
|
|
496
|
+
模板权限与网页 `/projects` 保持一致,仅系统管理员可以读取团队模板项目。CLI 只在服务端明确返回 `403 Forbidden` 时降级为仅系统推荐;网络错误或服务端异常会原样报错,不会误判成权限不足。
|
|
494
497
|
|
|
495
498
|
创建后留空 Prompt 的输出:
|
|
496
499
|
|
package/dist/commands/remix.js
CHANGED
|
@@ -1,15 +1,99 @@
|
|
|
1
1
|
import inquirer from 'inquirer';
|
|
2
|
+
import autocomplete from 'inquirer-autocomplete-prompt';
|
|
2
3
|
import { exec } from 'node:child_process';
|
|
3
4
|
import { readFileSync } from 'node:fs';
|
|
4
5
|
import { PlayCraftApiError, PlayCraftHttpClient, isPlayCraftApiError } from '../utils/playcraft-http-client.js';
|
|
5
6
|
import { IMAGE_OPTION_HELP, buildReferenceImagesPrompt, readImageAttachments, uploadImageAttachments, } from '../utils/remix-image-attachments.js';
|
|
6
7
|
import { initTemplateCommand } from '../remix/init-template.js';
|
|
8
|
+
// Register autocomplete prompt
|
|
9
|
+
inquirer.registerPrompt('autocomplete', autocomplete);
|
|
7
10
|
const JSON_OUTPUT_HELP = '输出一条 JSON (JavaScript Object Notation, JavaScript 对象表示法) 结果';
|
|
8
11
|
const SERVICE_ADDRESS_HELP = 'PlayCraft 服务地址';
|
|
9
12
|
const PAT_HELP = 'PAT (Personal Access Token, 个人访问令牌)';
|
|
13
|
+
const BACK_TO_TEAMS = '__back_to_teams__';
|
|
14
|
+
const BACK_TO_TEMPLATES = '__back_to_templates__';
|
|
15
|
+
const NO_MATCH = '__no_match__';
|
|
10
16
|
function emitProgress(options, message) {
|
|
11
17
|
(options.json ? process.stderr : process.stdout).write(`${message}\n`);
|
|
12
18
|
}
|
|
19
|
+
function shellQuote(value) {
|
|
20
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value))
|
|
21
|
+
return value;
|
|
22
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
23
|
+
}
|
|
24
|
+
function buildOneLineRetryCommand(command, options) {
|
|
25
|
+
const parts = ['playcraft'];
|
|
26
|
+
if (command === 'remix.start')
|
|
27
|
+
parts.push('remix', 'start');
|
|
28
|
+
else if (command === 'remix.prompt')
|
|
29
|
+
parts.push('remix', 'prompt');
|
|
30
|
+
else if (command === 'remix.run')
|
|
31
|
+
parts.push('remix', 'run');
|
|
32
|
+
else if (command === 'remix.create')
|
|
33
|
+
parts.push('remix', 'create');
|
|
34
|
+
else
|
|
35
|
+
return undefined;
|
|
36
|
+
const projectId = options.projectId != null ? String(options.projectId) : '';
|
|
37
|
+
const branch = options.branch != null ? String(options.branch) : '';
|
|
38
|
+
if (projectId)
|
|
39
|
+
parts.push('--project-id', shellQuote(projectId));
|
|
40
|
+
if (branch)
|
|
41
|
+
parts.push('--branch', shellQuote(branch));
|
|
42
|
+
if (options.sourceBranch)
|
|
43
|
+
parts.push('--source-branch', shellQuote(String(options.sourceBranch)));
|
|
44
|
+
if (options.teamId)
|
|
45
|
+
parts.push('--team-id', shellQuote(String(options.teamId)));
|
|
46
|
+
if (options.templateId)
|
|
47
|
+
parts.push('--template-id', shellQuote(String(options.templateId)));
|
|
48
|
+
if (options.name)
|
|
49
|
+
parts.push('--name', shellQuote(String(options.name)));
|
|
50
|
+
if (options.prompt)
|
|
51
|
+
parts.push('--prompt', shellQuote(String(options.prompt)));
|
|
52
|
+
if (options.promptFile)
|
|
53
|
+
parts.push('--prompt-file', shellQuote(String(options.promptFile)));
|
|
54
|
+
if (options.evictSlotId)
|
|
55
|
+
parts.push('--evict-slot-id', shellQuote(String(options.evictSlotId)));
|
|
56
|
+
if (options.evictConfirmed)
|
|
57
|
+
parts.push('--evict-confirmed');
|
|
58
|
+
if (options.open)
|
|
59
|
+
parts.push('--open');
|
|
60
|
+
if (options.wait === false)
|
|
61
|
+
parts.push('--no-wait');
|
|
62
|
+
if (options.url)
|
|
63
|
+
parts.push('--url', shellQuote(String(options.url)));
|
|
64
|
+
if (Array.isArray(options.images) && options.images.length) {
|
|
65
|
+
parts.push('--image', ...options.images.map((image) => shellQuote(String(image))));
|
|
66
|
+
}
|
|
67
|
+
return parts.join(' ');
|
|
68
|
+
}
|
|
69
|
+
function isNetworkLikeError(error) {
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71
|
+
return /网络请求失败|fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|ECONNREFUSED|socket hang up/i.test(message);
|
|
72
|
+
}
|
|
73
|
+
async function sleep(ms) {
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
75
|
+
}
|
|
76
|
+
async function fetchWithRetry(input, init, options = {}) {
|
|
77
|
+
const attempts = options.attempts ?? 3;
|
|
78
|
+
const delays = [1000, 2000, 4000];
|
|
79
|
+
let lastError;
|
|
80
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
81
|
+
try {
|
|
82
|
+
return await fetch(input, init);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
lastError = error;
|
|
86
|
+
if (attempt === attempts - 1)
|
|
87
|
+
break;
|
|
88
|
+
await sleep(delays[attempt] ?? 4000);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw new PlayCraftApiError({
|
|
92
|
+
code: 'AGENT_REQUEST_FAILED',
|
|
93
|
+
message: `网络请求失败(${lastError instanceof Error ? lastError.message : String(lastError)})。请稍后重试。`,
|
|
94
|
+
retryable: true,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
13
97
|
function formatIdleSec(idleSec) {
|
|
14
98
|
if (!idleSec || idleSec <= 0)
|
|
15
99
|
return '0s';
|
|
@@ -243,6 +327,97 @@ function parseProjectTemplates(payload) {
|
|
|
243
327
|
sourceBranch: project.settings?.git?.branch || 'main',
|
|
244
328
|
}));
|
|
245
329
|
}
|
|
330
|
+
export function parseRecommendedPromptTemplates(payload) {
|
|
331
|
+
const items = payload && typeof payload === 'object' && Array.isArray(payload.items)
|
|
332
|
+
? payload.items
|
|
333
|
+
: [];
|
|
334
|
+
return items.flatMap((item) => {
|
|
335
|
+
const projectId = item.baseProject?.numericId ?? item.baseProject?.id ?? item.baseProjectId;
|
|
336
|
+
const name = item.name?.trim();
|
|
337
|
+
if (!projectId || !name)
|
|
338
|
+
return [];
|
|
339
|
+
return [{
|
|
340
|
+
id: String(projectId),
|
|
341
|
+
uuid: item.baseProject?.id,
|
|
342
|
+
choiceValue: `recommended-prompt:${item.id ?? name}:${projectId}`,
|
|
343
|
+
name,
|
|
344
|
+
baseProjectName: item.baseProject?.name,
|
|
345
|
+
projectType: item.baseProject?.projectType,
|
|
346
|
+
thumbnailUrl: item.coverImageUrl ?? item.baseProject?.thumbnailUrl,
|
|
347
|
+
sourceBranch: 'main',
|
|
348
|
+
useExactSourceBranch: true,
|
|
349
|
+
source: 'system',
|
|
350
|
+
}];
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
export function parseGalleryTemplates(payload) {
|
|
354
|
+
const items = payload && typeof payload === 'object' && Array.isArray(payload.items)
|
|
355
|
+
? payload.items
|
|
356
|
+
: [];
|
|
357
|
+
return items.flatMap((item) => {
|
|
358
|
+
const projectId = item.projectId ?? item.template?.id;
|
|
359
|
+
const name = item.displayName?.trim() || item.template?.name?.trim();
|
|
360
|
+
if (projectId === undefined || !name)
|
|
361
|
+
return [];
|
|
362
|
+
const sourceBranch = item.isMain ? 'main' : item.branchName?.trim() || 'main';
|
|
363
|
+
return [{
|
|
364
|
+
id: String(projectId),
|
|
365
|
+
uuid: item.projectUuid,
|
|
366
|
+
choiceValue: `recommended-remix:${item.id ?? `${projectId}/${sourceBranch}`}`,
|
|
367
|
+
name,
|
|
368
|
+
baseProjectName: item.template?.name,
|
|
369
|
+
projectType: item.projectType,
|
|
370
|
+
thumbnailUrl: item.thumbnailUrl,
|
|
371
|
+
sourceBranch,
|
|
372
|
+
useExactSourceBranch: true,
|
|
373
|
+
source: 'system',
|
|
374
|
+
}];
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
export function buildTemplateChoices(templates) {
|
|
378
|
+
return templates.map((template) => {
|
|
379
|
+
const sourceLabel = template.source === 'system' ? '[系统推荐]' : '[团队模板]';
|
|
380
|
+
const baseLabel = template.baseProjectName && template.baseProjectName !== template.name
|
|
381
|
+
? ` (模板: ${template.baseProjectName}, ID: ${template.id})`
|
|
382
|
+
: ` (ID: ${template.id})`;
|
|
383
|
+
return {
|
|
384
|
+
name: `${sourceLabel} ${template.name}${baseLabel}`,
|
|
385
|
+
value: template.choiceValue ?? template.id,
|
|
386
|
+
};
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
export function isTemplateProjectAccessDenied(error) {
|
|
390
|
+
return isPlayCraftApiError(error) && (error.status === 403 || error.code === 'FORBIDDEN');
|
|
391
|
+
}
|
|
392
|
+
export function buildRemixBranchChoices(payload, mainBranch) {
|
|
393
|
+
const seen = new Set([mainBranch]);
|
|
394
|
+
const remixChoices = parseList(payload).flatMap((branch) => {
|
|
395
|
+
const branchName = branch.id?.trim();
|
|
396
|
+
if (!branchName || seen.has(branchName) || branchName === 'main' || branchName === 'master') {
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
seen.add(branchName);
|
|
400
|
+
return [{
|
|
401
|
+
name: `[Remix] ${branch.name?.trim() || branchName} (${branchName})`,
|
|
402
|
+
value: branchName,
|
|
403
|
+
}];
|
|
404
|
+
});
|
|
405
|
+
return [
|
|
406
|
+
{ name: '[返回] 重新选择模板(输入 back 快速定位)', value: BACK_TO_TEMPLATES },
|
|
407
|
+
{ name: `${mainBranch} (从模板主分支创建新 Remix)`, value: mainBranch },
|
|
408
|
+
...remixChoices,
|
|
409
|
+
];
|
|
410
|
+
}
|
|
411
|
+
function filterPromptChoices(choices, input) {
|
|
412
|
+
const rawQuery = input?.trim().toLowerCase() || '';
|
|
413
|
+
const query = rawQuery === 'back' ? '返回' : rawQuery;
|
|
414
|
+
if (!query)
|
|
415
|
+
return choices;
|
|
416
|
+
const filtered = choices.filter((choice) => choice.name.toLowerCase().includes(query) || choice.value.toLowerCase().includes(query));
|
|
417
|
+
return filtered.length
|
|
418
|
+
? filtered
|
|
419
|
+
: [{ name: '[无匹配] 请按 Backspace 删除关键词,或输入 back 返回', value: NO_MATCH }];
|
|
420
|
+
}
|
|
246
421
|
function readPrompt(options) {
|
|
247
422
|
if (options.prompt && options.promptFile) {
|
|
248
423
|
throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '--prompt 和 --prompt-file 只能使用一个。' });
|
|
@@ -293,12 +468,27 @@ function handleCommandError(command, options, client, error) {
|
|
|
293
468
|
}
|
|
294
469
|
else {
|
|
295
470
|
const code = isPlayCraftApiError(error) ? error.code : undefined;
|
|
296
|
-
|
|
471
|
+
let message = code === 'AUTH_REQUIRED'
|
|
297
472
|
? '需要登录。请先运行 `playcraft login`,或提供 PLAYCRAFT_TOKEN。'
|
|
298
473
|
: code === 'AUTH_EXPIRED'
|
|
299
474
|
? '登录已过期,请重新运行 `playcraft login`。'
|
|
300
475
|
: error instanceof Error ? error.message : String(error);
|
|
301
|
-
|
|
476
|
+
// 把裸的 fetch failed 转成可操作的中文提示
|
|
477
|
+
if (message === 'fetch failed' || /^TypeError: fetch failed$/i.test(message)) {
|
|
478
|
+
message = '网络请求失败(fetch failed)。请稍后重试。';
|
|
479
|
+
}
|
|
480
|
+
const retryable = isPlayCraftApiError(error) ? error.retryable : isNetworkLikeError(error);
|
|
481
|
+
const retryCommand = (retryable || isNetworkLikeError(error))
|
|
482
|
+
? buildOneLineRetryCommand(command, options)
|
|
483
|
+
: undefined;
|
|
484
|
+
if (retryCommand) {
|
|
485
|
+
console.error(`错误:${message}`);
|
|
486
|
+
console.error('请重试:');
|
|
487
|
+
console.error(retryCommand);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
console.error(`错误:${message}`);
|
|
491
|
+
}
|
|
302
492
|
}
|
|
303
493
|
process.exitCode = exitCodeForError(error);
|
|
304
494
|
}
|
|
@@ -332,7 +522,7 @@ async function remixTemplates(options) {
|
|
|
332
522
|
if (!options.teamId)
|
|
333
523
|
throw new PlayCraftApiError({ code: 'TEAM_NOT_FOUND', message: '必须提供 --team-id。' });
|
|
334
524
|
client = createRemixClient(options);
|
|
335
|
-
const templates = parseProjectTemplates(await client.get('/projects', {
|
|
525
|
+
const templates = parseProjectTemplates(await client.get('/projects/templates', {
|
|
336
526
|
teamId: options.teamId,
|
|
337
527
|
limit: '100',
|
|
338
528
|
skip: '0',
|
|
@@ -394,9 +584,9 @@ async function remixEnsureStream(options) {
|
|
|
394
584
|
if (options.evictSlotId)
|
|
395
585
|
endpoint.searchParams.set('evictSlotId', options.evictSlotId);
|
|
396
586
|
emitProgress(options, '[连接中] 正在连接沙箱服务...');
|
|
397
|
-
const response = await
|
|
587
|
+
const response = await fetchWithRetry(endpoint, {
|
|
398
588
|
headers: { Authorization: `Bearer ${client.token}`, Accept: 'text/event-stream' },
|
|
399
|
-
});
|
|
589
|
+
}, { label: 'ensure-stream' });
|
|
400
590
|
if (!response.ok || !response.body) {
|
|
401
591
|
throw new PlayCraftApiError({ code: response.status === 401 ? 'AUTH_EXPIRED' : 'AGENT_REQUEST_FAILED', message: `启动沙箱失败,状态码:${response.status}`, status: response.status, retryable: response.status >= 500 });
|
|
402
592
|
}
|
|
@@ -519,7 +709,7 @@ async function waitForAgentIdle(client, ready, timeoutMs, options, handledQuesti
|
|
|
519
709
|
const controller = new AbortController();
|
|
520
710
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
521
711
|
try {
|
|
522
|
-
const response = await
|
|
712
|
+
const response = await fetchWithRetry(client.url(path), { headers: { Authorization: `Bearer ${client.token}`, Accept: 'text/event-stream' }, signal: controller.signal }, { label: 'agent-event-stream' });
|
|
523
713
|
if (!response.ok || !response.body)
|
|
524
714
|
throw new Error(`Agent 事件流连接失败,状态码:${response.status}`);
|
|
525
715
|
const reader = response.body.getReader();
|
|
@@ -717,37 +907,144 @@ async function remixRun(options) {
|
|
|
717
907
|
throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '已有 Remix 模式的 --project-id/--branch 不能与 --team-id、--template-id 或 --name 同时使用。' });
|
|
718
908
|
}
|
|
719
909
|
if (!options.projectId) {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
910
|
+
let sourceBranch;
|
|
911
|
+
let teams;
|
|
912
|
+
while (!selectedTemplate || !sourceBranch) {
|
|
913
|
+
if (!teamId) {
|
|
914
|
+
if (!interactive)
|
|
915
|
+
throw new PlayCraftApiError({ code: 'TEAM_NOT_FOUND', message: '非交互模式必须提供 --team-id。' });
|
|
916
|
+
teams ??= parseList(await client.get('/teams'));
|
|
917
|
+
if (!teams.length)
|
|
918
|
+
throw new PlayCraftApiError({ code: 'TEAM_NOT_FOUND', message: '当前登录账号没有可用团队。' });
|
|
919
|
+
teamId = (await inquirer.prompt([{
|
|
920
|
+
type: 'list',
|
|
921
|
+
name: 'teamId',
|
|
922
|
+
message: '请选择团队:',
|
|
923
|
+
choices: teams.map((team) => ({ name: team.name, value: team.id })),
|
|
924
|
+
}])).teamId;
|
|
925
|
+
}
|
|
926
|
+
let projectTemplates = [];
|
|
927
|
+
let recommendedPromptTemplates = [];
|
|
928
|
+
let galleryTemplates = [];
|
|
929
|
+
try {
|
|
930
|
+
const projectData = await client.get('/projects/templates', {
|
|
931
|
+
teamId,
|
|
932
|
+
limit: '100',
|
|
933
|
+
skip: '0',
|
|
934
|
+
});
|
|
935
|
+
projectTemplates = parseProjectTemplates(projectData)
|
|
936
|
+
.map((template) => ({ ...template, source: 'team' }));
|
|
937
|
+
}
|
|
938
|
+
catch (error) {
|
|
939
|
+
if (!isTemplateProjectAccessDenied(error))
|
|
940
|
+
throw error;
|
|
941
|
+
}
|
|
942
|
+
const [promptResult, remixResult] = await Promise.allSettled([
|
|
943
|
+
client.get('/recommended-prompts', { teamId, limit: '100' }),
|
|
944
|
+
client.get('/remixes', {
|
|
945
|
+
scope: 'gallery',
|
|
946
|
+
recommended: 'true',
|
|
947
|
+
teamId,
|
|
948
|
+
limit: '100',
|
|
949
|
+
}),
|
|
950
|
+
]);
|
|
951
|
+
if (promptResult.status === 'fulfilled') {
|
|
952
|
+
recommendedPromptTemplates = parseRecommendedPromptTemplates(promptResult.value);
|
|
953
|
+
}
|
|
954
|
+
else if (!options.json) {
|
|
955
|
+
emitProgress(options, '[警告] 获取系统推荐 Prompt 模板失败');
|
|
956
|
+
}
|
|
957
|
+
if (remixResult.status === 'fulfilled') {
|
|
958
|
+
galleryTemplates = parseGalleryTemplates(remixResult.value);
|
|
959
|
+
}
|
|
960
|
+
else if (!options.json) {
|
|
961
|
+
emitProgress(options, '[警告] 获取系统推荐 Remix 失败');
|
|
962
|
+
}
|
|
963
|
+
const templates = [
|
|
964
|
+
...projectTemplates,
|
|
965
|
+
...recommendedPromptTemplates,
|
|
966
|
+
...galleryTemplates,
|
|
967
|
+
];
|
|
968
|
+
if (!templates.length) {
|
|
969
|
+
throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: '当前没有可用的模板项目。请联系管理员开通权限或检查系统推荐模板配置。' });
|
|
970
|
+
}
|
|
971
|
+
if (!templateId) {
|
|
972
|
+
if (!interactive)
|
|
973
|
+
throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: '非交互模式必须提供 --template-id。' });
|
|
974
|
+
const templateChoices = buildTemplateChoices(templates);
|
|
975
|
+
if (!options.teamId) {
|
|
976
|
+
templateChoices.unshift({
|
|
977
|
+
name: '[返回] 重新选择团队(输入 back 快速定位)',
|
|
978
|
+
value: BACK_TO_TEAMS,
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
let answer = NO_MATCH;
|
|
982
|
+
while (answer === NO_MATCH) {
|
|
983
|
+
answer = (await inquirer.prompt([{
|
|
984
|
+
type: 'autocomplete',
|
|
985
|
+
name: 'templateId',
|
|
986
|
+
message: '请选择模板(可输入关键词搜索,输入 back 返回):',
|
|
987
|
+
source: async (_answersSoFar, input) => filterPromptChoices(templateChoices, input),
|
|
988
|
+
}])).templateId;
|
|
989
|
+
}
|
|
990
|
+
if (answer === BACK_TO_TEAMS) {
|
|
991
|
+
teamId = undefined;
|
|
992
|
+
templateId = undefined;
|
|
993
|
+
selectedTemplate = undefined;
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
templateId = answer;
|
|
997
|
+
}
|
|
998
|
+
selectedTemplate = templates.find((template) => template.choiceValue === templateId || template.id === templateId);
|
|
999
|
+
if (!selectedTemplate) {
|
|
1000
|
+
throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: `模板 ${templateId} 不存在或对所选团队不可见。` });
|
|
1001
|
+
}
|
|
1002
|
+
templateId = selectedTemplate.id;
|
|
1003
|
+
if (selectedTemplate.useExactSourceBranch) {
|
|
1004
|
+
sourceBranch = selectedTemplate.sourceBranch || 'main';
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
if (!options.json)
|
|
1008
|
+
emitProgress(options, '[加载中] 获取模板的 Remix 分支列表...');
|
|
1009
|
+
const branchData = await client.get(`/projects/${encodeURIComponent(selectedTemplate.id)}/branches`, {
|
|
1010
|
+
includeAll: 'true',
|
|
1011
|
+
teamId,
|
|
1012
|
+
});
|
|
1013
|
+
const mainBranch = selectedTemplate.sourceBranch || options.sourceBranch || 'main';
|
|
1014
|
+
const branchChoices = buildRemixBranchChoices(branchData, mainBranch);
|
|
1015
|
+
let branchAnswer = NO_MATCH;
|
|
1016
|
+
while (branchAnswer === NO_MATCH) {
|
|
1017
|
+
branchAnswer = (await inquirer.prompt([{
|
|
1018
|
+
type: 'autocomplete',
|
|
1019
|
+
name: 'branch',
|
|
1020
|
+
message: '请选择基础分支(输入 back 返回模板列表):',
|
|
1021
|
+
source: async (_answersSoFar, input) => filterPromptChoices(branchChoices, input),
|
|
1022
|
+
}])).branch;
|
|
1023
|
+
}
|
|
1024
|
+
if (branchAnswer === BACK_TO_TEMPLATES) {
|
|
1025
|
+
templateId = undefined;
|
|
1026
|
+
selectedTemplate = undefined;
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
sourceBranch = branchAnswer;
|
|
727
1030
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
limit: '100',
|
|
731
|
-
skip: '0',
|
|
732
|
-
}));
|
|
733
|
-
if (!templates.length)
|
|
734
|
-
throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: '所选团队当前没有可见项目。' });
|
|
735
|
-
if (!templateId) {
|
|
736
|
-
if (!interactive)
|
|
737
|
-
throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: '非交互模式必须提供 --template-id。' });
|
|
738
|
-
templateId = (await inquirer.prompt([{ type: 'list', name: 'templateId', message: '请选择模板:', choices: templates.map((template) => ({ name: template.name, value: template.id })) }])).templateId;
|
|
1031
|
+
if (!selectedTemplate || !sourceBranch) {
|
|
1032
|
+
throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: '未选择有效的模板或基础分支。' });
|
|
739
1033
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
const
|
|
1034
|
+
if (!options.json)
|
|
1035
|
+
emitProgress(options, `[已选择] 基于分支: ${sourceBranch}`);
|
|
1036
|
+
// Auto-generate remix name following platform naming convention
|
|
1037
|
+
const timestamp = Date.now();
|
|
1038
|
+
const randomSuffix = Math.random().toString(36).substring(2, 10);
|
|
1039
|
+
const baseName = selectedTemplate.name.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
|
|
1040
|
+
const name = options.name || `remix-${baseName}-${timestamp}-${randomSuffix}`;
|
|
1041
|
+
if (!options.json)
|
|
1042
|
+
emitProgress(options, `[自动生成] Remix 名称: ${name}`);
|
|
746
1043
|
const createdRemix = await remixCreate({
|
|
747
1044
|
...options,
|
|
748
1045
|
projectId: selectedTemplate.id,
|
|
749
1046
|
name,
|
|
750
|
-
sourceBranch
|
|
1047
|
+
sourceBranch, // Use the sourceBranch determined above
|
|
751
1048
|
teamId,
|
|
752
1049
|
editorProjectId: selectedTemplate.projectType === 'external' && selectedTemplate.uuid
|
|
753
1050
|
? selectedTemplate.uuid
|
|
@@ -888,14 +1185,44 @@ export function registerRemixCommands(program) {
|
|
|
888
1185
|
.action(async (opts) => {
|
|
889
1186
|
let client;
|
|
890
1187
|
try {
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1188
|
+
const options = {
|
|
1189
|
+
...opts,
|
|
1190
|
+
projectId: String(opts.projectId),
|
|
1191
|
+
branch: String(opts.branch),
|
|
1192
|
+
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY && !opts.json),
|
|
1193
|
+
};
|
|
1194
|
+
client = createRemixClient(options);
|
|
1195
|
+
const sandbox = await remixEnsureStream(options);
|
|
1196
|
+
const ready = await ensureAgentSession(client, sandbox, options);
|
|
1197
|
+
const editorProjectId = await resolveEditorProjectIdBestEffort(client, options.projectId, options);
|
|
1198
|
+
const pendingQuestions = await listPendingAgentQuestions(client, ready);
|
|
1199
|
+
const handledQuestionIds = new Set();
|
|
1200
|
+
if (pendingQuestions.length) {
|
|
1201
|
+
emitProgress(options, `[恢复中] 检测到 ${pendingQuestions.length} 个待回答问题,Agent 正在等待你的选择。`);
|
|
1202
|
+
for (const request of pendingQuestions) {
|
|
1203
|
+
await replyToAgentQuestion(client, ready, options, request, handledQuestionIds);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
const data = {
|
|
1207
|
+
projectId: options.projectId,
|
|
1208
|
+
branch: options.branch,
|
|
1209
|
+
urls: {
|
|
1210
|
+
editor: editorUrl(client, editorProjectId, options.branch),
|
|
1211
|
+
...(ready.previewUrl ? { preview: ready.previewUrl } : {}),
|
|
1212
|
+
},
|
|
1213
|
+
sandbox: { id: ready.sandboxId, slotId: ready.slotId, status: ready.status || 'ready' },
|
|
1214
|
+
agent: {
|
|
1215
|
+
slug: ready.codeAgentSlug,
|
|
1216
|
+
sessionId: ready.agentSessionId,
|
|
1217
|
+
pendingQuestions: pendingQuestions.length,
|
|
1218
|
+
},
|
|
1219
|
+
};
|
|
895
1220
|
if (opts.open)
|
|
896
1221
|
openBrowser(data.urls.editor);
|
|
897
1222
|
if (opts.json)
|
|
898
1223
|
console.log(JSON.stringify(resultEnvelope('remix.start', client, data)));
|
|
1224
|
+
else if (pendingQuestions.length)
|
|
1225
|
+
console.log(`待回答问题已处理,沙箱已就绪:${data.urls.editor}`);
|
|
899
1226
|
else
|
|
900
1227
|
console.log(`沙箱已就绪:${data.urls.editor}`);
|
|
901
1228
|
}
|
|
@@ -36,6 +36,15 @@ function resolveToken(token) {
|
|
|
36
36
|
}
|
|
37
37
|
return resolved;
|
|
38
38
|
}
|
|
39
|
+
function humanizeNetworkError(error) {
|
|
40
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
41
|
+
if (raw.includes('网络请求失败'))
|
|
42
|
+
return raw;
|
|
43
|
+
if (/fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|ECONNREFUSED|socket hang up|UND_ERR/i.test(raw)) {
|
|
44
|
+
return `网络请求失败(${raw})。请稍后重试。`;
|
|
45
|
+
}
|
|
46
|
+
return raw;
|
|
47
|
+
}
|
|
39
48
|
function statusCode(status) {
|
|
40
49
|
if (status === 401)
|
|
41
50
|
return 'AUTH_EXPIRED';
|
|
@@ -67,49 +76,80 @@ export class PlayCraftHttpClient {
|
|
|
67
76
|
headers.set('X-Request-Id', this.requestId);
|
|
68
77
|
if (init.body && !headers.has('Content-Type'))
|
|
69
78
|
headers.set('Content-Type', 'application/json');
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
catch (error) {
|
|
75
|
-
throw new PlayCraftApiError({
|
|
76
|
-
code: 'AGENT_REQUEST_FAILED',
|
|
77
|
-
message: error instanceof Error ? error.message : String(error),
|
|
78
|
-
retryable: true,
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
const contentType = response.headers.get('content-type') || '';
|
|
82
|
-
const raw = await response.text();
|
|
83
|
-
let payload = undefined;
|
|
84
|
-
if (raw) {
|
|
79
|
+
const maxRetries = 3;
|
|
80
|
+
const retryDelays = [1000, 2000, 4000]; // 指数退避:1s, 2s, 4s
|
|
81
|
+
let lastError;
|
|
82
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
85
83
|
try {
|
|
86
|
-
|
|
84
|
+
const response = await fetch(this.url(path), { ...init, headers });
|
|
85
|
+
const contentType = response.headers.get('content-type') || '';
|
|
86
|
+
const raw = await response.text();
|
|
87
|
+
let payload = undefined;
|
|
88
|
+
if (raw) {
|
|
89
|
+
try {
|
|
90
|
+
payload = contentType.includes('json') ? JSON.parse(raw) : raw;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
payload = raw;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
const error = payload?.error;
|
|
98
|
+
const code = typeof error?.code === 'string' ? error.code : statusCode(response.status);
|
|
99
|
+
const message = typeof error?.message === 'string'
|
|
100
|
+
? error.message
|
|
101
|
+
: typeof payload?.message === 'string'
|
|
102
|
+
? payload.message
|
|
103
|
+
: typeof payload === 'string'
|
|
104
|
+
? payload
|
|
105
|
+
: response.statusText || 'PlayCraft request failed';
|
|
106
|
+
throw new PlayCraftApiError({
|
|
107
|
+
code,
|
|
108
|
+
message,
|
|
109
|
+
status: response.status,
|
|
110
|
+
details: error?.details ?? payload,
|
|
111
|
+
retryable: response.status >= 500,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (!raw)
|
|
115
|
+
return undefined;
|
|
116
|
+
return payload;
|
|
87
117
|
}
|
|
88
|
-
catch {
|
|
89
|
-
|
|
118
|
+
catch (error) {
|
|
119
|
+
lastError = isPlayCraftApiError(error)
|
|
120
|
+
? error
|
|
121
|
+
: new PlayCraftApiError({
|
|
122
|
+
code: 'AGENT_REQUEST_FAILED',
|
|
123
|
+
message: humanizeNetworkError(error),
|
|
124
|
+
retryable: true,
|
|
125
|
+
});
|
|
126
|
+
// 如果是客户端错误(4xx)或不可重试的错误,立即抛出
|
|
127
|
+
if (isPlayCraftApiError(error) && !error.retryable) {
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
// 最后一次尝试失败,抛出错误
|
|
131
|
+
if (attempt === maxRetries - 1) {
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
// 等待后重试
|
|
135
|
+
await new Promise(resolve => setTimeout(resolve, retryDelays[attempt]));
|
|
90
136
|
}
|
|
91
137
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const code = typeof error?.code === 'string' ? error.code : statusCode(response.status);
|
|
95
|
-
const message = typeof error?.message === 'string'
|
|
96
|
-
? error.message
|
|
97
|
-
: typeof payload?.message === 'string'
|
|
98
|
-
? payload.message
|
|
99
|
-
: typeof payload === 'string'
|
|
100
|
-
? payload
|
|
101
|
-
: response.statusText || 'PlayCraft request failed';
|
|
138
|
+
// 所有重试都失败了
|
|
139
|
+
if (isPlayCraftApiError(lastError)) {
|
|
102
140
|
throw new PlayCraftApiError({
|
|
103
|
-
code,
|
|
104
|
-
message,
|
|
105
|
-
status:
|
|
106
|
-
details:
|
|
107
|
-
retryable:
|
|
141
|
+
code: lastError.code,
|
|
142
|
+
message: humanizeNetworkError(lastError),
|
|
143
|
+
status: lastError.status,
|
|
144
|
+
details: lastError.details,
|
|
145
|
+
retryable: true,
|
|
108
146
|
});
|
|
109
147
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
148
|
+
throw new PlayCraftApiError({
|
|
149
|
+
code: 'AGENT_REQUEST_FAILED',
|
|
150
|
+
message: humanizeNetworkError(lastError),
|
|
151
|
+
retryable: true,
|
|
152
|
+
});
|
|
113
153
|
}
|
|
114
154
|
get(path, params, init = {}) {
|
|
115
155
|
const url = new URL(this.url(path));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.52",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"@gltf-transform/core": "^4.3.0",
|
|
46
46
|
"@gltf-transform/extensions": "^4.3.0",
|
|
47
47
|
"@gltf-transform/functions": "^4.3.0",
|
|
48
|
-
"@playcraft/build": "^0.0.
|
|
49
|
-
"@playcraft/common": "^0.0.
|
|
48
|
+
"@playcraft/build": "^0.0.52",
|
|
49
|
+
"@playcraft/common": "^0.0.52",
|
|
50
50
|
"chokidar": "^4.0.3",
|
|
51
51
|
"commander": "^13.1.0",
|
|
52
52
|
"cors": "^2.8.6",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"express": "^5.2.1",
|
|
58
58
|
"fluent-ffmpeg": "^2.1.3",
|
|
59
59
|
"inquirer": "^9.3.8",
|
|
60
|
+
"inquirer-autocomplete-prompt": "^3.0.1",
|
|
60
61
|
"js-yaml": "^4.1.1",
|
|
61
62
|
"json5": "^2.2.3",
|
|
62
63
|
"latest-version": "^7.0.0",
|