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,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ module MOD
7
+ # List installed MODs
8
+ class List < Base
9
+ # @!parse
10
+ # # @return [Dry::Logger::Dispatcher]
11
+ # attr_reader :logger
12
+ # # @return [Factorix::Runtime]
13
+ # attr_reader :runtime
14
+ # # @return [Factorix::API::MODPortalAPI]
15
+ # attr_reader :mod_portal_api
16
+ include Import[:logger, :runtime, :mod_portal_api]
17
+
18
+ desc "List installed MOD(s)"
19
+
20
+ example [
21
+ " # List all installed MOD(s)",
22
+ "--enabled # List only enabled MOD(s)",
23
+ "--outdated # List MOD(s) with available updates",
24
+ "--json # Output in JSON format"
25
+ ]
26
+
27
+ option :enabled, type: :flag, default: false, desc: "Show only enabled MOD(s)"
28
+ option :disabled, type: :flag, default: false, desc: "Show only disabled MOD(s)"
29
+ option :errors, type: :flag, default: false, desc: "Show only MOD(s) with dependency errors"
30
+ option :outdated, type: :flag, default: false, desc: "Show only MOD(s) with available updates"
31
+ option :json, type: :flag, default: false, desc: "Output in JSON format"
32
+
33
+ MODInfo = Data.define(:name, :version, :enabled, :error, :latest_version)
34
+
35
+ # MOD information for display
36
+ #
37
+ # This class encapsulates MOD information for display purposes,
38
+ # including the MOD name, version, enabled status, error messages,
39
+ # and latest available version.
40
+ class MODInfo
41
+ # @!attribute [r] name
42
+ # @return [String] MOD name
43
+ # @!attribute [r] version
44
+ # @return [MODVersion] MOD version
45
+ # @!attribute [r] enabled
46
+ # @return [Boolean] enabled status
47
+ # @!attribute [r] error
48
+ # @return [String, nil] error message if any
49
+ # @!attribute [r] latest_version
50
+ # @return [MODVersion, nil] latest version available on portal
51
+
52
+ # Get the display status string
53
+ #
54
+ # @return [String] "error", "enabled", or "disabled"
55
+ def status
56
+ return "error" if error
57
+
58
+ enabled ? "enabled" : "disabled"
59
+ end
60
+
61
+ # Check if a newer version is available
62
+ #
63
+ # @return [Boolean] true if latest_version is newer than current version
64
+ def outdated?
65
+ return false unless latest_version
66
+
67
+ latest_version > version
68
+ end
69
+ end
70
+
71
+ # Execute the list command
72
+ #
73
+ # @param enabled [Boolean] show only enabled MODs
74
+ # @param disabled [Boolean] show only disabled MODs
75
+ # @param errors [Boolean] show only MODs with dependency errors
76
+ # @param outdated [Boolean] show only MODs with available updates
77
+ # @param json [Boolean] output in JSON format
78
+ # @return [void]
79
+ def call(enabled:, disabled:, errors:, outdated:, json:, **)
80
+ validate_filter_options!(enabled:, disabled:, errors:, outdated:)
81
+
82
+ mod_list = MODList.load
83
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Scanning MOD(s)", output: $stderr)
84
+ handler = Progress::ScanHandler.new(presenter)
85
+ installed_mods = InstalledMOD.all(handler:)
86
+ graph = Dependency::Graph::Builder.build(installed_mods:, mod_list:)
87
+
88
+ validator = Dependency::Validator.new(graph:, mod_list:, installed_mods:)
89
+ validation_result = validator.validate
90
+
91
+ mod_infos = build_mod_infos(installed_mods, mod_list, validation_result)
92
+ total_count = mod_infos.size
93
+
94
+ # Apply filters
95
+ mod_infos = apply_filters(mod_infos, enabled:, disabled:, errors:, outdated:)
96
+
97
+ # Sort
98
+ mod_infos = sort_mods(mod_infos)
99
+
100
+ # Determine active filter for summary
101
+ active_filter = if enabled then :enabled
102
+ elsif disabled then :disabled
103
+ elsif errors then :errors
104
+ elsif outdated then :outdated
105
+ end
106
+
107
+ # Output
108
+ if json
109
+ output_json(mod_infos)
110
+ else
111
+ output_table(mod_infos, show_latest: outdated, active_filter:, total_count:)
112
+ end
113
+ end
114
+
115
+ # Validate that conflicting filter options are not specified together
116
+ #
117
+ # @param enabled [Boolean] show only enabled MODs
118
+ # @param disabled [Boolean] show only disabled MODs
119
+ # @param errors [Boolean] show only MODs with dependency errors
120
+ # @param outdated [Boolean] show only MODs with available updates
121
+ # @return [void]
122
+ # @raise [InvalidArgumentError] if conflicting options are specified
123
+ private def validate_filter_options!(enabled:, disabled:, errors:, outdated:)
124
+ filters = []
125
+ filters << "--enabled" if enabled
126
+ filters << "--disabled" if disabled
127
+ filters << "--errors" if errors
128
+ filters << "--outdated" if outdated
129
+
130
+ return if filters.size <= 1
131
+
132
+ raise InvalidArgumentError, "Cannot combine #{filters.join(", ")} options"
133
+ end
134
+
135
+ # Build list of MOD info from installed MODs
136
+ #
137
+ # @param installed_mods [Array<InstalledMOD>] installed MODs
138
+ # @param mod_list [MODList] MOD list with enabled status
139
+ # @param validation_result [Dependency::ValidationResult] validation result with errors
140
+ # @return [Array<MODInfo>] MOD info list
141
+ private def build_mod_infos(installed_mods, mod_list, validation_result)
142
+ grouped = installed_mods.group_by(&:mod)
143
+ error_map = build_error_map(validation_result)
144
+
145
+ grouped.map {|mod, versions|
146
+ display_version = determine_display_version(mod, versions, mod_list)
147
+ enabled = mod_list.exist?(mod) && mod_list.enabled?(mod)
148
+ error = error_map[mod.name]
149
+
150
+ MODInfo.new(name: mod.name, version: display_version, enabled:, error:, latest_version: nil)
151
+ }
152
+ end
153
+
154
+ # Build a map of MOD name to error messages from validation result
155
+ #
156
+ # @param validation_result [Dependency::ValidationResult] validation result
157
+ # @return [Hash<String, String>] map of MOD name to error message
158
+ private def build_error_map(validation_result)
159
+ error_map = {}
160
+
161
+ validation_result.errors.each do |error|
162
+ mod = error.mod
163
+ next unless mod
164
+
165
+ # Use the first error for each MOD
166
+ error_map[mod.name] ||= error.message
167
+ end
168
+
169
+ error_map
170
+ end
171
+
172
+ # Determine which version of a MOD to display
173
+ #
174
+ # Returns the specified version from mod-list.json if present,
175
+ # otherwise returns the latest installed version.
176
+ #
177
+ # @param mod [MOD] the MOD
178
+ # @param versions [Array<InstalledMOD>] installed versions of the MOD
179
+ # @param mod_list [MODList] MOD list with enabled status and version
180
+ # @return [MODVersion] the version to display
181
+ private def determine_display_version(mod, versions, mod_list)
182
+ if mod_list.exist?(mod)
183
+ specified_version = mod_list.version(mod)
184
+ return specified_version if specified_version
185
+ end
186
+
187
+ versions.map(&:version).max
188
+ end
189
+
190
+ # Apply filters to MOD info list
191
+ #
192
+ # @param mod_infos [Array<MODInfo>] MOD info list
193
+ # @param enabled [Boolean] show only enabled MODs
194
+ # @param disabled [Boolean] show only disabled MODs
195
+ # @param errors [Boolean] show only MODs with dependency errors
196
+ # @param outdated [Boolean] show only MODs with available updates
197
+ # @return [Array<MODInfo>] filtered MOD info list
198
+ private def apply_filters(mod_infos, enabled:, disabled:, errors:, outdated:)
199
+ if enabled
200
+ mod_infos = mod_infos.select(&:enabled)
201
+ elsif disabled
202
+ mod_infos = mod_infos.reject(&:enabled)
203
+ elsif errors
204
+ mod_infos = mod_infos.select(&:error)
205
+ elsif outdated
206
+ mod_infos = fetch_latest_versions(mod_infos)
207
+ mod_infos = mod_infos.select(&:outdated?)
208
+ end
209
+
210
+ mod_infos
211
+ end
212
+
213
+ # Default number of parallel jobs for fetching latest versions
214
+ DEFAULT_JOBS = 4
215
+ private_constant :DEFAULT_JOBS
216
+
217
+ # Fetch latest versions from portal for outdated check
218
+ #
219
+ # @param mod_infos [Array<MODInfo>] MOD info list
220
+ # @return [Array<MODInfo>] MOD info list with latest versions
221
+ private def fetch_latest_versions(mod_infos)
222
+ # Separate base/expansion from regular MODs
223
+ base_and_expansions, regular_mods = mod_infos.partition {|info|
224
+ mod = Factorix::MOD[name: info.name]
225
+ mod.base? || mod.expansion?
226
+ }
227
+
228
+ # Only show progress for MOD(s) that need API calls
229
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Checking for updates", output: $stderr)
230
+ presenter.start(total: regular_mods.size)
231
+
232
+ pool = Concurrent::FixedThreadPool.new(DEFAULT_JOBS)
233
+
234
+ futures = regular_mods.map {|info|
235
+ Concurrent::Future.execute(executor: pool) do
236
+ result = fetch_latest_version_for_mod(info)
237
+ presenter.update
238
+ result
239
+ end
240
+ }
241
+
242
+ results = futures.map(&:value!)
243
+ presenter.finish
244
+
245
+ # Combine base/expansion (unchanged) with fetched results
246
+ base_and_expansions + results
247
+ ensure
248
+ pool&.shutdown
249
+ pool&.wait_for_termination
250
+ end
251
+
252
+ # Fetch latest version for a single MOD
253
+ #
254
+ # @param info [MODInfo] MOD info
255
+ # @return [MODInfo] MOD info with latest version
256
+ private def fetch_latest_version_for_mod(info)
257
+ portal_info = mod_portal_api.get_mod(info.name)
258
+ latest = portal_info[:releases]&.map {|r| MODVersion.from_string(r[:version]) }&.max
259
+ MODInfo.new(
260
+ name: info.name,
261
+ version: info.version,
262
+ enabled: info.enabled,
263
+ error: info.error,
264
+ latest_version: latest
265
+ )
266
+ rescue MODNotOnPortalError
267
+ logger.debug("MOD not found on portal", mod: info.name)
268
+ info
269
+ end
270
+
271
+ # Sort MODs: base -> expansion (alphabetically) -> others (alphabetically)
272
+ #
273
+ # @param mod_infos [Array<MODInfo>] MOD info list
274
+ # @return [Array<MODInfo>] sorted MOD info list
275
+ private def sort_mods(mod_infos)
276
+ mod_infos.sort_by do |info|
277
+ mod = Factorix::MOD[name: info.name]
278
+ if mod.base?
279
+ [0, info.name]
280
+ elsif mod.expansion?
281
+ [1, info.name]
282
+ else
283
+ [2, info.name]
284
+ end
285
+ end
286
+ end
287
+
288
+ # Output MOD list in table format
289
+ #
290
+ # @param mod_infos [Array<MODInfo>] MOD info list
291
+ # @param show_latest [Boolean] show LATEST column for outdated MODs
292
+ # @param active_filter [Symbol, nil] active filter (:enabled, :disabled, :errors, :outdated, or nil)
293
+ # @param total_count [Integer] total MOD count before filtering
294
+ # @return [void]
295
+ private def output_table(mod_infos, show_latest: false, active_filter: nil, total_count: 0)
296
+ if mod_infos.empty?
297
+ message = active_filter ? "No MOD(s) match the specified criteria" : "No MOD(s) found"
298
+ say message, prefix: :info
299
+ return
300
+ end
301
+
302
+ # Calculate column widths
303
+ name_width = [mod_infos.map {|m| m.name.length }.max, 4].max
304
+ version_width = [mod_infos.map {|m| m.version.to_s.length }.max, 7].max
305
+
306
+ if show_latest
307
+ latest_width = [mod_infos.map {|m| m.latest_version&.to_s&.length || 0 }.max, 6].max
308
+
309
+ # Header with LATEST column
310
+ puts "%-#{name_width}s %-#{version_width}s %-#{latest_width}s %s" % %w[NAME VERSION LATEST STATUS]
311
+
312
+ # Rows with LATEST column
313
+ mod_infos.each do |info|
314
+ puts "%-#{name_width}s %-#{version_width}s %-#{latest_width}s %s" % [info.name, info.version, info.latest_version, info.status]
315
+ end
316
+ else
317
+ # Header
318
+ puts "%-#{name_width}s %-#{version_width}s %s" % %w[NAME VERSION STATUS]
319
+
320
+ # Rows
321
+ mod_infos.each do |info|
322
+ puts "%-#{name_width}s %-#{version_width}s %s" % [info.name, info.version, info.status]
323
+ end
324
+ end
325
+
326
+ say format_summary(mod_infos.size, active_filter, total_count), prefix: :info
327
+ end
328
+
329
+ # Format summary message based on active filter
330
+ #
331
+ # @param count [Integer] filtered MOD count
332
+ # @param active_filter [Symbol, nil] active filter
333
+ # @param total_count [Integer] total MOD count
334
+ # @return [String] formatted summary message
335
+ private def format_summary(count, active_filter, total_count)
336
+ case active_filter
337
+ when :enabled
338
+ "Summary: #{count} enabled MOD(s), #{total_count} total MOD(s)"
339
+ when :disabled
340
+ "Summary: #{count} disabled MOD(s), #{total_count} total MOD(s)"
341
+ when :errors
342
+ "Summary: #{count} MOD(s) with errors, #{total_count} total MOD(s)"
343
+ when :outdated
344
+ "Summary: #{count} outdated MOD(s), #{total_count} total MOD(s)"
345
+ else
346
+ "Summary: #{count} MOD(s)"
347
+ end
348
+ end
349
+
350
+ # Output MOD list in JSON format
351
+ #
352
+ # @param mod_infos [Array<MODInfo>] MOD info list
353
+ # @return [void]
354
+ private def output_json(mod_infos)
355
+ data = mod_infos.map {|info|
356
+ {
357
+ name: info.name,
358
+ version: info.version.to_s,
359
+ enabled: info.enabled,
360
+ error: info.error
361
+ }.tap do |h|
362
+ h[:latest_version] = info.latest_version.to_s if info.latest_version
363
+ end
364
+ }
365
+
366
+ puts JSON.pretty_generate(data)
367
+ end
368
+ end
369
+ end
370
+ end
371
+ end
372
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Factorix
6
+ class CLI
7
+ module Commands
8
+ module MOD
9
+ # Search MODs on Factorio MOD Portal
10
+ class Search < Base
11
+ # @!parse
12
+ # # @return [Portal]
13
+ # attr_reader :portal
14
+ # # @return [Runtime]
15
+ # attr_reader :runtime
16
+ include Import[:portal, :runtime]
17
+
18
+ desc "Search MOD(s) on Factorio MOD Portal"
19
+
20
+ example [
21
+ " # List MOD(s) for current Factorio version",
22
+ "mod-a mod-b # Search specific MOD(s) by name",
23
+ "--sort name # List MOD(s) sorted by name",
24
+ "--page 2 --page-size 25 # Paginate results",
25
+ "--no-hide-deprecated # Include deprecated MOD(s)",
26
+ "--version 1.1 # Filter by specific Factorio version"
27
+ ]
28
+
29
+ argument :mod_names, type: :array, required: false, default: [], desc: "MOD names to search"
30
+
31
+ option :hide_deprecated, type: :boolean, default: true, desc: "Hide deprecated MOD(s)"
32
+ option :page, type: :integer, default: 1, desc: "Page number"
33
+ option :page_size, type: :integer, default: 25, desc: "Results per page (max 500)"
34
+ option :sort, type: :string, values: %w[name created_at updated_at], desc: "Sort field"
35
+ option :sort_order, type: :string, values: %w[asc desc], desc: "Sort order"
36
+ option :version, type: :string, desc: "Filter by Factorio version (default: installed version)"
37
+ option :json, type: :flag, default: false, desc: "Output in JSON format"
38
+
39
+ # Execute the search command
40
+ #
41
+ # @param mod_names [Array<String>] MOD names to search
42
+ # @param hide_deprecated [Boolean] Hide deprecated MODs
43
+ # @param page [Integer] Page number
44
+ # @param page_size [Integer] Results per page
45
+ # @param sort [String, nil] Sort field
46
+ # @param sort_order [String, nil] Sort order
47
+ # @param version [String, nil] Factorio version filter
48
+ # @param json [Boolean] Output in JSON format
49
+ # @return [void]
50
+ def call(mod_names: [], hide_deprecated: true, page: 1, page_size: 25, sort: nil, sort_order: nil, version: nil, json: false, **)
51
+ version ||= default_factorio_version
52
+
53
+ mods = portal.list_mods(*mod_names, hide_deprecated: hide_deprecated || nil, page:, page_size:, sort:, sort_order:, version:)
54
+
55
+ if json
56
+ output_json(mods)
57
+ else
58
+ output_table(mods)
59
+ end
60
+ end
61
+
62
+ private def output_json(mods)
63
+ puts JSON.pretty_generate(mods.map {|mod| mod_to_hash(mod) })
64
+ end
65
+
66
+ private def mod_to_hash(mod)
67
+ {
68
+ name: mod.name,
69
+ title: mod.title,
70
+ owner: mod.owner,
71
+ summary: mod.summary,
72
+ downloads_count: mod.downloads_count,
73
+ category: mod.category.value,
74
+ score: mod.score,
75
+ thumbnail: mod.thumbnail&.to_s,
76
+ latest_release: mod.latest_release && release_to_hash(mod.latest_release),
77
+ releases: mod.releases.map {|r| release_to_hash(r) }
78
+ }
79
+ end
80
+
81
+ private def release_to_hash(release)
82
+ {
83
+ version: release.version.to_s,
84
+ file_name: release.file_name,
85
+ released_at: release.released_at.iso8601,
86
+ factorio_version: release.info_json[:factorio_version],
87
+ sha1: release.sha1
88
+ }
89
+ end
90
+
91
+ private def output_table(mods)
92
+ if mods.empty?
93
+ say "No MOD(s) found", prefix: :info
94
+ return
95
+ end
96
+
97
+ rows = mods.map {|mod| format_row(mod) }
98
+
99
+ headers = %w[NAME TITLE CATEGORY OWNER LATEST]
100
+ widths = headers.map.with_index {|h, i| [h.length, *rows.map {|r| r[i].to_s.length }].max }
101
+
102
+ puts format_table_row(headers, widths)
103
+
104
+ rows.each do |row|
105
+ puts format_table_row(row, widths)
106
+ end
107
+
108
+ say "#{mods.size} MOD(s) found", prefix: :info
109
+ end
110
+
111
+ private def format_table_row(values, widths)
112
+ pairs = values.zip(widths)
113
+ pairs.map {|v, w| v.to_s.ljust(w) }.join(" ")
114
+ end
115
+
116
+ private def format_row(mod)
117
+ [
118
+ mod.name,
119
+ mod.title,
120
+ mod.category.name,
121
+ mod.owner,
122
+ mod.latest_release&.version&.to_s
123
+ ]
124
+ end
125
+
126
+ private def default_factorio_version
127
+ base_mod = InstalledMOD.from_directory(runtime.data_dir + "base")
128
+ "#{base_mod.version.major}.#{base_mod.version.minor}"
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Factorix
6
+ class CLI
7
+ module Commands
8
+ module MOD
9
+ module Settings
10
+ # Dump MOD settings to JSON format
11
+ class Dump < Base
12
+ # @!parse
13
+ # # @return [Runtime::Base]
14
+ # attr_reader :runtime
15
+ include Import[:runtime]
16
+
17
+ desc "Dump MOD settings to JSON format"
18
+
19
+ example [
20
+ " # Dump to stdout",
21
+ "-o settings.json # Dump to file",
22
+ "/path/to/mod-settings.dat -o out.json # Dump specific file"
23
+ ]
24
+
25
+ argument :settings_file, type: :string, required: false, desc: "Path to mod-settings.dat file"
26
+ option :output, type: :string, aliases: ["-o"], desc: "Output file path"
27
+
28
+ # Execute the dump command
29
+ #
30
+ # @param settings_file [String, nil] Path to mod-settings.dat file
31
+ # @param output [String, nil] Output file path
32
+ # @return [void]
33
+ def call(settings_file: nil, output: nil, **)
34
+ # Load MOD settings
35
+ settings_path = settings_file ? Pathname(settings_file) : runtime.mod_settings_path
36
+ settings = MODSettings.load(settings_path)
37
+
38
+ # Convert to JSON format
39
+ data = build_hash(settings)
40
+ output_string = JSON.pretty_generate(data)
41
+
42
+ # Write to output
43
+ if output
44
+ Pathname(output).write(output_string)
45
+ else
46
+ puts output_string
47
+ end
48
+ end
49
+
50
+ # Build hash from MODSettings for JSON output
51
+ #
52
+ # @param settings [Factorix::MODSettings] The MOD settings to convert
53
+ # @return [Hash] Hash representation of the settings
54
+ private def build_hash(settings)
55
+ result = {
56
+ "game_version" => settings.game_version.to_s
57
+ }
58
+
59
+ settings.each_section do |section|
60
+ section_hash = {}
61
+ section.each do |key, value|
62
+ section_hash[key] = convert_value_for_output(value)
63
+ end
64
+ result[section.name] = section_hash unless section_hash.empty?
65
+ end
66
+
67
+ result
68
+ end
69
+
70
+ # Convert value for JSON output (handle SignedInteger/UnsignedInteger)
71
+ #
72
+ # @param value [Object] The value to convert
73
+ # @return [Object] Converted value
74
+ private def convert_value_for_output(value)
75
+ case value
76
+ when SerDes::SignedInteger, SerDes::UnsignedInteger
77
+ # Integer(...) does not accept Integer instance
78
+ Integer(value.to_s, 10)
79
+ else
80
+ value
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Factorix
6
+ class CLI
7
+ module Commands
8
+ module MOD
9
+ module Settings
10
+ # Restore MOD settings from JSON format
11
+ class Restore < Base
12
+ require_game_stopped!
13
+ backup_support!
14
+
15
+ # @!parse
16
+ # # @return [Runtime::Base]
17
+ # attr_reader :runtime
18
+ include Import[:runtime]
19
+
20
+ desc "Restore MOD settings from JSON format"
21
+
22
+ example [
23
+ "-i settings.json # Restore from file",
24
+ " # Restore from stdin"
25
+ ]
26
+
27
+ argument :settings_file, type: :string, required: false, desc: "Path to mod-settings.dat file to write"
28
+ option :input, type: :string, aliases: ["-i"], desc: "Input file path"
29
+
30
+ # Execute the restore command
31
+ #
32
+ # @param input [String, nil] Path to JSON file
33
+ # @param settings_file [String, nil] Path to mod-settings.dat file
34
+ # @return [void]
35
+ def call(input: nil, settings_file: nil, **)
36
+ # Read input
37
+ if input
38
+ input_path = Pathname(input)
39
+ input_string = input_path.read
40
+ else
41
+ # Read from stdin
42
+ input_string = $stdin.read
43
+ end
44
+
45
+ # Parse input
46
+ data = JSON.parse(input_string)
47
+ settings = build_settings(data)
48
+
49
+ # Determine output path
50
+ output_path = settings_file ? Pathname(settings_file) : runtime.mod_settings_path
51
+
52
+ # Backup existing file if it exists
53
+ backup_if_exists(output_path)
54
+
55
+ # Save settings
56
+ settings.save(output_path)
57
+ end
58
+
59
+ # Build MODSettings from parsed JSON data
60
+ #
61
+ # @param data [Hash] Parsed JSON data
62
+ # @return [Factorix::MODSettings] The MOD settings
63
+ private def build_settings(data)
64
+ game_version = GameVersion.from_string(data["game_version"])
65
+ sections = {}
66
+
67
+ MODSettings::VALID_SECTIONS.each do |section_name|
68
+ section = MODSettings::Section.new(section_name)
69
+ if data.key?(section_name)
70
+ data[section_name].each do |key, value|
71
+ section[key] = convert_value_for_input(value)
72
+ end
73
+ end
74
+ sections[section_name] = section
75
+ end
76
+
77
+ MODSettings.new(game_version, sections)
78
+ end
79
+
80
+ # Convert value from JSON input (detect integer types)
81
+ #
82
+ # @param value [Object] The value to convert
83
+ # @return [Object] Converted value
84
+ # @note Factorio MOD settings use signed integers for int-setting type.
85
+ # Since JSON doesn't preserve signed/unsigned distinction,
86
+ # we use SignedInteger for all integer values.
87
+ # @see https://wiki.factorio.com/Tutorial:Mod_settings#int-setting
88
+ private def convert_value_for_input(value)
89
+ case value
90
+ when Integer
91
+ SerDes::SignedInteger.new(value)
92
+ else
93
+ value
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end