@qmuse/qmuse-cli 1.0.9-dev.1 → 1.0.9-dev.2
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 +20 -0
- package/dist/cli.js +24 -1
- package/dist/import/assets.js +12 -4
- package/dist/import/diagnostic.js +8 -7
- package/dist/import/project.js +3 -0
- package/dist/import/publish-repair.js +74 -0
- package/dist/import/update.js +1 -1
- package/dist/project-lock.js +36 -0
- package/dist/pull.js +249 -0
- package/package.json +1 -1
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,24 @@ 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
|
+
|
|
87
107
|
### 外部云服务
|
|
88
108
|
|
|
89
109
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -27,6 +27,8 @@ import { QMUSE_CLI_VERSION } from './version.js';
|
|
|
27
27
|
import { startUpdateNotice } from './update-notice.js';
|
|
28
28
|
import { startDevelopment } from './dev/index.js';
|
|
29
29
|
import { createProject } from './create.js';
|
|
30
|
+
import { pullProject } from './pull.js';
|
|
31
|
+
import { withProjectLock } from './project-lock.js';
|
|
30
32
|
// ---------------- 参数解析 ----------------
|
|
31
33
|
const BOOL_FLAGS = new Set([
|
|
32
34
|
'--help', '-h', '--version', '-V', '--json', '--status',
|
|
@@ -880,6 +882,7 @@ const HELP = `QMuse CLI —— 将本地 React 项目导入 QMuse
|
|
|
880
882
|
config [get|path] 查看配置
|
|
881
883
|
|
|
882
884
|
create <directory> 下载 QMuse 模板并解压到新项目目录
|
|
885
|
+
pull [directory] 免费同步已导入应用的最新已保存源码(本地有改动时停止)
|
|
883
886
|
|
|
884
887
|
cloud init -d "<应用功能描述>" 校验当前空间的 QMuse 云服务权益
|
|
885
888
|
cloud set-secret KEY1,KEY2 --stdin 从标准输入 JSON 保存密钥,不输出密钥值
|
|
@@ -946,6 +949,22 @@ async function main() {
|
|
|
946
949
|
case 'create':
|
|
947
950
|
await cmdCreate(command, flags);
|
|
948
951
|
return;
|
|
952
|
+
case 'pull': {
|
|
953
|
+
if (command.length > 2 || Object.keys(flags).some(key => !['--env', '--json'].includes(key))) {
|
|
954
|
+
throw new Error('用法:qmuse pull [directory] [--env prod|pre|local] [--json]');
|
|
955
|
+
}
|
|
956
|
+
const result = await pullProject(command[1] || '.', resolveCfg(flags), str(flags, '--env'));
|
|
957
|
+
if (flags['--json'] === true)
|
|
958
|
+
output(result, flags);
|
|
959
|
+
else {
|
|
960
|
+
log.success(result.noChanges ? '本地已是平台最新版本' : `已拉取平台版本 ${result.versionId}`);
|
|
961
|
+
if (result.backupDirectory)
|
|
962
|
+
log.info(`原源码备份:${result.backupDirectory}`);
|
|
963
|
+
if (!result.noChanges)
|
|
964
|
+
log.info('修改完成后执行 qmuse import,更新同一应用。');
|
|
965
|
+
}
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
949
968
|
case 'cloud':
|
|
950
969
|
await cmdCloud(command, flags);
|
|
951
970
|
return;
|
|
@@ -956,7 +975,11 @@ async function main() {
|
|
|
956
975
|
await cmdAssets(command, flags);
|
|
957
976
|
break;
|
|
958
977
|
case 'import':
|
|
959
|
-
|
|
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);
|
|
960
983
|
return;
|
|
961
984
|
case 'space':
|
|
962
985
|
await cmdSpace(command, flags);
|
package/dist/import/assets.js
CHANGED
|
@@ -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
|
-
'
|
|
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:
|
|
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 {
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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,
|
package/dist/import/project.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/import/update.js
CHANGED
|
@@ -13,7 +13,7 @@ export async function resolveUpdateBase(options) {
|
|
|
13
13
|
if (remoteChanged && !force) {
|
|
14
14
|
const conflict = `平台版本已从 ${target.lastVersionId} 更新为 ${latest.id}`;
|
|
15
15
|
if (!interactive) {
|
|
16
|
-
throw new Error(`${conflict}
|
|
16
|
+
throw new Error(`${conflict}。请使用 qmuse pull 同步最新源码;确认以本地源码覆盖时,使用 qmuse import . --force`);
|
|
17
17
|
}
|
|
18
18
|
const answer = await prompt(`${conflict}。是否以本地全部源码覆盖平台代码(不会合并平台修改),并在原应用创建新版本?[y/N]:`);
|
|
19
19
|
if (!/^y(?:es)?$/iu.test(answer.trim())) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { lstat, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export async function withProjectLock(directory, action) {
|
|
4
|
+
const root = path.resolve(directory);
|
|
5
|
+
const metadata = path.join(root, '.qmuse');
|
|
6
|
+
const rootStat = await lstat(root);
|
|
7
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
8
|
+
throw new Error('项目目录不能是符号链接');
|
|
9
|
+
await mkdir(metadata, { recursive: true });
|
|
10
|
+
if ((await lstat(metadata)).isSymbolicLink())
|
|
11
|
+
throw new Error('.qmuse 不能是符号链接');
|
|
12
|
+
const lock = path.join(metadata, 'sync.lock');
|
|
13
|
+
try {
|
|
14
|
+
await mkdir(lock);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code === 'EEXIST') {
|
|
18
|
+
throw new Error(`项目正在导入或拉取。若上次进程已退出,请先检查 .qmuse/pull-backups,再移除 ${lock}`);
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const recovery = await lstat(path.join(metadata, 'pull-recovery.json')).catch(error => {
|
|
24
|
+
if (error.code === 'ENOENT')
|
|
25
|
+
return undefined;
|
|
26
|
+
throw error;
|
|
27
|
+
});
|
|
28
|
+
if (recovery)
|
|
29
|
+
throw new Error('上次拉取未完成,请按 .qmuse/pull-recovery.json 的备份路径恢复文件和 project.json,确认后移除恢复标记');
|
|
30
|
+
await writeFile(path.join(lock, 'owner.json'), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
|
|
31
|
+
return await action();
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
await rm(lock, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
package/dist/pull.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { copyFile, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { authHeaders, buildUrl, hasAuth, requestSourceHeaders } from './client.js';
|
|
6
|
+
import { loadManagedProject, saveManagedProject } from './import/project.js';
|
|
7
|
+
import { preflightImport } from './import/preflight.js';
|
|
8
|
+
import { withProjectLock } from './project-lock.js';
|
|
9
|
+
const MAX_RESPONSE_BYTES = 150 * 1024 * 1024;
|
|
10
|
+
const MAX_SOURCE_BYTES = 100 * 1024 * 1024;
|
|
11
|
+
const PROTECTED = new Set(['.git', '.qmuse', '.agents', '.local', '.muse-tmp', 'node_modules', 'dist', 'build', '.vite', '.turbo', '.qmuse-cli.json']);
|
|
12
|
+
async function download(cfg, body) {
|
|
13
|
+
const response = await fetch(buildUrl(cfg, '/api/app-import/pull'), {
|
|
14
|
+
method: 'POST', headers: { ...authHeaders(cfg), ...requestSourceHeaders(cfg) },
|
|
15
|
+
body: JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(120_000),
|
|
16
|
+
});
|
|
17
|
+
if (Number(response.headers.get('content-length')) > MAX_RESPONSE_BYTES) {
|
|
18
|
+
await response.body?.cancel();
|
|
19
|
+
throw new Error('平台源码响应超过 150MB 限制');
|
|
20
|
+
}
|
|
21
|
+
const reader = response.body?.getReader();
|
|
22
|
+
if (!reader)
|
|
23
|
+
throw new Error('平台未返回源码');
|
|
24
|
+
const chunks = [];
|
|
25
|
+
let bytes = 0;
|
|
26
|
+
try {
|
|
27
|
+
for (;;) {
|
|
28
|
+
const { done, value } = await reader.read();
|
|
29
|
+
if (done)
|
|
30
|
+
break;
|
|
31
|
+
bytes += value.byteLength;
|
|
32
|
+
if (bytes > MAX_RESPONSE_BYTES)
|
|
33
|
+
throw new Error('平台源码响应超过 150MB 限制');
|
|
34
|
+
chunks.push(value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await reader.cancel();
|
|
39
|
+
}
|
|
40
|
+
let result;
|
|
41
|
+
try {
|
|
42
|
+
result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new Error('平台未返回有效的同步响应,请确认 MusePub 已部署 qmuse pull 接口');
|
|
46
|
+
}
|
|
47
|
+
if (!response.ok || result?.success !== true) {
|
|
48
|
+
throw new Error(`拉取失败:${result?.errorMessage || result?.errorCode || `HTTP ${response.status}`}`);
|
|
49
|
+
}
|
|
50
|
+
return result.data;
|
|
51
|
+
}
|
|
52
|
+
function safePath(name) {
|
|
53
|
+
if (!name || name !== name.normalize('NFC') || /[\\:\x00-\x1f\x7f]/u.test(name) ||
|
|
54
|
+
name.split('/').some(part => !part || part === '.' || part === '..' || /[. ]$/u.test(part))) {
|
|
55
|
+
throw new Error(`平台源码包含不安全路径:${name}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function stageSnapshot(snapshot, directory) {
|
|
59
|
+
if (!snapshot.files || typeof snapshot.files !== 'object' || Array.isArray(snapshot.files))
|
|
60
|
+
throw new Error('平台源码文件列表无效');
|
|
61
|
+
const entries = Object.entries(snapshot.files);
|
|
62
|
+
if (!entries.length || entries.length > 20_000)
|
|
63
|
+
throw new Error('平台源码文件数无效');
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
const directories = new Map();
|
|
66
|
+
let bytes = 0;
|
|
67
|
+
for (const [name, entry] of entries) {
|
|
68
|
+
safePath(name);
|
|
69
|
+
if (!entry || entry.filePath !== name || typeof entry.value !== 'string' ||
|
|
70
|
+
(entry.encoding !== undefined && !['utf8', 'base64'].includes(entry.encoding)))
|
|
71
|
+
throw new Error(`平台源码格式无效:${name}`);
|
|
72
|
+
const folded = name.toLowerCase();
|
|
73
|
+
if (seen.has(folded))
|
|
74
|
+
throw new Error(`平台源码路径大小写冲突:${name}`);
|
|
75
|
+
seen.add(folded);
|
|
76
|
+
const parts = name.split('/');
|
|
77
|
+
for (let index = 1; index < parts.length; index++) {
|
|
78
|
+
const prefix = parts.slice(0, index).join('/');
|
|
79
|
+
const previous = directories.get(prefix.toLowerCase());
|
|
80
|
+
if (previous && previous !== prefix)
|
|
81
|
+
throw new Error(`平台源码目录大小写冲突:${name}`);
|
|
82
|
+
directories.set(prefix.toLowerCase(), prefix);
|
|
83
|
+
}
|
|
84
|
+
if (name.split('/').some(part => PROTECTED.has(part) || part === '.env' || part.startsWith('.env.')) || name === 'AGENTS.md')
|
|
85
|
+
continue;
|
|
86
|
+
const content = Buffer.from(entry.value, entry.encoding || 'utf8');
|
|
87
|
+
if (entry.encoding === 'base64' && content.toString('base64') !== entry.value)
|
|
88
|
+
throw new Error(`平台二进制编码无效:${name}`);
|
|
89
|
+
bytes += content.length;
|
|
90
|
+
if (bytes > MAX_SOURCE_BYTES)
|
|
91
|
+
throw new Error('平台源码超过 100MB 限制');
|
|
92
|
+
const destination = path.join(directory, name);
|
|
93
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
94
|
+
await writeFile(destination, content, { flag: 'wx' });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function assertParents(root, name) {
|
|
98
|
+
let current = root;
|
|
99
|
+
for (const part of name.split('/').slice(0, -1)) {
|
|
100
|
+
current = path.join(current, part);
|
|
101
|
+
const stat = await lstat(current).catch(error => {
|
|
102
|
+
if (error.code === 'ENOENT')
|
|
103
|
+
return undefined;
|
|
104
|
+
throw error;
|
|
105
|
+
});
|
|
106
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
|
|
107
|
+
throw new Error(`本地路径冲突:${name}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export async function pullProject(directory, cfg, environment) {
|
|
111
|
+
if (!hasAuth(cfg))
|
|
112
|
+
throw new Error('请先执行 qmuse login');
|
|
113
|
+
return withProjectLock(directory, async () => {
|
|
114
|
+
const root = path.resolve(directory);
|
|
115
|
+
const context = await loadManagedProject(root, cfg, environment);
|
|
116
|
+
if (!context?.target)
|
|
117
|
+
throw new Error('当前环境没有导入绑定,请先执行 qmuse import');
|
|
118
|
+
if (Object.keys(context.manifest.pendingImports || {}).length)
|
|
119
|
+
throw new Error('存在未完成的导入,请先执行 qmuse import status <jobId> --wait');
|
|
120
|
+
const target = context.target;
|
|
121
|
+
const before = await preflightImport(root);
|
|
122
|
+
if (before.sourceHash !== target.lastSourceHash)
|
|
123
|
+
throw new Error('本地源码自上次导入或拉取后已有改动,已停止拉取。请先备份并处理这些改动;Git 提交不会改变 QMuse 同步基线。');
|
|
124
|
+
const originalManifest = await readFile(context.manifestPath);
|
|
125
|
+
const snapshot = await download({ ...cfg, spaceId: target.spaceId }, {
|
|
126
|
+
appId: target.appId, spaceId: target.spaceId,
|
|
127
|
+
importVersionId: target.importVersionId || target.lastVersionId, lastVersionId: target.lastVersionId,
|
|
128
|
+
});
|
|
129
|
+
if (!snapshot || snapshot.appId !== target.appId || !/^\d+$/u.test(snapshot.versionId) ||
|
|
130
|
+
snapshot.importVersionId !== (target.importVersionId || target.lastVersionId) || typeof snapshot.unchanged !== 'boolean') {
|
|
131
|
+
throw new Error('平台返回的应用或版本与绑定不一致');
|
|
132
|
+
}
|
|
133
|
+
if (snapshot.unchanged) {
|
|
134
|
+
if (snapshot.versionId !== target.lastVersionId)
|
|
135
|
+
throw new Error('平台返回的版本状态不一致');
|
|
136
|
+
return { appId: target.appId, versionId: snapshot.versionId, noChanges: true };
|
|
137
|
+
}
|
|
138
|
+
const backups = path.join(root, '.qmuse', 'pull-backups');
|
|
139
|
+
await mkdir(backups, { recursive: true });
|
|
140
|
+
if ((await lstat(backups)).isSymbolicLink())
|
|
141
|
+
throw new Error('备份目录不能是符号链接');
|
|
142
|
+
const transaction = await mkdtemp(path.join(backups, 'pull-'));
|
|
143
|
+
const stage = path.join(transaction, 'incoming');
|
|
144
|
+
const backup = path.join(transaction, 'original');
|
|
145
|
+
const recoveryPath = path.join(root, '.qmuse', 'pull-recovery.json');
|
|
146
|
+
await mkdir(stage);
|
|
147
|
+
await mkdir(backup);
|
|
148
|
+
const moved = [];
|
|
149
|
+
const written = [];
|
|
150
|
+
let applying = false;
|
|
151
|
+
let manifestSaved = false;
|
|
152
|
+
try {
|
|
153
|
+
await stageSnapshot(snapshot, stage);
|
|
154
|
+
// Validate the platform manifest through the same parser used by import.
|
|
155
|
+
if (!snapshot.project || snapshot.project.projectKind !== 'QMUSE_TEMPLATE')
|
|
156
|
+
throw new Error('平台未返回有效的工程模板信息');
|
|
157
|
+
const nextManifest = {
|
|
158
|
+
...snapshot.project, defaultSpaceId: context.manifest.defaultSpaceId,
|
|
159
|
+
targets: context.manifest.targets, pendingImports: context.manifest.pendingImports,
|
|
160
|
+
};
|
|
161
|
+
await mkdir(path.join(stage, '.qmuse'), { recursive: true });
|
|
162
|
+
await writeFile(path.join(stage, '.qmuse', 'project.json'), JSON.stringify(nextManifest));
|
|
163
|
+
const validated = await loadManagedProject(stage, cfg, environment);
|
|
164
|
+
if (!validated)
|
|
165
|
+
throw new Error('平台工程模板信息无效');
|
|
166
|
+
const incoming = await preflightImport(stage);
|
|
167
|
+
const oldPaths = new Set(before.files.map(file => file.relativePath));
|
|
168
|
+
for (const file of incoming.files) {
|
|
169
|
+
await assertParents(root, file.relativePath);
|
|
170
|
+
const existing = await lstat(path.join(root, file.relativePath)).catch(error => {
|
|
171
|
+
if (error.code === 'ENOENT')
|
|
172
|
+
return undefined;
|
|
173
|
+
throw error;
|
|
174
|
+
});
|
|
175
|
+
if (existing && (!oldPaths.has(file.relativePath) || !existing.isFile() || existing.isSymbolicLink())) {
|
|
176
|
+
throw new Error(`平台文件与本地保留文件冲突,已停止:${file.relativePath}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if ((await preflightImport(root)).sourceHash !== before.sourceHash ||
|
|
180
|
+
!(await readFile(context.manifestPath)).equals(originalManifest))
|
|
181
|
+
throw new Error('下载期间本地源码或绑定发生变化,已停止拉取');
|
|
182
|
+
await writeFile(path.join(transaction, 'project.json'), originalManifest, { flag: 'wx' });
|
|
183
|
+
await writeFile(path.join(transaction, 'transaction.json'), JSON.stringify({
|
|
184
|
+
appId: target.appId, from: target.lastVersionId, to: snapshot.versionId,
|
|
185
|
+
originalFiles: before.files.map(file => file.relativePath), incomingFiles: incoming.files.map(file => file.relativePath),
|
|
186
|
+
}, null, 2));
|
|
187
|
+
await writeFile(recoveryPath, JSON.stringify({ backupDirectory: transaction }), { flag: 'wx' });
|
|
188
|
+
applying = true;
|
|
189
|
+
for (const file of before.files) {
|
|
190
|
+
await assertParents(root, file.relativePath);
|
|
191
|
+
const destination = path.join(backup, file.relativePath);
|
|
192
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
193
|
+
await rename(file.absolutePath, destination);
|
|
194
|
+
moved.push(file.relativePath);
|
|
195
|
+
if (createHash('sha256').update(await readFile(destination)).digest('hex') !== file.sha256)
|
|
196
|
+
throw new Error('写入期间本地文件发生变化');
|
|
197
|
+
}
|
|
198
|
+
for (const file of incoming.files) {
|
|
199
|
+
await assertParents(root, file.relativePath);
|
|
200
|
+
const destination = path.join(root, file.relativePath);
|
|
201
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
202
|
+
await copyFile(file.absolutePath, destination, constants.COPYFILE_EXCL);
|
|
203
|
+
written.push({ name: file.relativePath, sha256: file.sha256 });
|
|
204
|
+
}
|
|
205
|
+
const after = await preflightImport(root);
|
|
206
|
+
if (after.sourceHash !== incoming.sourceHash)
|
|
207
|
+
throw new Error('本地保留文件与平台源码的忽略规则冲突,已停止拉取');
|
|
208
|
+
context.manifest = validated.manifest;
|
|
209
|
+
context.manifest.targets[context.environment] = {
|
|
210
|
+
...target, importVersionId: snapshot.importVersionId,
|
|
211
|
+
lastVersionId: snapshot.versionId, lastSourceHash: after.sourceHash, updatedAt: new Date().toISOString(),
|
|
212
|
+
};
|
|
213
|
+
if (!(await readFile(context.manifestPath)).equals(originalManifest))
|
|
214
|
+
throw new Error('写入期间本地绑定发生变化');
|
|
215
|
+
await saveManagedProject(context);
|
|
216
|
+
manifestSaved = true;
|
|
217
|
+
await rm(recoveryPath);
|
|
218
|
+
return { appId: target.appId, versionId: snapshot.versionId, noChanges: false, fileCount: after.fileCount, backupDirectory: transaction };
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (applying) {
|
|
222
|
+
try {
|
|
223
|
+
for (const file of written.reverse()) {
|
|
224
|
+
const filename = path.join(root, file.name);
|
|
225
|
+
await assertParents(root, file.name);
|
|
226
|
+
const current = await readFile(filename);
|
|
227
|
+
if (createHash('sha256').update(current).digest('hex') !== file.sha256)
|
|
228
|
+
throw new Error(`写入期间文件被修改:${file.name}`);
|
|
229
|
+
await rm(filename);
|
|
230
|
+
}
|
|
231
|
+
for (const name of moved) {
|
|
232
|
+
await assertParents(root, name);
|
|
233
|
+
await copyFile(path.join(backup, name), path.join(root, name), constants.COPYFILE_EXCL);
|
|
234
|
+
}
|
|
235
|
+
if (manifestSaved)
|
|
236
|
+
await writeFile(context.manifestPath, originalManifest);
|
|
237
|
+
await rm(recoveryPath);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
throw new Error(`拉取失败且回滚未完成,原文件保存在 ${transaction},请从备份恢复后再导入。原错误:${error instanceof Error ? error.message : error}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
await rm(transaction, { recursive: true, force: true });
|
|
245
|
+
}
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|