dsh-eda-mcp 1.0.2 → 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 +21 -0
- package/CONTRIBUTING.md +33 -0
- package/README.md +18 -0
- package/README.zh.md +11 -0
- package/docs/schematic-workflow.md +1 -0
- package/docs/tools.md +6 -0
- package/dsh-eda-bridge-extension-0.4.9.zip +0 -0
- package/lib/bridge.mjs +93 -3
- package/lib/index.js +1 -0
- package/package.json +2 -2
- package/src/bridge/extension-package.ts +35 -0
- package/src/bridge/index.ts +85 -0
- package/src/skills-content.ts +1 -0
- package/test/extension-package.test.ts +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
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
|
+
|
|
3
24
|
## 1.0.2 — 2026-10-20
|
|
4
25
|
|
|
5
26
|
### 覆铜调查结案(扩展 0.4.7 / 0.4.8 真机)
|
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
|
|
@@ -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
|
+
|
|
Binary file
|
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) {
|
|
@@ -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 =
|
|
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
|
@@ -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
|
|
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
|
+
}
|
package/src/bridge/index.ts
CHANGED
|
@@ -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,
|
|
@@ -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)
|
package/src/skills-content.ts
CHANGED
|
@@ -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')
|