@qmuse/qmuse-cli 1.0.8 → 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 +33 -0
- package/dist/cli.js +46 -1
- package/dist/import/assets.js +173 -0
- package/dist/import/diagnostic.js +9 -0
- 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
|
@@ -49,6 +49,21 @@ npm install
|
|
|
49
49
|
|
|
50
50
|
支持 `qmuse --env pre create my-app` 下载预发模板,以及 `--json` 输出项目目录和模板 commit。后续导入使用相同的 `--env`。
|
|
51
51
|
|
|
52
|
+
### 大资源上传与发布体积检查
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
qmuse assets upload public/photo.png public/audio/clip.wav --json
|
|
56
|
+
# 文件路径相对于项目;可加 --project /path/to/project、--space-id <id>、--env pre
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
返回本地路径、SHA-256、大小、文件 ID、稳定 CDN 地址及修复步骤。映射保存在 `.qmuse/assets/`,按 API 环境和空间隔离;失败重试复用已上传文件,重新完成登记和内容检查。旧服务端会在上传前提示升级。
|
|
60
|
+
|
|
61
|
+
让本地模型根据映射替换全部引用,尤其是动态路径、JSON 音频清单和 Three.js 加载器;验证构建及页面的图片、纹理、音频播放后,再删除本地副本并重新 `qmuse import .`。上传命令不会自动改代码或删除资源。支持常用图片、音视频,单文件最多 100 MiB、单次 200 个;SVG、字体及模型关联文件暂不支持。
|
|
62
|
+
|
|
63
|
+
导入时会生成 `.qmuse/asset-preflight.json`。源码资源总大小仅用于预警;服务端在 READY 前用 APC dry-run 检查实际压缩发布包。初始预算为 10,000,000 字节减去 65,536 字节预留量,超限返回可供模型修复的 `publishBudget` 诊断。此预算不等于已经证实的网关限制,也不代表未来代码或运行配置变更后的发布保证。
|
|
64
|
+
|
|
65
|
+
超限后的 `.qmuse/last-import-error.json` 按媒体、数据、代码及关联资源分类给出建议:JSON 数据需精简或设计外置加载,JS/CSS 需优化依赖及构建产物,SVG、字体和模型需单独处理。服务端大文件清单使用构建产物路径;CLI 重新检查本地项目后列出的媒体候选与之没有自动映射,须确认源文件后再上传。缺少清单或可确认的本地媒体时不生成上传命令;修复后按诊断中的应用绑定与重试建议操作,避免重复创建应用。
|
|
66
|
+
|
|
52
67
|
### 导入本地代码
|
|
53
68
|
|
|
54
69
|
```bash
|
|
@@ -71,6 +86,24 @@ qmuse import /path/to/react-project --space-id <spaceId>
|
|
|
71
86
|
|
|
72
87
|
平台已有新版本时,终端会询问是否用本地全部源码覆盖,不会自动合并平台修改。确认覆盖可执行 `qmuse import . --force`;脚本或 `--json` 模式须显式指定该参数。覆盖会在原应用创建新版本,仍需通过正常导入校验。
|
|
73
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
|
+
|
|
74
107
|
### 外部云服务
|
|
75
108
|
|
|
76
109
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ import { homedir } from 'node:os';
|
|
|
16
16
|
import { createImportArchive } from './import/archive.js';
|
|
17
17
|
import { configureAppImport, assertTemplateProtocolSupported, getAppOpenUrl, getImportStatusUrl, queryAppImportStatus, startAppImport, uploadImportArchive, } from './import/api.js';
|
|
18
18
|
import { pollAppImport } from './import/poll.js';
|
|
19
|
+
import { uploadLocalAssets, writeAssetPreflightReport } from './import/assets.js';
|
|
19
20
|
import { preflightImport } from './import/preflight.js';
|
|
20
21
|
import { resolveUpdateBase } from './import/update.js';
|
|
21
22
|
import { clearPendingImport, completeManagedImport, createExternalProject, importArchitectureProtocol, loadManagedProject, saveManagedProject, resolveProjectEnvironment, setPendingImport, } from './import/project.js';
|
|
@@ -26,6 +27,8 @@ import { QMUSE_CLI_VERSION } from './version.js';
|
|
|
26
27
|
import { startUpdateNotice } from './update-notice.js';
|
|
27
28
|
import { startDevelopment } from './dev/index.js';
|
|
28
29
|
import { createProject } from './create.js';
|
|
30
|
+
import { pullProject } from './pull.js';
|
|
31
|
+
import { withProjectLock } from './project-lock.js';
|
|
29
32
|
// ---------------- 参数解析 ----------------
|
|
30
33
|
const BOOL_FLAGS = new Set([
|
|
31
34
|
'--help', '-h', '--version', '-V', '--json', '--status',
|
|
@@ -358,6 +361,19 @@ async function finishImport(status, sourceHash, cfg, flags, managedProject, root
|
|
|
358
361
|
log.warn('无法自动打开浏览器');
|
|
359
362
|
}
|
|
360
363
|
}
|
|
364
|
+
async function cmdAssets(command, flags) {
|
|
365
|
+
if (command[1] !== 'upload' || command.length < 3)
|
|
366
|
+
fail('用法: qmuse assets upload <relative-file> [more-files...] [--project <dir>] [--space-id <id>] [--json]');
|
|
367
|
+
const cfg = resolveCfg(flags);
|
|
368
|
+
requireAuth(cfg);
|
|
369
|
+
const rootDir = path.resolve(str(flags, '--project') || '.');
|
|
370
|
+
const project = await loadManagedProject(rootDir, cfg, str(flags, '--env'));
|
|
371
|
+
const spaceId = str(flags, '--space-id') || project?.target?.spaceId || project?.manifest.defaultSpaceId || cfg.spaceId;
|
|
372
|
+
if (!spaceId)
|
|
373
|
+
fail('请指定资源所属空间 --space-id');
|
|
374
|
+
const result = await uploadLocalAssets({ rootDir, files: command.slice(2), cfg, spaceId });
|
|
375
|
+
output({ success: true, ...result }, flags);
|
|
376
|
+
}
|
|
361
377
|
async function cmdImport(command, flags) {
|
|
362
378
|
const cfg = resolveCfg(flags);
|
|
363
379
|
requireAuth(cfg);
|
|
@@ -468,6 +484,10 @@ async function cmdImport(command, flags) {
|
|
|
468
484
|
if (flags['--json'] !== true)
|
|
469
485
|
log.info('正在只读预检本地仓库...');
|
|
470
486
|
const preflight = await preflightImport(directory);
|
|
487
|
+
const assetReportPath = await writeAssetPreflightReport(preflight);
|
|
488
|
+
if (preflight.assets.reduce((sum, asset) => sum + asset.sizeBytes, 0) >= 10_000_000 && flags['--json'] !== true) {
|
|
489
|
+
log.warn(`本地静态资源较大,可能超过发布预算。清单:${assetReportPath};可先执行 qmuse assets upload <资源路径> --json。最终以服务端 APC 压缩包检查为准。`);
|
|
490
|
+
}
|
|
471
491
|
const updateBase = managedProject?.target
|
|
472
492
|
? await resolveUpdateBase({
|
|
473
493
|
target: managedProject.target,
|
|
@@ -862,6 +882,7 @@ const HELP = `QMuse CLI —— 将本地 React 项目导入 QMuse
|
|
|
862
882
|
config [get|path] 查看配置
|
|
863
883
|
|
|
864
884
|
create <directory> 下载 QMuse 模板并解压到新项目目录
|
|
885
|
+
pull [directory] 免费同步已导入应用的最新已保存源码(本地有改动时停止)
|
|
865
886
|
|
|
866
887
|
cloud init -d "<应用功能描述>" 校验当前空间的 QMuse 云服务权益
|
|
867
888
|
cloud set-secret KEY1,KEY2 --stdin 从标准输入 JSON 保存密钥,不输出密钥值
|
|
@@ -870,6 +891,7 @@ const HELP = `QMuse CLI —— 将本地 React 项目导入 QMuse
|
|
|
870
891
|
cloud schema export [--app-id <id>] 输出当前开发环境的 READY schema
|
|
871
892
|
dev [directory] [--port <port>] 连接已导入应用的开发云资源并启动本地 Vite
|
|
872
893
|
|
|
894
|
+
assets upload <file> [more-files...] --json 上传媒体到 CDN,返回引用替换清单
|
|
873
895
|
import <directory> [--space-id <id>] 导入本地 React 仓库
|
|
874
896
|
[--title <title>] [--force] [--cloud-mode AUTO|EXTERNAL|QMUSE]
|
|
875
897
|
[--public-config '<json>'|@file] [--acknowledge-empty-cloud-data]
|
|
@@ -927,14 +949,37 @@ async function main() {
|
|
|
927
949
|
case 'create':
|
|
928
950
|
await cmdCreate(command, flags);
|
|
929
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
|
+
}
|
|
930
968
|
case 'cloud':
|
|
931
969
|
await cmdCloud(command, flags);
|
|
932
970
|
return;
|
|
933
971
|
case 'dev':
|
|
934
972
|
await cmdDev(command, flags);
|
|
935
973
|
return;
|
|
974
|
+
case 'assets':
|
|
975
|
+
await cmdAssets(command, flags);
|
|
976
|
+
break;
|
|
936
977
|
case 'import':
|
|
937
|
-
|
|
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);
|
|
938
983
|
return;
|
|
939
984
|
case 'space':
|
|
940
985
|
await cmdSpace(command, flags);
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { apiCall, hasAuth } from '../client.js';
|
|
5
|
+
import { resolveProjectEnvironment } from './project.js';
|
|
6
|
+
const MEDIA = {
|
|
7
|
+
'.png': { mimeType: 'image/png', materialType: 'image' },
|
|
8
|
+
'.jpg': { mimeType: 'image/jpeg', materialType: 'image' },
|
|
9
|
+
'.jpeg': { mimeType: 'image/jpeg', materialType: 'image' },
|
|
10
|
+
'.gif': { mimeType: 'image/gif', materialType: 'image' },
|
|
11
|
+
'.webp': { mimeType: 'image/webp', materialType: 'image' },
|
|
12
|
+
'.avif': { mimeType: 'image/avif', materialType: 'image' },
|
|
13
|
+
'.mp3': { mimeType: 'audio/mpeg', materialType: 'audio' },
|
|
14
|
+
'.wav': { mimeType: 'audio/wav', materialType: 'audio' },
|
|
15
|
+
'.ogg': { mimeType: 'audio/ogg', materialType: 'audio' },
|
|
16
|
+
'.mp4': { mimeType: 'video/mp4', materialType: 'video' },
|
|
17
|
+
'.webm': { mimeType: 'video/webm', materialType: 'video' },
|
|
18
|
+
};
|
|
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
|
+
}
|
|
24
|
+
export const ASSET_REPAIR_STEPS = [
|
|
25
|
+
'按 sourcePath、sha256 和 stableUrl 替换全部资源引用,包括动态拼接、JSON 清单、CSS、HTML 和 Three.js 加载器。',
|
|
26
|
+
'保留 URL 的运行时语义;fetch、纹理、音频须验证 CORS、解码和播放,模型资源须检查关联文件及相对路径。',
|
|
27
|
+
'检查 assetRoot + clip.url 等拼接:完整 CDN 地址不能再拼接本地目录;JSON 清单和读取清单的代码需要一起验证。',
|
|
28
|
+
'运行构建和相关页面检查,确认没有旧引用后,再删除已迁移资源的本地副本。不要仅凭上传成功删除文件。',
|
|
29
|
+
'确认当前环境的应用绑定后,重新执行 qmuse import .;服务端会重新构建并检查 APC 压缩发布包,单纯上传 CDN 不会减少发布体积。',
|
|
30
|
+
];
|
|
31
|
+
async function writeJson(file, value) {
|
|
32
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
33
|
+
try {
|
|
34
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
35
|
+
await rename(temporary, file);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await rm(temporary, { force: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Source bytes are an early warning only; they are not APC compressed package bytes. */
|
|
42
|
+
export async function writeAssetPreflightReport(preflight) {
|
|
43
|
+
const directory = path.join(preflight.rootDir, '.qmuse');
|
|
44
|
+
await mkdir(directory, { recursive: true });
|
|
45
|
+
const file = path.join(directory, 'asset-preflight.json');
|
|
46
|
+
const hasUploadCandidates = preflight.assets.some(asset => supportsAssetUpload(asset.sourcePath, asset.sizeBytes));
|
|
47
|
+
await writeJson(file, {
|
|
48
|
+
schemaVersion: 1, metric: 'SOURCE_ASSET_BYTES',
|
|
49
|
+
totalAssetBytes: preflight.assets.reduce((sum, asset) => sum + asset.sizeBytes, 0),
|
|
50
|
+
finalPublishSizeCheck: 'SERVER_APC_DRY_RUN_BEFORE_READY',
|
|
51
|
+
assets: preflight.assets.map(asset => ({ ...asset,
|
|
52
|
+
sha256: preflight.files.find(file => file.relativePath === asset.sourcePath)?.sha256,
|
|
53
|
+
uploadSupported: supportsAssetUpload(asset.sourcePath, asset.sizeBytes),
|
|
54
|
+
})).sort((a, b) => b.sizeBytes - a.sizeBytes),
|
|
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
|
+
],
|
|
59
|
+
});
|
|
60
|
+
return file;
|
|
61
|
+
}
|
|
62
|
+
function requireStableUrl(value) {
|
|
63
|
+
const url = new URL(value);
|
|
64
|
+
if (url.protocol !== 'https:' || url.username || url.password ||
|
|
65
|
+
[...url.searchParams.keys()].some(key => /^(?:expires|signature|x-oss-signature|x-oss-expires)$/iu.test(key))) {
|
|
66
|
+
throw new Error('CDN 未返回稳定 HTTPS 地址');
|
|
67
|
+
}
|
|
68
|
+
return url.toString();
|
|
69
|
+
}
|
|
70
|
+
export async function uploadLocalAssets(input) {
|
|
71
|
+
if (!hasAuth(input.cfg))
|
|
72
|
+
throw new Error('请先登录 QMuse');
|
|
73
|
+
if (!input.spaceId)
|
|
74
|
+
throw new Error('请指定资源所属空间 --space-id');
|
|
75
|
+
if (!input.files.length || input.files.length > 200)
|
|
76
|
+
throw new Error('每次上传需指定 1 到 200 个资源文件');
|
|
77
|
+
const root = await realpath(input.rootDir);
|
|
78
|
+
const cfg = { ...input.cfg, spaceId: input.spaceId };
|
|
79
|
+
const scope = createHash('sha256').update(`${cfg.baseUrl}\n${input.spaceId}`).digest('hex').slice(0, 24);
|
|
80
|
+
const directory = path.join(root, '.qmuse', 'assets');
|
|
81
|
+
await mkdir(directory, { recursive: true });
|
|
82
|
+
const manifestPath = path.join(directory, `${scope}.json`);
|
|
83
|
+
const lock = `${manifestPath}.lock`;
|
|
84
|
+
try {
|
|
85
|
+
await mkdir(lock);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new Error(`资源上传正在运行;若进程已退出,请检查并删除锁目录 ${lock}`);
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const protocol = await apiCall('GET', '/api/app-import/protocol', undefined, { cfg, redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
92
|
+
if (!protocol.ok || protocol.data?.localAssetUpload !== true)
|
|
93
|
+
throw new Error('当前服务端尚不支持 CLI 资源上传,请先升级服务端后重试');
|
|
94
|
+
let cached = [];
|
|
95
|
+
try {
|
|
96
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
97
|
+
if (manifest.apiBaseUrl === cfg.baseUrl && manifest.spaceId === input.spaceId && Array.isArray(manifest.mappings))
|
|
98
|
+
cached = manifest.mappings;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error.code !== 'ENOENT')
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
const save = () => writeJson(manifestPath, { schemaVersion: 1, apiBaseUrl: cfg.baseUrl, spaceId: input.spaceId, mappings: cached });
|
|
105
|
+
const mappings = [];
|
|
106
|
+
for (const file of [...new Set(input.files)]) {
|
|
107
|
+
const absolute = path.resolve(root, file);
|
|
108
|
+
const sourcePath = path.relative(root, absolute).split(path.sep).join('/');
|
|
109
|
+
if (!sourcePath || sourcePath.startsWith('../') || path.isAbsolute(sourcePath))
|
|
110
|
+
throw new Error(`资源不能位于项目外:${file}`);
|
|
111
|
+
// Reject symbolic links, including links in parent directories.
|
|
112
|
+
let current = root;
|
|
113
|
+
for (const segment of sourcePath.split('/')) {
|
|
114
|
+
current = path.join(current, segment);
|
|
115
|
+
if ((await lstat(current)).isSymbolicLink())
|
|
116
|
+
throw new Error(`不支持符号链接:${sourcePath}`);
|
|
117
|
+
}
|
|
118
|
+
const extension = path.extname(sourcePath).toLowerCase();
|
|
119
|
+
const media = MEDIA[extension];
|
|
120
|
+
if (!media)
|
|
121
|
+
throw new Error(`暂不支持上传该类型:${sourcePath};SVG、字体和含关联文件的模型资源需保留并单独处理`);
|
|
122
|
+
const stat = await lstat(absolute);
|
|
123
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_ASSET_BYTES)
|
|
124
|
+
throw new Error(`资源必须是 1 字节至 100 MiB 的普通文件:${sourcePath}`);
|
|
125
|
+
const bytes = await readFile(absolute);
|
|
126
|
+
if (bytes.length <= 0 || bytes.length > MAX_ASSET_BYTES)
|
|
127
|
+
throw new Error(`读取期间资源大小改变:${sourcePath}`);
|
|
128
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
129
|
+
let mapping = cached.find(item => item.sourcePath === sourcePath && item.sha256 === sha256 && item.sizeBytes === bytes.length && item.mimeType === media.mimeType && typeof item.fileId === 'string');
|
|
130
|
+
if (!mapping) {
|
|
131
|
+
const grant = await apiCall('GET', `/api/file/upload-url?spaceId=${encodeURIComponent(input.spaceId)}`, undefined, { cfg, redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
132
|
+
if (!grant.ok || !grant.data?.uploadUrl)
|
|
133
|
+
throw new Error(`获取上传地址失败:${grant.errorMessage ?? grant.errorCode ?? grant.status}`);
|
|
134
|
+
const url = new URL(grant.data.uploadUrl);
|
|
135
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
136
|
+
throw new Error('上传地址必须是 HTTPS');
|
|
137
|
+
url.searchParams.set('public', 'true');
|
|
138
|
+
const form = new FormData();
|
|
139
|
+
form.append('file', new Blob([bytes], { type: media.mimeType }), path.basename(sourcePath));
|
|
140
|
+
// No session, cookie or QMuse credentials are forwarded to the CDN.
|
|
141
|
+
const response = await fetch(url, { method: 'POST', redirect: 'error', body: form,
|
|
142
|
+
headers: { 'x-mass-ext-name': extension, 'x-afts-text-encoding': 'utf-8' }, signal: AbortSignal.timeout(300_000) });
|
|
143
|
+
if (!response.ok)
|
|
144
|
+
throw new Error(`CDN 上传失败:HTTP ${response.status}`);
|
|
145
|
+
const result = await response.json();
|
|
146
|
+
const uploaded = Array.isArray(result.data) ? result.data[0] : result.data;
|
|
147
|
+
if (result.code !== 0 || typeof uploaded?.id !== 'string' || !uploaded.id.trim())
|
|
148
|
+
throw new Error('CDN 上传未返回有效文件 ID');
|
|
149
|
+
mapping = { sourcePath, sha256, sizeBytes: bytes.length, mimeType: media.mimeType, fileId: uploaded.id };
|
|
150
|
+
cached = cached.filter(item => item.sourcePath !== sourcePath);
|
|
151
|
+
cached.push(mapping);
|
|
152
|
+
await save(); // Keep completed uploads even if registration/review or a later file fails.
|
|
153
|
+
}
|
|
154
|
+
delete mapping.stableUrl;
|
|
155
|
+
await save();
|
|
156
|
+
const registered = await apiCall('POST', '/api/app-import/assets/register', {
|
|
157
|
+
spaceId: input.spaceId, fileId: mapping.fileId, name: path.basename(sourcePath), materialType: media.materialType,
|
|
158
|
+
}, { cfg, redirect: 'error', signal: AbortSignal.timeout(60_000) });
|
|
159
|
+
if (!registered.ok || !registered.data)
|
|
160
|
+
throw new Error(`资源登记或内容检查未通过:${registered.errorMessage ?? registered.errorCode ?? registered.status};已上传文件保存在 ${manifestPath},重试将复用`);
|
|
161
|
+
if (registered.data.fileId !== mapping.fileId || registered.data.sizeBytes !== mapping.sizeBytes)
|
|
162
|
+
throw new Error(`CDN 资源大小或身份不一致:${sourcePath}`);
|
|
163
|
+
mapping.stableUrl = requireStableUrl(registered.data.stableUrl);
|
|
164
|
+
await save();
|
|
165
|
+
mappings.push({ ...mapping });
|
|
166
|
+
}
|
|
167
|
+
const environment = resolveProjectEnvironment(cfg);
|
|
168
|
+
return { mappings, manifestPath, repairSteps: ASSET_REPAIR_STEPS.map(step => step.replace('qmuse import .', `qmuse import . --env ${environment}`)) };
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
await rm(lock, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildPublishRepairGuidance } from './publish-repair.js';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
@@ -26,6 +27,14 @@ export async function writeImportFailureDiagnostic(input) {
|
|
|
26
27
|
message: input.status.message,
|
|
27
28
|
diagnostic: input.status.diagnostic,
|
|
28
29
|
failureDiagnostic: input.status.failureDiagnostic,
|
|
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
|
+
}),
|
|
37
|
+
} : {}),
|
|
29
38
|
...importRetryGuidance(input.status, input.project),
|
|
30
39
|
environment: input.project?.environment ?? input.environment,
|
|
31
40
|
generatedAt: new Date().toISOString(),
|
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
|
+
}
|