@qmuse/qmuse-cli 1.0.9-dev.2 → 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
@@ -104,6 +104,14 @@ qmuse import . # 更新同一应用
104
104
 
105
105
  每次更新前,原源码和绑定保存在终端返回的 `.qmuse/pull-backups/pull-*/`,其中 `original/` 是原源码,`project.json` 是原绑定,`transaction.json` 是文件清单。普通失败自动回滚;进程异常退出时保留 `.qmuse/pull-recovery.json` 和锁,后续导入/拉取会停止。恢复时先确认原进程已退出,备份当前目录,再按清单清理本次新增文件、恢复 `original/` 和原绑定,核对后移除恢复标记和 `.qmuse/sync.lock`。备份包含完整源码,请确认无需恢复后自行清理。
106
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
+
107
115
  ### 外部云服务
108
116
 
109
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
  *
@@ -194,12 +196,13 @@ async function pollImportWithLogs(jobId, cfg, flags) {
194
196
  return await pollAppImport(() => queryAppImportStatus(jobId, cfg), {
195
197
  signal: abortController.signal,
196
198
  onStatus: status => {
197
- if (flags['--json'] === true)
198
- return;
199
199
  const stage = `${status.status}:${status.stage ?? ''}`;
200
200
  if (stage === lastStage)
201
201
  return;
202
202
  lastStage = stage;
203
+ recordOperation('IMPORT_STAGE', { jobId, appId: status.appId, status: status.status, stage: status.stage });
204
+ if (flags['--json'] === true)
205
+ return;
203
206
  log.info(status.message || status.stage || status.status);
204
207
  },
205
208
  });
@@ -272,6 +275,8 @@ async function resolveImportConfiguration(status, cfg, flags, cloudTarget) {
272
275
  return configured.data;
273
276
  }
274
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 });
275
280
  if (status.status !== 'READY' || !status.appId || !status.versionId) {
276
281
  const guidance = configurationGuidance(status, resolveProjectEnvironment(cfg, str(flags, '--env')), parseImportCloudMode(str(flags, '--cloud-mode')));
277
282
  if (flags['--json'] === true) {
@@ -376,6 +381,11 @@ async function cmdAssets(command, flags) {
376
381
  }
377
382
  async function cmdImport(command, flags) {
378
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' });
379
389
  requireAuth(cfg);
380
390
  const subcommand = command[1];
381
391
  if (subcommand === 'status') {
@@ -404,6 +414,7 @@ async function cmdImport(command, flags) {
404
414
  if (!result.ok || !result.data) {
405
415
  fail(`查询导入任务失败:${result.errorMessage ?? result.errorCode ?? `HTTP ${result.status}`}`);
406
416
  }
417
+ recordOperation('IMPORT_STAGE', { jobId, appId: result.data.appId, status: result.data.status, stage: result.data.stage, toVersionId: result.data.versionId });
407
418
  printImportStatus(result.data, flags);
408
419
  }
409
420
  return;
@@ -458,10 +469,15 @@ async function cmdImport(command, flags) {
458
469
  const rootDir = path.resolve(directory);
459
470
  const managedProject = await loadManagedProject(rootDir, cfg, str(flags, '--env')) ??
460
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 });
461
476
  if (managedProject.manifest.schemaVersion === 2)
462
477
  await assertTemplateProtocolSupported(cfg);
463
478
  const pendingImport = managedProject.manifest.pendingImports?.[managedProject.environment];
464
479
  if (pendingImport) {
480
+ recordOperation('STOP', { reason: 'IMPORT_PENDING', jobId: pendingImport.jobId });
465
481
  throw new Error(`当前目录有尚未完成的导入任务,请先执行 qmuse import status ${pendingImport.jobId} --wait --env ${managedProject.environment},避免重复创建应用`);
466
482
  }
467
483
  const explicitSpaceId = str(flags, '--space-id');
@@ -483,7 +499,9 @@ async function cmdImport(command, flags) {
483
499
  const cloudMode = parseImportCloudMode(str(flags, '--cloud-mode'));
484
500
  if (flags['--json'] !== true)
485
501
  log.info('正在只读预检本地仓库...');
502
+ recordOperation('STEP', { stage: 'PREFLIGHT' });
486
503
  const preflight = await preflightImport(directory);
504
+ recordOperation('PREFLIGHT', { sourceHash: preflight.sourceHash, fileCount: preflight.fileCount, operation, spaceId });
487
505
  const assetReportPath = await writeAssetPreflightReport(preflight);
488
506
  if (preflight.assets.reduce((sum, asset) => sum + asset.sizeBytes, 0) >= 10_000_000 && flags['--json'] !== true) {
489
507
  log.warn(`本地静态资源较大,可能超过发布预算。清单:${assetReportPath};可先执行 qmuse assets upload <资源路径> --json。最终以服务端 APC 压缩包检查为准。`);
@@ -498,6 +516,7 @@ async function cmdImport(command, flags) {
498
516
  })
499
517
  : undefined;
500
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 });
501
520
  const result = {
502
521
  success: true,
503
522
  noChanges: true,
@@ -530,6 +549,7 @@ async function cmdImport(command, flags) {
530
549
  }
531
550
  log.info('正在打包并上传源码...');
532
551
  }
552
+ recordOperation('STEP', { stage: 'ARCHIVE' });
533
553
  const archive = await createImportArchive(preflight);
534
554
  let sourceToken;
535
555
  try {
@@ -566,6 +586,7 @@ async function cmdImport(command, flags) {
566
586
  throw new Error(`创建导入任务失败:${started.errorMessage ?? started.errorCode ?? `HTTP ${started.status}`}`);
567
587
  }
568
588
  const jobId = started.data.jobId;
589
+ recordOperation('IMPORT_STARTED', { jobId, appId: managedProject.target?.appId, operation, fromVersionId: updateBase?.baseVersionId });
569
590
  if (managedProject) {
570
591
  setPendingImport(managedProject, {
571
592
  apiBaseUrl: cfg.baseUrl,
@@ -953,7 +974,8 @@ async function main() {
953
974
  if (command.length > 2 || Object.keys(flags).some(key => !['--env', '--json'].includes(key))) {
954
975
  throw new Error('用法:qmuse pull [directory] [--env prod|pre|local] [--json]');
955
976
  }
956
- const result = await pullProject(command[1] || '.', resolveCfg(flags), str(flags, '--env'));
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')));
957
979
  if (flags['--json'] === true)
958
980
  output(result, flags);
959
981
  else {
@@ -975,11 +997,18 @@ async function main() {
975
997
  await cmdAssets(command, flags);
976
998
  break;
977
999
  case 'import':
978
- if (command[1] && !['status', 'configure'].includes(command[1])) {
979
- await withProjectLock(command[1], () => cmdImport(command, flags));
980
- }
981
- else
982
- 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
+ });
983
1012
  return;
984
1013
  case 'space':
985
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 : '服务端拒绝上传';
@@ -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) {
18
+ recordOperation('CONFLICT', { reason: 'REMOTE_VERSION_CHANGED', confirmed: false });
16
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
+ }
@@ -1,3 +1,4 @@
1
+ import { recordOperation } from './operation-log.js';
1
2
  import { lstat, mkdir, rm, writeFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  export async function withProjectLock(directory, action) {
@@ -15,6 +16,7 @@ export async function withProjectLock(directory, action) {
15
16
  }
16
17
  catch (error) {
17
18
  if (error.code === 'EEXIST') {
19
+ recordOperation('STOP', { reason: 'PROJECT_LOCKED' });
18
20
  throw new Error(`项目正在导入或拉取。若上次进程已退出,请先检查 .qmuse/pull-backups,再移除 ${lock}`);
19
21
  }
20
22
  throw error;
@@ -25,8 +27,10 @@ export async function withProjectLock(directory, action) {
25
27
  return undefined;
26
28
  throw error;
27
29
  });
28
- if (recovery)
30
+ if (recovery) {
31
+ recordOperation('STOP', { reason: 'MANUAL_RECOVERY_REQUIRED' });
29
32
  throw new Error('上次拉取未完成,请按 .qmuse/pull-recovery.json 的备份路径恢复文件和 project.json,确认后移除恢复标记');
33
+ }
30
34
  await writeFile(path.join(lock, 'owner.json'), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
31
35
  return await action();
32
36
  }
package/dist/pull.js CHANGED
@@ -1,19 +1,25 @@
1
+ import { recordHttpResult, recordOperation } from './operation-log.js';
1
2
  import { createHash } from 'node:crypto';
2
3
  import { constants } from 'node:fs';
3
4
  import { copyFile, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
5
  import path from 'node:path';
5
6
  import { authHeaders, buildUrl, hasAuth, requestSourceHeaders } from './client.js';
6
- import { loadManagedProject, saveManagedProject } from './import/project.js';
7
+ import { loadManagedProject, saveManagedProject, resolveProjectEnvironment } from './import/project.js';
7
8
  import { preflightImport } from './import/preflight.js';
8
9
  import { withProjectLock } from './project-lock.js';
9
10
  const MAX_RESPONSE_BYTES = 150 * 1024 * 1024;
10
11
  const MAX_SOURCE_BYTES = 100 * 1024 * 1024;
11
12
  const PROTECTED = new Set(['.git', '.qmuse', '.agents', '.local', '.muse-tmp', 'node_modules', 'dist', 'build', '.vite', '.turbo', '.qmuse-cli.json']);
12
13
  async function download(cfg, body) {
14
+ const startedAt = Date.now();
13
15
  const response = await fetch(buildUrl(cfg, '/api/app-import/pull'), {
14
16
  method: 'POST', headers: { ...authHeaders(cfg), ...requestSourceHeaders(cfg) },
15
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;
16
21
  });
22
+ recordHttpResult({ method: 'POST', route: '/api/app-import/pull', startedAt, response });
17
23
  if (Number(response.headers.get('content-length')) > MAX_RESPONSE_BYTES) {
18
24
  await response.body?.cancel();
19
25
  throw new Error('平台源码响应超过 150MB 限制');
@@ -44,6 +50,8 @@ async function download(cfg, body) {
44
50
  catch {
45
51
  throw new Error('平台未返回有效的同步响应,请确认 MusePub 已部署 qmuse pull 接口');
46
52
  }
53
+ recordOperation('PULL_RESPONSE', { status: result?.success === true ? 'SUCCESS' : 'FAILED',
54
+ errorCode: result?.errorCode, traceId: result?.traceId });
47
55
  if (!response.ok || result?.success !== true) {
48
56
  throw new Error(`拉取失败:${result?.errorMessage || result?.errorCode || `HTTP ${response.status}`}`);
49
57
  }
@@ -108,20 +116,34 @@ async function assertParents(root, name) {
108
116
  }
109
117
  }
110
118
  export async function pullProject(directory, cfg, environment) {
111
- if (!hasAuth(cfg))
119
+ recordOperation('CONTEXT', { environment: resolveProjectEnvironment(cfg, environment), spaceId: cfg.spaceId });
120
+ if (!hasAuth(cfg)) {
121
+ recordOperation('STOP', { reason: 'AUTH_REQUIRED' });
112
122
  throw new Error('请先执行 qmuse login');
123
+ }
113
124
  return withProjectLock(directory, async () => {
114
125
  const root = path.resolve(directory);
115
126
  const context = await loadManagedProject(root, cfg, environment);
116
- if (!context?.target)
127
+ if (!context?.target) {
128
+ recordOperation('STOP', { reason: 'BINDING_MISSING' });
117
129
  throw new Error('当前环境没有导入绑定,请先执行 qmuse import');
118
- if (Object.keys(context.manifest.pendingImports || {}).length)
130
+ }
131
+ if (Object.keys(context.manifest.pendingImports || {}).length) {
132
+ recordOperation('STOP', { reason: 'IMPORT_PENDING' });
119
133
  throw new Error('存在未完成的导入,请先执行 qmuse import status <jobId> --wait');
134
+ }
120
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' });
121
139
  const before = await preflightImport(root);
122
- if (before.sourceHash !== target.lastSourceHash)
140
+ recordOperation('PREFLIGHT', { sourceHash: before.sourceHash, fileCount: before.fileCount });
141
+ if (before.sourceHash !== target.lastSourceHash) {
142
+ recordOperation('STOP', { reason: 'LOCAL_SOURCE_CHANGED' });
123
143
  throw new Error('本地源码自上次导入或拉取后已有改动,已停止拉取。请先备份并处理这些改动;Git 提交不会改变 QMuse 同步基线。');
144
+ }
124
145
  const originalManifest = await readFile(context.manifestPath);
146
+ recordOperation('STEP', { stage: 'DOWNLOAD' });
125
147
  const snapshot = await download({ ...cfg, spaceId: target.spaceId }, {
126
148
  appId: target.appId, spaceId: target.spaceId,
127
149
  importVersionId: target.importVersionId || target.lastVersionId, lastVersionId: target.lastVersionId,
@@ -130,6 +152,7 @@ export async function pullProject(directory, cfg, environment) {
130
152
  snapshot.importVersionId !== (target.importVersionId || target.lastVersionId) || typeof snapshot.unchanged !== 'boolean') {
131
153
  throw new Error('平台返回的应用或版本与绑定不一致');
132
154
  }
155
+ recordOperation('VERSION_CHECK', { toVersionId: snapshot.versionId, importVersionId: snapshot.importVersionId, noChanges: snapshot.unchanged });
133
156
  if (snapshot.unchanged) {
134
157
  if (snapshot.versionId !== target.lastVersionId)
135
158
  throw new Error('平台返回的版本状态不一致');
@@ -150,6 +173,7 @@ export async function pullProject(directory, cfg, environment) {
150
173
  let applying = false;
151
174
  let manifestSaved = false;
152
175
  try {
176
+ recordOperation('STEP', { stage: 'VALIDATE_SNAPSHOT' });
153
177
  await stageSnapshot(snapshot, stage);
154
178
  // Validate the platform manifest through the same parser used by import.
155
179
  if (!snapshot.project || snapshot.project.projectKind !== 'QMUSE_TEMPLATE')
@@ -185,6 +209,7 @@ export async function pullProject(directory, cfg, environment) {
185
209
  originalFiles: before.files.map(file => file.relativePath), incomingFiles: incoming.files.map(file => file.relativePath),
186
210
  }, null, 2));
187
211
  await writeFile(recoveryPath, JSON.stringify({ backupDirectory: transaction }), { flag: 'wx' });
212
+ recordOperation('STEP', { stage: 'APPLY_SNAPSHOT' });
188
213
  applying = true;
189
214
  for (const file of before.files) {
190
215
  await assertParents(root, file.relativePath);
@@ -215,10 +240,12 @@ export async function pullProject(directory, cfg, environment) {
215
240
  await saveManagedProject(context);
216
241
  manifestSaved = true;
217
242
  await rm(recoveryPath);
243
+ recordOperation('PULL_APPLIED', { appId: target.appId, toVersionId: snapshot.versionId, sourceHash: after.sourceHash, fileCount: after.fileCount });
218
244
  return { appId: target.appId, versionId: snapshot.versionId, noChanges: false, fileCount: after.fileCount, backupDirectory: transaction };
219
245
  }
220
246
  catch (error) {
221
247
  if (applying) {
248
+ recordOperation('ROLLBACK', { status: 'STARTED' });
222
249
  try {
223
250
  for (const file of written.reverse()) {
224
251
  const filename = path.join(root, file.name);
@@ -235,8 +262,10 @@ export async function pullProject(directory, cfg, environment) {
235
262
  if (manifestSaved)
236
263
  await writeFile(context.manifestPath, originalManifest);
237
264
  await rm(recoveryPath);
265
+ recordOperation('ROLLBACK', { status: 'SUCCESS' });
238
266
  }
239
267
  catch {
268
+ recordOperation('ROLLBACK', { status: 'FAILED', reason: 'MANUAL_RECOVERY_REQUIRED' });
240
269
  throw new Error(`拉取失败且回滚未完成,原文件保存在 ${transaction},请从备份恢复后再导入。原错误:${error instanceof Error ? error.message : error}`);
241
270
  }
242
271
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qmuse/qmuse-cli",
3
- "version": "1.0.9-dev.2",
3
+ "version": "1.0.9-dev.3",
4
4
  "description": "将本地 React 项目安全导入 QMuse 的命令行客户端",
5
5
  "type": "module",
6
6
  "bin": {