mendix-ruby-bridge 0.1.3 → 0.1.4
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 +4 -4
- data/bin/setup-tools +5 -20
- data/lib/mendix_bridge/alter_page_builder.rb +176 -0
- data/lib/mendix_bridge/backend_server.rb +363 -11
- data/lib/mendix_bridge/git_workflow.rb +323 -16
- data/lib/mendix_bridge/mdl_parser.rb +264 -0
- data/lib/mendix_bridge/page_parser.rb +31 -37
- data/lib/mendix_bridge/version.rb +1 -1
- data/lib/mendix_bridge.rb +2 -0
- data/web/dist/assets/index-DdcSfFYq.css +1 -0
- data/web/dist/assets/index-FGJN6neJ.js +36 -0
- data/web/dist/favicon.png +0 -0
- data/web/dist/index.html +4 -4
- metadata +5 -3
- data/web/dist/assets/index-BO6iPT1P.css +0 -1
- data/web/dist/assets/index-JGODw81W.js +0 -27
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 767361072737fe4fd5c02587af41776895fa64a24c143ec599ad54df874bd1f8
|
|
4
|
+
data.tar.gz: 50da6cebeb94f6fb0ab8ee1f5418488b2d9e148b7d30a1fb773eebda58b5af04
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ea4cc6ba22ea7d9f075549da294d77e49afd478d851f2388c7685384783c83e64ab9f89c607ebe707d6204c1e01d2a5826fd13d8ec8692c0d16569bb217e0de3
|
|
7
|
+
data.tar.gz: 042d3e85502ddae7a63e8c553c3e6a720335b595fb8862d072257ede8d306a0fd67d6ecb8d6f66cd18d4ecec5dbd81425692d395ace4fde2b2f9f883b090cbeb
|
data/bin/setup-tools
CHANGED
|
@@ -16,25 +16,10 @@ download_url="https://github.com/mendixlabs/mxcli/releases/download/v${version}/
|
|
|
16
16
|
|
|
17
17
|
if [[ -x "$executable" ]]; then
|
|
18
18
|
echo "mxcli $version is already installed"
|
|
19
|
-
|
|
20
|
-
mkdir -p "$install_dir"
|
|
21
|
-
curl --fail --location "$download_url" --output "$executable"
|
|
22
|
-
chmod +x "$executable"
|
|
23
|
-
"$executable" --version
|
|
19
|
+
exit 0
|
|
24
20
|
fi
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
elif command -v apt-get &>/dev/null; then
|
|
31
|
-
sudo apt-get install -y libgtk-3-dev libgirepository1.0-dev libwebkit2gtk-4.1-dev
|
|
32
|
-
gem install gtk3 webkit2-gtk
|
|
33
|
-
elif command -v dnf &>/dev/null; then
|
|
34
|
-
sudo dnf install -y gtk3-devel gobject-introspection-devel webkit2gtk4.1-devel
|
|
35
|
-
gem install gtk3 webkit2-gtk
|
|
36
|
-
else
|
|
37
|
-
echo "Package manager not detected; installing via gem (system libs must already be present)..."
|
|
38
|
-
gem install gtk3 webkit2-gtk
|
|
39
|
-
fi
|
|
40
|
-
echo "Desktop dependencies installed."
|
|
22
|
+
mkdir -p "$install_dir"
|
|
23
|
+
curl --fail --location "$download_url" --output "$executable"
|
|
24
|
+
chmod +x "$executable"
|
|
25
|
+
"$executable" --version
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MendixBridge
|
|
4
|
+
# Builds ALTER PAGE / ALTER SNIPPET MDL statements from a structured list of
|
|
5
|
+
# operations. Each operation is a Hash with an "op" key and operation-
|
|
6
|
+
# specific fields:
|
|
7
|
+
#
|
|
8
|
+
# { "op" => "set", "widget" => "btnSave",
|
|
9
|
+
# "props" => { "Caption" => "Save & Close", "ButtonStyle" => "Success" } }
|
|
10
|
+
#
|
|
11
|
+
# { "op" => "insert_after", "widget" => "tbEmail",
|
|
12
|
+
# "body" => "textbox tbPhone (Label: 'Phone', Attribute: Phone)" }
|
|
13
|
+
#
|
|
14
|
+
# { "op" => "insert_before","widget" => "btnCancel",
|
|
15
|
+
# "body" => "actionbutton btnBack (Caption: 'Back', Action: close_page)" }
|
|
16
|
+
#
|
|
17
|
+
# { "op" => "drop", "widgets" => ["tbUnused", "lblOld"] }
|
|
18
|
+
#
|
|
19
|
+
# { "op" => "replace", "widget" => "tbName",
|
|
20
|
+
# "body" => "textarea taName (Label: 'Name', Attribute: Name)" }
|
|
21
|
+
#
|
|
22
|
+
# { "op" => "set_layout", "layout" => "Atlas_Core.Atlas_Sidebar_Full" }
|
|
23
|
+
#
|
|
24
|
+
# { "op" => "add_variable", "name" => "counter",
|
|
25
|
+
# "type" => "Integer", "default" => "0" }
|
|
26
|
+
#
|
|
27
|
+
# { "op" => "drop_variable","name" => "counter" }
|
|
28
|
+
#
|
|
29
|
+
# Validation errors are returned as an array of strings from `validate`.
|
|
30
|
+
# `build` raises `ArgumentError` when there are errors.
|
|
31
|
+
module AlterPageBuilder
|
|
32
|
+
VALID_OPS = %w[
|
|
33
|
+
set insert_after insert_before drop replace set_layout add_variable drop_variable
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
IDENTIFIER = /\A[A-Za-z_]\w*\z/
|
|
37
|
+
QN_PATTERN = /\A[A-Za-z_]\w*\.[A-Za-z_]\w*\z/
|
|
38
|
+
LAYOUT_QN = /\A[A-Za-z_]\w*\.[A-Za-z_]\w*\z/
|
|
39
|
+
|
|
40
|
+
# Build an ALTER PAGE MDL string from `qn` and an array of `operations`.
|
|
41
|
+
# Raises ArgumentError if any operation is invalid.
|
|
42
|
+
# Pass `known_names:` (array of widget name strings from the page tree) to
|
|
43
|
+
# enable widget-name existence checks. Omit it to skip those checks.
|
|
44
|
+
def self.build(qn:, operations:, known_names: nil)
|
|
45
|
+
errors = validate(operations, known_names: known_names)
|
|
46
|
+
raise ArgumentError, errors.join("; ") if errors.any?
|
|
47
|
+
|
|
48
|
+
lines = operations.map { |op| build_operation(op) }.compact
|
|
49
|
+
"ALTER PAGE #{qn} {\n#{lines.join("\n")}\n};\n"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns an array of human-readable error strings, or [] if valid.
|
|
53
|
+
def self.validate(operations, known_names: nil)
|
|
54
|
+
return ["operations must be an array"] unless operations.is_a?(Array)
|
|
55
|
+
|
|
56
|
+
errors = []
|
|
57
|
+
operations.each_with_index do |op, i|
|
|
58
|
+
prefix = "operation[#{i}]"
|
|
59
|
+
unless op.is_a?(Hash)
|
|
60
|
+
errors << "#{prefix}: must be a hash"
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
kind = op["op"].to_s
|
|
65
|
+
unless VALID_OPS.include?(kind)
|
|
66
|
+
errors << "#{prefix}: unknown op '#{kind}' (valid: #{VALID_OPS.join(", ")})"
|
|
67
|
+
next
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
case kind
|
|
71
|
+
when "set"
|
|
72
|
+
errors << "#{prefix}: 'widget' required" unless op["widget"].is_a?(String)
|
|
73
|
+
errors << "#{prefix}: 'props' must be a non-empty hash" unless
|
|
74
|
+
op["props"].is_a?(Hash) && op["props"].any?
|
|
75
|
+
check_widget_exists(prefix, op["widget"], known_names, errors)
|
|
76
|
+
|
|
77
|
+
when "insert_after", "insert_before"
|
|
78
|
+
errors << "#{prefix}: 'widget' required" unless op["widget"].is_a?(String)
|
|
79
|
+
errors << "#{prefix}: 'body' required (MDL widget definition)" unless
|
|
80
|
+
op["body"].is_a?(String) && !op["body"].strip.empty?
|
|
81
|
+
check_widget_exists(prefix, op["widget"], known_names, errors)
|
|
82
|
+
|
|
83
|
+
when "drop"
|
|
84
|
+
unless op["widgets"].is_a?(Array) && op["widgets"].all? { |w| w.is_a?(String) }
|
|
85
|
+
errors << "#{prefix}: 'widgets' must be an array of strings"
|
|
86
|
+
next
|
|
87
|
+
end
|
|
88
|
+
op["widgets"].each { |w| check_widget_exists(prefix, w, known_names, errors) }
|
|
89
|
+
|
|
90
|
+
when "replace"
|
|
91
|
+
errors << "#{prefix}: 'widget' required" unless op["widget"].is_a?(String)
|
|
92
|
+
errors << "#{prefix}: 'body' required (MDL widget definition)" unless
|
|
93
|
+
op["body"].is_a?(String) && !op["body"].strip.empty?
|
|
94
|
+
check_widget_exists(prefix, op["widget"], known_names, errors)
|
|
95
|
+
|
|
96
|
+
when "set_layout"
|
|
97
|
+
unless op["layout"].is_a?(String) && op["layout"].match?(LAYOUT_QN)
|
|
98
|
+
errors << "#{prefix}: 'layout' must be a qualified name (Module.Layout)"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
when "add_variable"
|
|
102
|
+
errors << "#{prefix}: 'name' must be a valid identifier" unless
|
|
103
|
+
op["name"].is_a?(String) && op["name"].match?(IDENTIFIER)
|
|
104
|
+
errors << "#{prefix}: 'type' required" unless op["type"].is_a?(String)
|
|
105
|
+
|
|
106
|
+
when "drop_variable"
|
|
107
|
+
errors << "#{prefix}: 'name' must be a valid identifier" unless
|
|
108
|
+
op["name"].is_a?(String) && op["name"].match?(IDENTIFIER)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
errors
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# -------------------------------------------------------------------------
|
|
116
|
+
private_class_method def self.check_widget_exists(prefix, name, known_names, errors)
|
|
117
|
+
return unless known_names && name.is_a?(String)
|
|
118
|
+
|
|
119
|
+
unless known_names.include?(name)
|
|
120
|
+
errors << "#{prefix}: widget '#{name}' not found in page (known: #{known_names.sort.join(", ")})"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private_class_method def self.build_operation(op)
|
|
125
|
+
case op["op"]
|
|
126
|
+
when "set"
|
|
127
|
+
props = render_props(op["props"])
|
|
128
|
+
" SET (#{props}) ON #{op["widget"]};"
|
|
129
|
+
|
|
130
|
+
when "insert_after"
|
|
131
|
+
" INSERT AFTER #{op["widget"]} {\n#{indent_body(op["body"])}\n };"
|
|
132
|
+
|
|
133
|
+
when "insert_before"
|
|
134
|
+
" INSERT BEFORE #{op["widget"]} {\n#{indent_body(op["body"])}\n };"
|
|
135
|
+
|
|
136
|
+
when "drop"
|
|
137
|
+
names = Array(op["widgets"]).join(", ")
|
|
138
|
+
" DROP WIDGET #{names};"
|
|
139
|
+
|
|
140
|
+
when "replace"
|
|
141
|
+
" REPLACE #{op["widget"]} WITH {\n#{indent_body(op["body"])}\n };"
|
|
142
|
+
|
|
143
|
+
when "set_layout"
|
|
144
|
+
" SET Layout #{op["layout"]};"
|
|
145
|
+
|
|
146
|
+
when "add_variable"
|
|
147
|
+
default = op["default"] ? " = '#{escape_mdl(op["default"].to_s)}'" : ""
|
|
148
|
+
" ADD VARIABLES $#{op["name"]}: #{op["type"]}#{default};"
|
|
149
|
+
|
|
150
|
+
when "drop_variable"
|
|
151
|
+
" DROP VARIABLES $#{op["name"]};"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private_class_method def self.render_props(props)
|
|
156
|
+
props.map do |key, value|
|
|
157
|
+
rendered =
|
|
158
|
+
case value
|
|
159
|
+
when String then value.match?(/\A[A-Za-z_$][\w.$\/]*\z/) ? value : "'#{escape_mdl(value)}'"
|
|
160
|
+
when Integer, Float then value.to_s
|
|
161
|
+
when TrueClass, FalseClass then value.to_s
|
|
162
|
+
else "'#{escape_mdl(value.to_s)}'"
|
|
163
|
+
end
|
|
164
|
+
"#{key}: #{rendered}"
|
|
165
|
+
end.join(", ")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private_class_method def self.indent_body(body)
|
|
169
|
+
body.to_s.strip.lines.map { |line| " #{line.rstrip}" }.join("\n")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private_class_method def self.escape_mdl(str)
|
|
173
|
+
str.to_s.gsub("'", "''")
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "net/http"
|
|
4
5
|
require "open3"
|
|
6
|
+
require "socket"
|
|
5
7
|
require "tempfile"
|
|
6
8
|
require "time"
|
|
9
|
+
require "timeout"
|
|
7
10
|
require "webrick"
|
|
8
11
|
|
|
9
12
|
module MendixBridge
|
|
@@ -40,6 +43,11 @@ module MendixBridge
|
|
|
40
43
|
@docker_running = false
|
|
41
44
|
@auth_user = nil
|
|
42
45
|
@run_cmd_override = run_cmd
|
|
46
|
+
@xas_log = []
|
|
47
|
+
@xas_mutex = Mutex.new
|
|
48
|
+
@database_mutex = Mutex.new
|
|
49
|
+
@xas_proxy_enabled = false
|
|
50
|
+
@xas_target_port = nil
|
|
43
51
|
validate!
|
|
44
52
|
@server = WEBrick::HTTPServer.new(
|
|
45
53
|
BindAddress: bind,
|
|
@@ -86,6 +94,7 @@ module MendixBridge
|
|
|
86
94
|
end
|
|
87
95
|
@server.mount_proc("/api/git") { |request, response| git_route(request, response) }
|
|
88
96
|
@server.mount_proc("/api/page") { |request, response| page_route(request, response) }
|
|
97
|
+
@server.mount_proc("/api/alter") { |request, response| alter_route(request, response) }
|
|
89
98
|
@server.mount_proc("/api/flow") { |request, response| flow_route(request, response) }
|
|
90
99
|
@server.mount_proc("/api/drafts") { |_request, response| drafts(response) }
|
|
91
100
|
@server.mount_proc("/api/apply") { |request, response| apply_draft_route(request, response) }
|
|
@@ -99,6 +108,8 @@ module MendixBridge
|
|
|
99
108
|
@server.mount_proc("/api/marketplace/install") do |request, response|
|
|
100
109
|
marketplace_install(request, response)
|
|
101
110
|
end
|
|
111
|
+
@server.mount_proc("/api/xas") { |request, response| xas_control(request, response) }
|
|
112
|
+
@server.mount_proc("/xas") { |request, response| xas_proxy(request, response) }
|
|
102
113
|
@server.mount(
|
|
103
114
|
"/",
|
|
104
115
|
WEBrick::HTTPServlet::FileHandler,
|
|
@@ -327,13 +338,49 @@ module MendixBridge
|
|
|
327
338
|
|
|
328
339
|
result =
|
|
329
340
|
case [request.request_method, action]
|
|
330
|
-
when ["GET",
|
|
331
|
-
when ["GET",
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
341
|
+
when ["GET", "status"] then workflow.status
|
|
342
|
+
when ["GET", "branches"]
|
|
343
|
+
{
|
|
344
|
+
"branches" => workflow.branches,
|
|
345
|
+
"current" => workflow.status["branch"],
|
|
346
|
+
"remotes" => workflow.remote_names
|
|
347
|
+
}
|
|
348
|
+
when ["GET", "tags"] then { "tags" => workflow.tags }
|
|
349
|
+
when ["GET", "worktrees"] then { "worktrees" => workflow.worktrees, "root" => workflow.root }
|
|
350
|
+
when ["GET", "stash"] then { "stash" => workflow.stash_list.lines.map(&:strip).reject(&:empty?) }
|
|
351
|
+
when ["GET", "log"]
|
|
352
|
+
max = [[(request.query["max"] || "200").to_i, 1].max, 500].min
|
|
353
|
+
{ "commits" => workflow.log(max:) }
|
|
354
|
+
when ["GET", "file-status"] then { "files" => workflow.file_status }
|
|
355
|
+
when ["POST", "fetch"] then workflow.fetch && workflow.status
|
|
356
|
+
when ["POST", "switch"] then workflow.switch(payload.fetch("branch"), studio_closed: closed)
|
|
357
|
+
when ["POST", "create"]
|
|
358
|
+
workflow.create(
|
|
359
|
+
payload.fetch("branch"),
|
|
360
|
+
studio_closed: closed,
|
|
361
|
+
start_point: payload["start_point"],
|
|
362
|
+
carry_changes: payload["carry_changes"] == true
|
|
363
|
+
)
|
|
364
|
+
when ["POST", "command"]
|
|
365
|
+
workflow.terminal_command(payload.fetch("command"), studio_closed: closed)
|
|
366
|
+
when ["POST", "commit"] then workflow.commit(payload.fetch("message"), studio_closed: closed)
|
|
367
|
+
when ["POST", "commit-staged"] then workflow.commit_staged(payload.fetch("message"), studio_closed: closed)
|
|
368
|
+
when ["POST", "stage"] then workflow.stage(payload.fetch("path"))
|
|
369
|
+
when ["POST", "unstage"] then workflow.unstage(payload.fetch("path"))
|
|
370
|
+
when ["POST", "discard"] then workflow.discard(payload.fetch("path"))
|
|
371
|
+
when ["POST", "push"] then workflow.push
|
|
372
|
+
when ["POST", "remote"] then workflow.add_remote(payload.fetch("name"), payload.fetch("url"))
|
|
373
|
+
when ["POST", "pull"] then workflow.pull(studio_closed: closed)
|
|
374
|
+
when ["POST", "cherry-pick"] then workflow.cherry_pick(payload.fetch("sha"), studio_closed: closed)
|
|
375
|
+
when ["POST", "revert"] then workflow.revert_commit(payload.fetch("sha"), studio_closed: closed)
|
|
376
|
+
when ["POST", "reset"] then workflow.reset_to(payload.fetch("sha"), mode: payload.fetch("mode", "mixed"), studio_closed: closed)
|
|
377
|
+
when ["POST", "tag"]
|
|
378
|
+
workflow.create_tag(payload.fetch("name"), sha: payload["sha"], message: payload["message"])
|
|
379
|
+
when ["POST", "delete-tag"] then workflow.delete_tag(payload.fetch("name"))
|
|
380
|
+
when ["POST", "delete-branch"]
|
|
381
|
+
workflow.delete_branch(payload.fetch("name"), force: payload["force"] == true)
|
|
382
|
+
when ["POST", "merge"] then workflow.merge(payload.fetch("branch"), studio_closed: closed)
|
|
383
|
+
when ["POST", "rebase"] then workflow.rebase(payload.fetch("branch"), studio_closed: closed)
|
|
337
384
|
when ["POST", "stash"]
|
|
338
385
|
workflow.stash_push(
|
|
339
386
|
studio_closed: closed,
|
|
@@ -462,7 +509,7 @@ module MendixBridge
|
|
|
462
509
|
persist_page_draft(qn, content, mdl, ok, message)
|
|
463
510
|
json(
|
|
464
511
|
response,
|
|
465
|
-
{ ok:, mdl:, message: ok ? "Page
|
|
512
|
+
{ ok:, mdl:, message: ok ? "Page changes validated and saved." : message },
|
|
466
513
|
status: ok ? 200 : 422
|
|
467
514
|
)
|
|
468
515
|
rescue KeyError => error
|
|
@@ -489,7 +536,7 @@ module MendixBridge
|
|
|
489
536
|
persist_draft("flow-plans.json", qn, "body" => body, "mdl" => mdl, "valid" => ok, "message" => message)
|
|
490
537
|
json(
|
|
491
538
|
response,
|
|
492
|
-
{ ok:, mdl:, message: ok ? "Flow
|
|
539
|
+
{ ok:, mdl:, message: ok ? "Flow changes validated and saved." : message },
|
|
493
540
|
status: ok ? 200 : 422
|
|
494
541
|
)
|
|
495
542
|
rescue KeyError => error
|
|
@@ -521,8 +568,9 @@ module MendixBridge
|
|
|
521
568
|
response,
|
|
522
569
|
{
|
|
523
570
|
entities: read.call("visual-plans.json"),
|
|
524
|
-
pages:
|
|
525
|
-
flows:
|
|
571
|
+
pages: read.call("page-plans.json"),
|
|
572
|
+
flows: read.call("flow-plans.json"),
|
|
573
|
+
alters: read.call("alter-plans.json")
|
|
526
574
|
}
|
|
527
575
|
)
|
|
528
576
|
end
|
|
@@ -760,6 +808,14 @@ module MendixBridge
|
|
|
760
808
|
status: 422
|
|
761
809
|
)
|
|
762
810
|
end
|
|
811
|
+
startup_error = ensure_local_database_available(project)
|
|
812
|
+
if startup_error
|
|
813
|
+
return json(
|
|
814
|
+
response,
|
|
815
|
+
{ ok: false, message: startup_error },
|
|
816
|
+
status: 503
|
|
817
|
+
)
|
|
818
|
+
end
|
|
763
819
|
[@mxcli, "sql", "-p", project, "--json", "--dsn", dsn, query]
|
|
764
820
|
end
|
|
765
821
|
|
|
@@ -804,6 +860,78 @@ module MendixBridge
|
|
|
804
860
|
"postgres://#{CGI.escape(user)}:#{CGI.escape(pass)}@#{host}:#{port}/#{name}?sslmode=disable"
|
|
805
861
|
end
|
|
806
862
|
|
|
863
|
+
# SQL does not require the Mendix runtime. For a project-local database,
|
|
864
|
+
# start only its PostgreSQL Compose service on demand and keep it running
|
|
865
|
+
# between queries. External databases are contacted directly.
|
|
866
|
+
def ensure_local_database_available(project)
|
|
867
|
+
env_path = docker_env_path
|
|
868
|
+
return "No database connection configured." unless env_path
|
|
869
|
+
|
|
870
|
+
env = parse_env_file(env_path)
|
|
871
|
+
return nil if env["DB_MODE"].to_s == "external"
|
|
872
|
+
|
|
873
|
+
port = env.fetch("DB_PORT", "5432").to_i
|
|
874
|
+
return "Invalid local database port." unless port.positive?
|
|
875
|
+
docker_dir = File.dirname(env_path)
|
|
876
|
+
compose_path = File.join(docker_dir, "docker-compose.yml")
|
|
877
|
+
return "Local database definition is missing: #{compose_path}" unless File.file?(compose_path)
|
|
878
|
+
return nil if postgres_compose_ready?(docker_dir, env_path, compose_path, env)
|
|
879
|
+
|
|
880
|
+
@database_mutex.synchronize do
|
|
881
|
+
return nil if postgres_compose_ready?(docker_dir, env_path, compose_path, env)
|
|
882
|
+
|
|
883
|
+
output, error, status = Open3.capture3(
|
|
884
|
+
"docker", "compose",
|
|
885
|
+
"--env-file", env_path,
|
|
886
|
+
"-f", compose_path,
|
|
887
|
+
"up", "-d", "db",
|
|
888
|
+
chdir: docker_dir
|
|
889
|
+
)
|
|
890
|
+
unless status.success?
|
|
891
|
+
detail = clean_mxcli_error(error.empty? ? output : error)
|
|
892
|
+
return "Could not start the local PostgreSQL service: #{detail}"
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
begin
|
|
896
|
+
Timeout.timeout(30) do
|
|
897
|
+
sleep 0.25 until postgres_compose_ready?(docker_dir, env_path, compose_path, env)
|
|
898
|
+
end
|
|
899
|
+
rescue Timeout::Error
|
|
900
|
+
return "PostgreSQL was started but did not become ready on localhost:#{port} within 30 seconds."
|
|
901
|
+
end
|
|
902
|
+
end
|
|
903
|
+
nil
|
|
904
|
+
rescue Errno::ENOENT
|
|
905
|
+
"Docker is required to start the project-local PostgreSQL service."
|
|
906
|
+
rescue StandardError => error
|
|
907
|
+
"Could not prepare the local database: #{error.message}"
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
def tcp_reachable?(host, port)
|
|
911
|
+
Socket.tcp(host, port, connect_timeout: 0.25) { |socket| socket.close }
|
|
912
|
+
true
|
|
913
|
+
rescue SystemCallError, IOError
|
|
914
|
+
false
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
def postgres_compose_ready?(docker_dir, env_path, compose_path, env)
|
|
918
|
+
return false unless tcp_reachable?("127.0.0.1", env.fetch("DB_PORT", "5432").to_i)
|
|
919
|
+
|
|
920
|
+
_output, _error, status = Open3.capture3(
|
|
921
|
+
"docker", "compose",
|
|
922
|
+
"--env-file", env_path,
|
|
923
|
+
"-f", compose_path,
|
|
924
|
+
"exec", "-T", "db",
|
|
925
|
+
"pg_isready",
|
|
926
|
+
"-U", env.fetch("DB_USER", "mendix"),
|
|
927
|
+
"-d", env.fetch("DB_NAME", "mendix"),
|
|
928
|
+
chdir: docker_dir
|
|
929
|
+
)
|
|
930
|
+
status.success?
|
|
931
|
+
rescue Errno::ENOENT
|
|
932
|
+
false
|
|
933
|
+
end
|
|
934
|
+
|
|
807
935
|
def parse_query_rows(stdout)
|
|
808
936
|
out = stdout.to_s.strip
|
|
809
937
|
return [] if out.empty?
|
|
@@ -903,6 +1031,95 @@ module MendixBridge
|
|
|
903
1031
|
json(response, { error: "invalid JSON payload" }, status: 400)
|
|
904
1032
|
end
|
|
905
1033
|
|
|
1034
|
+
# ---- ALTER PAGE / ALTER SNIPPET --------------------------------------
|
|
1035
|
+
#
|
|
1036
|
+
# GET /api/alter?qn=Module.Page — widget tree + names for the page
|
|
1037
|
+
# POST /api/alter — apply operations, check & save draft
|
|
1038
|
+
#
|
|
1039
|
+
# POST body:
|
|
1040
|
+
# { "qn": "Module.Page",
|
|
1041
|
+
# "operations": [
|
|
1042
|
+
# { "op": "set", "widget": "btnSave",
|
|
1043
|
+
# "props": { "Caption": "Save & Close", "ButtonStyle": "Success" } },
|
|
1044
|
+
# { "op": "insert_after", "widget": "tbEmail",
|
|
1045
|
+
# "body": "textbox tbPhone (Label: 'Phone', Attribute: Phone)" },
|
|
1046
|
+
# { "op": "drop", "widgets": ["tbUnused"] },
|
|
1047
|
+
# { "op": "replace", "widget": "tbName",
|
|
1048
|
+
# "body": "textarea taName (Label: 'Name', Attribute: Name)" },
|
|
1049
|
+
# { "op": "set_layout", "layout": "Atlas_Core.Atlas_Sidebar_Full" },
|
|
1050
|
+
# { "op": "add_variable", "name": "counter",
|
|
1051
|
+
# "type": "Integer", "default": "0" },
|
|
1052
|
+
# { "op": "drop_variable", "name": "legacyVar" }
|
|
1053
|
+
# ]
|
|
1054
|
+
# }
|
|
1055
|
+
|
|
1056
|
+
def alter_route(request, response)
|
|
1057
|
+
case request.request_method
|
|
1058
|
+
when "GET" then alter_info(request, response)
|
|
1059
|
+
when "POST" then alter_apply(request, response)
|
|
1060
|
+
else json(response, { error: "method not allowed" }, status: 405)
|
|
1061
|
+
end
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
def alter_info(request, response)
|
|
1065
|
+
qn = request.query["qn"].to_s
|
|
1066
|
+
return json(response, { error: "qn required" }, status: 400) if qn.empty?
|
|
1067
|
+
|
|
1068
|
+
detail = page_detail(qn)
|
|
1069
|
+
return json(response, { error: "unknown page '#{qn}'" }, status: 404) unless detail
|
|
1070
|
+
|
|
1071
|
+
widget_tree = detail["widget_tree"] || []
|
|
1072
|
+
widget_names = detail["widget_names"] || MdlParser.flat_widget_names(widget_tree)
|
|
1073
|
+
|
|
1074
|
+
json(response, {
|
|
1075
|
+
qn:,
|
|
1076
|
+
widget_tree:,
|
|
1077
|
+
widget_names:,
|
|
1078
|
+
layout: detail["layout"],
|
|
1079
|
+
title: detail["title"]
|
|
1080
|
+
})
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
def alter_apply(request, response)
|
|
1084
|
+
payload = JSON.parse(request.body.to_s)
|
|
1085
|
+
qn = payload.fetch("qn").to_s
|
|
1086
|
+
operations = payload.fetch("operations")
|
|
1087
|
+
|
|
1088
|
+
detail = page_detail(qn)
|
|
1089
|
+
return json(response, { error: "unknown page '#{qn}'" }, status: 404) unless detail
|
|
1090
|
+
|
|
1091
|
+
widget_tree = detail["widget_tree"] || []
|
|
1092
|
+
known_names = detail["widget_names"] || MdlParser.flat_widget_names(widget_tree)
|
|
1093
|
+
|
|
1094
|
+
# Validate before generating MDL
|
|
1095
|
+
errors = AlterPageBuilder.validate(operations, known_names: known_names)
|
|
1096
|
+
if errors.any?
|
|
1097
|
+
return json(response, { ok: false, errors: }, status: 422)
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
mdl = AlterPageBuilder.build(qn:, operations:, known_names: known_names)
|
|
1101
|
+
|
|
1102
|
+
ok, message = check_mdl(mdl)
|
|
1103
|
+
persist_draft(
|
|
1104
|
+
"alter-plans.json", qn,
|
|
1105
|
+
"operations" => operations,
|
|
1106
|
+
"mdl" => mdl,
|
|
1107
|
+
"valid" => ok,
|
|
1108
|
+
"message" => message
|
|
1109
|
+
)
|
|
1110
|
+
json(
|
|
1111
|
+
response,
|
|
1112
|
+
{ ok:, mdl:, message: ok ? "Page changes validated and saved." : message },
|
|
1113
|
+
status: ok ? 200 : 422
|
|
1114
|
+
)
|
|
1115
|
+
rescue KeyError => e
|
|
1116
|
+
json(response, { error: "missing parameter: #{e.key}" }, status: 400)
|
|
1117
|
+
rescue JSON::ParserError
|
|
1118
|
+
json(response, { error: "invalid JSON payload" }, status: 400)
|
|
1119
|
+
rescue ArgumentError => e
|
|
1120
|
+
json(response, { ok: false, errors: [e.message] }, status: 422)
|
|
1121
|
+
end
|
|
1122
|
+
|
|
906
1123
|
# ---- app run/stop/log ------------------------------------------------
|
|
907
1124
|
|
|
908
1125
|
def app_route(request, response)
|
|
@@ -1394,6 +1611,141 @@ module MendixBridge
|
|
|
1394
1611
|
end
|
|
1395
1612
|
end
|
|
1396
1613
|
|
|
1614
|
+
# ---- XAS proxy / interceptor -----------------------------------------
|
|
1615
|
+
#
|
|
1616
|
+
# When enabled, the bridge sits between the browser and the Mendix runtime,
|
|
1617
|
+
# logging every /xas/ call so you can inspect actions, operationIds, and
|
|
1618
|
+
# the data the client reads or writes. The runtime stays unmodified — all
|
|
1619
|
+
# requests are forwarded transparently and the original response is returned
|
|
1620
|
+
# verbatim. Enable with POST /api/xas/enable; browser/Studio Pro must then
|
|
1621
|
+
# point at the bridge port instead of the Mendix runtime port.
|
|
1622
|
+
|
|
1623
|
+
def xas_proxy(request, response)
|
|
1624
|
+
target_port = mendix_runtime_port
|
|
1625
|
+
unless @xas_proxy_enabled && target_port
|
|
1626
|
+
return json(
|
|
1627
|
+
response,
|
|
1628
|
+
{ error: "XAS proxy not enabled — POST /api/xas/enable first" },
|
|
1629
|
+
status: 503
|
|
1630
|
+
)
|
|
1631
|
+
end
|
|
1632
|
+
|
|
1633
|
+
body = request.body.to_s
|
|
1634
|
+
payload = body.empty? ? {} : JSON.parse(body)
|
|
1635
|
+
action = payload["action"].to_s
|
|
1636
|
+
operation_id = payload.dig("params", "operationId").to_s
|
|
1637
|
+
|
|
1638
|
+
uri = URI("http://127.0.0.1:#{target_port}/xas/")
|
|
1639
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
1640
|
+
http.read_timeout = 30
|
|
1641
|
+
|
|
1642
|
+
proxy_req = Net::HTTP::Post.new(uri.path)
|
|
1643
|
+
%w[cookie x-csrf-token content-type].each do |h|
|
|
1644
|
+
proxy_req[h] = request[h] if request[h]
|
|
1645
|
+
end
|
|
1646
|
+
proxy_req.body = body
|
|
1647
|
+
|
|
1648
|
+
proxy_res = http.request(proxy_req)
|
|
1649
|
+
res_body = proxy_res.body.to_s
|
|
1650
|
+
res_status = proxy_res.code.to_i
|
|
1651
|
+
|
|
1652
|
+
entry = {
|
|
1653
|
+
"at" => Time.now.iso8601,
|
|
1654
|
+
"action" => action,
|
|
1655
|
+
"operation_id" => operation_id.empty? ? nil : operation_id,
|
|
1656
|
+
"params" => payload["params"],
|
|
1657
|
+
"status" => res_status,
|
|
1658
|
+
"response_size" => res_body.bytesize
|
|
1659
|
+
}
|
|
1660
|
+
if res_body.bytesize < 16_384
|
|
1661
|
+
begin
|
|
1662
|
+
entry["response"] = JSON.parse(res_body)
|
|
1663
|
+
rescue JSON::ParserError
|
|
1664
|
+
nil
|
|
1665
|
+
end
|
|
1666
|
+
end
|
|
1667
|
+
|
|
1668
|
+
@xas_mutex.synchronize do
|
|
1669
|
+
@xas_log << entry
|
|
1670
|
+
@xas_log = @xas_log.last(500)
|
|
1671
|
+
end
|
|
1672
|
+
|
|
1673
|
+
response.status = res_status
|
|
1674
|
+
response["content-type"] = proxy_res["content-type"] || "application/json"
|
|
1675
|
+
response["cache-control"] = "no-store"
|
|
1676
|
+
response.body = res_body
|
|
1677
|
+
rescue JSON::ParserError
|
|
1678
|
+
json(response, { error: "invalid JSON in request body" }, status: 400)
|
|
1679
|
+
rescue Errno::ECONNREFUSED
|
|
1680
|
+
json(response, { error: "Mendix runtime not reachable on port #{target_port}" }, status: 502)
|
|
1681
|
+
rescue Net::ReadTimeout
|
|
1682
|
+
json(response, { error: "Mendix runtime timed out" }, status: 504)
|
|
1683
|
+
end
|
|
1684
|
+
|
|
1685
|
+
# GET /api/xas/log — query the intercepted call log
|
|
1686
|
+
# GET /api/xas/status — proxy enabled state + log size
|
|
1687
|
+
# POST /api/xas/enable — start proxying (optional body: {"port": 8080})
|
|
1688
|
+
# POST /api/xas/disable — stop proxying
|
|
1689
|
+
# POST /api/xas/clear — wipe the log
|
|
1690
|
+
def xas_control(request, response)
|
|
1691
|
+
action = request.path.delete_prefix("/api/xas").sub(%r{\A/}, "")
|
|
1692
|
+
|
|
1693
|
+
case [request.request_method, action]
|
|
1694
|
+
when ["GET", "log"]
|
|
1695
|
+
limit = request.query["limit"].to_s.empty? ? 50 : [[request.query["limit"].to_i, 1].max, 500].min
|
|
1696
|
+
entries = @xas_mutex.synchronize { @xas_log.dup }
|
|
1697
|
+
entries = entries.select { |e| e["action"] == request.query["action"] } if request.query["action"].to_s != ""
|
|
1698
|
+
entries = entries.select { |e| e["at"] >= request.query["since"] } if request.query["since"].to_s != ""
|
|
1699
|
+
json(response, { entries: entries.last(limit), total: @xas_log.size })
|
|
1700
|
+
|
|
1701
|
+
when ["GET", "status"]
|
|
1702
|
+
json(response, {
|
|
1703
|
+
enabled: @xas_proxy_enabled,
|
|
1704
|
+
target_port: @xas_target_port || mendix_runtime_port,
|
|
1705
|
+
log_size: @xas_mutex.synchronize { @xas_log.size }
|
|
1706
|
+
})
|
|
1707
|
+
|
|
1708
|
+
when ["POST", "enable"]
|
|
1709
|
+
payload = request.body.to_s.empty? ? {} : JSON.parse(request.body.to_s)
|
|
1710
|
+
port = payload["port"]&.to_i.then { |p| p&.positive? ? p : nil } || mendix_runtime_port
|
|
1711
|
+
unless port
|
|
1712
|
+
return json(
|
|
1713
|
+
response,
|
|
1714
|
+
{ ok: false, message: "Cannot determine Mendix runtime port. Pass {\"port\": 8080} or set APP_PORT in .docker/.env." },
|
|
1715
|
+
status: 422
|
|
1716
|
+
)
|
|
1717
|
+
end
|
|
1718
|
+
@xas_target_port = port
|
|
1719
|
+
@xas_proxy_enabled = true
|
|
1720
|
+
json(response, { ok: true, message: "XAS proxy enabled → port #{port}. Point your browser at http://localhost:#{@port} instead of :#{port}." })
|
|
1721
|
+
|
|
1722
|
+
when ["POST", "disable"]
|
|
1723
|
+
@xas_proxy_enabled = false
|
|
1724
|
+
json(response, { ok: true, message: "XAS proxy disabled." })
|
|
1725
|
+
|
|
1726
|
+
when ["POST", "clear"]
|
|
1727
|
+
@xas_mutex.synchronize { @xas_log.clear }
|
|
1728
|
+
json(response, { ok: true, message: "XAS log cleared." })
|
|
1729
|
+
|
|
1730
|
+
else
|
|
1731
|
+
json(response, { error: "not found" }, status: 404)
|
|
1732
|
+
end
|
|
1733
|
+
rescue JSON::ParserError
|
|
1734
|
+
json(response, { error: "invalid JSON payload" }, status: 400)
|
|
1735
|
+
end
|
|
1736
|
+
|
|
1737
|
+
# Resolves the Mendix runtime port: explicit override > .docker/.env APP_PORT > 8080.
|
|
1738
|
+
def mendix_runtime_port
|
|
1739
|
+
return @xas_target_port if @xas_target_port
|
|
1740
|
+
|
|
1741
|
+
path = docker_env_path
|
|
1742
|
+
return 8080 unless path
|
|
1743
|
+
|
|
1744
|
+
env = parse_env_file(path)
|
|
1745
|
+
port = env["APP_PORT"].to_i
|
|
1746
|
+
port.positive? ? port : 8080
|
|
1747
|
+
end
|
|
1748
|
+
|
|
1397
1749
|
def json(response, payload, status: 200)
|
|
1398
1750
|
response.status = status
|
|
1399
1751
|
response["content-type"] = "application/json; charset=utf-8"
|