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.
@@ -1,8 +1,15 @@
1
1
  // @ts-nocheck -- the bridge is authored as dependency-free JS and bundled by esbuild; runtime tests cover it.
2
2
  import { createHash } from 'node:crypto'
3
+ import { execFileSync, spawn } from 'node:child_process'
4
+ import { existsSync, 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, type IncomingMessage, type ServerResponse } from 'node:http'
4
10
  import type { Socket } from 'node:net'
5
11
  import { createInterface } from 'node:readline'
12
+ import { buildLaunchArgs, buildLaunchScript, decodeRegistryOutput, resolveExecutable, type LaunchProbe } from './eda-launch.ts'
6
13
  import {
7
14
  ANNULUS_MIN_MM, BOARD_OUTLINE_WIDTH_MIL, MIN_TRACK_WIDTH_MIL, POUR_CLEARANCE_MIN_MIL,
8
15
  POUR_CLEARANCE_RECOMMENDED_MIL, SILKSCREEN_HEIGHT_MIN_MM, SILKSCREEN_WIDTH_MIN_MM,
@@ -10,7 +17,7 @@ import {
10
17
  assertNetName, checkNetName, checkTrackWidth, checkVia, classifyNet, milToMm, suggestTrackWidthMil,
11
18
  } from './design-rules.ts'
12
19
 
13
- const VERSION = '0.3.5'
20
+ const VERSION = '0.4.0'
14
21
  const WS_PORT = Number(process.env.DSH_EDA_MCP_WS_PORT ?? 39009)
15
22
  const WS_HOST = '127.0.0.1'
16
23
  const ALLOW_RAW_API = (process.env.DSH_EDA_MCP_ALLOW_RAW_API ?? '1') !== '0'
@@ -230,6 +237,21 @@ function objectSchema(properties, required = []) {
230
237
 
231
238
  const TOOLS = [
232
239
  { name: 'eda_status', description: 'Get the dsh-eda-mcp bridge status and how many JLCEDA Pro bridge extensions are connected.', inputSchema: objectSchema({}) },
240
+ { 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' } }) },
241
+ { name: 'eda_get_project', description: 'Read the current project (dmt_Project.getCurrentProjectInfo): uuid, friendly name, folder/team and other brief fields.', inputSchema: objectSchema({}) },
242
+ { 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' } }) },
243
+ { 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']) },
244
+ { 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({}) },
245
+ { name: 'eda_list_teams', description: 'List teams (dmt_Team.getAllTeamsInfo), optionally including the teams this account is involved in.', inputSchema: objectSchema({ involved: { type: 'boolean' } }) },
246
+ { 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', } }) },
247
+ { name: 'eda_delete_project', description: 'Delete a project (dmt_Project.deleteProject) by uuid or friendly name.', inputSchema: objectSchema({ uuid: { type: 'string' }, name: { type: 'string' } }) },
248
+ { 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' } }) },
249
+ { 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' } }) },
250
+ { name: 'eda_close_document', description: 'Close one editor tab (dmt_EditorControl.closeDocument).', inputSchema: objectSchema({ tabId: { type: 'string' } }, ['tabId']) },
251
+ { 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' } }) },
252
+ { 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']) },
253
+ { 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' } }) },
254
+ { 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' } }) },
233
255
  { 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' } }) },
234
256
  { 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' } }) },
235
257
  { 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']) },
@@ -391,6 +413,9 @@ class WsConnection {
391
413
  this.socket.destroy()
392
414
  }
393
415
  handleData(chunk) {
416
+ // Liveness: a page that navigates away leaves a half-open socket that still
417
+ // looks ready, so every timestamp counts, not just pongs.
418
+ this.lastSeenAt = Date.now()
394
419
  this.buffer = Buffer.concat([this.buffer, chunk])
395
420
  for (;;) {
396
421
  const frame = parseFrame(this.buffer)
@@ -449,16 +474,25 @@ async function pcbComponentIds(timeoutMs) {
449
474
  }
450
475
  let nextCallId = 1
451
476
 
452
- function firstReadyClient() {
453
- for (const client of clients) if (client.ready) return client
454
- return undefined
477
+ /** A socket that has been silent this long is treated as dead even if it never closed. */
478
+ const CLIENT_STALE_MS = 60000
479
+
480
+ /** Ready clients, freshest first. Stale sockets are used only as a last resort. */
481
+ function pickClients() {
482
+ const now = Date.now()
483
+ const ready = [...clients].filter(client => client.ready)
484
+ const fresh = ready.filter(client => now - (client.lastSeenAt ?? 0) < CLIENT_STALE_MS)
485
+ const pool = fresh.length > 0 ? fresh : ready
486
+ return pool.sort((left, right) => (right.lastSeenAt ?? 0) - (left.lastSeenAt ?? 0))
455
487
  }
456
488
 
457
- function callExtension(method, args, timeoutMs) {
458
- const client = firstReadyClient()
459
- if (client === undefined) {
460
- 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.'))
461
- }
489
+ /** Read-only calls may be repeated on another page; a write must never be. */
490
+ function isIdempotentRead(method) {
491
+ const fn = method.slice(method.lastIndexOf('.') + 1).toLowerCase()
492
+ return fn.startsWith('get') || fn.startsWith('is') || fn.startsWith('has') || fn.startsWith('list') || fn.startsWith('search')
493
+ }
494
+
495
+ function sendToClient(client, method, args, timeoutMs) {
462
496
  const id = nextCallId++
463
497
  return new Promise((resolve, reject) => {
464
498
  const timer = setTimeout(() => {
@@ -470,6 +504,27 @@ function callExtension(method, args, timeoutMs) {
470
504
  })
471
505
  }
472
506
 
507
+ async function callExtension(method, args, timeoutMs) {
508
+ const pool = pickClients()
509
+ if (pool.length === 0) {
510
+ 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.')
511
+ }
512
+ const deadline = Date.now() + timeoutMs
513
+ let lastError
514
+ for (const client of pool) {
515
+ const remaining = deadline - Date.now()
516
+ if (remaining <= 100) break
517
+ try {
518
+ return await sendToClient(client, method, args, remaining)
519
+ } catch (error) {
520
+ lastError = error
521
+ // A different error means the page answered; retrying would only repeat it.
522
+ if (!isIdempotentRead(method) || !/timed out/.test(String((error as Error)?.message))) throw error
523
+ }
524
+ }
525
+ throw lastError ?? new Error('JLCEDA bridge call timed out after ' + timeoutMs + 'ms: ' + method)
526
+ }
527
+
473
528
  function asRecord(value) {
474
529
  if (value === null || typeof value !== 'object') return {}
475
530
  return value
@@ -773,6 +828,86 @@ async function auditPcb(args, timeoutMs) {
773
828
  }
774
829
  }
775
830
 
831
+ /**
832
+ * Real probe for `resolveExecutable`: filesystem existence plus `reg query`.
833
+ * Kept separate from the pure resolver so the resolution order stays testable.
834
+ */
835
+ function launchProbe() {
836
+ const cache = new Map()
837
+ return {
838
+ platform: process.platform,
839
+ env: process.env,
840
+ exists(path) {
841
+ try { return statSync(path).isFile() } catch { return false }
842
+ },
843
+ registryQuery(key, valueName) {
844
+ if (process.platform !== 'win32') return undefined
845
+ // `undefined` (list every value) and `''` (the default value) are different
846
+ // queries and must not share a cache entry.
847
+ const cacheKey = key + '|' + (valueName === undefined ? 'LIST' : (valueName === '' ? 'DEFAULT' : 'V:' + valueName))
848
+ if (cache.has(cacheKey)) return cache.get(cacheKey)
849
+ let output
850
+ try {
851
+ // `reg query <key>` alone prints only the default value, so listing a key
852
+ // (valueName undefined) needs `/s`; a single value uses `/v`, and the
853
+ // default value uses `/ve`.
854
+ const extra = valueName === undefined ? ['/s'] : (valueName === '' ? ['/ve'] : ['/v', valueName])
855
+ const raw = execFileSync('reg', ['query', key, ...extra], {
856
+ timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'],
857
+ })
858
+ output = decodeRegistryOutput(raw)
859
+ } catch {
860
+ output = undefined
861
+ }
862
+ cache.set(cacheKey, output)
863
+ return output
864
+ },
865
+ }
866
+ }
867
+
868
+ /** How many processes currently run that image name (Windows tasklist). */
869
+ function processCountByName(imageName) {
870
+ if (process.platform !== 'win32') {
871
+ // pgrep exits non-zero when nothing matches, which is the "closed" case.
872
+ try {
873
+ const out = execFileSync('pgrep', ['-f', imageName], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
874
+ return out.split(/\r?\n/).filter(line => line.trim() !== '').length
875
+ } catch {
876
+ return 0
877
+ }
878
+ }
879
+ try {
880
+ const out = execFileSync('tasklist', ['/FI', 'IMAGENAME eq ' + imageName, '/FO', 'CSV', '/NH'], {
881
+ encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
882
+ })
883
+ return out.split(/\r?\n/).filter(line => line.trim().startsWith('"')).length
884
+ } catch {
885
+ return 0
886
+ }
887
+ }
888
+
889
+ /** Ask the app to close (/T closes the renderer children too); force kills it. */
890
+ function terminateByName(imageName, force) {
891
+ if (process.platform === 'win32') {
892
+ execFileSync('taskkill', ['/IM', imageName, '/T'].concat(force ? ['/F'] : []), {
893
+ timeout: 10000, stdio: ['ignore', 'ignore', 'ignore'],
894
+ })
895
+ return
896
+ }
897
+ execFileSync('pkill', [force ? '-9' : '-TERM', '-f', imageName], { timeout: 10000, stdio: ['ignore', 'ignore', 'ignore'] })
898
+ }
899
+
900
+ /** Wait (bounded) until at least one EDA extension reports ready. */
901
+ async function waitForExtensionConnection(timeoutMs) {
902
+ const started = Date.now()
903
+ const readyCount = () => pickClients().length
904
+ while (Date.now() - started < timeoutMs) {
905
+ if (readyCount() > 0) return { connected: true, clients: readyCount(), waitedMs: Date.now() - started }
906
+ await new Promise(resolve => setTimeout(resolve, 500))
907
+ }
908
+ return { connected: false, clients: readyCount(), waitedMs: Date.now() - started }
909
+ }
910
+
776
911
  async function clearRoutingBatched(args, type, timeoutMs) {
777
912
  if (type === 'connection') throw new Error('eda_pcb_clear_routing: type=connection needs the EDA user interface; use type=all/net with mode=batched')
778
913
  const net = args.net === undefined ? undefined : String(args.net)
@@ -877,28 +1012,457 @@ function pickDefined(source, keys) {
877
1012
  return out
878
1013
  }
879
1014
 
1015
+ /** Resolve a project uuid from { uuid } or { name } (exact match first, then case-insensitive substring). */
1016
+ async function resolveProjectUuid(args, timeoutMs) {
1017
+ const direct = args.uuid === undefined ? '' : String(args.uuid).trim()
1018
+ if (direct !== '') return { uuid: direct, matched: null }
1019
+ const name = args.name === undefined ? '' : String(args.name).trim()
1020
+ if (name === '') throw new Error('provide uuid or name')
1021
+ const unfiltered = await callExtension('dmt_Project.getAllProjectsUuid', [], toolTimeout(args, timeoutMs))
1022
+ const candidates = Array.isArray(unfiltered) ? unfiltered.map(String) : []
1023
+ // Local directories are modelled as teams, so an unfiltered listing is empty;
1024
+ // collect every team's projects before giving up on the name.
1025
+ if (candidates.length === 0) {
1026
+ const teams = await callExtension('dmt_Team.getAllTeamsInfo', [], toolTimeout(args, timeoutMs)).catch(() => [])
1027
+ const seen = new Set()
1028
+ for (const team of Array.isArray(teams) ? teams : []) {
1029
+ const teamUuid = String(pickValue(asRecord(team), 'uuid', 'Uuid') ?? '')
1030
+ if (teamUuid === '') continue
1031
+ const owned = await callExtension('dmt_Project.getAllProjectsUuid', [teamUuid], toolTimeout(args, timeoutMs)).catch(() => [])
1032
+ for (const uuid of Array.isArray(owned) ? owned.map(String) : []) {
1033
+ if (seen.has(uuid)) continue
1034
+ seen.add(uuid)
1035
+ candidates.push(uuid)
1036
+ }
1037
+ }
1038
+ }
1039
+ const resolved = []
1040
+ for (const candidate of candidates) {
1041
+ try {
1042
+ const info = asRecord(await callExtension('dmt_Project.getProjectInfo', [candidate], toolTimeout(args, timeoutMs)))
1043
+ resolved.push({ uuid: candidate, friendly: String(pickValue(info, 'FriendlyName', 'friendlyName', 'Name', 'name') ?? '') })
1044
+ } catch {
1045
+ resolved.push({ uuid: candidate, friendly: '' })
1046
+ }
1047
+ }
1048
+ const matched = resolved.find(item => item.friendly === name)
1049
+ ?? resolved.find(item => item.friendly.toLowerCase().includes(name.toLowerCase()))
1050
+ ?? null
1051
+ if (matched === null) {
1052
+ throw new Error('no project matches "' + name + '"; known names: ' +
1053
+ resolved.map(item => item.friendly || item.uuid).slice(0, 20).join(', '))
1054
+ }
1055
+ return { uuid: matched.uuid, matched }
1056
+ }
1057
+
880
1058
  async function executeTool(name, rawArgs, timeoutMs) {
881
1059
  const args = asRecord(rawArgs)
882
1060
  const tool = PCB_TOOL_ALIASES[name] ?? name
883
1061
 
884
1062
  switch (tool) {
885
1063
  case 'eda_status': {
886
- const ready = [...clients].filter(client => client.ready)
887
- const extensionVersion = ready.map(client => client.extensionVersion).find(value => typeof value === 'string')
1064
+ const ready = pickClients()
1065
+ // Every EDA page keeps its own socket, so several clients can disagree: list
1066
+ // them all and judge the bridge as outdated when ANY of them is not on VERSION.
1067
+ const versions = ready.map(client => (typeof client.extensionVersion === 'string' ? client.extensionVersion : null))
1068
+ const extensionVersion = versions.find(value => typeof value === 'string') ?? null
1069
+ const stale = versions.filter(value => value !== VERSION)
888
1070
  return {
889
1071
  ok: true, bridgeVersion: VERSION, wsPort: WS_PORT, allowRawApi: ALLOW_RAW_API,
890
1072
  connectedClients: ready.length,
891
1073
  extensionVersion: extensionVersion ?? null,
892
- versionMismatch: typeof extensionVersion === 'string' && extensionVersion !== VERSION,
1074
+ extensionVersions: versions,
1075
+ versionMismatch: stale.length > 0,
893
1076
  mcpUnits: '0.01 inch', defaultPaper: DEFAULT_PAPER,
894
1077
  hint: ready.length === 0
895
1078
  ? 'No JLCEDA extension is connected. Open JLCEDA Pro and click DSH MCP -> connect, or re-import dsh-eda-bridge-extension.zip.'
896
- : (typeof extensionVersion === 'string' && extensionVersion !== VERSION
897
- ? 'The connected EDA extension version differs from the bridge version. Re-import dsh-eda-bridge-extension.zip and restart JLCEDA Pro.'
1079
+ : (stale.length > 0
1080
+ ? 'Connected EDA extension ' + stale.join(', ') + ' does not match bridge ' + VERSION +
1081
+ '. 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.'
898
1082
  : 'ok'),
899
1083
  }
900
1084
  }
901
1085
 
1086
+ case 'eda_close_document':
1087
+ return await callExtension('dmt_EditorControl.closeDocument', [String(args.tabId ?? '')], timeoutMs)
1088
+
1089
+ case 'eda_export_project': {
1090
+ const result = await callExtension('dsh.internal.exportProjectFile', [
1091
+ args.uuid === undefined ? null : String(args.uuid),
1092
+ args.fileName === undefined ? undefined : String(args.fileName),
1093
+ undefined,
1094
+ args.fileType === undefined ? undefined : String(args.fileType),
1095
+ ], toolTimeout(args, timeoutMs))
1096
+ const base64 = typeof result?.base64 === 'string' ? result.base64 : ''
1097
+ const outPath = args.outPath === undefined ? '' : String(args.outPath).trim()
1098
+ if (outPath !== '') {
1099
+ await writeFile(outPath, Buffer.from(base64, 'base64'))
1100
+ return { ok: true, written: outPath, name: result?.name ?? null, size: base64 === '' ? 0 : Buffer.from(base64, 'base64').length }
1101
+ }
1102
+ if (args.inline !== true) {
1103
+ return { ok: true, name: result?.name ?? null, base64Length: base64.length, hint: 'Pass outPath to write the file, or inline=true to receive the base64 payload.' }
1104
+ }
1105
+ return { ok: true, name: result?.name ?? null, base64 }
1106
+ }
1107
+
1108
+ case 'eda_import_project': {
1109
+ const path = String(args.path ?? '').trim()
1110
+ if (path === '') throw new Error('eda_import_project requires path')
1111
+ let bytes
1112
+ try { bytes = await readFile(path) } catch {
1113
+ throw new Error('eda_import_project: cannot read "' + path + '"')
1114
+ }
1115
+ // importProjectByProjectFile only lands anything when the target team is given
1116
+ // (saveTo); without it the app answers undefined and nothing is imported.
1117
+ let teamUuid = args.teamUuid === undefined ? '' : String(args.teamUuid).trim()
1118
+ if (teamUuid === '') {
1119
+ const current = await callExtension('dmt_Team.getCurrentTeamInfo', [], timeoutMs).catch(() => undefined)
1120
+ teamUuid = String(pickValue(asRecord(current), 'uuid', 'Uuid') ?? '')
1121
+ }
1122
+ if (teamUuid === '') {
1123
+ 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).')
1124
+ }
1125
+ const saveTo: Record<string, unknown> = { operation: 'New Project', newProjectOwnerTeamUuid: teamUuid }
1126
+ if (args.folderUuid !== undefined) saveTo.newProjectOwnerFolderUuid = String(args.folderUuid)
1127
+ if (args.friendlyName !== undefined) saveTo.newProjectFriendlyName = String(args.friendlyName)
1128
+ const imported = await callExtension('dsh.internal.importProjectFile', [
1129
+ bytes.toString('base64'),
1130
+ basename(path),
1131
+ args.fileType === undefined ? undefined : String(args.fileType),
1132
+ saveTo,
1133
+ ], toolTimeout(args, timeoutMs))
1134
+ const raw = imported?.result ?? imported
1135
+ const uuid = typeof raw === 'string' ? raw : (typeof raw?.uuid === 'string' ? raw.uuid : undefined)
1136
+ const landed = raw !== null && raw !== undefined
1137
+ let opened = false
1138
+ if (args.open === true && uuid !== undefined) {
1139
+ try { opened = (await callExtension('dmt_Project.openProject', [uuid], timeoutMs)) !== false } catch { opened = false }
1140
+ }
1141
+ return {
1142
+ ok: landed,
1143
+ imported: landed,
1144
+ name: basename(path),
1145
+ size: bytes.length,
1146
+ teamUuid,
1147
+ uuid: uuid ?? null,
1148
+ opened,
1149
+ result: raw ?? null,
1150
+ 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.',
1151
+ }
1152
+ }
1153
+
1154
+ case 'eda_close': {
1155
+ const resolved = resolveExecutable(launchProbe(), {
1156
+ executable: args.executable === undefined ? undefined : String(args.executable),
1157
+ })
1158
+ const imageName = resolved === undefined ? 'lceda-pro.exe' : basename(resolved.executable)
1159
+ const before = processCountByName(imageName)
1160
+ const waitMs = Number.isFinite(Number(args.waitMs)) ? Math.max(1000, Number(args.waitMs)) : 8000
1161
+ let error = null
1162
+ try {
1163
+ terminateByName(imageName, false)
1164
+ } catch (failure) {
1165
+ error = failure instanceof Error ? failure.message : String(failure)
1166
+ }
1167
+ const started = Date.now()
1168
+ let remaining = processCountByName(imageName)
1169
+ while (remaining > 0 && Date.now() - started < waitMs) {
1170
+ await new Promise(resolve => setTimeout(resolve, 500))
1171
+ remaining = processCountByName(imageName)
1172
+ }
1173
+ let forced = false
1174
+ if (remaining > 0) {
1175
+ try {
1176
+ terminateByName(imageName, true)
1177
+ forced = true
1178
+ await new Promise(resolve => setTimeout(resolve, 1500))
1179
+ remaining = processCountByName(imageName)
1180
+ } catch (failure) {
1181
+ error = error ?? (failure instanceof Error ? failure.message : String(failure))
1182
+ }
1183
+ }
1184
+ const connected = pickClients().length
1185
+ return {
1186
+ ok: remaining <= 0,
1187
+ closed: remaining <= 0,
1188
+ forced,
1189
+ imageName,
1190
+ processesBefore: before,
1191
+ processesAfter: remaining,
1192
+ connectedClients: connected,
1193
+ waitedMs: Date.now() - started,
1194
+ error,
1195
+ }
1196
+ }
1197
+
1198
+ case 'eda_list_projects': {
1199
+ // Omit the filters entirely when they are not given: the official API treats a
1200
+ // literal null as a filter value and the call does not resolve.
1201
+ const filters = []
1202
+ if (args.teamUuid !== undefined) filters.push(String(args.teamUuid))
1203
+ if (args.folderUuid !== undefined || args.workspaceUuid !== undefined) filters.push(args.folderUuid === undefined ? null : String(args.folderUuid))
1204
+ if (args.workspaceUuid !== undefined) filters.push(String(args.workspaceUuid))
1205
+ let uuids = await callExtension('dmt_Project.getAllProjectsUuid', filters, toolTimeout(args, timeoutMs))
1206
+ let list = Array.isArray(uuids) ? uuids.map(String) : []
1207
+ let aggregated = false
1208
+ // In semi-offline mode the app models each local project directory as a
1209
+ // "team", so an unfiltered call returns nothing; walk the teams instead.
1210
+ if (list.length === 0 && filters.length === 0) {
1211
+ const teams = await callExtension('dmt_Team.getAllTeamsInfo', [], toolTimeout(args, timeoutMs)).catch(() => [])
1212
+ const seen = new Set()
1213
+ for (const team of Array.isArray(teams) ? teams : []) {
1214
+ const teamUuid = String(pickValue(asRecord(team), 'uuid', 'Uuid') ?? '')
1215
+ if (teamUuid === '') continue
1216
+ const owned = await callExtension('dmt_Project.getAllProjectsUuid', [teamUuid], toolTimeout(args, timeoutMs)).catch(() => [])
1217
+ for (const uuid of Array.isArray(owned) ? owned.map(String) : []) {
1218
+ if (seen.has(uuid)) continue
1219
+ seen.add(uuid)
1220
+ list.push(uuid)
1221
+ }
1222
+ }
1223
+ aggregated = true
1224
+ }
1225
+ if (args.resolveNames === false) return { count: list.length, uuids: list, aggregated }
1226
+ const limit = Number.isFinite(Number(args.limit)) ? Math.max(1, Number(args.limit)) : list.length
1227
+ const projects = []
1228
+ for (const uuid of list.slice(0, limit)) {
1229
+ try {
1230
+ // Verbatim: project records carry unstable keys and summarising them
1231
+ // produced empty objects.
1232
+ projects.push(await callExtension('dmt_Project.getProjectInfo', [uuid], toolTimeout(args, timeoutMs)))
1233
+ } catch {
1234
+ projects.push({ uuid })
1235
+ }
1236
+ }
1237
+ return { count: list.length, resolved: projects.length, aggregated, projects }
1238
+ }
1239
+
1240
+ case 'eda_get_project': {
1241
+ const project = await callExtension('dmt_Project.getCurrentProjectInfo', [], timeoutMs)
1242
+ return project === undefined || project === null
1243
+ ? { 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.' }
1244
+ : project
1245
+ }
1246
+
1247
+ case 'eda_open_project': {
1248
+ const resolvedProject = await resolveProjectUuid(args, timeoutMs)
1249
+ const uuid = resolvedProject.uuid
1250
+ const opened = await callExtension('dmt_Project.openProject', [uuid], toolTimeout(args, timeoutMs))
1251
+ const current = await callExtension('dmt_Project.getCurrentProjectInfo', [], timeoutMs).catch(() => undefined)
1252
+ return { opened: opened !== false, uuid, matched: resolvedProject.matched, current: current === undefined ? null : summarizePrimitive(current) }
1253
+ }
1254
+
1255
+ case 'eda_get_workspace': {
1256
+ const workspace = await callExtension('dmt_Workspace.getCurrentWorkspaceInfo', [], timeoutMs).catch(() => undefined)
1257
+ const team = await callExtension('dmt_Team.getCurrentTeamInfo', [], timeoutMs).catch(() => undefined)
1258
+ // Team/workspace payloads are small and their field names are unstable, so
1259
+ // they are returned verbatim: summarising them produced empty objects.
1260
+ return {
1261
+ workspace: workspace === undefined ? null : workspace,
1262
+ team: team === undefined ? null : team,
1263
+ hint: workspace === undefined && team === undefined
1264
+ ? 'No workspace/team is available: start JLCEDA Pro and sign in to a JLC account. Workspace projects cannot be created or listed without one.'
1265
+ : 'ok',
1266
+ }
1267
+ }
1268
+
1269
+ case 'eda_list_teams': {
1270
+ const teams = await callExtension(args.involved === true ? 'dmt_Team.getAllInvolvedTeamInfo' : 'dmt_Team.getAllTeamsInfo', [], toolTimeout(args, timeoutMs))
1271
+ const list = Array.isArray(teams) ? teams : []
1272
+ return { count: list.length, teams: list }
1273
+ }
1274
+
1275
+ case 'eda_list_folders': {
1276
+ const teamUuid = args.teamUuid === undefined ? '' : String(args.teamUuid).trim()
1277
+ let resolvedTeam = teamUuid
1278
+ if (resolvedTeam === '') {
1279
+ const current = await callExtension('dmt_Team.getCurrentTeamInfo', [], timeoutMs).catch(() => undefined)
1280
+ resolvedTeam = String(pickValue(asRecord(current), 'Uuid', 'uuid') ?? '')
1281
+ }
1282
+ if (resolvedTeam === '') throw new Error('eda_list_folders needs teamUuid (eda_list_teams shows them)')
1283
+ // The app throws a bare TypeError when the team is not a real uuid (the local
1284
+ // directories reported by eda_list_teams are paths), so say what to pass.
1285
+ let uuids
1286
+ try {
1287
+ uuids = await callExtension('dmt_Folder.getAllFoldersUuid', [resolvedTeam], toolTimeout(args, timeoutMs))
1288
+ } catch (failure) {
1289
+ throw new Error('eda_list_folders: the app rejected team "' + resolvedTeam + '" (' +
1290
+ (failure instanceof Error ? failure.message : String(failure)) +
1291
+ '); pass the uuid from a project record (eda_list_projects) or eda_list_teams')
1292
+ }
1293
+ const list = Array.isArray(uuids) ? uuids.map(String) : []
1294
+ if (args.resolveNames === false) return { teamUuid: resolvedTeam, count: list.length, uuids: list }
1295
+ const folders = []
1296
+ for (const folderUuid of list.slice(0, 20)) {
1297
+ try {
1298
+ folders.push({ folderUuid, info: await callExtension('dmt_Folder.getFolderInfo', [resolvedTeam, folderUuid], timeoutMs) })
1299
+ } catch {
1300
+ folders.push({ folderUuid, info: null })
1301
+ }
1302
+ }
1303
+ return { teamUuid: resolvedTeam, count: list.length, folders }
1304
+ }
1305
+
1306
+ case 'eda_delete_project': {
1307
+ const resolvedProject = await resolveProjectUuid(args, timeoutMs)
1308
+ const deleted = await callExtension('dmt_Project.deleteProject', [resolvedProject.uuid], toolTimeout(args, timeoutMs))
1309
+ const ok = deleted !== false
1310
+ return {
1311
+ deleted: ok,
1312
+ uuid: resolvedProject.uuid,
1313
+ matched: resolvedProject.matched,
1314
+ 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.',
1315
+ }
1316
+ }
1317
+
1318
+ case 'eda_modify_project': {
1319
+ const resolvedProject = await resolveProjectUuid(args, timeoutMs)
1320
+ const changes = []
1321
+ if (args.friendlyName !== undefined) {
1322
+ changes.push(['friendlyName', await callExtension('dmt_Project.modifyProjectFriendlyName', [resolvedProject.uuid, String(args.friendlyName)], timeoutMs)])
1323
+ }
1324
+ if (args.description !== undefined) {
1325
+ changes.push(['description', await callExtension('dmt_Project.modifyProjectDescription', [resolvedProject.uuid, String(args.description)], timeoutMs)])
1326
+ }
1327
+ if (changes.length === 0) throw new Error('eda_modify_project requires friendlyName or description')
1328
+ return {
1329
+ ok: changes.every(([, value]) => value !== false),
1330
+ uuid: resolvedProject.uuid,
1331
+ matched: resolvedProject.matched,
1332
+ applied: changes.map(([field, value]) => ({ field, ok: value !== false })),
1333
+ }
1334
+ }
1335
+
1336
+ case 'eda_copy_project': {
1337
+ const source = await resolveProjectUuid({
1338
+ uuid: args.sourceUuid, name: args.sourceName,
1339
+ }, timeoutMs)
1340
+ const uuid = await callExtension('dmt_Project.copyProject', [
1341
+ source.uuid,
1342
+ args.targetTeamUuid === undefined ? undefined : String(args.targetTeamUuid),
1343
+ args.targetFolderUuid === undefined ? undefined : String(args.targetFolderUuid),
1344
+ args.newFriendlyName === undefined ? undefined : String(args.newFriendlyName),
1345
+ args.newName === undefined ? undefined : String(args.newName),
1346
+ ], toolTimeout(args, timeoutMs))
1347
+ return { copied: typeof uuid === 'string' && uuid !== '', sourceUuid: source.uuid, uuid: uuid ?? null }
1348
+ }
1349
+
1350
+ case 'eda_create_project': {
1351
+ const friendlyName = String(args.friendlyName ?? '').trim()
1352
+ if (friendlyName === '') throw new Error('eda_create_project requires friendlyName')
1353
+ // createProject needs an owning team: default to the current one instead of
1354
+ // passing undefined, which the app answers with an empty uuid.
1355
+ let teamUuid = args.teamUuid === undefined ? '' : String(args.teamUuid).trim()
1356
+ let teamSource = 'argument'
1357
+ if (teamUuid === '') {
1358
+ const current = await callExtension('dmt_Team.getCurrentTeamInfo', [], timeoutMs).catch(() => undefined)
1359
+ teamUuid = String(pickValue(asRecord(current), 'Uuid', 'uuid') ?? '')
1360
+ teamSource = teamUuid === '' ? 'unavailable' : 'current-team'
1361
+ }
1362
+ if (teamUuid === '') {
1363
+ 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)')
1364
+ }
1365
+ const folderUuid = args.folderUuid === undefined ? '' : String(args.folderUuid).trim()
1366
+ const uuid = await callExtension('dmt_Project.createProject', [
1367
+ friendlyName,
1368
+ args.name === undefined ? undefined : String(args.name),
1369
+ teamUuid,
1370
+ folderUuid === '' ? undefined : folderUuid,
1371
+ args.description === undefined ? undefined : String(args.description),
1372
+ ], toolTimeout(args, timeoutMs))
1373
+ if (typeof uuid !== 'string' || uuid === '') {
1374
+ 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)')
1375
+ }
1376
+ let opened = false
1377
+ if (args.open === true) {
1378
+ try {
1379
+ opened = (await callExtension('dmt_Project.openProject', [uuid], timeoutMs)) !== false
1380
+ } catch {
1381
+ opened = false
1382
+ }
1383
+ }
1384
+ return { created: true, uuid, friendlyName, teamUuid, teamSource, folderUuid: folderUuid === '' ? null : folderUuid, opened }
1385
+ }
1386
+
1387
+ case 'eda_launch': {
1388
+ const probe = launchProbe()
1389
+ const resolved = resolveExecutable(probe, {
1390
+ executable: args.executable === undefined ? undefined : String(args.executable),
1391
+ project: args.project === undefined ? undefined : String(args.project),
1392
+ })
1393
+ if (resolved === undefined) {
1394
+ throw new Error([
1395
+ 'eda_launch: could not find the JLCEDA Pro executable on this machine.',
1396
+ 'Set `DSH_EDA_EXECUTABLE` (or the plugin setting edaExecutable) to the full path of lceda-pro.exe.',
1397
+ 'Searched: DSH_EDA_EXECUTABLE / JLCEDA_EXECUTABLE, the registry handler registered for .eprj2/.eprj,',
1398
+ 'conventional install directories under Program Files / LOCALAPPDATA / the system drive, and PATH.',
1399
+ ].join(' '))
1400
+ }
1401
+
1402
+ const alreadyConnected = pickClients().length
1403
+ const project = args.project === undefined ? undefined : String(args.project)
1404
+ if (project !== undefined && project !== '') {
1405
+ try { if (!statSync(project).isFile()) throw new Error('not a file') } catch {
1406
+ throw new Error('eda_launch: project "' + project + '" does not exist or is not a file')
1407
+ }
1408
+ }
1409
+
1410
+ // On Windows a direct spawn (and even ShellExecute) makes lceda-pro.exe start
1411
+ // and quit immediately, while the same executable works when the desktop
1412
+ // shell launches it. Explorer runs in the interactive desktop, so the launch
1413
+ // is handed to it through a tiny script that starts the app (with the project
1414
+ // when given). Other platforms spawn the resolved command directly.
1415
+ const plan = buildLaunchArgs(process.platform, resolved.executable, project)
1416
+ let child
1417
+ let via = 'direct'
1418
+ if (process.platform === 'win32') {
1419
+ try {
1420
+ const scriptPath = join(tmpdir(), 'dsh-eda-launch-' + process.pid + '-' + Date.now() + '.cmd')
1421
+ await writeFile(scriptPath, buildLaunchScript(resolved.executable, project), 'utf8')
1422
+ child = spawn(process.env.SystemRoot ? join(process.env.SystemRoot, 'explorer.exe') : 'explorer.exe', [scriptPath], {
1423
+ detached: true, stdio: 'ignore', windowsHide: false,
1424
+ })
1425
+ via = 'explorer'
1426
+ } catch {
1427
+ child = spawn(plan.command, plan.args, { detached: true, stdio: 'ignore', windowsHide: false })
1428
+ via = 'direct'
1429
+ }
1430
+ } else {
1431
+ child = spawn(plan.command, plan.args, { detached: true, stdio: 'ignore', windowsHide: false })
1432
+ }
1433
+ child.unref()
1434
+
1435
+ const shouldWait = args.waitForExtension !== false
1436
+ const timeoutMs = Number.isFinite(Number(args.timeoutMs)) ? Math.max(1000, Number(args.timeoutMs)) : 30000
1437
+ const waited = shouldWait
1438
+ ? await waitForExtensionConnection(timeoutMs)
1439
+ : { connected: alreadyConnected > 0, clients: alreadyConnected, waitedMs: 0 }
1440
+
1441
+ return {
1442
+ ok: true,
1443
+ launched: true,
1444
+ pid: child.pid ?? null,
1445
+ via,
1446
+ executable: resolved.executable,
1447
+ source: resolved.source,
1448
+ project: project ?? null,
1449
+ // Verified on JLCEDA Pro 3.2.186: the app starts but ignores the project
1450
+ // argument passed on the command line (it opens on HOME), so the caller must
1451
+ // not assume the project was opened.
1452
+ projectOpenedByArgument: false,
1453
+ projectNote: project === undefined || project === ''
1454
+ ? null
1455
+ : '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.',
1456
+ target: { command: plan.command, args: plan.args },
1457
+ connected: waited.connected,
1458
+ connectedClients: waited.clients,
1459
+ waitedMs: waited.waitedMs,
1460
+ hint: waited.connected
1461
+ ? 'JLCEDA is connected; the eda_* tools can be used now.'
1462
+ : 'The app was started but no EDA extension connected yet. In JLCEDA Pro: 扩展 → DSH MCP → 连接 (and enable “允许外部交互”); the extension must be imported once from dsh-eda-bridge-extension.zip.',
1463
+ }
1464
+ }
1465
+
902
1466
  case 'eda_get_page_info': {
903
1467
  const paper = paperOf(args.paper)
904
1468
  let page
@@ -1807,7 +2371,7 @@ const server = createServer((req, res) => {
1807
2371
  const url = new URL(req.url ?? '/', 'http://127.0.0.1')
1808
2372
  if (req.method === 'GET' && url.pathname === '/healthz') {
1809
2373
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1810
- res.end(JSON.stringify({ status: 'ok', version: VERSION, clients: [...clients].filter(client => client.ready).length }))
2374
+ res.end(JSON.stringify({ status: 'ok', version: VERSION, clients: pickClients().length }))
1811
2375
  return
1812
2376
  }
1813
2377
  res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })