mendix-ruby-bridge 0.1.1

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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/.mxcli-version +2 -0
  3. data/LICENSE +21 -0
  4. data/README.md +77 -0
  5. data/bin/git-mendix +4 -0
  6. data/bin/mendix-apply +145 -0
  7. data/bin/mendix-desktop +26 -0
  8. data/bin/mendix-git +121 -0
  9. data/bin/mendix-ruby +580 -0
  10. data/bin/mxcli +20 -0
  11. data/bin/setup-tools +25 -0
  12. data/lib/mendix_bridge/backend_server.rb +1404 -0
  13. data/lib/mendix_bridge/change_planner.rb +594 -0
  14. data/lib/mendix_bridge/config.rb +89 -0
  15. data/lib/mendix_bridge/dependency_index.rb +188 -0
  16. data/lib/mendix_bridge/desktop_app.rb +121 -0
  17. data/lib/mendix_bridge/document_parser.rb +120 -0
  18. data/lib/mendix_bridge/domain_parser.rb +103 -0
  19. data/lib/mendix_bridge/dsl.rb +540 -0
  20. data/lib/mendix_bridge/enumeration_parser.rb +32 -0
  21. data/lib/mendix_bridge/git_workflow.rb +321 -0
  22. data/lib/mendix_bridge/html_viewer.rb +672 -0
  23. data/lib/mendix_bridge/importer.rb +415 -0
  24. data/lib/mendix_bridge/inventory.rb +93 -0
  25. data/lib/mendix_bridge/mdl_generator.rb +343 -0
  26. data/lib/mendix_bridge/microflow_parser.rb +131 -0
  27. data/lib/mendix_bridge/migration.rb +290 -0
  28. data/lib/mendix_bridge/migration_executor.rb +234 -0
  29. data/lib/mendix_bridge/model.rb +204 -0
  30. data/lib/mendix_bridge/page_parser.rb +95 -0
  31. data/lib/mendix_bridge/presenter.rb +35 -0
  32. data/lib/mendix_bridge/project_creator.rb +181 -0
  33. data/lib/mendix_bridge/ruby_inventory_generator.rb +63 -0
  34. data/lib/mendix_bridge/security_parser.rb +72 -0
  35. data/lib/mendix_bridge/snapshot_diff.rb +58 -0
  36. data/lib/mendix_bridge/validator.rb +46 -0
  37. data/lib/mendix_bridge/version.rb +5 -0
  38. data/lib/mendix_bridge/visual_entity_plan.rb +176 -0
  39. data/lib/mendix_bridge.rb +127 -0
  40. data/share/applications/mendix-ruby-bridge.desktop +12 -0
  41. data/share/icons/hicolor/128x128/apps/mendix-ruby-bridge.png +0 -0
  42. data/share/icons/hicolor/256x256/apps/mendix-ruby-bridge.png +0 -0
  43. data/share/icons/hicolor/32x32/apps/mendix-ruby-bridge.png +0 -0
  44. data/share/icons/hicolor/48x48/apps/mendix-ruby-bridge.png +0 -0
  45. data/share/icons/hicolor/512x512/apps/mendix-ruby-bridge.png +0 -0
  46. data/share/icons/hicolor/64x64/apps/mendix-ruby-bridge.png +0 -0
  47. data/web/dist/apple-touch-icon.png +0 -0
  48. data/web/dist/assets/index-BO6iPT1P.css +1 -0
  49. data/web/dist/assets/index-JGODw81W.js +27 -0
  50. data/web/dist/brand/mendix-ruby-bridge.png +0 -0
  51. data/web/dist/favicon.png +0 -0
  52. data/web/dist/icons.svg +24 -0
  53. data/web/dist/index.html +17 -0
  54. metadata +179 -0
@@ -0,0 +1,1404 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "tempfile"
6
+ require "time"
7
+ require "webrick"
8
+
9
+ module MendixBridge
10
+ class BackendServerError < StandardError; end
11
+
12
+ class BackendServer
13
+ INVENTORY_FILES = {
14
+ "/inventory/project-tree.json" => ["inventory", "project-tree.json"],
15
+ "/inventory/element-details.json" => ["inventory", "element-details.json"],
16
+ "/inventory/dependencies.json" => ["inventory", "dependencies.json"],
17
+ "/inventory/mendix-project.json" => ["mendix-project.json"],
18
+ "/inventory/ui-layouts.json" => ["inventory", "ui-layouts.json"]
19
+ }.freeze
20
+
21
+ attr_reader :port
22
+
23
+ def initialize(
24
+ inventory_dir:,
25
+ web_root:,
26
+ mxcli:,
27
+ bind: "127.0.0.1",
28
+ port: 4567,
29
+ logger: nil,
30
+ run_cmd: nil
31
+ )
32
+ @inventory_dir = File.expand_path(inventory_dir)
33
+ @web_root = File.expand_path(web_root)
34
+ @mxcli = File.expand_path(mxcli)
35
+ @layout_mutex = Mutex.new
36
+ @app_mutex = Mutex.new
37
+ @app_log = []
38
+ @app_pid = nil
39
+ @app_type = nil # :docker | :direct
40
+ @docker_running = false
41
+ @auth_user = nil
42
+ @run_cmd_override = run_cmd
43
+ validate!
44
+ @server = WEBrick::HTTPServer.new(
45
+ BindAddress: bind,
46
+ Port: port,
47
+ Logger: logger || WEBrick::Log.new(File::NULL),
48
+ AccessLog: []
49
+ )
50
+ @port = @server.listeners.first.addr[1]
51
+ mount_routes
52
+ end
53
+
54
+ def start
55
+ @server.start
56
+ end
57
+
58
+ def shutdown
59
+ @server.shutdown
60
+ end
61
+
62
+ private
63
+
64
+ def validate!
65
+ tree = File.join(@inventory_dir, "inventory", "project-tree.json")
66
+ raise BackendServerError, "not an imported inventory: #{@inventory_dir}" unless
67
+ File.file?(tree)
68
+ raise BackendServerError, "frontend build does not exist: #{@web_root}" unless
69
+ File.file?(File.join(@web_root, "index.html"))
70
+ raise BackendServerError, "mxcli is not executable: #{@mxcli}" unless
71
+ File.executable?(@mxcli)
72
+ end
73
+
74
+ def mount_routes
75
+ @server.mount_proc("/inventory") { |request, response| inventory(request, response) }
76
+ @server.mount_proc("/api/health") { |_request, response| health(response) }
77
+ @server.mount_proc("/api/layout") { |request, response| layout(request, response) }
78
+ @server.mount_proc("/api/entity-plan") do |request, response|
79
+ entity_plan(request, response)
80
+ end
81
+ @server.mount_proc("/api/marketplace/search") do |request, response|
82
+ marketplace_search(request, response)
83
+ end
84
+ @server.mount_proc("/api/marketplace/item") do |request, response|
85
+ marketplace_item(request, response)
86
+ end
87
+ @server.mount_proc("/api/git") { |request, response| git_route(request, response) }
88
+ @server.mount_proc("/api/page") { |request, response| page_route(request, response) }
89
+ @server.mount_proc("/api/flow") { |request, response| flow_route(request, response) }
90
+ @server.mount_proc("/api/drafts") { |_request, response| drafts(response) }
91
+ @server.mount_proc("/api/apply") { |request, response| apply_draft_route(request, response) }
92
+ @server.mount_proc("/api/create") { |request, response| create_element_route(request, response) }
93
+ @server.mount_proc("/api/query") { |request, response| query_route(request, response) }
94
+ @server.mount_proc("/api/errors") { |_request, response| errors_route(response) }
95
+ @server.mount_proc("/api/mdl") { |request, response| mdl_route(request, response) }
96
+ @server.mount_proc("/api/app") { |request, response| app_route(request, response) }
97
+ @server.mount_proc("/api/auth") { |request, response| auth_route(request, response) }
98
+ @server.mount_proc("/api/settings") { |request, response| settings_route(request, response) }
99
+ @server.mount_proc("/api/marketplace/install") do |request, response|
100
+ marketplace_install(request, response)
101
+ end
102
+ @server.mount(
103
+ "/",
104
+ WEBrick::HTTPServlet::FileHandler,
105
+ @web_root,
106
+ FancyIndexing: false
107
+ )
108
+ end
109
+
110
+ def inventory(request, response)
111
+ relative = INVENTORY_FILES[request.path]
112
+ return json(response, { error: "not found" }, status: 404) unless relative
113
+
114
+ path = File.join(@inventory_dir, *relative)
115
+ if request.path == "/inventory/ui-layouts.json" && !File.file?(path)
116
+ return json(response, {})
117
+ end
118
+ return json(response, { error: "not found" }, status: 404) unless File.file?(path)
119
+
120
+ response.status = 200
121
+ response["content-type"] = "application/json; charset=utf-8"
122
+ response["cache-control"] = "no-store"
123
+ response.body = File.binread(path)
124
+ end
125
+
126
+ def health(response)
127
+ metadata_path = File.join(@inventory_dir, "mendix-project.json")
128
+ metadata = File.file?(metadata_path) ? JSON.parse(File.read(metadata_path)) : {}
129
+ json(
130
+ response,
131
+ {
132
+ ok: true,
133
+ version: MendixBridge::VERSION,
134
+ element_count: metadata["element_count"],
135
+ imported_at: metadata["imported_at"],
136
+ capabilities: {
137
+ inventory: true,
138
+ dependencies: File.file?(
139
+ File.join(@inventory_dir, "inventory", "dependencies.json")
140
+ ),
141
+ layout_persistence: true,
142
+ visual_entity_plans: true,
143
+ marketplace_install: source_project ? true : false,
144
+ git: git_workflow ? true : false,
145
+ page_drafts: true,
146
+ flow_drafts: true,
147
+ apply_drafts: source_project ? true : false,
148
+ app_run: (source_project && !resolve_run_cmd(source_project).nil?) ? true : false
149
+ },
150
+ app: {
151
+ running: @app_mutex.synchronize { !@app_pid.nil? || @docker_running },
152
+ log_length: @app_mutex.synchronize { @app_log.length },
153
+ mode: @app_mutex.synchronize { @app_type }
154
+ },
155
+ auth: {
156
+ logged_in: !@auth_user.nil?,
157
+ username: @auth_user
158
+ }
159
+ }
160
+ )
161
+ end
162
+
163
+ def layout(request, response)
164
+ return json(response, { error: "method not allowed" }, status: 405) unless
165
+ request.request_method == "POST"
166
+
167
+ payload = JSON.parse(request.body.to_s)
168
+ qn = payload.fetch("qn")
169
+ positions = payload.fetch("positions")
170
+ unless qn.is_a?(String) && qn.match?(/\A[A-Za-z_]\w*\.[A-Za-z_]\w*\z/)
171
+ return json(response, { error: "invalid qualified name" }, status: 422)
172
+ end
173
+ unless positions.is_a?(Array) && positions.all? { |position| valid_position?(position) }
174
+ return json(response, { error: "invalid node positions" }, status: 422)
175
+ end
176
+ details_path = File.join(@inventory_dir, "inventory", "element-details.json")
177
+ details = JSON.parse(File.read(details_path))
178
+ return json(response, { error: "unknown element" }, status: 404) unless details.key?(qn)
179
+
180
+ path = File.join(@inventory_dir, "inventory", "ui-layouts.json")
181
+ @layout_mutex.synchronize do
182
+ layouts = File.file?(path) ? JSON.parse(File.read(path)) : {}
183
+ layouts[qn] = positions
184
+ temporary = "#{path}.tmp"
185
+ File.write(temporary, "#{JSON.pretty_generate(layouts)}\n")
186
+ File.rename(temporary, path)
187
+ end
188
+ preview = positions.map do |position|
189
+ "@position(#{position.fetch('x').round}, #{position.fetch('y').round}) " \
190
+ "#{position.fetch('label')}"
191
+ end.join("\n")
192
+ json(
193
+ response,
194
+ {
195
+ ok: true,
196
+ persisted: true,
197
+ message: "Layout positions saved in the Ruby inventory.",
198
+ mdlPreview: preview
199
+ }
200
+ )
201
+ rescue JSON::ParserError, KeyError
202
+ json(response, { error: "invalid JSON payload" }, status: 400)
203
+ end
204
+
205
+ def marketplace_search(request, response)
206
+ query = request.query["q"].to_s
207
+ limit = [[request.query["limit"].to_i, 1].max, 100].min
208
+ refresh = %w[1 true yes].include?(request.query["refresh"].to_s)
209
+
210
+ # Live search against the official Mendix marketplace via mxcli. The
211
+ # marketplace is entirely behind Mendix SSO, so this only works with a
212
+ # logged-in credential; --refresh bypasses mxcli's local catalog cache.
213
+ args = ["marketplace", "search", query, "--limit", limit.to_s, "--json"]
214
+ args << "--refresh" if refresh
215
+ output = run_mxcli(*args)
216
+ data = JSON.parse(output)
217
+ json(response, { items: normalize_marketplace(data), source: "live" })
218
+ rescue BackendServerError => error
219
+ # Not logged in (or mxcli failed): serve the bundled offline catalog so
220
+ # browsing still works. Flag it so the UI can invite the user to log in
221
+ # for the full live marketplace.
222
+ items = catalog_search(query, limit)
223
+ needs_login = auth_error?(error.message)
224
+ if items
225
+ return json(response, {
226
+ items: items, source: "offline", needs_login: needs_login,
227
+ message: needs_login ? "Log in to Mendix for live marketplace search." : nil
228
+ })
229
+ end
230
+
231
+ json(response, { error: error.message }, status: 502)
232
+ rescue JSON::ParserError
233
+ json(response, { error: "mxcli returned invalid marketplace JSON" }, status: 502)
234
+ end
235
+
236
+ # True when an mxcli error is about a missing/expired credential.
237
+ def auth_error?(message)
238
+ message.to_s.match?(/no credential|auth login|not authenticated|unauthorized/i)
239
+ end
240
+
241
+ # Path of the offline marketplace catalog: refreshed copy in the inventory
242
+ # dir wins over the seed bundled with the gem. Override with
243
+ # MRB_MARKETPLACE_CATALOG.
244
+ def marketplace_catalog_path
245
+ [
246
+ ENV["MRB_MARKETPLACE_CATALOG"],
247
+ File.join(@inventory_dir, "marketplace_catalog.json"),
248
+ File.expand_path("../../data/marketplace_catalog.json", __dir__)
249
+ ].compact.find { |path| File.file?(path) }
250
+ end
251
+
252
+ def marketplace_catalog
253
+ path = marketplace_catalog_path
254
+ return nil unless path
255
+
256
+ data = JSON.parse(File.read(path))
257
+ data.is_a?(Array) ? data : data.fetch("items", [])
258
+ rescue JSON::ParserError
259
+ nil
260
+ end
261
+
262
+ def catalog_search(query, limit)
263
+ items = marketplace_catalog
264
+ return nil unless items
265
+
266
+ q = query.strip.downcase
267
+ hits = items.select do |item|
268
+ q.empty? || %w[name summary category publisher].any? { |k| item[k].to_s.downcase.include?(q) }
269
+ end
270
+ hits.first(limit).map { |item| normalize_marketplace_item(item) }
271
+ end
272
+
273
+ def entity_plan(request, response)
274
+ return json(response, { error: "method not allowed" }, status: 405) unless
275
+ request.request_method == "POST"
276
+
277
+ payload = JSON.parse(request.body.to_s)
278
+ inventory = Inventory.load(
279
+ File.join(@inventory_dir, "inventory", "project-tree.json"),
280
+ details_path: File.join(@inventory_dir, "inventory", "element-details.json")
281
+ )
282
+ result = VisualEntityPlan.build(inventory:, payload:)
283
+ persist_visual_plan(payload.fetch("qn"), payload, result)
284
+ json(response, result)
285
+ rescue JSON::ParserError, KeyError
286
+ json(response, { error: "invalid JSON payload" }, status: 400)
287
+ rescue ArgumentError => error
288
+ json(response, { error: error.message }, status: 422)
289
+ end
290
+
291
+ def marketplace_item(request, response)
292
+ id = request.path.delete_prefix("/api/marketplace/item/")
293
+ return json(response, { error: "invalid marketplace id" }, status: 422) unless
294
+ id.match?(/\A[\w.-]+\z/)
295
+
296
+ # Offline catalog entries (non-numeric ids) resolve locally.
297
+ unless id.match?(/\A\d+\z/)
298
+ hit = (marketplace_catalog || []).find { |item| item["id"].to_s == id }
299
+ return json(response, normalize_marketplace_item(hit)) if hit
300
+
301
+ return json(response, { error: "unknown catalog id" }, status: 404)
302
+ end
303
+
304
+ output = run_mxcli("marketplace", "info", id, "--json")
305
+ json(response, normalize_marketplace_item(JSON.parse(output)))
306
+ rescue BackendServerError => error
307
+ hit = (marketplace_catalog || []).find { |item| item["id"].to_s == id }
308
+ return json(response, normalize_marketplace_item(hit)) if hit
309
+
310
+ json(response, { error: error.message }, status: 502)
311
+ rescue JSON::ParserError
312
+ json(response, { error: "mxcli returned invalid marketplace JSON" }, status: 502)
313
+ end
314
+
315
+ # Guarded Git operations for the imported Mendix project, surfaced to the
316
+ # viewer. Mutating actions require `studio_closed: true` (Studio Pro locks
317
+ # the .mpr), matching bin/mendix-git. Returns 503 when the Git/Mendix
318
+ # toolchain is unavailable so the frontend can hide the panel.
319
+ def git_route(request, response)
320
+ workflow = git_workflow
321
+ return json(response, { error: git_workflow_error }, status: 503) unless workflow
322
+
323
+ action = request.path.delete_prefix("/api/git").sub(%r{\A/}, "")
324
+ post = request.request_method == "POST"
325
+ payload = post && !request.body.to_s.empty? ? JSON.parse(request.body.to_s) : {}
326
+ closed = payload["studio_closed"] == true
327
+
328
+ result =
329
+ case [request.request_method, action]
330
+ when ["GET", "status"] then workflow.status
331
+ when ["GET", "branches"] then { "branches" => workflow.branches, "current" => workflow.status["branch"] }
332
+ when ["GET", "stash"] then { "stash" => workflow.stash_list.lines.map(&:strip).reject(&:empty?) }
333
+ when ["POST", "fetch"] then workflow.fetch && workflow.status
334
+ when ["POST", "switch"] then workflow.switch(payload.fetch("branch"), studio_closed: closed)
335
+ when ["POST", "create"] then workflow.create(payload.fetch("branch"), studio_closed: closed)
336
+ when ["POST", "commit"] then workflow.commit(payload.fetch("message"), studio_closed: closed)
337
+ when ["POST", "stash"]
338
+ workflow.stash_push(
339
+ studio_closed: closed,
340
+ message: payload["message"],
341
+ include_untracked: payload["include_untracked"] == true
342
+ )
343
+ when ["POST", "stash/apply"]
344
+ workflow.stash_apply(
345
+ payload.fetch("reference", "stash@{0}"),
346
+ studio_closed: closed,
347
+ drop: payload["drop"] == true
348
+ )
349
+ when ["POST", "stash/drop"]
350
+ { "dropped" => workflow.stash_drop(payload.fetch("reference", "stash@{0}")) }
351
+ else
352
+ return json(response, { error: "unknown git action" }, status: 404)
353
+ end
354
+
355
+ json(response, result)
356
+ rescue KeyError => error
357
+ json(response, { error: "missing parameter: #{error.key}" }, status: 400)
358
+ rescue JSON::ParserError
359
+ json(response, { error: "invalid JSON payload" }, status: 400)
360
+ rescue GitWorkflowError => error
361
+ json(response, { error: error.message }, status: 409)
362
+ end
363
+
364
+ def git_workflow
365
+ return @git_workflow if defined?(@git_workflow)
366
+
367
+ @git_workflow_error = nil
368
+ metadata_path = File.join(@inventory_dir, "mendix-project.json")
369
+ source = File.file?(metadata_path) ? JSON.parse(File.read(metadata_path))["source_project"] : nil
370
+ @git_workflow =
371
+ if source && File.file?(source)
372
+ GitWorkflow.new(source, inventory_dir: @inventory_dir, mxcli: @mxcli)
373
+ end
374
+ rescue GitWorkflowError, JSON::ParserError => error
375
+ @git_workflow_error = error.message
376
+ @git_workflow = nil
377
+ end
378
+
379
+ def git_workflow_error
380
+ @git_workflow_error || "Git workflow is unavailable for this project."
381
+ end
382
+
383
+ def run_mxcli(*arguments)
384
+ stdout, stderr, status = Open3.capture3(@mxcli, *arguments)
385
+ return stdout if status.success?
386
+
387
+ message = stderr.lines.reject { |line| line.start_with?("WARNING:") }.join.strip
388
+ raise BackendServerError, message
389
+ end
390
+
391
+ def normalize_marketplace(data)
392
+ items = data.is_a?(Array) ? data : data.fetch("items", data.fetch("results", []))
393
+ items.map { |item| normalize_marketplace_item(item) }
394
+ end
395
+
396
+ def normalize_marketplace_item(item)
397
+ {
398
+ id: pick(item, "id", "contentId", "content_id").to_s,
399
+ name: pick(item, "name", "title"),
400
+ publisher: pick(item, "publisher", "provider"),
401
+ latestVersion: pick(item, "latestVersion", "latest_version", "version"),
402
+ category: pick(item, "category", "type"),
403
+ rating: pick(item, "rating"),
404
+ downloads: pick(item, "downloads", "downloadCount", "download_count"),
405
+ summary: pick(item, "summary", "description"),
406
+ url: pick(item, "url", "marketplaceUrl", "marketplace_url")
407
+ }.compact
408
+ end
409
+
410
+ def pick(item, *keys)
411
+ keys.each { |key| return item[key] if item.key?(key) }
412
+ nil
413
+ end
414
+
415
+ def valid_position?(position)
416
+ position.is_a?(Hash) &&
417
+ position["id"].is_a?(String) &&
418
+ position["label"].is_a?(String) &&
419
+ position["x"].is_a?(Numeric) &&
420
+ position["y"].is_a?(Numeric)
421
+ end
422
+
423
+ def persist_visual_plan(qn, payload, result)
424
+ path = File.join(@inventory_dir, "inventory", "visual-plans.json")
425
+ @layout_mutex.synchronize do
426
+ plans = File.file?(path) ? JSON.parse(File.read(path)) : {}
427
+ plans[qn] = {
428
+ "saved_at" => Time.now.iso8601,
429
+ "request" => payload,
430
+ "result" => result
431
+ }
432
+ temporary = "#{path}.tmp"
433
+ File.write(temporary, "#{JSON.pretty_generate(plans)}\n")
434
+ File.rename(temporary, path)
435
+ end
436
+ end
437
+
438
+ # Accepts a visually-built page body, wraps it in CREATE OR MODIFY PAGE using
439
+ # the page's imported settings, validates the MDL with `mxcli check`, and saves
440
+ # a reviewable draft. It never writes the .mpr — applying stays in the guarded
441
+ # workflow (mxcli exec / bin/mendix-apply).
442
+ def page_route(request, response)
443
+ return json(response, { error: "method not allowed" }, status: 405) unless
444
+ request.request_method == "POST"
445
+
446
+ payload = JSON.parse(request.body.to_s)
447
+ qn = payload.fetch("qn")
448
+ detail = page_detail(qn)
449
+ return json(response, { error: "unknown page" }, status: 404) unless detail
450
+
451
+ # Two modes: "alter" sends a raw ALTER PAGE statement (in-place edits,
452
+ # preserving everything else on the page); default regenerates the whole
453
+ # page from the edited widget tree (CREATE OR MODIFY).
454
+ if payload["mode"] == "alter"
455
+ mdl = payload.fetch("mdl").to_s
456
+ content = payload["content"].to_s
457
+ else
458
+ content = payload.fetch("content").to_s
459
+ mdl = build_page_mdl(qn, detail, content)
460
+ end
461
+ ok, message = check_mdl(mdl)
462
+ persist_page_draft(qn, content, mdl, ok, message)
463
+ json(
464
+ response,
465
+ { ok:, mdl:, message: ok ? "Page MDL validated and draft saved." : message },
466
+ status: ok ? 200 : 422
467
+ )
468
+ rescue KeyError => error
469
+ json(response, { error: "missing parameter: #{error.key}" }, status: 400)
470
+ rescue JSON::ParserError
471
+ json(response, { error: "invalid JSON payload" }, status: 400)
472
+ end
473
+
474
+ # Accepts a rebuilt microflow/nanoflow body from the flow editor, wraps it in
475
+ # CREATE OR MODIFY MICROFLOW/NANOFLOW using the imported signature, validates
476
+ # with `mxcli check`, and saves a reviewable draft — same contract as pages.
477
+ def flow_route(request, response)
478
+ return json(response, { error: "method not allowed" }, status: 405) unless
479
+ request.request_method == "POST"
480
+
481
+ payload = JSON.parse(request.body.to_s)
482
+ qn = payload.fetch("qn")
483
+ body = payload.fetch("body").to_s
484
+ detail = page_detail(qn)
485
+ return json(response, { error: "unknown flow" }, status: 404) unless detail
486
+
487
+ mdl = build_flow_mdl(qn, detail, body)
488
+ ok, message = check_mdl(mdl)
489
+ persist_draft("flow-plans.json", qn, "body" => body, "mdl" => mdl, "valid" => ok, "message" => message)
490
+ json(
491
+ response,
492
+ { ok:, mdl:, message: ok ? "Flow MDL validated and draft saved." : message },
493
+ status: ok ? 200 : 422
494
+ )
495
+ rescue KeyError => error
496
+ json(response, { error: "missing parameter: #{error.key}" }, status: 400)
497
+ rescue JSON::ParserError
498
+ json(response, { error: "invalid JSON payload" }, status: 400)
499
+ end
500
+
501
+ def build_flow_mdl(qn, detail, body)
502
+ keyword = detail["mdl"].to_s.match?(/\bnanoflow\b/i) ? "NANOFLOW" : "MICROFLOW"
503
+ params = Array(detail["parameters"]).map do |parameter|
504
+ "$#{parameter['name']}: #{parameter['type']}"
505
+ end
506
+ header = +"CREATE OR MODIFY #{keyword} #{qn} (#{params.join(', ')})"
507
+ header << "\nRETURNS #{detail['return_type']}" if detail["return_type"]
508
+ header << "\nFOLDER '#{escape_mdl(detail['folder'])}'" if detail["folder"]
509
+ indented = body.strip.empty? ? "" : body.lines.map { |line| line.rstrip }.join("\n")
510
+ "#{header}\nBEGIN\n#{indented}\nEND;\n"
511
+ end
512
+
513
+ # Lists the reviewable drafts saved by the visual builders so the viewer can
514
+ # surface them (they only exist as inventory sidecars otherwise).
515
+ def drafts(response)
516
+ read = lambda do |file|
517
+ path = File.join(@inventory_dir, "inventory", file)
518
+ File.file?(path) ? JSON.parse(File.read(path)) : {}
519
+ end
520
+ json(
521
+ response,
522
+ {
523
+ entities: read.call("visual-plans.json"),
524
+ pages: read.call("page-plans.json"),
525
+ flows: read.call("flow-plans.json")
526
+ }
527
+ )
528
+ end
529
+
530
+ # Guarded marketplace install, Studio Pro-like: downloads the module and
531
+ # imports it into the source project via mxcli, then refreshes the inventory
532
+ # so the new module shows up in the explorer. Requires confirming Studio Pro
533
+ # is closed (the .mpr is locked while it runs).
534
+ def marketplace_install(request, response)
535
+ return json(response, { error: "method not allowed" }, status: 405) unless
536
+ request.request_method == "POST"
537
+
538
+ payload = JSON.parse(request.body.to_s)
539
+ id = payload.fetch("id").to_s
540
+ if id.start_with?("seed-")
541
+ return json(
542
+ response,
543
+ { ok: false, message: "Offline catalog entry — installing requires a Mendix login (mxcli auth login), which also refreshes the catalog with real ids." },
544
+ status: 403
545
+ )
546
+ end
547
+ return json(response, { error: "invalid marketplace id" }, status: 422) unless
548
+ id.match?(/\A\d+\z/)
549
+ unless payload["studio_closed"] == true
550
+ return json(
551
+ response,
552
+ { ok: false, message: "Confirm Studio Pro is closed before installing." },
553
+ status: 403
554
+ )
555
+ end
556
+
557
+ project = source_project
558
+ return json(response, { error: "source project unavailable" }, status: 503) unless project
559
+
560
+ arguments = ["marketplace", "install", id]
561
+ arguments.concat(["--version", payload["version"].to_s]) if payload["version"]
562
+ arguments.concat(["-p", project])
563
+ output = run_mxcli(*arguments)
564
+ refresh_error = refresh_inventory(project)
565
+
566
+ json(
567
+ response,
568
+ {
569
+ ok: true,
570
+ message: refresh_error || "Module installed and inventory refreshed.",
571
+ output: output.lines.last(6).join.strip
572
+ }
573
+ )
574
+ rescue KeyError => error
575
+ json(response, { error: "missing parameter: #{error.key}" }, status: 400)
576
+ rescue JSON::ParserError
577
+ json(response, { error: "invalid JSON payload" }, status: 400)
578
+ rescue BackendServerError => error
579
+ if auth_error?(error.message)
580
+ return json(
581
+ response,
582
+ { ok: false, needs_login: true,
583
+ message: "Log in to Mendix first (👤 button) to install marketplace modules." },
584
+ status: 401
585
+ )
586
+ end
587
+ # Trim the mxcli usage/help dump to the first meaningful error line.
588
+ brief = error.message.to_s.lines.map(&:strip)
589
+ .find { |l| !l.empty? && !l.start_with?("WARNING:", "Usage:", "hint:") } || error.message
590
+ json(response, { ok: false, message: "Install failed: #{brief}" }, status: 502)
591
+ end
592
+
593
+ # Applies a validated page or flow draft to the source .mpr via mxcli exec,
594
+ # then removes the draft entry and refreshes the inventory.
595
+ def apply_draft_route(request, response)
596
+ return json(response, { error: "method not allowed" }, status: 405) unless
597
+ request.request_method == "POST"
598
+
599
+ payload = JSON.parse(request.body.to_s)
600
+ qn = payload.fetch("qn")
601
+ type = payload.fetch("type")
602
+ unless payload["studio_closed"] == true
603
+ return json(
604
+ response,
605
+ { ok: false, message: "Confirm Studio Pro is closed before applying." },
606
+ status: 403
607
+ )
608
+ end
609
+
610
+ project = source_project
611
+ return json(response, { error: "source project unavailable" }, status: 503) unless project
612
+
613
+ file = type == "flow" ? "flow-plans.json" : "page-plans.json"
614
+ draft = read_draft(file, qn)
615
+ return json(response, { error: "draft not found for #{qn}" }, status: 404) unless draft
616
+ unless draft["valid"]
617
+ return json(
618
+ response,
619
+ { ok: false, message: "Draft is not valid; save a valid version first." },
620
+ status: 422
621
+ )
622
+ end
623
+
624
+ mdl = draft["mdl"].to_s
625
+ if mdl.strip.empty?
626
+ return json(response, { ok: false, message: "Draft MDL is empty." }, status: 422)
627
+ end
628
+
629
+ Tempfile.create(["mendix-apply-draft-", ".mdl"]) do |file_handle|
630
+ file_handle.write(mdl)
631
+ file_handle.flush
632
+ stdout, stderr, status = Open3.capture3(
633
+ @mxcli, "exec", file_handle.path, "-p", project
634
+ )
635
+ unless status.success?
636
+ detail = (stderr.empty? ? stdout : stderr).strip
637
+ return json(response, { ok: false, message: "Apply failed: #{detail}" }, status: 422)
638
+ end
639
+ end
640
+
641
+ delete_draft(file, qn)
642
+ # The inventory refresh describes every element and can take a while —
643
+ # run it in the background so the Apply button returns right away.
644
+ refresh_inventory_async(project)
645
+ json(
646
+ response,
647
+ { ok: true, message: "Draft applied — inventory refresh running in background." }
648
+ )
649
+ rescue KeyError => error
650
+ json(response, { error: "missing parameter: #{error.key}" }, status: 400)
651
+ rescue JSON::ParserError
652
+ json(response, { error: "invalid JSON payload" }, status: 400)
653
+ end
654
+
655
+ # Create a new element (module, page, microflow, nanoflow, entity,
656
+ # enumeration) in the source .mpr via mxcli exec, Studio Pro-style. An
657
+ # optional folder path places the document (folders are created by MOVE).
658
+ IDENTIFIER = /\A[A-Za-z][A-Za-z0-9_]*\z/
659
+ FOLDER_PATH = %r{\A[A-Za-z0-9_ -]+(/[A-Za-z0-9_ -]+)*\z}
660
+
661
+ CREATE_KINDS = {
662
+ "module" => nil,
663
+ "page" => "PAGE",
664
+ "microflow" => "MICROFLOW",
665
+ "nanoflow" => "NANOFLOW",
666
+ "entity" => "ENTITY",
667
+ "enumeration" => "ENUMERATION"
668
+ }.freeze
669
+
670
+ def create_element_route(request, response)
671
+ return json(response, { error: "method not allowed" }, status: 405) unless
672
+ request.request_method == "POST"
673
+
674
+ payload = JSON.parse(request.body.to_s)
675
+ kind = payload.fetch("kind").to_s
676
+ name = payload.fetch("name").to_s
677
+ mod = payload["module"].to_s
678
+ folder = payload["folder"].to_s.strip
679
+
680
+ return json(response, { error: "unknown kind: #{kind}" }, status: 422) unless
681
+ CREATE_KINDS.key?(kind)
682
+ return json(response, { error: "invalid name" }, status: 422) unless name.match?(IDENTIFIER)
683
+ if kind != "module"
684
+ return json(response, { error: "invalid module" }, status: 422) unless mod.match?(IDENTIFIER)
685
+ end
686
+ if !folder.empty? && !folder.match?(FOLDER_PATH)
687
+ return json(response, { error: "invalid folder path" }, status: 422)
688
+ end
689
+ unless payload["studio_closed"] == true
690
+ return json(
691
+ response,
692
+ { ok: false, message: "Confirm Studio Pro is closed before creating elements." },
693
+ status: 403
694
+ )
695
+ end
696
+
697
+ project = source_project
698
+ return json(response, { error: "source project unavailable" }, status: 503) unless project
699
+
700
+ mdl =
701
+ case kind
702
+ when "module" then "CREATE MODULE #{name};"
703
+ when "page" then "CREATE PAGE #{mod}.#{name} (Title: '#{name}', Layout: Atlas_Core.Atlas_Default) {}"
704
+ when "microflow" then "CREATE MICROFLOW #{mod}.#{name} ()\nBEGIN\n return;\nEND;"
705
+ when "nanoflow" then "CREATE NANOFLOW #{mod}.#{name} ()\nBEGIN\n return;\nEND;"
706
+ when "entity" then "CREATE ENTITY #{mod}.#{name};"
707
+ when "enumeration" then "CREATE ENUMERATION #{mod}.#{name} VALUES ('Default');"
708
+ end
709
+ if kind != "module" && !folder.empty?
710
+ mdl += "\nMOVE #{CREATE_KINDS[kind]} #{mod}.#{name} TO FOLDER '#{folder}';"
711
+ end
712
+
713
+ Tempfile.create(["mendix-create-", ".mdl"]) do |file_handle|
714
+ file_handle.write("#{mdl}\n")
715
+ file_handle.flush
716
+ stdout, stderr, status = Open3.capture3(@mxcli, "exec", file_handle.path, "-p", project)
717
+ unless status.success?
718
+ detail = (stderr.empty? ? stdout : stderr).strip
719
+ return json(response, { ok: false, message: "Create failed: #{detail}" }, status: 422)
720
+ end
721
+ end
722
+
723
+ refresh_inventory_async(project)
724
+ qn = kind == "module" ? name : "#{mod}.#{name}"
725
+ json(response, { ok: true, qn: qn, message: "#{kind.capitalize} #{qn} created — inventory refreshing." })
726
+ rescue KeyError => error
727
+ json(response, { error: "missing parameter: #{error.key}" }, status: 400)
728
+ rescue JSON::ParserError
729
+ json(response, { error: "invalid JSON payload" }, status: 400)
730
+ end
731
+
732
+ # Read-only data queries against the project. OQL runs against the running
733
+ # Mendix app via the M2EE admin API (rollback/preview mode); SQL runs
734
+ # against the configured database. Neither needs a marketplace login — OQL
735
+ # is a built-in Mendix query language, not an installable module.
736
+ def query_route(request, response)
737
+ return json(response, { error: "method not allowed" }, status: 405) unless
738
+ request.request_method == "POST"
739
+
740
+ payload = JSON.parse(request.body.to_s)
741
+ engine = payload.fetch("engine").to_s
742
+ query = payload.fetch("query").to_s.strip
743
+ return json(response, { ok: false, message: "Query is empty." }, status: 422) if query.empty?
744
+ unless %w[oql sql].include?(engine)
745
+ return json(response, { error: "unknown engine: #{engine}" }, status: 422)
746
+ end
747
+
748
+ project = source_project
749
+ return json(response, { error: "source project unavailable" }, status: 503) unless project
750
+
751
+ args =
752
+ if engine == "oql"
753
+ [@mxcli, "oql", "-p", project, "--json", query]
754
+ else
755
+ dsn = sql_dsn
756
+ unless dsn
757
+ return json(
758
+ response,
759
+ { ok: false, message: "No database connection configured. Set it in Settings (DB host/port/name/user/password)." },
760
+ status: 422
761
+ )
762
+ end
763
+ [@mxcli, "sql", "-p", project, "--json", "--dsn", dsn, query]
764
+ end
765
+
766
+ stdout, stderr, status = Open3.capture3(*args)
767
+ unless status.success?
768
+ detail = clean_mxcli_error(stderr.empty? ? stdout : stderr)
769
+ hint = engine == "oql" ? " (is the app running? OQL needs the app up)" : ""
770
+ return json(response, { ok: false, message: "Query failed: #{detail}#{hint}" }, status: 422)
771
+ end
772
+
773
+ rows = parse_query_rows(stdout)
774
+ json(response, { ok: true, engine: engine, rows: rows, count: rows.length })
775
+ rescue KeyError => error
776
+ json(response, { error: "missing parameter: #{error.key}" }, status: 400)
777
+ rescue JSON::ParserError
778
+ json(response, { error: "invalid JSON payload" }, status: 400)
779
+ end
780
+
781
+ # Build a Postgres DSN from the project's .docker/.env. External DB mode
782
+ # points at the configured host; local Docker mode reaches the container's
783
+ # published port on localhost.
784
+ def sql_dsn
785
+ path = docker_env_path
786
+ return nil unless path
787
+
788
+ env = parse_env_file(path)
789
+ name = env["DB_NAME"].to_s
790
+ user = env["DB_USER"].to_s
791
+ pass = env["DB_PASSWORD"].to_s
792
+ return nil if name.empty? || user.empty?
793
+
794
+ if env["DB_MODE"].to_s == "external"
795
+ host = env["EXT_DB_HOST"].to_s
796
+ port = env.fetch("EXT_DB_PORT", "5432")
797
+ return nil if host.empty?
798
+ else
799
+ host = "localhost"
800
+ port = env.fetch("DB_PORT", "5432")
801
+ end
802
+
803
+ require "cgi"
804
+ "postgres://#{CGI.escape(user)}:#{CGI.escape(pass)}@#{host}:#{port}/#{name}?sslmode=disable"
805
+ end
806
+
807
+ def parse_query_rows(stdout)
808
+ out = stdout.to_s.strip
809
+ return [] if out.empty?
810
+
811
+ data = JSON.parse(out)
812
+ data.is_a?(Array) ? data : [data]
813
+ rescue JSON::ParserError
814
+ # Fall back to raw text so the user still sees the tool's output.
815
+ [{ "output" => stdout.to_s.strip }]
816
+ end
817
+
818
+ def clean_mxcli_error(text)
819
+ text.to_s.lines.reject { |l| l.start_with?("WARNING:") }.join.strip
820
+ end
821
+
822
+ # Project consistency check via `mxcli lint`. Powers the Errors tab —
823
+ # naming, empty microflows, missing access rules, security rules, etc.
824
+ def errors_route(response)
825
+ project = source_project
826
+ return json(response, { error: "source project unavailable" }, status: 503) unless project
827
+
828
+ # A non-zero exit just means violations were found, so ignore the status.
829
+ stdout, stderr, = Open3.capture3(@mxcli, "lint", "-p", project, "-f", "json")
830
+ # lint prints a catalog-building progress preamble before the JSON body;
831
+ # slice from the first brace.
832
+ brace = stdout.index("{")
833
+ unless brace
834
+ detail = clean_mxcli_error(stderr.empty? ? stdout : stderr)
835
+ return json(response, { ok: false, message: "Lint failed: #{detail}" }, status: 502)
836
+ end
837
+
838
+ data = JSON.parse(stdout[brace..])
839
+ violations = (data["violations"] || []).map do |v|
840
+ {
841
+ ruleId: v["ruleId"],
842
+ severity: v["severity"] || "warning",
843
+ message: v["message"],
844
+ module: v["module"],
845
+ document: v["document"],
846
+ documentType: v["documentType"],
847
+ qn: [v["module"], v["document"]].compact.join("."),
848
+ suggestion: v["suggestion"]
849
+ }
850
+ end
851
+ json(response, { ok: true, violations: violations, count: violations.length })
852
+ rescue JSON::ParserError
853
+ json(response, { ok: false, message: "mxcli lint returned invalid JSON." }, status: 502)
854
+ end
855
+
856
+ # General-purpose MDL execution primitive. Powers live editing from the AI
857
+ # chat (agents emit MDL; the user validates/applies it) and any other
858
+ # feature that needs to run arbitrary MDL. `apply: false` only validates
859
+ # (mxcli check); `apply: true` runs `mxcli exec` and requires Studio Pro
860
+ # closed, then refreshes the inventory in the background.
861
+ def mdl_route(request, response)
862
+ return json(response, { error: "method not allowed" }, status: 405) unless
863
+ request.request_method == "POST"
864
+
865
+ payload = JSON.parse(request.body.to_s)
866
+ mdl = payload.fetch("mdl").to_s
867
+ return json(response, { ok: false, message: "MDL is empty." }, status: 422) if mdl.strip.empty?
868
+
869
+ apply = payload["apply"] == true
870
+ project = source_project
871
+ return json(response, { error: "source project unavailable" }, status: 503) unless project
872
+
873
+ ok, message = check_mdl(mdl)
874
+ return json(response, { ok: false, applied: false, message: message }, status: 422) unless ok
875
+
876
+ unless apply
877
+ return json(response, { ok: true, applied: false, message: "MDL is valid." })
878
+ end
879
+ unless payload["studio_closed"] == true
880
+ return json(
881
+ response,
882
+ { ok: false, applied: false, needs_studio_closed: true,
883
+ message: "Confirm Studio Pro is closed to apply changes to the project." },
884
+ status: 403
885
+ )
886
+ end
887
+
888
+ Tempfile.create(["mendix-mdl-", ".mdl"]) do |file_handle|
889
+ file_handle.write(mdl)
890
+ file_handle.flush
891
+ stdout, stderr, status = Open3.capture3(@mxcli, "exec", file_handle.path, "-p", project)
892
+ unless status.success?
893
+ detail = clean_mxcli_error(stderr.empty? ? stdout : stderr)
894
+ return json(response, { ok: false, applied: false, message: "Apply failed: #{detail}" }, status: 422)
895
+ end
896
+ end
897
+
898
+ refresh_inventory_async(project)
899
+ json(response, { ok: true, applied: true, message: "MDL applied — inventory refreshing in background." })
900
+ rescue KeyError => error
901
+ json(response, { error: "missing parameter: #{error.key}" }, status: 400)
902
+ rescue JSON::ParserError
903
+ json(response, { error: "invalid JSON payload" }, status: 400)
904
+ end
905
+
906
+ # ---- app run/stop/log ------------------------------------------------
907
+
908
+ def app_route(request, response)
909
+ action = request.path.delete_prefix("/api/app").sub(%r{\A/}, "")
910
+ case [request.request_method, action]
911
+ when ["POST", "run"] then app_run(response)
912
+ when ["POST", "stop"] then app_stop(response)
913
+ when ["GET", "status"] then app_status(response)
914
+ when ["GET", "log"] then app_log_response(request, response)
915
+ else json(response, { error: "not found" }, status: 404)
916
+ end
917
+ end
918
+
919
+ def app_run(response)
920
+ already = @app_mutex.synchronize { !@app_pid.nil? || @docker_running }
921
+ return json(response, { ok: false, message: "Already running.", running: true }, status: 409) if already
922
+
923
+ project = source_project
924
+ return json(response, { ok: false, message: "No source project configured.", running: false }, status: 503) unless project
925
+
926
+ mode, cmd = resolve_run_mode(project)
927
+ unless cmd
928
+ return json(
929
+ response,
930
+ {
931
+ ok: false, running: false,
932
+ message: "No run command found. Add a local mxcli to the project directory, " \
933
+ "create a .mendix-run-cmd file, or set MRB_RUN_CMD env var."
934
+ },
935
+ status: 503
936
+ )
937
+ end
938
+
939
+ @app_mutex.synchronize do
940
+ # Append (don't reset): log consumers poll by offset, and a reset
941
+ # would strand them past the end of the new, shorter log.
942
+ @app_log << "" unless @app_log.empty?
943
+ @app_log << "$ #{cmd.join(' ')}" << "Starting…"
944
+ @app_type = mode
945
+ @docker_running = false
946
+ end
947
+
948
+ Thread.new do
949
+ success = false
950
+ begin
951
+ Open3.popen2e(*cmd) do |stdin, stdouterr, wait_thr|
952
+ stdin.close
953
+ @app_mutex.synchronize { @app_pid = wait_thr.pid }
954
+ stdouterr.each_line do |line|
955
+ @app_mutex.synchronize do
956
+ @app_log << line.chomp
957
+ @app_log = @app_log.last(2000)
958
+ end
959
+ end
960
+ success = wait_thr.value.success?
961
+ end
962
+ rescue Errno::ENOENT => error
963
+ @app_mutex.synchronize { @app_log << "[ERROR] Command not found: #{error.message}" }
964
+ rescue StandardError => error
965
+ @app_mutex.synchronize { @app_log << "[ERROR] #{error.message}" }
966
+ ensure
967
+ @app_mutex.synchronize do
968
+ @app_pid = nil
969
+ if mode == :docker && success
970
+ # Containers are running independently; keep @docker_running true
971
+ @docker_running = true
972
+ @app_log << "[App running in Docker containers]"
973
+ else
974
+ @docker_running = false
975
+ @app_log << "[Stopped]" unless success && mode == :docker
976
+ end
977
+ end
978
+ end
979
+ end
980
+
981
+ json(response, { ok: true, message: "Starting Mendix application…", running: true })
982
+ end
983
+
984
+ def app_stop(response)
985
+ project = source_project
986
+ type, docker = @app_mutex.synchronize { [@app_type, @docker_running] }
987
+ pid = @app_mutex.synchronize { @app_pid }
988
+
989
+ if type == :docker && (docker || pid)
990
+ stop_cmd = docker_stop_cmd(project)
991
+ if stop_cmd
992
+ Thread.new do
993
+ @app_mutex.synchronize { @app_log << "$ #{stop_cmd.join(' ')}" }
994
+ Open3.popen2e(*stop_cmd) do |stdin, stdouterr, wait_thr|
995
+ stdin.close
996
+ stdouterr.each_line { |l| @app_mutex.synchronize { @app_log << l.chomp } }
997
+ wait_thr.value
998
+ end
999
+ @app_mutex.synchronize do
1000
+ @docker_running = false
1001
+ @app_pid = nil
1002
+ @app_log << "[Containers stopped]"
1003
+ end
1004
+ rescue StandardError => e
1005
+ @app_mutex.synchronize { @docker_running = false; @app_log << "[ERROR] #{e.message}" }
1006
+ end
1007
+ return json(response, { ok: true, message: "Stopping Docker containers…" })
1008
+ end
1009
+ end
1010
+
1011
+ if pid
1012
+ begin
1013
+ Process.kill("TERM", pid)
1014
+ rescue Errno::ESRCH
1015
+ @app_mutex.synchronize { @app_pid = nil }
1016
+ end
1017
+ return json(response, { ok: true, message: "Stop signal sent." })
1018
+ end
1019
+
1020
+ json(response, { ok: false, message: "No application is running." })
1021
+ end
1022
+
1023
+ def app_status(response)
1024
+ pid, docker, len = @app_mutex.synchronize { [@app_pid, @docker_running, @app_log.length] }
1025
+ json(response, { running: !pid.nil? || docker, log_length: len })
1026
+ end
1027
+
1028
+ def app_log_response(request, response)
1029
+ offset = request.query["offset"].to_i.clamp(0, Float::INFINITY)
1030
+ pid, docker, log = @app_mutex.synchronize { [@app_pid, @docker_running, @app_log.dup] }
1031
+ lines = log.drop(offset)
1032
+ json(
1033
+ response,
1034
+ { lines: lines, offset: offset + lines.length, total: log.length, running: !pid.nil? || docker }
1035
+ )
1036
+ end
1037
+
1038
+ # Returns [mode, cmd_array] where mode is :docker, :direct, or nil
1039
+ def resolve_run_mode(project)
1040
+ # 1. Explicit override → direct
1041
+ if (raw = @run_cmd_override || ENV["MRB_RUN_CMD"]) && !raw.strip.empty?
1042
+ require "shellwords"
1043
+ return [:direct, Shellwords.split(raw) + [project]]
1044
+ end
1045
+
1046
+ project_dir = File.dirname(File.expand_path(project))
1047
+
1048
+ # 2. .mendix-run-cmd file in project directory → direct
1049
+ run_file = File.join(project_dir, ".mendix-run-cmd")
1050
+ if File.file?(run_file)
1051
+ raw = File.read(run_file).lines
1052
+ .reject { |l| l.strip.start_with?("#") || l.strip.empty? }.first&.strip
1053
+ if raw
1054
+ require "shellwords"
1055
+ return [:direct, Shellwords.split(raw) + [project]]
1056
+ end
1057
+ end
1058
+
1059
+ # 3. Local mxcli in project directory → docker mode
1060
+ local_mxcli = File.join(project_dir, "mxcli")
1061
+ if File.executable?(local_mxcli)
1062
+ return [:docker, [local_mxcli, "docker", "run", "-p", project]]
1063
+ end
1064
+
1065
+ # 4. mx in PATH (Mendix runtime CLI, not MxBuild)
1066
+ mx = which_binary("mx")
1067
+ return [:direct, [mx, "run", project]] if mx
1068
+
1069
+ [nil, nil]
1070
+ rescue ArgumentError
1071
+ [nil, nil]
1072
+ end
1073
+
1074
+ def docker_stop_cmd(project)
1075
+ return nil unless project
1076
+
1077
+ project_dir = File.dirname(File.expand_path(project))
1078
+ local_mxcli = File.join(project_dir, "mxcli")
1079
+ return nil unless File.executable?(local_mxcli)
1080
+
1081
+ [local_mxcli, "docker", "down", "-p", project]
1082
+ end
1083
+
1084
+ def resolve_run_cmd(project)
1085
+ _, cmd = resolve_run_mode(project)
1086
+ cmd
1087
+ end
1088
+
1089
+ def which_binary(name)
1090
+ (ENV["PATH"] || "").split(File::PATH_SEPARATOR).each do |dir|
1091
+ candidate = File.join(dir, name)
1092
+ return candidate if File.executable?(candidate) && !File.directory?(candidate)
1093
+ end
1094
+ nil
1095
+ end
1096
+
1097
+ # ---- auth (Mendix marketplace login) ---------------------------------
1098
+
1099
+ def auth_route(request, response)
1100
+ action = request.path.delete_prefix("/api/auth").sub(%r{\A/}, "")
1101
+ case [request.request_method, action]
1102
+ when ["GET", "status"] then auth_status(response)
1103
+ when ["POST", "login"] then auth_login(request, response)
1104
+ when ["POST", "logout"] then auth_logout(response)
1105
+ else json(response, { error: "not found" }, status: 404)
1106
+ end
1107
+ end
1108
+
1109
+ def auth_status(response)
1110
+ # Always ask mxcli for the live credential status
1111
+ _, _, st = Open3.capture3(@mxcli, "auth", "status", "--offline")
1112
+ live = st.success?
1113
+ @auth_user = nil unless live
1114
+ json(response, { logged_in: live, username: @auth_user })
1115
+ rescue StandardError
1116
+ json(response, { logged_in: false, username: nil })
1117
+ end
1118
+
1119
+ def auth_login(request, response)
1120
+ payload = JSON.parse(request.body.to_s)
1121
+ pat = payload.fetch("pat").to_s.strip
1122
+ return json(response, { ok: false, message: "Personal Access Token is required." }, status: 400) if pat.empty?
1123
+
1124
+ stdout, stderr, status = Open3.capture3(@mxcli, "auth", "login", "--token", pat)
1125
+ if status.success?
1126
+ # Try to read username from status after login
1127
+ out2, _, _ = Open3.capture3(@mxcli, "auth", "status", "--json", "--offline")
1128
+ begin
1129
+ data = JSON.parse(out2)
1130
+ @auth_user = data["username"] || data["user"] || data["email"] || "authenticated"
1131
+ rescue JSON::ParserError
1132
+ @auth_user = "authenticated"
1133
+ end
1134
+ json(response, { ok: true, message: "PAT stored. Marketplace access enabled.", username: @auth_user })
1135
+ else
1136
+ msg = (stderr.empty? ? stdout : stderr).lines
1137
+ .reject { |l| l.strip.start_with?("WARNING:") }.join.strip
1138
+ json(response, { ok: false, message: msg.empty? ? "Login failed. Check your PAT." : msg }, status: 401)
1139
+ end
1140
+ rescue KeyError => error
1141
+ json(response, { error: "missing parameter: #{error.key}" }, status: 400)
1142
+ rescue JSON::ParserError
1143
+ json(response, { error: "invalid JSON payload" }, status: 400)
1144
+ end
1145
+
1146
+ def auth_logout(response)
1147
+ _, _, st = Open3.capture3(@mxcli, "auth", "logout")
1148
+ @auth_user = nil
1149
+ if st.success?
1150
+ json(response, { ok: true, message: "PAT removed." })
1151
+ else
1152
+ json(response, { ok: true, message: "Logged out (no stored PAT)." })
1153
+ end
1154
+ rescue StandardError => error
1155
+ @auth_user = nil
1156
+ json(response, { ok: false, message: error.message })
1157
+ end
1158
+
1159
+ def source_project
1160
+ metadata_path = File.join(@inventory_dir, "mendix-project.json")
1161
+ return nil unless File.file?(metadata_path)
1162
+
1163
+ source = JSON.parse(File.read(metadata_path))["source_project"]
1164
+ source if source && File.file?(source)
1165
+ rescue JSON::ParserError
1166
+ nil
1167
+ end
1168
+
1169
+ def refresh_inventory(project)
1170
+ Importer.new(mxcli: @mxcli).import(project, @inventory_dir)
1171
+ nil
1172
+ rescue StandardError => error
1173
+ "Module installed, but the inventory refresh failed: #{error.message}"
1174
+ end
1175
+
1176
+ # Background inventory refresh with a single-flight guard: a second
1177
+ # request while one is running just marks it stale and re-runs once.
1178
+ def refresh_inventory_async(project)
1179
+ @refresh_mutex ||= Mutex.new
1180
+ again = @refresh_mutex.synchronize do
1181
+ if @refreshing
1182
+ @refresh_again = true
1183
+ true
1184
+ else
1185
+ @refreshing = true
1186
+ false
1187
+ end
1188
+ end
1189
+ return if again
1190
+
1191
+ Thread.new do
1192
+ loop do
1193
+ refresh_inventory(project)
1194
+ done = @refresh_mutex.synchronize do
1195
+ if @refresh_again
1196
+ @refresh_again = false
1197
+ false
1198
+ else
1199
+ @refreshing = false
1200
+ true
1201
+ end
1202
+ end
1203
+ break if done
1204
+ end
1205
+ end
1206
+ end
1207
+
1208
+ def read_draft(file, qn)
1209
+ path = File.join(@inventory_dir, "inventory", file)
1210
+ return nil unless File.file?(path)
1211
+
1212
+ JSON.parse(File.read(path))[qn]
1213
+ rescue JSON::ParserError
1214
+ nil
1215
+ end
1216
+
1217
+ def delete_draft(file, qn)
1218
+ path = File.join(@inventory_dir, "inventory", file)
1219
+ @layout_mutex.synchronize do
1220
+ return unless File.file?(path)
1221
+
1222
+ plans = JSON.parse(File.read(path))
1223
+ plans.delete(qn)
1224
+ temporary = "#{path}.tmp"
1225
+ File.write(temporary, "#{JSON.pretty_generate(plans)}\n")
1226
+ File.rename(temporary, path)
1227
+ end
1228
+ end
1229
+
1230
+ def page_detail(qn)
1231
+ path = File.join(@inventory_dir, "inventory", "element-details.json")
1232
+ return nil unless File.file?(path)
1233
+
1234
+ detail = JSON.parse(File.read(path))[qn]
1235
+ detail if detail.is_a?(Hash) && detail.key?("mdl")
1236
+ end
1237
+
1238
+ def build_page_mdl(qn, detail, content)
1239
+ settings = ["Title: '#{escape_mdl(detail['title'] || qn.split('.').last)}'"]
1240
+ settings << "Layout: #{detail['layout']}" if detail["layout"]
1241
+ settings << "Folder: '#{escape_mdl(detail['folder'])}'" if detail["folder"]
1242
+ params = Array(detail["parameters"]).map do |parameter|
1243
+ "$#{parameter['name']}: #{parameter['type']}"
1244
+ end
1245
+ settings << "Params: { #{params.join(', ')} }" unless params.empty?
1246
+
1247
+ body = content.strip.empty? ? "" : content.lines.map { |line| " #{line.rstrip}" }.join("\n")
1248
+ "CREATE OR MODIFY PAGE #{qn} (\n #{settings.join(",\n ")}\n) {\n#{body}\n}\n"
1249
+ end
1250
+
1251
+ def check_mdl(mdl)
1252
+ Tempfile.create(["mendix-page-", ".mdl"]) do |file|
1253
+ file.write(mdl)
1254
+ file.flush
1255
+ stdout, stderr, status = Open3.capture3(@mxcli, "check", file.path)
1256
+ return [true, nil] if status.success?
1257
+
1258
+ output = (stderr.empty? ? stdout : stderr).lines
1259
+ .reject { |line| line.start_with?("WARNING:") }.join.strip
1260
+ [false, output]
1261
+ end
1262
+ end
1263
+
1264
+ def persist_page_draft(qn, content, mdl, ok, message)
1265
+ persist_draft(
1266
+ "page-plans.json", qn,
1267
+ "content" => content, "mdl" => mdl, "valid" => ok, "message" => message
1268
+ )
1269
+ end
1270
+
1271
+ # Atomic upsert into a draft sidecar under inventory/ (temp + rename, mutex).
1272
+ def persist_draft(file, qn, entry)
1273
+ path = File.join(@inventory_dir, "inventory", file)
1274
+ @layout_mutex.synchronize do
1275
+ plans = File.file?(path) ? JSON.parse(File.read(path)) : {}
1276
+ plans[qn] = { "saved_at" => Time.now.iso8601 }.merge(entry.compact)
1277
+ temporary = "#{path}.tmp"
1278
+ File.write(temporary, "#{JSON.pretty_generate(plans)}\n")
1279
+ File.rename(temporary, path)
1280
+ end
1281
+ end
1282
+
1283
+ def escape_mdl(value)
1284
+ value.to_s.gsub("'", "''")
1285
+ end
1286
+
1287
+ # ---- project settings (docker env + db mode) -------------------------
1288
+
1289
+ SETTINGS_KEYS = %w[
1290
+ APP_PORT ADMIN_PORT M2EE_ADMIN_PASS MX_LOG_LEVEL
1291
+ DB_MODE DB_PORT DB_NAME DB_USER DB_PASSWORD
1292
+ EXT_DB_HOST EXT_DB_PORT EXT_DB_SSL
1293
+ ].freeze
1294
+
1295
+ def settings_route(request, response)
1296
+ env_path = docker_env_path
1297
+ case request.request_method
1298
+ when "GET"
1299
+ current = env_path ? parse_env_file(env_path) : {}
1300
+ json(
1301
+ response,
1302
+ {
1303
+ settings: current.slice(*SETTINGS_KEYS),
1304
+ env_path:,
1305
+ editable: !env_path.nil?
1306
+ }
1307
+ )
1308
+ when "PUT"
1309
+ return json(response, { error: "no .docker/.env found" }, status: 503) unless env_path
1310
+ payload = JSON.parse(request.body.to_s)
1311
+ updates = payload.slice(*SETTINGS_KEYS)
1312
+ update_env_file(env_path, updates)
1313
+ apply_db_mode(env_path, updates)
1314
+ json(response, { ok: true, message: "Settings saved. Restart the application for changes to take effect." })
1315
+ else
1316
+ json(response, { error: "method not allowed" }, status: 405)
1317
+ end
1318
+ rescue JSON::ParserError
1319
+ json(response, { error: "invalid JSON payload" }, status: 400)
1320
+ end
1321
+
1322
+ def docker_env_path
1323
+ project = source_project
1324
+ return nil unless project
1325
+
1326
+ path = File.join(File.dirname(File.expand_path(project)), ".docker", ".env")
1327
+ path if File.file?(path)
1328
+ end
1329
+
1330
+ def parse_env_file(path)
1331
+ File.readlines(path, chomp: true).each_with_object({}) do |line, h|
1332
+ next if line.strip.start_with?("#") || !line.include?("=")
1333
+ key, _, value = line.partition("=")
1334
+ h[key.strip] = value
1335
+ end
1336
+ end
1337
+
1338
+ def update_env_file(path, updates)
1339
+ lines = File.readlines(path)
1340
+ updates.each do |key, value|
1341
+ found = false
1342
+ lines.map! do |line|
1343
+ if line =~ /\A#{Regexp.escape(key)}=/
1344
+ found = true
1345
+ "#{key}=#{value}\n"
1346
+ else
1347
+ line
1348
+ end
1349
+ end
1350
+ lines << "#{key}=#{value}\n" unless found
1351
+ end
1352
+ File.write(path, lines.join)
1353
+ end
1354
+
1355
+ # When DB_MODE=external, write a docker-compose.override.yml that:
1356
+ # - Points the mendix service at the external DB host
1357
+ # - Replaces the db service with a no-op container so depends_on succeeds instantly
1358
+ # When DB_MODE=local (or absent), remove any override file we created.
1359
+ def apply_db_mode(env_path, updates)
1360
+ mode = updates.fetch("DB_MODE", "local")
1361
+ docker_dir = File.dirname(env_path)
1362
+ override_path = File.join(docker_dir, "docker-compose.override.yml")
1363
+
1364
+ if mode == "external"
1365
+ ext_host = updates.fetch("EXT_DB_HOST", "").strip
1366
+ ext_port = updates.fetch("EXT_DB_PORT", "5432").strip
1367
+ ssl = updates.fetch("EXT_DB_SSL", "false") == "true"
1368
+
1369
+ db_type = ssl ? "POSTGRESQL_SSL" : "POSTGRESQL"
1370
+ db_host = "#{ext_host}:#{ext_port}"
1371
+
1372
+ override_content = <<~YAML
1373
+ # Generated by Mendix Ruby Bridge — external database mode
1374
+ # Remove this file to revert to the local Docker PostgreSQL container
1375
+ services:
1376
+ mendix:
1377
+ environment:
1378
+ - RUNTIME_PARAMS_DATABASETYPE=#{db_type}
1379
+ - RUNTIME_PARAMS_DATABASEHOST=#{db_host}
1380
+ db:
1381
+ image: alpine
1382
+ command: ["echo", "External DB mode — local PostgreSQL container skipped"]
1383
+ healthcheck:
1384
+ test: ["CMD", "true"]
1385
+ interval: 1s
1386
+ timeout: 1s
1387
+ retries: 1
1388
+ YAML
1389
+ File.write(override_path, override_content)
1390
+ elsif File.file?(override_path)
1391
+ # Check that it's one we generated before removing
1392
+ content = File.read(override_path)
1393
+ File.delete(override_path) if content.include?("Generated by Mendix Ruby Bridge")
1394
+ end
1395
+ end
1396
+
1397
+ def json(response, payload, status: 200)
1398
+ response.status = status
1399
+ response["content-type"] = "application/json; charset=utf-8"
1400
+ response["cache-control"] = "no-store"
1401
+ response.body = "#{JSON.generate(payload)}\n"
1402
+ end
1403
+ end
1404
+ end