@dmytro-prototypes/cadbridge-mcp 0.1.14 → 0.1.15

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.
Files changed (2) hide show
  1. package/bin/plugin.js +105 -45
  2. package/package.json +3 -3
package/bin/plugin.js CHANGED
@@ -18,16 +18,25 @@ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync,
18
18
  import { homedir } from "node:os"
19
19
  import { dirname, join, sep } from "node:path"
20
20
 
21
- // Autodesk's per-user auto-load location. Anything under it with a
22
- // PackageContents.xml is loaded at AutoCAD startup, no admin rights and no
23
- // registry entry — and it is a trusted path, so SECURELOAD does not prompt.
21
+ // Autodesk's per-user auto-load location — and it is not the same mechanism on
22
+ // the two platforms.
23
+ //
24
+ // Windows reads ApplicationPlugins: a container folder holding
25
+ // PackageContents.xml plus the modules it names. That is the AutoLoader, and
26
+ // the folder is trusted, so SECURELOAD does not prompt.
27
+ //
28
+ // macOS does not consume PackageContents.xml at all. Observed on AutoCAD 2026
29
+ // for Mac: a container in ApplicationPlugins never loads — not ours, and not
30
+ // Autodesk's own sample plug-in — while a plain .bundle placed directly in
31
+ // ApplicationAddins loads at startup with no prompt. So macOS gets
32
+ // ApplicationAddins, and installPlugin puts the modules there unwrapped.
24
33
  export function pluginRoot({
25
34
  platform = process.platform,
26
35
  home = homedir(),
27
36
  appData = process.env.APPDATA || join(home, "AppData", "Roaming"),
28
37
  } = {}) {
29
38
  if (platform === "win32") return join(appData, "Autodesk", "ApplicationPlugins")
30
- if (platform === "darwin") return join(home, "Library", "Application Support", "Autodesk", "ApplicationPlugins")
39
+ if (platform === "darwin") return join(home, "Library", "Application Support", "Autodesk", "ApplicationAddins")
31
40
  return null
32
41
  }
33
42
 
@@ -110,9 +119,9 @@ export function coveredReleases(manifest) {
110
119
  // seal that Gatekeeper and AutoCAD both check.
111
120
  const STAMP = ".cadbridge-npm-install"
112
121
 
113
- function installed(destination) {
122
+ function installedStamp(stamp) {
114
123
  try {
115
- return JSON.parse(readFileSync(join(destination, STAMP), "utf8"))
124
+ return JSON.parse(readFileSync(stamp, "utf8"))
116
125
  } catch {
117
126
  // Missing, unreadable or hand-edited all mean the same thing: this is not
118
127
  // a copy we can prove we put there, so replace it.
@@ -120,6 +129,39 @@ function installed(destination) {
120
129
  }
121
130
  }
122
131
 
132
+ // The modules to place, and under what names.
133
+ //
134
+ // Windows installs the container itself: AutoCAD reads PackageContents.xml and
135
+ // picks the Components block matching its release.
136
+ //
137
+ // macOS has no manifest to pick with, so the selection has to be made here —
138
+ // and it is made per ObjectARX SERIES, not per release. 2025 and 2026 both link
139
+ // acdb25, so both binaries load into either one: installing them side by side
140
+ // gives AutoCAD two live CADBridge instances that fight over the same command
141
+ // names. One binary per series is what the old single Components block
142
+ // (R25.0-R25.99) always asserted; this makes it true on disk.
143
+ export function pluginModules(source, { platform = process.platform } = {}) {
144
+ if (platform !== "darwin") return [{ name: "CADBridge.bundle", from: source }]
145
+
146
+ const osx = join(source, "Contents", "Resources", "osx")
147
+ const bySeries = new Map()
148
+ for (const entry of readdirSync(osx, { withFileTypes: true })) {
149
+ // CADBridge-R25.1.bundle -> series R25, minor 1.
150
+ const match = /^CADBridge-R(\d+)\.(\d+)\.bundle$/.exec(entry.name)
151
+ if (!entry.isDirectory() || !match) continue
152
+ const [, series, minor] = match
153
+ const previous = bySeries.get(series)
154
+ // Highest minor wins: it is built against the newest SDK of the series and
155
+ // serves every release in it.
156
+ if (!previous || Number(minor) > previous.minor) {
157
+ bySeries.set(series, { minor: Number(minor), from: join(osx, entry.name) })
158
+ }
159
+ }
160
+ return [...bySeries]
161
+ .sort(([a], [b]) => (a < b ? -1 : 1))
162
+ .map(([series, { from }]) => ({ name: `CADBridge-R${series}.bundle`, from }))
163
+ }
164
+
123
165
  // A directory cannot be swapped the way installBinary swaps a file: rename
124
166
  // onto a non-empty directory fails on both platforms. So stage a complete
125
167
  // copy, move the old tree aside, move the new one in, and only then delete —
@@ -142,8 +184,9 @@ export function installPlugin(binary, {
142
184
  let version
143
185
  let manifest
144
186
  try {
145
- // PackageContents.xml is the file AutoCAD itself reads, so its absence
146
- // means there is no loadable bundle here whatever else was shipped.
187
+ // PackageContents.xml is the file AutoCAD itself reads on Windows, and on
188
+ // both platforms it is what states the version and the releases served, so
189
+ // its absence means there is no loadable bundle here whatever else shipped.
147
190
  manifest = readFileSync(join(source, "PackageContents.xml"), "utf8")
148
191
  version = /AppVersion="([^"]*)"/.exec(manifest)?.[1] ?? "unknown"
149
192
  } catch {
@@ -152,57 +195,74 @@ export function installPlugin(binary, {
152
195
  return { status: "absent" }
153
196
  }
154
197
 
155
- const destination = join(root, "CADBridge.bundle")
198
+ let modules
199
+ try {
200
+ modules = pluginModules(source, { platform })
201
+ } catch {
202
+ return { status: "absent" }
203
+ }
204
+ if (!modules.length) return { status: "absent" }
205
+
206
+ const destination = platform === "darwin" ? root : join(root, "CADBridge.bundle")
207
+ // Windows keeps its stamp inside the container, beside PackageContents.xml.
208
+ // macOS cannot: there is no container, and a file added inside a module would
209
+ // invalidate the signature Gatekeeper and AutoCAD both check — so the stamp
210
+ // sits beside the modules, where AutoCAD ignores it for not being a .bundle.
211
+ const stamp = platform === "darwin" ? join(root, STAMP) : join(destination, STAMP)
156
212
  const digest = hashTree(source)
157
213
  const covers = { manifest, installedAutoCAD: autocad ?? installedAutoCAD({ platform }) }
158
- if (installed(destination)?.sha256 === digest) return { status: "current", version, destination, ...covers }
214
+ if (installedStamp(stamp)?.sha256 === digest) return { status: "current", version, destination, ...covers }
159
215
 
160
216
  mkdirSync(root, { recursive: true })
161
- // One temp directory holds both the new tree being assembled and the old one
162
- // moved out of the way, so a single cleanup covers both and no name has to be
163
- // derived from the pid — a pid-derived name collides with the leftovers of a
164
- // run that was killed mid-swap, and a rename onto that stale directory fails
165
- // with exactly the codes that mean "AutoCAD has it open", wedging every later
166
- // launch behind advice that cannot help.
217
+ // One temp directory holds both the new trees being assembled and the old
218
+ // ones moved out of the way, so a single cleanup covers both and no name has
219
+ // to be derived from the pid — a pid-derived name collides with the leftovers
220
+ // of a run that was killed mid-swap, and a rename onto that stale directory
221
+ // fails with exactly the codes that mean "AutoCAD has it open", wedging every
222
+ // later launch behind advice that cannot help.
167
223
  const staging = mkdtempSync(join(root, ".cadbridge-staging-"))
168
- const displaced = join(staging, "displaced")
169
- // Set only when the previous bundle could NOT be put back, in which case this
224
+ // Set only when a previous bundle could NOT be put back, in which case this
170
225
  // directory holds the user's sole copy and must survive the cleanup below.
171
226
  let stranded = null
172
227
  try {
173
- const staged = join(staging, "CADBridge.bundle")
174
- cpSync(source, staged, { recursive: true })
175
- writeFileSync(join(staged, STAMP), `${JSON.stringify({ sha256: digest, version }, null, 2)}\n`)
228
+ for (const [index, module] of modules.entries()) {
229
+ const staged = join(staging, module.name)
230
+ const displaced = join(staging, `displaced-${index}`)
231
+ const target = join(root, module.name)
232
+ cpSync(module.from, staged, { recursive: true })
176
233
 
177
- let moved = false
178
- try {
179
- renameSync(destination, displaced)
180
- moved = true
181
- } catch (error) {
182
- if (error.code !== "ENOENT") throw error
183
- }
184
- try {
185
- renameSync(staged, destination)
186
- } catch (error) {
187
- // Leave the working plugin in place rather than a half-installed one.
188
- if (moved) {
189
- try {
190
- renameSync(displaced, destination)
191
- } catch {
192
- // Both moves failed, so the old bundle is still in the temp
193
- // directory and is now the only copy there is. Keep it and say
194
- // where, rather than deleting the user's working plugin.
195
- stranded = displaced
234
+ let moved = false
235
+ try {
236
+ renameSync(target, displaced)
237
+ moved = true
238
+ } catch (error) {
239
+ if (error.code !== "ENOENT") throw error
240
+ }
241
+ try {
242
+ renameSync(staged, target)
243
+ } catch (error) {
244
+ // Leave the working plugin in place rather than a half-installed one.
245
+ if (moved) {
246
+ try {
247
+ renameSync(displaced, target)
248
+ } catch {
249
+ // Both moves failed, so the old bundle is still in the temp
250
+ // directory and is now the only copy there is. Keep it and say
251
+ // where, rather than deleting the user's working plugin.
252
+ stranded = displaced
253
+ }
196
254
  }
255
+ throw error
197
256
  }
198
- throw error
199
257
  }
258
+ // Written last: a stamp is a claim that every module above is in place.
259
+ writeFileSync(stamp, `${JSON.stringify({ sha256: digest, version }, null, 2)}\n`)
200
260
  return { status: "installed", version, destination, ...covers }
201
261
  } catch (error) {
202
- // AutoCAD maps CADBridge.arx for the whole session (PackageContents.xml
203
- // sets LoadOnAutoCADStartup), and Windows will not let a mapped module be
204
- // renamed or deleted. That is the ordinary case of "the user started
205
- // AutoCAD first", not a failure worth refusing to start the server over.
262
+ // AutoCAD maps the plugin for the whole session, and Windows will not let a
263
+ // mapped module be renamed or deleted. That is the ordinary case of "the
264
+ // user started AutoCAD first", not a failure worth refusing to start the
265
+ // server over.
206
266
  const status = !stranded && ["EBUSY", "EPERM", "EACCES", "ENOTEMPTY"].includes(error.code) ? "locked" : "failed"
207
267
  return { status, version, destination, stranded, error, ...covers }
208
268
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmytro-prototypes/cadbridge-mcp",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "mcpName": "net.dmytro-prototypes/cadbridge-mcp-autocad",
5
5
  "description": "Drive a live AutoCAD session from an AI client through MCP",
6
6
  "type": "module",
@@ -30,8 +30,8 @@
30
30
  "LICENSE.txt"
31
31
  ],
32
32
  "optionalDependencies": {
33
- "@dmytro-prototypes/cadbridge-mcp-darwin-arm64": "0.1.14",
34
- "@dmytro-prototypes/cadbridge-mcp-win32-x64": "0.1.14"
33
+ "@dmytro-prototypes/cadbridge-mcp-darwin-arm64": "0.1.15",
34
+ "@dmytro-prototypes/cadbridge-mcp-win32-x64": "0.1.15"
35
35
  },
36
36
  "publishConfig": {
37
37
  "access": "public"