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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +3 -0
- data/completion/_factorix.bash +15 -1
- data/completion/_factorix.fish +15 -7
- data/completion/_factorix.zsh +22 -0
- data/exe/factorix +17 -0
- data/lib/factorix/api/game_download_api.rb +154 -0
- data/lib/factorix/api/mod_download_api.rb +10 -5
- data/lib/factorix/api/mod_info.rb +1 -1
- data/lib/factorix/api/mod_portal_api.rb +6 -49
- data/lib/factorix/cache/base.rb +116 -0
- data/lib/factorix/cache/entry.rb +25 -0
- data/lib/factorix/cache/file_system.rb +137 -57
- data/lib/factorix/cache/redis.rb +287 -0
- data/lib/factorix/cache/s3.rb +388 -0
- data/lib/factorix/cli/commands/cache/evict.rb +17 -22
- data/lib/factorix/cli/commands/cache/stat.rb +57 -58
- data/lib/factorix/cli/commands/download.rb +150 -0
- data/lib/factorix/cli/commands/download_support.rb +1 -6
- data/lib/factorix/cli/commands/mod/download.rb +2 -3
- data/lib/factorix/cli/commands/mod/edit.rb +1 -4
- data/lib/factorix/cli/commands/mod/image/add.rb +1 -4
- data/lib/factorix/cli/commands/mod/image/edit.rb +1 -4
- data/lib/factorix/cli/commands/mod/image/list.rb +1 -4
- data/lib/factorix/cli/commands/mod/install.rb +2 -3
- data/lib/factorix/cli/commands/mod/list.rb +3 -3
- data/lib/factorix/cli/commands/mod/search.rb +2 -3
- data/lib/factorix/cli/commands/mod/show.rb +2 -3
- data/lib/factorix/cli/commands/mod/sync.rb +2 -3
- data/lib/factorix/cli/commands/mod/update.rb +6 -39
- data/lib/factorix/cli/commands/mod/upload.rb +1 -4
- data/lib/factorix/cli/commands/portal_support.rb +27 -0
- data/lib/factorix/cli.rb +1 -0
- data/lib/factorix/container.rb +32 -13
- data/lib/factorix/dependency/graph/builder.rb +2 -2
- data/lib/factorix/dependency/graph.rb +2 -2
- data/lib/factorix/dependency/validation_result.rb +3 -3
- data/lib/factorix/errors.rb +3 -0
- data/lib/factorix/http/cache_decorator.rb +14 -7
- data/lib/factorix/http/cached_response.rb +4 -1
- data/lib/factorix/http/client.rb +13 -3
- data/lib/factorix/http/response.rb +4 -1
- data/lib/factorix/http/retry_decorator.rb +11 -0
- data/lib/factorix/info_json.rb +5 -5
- data/lib/factorix/portal.rb +3 -2
- data/lib/factorix/save_file.rb +2 -2
- data/lib/factorix/transfer/downloader.rb +19 -11
- data/lib/factorix/version.rb +1 -1
- data/lib/factorix.rb +46 -53
- data/sig/factorix/api/mod_download_api.rbs +1 -2
- data/sig/factorix/cache/base.rbs +28 -0
- data/sig/factorix/cache/entry.rbs +14 -0
- data/sig/factorix/cache/file_system.rbs +7 -6
- data/sig/factorix/cache/redis.rbs +36 -0
- data/sig/factorix/cache/s3.rbs +38 -0
- data/sig/factorix/errors.rbs +3 -0
- data/sig/factorix/portal.rbs +1 -1
- metadata +27 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3cc75b1211b8487fb9f93c19751c2086415385d32356282dfd3d71ef4158fdd1
|
|
4
|
+
data.tar.gz: 8d55640b9704c518ec15660cf17604b6f89b96356bee3044e5e663d28afebca0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/completion/_factorix.bash
CHANGED
|
@@ -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"))
|
data/completion/_factorix.fish
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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'
|
data/completion/_factorix.zsh
CHANGED
|
@@ -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[:
|
|
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
|
|
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
|
|
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
|
-
|
|
90
|
-
cache.with_lock(
|
|
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
|
-
|
|
95
|
-
cache.with_lock(
|
|
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
|
|
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
|
-
|
|
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
|