mxrb 0.1.1 → 0.1.3
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/README.de-DE.md +4 -0
- data/README.md +4 -0
- data/README.pt-BR.md +4 -0
- data/bin/mxrb +38 -2
- data/docs/de-DE/oql-sql.md +18 -0
- data/docs/de-DE/scaffolds.md +23 -0
- data/docs/en-US/oql-sql.md +18 -0
- data/docs/en-US/scaffolds.md +22 -0
- data/docs/pt-BR/entity-dsl.md +8 -0
- data/docs/pt-BR/oql-sql.md +18 -0
- data/docs/pt-BR/scaffolds.md +22 -0
- data/docs/pt-BR/writing.md +28 -0
- data/lib/mxrb/compare.rb +7 -0
- data/lib/mxrb/compiler/packager.rb +27 -13
- data/lib/mxrb/compiler/portable_packager.rb +16 -11
- data/lib/mxrb/dsl/builder.rb +100 -2
- data/lib/mxrb/exporter.rb +184 -32
- data/lib/mxrb/initializer.rb +1 -0
- data/lib/mxrb/integrity/validator.rb +18 -7
- data/lib/mxrb/model/entity.rb +17 -1
- data/lib/mxrb/model/module.rb +71 -0
- data/lib/mxrb/official_marketplace/module_package_importer.rb +23 -12
- data/lib/mxrb/official_marketplace/widget_package_installer.rb +15 -11
- data/lib/mxrb/official_marketplace.rb +9 -3
- data/lib/mxrb/oql.rb +9 -3
- data/lib/mxrb/progress.rb +242 -0
- data/lib/mxrb/runtime/clipboard.rb +62 -0
- data/lib/mxrb/runtime/database_workspace.rb +28 -0
- data/lib/mxrb/scaffold/cli.rb +20 -1
- data/lib/mxrb/scaffold/generator.rb +125 -3
- data/lib/mxrb/scaffold/help.rb +16 -2
- data/lib/mxrb/scaffold/recipes.rb +27 -0
- data/lib/mxrb/scaffold/templates.rb +11 -0
- data/lib/mxrb/scaffold/transaction.rb +2 -2
- data/lib/mxrb/team_server.rb +10 -7
- data/lib/mxrb/version.rb +1 -1
- data/lib/mxrb/writer.rb +94 -19
- data/lib/mxrb.rb +13 -7
- metadata +3 -1
data/lib/mxrb/model/module.rb
CHANGED
|
@@ -7,6 +7,40 @@ module Mxrb
|
|
|
7
7
|
# $Type: Projects$Module
|
|
8
8
|
# ContainmentName: "Modules"
|
|
9
9
|
class Module < Unit
|
|
10
|
+
INFRASTRUCTURE_DOCUMENT_ROUTES = {
|
|
11
|
+
'ExportMappings$ExportMapping' => 'mappings/exports',
|
|
12
|
+
'ImportMappings$ImportMapping' => 'mappings/imports',
|
|
13
|
+
'JsonStructures$JsonStructure' => 'mappings/json_structures',
|
|
14
|
+
'MessageDefinitions$MessageDefinitionCollection' => 'mappings/message_definitions',
|
|
15
|
+
'MessageDefinitions$MessageDefinition2' => 'mappings/message_definitions',
|
|
16
|
+
'XmlSchemas$XmlSchema' => 'mappings/xml_schemas',
|
|
17
|
+
'Rest$PublishedRestService' => 'endpoints',
|
|
18
|
+
'WebServices$PublishedService' => 'endpoints',
|
|
19
|
+
'WebServices$PublishedWebService' => 'endpoints',
|
|
20
|
+
'ODataPublish$PublishedODataService' => 'endpoints',
|
|
21
|
+
'ODataPublish$PublishedODataService2' => 'endpoints',
|
|
22
|
+
'Rest$ConsumedRestService' => 'integrations',
|
|
23
|
+
'Rest$ConsumedODataService' => 'integrations',
|
|
24
|
+
'AppServices$ConsumedAppService' => 'integrations',
|
|
25
|
+
'ODataImport$ConsumedODataService' => 'integrations'
|
|
26
|
+
}.freeze
|
|
27
|
+
MAPPING_DOCUMENT_TYPES = INFRASTRUCTURE_DOCUMENT_ROUTES.keys.grep(
|
|
28
|
+
/Mappings|JsonStructures|MessageDefinitions|XmlSchemas/
|
|
29
|
+
).freeze
|
|
30
|
+
APPLICATION_DOCUMENT_ROUTES = {
|
|
31
|
+
'DataSets$DataSet' => 'queries/datasets',
|
|
32
|
+
'ScheduledEvents$ScheduledEvent' => 'jobs/scheduled_events'
|
|
33
|
+
}.freeze
|
|
34
|
+
DOMAIN_DOCUMENT_ROUTES = {
|
|
35
|
+
'DomainModels$ViewEntitySourceDocument' => 'oql_views',
|
|
36
|
+
'Enumerations$Enumeration' => 'enumerations',
|
|
37
|
+
'Constants$Constant' => 'constants'
|
|
38
|
+
}.freeze
|
|
39
|
+
EDITABLE_DOCUMENT_TYPES = (
|
|
40
|
+
INFRASTRUCTURE_DOCUMENT_ROUTES.keys + APPLICATION_DOCUMENT_ROUTES.keys +
|
|
41
|
+
DOMAIN_DOCUMENT_ROUTES.keys
|
|
42
|
+
).freeze
|
|
43
|
+
|
|
10
44
|
attr_reader :name, :sort_index, :from_app_store,
|
|
11
45
|
:app_store_guid, :app_store_version, :export_level
|
|
12
46
|
|
|
@@ -84,6 +118,28 @@ module Mxrb
|
|
|
84
118
|
.map { unit_to_doc(_1) }
|
|
85
119
|
end
|
|
86
120
|
|
|
121
|
+
def mapping_documents
|
|
122
|
+
infrastructure_documents.select { MAPPING_DOCUMENT_TYPES.include?(_1[:type]) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def infrastructure_documents
|
|
126
|
+
@infrastructure_documents ||= routed_documents(INFRASTRUCTURE_DOCUMENT_ROUTES)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def application_documents
|
|
130
|
+
@application_documents ||= routed_documents(APPLICATION_DOCUMENT_ROUTES)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def domain_documents
|
|
134
|
+
@domain_documents ||= routed_documents(DOMAIN_DOCUMENT_ROUTES)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def oql_view_documents
|
|
138
|
+
@oql_view_documents ||= domain_documents.select do |document|
|
|
139
|
+
document[:type] == 'DomainModels$ViewEntitySourceDocument'
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
87
143
|
def module_roles
|
|
88
144
|
@module_roles ||= begin
|
|
89
145
|
raw = @mpr.children_of(@id).find { _1["ContainmentName"] == "ModuleSecurity" }
|
|
@@ -109,6 +165,21 @@ module Mxrb
|
|
|
109
165
|
@mpr.parse_contents(unit_hash[:raw])
|
|
110
166
|
end
|
|
111
167
|
|
|
168
|
+
def routed_documents(routes)
|
|
169
|
+
document_units.filter_map do |unit|
|
|
170
|
+
route = routes[unit[:type]]
|
|
171
|
+
next unless route
|
|
172
|
+
|
|
173
|
+
raw = unit.fetch(:raw)
|
|
174
|
+
doc = @mpr.parse_contents(raw)
|
|
175
|
+
{
|
|
176
|
+
id: raw.fetch("UnitID"), container_id: raw.fetch("ContainerID"),
|
|
177
|
+
containment: raw.fetch("ContainmentName"), type: unit.fetch(:type),
|
|
178
|
+
name: doc["Name"] || doc["name"] || raw.fetch("UnitID"), doc:, route:
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
112
183
|
# Documents live in ContainmentName = "Documents" recursively under this module.
|
|
113
184
|
# We do a simple two-pass: direct Documents children + Documents inside Folders.
|
|
114
185
|
def document_units
|
|
@@ -122,6 +122,7 @@ module Mxrb
|
|
|
122
122
|
def install(staged_files, protected_files = [])
|
|
123
123
|
staged_files.each do |relative, source|
|
|
124
124
|
install_file(relative, source) unless protected_files.include?(relative)
|
|
125
|
+
yield(relative) if block_given?
|
|
125
126
|
end
|
|
126
127
|
end
|
|
127
128
|
|
|
@@ -179,9 +180,12 @@ module Mxrb
|
|
|
179
180
|
end
|
|
180
181
|
|
|
181
182
|
def import!
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
183
|
+
Progress.with("Importing #{File.basename(@package_path)}") do |progress|
|
|
184
|
+
validate_inputs!
|
|
185
|
+
progress.update(detail: 'reading package')
|
|
186
|
+
Dir.mktmpdir('mxrb-module-import-') do |temporary|
|
|
187
|
+
Zip::File.open(@package_path) { import_archive(_1, temporary, progress) }
|
|
188
|
+
end
|
|
185
189
|
end
|
|
186
190
|
rescue Zip::Error, REXML::ParseException => e
|
|
187
191
|
raise MarketplaceError, "invalid Mendix module package: #{e.message}"
|
|
@@ -189,12 +193,12 @@ module Mxrb
|
|
|
189
193
|
|
|
190
194
|
private
|
|
191
195
|
|
|
192
|
-
def import_archive(archive, temporary)
|
|
196
|
+
def import_archive(archive, temporary, progress)
|
|
193
197
|
reader = ModulePackageReader.new(archive)
|
|
194
198
|
descriptor = reader.descriptor
|
|
195
199
|
source_path = reader.extract_project(descriptor, temporary)
|
|
196
200
|
staged_files = reader.stage_files(descriptor.files, temporary)
|
|
197
|
-
import_project(source_path, descriptor, staged_files, temporary)
|
|
201
|
+
import_project(source_path, descriptor, staged_files, temporary, progress)
|
|
198
202
|
end
|
|
199
203
|
|
|
200
204
|
def validate_inputs!
|
|
@@ -203,12 +207,13 @@ module Mxrb
|
|
|
203
207
|
raise MarketplaceError, "target root not found: #{@target_root}" unless File.directory?(@target_root)
|
|
204
208
|
end
|
|
205
209
|
|
|
206
|
-
def import_project(source_path, descriptor, staged_files, temporary) # rubocop:disable Metrics/MethodLength
|
|
210
|
+
def import_project(source_path, descriptor, staged_files, temporary, progress) # rubocop:disable Metrics/MethodLength
|
|
207
211
|
source = IO::MprFile.open(source_path, readonly: true)
|
|
208
212
|
target = IO::MprFile.open(@mpr_path)
|
|
209
213
|
assets = ModulePackageAssets.new(@target_root, temporary)
|
|
210
214
|
imported_ids = []
|
|
211
|
-
import_transaction(source, target, descriptor, staged_files, assets, imported_ids)
|
|
215
|
+
import_transaction(source, target, descriptor, staged_files, assets, imported_ids, progress)
|
|
216
|
+
progress.advance(detail: 'import transaction')
|
|
212
217
|
import_result(source, target, descriptor, staged_files, imported_ids)
|
|
213
218
|
rescue StandardError
|
|
214
219
|
assets&.rollback
|
|
@@ -220,24 +225,30 @@ module Mxrb
|
|
|
220
225
|
end
|
|
221
226
|
|
|
222
227
|
# rubocop:disable Metrics/ParameterLists
|
|
223
|
-
def import_transaction(source, target, descriptor, staged_files, assets, imported_ids)
|
|
228
|
+
def import_transaction(source, target, descriptor, staged_files, assets, imported_ids, progress) # rubocop:disable Metrics/MethodLength
|
|
224
229
|
validate_versions!(source, target, descriptor)
|
|
225
230
|
module_unit, units = package_units(source, descriptor.name)
|
|
226
231
|
validate_target!(target, descriptor.name, units)
|
|
232
|
+
progress.update(
|
|
233
|
+
current: 2, total: units.size + staged_files.size + 3,
|
|
234
|
+
detail: "#{units.size} model units"
|
|
235
|
+
)
|
|
227
236
|
target.transaction do
|
|
228
|
-
imported_ids.concat(insert_units(source, target, module_unit, units))
|
|
229
|
-
assets.install(staged_files, @protected_files)
|
|
237
|
+
imported_ids.concat(insert_units(source, target, module_unit, units, progress))
|
|
238
|
+
assets.install(staged_files, @protected_files) do |relative|
|
|
239
|
+
progress.advance(detail: "asset #{relative}")
|
|
240
|
+
end
|
|
230
241
|
end
|
|
231
242
|
end
|
|
232
243
|
# rubocop:enable Metrics/ParameterLists
|
|
233
244
|
|
|
234
|
-
def insert_units(source, target, module_unit, units)
|
|
245
|
+
def insert_units(source, target, module_unit, units, progress)
|
|
235
246
|
units.map do |unit|
|
|
236
247
|
container = unit == module_unit ? target.root_unit.fetch('UnitID') : unit.fetch('ContainerID')
|
|
237
248
|
target.insert_unit(
|
|
238
249
|
container_uuid: container, containment_name: unit.fetch('ContainmentName'),
|
|
239
250
|
contents_doc: source.parse_contents(unit)
|
|
240
|
-
)
|
|
251
|
+
).tap { progress.advance(detail: "unit #{unit.fetch('UnitID')}") }
|
|
241
252
|
end
|
|
242
253
|
end
|
|
243
254
|
|
|
@@ -200,17 +200,21 @@ module Mxrb
|
|
|
200
200
|
end
|
|
201
201
|
|
|
202
202
|
def install(archive, package)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
203
|
+
Progress.with("Installing widget #{package.name}") do |progress|
|
|
204
|
+
validate_target!
|
|
205
|
+
progress.update(detail: 'reading widget package')
|
|
206
|
+
inventory = WidgetPackageInventory.read(archive)
|
|
207
|
+
validate_identity!(inventory, package)
|
|
208
|
+
digest = Digest::SHA256.file(archive).hexdigest
|
|
209
|
+
destination = safe_path(File.join('widgets', inventory.project_filename))
|
|
210
|
+
cache = safe_path(cache_relative(inventory, package))
|
|
211
|
+
lock_path = safe_path(File.join('.mxrb', 'marketplace.lock.json'))
|
|
212
|
+
current = validate_boundaries!(package, destination, cache, lock_path)
|
|
213
|
+
progress.update(detail: 'installing project assets')
|
|
214
|
+
install_transaction(
|
|
215
|
+
archive, package, inventory, digest, destination, cache, lock_path, current
|
|
216
|
+
)
|
|
217
|
+
end
|
|
214
218
|
end
|
|
215
219
|
|
|
216
220
|
private
|
|
@@ -223,14 +223,20 @@ module Mxrb
|
|
|
223
223
|
end
|
|
224
224
|
|
|
225
225
|
def json(url, authorization: default_authorization)
|
|
226
|
-
|
|
226
|
+
Progress.with('Loading Marketplace data') do |progress|
|
|
227
|
+
progress.update(detail: URI.parse(url).host)
|
|
228
|
+
JSON.parse(get(url, accept: 'application/json', authorization:))
|
|
229
|
+
end
|
|
227
230
|
rescue JSON::ParserError => e
|
|
228
231
|
raise MarketplaceError, "invalid JSON response: #{e.message}"
|
|
229
232
|
end
|
|
230
233
|
|
|
231
234
|
def download(url, destination, authorization: default_authorization)
|
|
232
|
-
File.
|
|
233
|
-
|
|
235
|
+
Progress.with("Downloading #{File.basename(destination)}") do |progress|
|
|
236
|
+
progress.update(detail: URI.parse(url).host)
|
|
237
|
+
File.binwrite(destination, get(url, accept: 'application/octet-stream', authorization:))
|
|
238
|
+
destination
|
|
239
|
+
end
|
|
234
240
|
end
|
|
235
241
|
|
|
236
242
|
private
|
data/lib/mxrb/oql.rb
CHANGED
|
@@ -49,7 +49,7 @@ module Mxrb
|
|
|
49
49
|
|
|
50
50
|
# Recursive BSON discovery is intentionally kept together so that its
|
|
51
51
|
# source-type guard and ownership context cannot diverge.
|
|
52
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
52
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
53
53
|
def discover(document, raw, module_name)
|
|
54
54
|
found = []
|
|
55
55
|
walk(document) do |node, path, ancestors|
|
|
@@ -59,6 +59,11 @@ module Mxrb
|
|
|
59
59
|
node['Query'], raw, module_name, path + ['Query'], source_type, ancestors
|
|
60
60
|
)
|
|
61
61
|
end
|
|
62
|
+
if source_type == 'DomainModels$ViewEntitySourceDocument' && node['Oql'].is_a?(String)
|
|
63
|
+
found << build_query(
|
|
64
|
+
node['Oql'], raw, module_name, path + ['Oql'], source_type, ancestors
|
|
65
|
+
)
|
|
66
|
+
end
|
|
62
67
|
node.each do |key, value|
|
|
63
68
|
next unless key.to_s.match?(/\AOqlQuery\z/i) && value.is_a?(String)
|
|
64
69
|
|
|
@@ -67,7 +72,7 @@ module Mxrb
|
|
|
67
72
|
end
|
|
68
73
|
found.uniq { [_1.unit_id, _1.path] }
|
|
69
74
|
end
|
|
70
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
75
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
71
76
|
|
|
72
77
|
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
73
78
|
def walk(node, path = [], ancestors = [], &block)
|
|
@@ -97,7 +102,8 @@ module Mxrb
|
|
|
97
102
|
unit_type = @project.parse_bson(raw)['$Type'].to_s
|
|
98
103
|
kind = if unit_type == 'DataSets$DataSet'
|
|
99
104
|
:dataset
|
|
100
|
-
elsif
|
|
105
|
+
elsif unit_type == 'DomainModels$ViewEntitySourceDocument' ||
|
|
106
|
+
owner['$Type'].to_s.match?(/Entity/i)
|
|
101
107
|
:view_entity
|
|
102
108
|
else
|
|
103
109
|
:oql
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/console'
|
|
4
|
+
require 'singleton'
|
|
5
|
+
|
|
6
|
+
module Mxrb
|
|
7
|
+
# Terminal progress rendering shared by every long-running MXRB operation.
|
|
8
|
+
# It is enabled automatically only for an interactive terminal, writes to
|
|
9
|
+
# stderr, and therefore never contaminates command output or JSON on stdout.
|
|
10
|
+
module Progress
|
|
11
|
+
THREAD_KEY = :mxrb_progress_task
|
|
12
|
+
FALSE_VALUES = %w[0 false no off].freeze
|
|
13
|
+
|
|
14
|
+
# No-op object returned when progress rendering is disabled.
|
|
15
|
+
class NullTask
|
|
16
|
+
include Singleton
|
|
17
|
+
|
|
18
|
+
def start = self
|
|
19
|
+
def update(**) = self
|
|
20
|
+
def advance(*, **) = self
|
|
21
|
+
def add_total(*) = self
|
|
22
|
+
def finish(*) = self
|
|
23
|
+
def fail(*) = self
|
|
24
|
+
def enabled? = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Thread-safe terminal renderer for a single operation.
|
|
28
|
+
class Task # rubocop:disable Metrics/ClassLength
|
|
29
|
+
SPINNER = %w[| / - \\].freeze
|
|
30
|
+
BAR_WIDTH = 28
|
|
31
|
+
REFRESH_INTERVAL = 0.08
|
|
32
|
+
|
|
33
|
+
attr_reader :label, :total, :current
|
|
34
|
+
|
|
35
|
+
def initialize(label, total: nil, io: $stderr)
|
|
36
|
+
@label = label.to_s
|
|
37
|
+
@total = normalize_total(total)
|
|
38
|
+
@io = io
|
|
39
|
+
@current = 0
|
|
40
|
+
@detail = nil
|
|
41
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
42
|
+
@last_rendered_at = 0.0
|
|
43
|
+
@spinner_index = 0
|
|
44
|
+
@mutex = Mutex.new
|
|
45
|
+
@finished = false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def enabled? = true
|
|
49
|
+
|
|
50
|
+
def start
|
|
51
|
+
render(force: true)
|
|
52
|
+
start_spinner unless determinate?
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def update(current: nil, total: nil, detail: nil, force: false)
|
|
57
|
+
became_determinate = false
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
became_determinate = @total.nil? && !total.nil?
|
|
60
|
+
@total = normalize_total(total) unless total.nil?
|
|
61
|
+
@current = [[Integer(current), 0].max, @total || Float::INFINITY].min unless current.nil?
|
|
62
|
+
@detail = detail.to_s unless detail.nil?
|
|
63
|
+
end
|
|
64
|
+
stop_spinner if became_determinate
|
|
65
|
+
render(force:)
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def advance(amount = 1, detail: nil, force: false)
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
@current += amount
|
|
72
|
+
@current = [@current, @total].min if @total
|
|
73
|
+
@detail = detail.to_s unless detail.nil?
|
|
74
|
+
end
|
|
75
|
+
render(force:)
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def add_total(amount)
|
|
80
|
+
@mutex.synchronize { @total = (@total || 0) + Integer(amount) }
|
|
81
|
+
render(force: true)
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def finish(detail = nil)
|
|
86
|
+
stop_spinner
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
return self if @finished
|
|
89
|
+
|
|
90
|
+
@detail = detail.to_s if detail
|
|
91
|
+
@current = @total if @total
|
|
92
|
+
@finished = true
|
|
93
|
+
end
|
|
94
|
+
render(force: true, final: true)
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def fail(message = nil)
|
|
99
|
+
stop_spinner
|
|
100
|
+
@mutex.synchronize do
|
|
101
|
+
return self if @finished
|
|
102
|
+
|
|
103
|
+
@detail = message.to_s unless message.to_s.empty?
|
|
104
|
+
@finished = true
|
|
105
|
+
end
|
|
106
|
+
render(force: true, final: true, failed: true)
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def normalize_total(value)
|
|
113
|
+
return nil if value.nil?
|
|
114
|
+
|
|
115
|
+
[Integer(value), 1].max
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def determinate? = !@total.nil?
|
|
119
|
+
|
|
120
|
+
def start_spinner
|
|
121
|
+
return unless dynamic_terminal?
|
|
122
|
+
|
|
123
|
+
@spinner_thread = Thread.new do
|
|
124
|
+
loop do
|
|
125
|
+
sleep REFRESH_INTERVAL
|
|
126
|
+
break if @mutex.synchronize { @finished }
|
|
127
|
+
|
|
128
|
+
render(force: true)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def stop_spinner
|
|
134
|
+
thread = @spinner_thread
|
|
135
|
+
return unless thread
|
|
136
|
+
|
|
137
|
+
@mutex.synchronize { @finished = true }
|
|
138
|
+
thread.join
|
|
139
|
+
@spinner_thread = nil
|
|
140
|
+
@mutex.synchronize { @finished = false }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def render(force: false, final: false, failed: false) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
144
|
+
line = nil
|
|
145
|
+
@mutex.synchronize do
|
|
146
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
147
|
+
return if !force && !final && now - @last_rendered_at < REFRESH_INTERVAL
|
|
148
|
+
|
|
149
|
+
@last_rendered_at = now
|
|
150
|
+
line = rendered_line(now, failed:)
|
|
151
|
+
end
|
|
152
|
+
prefix = dynamic_terminal? ? "\r\e[2K" : ''
|
|
153
|
+
suffix = final || !dynamic_terminal? ? "\n" : ''
|
|
154
|
+
@io.write("#{prefix}#{truncate(line)}#{suffix}")
|
|
155
|
+
@io.flush if @io.respond_to?(:flush)
|
|
156
|
+
rescue IOError, Errno::EPIPE
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def rendered_line(now, failed:)
|
|
161
|
+
detail = @detail.to_s.empty? ? '' : " - #{@detail}"
|
|
162
|
+
elapsed = format('%.1fs', now - @started_at)
|
|
163
|
+
return "[mxrb] [FAILED] #{@label}#{detail} (#{elapsed})" if failed
|
|
164
|
+
|
|
165
|
+
determinate_line(detail, elapsed) || spinner_line(detail, elapsed)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def determinate_line(detail, elapsed)
|
|
169
|
+
return unless determinate?
|
|
170
|
+
|
|
171
|
+
ratio = [@current.fdiv(@total), 1.0].min
|
|
172
|
+
filled = (ratio * BAR_WIDTH).round
|
|
173
|
+
bar = '#' * filled + '-' * (BAR_WIDTH - filled)
|
|
174
|
+
"[mxrb] [#{bar}] #{format('%3d%%', ratio * 100)} #{@label}#{detail} (#{elapsed})"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def spinner_line(detail, elapsed)
|
|
178
|
+
frame = SPINNER[@spinner_index % SPINNER.length]
|
|
179
|
+
@spinner_index += 1
|
|
180
|
+
"[mxrb] [#{frame}] #{@label}#{detail} (#{elapsed})"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def dynamic_terminal?
|
|
184
|
+
@io.respond_to?(:tty?) && @io.tty?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def truncate(line)
|
|
188
|
+
width = @io.respond_to?(:winsize) ? @io.winsize.last : 100
|
|
189
|
+
width = 100 unless width.to_i.positive?
|
|
190
|
+
return line if line.length <= width
|
|
191
|
+
|
|
192
|
+
"#{line[0, width - 3]}..."
|
|
193
|
+
rescue IOError, Errno::ENOTTY
|
|
194
|
+
line
|
|
195
|
+
end
|
|
196
|
+
end # rubocop:enable Metrics/ClassLength
|
|
197
|
+
|
|
198
|
+
class << self
|
|
199
|
+
attr_writer :io
|
|
200
|
+
|
|
201
|
+
def configure(enabled: nil, io: nil)
|
|
202
|
+
@enabled = enabled unless enabled.nil?
|
|
203
|
+
@io = io if io
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def reset!
|
|
207
|
+
@enabled = nil
|
|
208
|
+
@io = nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def enabled?
|
|
212
|
+
return @enabled unless @enabled.nil?
|
|
213
|
+
return false if FALSE_VALUES.include?(ENV.fetch('MXRB_PROGRESS', '').downcase)
|
|
214
|
+
|
|
215
|
+
output.respond_to?(:tty?) && output.tty?
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def with(label, total: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
219
|
+
active = Thread.current[THREAD_KEY]
|
|
220
|
+
return yield(active) if active
|
|
221
|
+
return yield(NullTask.instance) unless enabled?
|
|
222
|
+
|
|
223
|
+
task = Task.new(label, total:, io: output).start
|
|
224
|
+
Thread.current[THREAD_KEY] = task
|
|
225
|
+
result = yield(task)
|
|
226
|
+
task.finish
|
|
227
|
+
result
|
|
228
|
+
rescue StandardError => e
|
|
229
|
+
task&.fail(e.message)
|
|
230
|
+
raise
|
|
231
|
+
ensure
|
|
232
|
+
Thread.current[THREAD_KEY] = nil if defined?(task) && task
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def current = Thread.current[THREAD_KEY] || NullTask.instance
|
|
236
|
+
|
|
237
|
+
private
|
|
238
|
+
|
|
239
|
+
def output = @io || $stderr
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
|
|
5
|
+
module Mxrb
|
|
6
|
+
module Runtime
|
|
7
|
+
# Copies secrets through stdin so they never appear in the process list.
|
|
8
|
+
class Clipboard
|
|
9
|
+
COMMANDS = [
|
|
10
|
+
['wl-copy'], ['pbcopy'], ['xclip', '-selection', 'clipboard'],
|
|
11
|
+
['xsel', '--clipboard', '--input'], ['clip.exe']
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(path: ENV.fetch('PATH', ''), runner: nil)
|
|
15
|
+
@path = path
|
|
16
|
+
@runner = runner || method(:capture)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def copy(text)
|
|
20
|
+
command = available_command
|
|
21
|
+
unless command
|
|
22
|
+
raise ToolchainError,
|
|
23
|
+
'no clipboard command found; install wl-copy, pbcopy, xclip, xsel, or clip.exe'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
_stdout, stderr, status = @runner.call(command, text.to_s)
|
|
27
|
+
raise ToolchainError, "clipboard command failed: #{stderr}" unless status.success?
|
|
28
|
+
|
|
29
|
+
command.first
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def available_command
|
|
35
|
+
COMMANDS.find { executable?(_1.first) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def executable?(name)
|
|
39
|
+
@path.split(File::PATH_SEPARATOR).any? do |directory|
|
|
40
|
+
file = File.join(directory, name)
|
|
41
|
+
File.file?(file) && File.executable?(file)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def capture(command, input)
|
|
46
|
+
Tempfile.create('mxrb-clipboard') { capture_process(command, input, _1) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def capture_process(command, input, stderr)
|
|
50
|
+
::IO.pipe do |reader, writer|
|
|
51
|
+
pid = Process.spawn({ 'PATH' => @path }, *command, in: reader, out: File::NULL, err: stderr)
|
|
52
|
+
reader.close
|
|
53
|
+
writer.write(input)
|
|
54
|
+
writer.close
|
|
55
|
+
_child, status = Process.wait2(pid)
|
|
56
|
+
stderr.rewind
|
|
57
|
+
['', stderr.read, status]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -14,6 +14,7 @@ module Mxrb
|
|
|
14
14
|
:host, :port, :database, :reader_user, :running, :model_fingerprint,
|
|
15
15
|
:runtime_url
|
|
16
16
|
)
|
|
17
|
+
AdminCredentials = Data.define(:username, :password, :state_dir, :path)
|
|
17
18
|
|
|
18
19
|
# Materializes a private PostgreSQL database for one MPR. The Mendix Runtime
|
|
19
20
|
# owns schema synchronization; analyst access uses a separate read-only role.
|
|
@@ -148,8 +149,35 @@ module Mxrb
|
|
|
148
149
|
"postgresql://#{READER}:#{secret}@127.0.0.1:#{@port}/#{DATABASE}"
|
|
149
150
|
end
|
|
150
151
|
|
|
152
|
+
def admin_credentials
|
|
153
|
+
unless File.file?(credentials_path)
|
|
154
|
+
raise ToolchainError,
|
|
155
|
+
"Runtime credentials not found; run `mxrb db up #{@project_path}` first"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
values = JSON.parse(File.read(credentials_path))
|
|
159
|
+
password = values.fetch('admin_password')
|
|
160
|
+
AdminCredentials.new(admin_user_name, password, @state_dir, credentials_path)
|
|
161
|
+
rescue JSON::ParserError, KeyError => e
|
|
162
|
+
raise ToolchainError, "invalid Runtime credentials file #{credentials_path}: #{e.message}"
|
|
163
|
+
end
|
|
164
|
+
|
|
151
165
|
private
|
|
152
166
|
|
|
167
|
+
def admin_user_name
|
|
168
|
+
mpr = IO::MprFile.open(@project_path, readonly: true)
|
|
169
|
+
begin
|
|
170
|
+
security = mpr.all_units.lazy.map { mpr.parse_contents(_1) }
|
|
171
|
+
.find { _1['$Type'] == 'Security$ProjectSecurity' }
|
|
172
|
+
name = security.to_h.fetch('AdminUserName', '').to_s
|
|
173
|
+
raise ToolchainError, 'MPR has no configured administrator user' if name.empty?
|
|
174
|
+
|
|
175
|
+
name
|
|
176
|
+
ensure
|
|
177
|
+
mpr.close
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
153
181
|
def bind_parameters(statement, params)
|
|
154
182
|
values = params.to_h.transform_keys(&:to_s)
|
|
155
183
|
return [statement, []] if values.empty?
|
data/lib/mxrb/scaffold/cli.rb
CHANGED
|
@@ -31,9 +31,12 @@ module Mxrb
|
|
|
31
31
|
def generator
|
|
32
32
|
chain = extract_option('--chain') if @command == 'page'
|
|
33
33
|
template = extract_option('--template') if @command == 'page'
|
|
34
|
+
entity = extract_option('--entity') if @command == 'demo-user'
|
|
35
|
+
roles = extract_options('--role') if @command == 'demo-user'
|
|
34
36
|
Generator.new(
|
|
35
37
|
kind, scaffold_name, target: target, dry_run: @dry_run,
|
|
36
|
-
page_chain: chain, page_template: template
|
|
38
|
+
page_chain: chain, page_template: template,
|
|
39
|
+
demo_entity: entity, demo_roles: roles
|
|
37
40
|
)
|
|
38
41
|
end
|
|
39
42
|
|
|
@@ -56,6 +59,12 @@ module Mxrb
|
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
def validate_action!
|
|
62
|
+
if @command == 'demo-user'
|
|
63
|
+
@argv.shift if @argv.first == 'new'
|
|
64
|
+
abort Help.text(@command) if @argv.empty?
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
59
68
|
action = @argv.shift
|
|
60
69
|
abort Help.text(@command) unless accepted_actions.include?(action)
|
|
61
70
|
end
|
|
@@ -90,6 +99,16 @@ module Mxrb
|
|
|
90
99
|
@argv.slice!(index, 2).last
|
|
91
100
|
end
|
|
92
101
|
|
|
102
|
+
def extract_options(name)
|
|
103
|
+
values = []
|
|
104
|
+
while (index = @argv.index(name))
|
|
105
|
+
abort "#{name} requires a value" unless @argv[index + 1]
|
|
106
|
+
|
|
107
|
+
values << @argv.slice!(index, 2).last
|
|
108
|
+
end
|
|
109
|
+
values
|
|
110
|
+
end
|
|
111
|
+
|
|
93
112
|
def kind = @command.tr('-', '_').to_sym
|
|
94
113
|
|
|
95
114
|
def render(result) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|