@qmuse/qmuse-cli 1.0.5 → 1.0.6
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 +2 -0
- package/dist/import/iframe.js +119 -0
- package/dist/import/preflight.js +4 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -46,6 +46,8 @@ qmuse import /path/to/react-project --space-id <spaceId>
|
|
|
46
46
|
|
|
47
47
|
导入遵循项目根目录的 `.gitignore`,自动排除依赖目录、构建产物和 `.env*` 等文件。检测到私钥或凭证时会停止导入,请按提示处理后重试。
|
|
48
48
|
|
|
49
|
+
新建和更新导入都会检查 HTML、JSX/TSX 等源码中能静态识别的 iframe `src`,拒绝 `/game/index.html`、`./page.html`、`../page.html`、`page.html` 等相对地址,并提示文件和行号。请将内嵌页面内容并入 React 应用,或使用完整的 HTTPS 地址。该检查不保证识别运行时计算或赋值的地址。
|
|
50
|
+
|
|
49
51
|
导入失败时,按终端提示处理;如果生成了 `.qmuse/last-import-error.json`,可查看该文件定位问题。修复后重新执行导入命令。
|
|
50
52
|
|
|
51
53
|
### 更新已有应用
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
const SOURCE_EXTENSIONS = /\.(?:[cm]?[jt]s|[jt]sx|html?)$/iu;
|
|
3
|
+
export function inspectsIframeSources(filename) {
|
|
4
|
+
return SOURCE_EXTENSIONS.test(filename) && !filename.endsWith('.d.ts');
|
|
5
|
+
}
|
|
6
|
+
function staticText(node) {
|
|
7
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
8
|
+
return node.text;
|
|
9
|
+
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
|
|
10
|
+
return staticText(node.expression);
|
|
11
|
+
}
|
|
12
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
13
|
+
const left = staticText(node.left);
|
|
14
|
+
const right = staticText(node.right);
|
|
15
|
+
if (left !== undefined && right !== undefined)
|
|
16
|
+
return left + right;
|
|
17
|
+
}
|
|
18
|
+
if (ts.isTemplateExpression(node)) {
|
|
19
|
+
let value = node.head.text;
|
|
20
|
+
for (const span of node.templateSpans) {
|
|
21
|
+
const part = staticText(span.expression);
|
|
22
|
+
if (part === undefined)
|
|
23
|
+
return undefined;
|
|
24
|
+
value += part + span.literal.text;
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
// Decode URL-relevant HTML entities before deciding whether an address is absolute.
|
|
31
|
+
function decodeHtml(value) {
|
|
32
|
+
return value.replace(/&(?:#(x[\da-f]+|\d+)|([a-z]+));/giu, (entity, numeric, name) => {
|
|
33
|
+
if (numeric) {
|
|
34
|
+
const code = numeric[0].toLowerCase() === 'x'
|
|
35
|
+
? Number.parseInt(numeric.slice(1), 16) : Number(numeric);
|
|
36
|
+
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : entity;
|
|
37
|
+
}
|
|
38
|
+
return { colon: ':', sol: '/', period: '.', amp: '&', quot: '"', apos: "'" }[name.toLowerCase()] ?? entity;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Reject statically identifiable relative iframe URLs, including origin-root paths. */
|
|
42
|
+
export function assertNoRelativeIframeSources(content, filename) {
|
|
43
|
+
const check = (value, offset) => {
|
|
44
|
+
const address = value.trim();
|
|
45
|
+
// Network-path references (//host/path) do not point into the published app.
|
|
46
|
+
if (/^[a-z][a-z\d+.-]*:/iu.test(address) || address.startsWith('//'))
|
|
47
|
+
return;
|
|
48
|
+
const line = content.slice(0, offset).split('\n').length;
|
|
49
|
+
throw new Error(`iframe 使用相对地址,拒绝导入:${filename}:${line},src=${JSON.stringify(address)}。`
|
|
50
|
+
+ '发布后该路径可能返回 404,请将页面内容并入 React 应用,或改用完整的 https:// 地址。');
|
|
51
|
+
};
|
|
52
|
+
const inspectHtml = (html, baseOffset) => {
|
|
53
|
+
// Preserve offsets while excluding HTML comments. Consume whole tags so an
|
|
54
|
+
// iframe-looking string inside another tag's quoted attribute is not a tag.
|
|
55
|
+
const visible = html.replace(/<!--[\s\S]*?(?:-->|$)/gu, match => ' '.repeat(match.length));
|
|
56
|
+
const tags = /<([a-z][\w:-]*)\b((?:[^>"']|"[^"]*"|'[^']*')*)>/giu;
|
|
57
|
+
for (const tag of visible.matchAll(tags)) {
|
|
58
|
+
if (tag[1].toLowerCase() !== 'iframe')
|
|
59
|
+
continue;
|
|
60
|
+
const attributes = /([^\s=/'"<>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/gu;
|
|
61
|
+
for (const attribute of tag[2].matchAll(attributes)) {
|
|
62
|
+
if (attribute[1].toLowerCase() !== 'src')
|
|
63
|
+
continue;
|
|
64
|
+
const value = attribute[2] ?? attribute[3] ?? attribute[4];
|
|
65
|
+
if (value !== undefined)
|
|
66
|
+
check(decodeHtml(value), baseOffset + tag.index);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
if (/\.html?$/iu.test(filename)) {
|
|
71
|
+
inspectHtml(content, 0);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!/iframe/iu.test(content))
|
|
75
|
+
return;
|
|
76
|
+
const source = ts.createSourceFile(filename, content, ts.ScriptTarget.Latest, true, /\.[jt]sx$/iu.test(filename) ? ts.ScriptKind.TSX
|
|
77
|
+
: /\.[cm]?ts$/iu.test(filename) ? ts.ScriptKind.TS : ts.ScriptKind.JS);
|
|
78
|
+
const visit = (node) => {
|
|
79
|
+
if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node))
|
|
80
|
+
&& node.tagName.getText(source) === 'iframe') {
|
|
81
|
+
for (const attribute of node.attributes.properties) {
|
|
82
|
+
if (!ts.isJsxAttribute(attribute) || attribute.name.getText(source) !== 'src')
|
|
83
|
+
continue;
|
|
84
|
+
const initializer = attribute.initializer;
|
|
85
|
+
if (initializer && ts.isStringLiteral(initializer)) {
|
|
86
|
+
check(decodeHtml(initializer.text), initializer.getStart(source));
|
|
87
|
+
}
|
|
88
|
+
else if (initializer && ts.isJsxExpression(initializer) && initializer.expression) {
|
|
89
|
+
const value = staticText(initializer.expression);
|
|
90
|
+
if (value !== undefined)
|
|
91
|
+
check(value, initializer.getStart(source));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (ts.isCallExpression(node)
|
|
96
|
+
&& (node.expression.getText(source) === 'React.createElement'
|
|
97
|
+
|| node.expression.getText(source) === 'createElement')
|
|
98
|
+
&& node.arguments[0] && staticText(node.arguments[0]) === 'iframe'
|
|
99
|
+
&& node.arguments[1] && ts.isObjectLiteralExpression(node.arguments[1])) {
|
|
100
|
+
for (const property of node.arguments[1].properties) {
|
|
101
|
+
if (!ts.isPropertyAssignment(property))
|
|
102
|
+
continue;
|
|
103
|
+
const name = ts.isStringLiteral(property.name) ? property.name.text : property.name.getText(source);
|
|
104
|
+
if (name !== 'src')
|
|
105
|
+
continue;
|
|
106
|
+
const value = staticText(property.initializer);
|
|
107
|
+
if (value !== undefined)
|
|
108
|
+
check(value, property.initializer.getStart(source));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Also cover HTML passed to innerHTML / dangerouslySetInnerHTML and templates.
|
|
112
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
113
|
+
if (/<iframe\b/iu.test(node.text))
|
|
114
|
+
inspectHtml(node.text, node.getStart(source));
|
|
115
|
+
}
|
|
116
|
+
ts.forEachChild(node, visit);
|
|
117
|
+
};
|
|
118
|
+
visit(source);
|
|
119
|
+
}
|
package/dist/import/preflight.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createReadStream } from 'node:fs';
|
|
|
3
3
|
import { lstat, readFile, readdir } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import ignore from 'ignore';
|
|
6
|
+
import { assertNoRelativeIframeSources, inspectsIframeSources } from './iframe.js';
|
|
6
7
|
const IGNORED_DIRECTORIES = new Set([
|
|
7
8
|
'.git', '.agents', '.local', '.qmuse', '.muse-tmp', 'node_modules', 'dist', 'build', '.vite', '.turbo',
|
|
8
9
|
]);
|
|
@@ -203,6 +204,9 @@ export async function preflightImport(directory) {
|
|
|
203
204
|
sourceHash.update('\0');
|
|
204
205
|
await updateHashFromFile(sourceHash, file.absolutePath);
|
|
205
206
|
sourceHash.update('\0');
|
|
207
|
+
if (inspectsIframeSources(file.relativePath)) {
|
|
208
|
+
assertNoRelativeIframeSources(await readFile(file.absolutePath, 'utf8'), file.relativePath);
|
|
209
|
+
}
|
|
206
210
|
if (TEXT_EXTENSIONS.has(path.extname(file.relativePath).toLowerCase()) &&
|
|
207
211
|
file.sizeBytes <= MAX_INSPECT_TEXT_BYTES) {
|
|
208
212
|
const content = await readFile(file.absolutePath, 'utf8');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qmuse/qmuse-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "将本地 React 项目安全导入 QMuse 的命令行客户端",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"ignore": "^7.0.8",
|
|
24
24
|
"open": "^11.0.4",
|
|
25
25
|
"semver": "7.7.4",
|
|
26
|
+
"typescript": "^5.9.3",
|
|
26
27
|
"yazl": "^3.3.1"
|
|
27
28
|
},
|
|
28
29
|
"files": [
|
|
@@ -38,7 +39,6 @@
|
|
|
38
39
|
"@types/semver": "7.7.1",
|
|
39
40
|
"@types/yazl": "^3.3.1",
|
|
40
41
|
"tsx": "^4.19.2",
|
|
41
|
-
"typescript": "^5.9.3",
|
|
42
42
|
"vite": "7.3.6"
|
|
43
43
|
},
|
|
44
44
|
"license": "MIT",
|