@qmuse/qmuse-cli 1.0.9-dev.2 → 1.0.9-dev.4
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 +8 -0
- package/dist/cli.js +39 -9
- package/dist/client.js +11 -9
- package/dist/import/api.js +7 -0
- package/dist/import/update.js +8 -1
- package/dist/operation-log.js +156 -0
- package/dist/operation-report.js +24 -0
- package/dist/project-lock.js +5 -1
- package/dist/pull.js +34 -5
- package/package.json +1 -1
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
|
*
|
|
@@ -9,7 +11,7 @@ import { hasAuth } from './client.js';
|
|
|
9
11
|
import { log } from './logger.js';
|
|
10
12
|
import * as api from './api.js';
|
|
11
13
|
import * as login from './login.js';
|
|
12
|
-
import { prompt } from './login.js';
|
|
14
|
+
import { prompt, closePrompt } from './login.js';
|
|
13
15
|
import { readFileSync } from 'node:fs';
|
|
14
16
|
import path from 'node:path';
|
|
15
17
|
import { homedir } from 'node:os';
|
|
@@ -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
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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);
|
|
@@ -1003,6 +1032,7 @@ async function main() {
|
|
|
1003
1032
|
process.exitCode = 1;
|
|
1004
1033
|
}
|
|
1005
1034
|
finally {
|
|
1035
|
+
closePrompt();
|
|
1006
1036
|
finishUpdateNotice();
|
|
1007
1037
|
process.off('exit', finishUpdateNotice);
|
|
1008
1038
|
}
|
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 {
|
package/dist/import/api.js
CHANGED
|
@@ -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 : '服务端拒绝上传';
|
package/dist/import/update.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/project-lock.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|