factorix 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f1d6b99d769a238f64676cfd19aef4d0f8852d2dc38df210eb753a8bc202f67f
4
- data.tar.gz: c51b472d2eb47e7d0d39c4789112da30faf5fbf7e225da330d19e989bce86f43
3
+ metadata.gz: 3cc75b1211b8487fb9f93c19751c2086415385d32356282dfd3d71ef4158fdd1
4
+ data.tar.gz: 8d55640b9704c518ec15660cf17604b6f89b96356bee3044e5e663d28afebca0
5
5
  SHA512:
6
- metadata.gz: 0e9bf68578d26f4b08e77028b1da59ff8d972f807822be5fa19ca1eb951e89412a3314a3db5fde1fcd821f399ee6c9e4e8c28e37d2d0c5e3d000046c5e663d2f
7
- data.tar.gz: 81222393a0ab0a6b6e2cbbccac52dec243d4806ef688a1240a01367142094e368175caba564d4842ebd627ebdef98e322f8217b14a3424e8e20c7bbeddc248a4
6
+ metadata.gz: 638164922741c6f7225125b1c1f676c21adc5253e7ab1e40a99c0c6baef3f1e3d554097a254cf3cc4cb0ffc4e055c9153c4a37132222216ea00097cbf24f044c
7
+ data.tar.gz: dfc7c5bce1df717c321f2189d78486a0443d640e7c32571dd0038b07f34c7be5da68b72e5aa12c9ed1de59ca55531b37937a4cc9194d15aea88ff6a834d6cd95
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
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
+
3
13
  ## [0.7.0] - 2026-01-24
4
14
 
5
15
  ### Added
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
@@ -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
@@ -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
@@ -273,11 +273,11 @@ module Factorix
273
273
 
274
274
  logical_key = JSON.parse(metadata_path.read)["logical_key"]
275
275
  age = Time.now - path.mtime
276
- entry = Entry.new(
276
+ entry = Entry[
277
277
  size: path.size,
278
278
  age:,
279
279
  expired: @ttl ? age > @ttl : false
280
- )
280
+ ]
281
281
 
282
282
  yield logical_key, entry
283
283
  end
@@ -223,11 +223,11 @@ module Factorix
223
223
  logical_key = logical_key_from_data_key(data_k)
224
224
  meta = @redis.hgetall(meta_key(logical_key))
225
225
 
226
- entry = Entry.new(
226
+ entry = Entry[
227
227
  size: meta["size"] ? Integer(meta["size"], 10) : 0,
228
228
  age: meta["created_at"] ? Time.now.to_i - Integer(meta["created_at"], 10) : 0,
229
229
  expired: false # Redis handles expiry natively
230
- )
230
+ ]
231
231
 
232
232
  yield logical_key, entry
233
233
  end
@@ -360,11 +360,11 @@ module Factorix
360
360
  age = Time.now - obj.last_modified
361
361
  expired = check_expired_from_head_response(resp)
362
362
 
363
- entry = Entry.new(
363
+ entry = Entry[
364
364
  size: obj.size,
365
365
  age:,
366
366
  expired:
367
- )
367
+ ]
368
368
 
369
369
  [logical_key, entry]
370
370
  rescue Aws::S3::Errors::NotFound
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ # Download Factorio game files from the official download API
7
+ class Download < Base
8
+ # @!parse
9
+ # # @return [Dry::Logger::Dispatcher]
10
+ # attr_reader :logger
11
+ # # @return [Runtime]
12
+ # attr_reader :runtime
13
+ # # @return [API::GameDownloadAPI]
14
+ # attr_reader :game_download_api
15
+ include Import[:logger, :runtime, :game_download_api]
16
+
17
+ # Platform mapping from Runtime to API platform identifier
18
+ PLATFORM_MAP = {
19
+ "MacOS" => "osx",
20
+ "Linux" => "linux64",
21
+ "Windows" => "win64",
22
+ "WSL" => "win64"
23
+ }.freeze
24
+ private_constant :PLATFORM_MAP
25
+
26
+ desc "Download Factorio game files"
27
+
28
+ argument :version, required: false, default: "latest", desc: "Version (e.g., 2.0.73, latest)"
29
+
30
+ option :build, aliases: ["-b"], default: "alpha", values: API::GameDownloadAPI::BUILDS, desc: "Build type"
31
+ option :platform, aliases: ["-p"], values: API::GameDownloadAPI::PLATFORMS, desc: "Platform (default: auto-detect)"
32
+ option :channel, aliases: ["-c"], default: "stable", values: API::GameDownloadAPI::CHANNELS, desc: "Release channel"
33
+ option :directory, aliases: ["-d"], default: ".", desc: "Download directory"
34
+ option :output, aliases: ["-o"], desc: "Output filename (default: from server)"
35
+
36
+ example [
37
+ " # Download latest stable version (auto-detect platform)",
38
+ "2.0.73 # Download specific version",
39
+ "--build expansion # Download expansion build",
40
+ "--build headless -p linux64 # Download headless server for Linux",
41
+ "--channel experimental # Download experimental release",
42
+ "-o factorio-server.tar.xz # Specify output filename"
43
+ ]
44
+
45
+ # Execute the download command
46
+ #
47
+ # @param version [String] Version to download
48
+ # @param build [String] Build type
49
+ # @param platform [String, nil] Platform (nil for auto-detect)
50
+ # @param channel [String] Release channel
51
+ # @param directory [String] Download directory
52
+ # @param output [String, nil] Output filename
53
+ # @return [void]
54
+ def call(version: "latest", build: "alpha", platform: nil, channel: "stable", directory: ".", output: nil, **)
55
+ platform ||= detect_platform
56
+ resolved_version = resolve_version(version, channel, build)
57
+
58
+ download_dir = Pathname(directory).expand_path
59
+ raise DirectoryNotFoundError, "Download directory does not exist: #{download_dir}" unless download_dir.exist?
60
+
61
+ filename = output || resolve_filename(resolved_version, build, platform)
62
+ output_path = download_dir / filename
63
+
64
+ say "Downloading Factorio #{resolved_version} (#{build}/#{platform})...", prefix: :info
65
+
66
+ download_game(resolved_version, build, platform, output_path)
67
+
68
+ say "Downloaded to #{output_path}", prefix: :success
69
+ end
70
+
71
+ # Detect platform from Runtime
72
+ #
73
+ # @return [String] Platform identifier
74
+ private def detect_platform
75
+ runtime_class = runtime.class.name.split("::").last
76
+ platform = PLATFORM_MAP[runtime_class]
77
+ raise UnsupportedPlatformError, "Cannot auto-detect platform for #{runtime_class}" unless platform
78
+
79
+ logger.debug("Auto-detected platform", platform:)
80
+ platform
81
+ end
82
+
83
+ # Minimum supported major version
84
+ MINIMUM_MAJOR_VERSION = 2
85
+ private_constant :MINIMUM_MAJOR_VERSION
86
+
87
+ # Resolve version, handling "latest" by fetching from API
88
+ #
89
+ # @param version [String] Version or "latest"
90
+ # @param channel [String] Release channel
91
+ # @param build [String] Build type
92
+ # @return [String] Resolved version
93
+ # @raise [InvalidArgumentError] if version is invalid or < 2.0
94
+ private def resolve_version(version, channel, build)
95
+ resolved = if version == "latest"
96
+ v = game_download_api.latest_version(channel:, build:)
97
+ raise InvalidArgumentError, "No #{channel} version available for #{build}" unless v
98
+
99
+ logger.debug("Resolved latest version", channel:, build:, version: v)
100
+ v
101
+ else
102
+ version
103
+ end
104
+
105
+ validate_version!(resolved)
106
+ resolved
107
+ end
108
+
109
+ # Validate version format and minimum version requirement
110
+ #
111
+ # @param version [String] Version string
112
+ # @return [void]
113
+ # @raise [InvalidArgumentError] if version is invalid or < 2.0
114
+ private def validate_version!(version)
115
+ game_version = GameVersion.from_string(version)
116
+
117
+ return if game_version.major >= MINIMUM_MAJOR_VERSION
118
+
119
+ raise InvalidArgumentError, "Version #{version} is not supported. Minimum version is #{MINIMUM_MAJOR_VERSION}.0.0"
120
+ rescue VersionParseError => e
121
+ raise InvalidArgumentError, "Invalid version format: #{e.message}"
122
+ end
123
+
124
+ # Resolve filename by making HEAD request
125
+ #
126
+ # @param version [String] Version
127
+ # @param build [String] Build type
128
+ # @param platform [String] Platform
129
+ # @return [String] Filename
130
+ private def resolve_filename(version, build, platform)
131
+ game_download_api.resolve_filename(version:, build:, platform:)
132
+ end
133
+
134
+ # Download the game with progress tracking
135
+ #
136
+ # @param version [String] Version
137
+ # @param build [String] Build type
138
+ # @param platform [String] Platform
139
+ # @param output_path [Pathname] Output file path
140
+ # @return [void]
141
+ private def download_game(version, build, platform, output_path)
142
+ presenter = Progress::Presenter.new(title: output_path.basename.to_s, output: err)
143
+ handler = Progress::DownloadHandler.new(presenter)
144
+
145
+ game_download_api.download(version:, build:, platform:, output: output_path, handler:)
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -147,7 +147,7 @@ module Factorix
147
147
  enabled = mod_list.exist?(mod) && mod_list.enabled?(mod)
148
148
  error = error_map[mod.name]
149
149
 
150
- MODInfo.new(name: mod.name, version: display_version, enabled:, error:, latest_version: nil)
150
+ MODInfo[name: mod.name, version: display_version, enabled:, error:, latest_version: nil]
151
151
  }
152
152
  end
153
153
 
@@ -256,13 +256,13 @@ module Factorix
256
256
  private def fetch_latest_version_for_mod(info)
257
257
  portal_info = mod_portal_api.get_mod(info.name)
258
258
  latest = portal_info[:releases]&.map {|r| MODVersion.from_string(r[:version]) }&.max
259
- MODInfo.new(
259
+ MODInfo[
260
260
  name: info.name,
261
261
  version: info.version,
262
262
  enabled: info.enabled,
263
263
  error: info.error,
264
264
  latest_version: latest
265
- )
265
+ ]
266
266
  rescue MODNotOnPortalError
267
267
  logger.debug("MOD not found on portal", mod: info.name)
268
268
  info
data/lib/factorix/cli.rb CHANGED
@@ -17,6 +17,7 @@ module Factorix
17
17
  register "man", Commands::Man
18
18
  register "launch", Commands::Launch
19
19
  register "path", Commands::Path
20
+ register "download", Commands::Download
20
21
  register "completion", Commands::Completion
21
22
  register "mod check", Commands::MOD::Check
22
23
  register "mod list", Commands::MOD::List
@@ -136,6 +136,11 @@ module Factorix
136
136
  API::MODDownloadAPI.new
137
137
  end
138
138
 
139
+ # Register Game Download API client
140
+ register(:game_download_api, memoize: true) do
141
+ API::GameDownloadAPI.new
142
+ end
143
+
139
144
  # Register API credential (for MOD upload/management)
140
145
  register(:api_credential, memoize: true) { APICredential.load }
141
146
 
@@ -53,7 +53,7 @@ module Factorix
53
53
  version = select_version_for_mod(mod)
54
54
  enabled = mod_enabled?(mod)
55
55
 
56
- node = Node.new(mod:, version:, enabled:, installed: true)
56
+ node = Node[mod:, version:, enabled:, installed: true]
57
57
  graph.add_node(node)
58
58
  end
59
59
 
@@ -88,7 +88,7 @@ module Factorix
88
88
  # Expansion MODs can be disabled, so they must be validated
89
89
  next if dependency.mod.base?
90
90
 
91
- edge = Edge.new(from_mod:, to_mod: dependency.mod, type: dependency.type, version_requirement: dependency.version_requirement)
91
+ edge = Edge[from_mod:, to_mod: dependency.mod, type: dependency.type, version_requirement: dependency.version_requirement]
92
92
  graph.add_edge(edge)
93
93
  end
94
94
  end
@@ -75,7 +75,7 @@ module Factorix
75
75
  return
76
76
  end
77
77
 
78
- node = Node.new(mod:, version: release.version, enabled: false, installed: false, operation:)
78
+ node = Node[mod:, version: release.version, enabled: false, installed: false, operation:]
79
79
  add_node(node)
80
80
 
81
81
  dependencies = release.info_json[:dependencies] || []
@@ -85,7 +85,7 @@ module Factorix
85
85
  dependency = parser.parse(dep_string)
86
86
  next if dependency.mod.base?
87
87
 
88
- edge = Edge.new(from_mod: mod, to_mod: dependency.mod, type: dependency.type, version_requirement: dependency.version_requirement)
88
+ edge = Edge[from_mod: mod, to_mod: dependency.mod, type: dependency.type, version_requirement: dependency.version_requirement]
89
89
 
90
90
  add_edge(edge)
91
91
  end
@@ -81,7 +81,7 @@ module Factorix
81
81
  # @param mod [Factorix::MOD, nil] Related MOD
82
82
  # @param dependency [Factorix::MOD, nil] Dependency MOD
83
83
  # @return [void]
84
- def add_error(type:, message:, mod: nil, dependency: nil) = @errors << Error.new(type:, message:, mod:, dependency:)
84
+ def add_error(type:, message:, mod: nil, dependency: nil) = @errors << Error[type:, message:, mod:, dependency:]
85
85
 
86
86
  # Add a warning
87
87
  #
@@ -89,7 +89,7 @@ module Factorix
89
89
  # @param message [String] Warning message
90
90
  # @param mod [Factorix::MOD, nil] Related MOD
91
91
  # @return [void]
92
- def add_warning(type:, message:, mod: nil) = @warnings << Warning.new(type:, message:, mod:)
92
+ def add_warning(type:, message:, mod: nil) = @warnings << Warning[type:, message:, mod:]
93
93
 
94
94
  # Add a suggestion
95
95
  #
@@ -97,7 +97,7 @@ module Factorix
97
97
  # @param mod [Factorix::MOD] Related MOD
98
98
  # @param version [Factorix::MODVersion] Suggested version
99
99
  # @return [void]
100
- def add_suggestion(message:, mod:, version:) = @suggestions << Suggestion.new(message:, mod:, version:)
100
+ def add_suggestion(message:, mod:, version:) = @suggestions << Suggestion[message:, mod:, version:]
101
101
 
102
102
  # Get all errors
103
103
  #
@@ -56,7 +56,7 @@ module Factorix
56
56
  if cached_body
57
57
  logger.debug("Cache hit", uri: uri.to_s)
58
58
  publish("cache.hit", url: uri.to_s)
59
- return CachedResponse.new(cached_body)
59
+ return CachedResponse.new(cached_body, uri:)
60
60
  end
61
61
 
62
62
  logger.debug("Cache miss", uri: uri.to_s)
@@ -68,7 +68,7 @@ module Factorix
68
68
  cached_body = cache.read(cache_key)
69
69
  if cached_body
70
70
  publish("cache.hit", url: uri.to_s)
71
- return CachedResponse.new(cached_body)
71
+ return CachedResponse.new(cached_body, uri:)
72
72
  end
73
73
 
74
74
  response = client.get(uri, headers:)
@@ -94,6 +94,13 @@ module Factorix
94
94
  # @return [Response] response object
95
95
  def post(uri, body:, headers: {}, content_type: nil) = client.post(uri, body:, headers:, content_type:)
96
96
 
97
+ # Execute a HEAD request (never cached)
98
+ #
99
+ # @param uri [URI::HTTPS] target URI
100
+ # @param headers [Hash<String, String>] request headers
101
+ # @return [Response] response object
102
+ def head(uri, headers: {}) = client.head(uri, headers:)
103
+
97
104
  private def with_temporary_file
98
105
  temp_file = Tempfile.new("http_cache")
99
106
  yield temp_file
@@ -12,12 +12,15 @@ module Factorix
12
12
  attr_reader :body
13
13
  attr_reader :code
14
14
  attr_reader :headers
15
+ attr_reader :uri
15
16
 
16
17
  # @param body [String] cached response body
17
- def initialize(body)
18
+ # @param uri [URI, nil] original request URI (not stored in cache, always nil)
19
+ def initialize(body, uri: nil)
18
20
  @body = body
19
21
  @code = 200
20
22
  @headers = {"content-type" => ["application/octet-stream"]}
23
+ @uri = uri
21
24
  end
22
25
 
23
26
  # Always returns true for cached responses
@@ -58,6 +58,13 @@ module Factorix
58
58
  # @return [Response] response object
59
59
  def get(uri, headers: {}, &) = request(:get, uri, headers:, &)
60
60
 
61
+ # Execute a HEAD request
62
+ #
63
+ # @param uri [URI::HTTPS] target URI
64
+ # @param headers [Hash<String, String>] request headers
65
+ # @return [Response] response object
66
+ def head(uri, headers: {}) = request(:head, uri, headers:)
67
+
61
68
  # Execute a POST request
62
69
  #
63
70
  # @param uri [URI::HTTPS] target URI
@@ -86,18 +93,20 @@ module Factorix
86
93
  result
87
94
  end
88
95
 
89
- private def handle_response(response, _method, _uri, redirect_count, &block)
96
+ private def handle_response(response, method, uri, redirect_count, &block)
90
97
  case response
91
98
  when Net::HTTPSuccess, Net::HTTPPartialContent
92
99
  yield(response) if block
93
- Response.new(response)
100
+ Response.new(response, uri:)
94
101
 
95
102
  when Net::HTTPRedirection
96
103
  location = response["Location"]
97
104
  redirect_url = URI(location)
98
105
  logger.info("Following redirect", location: mask_credentials(redirect_url))
99
106
 
100
- perform_request(:get, redirect_url, redirect_count: redirect_count + 1, headers: {}, body: nil, &block)
107
+ # HEAD stays HEAD, others become GET (standard redirect behavior)
108
+ redirect_method = method == :head ? :head : :get
109
+ perform_request(redirect_method, redirect_url, redirect_count: redirect_count + 1, headers: {}, body: nil, &block)
101
110
 
102
111
  when Net::HTTPNotFound
103
112
  api_error, api_message = parse_api_error(response)
@@ -139,6 +148,7 @@ module Factorix
139
148
  private def build_request(method, uri, headers:, body:)
140
149
  request = case method
141
150
  when :get then Net::HTTP::Get.new(uri)
151
+ when :head then Net::HTTP::Head.new(uri)
142
152
  when :post then Net::HTTP::Post.new(uri)
143
153
  when :put then Net::HTTP::Put.new(uri)
144
154
  when :delete then Net::HTTP::Delete.new(uri)
@@ -8,13 +8,16 @@ module Factorix
8
8
  attr_reader :body
9
9
  attr_reader :headers
10
10
  attr_reader :raw_response
11
+ attr_reader :uri
11
12
 
12
13
  # @param net_http_response [Net::HTTPResponse] Raw Net::HTTP response
13
- def initialize(net_http_response)
14
+ # @param uri [URI, nil] Final URI after following redirects
15
+ def initialize(net_http_response, uri: nil)
14
16
  @code = Integer(net_http_response.code, 10)
15
17
  @body = net_http_response.body
16
18
  @headers = net_http_response.to_hash
17
19
  @raw_response = net_http_response
20
+ @uri = uri
18
21
  end
19
22
 
20
23
  # Check if response is successful (2xx)
@@ -54,6 +54,17 @@ module Factorix
54
54
  client.post(uri, body:, headers:, content_type:)
55
55
  end
56
56
  end
57
+
58
+ # Execute a HEAD request with retry
59
+ #
60
+ # @param uri [URI::HTTPS] target URI
61
+ # @param headers [Hash<String, String>] request headers
62
+ # @return [Response] response object
63
+ def head(uri, headers: {})
64
+ retry_strategy.with_retry do
65
+ client.head(uri, headers:)
66
+ end
67
+ end
57
68
  end
58
69
  end
59
70
  end
@@ -49,7 +49,7 @@ module Factorix
49
49
  parse_startup_settings(deserializer)
50
50
  end
51
51
 
52
- SaveFile.new(version: @version, mods: @mods, startup_settings: @startup_settings)
52
+ SaveFile[version: @version, mods: @mods, startup_settings: @startup_settings]
53
53
  end
54
54
 
55
55
  private def open_level_file
@@ -143,7 +143,7 @@ module Factorix
143
143
  _crc = deserializer.read_u32
144
144
 
145
145
  # All MODs in save file are enabled
146
- @mods[name] = MODState.new(enabled: true, version:)
146
+ @mods[name] = MODState[enabled: true, version:]
147
147
  end
148
148
  end
149
149
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Factorix
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  public_constant :VERSION
6
6
  end
data/lib/factorix.rb CHANGED
@@ -129,6 +129,7 @@ module Factorix
129
129
  "installed_mod" => "InstalledMOD",
130
130
  "mac_os" => "MacOS",
131
131
  "mod" => "MOD",
132
+ "game_download_api" => "GameDownloadAPI",
132
133
  "mod_download_api" => "MODDownloadAPI",
133
134
  "mod_info" => "MODInfo",
134
135
  "mod_management_api" => "MODManagementAPI",
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: factorix
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - OZAWA Sakuro
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-24 00:00:00.000000000 Z
11
+ date: 2026-02-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -226,6 +226,7 @@ files:
226
226
  - lib/factorix.rb
227
227
  - lib/factorix/api.rb
228
228
  - lib/factorix/api/category.rb
229
+ - lib/factorix/api/game_download_api.rb
229
230
  - lib/factorix/api/image.rb
230
231
  - lib/factorix/api/license.rb
231
232
  - lib/factorix/api/mod_download_api.rb
@@ -248,6 +249,7 @@ files:
248
249
  - lib/factorix/cli/commands/command_wrapper.rb
249
250
  - lib/factorix/cli/commands/completion.rb
250
251
  - lib/factorix/cli/commands/confirmable.rb
252
+ - lib/factorix/cli/commands/download.rb
251
253
  - lib/factorix/cli/commands/download_support.rb
252
254
  - lib/factorix/cli/commands/launch.rb
253
255
  - lib/factorix/cli/commands/man.rb