factorix 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +1 -1
  4. data/exe/factorix +17 -0
  5. data/lib/factorix/api/mod_download_api.rb +11 -6
  6. data/lib/factorix/api/mod_info.rb +2 -2
  7. data/lib/factorix/api/mod_management_api.rb +1 -1
  8. data/lib/factorix/api/mod_portal_api.rb +6 -49
  9. data/lib/factorix/api_credential.rb +1 -1
  10. data/lib/factorix/cache/base.rb +116 -0
  11. data/lib/factorix/cache/entry.rb +25 -0
  12. data/lib/factorix/cache/file_system.rb +137 -57
  13. data/lib/factorix/cache/redis.rb +287 -0
  14. data/lib/factorix/cache/s3.rb +388 -0
  15. data/lib/factorix/cli/commands/backup_support.rb +1 -1
  16. data/lib/factorix/cli/commands/base.rb +3 -3
  17. data/lib/factorix/cli/commands/cache/evict.rb +19 -24
  18. data/lib/factorix/cli/commands/cache/stat.rb +66 -67
  19. data/lib/factorix/cli/commands/command_wrapper.rb +5 -5
  20. data/lib/factorix/cli/commands/completion.rb +1 -2
  21. data/lib/factorix/cli/commands/confirmable.rb +1 -1
  22. data/lib/factorix/cli/commands/download_support.rb +2 -7
  23. data/lib/factorix/cli/commands/mod/check.rb +1 -1
  24. data/lib/factorix/cli/commands/mod/disable.rb +1 -1
  25. data/lib/factorix/cli/commands/mod/download.rb +7 -7
  26. data/lib/factorix/cli/commands/mod/edit.rb +10 -13
  27. data/lib/factorix/cli/commands/mod/enable.rb +1 -1
  28. data/lib/factorix/cli/commands/mod/image/add.rb +3 -6
  29. data/lib/factorix/cli/commands/mod/image/edit.rb +2 -5
  30. data/lib/factorix/cli/commands/mod/image/list.rb +5 -8
  31. data/lib/factorix/cli/commands/mod/install.rb +7 -7
  32. data/lib/factorix/cli/commands/mod/list.rb +7 -7
  33. data/lib/factorix/cli/commands/mod/search.rb +13 -12
  34. data/lib/factorix/cli/commands/mod/settings/dump.rb +3 -3
  35. data/lib/factorix/cli/commands/mod/settings/restore.rb +2 -2
  36. data/lib/factorix/cli/commands/mod/show.rb +22 -23
  37. data/lib/factorix/cli/commands/mod/sync.rb +8 -8
  38. data/lib/factorix/cli/commands/mod/uninstall.rb +1 -1
  39. data/lib/factorix/cli/commands/mod/update.rb +11 -43
  40. data/lib/factorix/cli/commands/mod/upload.rb +7 -10
  41. data/lib/factorix/cli/commands/path.rb +2 -2
  42. data/lib/factorix/cli/commands/portal_support.rb +27 -0
  43. data/lib/factorix/cli/commands/version.rb +1 -1
  44. data/lib/factorix/container.rb +155 -0
  45. data/lib/factorix/dependency/parser.rb +1 -1
  46. data/lib/factorix/errors.rb +3 -0
  47. data/lib/factorix/http/cache_decorator.rb +5 -5
  48. data/lib/factorix/http/client.rb +3 -3
  49. data/lib/factorix/info_json.rb +7 -7
  50. data/lib/factorix/mod_list.rb +2 -2
  51. data/lib/factorix/mod_settings.rb +2 -2
  52. data/lib/factorix/portal.rb +3 -2
  53. data/lib/factorix/runtime/user_configurable.rb +9 -9
  54. data/lib/factorix/service_credential.rb +3 -3
  55. data/lib/factorix/transfer/downloader.rb +19 -11
  56. data/lib/factorix/version.rb +1 -1
  57. data/lib/factorix.rb +110 -1
  58. data/sig/factorix/api/mod_download_api.rbs +1 -2
  59. data/sig/factorix/cache/base.rbs +28 -0
  60. data/sig/factorix/cache/entry.rbs +14 -0
  61. data/sig/factorix/cache/file_system.rbs +7 -6
  62. data/sig/factorix/cache/redis.rbs +36 -0
  63. data/sig/factorix/cache/s3.rbs +38 -0
  64. data/sig/factorix/container.rbs +15 -0
  65. data/sig/factorix/errors.rbs +3 -0
  66. data/sig/factorix/portal.rbs +1 -1
  67. data/sig/factorix.rbs +99 -0
  68. metadata +27 -4
  69. data/lib/factorix/application.rb +0 -218
  70. data/sig/factorix/application.rbs +0 -86
@@ -8,12 +8,11 @@ module Factorix
8
8
  module MOD
9
9
  # Search MODs on Factorio MOD Portal
10
10
  class Search < Base
11
+ include PortalSupport
11
12
  # @!parse
12
- # # @return [Portal]
13
- # attr_reader :portal
14
13
  # # @return [Runtime]
15
14
  # attr_reader :runtime
16
- include Import[:portal, :runtime]
15
+ include Import[:runtime]
17
16
 
18
17
  desc "Search MOD(s) on Factorio MOD Portal"
19
18
 
@@ -29,11 +28,11 @@ module Factorix
29
28
  argument :mod_names, type: :array, required: false, default: [], desc: "MOD names to search"
30
29
 
31
30
  option :hide_deprecated, type: :boolean, default: true, desc: "Hide deprecated MOD(s)"
32
- option :page, type: :integer, default: 1, desc: "Page number"
33
- option :page_size, type: :integer, default: 25, desc: "Results per page (max 500)"
34
- option :sort, type: :string, values: %w[name created_at updated_at], desc: "Sort field"
35
- option :sort_order, type: :string, values: %w[asc desc], desc: "Sort order"
36
- option :version, type: :string, desc: "Filter by Factorio version (default: installed version)"
31
+ option :page, default: "1", desc: "Page number"
32
+ option :page_size, default: "25", desc: "Results per page (max 500)"
33
+ option :sort, values: %w[name created_at updated_at], desc: "Sort field"
34
+ option :sort_order, values: %w[asc desc], desc: "Sort order"
35
+ option :version, desc: "Filter by Factorio version (default: installed version)"
37
36
  option :json, type: :flag, default: false, desc: "Output in JSON format"
38
37
 
39
38
  # Execute the search command
@@ -47,7 +46,9 @@ module Factorix
47
46
  # @param version [String, nil] Factorio version filter
48
47
  # @param json [Boolean] Output in JSON format
49
48
  # @return [void]
50
- def call(mod_names: [], hide_deprecated: true, page: 1, page_size: 25, sort: nil, sort_order: nil, version: nil, json: false, **)
49
+ def call(mod_names: [], hide_deprecated: true, page: "1", page_size: "25", sort: nil, sort_order: nil, version: nil, json: false, **)
50
+ page = Integer(page)
51
+ page_size = Integer(page_size)
51
52
  version ||= default_factorio_version
52
53
 
53
54
  mods = portal.list_mods(*mod_names, hide_deprecated: hide_deprecated || nil, page:, page_size:, sort:, sort_order:, version:)
@@ -60,7 +61,7 @@ module Factorix
60
61
  end
61
62
 
62
63
  private def output_json(mods)
63
- puts JSON.pretty_generate(mods.map {|mod| mod_to_hash(mod) })
64
+ out.puts JSON.pretty_generate(mods.map {|mod| mod_to_hash(mod) })
64
65
  end
65
66
 
66
67
  private def mod_to_hash(mod)
@@ -99,10 +100,10 @@ module Factorix
99
100
  headers = %w[NAME TITLE CATEGORY OWNER LATEST]
100
101
  widths = headers.map.with_index {|h, i| [h.length, *rows.map {|r| r[i].to_s.length }].max }
101
102
 
102
- puts format_table_row(headers, widths)
103
+ out.puts format_table_row(headers, widths)
103
104
 
104
105
  rows.each do |row|
105
- puts format_table_row(row, widths)
106
+ out.puts format_table_row(row, widths)
106
107
  end
107
108
 
108
109
  say "#{mods.size} MOD(s) found", prefix: :info
@@ -22,8 +22,8 @@ module Factorix
22
22
  "/path/to/mod-settings.dat -o out.json # Dump specific file"
23
23
  ]
24
24
 
25
- argument :settings_file, type: :string, required: false, desc: "Path to mod-settings.dat file"
26
- option :output, type: :string, aliases: ["-o"], desc: "Output file path"
25
+ argument :settings_file, required: false, desc: "Path to mod-settings.dat file"
26
+ option :output, aliases: ["-o"], desc: "Output file path"
27
27
 
28
28
  # Execute the dump command
29
29
  #
@@ -43,7 +43,7 @@ module Factorix
43
43
  if output
44
44
  Pathname(output).write(output_string)
45
45
  else
46
- puts output_string
46
+ out.puts output_string
47
47
  end
48
48
  end
49
49
 
@@ -24,8 +24,8 @@ module Factorix
24
24
  " # Restore from stdin"
25
25
  ]
26
26
 
27
- argument :settings_file, type: :string, required: false, desc: "Path to mod-settings.dat file to write"
28
- option :input, type: :string, aliases: ["-i"], desc: "Input file path"
27
+ argument :settings_file, required: false, desc: "Path to mod-settings.dat file to write"
28
+ option :input, aliases: ["-i"], desc: "Input file path"
29
29
 
30
30
  # Execute the restore command
31
31
  #
@@ -20,11 +20,10 @@ module Factorix
20
20
  INCOMPATIBLE_MOD_STYLE = TIntMe[:red]
21
21
  private_constant :INCOMPATIBLE_MOD_STYLE
22
22
  # @!parse
23
- # # @return [Portal]
24
- # attr_reader :portal
25
23
  # # @return [Runtime]
26
24
  # attr_reader :runtime
27
- include Import[:portal, :runtime]
25
+ include Import[:runtime]
26
+ include PortalSupport
28
27
 
29
28
  desc "Show MOD details from Factorio MOD Portal"
30
29
 
@@ -32,7 +31,7 @@ module Factorix
32
31
  "some-mod # Show details for some-mod"
33
32
  ]
34
33
 
35
- argument :mod_name, type: :string, required: true, desc: "MOD name to show"
34
+ argument :mod_name, required: true, desc: "MOD name to show"
36
35
 
37
36
  # Execute the show command
38
37
  #
@@ -75,10 +74,10 @@ module Factorix
75
74
  end
76
75
 
77
76
  private def display_header(mod_info)
78
- puts TITLE_STYLE[mod_info.title]
79
- puts
80
- puts mod_info.summary unless mod_info.summary.empty?
81
- puts
77
+ out.puts TITLE_STYLE[mod_info.title]
78
+ out.puts
79
+ out.puts mod_info.summary unless mod_info.summary.empty?
80
+ out.puts
82
81
  end
83
82
 
84
83
  private def display_basic_info(mod_info, local_status)
@@ -103,9 +102,9 @@ module Factorix
103
102
 
104
103
  max_label_width = rows.map {|label, _| label.length }.max
105
104
  rows.each do |label, value|
106
- puts "#{label.ljust(max_label_width)} #{value}"
105
+ out.puts "#{label.ljust(max_label_width)} #{value}"
107
106
  end
108
- puts
107
+ out.puts
109
108
  end
110
109
 
111
110
  private def format_status(local_status)
@@ -123,18 +122,18 @@ module Factorix
123
122
  end
124
123
 
125
124
  private def display_links(mod_info)
126
- puts HEADER_STYLE["Links"]
127
- puts " MOD Portal: https://mods.factorio.com/mod/#{mod_info.name}"
125
+ out.puts HEADER_STYLE["Links"]
126
+ out.puts " MOD Portal: https://mods.factorio.com/mod/#{mod_info.name}"
128
127
 
129
128
  if mod_info.detail
130
129
  if mod_info.detail.source_url
131
- puts " Source: #{mod_info.detail.source_url}"
130
+ out.puts " Source: #{mod_info.detail.source_url}"
132
131
  end
133
132
  if mod_info.detail.homepage
134
- puts " Homepage: #{mod_info.detail.homepage}"
133
+ out.puts " Homepage: #{mod_info.detail.homepage}"
135
134
  end
136
135
  end
137
- puts
136
+ out.puts
138
137
  end
139
138
 
140
139
  private def display_dependencies(mod_info)
@@ -149,16 +148,16 @@ module Factorix
149
148
  optional = parsed.select {|d| d[:type] == :optional }
150
149
 
151
150
  unless required.empty?
152
- puts HEADER_STYLE["Dependencies"]
151
+ out.puts HEADER_STYLE["Dependencies"]
153
152
  required.each {|dep| display_dependency(dep) }
154
- puts
153
+ out.puts
155
154
  end
156
155
 
157
156
  return if optional.empty?
158
157
 
159
- puts HEADER_STYLE["Optional Dependencies"]
158
+ out.puts HEADER_STYLE["Optional Dependencies"]
160
159
  optional.each {|dep| display_dependency(dep) }
161
- puts
160
+ out.puts
162
161
  end
163
162
 
164
163
  private def parse_dependency(dep_str)
@@ -178,7 +177,7 @@ module Factorix
178
177
  end
179
178
 
180
179
  private def display_dependency(dep)
181
- puts " #{dep[:spec]}"
180
+ out.puts " #{dep[:spec]}"
182
181
  end
183
182
 
184
183
  private def display_incompatibilities(mod_info)
@@ -191,9 +190,9 @@ module Factorix
191
190
 
192
191
  return if incompatible.empty?
193
192
 
194
- puts HEADER_STYLE["Incompatibilities"]
195
- incompatible.each {|dep| puts " #{INCOMPATIBLE_MOD_STYLE[dep[:spec]]}" }
196
- puts
193
+ out.puts HEADER_STYLE["Incompatibilities"]
194
+ incompatible.each {|dep| out.puts " #{INCOMPATIBLE_MOD_STYLE[dep[:spec]]}" }
195
+ out.puts
197
196
  end
198
197
  end
199
198
  end
@@ -14,15 +14,14 @@ module Factorix
14
14
  backup_support!
15
15
 
16
16
  include DownloadSupport
17
+ include PortalSupport
17
18
 
18
19
  # @!parse
19
- # # @return [Portal]
20
- # attr_reader :portal
21
20
  # # @return [Dry::Logger::Dispatcher]
22
21
  # attr_reader :logger
23
22
  # # @return [Factorix::Runtime]
24
23
  # attr_reader :runtime
25
- include Import[:portal, :logger, :runtime]
24
+ include Import[:logger, :runtime]
26
25
 
27
26
  desc "Sync MOD states and startup settings from a save file"
28
27
 
@@ -31,15 +30,16 @@ module Factorix
31
30
  "-j 8 save.zip # Use 8 parallel downloads"
32
31
  ]
33
32
 
34
- argument :save_file, type: :string, required: true, desc: "Path to Factorio save file (.zip)"
35
- option :jobs, type: :integer, aliases: ["-j"], default: 4, desc: "Number of parallel downloads"
33
+ argument :save_file, required: true, desc: "Path to Factorio save file (.zip)"
34
+ option :jobs, aliases: ["-j"], default: "4", desc: "Number of parallel downloads"
36
35
 
37
36
  # Execute the sync command
38
37
  #
39
38
  # @param save_file [String] Path to save file
40
39
  # @param jobs [Integer] Number of parallel downloads
41
40
  # @return [void]
42
- def call(save_file:, jobs: 4, **)
41
+ def call(save_file:, jobs: "4", **)
42
+ jobs = Integer(jobs)
43
43
  # Load save file
44
44
  say "Loading save file: #{save_file}", prefix: :info
45
45
  save_data = SaveFile.load(Pathname(save_file))
@@ -47,7 +47,7 @@ module Factorix
47
47
 
48
48
  # Load current state
49
49
  mod_list = MODList.load
50
- presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Scanning MOD(s)", output: $stderr)
50
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Scanning MOD(s)", output: err)
51
51
  handler = Progress::ScanHandler.new(presenter)
52
52
  installed_mods = InstalledMOD.all(handler:)
53
53
  graph = Dependency::Graph::Builder.build(installed_mods:, mod_list:)
@@ -109,7 +109,7 @@ module Factorix
109
109
  # @return [Array<Hash>] Installation targets with MOD info and releases
110
110
  private def plan_installation(mods_to_install, graph, jobs)
111
111
  # Create progress presenter for info fetching
112
- presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Fetching MOD info", output: $stderr)
112
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Fetching MOD info", output: err)
113
113
 
114
114
  # Fetch info for MODs to install
115
115
  target_infos = fetch_target_mod_info(mods_to_install, jobs, presenter)
@@ -45,7 +45,7 @@ module Factorix
45
45
 
46
46
  # Load current state (without validation to allow fixing issues)
47
47
  mod_list = MODList.load
48
- presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Scanning MOD(s)", output: $stderr)
48
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Scanning MOD(s)", output: err)
49
49
  handler = Progress::ScanHandler.new(presenter)
50
50
  installed_mods = InstalledMOD.all(handler:)
51
51
  graph = Dependency::Graph::Builder.build(installed_mods:, mod_list:)
@@ -13,14 +13,14 @@ module Factorix
13
13
  require_game_stopped!
14
14
  backup_support!
15
15
 
16
+ include PortalSupport
17
+ include DownloadSupport
16
18
  # @!parse
17
- # # @return [Portal]
18
- # attr_reader :portal
19
19
  # # @return [Dry::Logger::Dispatcher]
20
20
  # attr_reader :logger
21
21
  # # @return [Factorix::Runtime]
22
22
  # attr_reader :runtime
23
- include Import[:portal, :logger, :runtime]
23
+ include Import[:logger, :runtime]
24
24
 
25
25
  desc "Update MOD(s) to their latest versions"
26
26
 
@@ -32,15 +32,16 @@ module Factorix
32
32
  ]
33
33
 
34
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"
35
+ option :jobs, aliases: ["-j"], default: "4", desc: "Number of parallel downloads"
36
36
 
37
37
  # Execute the update command
38
38
  #
39
39
  # @param mod_names [Array<String>] MOD names to update
40
40
  # @param jobs [Integer] Number of parallel downloads
41
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)
42
+ def call(mod_names: [], jobs: "4", **)
43
+ jobs = Integer(jobs)
44
+ presenter = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Scanning MOD(s)", output: err)
44
45
  handler = Progress::ScanHandler.new(presenter)
45
46
  installed_mods = InstalledMOD.all(handler:)
46
47
  mod_list = MODList.load
@@ -98,7 +99,7 @@ module Factorix
98
99
  # @param jobs [Integer] Number of parallel jobs
99
100
  # @return [Array<Hash>] Update targets with current and latest versions
100
101
  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 = Progress::Presenter.new(title: "\u{1F50D}\u{FE0E} Checking for updates", output: err)
102
103
  presenter.start(total: target_mods.size)
103
104
 
104
105
  pool = Concurrent::FixedThreadPool.new(jobs)
@@ -140,7 +141,7 @@ module Factorix
140
141
  mod:,
141
142
  mod_info:,
142
143
  current_version:,
143
- latest_release:,
144
+ release: latest_release,
144
145
  output_path: runtime.mod_dir / latest_release.file_name
145
146
  }
146
147
  rescue MODNotOnPortalError
@@ -155,7 +156,7 @@ module Factorix
155
156
  private def show_plan(targets)
156
157
  say "Planning to update #{targets.size} MOD(s):", prefix: :info
157
158
  targets.each do |target|
158
- say " - #{target[:mod]}: #{target[:current_version]} -> #{target[:latest_release].version}"
159
+ say " - #{target[:mod]}: #{target[:current_version]} -> #{target[:release].version}"
159
160
  end
160
161
  end
161
162
 
@@ -175,46 +176,13 @@ module Factorix
175
176
  current_enabled = mod_list.enabled?(mod)
176
177
  mod_list.remove(mod)
177
178
  mod_list.add(mod, enabled: current_enabled)
178
- say "Updated #{mod} to #{target[:latest_release].version}", prefix: :success
179
+ say "Updated #{mod} to #{target[:release].version}", prefix: :success
179
180
  else
180
181
  mod_list.add(mod, enabled: true)
181
182
  say "Added #{mod} to mod-list.json", prefix: :success
182
183
  end
183
184
  end
184
185
  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
186
  end
219
187
  end
220
188
  end
@@ -6,10 +6,7 @@ module Factorix
6
6
  module MOD
7
7
  # Upload MOD to Factorio MOD Portal (handles both new and update)
8
8
  class Upload < Base
9
- # @!parse
10
- # # @return [Portal]
11
- # attr_reader :portal
12
- include Import[:portal]
9
+ include PortalSupport
13
10
 
14
11
  desc "Upload MOD to Factorio MOD Portal (handles both new and update)"
15
12
 
@@ -18,11 +15,11 @@ module Factorix
18
15
  "my-mod_1.0.0.zip --category automation # Upload with category"
19
16
  ]
20
17
 
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"
18
+ argument :file, required: true, desc: "Path to MOD zip file"
19
+ option :description, desc: "Markdown description"
20
+ option :category, desc: "MOD category"
21
+ option :license, desc: "License identifier"
22
+ option :source_url, desc: "Repository URL"
26
23
 
27
24
  # Execute the upload command
28
25
  #
@@ -44,7 +41,7 @@ module Factorix
44
41
  mod_name = extract_mod_name(file_path)
45
42
  metadata = build_metadata(description:, category:, license:, source_url:)
46
43
 
47
- presenter = Progress::Presenter.new(title: "\u{1F4E4} Uploading #{file_path.basename}", output: $stderr)
44
+ presenter = Progress::Presenter.new(title: "\u{1F4E4} Uploading #{file_path.basename}", output: err)
48
45
 
49
46
  uploader = portal.mod_management_api.uploader
50
47
  handler = Progress::UploadHandler.new(presenter)
@@ -61,7 +61,7 @@ module Factorix
61
61
  result = PATH_TYPES.transform_values {|method_name| runtime.public_send(method_name).to_s }
62
62
 
63
63
  if json
64
- puts JSON.pretty_generate(result)
64
+ out.puts JSON.pretty_generate(result)
65
65
  else
66
66
  output_table(result)
67
67
  end
@@ -70,7 +70,7 @@ module Factorix
70
70
  private def output_table(result)
71
71
  key_width = result.keys.map(&:length).max
72
72
  result.each do |key, value|
73
- puts "%-#{key_width}s %s" % [key, value]
73
+ out.puts "%-#{key_width}s %s" % [key, value]
74
74
  end
75
75
  end
76
76
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ # Provides lazy Portal resolution for CLI commands
7
+ #
8
+ # This module defers Portal dependency resolution until first use,
9
+ # allowing configuration to be loaded before cache backends are resolved.
10
+ #
11
+ # @example
12
+ # class Show < Base
13
+ # include PortalSupport
14
+ #
15
+ # def call(mod_name:, **)
16
+ # mod_info = portal.get_mod(mod_name)
17
+ # end
18
+ # end
19
+ module PortalSupport
20
+ # Lazily resolve Portal from Container
21
+ #
22
+ # @return [Portal] the portal instance
23
+ private def portal = @portal ||= Container[:portal]
24
+ end
25
+ end
26
+ end
27
+ end
@@ -18,7 +18,7 @@ module Factorix
18
18
  # Outputs the current version of the Factorix gem to stdout.
19
19
  #
20
20
  # @return [void]
21
- def call(**) = puts VERSION
21
+ def call(**) = out.puts VERSION
22
22
  end
23
23
  end
24
24
  end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core"
4
+ require "dry/inflector"
5
+ require "dry/logger"
6
+
7
+ module Factorix
8
+ # DI container for dependency injection
9
+ #
10
+ # Provides dependency injection container using dry-core's Container.
11
+ #
12
+ # @example Resolve dependencies
13
+ # runtime = Factorix::Container[:runtime]
14
+ class Container
15
+ extend Dry::Core::Container::Mixin
16
+
17
+ INFLECTOR = Dry::Inflector.new do |inflections|
18
+ inflections.uncountable("redis")
19
+ end
20
+ private_constant :INFLECTOR
21
+
22
+ # Build a cache instance from configuration.
23
+ #
24
+ # @param cache_type [Symbol] cache type (:download, :api, :info_json)
25
+ # @param config [Dry::Configurable::Config] cache configuration
26
+ # @return [Cache::Base] cache instance
27
+ def self.build_cache(cache_type, config)
28
+ backend = config.backend
29
+ backend_config = config.public_send(backend).to_h
30
+ backend_class = Cache.const_get(INFLECTOR.classify(backend.to_s))
31
+ backend_class.new(cache_type:, ttl: config.ttl, **backend_config)
32
+ end
33
+ private_class_method :build_cache
34
+
35
+ # :downloader is registered with memoize: false to support independent event handlers
36
+ # for each parallel download task (e.g., progress tracking).
37
+ # MODDownloadAPI resolves :downloader lazily per download call, allowing
38
+ # :mod_download_api and :portal to be safely memoized.
39
+
40
+ # Register runtime detector
41
+ register(:runtime, memoize: true) do
42
+ Runtime.detect
43
+ end
44
+
45
+ # Register logger
46
+ register(:logger, memoize: true) do
47
+ runtime = resolve(:runtime)
48
+ log_path = runtime.factorix_log_path
49
+
50
+ # Ensure log directory exists
51
+ log_path.dirname.mkpath unless log_path.dirname.exist?
52
+
53
+ # Create logger with file backend
54
+ # Dispatcher level set to DEBUG to allow all messages through
55
+ # Backend controls filtering based on --log-level option
56
+ Dry.Logger(:factorix, level: :debug) do |dispatcher|
57
+ dispatcher.add_backend(level: Factorix.config.log_level, stream: log_path.to_s, template: "[%<time>s] %<severity>s: %<message>s %<payload>s")
58
+ end
59
+ end
60
+
61
+ # Register retry strategy for network operations
62
+ register(:retry_strategy, memoize: true) do
63
+ HTTP::RetryStrategy.new
64
+ end
65
+
66
+ # Register download cache
67
+ register(:download_cache, memoize: true) do
68
+ build_cache(:download, Factorix.config.cache.download)
69
+ end
70
+
71
+ # Register API cache (with compression for JSON responses)
72
+ register(:api_cache, memoize: true) do
73
+ build_cache(:api, Factorix.config.cache.api)
74
+ end
75
+
76
+ # Register info.json cache (for MOD metadata from ZIP files)
77
+ register(:info_json_cache, memoize: true) do
78
+ build_cache(:info_json, Factorix.config.cache.info_json)
79
+ end
80
+
81
+ # Register base HTTP client
82
+ register(:http_client, memoize: true) do
83
+ HTTP::Client.new(masked_params: %w[username token secure])
84
+ end
85
+
86
+ # Register decorated HTTP client for downloads (with retry only)
87
+ # Note: Caching is handled by Downloader, not at HTTP client level
88
+ register(:download_http_client, memoize: true) do
89
+ client = resolve(:http_client)
90
+ retry_strategy = resolve(:retry_strategy)
91
+
92
+ # Decorate: Client -> Retry (no cache, handled by Downloader)
93
+ HTTP::RetryDecorator.new(client:, retry_strategy:)
94
+ end
95
+
96
+ # Register decorated HTTP client for API calls (with retry + cache)
97
+ register(:api_http_client, memoize: true) do
98
+ client = resolve(:http_client)
99
+ api_cache = resolve(:api_cache)
100
+ retry_strategy = resolve(:retry_strategy)
101
+
102
+ # Decorate: Client -> Cache -> Retry
103
+ cached = HTTP::CacheDecorator.new(client:, cache: api_cache)
104
+ HTTP::RetryDecorator.new(client: cached, retry_strategy:)
105
+ end
106
+
107
+ # Register decorated HTTP client for uploads (with retry only, no cache)
108
+ register(:upload_http_client, memoize: true) do
109
+ client = resolve(:http_client)
110
+ retry_strategy = resolve(:retry_strategy)
111
+
112
+ # Decorate: Client -> Retry (no cache for uploads)
113
+ HTTP::RetryDecorator.new(client:, retry_strategy:)
114
+ end
115
+
116
+ # Register downloader
117
+ register(:downloader, memoize: false) do
118
+ Transfer::Downloader.new
119
+ end
120
+
121
+ # Register uploader
122
+ register(:uploader, memoize: true) do
123
+ Transfer::Uploader.new
124
+ end
125
+
126
+ # Register service credential
127
+ register(:service_credential, memoize: true) { ServiceCredential.load }
128
+
129
+ # Register MOD Portal API client
130
+ register(:mod_portal_api, memoize: true) do
131
+ API::MODPortalAPI.new
132
+ end
133
+
134
+ # Register MOD Download API client
135
+ register(:mod_download_api, memoize: true) do
136
+ API::MODDownloadAPI.new
137
+ end
138
+
139
+ # Register API credential (for MOD upload/management)
140
+ register(:api_credential, memoize: true) { APICredential.load }
141
+
142
+ # Register MOD Management API client
143
+ register(:mod_management_api, memoize: true) do
144
+ api = API::MODManagementAPI.new
145
+ # Subscribe mod_portal_api to invalidate cache when MOD is changed on portal
146
+ api.subscribe(resolve(:mod_portal_api))
147
+ api
148
+ end
149
+
150
+ # Register portal (high-level API wrapper)
151
+ register(:portal, memoize: true) do
152
+ Portal.new
153
+ end
154
+ end
155
+ end
@@ -128,7 +128,7 @@ module Factorix
128
128
  MODVersionRequirement[operator:, version:]
129
129
  rescue VersionParseError => e
130
130
  # Skip version requirements with out-of-range version components
131
- Application[:logger].warn("Skipping version requirement '#{version_string}': #{e.message}")
131
+ Container[:logger].warn("Skipping version requirement '#{version_string}': #{e.message}")
132
132
  nil
133
133
  end
134
134
 
@@ -48,6 +48,9 @@ module Factorix
48
48
  class HTTPNotFoundError < HTTPClientError; end
49
49
  class HTTPServerError < HTTPError; end
50
50
 
51
+ # Cache lock timeout errors
52
+ class LockTimeoutError < InfrastructureError; end
53
+
51
54
  # Digest verification errors
52
55
  class DigestMismatchError < InfrastructureError; end
53
56