@qmuse/qmuse-cli 1.0.8-dev.1 → 1.0.9-dev.1
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 +13 -0
- package/dist/cli.js +22 -0
- package/dist/import/assets.js +165 -0
- package/dist/import/diagnostic.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,6 +49,19 @@ 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
|
+
|
|
52
65
|
### 导入本地代码
|
|
53
66
|
|
|
54
67
|
```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';
|
|
@@ -358,6 +359,19 @@ async function finishImport(status, sourceHash, cfg, flags, managedProject, root
|
|
|
358
359
|
log.warn('无法自动打开浏览器');
|
|
359
360
|
}
|
|
360
361
|
}
|
|
362
|
+
async function cmdAssets(command, flags) {
|
|
363
|
+
if (command[1] !== 'upload' || command.length < 3)
|
|
364
|
+
fail('用法: qmuse assets upload <relative-file> [more-files...] [--project <dir>] [--space-id <id>] [--json]');
|
|
365
|
+
const cfg = resolveCfg(flags);
|
|
366
|
+
requireAuth(cfg);
|
|
367
|
+
const rootDir = path.resolve(str(flags, '--project') || '.');
|
|
368
|
+
const project = await loadManagedProject(rootDir, cfg, str(flags, '--env'));
|
|
369
|
+
const spaceId = str(flags, '--space-id') || project?.target?.spaceId || project?.manifest.defaultSpaceId || cfg.spaceId;
|
|
370
|
+
if (!spaceId)
|
|
371
|
+
fail('请指定资源所属空间 --space-id');
|
|
372
|
+
const result = await uploadLocalAssets({ rootDir, files: command.slice(2), cfg, spaceId });
|
|
373
|
+
output({ success: true, ...result }, flags);
|
|
374
|
+
}
|
|
361
375
|
async function cmdImport(command, flags) {
|
|
362
376
|
const cfg = resolveCfg(flags);
|
|
363
377
|
requireAuth(cfg);
|
|
@@ -468,6 +482,10 @@ async function cmdImport(command, flags) {
|
|
|
468
482
|
if (flags['--json'] !== true)
|
|
469
483
|
log.info('正在只读预检本地仓库...');
|
|
470
484
|
const preflight = await preflightImport(directory);
|
|
485
|
+
const assetReportPath = await writeAssetPreflightReport(preflight);
|
|
486
|
+
if (preflight.assets.reduce((sum, asset) => sum + asset.sizeBytes, 0) >= 10_000_000 && flags['--json'] !== true) {
|
|
487
|
+
log.warn(`本地静态资源较大,可能超过发布预算。清单:${assetReportPath};可先执行 qmuse assets upload <资源路径> --json。最终以服务端 APC 压缩包检查为准。`);
|
|
488
|
+
}
|
|
471
489
|
const updateBase = managedProject?.target
|
|
472
490
|
? await resolveUpdateBase({
|
|
473
491
|
target: managedProject.target,
|
|
@@ -870,6 +888,7 @@ const HELP = `QMuse CLI —— 将本地 React 项目导入 QMuse
|
|
|
870
888
|
cloud schema export [--app-id <id>] 输出当前开发环境的 READY schema
|
|
871
889
|
dev [directory] [--port <port>] 连接已导入应用的开发云资源并启动本地 Vite
|
|
872
890
|
|
|
891
|
+
assets upload <file> [more-files...] --json 上传媒体到 CDN,返回引用替换清单
|
|
873
892
|
import <directory> [--space-id <id>] 导入本地 React 仓库
|
|
874
893
|
[--title <title>] [--force] [--cloud-mode AUTO|EXTERNAL|QMUSE]
|
|
875
894
|
[--public-config '<json>'|@file] [--acknowledge-empty-cloud-data]
|
|
@@ -933,6 +952,9 @@ async function main() {
|
|
|
933
952
|
case 'dev':
|
|
934
953
|
await cmdDev(command, flags);
|
|
935
954
|
return;
|
|
955
|
+
case 'assets':
|
|
956
|
+
await cmdAssets(command, flags);
|
|
957
|
+
break;
|
|
936
958
|
case 'import':
|
|
937
959
|
await cmdImport(command, flags);
|
|
938
960
|
return;
|
|
@@ -0,0 +1,165 @@
|
|
|
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 const ASSET_REPAIR_STEPS = [
|
|
21
|
+
'按 sourcePath、sha256 和 stableUrl 替换全部资源引用,包括动态拼接、JSON 清单、CSS、HTML 和 Three.js 加载器。',
|
|
22
|
+
'保留 URL 的运行时语义;fetch、纹理、音频须验证 CORS、解码和播放,模型资源须检查关联文件及相对路径。',
|
|
23
|
+
'运行构建和相关页面检查,确认没有旧引用后,再删除已迁移资源的本地副本。不要仅凭上传成功删除文件。',
|
|
24
|
+
'重新执行 qmuse import .;服务端会重新构建并检查 APC 压缩发布包,单纯上传 CDN 不会减少发布体积。',
|
|
25
|
+
];
|
|
26
|
+
async function writeJson(file, value) {
|
|
27
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
28
|
+
try {
|
|
29
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
30
|
+
await rename(temporary, file);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await rm(temporary, { force: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Source bytes are an early warning only; they are not APC compressed package bytes. */
|
|
37
|
+
export async function writeAssetPreflightReport(preflight) {
|
|
38
|
+
const directory = path.join(preflight.rootDir, '.qmuse');
|
|
39
|
+
await mkdir(directory, { recursive: true });
|
|
40
|
+
const file = path.join(directory, 'asset-preflight.json');
|
|
41
|
+
await writeJson(file, {
|
|
42
|
+
schemaVersion: 1, metric: 'SOURCE_ASSET_BYTES',
|
|
43
|
+
totalAssetBytes: preflight.assets.reduce((sum, asset) => sum + asset.sizeBytes, 0),
|
|
44
|
+
finalPublishSizeCheck: 'SERVER_APC_DRY_RUN_BEFORE_READY',
|
|
45
|
+
assets: preflight.assets.map(asset => ({ ...asset,
|
|
46
|
+
sha256: preflight.files.find(file => file.relativePath === asset.sourcePath)?.sha256,
|
|
47
|
+
uploadSupported: Boolean(MEDIA[path.extname(asset.sourcePath).toLowerCase()]),
|
|
48
|
+
})).sort((a, b) => b.sizeBytes - a.sizeBytes),
|
|
49
|
+
uploadCommand: 'qmuse assets upload <relative-file> [more-files...] --json',
|
|
50
|
+
repairSteps: ASSET_REPAIR_STEPS,
|
|
51
|
+
});
|
|
52
|
+
return file;
|
|
53
|
+
}
|
|
54
|
+
function requireStableUrl(value) {
|
|
55
|
+
const url = new URL(value);
|
|
56
|
+
if (url.protocol !== 'https:' || url.username || url.password ||
|
|
57
|
+
[...url.searchParams.keys()].some(key => /^(?:expires|signature|x-oss-signature|x-oss-expires)$/iu.test(key))) {
|
|
58
|
+
throw new Error('CDN 未返回稳定 HTTPS 地址');
|
|
59
|
+
}
|
|
60
|
+
return url.toString();
|
|
61
|
+
}
|
|
62
|
+
export async function uploadLocalAssets(input) {
|
|
63
|
+
if (!hasAuth(input.cfg))
|
|
64
|
+
throw new Error('请先登录 QMuse');
|
|
65
|
+
if (!input.spaceId)
|
|
66
|
+
throw new Error('请指定资源所属空间 --space-id');
|
|
67
|
+
if (!input.files.length || input.files.length > 200)
|
|
68
|
+
throw new Error('每次上传需指定 1 到 200 个资源文件');
|
|
69
|
+
const root = await realpath(input.rootDir);
|
|
70
|
+
const cfg = { ...input.cfg, spaceId: input.spaceId };
|
|
71
|
+
const scope = createHash('sha256').update(`${cfg.baseUrl}\n${input.spaceId}`).digest('hex').slice(0, 24);
|
|
72
|
+
const directory = path.join(root, '.qmuse', 'assets');
|
|
73
|
+
await mkdir(directory, { recursive: true });
|
|
74
|
+
const manifestPath = path.join(directory, `${scope}.json`);
|
|
75
|
+
const lock = `${manifestPath}.lock`;
|
|
76
|
+
try {
|
|
77
|
+
await mkdir(lock);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw new Error(`资源上传正在运行;若进程已退出,请检查并删除锁目录 ${lock}`);
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const protocol = await apiCall('GET', '/api/app-import/protocol', undefined, { cfg, redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
84
|
+
if (!protocol.ok || protocol.data?.localAssetUpload !== true)
|
|
85
|
+
throw new Error('当前服务端尚不支持 CLI 资源上传,请先升级服务端后重试');
|
|
86
|
+
let cached = [];
|
|
87
|
+
try {
|
|
88
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
89
|
+
if (manifest.apiBaseUrl === cfg.baseUrl && manifest.spaceId === input.spaceId && Array.isArray(manifest.mappings))
|
|
90
|
+
cached = manifest.mappings;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error.code !== 'ENOENT')
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
const save = () => writeJson(manifestPath, { schemaVersion: 1, apiBaseUrl: cfg.baseUrl, spaceId: input.spaceId, mappings: cached });
|
|
97
|
+
const mappings = [];
|
|
98
|
+
for (const file of [...new Set(input.files)]) {
|
|
99
|
+
const absolute = path.resolve(root, file);
|
|
100
|
+
const sourcePath = path.relative(root, absolute).split(path.sep).join('/');
|
|
101
|
+
if (!sourcePath || sourcePath.startsWith('../') || path.isAbsolute(sourcePath))
|
|
102
|
+
throw new Error(`资源不能位于项目外:${file}`);
|
|
103
|
+
// Reject symbolic links, including links in parent directories.
|
|
104
|
+
let current = root;
|
|
105
|
+
for (const segment of sourcePath.split('/')) {
|
|
106
|
+
current = path.join(current, segment);
|
|
107
|
+
if ((await lstat(current)).isSymbolicLink())
|
|
108
|
+
throw new Error(`不支持符号链接:${sourcePath}`);
|
|
109
|
+
}
|
|
110
|
+
const extension = path.extname(sourcePath).toLowerCase();
|
|
111
|
+
const media = MEDIA[extension];
|
|
112
|
+
if (!media)
|
|
113
|
+
throw new Error(`暂不支持上传该类型:${sourcePath};SVG、字体和含关联文件的模型资源需保留并单独处理`);
|
|
114
|
+
const stat = await lstat(absolute);
|
|
115
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_ASSET_BYTES)
|
|
116
|
+
throw new Error(`资源必须是 1 字节至 100 MiB 的普通文件:${sourcePath}`);
|
|
117
|
+
const bytes = await readFile(absolute);
|
|
118
|
+
if (bytes.length <= 0 || bytes.length > MAX_ASSET_BYTES)
|
|
119
|
+
throw new Error(`读取期间资源大小改变:${sourcePath}`);
|
|
120
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
121
|
+
let mapping = cached.find(item => item.sourcePath === sourcePath && item.sha256 === sha256 && item.sizeBytes === bytes.length && item.mimeType === media.mimeType && typeof item.fileId === 'string');
|
|
122
|
+
if (!mapping) {
|
|
123
|
+
const grant = await apiCall('GET', `/api/file/upload-url?spaceId=${encodeURIComponent(input.spaceId)}`, undefined, { cfg, redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
124
|
+
if (!grant.ok || !grant.data?.uploadUrl)
|
|
125
|
+
throw new Error(`获取上传地址失败:${grant.errorMessage ?? grant.errorCode ?? grant.status}`);
|
|
126
|
+
const url = new URL(grant.data.uploadUrl);
|
|
127
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
128
|
+
throw new Error('上传地址必须是 HTTPS');
|
|
129
|
+
url.searchParams.set('public', 'true');
|
|
130
|
+
const form = new FormData();
|
|
131
|
+
form.append('file', new Blob([bytes], { type: media.mimeType }), path.basename(sourcePath));
|
|
132
|
+
// No session, cookie or QMuse credentials are forwarded to the CDN.
|
|
133
|
+
const response = await fetch(url, { method: 'POST', redirect: 'error', body: form,
|
|
134
|
+
headers: { 'x-mass-ext-name': extension, 'x-afts-text-encoding': 'utf-8' }, signal: AbortSignal.timeout(300_000) });
|
|
135
|
+
if (!response.ok)
|
|
136
|
+
throw new Error(`CDN 上传失败:HTTP ${response.status}`);
|
|
137
|
+
const result = await response.json();
|
|
138
|
+
const uploaded = Array.isArray(result.data) ? result.data[0] : result.data;
|
|
139
|
+
if (result.code !== 0 || typeof uploaded?.id !== 'string' || !uploaded.id.trim())
|
|
140
|
+
throw new Error('CDN 上传未返回有效文件 ID');
|
|
141
|
+
mapping = { sourcePath, sha256, sizeBytes: bytes.length, mimeType: media.mimeType, fileId: uploaded.id };
|
|
142
|
+
cached = cached.filter(item => item.sourcePath !== sourcePath);
|
|
143
|
+
cached.push(mapping);
|
|
144
|
+
await save(); // Keep completed uploads even if registration/review or a later file fails.
|
|
145
|
+
}
|
|
146
|
+
delete mapping.stableUrl;
|
|
147
|
+
await save();
|
|
148
|
+
const registered = await apiCall('POST', '/api/app-import/assets/register', {
|
|
149
|
+
spaceId: input.spaceId, fileId: mapping.fileId, name: path.basename(sourcePath), materialType: media.materialType,
|
|
150
|
+
}, { cfg, redirect: 'error', signal: AbortSignal.timeout(60_000) });
|
|
151
|
+
if (!registered.ok || !registered.data)
|
|
152
|
+
throw new Error(`资源登记或内容检查未通过:${registered.errorMessage ?? registered.errorCode ?? registered.status};已上传文件保存在 ${manifestPath},重试将复用`);
|
|
153
|
+
if (registered.data.fileId !== mapping.fileId || registered.data.sizeBytes !== mapping.sizeBytes)
|
|
154
|
+
throw new Error(`CDN 资源大小或身份不一致:${sourcePath}`);
|
|
155
|
+
mapping.stableUrl = requireStableUrl(registered.data.stableUrl);
|
|
156
|
+
await save();
|
|
157
|
+
mappings.push({ ...mapping });
|
|
158
|
+
}
|
|
159
|
+
const environment = resolveProjectEnvironment(cfg);
|
|
160
|
+
return { mappings, manifestPath, repairSteps: ASSET_REPAIR_STEPS.map(step => step.replace('qmuse import .', `qmuse import . --env ${environment}`)) };
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
await rm(lock, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ASSET_REPAIR_STEPS } from './assets.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,13 @@ 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?.code?.startsWith('APP_PUBLISH_') ? {
|
|
31
|
+
assetRepair: {
|
|
32
|
+
preflightReport: '.qmuse/asset-preflight.json',
|
|
33
|
+
uploadCommand: `qmuse assets upload <relative-file> [more-files...] --env ${input.project?.environment ?? input.environment ?? 'prod'} --json`,
|
|
34
|
+
steps: ASSET_REPAIR_STEPS.map(step => step.replace('qmuse import .', `qmuse import . --env ${input.project?.environment ?? input.environment ?? 'prod'}`)),
|
|
35
|
+
},
|
|
36
|
+
} : {}),
|
|
29
37
|
...importRetryGuidance(input.status, input.project),
|
|
30
38
|
environment: input.project?.environment ?? input.environment,
|
|
31
39
|
generatedAt: new Date().toISOString(),
|