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,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ module MOD
7
+ # Uninstall MODs from MOD directory
8
+ class Uninstall < 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 "Uninstall MOD(s) from MOD directory"
21
+
22
+ example [
23
+ "some-mod # Uninstall all versions of MOD",
24
+ "some-mod@1.2.0 # Uninstall specific version",
25
+ "--all # Uninstall all MOD(s)"
26
+ ]
27
+
28
+ argument :mod_specs, type: :array, required: false, desc: "MOD specifications (name@version or name)"
29
+ option :all, type: :flag, default: false, desc: "Uninstall all MOD(s) (base remains enabled, expansions disabled, others removed)"
30
+
31
+ # Execute the uninstall command
32
+ #
33
+ # @param mod_specs [Array<String>] MOD specifications
34
+ # @param all [Boolean] Uninstall all MODs
35
+ # @return [void]
36
+ def call(mod_specs: [], all: false, **)
37
+ # Validate arguments
38
+ if all && mod_specs.any?
39
+ raise InvalidArgumentError, "Cannot specify MOD names with --all option"
40
+ end
41
+
42
+ unless all || mod_specs.any?
43
+ raise InvalidArgumentError, "Must specify MOD names or use --all option"
44
+ end
45
+
46
+ # Load current state (without validation to allow fixing issues)
47
+ mod_list = MODList.load
48
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Scanning MOD(s)", output: $stderr)
49
+ handler = Progress::ScanHandler.new(presenter)
50
+ installed_mods = InstalledMOD.all(handler:)
51
+ graph = Dependency::Graph::Builder.build(installed_mods:, mod_list:)
52
+
53
+ # Determine uninstall targets
54
+ uninstall_targets = if all
55
+ plan_uninstall_all(graph, installed_mods)
56
+ else
57
+ # Parse mod specs to extract MOD and optional version
58
+ mod_specs.map {|spec| parse_mod_spec(spec) }
59
+ end
60
+
61
+ targets_to_uninstall = plan_uninstall(uninstall_targets, graph, installed_mods, all:)
62
+
63
+ if all
64
+ expansions_to_disable = graph.nodes.count {|node|
65
+ mod = node.mod
66
+ mod.expansion? && mod_list.exist?(mod) && mod_list.enabled?(mod)
67
+ }
68
+
69
+ if targets_to_uninstall.empty? && expansions_to_disable.zero?
70
+ say "No MOD(s) to uninstall or disable", prefix: :info
71
+ return
72
+ end
73
+ elsif targets_to_uninstall.empty?
74
+ say "No MOD(s) to uninstall", prefix: :info
75
+ return
76
+ end
77
+
78
+ show_plan(targets_to_uninstall, all:, graph:, mod_list:)
79
+ return unless confirm?("Do you want to uninstall these MOD(s)?")
80
+
81
+ execute_uninstall(targets_to_uninstall, installed_mods, mod_list)
82
+ disable_expansion_mods(graph, mod_list) if all
83
+ backup_if_exists(runtime.mod_list_path)
84
+ mod_list.save
85
+ say "Uninstalled #{targets_to_uninstall.size} MOD(s)", prefix: :success
86
+ say "Saved mod-list.json", prefix: :success
87
+ end
88
+
89
+ # Plan uninstall all MODs
90
+ #
91
+ # @param graph [Dependency::Graph] Dependency graph
92
+ # @param installed_mods [Array<InstalledMOD>] All installed MODs
93
+ # @return [Array<Hash>] Uninstall targets
94
+ private def plan_uninstall_all(graph, _installed_mods)
95
+ graph.nodes.filter_map do |node|
96
+ mod = node.mod
97
+ next if mod.base? || mod.expansion?
98
+
99
+ {mod:, version: nil}
100
+ end
101
+ end
102
+
103
+ private def parse_mod_spec(mod_spec)
104
+ if mod_spec.include?("@")
105
+ mod_name, version_str = mod_spec.split("@", 2)
106
+ mod = Factorix::MOD[name: mod_name]
107
+ version = MODVersion.from_string(version_str)
108
+ {mod:, version:}
109
+ else
110
+ mod = Factorix::MOD[name: mod_spec]
111
+ {mod:, version: nil}
112
+ end
113
+ end
114
+
115
+ private def plan_uninstall(targets, graph, installed_mods, all: false)
116
+ targets.filter_map do |target|
117
+ validate_uninstall_target(target, graph, installed_mods, all:)
118
+ end
119
+ end
120
+
121
+ # Validate a single uninstall target
122
+ #
123
+ # @param target [Hash] Target to validate ({mod:, version:})
124
+ # @param graph [Dependency::Graph] Dependency graph
125
+ # @param installed_mods [Array<InstalledMOD>] All installed MODs
126
+ # @param all [Boolean] Whether this is part of --all uninstall
127
+ # @return [Hash, nil] The target if valid, nil if should be skipped
128
+ # @raise [InvalidOperationError] if trying to uninstall base or expansion MOD
129
+ private def validate_uninstall_target(target, graph, installed_mods, all: false)
130
+ mod = target[:mod]
131
+
132
+ # Check if base/expansion
133
+ raise InvalidOperationError, "Cannot uninstall base MOD" if mod.base?
134
+ raise InvalidOperationError, "Cannot uninstall expansion MOD: #{mod}" if mod.expansion?
135
+
136
+ # Check if installed
137
+ unless graph.node?(mod)
138
+ say "MOD not installed: #{mod}", prefix: :warn
139
+ logger.debug("MOD not installed", mod_name: mod.name)
140
+ return nil
141
+ end
142
+
143
+ if target[:version] && !version_installed?(target, installed_mods)
144
+ say "MOD version not installed: #{format_target(target)}", prefix: :warn
145
+ logger.debug("MOD version not installed", target: format_target(target))
146
+ return nil
147
+ end
148
+
149
+ # Skip dependent check for --all since all MODs are being uninstalled
150
+ check_dependents_with_version(target, graph, installed_mods) unless all
151
+
152
+ target
153
+ end
154
+
155
+ # Check if a specific version is installed
156
+ #
157
+ # @param target [Hash] Target with version ({mod:, version:})
158
+ # @param installed_mods [Array<InstalledMOD>] All installed MODs
159
+ # @return [Boolean] true if version is installed
160
+ private def version_installed?(target, installed_mods)
161
+ installed_mods.any? {|im| im.mod == target[:mod] && im.version == target[:version] }
162
+ end
163
+
164
+ # Check for enabled dependents considering remaining versions
165
+ #
166
+ # @param target [Hash] Target to check ({mod:, version:})
167
+ # @param graph [Dependency::Graph] Dependency graph
168
+ # @param installed_mods [Array<InstalledMOD>] All installed MODs
169
+ # @return [void]
170
+ # @raise [DependencyViolationError] if dependencies cannot be satisfied after uninstall
171
+ private def check_dependents_with_version(target, graph, installed_mods)
172
+ mod = target[:mod]
173
+ dependents = graph.find_enabled_dependents(mod)
174
+ return if dependents.none?
175
+
176
+ # Find versions that will remain after this uninstall
177
+ remaining_versions = if target[:version]
178
+ installed_mods.select {|im| im.mod == mod && im.version != target[:version] }
179
+ else
180
+ [] # Uninstalling all versions
181
+ end
182
+
183
+ # Check each dependent to see if remaining versions can satisfy their requirements
184
+ unsatisfied_dependents = []
185
+
186
+ dependents.each do |dependent_mod|
187
+ # Find dependency edges from dependent to target MOD
188
+ edges = graph.edges_from(dependent_mod).select {|edge|
189
+ edge.to_mod == mod && edge.required?
190
+ }
191
+
192
+ edges.each do |edge|
193
+ # Check if any remaining version satisfies this requirement
194
+ can_satisfy = remaining_versions.any? {|im| edge.satisfied_by?(im.version) }
195
+
196
+ unsatisfied_dependents << dependent_mod unless can_satisfy
197
+ end
198
+ end
199
+
200
+ return if unsatisfied_dependents.empty?
201
+
202
+ raise DependencyViolationError,
203
+ "Cannot uninstall #{format_target(target)}: " \
204
+ "the following enabled MOD(s) depend on it: #{unsatisfied_dependents.uniq.join(", ")}"
205
+ end
206
+
207
+ # Show the uninstall plan
208
+ #
209
+ # @param targets [Array<Hash>] Targets to uninstall
210
+ # @param all [Boolean] Whether --all was specified
211
+ # @param graph [Dependency::Graph] Dependency graph
212
+ # @param mod_list [MODList] The MOD list
213
+ # @return [void]
214
+ private def show_plan(targets, all: false, graph: nil, mod_list: nil)
215
+ say "Planning to uninstall #{targets.size} MOD(s):", prefix: :info
216
+ targets.each do |target|
217
+ say " - #{format_target(target)}"
218
+ end
219
+
220
+ # If --all, also show expansion MODs to be disabled
221
+ return unless all && graph && mod_list
222
+
223
+ expansions_to_disable = graph.nodes.filter_map {|node|
224
+ mod = node.mod
225
+ mod if mod.expansion? && mod_list.exist?(mod) && mod_list.enabled?(mod)
226
+ }
227
+
228
+ return if expansions_to_disable.none?
229
+
230
+ say "Expansion MOD(s) to be disabled:", prefix: :info
231
+ expansions_to_disable.each do |mod|
232
+ say " - #{mod}"
233
+ end
234
+ end
235
+
236
+ # Execute the uninstall
237
+ #
238
+ # @param targets [Array<Hash>] Targets to uninstall
239
+ # @param installed_mods [Array<InstalledMOD>] All installed MODs
240
+ # @param mod_list [MODList] The MOD list
241
+ # @return [void]
242
+ private def execute_uninstall(targets, installed_mods, mod_list)
243
+ targets.each do |target|
244
+ mod = target[:mod]
245
+
246
+ # Find versions to uninstall
247
+ mod_versions = if target[:version]
248
+ # Uninstall only the specified version
249
+ installed_mods.select {|im|
250
+ im.mod == mod && im.version == target[:version]
251
+ }
252
+ else
253
+ # Uninstall all versions
254
+ installed_mods.select {|im| im.mod == mod }
255
+ end
256
+
257
+ # Remove versions from file system
258
+ mod_versions.each do |installed_mod|
259
+ remove_mod_files(installed_mod)
260
+ end
261
+
262
+ # Remove from mod-list.json if appropriate
263
+ should_remove_from_list = if target[:version]
264
+ # Only remove if mod-list references this version or if no versions remain
265
+ remaining_versions = installed_mods.select {|im|
266
+ im.mod == mod && !mod_versions.include?(im)
267
+ }
268
+ remaining_versions.empty?
269
+ else
270
+ # Always remove when uninstalling all versions
271
+ true
272
+ end
273
+
274
+ if should_remove_from_list && mod_list.exist?(mod)
275
+ mod_list.remove(mod)
276
+ say "Removed #{mod} from mod-list.json", prefix: :success
277
+ end
278
+ end
279
+ end
280
+
281
+ # Disable expansion MODs in mod-list.json
282
+ #
283
+ # @param graph [Dependency::Graph] Dependency graph
284
+ # @param mod_list [MODList] The MOD list
285
+ # @return [void]
286
+ private def disable_expansion_mods(graph, mod_list)
287
+ graph.nodes.each do |node|
288
+ mod = node.mod
289
+ next unless mod.expansion?
290
+ next unless mod_list.exist?(mod) && mod_list.enabled?(mod)
291
+
292
+ mod_list.disable(mod)
293
+ say "Disabled expansion MOD: #{mod}", prefix: :success
294
+ logger.info("Disabled expansion MOD", mod_name: mod.name)
295
+ end
296
+ end
297
+
298
+ # Remove MOD files from the file system
299
+ #
300
+ # @param installed_mod [InstalledMOD] The installed MOD to remove
301
+ # @return [void]
302
+ private def remove_mod_files(installed_mod)
303
+ path = installed_mod.path
304
+
305
+ if installed_mod.form == InstalledMOD::ZIP_FORM
306
+ path.delete
307
+ logger.info("Removed ZIP file", mod_name: installed_mod.mod.name, version: installed_mod.version.to_s)
308
+ elsif installed_mod.form == InstalledMOD::DIRECTORY_FORM
309
+ path.rmtree
310
+ logger.info("Removed directory", mod_name: installed_mod.mod.name, version: installed_mod.version.to_s)
311
+ end
312
+ end
313
+
314
+ # Format uninstall target for display
315
+ #
316
+ # @param target [Hash] Target to format ({mod:, version:})
317
+ # @return [String] Formatted string (e.g., "mod-a@1.0.0" or "mod-a")
318
+ private def format_target(target)
319
+ target[:version] ? "#{target[:mod]}@#{target[:version]}" : target[:mod].to_s
320
+ end
321
+ end
322
+ end
323
+ end
324
+ end
325
+ end
@@ -0,0 +1,222 @@
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
+ # Update installed MODs to their latest versions
11
+ class Update < Base
12
+ confirmable!
13
+ require_game_stopped!
14
+ backup_support!
15
+
16
+ # @!parse
17
+ # # @return [Portal]
18
+ # attr_reader :portal
19
+ # # @return [Dry::Logger::Dispatcher]
20
+ # attr_reader :logger
21
+ # # @return [Factorix::Runtime]
22
+ # attr_reader :runtime
23
+ include Import[:portal, :logger, :runtime]
24
+
25
+ desc "Update MOD(s) to their latest versions"
26
+
27
+ example [
28
+ " # Update all installed MOD(s)",
29
+ "some-mod # Update specific MOD",
30
+ "mod-a mod-b # Update multiple MOD(s)",
31
+ "-j 8 mod-a mod-b # Use 8 parallel downloads"
32
+ ]
33
+
34
+ argument :mod_names, type: :array, required: false, desc: "MOD names to update (all if not specified)"
35
+ option :jobs, type: :integer, aliases: ["-j"], default: 4, desc: "Number of parallel downloads"
36
+
37
+ # Execute the update command
38
+ #
39
+ # @param mod_names [Array<String>] MOD names to update
40
+ # @param jobs [Integer] Number of parallel downloads
41
+ # @return [void]
42
+ def call(mod_names: [], jobs: 4, **)
43
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Scanning MOD(s)", output: $stderr)
44
+ handler = Progress::ScanHandler.new(presenter)
45
+ installed_mods = InstalledMOD.all(handler:)
46
+ mod_list = MODList.load
47
+
48
+ target_mods = if mod_names.empty?
49
+ mods = installed_mods.map(&:mod)
50
+ mods.uniq!
51
+ mods.reject! {|mod| mod.base? || mod.expansion? }
52
+ mods
53
+ else
54
+ mod_names.map {|name| validate_and_get_mod(name) }
55
+ end
56
+
57
+ if target_mods.empty?
58
+ say "No MOD(s) to update", prefix: :info
59
+ return
60
+ end
61
+
62
+ update_targets = find_update_targets(target_mods, installed_mods, jobs)
63
+
64
+ if update_targets.empty?
65
+ say "All MOD(s) are up to date", prefix: :info
66
+ return
67
+ end
68
+
69
+ show_plan(update_targets)
70
+ return unless confirm?("Do you want to update these MOD(s)?")
71
+
72
+ execute_updates(update_targets, mod_list, jobs)
73
+
74
+ backup_if_exists(runtime.mod_list_path)
75
+ mod_list.save
76
+ say "Updated #{update_targets.size} MOD(s)", prefix: :success
77
+ say "Saved mod-list.json", prefix: :success
78
+ end
79
+
80
+ # Validate MOD name and return MOD object
81
+ #
82
+ # @param mod_name [String] MOD name
83
+ # @return [MOD] MOD object
84
+ # @raise [InvalidOperationError] if MOD is base or expansion
85
+ private def validate_and_get_mod(mod_name)
86
+ mod = Factorix::MOD[name: mod_name]
87
+
88
+ raise InvalidOperationError, "Cannot update base MOD" if mod.base?
89
+ raise InvalidOperationError, "Cannot update expansion MOD: #{mod}" if mod.expansion?
90
+
91
+ mod
92
+ end
93
+
94
+ # Find MODs that have available updates
95
+ #
96
+ # @param target_mods [Array<MOD>] Target MODs to check
97
+ # @param installed_mods [Array<InstalledMOD>] All installed MODs
98
+ # @param jobs [Integer] Number of parallel jobs
99
+ # @return [Array<Hash>] Update targets with current and latest versions
100
+ private def find_update_targets(target_mods, installed_mods, jobs)
101
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Checking for updates", output: $stderr)
102
+ presenter.start(total: target_mods.size)
103
+
104
+ pool = Concurrent::FixedThreadPool.new(jobs)
105
+
106
+ futures = target_mods.map {|mod|
107
+ Concurrent::Future.execute(executor: pool) do
108
+ result = check_mod_for_update(mod, installed_mods)
109
+ presenter.update
110
+ result
111
+ end
112
+ }
113
+
114
+ results = futures.filter_map(&:value!)
115
+ presenter.finish
116
+ results
117
+ ensure
118
+ pool&.shutdown
119
+ pool&.wait_for_termination
120
+ end
121
+
122
+ # Check a single MOD for available updates
123
+ #
124
+ # @param mod [MOD] MOD to check
125
+ # @param installed_mods [Array<InstalledMOD>] All installed MODs
126
+ # @return [Hash, nil] Update target info or nil if no update available
127
+ private def check_mod_for_update(mod, installed_mods)
128
+ current_versions = installed_mods.select {|im| im.mod == mod }
129
+ return nil if current_versions.empty?
130
+
131
+ current_version = current_versions.map(&:version).max
132
+
133
+ mod_info = portal.get_mod_full(mod.name)
134
+ latest_release = mod_info.releases.max_by(&:released_at)
135
+
136
+ return nil unless latest_release
137
+ return nil if latest_release.version <= current_version
138
+
139
+ {
140
+ mod:,
141
+ mod_info:,
142
+ current_version:,
143
+ latest_release:,
144
+ output_path: runtime.mod_dir / latest_release.file_name
145
+ }
146
+ rescue MODNotOnPortalError
147
+ logger.debug("MOD not found on portal", mod: mod.name)
148
+ nil
149
+ end
150
+
151
+ # Show the update plan
152
+ #
153
+ # @param targets [Array<Hash>] Update targets
154
+ # @return [void]
155
+ private def show_plan(targets)
156
+ say "Planning to update #{targets.size} MOD(s):", prefix: :info
157
+ targets.each do |target|
158
+ say " - #{target[:mod]}: #{target[:current_version]} -> #{target[:latest_release].version}"
159
+ end
160
+ end
161
+
162
+ # Execute the updates
163
+ #
164
+ # @param targets [Array<Hash>] Update targets
165
+ # @param mod_list [MODList] MOD list
166
+ # @param jobs [Integer] Number of parallel jobs
167
+ # @return [void]
168
+ private def execute_updates(targets, mod_list, jobs)
169
+ download_mods(targets, jobs)
170
+
171
+ targets.each do |target|
172
+ mod = target[:mod]
173
+
174
+ if mod_list.exist?(mod)
175
+ current_enabled = mod_list.enabled?(mod)
176
+ mod_list.remove(mod)
177
+ mod_list.add(mod, enabled: current_enabled)
178
+ say "Updated #{mod} to #{target[:latest_release].version}", prefix: :success
179
+ else
180
+ mod_list.add(mod, enabled: true)
181
+ say "Added #{mod} to mod-list.json", prefix: :success
182
+ end
183
+ end
184
+ end
185
+
186
+ # Download MODs in parallel
187
+ #
188
+ # @param targets [Array<Hash>] Update targets
189
+ # @param jobs [Integer] Number of parallel jobs
190
+ # @return [void]
191
+ private def download_mods(targets, jobs)
192
+ multi_presenter = Progress::MultiPresenter.new(title: "\u{1F4E5}\u{FE0E} Downloads")
193
+
194
+ pool = Concurrent::FixedThreadPool.new(jobs)
195
+
196
+ futures = targets.map {|target|
197
+ Concurrent::Future.execute(executor: pool) do
198
+ thread_portal = Application[:portal]
199
+ thread_downloader = thread_portal.mod_download_api.downloader
200
+
201
+ presenter = multi_presenter.register(
202
+ target[:mod].name,
203
+ title: target[:latest_release].file_name
204
+ )
205
+ handler = Progress::DownloadHandler.new(presenter)
206
+
207
+ thread_downloader.subscribe(handler)
208
+ thread_portal.download_mod(target[:latest_release], target[:output_path])
209
+ thread_downloader.unsubscribe(handler)
210
+ end
211
+ }
212
+
213
+ futures.each(&:wait!)
214
+ ensure
215
+ pool&.shutdown
216
+ pool&.wait_for_termination
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ module MOD
7
+ # Upload MOD to Factorio MOD Portal (handles both new and update)
8
+ class Upload < Base
9
+ # @!parse
10
+ # # @return [Portal]
11
+ # attr_reader :portal
12
+ include Import[:portal]
13
+
14
+ desc "Upload MOD to Factorio MOD Portal (handles both new and update)"
15
+
16
+ example [
17
+ "my-mod_1.0.0.zip # Upload MOD",
18
+ "my-mod_1.0.0.zip --category automation # Upload with category"
19
+ ]
20
+
21
+ argument :file, type: :string, required: true, desc: "Path to MOD zip file"
22
+ option :description, type: :string, desc: "Markdown description"
23
+ option :category, type: :string, desc: "MOD category"
24
+ option :license, type: :string, desc: "License identifier"
25
+ option :source_url, type: :string, desc: "Repository URL"
26
+
27
+ # Execute the upload command
28
+ #
29
+ # @param file [String] path to MOD zip file
30
+ # @param description [String, nil] optional description
31
+ # @param category [String, nil] optional category
32
+ # @param license [String, nil] optional license
33
+ # @param source_url [String, nil] optional source URL
34
+ # @return [void]
35
+ # @raise [InvalidArgumentError] if file does not exist, is not a file, or is not a .zip file
36
+ # @raise [FileFormatError] if zip is invalid or info.json is missing/malformed
37
+ def call(file:, description: nil, category: nil, license: nil, source_url: nil, **)
38
+ file_path = Pathname(file)
39
+
40
+ raise InvalidArgumentError, "File not found: #{file}" unless file_path.exist?
41
+ raise InvalidArgumentError, "Not a file: #{file}" unless file_path.file?
42
+ raise InvalidArgumentError, "File must be a .zip file" if file_path.extname.casecmp(".zip").nonzero?
43
+
44
+ mod_name = extract_mod_name(file_path)
45
+ metadata = build_metadata(description:, category:, license:, source_url:)
46
+
47
+ presenter = Progress::Presenter.new(title: "\u{1F4E4} Uploading #{file_path.basename}", output: $stderr)
48
+
49
+ uploader = portal.mod_management_api.uploader
50
+ handler = Progress::UploadHandler.new(presenter)
51
+ uploader.subscribe(handler)
52
+
53
+ begin
54
+ portal.upload_mod(mod_name, file_path, **metadata)
55
+ say "Upload completed successfully!", prefix: :success
56
+ ensure
57
+ uploader.unsubscribe(handler)
58
+ end
59
+ end
60
+
61
+ # Extract MOD name from info.json inside zip file
62
+ #
63
+ # @param file_path [Pathname] path to zip file
64
+ # @return [String] MOD name from info.json
65
+ # @raise [FileFormatError] if info.json not found or invalid
66
+ private def extract_mod_name(file_path)
67
+ info = InfoJSON.from_zip(file_path)
68
+ info.name
69
+ end
70
+
71
+ # Build metadata hash from options
72
+ #
73
+ # @param description [String, nil] description
74
+ # @param category [String, nil] category
75
+ # @param license [String, nil] license
76
+ # @param source_url [String, nil] source URL
77
+ # @return [Hash] metadata hash with symbol keys
78
+ private def build_metadata(description: nil, category: nil, license: nil, source_url: nil)
79
+ metadata = {}
80
+ metadata[:description] = description if description
81
+ metadata[:category] = category if category
82
+ metadata[:license] = license if license
83
+ metadata[:source_url] = source_url if source_url
84
+ metadata
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end