mxrb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.de-DE.md +108 -0
  4. data/README.md +172 -0
  5. data/README.pt-BR.md +109 -0
  6. data/bin/mxrb +1613 -0
  7. data/docs/README.md +10 -0
  8. data/docs/de-DE/README.md +24 -0
  9. data/docs/de-DE/architectural-patterns.md +26 -0
  10. data/docs/de-DE/architectural-standard.md +48 -0
  11. data/docs/de-DE/architecture.md +76 -0
  12. data/docs/de-DE/compiler.md +162 -0
  13. data/docs/de-DE/conventions.md +19 -0
  14. data/docs/de-DE/design-system.md +52 -0
  15. data/docs/de-DE/entity-dsl.md +53 -0
  16. data/docs/de-DE/native-runtime-quality-report.md +74 -0
  17. data/docs/de-DE/oql-sql.md +211 -0
  18. data/docs/de-DE/platform-api-integration.md +36 -0
  19. data/docs/de-DE/platform-operations.md +243 -0
  20. data/docs/de-DE/project-structure.md +32 -0
  21. data/docs/de-DE/ruby-first-roadmap.md +116 -0
  22. data/docs/de-DE/scaffolds.md +59 -0
  23. data/docs/de-DE/semantic-refactoring.md +56 -0
  24. data/docs/de-DE/team-server.md +33 -0
  25. data/docs/de-DE/validation-matrix.md +64 -0
  26. data/docs/de-DE/vetclinic-acceptance.md +24 -0
  27. data/docs/de-DE/writing.md +138 -0
  28. data/docs/en-US/README.md +24 -0
  29. data/docs/en-US/architectural-patterns.md +151 -0
  30. data/docs/en-US/architectural-standard.md +89 -0
  31. data/docs/en-US/architecture.md +170 -0
  32. data/docs/en-US/compiler.md +157 -0
  33. data/docs/en-US/conventions.md +82 -0
  34. data/docs/en-US/design-system.md +163 -0
  35. data/docs/en-US/entity-dsl.md +53 -0
  36. data/docs/en-US/native-runtime-quality-report.md +73 -0
  37. data/docs/en-US/oql-sql.md +201 -0
  38. data/docs/en-US/platform-api-integration.md +36 -0
  39. data/docs/en-US/platform-operations.md +244 -0
  40. data/docs/en-US/project-structure.md +49 -0
  41. data/docs/en-US/ruby-first-roadmap.md +186 -0
  42. data/docs/en-US/scaffolds.md +57 -0
  43. data/docs/en-US/semantic-refactoring.md +198 -0
  44. data/docs/en-US/team-server.md +32 -0
  45. data/docs/en-US/validation-matrix.md +257 -0
  46. data/docs/en-US/vetclinic-acceptance.md +23 -0
  47. data/docs/en-US/writing.md +655 -0
  48. data/docs/pt-BR/README.md +24 -0
  49. data/docs/pt-BR/architectural-patterns.md +25 -0
  50. data/docs/pt-BR/architectural-standard.md +533 -0
  51. data/docs/pt-BR/architecture.md +300 -0
  52. data/docs/pt-BR/compiler.md +162 -0
  53. data/docs/pt-BR/conventions.md +19 -0
  54. data/docs/pt-BR/design-system.md +65 -0
  55. data/docs/pt-BR/entity-dsl.md +116 -0
  56. data/docs/pt-BR/native-runtime-quality-report.md +73 -0
  57. data/docs/pt-BR/oql-sql.md +208 -0
  58. data/docs/pt-BR/platform-api-integration.md +36 -0
  59. data/docs/pt-BR/platform-operations.md +260 -0
  60. data/docs/pt-BR/project-structure.md +32 -0
  61. data/docs/pt-BR/ruby-first-roadmap.md +239 -0
  62. data/docs/pt-BR/scaffolds.md +104 -0
  63. data/docs/pt-BR/semantic-refactoring.md +55 -0
  64. data/docs/pt-BR/team-server.md +59 -0
  65. data/docs/pt-BR/validation-matrix.md +86 -0
  66. data/docs/pt-BR/vetclinic-acceptance.md +50 -0
  67. data/docs/pt-BR/writing.md +177 -0
  68. data/examples/sudoku_evaluation.rb +14 -0
  69. data/examples/sudoku_functional_test.rb +28 -0
  70. data/lib/mxrb/architecture/graph.rb +124 -0
  71. data/lib/mxrb/architecture/validator.rb +98 -0
  72. data/lib/mxrb/benchmark.rb +33 -0
  73. data/lib/mxrb/compare.rb +390 -0
  74. data/lib/mxrb/compiler/adapter.rb +96 -0
  75. data/lib/mxrb/compiler/artifact_document_compiler.rb +102 -0
  76. data/lib/mxrb/compiler/artifact_materializer.rb +53 -0
  77. data/lib/mxrb/compiler/client_model_materializer.rb +42 -0
  78. data/lib/mxrb/compiler/code_action_document_compiler.rb +64 -0
  79. data/lib/mxrb/compiler/code_action_materializer.rb +35 -0
  80. data/lib/mxrb/compiler/code_action_type_compiler.rb +32 -0
  81. data/lib/mxrb/compiler/combo_box_bundle_compiler.rb +314 -0
  82. data/lib/mxrb/compiler/compatibility_analyzer.rb +169 -0
  83. data/lib/mxrb/compiler/constants_materializer.rb +111 -0
  84. data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +290 -0
  85. data/lib/mxrb/compiler/database_connector_action_compiler.rb +280 -0
  86. data/lib/mxrb/compiler/deployment_asset_copier.rb +60 -0
  87. data/lib/mxrb/compiler/deployment_bootstrapper.rb +208 -0
  88. data/lib/mxrb/compiler/deployment_materializer.rb +43 -0
  89. data/lib/mxrb/compiler/deployment_metadata.rb +64 -0
  90. data/lib/mxrb/compiler/domain_document_compiler.rb +153 -0
  91. data/lib/mxrb/compiler/domain_model_materializer.rb +35 -0
  92. data/lib/mxrb/compiler/domain_security_compiler.rb +82 -0
  93. data/lib/mxrb/compiler/gallery_bundle_compiler.rb +190 -0
  94. data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +281 -0
  95. data/lib/mxrb/compiler/image_bundle_compiler.rb +216 -0
  96. data/lib/mxrb/compiler/java_proxy_generator.rb +509 -0
  97. data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +267 -0
  98. data/lib/mxrb/compiler/legacy_page_builder.rb +287 -0
  99. data/lib/mxrb/compiler/mda.rb +99 -0
  100. data/lib/mxrb/compiler/microflow_document_compiler.rb +85 -0
  101. data/lib/mxrb/compiler/microflow_materializer.rb +48 -0
  102. data/lib/mxrb/compiler/microflow_node_compiler.rb +250 -0
  103. data/lib/mxrb/compiler/model_package.rb +116 -0
  104. data/lib/mxrb/compiler/model_values.rb +67 -0
  105. data/lib/mxrb/compiler/nanoflow_program_compiler.rb +483 -0
  106. data/lib/mxrb/compiler/navigation_document_compiler.rb +93 -0
  107. data/lib/mxrb/compiler/packager.rb +100 -0
  108. data/lib/mxrb/compiler/page_bundle_builder.rb +56 -0
  109. data/lib/mxrb/compiler/page_bundle_compiler.rb +2004 -0
  110. data/lib/mxrb/compiler/page_document_compiler.rb +77 -0
  111. data/lib/mxrb/compiler/portable_packager.rb +356 -0
  112. data/lib/mxrb/compiler/project_jar_archive.rb +84 -0
  113. data/lib/mxrb/compiler/project_jar_builder.rb +121 -0
  114. data/lib/mxrb/compiler/project_materializer.rb +139 -0
  115. data/lib/mxrb/compiler/project_model_orderer.rb +52 -0
  116. data/lib/mxrb/compiler/runtime_data_types.rb +31 -0
  117. data/lib/mxrb/compiler/runtime_model_schema.rb +90 -0
  118. data/lib/mxrb/compiler/schemas/runtime-10.24.0.73019.json +1052 -0
  119. data/lib/mxrb/compiler/schemas/runtime-11.json +1215 -0
  120. data/lib/mxrb/compiler/schemas/runtime-6.10.8.json +1148 -0
  121. data/lib/mxrb/compiler/schemas/runtime-7.17.0.json +1144 -0
  122. data/lib/mxrb/compiler/schemas/runtime-7.5.0.json +1215 -0
  123. data/lib/mxrb/compiler/schemas/runtime-9.6.1.29396.json +868 -0
  124. data/lib/mxrb/compiler/schemas/system-model-10.24.0.73019.b64 +1135 -0
  125. data/lib/mxrb/compiler/schemas/system-model-11.12.1.b64 +815 -0
  126. data/lib/mxrb/compiler/schemas/system-model-6.10.8.b64 +497 -0
  127. data/lib/mxrb/compiler/schemas/system-model-7.17.0.b64 +519 -0
  128. data/lib/mxrb/compiler/schemas/system-model-7.5.0.b64 +504 -0
  129. data/lib/mxrb/compiler/schemas/system-model-9.6.1.29396.b64 +816 -0
  130. data/lib/mxrb/compiler/security_materializer.rb +104 -0
  131. data/lib/mxrb/compiler/settings_document_compiler.rb +45 -0
  132. data/lib/mxrb/compiler/settings_materializer.rb +25 -0
  133. data/lib/mxrb/compiler/source_model.rb +128 -0
  134. data/lib/mxrb/compiler/system_model_seed.rb +88 -0
  135. data/lib/mxrb/compiler/system_queue_materializer.rb +54 -0
  136. data/lib/mxrb/compiler/system_text_materializer.rb +49 -0
  137. data/lib/mxrb/compiler/translation_materializer.rb +95 -0
  138. data/lib/mxrb/compiler/web_bundle_builder.rb +155 -0
  139. data/lib/mxrb/compiler/web_list_data_source.rb +139 -0
  140. data/lib/mxrb/compiler/web_operation_compiler.rb +614 -0
  141. data/lib/mxrb/compiler/web_shell_materializer.rb +194 -0
  142. data/lib/mxrb/compiler/widget_package_extractor.rb +63 -0
  143. data/lib/mxrb/doctor.rb +116 -0
  144. data/lib/mxrb/dsl/builder.rb +1636 -0
  145. data/lib/mxrb/errors.rb +18 -0
  146. data/lib/mxrb/evaluation.rb +131 -0
  147. data/lib/mxrb/exporter.rb +1716 -0
  148. data/lib/mxrb/frontend/migrator.rb +775 -0
  149. data/lib/mxrb/functional.rb +307 -0
  150. data/lib/mxrb/github/annotator.rb +161 -0
  151. data/lib/mxrb/initializer.rb +257 -0
  152. data/lib/mxrb/integrity/validator.rb +136 -0
  153. data/lib/mxrb/io/bson_codec.rb +145 -0
  154. data/lib/mxrb/io/mpr_file.rb +558 -0
  155. data/lib/mxrb/io/mxunit_codec.rb +42 -0
  156. data/lib/mxrb/marketplace.rb +380 -0
  157. data/lib/mxrb/model/association.rb +98 -0
  158. data/lib/mxrb/model/attribute.rb +100 -0
  159. data/lib/mxrb/model/connector.rb +17 -0
  160. data/lib/mxrb/model/design_materializer.rb +133 -0
  161. data/lib/mxrb/model/design_migration.rb +126 -0
  162. data/lib/mxrb/model/design_system.rb +111 -0
  163. data/lib/mxrb/model/domain_model.rb +51 -0
  164. data/lib/mxrb/model/entity.rb +180 -0
  165. data/lib/mxrb/model/menu.rb +38 -0
  166. data/lib/mxrb/model/microflow.rb +100 -0
  167. data/lib/mxrb/model/module.rb +142 -0
  168. data/lib/mxrb/model/navigation.rb +127 -0
  169. data/lib/mxrb/model/page.rb +410 -0
  170. data/lib/mxrb/model/project.rb +187 -0
  171. data/lib/mxrb/model/unit.rb +48 -0
  172. data/lib/mxrb/module_initializer.rb +75 -0
  173. data/lib/mxrb/official_marketplace/content_api.rb +292 -0
  174. data/lib/mxrb/official_marketplace/dependency_resolver.rb +329 -0
  175. data/lib/mxrb/official_marketplace/lifecycle.rb +367 -0
  176. data/lib/mxrb/official_marketplace/module_package_importer.rb +318 -0
  177. data/lib/mxrb/official_marketplace/widget_package_installer.rb +414 -0
  178. data/lib/mxrb/official_marketplace.rb +720 -0
  179. data/lib/mxrb/oql/analyzer.rb +155 -0
  180. data/lib/mxrb/oql/index_advisor.rb +85 -0
  181. data/lib/mxrb/oql/plan_analyzer.rb +185 -0
  182. data/lib/mxrb/oql/server.rb +132 -0
  183. data/lib/mxrb/oql/sql_server_plan_analyzer.rb +190 -0
  184. data/lib/mxrb/oql/sql_server_workload_analyzer.rb +80 -0
  185. data/lib/mxrb/oql/workload_analyzer.rb +175 -0
  186. data/lib/mxrb/oql/workload_baseline.rb +60 -0
  187. data/lib/mxrb/oql.rb +424 -0
  188. data/lib/mxrb/project_lifecycle.rb +76 -0
  189. data/lib/mxrb/protocols/plan.rb +86 -0
  190. data/lib/mxrb/protocols.rb +123 -0
  191. data/lib/mxrb/runtime/database_workspace.rb +677 -0
  192. data/lib/mxrb/runtime/docker_executor.rb +41 -0
  193. data/lib/mxrb/runtime/docker_workspace.rb +29 -0
  194. data/lib/mxrb/runtime/executor.rb +169 -0
  195. data/lib/mxrb/runtime/java_locator.rb +26 -0
  196. data/lib/mxrb/runtime/native.rb +474 -0
  197. data/lib/mxrb/runtime/sql_server_database.rb +146 -0
  198. data/lib/mxrb/runtime/toolchain.rb +82 -0
  199. data/lib/mxrb/scaffold/cli.rb +113 -0
  200. data/lib/mxrb/scaffold/generator.rb +246 -0
  201. data/lib/mxrb/scaffold/help.rb +80 -0
  202. data/lib/mxrb/scaffold/page_templates.rb +54 -0
  203. data/lib/mxrb/scaffold/recipes.rb +198 -0
  204. data/lib/mxrb/scaffold/registry.rb +82 -0
  205. data/lib/mxrb/scaffold/templates.rb +723 -0
  206. data/lib/mxrb/scaffold/transaction.rb +84 -0
  207. data/lib/mxrb/schema/tables.rb +55 -0
  208. data/lib/mxrb/semantic/analyzer.rb +473 -0
  209. data/lib/mxrb/semantic/batch_plan.rb +68 -0
  210. data/lib/mxrb/semantic/domain_mutator.rb +419 -0
  211. data/lib/mxrb/semantic/embedder.rb +51 -0
  212. data/lib/mxrb/semantic/extractor.rb +290 -0
  213. data/lib/mxrb/semantic/index.rb +623 -0
  214. data/lib/mxrb/semantic/inliner.rb +216 -0
  215. data/lib/mxrb/semantic/mover.rb +153 -0
  216. data/lib/mxrb/semantic/onnx_embedder.rb +34 -0
  217. data/lib/mxrb/semantic/remover.rb +70 -0
  218. data/lib/mxrb/semantic/renamer.rb +211 -0
  219. data/lib/mxrb/semantic/tfidf_embedder.rb +46 -0
  220. data/lib/mxrb/semantic/vec_store.rb +75 -0
  221. data/lib/mxrb/team_server.rb +390 -0
  222. data/lib/mxrb/templates/project/10.24.0.73019.json +32 -0
  223. data/lib/mxrb/templates/project/11.12.1.json +32 -0
  224. data/lib/mxrb/templates/project/6.10.8.json +50 -0
  225. data/lib/mxrb/templates/project/7.17.0.json +50 -0
  226. data/lib/mxrb/templates/project/7.5.0.json +50 -0
  227. data/lib/mxrb/templates/project/9.6.1.29396.json +50 -0
  228. data/lib/mxrb/version.rb +5 -0
  229. data/lib/mxrb/widget_package.rb +338 -0
  230. data/lib/mxrb/widget_synchronizer.rb +31 -0
  231. data/lib/mxrb/writer.rb +3775 -0
  232. data/lib/mxrb.rb +188 -0
  233. data/marketplace/catalog.json +10 -0
  234. data/marketplace/modules/shared-kernel/domain/README.md +4 -0
  235. data/marketplace/modules/shared-kernel/module.rb +5 -0
  236. data/marketplace/modules/shared-kernel/mxrb-module.json +9 -0
  237. metadata +447 -0
@@ -0,0 +1,720 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'date'
5
+ require 'fileutils'
6
+ require 'json'
7
+ require 'net/http'
8
+ require 'tmpdir'
9
+ require 'uri'
10
+ require 'zip'
11
+ require_relative 'official_marketplace/module_package_importer'
12
+ require_relative 'official_marketplace/widget_package_installer'
13
+ require_relative 'official_marketplace/lifecycle'
14
+ require_relative 'official_marketplace/dependency_resolver'
15
+
16
+ module Mxrb
17
+ # Installer for official/community Mendix packages, separate from Ruby modules.
18
+ module OfficialMarketplace
19
+ Package = Data.define(:name, :version, :source, :download_url, :repository)
20
+ OfficialPackage = Data.define(
21
+ :name, :version, :source, :download_url, :repository, :content_id, :version_id,
22
+ :content_type, :version_type, :security_issues, :private, :company_approved
23
+ )
24
+ Installation = Data.define(:package, :destination, :sha256)
25
+ ModuleInstallation = Data.define(
26
+ :package, :destination, :sha256, :module_name, :module_id, :units, :files, :archive
27
+ )
28
+
29
+ CATALOG = {
30
+ 'CommunityCommons' => 'mendix/CommunityCommons',
31
+ 'UnitTesting' => 'mendix/UnitTesting'
32
+ }.freeze
33
+
34
+ def self.tree_digest(path)
35
+ digest = Digest::SHA256.new
36
+ Dir.glob(File.join(path, '**', '*'), File::FNM_DOTMATCH).sort.each do |file|
37
+ next unless File.file?(file)
38
+
39
+ digest << file.delete_prefix("#{path}/") << "\0" << File.binread(file)
40
+ end
41
+ digest.hexdigest
42
+ end
43
+
44
+ def self.lock(target)
45
+ path = File.join(File.expand_path(target), '.mxrb', 'marketplace.lock.json')
46
+ File.file?(path) ? JSON.parse(File.read(path)) : { 'packages' => {} }
47
+ rescue JSON::ParserError => e
48
+ raise MarketplaceError, "invalid marketplace lockfile: #{e.message}"
49
+ end
50
+
51
+ def self.verify(target)
52
+ root = File.expand_path(target)
53
+ lock(root).fetch('packages').to_h do |name, entry|
54
+ result = case entry['kind']
55
+ when 'module' then verify_module(root, name, entry)
56
+ when 'widget' then verify_widget(root, entry)
57
+ else verify_tree(root, entry)
58
+ end
59
+ [name, result]
60
+ end
61
+ end
62
+
63
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
64
+ def self.verify_widget(root, entry)
65
+ destination = File.join(root, entry.fetch('destination'))
66
+ archive = File.join(root, entry.fetch('archive'))
67
+ actual = File.file?(destination) ? Digest::SHA256.file(destination).hexdigest : nil
68
+ cached = File.file?(archive) ? Digest::SHA256.file(archive).hexdigest : nil
69
+ inventory = WidgetPackageInventory.read(destination) if actual == entry['sha256']
70
+ identity = inventory&.name == entry['widget_name'] && inventory&.widget_ids == entry['widget_ids']
71
+ {
72
+ valid: actual == entry['sha256'] && cached == entry['sha256'] && identity,
73
+ expected: entry['sha256'], actual:, cached:, identity:
74
+ }
75
+ rescue MarketplaceError
76
+ { valid: false, expected: entry['sha256'], actual:, cached:, identity: false }
77
+ end
78
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
79
+
80
+ def self.verify_tree(root, entry)
81
+ destination = File.join(root, entry.fetch('destination'))
82
+ actual = File.directory?(destination) ? tree_digest(destination) : nil
83
+ { valid: actual == entry['sha256'], expected: entry['sha256'], actual: }
84
+ end
85
+
86
+ def self.verify_module(root, name, entry) # rubocop:disable Metrics/AbcSize
87
+ archive = File.join(root, entry.fetch('archive'))
88
+ actual = File.file?(archive) ? Digest::SHA256.file(archive).hexdigest : nil
89
+ mpr_path = File.join(root, entry.fetch('destination'))
90
+ present = module_present?(mpr_path, name, entry['module_id'])
91
+ files_present = Array(entry['files']).all? { File.file?(File.join(root, _1)) }
92
+ {
93
+ valid: actual == entry['sha256'] && present && files_present,
94
+ expected: entry['sha256'], actual:, module_present: present, files_present:
95
+ }
96
+ end
97
+
98
+ def self.module_present?(mpr_path, name, module_id)
99
+ return false unless File.file?(mpr_path)
100
+
101
+ mpr = IO::MprFile.open(mpr_path, readonly: true)
102
+ unit = module_id ? mpr.unit(module_id) : module_by_name(mpr, name)
103
+ unit && mpr.parse_contents(unit)['Name'] == name
104
+ rescue Error, SQLite3::Exception
105
+ false
106
+ ensure
107
+ mpr&.close
108
+ end
109
+
110
+ def self.module_by_name(mpr, name)
111
+ mpr.units_by_containment('Modules').find { mpr.parse_contents(_1)['Name'] == name }
112
+ end
113
+
114
+ # Resolves a user-owned PAT file. Embedding the PAT remains an explicit,
115
+ # backwards-compatible opt-in for users who prefer MXRB-managed storage.
116
+ class Credentials
117
+ attr_reader :path
118
+
119
+ def initialize(path: nil, pat_file: ENV['MXRB_MENDIX_PAT_FILE'])
120
+ base = ENV['XDG_CONFIG_HOME'].to_s
121
+ base = File.join(Dir.home, '.config') if base.empty?
122
+ @path = path || File.join(base, 'mxrb', 'credentials')
123
+ @pat_file = expand_pat_file(pat_file)
124
+ end
125
+
126
+ def configure_pat_file(source)
127
+ @pat_file = expand_pat_file(source)
128
+ raise MarketplaceError, 'Mendix PAT file path must not be empty' unless @pat_file
129
+
130
+ read_pat_file(@pat_file)
131
+ write_config('mendix_pat_file' => @pat_file)
132
+ end
133
+
134
+ def save_pat(token)
135
+ value = token.to_s.strip
136
+ raise MarketplaceError, 'Mendix PAT must not be empty' if value.empty?
137
+
138
+ @pat_file = nil
139
+ write_config('mendix_pat' => value)
140
+ end
141
+
142
+ def pat
143
+ return read_pat_file(@pat_file) if @pat_file
144
+ return unless File.file?(@path)
145
+
146
+ config = read_config
147
+ source = expand_pat_file(config['mendix_pat_file'])
148
+ return read_pat_file(source) if source
149
+
150
+ config['mendix_pat']
151
+ end
152
+
153
+ private
154
+
155
+ def write_config(config)
156
+ FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
157
+ temporary = "#{@path}.tmp-#{Process.pid}"
158
+ File.binwrite(temporary, JSON.generate(config) << "\n")
159
+ File.chmod(0o600, temporary)
160
+ File.rename(temporary, @path)
161
+ @path
162
+ ensure
163
+ FileUtils.rm_f(temporary) if defined?(temporary) && temporary && File.exist?(temporary)
164
+ end
165
+
166
+ def read_config
167
+ value = JSON.parse(File.binread(@path))
168
+ raise MarketplaceError, 'invalid credentials file: expected a JSON object' unless value.is_a?(Hash)
169
+
170
+ value
171
+ rescue JSON::ParserError => e
172
+ raise MarketplaceError, "invalid credentials file: #{e.message}"
173
+ end
174
+
175
+ def expand_pat_file(value)
176
+ source = value.to_s.strip
177
+ source.empty? ? nil : File.expand_path(source)
178
+ end
179
+
180
+ def read_pat_file(source)
181
+ raise MarketplaceError, "Mendix PAT file not found: #{source}" unless File.file?(source)
182
+
183
+ raw = File.binread(source).strip
184
+ value = pat_from_file_contents(raw, source)
185
+ raise MarketplaceError, "Mendix PAT file is empty: #{source}" if value.empty?
186
+
187
+ value
188
+ rescue SystemCallError => e
189
+ raise MarketplaceError, "cannot read Mendix PAT file #{source}: #{e.message}"
190
+ end
191
+
192
+ def pat_from_file_contents(raw, source)
193
+ return pat_from_env(raw, source) if env_file?(source) || raw.match?(/^\s*(?:export\s+)?MXRB_MENDIX_PAT\s*=/)
194
+
195
+ value = JSON.parse(raw)
196
+ return value['mendix_pat'].to_s.strip if value.is_a?(Hash) && value.key?('mendix_pat')
197
+
198
+ raise MarketplaceError, 'JSON Mendix PAT file must contain "mendix_pat"'
199
+ rescue JSON::ParserError
200
+ raw
201
+ end
202
+
203
+ def env_file?(source)
204
+ File.basename(source).start_with?('.env')
205
+ end
206
+
207
+ def pat_from_env(raw, source)
208
+ line = raw.lines.reverse.find { _1.match?(/^\s*(?:export\s+)?MXRB_MENDIX_PAT\s*=/) }
209
+ raise MarketplaceError, "MXRB_MENDIX_PAT not found in #{source}" unless line
210
+
211
+ value = line.sub(/^\s*(?:export\s+)?MXRB_MENDIX_PAT\s*=\s*/, '').strip
212
+ value = value[1...-1] if value.length >= 2 && %w[' "].include?(value[0]) && value[-1] == value[0]
213
+ value.strip
214
+ end
215
+ end
216
+
217
+ # Small HTTPS-only GitHub client with bounded redirects and response size.
218
+ class HttpClient
219
+ MAX_BYTES = 512 * 1024 * 1024
220
+
221
+ def initialize(github_token: ENV['GITHUB_TOKEN'])
222
+ @github_token = github_token.to_s
223
+ end
224
+
225
+ def json(url, authorization: default_authorization)
226
+ JSON.parse(get(url, accept: 'application/json', authorization:))
227
+ rescue JSON::ParserError => e
228
+ raise MarketplaceError, "invalid JSON response: #{e.message}"
229
+ end
230
+
231
+ def download(url, destination, authorization: default_authorization)
232
+ File.binwrite(destination, get(url, accept: 'application/octet-stream', authorization:))
233
+ destination
234
+ end
235
+
236
+ private
237
+
238
+ def get(url, accept:, redirects: 5, authorization: default_authorization,
239
+ authorization_host: nil)
240
+ raise MarketplaceError, 'too many marketplace redirects' if redirects.negative?
241
+
242
+ uri = https_uri(url)
243
+ authorization_host ||= uri.host
244
+ scoped_authorization = authorization if uri.host == authorization_host
245
+ response = perform(uri, accept, scoped_authorization)
246
+ return response_body(response) unless response.is_a?(Net::HTTPRedirection)
247
+
248
+ options = { accept:, redirects: redirects - 1, authorization:, authorization_host: }
249
+ follow_redirect(uri, response, options)
250
+ rescue URI::InvalidURIError, SocketError, SystemCallError => e
251
+ raise MarketplaceError, "marketplace request failed: #{e.message}"
252
+ end
253
+
254
+ def follow_redirect(uri, response, options)
255
+ get(
256
+ redirect_url(uri, response['location']), **options
257
+ )
258
+ end
259
+
260
+ def redirect_url(origin, location)
261
+ target = URI.join(origin, location)
262
+ target.scheme = 'https' if origin.is_a?(URI::HTTPS) && target.host == origin.host && target.scheme == 'http'
263
+ target.to_s
264
+ end
265
+
266
+ def https_uri(url)
267
+ URI.parse(url).tap do |uri|
268
+ raise MarketplaceError, 'marketplace downloads require HTTPS' unless uri.is_a?(URI::HTTPS)
269
+ end
270
+ end
271
+
272
+ def perform(uri, accept, authorization = default_authorization)
273
+ request = Net::HTTP::Get.new(uri)
274
+ request['Accept'] = accept
275
+ request['User-Agent'] = "mxrb/#{Mxrb::VERSION}"
276
+ request['Authorization'] = authorization unless authorization.to_s.empty?
277
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) { _1.request(request) }
278
+ end
279
+
280
+ def default_authorization
281
+ "Bearer #{@github_token}" unless @github_token.empty?
282
+ end
283
+
284
+ def response_body(response)
285
+ unless response.is_a?(Net::HTTPSuccess)
286
+ raise MarketplaceError, "marketplace request failed with HTTP #{response.code}"
287
+ end
288
+ raise MarketplaceError, 'marketplace response exceeds 512 MiB' \
289
+ if response.body.to_s.bytesize > MAX_BYTES
290
+
291
+ response.body
292
+ end
293
+ end
294
+
295
+ require_relative 'official_marketplace/content_api'
296
+
297
+ # Resolves known names or explicit github:org/repo releases.
298
+ class GitHubResolver
299
+ def initialize(client: HttpClient.new)
300
+ @client = client
301
+ end
302
+
303
+ def search(query)
304
+ local = catalog_search(query)
305
+ return local unless local.empty?
306
+
307
+ github_search(query)
308
+ end
309
+
310
+ def catalog_search(query)
311
+ term = query.to_s.downcase
312
+ CATALOG.filter_map do |name, repository|
313
+ { name:, repository: } if name.downcase.include?(term) || repository.downcase.include?(term)
314
+ end
315
+ end
316
+
317
+ def github_search(query)
318
+ payload = @client.json(
319
+ "https://api.github.com/search/repositories?q=#{URI.encode_www_form_component(query)}+org:mendix"
320
+ )
321
+ Array(payload['items']).first(20).map do |item|
322
+ { name: item['name'], repository: item['full_name'], description: item['description'] }
323
+ end
324
+ end
325
+
326
+ def resolve(identifier, version: nil)
327
+ repository, name = repository_and_name(identifier)
328
+ endpoint = release_endpoint(version)
329
+ release = @client.json("https://api.github.com/repos/#{repository}/#{endpoint}")
330
+ download_url = release_download(release)
331
+ Package.new(name, release['tag_name'] || version || 'latest', :github, download_url, repository)
332
+ rescue KeyError => e
333
+ raise MarketplaceError, "GitHub release is incomplete: #{e.message}"
334
+ end
335
+
336
+ private
337
+
338
+ def release_endpoint(version)
339
+ version ? "releases/tags/#{URI.encode_www_form_component(version)}" : 'releases/latest'
340
+ end
341
+
342
+ def release_download(release)
343
+ assets = Array(release['assets'])
344
+ asset = %w[.mpk .zip].filter_map do |extension|
345
+ assets.find { _1['name'].to_s.downcase.end_with?(extension) }
346
+ end.first
347
+ asset ? asset.fetch('browser_download_url') : release.fetch('zipball_url')
348
+ end
349
+
350
+ def repository_and_name(identifier)
351
+ source = identifier.to_s.delete_prefix('github:')
352
+ repository = source.include?('/') ? source : CATALOG[source]
353
+ raise MarketplaceError, "no GitHub repository is known for #{identifier.inspect}" unless repository
354
+
355
+ [repository, source.split('/').last]
356
+ end
357
+ end
358
+
359
+ # Safely extracts GitHub or local MPK archives into the target project.
360
+ class Installer # rubocop:disable Metrics/ClassLength
361
+ ATLAS_VARIABLES_IMPORT = '@import "../../themesource/atlas_core/web/variables";'
362
+ IMPORTABLE_CONTENT_TYPES = %w[Module Service].freeze
363
+ DOWNLOADABLE_CONTENT_TYPES = (IMPORTABLE_CONTENT_TYPES + %w[Widget]).freeze
364
+
365
+ def initialize(target:, client: HttpClient.new, mpr: nil)
366
+ @target = File.expand_path(target)
367
+ @client = client
368
+ @mpr = mpr && File.expand_path(mpr)
369
+ end
370
+
371
+ def pull(identifier, version: nil, mpr: @mpr)
372
+ package = GitHubResolver.new(client: @client).resolve(identifier, version:)
373
+ Dir.mktmpdir('mxrb-marketplace-') do |dir|
374
+ archive = @client.download(package.download_url, File.join(dir, 'package.zip'))
375
+ mpr ? import_module(archive, package, mpr) : install_archive(archive, package)
376
+ end
377
+ end
378
+
379
+ def pull_official(identifier, version: nil, mendix_version: nil,
380
+ allow_vulnerable: false, api: ContentApi.new)
381
+ mpr = @mpr
382
+ raise MarketplaceError, 'official Marketplace module pull requires --mpr FILE.mpr' unless mpr
383
+
384
+ mendix_version ||= detect_mendix_version(mpr)
385
+ package = api.resolve(identifier, version:, mendix_version:, allow_vulnerable:)
386
+ validate_official_package!(package)
387
+ download_official(package, mpr, api)
388
+ end
389
+
390
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
391
+ def update_official(identifier, version: nil, mendix_version: nil,
392
+ allow_vulnerable: false, apply: false, api: ContentApi.new)
393
+ lifecycle = Lifecycle.new(target: @target, mpr: @mpr, installer: self)
394
+ _name, installed = lifecycle.installed(identifier)
395
+ content_id = installed['content_id'] || identifier
396
+ mpr = lifecycle.mpr_path(installed)
397
+ mendix_version ||= detect_mendix_version(mpr)
398
+ package = api.resolve(content_id, version:, mendix_version:, allow_vulnerable:)
399
+ validate_official_package!(package)
400
+ Dir.mktmpdir('mxrb-marketplace-update-') do |dir|
401
+ archive = api.download(
402
+ package.version_id, File.join(dir, 'package.mpk'), download_url: package.download_url
403
+ )
404
+ plan = lifecycle.plan_update(identifier, archive, package)
405
+ plan.apply! if apply
406
+ plan
407
+ end
408
+ end
409
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
410
+
411
+ def remove(identifier, apply: false)
412
+ plan = Lifecycle.new(target: @target, mpr: @mpr, installer: self).plan_remove(identifier)
413
+ plan.apply! if apply
414
+ plan
415
+ end
416
+
417
+ def dependencies(identifier, apply: false, apply_resolved: false, api: ContentApi.new)
418
+ lifecycle = Lifecycle.new(target: @target, mpr: @mpr, installer: self)
419
+ _name, entry = lifecycle.installed(identifier)
420
+ mpr = lifecycle.mpr_path(entry)
421
+ resolver = DependencyResolver.new(
422
+ target: @target, mpr:, installer: self, api:,
423
+ mendix_version: detect_mendix_version(mpr)
424
+ )
425
+ resolver.resolve(identifier, apply:, apply_resolved:)
426
+ end
427
+
428
+ def download_official(package, mpr, api)
429
+ Dir.mktmpdir('mxrb-marketplace-') do |dir|
430
+ archive = api.download(
431
+ package.version_id, File.join(dir, 'package.mpk'), download_url: package.download_url
432
+ )
433
+ install_official_archive(archive, package, mpr)
434
+ end
435
+ end
436
+
437
+ def validate_official_package!(package)
438
+ return if DOWNLOADABLE_CONTENT_TYPES.include?(package.content_type)
439
+
440
+ raise MarketplaceError,
441
+ "Marketplace content #{package.name.inspect} is #{package.content_type}, not a Module, Service, or Widget"
442
+ end
443
+
444
+ def import(path, name: nil, mpr: @mpr)
445
+ source = File.expand_path(path)
446
+ raise MarketplaceError, "package not found: #{source}" unless File.file?(source)
447
+
448
+ package_name = valid_name(name || File.basename(source, File.extname(source)))
449
+ package = Package.new(package_name, 'local', :mpk, source, nil)
450
+ mpr ? import_module(source, package, mpr) : install_archive(source, package)
451
+ end
452
+
453
+ private
454
+
455
+ def install_official_archive(archive, package, mpr) # rubocop:disable Metrics/MethodLength
456
+ case PackageEnvelope.kind(archive)
457
+ when :module
458
+ import_module(archive, package, mpr)
459
+ when :widget
460
+ unless package.content_type == 'Widget'
461
+ raise MarketplaceError,
462
+ "Marketplace #{package.content_type} archive declares a clientModule widget"
463
+ end
464
+ WidgetPackageInstaller.new(target: @target).install(archive, package)
465
+ else
466
+ raise MarketplaceError, 'unsupported official Marketplace package envelope'
467
+ end
468
+ end
469
+
470
+ def detect_mendix_version(mpr)
471
+ file = IO::MprFile.open(mpr, readonly: true)
472
+ file.mendix_version
473
+ ensure
474
+ file&.close
475
+ end
476
+
477
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
478
+ def import_module(archive, package, mpr)
479
+ relative_target_path(mpr)
480
+ inventory = ModulePackageInventory.read(archive)
481
+ originals, created_backups = preserve_asset_originals(inventory)
482
+ result = module_importer(
483
+ archive, mpr, package, protected_files: protected_package_assets(inventory.name)
484
+ ).import!
485
+ ensure_atlas_theme_variables
486
+ resolved = resolved_package(package, result)
487
+ sha256 = Digest::SHA256.file(archive).hexdigest
488
+ cached = cache_package(archive, resolved, sha256)
489
+ write_module_lock(resolved, mpr, result, cached, sha256, originals)
490
+ ModuleInstallation.new(
491
+ resolved, File.expand_path(mpr), sha256, result.module_name, result.module_id,
492
+ result.units, result.files, cached
493
+ )
494
+ rescue StandardError
495
+ Array(created_backups).each { FileUtils.rm_f(_1) }
496
+ raise
497
+ end
498
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
499
+
500
+ def module_importer(archive, mpr, package, protected_files: [])
501
+ ModulePackageImporter.new(
502
+ archive, mpr, target_root: @target, allow_model_upgrade: package.source == :mendix,
503
+ protected_files:
504
+ )
505
+ end
506
+
507
+ def protected_package_assets(package_name)
508
+ OfficialMarketplace.lock(@target).fetch('packages').flat_map do |name, entry|
509
+ next [] if name == package_name
510
+
511
+ Array(entry['files'])
512
+ end.uniq
513
+ end
514
+
515
+ def ensure_atlas_theme_variables
516
+ atlas = File.join(@target, 'themesource', 'atlas_core', 'web', '_variables.scss')
517
+ return unless File.file?(atlas)
518
+
519
+ custom = File.join(@target, 'theme', 'web', 'custom-variables.scss')
520
+ content = File.file?(custom) ? File.read(custom) : ''
521
+ return if content.include?(ATLAS_VARIABLES_IMPORT)
522
+
523
+ FileUtils.mkdir_p(File.dirname(custom))
524
+ File.write(custom, [content.rstrip, ATLAS_VARIABLES_IMPORT, ''].reject(&:empty?).join("\n\n"))
525
+ end
526
+
527
+ def resolved_package(package, result)
528
+ manifest_version = result.package_version.to_s
529
+ version = manifest_version.empty? || manifest_version == 'unknown' ? package.version : manifest_version
530
+ values = { name: result.module_name, version: }
531
+ return package.with(**values) if package.respond_to?(:content_id)
532
+
533
+ Package.new(
534
+ values.fetch(:name), values.fetch(:version), package.source,
535
+ package.download_url, package.repository
536
+ )
537
+ end
538
+
539
+ def cache_package(archive, package, sha256)
540
+ version = package.version.to_s.gsub(/[^A-Za-z0-9_.-]/, '_')
541
+ destination = File.join(@target, '.mxrb', 'marketplace', "#{package.name}-#{version}.mpk")
542
+ FileUtils.mkdir_p(File.dirname(destination))
543
+ FileUtils.cp(archive, destination) unless File.expand_path(archive) == destination
544
+ raise MarketplaceError, 'cached marketplace package checksum mismatch' unless
545
+ Digest::SHA256.file(destination).hexdigest == sha256
546
+
547
+ destination
548
+ end
549
+
550
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
551
+ def write_module_lock(package, mpr, result, cached, digest, originals)
552
+ path = File.join(@target, '.mxrb', 'marketplace.lock.json')
553
+ FileUtils.mkdir_p(File.dirname(path))
554
+ lock = OfficialMarketplace.lock(@target)
555
+ lock['packages'][package.name] = {
556
+ 'kind' => 'module', 'version' => package.version, 'source' => package.source,
557
+ 'repository' => package.repository, 'sha256' => digest,
558
+ 'destination' => relative_target_path(mpr), 'archive' => relative_target_path(cached),
559
+ 'module_id' => result.module_id, 'units' => result.units, 'files' => result.files,
560
+ 'asset_originals' => originals
561
+ }.merge(official_lock_metadata(package)).compact
562
+ write_lock_atomically(path, lock)
563
+ end
564
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
565
+
566
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
567
+ def preserve_asset_originals(inventory)
568
+ existing = OfficialMarketplace.lock(@target).dig('packages', inventory.name)
569
+ legacy = Array(existing&.fetch('files', [])).to_h { [_1, nil] }
570
+ retained = existing&.fetch('asset_originals', legacy) || {}
571
+ originals = inventory.files.keys.to_h do |relative|
572
+ [relative, retained.key?(relative) ? retained[relative] : preserve_asset(relative, inventory.name)]
573
+ end
574
+ created = originals.values.compact.filter_map do |relative|
575
+ path = safe_target_path(relative)
576
+ path unless retained.value?(relative)
577
+ end
578
+ [originals, created]
579
+ end
580
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
581
+
582
+ def preserve_asset(relative, module_name)
583
+ source = safe_target_path(relative)
584
+ raise MarketplaceError, "package asset destination is a symbolic link: #{relative}" if File.symlink?(source)
585
+ return unless File.file?(source)
586
+
587
+ backup = safe_target_path(File.join('.mxrb', 'marketplace-originals', valid_name(module_name), relative))
588
+ FileUtils.mkdir_p(File.dirname(backup))
589
+ FileUtils.cp(source, backup)
590
+ relative_target_path(backup)
591
+ end
592
+
593
+ def safe_target_path(relative)
594
+ path = File.expand_path(File.join(@target, relative.to_s))
595
+ prefix = "#{@target}#{File::SEPARATOR}"
596
+ raise MarketplaceError, "path is outside marketplace target: #{path}" unless path.start_with?(prefix)
597
+
598
+ path
599
+ end
600
+
601
+ def official_lock_metadata(package)
602
+ return {} unless package.respond_to?(:content_id)
603
+
604
+ {
605
+ 'content_id' => package.content_id, 'version_id' => package.version_id,
606
+ 'content_type' => package.content_type, 'version_type' => package.version_type,
607
+ 'security_issues' => package.security_issues, 'private' => package.private,
608
+ 'company_approved' => package.company_approved
609
+ }
610
+ end
611
+
612
+ def relative_target_path(path)
613
+ absolute = File.expand_path(path)
614
+ prefix = "#{@target}#{File::SEPARATOR}"
615
+ raise MarketplaceError, "path is outside marketplace target: #{absolute}" unless absolute.start_with?(prefix)
616
+
617
+ absolute.delete_prefix(prefix)
618
+ end
619
+
620
+ # Transaction boundary intentionally keeps rollback and cleanup together.
621
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
622
+ def install_archive(archive, package)
623
+ name = valid_name(package.name)
624
+ destination = File.join(@target, 'modules', name)
625
+ raise MarketplaceError, "module destination already exists: #{destination}" if File.exist?(destination)
626
+
627
+ staging = File.join(@target, '.mxrb', 'marketplace-staging', "#{name}-#{Process.pid}")
628
+ extract_zip(archive, staging)
629
+ move_into_project(staging, destination)
630
+ digest = OfficialMarketplace.tree_digest(destination)
631
+ write_lock(package, destination, digest)
632
+ Installation.new(package, destination, digest)
633
+ rescue StandardError
634
+ FileUtils.rm_rf(destination) if defined?(destination) && destination && File.exist?(destination)
635
+ raise
636
+ ensure
637
+ FileUtils.rm_rf(staging) if defined?(staging) && staging && File.exist?(staging)
638
+ end
639
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
640
+
641
+ def extract_zip(archive, staging)
642
+ FileUtils.mkdir_p(staging)
643
+ Zip::File.open(archive) do |zip|
644
+ zip.each { extract_safe_entry(_1, staging) }
645
+ end
646
+ rescue Zip::Error => e
647
+ raise MarketplaceError, "invalid MPK/ZIP package: #{e.message}"
648
+ end
649
+
650
+ def extract_safe_entry(entry, staging)
651
+ destination = safe_destination(entry.name, staging)
652
+
653
+ entry.directory? ? FileUtils.mkdir_p(destination) : extract_entry(entry, destination)
654
+ end
655
+
656
+ def safe_destination(name, staging)
657
+ relative = name.to_s
658
+ unsafe = relative.start_with?('/', '\\') || relative.split(%r{[\\/]}).include?('..')
659
+ raise MarketplaceError, "unsafe package path #{name.inspect}" if unsafe
660
+
661
+ destination = File.expand_path(File.join(staging, relative))
662
+ prefix = "#{File.expand_path(staging)}#{File::SEPARATOR}"
663
+ raise MarketplaceError, "unsafe package path #{name.inspect}" unless destination.start_with?(prefix)
664
+
665
+ destination
666
+ end
667
+
668
+ def extract_entry(entry, destination)
669
+ FileUtils.mkdir_p(File.dirname(destination))
670
+ File.open(destination, 'wb') { ::IO.copy_stream(entry.get_input_stream, _1) }
671
+ end
672
+
673
+ def collapse_single_root(staging)
674
+ children = Dir.children(staging)
675
+ if children.one? && File.directory?(File.join(staging, children.first))
676
+ return File.join(staging, children.first)
677
+ end
678
+
679
+ staging
680
+ end
681
+
682
+ def move_into_project(staging, destination)
683
+ FileUtils.mkdir_p(File.dirname(destination))
684
+ FileUtils.mv(collapse_single_root(staging), destination)
685
+ end
686
+
687
+ def valid_name(value)
688
+ name = value.to_s.gsub(/[^A-Za-z0-9_]/, '')
689
+ raise MarketplaceError, "invalid marketplace module name #{value.inspect}" \
690
+ unless name.match?(/\A[A-Za-z][A-Za-z0-9_]*\z/)
691
+
692
+ name
693
+ end
694
+
695
+ def write_lock(package, destination, digest)
696
+ path = File.join(@target, '.mxrb', 'marketplace.lock.json')
697
+ FileUtils.mkdir_p(File.dirname(path))
698
+ lock = OfficialMarketplace.lock(@target)
699
+ lock['packages'][package.name] = lock_entry(package, destination, digest)
700
+ write_lock_atomically(path, lock)
701
+ end
702
+
703
+ def lock_entry(package, destination, digest)
704
+ {
705
+ 'version' => package.version, 'source' => package.source,
706
+ 'repository' => package.repository, 'sha256' => digest,
707
+ 'destination' => destination.delete_prefix("#{@target}/")
708
+ }.merge(official_lock_metadata(package)).compact
709
+ end
710
+
711
+ def write_lock_atomically(path, lock)
712
+ temporary = "#{path}.tmp-#{Process.pid}"
713
+ File.write(temporary, JSON.pretty_generate(lock) << "\n")
714
+ File.rename(temporary, path)
715
+ ensure
716
+ FileUtils.rm_f(temporary) if defined?(temporary) && temporary && File.exist?(temporary)
717
+ end
718
+ end
719
+ end
720
+ end