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