comate-ui-inspector 0.1.5 → 0.1.6-alpha.2
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/bridge/server.mjs +45 -19
- package/cli.mjs +38 -3
- package/enhancements/figma/figma-exec.js +49 -0
- package/enhancements/figma/figma-ui.js +74 -0
- package/enhancements/figma/figma.js +1 -0
- package/enhancements/figma/pane.html +18 -0
- package/enhancements/figma/pane.js +17 -0
- package/enhancements/figma/plugin.json +43 -0
- package/enhancements/figma/rewrite.js +2 -0
- package/extension/background.js +60 -23
- package/extension/native-host/host.mjs +26 -1
- package/hooks/inject-ui-inspector.mjs +30 -2
- package/mcp/server.mjs +10 -10
- package/package.json +6 -2
package/bridge/server.mjs
CHANGED
|
@@ -3,11 +3,7 @@
|
|
|
3
3
|
* ui-inspector HTTP bridge(零依赖)——远程开发用。
|
|
4
4
|
*
|
|
5
5
|
* 跑在「Comate agent / hook / MCP 所在的那台机器」(通常是远程开发机),读写和它们同一份
|
|
6
|
-
* ~/.ui-inspector/*.json
|
|
7
|
-
* POST /ingest {targets:[...]} → 覆盖写 pending.json,回 {ok,pending}
|
|
8
|
-
* GET /state → 回 {ok,count,status}
|
|
9
|
-
*
|
|
10
|
-
* 安全:只绑 127.0.0.1,不暴露到网络;远程访问一律走 `ssh -L` 把端口转发到本地 127.0.0.1。
|
|
6
|
+
* ~/.ui-inspector/*.json。浏览器扩展(transport=http)直连本服务。
|
|
11
7
|
*/
|
|
12
8
|
import {createServer} from 'node:http';
|
|
13
9
|
import {readFileSync, writeFileSync, appendFileSync, mkdirSync} from 'node:fs';
|
|
@@ -19,8 +15,9 @@ const statusFile = resolve(homedir(), '.ui-inspector/status.json');
|
|
|
19
15
|
// 录制:元信息 + append-only 事件流分离,避免多请求读改写竞态把 active 盖回 true。
|
|
20
16
|
const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
|
|
21
17
|
const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
|
|
18
|
+
// figma「带入对话」采集的节点结构。
|
|
19
|
+
const figmaPendingFile = resolve(homedir(), '.ui-inspector/figma-pending.json');
|
|
22
20
|
const PORT = Number(process.env.UI_INSPECTOR_BRIDGE_PORT) || 8917;
|
|
23
|
-
// 默认只绑 127.0.0.1(走 SSH 隧道);直连模式(笔记本能直达开发机内网 IP)时用 0.0.0.0 暴露到网卡。
|
|
24
21
|
const HOST = process.env.UI_INSPECTOR_BRIDGE_HOST || '127.0.0.1';
|
|
25
22
|
|
|
26
23
|
function readStore() {
|
|
@@ -45,7 +42,9 @@ function writeStore(list) {
|
|
|
45
42
|
writeFileSync(storeFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
|
|
46
43
|
}
|
|
47
44
|
|
|
48
|
-
//
|
|
45
|
+
// PLACEHOLDER_HELPERS
|
|
46
|
+
|
|
47
|
+
// 录制累积:元信息与事件流分离。
|
|
49
48
|
function readMeta() {
|
|
50
49
|
try {
|
|
51
50
|
return JSON.parse(readFileSync(recordingFile, 'utf8'));
|
|
@@ -60,7 +59,7 @@ function writeMeta(meta) {
|
|
|
60
59
|
function recStart(startedAt, pageUrl) {
|
|
61
60
|
const cur = readMeta();
|
|
62
61
|
if (cur && cur.active && cur.startedAt === startedAt) {
|
|
63
|
-
return;
|
|
62
|
+
return;
|
|
64
63
|
}
|
|
65
64
|
writeMeta({active: true, startedAt, pageUrl});
|
|
66
65
|
mkdirSync(dirname(eventsFile), {recursive: true});
|
|
@@ -84,6 +83,26 @@ function recStop(endedAt) {
|
|
|
84
83
|
writeMeta(cur);
|
|
85
84
|
}
|
|
86
85
|
|
|
86
|
+
// figma 节点采集:追加到 figma-pending.json(上限 10 个,防爆)。
|
|
87
|
+
function figmaCapture(node) {
|
|
88
|
+
if (!node) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
let list = [];
|
|
92
|
+
try {
|
|
93
|
+
const p = JSON.parse(readFileSync(figmaPendingFile, 'utf8'));
|
|
94
|
+
list = Array.isArray(p) ? p : [];
|
|
95
|
+
} catch {
|
|
96
|
+
list = [];
|
|
97
|
+
}
|
|
98
|
+
list.push({node, ts: Date.now()});
|
|
99
|
+
if (list.length > 10) {
|
|
100
|
+
list = list.slice(-10);
|
|
101
|
+
}
|
|
102
|
+
mkdirSync(dirname(figmaPendingFile), {recursive: true});
|
|
103
|
+
writeFileSync(figmaPendingFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
|
|
104
|
+
}
|
|
105
|
+
|
|
87
106
|
function readBody(req, cb) {
|
|
88
107
|
let body = '';
|
|
89
108
|
req.on('data', chunk => (body += chunk));
|
|
@@ -117,18 +136,15 @@ const server = createServer((req, res) => {
|
|
|
117
136
|
}
|
|
118
137
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
119
138
|
if (req.method === 'POST' && url.pathname === '/ingest') {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
const {targets} = JSON.parse(body || '{}');
|
|
125
|
-
const now = new Date().toISOString();
|
|
126
|
-
const list = (Array.isArray(targets) ? targets : []).map(target => ({target, receivedAt: now}));
|
|
127
|
-
writeStore(list);
|
|
128
|
-
json(res, 200, {ok: true, pending: list.length});
|
|
129
|
-
} catch (error) {
|
|
130
|
-
json(res, 400, {ok: false, error: String(error)});
|
|
139
|
+
readBody(req, (data, err) => {
|
|
140
|
+
if (err) {
|
|
141
|
+
return json(res, 400, {ok: false, error: String(err)});
|
|
131
142
|
}
|
|
143
|
+
const targets = Array.isArray(data.targets) ? data.targets : [];
|
|
144
|
+
const now = new Date().toISOString();
|
|
145
|
+
const list = targets.map(target => ({target, receivedAt: now}));
|
|
146
|
+
writeStore(list);
|
|
147
|
+
json(res, 200, {ok: true, pending: list.length});
|
|
132
148
|
});
|
|
133
149
|
return;
|
|
134
150
|
}
|
|
@@ -166,6 +182,16 @@ const server = createServer((req, res) => {
|
|
|
166
182
|
});
|
|
167
183
|
return;
|
|
168
184
|
}
|
|
185
|
+
if (req.method === 'POST' && url.pathname === '/figma/capture') {
|
|
186
|
+
readBody(req, (data, err) => {
|
|
187
|
+
if (err) {
|
|
188
|
+
return json(res, 400, {ok: false, error: String(err)});
|
|
189
|
+
}
|
|
190
|
+
figmaCapture(data.node);
|
|
191
|
+
json(res, 200, {ok: true});
|
|
192
|
+
});
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
169
195
|
json(res, 404, {ok: false, error: 'not found'});
|
|
170
196
|
});
|
|
171
197
|
|
package/cli.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
openSync,
|
|
21
21
|
readdirSync,
|
|
22
22
|
readFileSync,
|
|
23
|
+
statSync,
|
|
23
24
|
writeFileSync,
|
|
24
25
|
rmSync,
|
|
25
26
|
} from 'node:fs';
|
|
@@ -196,7 +197,7 @@ function availableEnhancements() {
|
|
|
196
197
|
}
|
|
197
198
|
}
|
|
198
199
|
|
|
199
|
-
function addEnhancement(name) {
|
|
200
|
+
async function addEnhancement(name) {
|
|
200
201
|
if (!name) {
|
|
201
202
|
out(`用法:ui-inspector add <名>。可用:${availableEnhancements().join(', ') || '(无)'}`);
|
|
202
203
|
return;
|
|
@@ -215,11 +216,45 @@ function addEnhancement(name) {
|
|
|
215
216
|
return;
|
|
216
217
|
}
|
|
217
218
|
const id = descriptor.id || name;
|
|
218
|
-
|
|
219
|
+
const dst = join(enhanceDstDir, id);
|
|
220
|
+
cpSync(src, dst, {recursive: true});
|
|
221
|
+
// 按需下载大文件(如 figma 引擎):主包不内置以保持精简,首次 add 时拉取。
|
|
222
|
+
// 本地源目录已带该文件(开发/仓库场景)则跳过,离线可用。
|
|
223
|
+
for (const d of descriptor.download || []) {
|
|
224
|
+
const target = join(dst, d.file);
|
|
225
|
+
let haveLocal = false;
|
|
226
|
+
try {
|
|
227
|
+
haveLocal = existsSync(target) && statSync(target).size >= (d.minBytes || 1);
|
|
228
|
+
} catch {
|
|
229
|
+
haveLocal = false;
|
|
230
|
+
}
|
|
231
|
+
if (haveLocal) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
out(`按需下载 ${d.file} …(${d.url})`);
|
|
235
|
+
try {
|
|
236
|
+
const resp = await fetch(d.url);
|
|
237
|
+
if (!resp.ok) {
|
|
238
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
239
|
+
}
|
|
240
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
241
|
+
if (d.minBytes && buf.length < d.minBytes) {
|
|
242
|
+
throw new Error(`文件过小(${buf.length}B),疑似下载失败`);
|
|
243
|
+
}
|
|
244
|
+
mkdirSync(dirname(target), {recursive: true});
|
|
245
|
+
writeFileSync(target, buf);
|
|
246
|
+
out(` 已写入 ${d.file}(${buf.length}B)`);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
rmSync(dst, {recursive: true, force: true});
|
|
249
|
+
out(`下载 ${d.file} 失败:${String(error)}`);
|
|
250
|
+
out(`增强包「${id}」安装已回滚。请检查网络后重试,或手动把该文件放到 ${target}。`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
219
254
|
const list = readRegistry().filter(p => p.id !== id);
|
|
220
255
|
list.push(descriptor);
|
|
221
256
|
writeRegistry(list);
|
|
222
|
-
out(`已安装增强包「${descriptor.name || id}」(${id}) -> ${
|
|
257
|
+
out(`已安装增强包「${descriptor.name || id}」(${id}) -> ${dst}`);
|
|
223
258
|
out('在 chrome://extensions 点「刷新」重新加载扩展后生效:弹窗出现对应「模式」tab。');
|
|
224
259
|
}
|
|
225
260
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Figma 命令执行器(MAIN world,figma.com):响应「带入对话」——把当前选中节点转成 F2C HTML 结构,
|
|
3
|
+
* 回传给 figma-ui 落盘待 hook 注入。页面内已解锁的 window.figma / window.F2CConverter 由
|
|
4
|
+
* rewrite.js + browser.global.js 提供。
|
|
5
|
+
*/
|
|
6
|
+
(function () {
|
|
7
|
+
if (window.__oreateFigmaExec) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
window.__oreateFigmaExec = true;
|
|
11
|
+
|
|
12
|
+
function resolveNode() {
|
|
13
|
+
var fig = window.figma;
|
|
14
|
+
if (!fig) {
|
|
15
|
+
throw new Error('window.figma 不可用(请确认在 figma.com 设计稿页,且扩展已解锁)');
|
|
16
|
+
}
|
|
17
|
+
var sel = fig.currentPage && fig.currentPage.selection;
|
|
18
|
+
if (sel && sel.length) {
|
|
19
|
+
return sel[0];
|
|
20
|
+
}
|
|
21
|
+
throw new Error('未选中节点:请先在画布选中一个节点');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
window.addEventListener('message', function (ev) {
|
|
25
|
+
if (ev.source !== window || !ev.data || ev.data.__uiInspectorFigmaCapture !== true) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
Promise.resolve()
|
|
29
|
+
.then(async function () {
|
|
30
|
+
if (!window.F2CConverter || typeof window.F2CConverter.convertFigmaNodeToHtml !== 'function') {
|
|
31
|
+
throw new Error('F2CConverter 不可用');
|
|
32
|
+
}
|
|
33
|
+
var node = resolveNode();
|
|
34
|
+
var html = await window.F2CConverter.convertFigmaNodeToHtml(node, {});
|
|
35
|
+
return {
|
|
36
|
+
nodeId: node.id,
|
|
37
|
+
name: node.name,
|
|
38
|
+
type: node.type,
|
|
39
|
+
html: typeof html === 'string' ? html : JSON.stringify(html),
|
|
40
|
+
};
|
|
41
|
+
})
|
|
42
|
+
.then(function (node) {
|
|
43
|
+
window.postMessage({__uiInspectorFigmaCaptured: {node: node}}, '*');
|
|
44
|
+
})
|
|
45
|
+
.catch(function (err) {
|
|
46
|
+
window.postMessage({__uiInspectorFigmaCaptured: {error: String(err)}}, '*');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
})();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Figma「带入对话」浮层(ISOLATED world,figma.com):页面右下角一个按钮,点一下把当前选中节点
|
|
3
|
+
* 的 F2C HTML 结构采集下来,经 background 落盘 figma-pending.json;下次在 Comate 发消息时,
|
|
4
|
+
* hook 会把该结构注入对话,Agent 据此生成代码——用户无需手动调工具、无需填路径。
|
|
5
|
+
*/
|
|
6
|
+
(function () {
|
|
7
|
+
if (window.__oreateFigmaUi) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
window.__oreateFigmaUi = true;
|
|
11
|
+
const alive = () => !!(chrome.runtime && chrome.runtime.id);
|
|
12
|
+
|
|
13
|
+
const btn = document.createElement('button');
|
|
14
|
+
btn.textContent = '带入 Comate 生成代码';
|
|
15
|
+
btn.style.cssText =
|
|
16
|
+
'position:fixed;right:16px;bottom:16px;z-index:2147483647;border:none;border-radius:999px;' +
|
|
17
|
+
'padding:8px 14px;background:#0d99ff;color:#fff;font:12px/1.4 sans-serif;cursor:pointer;' +
|
|
18
|
+
'box-shadow:0 2px 12px rgba(0,0,0,0.25);';
|
|
19
|
+
|
|
20
|
+
let tip = null;
|
|
21
|
+
function toast(text, ok) {
|
|
22
|
+
if (!tip) {
|
|
23
|
+
tip = document.createElement('div');
|
|
24
|
+
tip.style.cssText =
|
|
25
|
+
'position:fixed;right:16px;bottom:56px;z-index:2147483647;max-width:280px;padding:6px 10px;' +
|
|
26
|
+
'border-radius:8px;background:#111;color:#fff;font:12px/1.4 sans-serif;box-shadow:0 2px 12px rgba(0,0,0,0.25);';
|
|
27
|
+
document.documentElement.appendChild(tip);
|
|
28
|
+
}
|
|
29
|
+
tip.textContent = text;
|
|
30
|
+
tip.style.background = ok === false ? '#dc2626' : ok ? '#16a34a' : '#111';
|
|
31
|
+
tip.style.display = 'block';
|
|
32
|
+
clearTimeout(toast._t);
|
|
33
|
+
toast._t = setTimeout(() => (tip.style.display = 'none'), 3000);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
btn.onclick = () => {
|
|
37
|
+
toast('采集选中节点…');
|
|
38
|
+
window.postMessage({__uiInspectorFigmaCapture: true}, '*');
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
window.addEventListener('message', event => {
|
|
42
|
+
if (event.source !== window || !event.data || !event.data.__uiInspectorFigmaCaptured) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const payload = event.data.__uiInspectorFigmaCaptured;
|
|
46
|
+
if (payload.error) {
|
|
47
|
+
toast('采集失败:' + payload.error, false);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!alive()) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
chrome.runtime.sendMessage({type: 'figma-capture', node: payload.node}, resp => {
|
|
55
|
+
if (chrome.runtime.lastError || !resp || !resp.ok) {
|
|
56
|
+
toast('落盘失败,请重试', false);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
toast(`已带入「${payload.node.name || payload.node.nodeId}」,去 Comate 发消息即可生成代码`, true);
|
|
60
|
+
});
|
|
61
|
+
} catch {
|
|
62
|
+
toast('扩展上下文失效,请刷新页面', false);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function mount() {
|
|
67
|
+
if (document.body && !btn.isConnected) {
|
|
68
|
+
document.body.appendChild(btn);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
mount();
|
|
72
|
+
document.addEventListener('DOMContentLoaded', mount);
|
|
73
|
+
setInterval(mount, 2000); // figma 频繁重渲染,兜底保证按钮在
|
|
74
|
+
})();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var figma=(function(){function e(e){return e==null||typeof e==`function`?{main:e}:e}let t=[{markers:[`.appModel.isReadOnly`],replacements:[{pattern:`.appModel.isReadOnly`,replacer:`.appModel.__isReadOnly__`}]},{markers:[`const __html__ = (() => {`],replacements:[{pattern:/([A-Za-z_$][A-Za-z0-9_$]*)=\(0,([A-Za-z_$][A-Za-z0-9_$]*\.[A-Za-z_$][A-Za-z0-9_$]*)\)\(\)\|\|([A-Za-z_$][A-Za-z0-9_$]*);if\(!\1\)/,replacer:`$1=(0,$2)()||$3;if(false)`},{pattern:/if\(\(0,([A-Za-z_$][A-Za-z0-9_$]*\.[A-Za-z_$][A-Za-z0-9_$]*)\)\(\)\)return;([A-Za-z_$][A-Za-z0-9_$]*)&&/,replacer:`if((0,$1)())return;true&&`}]},{markers:[`dispnf.fyufotjpo;00`,`np{.fyufotjpo;00`],replacements:[{pattern:/dispnf\.fyufotjpo;00|np{\.fyufotjpo;00/g,replacer:`FIGMA_PLEASE_STOP`}]},{markers:[`{type:"global",closePluginFunc:`],replacements:[{pattern:/{type:"global",closePluginFunc:[A-Za-z_$][A-Za-z0-9_$]*}/,replacer:`{type:"global",closePluginFunc:()=>{}}`}]},{markers:[`ext_init_wdf`],replacements:[{pattern:`ext_init_wdf`,replacer:`__ext_init_wdf__`}]},{markers:[`let{canRunExtensions:`],replacements:[{pattern:/let\{canRunExtensions:([A-Za-z_$][A-Za-z0-9_$]*),canAccessFullDevMode:([A-Za-z_$][A-Za-z0-9_$]*)\}=([A-Za-z_$][A-Za-z0-9_$]*).openFile;/,replacer:`let{canRunExtensions:$1,canAccessFullDevMode:$2}=$3.openFile;$1=true;`}]}];var n=`[tempad-dev]`,r=e=>{if(!e.length)return[n];let[t,...r]=e;return typeof t==`string`?t.startsWith(n)?e:[`${n} ${t}`,...r]:[n,...e]};let i={log:(...e)=>{console.log(...r(e))},warn:(...e)=>{console.warn(...r(e))},error:(...e)=>{console.error(...r(e))},debug:(...e)=>{}};function a(e,t){return(t.markers||[]).every(t=>e.includes(t))}function o(e,t){let n=e,r=[],o=[];for(let[e,s]of t.entries()){if(!a(n,s))continue;r.push(e);let t=!1;for(let{pattern:e,replacer:r}of s.replacements){let a=n;n=typeof e==`string`?n.replaceAll(e,r):n.replace(e,r),n===a?i.warn(`Replacement had no effect: ${e} -> ${r}`):(t=!0,i.log(`Applied replacement: ${e} -> ${r}`))}t&&o.push(e)}return{content:n,changed:n!==e,matchedGroups:r,rewrittenGroups:o}}var s=e(()=>{async function e(){let e=document.currentScript,n=e?.src;if(!e||!n)return;let r=Object.getOwnPropertyDescriptor(Document.prototype,`currentScript`);function a(t){let n=document.createElement(`script`);n.src=t,n.defer=!0,e.replaceWith(n)}try{let{content:r,changed:a}=o(await(await fetch(n)).text(),t);a&&i.log(`Rewrote script: ${n}`);let s=r.replaceAll(`delete window.figma`,`window.figma = undefined`);Object.defineProperty(document,`currentScript`,{configurable:!0,get(){return e}}),Function(s)()}catch(e){i.error(e),a(`${n}?fallback`)}finally{r?Object.defineProperty(document,`currentScript`,r):delete document.currentScript}}e()}),c={debug:(...e)=>(console.debug,[...e],void 0),log:(...e)=>(console.log,[...e],void 0),warn:(...e)=>(console.warn,[...e],void 0),error:(...e)=>(console.error,[...e],void 0)};return(()=>{let e;try{e=s.main(),e instanceof Promise&&(e=e.catch(e=>{throw c.error(`The unlisted script "figma" crashed on startup!`,e),e}))}catch(e){throw c.error(`The unlisted script "figma" crashed on startup!`,e),e}return e})()})();figma;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<div class="hint">
|
|
2
|
+
在
|
|
3
|
+
<code>www.figma.com</code>
|
|
4
|
+
打开设计稿后,本模式会在页面注入 F2C 引擎(解锁 Dev Mode / 插件能力)。
|
|
5
|
+
</div>
|
|
6
|
+
<div
|
|
7
|
+
id="figma-status"
|
|
8
|
+
class="hint"
|
|
9
|
+
style="margin-top: 6px"
|
|
10
|
+
></div>
|
|
11
|
+
<div
|
|
12
|
+
class="hint"
|
|
13
|
+
style="margin-top: 6px"
|
|
14
|
+
>
|
|
15
|
+
需先在上方「设置 → 生效域名」添加
|
|
16
|
+
<code>https://www.figma.com</code>
|
|
17
|
+
并授权,然后重载扩展。
|
|
18
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Figma 增强包弹窗面板:只做状态提示。真正的注入在 figma.com 页面内由 rewrite.js + browser.global.js
|
|
3
|
+
* (MAIN world,document_start)完成;导出能力(节点→HTML/图片)由后续的命令通道 + MCP 工具提供。
|
|
4
|
+
*/
|
|
5
|
+
(function () {
|
|
6
|
+
const el = document.getElementById('figma-status');
|
|
7
|
+
if (!el) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
chrome.permissions.contains({origins: ['https://www.figma.com/*']}, granted => {
|
|
11
|
+
if (chrome.runtime.lastError) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
el.textContent = granted ? '✓ 已授权 figma.com,打开设计稿即生效' : '✗ 尚未授权 figma.com';
|
|
15
|
+
el.style.color = granted ? '#1a7f37' : '#cf222e';
|
|
16
|
+
});
|
|
17
|
+
})();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "figma",
|
|
3
|
+
"name": "Figma 转代码",
|
|
4
|
+
"scripts": [
|
|
5
|
+
{
|
|
6
|
+
"id": "rewrite",
|
|
7
|
+
"js": ["rewrite.js"],
|
|
8
|
+
"matches": ["https://www.figma.com/*"],
|
|
9
|
+
"world": "MAIN",
|
|
10
|
+
"runAt": "document_start"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "converter",
|
|
14
|
+
"js": ["browser.global.js"],
|
|
15
|
+
"matches": ["https://www.figma.com/*"],
|
|
16
|
+
"world": "MAIN",
|
|
17
|
+
"runAt": "document_start"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "exec",
|
|
21
|
+
"js": ["figma-exec.js"],
|
|
22
|
+
"matches": ["https://www.figma.com/*"],
|
|
23
|
+
"world": "MAIN",
|
|
24
|
+
"runAt": "document_idle"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "ui",
|
|
28
|
+
"js": ["figma-ui.js"],
|
|
29
|
+
"matches": ["https://www.figma.com/*"],
|
|
30
|
+
"world": "ISOLATED",
|
|
31
|
+
"runAt": "document_idle"
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"pane": "pane.html",
|
|
35
|
+
"paneJs": "pane.js",
|
|
36
|
+
"download": [
|
|
37
|
+
{
|
|
38
|
+
"file": "browser.global.js",
|
|
39
|
+
"url": "https://cdn.jsdelivr.net/npm/comate-ui-inspector-figma@latest/browser.global.js",
|
|
40
|
+
"minBytes": 100000
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var rewrite=function(){"use strict";function j(r){return r}const b={matches:["https://www.figma.com/*"],runAt:"document_start",world:"MAIN",main(){const r="https://ecomfe.github.io/tempad-dev/figma.json",v=[{markers:[".appModel.isReadOnly"],replacements:[{pattern:".appModel.isReadOnly",replacer:".appModel.__isReadOnly__"}]},{markers:['{type:"global",closePluginFunc:'],replacements:[{pattern:/{type:"global",closePluginFunc:[A-Za-z_$][A-Za-z0-9_$]*}/,replacer:'{type:"global",closePluginFunc:()=>{}}'}]},{markers:["let{canRunExtensions:"],replacements:[{pattern:/let\{canRunExtensions:([A-Za-z_$][A-Za-z0-9_$]*),canAccessFullDevMode:([A-Za-z_$][A-Za-z0-9_$]*)\}=([A-Za-z_$][A-Za-z0-9_$]*).openFile;/,replacer:"let{canRunExtensions:$1,canAccessFullDevMode:$2}=$3.openFile;$1=true;"}]}],L=[{id:2,priority:1,action:{type:"redirect",redirect:{extensionPath:"/figma.js"}},condition:{regexFilter:"/webpack-artifacts/assets/(?:figma_app[^.]+|[0-9a-zA-Z-]+)\\.min\\.js(?:\\.br)?$",resourceTypes:["script"]}}];function U(e,t){return(t.markers||[]).every(c=>e.includes(c))}function F(e,t){let n=e;const c=[],o=[];for(const[l,i]of t.entries()){if(!U(n,i))continue;c.push(l);let E=!1;for(const{pattern:f,replacer:A}of i.replacements){const S=n;typeof f=="string"?n=n.replaceAll(f,A):n=n.replace(f,A),n!==S&&(E=!0)}E&&o.push(l)}return{content:n,changed:n!==e,matchedGroups:c,rewrittenGroups:o}}function d(e){var t;try{const n=e.find(c=>c.id===2);return(t=n==null?void 0:n.condition)!=null&&t.regexFilter?new RegExp(n.condition.regexFilter,"i"):null}catch{return null}}async function k(e){try{const t=await fetch(e,{credentials:"omit",cache:"no-cache"});return t.ok?d(await t.json()):null}catch{return null}}let g;Reflect.defineProperty(window,"figma",{set(e){e!==void 0&&(g=e)},get(){return g}});let u=d(L);k(r).then(e=>{e&&(u=e)});const{appendChild:h,insertBefore:m}=Element.prototype,y=new WeakSet,s=new Map;function z(e){return!!u&&u.test(e)}function w(e){return e instanceof HTMLScriptElement&&typeof e.src=="string"&&e.src.length>0}function R(e,t){const n=s.get(e);if(!(!n||n.url!==t)&&(n.ref-=1,n.ref<=0)){try{URL.revokeObjectURL(n.url)}catch{}s.delete(e)}}async function M(e){const t=s.get(e);if(t)return t.ref+=1,{url:t.url,release:()=>R(e,t.url)};const c=await(await fetch(e,{credentials:"include",cache:"force-cache"})).text(),{content:o}=F(c,v),l=new Blob([o],{type:"application/javascript; charset=utf-8"}),i=URL.createObjectURL(l);return s.set(e,{url:i,ref:1}),{url:i,release:()=>R(e,i)}}function p(e,t,n){n?m.call(e,t,n):h.call(e,t)}async function _(e,t,n){if(y.has(t)){p(e,t,n);return}if(y.add(t),!z(t.src)){p(e,t,n);return}try{const{url:c,release:o}=await M(t.src);t.removeAttribute("integrity"),t.addEventListener("load",o,{once:!0}),t.addEventListener("error",o,{once:!0}),t.src=c}catch{}p(e,t,n)}const Z=function(e){return w(e)?(_(this,e,null),e):(h.call(this,e),e)},I=function(e,t){return w(e)?(_(this,e,t),e):(m.call(this,e,t),e)};Element.prototype.appendChild=Z,Element.prototype.insertBefore=I}};function P(){}function a(r,...$){}const x={debug:(...r)=>a(console.debug,...r),log:(...r)=>a(console.log,...r),warn:(...r)=>a(console.warn,...r),error:(...r)=>a(console.error,...r)};return(async()=>{try{return await b.main()}catch(r){throw x.error('The content script "rewrite" crashed on startup!',r),r}})()}();
|
|
2
|
+
rewrite;
|
package/extension/background.js
CHANGED
|
@@ -83,6 +83,14 @@ async function viaHttp(target, message) {
|
|
|
83
83
|
});
|
|
84
84
|
return await r.json();
|
|
85
85
|
}
|
|
86
|
+
if (message.type === 'figma-capture') {
|
|
87
|
+
const r = await fetch(`${base}/figma/capture`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: {'content-type': 'application/json'},
|
|
90
|
+
body: JSON.stringify({node: message.node}),
|
|
91
|
+
});
|
|
92
|
+
return await r.json();
|
|
93
|
+
}
|
|
86
94
|
const r = await fetch(`${base}/state`);
|
|
87
95
|
return await r.json();
|
|
88
96
|
} catch (error) {
|
|
@@ -104,7 +112,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
|
104
112
|
msg.type === 'state' ||
|
|
105
113
|
msg.type === 'rec-start' ||
|
|
106
114
|
msg.type === 'rec-event' ||
|
|
107
|
-
msg.type === 'rec-stop'
|
|
115
|
+
msg.type === 'rec-stop' ||
|
|
116
|
+
msg.type === 'figma-capture'
|
|
108
117
|
) {
|
|
109
118
|
dispatch(msg).then(sendResponse);
|
|
110
119
|
return true; // 异步 sendResponse
|
|
@@ -137,48 +146,76 @@ async function readEnhancements() {
|
|
|
137
146
|
async function buildScripts() {
|
|
138
147
|
const plugins = await readEnhancements();
|
|
139
148
|
const mainJs = [...BASE_MAIN_JS];
|
|
149
|
+
const extra = []; // 插件专用脚本:各自 matches/world/runAt(如 figma 的 figma.com + document_start + MAIN)
|
|
140
150
|
for (const p of plugins) {
|
|
141
151
|
for (const file of p.main || []) {
|
|
142
152
|
mainJs.push(`enhancements/${p.id}/${file}`);
|
|
143
153
|
}
|
|
154
|
+
for (const s of p.scripts || []) {
|
|
155
|
+
extra.push({
|
|
156
|
+
id: `enh-${p.id}-${s.id}`,
|
|
157
|
+
js: (s.js || []).map(f => `enhancements/${p.id}/${f}`),
|
|
158
|
+
matches: s.matches || [],
|
|
159
|
+
world: s.world || 'ISOLATED',
|
|
160
|
+
runAt: s.runAt || 'document_idle',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
144
163
|
}
|
|
145
|
-
|
|
164
|
+
const base = [
|
|
165
|
+
{id: 'ui-inspector-main', js: mainJs, world: 'MAIN', runAt: 'document_idle'},
|
|
166
|
+
{id: BRIDGE_SCRIPT.id, js: BRIDGE_SCRIPT.js, world: 'ISOLATED', runAt: 'document_idle'},
|
|
167
|
+
];
|
|
168
|
+
return {base, extra};
|
|
146
169
|
}
|
|
147
170
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
const allowed = [];
|
|
153
|
-
for (const match of domains || []) {
|
|
171
|
+
// 过滤出已授予主机权限的 match(未授权的等弹窗 request 后再同步)。
|
|
172
|
+
async function filterGranted(matches) {
|
|
173
|
+
const out = [];
|
|
174
|
+
for (const match of matches || []) {
|
|
154
175
|
try {
|
|
155
176
|
if (await chrome.permissions.contains({origins: [match]})) {
|
|
156
|
-
|
|
177
|
+
out.push(match);
|
|
157
178
|
}
|
|
158
179
|
} catch {
|
|
159
180
|
// 非法 match 模式,跳过
|
|
160
181
|
}
|
|
161
182
|
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function syncScripts() {
|
|
187
|
+
const {domains} = await getConfig();
|
|
188
|
+
const {base, extra} = await buildScripts();
|
|
189
|
+
const grantedPopup = await filterGranted(domains);
|
|
190
|
+
|
|
191
|
+
const regs = [];
|
|
192
|
+
// 基础脚本(选取 + 录制 main + bridge)→ 弹窗配置的生效域名
|
|
193
|
+
if (grantedPopup.length) {
|
|
194
|
+
for (const s of base) {
|
|
195
|
+
regs.push({id: s.id, js: s.js, matches: grantedPopup, world: s.world, runAt: s.runAt});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// 插件专用脚本 → 各自声明的 matches(按授权过滤)
|
|
199
|
+
for (const s of extra) {
|
|
200
|
+
const m = await filterGranted(s.matches);
|
|
201
|
+
if (m.length) {
|
|
202
|
+
regs.push({id: s.id, js: s.js, matches: m, world: s.world, runAt: s.runAt});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 先清掉我们注册过的所有脚本(前缀 ui-inspector- / enh-),再按最新结果注册。
|
|
162
207
|
try {
|
|
163
|
-
const existing = await chrome.scripting.getRegisteredContentScripts(
|
|
164
|
-
|
|
165
|
-
|
|
208
|
+
const existing = await chrome.scripting.getRegisteredContentScripts();
|
|
209
|
+
const ours = existing.filter(s => /^(ui-inspector-|enh-)/.test(s.id)).map(s => s.id);
|
|
210
|
+
if (ours.length) {
|
|
211
|
+
await chrome.scripting.unregisterContentScripts({ids: ours});
|
|
166
212
|
}
|
|
167
213
|
} catch {
|
|
168
214
|
// 没注册过,忽略
|
|
169
215
|
}
|
|
170
|
-
if (
|
|
171
|
-
|
|
216
|
+
if (regs.length) {
|
|
217
|
+
await chrome.scripting.registerContentScripts(regs);
|
|
172
218
|
}
|
|
173
|
-
await chrome.scripting.registerContentScripts(
|
|
174
|
-
scripts.map(s => ({
|
|
175
|
-
id: s.id,
|
|
176
|
-
js: s.js,
|
|
177
|
-
matches: allowed,
|
|
178
|
-
world: s.world,
|
|
179
|
-
runAt: 'document_idle',
|
|
180
|
-
}))
|
|
181
|
-
);
|
|
182
219
|
}
|
|
183
220
|
|
|
184
221
|
async function ensureDefaults() {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 数据文件与 MCP server / hook 共用:统一在用户主目录 ~/.ui-inspector/pending.json(与仓库位置无关)。
|
|
13
13
|
*/
|
|
14
|
-
import {readFileSync, writeFileSync, appendFileSync, mkdirSync} from 'node:fs';
|
|
14
|
+
import {readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync} from 'node:fs';
|
|
15
15
|
import {dirname, resolve} from 'node:path';
|
|
16
16
|
import {homedir} from 'node:os';
|
|
17
17
|
|
|
@@ -22,6 +22,8 @@ const statusFile = resolve(homedir(), '.ui-inspector/status.json');
|
|
|
22
22
|
// 分开是为了避免「每条事件读改写整份 JSON」的多进程竞态把 active 盖回 true——事件只追加,不碰 active。
|
|
23
23
|
const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
|
|
24
24
|
const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
|
|
25
|
+
// figma「带入对话」采集的节点结构,等 hook 注入下一条消息。
|
|
26
|
+
const figmaPendingFile = resolve(homedir(), '.ui-inspector/figma-pending.json');
|
|
25
27
|
|
|
26
28
|
function readStore() {
|
|
27
29
|
try {
|
|
@@ -85,6 +87,26 @@ function recStop(endedAt) {
|
|
|
85
87
|
writeMeta(cur); // 只有 start/stop 动 active,事件追加不碰它 → stop 后 active 不会被盖回
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
// figma 节点采集:追加到 figma-pending.json(上限 10 个,防爆)。
|
|
91
|
+
function figmaCapture(node) {
|
|
92
|
+
if (!node) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
let list = [];
|
|
96
|
+
try {
|
|
97
|
+
const p = JSON.parse(readFileSync(figmaPendingFile, 'utf8'));
|
|
98
|
+
list = Array.isArray(p) ? p : [];
|
|
99
|
+
} catch {
|
|
100
|
+
list = [];
|
|
101
|
+
}
|
|
102
|
+
list.push({node, ts: Date.now()});
|
|
103
|
+
if (list.length > 10) {
|
|
104
|
+
list = list.slice(-10);
|
|
105
|
+
}
|
|
106
|
+
mkdirSync(dirname(figmaPendingFile), {recursive: true});
|
|
107
|
+
writeFileSync(figmaPendingFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
|
|
108
|
+
}
|
|
109
|
+
|
|
88
110
|
function send(obj) {
|
|
89
111
|
const body = Buffer.from(JSON.stringify(obj), 'utf8');
|
|
90
112
|
const header = Buffer.alloc(4);
|
|
@@ -110,6 +132,9 @@ function handle(msg) {
|
|
|
110
132
|
} else if (msg && msg.type === 'rec-stop') {
|
|
111
133
|
recStop(msg.endedAt);
|
|
112
134
|
resp = {ok: true};
|
|
135
|
+
} else if (msg && msg.type === 'figma-capture') {
|
|
136
|
+
figmaCapture(msg.node);
|
|
137
|
+
resp = {ok: true};
|
|
113
138
|
} else {
|
|
114
139
|
resp = {ok: false, error: 'unknown message'};
|
|
115
140
|
}
|
|
@@ -19,6 +19,8 @@ const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
|
|
|
19
19
|
// 录制:元信息(active/startedAt/pageUrl/endedAt)+ append-only 事件流(每行一条 {kind,item})。
|
|
20
20
|
const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
|
|
21
21
|
const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
|
|
22
|
+
// figma「带入对话」采集的节点结构(页面按钮触发落盘),注入后让 Agent 据结构生成代码。
|
|
23
|
+
const figmaPendingFile = resolve(homedir(), '.ui-inspector/figma-pending.json');
|
|
22
24
|
|
|
23
25
|
function emit(output) {
|
|
24
26
|
process.stdout.write(JSON.stringify(output));
|
|
@@ -67,8 +69,11 @@ const meta = readJson(recordingFile, null);
|
|
|
67
69
|
const recDone = meta && meta.active === false;
|
|
68
70
|
const recEvents = recDone ? readEvents() : [];
|
|
69
71
|
const hasRec = recDone && recEvents.length > 0;
|
|
72
|
+
const figmaParsed = readJson(figmaPendingFile, []);
|
|
73
|
+
const figmaNodes = Array.isArray(figmaParsed) ? figmaParsed : [];
|
|
74
|
+
const hasFigma = figmaNodes.length > 0;
|
|
70
75
|
|
|
71
|
-
if (!list.length && !hasRec) {
|
|
76
|
+
if (!list.length && !hasRec && !hasFigma) {
|
|
72
77
|
emit({decision: 'allow'});
|
|
73
78
|
}
|
|
74
79
|
|
|
@@ -135,6 +140,23 @@ function describeRecording(meta, evs) {
|
|
|
135
140
|
return parts.join('\n');
|
|
136
141
|
}
|
|
137
142
|
|
|
143
|
+
// figma「带入对话」节点:把每个节点的 F2C HTML 结构铺开,供 Agent 据此生成代码。
|
|
144
|
+
function describeFigma(nodes) {
|
|
145
|
+
const parts = [
|
|
146
|
+
`【ui-inspector Figma】用户在 Figma 选中并带入了 ${nodes.length} 个节点,请据下面的结构(F2C 导出的 HTML)生成对应前端代码:`,
|
|
147
|
+
];
|
|
148
|
+
nodes.forEach((entry, i) => {
|
|
149
|
+
const n = (entry && entry.node) || {};
|
|
150
|
+
const html = String(n.html || '').slice(0, 20000);
|
|
151
|
+
parts.push(`—— 节点 ${i + 1}:${n.name || ''}(id ${n.nodeId || '?'},${n.type || ''})——`);
|
|
152
|
+
parts.push(html || '(无 HTML)');
|
|
153
|
+
if ((n.html || '').length > 20000) {
|
|
154
|
+
parts.push('…(结构过长已截断)');
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
return parts.join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
138
160
|
const sections = [];
|
|
139
161
|
if (list.length) {
|
|
140
162
|
const total = list.length;
|
|
@@ -148,11 +170,14 @@ if (list.length) {
|
|
|
148
170
|
if (hasRec) {
|
|
149
171
|
sections.push(describeRecording(meta, recEvents));
|
|
150
172
|
}
|
|
173
|
+
if (hasFigma) {
|
|
174
|
+
sections.push(describeFigma(figmaNodes));
|
|
175
|
+
}
|
|
151
176
|
sections.push(
|
|
152
177
|
'请据此定位源码并按用户意见修改。页面选中态已自动取消;改动过程中可调用 MCP 工具 notify_browser 向页面推送状态(如「修改中…」「已完成」),会在浏览器弹 toast。'
|
|
153
178
|
);
|
|
154
179
|
|
|
155
|
-
// 「拿到即消费」:注入后清空 pending
|
|
180
|
+
// 「拿到即消费」:注入后清空 pending / figma;录制仅在确实注入(已停止)时清空,进行中的保留。
|
|
156
181
|
try {
|
|
157
182
|
mkdirSync(dirname(storeFile), {recursive: true});
|
|
158
183
|
writeFileSync(storeFile, '[]\n', 'utf8');
|
|
@@ -160,6 +185,9 @@ try {
|
|
|
160
185
|
writeFileSync(recordingFile, 'null\n', 'utf8');
|
|
161
186
|
writeFileSync(eventsFile, '', 'utf8');
|
|
162
187
|
}
|
|
188
|
+
if (hasFigma) {
|
|
189
|
+
writeFileSync(figmaPendingFile, '[]\n', 'utf8');
|
|
190
|
+
}
|
|
163
191
|
} catch {
|
|
164
192
|
// 清空失败不影响注入
|
|
165
193
|
}
|
package/mcp/server.mjs
CHANGED
|
@@ -113,16 +113,16 @@ function handle(msg) {
|
|
|
113
113
|
return;
|
|
114
114
|
}
|
|
115
115
|
if (msg.method === 'tools/call') {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
116
|
+
Promise.resolve()
|
|
117
|
+
.then(() => callTool(msg.params?.name, msg.params?.arguments))
|
|
118
|
+
.then(result =>
|
|
119
|
+
send({
|
|
120
|
+
jsonrpc: '2.0',
|
|
121
|
+
id: msg.id,
|
|
122
|
+
result: {content: [{type: 'text', text: JSON.stringify(result, null, 4)}]},
|
|
123
|
+
})
|
|
124
|
+
)
|
|
125
|
+
.catch(error => send({jsonrpc: '2.0', id: msg.id, error: {code: -32000, message: String(error)}}));
|
|
126
126
|
return;
|
|
127
127
|
}
|
|
128
128
|
if (msg.id !== undefined) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "comate-ui-inspector",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6-alpha.2",
|
|
4
4
|
"description": "按住 Alt 点选页面元素,把「元素 + Vue 组件源码路径」交给 Comate 定位源码修改。浏览器扩展 + Native Messaging host + MCP + hook。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
"mcp",
|
|
15
15
|
"bridge",
|
|
16
16
|
"hooks",
|
|
17
|
-
"README.md"
|
|
17
|
+
"README.md",
|
|
18
|
+
"!extension/enhancements",
|
|
19
|
+
"!extension/enhancements.json",
|
|
20
|
+
"!extension/native-host/host-wrapper.sh",
|
|
21
|
+
"!enhancements/figma/browser.global.js"
|
|
18
22
|
],
|
|
19
23
|
"engines": {
|
|
20
24
|
"node": ">=18"
|