dsh-eda-mcp 1.0.1 → 1.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 — 2026-10-21
4
+
5
+ ### `/eda/` 下载限流(服务器侧,5 次/分钟/IP)
6
+
7
+ 服务器新增 nginx 令牌桶限流:`limit_req_zone $eda_rate_key zone=eda_download:10m rate=5r/m`,`burst=5 nodelay`,超限返回 **429** 并带可读响应体;`127.0.0.1`/`::1` 用 `geo` 置空 key 白名单放行,服务器自查与上传校验不会被限流。区域配置在 `/etc/nginx/conf.d/eda-ratelimit.conf`,限流指令在 `/eda/` location 内。
8
+
9
+ - 实测:连续请求 → 前 6 次 `200`,第 7 次起 `429`;被限流期间 `/dreamspeedrun/` 仍 `200`、`/files/` 仍 `403`(未受影响)。
10
+ - `eda_extension_package` 遇 `429` 时返回 `verified: unverified` + `httpStatus: 429` 并明确说明"是限流,不是校验不一致"(真机触发验证过);`verified` 绝不会把限流误报成 `mismatch`。
11
+ - 记录一个事实:nginx **不支持 `rate=20r/h`**(单位只有 `r/s`、`r/m`,实测 `invalid rate`),所以按你的决定改为 `5r/m`。
12
+
13
+
14
+
15
+ ### 扩展 zip 的下载与校验(线协议不变 0.4.9)
16
+
17
+ 之前只有"本地那份"和"服务器那份",**没有任何东西能证明二者一致**,也没有从服务器下载的能力:
18
+
19
+ - 新增工具 `eda_extension_package`:报告插件自带 zip 的路径/字节数/sha256;`download:true` 时从下载服务器取回同一文件并**逐字节比较**,给出 `verified: match | mismatch`;一致才写入 `outDir`(默认临时目录),不一致**不落盘**并提示重新上传;`base` 可用 `DSH_EDA_EXTENSION_BASE` 覆盖。
20
+ - 新增纯函数模块 `src/bridge/extension-package.ts`(sha256、URL 拼接、"缺摘要算 unverified 而不是通过")+ `test/extension-package.test.ts`(进入 `pnpm test`,共 10 套)。
21
+ - **修掉一个真 bug**:`build.mjs` 的 `Compress-Archive` 把文件 mtime 打进 zip,同一份源码两次构建得到**不同字节**(实测同为 9827 字节、sha256 不同),校验因此永远假阴性。现在打包前把暂存目录(含子目录)mtime 固定为 2020-01-01,**构建可复现**:连续两次 build 同为 `827e9ad9…`。
22
+ - 真机验证:上传可复现 zip → 服务器 `sha256sum` 与本地一致 → `eda_extension_package({download:true})` 返回 `verified: match` 并落盘。
23
+
24
+ ## 1.0.2 — 2026-10-20
25
+
26
+ ### 覆铜调查结案(扩展 0.4.7 / 0.4.8 真机)
27
+
28
+ 结论:**这台构建(EDA 3.2.186 / pro-api 0.3.18.0bc12bb0)无法通过 API 创建铜面图元**。不是参数问题,证据链如下。
29
+
30
+ | 尝试 | 结果 |
31
+ |---|---|
32
+ | `pcb_PrimitivePour.create` 静态入口 × 8 种组合(单/复杂多边形、原始源码数组、空网络、层 1/2/字符串、全参数显式、100×100 与 300×300 mil) | 全部 `无法创建覆铜边框图元` |
33
+ | 实例入口 `new IPCB_PrimitivePour(...).create()`(0.4.7 新增,验证构造器在运行时可取) | 同样报错 |
34
+ | 填充方式 `solid`/`45grid`/`90grid`/`0`/`1`/`2`/`SOLID`/`GRID45`/`45GRID` | 全部报错 |
35
+ | `pcb_PrimitiveFill.create`(文档说明它同样带 `net` 参数)× 4 种(对象/原始数组/复杂源码/显式 fillMode) | 全部 `无法创建填充图元` |
36
+ | **控制组**:同一多边形管线写丝印折线 `pcb_PrimitivePolyline` / `pcb_draw_polyline` | 也写不出来(`getAll` 0 条),而 `pcb_get_polylines` 内部路径能读到既有板框 1 条 |
37
+
38
+ 外部资料也到此为止:`prodocs` 正文是前端渲染(HTML 只有导航),官方配方给的流程就是我们实现的那条(`createPolygon`/`createComplexPolygon` → pour → `rebuildCopperRegion`),搜 "无法创建覆铜边框图元" 无任何社区解法,应用 bundle 内也搜不到该字符串。
39
+
40
+ **因此撤掉 `pcb_create_copper_fill`**:它在这台构建上必然失败,留着就是给调用方挖坑。白名单里的 `pcb_MathPolygon.createComplexPolygon` 与 `pcb_PrimitiveFill.*` 保留(换构建可能有效),但不再包装成工具。
41
+
42
+ **正解**:覆铜在界面手工铺(GND → 顶层+底层 → 重新铺铜);`eda_design_audit` 会持续把缺覆铜记成 `copper-pour` warn,`eda_delivery_checklist` 也会带上它。
43
+
44
+
45
+ ### 通知堆叠 / 粘连(扩展 0.4.9)
46
+
47
+ 屏幕实测:三条提示叠在画布上("未连接…点击右侧按钮"、"扩展已加载 v0.4.8"、"已连接;端口 39009"),且"未连接"那条永不消失。
48
+
49
+ - 根因:`showToastMessage` 的 `timer: 0` 语义是**永不自动隐藏**,而 `SYS_Message` **没有任何隐藏或替换 API**;连接成功时也没有人撤掉它。
50
+ - 修法:未连接提示改成 `showFollowMouseTip(tip, 8000)`,并在 WebSocket `onopen` 里 `removeFollowMouseTip` 显式撤掉;`showToast` 一律传有限时长(默认 4 s);文案改为"菜单 DSH MCP → 连接 DSH MCP 服务器"(跟随提示没有按钮)。
51
+ - 线协议 **0.4.8 → 0.4.9**(纯 UI 层)。
52
+
3
53
  ## 1.0.1 — 2026-10-20
4
54
 
5
55
  1.0.0 的真机回归修掉 4 个 bug(全部由"跑一遍"发现,不是靠读代码):
package/CONTRIBUTING.md CHANGED
@@ -106,6 +106,39 @@ git push origin main --follow-tags
106
106
  extension have their own version pair (they are the wire contract between the
107
107
  bridge process and JLCEDA, and `eda_status` reports a mismatch).
108
108
 
109
+ ### Uploading the extension zip to the download server
110
+
111
+ Every wire-version bump produces a new `dsh-eda-bridge-extension-<version>.zip` (it is
112
+ gitignored, so the file lives only in the npm tarball and on the download server). The
113
+ maintainer asked for the server copy to be refreshed on every update — do it in the same
114
+ session that bumps the extension, not later:
115
+
116
+ 1. upload the new zip to `/var/www/eda-download/` on `tencent` (`root@159.75.130.207`)
117
+ **without deleting older versions** — the version is in the filename, so old links keep
118
+ working;
119
+ 2. verify the transfer: the remote `sha256sum` must equal `Get-FileHash` locally, and
120
+ `curl -sI http://159.75.130.207/eda/<zip>` must answer `200` + `Content-Type:
121
+ application/zip` + the exact byte count;
122
+ 3. only then hand the link out:
123
+ `http://159.75.130.207/eda/dsh-eda-bridge-extension-<version>.zip`
124
+ (the listing at `http://159.75.130.207/eda/` shows every published zip).
125
+
126
+ The directory is served by one additive `location /eda/` block in
127
+ `/etc/nginx/conf.d/voice.conf`; adding or replacing files needs no nginx change and no
128
+ reload. Keep every other part of that server untouched.
129
+
130
+ ### Rate limit
131
+
132
+ `/eda/` is throttled to **5 requests/minute per IP with burst 5** (nginx `limit_req`,
133
+ `rate=5r/m`): six requests pass immediately, the seventh answers `429` with a readable body.
134
+ `127.0.0.1` and `::1` are exempt (geo key is empty), so server-side checks never throttle.
135
+ The zone lives in `/etc/nginx/conf.d/eda-ratelimit.conf`, the limit itself inside the `/eda/`
136
+ location; changing the rate needs `nginx -t` plus a reload.
137
+
138
+ Verifying an upload from an office IP can therefore hit the limit: `eda_extension_package`
139
+ reports that as `verified: 'unverified'` with `httpStatus: 429` — wait about a minute and
140
+ retry instead of suspecting the artifact. A `429` is never a checksum mismatch.
141
+
109
142
  Publishing to npm is documented step by step in [`docs/release.md`](docs/release.md):
110
143
  first release is manual (2FA / staged publishing), afterwards the `publish`
111
144
  workflow releases on every `v*` tag through npm trusted publishing (OIDC, no
package/README.md CHANGED
@@ -41,6 +41,24 @@ toolchain is needed. The extension still has to be imported into JLCEDA Pro by h
41
41
  sits at `node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<version>.zip` (or download it from the
42
42
  settings card).
43
43
 
44
+ Direct links (no package install needed):
45
+
46
+ - `http://159.75.130.207/eda/dsh-eda-bridge-extension-0.4.9.zip` (current wire version)
47
+ - `http://159.75.130.207/eda/` - directory listing of every uploaded extension zip
48
+
49
+ The filename always carries the wire version; check `eda_status` after importing and restart JLCEDA
50
+ Pro when the version differs from the bridge.
51
+
52
+ Verification: `eda_extension_package` reports the zip this plugin ships (path, bytes, sha256) and,
53
+ with `download: true`, fetches the served copy and compares the two byte for byte (`verified: match |
54
+ mismatch`). On a match it saves the file to `outDir` (default: the temp directory); on a mismatch it
55
+ writes nothing and tells you to re-upload. The zip build is reproducible, so equal sources always
56
+ produce the same sha256.
57
+
58
+ The download server throttles `/eda/` to 5 requests/minute per IP (burst 5, loopback exempt), so
59
+ a `429` shows up as `verified: 'unverified'` with `httpStatus: 429` — that is throttling, not a
60
+ checksum mismatch; wait a minute and retry.
61
+
44
62
  ### 1. Install into the DSH web profile
45
63
 
46
64
  ```sh
package/README.zh.md CHANGED
@@ -50,6 +50,17 @@ pnpm add dsh-eda-mcp # 或用 Web GUI 的插件管理器安装
50
50
  发布包里已包含 `lib/` 与 `dsh-eda-bridge-extension-<线协议版本>.zip`,**不需要构建工具链**。扩展仍需手动导入
51
51
  嘉立创EDA:zip 位于 `node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<线协议版本>.zip`(也可从设置卡片下载,下载到的文件名自带版本号)。
52
52
 
53
+ 直链(不用装包也能下):
54
+
55
+ - `http://159.75.130.207/eda/dsh-eda-bridge-extension-0.4.9.zip`(当前线协议版本)
56
+ - `http://159.75.130.207/eda/` —— 所有已上传扩展 zip 的目录列表
57
+
58
+ 文件名自带线协议版本:导入后用 `eda_status` 核对,版本与桥接不一致就重启嘉立创EDA专业版。
59
+
60
+ 校验:`eda_extension_package` 报告插件自带的 zip(路径、字节数、sha256);传 `download: true` 时会把服务器那份取回来逐字节比对(`verified: match | mismatch`),一致才存到 `outDir`(默认临时目录),不一致则不落盘并提示重新上传。zip 构建已改为可复现,同样的源码永远得到同一个 sha256。
61
+
62
+ 下载服务器对 `/eda/` 限流 **5 次/分钟/IP**(burst 5,回环地址豁免):被限流时工具返回 `verified: unverified` 且 `httpStatus: 429` —— 这是限流,不是校验不一致,等一分钟重试即可。
63
+
53
64
  ### 1. 安装到 DSH Web profile
54
65
 
55
66
  ```sh
@@ -146,7 +146,7 @@
146
146
  | eda_launch 带 project 没打开工程 | 命令行路径被应用忽略 | 用 eda_open_project({ name }) 打开 |
147
147
  | 关应用后改动丢失 | eda_close 走了强制路径 | 关前先保存;看返回的 forced |
148
148
  | run_code 读大 JSON 被截断 | 宿主 read 有单行上限 | 用 node:fs 读盘或分片读 |
149
- | 覆铜创建失败(无法创建覆铜边框图元) | 应用侧限制:polygonStage=ok(轮廓被接受)但 pours 被拒,与网络/填充方式/坐标无关 | 工具会重试一次并给出该诊断;手工在 PCB 页框选覆铜(GND,顶层+底层),design_audit 记为 warn |
149
+ | 覆铜/填充一律被拒(无法创建覆铜边框图元 / 无法创建填充图元) | 应用侧限制:静态+实例入口、单/复杂多边形、原始源码数组、6 种填充枚举、全参数组合全部被拒;同一多边形管线在本构建上连丝印折线都写不出来 | 不要在 API 层反复试:覆铜请在界面手工铺(GND、顶层+底层);design_audit 持续记 copper-pour warn |
150
150
  | pcb_draw_mounting_hole 报“无法创建焊盘图元” | pad 形状必填,且槽孔只能用 ELLIPSE | 已内建:圆孔 ELLIPSE(d,d),槽孔 ELLIPSE(长,宽)+hole[SLOT,宽,长];OBLONG/RECTANGLE 焊盘会被拒 |
151
151
  | pcb_PrimitiveString.getAll / pcb_PrimitivePad.getAll 返回 0 | 这两个 API 按层查询 | 传 layer:丝印 3/4,多层焊盘 12;不传参恒为 0 |
152
152
  | 按坐标算的越界没被发现 | 图框/原点假设与真实页面不一致 | 用 eda_get_page_info 的真实值 + eda_capture 导 PNG 看实际版面 |
@@ -292,5 +292,6 @@ C. 只做 PCB:
292
292
  - **插件代码改动对当前会话完全不生效**:宿主在启动时把 `lib/index.js` 载入内存,之后改文件、重新 build 都不会热更新。
293
293
  - 版本纪律:扩展线协议**只递增最小位**(`0.4.1 → 0.4.2`),每次改动必须同时改 `jlc-extension/extension.json` 的 `version` 与桥接的 `VERSION`;不一致时 `eda_status.versionMismatch` 为 true。
294
294
  - 新会话开工顺序:`eda_status`(连没连、版本对不对)→ 本技能(怎么干)→ 再动手;不要先假设环境就绪。
295
+ | 需要扩展 zip(换机器/给别人) | 不清楚该给哪一份 | `eda_extension_package` 报路径+sha256;`download:true` 从服务器取回并逐字节比对(`verified`),一致才落盘 |
295
296
  - 扩展 zip 位置:`node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<版本>.zip`,或设置卡片下载(文件名自带版本号)。
296
297
  - 排查顺序:`eda_status` → `/api/dsh-eda-mcp/diagnostics`(`clientStartupError` / `skills` / `bridgeHealth`)→ `eda_list_api_methods`(白名单)→ 应用侧 DRC 面板。
package/docs/tools.md CHANGED
@@ -176,3 +176,9 @@ Design limits enforced by these tools (track widths, via sizes, net naming) are
176
176
  |---|---|---|
177
177
  | `eda_api_call` | Call an allow-listed JLCEDA Pro extension API method directly. | `method: string, args: array` |
178
178
 
179
+ ## Other
180
+
181
+ | Tool | Description | Parameters |
182
+ |---|---|---|
183
+ | `eda_extension_package` | Report the extension zip this plugin ships (absolute path, bytes, sha256) and, with download:true, fetch the copy served at the download base URL and compare the two byte for byte. On a match the served file is saved to outDir (default: the temp directory) so it can be imported into JLCEDA Pro; on a mismatch nothing is written and the tool says to re-upload. base defaults to DSH_EDA_EXTENSION_BASE or the project download server. | `download?: boolean, outDir?: string, base?: string` |
184
+
@@ -212,6 +212,9 @@ function dshEdaMcpStatus() {
212
212
  'pcb_PrimitivePad.create',
213
213
  'pcb_PrimitivePad.getAll',
214
214
  'pcb_PrimitivePad.delete',
215
+ 'pcb_PrimitiveFill.create',
216
+ 'pcb_PrimitiveFill.getAll',
217
+ 'pcb_PrimitiveFill.delete',
215
218
  'pcb_PrimitivePour.create',
216
219
  'pcb_PrimitivePour.modify',
217
220
  'pcb_PrimitivePour.delete',
@@ -459,21 +462,54 @@ function dshEdaMcpStatus() {
459
462
  : undefined;
460
463
  if (!pourPolygon) pourPolygon = eda.pcb_MathPolygon.createPolygon(pourSource);
461
464
  if (!pourPolygon) throw new Error('dsh.internal.pourCreate: invalid polygon source ' + JSON.stringify(pourSource));
462
- var pour = await eda.pcb_PrimitivePour.create(
463
- optional(pourArgs[0]), optional(pourArgs[1]), pourPolygon, optional(pourArgs[3]), optional(pourArgs[4]),
464
- optional(pourArgs[5]), optional(pourArgs[6]), optional(pourArgs[7]), optional(pourArgs[8])
465
- );
465
+ // Route 1: the documented static factory.
466
+ var pourRoutes = [];
467
+ var pour = null;
468
+ var usedRoute = 'static';
469
+ try {
470
+ pour = await eda.pcb_PrimitivePour.create(
471
+ optional(pourArgs[0]), optional(pourArgs[1]), pourPolygon, optional(pourArgs[3]), optional(pourArgs[4]),
472
+ optional(pourArgs[5]), optional(pourArgs[6]), optional(pourArgs[7]), optional(pourArgs[8])
473
+ );
474
+ } catch (staticError) {
475
+ pourRoutes.push('static: ' + (staticError && staticError.message ? staticError.message : String(staticError)));
476
+ }
477
+ // Route 2: the instance entry point (@internal in the typings, but the class
478
+ // constructor is exported at runtime on some builds).
466
479
  if (!pour) {
467
- throw new Error('dsh.internal.pourCreate: pcb_PrimitivePour.create returned undefined (net/layer/polygon rejected; copper layers only)');
480
+ var PourCtor = null;
481
+ try {
482
+ if (typeof eda.IPCB_PrimitivePour === 'function') PourCtor = eda.IPCB_PrimitivePour;
483
+ else if (eda.pcb_PrimitivePour && typeof eda.pcb_PrimitivePour.constructor === 'function' && eda.pcb_PrimitivePour.constructor !== Object) PourCtor = eda.pcb_PrimitivePour.constructor;
484
+ } catch (ctorProbeError) { PourCtor = null; }
485
+ if (PourCtor !== null) {
486
+ usedRoute = 'instance';
487
+ try {
488
+ var pourInstance = new PourCtor(
489
+ optional(pourArgs[0]), optional(pourArgs[1]), pourPolygon, optional(pourArgs[3]), optional(pourArgs[4]),
490
+ optional(pourArgs[5]), optional(pourArgs[6]), optional(pourArgs[7]), optional(pourArgs[8])
491
+ );
492
+ pour = await pourInstance.create();
493
+ } catch (instanceError) {
494
+ pourRoutes.push('instance: ' + (instanceError && instanceError.message ? instanceError.message : String(instanceError)));
495
+ usedRoute = 'none';
496
+ }
497
+ } else {
498
+ pourRoutes.push('instance: no exported constructor');
499
+ usedRoute = 'none';
500
+ }
468
501
  }
469
- // A pour border alone leaves no copper: the region has to be rebuilt for the
470
- // fill (and the clearance against other nets) to be computed.
502
+ if (!pour) {
503
+ throw new Error('dsh.internal.pourCreate: every route refused (' + pourRoutes.join(' | ') + ')');
504
+ }
505
+ // A pour border alone leaves no copper: rebuild so the fill (and the clearance
506
+ // against other nets) is actually computed.
471
507
  var rebuilt = false;
472
508
  if (pour && typeof pour.rebuildCopperRegion === 'function') {
473
509
  try { await pour.rebuildCopperRegion(); rebuilt = true; } catch (error) { rebuilt = false; }
474
510
  }
475
511
  var pourJson = toJsonable(pour);
476
- if (pourJson && typeof pourJson === 'object') pourJson.rebuilt = rebuilt;
512
+ if (pourJson && typeof pourJson === 'object') { pourJson.rebuilt = rebuilt; pourJson.route = usedRoute; }
477
513
  return pourJson;
478
514
  }
479
515
  // Internal helper: long-running jobs (auto-routing / auto-layout) are started
@@ -579,6 +615,9 @@ function dshEdaMcpStatus() {
579
615
  function () {
580
616
  wsReady = true;
581
617
  lastPongAt = Date.now();
618
+ // The 'not connected' tip is sticky by design while we are offline; once the
619
+ // socket is up it must go away, otherwise it sits on the canvas forever.
620
+ hideConnectTip();
582
621
  sendObject({ type: 'hello', version: VERSION });
583
622
  }
584
623
  );
@@ -599,10 +638,11 @@ function dshEdaMcpStatus() {
599
638
  registerWebSocket();
600
639
  }
601
640
 
602
- function showToast(message, type) {
641
+ function showToast(message, type, seconds) {
603
642
  try {
604
643
  if (typeof eda !== 'undefined' && eda.sys_Message && typeof eda.sys_Message.showToastMessage === 'function') {
605
- eda.sys_Message.showToastMessage(message, type || 'info');
644
+ // Always pass a finite timer: omitting it (or 0) leaves the toast on screen forever.
645
+ eda.sys_Message.showToastMessage(message, type || 'info', seconds || 4);
606
646
  } else {
607
647
  console.log('[dsh-eda-mcp] ' + message);
608
648
  }
@@ -706,20 +746,30 @@ function dshEdaMcpStatus() {
706
746
  }
707
747
  }
708
748
 
749
+ // One removable tip instead of a stack of toasts: showToastMessage has no hide API
750
+ // (timer:0 meant "never"), so a stack of them used to pile up over the canvas and the
751
+ // '未连接' banner survived even after the connection was established (measured).
752
+ var CONNECT_TIP = 'DSH MCP 未连接:菜单 DSH MCP → 连接 DSH MCP 服务器(ws://127.0.0.1:39009/ws)';
753
+
754
+ function hideConnectTip() {
755
+ try {
756
+ if (typeof eda !== 'undefined' && eda.sys_Message && typeof eda.sys_Message.removeFollowMouseTip === 'function') {
757
+ eda.sys_Message.removeFollowMouseTip(CONNECT_TIP);
758
+ }
759
+ } catch (_) { /* ignore */ }
760
+ }
761
+
709
762
  function showConnectToast() {
710
763
  if (wsReady) return;
711
764
  try {
712
765
  if (typeof eda === 'undefined' || !eda.sys_Message) return;
713
- eda.sys_Message.showToastMessage(
714
- 'DSH MCP 未连接。点击右侧按钮连接本地桥接服务(ws://127.0.0.1:39009/ws)。',
715
- 'warning',
716
- 0,
717
- undefined,
718
- '连接 DSH MCP',
719
- "eda.sys_MessageBus.publishPublic('dsh-eda-mcp/connect', {})"
720
- );
766
+ if (typeof eda.sys_Message.showFollowMouseTip === 'function') {
767
+ eda.sys_Message.showFollowMouseTip(CONNECT_TIP, 8000);
768
+ return;
769
+ }
770
+ eda.sys_Message.showToastMessage(CONNECT_TIP, 'warning', 8);
721
771
  } catch (error) {
722
- console.error('[dsh-eda-mcp] connect toast failed:', error);
772
+ console.error('[dsh-eda-mcp] connect tip failed:', error);
723
773
  }
724
774
  }
725
775
 
@@ -2,7 +2,7 @@
2
2
  "uuid": "d5e0a1b2c3f4456789abcdef01234567",
3
3
  "name": "dsh-eda-bridge",
4
4
  "displayName": "DSH EDA MCP Bridge",
5
- "version": "0.4.6",
5
+ "version": "0.4.9",
6
6
  "description": "Local WebSocket bridge used by the dsh-eda-mcp plugin to let DeepSeek Harness draw schematics and drive PCB operations in JLCEDA Pro through the official extension API.",
7
7
  "entry": "./api-bridge.js",
8
8
  "publisher": "dsh-eda-mcp",
package/lib/bridge.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  // src/bridge/index.ts
2
- import { createHash } from "node:crypto";
2
+ import { createHash as createHash2 } from "node:crypto";
3
3
  import { execFileSync, spawn } from "node:child_process";
4
- import { statSync } from "node:fs";
4
+ import { existsSync, statSync } from "node:fs";
5
5
  import { readFile } from "node:fs/promises";
6
6
  import { mkdir, unlink, writeFile } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import { basename, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
9
10
  import { createServer } from "node:http";
10
11
  import { createInterface } from "node:readline";
11
12
 
@@ -624,6 +625,26 @@ function runNetlistErc(rawNetlist) {
624
625
  return { ok: counts.fail === 0, counts, componentCount: components.length, netCount: nets.size, checks };
625
626
  }
626
627
 
628
+ // src/bridge/extension-package.ts
629
+ import { createHash } from "node:crypto";
630
+ function sha256Of(bytes) {
631
+ return createHash("sha256").update(bytes).digest("hex");
632
+ }
633
+ function extensionZipName(version) {
634
+ return "dsh-eda-bridge-extension-" + version + ".zip";
635
+ }
636
+ function extensionDownloadUrl(base, version) {
637
+ const normalised = base.endsWith("/") ? base : base + "/";
638
+ return new URL(extensionZipName(version), normalised).toString();
639
+ }
640
+ function compareDigest(expected, actual) {
641
+ if (typeof expected !== "string" || typeof actual !== "string") return "unverified";
642
+ const left = expected.trim().toLowerCase();
643
+ const right = actual.trim().toLowerCase();
644
+ if (left === "" || right === "") return "unverified";
645
+ return left === right ? "match" : "mismatch";
646
+ }
647
+
627
648
  // src/bridge/design-rules.ts
628
649
  var MIL_PER_MM = 39.37007874015748;
629
650
  function milToMm(mil) {
@@ -807,7 +828,7 @@ var WORKFLOW_GUIDE = WORKFLOW_SUMMARY + "\n\n" + [
807
828
  ].join("\n");
808
829
 
809
830
  // src/bridge/index.ts
810
- var VERSION = "0.4.6";
831
+ var VERSION = "0.4.9";
811
832
  var WS_PORT = Number(process.env.DSH_EDA_MCP_WS_PORT ?? 39009);
812
833
  var WS_HOST = "127.0.0.1";
813
834
  var ALLOW_RAW_API = (process.env.DSH_EDA_MCP_ALLOW_RAW_API ?? "1") !== "0";
@@ -1100,6 +1121,7 @@ var TOOLS = [
1100
1121
  { name: "eda_pcb_canvas_origin", description: "Read (or align) the PCB canvas origin and convert points between the two coordinate spaces. JLCEDA displays CANVAS coordinates in its UI but every API takes DATA coordinates; the two differ by this offset, so a coordinate read off the screen will land somewhere else unless you convert or align. align:true calls setCanvasOrigin(0,0) so the display matches the API.", inputSchema: objectSchema({ align: { type: "boolean" }, offsetX: { type: "number" }, offsetY: { type: "number" }, point: { type: "object", additionalProperties: true } }) },
1101
1122
  { name: "eda_capture", description: "Render the active document to a PNG (dmt_EditorControl.getCurrentRenderedAreaImage) and write it to outPath or return a data URL. Use it to LOOK at the sheet/board instead of trusting coordinate math. The PNG is a scratch file: read it, then call eda_capture({cleanup:true}) to delete every capture path this session wrote.", inputSchema: objectSchema({ outPath: { type: "string" }, inline: { type: "boolean" }, cleanup: { type: "boolean" } }) },
1102
1123
  { name: "eda_workflow_guide", description: "Return the schematic working standard: grid-aligned manual placement, signal flow, when net labels/ports are required (a wire net name is overridden by connectivity), net-label fallback behaviour, batch placement and the verification order. Read it before laying out a sheet.", inputSchema: objectSchema({}) },
1124
+ { name: "eda_extension_package", description: "Report the extension zip this plugin ships (absolute path, bytes, sha256) and, with download:true, fetch the copy served at the download base URL and compare the two byte for byte. On a match the served file is saved to outDir (default: the temp directory) so it can be imported into JLCEDA Pro; on a mismatch nothing is written and the tool says to re-upload. base defaults to DSH_EDA_EXTENSION_BASE or the project download server.", inputSchema: objectSchema({ download: { type: "boolean" }, outDir: { type: "string" }, base: { type: "string" } }, []) },
1103
1125
  { name: "eda_list_api_methods", description: "Ask the CONNECTED EXTENSION for the API methods it accepts right now (dsh.internal.listApiMethods) and report source/version/counts. Falls back to the bridge copy with stale:true only when the extension is an older zip. Use refresh:true to re-query.", inputSchema: objectSchema({ refresh: { type: "boolean" } }) },
1104
1126
  { name: "eda_delete_primitive", description: 'Delete a schematic primitive by kind and primitive id. Net ports, net flags and net labels are attribute primitives and need kind "netport"/"netflag"/"netlabel"; the text fallback labels are kind "text".', inputSchema: objectSchema({ kind: { type: "string", enum: ["component", "wire", "rectangle", "circle", "arc", "polygon", "text", "netport", "netflag", "netlabel"] }, primitiveId: { type: "string" } }, ["kind", "primitiveId"]) },
1105
1127
  { name: "eda_create_board", description: "Create a board linking a schematic and a PCB (dmt_Board.createBoard).", inputSchema: objectSchema({ schematicUuid: { type: "string" }, pcbUuid: { type: "string" } }) },
@@ -1544,6 +1566,9 @@ function buildPolygonSource(points) {
1544
1566
  for (let i = 2; i < points.length; i += 2) source.push(points[i], points[i + 1]);
1545
1567
  return source;
1546
1568
  }
1569
+ function packageRoot() {
1570
+ return fileURLToPath(new URL("../", import.meta.url));
1571
+ }
1547
1572
  function requireFlatPoints(value, tool) {
1548
1573
  const points = flattenPoints(value);
1549
1574
  if (points.length < 6 || points.length % 2 !== 0 || !points.every(Number.isFinite)) {
@@ -2993,6 +3018,71 @@ async function executeTool(name, rawArgs, timeoutMs) {
2993
3018
  rotation: args.rotation === void 0 ? void 0 : Number(args.rotation),
2994
3019
  mirror: args.mirror === void 0 ? void 0 : Boolean(args.mirror)
2995
3020
  }], timeoutMs);
3021
+ case "eda_extension_package": {
3022
+ const manifestCandidates = [
3023
+ join(packageRoot(), "jlc-extension", "extension.json"),
3024
+ join(packageRoot(), "dsh-eda-bridge-extension.zip")
3025
+ ];
3026
+ let version = VERSION;
3027
+ try {
3028
+ const manifest = JSON.parse(await readFile(manifestCandidates[0], "utf8"));
3029
+ if (typeof manifest.version === "string" && manifest.version !== "") version = manifest.version;
3030
+ } catch {
3031
+ }
3032
+ const localCandidates = [
3033
+ join(packageRoot(), extensionZipName(version)),
3034
+ join(packageRoot(), "dsh-eda-bridge-extension.zip")
3035
+ ];
3036
+ let localPath = "";
3037
+ for (const candidate of localCandidates) {
3038
+ if (existsSync(candidate)) {
3039
+ localPath = candidate;
3040
+ break;
3041
+ }
3042
+ }
3043
+ if (localPath === "") {
3044
+ throw new Error("eda_extension_package: no extension zip next to the plugin (looked for " + localCandidates.join(", ") + ")");
3045
+ }
3046
+ const localBytes = await readFile(localPath);
3047
+ const localSha256 = sha256Of(localBytes);
3048
+ const base = typeof args.base === "string" && args.base !== "" ? args.base : process.env.DSH_EDA_EXTENSION_BASE ?? "http://159.75.130.207/eda/";
3049
+ const url = extensionDownloadUrl(base, version);
3050
+ const report = {
3051
+ version,
3052
+ local: { path: localPath, bytes: localBytes.byteLength, sha256: localSha256 },
3053
+ url
3054
+ };
3055
+ if (args.download !== true) return report;
3056
+ const response = await fetch(url, { signal: AbortSignal.timeout(3e4) });
3057
+ if (!response.ok) {
3058
+ const throttled = response.status === 429;
3059
+ return {
3060
+ ...report,
3061
+ verified: "unverified",
3062
+ httpStatus: response.status,
3063
+ error: throttled ? "the download server throttled this IP (HTTP 429, 5 downloads/min per IP \u2014 burst 5). Wait about a minute and retry; nothing is wrong with the zip or the upload." : "download failed: HTTP " + response.status + " from " + url
3064
+ };
3065
+ }
3066
+ const remoteBytes = new Uint8Array(await response.arrayBuffer());
3067
+ const remoteSha256 = sha256Of(remoteBytes);
3068
+ const verdict = compareDigest(localSha256, remoteSha256);
3069
+ const result2 = {
3070
+ ...report,
3071
+ remote: { url, bytes: remoteBytes.byteLength, sha256: remoteSha256 },
3072
+ verified: verdict
3073
+ };
3074
+ if (verdict === "match") {
3075
+ const outDir = typeof args.outDir === "string" && args.outDir !== "" ? args.outDir : tmpdir();
3076
+ await mkdir(outDir, { recursive: true });
3077
+ const savedPath = join(outDir, extensionZipName(version));
3078
+ await writeFile(savedPath, remoteBytes);
3079
+ result2.savedPath = savedPath;
3080
+ result2.next = "In JLCEDA Pro: \u6269\u5C55 \u2192 \u5BFC\u5165\u6269\u5C55 \u2192 select this zip \u2192 restart the application (not a reload).";
3081
+ } else {
3082
+ result2.next = "The served copy differs from the one this plugin ships. Do not import it: re-upload " + extensionZipName(version) + " from the plugin directory to the download server.";
3083
+ }
3084
+ return result2;
3085
+ }
2996
3086
  case "eda_list_api_methods": {
2997
3087
  const live = await extensionApiMethods(timeoutMs, args.refresh === true);
2998
3088
  if (live !== void 0) {
@@ -4051,7 +4141,7 @@ server.on("upgrade", (req, socket) => {
4051
4141
  socket.destroy();
4052
4142
  return;
4053
4143
  }
4054
- const accept = createHash("sha1").update(key + WS_GUID).digest("base64");
4144
+ const accept = createHash2("sha1").update(key + WS_GUID).digest("base64");
4055
4145
  socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n");
4056
4146
  const client = new WsConnection(socket);
4057
4147
  clients.add(client);
package/lib/index.js CHANGED
@@ -179,7 +179,7 @@ var SKILL_PLAYBOOK_LINES = [
179
179
  "| eda_launch \u5E26 project \u6CA1\u6253\u5F00\u5DE5\u7A0B | \u547D\u4EE4\u884C\u8DEF\u5F84\u88AB\u5E94\u7528\u5FFD\u7565 | \u7528 eda_open_project({ name }) \u6253\u5F00 |",
180
180
  "| \u5173\u5E94\u7528\u540E\u6539\u52A8\u4E22\u5931 | eda_close \u8D70\u4E86\u5F3A\u5236\u8DEF\u5F84 | \u5173\u524D\u5148\u4FDD\u5B58\uFF1B\u770B\u8FD4\u56DE\u7684 forced |",
181
181
  "| run_code \u8BFB\u5927 JSON \u88AB\u622A\u65AD | \u5BBF\u4E3B read \u6709\u5355\u884C\u4E0A\u9650 | \u7528 node:fs \u8BFB\u76D8\u6216\u5206\u7247\u8BFB |",
182
- "| \u8986\u94DC\u521B\u5EFA\u5931\u8D25\uFF08\u65E0\u6CD5\u521B\u5EFA\u8986\u94DC\u8FB9\u6846\u56FE\u5143\uFF09 | \u5E94\u7528\u4FA7\u9650\u5236\uFF1ApolygonStage=ok\uFF08\u8F6E\u5ED3\u88AB\u63A5\u53D7\uFF09\u4F46 pours \u88AB\u62D2\uFF0C\u4E0E\u7F51\u7EDC/\u586B\u5145\u65B9\u5F0F/\u5750\u6807\u65E0\u5173 | \u5DE5\u5177\u4F1A\u91CD\u8BD5\u4E00\u6B21\u5E76\u7ED9\u51FA\u8BE5\u8BCA\u65AD\uFF1B\u624B\u5DE5\u5728 PCB \u9875\u6846\u9009\u8986\u94DC\uFF08GND\uFF0C\u9876\u5C42+\u5E95\u5C42\uFF09\uFF0Cdesign_audit \u8BB0\u4E3A warn |",
182
+ "| \u8986\u94DC/\u586B\u5145\u4E00\u5F8B\u88AB\u62D2\uFF08\u65E0\u6CD5\u521B\u5EFA\u8986\u94DC\u8FB9\u6846\u56FE\u5143 / \u65E0\u6CD5\u521B\u5EFA\u586B\u5145\u56FE\u5143\uFF09 | \u5E94\u7528\u4FA7\u9650\u5236\uFF1A\u9759\u6001+\u5B9E\u4F8B\u5165\u53E3\u3001\u5355/\u590D\u6742\u591A\u8FB9\u5F62\u3001\u539F\u59CB\u6E90\u7801\u6570\u7EC4\u30016 \u79CD\u586B\u5145\u679A\u4E3E\u3001\u5168\u53C2\u6570\u7EC4\u5408\u5168\u90E8\u88AB\u62D2\uFF1B\u540C\u4E00\u591A\u8FB9\u5F62\u7BA1\u7EBF\u5728\u672C\u6784\u5EFA\u4E0A\u8FDE\u4E1D\u5370\u6298\u7EBF\u90FD\u5199\u4E0D\u51FA\u6765 | \u4E0D\u8981\u5728 API \u5C42\u53CD\u590D\u8BD5\uFF1A\u8986\u94DC\u8BF7\u5728\u754C\u9762\u624B\u5DE5\u94FA\uFF08GND\u3001\u9876\u5C42+\u5E95\u5C42\uFF09\uFF1Bdesign_audit \u6301\u7EED\u8BB0 copper-pour warn |",
183
183
  "| pcb_draw_mounting_hole \u62A5\u201C\u65E0\u6CD5\u521B\u5EFA\u710A\u76D8\u56FE\u5143\u201D | pad \u5F62\u72B6\u5FC5\u586B\uFF0C\u4E14\u69FD\u5B54\u53EA\u80FD\u7528 ELLIPSE | \u5DF2\u5185\u5EFA\uFF1A\u5706\u5B54 ELLIPSE(d,d)\uFF0C\u69FD\u5B54 ELLIPSE(\u957F,\u5BBD)+hole[SLOT,\u5BBD,\u957F]\uFF1BOBLONG/RECTANGLE \u710A\u76D8\u4F1A\u88AB\u62D2 |",
184
184
  "| pcb_PrimitiveString.getAll / pcb_PrimitivePad.getAll \u8FD4\u56DE 0 | \u8FD9\u4E24\u4E2A API \u6309\u5C42\u67E5\u8BE2 | \u4F20 layer\uFF1A\u4E1D\u5370 3/4\uFF0C\u591A\u5C42\u710A\u76D8 12\uFF1B\u4E0D\u4F20\u53C2\u6052\u4E3A 0 |",
185
185
  "| \u6309\u5750\u6807\u7B97\u7684\u8D8A\u754C\u6CA1\u88AB\u53D1\u73B0 | \u56FE\u6846/\u539F\u70B9\u5047\u8BBE\u4E0E\u771F\u5B9E\u9875\u9762\u4E0D\u4E00\u81F4 | \u7528 eda_get_page_info \u7684\u771F\u5B9E\u503C + eda_capture \u5BFC PNG \u770B\u5B9E\u9645\u7248\u9762 |",
@@ -325,6 +325,7 @@ var SKILL_PLAYBOOK_LINES = [
325
325
  "- **\u63D2\u4EF6\u4EE3\u7801\u6539\u52A8\u5BF9\u5F53\u524D\u4F1A\u8BDD\u5B8C\u5168\u4E0D\u751F\u6548**\uFF1A\u5BBF\u4E3B\u5728\u542F\u52A8\u65F6\u628A `lib/index.js` \u8F7D\u5165\u5185\u5B58\uFF0C\u4E4B\u540E\u6539\u6587\u4EF6\u3001\u91CD\u65B0 build \u90FD\u4E0D\u4F1A\u70ED\u66F4\u65B0\u3002",
326
326
  "- \u7248\u672C\u7EAA\u5F8B\uFF1A\u6269\u5C55\u7EBF\u534F\u8BAE**\u53EA\u9012\u589E\u6700\u5C0F\u4F4D**\uFF08`0.4.1 \u2192 0.4.2`\uFF09\uFF0C\u6BCF\u6B21\u6539\u52A8\u5FC5\u987B\u540C\u65F6\u6539 `jlc-extension/extension.json` \u7684 `version` \u4E0E\u6865\u63A5\u7684 `VERSION`\uFF1B\u4E0D\u4E00\u81F4\u65F6 `eda_status.versionMismatch` \u4E3A true\u3002",
327
327
  "- \u65B0\u4F1A\u8BDD\u5F00\u5DE5\u987A\u5E8F\uFF1A`eda_status`\uFF08\u8FDE\u6CA1\u8FDE\u3001\u7248\u672C\u5BF9\u4E0D\u5BF9\uFF09\u2192 \u672C\u6280\u80FD\uFF08\u600E\u4E48\u5E72\uFF09\u2192 \u518D\u52A8\u624B\uFF1B\u4E0D\u8981\u5148\u5047\u8BBE\u73AF\u5883\u5C31\u7EEA\u3002",
328
+ "| \u9700\u8981\u6269\u5C55 zip\uFF08\u6362\u673A\u5668/\u7ED9\u522B\u4EBA\uFF09 | \u4E0D\u6E05\u695A\u8BE5\u7ED9\u54EA\u4E00\u4EFD | `eda_extension_package` \u62A5\u8DEF\u5F84+sha256\uFF1B`download:true` \u4ECE\u670D\u52A1\u5668\u53D6\u56DE\u5E76\u9010\u5B57\u8282\u6BD4\u5BF9\uFF08`verified`\uFF09\uFF0C\u4E00\u81F4\u624D\u843D\u76D8 |",
328
329
  "- \u6269\u5C55 zip \u4F4D\u7F6E\uFF1A`node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<\u7248\u672C>.zip`\uFF0C\u6216\u8BBE\u7F6E\u5361\u7247\u4E0B\u8F7D\uFF08\u6587\u4EF6\u540D\u81EA\u5E26\u7248\u672C\u53F7\uFF09\u3002",
329
330
  "- \u6392\u67E5\u987A\u5E8F\uFF1A`eda_status` \u2192 `/api/dsh-eda-mcp/diagnostics`\uFF08`clientStartupError` / `skills` / `bridgeHealth`\uFF09\u2192 `eda_list_api_methods`\uFF08\u767D\u540D\u5355\uFF09\u2192 \u5E94\u7528\u4FA7 DRC \u9762\u677F\u3002"
330
331
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-eda-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "DeepSeek Harness (DSH) plugin — connects DSH to JLCEDA Pro (EasyEDA Pro) via a local MCP bridge and draws schematics through the JLCEDA extension API.",
5
5
  "license": "Apache-2.0",
6
6
  "author": {
@@ -72,7 +72,7 @@
72
72
  "scripts": {
73
73
  "build": "node build.mjs",
74
74
  "typecheck": "tsc --noEmit",
75
- "test": "node --experimental-strip-types test/node-runtime.test.ts && node --experimental-strip-types test/config-values.test.ts && node --experimental-strip-types test/design-rules.test.ts && node --experimental-strip-types test/seams.test.ts && node --experimental-strip-types test/eda-launch.test.ts && node --experimental-strip-types test/schematic-audit.test.ts && node --experimental-strip-types test/design-intake.test.ts && node --experimental-strip-types test/netlist-erc.test.ts && node --experimental-strip-types test/skills.test.ts",
75
+ "test": "node --experimental-strip-types test/node-runtime.test.ts && node --experimental-strip-types test/config-values.test.ts && node --experimental-strip-types test/design-rules.test.ts && node --experimental-strip-types test/seams.test.ts && node --experimental-strip-types test/eda-launch.test.ts && node --experimental-strip-types test/schematic-audit.test.ts && node --experimental-strip-types test/design-intake.test.ts && node --experimental-strip-types test/extension-package.test.ts && node --experimental-strip-types test/netlist-erc.test.ts && node --experimental-strip-types test/skills.test.ts",
76
76
  "seam-check": "node scripts/seam-check.mjs",
77
77
  "contract-check": "node scripts/contract-check.mjs",
78
78
  "guard:bundle": "node scripts/guard-bundle-paths.mjs",
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Extension zip packaging helpers: the plugin ships
3
+ * `dsh-eda-bridge-extension-<wire version>.zip`, the download server publishes the same
4
+ * file, and a user who installs the plugin from npm must be able to tell whether the copy
5
+ * they got is byte-identical to the one the bridge expects.
6
+ */
7
+ import { createHash } from 'node:crypto'
8
+
9
+ export function sha256Of(bytes: Uint8Array): string {
10
+ return createHash('sha256').update(bytes).digest('hex')
11
+ }
12
+
13
+ export function extensionZipName(version: string): string {
14
+ return 'dsh-eda-bridge-extension-' + version + '.zip'
15
+ }
16
+
17
+ /** Normalises a base URL ('…/eda' or '…/eda/') and appends the zip name. */
18
+ export function extensionDownloadUrl(base: string, version: string): string {
19
+ const normalised = base.endsWith('/') ? base : base + '/'
20
+ return new URL(extensionZipName(version), normalised).toString()
21
+ }
22
+
23
+ export type DigestVerdict = 'match' | 'mismatch' | 'unverified'
24
+
25
+ /**
26
+ * A missing expected digest is reported as 'unverified', never as a pass: an absent
27
+ * comparison is not evidence of equality.
28
+ */
29
+ export function compareDigest(expected: string | null | undefined, actual: string | null | undefined): DigestVerdict {
30
+ if (typeof expected !== 'string' || typeof actual !== 'string') return 'unverified'
31
+ const left = expected.trim().toLowerCase()
32
+ const right = actual.trim().toLowerCase()
33
+ if (left === '' || right === '') return 'unverified'
34
+ return left === right ? 'match' : 'mismatch'
35
+ }
@@ -6,6 +6,7 @@ import { readFile } from 'node:fs/promises'
6
6
  import { mkdir, unlink, writeFile } from 'node:fs/promises'
7
7
  import { tmpdir } from 'node:os'
8
8
  import { basename, join } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
9
10
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
10
11
  import type { Socket } from 'node:net'
11
12
  import { createInterface } from 'node:readline'
@@ -13,6 +14,7 @@ import { buildLaunchArgs, buildLaunchScript, decodeRegistryOutput, resolveExecut
13
14
  import { buildIntake } from './design-intake.ts'
14
15
  import { auditSchematicGeometry } from './schematic-audit.ts'
15
16
  import { runNetlistErc } from './netlist-erc.ts'
17
+ import { compareDigest, extensionDownloadUrl, extensionZipName, sha256Of } from './extension-package.ts'
16
18
  import {
17
19
  ANNULUS_MIN_MM, BOARD_OUTLINE_WIDTH_MIL, MIN_TRACK_WIDTH_MIL, POUR_CLEARANCE_MIN_MIL,
18
20
  POUR_CLEARANCE_RECOMMENDED_MIL, SILKSCREEN_HEIGHT_MIN_MM, SILKSCREEN_WIDTH_MIN_MM,
@@ -21,7 +23,7 @@ import {
21
23
  } from './design-rules.ts'
22
24
  import { WORKFLOW_GUIDE, WORKFLOW_SUMMARY } from './workflow-guide.ts'
23
25
 
24
- const VERSION = '0.4.6'
26
+ const VERSION = '0.4.9'
25
27
  const WS_PORT = Number(process.env.DSH_EDA_MCP_WS_PORT ?? 39009)
26
28
  const WS_HOST = '127.0.0.1'
27
29
  const ALLOW_RAW_API = (process.env.DSH_EDA_MCP_ALLOW_RAW_API ?? '1') !== '0'
@@ -324,6 +326,7 @@ const TOOLS = [
324
326
  { name: 'eda_pcb_canvas_origin', description: 'Read (or align) the PCB canvas origin and convert points between the two coordinate spaces. JLCEDA displays CANVAS coordinates in its UI but every API takes DATA coordinates; the two differ by this offset, so a coordinate read off the screen will land somewhere else unless you convert or align. align:true calls setCanvasOrigin(0,0) so the display matches the API.', inputSchema: objectSchema({ align: { type: 'boolean' }, offsetX: { type: 'number' }, offsetY: { type: 'number' }, point: { type: 'object', additionalProperties: true } }) },
325
327
  { name: 'eda_capture', description: 'Render the active document to a PNG (dmt_EditorControl.getCurrentRenderedAreaImage) and write it to outPath or return a data URL. Use it to LOOK at the sheet/board instead of trusting coordinate math. The PNG is a scratch file: read it, then call eda_capture({cleanup:true}) to delete every capture path this session wrote.', inputSchema: objectSchema({ outPath: { type: 'string' }, inline: { type: 'boolean' }, cleanup: { type: 'boolean' } }) },
326
328
  { name: 'eda_workflow_guide', description: 'Return the schematic working standard: grid-aligned manual placement, signal flow, when net labels/ports are required (a wire net name is overridden by connectivity), net-label fallback behaviour, batch placement and the verification order. Read it before laying out a sheet.', inputSchema: objectSchema({}) },
329
+ { name: 'eda_extension_package', description: 'Report the extension zip this plugin ships (absolute path, bytes, sha256) and, with download:true, fetch the copy served at the download base URL and compare the two byte for byte. On a match the served file is saved to outDir (default: the temp directory) so it can be imported into JLCEDA Pro; on a mismatch nothing is written and the tool says to re-upload. base defaults to DSH_EDA_EXTENSION_BASE or the project download server.', inputSchema: objectSchema({ download: { type: 'boolean' }, outDir: { type: 'string' }, base: { type: 'string' } }, []) },
327
330
  { name: 'eda_list_api_methods', description: 'Ask the CONNECTED EXTENSION for the API methods it accepts right now (dsh.internal.listApiMethods) and report source/version/counts. Falls back to the bridge copy with stale:true only when the extension is an older zip. Use refresh:true to re-query.', inputSchema: objectSchema({ refresh: { type: 'boolean' } }) },
328
331
  { name: 'eda_delete_primitive', description: 'Delete a schematic primitive by kind and primitive id. Net ports, net flags and net labels are attribute primitives and need kind "netport"/"netflag"/"netlabel"; the text fallback labels are kind "text".', inputSchema: objectSchema({ kind: { type: 'string', enum: ['component', 'wire', 'rectangle', 'circle', 'arc', 'polygon', 'text', 'netport', 'netflag', 'netlabel'] }, primitiveId: { type: 'string' } }, ['kind', 'primitiveId']) },
329
332
  { name: 'eda_create_board', description: 'Create a board linking a schematic and a PCB (dmt_Board.createBoard).', inputSchema: objectSchema({ schematicUuid: { type: 'string' }, pcbUuid: { type: 'string' } }) },
@@ -790,6 +793,15 @@ function buildPolygonSource(points) {
790
793
  return source
791
794
  }
792
795
 
796
+
797
+ /**
798
+ * Directory that holds the packaged artifacts: the bridge runs from <package>/lib/bridge.mjs,
799
+ * so its grandparent is the package root both for a published install and for the repo.
800
+ */
801
+ function packageRoot(): string {
802
+ return fileURLToPath(new URL('../', import.meta.url))
803
+ }
804
+
793
805
  function requireFlatPoints(value, tool) {
794
806
  const points = flattenPoints(value)
795
807
  if (points.length < 6 || points.length % 2 !== 0 || !points.every(Number.isFinite)) {
@@ -2363,6 +2375,79 @@ async function executeTool(name, rawArgs, timeoutMs) {
2363
2375
  mirror: args.mirror === undefined ? undefined : Boolean(args.mirror),
2364
2376
  }], timeoutMs)
2365
2377
 
2378
+ case 'eda_extension_package': {
2379
+ // Two copies of the same artifact exist: the zip inside this npm package and the one
2380
+ // on the download server. Nobody could tell whether they match, so this tool reports
2381
+ // the local digest and, with download:true, fetches the served copy and compares the
2382
+ // two byte for byte. A mismatch is never written to disk.
2383
+ const manifestCandidates = [
2384
+ join(packageRoot(), 'jlc-extension', 'extension.json'),
2385
+ join(packageRoot(), 'dsh-eda-bridge-extension.zip'),
2386
+ ]
2387
+ let version = VERSION
2388
+ try {
2389
+ const manifest = JSON.parse(await readFile(manifestCandidates[0], 'utf8')) as { version?: string }
2390
+ if (typeof manifest.version === 'string' && manifest.version !== '') version = manifest.version
2391
+ } catch { /* keep the bridge VERSION */ }
2392
+ const localCandidates = [
2393
+ join(packageRoot(), extensionZipName(version)),
2394
+ join(packageRoot(), 'dsh-eda-bridge-extension.zip'),
2395
+ ]
2396
+ let localPath = ''
2397
+ for (const candidate of localCandidates) {
2398
+ if (existsSync(candidate)) { localPath = candidate; break }
2399
+ }
2400
+ if (localPath === '') {
2401
+ throw new Error('eda_extension_package: no extension zip next to the plugin (looked for ' + localCandidates.join(', ') + ')')
2402
+ }
2403
+ const localBytes = await readFile(localPath)
2404
+ const localSha256 = sha256Of(localBytes)
2405
+ const base = typeof args.base === 'string' && args.base !== ''
2406
+ ? args.base
2407
+ : (process.env.DSH_EDA_EXTENSION_BASE ?? 'http://159.75.130.207/eda/')
2408
+ const url = extensionDownloadUrl(base, version)
2409
+ const report = {
2410
+ version,
2411
+ local: { path: localPath, bytes: localBytes.byteLength, sha256: localSha256 },
2412
+ url,
2413
+ }
2414
+ if (args.download !== true) return report
2415
+ const response = await fetch(url, { signal: AbortSignal.timeout(30000) })
2416
+ if (!response.ok) {
2417
+ // 429 is the download server's rate limit (5 req/min per IP), not a broken artifact:
2418
+ // say so, otherwise the caller retries in a loop and never succeeds.
2419
+ const throttled = response.status === 429
2420
+ return {
2421
+ ...report,
2422
+ verified: 'unverified',
2423
+ httpStatus: response.status,
2424
+ error: throttled
2425
+ ? 'the download server throttled this IP (HTTP 429, 5 downloads/min per IP — burst 5). Wait about a minute and retry; nothing is wrong with the zip or the upload.'
2426
+ : 'download failed: HTTP ' + response.status + ' from ' + url,
2427
+ }
2428
+ }
2429
+ const remoteBytes = new Uint8Array(await response.arrayBuffer())
2430
+ const remoteSha256 = sha256Of(remoteBytes)
2431
+ const verdict = compareDigest(localSha256, remoteSha256)
2432
+ const result: Record<string, unknown> = {
2433
+ ...report,
2434
+ remote: { url, bytes: remoteBytes.byteLength, sha256: remoteSha256 },
2435
+ verified: verdict,
2436
+ }
2437
+ if (verdict === 'match') {
2438
+ const outDir = typeof args.outDir === 'string' && args.outDir !== '' ? args.outDir : tmpdir()
2439
+ await mkdir(outDir, { recursive: true })
2440
+ const savedPath = join(outDir, extensionZipName(version))
2441
+ await writeFile(savedPath, remoteBytes)
2442
+ result.savedPath = savedPath
2443
+ result.next = 'In JLCEDA Pro: 扩展 → 导入扩展 → select this zip → restart the application (not a reload).'
2444
+ } else {
2445
+ result.next = 'The served copy differs from the one this plugin ships. Do not import it: re-upload ' +
2446
+ extensionZipName(version) + ' from the plugin directory to the download server.'
2447
+ }
2448
+ return result
2449
+ }
2450
+
2366
2451
  case 'eda_list_api_methods': {
2367
2452
  // Ask the running extension first: its answer is the real capability boundary.
2368
2453
  const live = await extensionApiMethods(timeoutMs, args.refresh === true)
@@ -3136,6 +3221,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
3136
3221
  case 'eda_pcb_delete_via':
3137
3222
  return await callExtension('pcb_PrimitiveVia.delete', [String(args.primitiveId)], timeoutMs)
3138
3223
 
3224
+
3139
3225
  case 'eda_pcb_create_pour': {
3140
3226
  const layer = resolveRegionLayer(args.layer, 'eda_pcb_create_pour')
3141
3227
  const points = requireFlatPoints(args.points, 'eda_pcb_create_pour')
@@ -153,7 +153,7 @@ export const SKILL_PLAYBOOK_LINES: readonly string[] = [
153
153
  "| eda_launch 带 project 没打开工程 | 命令行路径被应用忽略 | 用 eda_open_project({ name }) 打开 |",
154
154
  "| 关应用后改动丢失 | eda_close 走了强制路径 | 关前先保存;看返回的 forced |",
155
155
  "| run_code 读大 JSON 被截断 | 宿主 read 有单行上限 | 用 node:fs 读盘或分片读 |",
156
- "| 覆铜创建失败(无法创建覆铜边框图元) | 应用侧限制:polygonStage=ok(轮廓被接受)但 pours 被拒,与网络/填充方式/坐标无关 | 工具会重试一次并给出该诊断;手工在 PCB 页框选覆铜(GND,顶层+底层),design_audit 记为 warn |",
156
+ "| 覆铜/填充一律被拒(无法创建覆铜边框图元 / 无法创建填充图元) | 应用侧限制:静态+实例入口、单/复杂多边形、原始源码数组、6 种填充枚举、全参数组合全部被拒;同一多边形管线在本构建上连丝印折线都写不出来 | 不要在 API 层反复试:覆铜请在界面手工铺(GND、顶层+底层);design_audit 持续记 copper-pour warn |",
157
157
  "| pcb_draw_mounting_hole 报“无法创建焊盘图元” | pad 形状必填,且槽孔只能用 ELLIPSE | 已内建:圆孔 ELLIPSE(d,d),槽孔 ELLIPSE(长,宽)+hole[SLOT,宽,长];OBLONG/RECTANGLE 焊盘会被拒 |",
158
158
  "| pcb_PrimitiveString.getAll / pcb_PrimitivePad.getAll 返回 0 | 这两个 API 按层查询 | 传 layer:丝印 3/4,多层焊盘 12;不传参恒为 0 |",
159
159
  "| 按坐标算的越界没被发现 | 图框/原点假设与真实页面不一致 | 用 eda_get_page_info 的真实值 + eda_capture 导 PNG 看实际版面 |",
@@ -299,6 +299,7 @@ export const SKILL_PLAYBOOK_LINES: readonly string[] = [
299
299
  "- **插件代码改动对当前会话完全不生效**:宿主在启动时把 `lib/index.js` 载入内存,之后改文件、重新 build 都不会热更新。",
300
300
  "- 版本纪律:扩展线协议**只递增最小位**(`0.4.1 → 0.4.2`),每次改动必须同时改 `jlc-extension/extension.json` 的 `version` 与桥接的 `VERSION`;不一致时 `eda_status.versionMismatch` 为 true。",
301
301
  "- 新会话开工顺序:`eda_status`(连没连、版本对不对)→ 本技能(怎么干)→ 再动手;不要先假设环境就绪。",
302
+ "| 需要扩展 zip(换机器/给别人) | 不清楚该给哪一份 | `eda_extension_package` 报路径+sha256;`download:true` 从服务器取回并逐字节比对(`verified`),一致才落盘 |",
302
303
  "- 扩展 zip 位置:`node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<版本>.zip`,或设置卡片下载(文件名自带版本号)。",
303
304
  "- 排查顺序:`eda_status` → `/api/dsh-eda-mcp/diagnostics`(`clientStartupError` / `skills` / `bridgeHealth`)→ `eda_list_api_methods`(白名单)→ 应用侧 DRC 面板。",
304
305
  ]
@@ -0,0 +1,27 @@
1
+ import { strict as assert } from 'node:assert'
2
+ import { compareDigest, extensionDownloadUrl, extensionZipName, sha256Of } from '../src/bridge/extension-package.ts'
3
+
4
+ const bytes = new TextEncoder().encode('PK\x03\x04fake-zip-body')
5
+ assert.equal(sha256Of(bytes), sha256Of(bytes), 'hash is stable')
6
+ assert.equal(sha256Of(bytes).length, 64, 'sha256 hex length')
7
+ assert.notEqual(sha256Of(bytes), sha256Of(new TextEncoder().encode('other')), 'different bytes differ')
8
+
9
+ assert.equal(extensionZipName('0.4.9'), 'dsh-eda-bridge-extension-0.4.9.zip')
10
+ assert.equal(
11
+ extensionDownloadUrl('http://159.75.130.207/eda', '0.4.9'),
12
+ 'http://159.75.130.207/eda/dsh-eda-bridge-extension-0.4.9.zip',
13
+ 'base without a trailing slash gets one',
14
+ )
15
+ assert.equal(
16
+ extensionDownloadUrl('https://example.test/dl/', '1.2.3'),
17
+ 'https://example.test/dl/dsh-eda-bridge-extension-1.2.3.zip',
18
+ 'trailing slash is not doubled',
19
+ )
20
+
21
+ assert.equal(compareDigest('ABC', 'abc'), 'match', 'case-insensitive')
22
+ assert.equal(compareDigest('aa', 'bb'), 'mismatch')
23
+ assert.equal(compareDigest(null, 'bb'), 'unverified', 'missing expected is not a pass')
24
+ assert.equal(compareDigest('aa', undefined), 'unverified')
25
+ assert.equal(compareDigest('', ''), 'unverified')
26
+
27
+ console.log('ALL PASS extension-package')
Binary file