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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/completion/_factorix.bash +202 -0
- data/completion/_factorix.fish +197 -0
- data/completion/_factorix.zsh +376 -0
- data/doc/factorix.1 +377 -0
- data/exe/factorix +20 -0
- data/lib/factorix/api/category.rb +69 -0
- data/lib/factorix/api/image.rb +35 -0
- data/lib/factorix/api/license.rb +71 -0
- data/lib/factorix/api/mod_download_api.rb +66 -0
- data/lib/factorix/api/mod_info.rb +166 -0
- data/lib/factorix/api/mod_management_api.rb +237 -0
- data/lib/factorix/api/mod_portal_api.rb +204 -0
- data/lib/factorix/api/release.rb +49 -0
- data/lib/factorix/api/tag.rb +95 -0
- data/lib/factorix/api.rb +7 -0
- data/lib/factorix/api_credential.rb +54 -0
- data/lib/factorix/application.rb +218 -0
- data/lib/factorix/cache/file_system.rb +307 -0
- data/lib/factorix/cli/commands/backup_support.rb +46 -0
- data/lib/factorix/cli/commands/base.rb +90 -0
- data/lib/factorix/cli/commands/cache/evict.rb +180 -0
- data/lib/factorix/cli/commands/cache/stat.rb +201 -0
- data/lib/factorix/cli/commands/command_wrapper.rb +71 -0
- data/lib/factorix/cli/commands/completion.rb +83 -0
- data/lib/factorix/cli/commands/confirmable.rb +53 -0
- data/lib/factorix/cli/commands/download_support.rb +123 -0
- data/lib/factorix/cli/commands/launch.rb +79 -0
- data/lib/factorix/cli/commands/man.rb +29 -0
- data/lib/factorix/cli/commands/mod/check.rb +99 -0
- data/lib/factorix/cli/commands/mod/disable.rb +188 -0
- data/lib/factorix/cli/commands/mod/download.rb +291 -0
- data/lib/factorix/cli/commands/mod/edit.rb +114 -0
- data/lib/factorix/cli/commands/mod/enable.rb +216 -0
- data/lib/factorix/cli/commands/mod/image/add.rb +47 -0
- data/lib/factorix/cli/commands/mod/image/edit.rb +41 -0
- data/lib/factorix/cli/commands/mod/image/list.rb +74 -0
- data/lib/factorix/cli/commands/mod/install.rb +443 -0
- data/lib/factorix/cli/commands/mod/list.rb +372 -0
- data/lib/factorix/cli/commands/mod/search.rb +134 -0
- data/lib/factorix/cli/commands/mod/settings/dump.rb +88 -0
- data/lib/factorix/cli/commands/mod/settings/restore.rb +101 -0
- data/lib/factorix/cli/commands/mod/show.rb +202 -0
- data/lib/factorix/cli/commands/mod/sync.rb +299 -0
- data/lib/factorix/cli/commands/mod/uninstall.rb +325 -0
- data/lib/factorix/cli/commands/mod/update.rb +222 -0
- data/lib/factorix/cli/commands/mod/upload.rb +90 -0
- data/lib/factorix/cli/commands/path.rb +79 -0
- data/lib/factorix/cli/commands/requires_game_stopped.rb +32 -0
- data/lib/factorix/cli/commands/version.rb +25 -0
- data/lib/factorix/cli.rb +42 -0
- data/lib/factorix/dependency/edge.rb +89 -0
- data/lib/factorix/dependency/entry.rb +124 -0
- data/lib/factorix/dependency/graph/builder.rb +108 -0
- data/lib/factorix/dependency/graph.rb +210 -0
- data/lib/factorix/dependency/list.rb +244 -0
- data/lib/factorix/dependency/mod_version_requirement.rb +73 -0
- data/lib/factorix/dependency/node.rb +60 -0
- data/lib/factorix/dependency/parser.rb +148 -0
- data/lib/factorix/dependency/validation_result.rb +138 -0
- data/lib/factorix/dependency/validator.rb +190 -0
- data/lib/factorix/errors.rb +112 -0
- data/lib/factorix/formatting.rb +56 -0
- data/lib/factorix/game_version.rb +98 -0
- data/lib/factorix/http/cache_decorator.rb +106 -0
- data/lib/factorix/http/cached_response.rb +37 -0
- data/lib/factorix/http/client.rb +187 -0
- data/lib/factorix/http/response.rb +31 -0
- data/lib/factorix/http/retry_decorator.rb +59 -0
- data/lib/factorix/http/retry_strategy.rb +80 -0
- data/lib/factorix/info_json.rb +90 -0
- data/lib/factorix/installed_mod.rb +239 -0
- data/lib/factorix/mod.rb +55 -0
- data/lib/factorix/mod_list.rb +174 -0
- data/lib/factorix/mod_settings.rb +278 -0
- data/lib/factorix/mod_state.rb +34 -0
- data/lib/factorix/mod_version.rb +99 -0
- data/lib/factorix/portal.rb +185 -0
- data/lib/factorix/progress/download_handler.rb +46 -0
- data/lib/factorix/progress/multi_presenter.rb +45 -0
- data/lib/factorix/progress/presenter.rb +67 -0
- data/lib/factorix/progress/presenter_adapter.rb +46 -0
- data/lib/factorix/progress/scan_handler.rb +33 -0
- data/lib/factorix/progress/upload_handler.rb +33 -0
- data/lib/factorix/runtime/base.rb +233 -0
- data/lib/factorix/runtime/linux.rb +32 -0
- data/lib/factorix/runtime/mac_os.rb +53 -0
- data/lib/factorix/runtime/user_configurable.rb +69 -0
- data/lib/factorix/runtime/windows.rb +85 -0
- data/lib/factorix/runtime/wsl.rb +118 -0
- data/lib/factorix/runtime.rb +32 -0
- data/lib/factorix/save_file.rb +178 -0
- data/lib/factorix/ser_des/deserializer.rb +198 -0
- data/lib/factorix/ser_des/serializer.rb +231 -0
- data/lib/factorix/ser_des/signed_integer.rb +63 -0
- data/lib/factorix/ser_des/unsigned_integer.rb +65 -0
- data/lib/factorix/service_credential.rb +127 -0
- data/lib/factorix/transfer/downloader.rb +162 -0
- data/lib/factorix/transfer/uploader.rb +232 -0
- data/lib/factorix/version.rb +6 -0
- data/lib/factorix.rb +38 -0
- data/sig/dry/auto_inject.rbs +15 -0
- data/sig/dry/cli.rbs +19 -0
- data/sig/dry/configurable.rbs +13 -0
- data/sig/dry/core/container.rbs +17 -0
- data/sig/dry/events/publisher.rbs +22 -0
- data/sig/dry/logger.rbs +16 -0
- data/sig/factorix/api/category.rbs +15 -0
- data/sig/factorix/api/image.rbs +15 -0
- data/sig/factorix/api/license.rbs +20 -0
- data/sig/factorix/api/mod_download_api.rbs +18 -0
- data/sig/factorix/api/mod_info.rbs +67 -0
- data/sig/factorix/api/mod_management_api.rbs +25 -0
- data/sig/factorix/api/mod_portal_api.rbs +31 -0
- data/sig/factorix/api/release.rbs +27 -0
- data/sig/factorix/api/tag.rbs +15 -0
- data/sig/factorix/api.rbs +8 -0
- data/sig/factorix/api_credential.rbs +17 -0
- data/sig/factorix/application.rbs +86 -0
- data/sig/factorix/cache/file_system.rbs +35 -0
- data/sig/factorix/cli/commands/base.rbs +13 -0
- data/sig/factorix/cli/commands/cache/evict.rbs +17 -0
- data/sig/factorix/cli/commands/cache/stat.rbs +17 -0
- data/sig/factorix/cli/commands/command_wrapper.rbs +13 -0
- data/sig/factorix/cli/commands/completion/zsh.rbs +15 -0
- data/sig/factorix/cli/commands/confirmable.rbs +12 -0
- data/sig/factorix/cli/commands/download_support.rbs +12 -0
- data/sig/factorix/cli/commands/launch.rbs +15 -0
- data/sig/factorix/cli/commands/mod/check.rbs +18 -0
- data/sig/factorix/cli/commands/mod/disable.rbs +20 -0
- data/sig/factorix/cli/commands/mod/download.rbs +18 -0
- data/sig/factorix/cli/commands/mod/edit.rbs +30 -0
- data/sig/factorix/cli/commands/mod/enable.rbs +20 -0
- data/sig/factorix/cli/commands/mod/image/add.rbs +19 -0
- data/sig/factorix/cli/commands/mod/image/edit.rbs +19 -0
- data/sig/factorix/cli/commands/mod/image/list.rbs +19 -0
- data/sig/factorix/cli/commands/mod/install.rbs +19 -0
- data/sig/factorix/cli/commands/mod/list.rbs +30 -0
- data/sig/factorix/cli/commands/mod/search.rbs +18 -0
- data/sig/factorix/cli/commands/mod/settings/dump.rbs +17 -0
- data/sig/factorix/cli/commands/mod/settings/restore.rbs +17 -0
- data/sig/factorix/cli/commands/mod/sync.rbs +19 -0
- data/sig/factorix/cli/commands/mod/uninstall.rbs +20 -0
- data/sig/factorix/cli/commands/mod/update.rbs +19 -0
- data/sig/factorix/cli/commands/mod/upload.rbs +24 -0
- data/sig/factorix/cli/commands/path.rbs +18 -0
- data/sig/factorix/cli/commands/requires_game_stopped.rbs +13 -0
- data/sig/factorix/cli/commands/version.rbs +13 -0
- data/sig/factorix/cli.rbs +11 -0
- data/sig/factorix/dependency/edge.rbs +32 -0
- data/sig/factorix/dependency/entry.rbs +30 -0
- data/sig/factorix/dependency/graph/builder.rbs +17 -0
- data/sig/factorix/dependency/graph.rbs +39 -0
- data/sig/factorix/dependency/list.rbs +69 -0
- data/sig/factorix/dependency/mod_version_requirement.rbs +18 -0
- data/sig/factorix/dependency/node.rbs +24 -0
- data/sig/factorix/dependency/parser.rbs +11 -0
- data/sig/factorix/dependency/validation_result.rbs +56 -0
- data/sig/factorix/dependency/validator.rbs +13 -0
- data/sig/factorix/errors.rbs +132 -0
- data/sig/factorix/formatting.rbs +8 -0
- data/sig/factorix/game_version.rbs +24 -0
- data/sig/factorix/http/cache_decorator.rbs +64 -0
- data/sig/factorix/http/client.rbs +55 -0
- data/sig/factorix/http/response.rbs +28 -0
- data/sig/factorix/http/retry_decorator.rbs +44 -0
- data/sig/factorix/http/retry_strategy.rbs +42 -0
- data/sig/factorix/info_json.rbs +19 -0
- data/sig/factorix/installed_mod.rbs +34 -0
- data/sig/factorix/mod.rbs +20 -0
- data/sig/factorix/mod_list.rbs +44 -0
- data/sig/factorix/mod_settings.rbs +47 -0
- data/sig/factorix/mod_state.rbs +18 -0
- data/sig/factorix/mod_version.rbs +23 -0
- data/sig/factorix/portal.rbs +37 -0
- data/sig/factorix/progress/download_handler.rbs +19 -0
- data/sig/factorix/progress/multi_presenter.rbs +15 -0
- data/sig/factorix/progress/presenter.rbs +17 -0
- data/sig/factorix/progress/presenter_adapter.rbs +17 -0
- data/sig/factorix/progress/scan_handler.rbs +16 -0
- data/sig/factorix/progress/upload_handler.rbs +17 -0
- data/sig/factorix/runtime/base.rbs +45 -0
- data/sig/factorix/runtime/linux.rbs +15 -0
- data/sig/factorix/runtime/mac_os.rbs +15 -0
- data/sig/factorix/runtime/user_configurable.rbs +13 -0
- data/sig/factorix/runtime/windows.rbs +23 -0
- data/sig/factorix/runtime/wsl.rbs +19 -0
- data/sig/factorix/runtime.rbs +9 -0
- data/sig/factorix/save_file.rbs +40 -0
- data/sig/factorix/ser_des/deserializer.rbs +49 -0
- data/sig/factorix/ser_des/serializer.rbs +45 -0
- data/sig/factorix/ser_des/signed_integer.rbs +37 -0
- data/sig/factorix/ser_des/unsigned_integer.rbs +37 -0
- data/sig/factorix/service_credential.rbs +19 -0
- data/sig/factorix/transfer/downloader.rbs +15 -0
- data/sig/factorix/transfer/uploader.rbs +21 -0
- data/sig/factorix.rbs +9 -0
- data/sig/tty/progressbar.rbs +18 -0
- 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
|