@qmuse/qmuse-cli 1.0.9-dev.1 → 1.0.9-dev.3

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 CHANGED
@@ -62,6 +62,8 @@ qmuse assets upload public/photo.png public/audio/clip.wav --json
62
62
 
63
63
  导入时会生成 `.qmuse/asset-preflight.json`。源码资源总大小仅用于预警;服务端在 READY 前用 APC dry-run 检查实际压缩发布包。初始预算为 10,000,000 字节减去 65,536 字节预留量,超限返回可供模型修复的 `publishBudget` 诊断。此预算不等于已经证实的网关限制,也不代表未来代码或运行配置变更后的发布保证。
64
64
 
65
+ 超限后的 `.qmuse/last-import-error.json` 按媒体、数据、代码及关联资源分类给出建议:JSON 数据需精简或设计外置加载,JS/CSS 需优化依赖及构建产物,SVG、字体和模型需单独处理。服务端大文件清单使用构建产物路径;CLI 重新检查本地项目后列出的媒体候选与之没有自动映射,须确认源文件后再上传。缺少清单或可确认的本地媒体时不生成上传命令;修复后按诊断中的应用绑定与重试建议操作,避免重复创建应用。
66
+
65
67
  ### 导入本地代码
66
68
 
67
69
  ```bash
@@ -84,6 +86,32 @@ qmuse import /path/to/react-project --space-id <spaceId>
84
86
 
85
87
  平台已有新版本时,终端会询问是否用本地全部源码覆盖,不会自动合并平台修改。确认覆盖可执行 `qmuse import . --force`;脚本或 `--json` 模式须显式指定该参数。覆盖会在原应用创建新版本,仍需通过正常导入校验。
86
88
 
89
+ ### 拉取平台对话更新
90
+
91
+ ```bash
92
+ qmuse pull . # 拉取当前绑定应用最新已保存的源码
93
+ # 本地修改、运行和验证
94
+ qmuse import . # 更新同一应用
95
+ ```
96
+
97
+ `pull` 支持 `--env prod|pre|local` 和 `--json`。需要 MusePub 部署 `/api/app-import/pull`;服务端校验应用编辑权限、空间和历史导入版本。已绑定的导入应用免费同步,不扣下载积分,不改变普通源码下载的套餐规则。旧的 `.qmuse/project.json` 可直接使用;成功拉取后会保留导入版本凭据,支持后续多次拉取。
98
+
99
+ 本地源码与上次成功导入或拉取的基线不一致时,命令直接停止,包括新增、修改和删除文件。请先备份并处理本地改动;仅执行 Git 提交不会清除 QMuse 检测到的差异。首版不自动合并,也不支持强制拉取覆盖。
100
+
101
+ 拉取的是平台已保存版本的完整工程结构,可能与最初上传的项目结构不同。成功后会删除平台已移除的源码,并更新本地版本、源码哈希和模板标识,使后续导入沿用平台工程处理方式。平台在拉取期间再次更新时,下次导入仍会检测版本冲突。尚未保存的对话工作区改动不会被拉取。
102
+
103
+ `.git`、`.qmuse`、`.agents`、`AGENTS.md`、`.env*`、依赖目录、构建产物和本地忽略文件按本地保留规则处理,不自动执行脚本或安装依赖。远端文件与本地保留文件冲突时停止。源码上限为 20000 个文件、100 MiB。
104
+
105
+ 每次更新前,原源码和绑定保存在终端返回的 `.qmuse/pull-backups/pull-*/`,其中 `original/` 是原源码,`project.json` 是原绑定,`transaction.json` 是文件清单。普通失败自动回滚;进程异常退出时保留 `.qmuse/pull-recovery.json` 和锁,后续导入/拉取会停止。恢复时先确认原进程已退出,备份当前目录,再按清单清理本次新增文件、恢复 `original/` 和原绑定,核对后移除恢复标记和 `.qmuse/sync.lock`。备份包含完整源码,请确认无需恢复后自行清理。
106
+
107
+ `import` 和 `pull` 会在命令结束时向当前登录环境自动上报一条诊断摘要,包括 CLI 版本、操作 ID、appId/jobId、版本与源码指纹、冲突/覆盖决定、结果、错误码和可用的服务端 traceId。服务端接入既有操作日志,维护方可直接排查,不需要用户发送本地日志。上报不包含源码、文件路径、请求/响应正文、原始错误文本或凭证;不读取和上传历史日志。
108
+
109
+ 需要 MusePub 部署 `/api/app-import/client-event`。上报要求已登录且已确定空间;每次最多等待 1 秒、不重试,离线、超时、权限拒绝或旧服务端返回 404 不影响原命令结果。关联操作 ID 通过请求头传递,兼容旧版 import/pull 接口。服务端分别记录“pull 源码快照返回结果”和“客户端操作结果”,下载成功不等于本地应用成功;导入成功率仍以原有服务端任务指标为准。上报是尽力而为,不能当作完整用户操作计数。
110
+
111
+ 详细过程仍写入项目的 `.qmuse/logs/<时间戳>-<操作ID>.jsonl`,作为复杂问题的本地补充,无需加 `--log`。`import status`、`import configure` 只记录本地过程,不重复上报任务指标,日志写入命令的当前目录。保留最近 100 次操作,单次最多 256 KiB;超限省略中间事件并保留最终结果和省略数量,异常退出可能没有 `FINISH`。写日志失败不阻断业务,不改变 `--json` 的 stdout 格式。日志不进入导入包,清理不会删除 `pull-backups` 或导入错误诊断。
112
+
113
+
114
+
87
115
  ### 外部云服务
88
116
 
89
117
  ```bash
package/dist/cli.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { reportCliOperation } from './operation-report.js';
3
+ import { recordOperation, withOperationLog } from './operation-log.js';
2
4
  /**
3
5
  * QMuse CLI —— 将本地 React 项目导入 QMuse。
4
6
  *
@@ -27,6 +29,8 @@ import { QMUSE_CLI_VERSION } from './version.js';
27
29
  import { startUpdateNotice } from './update-notice.js';
28
30
  import { startDevelopment } from './dev/index.js';
29
31
  import { createProject } from './create.js';
32
+ import { pullProject } from './pull.js';
33
+ import { withProjectLock } from './project-lock.js';
30
34
  // ---------------- 参数解析 ----------------
31
35
  const BOOL_FLAGS = new Set([
32
36
  '--help', '-h', '--version', '-V', '--json', '--status',
@@ -192,12 +196,13 @@ async function pollImportWithLogs(jobId, cfg, flags) {
192
196
  return await pollAppImport(() => queryAppImportStatus(jobId, cfg), {
193
197
  signal: abortController.signal,
194
198
  onStatus: status => {
195
- if (flags['--json'] === true)
196
- return;
197
199
  const stage = `${status.status}:${status.stage ?? ''}`;
198
200
  if (stage === lastStage)
199
201
  return;
200
202
  lastStage = stage;
203
+ recordOperation('IMPORT_STAGE', { jobId, appId: status.appId, status: status.status, stage: status.stage });
204
+ if (flags['--json'] === true)
205
+ return;
201
206
  log.info(status.message || status.stage || status.status);
202
207
  },
203
208
  });
@@ -270,6 +275,8 @@ async function resolveImportConfiguration(status, cfg, flags, cloudTarget) {
270
275
  return configured.data;
271
276
  }
272
277
  async function finishImport(status, sourceHash, cfg, flags, managedProject, rootDir) {
278
+ recordOperation('IMPORT_RESULT', { jobId: status.jobId, appId: status.appId, toVersionId: status.versionId,
279
+ status: status.status, stage: status.stage, errorCode: status.failureDiagnostic?.code });
273
280
  if (status.status !== 'READY' || !status.appId || !status.versionId) {
274
281
  const guidance = configurationGuidance(status, resolveProjectEnvironment(cfg, str(flags, '--env')), parseImportCloudMode(str(flags, '--cloud-mode')));
275
282
  if (flags['--json'] === true) {
@@ -374,6 +381,11 @@ async function cmdAssets(command, flags) {
374
381
  }
375
382
  async function cmdImport(command, flags) {
376
383
  const cfg = resolveCfg(flags);
384
+ recordOperation('CONTEXT', { environment: resolveProjectEnvironment(cfg, str(flags, '--env')),
385
+ spaceId: str(flags, '--space-id') || cfg.spaceId,
386
+ jobId: ['status', 'configure'].includes(command[1]) ? command[2] : undefined, force: flags['--force'] === true });
387
+ if (!hasAuth(cfg))
388
+ recordOperation('STOP', { reason: 'AUTH_REQUIRED' });
377
389
  requireAuth(cfg);
378
390
  const subcommand = command[1];
379
391
  if (subcommand === 'status') {
@@ -402,6 +414,7 @@ async function cmdImport(command, flags) {
402
414
  if (!result.ok || !result.data) {
403
415
  fail(`查询导入任务失败:${result.errorMessage ?? result.errorCode ?? `HTTP ${result.status}`}`);
404
416
  }
417
+ recordOperation('IMPORT_STAGE', { jobId, appId: result.data.appId, status: result.data.status, stage: result.data.stage, toVersionId: result.data.versionId });
405
418
  printImportStatus(result.data, flags);
406
419
  }
407
420
  return;
@@ -456,10 +469,15 @@ async function cmdImport(command, flags) {
456
469
  const rootDir = path.resolve(directory);
457
470
  const managedProject = await loadManagedProject(rootDir, cfg, str(flags, '--env')) ??
458
471
  createExternalProject(rootDir, cfg, str(flags, '--env'));
472
+ recordOperation('BINDING', { environment: managedProject.environment, appId: managedProject.target?.appId,
473
+ spaceId: managedProject.target?.spaceId || str(flags, '--space-id') || managedProject.manifest.defaultSpaceId || cfg.spaceId,
474
+ fromVersionId: managedProject.target?.lastVersionId,
475
+ importVersionId: managedProject.target?.importVersionId, baselineHash: managedProject.target?.lastSourceHash });
459
476
  if (managedProject.manifest.schemaVersion === 2)
460
477
  await assertTemplateProtocolSupported(cfg);
461
478
  const pendingImport = managedProject.manifest.pendingImports?.[managedProject.environment];
462
479
  if (pendingImport) {
480
+ recordOperation('STOP', { reason: 'IMPORT_PENDING', jobId: pendingImport.jobId });
463
481
  throw new Error(`当前目录有尚未完成的导入任务,请先执行 qmuse import status ${pendingImport.jobId} --wait --env ${managedProject.environment},避免重复创建应用`);
464
482
  }
465
483
  const explicitSpaceId = str(flags, '--space-id');
@@ -481,7 +499,9 @@ async function cmdImport(command, flags) {
481
499
  const cloudMode = parseImportCloudMode(str(flags, '--cloud-mode'));
482
500
  if (flags['--json'] !== true)
483
501
  log.info('正在只读预检本地仓库...');
502
+ recordOperation('STEP', { stage: 'PREFLIGHT' });
484
503
  const preflight = await preflightImport(directory);
504
+ recordOperation('PREFLIGHT', { sourceHash: preflight.sourceHash, fileCount: preflight.fileCount, operation, spaceId });
485
505
  const assetReportPath = await writeAssetPreflightReport(preflight);
486
506
  if (preflight.assets.reduce((sum, asset) => sum + asset.sizeBytes, 0) >= 10_000_000 && flags['--json'] !== true) {
487
507
  log.warn(`本地静态资源较大,可能超过发布预算。清单:${assetReportPath};可先执行 qmuse assets upload <资源路径> --json。最终以服务端 APC 压缩包检查为准。`);
@@ -496,6 +516,7 @@ async function cmdImport(command, flags) {
496
516
  })
497
517
  : undefined;
498
518
  if (managedProject?.target?.lastSourceHash === preflight.sourceHash && !updateBase?.remoteChanged) {
519
+ recordOperation('IMPORT_RESULT', { status: 'READY', noChanges: true, appId: managedProject.target.appId, toVersionId: managedProject.target.lastVersionId });
499
520
  const result = {
500
521
  success: true,
501
522
  noChanges: true,
@@ -528,6 +549,7 @@ async function cmdImport(command, flags) {
528
549
  }
529
550
  log.info('正在打包并上传源码...');
530
551
  }
552
+ recordOperation('STEP', { stage: 'ARCHIVE' });
531
553
  const archive = await createImportArchive(preflight);
532
554
  let sourceToken;
533
555
  try {
@@ -564,6 +586,7 @@ async function cmdImport(command, flags) {
564
586
  throw new Error(`创建导入任务失败:${started.errorMessage ?? started.errorCode ?? `HTTP ${started.status}`}`);
565
587
  }
566
588
  const jobId = started.data.jobId;
589
+ recordOperation('IMPORT_STARTED', { jobId, appId: managedProject.target?.appId, operation, fromVersionId: updateBase?.baseVersionId });
567
590
  if (managedProject) {
568
591
  setPendingImport(managedProject, {
569
592
  apiBaseUrl: cfg.baseUrl,
@@ -880,6 +903,7 @@ const HELP = `QMuse CLI —— 将本地 React 项目导入 QMuse
880
903
  config [get|path] 查看配置
881
904
 
882
905
  create <directory> 下载 QMuse 模板并解压到新项目目录
906
+ pull [directory] 免费同步已导入应用的最新已保存源码(本地有改动时停止)
883
907
 
884
908
  cloud init -d "<应用功能描述>" 校验当前空间的 QMuse 云服务权益
885
909
  cloud set-secret KEY1,KEY2 --stdin 从标准输入 JSON 保存密钥,不输出密钥值
@@ -946,6 +970,23 @@ async function main() {
946
970
  case 'create':
947
971
  await cmdCreate(command, flags);
948
972
  return;
973
+ case 'pull': {
974
+ if (command.length > 2 || Object.keys(flags).some(key => !['--env', '--json'].includes(key))) {
975
+ throw new Error('用法:qmuse pull [directory] [--env prod|pre|local] [--json]');
976
+ }
977
+ const result = await withOperationLog({ directory: command[1] || '.', command: 'pull',
978
+ report: summary => reportCliOperation(summary, resolveCfg(flags)) }, () => pullProject(command[1] || '.', resolveCfg(flags), str(flags, '--env')));
979
+ if (flags['--json'] === true)
980
+ output(result, flags);
981
+ else {
982
+ log.success(result.noChanges ? '本地已是平台最新版本' : `已拉取平台版本 ${result.versionId}`);
983
+ if (result.backupDirectory)
984
+ log.info(`原源码备份:${result.backupDirectory}`);
985
+ if (!result.noChanges)
986
+ log.info('修改完成后执行 qmuse import,更新同一应用。');
987
+ }
988
+ return;
989
+ }
949
990
  case 'cloud':
950
991
  await cmdCloud(command, flags);
951
992
  return;
@@ -956,7 +997,18 @@ async function main() {
956
997
  await cmdAssets(command, flags);
957
998
  break;
958
999
  case 'import':
959
- await cmdImport(command, flags);
1000
+ await withOperationLog({
1001
+ directory: command[1] && !['status', 'configure'].includes(command[1]) ? command[1] : process.cwd(),
1002
+ command: command[1] === 'status' ? 'import-status' : command[1] === 'configure' ? 'import-configure' : 'import',
1003
+ exitCode: () => Number(process.exitCode || 0),
1004
+ report: summary => reportCliOperation(summary, resolveCfg(flags)),
1005
+ }, async () => {
1006
+ if (command[1] && !['status', 'configure'].includes(command[1])) {
1007
+ await withProjectLock(command[1], () => cmdImport(command, flags));
1008
+ }
1009
+ else
1010
+ await cmdImport(command, flags);
1011
+ });
960
1012
  return;
961
1013
  case 'space':
962
1014
  await cmdSpace(command, flags);
package/dist/client.js CHANGED
@@ -1,11 +1,4 @@
1
- /**
2
- * QMuse HTTP 客户端:
3
- * - 拼接 baseUrl + apiPrefix + 路径
4
- * - 仅向登录时绑定的 API Origin 注入 sessionId
5
- * - 兼容两种响应包装:
6
- * * CommonHttpResult<T> = { success, data?, errorCode?, errorMessage?, traceId? }
7
- * * IQmuseResult<T> = { success, errorCode, errorMessage, retriable?, resultObj? }
8
- */
1
+ import { currentOperationId, recordHttpResult } from './operation-log.js';
9
2
  import { apiOrigin, loadConfig } from './config.js';
10
3
  import { QMUSE_CLI_DISTRIBUTION, QMUSE_CLI_NAME, QMUSE_CLI_VERSION, } from './version.js';
11
4
  /** 拼接完整请求地址。 */
@@ -40,6 +33,9 @@ export function authHeaders(cfg) {
40
33
  'x-qmuse-client-version': QMUSE_CLI_VERSION,
41
34
  'x-qmuse-client-distribution': QMUSE_CLI_DISTRIBUTION,
42
35
  };
36
+ const operationId = currentOperationId();
37
+ if (operationId)
38
+ headers['x-qmuse-operation-id'] = operationId;
43
39
  const sid = (cfg.sessionId || '').trim();
44
40
  if (sid && credentialMatchesTarget(cfg)) {
45
41
  headers['x-session-id'] = sid;
@@ -97,11 +93,13 @@ export async function apiCall(method, path, body, cfgOverrideOrOpts) {
97
93
  if (body !== undefined && method !== 'GET') {
98
94
  init.body = JSON.stringify(body);
99
95
  }
96
+ const startedAt = Date.now();
100
97
  let res;
101
98
  try {
102
99
  res = await fetch(url, init);
103
100
  }
104
101
  catch (err) {
102
+ recordHttpResult({ method, route: path, startedAt, networkError: true });
105
103
  const msg = err instanceof Error ? err.message : String(err);
106
104
  return {
107
105
  ok: false,
@@ -113,7 +111,10 @@ export async function apiCall(method, path, body, cfgOverrideOrOpts) {
113
111
  };
114
112
  }
115
113
  let json = null;
116
- const text = await res.text();
114
+ const text = await res.text().catch(error => {
115
+ recordHttpResult({ method, route: path, startedAt, response: res, payload: { success: false, errorCode: 'RESPONSE_READ_ERROR' } });
116
+ throw error;
117
+ });
117
118
  if (text) {
118
119
  try {
119
120
  json = JSON.parse(text);
@@ -122,6 +123,7 @@ export async function apiCall(method, path, body, cfgOverrideOrOpts) {
122
123
  json = null;
123
124
  }
124
125
  }
126
+ recordHttpResult({ method, route: path, startedAt, response: res, payload: json });
125
127
  const setCookie = readSetCookie(res);
126
128
  const extracted = extractData(json);
127
129
  return {
@@ -1,3 +1,4 @@
1
+ import { recordHttpResult, recordOperation } from '../operation-log.js';
1
2
  import { openAsBlob } from 'node:fs';
2
3
  import { apiCall, authHeaders, buildUrl, requestSourceHeaders, } from '../client.js';
3
4
  import { assertImportArchiveSize } from './limits.js';
@@ -15,6 +16,8 @@ export async function uploadImportArchive(input, cfg) {
15
16
  headers['Content-Length'] = String(archive.size);
16
17
  headers['x-space-id'] = input.spaceId;
17
18
  Object.assign(headers, requestSourceHeaders(cfg));
19
+ const startedAt = Date.now();
20
+ recordOperation('STEP', { stage: 'UPLOAD' });
18
21
  let response;
19
22
  try {
20
23
  response = await fetch(buildUrl(cfg, '/api/app-import/source'), {
@@ -24,8 +27,10 @@ export async function uploadImportArchive(input, cfg) {
24
27
  });
25
28
  }
26
29
  catch (error) {
30
+ recordHttpResult({ method: 'POST', route: '/api/app-import/source', startedAt, networkError: true });
27
31
  throw new Error(`上传源码失败:${error instanceof Error ? error.message : String(error)}`);
28
32
  }
33
+ recordHttpResult({ method: 'POST', route: '/api/app-import/source', startedAt, response });
29
34
  const contentType = response.headers.get('content-type')?.split(';')[0].trim();
30
35
  const traceId = response.headers.get('x-trace-id') ?? response.headers.get('trace-id');
31
36
  const details = [
@@ -50,6 +55,8 @@ export async function uploadImportArchive(input, cfg) {
50
55
  throw new Error(`上传源码失败:服务端响应结构错误(${details})`);
51
56
  }
52
57
  const result = payload;
58
+ recordOperation('UPLOAD_RESPONSE', { status: result.success === true ? 'SUCCESS' : 'FAILED', errorCode: result.errorCode,
59
+ traceId: typeof payload.traceId === 'string' ? payload.traceId : undefined });
53
60
  if (!response.ok || result.success !== true) {
54
61
  const reason = typeof result.errorMessage === 'string' ? result.errorMessage
55
62
  : typeof result.errorCode === 'string' ? result.errorCode : '服务端拒绝上传';
@@ -17,11 +17,16 @@ const MEDIA = {
17
17
  '.webm': { mimeType: 'video/webm', materialType: 'video' },
18
18
  };
19
19
  const MAX_ASSET_BYTES = 100 * 1024 * 1024;
20
+ export function supportsAssetUpload(sourcePath, sizeBytes) {
21
+ return Boolean(MEDIA[path.extname(sourcePath).toLowerCase()]) &&
22
+ Number.isSafeInteger(sizeBytes) && sizeBytes > 0 && sizeBytes <= MAX_ASSET_BYTES;
23
+ }
20
24
  export const ASSET_REPAIR_STEPS = [
21
25
  '按 sourcePath、sha256 和 stableUrl 替换全部资源引用,包括动态拼接、JSON 清单、CSS、HTML 和 Three.js 加载器。',
22
26
  '保留 URL 的运行时语义;fetch、纹理、音频须验证 CORS、解码和播放,模型资源须检查关联文件及相对路径。',
27
+ '检查 assetRoot + clip.url 等拼接:完整 CDN 地址不能再拼接本地目录;JSON 清单和读取清单的代码需要一起验证。',
23
28
  '运行构建和相关页面检查,确认没有旧引用后,再删除已迁移资源的本地副本。不要仅凭上传成功删除文件。',
24
- '重新执行 qmuse import .;服务端会重新构建并检查 APC 压缩发布包,单纯上传 CDN 不会减少发布体积。',
29
+ '确认当前环境的应用绑定后,重新执行 qmuse import .;服务端会重新构建并检查 APC 压缩发布包,单纯上传 CDN 不会减少发布体积。',
25
30
  ];
26
31
  async function writeJson(file, value) {
27
32
  const temporary = `${file}.${randomUUID()}.tmp`;
@@ -38,16 +43,19 @@ export async function writeAssetPreflightReport(preflight) {
38
43
  const directory = path.join(preflight.rootDir, '.qmuse');
39
44
  await mkdir(directory, { recursive: true });
40
45
  const file = path.join(directory, 'asset-preflight.json');
46
+ const hasUploadCandidates = preflight.assets.some(asset => supportsAssetUpload(asset.sourcePath, asset.sizeBytes));
41
47
  await writeJson(file, {
42
48
  schemaVersion: 1, metric: 'SOURCE_ASSET_BYTES',
43
49
  totalAssetBytes: preflight.assets.reduce((sum, asset) => sum + asset.sizeBytes, 0),
44
50
  finalPublishSizeCheck: 'SERVER_APC_DRY_RUN_BEFORE_READY',
45
51
  assets: preflight.assets.map(asset => ({ ...asset,
46
52
  sha256: preflight.files.find(file => file.relativePath === asset.sourcePath)?.sha256,
47
- uploadSupported: Boolean(MEDIA[path.extname(asset.sourcePath).toLowerCase()]),
53
+ uploadSupported: supportsAssetUpload(asset.sourcePath, asset.sizeBytes),
48
54
  })).sort((a, b) => b.sizeBytes - a.sizeBytes),
49
- uploadCommand: 'qmuse assets upload <relative-file> [more-files...] --json',
50
- repairSteps: ASSET_REPAIR_STEPS,
55
+ uploadCommand: hasUploadCandidates ? 'qmuse assets upload <relative-file> [more-files...] --json' : null,
56
+ repairSteps: hasUploadCandidates ? ASSET_REPAIR_STEPS : [
57
+ '本地预检没有发现受支持的媒体上传候选。结合服务端发布包诊断检查数据、代码及其他构建产物;不要把 JSON、JS/CSS、SVG、字体或模型文件直接传给 assets upload。',
58
+ ],
51
59
  });
52
60
  return file;
53
61
  }
@@ -1,4 +1,4 @@
1
- import { ASSET_REPAIR_STEPS } from './assets.js';
1
+ import { buildPublishRepairGuidance } from './publish-repair.js';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
@@ -27,12 +27,13 @@ export async function writeImportFailureDiagnostic(input) {
27
27
  message: input.status.message,
28
28
  diagnostic: input.status.diagnostic,
29
29
  failureDiagnostic: input.status.failureDiagnostic,
30
- ...(input.status.failureDiagnostic?.code?.startsWith('APP_PUBLISH_') ? {
31
- assetRepair: {
32
- preflightReport: '.qmuse/asset-preflight.json',
33
- uploadCommand: `qmuse assets upload <relative-file> [more-files...] --env ${input.project?.environment ?? input.environment ?? 'prod'} --json`,
34
- steps: ASSET_REPAIR_STEPS.map(step => step.replace('qmuse import .', `qmuse import . --env ${input.project?.environment ?? input.environment ?? 'prod'}`)),
35
- },
30
+ ...(input.status.failureDiagnostic &&
31
+ ['APP_PUBLISH_PACKAGE_TOO_LARGE', 'APP_PUBLISH_FILE_TOO_LARGE'].includes(input.status.failureDiagnostic.code ?? '') ? {
32
+ assetRepair: await buildPublishRepairGuidance({
33
+ rootDir: input.rootDir,
34
+ diagnostic: input.status.failureDiagnostic,
35
+ environment: input.project?.environment ?? input.environment ?? 'prod',
36
+ }),
36
37
  } : {}),
37
38
  ...importRetryGuidance(input.status, input.project),
38
39
  environment: input.project?.environment ?? input.environment,
@@ -35,6 +35,9 @@ function parseTarget(value, field) {
35
35
  appId: requireNonEmptyString(value.appId, `${field}.appId`),
36
36
  spaceId: requireNonEmptyString(value.spaceId, `${field}.spaceId`),
37
37
  lastVersionId: requireNonEmptyString(value.lastVersionId, `${field}.lastVersionId`),
38
+ ...(value.importVersionId === undefined ? {} : {
39
+ importVersionId: requireNonEmptyString(value.importVersionId, `${field}.importVersionId`),
40
+ }),
38
41
  lastSourceHash: requireNonEmptyString(value.lastSourceHash, `${field}.lastSourceHash`),
39
42
  updatedAt: requireNonEmptyString(value.updatedAt, `${field}.updatedAt`),
40
43
  };
@@ -0,0 +1,74 @@
1
+ import path from 'node:path';
2
+ import { ASSET_REPAIR_STEPS, supportsAssetUpload } from './assets.js';
3
+ import { preflightImport } from './preflight.js';
4
+ const ADVICE = {
5
+ MEDIA: '支持的图片、音频和视频可使用 assets upload 外置;先确认构建产物对应的本地源文件及上传大小限制。',
6
+ DATA: 'JSON、CSV 等数据不能使用 assets upload。精简运行时数据、移除确认不参与运行的产物,或设计外置数据加载并验证接口、权限和 CORS。',
7
+ BUNDLE: '分析大依赖和重复代码,裁剪无用内容,移除非必要 source map 等产物;仅拆分 chunk 或懒加载不一定减少发布总包大小。JS/CSS 等代码不能使用 assets upload。',
8
+ DEPENDENT_ASSET: 'SVG、字体、模型及关联文件暂不支持 assets upload;优化资源并检查模型纹理、字体声明和相对路径依赖,不要直接删除文件。',
9
+ OTHER: '先确认文件类型、构建来源及运行时引用,再选择压缩、裁剪或单独的外置方案;不要默认使用 assets upload。',
10
+ };
11
+ function classify(filePath) {
12
+ const extension = path.extname(filePath).toLowerCase();
13
+ if (supportsAssetUpload(filePath, 1))
14
+ return 'MEDIA';
15
+ if (['.json', '.jsonl', '.csv', '.tsv', '.xml', '.yaml', '.yml'].includes(extension))
16
+ return 'DATA';
17
+ if (['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.css', '.html', '.map', '.wasm'].includes(extension))
18
+ return 'BUNDLE';
19
+ if (['.svg', '.woff', '.woff2', '.ttf', '.otf', '.eot', '.gltf', '.glb', '.obj', '.mtl', '.fbx', '.bin'].includes(extension))
20
+ return 'DEPENDENT_ASSET';
21
+ return 'OTHER';
22
+ }
23
+ /** Artifact paths describe build output, never an inferred local upload path. */
24
+ export async function buildPublishRepairGuidance(input) {
25
+ const artifactFiles = input.diagnostic.publishBudget?.largestFiles ?? [];
26
+ const kinds = artifactFiles.length
27
+ ? [...new Set(artifactFiles.map(file => classify(file.path)))]
28
+ : Object.keys(ADVICE);
29
+ const groups = kinds.map(kind => ({
30
+ kind,
31
+ artifactFiles: artifactFiles.filter(file => classify(file.path) === kind),
32
+ advice: ADVICE[kind],
33
+ }));
34
+ let localInspection = 'NOT_NEEDED';
35
+ let localMediaCandidates = [];
36
+ if (kinds.includes('MEDIA')) {
37
+ try {
38
+ // Reinspect the current tree; a previous report may describe deleted or changed files.
39
+ const preflight = await preflightImport(input.rootDir);
40
+ localMediaCandidates = preflight.files
41
+ .filter(file => supportsAssetUpload(file.relativePath, file.sizeBytes))
42
+ .sort((a, b) => b.sizeBytes - a.sizeBytes || a.relativePath.localeCompare(b.relativePath))
43
+ .slice(0, 20)
44
+ .map(file => ({ sourcePath: file.relativePath, sizeBytes: file.sizeBytes, sha256: file.sha256 }));
45
+ localInspection = 'AVAILABLE';
46
+ }
47
+ catch {
48
+ // Guidance must not replace the original server failure with a local preflight error.
49
+ localInspection = 'UNAVAILABLE';
50
+ }
51
+ }
52
+ const canSuggestUpload = artifactFiles.some(file => classify(file.path) === 'MEDIA') &&
53
+ localMediaCandidates.length > 0;
54
+ return {
55
+ preflightReport: '.qmuse/asset-preflight.json',
56
+ artifactListAvailable: artifactFiles.length > 0,
57
+ groups,
58
+ localInspection,
59
+ localMediaCandidates,
60
+ pathAdvice: 'artifactFiles 是构建产物路径;localMediaCandidates 是当前源码中按大小排列的最多 20 个可上传候选,两者没有自动对应关系。上传前追踪引用并确认源文件,不能把构建产物路径直接传给上传命令。',
61
+ uploadCommand: canSuggestUpload
62
+ ? `qmuse assets upload <confirmed-local-media-file> [more-files...] --env ${input.environment} --json`
63
+ : null,
64
+ steps: [
65
+ artifactFiles.length
66
+ ? '按服务端最大文件清单逐项定位体积来源;其中单文件大小未压缩,最终以 APC 压缩包检查为准。'
67
+ : '服务端没有提供大文件清单,先查看构建产物和原始错误定位超限文件;不要推断超限原因或猜测本地上传路径。',
68
+ ...groups.map(group => group.advice),
69
+ ...(canSuggestUpload ? ASSET_REPAIR_STEPS.slice(0, -1) : []),
70
+ ...(localInspection === 'UNAVAILABLE' ? ['当前本地预检未完成,未生成上传命令;先检查项目目录和本地文件。'] : []),
71
+ '修复后运行构建及相关页面验证;确认当前环境的应用绑定,再按 retryCommand / retryAdvice 重新导入。',
72
+ ],
73
+ };
74
+ }
@@ -1,3 +1,4 @@
1
+ import { recordOperation } from '../operation-log.js';
1
2
  import { apiCall } from '../client.js';
2
3
  export async function resolveUpdateBase(options) {
3
4
  const { target, cfg, force, interactive, prompt } = options;
@@ -10,16 +11,22 @@ export async function resolveUpdateBase(options) {
10
11
  throw new Error('平台未返回有效的应用最新版本,已停止导入');
11
12
  }
12
13
  const remoteChanged = latest.id !== target.lastVersionId;
14
+ recordOperation('VERSION_CHECK', { appId: target.appId, fromVersionId: target.lastVersionId, toVersionId: latest.id, remoteChanged, force });
13
15
  if (remoteChanged && !force) {
14
16
  const conflict = `平台版本已从 ${target.lastVersionId} 更新为 ${latest.id}`;
15
17
  if (!interactive) {
16
- throw new Error(`${conflict}。请同步最新源码;确认以本地源码覆盖时,使用 qmuse import . --force`);
18
+ recordOperation('CONFLICT', { reason: 'REMOTE_VERSION_CHANGED', confirmed: false });
19
+ throw new Error(`${conflict}。请使用 qmuse pull 同步最新源码;确认以本地源码覆盖时,使用 qmuse import . --force`);
17
20
  }
18
21
  const answer = await prompt(`${conflict}。是否以本地全部源码覆盖平台代码(不会合并平台修改),并在原应用创建新版本?[y/N]:`);
19
- if (!/^y(?:es)?$/iu.test(answer.trim())) {
22
+ const confirmed = /^y(?:es)?$/iu.test(answer.trim());
23
+ recordOperation('OVERWRITE_DECISION', { confirmed, force: false });
24
+ if (!confirmed) {
20
25
  throw new Error('已取消覆盖,未上传源码或更改本地版本绑定');
21
26
  }
22
27
  }
28
+ if (force)
29
+ recordOperation('OVERWRITE_DECISION', { confirmed: true, force: true });
23
30
  // Keep the confirmed version fixed so server-side concurrency checks still apply.
24
31
  return { baseVersionId: latest.id, remoteChanged };
25
32
  }
@@ -0,0 +1,156 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, unlinkSync, writeSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { QMUSE_CLI_VERSION } from './version.js';
6
+ const MAX_FILES = 100;
7
+ const MAX_BYTES = 256 * 1024;
8
+ const LOG_NAME = /^\d{13}-[a-f0-9-]{36}\.jsonl$/u;
9
+ const context = new AsyncLocalStorage();
10
+ const summaryKeys = ['environment', 'appId', 'spaceId', 'jobId', 'fromVersionId', 'toVersionId', 'importVersionId',
11
+ 'sourceHash', 'baselineHash', 'stage', 'reason', 'errorCode', 'traceId', 'force', 'confirmed', 'remoteChanged', 'noChanges'];
12
+ export function currentOperationId() {
13
+ return context.getStore()?.operationId;
14
+ }
15
+ const strings = ['cliVersion', 'environment', 'appId', 'spaceId', 'jobId', 'operation', 'fromVersionId', 'toVersionId',
16
+ 'importVersionId', 'sourceHash', 'baselineHash', 'status', 'stage', 'reason', 'traceId', 'method'];
17
+ const numbers = ['httpStatus', 'durationMs', 'fileCount', 'exitCode'];
18
+ const booleans = ['force', 'confirmed', 'remoteChanged', 'noChanges'];
19
+ function safeFields(fields) {
20
+ const safe = {};
21
+ for (const key of strings) {
22
+ const value = fields[key];
23
+ if (typeof value === 'string' && /^[a-z0-9_.:-]{1,128}$/iu.test(value))
24
+ safe[key] = value;
25
+ }
26
+ if (typeof fields.errorCode === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,95}$/u.test(fields.errorCode))
27
+ safe.errorCode = fields.errorCode;
28
+ if (typeof fields.route === 'string' && /^\/api\/[a-z0-9/_-]{1,120}$/iu.test(fields.route))
29
+ safe.route = fields.route;
30
+ for (const key of numbers)
31
+ if (typeof fields[key] === 'number' && Number.isFinite(fields[key]))
32
+ safe[key] = fields[key];
33
+ for (const key of booleans)
34
+ if (typeof fields[key] === 'boolean')
35
+ safe[key] = fields[key];
36
+ return safe;
37
+ }
38
+ function warn(log) {
39
+ if (!log.warned) {
40
+ log.warned = true;
41
+ process.stderr.write('QMuse 操作日志写入失败;本次命令继续执行。\n');
42
+ }
43
+ }
44
+ function append(log, event, fields = {}, terminal = false) {
45
+ const safe = safeFields(fields);
46
+ for (const key of summaryKeys) {
47
+ if (safe[key] !== undefined)
48
+ Object.assign(log.summary, { [key]: safe[key] });
49
+ }
50
+ if (event === 'HTTP_RESULT' && !safe.errorCode)
51
+ delete log.summary.errorCode;
52
+ if (terminal) {
53
+ log.summary.result = safe.status;
54
+ log.summary.durationMs = safe.durationMs;
55
+ }
56
+ if (log.fd === undefined)
57
+ return;
58
+ const line = JSON.stringify({ schemaVersion: 1, time: new Date().toISOString(), operationId: log.operationId,
59
+ event, ...safe, ...(terminal ? { droppedEvents: log.dropped } : {}) }) + '\n';
60
+ const bytes = Buffer.byteLength(line);
61
+ // Reserve space for the terminal result even after a long polling session.
62
+ if (!terminal && log.bytes + bytes > MAX_BYTES - 4096) {
63
+ log.dropped++;
64
+ return;
65
+ }
66
+ try {
67
+ writeSync(log.fd, line);
68
+ log.bytes += bytes;
69
+ }
70
+ catch {
71
+ warn(log);
72
+ }
73
+ }
74
+ export function recordOperation(event, fields = {}) {
75
+ const log = context.getStore();
76
+ if (log)
77
+ append(log, event, fields);
78
+ }
79
+ export function recordHttpResult(input) {
80
+ if (!context.getStore())
81
+ return;
82
+ const payload = input.payload && typeof input.payload === 'object' ? input.payload : {};
83
+ const trace = input.response?.headers.get('x-trace-id') || input.response?.headers.get('trace-id') || payload.traceId;
84
+ recordOperation('HTTP_RESULT', {
85
+ method: input.method, route: input.route.split('?')[0], httpStatus: input.response?.status ?? 0,
86
+ durationMs: Date.now() - input.startedAt,
87
+ traceId: typeof trace === 'string' ? trace : undefined,
88
+ errorCode: input.networkError ? 'NETWORK_ERROR' : typeof payload.errorCode === 'string' ? payload.errorCode : undefined,
89
+ status: input.networkError || input.response?.ok === false || payload.success === false ? 'FAILED' : 'RECEIVED',
90
+ });
91
+ }
92
+ export async function withOperationLog(options, action) {
93
+ const operationId = randomUUID();
94
+ const log = { operationId, startedAt: Date.now(), bytes: 0, dropped: 0, warned: false,
95
+ summary: { operationId, command: options.command, cliVersion: QMUSE_CLI_VERSION } };
96
+ try {
97
+ const root = path.resolve(options.directory);
98
+ if (!lstatSync(root).isDirectory())
99
+ throw new Error('not a project directory');
100
+ let directory = root;
101
+ for (const name of ['.qmuse', 'logs']) {
102
+ directory = path.join(directory, name);
103
+ try {
104
+ mkdirSync(directory, { mode: 0o700 });
105
+ }
106
+ catch (error) {
107
+ if (error.code !== 'EEXIST')
108
+ throw error;
109
+ }
110
+ const stat = lstatSync(directory);
111
+ if (!stat.isDirectory() || stat.isSymbolicLink())
112
+ throw new Error('unsafe log directory');
113
+ }
114
+ log.fd = openSync(path.join(directory, `${log.startedAt}-${log.operationId}.jsonl`), 'wx', 0o600);
115
+ // Delete only this logger's own files. Never prune pull backups or diagnostics.
116
+ const files = readdirSync(directory).filter(name => LOG_NAME.test(name)).sort();
117
+ for (const name of files.slice(0, Math.max(0, files.length - MAX_FILES))) {
118
+ try {
119
+ unlinkSync(path.join(directory, name));
120
+ }
121
+ catch { /* another command may have pruned it */ }
122
+ }
123
+ }
124
+ catch {
125
+ warn(log);
126
+ }
127
+ return context.run(log, async () => {
128
+ append(log, 'START', { operation: options.command, cliVersion: QMUSE_CLI_VERSION });
129
+ try {
130
+ const value = await action();
131
+ const exitCode = options.exitCode?.() ?? 0;
132
+ append(log, 'FINISH', { status: exitCode === 0 ? 'SUCCESS' : exitCode === 130 ? 'INTERRUPTED' : exitCode === 1 ? 'FAILED' : 'INCOMPLETE',
133
+ exitCode, durationMs: Date.now() - log.startedAt }, true);
134
+ return value;
135
+ }
136
+ catch (error) {
137
+ append(log, 'FINISH', { status: 'FAILED', exitCode: 1, durationMs: Date.now() - log.startedAt }, true);
138
+ throw error;
139
+ }
140
+ finally {
141
+ // A reporting failure must never replace the command's result or exception.
142
+ try {
143
+ await options.report?.({ ...log.summary });
144
+ }
145
+ catch { /* best effort */ }
146
+ if (log.fd !== undefined) {
147
+ try {
148
+ closeSync(log.fd);
149
+ }
150
+ catch {
151
+ warn(log);
152
+ }
153
+ }
154
+ }
155
+ });
156
+ }
@@ -0,0 +1,24 @@
1
+ import { authHeaders, buildUrl, hasAuth, requestSourceHeaders } from './client.js';
2
+ /** One bounded summary per import/pull; never upload log files or retry a failed report. */
3
+ export async function reportCliOperation(summary, cfg) {
4
+ if (!hasAuth(cfg) || !summary.spaceId || !['import', 'pull'].includes(summary.command))
5
+ return;
6
+ const controller = new AbortController();
7
+ let timer;
8
+ try {
9
+ const timeout = new Promise(resolve => {
10
+ timer = setTimeout(() => { controller.abort(); resolve(); }, 1000);
11
+ });
12
+ const request = fetch(buildUrl(cfg, '/api/app-import/client-event'), {
13
+ method: 'POST', redirect: 'error', signal: controller.signal,
14
+ headers: { ...authHeaders({ ...cfg, spaceId: summary.spaceId }), ...requestSourceHeaders(cfg) },
15
+ body: JSON.stringify(summary),
16
+ }).then(response => { void response.body?.cancel().catch(() => undefined); }).catch(() => undefined);
17
+ await Promise.race([request, timeout]);
18
+ }
19
+ catch { /* offline, old servers and telemetry errors do not affect the operation */ }
20
+ finally {
21
+ if (timer)
22
+ clearTimeout(timer);
23
+ }
24
+ }
@@ -0,0 +1,40 @@
1
+ import { recordOperation } from './operation-log.js';
2
+ import { lstat, mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ export async function withProjectLock(directory, action) {
5
+ const root = path.resolve(directory);
6
+ const metadata = path.join(root, '.qmuse');
7
+ const rootStat = await lstat(root);
8
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
9
+ throw new Error('项目目录不能是符号链接');
10
+ await mkdir(metadata, { recursive: true });
11
+ if ((await lstat(metadata)).isSymbolicLink())
12
+ throw new Error('.qmuse 不能是符号链接');
13
+ const lock = path.join(metadata, 'sync.lock');
14
+ try {
15
+ await mkdir(lock);
16
+ }
17
+ catch (error) {
18
+ if (error.code === 'EEXIST') {
19
+ recordOperation('STOP', { reason: 'PROJECT_LOCKED' });
20
+ throw new Error(`项目正在导入或拉取。若上次进程已退出,请先检查 .qmuse/pull-backups,再移除 ${lock}`);
21
+ }
22
+ throw error;
23
+ }
24
+ try {
25
+ const recovery = await lstat(path.join(metadata, 'pull-recovery.json')).catch(error => {
26
+ if (error.code === 'ENOENT')
27
+ return undefined;
28
+ throw error;
29
+ });
30
+ if (recovery) {
31
+ recordOperation('STOP', { reason: 'MANUAL_RECOVERY_REQUIRED' });
32
+ throw new Error('上次拉取未完成,请按 .qmuse/pull-recovery.json 的备份路径恢复文件和 project.json,确认后移除恢复标记');
33
+ }
34
+ await writeFile(path.join(lock, 'owner.json'), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
35
+ return await action();
36
+ }
37
+ finally {
38
+ await rm(lock, { recursive: true, force: true });
39
+ }
40
+ }
package/dist/pull.js ADDED
@@ -0,0 +1,278 @@
1
+ import { recordHttpResult, recordOperation } from './operation-log.js';
2
+ import { createHash } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import { copyFile, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { authHeaders, buildUrl, hasAuth, requestSourceHeaders } from './client.js';
7
+ import { loadManagedProject, saveManagedProject, resolveProjectEnvironment } from './import/project.js';
8
+ import { preflightImport } from './import/preflight.js';
9
+ import { withProjectLock } from './project-lock.js';
10
+ const MAX_RESPONSE_BYTES = 150 * 1024 * 1024;
11
+ const MAX_SOURCE_BYTES = 100 * 1024 * 1024;
12
+ const PROTECTED = new Set(['.git', '.qmuse', '.agents', '.local', '.muse-tmp', 'node_modules', 'dist', 'build', '.vite', '.turbo', '.qmuse-cli.json']);
13
+ async function download(cfg, body) {
14
+ const startedAt = Date.now();
15
+ const response = await fetch(buildUrl(cfg, '/api/app-import/pull'), {
16
+ method: 'POST', headers: { ...authHeaders(cfg), ...requestSourceHeaders(cfg) },
17
+ body: JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(120_000),
18
+ }).catch(error => {
19
+ recordHttpResult({ method: 'POST', route: '/api/app-import/pull', startedAt, networkError: true });
20
+ throw error;
21
+ });
22
+ recordHttpResult({ method: 'POST', route: '/api/app-import/pull', startedAt, response });
23
+ if (Number(response.headers.get('content-length')) > MAX_RESPONSE_BYTES) {
24
+ await response.body?.cancel();
25
+ throw new Error('平台源码响应超过 150MB 限制');
26
+ }
27
+ const reader = response.body?.getReader();
28
+ if (!reader)
29
+ throw new Error('平台未返回源码');
30
+ const chunks = [];
31
+ let bytes = 0;
32
+ try {
33
+ for (;;) {
34
+ const { done, value } = await reader.read();
35
+ if (done)
36
+ break;
37
+ bytes += value.byteLength;
38
+ if (bytes > MAX_RESPONSE_BYTES)
39
+ throw new Error('平台源码响应超过 150MB 限制');
40
+ chunks.push(value);
41
+ }
42
+ }
43
+ finally {
44
+ await reader.cancel();
45
+ }
46
+ let result;
47
+ try {
48
+ result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
49
+ }
50
+ catch {
51
+ throw new Error('平台未返回有效的同步响应,请确认 MusePub 已部署 qmuse pull 接口');
52
+ }
53
+ recordOperation('PULL_RESPONSE', { status: result?.success === true ? 'SUCCESS' : 'FAILED',
54
+ errorCode: result?.errorCode, traceId: result?.traceId });
55
+ if (!response.ok || result?.success !== true) {
56
+ throw new Error(`拉取失败:${result?.errorMessage || result?.errorCode || `HTTP ${response.status}`}`);
57
+ }
58
+ return result.data;
59
+ }
60
+ function safePath(name) {
61
+ if (!name || name !== name.normalize('NFC') || /[\\:\x00-\x1f\x7f]/u.test(name) ||
62
+ name.split('/').some(part => !part || part === '.' || part === '..' || /[. ]$/u.test(part))) {
63
+ throw new Error(`平台源码包含不安全路径:${name}`);
64
+ }
65
+ }
66
+ async function stageSnapshot(snapshot, directory) {
67
+ if (!snapshot.files || typeof snapshot.files !== 'object' || Array.isArray(snapshot.files))
68
+ throw new Error('平台源码文件列表无效');
69
+ const entries = Object.entries(snapshot.files);
70
+ if (!entries.length || entries.length > 20_000)
71
+ throw new Error('平台源码文件数无效');
72
+ const seen = new Set();
73
+ const directories = new Map();
74
+ let bytes = 0;
75
+ for (const [name, entry] of entries) {
76
+ safePath(name);
77
+ if (!entry || entry.filePath !== name || typeof entry.value !== 'string' ||
78
+ (entry.encoding !== undefined && !['utf8', 'base64'].includes(entry.encoding)))
79
+ throw new Error(`平台源码格式无效:${name}`);
80
+ const folded = name.toLowerCase();
81
+ if (seen.has(folded))
82
+ throw new Error(`平台源码路径大小写冲突:${name}`);
83
+ seen.add(folded);
84
+ const parts = name.split('/');
85
+ for (let index = 1; index < parts.length; index++) {
86
+ const prefix = parts.slice(0, index).join('/');
87
+ const previous = directories.get(prefix.toLowerCase());
88
+ if (previous && previous !== prefix)
89
+ throw new Error(`平台源码目录大小写冲突:${name}`);
90
+ directories.set(prefix.toLowerCase(), prefix);
91
+ }
92
+ if (name.split('/').some(part => PROTECTED.has(part) || part === '.env' || part.startsWith('.env.')) || name === 'AGENTS.md')
93
+ continue;
94
+ const content = Buffer.from(entry.value, entry.encoding || 'utf8');
95
+ if (entry.encoding === 'base64' && content.toString('base64') !== entry.value)
96
+ throw new Error(`平台二进制编码无效:${name}`);
97
+ bytes += content.length;
98
+ if (bytes > MAX_SOURCE_BYTES)
99
+ throw new Error('平台源码超过 100MB 限制');
100
+ const destination = path.join(directory, name);
101
+ await mkdir(path.dirname(destination), { recursive: true });
102
+ await writeFile(destination, content, { flag: 'wx' });
103
+ }
104
+ }
105
+ async function assertParents(root, name) {
106
+ let current = root;
107
+ for (const part of name.split('/').slice(0, -1)) {
108
+ current = path.join(current, part);
109
+ const stat = await lstat(current).catch(error => {
110
+ if (error.code === 'ENOENT')
111
+ return undefined;
112
+ throw error;
113
+ });
114
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
115
+ throw new Error(`本地路径冲突:${name}`);
116
+ }
117
+ }
118
+ export async function pullProject(directory, cfg, environment) {
119
+ recordOperation('CONTEXT', { environment: resolveProjectEnvironment(cfg, environment), spaceId: cfg.spaceId });
120
+ if (!hasAuth(cfg)) {
121
+ recordOperation('STOP', { reason: 'AUTH_REQUIRED' });
122
+ throw new Error('请先执行 qmuse login');
123
+ }
124
+ return withProjectLock(directory, async () => {
125
+ const root = path.resolve(directory);
126
+ const context = await loadManagedProject(root, cfg, environment);
127
+ if (!context?.target) {
128
+ recordOperation('STOP', { reason: 'BINDING_MISSING' });
129
+ throw new Error('当前环境没有导入绑定,请先执行 qmuse import');
130
+ }
131
+ if (Object.keys(context.manifest.pendingImports || {}).length) {
132
+ recordOperation('STOP', { reason: 'IMPORT_PENDING' });
133
+ throw new Error('存在未完成的导入,请先执行 qmuse import status <jobId> --wait');
134
+ }
135
+ const target = context.target;
136
+ recordOperation('BINDING', { appId: target.appId, spaceId: target.spaceId, fromVersionId: target.lastVersionId,
137
+ importVersionId: target.importVersionId || target.lastVersionId, baselineHash: target.lastSourceHash });
138
+ recordOperation('STEP', { stage: 'PREFLIGHT' });
139
+ const before = await preflightImport(root);
140
+ recordOperation('PREFLIGHT', { sourceHash: before.sourceHash, fileCount: before.fileCount });
141
+ if (before.sourceHash !== target.lastSourceHash) {
142
+ recordOperation('STOP', { reason: 'LOCAL_SOURCE_CHANGED' });
143
+ throw new Error('本地源码自上次导入或拉取后已有改动,已停止拉取。请先备份并处理这些改动;Git 提交不会改变 QMuse 同步基线。');
144
+ }
145
+ const originalManifest = await readFile(context.manifestPath);
146
+ recordOperation('STEP', { stage: 'DOWNLOAD' });
147
+ const snapshot = await download({ ...cfg, spaceId: target.spaceId }, {
148
+ appId: target.appId, spaceId: target.spaceId,
149
+ importVersionId: target.importVersionId || target.lastVersionId, lastVersionId: target.lastVersionId,
150
+ });
151
+ if (!snapshot || snapshot.appId !== target.appId || !/^\d+$/u.test(snapshot.versionId) ||
152
+ snapshot.importVersionId !== (target.importVersionId || target.lastVersionId) || typeof snapshot.unchanged !== 'boolean') {
153
+ throw new Error('平台返回的应用或版本与绑定不一致');
154
+ }
155
+ recordOperation('VERSION_CHECK', { toVersionId: snapshot.versionId, importVersionId: snapshot.importVersionId, noChanges: snapshot.unchanged });
156
+ if (snapshot.unchanged) {
157
+ if (snapshot.versionId !== target.lastVersionId)
158
+ throw new Error('平台返回的版本状态不一致');
159
+ return { appId: target.appId, versionId: snapshot.versionId, noChanges: true };
160
+ }
161
+ const backups = path.join(root, '.qmuse', 'pull-backups');
162
+ await mkdir(backups, { recursive: true });
163
+ if ((await lstat(backups)).isSymbolicLink())
164
+ throw new Error('备份目录不能是符号链接');
165
+ const transaction = await mkdtemp(path.join(backups, 'pull-'));
166
+ const stage = path.join(transaction, 'incoming');
167
+ const backup = path.join(transaction, 'original');
168
+ const recoveryPath = path.join(root, '.qmuse', 'pull-recovery.json');
169
+ await mkdir(stage);
170
+ await mkdir(backup);
171
+ const moved = [];
172
+ const written = [];
173
+ let applying = false;
174
+ let manifestSaved = false;
175
+ try {
176
+ recordOperation('STEP', { stage: 'VALIDATE_SNAPSHOT' });
177
+ await stageSnapshot(snapshot, stage);
178
+ // Validate the platform manifest through the same parser used by import.
179
+ if (!snapshot.project || snapshot.project.projectKind !== 'QMUSE_TEMPLATE')
180
+ throw new Error('平台未返回有效的工程模板信息');
181
+ const nextManifest = {
182
+ ...snapshot.project, defaultSpaceId: context.manifest.defaultSpaceId,
183
+ targets: context.manifest.targets, pendingImports: context.manifest.pendingImports,
184
+ };
185
+ await mkdir(path.join(stage, '.qmuse'), { recursive: true });
186
+ await writeFile(path.join(stage, '.qmuse', 'project.json'), JSON.stringify(nextManifest));
187
+ const validated = await loadManagedProject(stage, cfg, environment);
188
+ if (!validated)
189
+ throw new Error('平台工程模板信息无效');
190
+ const incoming = await preflightImport(stage);
191
+ const oldPaths = new Set(before.files.map(file => file.relativePath));
192
+ for (const file of incoming.files) {
193
+ await assertParents(root, file.relativePath);
194
+ const existing = await lstat(path.join(root, file.relativePath)).catch(error => {
195
+ if (error.code === 'ENOENT')
196
+ return undefined;
197
+ throw error;
198
+ });
199
+ if (existing && (!oldPaths.has(file.relativePath) || !existing.isFile() || existing.isSymbolicLink())) {
200
+ throw new Error(`平台文件与本地保留文件冲突,已停止:${file.relativePath}`);
201
+ }
202
+ }
203
+ if ((await preflightImport(root)).sourceHash !== before.sourceHash ||
204
+ !(await readFile(context.manifestPath)).equals(originalManifest))
205
+ throw new Error('下载期间本地源码或绑定发生变化,已停止拉取');
206
+ await writeFile(path.join(transaction, 'project.json'), originalManifest, { flag: 'wx' });
207
+ await writeFile(path.join(transaction, 'transaction.json'), JSON.stringify({
208
+ appId: target.appId, from: target.lastVersionId, to: snapshot.versionId,
209
+ originalFiles: before.files.map(file => file.relativePath), incomingFiles: incoming.files.map(file => file.relativePath),
210
+ }, null, 2));
211
+ await writeFile(recoveryPath, JSON.stringify({ backupDirectory: transaction }), { flag: 'wx' });
212
+ recordOperation('STEP', { stage: 'APPLY_SNAPSHOT' });
213
+ applying = true;
214
+ for (const file of before.files) {
215
+ await assertParents(root, file.relativePath);
216
+ const destination = path.join(backup, file.relativePath);
217
+ await mkdir(path.dirname(destination), { recursive: true });
218
+ await rename(file.absolutePath, destination);
219
+ moved.push(file.relativePath);
220
+ if (createHash('sha256').update(await readFile(destination)).digest('hex') !== file.sha256)
221
+ throw new Error('写入期间本地文件发生变化');
222
+ }
223
+ for (const file of incoming.files) {
224
+ await assertParents(root, file.relativePath);
225
+ const destination = path.join(root, file.relativePath);
226
+ await mkdir(path.dirname(destination), { recursive: true });
227
+ await copyFile(file.absolutePath, destination, constants.COPYFILE_EXCL);
228
+ written.push({ name: file.relativePath, sha256: file.sha256 });
229
+ }
230
+ const after = await preflightImport(root);
231
+ if (after.sourceHash !== incoming.sourceHash)
232
+ throw new Error('本地保留文件与平台源码的忽略规则冲突,已停止拉取');
233
+ context.manifest = validated.manifest;
234
+ context.manifest.targets[context.environment] = {
235
+ ...target, importVersionId: snapshot.importVersionId,
236
+ lastVersionId: snapshot.versionId, lastSourceHash: after.sourceHash, updatedAt: new Date().toISOString(),
237
+ };
238
+ if (!(await readFile(context.manifestPath)).equals(originalManifest))
239
+ throw new Error('写入期间本地绑定发生变化');
240
+ await saveManagedProject(context);
241
+ manifestSaved = true;
242
+ await rm(recoveryPath);
243
+ recordOperation('PULL_APPLIED', { appId: target.appId, toVersionId: snapshot.versionId, sourceHash: after.sourceHash, fileCount: after.fileCount });
244
+ return { appId: target.appId, versionId: snapshot.versionId, noChanges: false, fileCount: after.fileCount, backupDirectory: transaction };
245
+ }
246
+ catch (error) {
247
+ if (applying) {
248
+ recordOperation('ROLLBACK', { status: 'STARTED' });
249
+ try {
250
+ for (const file of written.reverse()) {
251
+ const filename = path.join(root, file.name);
252
+ await assertParents(root, file.name);
253
+ const current = await readFile(filename);
254
+ if (createHash('sha256').update(current).digest('hex') !== file.sha256)
255
+ throw new Error(`写入期间文件被修改:${file.name}`);
256
+ await rm(filename);
257
+ }
258
+ for (const name of moved) {
259
+ await assertParents(root, name);
260
+ await copyFile(path.join(backup, name), path.join(root, name), constants.COPYFILE_EXCL);
261
+ }
262
+ if (manifestSaved)
263
+ await writeFile(context.manifestPath, originalManifest);
264
+ await rm(recoveryPath);
265
+ recordOperation('ROLLBACK', { status: 'SUCCESS' });
266
+ }
267
+ catch {
268
+ recordOperation('ROLLBACK', { status: 'FAILED', reason: 'MANUAL_RECOVERY_REQUIRED' });
269
+ throw new Error(`拉取失败且回滚未完成,原文件保存在 ${transaction},请从备份恢复后再导入。原错误:${error instanceof Error ? error.message : error}`);
270
+ }
271
+ }
272
+ else {
273
+ await rm(transaction, { recursive: true, force: true });
274
+ }
275
+ throw error;
276
+ }
277
+ });
278
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qmuse/qmuse-cli",
3
- "version": "1.0.9-dev.1",
3
+ "version": "1.0.9-dev.3",
4
4
  "description": "将本地 React 项目安全导入 QMuse 的命令行客户端",
5
5
  "type": "module",
6
6
  "bin": {