factorix 0.6.0 → 0.8.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +3 -0
  4. data/completion/_factorix.bash +15 -1
  5. data/completion/_factorix.fish +15 -7
  6. data/completion/_factorix.zsh +22 -0
  7. data/exe/factorix +17 -0
  8. data/lib/factorix/api/game_download_api.rb +154 -0
  9. data/lib/factorix/api/mod_download_api.rb +10 -5
  10. data/lib/factorix/api/mod_info.rb +1 -1
  11. data/lib/factorix/api/mod_portal_api.rb +6 -49
  12. data/lib/factorix/cache/base.rb +116 -0
  13. data/lib/factorix/cache/entry.rb +25 -0
  14. data/lib/factorix/cache/file_system.rb +137 -57
  15. data/lib/factorix/cache/redis.rb +287 -0
  16. data/lib/factorix/cache/s3.rb +388 -0
  17. data/lib/factorix/cli/commands/cache/evict.rb +17 -22
  18. data/lib/factorix/cli/commands/cache/stat.rb +57 -58
  19. data/lib/factorix/cli/commands/download.rb +150 -0
  20. data/lib/factorix/cli/commands/download_support.rb +1 -6
  21. data/lib/factorix/cli/commands/mod/download.rb +2 -3
  22. data/lib/factorix/cli/commands/mod/edit.rb +1 -4
  23. data/lib/factorix/cli/commands/mod/image/add.rb +1 -4
  24. data/lib/factorix/cli/commands/mod/image/edit.rb +1 -4
  25. data/lib/factorix/cli/commands/mod/image/list.rb +1 -4
  26. data/lib/factorix/cli/commands/mod/install.rb +2 -3
  27. data/lib/factorix/cli/commands/mod/list.rb +3 -3
  28. data/lib/factorix/cli/commands/mod/search.rb +2 -3
  29. data/lib/factorix/cli/commands/mod/show.rb +2 -3
  30. data/lib/factorix/cli/commands/mod/sync.rb +2 -3
  31. data/lib/factorix/cli/commands/mod/update.rb +6 -39
  32. data/lib/factorix/cli/commands/mod/upload.rb +1 -4
  33. data/lib/factorix/cli/commands/portal_support.rb +27 -0
  34. data/lib/factorix/cli.rb +1 -0
  35. data/lib/factorix/container.rb +32 -13
  36. data/lib/factorix/dependency/graph/builder.rb +2 -2
  37. data/lib/factorix/dependency/graph.rb +2 -2
  38. data/lib/factorix/dependency/validation_result.rb +3 -3
  39. data/lib/factorix/errors.rb +3 -0
  40. data/lib/factorix/http/cache_decorator.rb +14 -7
  41. data/lib/factorix/http/cached_response.rb +4 -1
  42. data/lib/factorix/http/client.rb +13 -3
  43. data/lib/factorix/http/response.rb +4 -1
  44. data/lib/factorix/http/retry_decorator.rb +11 -0
  45. data/lib/factorix/info_json.rb +5 -5
  46. data/lib/factorix/portal.rb +3 -2
  47. data/lib/factorix/save_file.rb +2 -2
  48. data/lib/factorix/transfer/downloader.rb +19 -11
  49. data/lib/factorix/version.rb +1 -1
  50. data/lib/factorix.rb +46 -53
  51. data/sig/factorix/api/mod_download_api.rbs +1 -2
  52. data/sig/factorix/cache/base.rbs +28 -0
  53. data/sig/factorix/cache/entry.rbs +14 -0
  54. data/sig/factorix/cache/file_system.rbs +7 -6
  55. data/sig/factorix/cache/redis.rbs +36 -0
  56. data/sig/factorix/cache/s3.rbs +38 -0
  57. data/sig/factorix/errors.rbs +3 -0
  58. data/sig/factorix/portal.rbs +1 -1
  59. metadata +27 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1eb581275b20274cebfa09669eb5f0111ed00f358d5a68947333b5990eb04d56
4
- data.tar.gz: 2a57a400c94476e860c2647af1e8fb3f845b231b1e377e265bfcc1d1de11d7ef
3
+ metadata.gz: 3cc75b1211b8487fb9f93c19751c2086415385d32356282dfd3d71ef4158fdd1
4
+ data.tar.gz: 8d55640b9704c518ec15660cf17604b6f89b96356bee3044e5e663d28afebca0
5
5
  SHA512:
6
- metadata.gz: 9134480ff3cd2675216f6fc9407bb0719467c2488a43449c727294f5871289cb955b6b5a20257314e11d562a60e2eea8edf81bf72c8df739055675142dff8ad5
7
- data.tar.gz: 15630f358ac2dbe3ae85b3a0798fdea532b7d9cba43352ffc982c74cf6e5eea1a264329f9543c26100618c7d3e45c2b6cc8a69bf5aca203333a2c77157b68571
6
+ metadata.gz: 638164922741c6f7225125b1c1f676c21adc5253e7ab1e40a99c0c6baef3f1e3d554097a254cf3cc4cb0ffc4e055c9153c4a37132222216ea00097cbf24f044c
7
+ data.tar.gz: dfc7c5bce1df717c321f2189d78486a0443d640e7c32571dd0038b07f34c7be5da68b72e5aa12c9ed1de59ca55531b37937a4cc9194d15aea88ff6a834d6cd95
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.0] - 2026-02-03
4
+
5
+ ### Added
6
+
7
+ - Add `download` command to download Factorio game files from the official Download API (#51)
8
+ - Supports alpha, expansion, demo, and headless builds
9
+ - Auto-detects platform (Windows, Linux, macOS, WSL)
10
+ - Resolves latest version from stable/experimental channels
11
+ - Add `head` method to HTTP client and decorators (`Client`, `RetryDecorator`, `CacheDecorator`)
12
+
13
+ ## [0.7.0] - 2026-01-24
14
+
15
+ ### Added
16
+
17
+ - Add pluggable cache backend architecture with Redis and S3 support (#18, #19)
18
+ - Configure backend per cache type: `config.cache.<type>.backend = :file_system | :redis | :s3`
19
+ - **Redis backend** (`Cache::Redis`): requires `redis` gem (~> 5)
20
+ - Distributed locking via Lua script for atomic lock release
21
+ - Auto-namespaced keys: `factorix-cache:{cache_type}:{key}`
22
+ - **S3 backend** (`Cache::S3`): requires `aws-sdk-s3` gem
23
+ - Distributed locking via conditional PUT (`if_none_match: "*"`)
24
+ - TTL managed via S3 custom metadata, age from native `Last-Modified`
25
+ - `cache stat` command displays backend-specific information (directory, URL, bucket, etc.)
26
+
27
+ ### Changed
28
+
29
+ - Refactor `Cache::FileSystem` to use `cache_type:` parameter instead of `root:` (#25)
30
+ - Aligns interface with other backends for consistent initialization
31
+ - Cache directory is now auto-computed from `Container[:runtime].factorix_cache_dir / cache_type`
32
+
33
+ ### Removed
34
+
35
+ - Remove deprecated `Factorix::Application` compatibility class
36
+ - Use `Factorix::Container` for DI (`[]`, `resolve`, `register`)
37
+ - Use `Factorix.config` and `Factorix.configure` for configuration
38
+
3
39
  ## [0.6.0] - 2026-01-18
4
40
 
5
41
  ### Changed
data/README.md CHANGED
@@ -20,6 +20,7 @@ Factorix simplifies Factorio MOD management by providing:
20
20
  - **Settings Management**: Export/import MOD settings in JSON format
21
21
  - **MOD Portal Integration**: Upload new MODs or update existing ones, edit metadata
22
22
  - **Game Control**: Launch Factorio from the command line
23
+ - **Game Download**: Download Factorio game files (alpha, expansion, demo, headless)
23
24
  - **Cross-platform Support**: Works on Windows, Linux, macOS, and WSL
24
25
 
25
26
  ## Requirements
@@ -38,6 +39,8 @@ export FACTORIO_API_KEY=your_api_key_here
38
39
 
39
40
  API key is not required for downloading, installing, or managing local MODs.
40
41
 
42
+ For downloading the game itself (`factorix download`), service credentials are required. These are automatically loaded from `player-data.json` if you have logged into Factorio, or you can set `FACTORIO_USERNAME` and `FACTORIO_TOKEN` environment variables.
43
+
41
44
  ## Configuration
42
45
 
43
46
  ### Path Configuration
@@ -17,7 +17,7 @@ _factorix() {
17
17
  local confirmable_opts="-y --yes"
18
18
 
19
19
  # Top-level commands
20
- local commands="version man launch path mod cache completion"
20
+ local commands="version man launch path download mod cache completion"
21
21
 
22
22
  # mod subcommands
23
23
  local mod_commands="check list show enable disable install uninstall update download upload edit search sync image settings"
@@ -52,6 +52,20 @@ _factorix() {
52
52
  fi
53
53
  return
54
54
  ;;
55
+ download)
56
+ if [[ "$cur" == -* ]]; then
57
+ COMPREPLY=($(compgen -W "$global_opts -b --build -p --platform -c --channel -d --directory -o --output" -- "$cur"))
58
+ elif [[ "$prev" == "--build" ]] || [[ "$prev" == "-b" ]]; then
59
+ COMPREPLY=($(compgen -W "alpha expansion demo headless" -- "$cur"))
60
+ elif [[ "$prev" == "--platform" ]] || [[ "$prev" == "-p" ]]; then
61
+ COMPREPLY=($(compgen -W "win64 win64-manual osx linux64" -- "$cur"))
62
+ elif [[ "$prev" == "--channel" ]] || [[ "$prev" == "-c" ]]; then
63
+ COMPREPLY=($(compgen -W "stable experimental" -- "$cur"))
64
+ elif [[ "$prev" == "--directory" ]] || [[ "$prev" == "-d" ]]; then
65
+ COMPREPLY=($(compgen -d -- "$cur"))
66
+ fi
67
+ return
68
+ ;;
55
69
  cache)
56
70
  if [[ $cword -eq 2 ]]; then
57
71
  COMPREPLY=($(compgen -W "$cache_commands" -- "$cur"))
@@ -73,13 +73,14 @@ complete -c factorix -l log-level -d 'Set log level' -xa 'debug info warn error
73
73
  complete -c factorix -s q -l quiet -d 'Suppress non-essential output'
74
74
 
75
75
  # Top-level commands
76
- complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a version -d 'Display Factorix version'
77
- complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a man -d 'Display the Factorix manual page'
78
- complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a launch -d 'Launch Factorio game'
79
- complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a path -d 'Display Factorio and Factorix paths'
80
- complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a mod -d 'MOD management commands'
81
- complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a cache -d 'Cache management commands'
82
- complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a completion -d 'Generate shell completion script'
76
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a version -d 'Display Factorix version'
77
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a man -d 'Display the Factorix manual page'
78
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a launch -d 'Launch Factorio game'
79
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a path -d 'Display Factorio and Factorix paths'
80
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a download -d 'Download Factorio game files'
81
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a mod -d 'MOD management commands'
82
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a cache -d 'Cache management commands'
83
+ complete -c factorix -n "not __factorix_using_command version; and not __factorix_using_command man; and not __factorix_using_command launch; and not __factorix_using_command path; and not __factorix_using_command download; and not __factorix_using_command mod; and not __factorix_using_command cache; and not __factorix_using_command completion" -a completion -d 'Generate shell completion script'
83
84
 
84
85
  # launch options
85
86
  complete -c factorix -n "__factorix_using_command launch" -s w -l wait -d 'Wait for the game to finish'
@@ -90,6 +91,13 @@ complete -c factorix -n "__factorix_using_command path" -l json -d 'Output in JS
90
91
  # completion shell argument
91
92
  complete -c factorix -n "__factorix_using_command completion" -a 'zsh bash fish' -d 'Shell type'
92
93
 
94
+ # download options
95
+ complete -c factorix -n "__factorix_using_command download" -s b -l build -d 'Build type' -xa 'alpha expansion demo headless'
96
+ complete -c factorix -n "__factorix_using_command download" -s p -l platform -d 'Platform' -xa 'win64 win64-manual osx linux64'
97
+ complete -c factorix -n "__factorix_using_command download" -s c -l channel -d 'Release channel' -xa 'stable experimental'
98
+ complete -c factorix -n "__factorix_using_command download" -s d -l directory -d 'Download directory' -ra '(__fish_complete_directories)'
99
+ complete -c factorix -n "__factorix_using_command download" -s o -l output -d 'Output filename' -r
100
+
93
101
  # cache subcommands
94
102
  complete -c factorix -n "__factorix_using_command cache" -a stat -d 'Display cache statistics'
95
103
  complete -c factorix -n "__factorix_using_command cache" -a evict -d 'Evict cache entries'
@@ -39,6 +39,7 @@ _factorix() {
39
39
  'man:Display the Factorix manual page'
40
40
  'launch:Launch Factorio game'
41
41
  'path:Display Factorio and Factorix paths'
42
+ 'download:Download Factorio game files'
42
43
  'mod:MOD management commands'
43
44
  'cache:Cache management commands'
44
45
  'completion:Generate shell completion script'
@@ -61,6 +62,9 @@ _factorix() {
61
62
  $global_opts \
62
63
  '--json[Output in JSON format]'
63
64
  ;;
65
+ download)
66
+ _factorix_download
67
+ ;;
64
68
  completion)
65
69
  _factorix_completion
66
70
  ;;
@@ -88,6 +92,24 @@ _factorix_completion() {
88
92
  '1:shell:(zsh bash fish)'
89
93
  }
90
94
 
95
+ _factorix_download() {
96
+ local -a global_opts
97
+ global_opts=(
98
+ '(-c --config-path)'{-c,--config-path}'[Path to configuration file]:config file:_files'
99
+ '--log-level[Set log level]:level:(debug info warn error fatal)'
100
+ '(-q --quiet)'{-q,--quiet}'[Suppress non-essential output]'
101
+ )
102
+
103
+ _arguments \
104
+ $global_opts \
105
+ '(-b --build)'{-b,--build}'[Build type]:build:(alpha expansion demo headless)' \
106
+ '(-p --platform)'{-p,--platform}'[Platform]:platform:(win64 win64-manual osx linux64)' \
107
+ '(-c --channel)'{-c,--channel}'[Release channel]:channel:(stable experimental)' \
108
+ '(-d --directory)'{-d,--directory}'[Download directory]:directory:_files -/' \
109
+ '(-o --output)'{-o,--output}'[Output filename]:filename:' \
110
+ '1:version:'
111
+ }
112
+
91
113
  _factorix_mod() {
92
114
  local context state state_descr line
93
115
  typeset -A opt_args
data/exe/factorix CHANGED
@@ -8,6 +8,23 @@ require "zip"
8
8
  # Suppress warnings about invalid dates in ZIP files
9
9
  Zip.warn_invalid_date = false
10
10
 
11
+ # Load config early, before dry-cli instantiates commands.
12
+ # This is necessary because command classes use Import which resolves
13
+ # dependencies at instantiation time (before CommandWrapper#call).
14
+ # Without this, cache backends would use default config instead of user config.
15
+ #
16
+ # Note: --config-path option is handled separately in CommandWrapper and
17
+ # will override settings if specified.
18
+ config_path_index = ARGV.index("--config-path") || ARGV.index("-c")
19
+ if config_path_index && ARGV[config_path_index + 1]
20
+ Factorix.load_config(Pathname(ARGV[config_path_index + 1]))
21
+ elsif ENV["FACTORIX_CONFIG"]
22
+ Factorix.load_config(Pathname(ENV.fetch("FACTORIX_CONFIG")))
23
+ else
24
+ default_config = Factorix::Container[:runtime].factorix_config_path
25
+ Factorix.load_config(default_config) if default_config.exist?
26
+ end
27
+
11
28
  begin
12
29
  Dry::CLI.new(Factorix::CLI).call
13
30
  exit 0
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Factorix
7
+ module API
8
+ # API client for downloading Factorio game files
9
+ #
10
+ # Corresponds to: https://wiki.factorio.com/Download_API
11
+ class GameDownloadAPI
12
+ # @!parse
13
+ # # @return [Dry::Logger::Dispatcher]
14
+ # attr_reader :logger
15
+ # # @return [HTTP::Client]
16
+ # attr_reader :client
17
+ include Import[:logger, client: :api_http_client]
18
+
19
+ # Base URL for game downloads
20
+ DOWNLOAD_BASE_URL = "https://www.factorio.com"
21
+ private_constant :DOWNLOAD_BASE_URL
22
+
23
+ # Base URL for API endpoints
24
+ API_BASE_URL = "https://factorio.com"
25
+ private_constant :API_BASE_URL
26
+
27
+ # Valid build types
28
+ BUILDS = %w[alpha expansion demo headless].freeze
29
+ public_constant :BUILDS
30
+
31
+ # Valid platforms
32
+ PLATFORMS = %w[win64 win64-manual osx linux64].freeze
33
+ public_constant :PLATFORMS
34
+
35
+ # Valid release channels
36
+ CHANNELS = %w[stable experimental].freeze
37
+ public_constant :CHANNELS
38
+
39
+ # Initialize with thread-safe credential loading
40
+ #
41
+ # @param args [Hash] dependency injection arguments
42
+ def initialize(...)
43
+ super
44
+ @service_credential_mutex = Mutex.new
45
+ end
46
+
47
+ # Fetch latest release information
48
+ #
49
+ # @return [Hash{Symbol => Hash}] Hash containing stable and experimental release info
50
+ # @example Response format
51
+ # {
52
+ # stable: { alpha: "2.0.28", expansion: "2.0.28", headless: "2.0.28" },
53
+ # experimental: { alpha: "2.0.29", expansion: "2.0.29", headless: "2.0.29" }
54
+ # }
55
+ def latest_releases
56
+ logger.debug "Fetching latest releases"
57
+ uri = URI.join(API_BASE_URL, "/api/latest-releases")
58
+ response = client.get(uri)
59
+ JSON.parse((+response.body).force_encoding(Encoding::UTF_8), symbolize_names: true)
60
+ end
61
+
62
+ # Get the latest version for a specific channel and build
63
+ #
64
+ # @param channel [String] Release channel (stable, experimental)
65
+ # @param build [String] Build type (alpha, expansion, demo, headless)
66
+ # @return [String, nil] Version string or nil if not available
67
+ def latest_version(channel:, build:)
68
+ releases = latest_releases
69
+ releases.dig(channel.to_sym, build.to_sym)
70
+ end
71
+
72
+ # Resolve the download filename by making a HEAD request
73
+ #
74
+ # @param version [String] Game version (e.g., "2.0.28")
75
+ # @param build [String] Build type (alpha, expansion, demo, headless)
76
+ # @param platform [String] Platform (win64, win64-manual, osx, linux64)
77
+ # @return [String] Filename extracted from final redirect URL
78
+ # @raise [ArgumentError] if build or platform is invalid
79
+ def resolve_filename(version:, build:, platform:)
80
+ validate_build!(build)
81
+ validate_platform!(platform)
82
+
83
+ uri = build_download_uri(version, build, platform)
84
+ response = client.head(uri)
85
+ File.basename(response.uri.path)
86
+ end
87
+
88
+ # Download the game to the specified output path
89
+ #
90
+ # @param version [String] Game version (e.g., "2.0.28")
91
+ # @param build [String] Build type (alpha, expansion, demo, headless)
92
+ # @param platform [String] Platform (win64, win64-manual, osx, linux64)
93
+ # @param output [Pathname] Output file path
94
+ # @param handler [Object, nil] Event handler for download progress (optional)
95
+ # @return [void]
96
+ # @raise [ArgumentError] if build or platform is invalid
97
+ def download(version:, build:, platform:, output:, handler: nil)
98
+ validate_build!(build)
99
+ validate_platform!(platform)
100
+
101
+ uri = build_download_uri(version, build, platform)
102
+ downloader = Container[:downloader]
103
+ downloader.subscribe(handler) if handler
104
+ begin
105
+ downloader.download(uri, output)
106
+ ensure
107
+ downloader.unsubscribe(handler) if handler
108
+ end
109
+ end
110
+
111
+ # Build the download URI with authentication
112
+ #
113
+ # @param version [String] Game version
114
+ # @param build [String] Build type
115
+ # @param platform [String] Platform
116
+ # @return [URI::HTTPS] Complete download URI with credentials
117
+ private def build_download_uri(version, build, platform)
118
+ path = "/get-download/#{version}/#{build}/#{platform}"
119
+ uri = URI.join(DOWNLOAD_BASE_URL, path)
120
+ params = {username: service_credential.username, token: service_credential.token}
121
+ uri.query = URI.encode_www_form(params)
122
+ uri
123
+ end
124
+
125
+ private def service_credential
126
+ return @service_credential if defined?(@service_credential)
127
+
128
+ @service_credential_mutex.synchronize do
129
+ @service_credential ||= Container[:service_credential]
130
+ end
131
+ end
132
+
133
+ # Validate build type
134
+ #
135
+ # @param build [String] Build type to validate
136
+ # @raise [ArgumentError] if build type is invalid
137
+ private def validate_build!(build)
138
+ return if BUILDS.include?(build)
139
+
140
+ raise ArgumentError, "Invalid build type: #{build}. Valid types: #{BUILDS.join(", ")}"
141
+ end
142
+
143
+ # Validate platform
144
+ #
145
+ # @param platform [String] Platform to validate
146
+ # @raise [ArgumentError] if platform is invalid
147
+ private def validate_platform!(platform)
148
+ return if PLATFORMS.include?(platform)
149
+
150
+ raise ArgumentError, "Invalid platform: #{platform}. Valid platforms: #{PLATFORMS.join(", ")}"
151
+ end
152
+ end
153
+ end
154
+ end
@@ -12,11 +12,9 @@ module Factorix
12
12
  # when FACTORIO_USERNAME/FACTORIO_TOKEN environment variables are not set.
13
13
  # It's resolved lazily via reader method instead.
14
14
  # @!parse
15
- # # @return [Transfer::Downloader]
16
- # attr_reader :downloader
17
15
  # # @return [Dry::Logger::Dispatcher]
18
16
  # attr_reader :logger
19
- include Import[:downloader, :logger]
17
+ include Import[:logger]
20
18
 
21
19
  BASE_URL = "https://mods.factorio.com"
22
20
  private_constant :BASE_URL
@@ -34,17 +32,24 @@ module Factorix
34
32
  # @param download_url [String] relative download URL from API response (e.g., "/download/mod-name/...")
35
33
  # @param output [Pathname] output file path
36
34
  # @param expected_sha1 [String, nil] expected SHA1 digest for verification (optional)
35
+ # @param handler [Object, nil] event handler for download progress (optional)
37
36
  # @return [void]
38
37
  # @raise [ArgumentError] if download_url is not a relative path starting with "/"
39
38
  # @raise [DigestMismatchError] if SHA1 verification fails
40
- def download(download_url, output, expected_sha1: nil)
39
+ def download(download_url, output, expected_sha1: nil, handler: nil)
41
40
  unless download_url.start_with?("/")
42
41
  logger.error("Invalid download_url", url: download_url)
43
42
  raise ArgumentError, "download_url must be a relative path starting with '/'"
44
43
  end
45
44
 
46
45
  uri = build_download_uri(download_url)
47
- downloader.download(uri, output, expected_sha1:)
46
+ downloader = Container[:downloader]
47
+ downloader.subscribe(handler) if handler
48
+ begin
49
+ downloader.download(uri, output, expected_sha1:)
50
+ ensure
51
+ downloader.unsubscribe(handler) if handler
52
+ end
48
53
  end
49
54
 
50
55
  private def service_credential
@@ -152,7 +152,7 @@ module Factorix
152
152
 
153
153
  # Filter detail_fields to only include keys that Detail.new accepts
154
154
  # Exclude deprecated fields like github_path
155
- detail = Detail.new(**detail_fields.slice(*DETAIL_ALLOWED_KEYS)) if all_required_detail_fields?(detail_fields)
155
+ detail = Detail[**detail_fields.slice(*DETAIL_ALLOWED_KEYS)] if all_required_detail_fields?(detail_fields)
156
156
 
157
157
  super(name:, title:, owner:, summary:, downloads_count:, category:, score:, thumbnail:, latest_release:, releases:, detail:)
158
158
  end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "erb"
4
4
  require "json"
5
- require "tempfile"
6
5
  require "uri"
7
6
 
8
7
  module Factorix
@@ -86,13 +85,13 @@ module Factorix
86
85
 
87
86
  # Invalidate get_mod cache
88
87
  mod_uri = build_uri("/api/mods/#{encoded_name}")
89
- mod_key = cache.key_for(mod_uri.to_s)
90
- cache.with_lock(mod_key) { cache.delete(mod_key) }
88
+ mod_cache_key = mod_uri.to_s
89
+ cache.with_lock(mod_cache_key) { cache.delete(mod_cache_key) }
91
90
 
92
91
  # Invalidate get_mod_full cache
93
92
  full_uri = build_uri("/api/mods/#{encoded_name}/full")
94
- full_key = cache.key_for(full_uri.to_s)
95
- cache.with_lock(full_key) { cache.delete(full_key) }
93
+ full_cache_key = full_uri.to_s
94
+ cache.with_lock(full_cache_key) { cache.delete(full_cache_key) }
96
95
 
97
96
  logger.debug("Invalidated cache for MOD", mod: mod_name)
98
97
  end
@@ -101,55 +100,13 @@ module Factorix
101
100
  URI.join(BASE_URL, path).tap {|uri| uri.query = URI.encode_www_form(params.sort.to_h) unless params.empty? }
102
101
  end
103
102
 
104
- # Fetch data with cache support
103
+ # Fetch data from API (caching is handled by CacheDecorator in api_http_client)
105
104
  #
106
105
  # @param uri [URI::HTTPS] URI to fetch
107
106
  # @return [Hash{Symbol => untyped}] parsed JSON response with symbolized keys
108
107
  private def fetch_with_cache(uri)
109
- key = cache.key_for(uri.to_s)
110
-
111
- cached = cache.read(key, encoding: "UTF-8")
112
- if cached
113
- logger.debug("API cache hit", uri: uri.to_s)
114
- return JSON.parse(cached, symbolize_names: true)
115
- end
116
-
117
- logger.debug("API cache miss", uri: uri.to_s)
118
- response_body = fetch_from_api(uri)
119
-
120
- store_in_cache(key, response_body)
121
-
122
- JSON.parse(response_body, symbolize_names: true)
123
- end
124
-
125
- # Fetch data from API via HTTP
126
- #
127
- # @param uri [URI::HTTPS] URI to fetch
128
- # @return [String] response body
129
- # @raise [HTTPClientError] for 4xx errors
130
- # @raise [HTTPServerError] for 5xx errors
131
- private def fetch_from_api(uri)
132
- logger.info("Fetching from API", uri: uri.to_s)
133
108
  response = client.get(uri)
134
- logger.info("API response", code: response.code, size_bytes: response.body.bytesize)
135
- response.body
136
- end
137
-
138
- # Store response body in cache via temporary file
139
- #
140
- # @param key [String] cache key
141
- # @param data [String] response body
142
- # @return [void]
143
- private def store_in_cache(key, data)
144
- temp_file = Tempfile.new("cache")
145
- begin
146
- temp_file.write(data)
147
- temp_file.close
148
- cache.store(key, Pathname(temp_file.path))
149
- logger.debug("Stored API response in cache", key:)
150
- ensure
151
- temp_file.unlink
152
- end
109
+ JSON.parse((+response.body).force_encoding(Encoding::UTF_8), symbolize_names: true)
153
110
  end
154
111
 
155
112
  # Validate page_size parameter
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ module Cache
5
+ # Abstract base class for cache backends.
6
+ #
7
+ # All cache backends (FileSystem, S3, Redis) inherit from this class
8
+ # and implement the abstract methods defined here.
9
+ #
10
+ # @abstract Subclasses must implement all abstract methods.
11
+ class Base
12
+ # @return [Integer, nil] time-to-live in seconds (nil for unlimited)
13
+ attr_reader :ttl
14
+
15
+ # Initialize a new cache backend.
16
+ #
17
+ # @param ttl [Integer, nil] time-to-live in seconds (nil for unlimited)
18
+ def initialize(ttl: nil)
19
+ @ttl = ttl
20
+ end
21
+
22
+ # Check if a cache entry exists and is not expired.
23
+ #
24
+ # @param key [String] logical cache key
25
+ # @return [Boolean] true if the cache entry exists and is valid
26
+ # @abstract
27
+ def exist?(key) = raise NotImplementedError, "#{self.class}#exist? must be implemented"
28
+
29
+ # Read a cached entry as a string.
30
+ #
31
+ # @param key [String] logical cache key
32
+ # @return [String, nil] cached content or nil if not found/expired
33
+ # @abstract
34
+ def read(key) = raise NotImplementedError, "#{self.class}#read must be implemented"
35
+
36
+ # Write cached content to a file.
37
+ #
38
+ # Unlike {#read} which returns content as a String, this method writes
39
+ # directly to a file path, which is more memory-efficient for large files.
40
+ #
41
+ # @param key [String] logical cache key
42
+ # @param output [Pathname] path to write the cached content
43
+ # @return [Boolean] true if written successfully, false if not found/expired
44
+ # @abstract
45
+ def write_to(key, output) = raise NotImplementedError, "#{self.class}#write_to must be implemented"
46
+
47
+ # Store data in the cache.
48
+ #
49
+ # @param key [String] logical cache key
50
+ # @param src [Pathname] path to the source file
51
+ # @return [Boolean] true if stored successfully
52
+ # @abstract
53
+ def store(key, src) = raise NotImplementedError, "#{self.class}#store must be implemented"
54
+
55
+ # Delete a cache entry.
56
+ #
57
+ # @param key [String] logical cache key
58
+ # @return [Boolean] true if deleted, false if not found
59
+ # @abstract
60
+ def delete(key) = raise NotImplementedError, "#{self.class}#delete must be implemented"
61
+
62
+ # Clear all cache entries.
63
+ #
64
+ # @return [void]
65
+ # @abstract
66
+ def clear = raise NotImplementedError, "#{self.class}#clear must be implemented"
67
+
68
+ # Execute a block with an exclusive lock on the cache entry.
69
+ #
70
+ # @param key [String] logical cache key
71
+ # @yield block to execute with lock held
72
+ # @abstract
73
+ def with_lock(key) = raise NotImplementedError, "#{self.class}#with_lock must be implemented"
74
+
75
+ # Get the age of a cache entry in seconds.
76
+ #
77
+ # @param key [String] logical cache key
78
+ # @return [Float, nil] age in seconds, or nil if entry doesn't exist
79
+ # @abstract
80
+ def age(key) = raise NotImplementedError, "#{self.class}#age must be implemented"
81
+
82
+ # Check if a cache entry has expired based on TTL.
83
+ #
84
+ # @param key [String] logical cache key
85
+ # @return [Boolean] true if expired, false otherwise
86
+ # @abstract
87
+ def expired?(key) = raise NotImplementedError, "#{self.class}#expired? must be implemented"
88
+
89
+ # Get the size of a cached entry in bytes.
90
+ #
91
+ # @param key [String] logical cache key
92
+ # @return [Integer, nil] size in bytes, or nil if entry doesn't exist/expired
93
+ # @abstract
94
+ def size(key) = raise NotImplementedError, "#{self.class}#size must be implemented"
95
+
96
+ # Enumerate cache entries.
97
+ #
98
+ # Yields [key, entry] pairs similar to Hash#each.
99
+ #
100
+ # @yield [key, entry] logical key and Entry object
101
+ # @yieldparam key [String] logical cache key
102
+ # @yieldparam entry [Entry] cache entry metadata
103
+ # @return [Enumerator] if no block given
104
+ # @abstract
105
+ def each = raise NotImplementedError, "#{self.class}#each must be implemented"
106
+
107
+ # Return backend-specific information.
108
+ #
109
+ # Subclasses should override this method to provide configuration details
110
+ # specific to their backend implementation.
111
+ #
112
+ # @return [Hash] backend-specific information (empty by default)
113
+ def backend_info = {}
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ module Cache
5
+ Entry = Data.define(:size, :age, :expired)
6
+
7
+ # Represents a cache entry for enumeration operations.
8
+ #
9
+ # Used by {Base#each} to yield entry metadata alongside keys.
10
+ # Note: The key is NOT included in Entry; it is yielded separately.
11
+ #
12
+ # @!attribute [r] size
13
+ # @return [Integer] entry size in bytes
14
+ # @!attribute [r] age
15
+ # @return [Float] age in seconds since creation/modification
16
+ class Entry
17
+ private :expired
18
+
19
+ # Check if the cache entry has expired.
20
+ #
21
+ # @return [Boolean] true if entry has exceeded TTL
22
+ def expired? = expired
23
+ end
24
+ end
25
+ end