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.
- checksums.yaml +7 -0
- data/.mxcli-version +2 -0
- data/LICENSE +21 -0
- data/README.md +77 -0
- data/bin/git-mendix +4 -0
- data/bin/mendix-apply +145 -0
- data/bin/mendix-desktop +26 -0
- data/bin/mendix-git +121 -0
- data/bin/mendix-ruby +580 -0
- data/bin/mxcli +20 -0
- data/bin/setup-tools +25 -0
- data/lib/mendix_bridge/backend_server.rb +1404 -0
- data/lib/mendix_bridge/change_planner.rb +594 -0
- data/lib/mendix_bridge/config.rb +89 -0
- data/lib/mendix_bridge/dependency_index.rb +188 -0
- data/lib/mendix_bridge/desktop_app.rb +121 -0
- data/lib/mendix_bridge/document_parser.rb +120 -0
- data/lib/mendix_bridge/domain_parser.rb +103 -0
- data/lib/mendix_bridge/dsl.rb +540 -0
- data/lib/mendix_bridge/enumeration_parser.rb +32 -0
- data/lib/mendix_bridge/git_workflow.rb +321 -0
- data/lib/mendix_bridge/html_viewer.rb +672 -0
- data/lib/mendix_bridge/importer.rb +415 -0
- data/lib/mendix_bridge/inventory.rb +93 -0
- data/lib/mendix_bridge/mdl_generator.rb +343 -0
- data/lib/mendix_bridge/microflow_parser.rb +131 -0
- data/lib/mendix_bridge/migration.rb +290 -0
- data/lib/mendix_bridge/migration_executor.rb +234 -0
- data/lib/mendix_bridge/model.rb +204 -0
- data/lib/mendix_bridge/page_parser.rb +95 -0
- data/lib/mendix_bridge/presenter.rb +35 -0
- data/lib/mendix_bridge/project_creator.rb +181 -0
- data/lib/mendix_bridge/ruby_inventory_generator.rb +63 -0
- data/lib/mendix_bridge/security_parser.rb +72 -0
- data/lib/mendix_bridge/snapshot_diff.rb +58 -0
- data/lib/mendix_bridge/validator.rb +46 -0
- data/lib/mendix_bridge/version.rb +5 -0
- data/lib/mendix_bridge/visual_entity_plan.rb +176 -0
- data/lib/mendix_bridge.rb +127 -0
- data/share/applications/mendix-ruby-bridge.desktop +12 -0
- data/share/icons/hicolor/128x128/apps/mendix-ruby-bridge.png +0 -0
- data/share/icons/hicolor/256x256/apps/mendix-ruby-bridge.png +0 -0
- data/share/icons/hicolor/32x32/apps/mendix-ruby-bridge.png +0 -0
- data/share/icons/hicolor/48x48/apps/mendix-ruby-bridge.png +0 -0
- data/share/icons/hicolor/512x512/apps/mendix-ruby-bridge.png +0 -0
- data/share/icons/hicolor/64x64/apps/mendix-ruby-bridge.png +0 -0
- data/web/dist/apple-touch-icon.png +0 -0
- data/web/dist/assets/index-BO6iPT1P.css +1 -0
- data/web/dist/assets/index-JGODw81W.js +27 -0
- data/web/dist/brand/mendix-ruby-bridge.png +0 -0
- data/web/dist/favicon.png +0 -0
- data/web/dist/icons.svg +24 -0
- data/web/dist/index.html +17 -0
- 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
|