dsh-eda-mcp 0.6.2 → 0.7.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/lib/bridge.mjs CHANGED
@@ -1,8 +1,181 @@
1
1
  // src/bridge/index.ts
2
2
  import { createHash } from "node:crypto";
3
+ import { execFileSync, spawn } from "node:child_process";
4
+ import { statSync } from "node:fs";
5
+ import { readFile } from "node:fs/promises";
6
+ import { writeFile } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { basename, join } from "node:path";
3
9
  import { createServer } from "node:http";
4
10
  import { createInterface } from "node:readline";
5
11
 
12
+ // src/bridge/eda-launch.ts
13
+ var EXECUTABLE_ENV_KEYS = ["DSH_EDA_EXECUTABLE", "JLCEDA_EXECUTABLE"];
14
+ var EXECUTABLE_NAMES = ["lceda-pro.exe", "lceda-pro", "LCEDA Pro"];
15
+ var PROJECT_EXTENSIONS = [".eprj2", ".eprj"];
16
+ var CN_PRO_EDITION = "\u5609\u7ACB\u521BEDA\u4E13\u4E1A\u7248";
17
+ var CLASSES = "Software\\Classes";
18
+ var INSTALL_SUFFIXES = [
19
+ "lceda-pro",
20
+ "Program Files\\lceda-pro",
21
+ "Program Files (x86)\\lceda-pro",
22
+ "Program Files\\" + CN_PRO_EDITION,
23
+ "Program Files (x86)\\" + CN_PRO_EDITION
24
+ ];
25
+ function trimSeparator(value) {
26
+ let end = value.length;
27
+ while (end > 0) {
28
+ const ch = value[end - 1];
29
+ if (ch !== "\\" && ch !== "/") break;
30
+ end -= 1;
31
+ }
32
+ return value.slice(0, end);
33
+ }
34
+ function trimLeadingSeparators(value) {
35
+ let start = 0;
36
+ while (start < value.length && (value[start] === "\\" || value[start] === "/")) start += 1;
37
+ return value.slice(start);
38
+ }
39
+ function joinPath(platform, ...parts) {
40
+ const separator = platform === "win32" ? "\\" : "/";
41
+ return parts.filter((part) => part !== "").map((part, index) => index === 0 ? part : trimLeadingSeparators(part)).join(separator);
42
+ }
43
+ function splitSegments(value) {
44
+ return value.split("\\").join("/").split("/").filter((part) => part !== "");
45
+ }
46
+ function parseExecutableFromCommand(command) {
47
+ const text = (command ?? "").trim();
48
+ if (text === "") return void 0;
49
+ if (text.startsWith('"')) {
50
+ const end2 = text.indexOf('"', 1);
51
+ if (end2 === -1) return void 0;
52
+ const quoted = text.slice(1, end2).trim();
53
+ return quoted === "" ? void 0 : quoted;
54
+ }
55
+ const space = text.indexOf(" ");
56
+ const tab = text.indexOf(String.fromCharCode(9));
57
+ const stops = [space, tab].filter((index) => index > 0);
58
+ const end = stops.length === 0 ? text.length : Math.min(...stops);
59
+ const first = text.slice(0, end).trim();
60
+ return first === "" ? void 0 : first;
61
+ }
62
+ function decodeRegistryOutput(raw) {
63
+ const utf8 = new TextDecoder("utf-8").decode(raw);
64
+ if (!utf8.includes(String.fromCharCode(65533))) return utf8;
65
+ try {
66
+ return new TextDecoder("gbk").decode(raw);
67
+ } catch {
68
+ return utf8;
69
+ }
70
+ }
71
+ function parseRegStringValue(output) {
72
+ for (const raw of (output ?? "").split(String.fromCharCode(10))) {
73
+ const line = raw.trim();
74
+ const index = line.toUpperCase().indexOf("REG_SZ");
75
+ if (index === -1) continue;
76
+ const value = line.slice(index + "REG_SZ".length).trim();
77
+ if (value === "" || value.startsWith("(")) continue;
78
+ return value;
79
+ }
80
+ return void 0;
81
+ }
82
+ function parseRegValueNames(output) {
83
+ const names = [];
84
+ for (const raw of (output ?? "").split(String.fromCharCode(10))) {
85
+ const line = raw.trim();
86
+ const marker = line.indexOf("REG_");
87
+ if (marker <= 0) continue;
88
+ const name = line.slice(0, marker).trim();
89
+ if (name !== "" && !name.startsWith("HKEY")) names.push(name);
90
+ }
91
+ return names;
92
+ }
93
+ function firstExisting(probe, candidates) {
94
+ for (const candidate of candidates) {
95
+ if (candidate !== "" && probe.exists(candidate)) return candidate;
96
+ }
97
+ return void 0;
98
+ }
99
+ function envCandidates(probe) {
100
+ const env = probe.env;
101
+ const roots = [
102
+ env.ProgramFiles,
103
+ env["ProgramFiles(x86)"],
104
+ env.LOCALAPPDATA === void 0 ? void 0 : joinPath(probe.platform, env.LOCALAPPDATA, "Programs"),
105
+ env.APPDATA === void 0 ? void 0 : joinPath(probe.platform, env.APPDATA, "Programs"),
106
+ env.SystemDrive,
107
+ env.USERPROFILE,
108
+ env.HOME
109
+ ].filter((root) => typeof root === "string" && root !== "");
110
+ const candidates = [];
111
+ for (const root of roots) {
112
+ for (const suffix of INSTALL_SUFFIXES) {
113
+ for (const name of EXECUTABLE_NAMES) {
114
+ candidates.push(joinPath(probe.platform, trimSeparator(root), ...splitSegments(suffix), name));
115
+ }
116
+ }
117
+ }
118
+ return candidates;
119
+ }
120
+ function pathCandidates(probe) {
121
+ const raw = probe.env.PATH ?? probe.env.Path ?? "";
122
+ const separator = probe.platform === "win32" ? ";" : ":";
123
+ return raw.split(separator).filter((dir) => dir.trim() !== "").flatMap((dir) => EXECUTABLE_NAMES.map((name) => joinPath(probe.platform, trimSeparator(dir), name)));
124
+ }
125
+ function registryCandidates(probe) {
126
+ if (probe.platform !== "win32") return [];
127
+ const found = [];
128
+ for (const hive of ["HKCU", "HKLM"]) {
129
+ for (const extension of PROJECT_EXTENSIONS) {
130
+ const base = hive + "\\" + CLASSES + "\\" + extension;
131
+ const defaults = [parseRegStringValue(probe.registryQuery(base, "")), parseRegStringValue(probe.registryQuery(base))];
132
+ const progids = [
133
+ ...defaults.filter((value) => value !== void 0),
134
+ ...parseRegValueNames(probe.registryQuery(base + "\\OpenWithProgids"))
135
+ ];
136
+ for (const progid of progids) {
137
+ if (progid.startsWith("Applications\\")) continue;
138
+ const command = parseRegStringValue(
139
+ probe.registryQuery(hive + "\\" + CLASSES + "\\" + progid + "\\shell\\open\\command")
140
+ );
141
+ const executable = parseExecutableFromCommand(command);
142
+ if (executable !== void 0) found.push(executable);
143
+ }
144
+ }
145
+ }
146
+ return found;
147
+ }
148
+ function resolveExecutable(probe, options = {}) {
149
+ const override = options.executable?.trim();
150
+ if (override !== void 0 && override !== "" && probe.exists(override)) {
151
+ return { executable: override, source: "config" };
152
+ }
153
+ for (const key of EXECUTABLE_ENV_KEYS) {
154
+ const value = probe.env[key]?.trim();
155
+ if (value !== void 0 && value !== "" && probe.exists(value)) return { executable: value, source: "env" };
156
+ }
157
+ const fromRegistry = firstExisting(probe, registryCandidates(probe));
158
+ if (fromRegistry !== void 0) return { executable: fromRegistry, source: "registry" };
159
+ const fromInstallDir = firstExisting(probe, envCandidates(probe));
160
+ if (fromInstallDir !== void 0) return { executable: fromInstallDir, source: "install-dir" };
161
+ const fromPath = firstExisting(probe, pathCandidates(probe));
162
+ if (fromPath !== void 0) return { executable: fromPath, source: "path" };
163
+ return void 0;
164
+ }
165
+ function buildLaunchArgs(platform, executable, project) {
166
+ const target = project?.trim();
167
+ const hasProject = target !== void 0 && target !== "";
168
+ if (platform === "darwin") {
169
+ return hasProject ? { command: "open", args: ["-a", executable, target] } : { command: "open", args: ["-a", executable] };
170
+ }
171
+ return hasProject ? { command: executable, args: [target] } : { command: executable, args: [] };
172
+ }
173
+ function buildLaunchScript(executable, project) {
174
+ const target = project?.trim();
175
+ const args = target === void 0 || target === "" ? "" : ' "' + target + '"';
176
+ return ["@echo off", 'start "" "' + executable + '"' + args, ""].join(String.fromCharCode(13, 10));
177
+ }
178
+
6
179
  // src/bridge/design-rules.ts
7
180
  var MIL_PER_MM = 39.37007874015748;
8
181
  function milToMm(mil) {
@@ -121,7 +294,7 @@ var SILKSCREEN_HEIGHT_MIN_MM = 1;
121
294
  var SILKSCREEN_WIDTH_MIN_MM = 0.15;
122
295
 
123
296
  // src/bridge/index.ts
124
- var VERSION = "0.3.5";
297
+ var VERSION = "0.4.0";
125
298
  var WS_PORT = Number(process.env.DSH_EDA_MCP_WS_PORT ?? 39009);
126
299
  var WS_HOST = "127.0.0.1";
127
300
  var ALLOW_RAW_API = (process.env.DSH_EDA_MCP_ALLOW_RAW_API ?? "1") !== "0";
@@ -330,6 +503,21 @@ function objectSchema(properties, required = []) {
330
503
  }
331
504
  var TOOLS = [
332
505
  { name: "eda_status", description: "Get the dsh-eda-mcp bridge status and how many JLCEDA Pro bridge extensions are connected.", inputSchema: objectSchema({}) },
506
+ { name: "eda_list_projects", description: "List project UUIDs in the workspace (dmt_Project.getAllProjectsUuid). resolveNames=true (default) also reads getProjectInfo per project so callers can match by friendly name.", inputSchema: objectSchema({ teamUuid: { type: "string" }, folderUuid: { type: "string" }, workspaceUuid: { type: "string" }, resolveNames: { type: "boolean" }, limit: { type: "integer" } }) },
507
+ { name: "eda_get_project", description: "Read the current project (dmt_Project.getCurrentProjectInfo): uuid, friendly name, folder/team and other brief fields.", inputSchema: objectSchema({}) },
508
+ { name: "eda_open_project", description: "Open a project by uuid, or by friendly name when only a name is known (the name is resolved through getAllProjectsUuid + getProjectInfo, exact match first, then case-insensitive substring).", inputSchema: objectSchema({ uuid: { type: "string" }, name: { type: "string" } }) },
509
+ { name: "eda_create_project", description: "Create a project (dmt_Project.createProject) and return its uuid; open=true (default false) also opens it. friendlyName is required; name and folder/team default to the app defaults.", inputSchema: objectSchema({ friendlyName: { type: "string" }, name: { type: "string" }, teamUuid: { type: "string" }, folderUuid: { type: "string" }, description: { type: "string" }, open: { type: "boolean" } }, ["friendlyName"]) },
510
+ { name: "eda_get_workspace", description: "Read the current workspace and team (dmt_Workspace.getCurrentWorkspaceInfo / dmt_Team.getCurrentTeamInfo). Needed before creating a project, which requires a team (and usually a folder) uuid.", inputSchema: objectSchema({}) },
511
+ { name: "eda_list_teams", description: "List teams (dmt_Team.getAllTeamsInfo), optionally including the teams this account is involved in.", inputSchema: objectSchema({ involved: { type: "boolean" } }) },
512
+ { name: "eda_list_folders", description: "List folder uuids of one team (dmt_Folder.getAllFoldersUuid), optionally resolved through getFolderInfo.", inputSchema: objectSchema({ teamUuid: { type: "string" }, resolveNames: { type: "boolean" } }) },
513
+ { name: "eda_delete_project", description: "Delete a project (dmt_Project.deleteProject) by uuid or friendly name.", inputSchema: objectSchema({ uuid: { type: "string" }, name: { type: "string" } }) },
514
+ { name: "eda_modify_project", description: "Rename a project or change its description (dmt_Project.modifyProjectFriendlyName / modifyProjectDescription), addressed by uuid or current friendly name.", inputSchema: objectSchema({ uuid: { type: "string" }, name: { type: "string" }, friendlyName: { type: "string" }, description: { type: "string" } }) },
515
+ { name: "eda_copy_project", description: "Copy a project (dmt_Project.copyProject), optionally into another team/folder and with a new friendly name; returns the new uuid.", inputSchema: objectSchema({ sourceUuid: { type: "string" }, sourceName: { type: "string" }, targetTeamUuid: { type: "string" }, targetFolderUuid: { type: "string" }, newFriendlyName: { type: "string" }, newName: { type: "string" } }) },
516
+ { name: "eda_close_document", description: "Close one editor tab (dmt_EditorControl.closeDocument).", inputSchema: objectSchema({ tabId: { type: "string" } }, ["tabId"]) },
517
+ { name: "eda_export_project", description: "Export a project file (sys_FileManager.getProjectFile / getProjectFileByProjectUuid). With outPath the bridge writes the bytes to disk and returns the path; without it the base64 payload is returned only when inline=true.", inputSchema: objectSchema({ uuid: { type: "string" }, fileName: { type: "string" }, fileType: { type: "string", enum: ["epro", "epro2"] }, outPath: { type: "string" }, inline: { type: "boolean" } }) },
518
+ { name: "eda_import_project", description: "Import a local project file (.eprj2/.epro/...) into a team (sys_FileManager.importProjectByProjectFile with a saveTo target). teamUuid is required in local mode: without a target team the app imports nothing. Returns whether the import actually landed.", inputSchema: objectSchema({ path: { type: "string" }, teamUuid: { type: "string" }, folderUuid: { type: "string" }, friendlyName: { type: "string" }, fileType: { type: "string" }, open: { type: "boolean" } }, ["path"]) },
519
+ { name: "eda_close", description: "Close JLCEDA Pro gracefully (taskkill /T on Windows, SIGTERM elsewhere), then force-kill if it is still running after waitMs (default 8000). Returns the remaining process count.", inputSchema: objectSchema({ force: { type: "boolean" }, waitMs: { type: "integer" } }) },
520
+ { name: "eda_launch", description: "Launch JLCEDA Pro (EasyEDA Pro), optionally opening a .eprj2/.eprj project, then wait until the bridge extension connects. The executable is resolved from DSH_EDA_EXECUTABLE/JLCEDA_EXECUTABLE, the registry handler the installer registered, conventional install directories, then PATH - so it works on any machine without configuration.", inputSchema: objectSchema({ project: { type: "string" }, executable: { type: "string" }, waitForExtension: { type: "boolean" }, timeoutMs: { type: "integer" } }) },
333
521
  { name: "eda_get_page_info", description: "Return the current schematic page, the A4 frame range, the title-block keep-out range, and the recommended safe placement areas. All coordinates are MCP units (0.01 inch).", inputSchema: objectSchema({ paper: { type: "string" } }) },
334
522
  { name: "eda_get_schematic_pages", description: "List the schematic pages of the current schematic, or fetch one page by uuid.", inputSchema: objectSchema({ pageUuid: { type: "string" } }) },
335
523
  { name: "eda_search_components", description: "Search JLCEDA library devices by keyword. Returns candidate components with uuid/libraryUuid needed by eda_place_component.", inputSchema: objectSchema({ keyword: { type: "string" }, limit: { type: "integer" } }, ["keyword"]) },
@@ -502,6 +690,7 @@ var WsConnection = class {
502
690
  this.socket.destroy();
503
691
  }
504
692
  handleData(chunk) {
693
+ this.lastSeenAt = Date.now();
505
694
  this.buffer = Buffer.concat([this.buffer, chunk]);
506
695
  for (; ; ) {
507
696
  const frame = parseFrame(this.buffer);
@@ -566,15 +755,19 @@ async function pcbComponentIds(timeoutMs) {
566
755
  return Array.isArray(raw) ? raw.map(String) : [];
567
756
  }
568
757
  var nextCallId = 1;
569
- function firstReadyClient() {
570
- for (const client of clients) if (client.ready) return client;
571
- return void 0;
758
+ var CLIENT_STALE_MS = 6e4;
759
+ function pickClients() {
760
+ const now = Date.now();
761
+ const ready = [...clients].filter((client) => client.ready);
762
+ const fresh = ready.filter((client) => now - (client.lastSeenAt ?? 0) < CLIENT_STALE_MS);
763
+ const pool = fresh.length > 0 ? fresh : ready;
764
+ return pool.sort((left, right) => (right.lastSeenAt ?? 0) - (left.lastSeenAt ?? 0));
572
765
  }
573
- function callExtension(method, args, timeoutMs) {
574
- const client = firstReadyClient();
575
- if (client === void 0) {
576
- return Promise.reject(new Error('JLCEDA bridge extension is not connected. Open JLCEDA Pro, import dsh-eda-bridge-extension.zip, enable "Allow interactive with external", and keep a schematic or PCB page open.'));
577
- }
766
+ function isIdempotentRead(method) {
767
+ const fn = method.slice(method.lastIndexOf(".") + 1).toLowerCase();
768
+ return fn.startsWith("get") || fn.startsWith("is") || fn.startsWith("has") || fn.startsWith("list") || fn.startsWith("search");
769
+ }
770
+ function sendToClient(client, method, args, timeoutMs) {
578
771
  const id = nextCallId++;
579
772
  return new Promise((resolve, reject) => {
580
773
  const timer = setTimeout(() => {
@@ -585,6 +778,25 @@ function callExtension(method, args, timeoutMs) {
585
778
  client.sendText(JSON.stringify({ id, method, args }));
586
779
  });
587
780
  }
781
+ async function callExtension(method, args, timeoutMs) {
782
+ const pool = pickClients();
783
+ if (pool.length === 0) {
784
+ throw new Error('JLCEDA bridge extension is not connected. Open JLCEDA Pro, import dsh-eda-bridge-extension.zip, enable "Allow interactive with external", and keep a schematic or PCB page open.');
785
+ }
786
+ const deadline = Date.now() + timeoutMs;
787
+ let lastError;
788
+ for (const client of pool) {
789
+ const remaining = deadline - Date.now();
790
+ if (remaining <= 100) break;
791
+ try {
792
+ return await sendToClient(client, method, args, remaining);
793
+ } catch (error) {
794
+ lastError = error;
795
+ if (!isIdempotentRead(method) || !/timed out/.test(String(error?.message))) throw error;
796
+ }
797
+ }
798
+ throw lastError ?? new Error("JLCEDA bridge call timed out after " + timeoutMs + "ms: " + method);
799
+ }
588
800
  function asRecord(value) {
589
801
  if (value === null || typeof value !== "object") return {};
590
802
  return value;
@@ -872,6 +1084,78 @@ async function auditPcb(args, timeoutMs) {
872
1084
  }
873
1085
  };
874
1086
  }
1087
+ function launchProbe() {
1088
+ const cache = /* @__PURE__ */ new Map();
1089
+ return {
1090
+ platform: process.platform,
1091
+ env: process.env,
1092
+ exists(path) {
1093
+ try {
1094
+ return statSync(path).isFile();
1095
+ } catch {
1096
+ return false;
1097
+ }
1098
+ },
1099
+ registryQuery(key, valueName) {
1100
+ if (process.platform !== "win32") return void 0;
1101
+ const cacheKey = key + "|" + (valueName === void 0 ? "LIST" : valueName === "" ? "DEFAULT" : "V:" + valueName);
1102
+ if (cache.has(cacheKey)) return cache.get(cacheKey);
1103
+ let output;
1104
+ try {
1105
+ const extra = valueName === void 0 ? ["/s"] : valueName === "" ? ["/ve"] : ["/v", valueName];
1106
+ const raw = execFileSync("reg", ["query", key, ...extra], {
1107
+ timeout: 5e3,
1108
+ windowsHide: true,
1109
+ stdio: ["ignore", "pipe", "ignore"]
1110
+ });
1111
+ output = decodeRegistryOutput(raw);
1112
+ } catch {
1113
+ output = void 0;
1114
+ }
1115
+ cache.set(cacheKey, output);
1116
+ return output;
1117
+ }
1118
+ };
1119
+ }
1120
+ function processCountByName(imageName) {
1121
+ if (process.platform !== "win32") {
1122
+ try {
1123
+ const out = execFileSync("pgrep", ["-f", imageName], { encoding: "utf8", timeout: 5e3, stdio: ["ignore", "pipe", "ignore"] });
1124
+ return out.split(/\r?\n/).filter((line) => line.trim() !== "").length;
1125
+ } catch {
1126
+ return 0;
1127
+ }
1128
+ }
1129
+ try {
1130
+ const out = execFileSync("tasklist", ["/FI", "IMAGENAME eq " + imageName, "/FO", "CSV", "/NH"], {
1131
+ encoding: "utf8",
1132
+ timeout: 5e3,
1133
+ stdio: ["ignore", "pipe", "ignore"]
1134
+ });
1135
+ return out.split(/\r?\n/).filter((line) => line.trim().startsWith('"')).length;
1136
+ } catch {
1137
+ return 0;
1138
+ }
1139
+ }
1140
+ function terminateByName(imageName, force) {
1141
+ if (process.platform === "win32") {
1142
+ execFileSync("taskkill", ["/IM", imageName, "/T"].concat(force ? ["/F"] : []), {
1143
+ timeout: 1e4,
1144
+ stdio: ["ignore", "ignore", "ignore"]
1145
+ });
1146
+ return;
1147
+ }
1148
+ execFileSync("pkill", [force ? "-9" : "-TERM", "-f", imageName], { timeout: 1e4, stdio: ["ignore", "ignore", "ignore"] });
1149
+ }
1150
+ async function waitForExtensionConnection(timeoutMs) {
1151
+ const started = Date.now();
1152
+ const readyCount = () => pickClients().length;
1153
+ while (Date.now() - started < timeoutMs) {
1154
+ if (readyCount() > 0) return { connected: true, clients: readyCount(), waitedMs: Date.now() - started };
1155
+ await new Promise((resolve) => setTimeout(resolve, 500));
1156
+ }
1157
+ return { connected: false, clients: readyCount(), waitedMs: Date.now() - started };
1158
+ }
875
1159
  async function clearRoutingBatched(args, type, timeoutMs) {
876
1160
  if (type === "connection") throw new Error("eda_pcb_clear_routing: type=connection needs the EDA user interface; use type=all/net with mode=batched");
877
1161
  const net = args.net === void 0 ? void 0 : String(args.net);
@@ -971,13 +1255,51 @@ function pickDefined(source, keys) {
971
1255
  }
972
1256
  return out;
973
1257
  }
1258
+ async function resolveProjectUuid(args, timeoutMs) {
1259
+ const direct = args.uuid === void 0 ? "" : String(args.uuid).trim();
1260
+ if (direct !== "") return { uuid: direct, matched: null };
1261
+ const name = args.name === void 0 ? "" : String(args.name).trim();
1262
+ if (name === "") throw new Error("provide uuid or name");
1263
+ const unfiltered = await callExtension("dmt_Project.getAllProjectsUuid", [], toolTimeout(args, timeoutMs));
1264
+ const candidates = Array.isArray(unfiltered) ? unfiltered.map(String) : [];
1265
+ if (candidates.length === 0) {
1266
+ const teams = await callExtension("dmt_Team.getAllTeamsInfo", [], toolTimeout(args, timeoutMs)).catch(() => []);
1267
+ const seen = /* @__PURE__ */ new Set();
1268
+ for (const team of Array.isArray(teams) ? teams : []) {
1269
+ const teamUuid = String(pickValue(asRecord(team), "uuid", "Uuid") ?? "");
1270
+ if (teamUuid === "") continue;
1271
+ const owned = await callExtension("dmt_Project.getAllProjectsUuid", [teamUuid], toolTimeout(args, timeoutMs)).catch(() => []);
1272
+ for (const uuid of Array.isArray(owned) ? owned.map(String) : []) {
1273
+ if (seen.has(uuid)) continue;
1274
+ seen.add(uuid);
1275
+ candidates.push(uuid);
1276
+ }
1277
+ }
1278
+ }
1279
+ const resolved = [];
1280
+ for (const candidate of candidates) {
1281
+ try {
1282
+ const info = asRecord(await callExtension("dmt_Project.getProjectInfo", [candidate], toolTimeout(args, timeoutMs)));
1283
+ resolved.push({ uuid: candidate, friendly: String(pickValue(info, "FriendlyName", "friendlyName", "Name", "name") ?? "") });
1284
+ } catch {
1285
+ resolved.push({ uuid: candidate, friendly: "" });
1286
+ }
1287
+ }
1288
+ const matched = resolved.find((item) => item.friendly === name) ?? resolved.find((item) => item.friendly.toLowerCase().includes(name.toLowerCase())) ?? null;
1289
+ if (matched === null) {
1290
+ throw new Error('no project matches "' + name + '"; known names: ' + resolved.map((item) => item.friendly || item.uuid).slice(0, 20).join(", "));
1291
+ }
1292
+ return { uuid: matched.uuid, matched };
1293
+ }
974
1294
  async function executeTool(name, rawArgs, timeoutMs) {
975
1295
  const args = asRecord(rawArgs);
976
1296
  const tool = PCB_TOOL_ALIASES[name] ?? name;
977
1297
  switch (tool) {
978
1298
  case "eda_status": {
979
- const ready = [...clients].filter((client) => client.ready);
980
- const extensionVersion = ready.map((client) => client.extensionVersion).find((value) => typeof value === "string");
1299
+ const ready = pickClients();
1300
+ const versions = ready.map((client) => typeof client.extensionVersion === "string" ? client.extensionVersion : null);
1301
+ const extensionVersion = versions.find((value) => typeof value === "string") ?? null;
1302
+ const stale = versions.filter((value) => value !== VERSION);
981
1303
  return {
982
1304
  ok: true,
983
1305
  bridgeVersion: VERSION,
@@ -985,10 +1307,352 @@ async function executeTool(name, rawArgs, timeoutMs) {
985
1307
  allowRawApi: ALLOW_RAW_API,
986
1308
  connectedClients: ready.length,
987
1309
  extensionVersion: extensionVersion ?? null,
988
- versionMismatch: typeof extensionVersion === "string" && extensionVersion !== VERSION,
1310
+ extensionVersions: versions,
1311
+ versionMismatch: stale.length > 0,
989
1312
  mcpUnits: "0.01 inch",
990
1313
  defaultPaper: DEFAULT_PAPER,
991
- hint: ready.length === 0 ? "No JLCEDA extension is connected. Open JLCEDA Pro and click DSH MCP -> connect, or re-import dsh-eda-bridge-extension.zip." : typeof extensionVersion === "string" && extensionVersion !== VERSION ? "The connected EDA extension version differs from the bridge version. Re-import dsh-eda-bridge-extension.zip and restart JLCEDA Pro." : "ok"
1314
+ hint: ready.length === 0 ? "No JLCEDA extension is connected. Open JLCEDA Pro and click DSH MCP -> connect, or re-import dsh-eda-bridge-extension.zip." : stale.length > 0 ? "Connected EDA extension " + stale.join(", ") + " does not match bridge " + VERSION + ". In JLCEDA Pro remove the OLD dsh-eda-bridge extension first, import dsh-eda-bridge-extension.zip again, then restart the app; a simple reload keeps the old copy." : "ok"
1315
+ };
1316
+ }
1317
+ case "eda_close_document":
1318
+ return await callExtension("dmt_EditorControl.closeDocument", [String(args.tabId ?? "")], timeoutMs);
1319
+ case "eda_export_project": {
1320
+ const result2 = await callExtension("dsh.internal.exportProjectFile", [
1321
+ args.uuid === void 0 ? null : String(args.uuid),
1322
+ args.fileName === void 0 ? void 0 : String(args.fileName),
1323
+ void 0,
1324
+ args.fileType === void 0 ? void 0 : String(args.fileType)
1325
+ ], toolTimeout(args, timeoutMs));
1326
+ const base64 = typeof result2?.base64 === "string" ? result2.base64 : "";
1327
+ const outPath = args.outPath === void 0 ? "" : String(args.outPath).trim();
1328
+ if (outPath !== "") {
1329
+ await writeFile(outPath, Buffer.from(base64, "base64"));
1330
+ return { ok: true, written: outPath, name: result2?.name ?? null, size: base64 === "" ? 0 : Buffer.from(base64, "base64").length };
1331
+ }
1332
+ if (args.inline !== true) {
1333
+ return { ok: true, name: result2?.name ?? null, base64Length: base64.length, hint: "Pass outPath to write the file, or inline=true to receive the base64 payload." };
1334
+ }
1335
+ return { ok: true, name: result2?.name ?? null, base64 };
1336
+ }
1337
+ case "eda_import_project": {
1338
+ const path = String(args.path ?? "").trim();
1339
+ if (path === "") throw new Error("eda_import_project requires path");
1340
+ let bytes;
1341
+ try {
1342
+ bytes = await readFile(path);
1343
+ } catch {
1344
+ throw new Error('eda_import_project: cannot read "' + path + '"');
1345
+ }
1346
+ let teamUuid = args.teamUuid === void 0 ? "" : String(args.teamUuid).trim();
1347
+ if (teamUuid === "") {
1348
+ const current = await callExtension("dmt_Team.getCurrentTeamInfo", [], timeoutMs).catch(() => void 0);
1349
+ teamUuid = String(pickValue(asRecord(current), "uuid", "Uuid") ?? "");
1350
+ }
1351
+ if (teamUuid === "") {
1352
+ throw new Error("eda_import_project needs teamUuid: the app imports nothing without a target team. Use a team uuid from eda_list_teams (local mode reports project directories) or the teamUuid of an existing project (eda_list_projects).");
1353
+ }
1354
+ const saveTo = { operation: "New Project", newProjectOwnerTeamUuid: teamUuid };
1355
+ if (args.folderUuid !== void 0) saveTo.newProjectOwnerFolderUuid = String(args.folderUuid);
1356
+ if (args.friendlyName !== void 0) saveTo.newProjectFriendlyName = String(args.friendlyName);
1357
+ const imported = await callExtension("dsh.internal.importProjectFile", [
1358
+ bytes.toString("base64"),
1359
+ basename(path),
1360
+ args.fileType === void 0 ? void 0 : String(args.fileType),
1361
+ saveTo
1362
+ ], toolTimeout(args, timeoutMs));
1363
+ const raw = imported?.result ?? imported;
1364
+ const uuid = typeof raw === "string" ? raw : typeof raw?.uuid === "string" ? raw.uuid : void 0;
1365
+ const landed = raw !== null && raw !== void 0;
1366
+ let opened = false;
1367
+ if (args.open === true && uuid !== void 0) {
1368
+ try {
1369
+ opened = await callExtension("dmt_Project.openProject", [uuid], timeoutMs) !== false;
1370
+ } catch {
1371
+ opened = false;
1372
+ }
1373
+ }
1374
+ return {
1375
+ ok: landed,
1376
+ imported: landed,
1377
+ name: basename(path),
1378
+ size: bytes.length,
1379
+ teamUuid,
1380
+ uuid: uuid ?? null,
1381
+ opened,
1382
+ result: raw ?? null,
1383
+ hint: landed ? null : "The app returned nothing: the import did not create a project. That means this build ignores the saveTo target (same limitation as createProject), so import cannot be used to create projects here."
1384
+ };
1385
+ }
1386
+ case "eda_close": {
1387
+ const resolved = resolveExecutable(launchProbe(), {
1388
+ executable: args.executable === void 0 ? void 0 : String(args.executable)
1389
+ });
1390
+ const imageName = resolved === void 0 ? "lceda-pro.exe" : basename(resolved.executable);
1391
+ const before = processCountByName(imageName);
1392
+ const waitMs = Number.isFinite(Number(args.waitMs)) ? Math.max(1e3, Number(args.waitMs)) : 8e3;
1393
+ let error = null;
1394
+ try {
1395
+ terminateByName(imageName, false);
1396
+ } catch (failure) {
1397
+ error = failure instanceof Error ? failure.message : String(failure);
1398
+ }
1399
+ const started = Date.now();
1400
+ let remaining = processCountByName(imageName);
1401
+ while (remaining > 0 && Date.now() - started < waitMs) {
1402
+ await new Promise((resolve) => setTimeout(resolve, 500));
1403
+ remaining = processCountByName(imageName);
1404
+ }
1405
+ let forced = false;
1406
+ if (remaining > 0) {
1407
+ try {
1408
+ terminateByName(imageName, true);
1409
+ forced = true;
1410
+ await new Promise((resolve) => setTimeout(resolve, 1500));
1411
+ remaining = processCountByName(imageName);
1412
+ } catch (failure) {
1413
+ error = error ?? (failure instanceof Error ? failure.message : String(failure));
1414
+ }
1415
+ }
1416
+ const connected = pickClients().length;
1417
+ return {
1418
+ ok: remaining <= 0,
1419
+ closed: remaining <= 0,
1420
+ forced,
1421
+ imageName,
1422
+ processesBefore: before,
1423
+ processesAfter: remaining,
1424
+ connectedClients: connected,
1425
+ waitedMs: Date.now() - started,
1426
+ error
1427
+ };
1428
+ }
1429
+ case "eda_list_projects": {
1430
+ const filters = [];
1431
+ if (args.teamUuid !== void 0) filters.push(String(args.teamUuid));
1432
+ if (args.folderUuid !== void 0 || args.workspaceUuid !== void 0) filters.push(args.folderUuid === void 0 ? null : String(args.folderUuid));
1433
+ if (args.workspaceUuid !== void 0) filters.push(String(args.workspaceUuid));
1434
+ let uuids = await callExtension("dmt_Project.getAllProjectsUuid", filters, toolTimeout(args, timeoutMs));
1435
+ let list = Array.isArray(uuids) ? uuids.map(String) : [];
1436
+ let aggregated = false;
1437
+ if (list.length === 0 && filters.length === 0) {
1438
+ const teams = await callExtension("dmt_Team.getAllTeamsInfo", [], toolTimeout(args, timeoutMs)).catch(() => []);
1439
+ const seen = /* @__PURE__ */ new Set();
1440
+ for (const team of Array.isArray(teams) ? teams : []) {
1441
+ const teamUuid = String(pickValue(asRecord(team), "uuid", "Uuid") ?? "");
1442
+ if (teamUuid === "") continue;
1443
+ const owned = await callExtension("dmt_Project.getAllProjectsUuid", [teamUuid], toolTimeout(args, timeoutMs)).catch(() => []);
1444
+ for (const uuid of Array.isArray(owned) ? owned.map(String) : []) {
1445
+ if (seen.has(uuid)) continue;
1446
+ seen.add(uuid);
1447
+ list.push(uuid);
1448
+ }
1449
+ }
1450
+ aggregated = true;
1451
+ }
1452
+ if (args.resolveNames === false) return { count: list.length, uuids: list, aggregated };
1453
+ const limit = Number.isFinite(Number(args.limit)) ? Math.max(1, Number(args.limit)) : list.length;
1454
+ const projects = [];
1455
+ for (const uuid of list.slice(0, limit)) {
1456
+ try {
1457
+ projects.push(await callExtension("dmt_Project.getProjectInfo", [uuid], toolTimeout(args, timeoutMs)));
1458
+ } catch {
1459
+ projects.push({ uuid });
1460
+ }
1461
+ }
1462
+ return { count: list.length, resolved: projects.length, aggregated, projects };
1463
+ }
1464
+ case "eda_get_project": {
1465
+ const project = await callExtension("dmt_Project.getCurrentProjectInfo", [], timeoutMs);
1466
+ return project === void 0 || project === null ? { project: null, hint: "No project is open: JLCEDA Pro is on the HOME page. Use eda_list_projects + eda_open_project, or open one in the app." } : project;
1467
+ }
1468
+ case "eda_open_project": {
1469
+ const resolvedProject = await resolveProjectUuid(args, timeoutMs);
1470
+ const uuid = resolvedProject.uuid;
1471
+ const opened = await callExtension("dmt_Project.openProject", [uuid], toolTimeout(args, timeoutMs));
1472
+ const current = await callExtension("dmt_Project.getCurrentProjectInfo", [], timeoutMs).catch(() => void 0);
1473
+ return { opened: opened !== false, uuid, matched: resolvedProject.matched, current: current === void 0 ? null : summarizePrimitive(current) };
1474
+ }
1475
+ case "eda_get_workspace": {
1476
+ const workspace = await callExtension("dmt_Workspace.getCurrentWorkspaceInfo", [], timeoutMs).catch(() => void 0);
1477
+ const team = await callExtension("dmt_Team.getCurrentTeamInfo", [], timeoutMs).catch(() => void 0);
1478
+ return {
1479
+ workspace: workspace === void 0 ? null : workspace,
1480
+ team: team === void 0 ? null : team,
1481
+ hint: workspace === void 0 && team === void 0 ? "No workspace/team is available: start JLCEDA Pro and sign in to a JLC account. Workspace projects cannot be created or listed without one." : "ok"
1482
+ };
1483
+ }
1484
+ case "eda_list_teams": {
1485
+ const teams = await callExtension(args.involved === true ? "dmt_Team.getAllInvolvedTeamInfo" : "dmt_Team.getAllTeamsInfo", [], toolTimeout(args, timeoutMs));
1486
+ const list = Array.isArray(teams) ? teams : [];
1487
+ return { count: list.length, teams: list };
1488
+ }
1489
+ case "eda_list_folders": {
1490
+ const teamUuid = args.teamUuid === void 0 ? "" : String(args.teamUuid).trim();
1491
+ let resolvedTeam = teamUuid;
1492
+ if (resolvedTeam === "") {
1493
+ const current = await callExtension("dmt_Team.getCurrentTeamInfo", [], timeoutMs).catch(() => void 0);
1494
+ resolvedTeam = String(pickValue(asRecord(current), "Uuid", "uuid") ?? "");
1495
+ }
1496
+ if (resolvedTeam === "") throw new Error("eda_list_folders needs teamUuid (eda_list_teams shows them)");
1497
+ let uuids;
1498
+ try {
1499
+ uuids = await callExtension("dmt_Folder.getAllFoldersUuid", [resolvedTeam], toolTimeout(args, timeoutMs));
1500
+ } catch (failure) {
1501
+ throw new Error('eda_list_folders: the app rejected team "' + resolvedTeam + '" (' + (failure instanceof Error ? failure.message : String(failure)) + "); pass the uuid from a project record (eda_list_projects) or eda_list_teams");
1502
+ }
1503
+ const list = Array.isArray(uuids) ? uuids.map(String) : [];
1504
+ if (args.resolveNames === false) return { teamUuid: resolvedTeam, count: list.length, uuids: list };
1505
+ const folders = [];
1506
+ for (const folderUuid of list.slice(0, 20)) {
1507
+ try {
1508
+ folders.push({ folderUuid, info: await callExtension("dmt_Folder.getFolderInfo", [resolvedTeam, folderUuid], timeoutMs) });
1509
+ } catch {
1510
+ folders.push({ folderUuid, info: null });
1511
+ }
1512
+ }
1513
+ return { teamUuid: resolvedTeam, count: list.length, folders };
1514
+ }
1515
+ case "eda_delete_project": {
1516
+ const resolvedProject = await resolveProjectUuid(args, timeoutMs);
1517
+ const deleted = await callExtension("dmt_Project.deleteProject", [resolvedProject.uuid], toolTimeout(args, timeoutMs));
1518
+ const ok = deleted !== false;
1519
+ return {
1520
+ deleted: ok,
1521
+ uuid: resolvedProject.uuid,
1522
+ matched: resolvedProject.matched,
1523
+ hint: ok ? null : "The app returned false and the project still exists: this build refuses deleteProject for local projects (verified with the project open and after switching away), so remove it in the app."
1524
+ };
1525
+ }
1526
+ case "eda_modify_project": {
1527
+ const resolvedProject = await resolveProjectUuid(args, timeoutMs);
1528
+ const changes = [];
1529
+ if (args.friendlyName !== void 0) {
1530
+ changes.push(["friendlyName", await callExtension("dmt_Project.modifyProjectFriendlyName", [resolvedProject.uuid, String(args.friendlyName)], timeoutMs)]);
1531
+ }
1532
+ if (args.description !== void 0) {
1533
+ changes.push(["description", await callExtension("dmt_Project.modifyProjectDescription", [resolvedProject.uuid, String(args.description)], timeoutMs)]);
1534
+ }
1535
+ if (changes.length === 0) throw new Error("eda_modify_project requires friendlyName or description");
1536
+ return {
1537
+ ok: changes.every(([, value]) => value !== false),
1538
+ uuid: resolvedProject.uuid,
1539
+ matched: resolvedProject.matched,
1540
+ applied: changes.map(([field, value]) => ({ field, ok: value !== false }))
1541
+ };
1542
+ }
1543
+ case "eda_copy_project": {
1544
+ const source = await resolveProjectUuid({
1545
+ uuid: args.sourceUuid,
1546
+ name: args.sourceName
1547
+ }, timeoutMs);
1548
+ const uuid = await callExtension("dmt_Project.copyProject", [
1549
+ source.uuid,
1550
+ args.targetTeamUuid === void 0 ? void 0 : String(args.targetTeamUuid),
1551
+ args.targetFolderUuid === void 0 ? void 0 : String(args.targetFolderUuid),
1552
+ args.newFriendlyName === void 0 ? void 0 : String(args.newFriendlyName),
1553
+ args.newName === void 0 ? void 0 : String(args.newName)
1554
+ ], toolTimeout(args, timeoutMs));
1555
+ return { copied: typeof uuid === "string" && uuid !== "", sourceUuid: source.uuid, uuid: uuid ?? null };
1556
+ }
1557
+ case "eda_create_project": {
1558
+ const friendlyName = String(args.friendlyName ?? "").trim();
1559
+ if (friendlyName === "") throw new Error("eda_create_project requires friendlyName");
1560
+ let teamUuid = args.teamUuid === void 0 ? "" : String(args.teamUuid).trim();
1561
+ let teamSource = "argument";
1562
+ if (teamUuid === "") {
1563
+ const current = await callExtension("dmt_Team.getCurrentTeamInfo", [], timeoutMs).catch(() => void 0);
1564
+ teamUuid = String(pickValue(asRecord(current), "Uuid", "uuid") ?? "");
1565
+ teamSource = teamUuid === "" ? "unavailable" : "current-team";
1566
+ }
1567
+ if (teamUuid === "") {
1568
+ throw new Error("eda_create_project: no team is available (sign in to a JLC account in JLCEDA Pro, or pass teamUuid from eda_list_teams)");
1569
+ }
1570
+ const folderUuid = args.folderUuid === void 0 ? "" : String(args.folderUuid).trim();
1571
+ const uuid = await callExtension("dmt_Project.createProject", [
1572
+ friendlyName,
1573
+ args.name === void 0 ? void 0 : String(args.name),
1574
+ teamUuid,
1575
+ folderUuid === "" ? void 0 : folderUuid,
1576
+ args.description === void 0 ? void 0 : String(args.description)
1577
+ ], toolTimeout(args, timeoutMs));
1578
+ if (typeof uuid !== "string" || uuid === "") {
1579
+ throw new Error("eda_create_project: the app returned no project uuid (team " + teamUuid + " from " + teamSource + "; the app may have shown a dialog or rejected the name)");
1580
+ }
1581
+ let opened = false;
1582
+ if (args.open === true) {
1583
+ try {
1584
+ opened = await callExtension("dmt_Project.openProject", [uuid], timeoutMs) !== false;
1585
+ } catch {
1586
+ opened = false;
1587
+ }
1588
+ }
1589
+ return { created: true, uuid, friendlyName, teamUuid, teamSource, folderUuid: folderUuid === "" ? null : folderUuid, opened };
1590
+ }
1591
+ case "eda_launch": {
1592
+ const probe = launchProbe();
1593
+ const resolved = resolveExecutable(probe, {
1594
+ executable: args.executable === void 0 ? void 0 : String(args.executable),
1595
+ project: args.project === void 0 ? void 0 : String(args.project)
1596
+ });
1597
+ if (resolved === void 0) {
1598
+ throw new Error([
1599
+ "eda_launch: could not find the JLCEDA Pro executable on this machine.",
1600
+ "Set `DSH_EDA_EXECUTABLE` (or the plugin setting edaExecutable) to the full path of lceda-pro.exe.",
1601
+ "Searched: DSH_EDA_EXECUTABLE / JLCEDA_EXECUTABLE, the registry handler registered for .eprj2/.eprj,",
1602
+ "conventional install directories under Program Files / LOCALAPPDATA / the system drive, and PATH."
1603
+ ].join(" "));
1604
+ }
1605
+ const alreadyConnected = pickClients().length;
1606
+ const project = args.project === void 0 ? void 0 : String(args.project);
1607
+ if (project !== void 0 && project !== "") {
1608
+ try {
1609
+ if (!statSync(project).isFile()) throw new Error("not a file");
1610
+ } catch {
1611
+ throw new Error('eda_launch: project "' + project + '" does not exist or is not a file');
1612
+ }
1613
+ }
1614
+ const plan = buildLaunchArgs(process.platform, resolved.executable, project);
1615
+ let child;
1616
+ let via = "direct";
1617
+ if (process.platform === "win32") {
1618
+ try {
1619
+ const scriptPath = join(tmpdir(), "dsh-eda-launch-" + process.pid + "-" + Date.now() + ".cmd");
1620
+ await writeFile(scriptPath, buildLaunchScript(resolved.executable, project), "utf8");
1621
+ child = spawn(process.env.SystemRoot ? join(process.env.SystemRoot, "explorer.exe") : "explorer.exe", [scriptPath], {
1622
+ detached: true,
1623
+ stdio: "ignore",
1624
+ windowsHide: false
1625
+ });
1626
+ via = "explorer";
1627
+ } catch {
1628
+ child = spawn(plan.command, plan.args, { detached: true, stdio: "ignore", windowsHide: false });
1629
+ via = "direct";
1630
+ }
1631
+ } else {
1632
+ child = spawn(plan.command, plan.args, { detached: true, stdio: "ignore", windowsHide: false });
1633
+ }
1634
+ child.unref();
1635
+ const shouldWait = args.waitForExtension !== false;
1636
+ const timeoutMs2 = Number.isFinite(Number(args.timeoutMs)) ? Math.max(1e3, Number(args.timeoutMs)) : 3e4;
1637
+ const waited = shouldWait ? await waitForExtensionConnection(timeoutMs2) : { connected: alreadyConnected > 0, clients: alreadyConnected, waitedMs: 0 };
1638
+ return {
1639
+ ok: true,
1640
+ launched: true,
1641
+ pid: child.pid ?? null,
1642
+ via,
1643
+ executable: resolved.executable,
1644
+ source: resolved.source,
1645
+ project: project ?? null,
1646
+ // Verified on JLCEDA Pro 3.2.186: the app starts but ignores the project
1647
+ // argument passed on the command line (it opens on HOME), so the caller must
1648
+ // not assume the project was opened.
1649
+ projectOpenedByArgument: false,
1650
+ projectNote: project === void 0 || project === "" ? null : "The app was started with the project path, but this JLCEDA Pro build ignores it and opens on HOME; open the project from the app itself.",
1651
+ target: { command: plan.command, args: plan.args },
1652
+ connected: waited.connected,
1653
+ connectedClients: waited.clients,
1654
+ waitedMs: waited.waitedMs,
1655
+ hint: waited.connected ? "JLCEDA is connected; the eda_* tools can be used now." : "The app was started but no EDA extension connected yet. In JLCEDA Pro: \u6269\u5C55 \u2192 DSH MCP \u2192 \u8FDE\u63A5 (and enable \u201C\u5141\u8BB8\u5916\u90E8\u4EA4\u4E92\u201D); the extension must be imported once from dsh-eda-bridge-extension.zip."
992
1656
  };
993
1657
  }
994
1658
  case "eda_get_page_info": {
@@ -1937,7 +2601,7 @@ var server = createServer((req, res) => {
1937
2601
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
1938
2602
  if (req.method === "GET" && url.pathname === "/healthz") {
1939
2603
  res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
1940
- res.end(JSON.stringify({ status: "ok", version: VERSION, clients: [...clients].filter((client) => client.ready).length }));
2604
+ res.end(JSON.stringify({ status: "ok", version: VERSION, clients: pickClients().length }));
1941
2605
  return;
1942
2606
  }
1943
2607
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });