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

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
@@ -95,9 +95,11 @@ qmuse dev . --port 5173
95
95
 
96
96
  CLI 获取服务端解析的公开云配置,复用 Vite 的 `MUSE_RUNTIME_CONFIG_PATH` 注入机制。配置放在系统临时目录,退出时删除。Token 按需获取且不落盘,CLI 登录凭据不交给页面。数据库、文件和函数请求由浏览器通过用户 Session 直接访问云端,不使用管理 API Key 或 Dev Key。
97
97
 
98
- CLI 的 Vite 插件在加载 `src/services/appwrite.ts` 时,通过语法解析为 Runtime 初始化自动注入本地 fetch,不写回项目文件。原版云服务资产和已下载的模板无需手工添加适配,也无需发布 qmuse-cloud 或修改 SDK。支持从 SDK 命名导入(含别名),在顶层使用对象参数初始化一次;不支持的写法在启动或热更新时明确报错。开发时 Runtime 的 fetch 由 CLI 接管,包括对象中已有的自定义 fetch。
98
+ CLI 将本地 Runtime 的 `__MUSE__.domain` 设置为实际监听地址(如 `http://127.0.0.1:5173`),保留真实的 `appwrite.endpoint`。SDK 使用默认 fetch,将配置、Token 和操作上报发往本地三个固定接口,由 CLI 处理或转发;数据库、文件、函数仍直连云端。接口仅接受同源 JSON POST,并校验 Host、Fetch Metadata 和应用绑定。
99
99
 
100
- 只有 `qmuse dev` 启用该插件;普通 `npm run dev` 和生产构建继续使用原始源码。服务端必须先部署 `/api/appwrite/local-dev/context` 和 `/api/appwrite/local-dev/mutation-events`,再发布并安装新版 CLI;缺少接口时不会降级为模拟云服务。
100
+ 不解析或改写业务源码,不注入自定义 fetch,不要求固定服务文件路径,也无需修改或发布 qmuse-cloud、Runtime SDK。已下载的模板使用原版云服务资产即可。普通开发和生产构建使用原有配置。其他自行基于 `__MUSE__.domain` 拼接的平台 API 不会被通用转发,需要单独确认接入方式。
101
+
102
+ 服务端必须先部署 `/api/appwrite/local-dev/context` 和 `/api/appwrite/local-dev/mutation-events`,再发布并安装新版 CLI;缺少接口时不会降级为模拟云服务。
101
103
 
102
104
  页面改动热更新,Schema/函数改动继续执行 `qmuse import .`。导入更新本地绑定后,连接会重新获取配置并刷新页面。切换登录账号或应用绑定需重启连接;退出 CLI 不会自动撤销浏览器已经建立的 AppBase Session。SDK 的匿名降级行为保持不变,验收必须检查实际 Session 用户,不能只看页面是否显示。
103
105
 
@@ -53,14 +53,14 @@ export async function loadDevContext(root, cfg, environment) {
53
53
  throw new Error(`获取开发连接失败:${result.errorMessage || result.errorCode || `HTTP ${result.status}`}`);
54
54
  return parseDevContext(result.data, project.target.appId);
55
55
  }
56
- export function browserRuntime(context) {
56
+ export function browserRuntime(context, localOrigin) {
57
57
  return {
58
58
  __MUSE__: {
59
59
  appId: context.appId,
60
60
  yfdAppId: context.appId,
61
61
  appVersionId: context.appVersionId,
62
62
  env: context.env,
63
- domain: context.domain,
63
+ domain: localOrigin,
64
64
  appwrite: context.appwrite,
65
65
  },
66
66
  __TERN__: {
package/dist/dev/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { watchFile, unwatchFile } from 'node:fs';
3
- import { mkdtemp, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { mkdtemp, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
@@ -9,7 +9,6 @@ import { loadConfig } from '../config.js';
9
9
  import { loadManagedProject } from '../import/project.js';
10
10
  import { browserRuntime, loadDevContext } from './context.js';
11
11
  import { localDevPlugin } from './plugin.js';
12
- import { injectLocalRuntimeFetch } from './transform.js';
13
12
  export async function startDevelopment(options) {
14
13
  const root = await realpath(path.resolve(options.root));
15
14
  const require = createRequire(path.join(root, 'package.json'));
@@ -23,8 +22,6 @@ export async function startDevelopment(options) {
23
22
  const project = await loadManagedProject(root, options.cfg, options.environment);
24
23
  if (!project?.target)
25
24
  throw new Error('请先执行 qmuse import . 并等待云服务 READY,再启动本地联调');
26
- const serviceFile = await readFile(path.join(root, 'src/services/appwrite.ts'), 'utf8').catch(() => '');
27
- injectLocalRuntimeFetch(serviceFile, path.join(root, 'src/services/appwrite.ts'));
28
25
  const context = await loadDevContext(root, options.cfg, options.environment);
29
26
  const directory = await mkdtemp(path.join(tmpdir(), 'qmuse-local-dev-'));
30
27
  const runtimePath = path.join(directory, 'runtime.json');
@@ -34,8 +31,9 @@ export async function startDevelopment(options) {
34
31
  const manifestPath = path.join(root, '.qmuse/project.json');
35
32
  let refresh;
36
33
  let refreshRequested = false;
34
+ let localOrigin = 'http://127.0.0.1:0';
37
35
  const writeRuntime = async () => {
38
- await writeFile(`${runtimePath}.next`, JSON.stringify(browserRuntime(context)), { mode: 0o600 });
36
+ await writeFile(`${runtimePath}.next`, JSON.stringify(browserRuntime(context, localOrigin)), { mode: 0o600 });
39
37
  await rename(`${runtimePath}.next`, runtimePath);
40
38
  };
41
39
  const currentConfig = () => {
@@ -94,6 +92,11 @@ export async function startDevelopment(options) {
94
92
  throw new Error('项目需要支持 runtime config 注入的 @qmuse/vite-config');
95
93
  }
96
94
  await server.listen();
95
+ const address = server.httpServer?.address();
96
+ if (!address || typeof address === 'string')
97
+ throw new Error('无法获取本地开发服务端口');
98
+ localOrigin = `http://127.0.0.1:${address.port}`;
99
+ await writeRuntime();
97
100
  watchFile(manifestPath, { interval: 1000 }, (current, previous) => {
98
101
  if (closed || current.mtimeMs === previous.mtimeMs)
99
102
  return;
@@ -1,8 +1,3 @@
1
- import { randomBytes, timingSafeEqual } from 'node:crypto';
2
- import path from 'node:path';
3
- import { injectLocalRuntimeFetch } from './transform.js';
4
- const PREFIX = '/__qmuse/local-dev/';
5
- const TOKEN_HEADER = 'x-qmuse-local-connection';
6
1
  const BODY_LIMIT = 64 * 1024;
7
2
  const ROUTES = {
8
3
  '/runtime/appwrite/config': 'config',
@@ -28,49 +23,12 @@ async function readBody(req) {
28
23
  throw new Error('Invalid JSON object');
29
24
  return value;
30
25
  }
31
- export function bridgeSource(context, connection) {
32
- const settings = JSON.stringify({ domain: context.domain, routes: ROUTES, prefix: PREFIX, connection, header: TOKEN_HEADER })
33
- .replace(/</gu, '\\u003c');
34
- return `(() => {
35
- const settings = ${settings};
36
- const nativeFetch = window.fetch.bind(window);
37
- window.__QMUSE_LOCAL_DEV__ = {
38
- protocolVersion: 1,
39
- async fetch(input, init) {
40
- const url = new URL(input instanceof Request ? input.url : String(input), location.href);
41
- const base = new URL(settings.domain);
42
- const relativePath = url.pathname.slice(base.pathname.replace(/\\/$/, '').length);
43
- const route = url.origin === base.origin && url.pathname.startsWith(base.pathname.replace(/\\/$/, '') + '/')
44
- ? settings.routes[relativePath] : undefined;
45
- if (!route) return nativeFetch(input, init);
46
- const request = new Request(input, init);
47
- const headers = new Headers(request.headers);
48
- headers.set(settings.header, settings.connection);
49
- return nativeFetch(settings.prefix + route, {
50
- method: request.method, headers,
51
- body: request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.text(),
52
- credentials: 'same-origin', signal: request.signal, keepalive: request.keepalive
53
- });
54
- }
55
- };
56
- })();`;
57
- }
58
26
  export function localDevPlugin(options) {
59
- const connection = randomBytes(32).toString('hex');
60
27
  let server;
61
- let servicePath;
62
28
  return {
63
29
  name: 'qmuse-local-dev',
64
30
  apply: 'serve',
65
31
  enforce: 'pre',
66
- configResolved(config) {
67
- servicePath = path.resolve(config.root, 'src/services/appwrite.ts');
68
- },
69
- transform(code, id) {
70
- if (id.split('?')[0] !== servicePath)
71
- return;
72
- return injectLocalRuntimeFetch(code, servicePath);
73
- },
74
32
  configureServer(vite) {
75
33
  server = vite;
76
34
  server.middlewares.use((req, res, next) => {
@@ -84,37 +42,35 @@ export function localDevPlugin(options) {
84
42
  return;
85
43
  }
86
44
  const url = new URL(req.url || '/', origin);
87
- if (!url.pathname.startsWith(PREFIX)) {
88
- next();
45
+ if (!Object.hasOwn(ROUTES, url.pathname)) {
46
+ if (url.pathname.startsWith('/runtime/') || url.pathname.startsWith('/api/runtime/')) {
47
+ respond(res, 404, { success: false, message: 'Unknown local operation' });
48
+ }
49
+ else
50
+ next();
89
51
  return;
90
52
  }
91
- if (url.pathname === `${PREFIX}bootstrap.js` && req.method === 'GET') {
92
- res.writeHead(200, { 'Content-Type': 'text/javascript', 'Cache-Control': 'no-store', 'Cross-Origin-Resource-Policy': 'same-origin' });
93
- res.end(bridgeSource(options.context, connection));
53
+ // Browser requests must be same-origin JSON; CLI credentials stay on the server.
54
+ if (req.headers.origin !== origin) {
55
+ respond(res, 403, { success: false, message: 'Invalid local development origin' });
94
56
  return;
95
57
  }
96
- const provided = req.headers[TOKEN_HEADER];
97
- if (req.method !== 'POST' || typeof provided !== 'string' || Buffer.byteLength(provided) !== Buffer.byteLength(connection) ||
98
- !timingSafeEqual(Buffer.from(provided), Buffer.from(connection))) {
99
- respond(res, 403, { success: false, message: 'Invalid local connection' });
58
+ if (req.method !== 'POST') {
59
+ respond(res, 405, { success: false, message: 'POST required' });
100
60
  return;
101
61
  }
102
- void handle(url.pathname.slice(PREFIX.length), req, res).catch(() => {
62
+ if (req.headers['content-type']?.split(';')[0].trim().toLowerCase() !== 'application/json') {
63
+ respond(res, 415, { success: false, message: 'JSON required' });
64
+ return;
65
+ }
66
+ void handle(ROUTES[url.pathname], req, res).catch(() => {
103
67
  if (!res.headersSent)
104
68
  respond(res, 400, { success: false, message: 'Invalid local development request' });
105
69
  });
106
70
  });
107
71
  },
108
- transformIndexHtml: {
109
- order: 'pre',
110
- handler: () => [{ tag: 'script', attrs: { src: `${PREFIX}bootstrap.js` }, injectTo: 'head-prepend' }],
111
- },
112
72
  };
113
73
  async function handle(route, req, res) {
114
- if (!['config', 'token', 'mutation-events'].includes(route)) {
115
- respond(res, 404, { success: false, message: 'Unknown local operation' });
116
- return;
117
- }
118
74
  const body = await readBody(req);
119
75
  const context = options.context;
120
76
  if (body.appId !== context.appId || (body.env !== undefined && body.env !== 'dev') ||
@@ -1,7 +1,9 @@
1
1
  import { openAsBlob } from 'node:fs';
2
2
  import { apiCall, authHeaders, buildUrl, requestSourceHeaders, } from '../client.js';
3
+ import { assertImportArchiveSize } from './limits.js';
3
4
  export async function uploadImportArchive(input, cfg) {
4
5
  const archive = await openAsBlob(input.filePath, { type: 'application/zip' });
6
+ assertImportArchiveSize(archive.size);
5
7
  const headers = authHeaders(cfg);
6
8
  headers['Content-Type'] = 'application/zip';
7
9
  headers['Content-Length'] = String(archive.size);
@@ -18,15 +20,37 @@ export async function uploadImportArchive(input, cfg) {
18
20
  catch (error) {
19
21
  throw new Error(`上传源码失败:${error instanceof Error ? error.message : String(error)}`);
20
22
  }
21
- let result;
23
+ const contentType = response.headers.get('content-type')?.split(';')[0].trim();
24
+ const traceId = response.headers.get('x-trace-id') ?? response.headers.get('trace-id');
25
+ const details = [
26
+ `HTTP ${response.status}`,
27
+ `源码包 ${(archive.size / 1024 / 1024).toFixed(2)} MiB(${archive.size} 字节)`,
28
+ contentType && /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/iu.test(contentType)
29
+ ? `响应类型 ${contentType}` : undefined,
30
+ traceId && /^[a-z0-9_-]{1,128}$/iu.test(traceId) ? `traceId=${traceId}` : undefined,
31
+ ].filter(Boolean).join(',');
32
+ if (response.status === 413) {
33
+ await response.body?.cancel().catch(() => undefined);
34
+ throw new Error(`上传源码失败:源码包超过上传入口大小限制(${details})。请将不参与运行的生成产物加入 .gitignore 后重试;如需上传完整源码包,请联系平台核对上传限制。`);
35
+ }
36
+ let payload;
22
37
  try {
23
- result = await response.json();
38
+ payload = await response.json();
24
39
  }
25
40
  catch {
26
- throw new Error('上传源码失败:服务端响应格式错误');
41
+ throw new Error(`上传源码失败:无法读取有效的 JSON 响应(${details}),请检查服务或网关响应后重试`);
42
+ }
43
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
44
+ throw new Error(`上传源码失败:服务端响应结构错误(${details})`);
45
+ }
46
+ const result = payload;
47
+ if (!response.ok || result.success !== true) {
48
+ const reason = typeof result.errorMessage === 'string' ? result.errorMessage
49
+ : typeof result.errorCode === 'string' ? result.errorCode : '服务端拒绝上传';
50
+ throw new Error(`上传源码失败:${reason}(${details})`);
27
51
  }
28
- if (!response.ok || result.success !== true || !result.data?.sourceToken) {
29
- throw new Error(`上传源码失败:${result.errorMessage ?? result.errorCode ?? `HTTP ${response.status}`}`);
52
+ if (typeof result.data?.sourceToken !== 'string' || !result.data.sourceToken.trim()) {
53
+ throw new Error(`上传源码失败:服务端响应缺少有效上传凭证(${details})`);
30
54
  }
31
55
  return result.data.sourceToken;
32
56
  }
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { ZipFile } from 'yazl';
6
6
  import { createSourceManifest, SOURCE_MANIFEST_PATH } from './source-manifest.js';
7
- const MAX_ARCHIVE_BYTES = 500 * 1024 * 1024;
7
+ import { assertImportArchiveSize } from './limits.js';
8
8
  const STABLE_ZIP_MTIME = new Date('1980-01-01T00:00:00.000Z');
9
9
  export async function createImportArchive(preflight) {
10
10
  const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'qmuse-import-'));
@@ -36,8 +36,7 @@ export async function createImportArchive(preflight) {
36
36
  const sizeBytes = (await stat(filePath)).size;
37
37
  if (sizeBytes <= 0)
38
38
  throw new Error('生成的源码压缩包为空');
39
- if (sizeBytes > MAX_ARCHIVE_BYTES)
40
- throw new Error('源码压缩包超过 500MB');
39
+ assertImportArchiveSize(sizeBytes);
41
40
  return {
42
41
  filePath,
43
42
  fileName,
@@ -0,0 +1,6 @@
1
+ export const MAX_IMPORT_ARCHIVE_BYTES = 10 * 1024 * 1024;
2
+ export function assertImportArchiveSize(sizeBytes) {
3
+ if (sizeBytes > MAX_IMPORT_ARCHIVE_BYTES) {
4
+ throw new Error(`源码压缩包超过 10 MiB 上传上限:当前 ${(sizeBytes / 1024 / 1024).toFixed(2)} MiB(${sizeBytes} 字节)。请将不参与运行的生成产物加入 .gitignore 后重试`);
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qmuse/qmuse-cli",
3
- "version": "1.0.3-dev.1",
3
+ "version": "1.0.3-dev.3",
4
4
  "description": "将本地 React 项目安全导入 QMuse 的命令行客户端",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,8 +21,6 @@
21
21
  "dependencies": {
22
22
  "@napi-rs/keyring": "2.0.0",
23
23
  "ignore": "^7.0.8",
24
- "magic-string": "^0.30.17",
25
- "typescript": "^5.9.3",
26
24
  "yazl": "^3.3.1"
27
25
  },
28
26
  "files": [
@@ -37,6 +35,7 @@
37
35
  "@types/node": "^22.10.0",
38
36
  "@types/yazl": "^3.3.1",
39
37
  "tsx": "^4.19.2",
38
+ "typescript": "^5.9.3",
40
39
  "vite": "7.3.6"
41
40
  },
42
41
  "license": "MIT",
@@ -1,38 +0,0 @@
1
- import ts from 'typescript';
2
- import MagicString from 'magic-string';
3
- export function injectLocalRuntimeFetch(code, id) {
4
- const source = ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
5
- const names = new Set();
6
- for (const statement of source.statements) {
7
- if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) ||
8
- statement.moduleSpecifier.text !== '@qmuse/appwrite-runtime-sdk' || statement.importClause?.isTypeOnly)
9
- continue;
10
- const bindings = statement.importClause?.namedBindings;
11
- if (!bindings || !ts.isNamedImports(bindings))
12
- continue;
13
- for (const binding of bindings.elements) {
14
- if (!binding.isTypeOnly && (binding.propertyName ?? binding.name).text === 'createQmuseAppwriteRuntime') {
15
- names.add(binding.name.text);
16
- }
17
- }
18
- }
19
- const calls = [];
20
- const visit = (node) => {
21
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && names.has(node.expression.text))
22
- calls.push(node);
23
- ts.forEachChild(node, visit);
24
- };
25
- visit(source);
26
- const call = calls[0];
27
- const argument = call?.arguments[0];
28
- // Restrict injection to the official asset's top-level initializer, avoiding shadowed bindings.
29
- if (calls.length !== 1 || !call || call.arguments.length !== 1 || !argument || !ts.isObjectLiteralExpression(argument) ||
30
- !ts.isVariableDeclaration(call.parent) || !ts.isVariableDeclarationList(call.parent.parent) ||
31
- !ts.isVariableStatement(call.parent.parent.parent) || call.parent.parent.parent.parent !== source) {
32
- throw new Error('无法注入本地云适配:src/services/appwrite.ts 需要从 @qmuse/appwrite-runtime-sdk 命名导入 createQmuseAppwriteRuntime,并在顶层用对象参数初始化一次;请对齐官方云服务资产');
33
- }
34
- const result = new MagicString(code);
35
- result.prependLeft(argument.getStart(source), '{ ...(');
36
- result.appendLeft(argument.end, '), fetch: window.__QMUSE_LOCAL_DEV__.fetch }');
37
- return { code: result.toString(), map: result.generateMap({ source: id, includeContent: true, hires: true }) };
38
- }