@playcraft/cli 0.0.51 → 0.0.53

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 CHANGED
@@ -486,11 +486,14 @@ playcraft remix run
486
486
  交互顺序:
487
487
 
488
488
  1. 选择团队。
489
- 2. 选择模板。
490
- 3. 输入 Remix 名称。
491
- 4. 创建真实 Remix 分支。
492
- 5. 输入需求描述。
493
- 6. 有需求时启动沙箱并运行 Agent;直接回车或输入 `exit` 时只创建 Remix。
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
 
@@ -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
- const message = code === 'AUTH_REQUIRED'
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
- console.error(`错误:${message}`);
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 fetch(endpoint, {
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 fetch(client.url(path), { headers: { Authorization: `Bearer ${client.token}`, Accept: 'text/event-stream' }, signal: controller.signal });
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
- if (!teamId) {
721
- if (!interactive)
722
- throw new PlayCraftApiError({ code: 'TEAM_NOT_FOUND', message: '非交互模式必须提供 --team-id。' });
723
- const teams = parseList(await client.get('/teams'));
724
- if (!teams.length)
725
- throw new PlayCraftApiError({ code: 'TEAM_NOT_FOUND', message: '当前登录账号没有可用团队。' });
726
- teamId = (await inquirer.prompt([{ type: 'list', name: 'teamId', message: '请选择团队:', choices: teams.map((team) => ({ name: team.name, value: team.id })) }])).teamId;
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
- const templates = parseProjectTemplates(await client.get('/projects', {
729
- teamId,
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
- selectedTemplate = templates.find((template) => template.id === templateId);
741
- if (!selectedTemplate)
742
- throw new PlayCraftApiError({ code: 'TEMPLATE_NOT_FOUND', message: `模板 ${templateId} 不存在或对所选团队不可见。` });
743
- if (!options.name && !interactive)
744
- throw new PlayCraftApiError({ code: 'AGENT_REQUEST_FAILED', message: '非交互模式必须提供 --name。' });
745
- const name = (options.name || (await inquirer.prompt([{ type: 'input', name: 'name', message: '请输入 Remix 名称:', validate: (value) => Boolean(value.trim()) || 'Remix 名称不能为空。' }])).name).trim();
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: selectedTemplate.sourceBranch || options.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
- client = createRemixClient(opts);
892
- const ready = await remixEnsureStream({ ...opts, projectId: String(opts.projectId), branch: String(opts.branch), interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY && !opts.json) });
893
- const editorProjectId = await resolveEditorProjectIdBestEffort(client, String(opts.projectId), opts);
894
- const data = { projectId: String(opts.projectId), branch: String(opts.branch), urls: { editor: editorUrl(client, editorProjectId, String(opts.branch)), ...(ready.previewUrl ? { preview: ready.previewUrl } : {}) }, sandbox: { id: ready.sandboxId, slotId: ready.slotId, status: ready.status || 'ready' }, agent: { slug: ready.codeAgentSlug, sessionId: ready.agentSessionId } };
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
- let response;
71
- try {
72
- response = await fetch(this.url(path), { ...init, headers });
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
- payload = contentType.includes('json') ? JSON.parse(raw) : raw;
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
- payload = raw;
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
- if (!response.ok) {
93
- const error = payload?.error;
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: response.status,
106
- details: error?.details ?? payload,
107
- retryable: response.status >= 500,
141
+ code: lastError.code,
142
+ message: humanizeNetworkError(lastError),
143
+ status: lastError.status,
144
+ details: lastError.details,
145
+ retryable: true,
108
146
  });
109
147
  }
110
- if (!raw)
111
- return undefined;
112
- return payload;
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.51",
3
+ "version": "0.0.53",
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.51",
49
- "@playcraft/common": "^0.0.51",
48
+ "@playcraft/build": "^0.0.53",
49
+ "@playcraft/common": "^0.0.53",
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",