@qmuse/qmuse-cli 1.0.2-dev.1 → 1.0.3-dev.0
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 +32 -0
- package/dist/cli.js +57 -4
- package/dist/client.js +4 -1
- package/dist/dev/context.js +77 -0
- package/dist/dev/index.js +128 -0
- package/dist/dev/plugin.js +147 -0
- package/dist/import/preflight.js +1 -1
- package/dist/import/update.js +25 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -52,6 +52,8 @@ qmuse import /path/to/react-project --space-id <spaceId>
|
|
|
52
52
|
|
|
53
53
|
从 QMuse 下载的项目,在项目根目录执行 `qmuse import .` 即可更新对应应用;没有关联应用时会创建新应用。普通外部项目每次导入都会创建新应用。
|
|
54
54
|
|
|
55
|
+
平台已有新版本时,终端会询问是否用本地全部源码覆盖,不会自动合并平台修改。确认覆盖可执行 `qmuse import . --force`;脚本或 `--json` 模式须显式指定该参数。覆盖会在原应用创建新版本,仍需通过正常导入校验。
|
|
56
|
+
|
|
55
57
|
### 外部云服务
|
|
56
58
|
|
|
57
59
|
```bash
|
|
@@ -81,6 +83,36 @@ qmuse import configure <jobId> --cloud-mode EXTERNAL \
|
|
|
81
83
|
|
|
82
84
|
本地等待中断不会取消任务。回到原项目目录,使用 `status <jobId> --wait` 继续等待;任务提示需要补充云服务配置时,使用 `configure`。
|
|
83
85
|
|
|
86
|
+
## 本地云联调
|
|
87
|
+
|
|
88
|
+
需要 Node.js 20.19+(或满足项目 Vite 的版本要求)、已安装依赖的 QMuse 模板、一次成功导入和 READY 云后端。首次需要云服务时,先生成最小业务代码与真实 Schema 并导入,再启动:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
qmuse dev . --port 5173
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
命令使用项目自己的 Vite 和配置,监听 `127.0.0.1`。端口占用时自动选择可用端口,以终端打印的 URL 为准。`--env prod|pre|local` 选择 QMuse 平台环境;云资源固定为对应应用的开发环境。终端打印应用、用户和后端版本;单环境应用可能与正式页面共用数据。
|
|
95
|
+
|
|
96
|
+
CLI 获取服务端解析的公开云配置,复用 Vite 的 `MUSE_RUNTIME_CONFIG_PATH` 注入机制。配置放在系统临时目录,退出时删除。Token 按需获取且不落盘,CLI 登录凭据不交给页面。数据库、文件和函数请求由浏览器通过用户 Session 直接访问云端,不使用管理 API Key 或 Dev Key。
|
|
97
|
+
|
|
98
|
+
新版云服务资产已接入适配。旧项目在 `src/services/appwrite.ts` 的 `createQmuseAppwriteRuntime({ ... })` 参数中增加:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
fetch: import.meta.env.DEV
|
|
102
|
+
? (window as Window & {
|
|
103
|
+
__QMUSE_LOCAL_DEV__?: { fetch: typeof globalThis.fetch };
|
|
104
|
+
}).__QMUSE_LOCAL_DEV__?.fetch
|
|
105
|
+
: undefined,
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
不修改 SDK。只有 `qmuse dev` 注入本地 fetch;普通 `npm run dev` 和线上运行继续使用默认行为。生产构建移除本地适配分支。服务端必须先部署 `/api/appwrite/local-dev/context` 和 `/api/appwrite/local-dev/mutation-events`;缺少接口时不会降级为模拟云服务。
|
|
109
|
+
|
|
110
|
+
页面改动热更新,Schema/函数改动继续执行 `qmuse import .`。导入更新本地绑定后,连接会重新获取配置并刷新页面。切换登录账号或应用绑定需重启连接;退出 CLI 不会自动撤销浏览器已经建立的 AppBase Session。SDK 的匿名降级行为保持不变,验收必须检查实际 Session 用户,不能只看页面是否显示。
|
|
111
|
+
|
|
112
|
+
联调验收包括 CRUD、刷新读回、文件上传/预览、函数调用和权限拒绝;浏览器 Cookie 限制需在目标浏览器验证。`.muse-tmp`、`.qmuse` 和系统临时配置不进入导入包。
|
|
113
|
+
|
|
114
|
+
研发验证:`npm test`、`npm run build`。浏览器集成测试使用已安装依赖的模板与相邻 `qmuse-cloud` 源仓库,运行 `QMUSE_DEV_TEMPLATE_ROOT=/path/to/installed-template npm run test:browser`,需本机 Chrome;测试中的云接口是隔离模拟后端,不代表真实云验收。
|
|
115
|
+
|
|
84
116
|
## 更多命令
|
|
85
117
|
|
|
86
118
|
运行 `qmuse help` 查看完整命令和参数,运行 `qmuse --version` 查看版本。
|
package/dist/cli.js
CHANGED
|
@@ -16,14 +16,16 @@ import { createImportArchive } from './import/archive.js';
|
|
|
16
16
|
import { configureAppImport, getAppOpenUrl, getImportStatusUrl, queryAppImportStatus, startAppImport, uploadImportArchive, } from './import/api.js';
|
|
17
17
|
import { pollAppImport } from './import/poll.js';
|
|
18
18
|
import { preflightImport } from './import/preflight.js';
|
|
19
|
+
import { resolveUpdateBase } from './import/update.js';
|
|
19
20
|
import { clearPendingImport, completeManagedImport, loadManagedProject, saveManagedProject, setPendingImport, } from './import/project.js';
|
|
20
21
|
import { clearImportFailureDiagnostic, writeImportFailureDiagnostic, } from './import/diagnostic.js';
|
|
21
22
|
import { checkCloudInit, CLOUD_INIT_SUCCESS_MESSAGE } from './cloud.js';
|
|
22
23
|
import { QMUSE_CLI_VERSION } from './version.js';
|
|
24
|
+
import { startDevelopment } from './dev/index.js';
|
|
23
25
|
// ---------------- 参数解析 ----------------
|
|
24
26
|
const BOOL_FLAGS = new Set([
|
|
25
27
|
'--help', '-h', '--version', '-V', '--json', '--status',
|
|
26
|
-
'--log', '--no-open', '--wait',
|
|
28
|
+
'--log', '--no-open', '--wait', '--force',
|
|
27
29
|
'--acknowledge-empty-cloud-data',
|
|
28
30
|
'--confirm-cloud-service',
|
|
29
31
|
]);
|
|
@@ -389,6 +391,9 @@ async function cmdImport(command, flags) {
|
|
|
389
391
|
const managedProject = await loadManagedProject(rootDir, cfg, str(flags, '--env'));
|
|
390
392
|
const explicitSpaceId = str(flags, '--space-id');
|
|
391
393
|
const operation = managedProject?.target ? 'UPDATE' : 'CREATE';
|
|
394
|
+
if (flags['--force'] === true && !managedProject?.target) {
|
|
395
|
+
throw new Error('--force 仅用于更新已关联的应用,请先确认项目的应用绑定');
|
|
396
|
+
}
|
|
392
397
|
if (managedProject?.target &&
|
|
393
398
|
explicitSpaceId &&
|
|
394
399
|
explicitSpaceId !== managedProject.target.spaceId) {
|
|
@@ -404,7 +409,16 @@ async function cmdImport(command, flags) {
|
|
|
404
409
|
if (flags['--json'] !== true)
|
|
405
410
|
log.info('正在只读预检本地仓库...');
|
|
406
411
|
const preflight = await preflightImport(directory);
|
|
407
|
-
|
|
412
|
+
const updateBase = managedProject?.target
|
|
413
|
+
? await resolveUpdateBase({
|
|
414
|
+
target: managedProject.target,
|
|
415
|
+
cfg,
|
|
416
|
+
force: flags['--force'] === true,
|
|
417
|
+
interactive: process.stdin.isTTY === true && flags['--json'] !== true,
|
|
418
|
+
prompt,
|
|
419
|
+
})
|
|
420
|
+
: undefined;
|
|
421
|
+
if (managedProject?.target?.lastSourceHash === preflight.sourceHash && !updateBase?.remoteChanged) {
|
|
408
422
|
const result = {
|
|
409
423
|
success: true,
|
|
410
424
|
noChanges: true,
|
|
@@ -457,7 +471,7 @@ async function cmdImport(command, flags) {
|
|
|
457
471
|
...(managedProject?.target
|
|
458
472
|
? {
|
|
459
473
|
targetAppId: managedProject.target.appId,
|
|
460
|
-
baseVersionId:
|
|
474
|
+
baseVersionId: updateBase.baseVersionId,
|
|
461
475
|
}
|
|
462
476
|
: {}),
|
|
463
477
|
}, cfg);
|
|
@@ -517,6 +531,41 @@ async function cmdCloud(command, flags) {
|
|
|
517
531
|
process.stdout.write(`${CLOUD_INIT_SUCCESS_MESSAGE}\n`);
|
|
518
532
|
}
|
|
519
533
|
// ---------------- 命令实现 ----------------
|
|
534
|
+
async function cmdDev(command, flags) {
|
|
535
|
+
const cfg = resolveCfg(flags);
|
|
536
|
+
requireAuth(cfg);
|
|
537
|
+
if (flags['--port'] !== undefined && typeof flags['--port'] !== 'string')
|
|
538
|
+
fail('--port 需要端口号');
|
|
539
|
+
const port = flags['--port'] === undefined ? 5173 : Number(str(flags, '--port'));
|
|
540
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
541
|
+
fail('--port 必须为 1 到 65535 的整数');
|
|
542
|
+
const development = await startDevelopment({
|
|
543
|
+
root: command[1] || process.cwd(), cfg,
|
|
544
|
+
environment: str(flags, '--env'),
|
|
545
|
+
port,
|
|
546
|
+
});
|
|
547
|
+
if (flags['--json'] === true) {
|
|
548
|
+
output({ success: true, appId: development.context.appId, userId: development.context.user.userId,
|
|
549
|
+
backendVersionId: development.context.backendVersionId, mode: development.context.mode,
|
|
550
|
+
urls: development.server.resolvedUrls?.local,
|
|
551
|
+
}, flags);
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
log.info(`本地云连接:appId=${development.context.appId},用户=${development.context.user.userId},后端版本=${development.context.backendVersionId}`);
|
|
555
|
+
if (development.context.mode === 'SINGLE_ENV')
|
|
556
|
+
log.warn('当前为单环境应用,开发云资源可能与正式页面共用。');
|
|
557
|
+
development.server.printUrls();
|
|
558
|
+
}
|
|
559
|
+
await new Promise(resolve => {
|
|
560
|
+
const stop = () => {
|
|
561
|
+
process.off('SIGINT', stop);
|
|
562
|
+
process.off('SIGTERM', stop);
|
|
563
|
+
void development.close().finally(resolve);
|
|
564
|
+
};
|
|
565
|
+
process.once('SIGINT', stop);
|
|
566
|
+
process.once('SIGTERM', stop);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
520
569
|
async function cmdWhoami(flags) {
|
|
521
570
|
const cfg = resolveCfg(flags);
|
|
522
571
|
if (flags['--json'] === true) {
|
|
@@ -642,9 +691,10 @@ const HELP = `QMuse CLI —— 将本地 React 项目导入 QMuse
|
|
|
642
691
|
config [get|path] 查看配置
|
|
643
692
|
|
|
644
693
|
cloud init -d "<应用功能描述>" 校验当前空间的 QMuse 云服务权益
|
|
694
|
+
dev [directory] [--port <port>] 连接已导入应用的开发云资源并启动本地 Vite
|
|
645
695
|
|
|
646
696
|
import <directory> [--space-id <id>] 导入本地 React 仓库
|
|
647
|
-
[--title <title>] [--cloud-mode AUTO|EXTERNAL|QMUSE]
|
|
697
|
+
[--title <title>] [--force] [--cloud-mode AUTO|EXTERNAL|QMUSE]
|
|
648
698
|
[--public-config '<json>'|@file] [--acknowledge-empty-cloud-data]
|
|
649
699
|
[--confirm-cloud-service]
|
|
650
700
|
[--no-open] [--json] [--log]
|
|
@@ -694,6 +744,9 @@ async function main() {
|
|
|
694
744
|
case 'cloud':
|
|
695
745
|
await cmdCloud(command, flags);
|
|
696
746
|
return;
|
|
747
|
+
case 'dev':
|
|
748
|
+
await cmdDev(command, flags);
|
|
749
|
+
return;
|
|
697
750
|
case 'import':
|
|
698
751
|
await cmdImport(command, flags);
|
|
699
752
|
return;
|
package/dist/client.js
CHANGED
|
@@ -90,7 +90,10 @@ export async function apiCall(method, path, body, cfgOverrideOrOpts) {
|
|
|
90
90
|
if (method === 'POST' && !headers['Referer']) {
|
|
91
91
|
Object.assign(headers, requestSourceHeaders(cfg));
|
|
92
92
|
}
|
|
93
|
-
const init = { method, headers
|
|
93
|
+
const init = { method, headers,
|
|
94
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
95
|
+
...(opts.redirect ? { redirect: opts.redirect } : {}),
|
|
96
|
+
};
|
|
94
97
|
if (body !== undefined && method !== 'GET') {
|
|
95
98
|
init.body = JSON.stringify(body);
|
|
96
99
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { apiCall, hasAuth } from '../client.js';
|
|
2
|
+
import { loadManagedProject } from '../import/project.js';
|
|
3
|
+
function text(value, field) {
|
|
4
|
+
if (typeof value !== 'string' || !value.trim())
|
|
5
|
+
throw new Error(`开发连接配置缺少 ${field}`);
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
function endpoint(value, field) {
|
|
9
|
+
const url = new URL(text(value, field));
|
|
10
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
11
|
+
throw new Error(`开发连接配置 ${field} 无效`);
|
|
12
|
+
}
|
|
13
|
+
return url.href.replace(/\/$/u, '');
|
|
14
|
+
}
|
|
15
|
+
export function parseDevContext(value, appId) {
|
|
16
|
+
const data = value;
|
|
17
|
+
if (!data || data.protocolVersion !== 1 || data.appId !== appId || data.env !== 'dev' ||
|
|
18
|
+
!['SINGLE_ENV', 'MULTI_ENV'].includes(data.mode)) {
|
|
19
|
+
throw new Error('开发连接协议、应用或环境不匹配,请更新 CLI 或重新导入');
|
|
20
|
+
}
|
|
21
|
+
// Whitelist public fields: never persist a server response wholesale.
|
|
22
|
+
return {
|
|
23
|
+
protocolVersion: 1,
|
|
24
|
+
appId,
|
|
25
|
+
appVersionId: text(data.appVersionId, 'appVersionId'),
|
|
26
|
+
backendVersionId: text(data.backendVersionId, 'backendVersionId'),
|
|
27
|
+
env: 'dev',
|
|
28
|
+
mode: data.mode,
|
|
29
|
+
domain: endpoint(data.domain, 'domain'),
|
|
30
|
+
appwrite: {
|
|
31
|
+
endpoint: endpoint(data.appwrite?.endpoint, 'endpoint'),
|
|
32
|
+
projectId: text(data.appwrite?.projectId, 'projectId'),
|
|
33
|
+
databaseId: text(data.appwrite?.databaseId, 'databaseId'),
|
|
34
|
+
...(data.appwrite?.storageBucketId ? { storageBucketId: text(data.appwrite.storageBucketId, 'storageBucketId') } : {}),
|
|
35
|
+
},
|
|
36
|
+
user: {
|
|
37
|
+
userId: text(data.user?.userId, 'userId'),
|
|
38
|
+
userName: typeof data.user?.userName === 'string' ? data.user.userName : '',
|
|
39
|
+
avatarUrl: typeof data.user?.avatarUrl === 'string' ? data.user.avatarUrl : '',
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export async function loadDevContext(root, cfg, environment) {
|
|
44
|
+
if (!hasAuth(cfg))
|
|
45
|
+
throw new Error('请先运行 qmuse login');
|
|
46
|
+
const project = await loadManagedProject(root, cfg, environment);
|
|
47
|
+
if (!project?.target)
|
|
48
|
+
throw new Error('请先执行 qmuse import . 并等待云服务 READY,再启动本地联调');
|
|
49
|
+
const result = await apiCall('POST', '/api/appwrite/local-dev/context', { appId: project.target.appId }, {
|
|
50
|
+
cfg: { ...cfg, spaceId: project.target.spaceId }, signal: AbortSignal.timeout(30_000), redirect: 'error',
|
|
51
|
+
});
|
|
52
|
+
if (!result.ok)
|
|
53
|
+
throw new Error(`获取开发连接失败:${result.errorMessage || result.errorCode || `HTTP ${result.status}`}`);
|
|
54
|
+
return parseDevContext(result.data, project.target.appId);
|
|
55
|
+
}
|
|
56
|
+
export function browserRuntime(context) {
|
|
57
|
+
return {
|
|
58
|
+
__MUSE__: {
|
|
59
|
+
appId: context.appId,
|
|
60
|
+
yfdAppId: context.appId,
|
|
61
|
+
appVersionId: context.appVersionId,
|
|
62
|
+
env: context.env,
|
|
63
|
+
domain: context.domain,
|
|
64
|
+
appwrite: context.appwrite,
|
|
65
|
+
},
|
|
66
|
+
__TERN__: {
|
|
67
|
+
user: {
|
|
68
|
+
clientUser: {
|
|
69
|
+
...context.user,
|
|
70
|
+
displayName: context.user.userName,
|
|
71
|
+
nickName: context.user.userName,
|
|
72
|
+
},
|
|
73
|
+
outUserNo: context.user.userId,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { watchFile, unwatchFile } from 'node:fs';
|
|
3
|
+
import { mkdtemp, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { apiCall } from '../client.js';
|
|
8
|
+
import { loadConfig } from '../config.js';
|
|
9
|
+
import { loadManagedProject } from '../import/project.js';
|
|
10
|
+
import { browserRuntime, loadDevContext } from './context.js';
|
|
11
|
+
import { localDevPlugin } from './plugin.js';
|
|
12
|
+
export async function startDevelopment(options) {
|
|
13
|
+
const root = await realpath(path.resolve(options.root));
|
|
14
|
+
const require = createRequire(path.join(root, 'package.json'));
|
|
15
|
+
let vitePath;
|
|
16
|
+
try {
|
|
17
|
+
vitePath = require.resolve('vite');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new Error('项目缺少 Vite,请先安装模板依赖');
|
|
21
|
+
}
|
|
22
|
+
const project = await loadManagedProject(root, options.cfg, options.environment);
|
|
23
|
+
if (!project?.target)
|
|
24
|
+
throw new Error('请先执行 qmuse import . 并等待云服务 READY,再启动本地联调');
|
|
25
|
+
const serviceFile = await readFile(path.join(root, 'src/services/appwrite.ts'), 'utf8').catch(() => '');
|
|
26
|
+
if (!serviceFile.includes('__QMUSE_LOCAL_DEV__')) {
|
|
27
|
+
throw new Error('项目尚未接入本地适配:请按最新版云服务资产在 src/services/appwrite.ts 的 Runtime options 中传入本地 fetch(见 qmuse-cli README)');
|
|
28
|
+
}
|
|
29
|
+
const context = await loadDevContext(root, options.cfg, options.environment);
|
|
30
|
+
const directory = await mkdtemp(path.join(tmpdir(), 'qmuse-local-dev-'));
|
|
31
|
+
const runtimePath = path.join(directory, 'runtime.json');
|
|
32
|
+
const previousPath = process.env.MUSE_RUNTIME_CONFIG_PATH;
|
|
33
|
+
let server;
|
|
34
|
+
let closed = false;
|
|
35
|
+
const manifestPath = path.join(root, '.qmuse/project.json');
|
|
36
|
+
let refresh;
|
|
37
|
+
let refreshRequested = false;
|
|
38
|
+
const writeRuntime = async () => {
|
|
39
|
+
await writeFile(`${runtimePath}.next`, JSON.stringify(browserRuntime(context)), { mode: 0o600 });
|
|
40
|
+
await rename(`${runtimePath}.next`, runtimePath);
|
|
41
|
+
};
|
|
42
|
+
const currentConfig = () => {
|
|
43
|
+
const cfg = loadConfig(undefined, { environment: options.environment });
|
|
44
|
+
if (cfg.sessionId !== options.cfg.sessionId || cfg.credentialOrigin !== options.cfg.credentialOrigin || cfg.baseUrl !== options.cfg.baseUrl) {
|
|
45
|
+
throw new Error('QMuse 登录已变更,请重启 qmuse dev');
|
|
46
|
+
}
|
|
47
|
+
return cfg;
|
|
48
|
+
};
|
|
49
|
+
const close = async () => {
|
|
50
|
+
if (closed)
|
|
51
|
+
return;
|
|
52
|
+
closed = true;
|
|
53
|
+
unwatchFile(manifestPath);
|
|
54
|
+
try {
|
|
55
|
+
await server?.close();
|
|
56
|
+
await refresh;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await rm(directory, { recursive: true, force: true });
|
|
60
|
+
if (previousPath === undefined)
|
|
61
|
+
delete process.env.MUSE_RUNTIME_CONFIG_PATH;
|
|
62
|
+
else
|
|
63
|
+
process.env.MUSE_RUNTIME_CONFIG_PATH = previousPath;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
await writeRuntime();
|
|
68
|
+
process.env.MUSE_RUNTIME_CONFIG_PATH = runtimePath;
|
|
69
|
+
const vite = await import(pathToFileURL(vitePath).href);
|
|
70
|
+
server = await vite.createServer({
|
|
71
|
+
root,
|
|
72
|
+
plugins: [localDevPlugin({
|
|
73
|
+
context,
|
|
74
|
+
call: async (apiPath, body) => {
|
|
75
|
+
const cfg = currentConfig();
|
|
76
|
+
const managed = await loadManagedProject(root, cfg, options.environment);
|
|
77
|
+
if (managed?.target?.appId !== context.appId)
|
|
78
|
+
throw new Error('应用绑定已变更,请重启 qmuse dev');
|
|
79
|
+
return apiCall('POST', apiPath, body, {
|
|
80
|
+
cfg: { ...cfg, spaceId: managed.target.spaceId },
|
|
81
|
+
signal: AbortSignal.timeout(30_000), redirect: 'error',
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
})],
|
|
85
|
+
server: {
|
|
86
|
+
host: '127.0.0.1',
|
|
87
|
+
port: options.port ?? 5173,
|
|
88
|
+
strictPort: false,
|
|
89
|
+
cors: false,
|
|
90
|
+
open: false,
|
|
91
|
+
allowedHosts: ['127.0.0.1'],
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
if (!server.config.plugins.some(plugin => plugin.name === 'qmuse-sandbox-runtime')) {
|
|
95
|
+
throw new Error('项目需要支持 runtime config 注入的 @qmuse/vite-config');
|
|
96
|
+
}
|
|
97
|
+
await server.listen();
|
|
98
|
+
watchFile(manifestPath, { interval: 1000 }, (current, previous) => {
|
|
99
|
+
if (closed || current.mtimeMs === previous.mtimeMs)
|
|
100
|
+
return;
|
|
101
|
+
refreshRequested = true;
|
|
102
|
+
if (refresh)
|
|
103
|
+
return;
|
|
104
|
+
refresh = (async () => {
|
|
105
|
+
do {
|
|
106
|
+
refreshRequested = false;
|
|
107
|
+
const cfg = currentConfig();
|
|
108
|
+
const managed = await loadManagedProject(root, cfg, options.environment);
|
|
109
|
+
if (managed?.target?.appId !== context.appId)
|
|
110
|
+
throw new Error('应用绑定已变更,请重启 qmuse dev');
|
|
111
|
+
const next = await loadDevContext(root, cfg, options.environment);
|
|
112
|
+
if (closed)
|
|
113
|
+
return;
|
|
114
|
+
Object.assign(context, next);
|
|
115
|
+
await writeRuntime();
|
|
116
|
+
server.ws.send({ type: 'full-reload' });
|
|
117
|
+
} while (refreshRequested && !closed);
|
|
118
|
+
})().catch(error => {
|
|
119
|
+
server.config.logger.error(error instanceof Error ? error.message : '开发配置刷新失败,请重启 qmuse dev');
|
|
120
|
+
}).finally(() => { refresh = undefined; });
|
|
121
|
+
});
|
|
122
|
+
return { server, context, close };
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
await close();
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
const PREFIX = '/__qmuse/local-dev/';
|
|
3
|
+
const TOKEN_HEADER = 'x-qmuse-local-connection';
|
|
4
|
+
const BODY_LIMIT = 64 * 1024;
|
|
5
|
+
const ROUTES = {
|
|
6
|
+
'/runtime/appwrite/config': 'config',
|
|
7
|
+
'/runtime/appwrite/auth/custom-token': 'token',
|
|
8
|
+
'/api/runtime/appwrite/mutation-events': 'mutation-events',
|
|
9
|
+
};
|
|
10
|
+
function respond(res, status, body) {
|
|
11
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
12
|
+
res.end(JSON.stringify(body));
|
|
13
|
+
}
|
|
14
|
+
async function readBody(req) {
|
|
15
|
+
let length = 0;
|
|
16
|
+
const chunks = [];
|
|
17
|
+
for await (const chunk of req) {
|
|
18
|
+
const bytes = Buffer.from(chunk);
|
|
19
|
+
length += bytes.length;
|
|
20
|
+
if (length > BODY_LIMIT)
|
|
21
|
+
throw new Error('Request too large');
|
|
22
|
+
chunks.push(bytes);
|
|
23
|
+
}
|
|
24
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
25
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
26
|
+
throw new Error('Invalid JSON object');
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
export function bridgeSource(context, connection) {
|
|
30
|
+
const settings = JSON.stringify({ domain: context.domain, routes: ROUTES, prefix: PREFIX, connection, header: TOKEN_HEADER })
|
|
31
|
+
.replace(/</gu, '\\u003c');
|
|
32
|
+
return `(() => {
|
|
33
|
+
const settings = ${settings};
|
|
34
|
+
const nativeFetch = window.fetch.bind(window);
|
|
35
|
+
window.__QMUSE_LOCAL_DEV__ = {
|
|
36
|
+
protocolVersion: 1,
|
|
37
|
+
async fetch(input, init) {
|
|
38
|
+
const url = new URL(input instanceof Request ? input.url : String(input), location.href);
|
|
39
|
+
const base = new URL(settings.domain);
|
|
40
|
+
const relativePath = url.pathname.slice(base.pathname.replace(/\\/$/, '').length);
|
|
41
|
+
const route = url.origin === base.origin && url.pathname.startsWith(base.pathname.replace(/\\/$/, '') + '/')
|
|
42
|
+
? settings.routes[relativePath] : undefined;
|
|
43
|
+
if (!route) return nativeFetch(input, init);
|
|
44
|
+
const request = new Request(input, init);
|
|
45
|
+
const headers = new Headers(request.headers);
|
|
46
|
+
headers.set(settings.header, settings.connection);
|
|
47
|
+
return nativeFetch(settings.prefix + route, {
|
|
48
|
+
method: request.method, headers,
|
|
49
|
+
body: request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.text(),
|
|
50
|
+
credentials: 'same-origin', signal: request.signal, keepalive: request.keepalive
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
})();`;
|
|
55
|
+
}
|
|
56
|
+
export function localDevPlugin(options) {
|
|
57
|
+
const connection = randomBytes(32).toString('hex');
|
|
58
|
+
let server;
|
|
59
|
+
return {
|
|
60
|
+
name: 'qmuse-local-dev',
|
|
61
|
+
apply: 'serve',
|
|
62
|
+
enforce: 'pre',
|
|
63
|
+
configureServer(vite) {
|
|
64
|
+
server = vite;
|
|
65
|
+
server.middlewares.use((req, res, next) => {
|
|
66
|
+
const address = server.httpServer?.address();
|
|
67
|
+
const host = address && typeof address !== 'string' ? `127.0.0.1:${address.port}` : '';
|
|
68
|
+
const origin = `http://${host}`;
|
|
69
|
+
if (!host || req.headers.host !== host ||
|
|
70
|
+
(req.headers.origin && req.headers.origin !== origin) ||
|
|
71
|
+
(req.headers['sec-fetch-site'] && !['same-origin', 'none'].includes(String(req.headers['sec-fetch-site'])))) {
|
|
72
|
+
respond(res, 403, { success: false, message: 'Invalid local development origin' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const url = new URL(req.url || '/', origin);
|
|
76
|
+
if (!url.pathname.startsWith(PREFIX)) {
|
|
77
|
+
next();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (url.pathname === `${PREFIX}bootstrap.js` && req.method === 'GET') {
|
|
81
|
+
res.writeHead(200, { 'Content-Type': 'text/javascript', 'Cache-Control': 'no-store', 'Cross-Origin-Resource-Policy': 'same-origin' });
|
|
82
|
+
res.end(bridgeSource(options.context, connection));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const provided = req.headers[TOKEN_HEADER];
|
|
86
|
+
if (req.method !== 'POST' || typeof provided !== 'string' || Buffer.byteLength(provided) !== Buffer.byteLength(connection) ||
|
|
87
|
+
!timingSafeEqual(Buffer.from(provided), Buffer.from(connection))) {
|
|
88
|
+
respond(res, 403, { success: false, message: 'Invalid local connection' });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
void handle(url.pathname.slice(PREFIX.length), req, res).catch(() => {
|
|
92
|
+
if (!res.headersSent)
|
|
93
|
+
respond(res, 400, { success: false, message: 'Invalid local development request' });
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
transformIndexHtml: {
|
|
98
|
+
order: 'pre',
|
|
99
|
+
handler: () => [{ tag: 'script', attrs: { src: `${PREFIX}bootstrap.js` }, injectTo: 'head-prepend' }],
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
async function handle(route, req, res) {
|
|
103
|
+
if (!['config', 'token', 'mutation-events'].includes(route)) {
|
|
104
|
+
respond(res, 404, { success: false, message: 'Unknown local operation' });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const body = await readBody(req);
|
|
108
|
+
const context = options.context;
|
|
109
|
+
if (body.appId !== context.appId || (body.env !== undefined && body.env !== 'dev') ||
|
|
110
|
+
(body.projectId !== undefined && body.projectId !== context.appwrite.projectId)) {
|
|
111
|
+
respond(res, 409, { success: false, message: 'Application binding changed; restart qmuse dev' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (route === 'config') {
|
|
115
|
+
respond(res, 200, { success: true, data: { ...context.appwrite, authTokenEndpoint: '/runtime/appwrite/auth/custom-token' } });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const requestBody = { appId: context.appId };
|
|
119
|
+
if (route === 'mutation-events') {
|
|
120
|
+
for (const key of ['schemaVersion', 'operation', 'outcome', 'tableId', 'rowId', 'durationMs', 'errorCode']) {
|
|
121
|
+
if (body[key] !== undefined)
|
|
122
|
+
requestBody[key] = body[key];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const result = await options.call(route === 'token'
|
|
126
|
+
? '/runtime/appwrite/auth/custom-token'
|
|
127
|
+
: '/api/appwrite/local-dev/mutation-events', requestBody);
|
|
128
|
+
if (!result.ok) {
|
|
129
|
+
respond(res, result.status >= 400 ? result.status : 502, {
|
|
130
|
+
success: false, code: result.status, type: result.errorCode,
|
|
131
|
+
message: result.errorMessage || 'QMuse request failed; check login and restart qmuse dev',
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (route === 'token') {
|
|
136
|
+
const token = result.data;
|
|
137
|
+
if (!token || token.projectId !== context.appwrite.projectId || token.endpoint !== context.appwrite.endpoint ||
|
|
138
|
+
token.databaseId !== context.appwrite.databaseId || typeof token.userId !== 'string' || !token.userId || typeof token.secret !== 'string' || !token.secret) {
|
|
139
|
+
respond(res, 409, { success: false, message: 'Cloud binding changed; restart qmuse dev' });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
respond(res, 200, { success: true, data: { userId: token.userId, secret: token.secret, expire: token.expire } });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
respond(res, 200, { success: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
package/dist/import/preflight.js
CHANGED
|
@@ -4,7 +4,7 @@ import { lstat, readFile, readdir } from 'node:fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import ignore from 'ignore';
|
|
6
6
|
const IGNORED_DIRECTORIES = new Set([
|
|
7
|
-
'.git', '.agents', '.local', '.qmuse', 'node_modules', 'dist', 'build', '.vite', '.turbo',
|
|
7
|
+
'.git', '.agents', '.local', '.qmuse', '.muse-tmp', 'node_modules', 'dist', 'build', '.vite', '.turbo',
|
|
8
8
|
]);
|
|
9
9
|
const IGNORED_ROOT_FILES = new Set(['AGENTS.md']);
|
|
10
10
|
const LOCAL_CONTROL_FILES = new Set(['.qmuse-cli.json']);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { apiCall } from '../client.js';
|
|
2
|
+
export async function resolveUpdateBase(options) {
|
|
3
|
+
const { target, cfg, force, interactive, prompt } = options;
|
|
4
|
+
const response = await apiCall('POST', '/api/appVersion/findAllByAppIdDesc', { appId: target.appId, pageIndex: 1, pageSize: 1 }, { ...cfg, spaceId: target.spaceId });
|
|
5
|
+
if (!response.ok) {
|
|
6
|
+
throw new Error(`查询平台最新版本失败:${response.errorMessage ?? response.errorCode ?? `HTTP ${response.status}`}`);
|
|
7
|
+
}
|
|
8
|
+
const latest = response.data?.list?.[0];
|
|
9
|
+
if (!latest || typeof latest.id !== 'string' || !latest.id.trim() || latest.appId !== target.appId) {
|
|
10
|
+
throw new Error('平台未返回有效的应用最新版本,已停止导入');
|
|
11
|
+
}
|
|
12
|
+
const remoteChanged = latest.id !== target.lastVersionId;
|
|
13
|
+
if (remoteChanged && !force) {
|
|
14
|
+
const conflict = `平台版本已从 ${target.lastVersionId} 更新为 ${latest.id}`;
|
|
15
|
+
if (!interactive) {
|
|
16
|
+
throw new Error(`${conflict}。请同步最新源码;确认以本地源码覆盖时,使用 qmuse import . --force`);
|
|
17
|
+
}
|
|
18
|
+
const answer = await prompt(`${conflict}。是否以本地全部源码覆盖平台代码(不会合并平台修改),并在原应用创建新版本?[y/N]:`);
|
|
19
|
+
if (!/^y(?:es)?$/iu.test(answer.trim())) {
|
|
20
|
+
throw new Error('已取消覆盖,未上传源码或更改本地版本绑定');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// Keep the confirmed version fixed so server-side concurrency checks still apply.
|
|
24
|
+
return { baseVersionId: latest.id, remoteChanged };
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qmuse/qmuse-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3-dev.0",
|
|
4
4
|
"description": "将本地 React 项目安全导入 QMuse 的命令行客户端",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"build": "npm run clean && tsc -p tsconfig.json",
|
|
14
14
|
"qmuse": "tsx src/cli.ts",
|
|
15
15
|
"test": "node --import tsx --test test/*.test.ts",
|
|
16
|
+
"test:browser": "node --import tsx --test test/dev.browser.ts",
|
|
16
17
|
"prepack": "npm run build",
|
|
17
18
|
"prepublishOnly": "npm run build",
|
|
18
19
|
"publish:public": "bash scripts/publish-public.sh"
|
|
@@ -30,10 +31,12 @@
|
|
|
30
31
|
"node": ">=20"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
34
|
+
"@playwright/test": "1.63.0",
|
|
33
35
|
"@types/node": "^22.10.0",
|
|
34
36
|
"@types/yazl": "^3.3.1",
|
|
35
37
|
"tsx": "^4.19.2",
|
|
36
|
-
"typescript": "^5.9.3"
|
|
38
|
+
"typescript": "^5.9.3",
|
|
39
|
+
"vite": "7.3.6"
|
|
37
40
|
},
|
|
38
41
|
"license": "MIT",
|
|
39
42
|
"publishConfig": {
|