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,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ module MOD
7
+ module Image
8
+ # Edit MOD's image list on Factorio MOD Portal
9
+ class Edit < Base
10
+ # @!parse
11
+ # # @return [Portal]
12
+ # attr_reader :portal
13
+ include Import[:portal]
14
+
15
+ desc "Edit MOD's image list (reorder/remove images)"
16
+
17
+ example [
18
+ "some-mod abc123 def456 # Set image order (IDs from 'image list')"
19
+ ]
20
+
21
+ argument :mod_name, type: :string, required: true, desc: "MOD name"
22
+ argument :image_ids, type: :array, required: true, desc: "Image IDs in desired order"
23
+
24
+ # Execute the edit command
25
+ #
26
+ # @param mod_name [String] the MOD name
27
+ # @param image_ids [Array<String>] array of image IDs
28
+ # @return [void]
29
+ def call(mod_name:, image_ids:, **)
30
+ # Edit images via Portal
31
+ portal.edit_mod_images(mod_name, image_ids)
32
+
33
+ say "Image list updated successfully!", prefix: :success
34
+ say "Total images: #{image_ids.size}", prefix: :info
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ module MOD
7
+ module Image
8
+ # List images for a MOD on Factorio MOD Portal
9
+ class List < Base
10
+ # @!parse
11
+ # # @return [Portal]
12
+ # attr_reader :portal
13
+ include Import[:portal]
14
+
15
+ desc "List images for a MOD"
16
+
17
+ example [
18
+ "some-mod # List images in table format",
19
+ "some-mod --json # List images in JSON format"
20
+ ]
21
+
22
+ argument :mod_name, type: :string, required: true, desc: "MOD name"
23
+
24
+ option :json, type: :flag, default: false, desc: "Output in JSON format"
25
+
26
+ # Execute the list command
27
+ #
28
+ # @param mod_name [String] the MOD name
29
+ # @param json [Boolean] output in JSON format
30
+ # @return [void]
31
+ def call(mod_name:, json:, **)
32
+ # Get full MOD info to retrieve images
33
+ mod_info = portal.get_mod_full(mod_name)
34
+
35
+ images = if mod_info.detail&.images&.any?
36
+ mod_info.detail.images.map do |image|
37
+ {
38
+ id: image.id,
39
+ thumbnail: image.thumbnail.to_s,
40
+ url: image.url.to_s
41
+ }
42
+ end
43
+ else
44
+ []
45
+ end
46
+
47
+ if json
48
+ puts JSON.pretty_generate(images)
49
+ else
50
+ output_table(images)
51
+ end
52
+ end
53
+
54
+ private def output_table(images)
55
+ if images.empty?
56
+ say "No images found", prefix: :info
57
+ return
58
+ end
59
+
60
+ id_width = [images.map {|i| i[:id].length }.max, 2].max
61
+ thumb_width = [images.map {|i| i[:thumbnail].length }.max, 9].max
62
+
63
+ puts "%-#{id_width}s %-#{thumb_width}s %s" % %w[ID THUMBNAIL URL]
64
+
65
+ images.each do |image|
66
+ puts "%-#{id_width}s %-#{thumb_width}s %s" % [image[:id], image[:thumbnail], image[:url]]
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,443 @@
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
+ # Install MODs from Factorio MOD Portal
11
+ class Install < Base
12
+ confirmable!
13
+ require_game_stopped!
14
+ backup_support!
15
+
16
+ include DownloadSupport
17
+
18
+ # @!parse
19
+ # # @return [Portal]
20
+ # attr_reader :portal
21
+ # # @return [Dry::Logger::Dispatcher]
22
+ # attr_reader :logger
23
+ # # @return [Factorix::Runtime]
24
+ # attr_reader :runtime
25
+ include Import[:portal, :logger, :runtime]
26
+
27
+ desc "Install MOD(s) from Factorio MOD Portal (downloads to MOD directory and enables)"
28
+
29
+ example [
30
+ "some-mod # Install latest version",
31
+ "some-mod@1.2.0 # Install specific version",
32
+ "some-mod@latest # Install latest version explicitly",
33
+ "-j 8 mod-a mod-b # Use 8 parallel downloads"
34
+ ]
35
+
36
+ argument :mod_specs, type: :array, required: true, desc: "MOD specifications (name@version or name@latest or name)"
37
+ option :jobs, type: :integer, aliases: ["-j"], default: 4, desc: "Number of parallel downloads"
38
+
39
+ # Execute the install command
40
+ #
41
+ # @param mod_specs [Array<String>] MOD specifications
42
+ # @param jobs [Integer] Number of parallel downloads
43
+ # @return [void]
44
+ def call(mod_specs:, jobs: 4, **)
45
+ # Load current state (without validation to allow fixing issues)
46
+ mod_list = MODList.load
47
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Scanning MOD(s)", output: $stderr)
48
+ handler = Progress::ScanHandler.new(presenter)
49
+ installed_mods = InstalledMOD.all(handler:)
50
+ graph = Dependency::Graph::Builder.build(installed_mods:, mod_list:)
51
+
52
+ raise DirectoryNotFoundError, "MOD directory does not exist: #{runtime.mod_dir}" unless runtime.mod_dir.exist?
53
+
54
+ # Plan installation (fetch info, extend graph, validate)
55
+ install_targets = plan_installation(mod_specs, graph, jobs)
56
+
57
+ if install_targets.empty?
58
+ say "All specified MOD(s) are already installed and enabled", prefix: :info
59
+ return
60
+ end
61
+
62
+ # Show plan
63
+ show_plan(install_targets)
64
+ return unless confirm?("Do you want to proceed?")
65
+
66
+ # Execute installation
67
+ execute_installation(install_targets, graph, mod_list, jobs)
68
+
69
+ # Save mod-list.json
70
+ backup_if_exists(runtime.mod_list_path)
71
+ mod_list.save
72
+
73
+ install_count = install_targets.count {|t| t[:operation] == :install }
74
+ enable_count = install_targets.count {|t| t[:operation] == :enable }
75
+
76
+ if install_count > 0
77
+ say "Installed #{install_count} MOD(s)", prefix: :success
78
+ end
79
+ if enable_count > 0
80
+ say "Enabled #{enable_count} disabled dependency MOD(s)", prefix: :success
81
+ end
82
+ say "Saved mod-list.json", prefix: :success
83
+ logger.debug("Saved mod-list.json")
84
+ end
85
+
86
+ # Mark disabled dependencies for enabling
87
+ #
88
+ # Recursively traverses required dependencies and marks disabled MODs for enabling.
89
+ #
90
+ # @param graph [Dependency::Graph] The dependency graph
91
+ # @return [void]
92
+ def mark_disabled_dependencies_for_enable(graph)
93
+ # Find all MODs that will be installed or enabled
94
+ target_operations = %i[install enable]
95
+ mods_to_process = graph.nodes.filter_map {|node| node.mod if target_operations.include?(node.operation) }
96
+
97
+ processed = Set.new
98
+
99
+ until mods_to_process.empty?
100
+ mod = mods_to_process.shift
101
+ next if processed.include?(mod)
102
+
103
+ processed.add(mod)
104
+
105
+ graph.edges_from(mod).each do |edge|
106
+ next unless edge.required?
107
+
108
+ dep_node = graph.node(edge.to_mod)
109
+ next unless dep_node
110
+
111
+ # Skip if already has an operation or is enabled
112
+ next if dep_node.operation
113
+ next if dep_node.enabled?
114
+
115
+ # Mark for enabling if installed but disabled
116
+ next unless dep_node.installed?
117
+
118
+ graph.set_node_operation(edge.to_mod, :enable)
119
+ mods_to_process << edge.to_mod
120
+ end
121
+ end
122
+ end
123
+ # Plan the installation by fetching MOD info and extending the graph
124
+ #
125
+ # @param mod_specs [Array<String>] MOD specifications
126
+ # @param graph [Dependency::Graph] Current dependency graph
127
+ # @param jobs [Integer] Number of parallel jobs
128
+ # @return [Array<Hash>] Installation targets with MOD info and releases
129
+ private def plan_installation(mod_specs, graph, jobs)
130
+ # Create progress presenter for info fetching
131
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Fetching MOD info", output: $stderr)
132
+
133
+ # Phase 1: Fetch info for target MODs
134
+ target_infos = fetch_target_mod_info(mod_specs, jobs, presenter)
135
+
136
+ # Phase 2: Recursively resolve dependencies and extend graph
137
+ all_mod_infos = resolve_dependencies_with_graph(target_infos, graph, jobs, presenter)
138
+
139
+ # Phase 3: Mark disabled dependencies for enabling
140
+ mark_disabled_dependencies_for_enable(graph)
141
+
142
+ # Phase 4: Validate graph (cycles, conflicts)
143
+ validate_installation_graph(graph)
144
+
145
+ # Phase 5: Extract install targets from graph
146
+ extract_install_targets(graph, all_mod_infos)
147
+ end
148
+
149
+ # Fetch MOD information for target specifications
150
+ #
151
+ # @param mod_specs [Array<String>] MOD specifications
152
+ # @param jobs [Integer] Number of parallel jobs
153
+ # @param presenter [Progress::Presenter] Progress presenter
154
+ # @return [Array<Hash>] Array of {mod_spec:, mod_info:, release:}
155
+ private def fetch_target_mod_info(mod_specs, jobs, presenter)
156
+ presenter.start
157
+
158
+ pool = Concurrent::FixedThreadPool.new(jobs)
159
+
160
+ futures = mod_specs.map {|mod_spec|
161
+ Concurrent::Future.execute(executor: pool) do
162
+ result = fetch_single_mod_info(mod_spec)
163
+ presenter.update
164
+ result
165
+ end
166
+ }
167
+
168
+ results = futures.map(&:value!)
169
+ results
170
+ ensure
171
+ pool&.shutdown
172
+ pool&.wait_for_termination
173
+ end
174
+
175
+ # Fetch information for a single MOD specification
176
+ #
177
+ # @param mod_spec [String] MOD specification (name@version or name)
178
+ # @return [Hash] {mod:, mod_name:, mod_info:, release:, version:}
179
+ private def fetch_single_mod_info(mod_spec)
180
+ parsed = parse_mod_spec(mod_spec)
181
+ mod = parsed[:mod]
182
+ version = parsed[:version]
183
+
184
+ mod_info = portal.get_mod_full(mod.name)
185
+ release = find_release(mod_info, version)
186
+
187
+ version_display = version == :latest ? "latest" : version.to_s
188
+ raise MODNotOnPortalError, "Release not found for #{mod}@#{version_display}" unless release
189
+
190
+ {mod:, mod_name: mod.name, mod_info:, release:, version:}
191
+ end
192
+
193
+ # Recursively resolve dependencies and extend the graph
194
+ #
195
+ # @param target_infos [Array<Hash>] Initial target MOD infos
196
+ # @param graph [Dependency::Graph] Graph to extend
197
+ # @param jobs [Integer] Number of parallel jobs
198
+ # @param presenter [Progress::Presenter] Progress presenter
199
+ # @return [Hash<String, Hash>] All MOD infos by name
200
+ private def resolve_dependencies_with_graph(target_infos, graph, jobs, presenter)
201
+ all_mod_infos = {}
202
+ to_process = []
203
+
204
+ # Add target MODs to graph and processing queue
205
+ target_infos.each do |info|
206
+ all_mod_infos[info[:mod_name]] = info
207
+ graph.add_uninstalled_mod(info[:mod_info], info[:release])
208
+ to_process << info[:mod_name]
209
+ end
210
+
211
+ # Process dependencies recursively
212
+ processed = Set.new
213
+
214
+ until to_process.empty?
215
+ # Get next batch of MODs to process
216
+ current_batch = to_process.shift(jobs)
217
+ current_batch.reject! {|mod_name| processed.include?(mod_name) }
218
+ break if current_batch.empty?
219
+
220
+ # Find dependencies for current batch
221
+ new_dependencies = []
222
+ current_batch.each do |mod_name|
223
+ processed.add(mod_name)
224
+
225
+ node = graph.node(Factorix::MOD[name: mod_name])
226
+ next unless node
227
+
228
+ # Find dependencies that aren't in graph yet
229
+ # Only process required dependencies - skip optional, hidden, load_neutral, and incompatible
230
+ graph.edges_from(node.mod).each do |edge|
231
+ next unless edge.required?
232
+
233
+ dep_mod = edge.to_mod
234
+
235
+ next if graph.node?(dep_mod)
236
+
237
+ # Need to fetch this dependency
238
+ new_dependencies << {mod: dep_mod, version_requirement: edge.version_requirement, required_by: mod_name}
239
+ end
240
+ end
241
+
242
+ # Fetch info for new dependencies
243
+ next if new_dependencies.empty?
244
+
245
+ # Increase progress bar total for newly discovered dependencies
246
+ presenter.increase_total(new_dependencies.size)
247
+
248
+ fetch_and_add_dependencies(new_dependencies, graph, all_mod_infos, jobs, presenter)
249
+
250
+ # Add newly added MODs to processing queue
251
+ new_dependencies.each do |dep|
252
+ to_process << dep[:mod].name unless processed.include?(dep[:mod].name)
253
+ end
254
+ end
255
+
256
+ all_mod_infos
257
+ end
258
+
259
+ # Fetch and add dependencies to the graph
260
+ #
261
+ # @param dependencies [Array<Hash>] Dependencies to fetch
262
+ # @param graph [Dependency::Graph] Graph to extend
263
+ # @param all_mod_infos [Hash] Accumulator for all MOD infos
264
+ # @param jobs [Integer] Number of parallel jobs
265
+ # @param presenter [Progress::Presenter] Progress presenter
266
+ # @return [void]
267
+ private def fetch_and_add_dependencies(dependencies, graph, all_mod_infos, jobs, presenter)
268
+ pool = Concurrent::FixedThreadPool.new(jobs)
269
+
270
+ futures = dependencies.map {|dep|
271
+ Concurrent::Future.execute(executor: pool) do
272
+ mod_info = portal.get_mod_full(dep[:mod].name)
273
+ release = find_compatible_release(mod_info, dep[:version_requirement])
274
+
275
+ unless release
276
+ # Skip dependencies without compatible releases (e.g., all releases have invalid versions)
277
+ logger.warn("Skipping dependency #{dep[:mod]} (required by #{dep[:required_by]}): No compatible release found")
278
+ presenter.update
279
+ next nil
280
+ end
281
+
282
+ presenter.update
283
+
284
+ {mod_name: dep[:mod].name, mod_info:, release:}
285
+ rescue HTTPClientError => e
286
+ # Skip dependencies that cannot be found (404, etc.)
287
+ logger.warn("Skipping dependency #{dep[:mod]} (required by #{dep[:required_by]}): #{e.message}")
288
+ presenter.update
289
+ nil
290
+ rescue JSON::ParserError
291
+ # Skip dependencies with invalid/empty API responses
292
+ logger.warn("Skipping dependency #{dep[:mod]} (required by #{dep[:required_by]}): Invalid API response")
293
+ presenter.update
294
+ nil
295
+ end
296
+ }
297
+
298
+ results = futures.filter_map(&:value!)
299
+
300
+ # Add to graph
301
+ results.each do |result|
302
+ all_mod_infos[result[:mod_name]] = result
303
+ graph.add_uninstalled_mod(result[:mod_info], result[:release])
304
+ end
305
+ ensure
306
+ pool&.shutdown
307
+ pool&.wait_for_termination
308
+ end
309
+
310
+ # Validate the installation graph
311
+ #
312
+ # @param graph [Dependency::Graph] Graph to validate
313
+ # @return [void]
314
+ # @raise [CircularDependencyError] if circular dependency detected
315
+ # @raise [MODConflictError] if MOD conflicts with enabled MOD
316
+
317
+ private def validate_installation_graph(graph)
318
+ # Check for cycles
319
+ if graph.cyclic?
320
+ # Get strongly connected components (cycles)
321
+ cycles = graph.strongly_connected_components.select {|component| component.size > 1 }
322
+
323
+ logger.error("Circular dependency detected. Cycles found:")
324
+ cycles.each do |cycle|
325
+ logger.error(" Cycle: #{cycle.join(" <-> ")}")
326
+ end
327
+
328
+ raise CircularDependencyError, "Circular dependency detected in MOD(s) to install"
329
+ end
330
+
331
+ graph.nodes.each do |node|
332
+ next unless node.operation == :install
333
+
334
+ graph.edges_from(node.mod).each do |edge|
335
+ next unless edge.incompatible?
336
+
337
+ target_node = graph.node(edge.to_mod)
338
+ if target_node&.enabled?
339
+ raise MODConflictError,
340
+ "Cannot install #{node.mod}: it conflicts with enabled MOD #{edge.to_mod}"
341
+ end
342
+ end
343
+ end
344
+ end
345
+
346
+ # Extract install targets from the graph
347
+ #
348
+ # @param graph [Dependency::Graph] Graph with install operations
349
+ # @param all_mod_infos [Hash] All MOD infos by name
350
+ # @return [Array<Hash>] Install targets
351
+ private def extract_install_targets(graph, all_mod_infos)
352
+ # Filter MODs marked for installation or enabling
353
+ graph.nodes.filter_map {|node|
354
+ if node.operation == :install
355
+ info = all_mod_infos[node.mod.name]
356
+ unless info
357
+ logger.warn("No info found for #{node.mod}, skipping")
358
+ next
359
+ end
360
+
361
+ {
362
+ mod: node.mod,
363
+ operation: :install,
364
+ mod_info: info[:mod_info],
365
+ release: info[:release],
366
+ output_path: runtime.mod_dir / info[:release].file_name,
367
+ category: info[:mod_info].category
368
+ }
369
+ elsif node.operation == :enable
370
+ {
371
+ mod: node.mod,
372
+ operation: :enable
373
+ }
374
+ end
375
+ }
376
+ end
377
+
378
+ # Show the installation plan
379
+ #
380
+ # @param targets [Array<Hash>] Installation targets
381
+ # @return [void]
382
+ private def show_plan(targets)
383
+ install_targets = targets.select {|t| t[:operation] == :install }
384
+ enable_targets = targets.select {|t| t[:operation] == :enable }
385
+
386
+ if install_targets.any?
387
+ say "Planning to install #{install_targets.size} MOD(s):", prefix: :info
388
+ install_targets.each do |target|
389
+ say " - #{target[:mod]}@#{target[:release].version}"
390
+ end
391
+ end
392
+
393
+ return if enable_targets.none?
394
+
395
+ say "Planning to enable #{enable_targets.size} disabled dependency MOD(s):", prefix: :info
396
+ enable_targets.each do |target|
397
+ say " - #{target[:mod]}"
398
+ end
399
+ end
400
+
401
+ # Execute the installation
402
+ #
403
+ # @param targets [Array<Hash>] Installation targets
404
+ # @param graph [Dependency::Graph] Dependency graph
405
+ # @param mod_list [MODList] MOD list
406
+ # @param jobs [Integer] Number of parallel jobs
407
+ # @return [void]
408
+ private def execute_installation(targets, _graph, mod_list, jobs)
409
+ # Download MODs that need to be installed (not just enabled)
410
+ install_targets = targets.select {|t| t[:operation] == :install }
411
+ download_mods(install_targets, jobs) unless install_targets.empty?
412
+
413
+ # Add/enable all MODs in mod-list.json
414
+ targets.each do |target|
415
+ mod = target[:mod]
416
+
417
+ case target[:operation]
418
+ when :install
419
+ if mod_list.exist?(mod)
420
+ unless mod_list.enabled?(mod)
421
+ mod_list.enable(mod)
422
+ say "Enabled #{mod} in mod-list.json", prefix: :success
423
+ logger.debug("Enabled in mod-list.json", mod_name: mod.name)
424
+ end
425
+ else
426
+ mod_list.add(mod, enabled: true)
427
+ say "Added #{mod} to mod-list.json", prefix: :success
428
+ logger.debug("Added to mod-list.json", mod_name: mod.name)
429
+ end
430
+ when :enable
431
+ mod_list.enable(mod)
432
+ say "Enabled dependency #{mod} in mod-list.json", prefix: :success
433
+ logger.debug("Enabled dependency in mod-list.json", mod_name: mod.name)
434
+ else
435
+ logger.warn("Unknown operation #{target[:operation]} for #{mod}")
436
+ end
437
+ end
438
+ end
439
+ end
440
+ end
441
+ end
442
+ end
443
+ end