@qmuse/qmuse-cli 1.0.3-dev.3 → 1.0.3-dev.5

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
@@ -50,7 +50,7 @@ qmuse import /path/to/react-project --space-id <spaceId>
50
50
 
51
51
  ### 更新已有应用
52
52
 
53
- 从 QMuse 下载的项目,在项目根目录执行 `qmuse import .` 即可更新对应应用;没有关联应用时会创建新应用。普通外部项目每次导入都会创建新应用。
53
+ 没有关联应用时,首次导入会创建新应用并自动保存本地绑定。之后在同一项目目录执行 `qmuse import .`,即可更新对应应用。请保留 `.qmuse/project.json`,从 QMuse 下载且带有绑定的项目也使用相同命令更新。
54
54
 
55
55
  平台已有新版本时,终端会询问是否用本地全部源码覆盖,不会自动合并平台修改。确认覆盖可执行 `qmuse import . --force`;脚本或 `--json` 模式须显式指定该参数。覆盖会在原应用创建新版本,仍需通过正常导入校验。
56
56
 
@@ -101,7 +101,7 @@ CLI 将本地 Runtime 的 `__MUSE__.domain` 设置为实际监听地址(如 `h
101
101
 
102
102
  服务端必须先部署 `/api/appwrite/local-dev/context` 和 `/api/appwrite/local-dev/mutation-events`,再发布并安装新版 CLI;缺少接口时不会降级为模拟云服务。
103
103
 
104
- 页面改动热更新,Schema/函数改动继续执行 `qmuse import .`。导入更新本地绑定后,连接会重新获取配置并刷新页面。切换登录账号或应用绑定需重启连接;退出 CLI 不会自动撤销浏览器已经建立的 AppBase Session。SDK 的匿名降级行为保持不变,验收必须检查实际 Session 用户,不能只看页面是否显示。
104
+ 页面改动热更新,Schema/函数改动继续执行 `qmuse import .`。CLI 启动期间设置 `QMUSE_HMR_GATE_ENABLED=false`,退出后恢复原值;项目的 `@qmuse/vite-config` 必须支持此开关,否则平台批量改码门禁仍会暂存本地修改。导入更新本地绑定后,连接会重新获取配置并刷新页面。切换登录账号或应用绑定需重启连接;退出 CLI 不会自动撤销浏览器已经建立的 AppBase Session。SDK 的匿名降级行为保持不变,验收必须检查实际 Session 用户,不能只看页面是否显示。
105
105
 
106
106
  联调验收包括 CRUD、刷新读回、文件上传/预览、函数调用和权限拒绝;浏览器 Cookie 限制需在目标浏览器验证。`.muse-tmp`、`.qmuse` 和系统临时配置不进入导入包。
107
107
 
package/dist/cli.js CHANGED
@@ -17,10 +17,12 @@ import { configureAppImport, getAppOpenUrl, getImportStatusUrl, queryAppImportSt
17
17
  import { pollAppImport } from './import/poll.js';
18
18
  import { preflightImport } from './import/preflight.js';
19
19
  import { resolveUpdateBase } from './import/update.js';
20
- import { clearPendingImport, completeManagedImport, loadManagedProject, saveManagedProject, setPendingImport, } from './import/project.js';
21
- import { clearImportFailureDiagnostic, writeImportFailureDiagnostic, } from './import/diagnostic.js';
22
- import { checkCloudInit, CLOUD_INIT_SUCCESS_MESSAGE } from './cloud.js';
20
+ import { clearPendingImport, completeManagedImport, createExternalProject, loadManagedProject, saveManagedProject, resolveProjectEnvironment, setPendingImport, } from './import/project.js';
21
+ import { clearImportFailureDiagnostic, writeImportFailureDiagnostic, importRetryGuidance, } from './import/diagnostic.js';
22
+ import { checkCloudInit, checkCloudEntitlement, CLOUD_INIT_SUCCESS_MESSAGE } from './cloud.js';
23
+ import { configurationGuidance } from './import/guidance.js';
23
24
  import { QMUSE_CLI_VERSION } from './version.js';
25
+ import { startUpdateNotice } from './update-notice.js';
24
26
  import { startDevelopment } from './dev/index.js';
25
27
  // ---------------- 参数解析 ----------------
26
28
  const BOOL_FLAGS = new Set([
@@ -190,11 +192,14 @@ async function pollImportWithLogs(jobId, cfg, flags) {
190
192
  process.removeListener('SIGINT', onInterrupt);
191
193
  }
192
194
  }
193
- async function resolveImportConfiguration(status, cfg, flags) {
195
+ async function resolveImportConfiguration(status, cfg, flags, cloudTarget) {
194
196
  if (status.stage === 'QMUSE_CLOUD_ADAPTATION_REQUIRED')
195
197
  return undefined;
196
198
  const interactive = process.stdin.isTTY === true && flags['--json'] !== true;
197
199
  let cloudMode = parseImportCloudMode(str(flags, '--cloud-mode'));
200
+ if (cloudMode === 'AUTO' && ['QMUSE', 'EXTERNAL'].includes(status.cloudMode ?? '')) {
201
+ cloudMode = status.cloudMode;
202
+ }
198
203
  if (cloudMode === 'AUTO') {
199
204
  if (!interactive)
200
205
  return undefined;
@@ -234,6 +239,8 @@ async function resolveImportConfiguration(status, cfg, flags) {
234
239
  if (!acknowledgeEmptyCloudData)
235
240
  return undefined;
236
241
  }
242
+ if (cloudMode === 'QMUSE')
243
+ await checkCloudEntitlement(cloudTarget, cfg);
237
244
  const configured = await configureAppImport({
238
245
  jobId: status.jobId,
239
246
  cloudMode,
@@ -250,23 +257,34 @@ async function resolveImportConfiguration(status, cfg, flags) {
250
257
  }
251
258
  async function finishImport(status, sourceHash, cfg, flags, managedProject, rootDir) {
252
259
  if (status.status !== 'READY' || !status.appId || !status.versionId) {
260
+ const guidance = configurationGuidance(status, resolveProjectEnvironment(cfg, str(flags, '--env')), parseImportCloudMode(str(flags, '--cloud-mode')));
253
261
  if (flags['--json'] === true) {
254
- output({ success: false, ...status }, flags);
262
+ output({ success: false, ...status, ...(guidance ? { configurationRequired: guidance } : {}) }, flags);
255
263
  }
256
264
  else {
257
265
  printImportStatus(status, flags);
258
266
  if (status.status === 'FAILED')
259
267
  log.error('导入失败');
260
- else
261
- log.warn(`任务需要继续配置,可执行 qmuse import status ${status.jobId} --wait`);
268
+ else if (guidance) {
269
+ log.warn(guidance.message);
270
+ if (guidance.resumeCommand)
271
+ log.warn(`继续配置:${guidance.resumeCommand}`);
272
+ if (guidance.waitCommand)
273
+ log.warn(`配置后在原项目目录执行:${guidance.waitCommand}`);
274
+ }
262
275
  }
263
276
  process.exitCode = status.status === 'FAILED' ? 1 : 2;
264
277
  if (status.status === 'FAILED' && rootDir) {
265
- const diagnosticPath = await writeImportFailureDiagnostic({ rootDir, status });
278
+ const diagnosticProject = managedProject ?? await loadManagedProject(rootDir, cfg, str(flags, '--env'));
279
+ const diagnosticPath = await writeImportFailureDiagnostic({ rootDir, status, project: diagnosticProject,
280
+ environment: resolveProjectEnvironment(cfg, str(flags, '--env')) });
266
281
  if (flags['--json'] !== true) {
267
282
  log.warn(`诊断已写入 ${diagnosticPath}`);
268
283
  if (status.failureDiagnostic?.fixableByModel) {
269
- log.warn(`请让当前大模型读取 ${diagnosticPath},修复源码后执行 qmuse import .`);
284
+ const retry = importRetryGuidance(status, diagnosticProject);
285
+ log.warn(`请让当前大模型读取 ${diagnosticPath},修复源码。${retry.retryAdvice}`);
286
+ if (retry.retryCommand)
287
+ log.warn(retry.retryCommand);
270
288
  }
271
289
  }
272
290
  }
@@ -368,27 +386,53 @@ async function cmdImport(command, flags) {
368
386
  const mode = parseImportCloudMode(str(flags, '--cloud-mode'));
369
387
  if (mode === 'AUTO')
370
388
  fail('configure 必须指定 --cloud-mode EXTERNAL 或 QMUSE');
389
+ const current = await queryAppImportStatus(jobId, cfg);
390
+ if (!current.ok || !current.data)
391
+ throw new Error(`查询导入任务失败:${current.errorMessage ?? current.errorCode}`);
392
+ const guidance = configurationGuidance(current.data, resolveProjectEnvironment(cfg, str(flags, '--env')), mode);
393
+ if (mode === 'QMUSE' && guidance?.requiredFlags?.some(flag => flags[flag] !== true)) {
394
+ await finishImport(current.data, undefined, cfg, flags);
395
+ return;
396
+ }
397
+ if (mode === 'QMUSE') {
398
+ const project = await loadManagedProject(process.cwd(), cfg, str(flags, '--env'));
399
+ const pending = project?.manifest.pendingImports?.[project.environment];
400
+ const matchingProject = pending?.jobId === jobId ? project : undefined;
401
+ const appId = current.data.appId || matchingProject?.target?.appId;
402
+ const spaceId = pending?.jobId === jobId ? pending.spaceId : str(flags, '--space-id');
403
+ if (appId || spaceId)
404
+ await checkCloudEntitlement({ appId, spaceId: spaceId ?? '' }, cfg);
405
+ }
371
406
  const result = await configureAppImport({
372
407
  jobId,
373
408
  cloudMode: mode,
374
409
  ...(mode === 'EXTERNAL'
375
410
  ? { publicConfig: readPublicConfig(str(flags, '--public-config')) }
376
- : flags['--confirm-cloud-service'] === true
377
- ? { confirmCloudService: true }
378
- : { acknowledgeEmptyCloudData: flags['--acknowledge-empty-cloud-data'] === true }),
411
+ : {
412
+ confirmCloudService: flags['--confirm-cloud-service'] === true,
413
+ acknowledgeEmptyCloudData: flags['--acknowledge-empty-cloud-data'] === true,
414
+ }),
379
415
  }, cfg);
380
416
  if (!result.ok || !result.data) {
381
417
  fail(`提交导入配置失败:${result.errorMessage ?? result.errorCode ?? `HTTP ${result.status}`}`);
382
418
  }
383
- printImportStatus(result.data, flags);
419
+ if (['WAITING_CLOUD_CONFIRMATION', 'NEEDS_CONFIGURATION', 'FAILED'].includes(result.data.status)) {
420
+ await finishImport(result.data, undefined, cfg, flags);
421
+ }
422
+ else
423
+ printImportStatus(result.data, flags);
384
424
  return;
385
425
  }
386
426
  const directory = subcommand;
387
427
  if (!directory)
388
428
  fail('用法: qmuse import <directory> [--space-id <id>]');
389
429
  const rootDir = path.resolve(directory);
390
- await clearImportFailureDiagnostic(rootDir);
391
- const managedProject = await loadManagedProject(rootDir, cfg, str(flags, '--env'));
430
+ const managedProject = await loadManagedProject(rootDir, cfg, str(flags, '--env')) ??
431
+ createExternalProject(rootDir, cfg, str(flags, '--env'));
432
+ const pendingImport = managedProject.manifest.pendingImports?.[managedProject.environment];
433
+ if (pendingImport) {
434
+ throw new Error(`当前目录有尚未完成的导入任务,请先执行 qmuse import status ${pendingImport.jobId} --wait --env ${managedProject.environment},避免重复创建应用`);
435
+ }
392
436
  const explicitSpaceId = str(flags, '--space-id');
393
437
  const operation = managedProject?.target ? 'UPDATE' : 'CREATE';
394
438
  if (flags['--force'] === true && !managedProject?.target) {
@@ -434,6 +478,12 @@ async function cmdImport(command, flags) {
434
478
  log.success('源码没有变化,无需创建新版本');
435
479
  return;
436
480
  }
481
+ if (cloudMode === 'QMUSE') {
482
+ const quota = await checkCloudEntitlement({ appId: managedProject.target?.appId, spaceId }, cfg);
483
+ if (flags['--json'] !== true && quota.appLimit !== undefined) {
484
+ log.info(`QMuse 云服务额度:已使用 ${quota.enabledAppCount ?? 0}/${quota.appLimit}${quota.alreadyEnabled ? '(当前应用已开通)' : ''}`);
485
+ }
486
+ }
437
487
  if (flags['--json'] !== true) {
438
488
  log.success(`识别为 React 项目(${preflight.fileCount} 个文件)`);
439
489
  if (flags['--log'] === true) {
@@ -448,6 +498,7 @@ async function cmdImport(command, flags) {
448
498
  const archive = await createImportArchive(preflight);
449
499
  let sourceToken;
450
500
  try {
501
+ await saveManagedProject(managedProject);
451
502
  sourceToken = await uploadImportArchive({
452
503
  filePath: archive.filePath,
453
504
  spaceId,
@@ -466,7 +517,7 @@ async function cmdImport(command, flags) {
466
517
  sourceType: 'REACT_VITE',
467
518
  cloudMode,
468
519
  idempotencyKey: str(flags, '--idempotency-key'),
469
- sourceScenario: managedProject ? 'QMUSE_TEMPLATE' : 'EXTERNAL_REPOSITORY',
520
+ sourceScenario: managedProject.manifest.projectKind,
470
521
  operation,
471
522
  ...(managedProject?.target
472
523
  ? {
@@ -494,7 +545,7 @@ async function cmdImport(command, flags) {
494
545
  log.success(`已创建导入任务 ${jobId}`);
495
546
  let polled = await pollImportWithLogs(jobId, cfg, flags);
496
547
  while (polled.reason === 'CONFIGURATION_REQUIRED' && polled.status) {
497
- const configured = await resolveImportConfiguration(polled.status, cfg, flags);
548
+ const configured = await resolveImportConfiguration(polled.status, cfg, flags, { appId: managedProject.target?.appId, spaceId });
498
549
  if (!configured) {
499
550
  await finishImport(polled.status, preflight.sourceHash, cfg, flags, managedProject, rootDir);
500
551
  return;
@@ -727,6 +778,11 @@ async function main() {
727
778
  return;
728
779
  }
729
780
  const root = command[0];
781
+ const finishUpdateNotice = startUpdateNotice({
782
+ enabled: root === 'import' && process.stderr.isTTY === true && flags['--json'] !== true,
783
+ notify: message => process.stderr.write(`${message}\n`),
784
+ });
785
+ process.once('exit', finishUpdateNotice);
730
786
  try {
731
787
  switch (root) {
732
788
  case 'whoami':
@@ -761,8 +817,18 @@ async function main() {
761
817
  }
762
818
  }
763
819
  catch (err) {
820
+ finishUpdateNotice();
764
821
  const msg = err instanceof Error ? err.message : String(err);
765
- fail(msg);
822
+ if (flags['--json'] === true) {
823
+ output({ success: false, message: msg }, flags);
824
+ process.exitCode = 1;
825
+ }
826
+ else
827
+ fail(msg);
828
+ }
829
+ finally {
830
+ finishUpdateNotice();
831
+ process.off('exit', finishUpdateNotice);
766
832
  }
767
833
  }
768
834
  main();
package/dist/cloud.js CHANGED
@@ -20,6 +20,9 @@ export async function checkCloudInit(rootDir, cfg, explicitEnvironment) {
20
20
  if (!spaceId) {
21
21
  throw new Error('请先执行 qmuse space use <spaceId>,或通过 --space-id 指定目标空间');
22
22
  }
23
+ return checkCloudEntitlement({ appId, spaceId }, cfg);
24
+ }
25
+ export async function checkCloudEntitlement({ appId, spaceId }, cfg) {
23
26
  const response = appId
24
27
  ? await apiCall('POST', '/api/appwrite/entitlement/check', { appId }, cfg)
25
28
  : await apiCall('POST', '/api/appwrite/entitlement/space/check', { spaceId }, cfg);
package/dist/dev/index.js CHANGED
@@ -26,6 +26,7 @@ export async function startDevelopment(options) {
26
26
  const directory = await mkdtemp(path.join(tmpdir(), 'qmuse-local-dev-'));
27
27
  const runtimePath = path.join(directory, 'runtime.json');
28
28
  const previousPath = process.env.MUSE_RUNTIME_CONFIG_PATH;
29
+ const previousHmrGate = process.env.QMUSE_HMR_GATE_ENABLED;
29
30
  let server;
30
31
  let closed = false;
31
32
  const manifestPath = path.join(root, '.qmuse/project.json');
@@ -58,11 +59,16 @@ export async function startDevelopment(options) {
58
59
  delete process.env.MUSE_RUNTIME_CONFIG_PATH;
59
60
  else
60
61
  process.env.MUSE_RUNTIME_CONFIG_PATH = previousPath;
62
+ if (previousHmrGate === undefined)
63
+ delete process.env.QMUSE_HMR_GATE_ENABLED;
64
+ else
65
+ process.env.QMUSE_HMR_GATE_ENABLED = previousHmrGate;
61
66
  }
62
67
  };
63
68
  try {
64
69
  await writeRuntime();
65
70
  process.env.MUSE_RUNTIME_CONFIG_PATH = runtimePath;
71
+ process.env.QMUSE_HMR_GATE_ENABLED = 'false';
66
72
  const vite = await import(pathToFileURL(vitePath).href);
67
73
  server = await vite.createServer({
68
74
  root,
@@ -54,8 +54,17 @@ export async function uploadImportArchive(input, cfg) {
54
54
  }
55
55
  return result.data.sourceToken;
56
56
  }
57
- export function startAppImport(body, cfg) {
58
- return apiCall('POST', '/api/app-import/start', body, cfg);
57
+ export async function startAppImport(body, cfg) {
58
+ const result = await apiCall('POST', '/api/app-import/start', body, cfg);
59
+ if (!result.ok && result.errorCode === 'APP_IMPORT_VERSION_CONFLICT') {
60
+ const raw = result.raw;
61
+ const latest = raw?.error?.latestVersionId;
62
+ const version = raw?.error?.appId === body.targetAppId &&
63
+ typeof latest === 'string' && /^[A-Za-z0-9_-]{1,128}$/u.test(latest)
64
+ ? `(当前平台版本 ${latest})` : '';
65
+ result.errorMessage = `应用已产生新版本${version}。请同步最新源码后重新导入;确认以本地源码覆盖时,重新执行 qmuse import . --force。`;
66
+ }
67
+ return result;
59
68
  }
60
69
  export function queryAppImportStatus(jobId, cfg) {
61
70
  return apiCall('POST', '/api/app-import/status', { jobId }, cfg);
@@ -2,6 +2,15 @@ import { randomUUID } from 'node:crypto';
2
2
  import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  export const QMUSE_IMPORT_DIAGNOSTIC_PATH = '.qmuse/last-import-error.json';
5
+ export function importRetryGuidance(status, project) {
6
+ const target = project?.target;
7
+ const pending = project?.manifest.pendingImports?.[project.environment];
8
+ const canRetryUpdate = target && status.operation !== 'CREATE' &&
9
+ (status.appId === target.appId || (!status.appId && pending?.jobId === status.jobId && pending.operation === 'UPDATE'));
10
+ return canRetryUpdate
11
+ ? { retryCommand: `qmuse import . --env ${project.environment}`, retryAdvice: '在当前项目目录修复源码后执行,将更新已绑定的应用。' }
12
+ : { retryCommand: null, retryAdvice: '当前任务没有可确认的原应用绑定。请先核对任务是否已创建应用,并恢复对应环境的项目绑定;直接重新 import 可能创建新应用。' };
13
+ }
5
14
  export async function writeImportFailureDiagnostic(input) {
6
15
  if (input.status.status !== 'FAILED') {
7
16
  throw new Error('只能为失败的导入任务写入诊断文件');
@@ -17,10 +26,18 @@ export async function writeImportFailureDiagnostic(input) {
17
26
  message: input.status.message,
18
27
  diagnostic: input.status.diagnostic,
19
28
  failureDiagnostic: input.status.failureDiagnostic,
20
- retryCommand: 'qmuse import .',
29
+ ...importRetryGuidance(input.status, input.project),
30
+ environment: input.project?.environment ?? input.environment,
21
31
  generatedAt: new Date().toISOString(),
22
32
  };
33
+ const historyPath = path.join(input.rootDir, '.qmuse', 'import-errors', input.project?.environment ?? input.environment ?? 'unknown', `${encodeURIComponent(input.status.jobId)}.json`);
34
+ await mkdir(path.dirname(historyPath), { recursive: true });
35
+ const historyTemporaryPath = `${historyPath}.${process.pid}.${randomUUID()}.tmp`;
23
36
  try {
37
+ await writeFile(historyTemporaryPath, `${JSON.stringify(payload, null, 2)}\n`, {
38
+ encoding: 'utf8', mode: 0o600, flag: 'wx',
39
+ });
40
+ await rename(historyTemporaryPath, historyPath);
24
41
  await writeFile(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, {
25
42
  encoding: 'utf8',
26
43
  mode: 0o600,
@@ -29,6 +46,7 @@ export async function writeImportFailureDiagnostic(input) {
29
46
  await rename(temporaryPath, filePath);
30
47
  }
31
48
  catch (error) {
49
+ await rm(historyTemporaryPath, { force: true });
32
50
  await rm(temporaryPath, { force: true });
33
51
  throw error;
34
52
  }
@@ -0,0 +1,35 @@
1
+ export function configurationGuidance(status, environment, requestedMode = 'AUTO') {
2
+ if (!['WAITING_CLOUD_CONFIRMATION', 'NEEDS_CONFIGURATION'].includes(status.status))
3
+ return undefined;
4
+ if (status.stage === 'QMUSE_CLOUD_ADAPTATION_REQUIRED') {
5
+ return { message: '请先根据诊断完成源码的 QMuse 云服务适配,再重新导入。' };
6
+ }
7
+ const mode = requestedMode === 'AUTO' ? status.cloudMode : requestedMode;
8
+ if (!/^[a-zA-Z0-9_-]+$/u.test(status.jobId))
9
+ return { message: '任务标识格式异常,请联系平台支持。' };
10
+ const base = `qmuse import configure ${status.jobId} --env ${environment}`;
11
+ const waitCommand = `qmuse import status ${status.jobId} --wait --env ${environment} --json`;
12
+ if (mode === 'QMUSE') {
13
+ const flag = status.sourceScenario === 'QMUSE_TEMPLATE'
14
+ ? '--confirm-cloud-service' : '--acknowledge-empty-cloud-data';
15
+ return {
16
+ message: `任务尚未完成,需要确认:模板使用 --confirm-cloud-service,外部项目使用 --acknowledge-empty-cloud-data。当前任务请使用 ${flag}。`,
17
+ requiredFlags: [flag],
18
+ resumeCommand: `${base} --cloud-mode QMUSE ${flag} --json`,
19
+ waitCommand,
20
+ };
21
+ }
22
+ if (mode === 'EXTERNAL') {
23
+ return {
24
+ message: `请提供外部云服务所需的公开配置:${(status.requiredConfigKeys ?? []).join(', ') || '请参考任务诊断'}。`,
25
+ requiredConfigKeys: status.requiredConfigKeys ?? [],
26
+ resumeCommand: `${base} --cloud-mode EXTERNAL --public-config @public-config.json --json`,
27
+ waitCommand,
28
+ };
29
+ }
30
+ return {
31
+ message: '请先选择 --cloud-mode QMUSE 或 EXTERNAL;QMUSE 模板需 --confirm-cloud-service,外部项目需 --acknowledge-empty-cloud-data。',
32
+ requiredFlags: ['--cloud-mode'],
33
+ waitCommand,
34
+ };
35
+ }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  export const QMUSE_PROJECT_PATH = '.qmuse/project.json';
5
5
  function isRecord(value) {
@@ -73,20 +73,21 @@ function parseManifest(value) {
73
73
  if (value.schemaVersion !== 1) {
74
74
  throw new Error(`${QMUSE_PROJECT_PATH} 的 schemaVersion 不受支持`);
75
75
  }
76
- if (value.projectKind !== 'QMUSE_TEMPLATE') {
77
- throw new Error(`${QMUSE_PROJECT_PATH} 的 projectKind 必须是 QMUSE_TEMPLATE`);
76
+ if (value.projectKind !== 'QMUSE_TEMPLATE' && value.projectKind !== 'EXTERNAL_REPOSITORY') {
77
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 projectKind 无效`);
78
78
  }
79
79
  if (value.appType !== 'GENERAL') {
80
80
  throw new Error(`${QMUSE_PROJECT_PATH} 的 appType 必须是 GENERAL`);
81
81
  }
82
- if (!Number.isSafeInteger(value.packageVersion) || Number(value.packageVersion) < 1) {
82
+ const isTemplate = value.projectKind === 'QMUSE_TEMPLATE';
83
+ if (isTemplate && (!Number.isSafeInteger(value.packageVersion) || Number(value.packageVersion) < 1)) {
83
84
  throw new Error(`${QMUSE_PROJECT_PATH} 的 packageVersion 无效`);
84
85
  }
85
- if (!isRecord(value.skillVersions)) {
86
+ if (isTemplate && !isRecord(value.skillVersions)) {
86
87
  throw new Error(`${QMUSE_PROJECT_PATH} 的 skillVersions 必须是对象`);
87
88
  }
88
89
  const skillVersions = {};
89
- for (const [name, version] of Object.entries(value.skillVersions)) {
90
+ for (const [name, version] of Object.entries(isTemplate ? value.skillVersions : {})) {
90
91
  skillVersions[requireNonEmptyString(name, 'skillVersions key')] = requireNonEmptyString(version, `skillVersions.${name}`);
91
92
  }
92
93
  const defaultSpaceId = value.defaultSpaceId === undefined
@@ -94,11 +95,13 @@ function parseManifest(value) {
94
95
  : requireNonEmptyString(value.defaultSpaceId, 'defaultSpaceId');
95
96
  return {
96
97
  schemaVersion: 1,
97
- packageVersion: Number(value.packageVersion),
98
- projectKind: 'QMUSE_TEMPLATE',
98
+ projectKind: value.projectKind,
99
99
  appType: 'GENERAL',
100
- templateCommit: requireNonEmptyString(value.templateCommit, 'templateCommit'),
101
- skillVersions,
100
+ ...(isTemplate ? {
101
+ packageVersion: Number(value.packageVersion),
102
+ templateCommit: requireNonEmptyString(value.templateCommit, 'templateCommit'),
103
+ skillVersions,
104
+ } : {}),
102
105
  ...(defaultSpaceId ? { defaultSpaceId } : {}),
103
106
  targets: parseEnvironmentMap(value.targets, 'targets', parseTarget),
104
107
  ...(value.pendingImports === undefined
@@ -150,6 +153,7 @@ export async function loadManagedProject(rootDir, cfg, explicitEnvironment) {
150
153
  return { manifestPath, manifest, environment, target };
151
154
  }
152
155
  export async function saveManagedProject(context) {
156
+ await mkdir(path.dirname(context.manifestPath), { recursive: true });
153
157
  const temporaryPath = `${context.manifestPath}.${process.pid}.${randomUUID()}.tmp`;
154
158
  const content = `${JSON.stringify(context.manifest, null, 2)}\n`;
155
159
  try {
@@ -162,6 +166,18 @@ export async function saveManagedProject(context) {
162
166
  throw error;
163
167
  }
164
168
  }
169
+ export function createExternalProject(rootDir, cfg, explicitEnvironment) {
170
+ return {
171
+ manifestPath: path.join(rootDir, QMUSE_PROJECT_PATH),
172
+ environment: resolveProjectEnvironment(cfg, explicitEnvironment),
173
+ manifest: {
174
+ schemaVersion: 1,
175
+ projectKind: 'EXTERNAL_REPOSITORY',
176
+ appType: 'GENERAL',
177
+ targets: {},
178
+ },
179
+ };
180
+ }
165
181
  export function setPendingImport(context, pending) {
166
182
  context.manifest.pendingImports = {
167
183
  ...context.manifest.pendingImports,
@@ -0,0 +1,80 @@
1
+ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import semver from 'semver';
5
+ import { QMUSE_CLI_DISTRIBUTION, QMUSE_CLI_VERSION } from './version.js';
6
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
7
+ export function updateMessage(current, latest, distribution) {
8
+ if (typeof latest !== 'string' || !semver.valid(latest) || semver.prerelease(latest) ||
9
+ !semver.valid(current) || !semver.gt(latest, current))
10
+ return;
11
+ const command = distribution === 'PUBLIC'
12
+ ? 'npm install -g @qmuse/qmuse-cli@latest --registry=https://registry.npmjs.org/'
13
+ : 'tnpm install -g @alipay/qmuse-cli@latest';
14
+ return `qmuse-cli 有新版本 ${latest}(当前 ${current})。建议更新:${command}`;
15
+ }
16
+ // Check alongside the command; finishing a command never waits for the registry.
17
+ export function startUpdateNotice(options) {
18
+ if (!options.enabled)
19
+ return () => { };
20
+ const distribution = options.distribution ?? QMUSE_CLI_DISTRIBUTION;
21
+ const current = options.current ?? QMUSE_CLI_VERSION;
22
+ const directory = options.cacheDirectory ?? join(homedir(), '.cache', 'qmuse-cli');
23
+ const cachePath = join(directory, `update-${distribution.toLowerCase()}.json`);
24
+ const controller = new AbortController();
25
+ let latest;
26
+ let finished = false;
27
+ let cached = false;
28
+ try {
29
+ const value = JSON.parse(readFileSync(cachePath, 'utf8'));
30
+ const age = Date.now() - value.checkedAt;
31
+ if (Number.isFinite(age) && age >= 0 && age < CACHE_TTL_MS) {
32
+ latest = value.latest;
33
+ cached = true;
34
+ }
35
+ }
36
+ catch { /* Missing or corrupt cache must not affect the command. */ }
37
+ let timer;
38
+ if (!cached) {
39
+ timer = setTimeout(() => controller.abort(), 2000);
40
+ timer.unref();
41
+ const url = distribution === 'PUBLIC'
42
+ ? 'https://registry.npmjs.org/@qmuse%2fqmuse-cli/latest'
43
+ : 'https://registry.antgroup-inc.cn/@alipay%2fqmuse-cli/latest';
44
+ void (async () => {
45
+ try {
46
+ const response = await (options.fetcher ?? fetch)(url, {
47
+ signal: controller.signal,
48
+ redirect: 'error',
49
+ headers: { Accept: 'application/json' },
50
+ });
51
+ if (!response.ok)
52
+ return;
53
+ const metadata = await response.json();
54
+ if (finished || controller.signal.aborted || typeof metadata.version !== 'string' ||
55
+ !semver.valid(metadata.version) || semver.prerelease(metadata.version))
56
+ return;
57
+ latest = metadata.version;
58
+ try {
59
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
60
+ writeFileSync(cachePath, JSON.stringify({ checkedAt: Date.now(), latest }), { mode: 0o600 });
61
+ }
62
+ catch { /* Cache is best effort. */ }
63
+ }
64
+ catch { /* Update checks never fail the user's command. */ }
65
+ finally {
66
+ clearTimeout(timer);
67
+ }
68
+ })();
69
+ }
70
+ return () => {
71
+ if (finished)
72
+ return;
73
+ finished = true;
74
+ clearTimeout(timer);
75
+ controller.abort();
76
+ const message = updateMessage(current, latest, distribution);
77
+ if (message)
78
+ options.notify(message);
79
+ };
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qmuse/qmuse-cli",
3
- "version": "1.0.3-dev.3",
3
+ "version": "1.0.3-dev.5",
4
4
  "description": "将本地 React 项目安全导入 QMuse 的命令行客户端",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "dependencies": {
22
22
  "@napi-rs/keyring": "2.0.0",
23
23
  "ignore": "^7.0.8",
24
+ "semver": "7.7.4",
24
25
  "yazl": "^3.3.1"
25
26
  },
26
27
  "files": [
@@ -33,6 +34,7 @@
33
34
  "devDependencies": {
34
35
  "@playwright/test": "1.63.0",
35
36
  "@types/node": "^22.10.0",
37
+ "@types/semver": "7.7.1",
36
38
  "@types/yazl": "^3.3.1",
37
39
  "tsx": "^4.19.2",
38
40
  "typescript": "^5.9.3",