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