@qmuse/qmuse-cli 1.0.7 → 1.0.8-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 CHANGED
@@ -36,6 +36,21 @@ qmuse space info
36
36
 
37
37
  ## 导入
38
38
 
39
+ ### 从模板开始
40
+
41
+ ```bash
42
+ qmuse login
43
+ qmuse create my-app
44
+ cd my-app
45
+ npm install
46
+ ```
47
+
48
+ `create` 从 QMuse 模板下载接口获取代码并直接解压,包含 `AGENTS.md`、云服务 Skill 和 `.qmuse/project.json`。只需登录,无需先选择空间;目标目录必须不存在。命令不会自动安装依赖。开发完成后选择空间并执行 `qmuse import .`。
49
+
50
+ 支持 `qmuse --env pre create my-app` 下载预发模板,以及 `--json` 输出项目目录和模板 commit。后续导入使用相同的 `--env`。
51
+
52
+ ### 导入本地代码
53
+
39
54
  ```bash
40
55
  qmuse import /path/to/react-project --space-id <spaceId>
41
56
  ```
package/dist/cli.js CHANGED
@@ -14,17 +14,18 @@ import { readFileSync } from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { homedir } from 'node:os';
16
16
  import { createImportArchive } from './import/archive.js';
17
- import { configureAppImport, getAppOpenUrl, getImportStatusUrl, queryAppImportStatus, startAppImport, uploadImportArchive, } from './import/api.js';
17
+ import { configureAppImport, assertTemplateProtocolSupported, getAppOpenUrl, getImportStatusUrl, queryAppImportStatus, startAppImport, uploadImportArchive, } from './import/api.js';
18
18
  import { pollAppImport } from './import/poll.js';
19
19
  import { preflightImport } from './import/preflight.js';
20
20
  import { resolveUpdateBase } from './import/update.js';
21
- import { clearPendingImport, completeManagedImport, createExternalProject, loadManagedProject, saveManagedProject, resolveProjectEnvironment, setPendingImport, } from './import/project.js';
21
+ import { clearPendingImport, completeManagedImport, createExternalProject, importArchitectureProtocol, loadManagedProject, saveManagedProject, resolveProjectEnvironment, setPendingImport, } from './import/project.js';
22
22
  import { clearImportFailureDiagnostic, writeImportFailureDiagnostic, importRetryGuidance, } from './import/diagnostic.js';
23
23
  import { checkCloudInit, checkCloudEntitlement, CLOUD_INIT_SUCCESS_MESSAGE, resolveCloudAppId, preflightCloudSchema, exportCloudSchema, parseCloudSecrets, setCloudSecrets } from './cloud.js';
24
24
  import { configurationGuidance } from './import/guidance.js';
25
25
  import { QMUSE_CLI_VERSION } from './version.js';
26
26
  import { startUpdateNotice } from './update-notice.js';
27
27
  import { startDevelopment } from './dev/index.js';
28
+ import { createProject } from './create.js';
28
29
  // ---------------- 参数解析 ----------------
29
30
  const BOOL_FLAGS = new Set([
30
31
  '--help', '-h', '--version', '-V', '--json', '--status',
@@ -441,6 +442,8 @@ async function cmdImport(command, flags) {
441
442
  const rootDir = path.resolve(directory);
442
443
  const managedProject = await loadManagedProject(rootDir, cfg, str(flags, '--env')) ??
443
444
  createExternalProject(rootDir, cfg, str(flags, '--env'));
445
+ if (managedProject.manifest.schemaVersion === 2)
446
+ await assertTemplateProtocolSupported(cfg);
444
447
  const pendingImport = managedProject.manifest.pendingImports?.[managedProject.environment];
445
448
  if (pendingImport) {
446
449
  throw new Error(`当前目录有尚未完成的导入任务,请先执行 qmuse import status ${pendingImport.jobId} --wait --env ${managedProject.environment},避免重复创建应用`);
@@ -522,6 +525,7 @@ async function cmdImport(command, flags) {
522
525
  if (flags['--json'] !== true)
523
526
  log.success('源代码上传完成');
524
527
  const started = await startAppImport({
528
+ ...importArchitectureProtocol(managedProject.manifest),
525
529
  sourceToken,
526
530
  spaceId,
527
531
  title: str(flags, '--title')?.trim() || undefined,
@@ -827,6 +831,18 @@ async function cmdLogout(flags) {
827
831
  }
828
832
  }
829
833
  // ---------------- 帮助 & 入口 ----------------
834
+ async function cmdCreate(command, flags) {
835
+ if (command.length !== 2)
836
+ fail('用法:qmuse create <directory>');
837
+ const result = await createProject(command[1], resolveCfg(flags));
838
+ if (flags['--json'] === true) {
839
+ output(result, flags);
840
+ return;
841
+ }
842
+ log.success(`模板已下载到 ${result.directory}`);
843
+ log.info('进入项目目录后执行:npm install');
844
+ log.info(`开发完成后执行:qmuse${str(flags, '--env') ? ` --env ${str(flags, '--env')}` : ''} import .`);
845
+ }
830
846
  const HELP = `QMuse CLI —— 将本地 React 项目导入 QMuse
831
847
 
832
848
  用法:
@@ -845,6 +861,8 @@ const HELP = `QMuse CLI —— 将本地 React 项目导入 QMuse
845
861
  logout 清除保存的登录态和空间选择
846
862
  config [get|path] 查看配置
847
863
 
864
+ create <directory> 下载 QMuse 模板并解压到新项目目录
865
+
848
866
  cloud init -d "<应用功能描述>" 校验当前空间的 QMuse 云服务权益
849
867
  cloud set-secret KEY1,KEY2 --stdin 从标准输入 JSON 保存密钥,不输出密钥值
850
868
  [--app-id <id>] [--cloud-env dev|prod]
@@ -868,6 +886,7 @@ const HELP = `QMuse CLI —— 将本地 React 项目导入 QMuse
868
886
  qmuse login
869
887
  qmuse space list
870
888
  qmuse space use S640459450135
889
+ qmuse create my-app
871
890
  qmuse import /path/to/react-vite-app
872
891
  `;
873
892
  async function main() {
@@ -905,6 +924,9 @@ async function main() {
905
924
  case 'config':
906
925
  await cmdConfig(command, flags);
907
926
  return;
927
+ case 'create':
928
+ await cmdCreate(command, flags);
929
+ return;
908
930
  case 'cloud':
909
931
  await cmdCloud(command, flags);
910
932
  return;
package/dist/create.js ADDED
@@ -0,0 +1,151 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { lstat, mkdir, rm } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { pipeline } from 'node:stream/promises';
5
+ import { fromBufferPromise } from 'yauzl';
6
+ import { authHeaders, buildUrl, hasAuth } from './client.js';
7
+ import { loadManagedProject } from './import/project.js';
8
+ const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024;
9
+ const MAX_FILE_BYTES = 10 * 1024 * 1024;
10
+ const MAX_FILES = 20_000;
11
+ async function readResponse(response, limit) {
12
+ if (!response.body)
13
+ throw new Error('模板下载响应为空');
14
+ const reader = response.body.getReader();
15
+ const chunks = [];
16
+ let size = 0;
17
+ try {
18
+ if (Number(response.headers.get('content-length')) > limit) {
19
+ throw new Error('模板下载响应超过大小限制');
20
+ }
21
+ while (true) {
22
+ const { value, done } = await reader.read();
23
+ if (done)
24
+ break;
25
+ size += value.byteLength;
26
+ if (size > limit)
27
+ throw new Error('模板下载响应超过大小限制');
28
+ chunks.push(Buffer.from(value));
29
+ }
30
+ return Buffer.concat(chunks, size);
31
+ }
32
+ finally {
33
+ await reader.cancel().catch(() => { });
34
+ reader.releaseLock();
35
+ }
36
+ }
37
+ async function downloadTemplate(projectName, cfg) {
38
+ const params = new URLSearchParams({ appType: 'GENERAL', projectName });
39
+ // 模板下载无需空间;不继承当前空间,避免触发无关的空间权限校验。
40
+ const headers = authHeaders({ ...cfg, spaceId: '' });
41
+ headers.Accept = 'application/zip';
42
+ delete headers['Content-Type'];
43
+ const response = await fetch(buildUrl(cfg, `/api/project-template/download?${params}`), {
44
+ headers,
45
+ redirect: 'error',
46
+ signal: AbortSignal.timeout(120_000),
47
+ });
48
+ if (!response.ok || response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/zip') {
49
+ const body = await readResponse(response, 64 * 1024);
50
+ let message = '';
51
+ try {
52
+ const error = JSON.parse(body.toString('utf8'));
53
+ if (typeof error.errorMessage === 'string')
54
+ message = error.errorMessage;
55
+ else if (typeof error.message === 'string')
56
+ message = error.message;
57
+ }
58
+ catch { /* HTML 登录页或代理错误页不作为模板落盘。 */ }
59
+ if (response.status === 401)
60
+ throw new Error('登录已失效,请先运行 `qmuse login`');
61
+ throw new Error(`模板下载失败(HTTP ${response.status})${message ? `:${message}` : ':服务端未返回 ZIP 模板'}`);
62
+ }
63
+ return readResponse(response, MAX_ARCHIVE_BYTES);
64
+ }
65
+ async function extractTemplate(archive, directory, projectName) {
66
+ const zip = await fromBufferPromise(archive, {
67
+ lazyEntries: true, strictFileNames: true, validateEntrySizes: true,
68
+ });
69
+ const seen = new Set();
70
+ let totalBytes = 0;
71
+ let count = 0;
72
+ try {
73
+ if (zip.entryCount > MAX_FILES)
74
+ throw new Error('模板文件数超过限制');
75
+ for await (const entry of zip.eachEntry()) {
76
+ if (++count > MAX_FILES)
77
+ throw new Error('模板文件数超过限制');
78
+ const name = entry.fileName;
79
+ const isDirectory = name.endsWith('/');
80
+ const parts = name.replace(/\/$/u, '').split('/');
81
+ if (parts[0] !== projectName || parts.some(part => !part || part === '.' || part === '..' || /[\\:\x00-\x1f]/u.test(part)))
82
+ throw new Error(`模板包含不安全路径:${name}`);
83
+ const fileType = (entry.externalFileAttributes >>> 16) & 0o170000;
84
+ if (fileType && fileType !== (isDirectory ? 0o040000 : 0o100000)) {
85
+ throw new Error(`模板包含符号链接或特殊文件:${name}`);
86
+ }
87
+ const relative = parts.slice(1).join('/');
88
+ if (!relative) {
89
+ if (!isDirectory)
90
+ throw new Error(`模板路径不是项目文件:${name}`);
91
+ continue;
92
+ }
93
+ if (seen.has(relative))
94
+ throw new Error(`模板包含重复路径:${name}`);
95
+ seen.add(relative);
96
+ if (entry.uncompressedSize > MAX_FILE_BYTES)
97
+ throw new Error(`模板文件过大:${name}`);
98
+ totalBytes += entry.uncompressedSize;
99
+ if (totalBytes > MAX_ARCHIVE_BYTES)
100
+ throw new Error('模板解压大小超过限制');
101
+ const destination = path.join(directory, relative);
102
+ if (isDirectory) {
103
+ await mkdir(destination, { recursive: true });
104
+ }
105
+ else {
106
+ await mkdir(path.dirname(destination), { recursive: true });
107
+ const stream = await zip.openReadStreamPromise(entry);
108
+ await pipeline(stream, createWriteStream(destination, { flags: 'wx', mode: 0o644 }));
109
+ }
110
+ }
111
+ }
112
+ finally {
113
+ zip.close();
114
+ }
115
+ }
116
+ export async function createProject(directory, cfg) {
117
+ const rootDir = path.resolve(directory);
118
+ const projectName = path.basename(rootDir);
119
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(projectName)) {
120
+ throw new Error('项目目录名称需以字母或数字开头,仅支持字母、数字、点、下划线和短横线,最多 64 个字符');
121
+ }
122
+ try {
123
+ await lstat(rootDir);
124
+ throw new Error(`目标目录已存在,请使用新目录:${rootDir}`);
125
+ }
126
+ catch (error) {
127
+ if (error.code !== 'ENOENT')
128
+ throw error;
129
+ }
130
+ if (!hasAuth(cfg))
131
+ throw new Error('当前环境未登录。请先运行 `qmuse login`。');
132
+ const archive = await downloadTemplate(projectName, cfg);
133
+ // mkdir 不覆盖已有目录,下载期间出现的同名目录也保留原样。
134
+ await mkdir(rootDir);
135
+ try {
136
+ await extractTemplate(archive, rootDir, projectName);
137
+ const project = await loadManagedProject(rootDir, cfg);
138
+ if (!project || project.manifest.projectKind !== 'QMUSE_TEMPLATE') {
139
+ throw new Error('模板缺少有效的 .qmuse/project.json');
140
+ }
141
+ for (const name of ['package.json', 'AGENTS.md', '.agents/skills/qmuse-cloud/SKILL.md']) {
142
+ if (!(await lstat(path.join(rootDir, name))).isFile())
143
+ throw new Error(`模板缺少文件:${name}`);
144
+ }
145
+ return { success: true, directory: rootDir, templateCommit: project.manifest.templateCommit };
146
+ }
147
+ catch (error) {
148
+ await rm(rootDir, { recursive: true, force: true });
149
+ throw error;
150
+ }
151
+ }
@@ -1,6 +1,12 @@
1
1
  import { openAsBlob } from 'node:fs';
2
2
  import { apiCall, authHeaders, buildUrl, requestSourceHeaders, } from '../client.js';
3
3
  import { assertImportArchiveSize } from './limits.js';
4
+ export async function assertTemplateProtocolSupported(cfg) {
5
+ const result = await apiCall('GET', '/api/app-import/protocol', undefined, cfg);
6
+ if (!result.ok || !result.data?.templateProtocolVersions?.includes(2)) {
7
+ throw new Error('当前平台尚不支持新模板架构协议,请等待服务端升级;未提交导入,不会降级为旧架构');
8
+ }
9
+ }
4
10
  export async function uploadImportArchive(input, cfg) {
5
11
  const archive = await openAsBlob(input.filePath, { type: 'application/zip' });
6
12
  assertImportArchiveSize(archive.size);
@@ -70,8 +70,8 @@ function parseEnvironmentMap(value, field, parse) {
70
70
  function parseManifest(value) {
71
71
  if (!isRecord(value))
72
72
  throw new Error(`${QMUSE_PROJECT_PATH} 必须是 JSON 对象`);
73
- if (value.schemaVersion !== 1) {
74
- throw new Error(`${QMUSE_PROJECT_PATH} 的 schemaVersion 不受支持`);
73
+ if (value.schemaVersion !== 1 && value.schemaVersion !== 2) {
74
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 schemaVersion 不受支持,请升级 QMuse CLI`);
75
75
  }
76
76
  if (value.projectKind !== 'QMUSE_TEMPLATE' && value.projectKind !== 'EXTERNAL_REPOSITORY') {
77
77
  throw new Error(`${QMUSE_PROJECT_PATH} 的 projectKind 无效`);
@@ -80,6 +80,14 @@ function parseManifest(value) {
80
80
  throw new Error(`${QMUSE_PROJECT_PATH} 的 appType 必须是 GENERAL`);
81
81
  }
82
82
  const isTemplate = value.projectKind === 'QMUSE_TEMPLATE';
83
+ if (value.schemaVersion === 2) {
84
+ if (!isTemplate || !['LEGACY_DIRECT_V1', 'HONO_BACKEND_V1'].includes(String(value.cloudArchitecture))) {
85
+ throw new Error(`${QMUSE_PROJECT_PATH} 的新模板协议必须声明有效的 cloudArchitecture`);
86
+ }
87
+ }
88
+ else if (value.cloudArchitecture !== undefined) {
89
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 cloudArchitecture 需要 schemaVersion 2,请使用新版模板`);
90
+ }
83
91
  if (isTemplate && (!Number.isSafeInteger(value.packageVersion) || Number(value.packageVersion) < 1)) {
84
92
  throw new Error(`${QMUSE_PROJECT_PATH} 的 packageVersion 无效`);
85
93
  }
@@ -94,7 +102,8 @@ function parseManifest(value) {
94
102
  ? undefined
95
103
  : requireNonEmptyString(value.defaultSpaceId, 'defaultSpaceId');
96
104
  return {
97
- schemaVersion: 1,
105
+ schemaVersion: value.schemaVersion,
106
+ ...(value.schemaVersion === 2 ? { cloudArchitecture: value.cloudArchitecture } : {}),
98
107
  projectKind: value.projectKind,
99
108
  appType: 'GENERAL',
100
109
  ...(isTemplate ? {
@@ -109,6 +118,14 @@ function parseManifest(value) {
109
118
  : { pendingImports: parseEnvironmentMap(value.pendingImports, 'pendingImports', parsePendingImport) }),
110
119
  };
111
120
  }
121
+ /** Keep v1 requests byte-compatible; never infer architecture from installed dependencies. */
122
+ export function importArchitectureProtocol(manifest) {
123
+ if (manifest.schemaVersion === 1)
124
+ return {};
125
+ if (!manifest.cloudArchitecture)
126
+ throw new Error('新模板协议缺少 cloudArchitecture');
127
+ return { templateProtocolVersion: 2, cloudArchitecture: manifest.cloudArchitecture };
128
+ }
112
129
  export function resolveProjectEnvironment(cfg, explicitEnvironment) {
113
130
  if (explicitEnvironment)
114
131
  return explicitEnvironment;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qmuse/qmuse-cli",
3
- "version": "1.0.7",
3
+ "version": "1.0.8-dev.1",
4
4
  "description": "将本地 React 项目安全导入 QMuse 的命令行客户端",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,6 +24,7 @@
24
24
  "open": "^11.0.4",
25
25
  "semver": "7.7.4",
26
26
  "typescript": "^5.9.3",
27
+ "yauzl": "^3.4.0",
27
28
  "yazl": "^3.3.1"
28
29
  },
29
30
  "files": [
@@ -37,6 +38,7 @@
37
38
  "@playwright/test": "1.63.0",
38
39
  "@types/node": "^22.10.0",
39
40
  "@types/semver": "7.7.1",
41
+ "@types/yauzl": "^3.4.0",
40
42
  "@types/yazl": "^3.3.1",
41
43
  "tsx": "^4.19.2",
42
44
  "vite": "7.3.6"