factorix 0.5.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 (202) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +105 -0
  5. data/completion/_factorix.bash +202 -0
  6. data/completion/_factorix.fish +197 -0
  7. data/completion/_factorix.zsh +376 -0
  8. data/doc/factorix.1 +377 -0
  9. data/exe/factorix +20 -0
  10. data/lib/factorix/api/category.rb +69 -0
  11. data/lib/factorix/api/image.rb +35 -0
  12. data/lib/factorix/api/license.rb +71 -0
  13. data/lib/factorix/api/mod_download_api.rb +66 -0
  14. data/lib/factorix/api/mod_info.rb +166 -0
  15. data/lib/factorix/api/mod_management_api.rb +237 -0
  16. data/lib/factorix/api/mod_portal_api.rb +204 -0
  17. data/lib/factorix/api/release.rb +49 -0
  18. data/lib/factorix/api/tag.rb +95 -0
  19. data/lib/factorix/api.rb +7 -0
  20. data/lib/factorix/api_credential.rb +54 -0
  21. data/lib/factorix/application.rb +218 -0
  22. data/lib/factorix/cache/file_system.rb +307 -0
  23. data/lib/factorix/cli/commands/backup_support.rb +46 -0
  24. data/lib/factorix/cli/commands/base.rb +90 -0
  25. data/lib/factorix/cli/commands/cache/evict.rb +180 -0
  26. data/lib/factorix/cli/commands/cache/stat.rb +201 -0
  27. data/lib/factorix/cli/commands/command_wrapper.rb +71 -0
  28. data/lib/factorix/cli/commands/completion.rb +83 -0
  29. data/lib/factorix/cli/commands/confirmable.rb +53 -0
  30. data/lib/factorix/cli/commands/download_support.rb +123 -0
  31. data/lib/factorix/cli/commands/launch.rb +79 -0
  32. data/lib/factorix/cli/commands/man.rb +29 -0
  33. data/lib/factorix/cli/commands/mod/check.rb +99 -0
  34. data/lib/factorix/cli/commands/mod/disable.rb +188 -0
  35. data/lib/factorix/cli/commands/mod/download.rb +291 -0
  36. data/lib/factorix/cli/commands/mod/edit.rb +114 -0
  37. data/lib/factorix/cli/commands/mod/enable.rb +216 -0
  38. data/lib/factorix/cli/commands/mod/image/add.rb +47 -0
  39. data/lib/factorix/cli/commands/mod/image/edit.rb +41 -0
  40. data/lib/factorix/cli/commands/mod/image/list.rb +74 -0
  41. data/lib/factorix/cli/commands/mod/install.rb +443 -0
  42. data/lib/factorix/cli/commands/mod/list.rb +372 -0
  43. data/lib/factorix/cli/commands/mod/search.rb +134 -0
  44. data/lib/factorix/cli/commands/mod/settings/dump.rb +88 -0
  45. data/lib/factorix/cli/commands/mod/settings/restore.rb +101 -0
  46. data/lib/factorix/cli/commands/mod/show.rb +202 -0
  47. data/lib/factorix/cli/commands/mod/sync.rb +299 -0
  48. data/lib/factorix/cli/commands/mod/uninstall.rb +325 -0
  49. data/lib/factorix/cli/commands/mod/update.rb +222 -0
  50. data/lib/factorix/cli/commands/mod/upload.rb +90 -0
  51. data/lib/factorix/cli/commands/path.rb +79 -0
  52. data/lib/factorix/cli/commands/requires_game_stopped.rb +32 -0
  53. data/lib/factorix/cli/commands/version.rb +25 -0
  54. data/lib/factorix/cli.rb +42 -0
  55. data/lib/factorix/dependency/edge.rb +89 -0
  56. data/lib/factorix/dependency/entry.rb +124 -0
  57. data/lib/factorix/dependency/graph/builder.rb +108 -0
  58. data/lib/factorix/dependency/graph.rb +210 -0
  59. data/lib/factorix/dependency/list.rb +244 -0
  60. data/lib/factorix/dependency/mod_version_requirement.rb +73 -0
  61. data/lib/factorix/dependency/node.rb +60 -0
  62. data/lib/factorix/dependency/parser.rb +148 -0
  63. data/lib/factorix/dependency/validation_result.rb +138 -0
  64. data/lib/factorix/dependency/validator.rb +190 -0
  65. data/lib/factorix/errors.rb +112 -0
  66. data/lib/factorix/formatting.rb +56 -0
  67. data/lib/factorix/game_version.rb +98 -0
  68. data/lib/factorix/http/cache_decorator.rb +106 -0
  69. data/lib/factorix/http/cached_response.rb +37 -0
  70. data/lib/factorix/http/client.rb +187 -0
  71. data/lib/factorix/http/response.rb +31 -0
  72. data/lib/factorix/http/retry_decorator.rb +59 -0
  73. data/lib/factorix/http/retry_strategy.rb +80 -0
  74. data/lib/factorix/info_json.rb +90 -0
  75. data/lib/factorix/installed_mod.rb +239 -0
  76. data/lib/factorix/mod.rb +55 -0
  77. data/lib/factorix/mod_list.rb +174 -0
  78. data/lib/factorix/mod_settings.rb +278 -0
  79. data/lib/factorix/mod_state.rb +34 -0
  80. data/lib/factorix/mod_version.rb +99 -0
  81. data/lib/factorix/portal.rb +185 -0
  82. data/lib/factorix/progress/download_handler.rb +46 -0
  83. data/lib/factorix/progress/multi_presenter.rb +45 -0
  84. data/lib/factorix/progress/presenter.rb +67 -0
  85. data/lib/factorix/progress/presenter_adapter.rb +46 -0
  86. data/lib/factorix/progress/scan_handler.rb +33 -0
  87. data/lib/factorix/progress/upload_handler.rb +33 -0
  88. data/lib/factorix/runtime/base.rb +233 -0
  89. data/lib/factorix/runtime/linux.rb +32 -0
  90. data/lib/factorix/runtime/mac_os.rb +53 -0
  91. data/lib/factorix/runtime/user_configurable.rb +69 -0
  92. data/lib/factorix/runtime/windows.rb +85 -0
  93. data/lib/factorix/runtime/wsl.rb +118 -0
  94. data/lib/factorix/runtime.rb +32 -0
  95. data/lib/factorix/save_file.rb +178 -0
  96. data/lib/factorix/ser_des/deserializer.rb +198 -0
  97. data/lib/factorix/ser_des/serializer.rb +231 -0
  98. data/lib/factorix/ser_des/signed_integer.rb +63 -0
  99. data/lib/factorix/ser_des/unsigned_integer.rb +65 -0
  100. data/lib/factorix/service_credential.rb +127 -0
  101. data/lib/factorix/transfer/downloader.rb +162 -0
  102. data/lib/factorix/transfer/uploader.rb +232 -0
  103. data/lib/factorix/version.rb +6 -0
  104. data/lib/factorix.rb +38 -0
  105. data/sig/dry/auto_inject.rbs +15 -0
  106. data/sig/dry/cli.rbs +19 -0
  107. data/sig/dry/configurable.rbs +13 -0
  108. data/sig/dry/core/container.rbs +17 -0
  109. data/sig/dry/events/publisher.rbs +22 -0
  110. data/sig/dry/logger.rbs +16 -0
  111. data/sig/factorix/api/category.rbs +15 -0
  112. data/sig/factorix/api/image.rbs +15 -0
  113. data/sig/factorix/api/license.rbs +20 -0
  114. data/sig/factorix/api/mod_download_api.rbs +18 -0
  115. data/sig/factorix/api/mod_info.rbs +67 -0
  116. data/sig/factorix/api/mod_management_api.rbs +25 -0
  117. data/sig/factorix/api/mod_portal_api.rbs +31 -0
  118. data/sig/factorix/api/release.rbs +27 -0
  119. data/sig/factorix/api/tag.rbs +15 -0
  120. data/sig/factorix/api.rbs +8 -0
  121. data/sig/factorix/api_credential.rbs +17 -0
  122. data/sig/factorix/application.rbs +86 -0
  123. data/sig/factorix/cache/file_system.rbs +35 -0
  124. data/sig/factorix/cli/commands/base.rbs +13 -0
  125. data/sig/factorix/cli/commands/cache/evict.rbs +17 -0
  126. data/sig/factorix/cli/commands/cache/stat.rbs +17 -0
  127. data/sig/factorix/cli/commands/command_wrapper.rbs +13 -0
  128. data/sig/factorix/cli/commands/completion/zsh.rbs +15 -0
  129. data/sig/factorix/cli/commands/confirmable.rbs +12 -0
  130. data/sig/factorix/cli/commands/download_support.rbs +12 -0
  131. data/sig/factorix/cli/commands/launch.rbs +15 -0
  132. data/sig/factorix/cli/commands/mod/check.rbs +18 -0
  133. data/sig/factorix/cli/commands/mod/disable.rbs +20 -0
  134. data/sig/factorix/cli/commands/mod/download.rbs +18 -0
  135. data/sig/factorix/cli/commands/mod/edit.rbs +30 -0
  136. data/sig/factorix/cli/commands/mod/enable.rbs +20 -0
  137. data/sig/factorix/cli/commands/mod/image/add.rbs +19 -0
  138. data/sig/factorix/cli/commands/mod/image/edit.rbs +19 -0
  139. data/sig/factorix/cli/commands/mod/image/list.rbs +19 -0
  140. data/sig/factorix/cli/commands/mod/install.rbs +19 -0
  141. data/sig/factorix/cli/commands/mod/list.rbs +30 -0
  142. data/sig/factorix/cli/commands/mod/search.rbs +18 -0
  143. data/sig/factorix/cli/commands/mod/settings/dump.rbs +17 -0
  144. data/sig/factorix/cli/commands/mod/settings/restore.rbs +17 -0
  145. data/sig/factorix/cli/commands/mod/sync.rbs +19 -0
  146. data/sig/factorix/cli/commands/mod/uninstall.rbs +20 -0
  147. data/sig/factorix/cli/commands/mod/update.rbs +19 -0
  148. data/sig/factorix/cli/commands/mod/upload.rbs +24 -0
  149. data/sig/factorix/cli/commands/path.rbs +18 -0
  150. data/sig/factorix/cli/commands/requires_game_stopped.rbs +13 -0
  151. data/sig/factorix/cli/commands/version.rbs +13 -0
  152. data/sig/factorix/cli.rbs +11 -0
  153. data/sig/factorix/dependency/edge.rbs +32 -0
  154. data/sig/factorix/dependency/entry.rbs +30 -0
  155. data/sig/factorix/dependency/graph/builder.rbs +17 -0
  156. data/sig/factorix/dependency/graph.rbs +39 -0
  157. data/sig/factorix/dependency/list.rbs +69 -0
  158. data/sig/factorix/dependency/mod_version_requirement.rbs +18 -0
  159. data/sig/factorix/dependency/node.rbs +24 -0
  160. data/sig/factorix/dependency/parser.rbs +11 -0
  161. data/sig/factorix/dependency/validation_result.rbs +56 -0
  162. data/sig/factorix/dependency/validator.rbs +13 -0
  163. data/sig/factorix/errors.rbs +132 -0
  164. data/sig/factorix/formatting.rbs +8 -0
  165. data/sig/factorix/game_version.rbs +24 -0
  166. data/sig/factorix/http/cache_decorator.rbs +64 -0
  167. data/sig/factorix/http/client.rbs +55 -0
  168. data/sig/factorix/http/response.rbs +28 -0
  169. data/sig/factorix/http/retry_decorator.rbs +44 -0
  170. data/sig/factorix/http/retry_strategy.rbs +42 -0
  171. data/sig/factorix/info_json.rbs +19 -0
  172. data/sig/factorix/installed_mod.rbs +34 -0
  173. data/sig/factorix/mod.rbs +20 -0
  174. data/sig/factorix/mod_list.rbs +44 -0
  175. data/sig/factorix/mod_settings.rbs +47 -0
  176. data/sig/factorix/mod_state.rbs +18 -0
  177. data/sig/factorix/mod_version.rbs +23 -0
  178. data/sig/factorix/portal.rbs +37 -0
  179. data/sig/factorix/progress/download_handler.rbs +19 -0
  180. data/sig/factorix/progress/multi_presenter.rbs +15 -0
  181. data/sig/factorix/progress/presenter.rbs +17 -0
  182. data/sig/factorix/progress/presenter_adapter.rbs +17 -0
  183. data/sig/factorix/progress/scan_handler.rbs +16 -0
  184. data/sig/factorix/progress/upload_handler.rbs +17 -0
  185. data/sig/factorix/runtime/base.rbs +45 -0
  186. data/sig/factorix/runtime/linux.rbs +15 -0
  187. data/sig/factorix/runtime/mac_os.rbs +15 -0
  188. data/sig/factorix/runtime/user_configurable.rbs +13 -0
  189. data/sig/factorix/runtime/windows.rbs +23 -0
  190. data/sig/factorix/runtime/wsl.rbs +19 -0
  191. data/sig/factorix/runtime.rbs +9 -0
  192. data/sig/factorix/save_file.rbs +40 -0
  193. data/sig/factorix/ser_des/deserializer.rbs +49 -0
  194. data/sig/factorix/ser_des/serializer.rbs +45 -0
  195. data/sig/factorix/ser_des/signed_integer.rbs +37 -0
  196. data/sig/factorix/ser_des/unsigned_integer.rbs +37 -0
  197. data/sig/factorix/service_credential.rbs +19 -0
  198. data/sig/factorix/transfer/downloader.rbs +15 -0
  199. data/sig/factorix/transfer/uploader.rbs +21 -0
  200. data/sig/factorix.rbs +9 -0
  201. data/sig/tty/progressbar.rbs +18 -0
  202. metadata +431 -0
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/executor/fixed_thread_pool"
4
+ require "concurrent/future"
5
+
6
+ module Factorix
7
+ class CLI
8
+ module Commands
9
+ module MOD
10
+ # Download MOD files from Factorio MOD Portal
11
+ class Download < Base
12
+ include DownloadSupport
13
+ # @!parse
14
+ # # @return [Portal]
15
+ # attr_reader :portal
16
+ # # @return [Dry::Logger::Dispatcher]
17
+ # attr_reader :logger
18
+ # # @return [Runtime]
19
+ # attr_reader :runtime
20
+ include Import[:portal, :logger, :runtime]
21
+
22
+ desc "Download MOD files from Factorio MOD Portal"
23
+
24
+ example [
25
+ "some-mod # Download latest version to current directory",
26
+ "some-mod@1.2.0 # Download specific version",
27
+ "-d /tmp/mods some-mod # Download to specific directory",
28
+ "-r some-mod # Include required dependencies"
29
+ ]
30
+
31
+ argument :mod_specs, type: :array, required: true, desc: "MOD specifications (name@version or name@latest or name)"
32
+ option :directory, type: :string, aliases: ["-d"], default: ".", desc: "Download directory"
33
+ option :jobs, type: :integer, aliases: ["-j"], default: 4, desc: "Number of parallel downloads"
34
+ option :recursive, type: :flag, aliases: ["-r"], default: false, desc: "Include required dependencies recursively"
35
+
36
+ # Execute the download command
37
+ #
38
+ # @param mod_specs [Array<String>] MOD specifications
39
+ # @param directory [String] Download directory
40
+ # @param jobs [Integer] Number of parallel downloads
41
+ # @param recursive [Boolean] Include required dependencies recursively
42
+ # @return [void]
43
+ def call(mod_specs:, directory: ".", jobs: 4, recursive: false, **)
44
+ download_dir = Pathname(directory).expand_path
45
+
46
+ raise DirectoryNotFoundError, "Download directory does not exist: #{download_dir}" unless download_dir.exist?
47
+
48
+ if runtime.mod_dir.exist? && download_dir.realpath == runtime.mod_dir.realpath
49
+ raise InvalidOperationError, "Cannot download to MOD directory. Use 'mod install' instead."
50
+ end
51
+
52
+ download_targets = plan_download(mod_specs, download_dir, jobs, recursive)
53
+
54
+ if download_targets.empty?
55
+ say "No MOD(s) to download", prefix: :info
56
+ return
57
+ end
58
+
59
+ download_mods(download_targets, jobs)
60
+
61
+ say "Downloaded #{download_targets.size} MOD(s)", prefix: :success
62
+ end
63
+
64
+ # Plan the download by fetching MOD info and optionally resolving dependencies
65
+ #
66
+ # @param mod_specs [Array<String>] MOD specifications
67
+ # @param download_dir [Pathname] Download directory
68
+ # @param jobs [Integer] Number of parallel jobs
69
+ # @param recursive [Boolean] Include dependencies
70
+ # @return [Array<Hash>] Download targets with MOD info and releases
71
+ private def plan_download(mod_specs, download_dir, jobs, recursive)
72
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Fetching MOD info", output: $stderr)
73
+
74
+ target_infos = fetch_target_mod_info(mod_specs, jobs, presenter)
75
+
76
+ all_mod_infos = if recursive
77
+ resolve_dependencies(target_infos, jobs, presenter)
78
+ else
79
+ target_infos.to_h {|info| [info[:mod_name], info] }
80
+ end
81
+
82
+ build_download_targets(all_mod_infos, download_dir)
83
+ end
84
+
85
+ # Fetch MOD information for target specifications
86
+ #
87
+ # @param mod_specs [Array<String>] MOD specifications
88
+ # @param jobs [Integer] Number of parallel jobs
89
+ # @param presenter [Progress::Presenter] Progress presenter
90
+ # @return [Array<Hash>] Array of {mod_spec:, mod_name:, mod_info:, release:}
91
+ private def fetch_target_mod_info(mod_specs, jobs, presenter)
92
+ presenter.start
93
+
94
+ pool = Concurrent::FixedThreadPool.new(jobs)
95
+
96
+ futures = mod_specs.map {|mod_spec|
97
+ Concurrent::Future.execute(executor: pool) do
98
+ result = fetch_single_mod_info(mod_spec)
99
+ presenter.update
100
+ result
101
+ end
102
+ }
103
+
104
+ futures.map(&:value!)
105
+ ensure
106
+ pool&.shutdown
107
+ pool&.wait_for_termination
108
+ end
109
+
110
+ # Fetch information for a single MOD specification
111
+ #
112
+ # @param mod_spec [String] MOD specification (name@version or name)
113
+ # @return [Hash] {mod:, mod_name:, mod_info:, release:, version:}
114
+ private def fetch_single_mod_info(mod_spec)
115
+ parsed = parse_mod_spec(mod_spec)
116
+ mod = parsed[:mod]
117
+ version = parsed[:version]
118
+
119
+ mod_info = portal.get_mod_full(mod.name)
120
+ release = find_release(mod_info, version)
121
+
122
+ version_display = version == :latest ? "latest" : version.to_s
123
+ raise MODNotOnPortalError, "Release not found for #{mod}@#{version_display}" unless release
124
+
125
+ {mod:, mod_name: mod.name, mod_info:, release:, version:}
126
+ end
127
+
128
+ # Recursively resolve dependencies
129
+ #
130
+ # @param target_infos [Array<Hash>] Initial target MOD infos
131
+ # @param jobs [Integer] Number of parallel jobs
132
+ # @param presenter [Progress::Presenter] Progress presenter
133
+ # @return [Hash<String, Hash>] All MOD infos by name
134
+ private def resolve_dependencies(target_infos, jobs, presenter)
135
+ all_mod_infos = {}
136
+ to_process = []
137
+
138
+ target_infos.each do |info|
139
+ all_mod_infos[info[:mod_name]] = info
140
+ to_process << info[:mod_name]
141
+ end
142
+
143
+ processed = Set.new
144
+
145
+ until to_process.empty?
146
+ current_batch = to_process.shift(jobs)
147
+ current_batch.reject! {|mod_name| processed.include?(mod_name) }
148
+ break if current_batch.empty?
149
+
150
+ new_dependencies = collect_new_dependencies(current_batch, all_mod_infos, processed)
151
+ next if new_dependencies.empty?
152
+
153
+ presenter.increase_total(new_dependencies.size)
154
+ fetch_and_add_dependencies(new_dependencies, all_mod_infos, jobs, presenter)
155
+
156
+ new_dependencies.each do |dep|
157
+ to_process << dep[:mod_name] unless processed.include?(dep[:mod_name])
158
+ end
159
+ end
160
+
161
+ all_mod_infos
162
+ end
163
+
164
+ # Collect new dependencies from a batch of MODs
165
+ #
166
+ # @param batch [Array<String>] Batch of MOD names
167
+ # @param all_mod_infos [Hash] All MOD infos by name
168
+ # @param processed [Set<String>] Mark MODs as processed
169
+ # @return [Array<Hash>] New dependencies to fetch
170
+ private def collect_new_dependencies(batch, all_mod_infos, processed)
171
+ new_dependencies = []
172
+
173
+ batch.each do |mod_name|
174
+ processed.add(mod_name)
175
+
176
+ info = all_mod_infos[mod_name]
177
+ next unless info
178
+
179
+ deps = extract_required_dependencies(info[:release])
180
+ deps.each do |dep|
181
+ next if builtin_mod?(dep[:mod_name])
182
+ next if all_mod_infos.key?(dep[:mod_name])
183
+
184
+ new_dependencies << dep
185
+ end
186
+ end
187
+
188
+ new_dependencies
189
+ end
190
+
191
+ # Extract required dependencies from a release
192
+ #
193
+ # @param release [API::Release] Release object
194
+ # @return [Array<Hash>] Array of {mod_name:, version_requirement:, required_by:}
195
+ private def extract_required_dependencies(release)
196
+ info_json = release.info_json
197
+ return [] unless info_json
198
+
199
+ raw_deps = info_json["dependencies"] || info_json[:dependencies]
200
+ return [] unless raw_deps
201
+
202
+ dep_list = Dependency::List.from_strings(raw_deps)
203
+ dep_list.required.filter_map do |entry|
204
+ {mod_name: entry.mod.name, version_requirement: entry.version_requirement}
205
+ end
206
+ end
207
+
208
+ # Check if a MOD is a built-in MOD
209
+ #
210
+ # @param mod_name [String] MOD name
211
+ # @return [Boolean] true if built-in
212
+ private def builtin_mod?(mod_name) = %w[base elevated-rails quality space-age].include?(mod_name)
213
+
214
+ # Fetch and add dependencies
215
+ #
216
+ # @param dependencies [Array<Hash>] Dependencies to fetch
217
+ # @param all_mod_infos [Hash] Accumulator for all MOD infos
218
+ # @param jobs [Integer] Number of parallel jobs
219
+ # @param presenter [Progress::Presenter] Progress presenter
220
+ # @return [void]
221
+ private def fetch_and_add_dependencies(dependencies, all_mod_infos, jobs, presenter)
222
+ pool = Concurrent::FixedThreadPool.new(jobs)
223
+
224
+ futures = dependencies.map {|dep|
225
+ Concurrent::Future.execute(executor: pool) do
226
+ mod_info = portal.get_mod_full(dep[:mod_name])
227
+ release = find_compatible_release(mod_info, dep[:version_requirement])
228
+
229
+ unless release
230
+ logger.warn("Skipping dependency #{dep[:mod_name]}: No compatible release found")
231
+ presenter.update
232
+ next nil
233
+ end
234
+
235
+ presenter.update
236
+
237
+ {mod_name: dep[:mod_name], mod_info:, release:}
238
+ rescue HTTPClientError => e
239
+ logger.warn("Skipping dependency #{dep[:mod_name]}: #{e.message}")
240
+ presenter.update
241
+ nil
242
+ rescue JSON::ParserError
243
+ logger.warn("Skipping dependency #{dep[:mod_name]}: Invalid API response")
244
+ presenter.update
245
+ nil
246
+ end
247
+ }
248
+
249
+ results = futures.filter_map(&:value!)
250
+ results.each {|result| all_mod_infos[result[:mod_name]] = result }
251
+ ensure
252
+ pool&.shutdown
253
+ pool&.wait_for_termination
254
+ end
255
+
256
+ # Build download targets from MOD infos
257
+ #
258
+ # @param all_mod_infos [Hash] All MOD infos by name
259
+ # @param download_dir [Pathname] Download directory
260
+ # @return [Array<Hash>] Download targets
261
+ private def build_download_targets(all_mod_infos, download_dir)
262
+ all_mod_infos.values.filter_map do |info|
263
+ release = info[:release]
264
+ validate_filename(release.file_name)
265
+
266
+ {
267
+ mod: Factorix::MOD[name: info[:mod_name]],
268
+ mod_info: info[:mod_info],
269
+ release:,
270
+ output_path: download_dir / release.file_name,
271
+ category: info[:mod_info].category
272
+ }
273
+ end
274
+ end
275
+
276
+ # Validate filename for security
277
+ #
278
+ # @param filename [String] Filename to validate
279
+ # @return [void]
280
+ # @raise [InvalidArgumentError] if filename is invalid
281
+ private def validate_filename(filename)
282
+ raise InvalidArgumentError, "Filename is empty" if filename.nil? || filename.empty?
283
+ raise InvalidArgumentError, "Filename contains path separators" if filename.include?(File::SEPARATOR)
284
+ raise InvalidArgumentError, "Filename contains path separators" if File::ALT_SEPARATOR && filename.include?(File::ALT_SEPARATOR)
285
+ raise InvalidArgumentError, "Filename contains parent directory reference" if filename.include?("..")
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ module MOD
7
+ # Edit MOD metadata on Factorio MOD Portal
8
+ class Edit < Base
9
+ # @!parse
10
+ # # @return [Portal]
11
+ # attr_reader :portal
12
+ include Import[:portal]
13
+
14
+ desc "Edit MOD metadata on Factorio MOD Portal"
15
+
16
+ example [
17
+ 'some-mod --title "New Title" # Update MOD title',
18
+ "some-mod --category automation # Update category",
19
+ "some-mod --deprecated # Mark as deprecated"
20
+ ]
21
+
22
+ argument :mod_name, type: :string, required: true, desc: "MOD name"
23
+ option :description, type: :string, desc: "Markdown description"
24
+ option :summary, type: :string, desc: "Brief description"
25
+ option :title, type: :string, desc: "MOD title"
26
+ option :category, type: :string, desc: "MOD category"
27
+ option :tags, type: :array, desc: "Array of tags"
28
+ option :license, type: :string, desc: "License identifier"
29
+ option :homepage, type: :string, desc: "Homepage URL"
30
+ option :source_url, type: :string, desc: "Repository URL"
31
+ option :faq, type: :string, desc: "FAQ text"
32
+ option :deprecated, type: :boolean, desc: "Deprecation flag"
33
+
34
+ # Execute the edit command
35
+ #
36
+ # @param mod_name [String] the MOD name
37
+ # @param description [String, nil] optional description
38
+ # @param summary [String, nil] optional summary
39
+ # @param title [String, nil] optional title
40
+ # @param category [String, nil] optional category
41
+ # @param tags [Array<String>, nil] optional tags
42
+ # @param license [String, nil] optional license
43
+ # @param homepage [String, nil] optional homepage
44
+ # @param source_url [String, nil] optional source URL
45
+ # @param faq [String, nil] optional FAQ
46
+ # @param deprecated [Boolean, nil] optional deprecation flag
47
+ # @return [void]
48
+ def call(mod_name:, description: nil, summary: nil, title: nil, category: nil, tags: nil, license: nil, homepage: nil, source_url: nil, faq: nil, deprecated: nil, **)
49
+ validate_license!(license) if license
50
+
51
+ metadata = build_metadata(
52
+ description:,
53
+ summary:,
54
+ title:,
55
+ category:,
56
+ tags:,
57
+ license:,
58
+ homepage:,
59
+ source_url:,
60
+ faq:,
61
+ deprecated:
62
+ )
63
+
64
+ if metadata.empty?
65
+ say "At least one metadata option must be provided", prefix: :error
66
+ say "Available options: --description, --summary, --title, --category, --tags, --license, --homepage, --source-url, --faq, --deprecated"
67
+ raise InvalidArgumentError, "No metadata options provided"
68
+ end
69
+
70
+ portal.edit_mod(mod_name, **metadata)
71
+ say "Metadata updated successfully!", prefix: :success
72
+ end
73
+
74
+ # Build metadata hash from options
75
+ #
76
+ # @param description [String, nil] description
77
+ # @param summary [String, nil] summary
78
+ # @param title [String, nil] title
79
+ # @param category [String, nil] category
80
+ # @param tags [Array<String>, nil] tags
81
+ # @param license [String, nil] license
82
+ # @param homepage [String, nil] homepage
83
+ # @param source_url [String, nil] source URL
84
+ # @param faq [String, nil] FAQ
85
+ # @param deprecated [Boolean, nil] deprecation flag
86
+ # @return [Hash] metadata hash with symbol keys
87
+ private def build_metadata(description: nil, summary: nil, title: nil, category: nil, tags: nil, license: nil, homepage: nil, source_url: nil, faq: nil, deprecated: nil)
88
+ metadata = {}
89
+ metadata[:description] = description if description
90
+ metadata[:summary] = summary if summary
91
+ metadata[:title] = title if title
92
+ metadata[:category] = category if category
93
+ metadata[:tags] = tags if tags
94
+ metadata[:license] = license if license
95
+ metadata[:homepage] = homepage if homepage
96
+ metadata[:source_url] = source_url if source_url
97
+ metadata[:faq] = faq if faq
98
+ metadata[:deprecated] = deprecated unless deprecated.nil?
99
+ metadata
100
+ end
101
+
102
+ private def validate_license!(license)
103
+ return if API::License.valid_identifier?(license)
104
+
105
+ say "Invalid license identifier: #{license}", prefix: :error
106
+ say "Valid identifiers: #{API::License.identifier_values.join(", ")}"
107
+ say "Custom licenses: custom_<24 hex chars> (e.g., custom_0123456789abcdef01234567)"
108
+ raise InvalidArgumentError, "Invalid license identifier"
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ module MOD
7
+ # Enable MODs in mod-list.json with dependency resolution
8
+ class Enable < Base
9
+ confirmable!
10
+ require_game_stopped!
11
+ backup_support!
12
+
13
+ # @!parse
14
+ # # @return [Dry::Logger::Dispatcher]
15
+ # attr_reader :logger
16
+ # # @return [Factorix::Runtime]
17
+ # attr_reader :runtime
18
+ include Import[:logger, :runtime]
19
+
20
+ desc "Enable MOD(s) in mod-list.json (recursively enables dependencies)"
21
+
22
+ example [
23
+ "some-mod # Enable single MOD",
24
+ "mod-a mod-b # Enable multiple MOD(s)"
25
+ ]
26
+
27
+ argument :mod_names, type: :array, required: true, desc: "MOD names to enable"
28
+
29
+ # Execute the enable command
30
+ #
31
+ # @param mod_names [Array<String>] MOD names to enable
32
+ # @return [void]
33
+ def call(mod_names:, **)
34
+ # Load current state (without validation to allow fixing issues)
35
+ mod_list = MODList.load
36
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Scanning MOD(s)", output: $stderr)
37
+ handler = Progress::ScanHandler.new(presenter)
38
+ installed_mods = InstalledMOD.all(handler:)
39
+ graph = Dependency::Graph::Builder.build(installed_mods:, mod_list:)
40
+
41
+ # Convert MOD names to MOD objects
42
+ target_mods = mod_names.map {|name| Factorix::MOD[name:] }
43
+
44
+ # Validate target MODs exist
45
+ validate_target_mods_exist(target_mods, graph)
46
+
47
+ # Determine MODs to enable
48
+ mods_to_enable = plan_with_dependencies(target_mods, graph)
49
+
50
+ # Validate the plan (check for conflicts)
51
+ validate_plan(mods_to_enable, graph)
52
+
53
+ # Show plan to user
54
+ show_plan(mods_to_enable)
55
+
56
+ # Return early if nothing to enable
57
+ return if mods_to_enable.empty?
58
+
59
+ # Ask for confirmation
60
+ return unless confirm?("Do you want to enable these MOD(s)?")
61
+
62
+ # Execute the plan
63
+ execute_plan(mods_to_enable, mod_list)
64
+
65
+ # Save mod-list.json
66
+ backup_if_exists(runtime.mod_list_path)
67
+ mod_list.save
68
+ say "Enabled #{mods_to_enable.size} MOD(s)", prefix: :success
69
+ say "Saved mod-list.json", prefix: :success
70
+ logger.debug("Saved mod-list.json")
71
+ end
72
+
73
+ # Validate that all target MODs are installed
74
+ #
75
+ # @param target_mods [Array<Factorix::MOD>] MODs to validate
76
+ # @param graph [Factorix::Dependency::Graph] Dependency graph
77
+ # @return [void]
78
+ # @raise [MODNotFoundError] if any MOD is not installed
79
+ private def validate_target_mods_exist(target_mods, graph)
80
+ target_mods.each do |mod|
81
+ unless graph.node?(mod)
82
+ raise MODNotFoundError, "MOD '#{mod}' is not installed"
83
+ end
84
+ end
85
+ end
86
+
87
+ # Plan enable with automatic dependency resolution
88
+ #
89
+ # @param target_mods [Array<Factorix::MOD>] MODs to enable
90
+ # @param graph [Factorix::Dependency::Graph] Dependency graph
91
+ # @return [Array<Factorix::MOD>] MODs to enable (including dependencies)
92
+ # @raise [DependencyMissingError] if any dependency is not installed
93
+ # @raise [DependencyVersionError] if any dependency version requirement is not satisfied
94
+ private def plan_with_dependencies(target_mods, graph)
95
+ mods_to_enable = Set.new
96
+ to_process = target_mods.dup
97
+
98
+ while (mod = to_process.shift)
99
+ node = graph.node(mod)
100
+
101
+ if node.enabled?
102
+ logger.debug("MOD already enabled", mod_name: mod.name)
103
+ next
104
+ end
105
+
106
+ next if mods_to_enable.include?(mod)
107
+
108
+ mods_to_enable.add(mod)
109
+
110
+ graph.edges_from(mod).select(&:required?).each do |edge|
111
+ next if edge.to_mod.base?
112
+
113
+ dep_mod = edge.to_mod
114
+ dep_node = graph.node(dep_mod)
115
+
116
+ unless dep_node
117
+ raise DependencyMissingError,
118
+ "MOD '#{mod}' requires '#{dep_mod}' which is not installed"
119
+ end
120
+ unless edge.satisfied_by?(dep_node.version)
121
+ raise DependencyVersionError,
122
+ "Cannot enable #{mod}: dependency #{dep_mod} version requirement not satisfied " \
123
+ "(required: #{edge.version_requirement}, installed: #{dep_node.version})"
124
+ end
125
+
126
+ # Add to process queue if not already enabled
127
+ to_process << dep_mod unless dep_node.enabled?
128
+ end
129
+ end
130
+
131
+ mods_to_enable.to_a
132
+ end
133
+
134
+ # Validate the enable plan
135
+ #
136
+ # Checks for conflicts with currently enabled MODs or MODs in the enable plan.
137
+ #
138
+ # @param mods_to_enable [Array<Factorix::MOD>] MODs to enable
139
+ # @param graph [Factorix::Dependency::Graph] Dependency graph
140
+ # @return [void]
141
+ # @raise [MODConflictError] if any conflict is detected
142
+ private def validate_plan(mods_to_enable, graph)
143
+ mods_to_enable_set = Set.new(mods_to_enable)
144
+
145
+ mods_to_enable.each do |mod|
146
+ # Check outgoing incompatibility edges (this MOD conflicts with others)
147
+ graph.edges_from(mod).select(&:incompatible?).each do |edge|
148
+ conflict_node = graph.node(edge.to_mod)
149
+
150
+ # Check if conflicting MOD is currently enabled
151
+ if conflict_node&.enabled?
152
+ raise MODConflictError,
153
+ "Cannot enable #{mod}: conflicts with #{edge.to_mod} which is currently enabled"
154
+ end
155
+
156
+ # Check if conflicting MOD is in the enable plan
157
+ if mods_to_enable_set.include?(edge.to_mod)
158
+ raise MODConflictError,
159
+ "Cannot enable #{mod}: conflicts with #{edge.to_mod} which is also being enabled"
160
+ end
161
+ end
162
+
163
+ # Check incoming incompatibility edges (other MODs conflict with this one)
164
+ graph.edges_to(mod).select(&:incompatible?).each do |edge|
165
+ conflict_node = graph.node(edge.from_mod)
166
+
167
+ # Check if conflicting MOD is currently enabled
168
+ if conflict_node&.enabled?
169
+ raise MODConflictError,
170
+ "Cannot enable #{mod}: conflicts with #{edge.from_mod} which is currently enabled"
171
+ end
172
+
173
+ # Check if conflicting MOD is in the enable plan
174
+ if mods_to_enable_set.include?(edge.from_mod)
175
+ raise MODConflictError,
176
+ "Cannot enable #{mod}: conflicts with #{edge.from_mod} which is also being enabled"
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ # Show the enable plan to user
183
+ #
184
+ # @param mods_to_enable [Array<Factorix::MOD>] MODs to enable
185
+ # @return [void]
186
+ private def show_plan(mods_to_enable)
187
+ if mods_to_enable.empty?
188
+ say "All specified MOD(s) are already enabled", prefix: :info
189
+ return
190
+ end
191
+
192
+ say "Planning to enable #{mods_to_enable.size} MOD(s):", prefix: :info
193
+ mods_to_enable.each do |mod|
194
+ say " - #{mod}"
195
+ end
196
+ end
197
+
198
+ # Execute the enable plan
199
+ #
200
+ # @param mods_to_enable [Array<Factorix::MOD>] MODs to enable
201
+ # @param mod_list [Factorix::MODList] MOD list to modify
202
+ # @return [void]
203
+ private def execute_plan(mods_to_enable, mod_list)
204
+ return if mods_to_enable.empty?
205
+
206
+ mods_to_enable.each do |mod|
207
+ mod_list.enable(mod)
208
+ say "Enabled #{mod}", prefix: :success
209
+ logger.debug("Enabled MOD", mod_name: mod.name)
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ module MOD
7
+ module Image
8
+ # Add an image to a MOD on Factorio MOD Portal
9
+ class Add < Base
10
+ # @!parse
11
+ # # @return [Portal]
12
+ # attr_reader :portal
13
+ include Import[:portal]
14
+
15
+ desc "Add an image to a MOD"
16
+
17
+ example [
18
+ "some-mod screenshot.png # Add image to MOD"
19
+ ]
20
+
21
+ argument :mod_name, type: :string, required: true, desc: "MOD name"
22
+ argument :image_file, type: :string, required: true, desc: "Path to image file"
23
+
24
+ # Execute the add command
25
+ #
26
+ # @param mod_name [String] the MOD name
27
+ # @param image_file [String] path to image file
28
+ # @return [void]
29
+ def call(mod_name:, image_file:, **)
30
+ file_path = Pathname(image_file)
31
+
32
+ raise InvalidArgumentError, "Image file not found: #{image_file}" unless file_path.exist?
33
+
34
+ # Add image via Portal
35
+ image = portal.add_mod_image(mod_name, file_path)
36
+
37
+ say "Image added successfully!", prefix: :success
38
+ say " ID: #{image.id}"
39
+ say " Thumbnail: #{image.thumbnail}"
40
+ say " Full URL: #{image.url}"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end