factorix 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/completion/_factorix.bash +202 -0
- data/completion/_factorix.fish +197 -0
- data/completion/_factorix.zsh +376 -0
- data/doc/factorix.1 +377 -0
- data/exe/factorix +20 -0
- data/lib/factorix/api/category.rb +69 -0
- data/lib/factorix/api/image.rb +35 -0
- data/lib/factorix/api/license.rb +71 -0
- data/lib/factorix/api/mod_download_api.rb +66 -0
- data/lib/factorix/api/mod_info.rb +166 -0
- data/lib/factorix/api/mod_management_api.rb +237 -0
- data/lib/factorix/api/mod_portal_api.rb +204 -0
- data/lib/factorix/api/release.rb +49 -0
- data/lib/factorix/api/tag.rb +95 -0
- data/lib/factorix/api.rb +7 -0
- data/lib/factorix/api_credential.rb +54 -0
- data/lib/factorix/application.rb +218 -0
- data/lib/factorix/cache/file_system.rb +307 -0
- data/lib/factorix/cli/commands/backup_support.rb +46 -0
- data/lib/factorix/cli/commands/base.rb +90 -0
- data/lib/factorix/cli/commands/cache/evict.rb +180 -0
- data/lib/factorix/cli/commands/cache/stat.rb +201 -0
- data/lib/factorix/cli/commands/command_wrapper.rb +71 -0
- data/lib/factorix/cli/commands/completion.rb +83 -0
- data/lib/factorix/cli/commands/confirmable.rb +53 -0
- data/lib/factorix/cli/commands/download_support.rb +123 -0
- data/lib/factorix/cli/commands/launch.rb +79 -0
- data/lib/factorix/cli/commands/man.rb +29 -0
- data/lib/factorix/cli/commands/mod/check.rb +99 -0
- data/lib/factorix/cli/commands/mod/disable.rb +188 -0
- data/lib/factorix/cli/commands/mod/download.rb +291 -0
- data/lib/factorix/cli/commands/mod/edit.rb +114 -0
- data/lib/factorix/cli/commands/mod/enable.rb +216 -0
- data/lib/factorix/cli/commands/mod/image/add.rb +47 -0
- data/lib/factorix/cli/commands/mod/image/edit.rb +41 -0
- data/lib/factorix/cli/commands/mod/image/list.rb +74 -0
- data/lib/factorix/cli/commands/mod/install.rb +443 -0
- data/lib/factorix/cli/commands/mod/list.rb +372 -0
- data/lib/factorix/cli/commands/mod/search.rb +134 -0
- data/lib/factorix/cli/commands/mod/settings/dump.rb +88 -0
- data/lib/factorix/cli/commands/mod/settings/restore.rb +101 -0
- data/lib/factorix/cli/commands/mod/show.rb +202 -0
- data/lib/factorix/cli/commands/mod/sync.rb +299 -0
- data/lib/factorix/cli/commands/mod/uninstall.rb +325 -0
- data/lib/factorix/cli/commands/mod/update.rb +222 -0
- data/lib/factorix/cli/commands/mod/upload.rb +90 -0
- data/lib/factorix/cli/commands/path.rb +79 -0
- data/lib/factorix/cli/commands/requires_game_stopped.rb +32 -0
- data/lib/factorix/cli/commands/version.rb +25 -0
- data/lib/factorix/cli.rb +42 -0
- data/lib/factorix/dependency/edge.rb +89 -0
- data/lib/factorix/dependency/entry.rb +124 -0
- data/lib/factorix/dependency/graph/builder.rb +108 -0
- data/lib/factorix/dependency/graph.rb +210 -0
- data/lib/factorix/dependency/list.rb +244 -0
- data/lib/factorix/dependency/mod_version_requirement.rb +73 -0
- data/lib/factorix/dependency/node.rb +60 -0
- data/lib/factorix/dependency/parser.rb +148 -0
- data/lib/factorix/dependency/validation_result.rb +138 -0
- data/lib/factorix/dependency/validator.rb +190 -0
- data/lib/factorix/errors.rb +112 -0
- data/lib/factorix/formatting.rb +56 -0
- data/lib/factorix/game_version.rb +98 -0
- data/lib/factorix/http/cache_decorator.rb +106 -0
- data/lib/factorix/http/cached_response.rb +37 -0
- data/lib/factorix/http/client.rb +187 -0
- data/lib/factorix/http/response.rb +31 -0
- data/lib/factorix/http/retry_decorator.rb +59 -0
- data/lib/factorix/http/retry_strategy.rb +80 -0
- data/lib/factorix/info_json.rb +90 -0
- data/lib/factorix/installed_mod.rb +239 -0
- data/lib/factorix/mod.rb +55 -0
- data/lib/factorix/mod_list.rb +174 -0
- data/lib/factorix/mod_settings.rb +278 -0
- data/lib/factorix/mod_state.rb +34 -0
- data/lib/factorix/mod_version.rb +99 -0
- data/lib/factorix/portal.rb +185 -0
- data/lib/factorix/progress/download_handler.rb +46 -0
- data/lib/factorix/progress/multi_presenter.rb +45 -0
- data/lib/factorix/progress/presenter.rb +67 -0
- data/lib/factorix/progress/presenter_adapter.rb +46 -0
- data/lib/factorix/progress/scan_handler.rb +33 -0
- data/lib/factorix/progress/upload_handler.rb +33 -0
- data/lib/factorix/runtime/base.rb +233 -0
- data/lib/factorix/runtime/linux.rb +32 -0
- data/lib/factorix/runtime/mac_os.rb +53 -0
- data/lib/factorix/runtime/user_configurable.rb +69 -0
- data/lib/factorix/runtime/windows.rb +85 -0
- data/lib/factorix/runtime/wsl.rb +118 -0
- data/lib/factorix/runtime.rb +32 -0
- data/lib/factorix/save_file.rb +178 -0
- data/lib/factorix/ser_des/deserializer.rb +198 -0
- data/lib/factorix/ser_des/serializer.rb +231 -0
- data/lib/factorix/ser_des/signed_integer.rb +63 -0
- data/lib/factorix/ser_des/unsigned_integer.rb +65 -0
- data/lib/factorix/service_credential.rb +127 -0
- data/lib/factorix/transfer/downloader.rb +162 -0
- data/lib/factorix/transfer/uploader.rb +232 -0
- data/lib/factorix/version.rb +6 -0
- data/lib/factorix.rb +38 -0
- data/sig/dry/auto_inject.rbs +15 -0
- data/sig/dry/cli.rbs +19 -0
- data/sig/dry/configurable.rbs +13 -0
- data/sig/dry/core/container.rbs +17 -0
- data/sig/dry/events/publisher.rbs +22 -0
- data/sig/dry/logger.rbs +16 -0
- data/sig/factorix/api/category.rbs +15 -0
- data/sig/factorix/api/image.rbs +15 -0
- data/sig/factorix/api/license.rbs +20 -0
- data/sig/factorix/api/mod_download_api.rbs +18 -0
- data/sig/factorix/api/mod_info.rbs +67 -0
- data/sig/factorix/api/mod_management_api.rbs +25 -0
- data/sig/factorix/api/mod_portal_api.rbs +31 -0
- data/sig/factorix/api/release.rbs +27 -0
- data/sig/factorix/api/tag.rbs +15 -0
- data/sig/factorix/api.rbs +8 -0
- data/sig/factorix/api_credential.rbs +17 -0
- data/sig/factorix/application.rbs +86 -0
- data/sig/factorix/cache/file_system.rbs +35 -0
- data/sig/factorix/cli/commands/base.rbs +13 -0
- data/sig/factorix/cli/commands/cache/evict.rbs +17 -0
- data/sig/factorix/cli/commands/cache/stat.rbs +17 -0
- data/sig/factorix/cli/commands/command_wrapper.rbs +13 -0
- data/sig/factorix/cli/commands/completion/zsh.rbs +15 -0
- data/sig/factorix/cli/commands/confirmable.rbs +12 -0
- data/sig/factorix/cli/commands/download_support.rbs +12 -0
- data/sig/factorix/cli/commands/launch.rbs +15 -0
- data/sig/factorix/cli/commands/mod/check.rbs +18 -0
- data/sig/factorix/cli/commands/mod/disable.rbs +20 -0
- data/sig/factorix/cli/commands/mod/download.rbs +18 -0
- data/sig/factorix/cli/commands/mod/edit.rbs +30 -0
- data/sig/factorix/cli/commands/mod/enable.rbs +20 -0
- data/sig/factorix/cli/commands/mod/image/add.rbs +19 -0
- data/sig/factorix/cli/commands/mod/image/edit.rbs +19 -0
- data/sig/factorix/cli/commands/mod/image/list.rbs +19 -0
- data/sig/factorix/cli/commands/mod/install.rbs +19 -0
- data/sig/factorix/cli/commands/mod/list.rbs +30 -0
- data/sig/factorix/cli/commands/mod/search.rbs +18 -0
- data/sig/factorix/cli/commands/mod/settings/dump.rbs +17 -0
- data/sig/factorix/cli/commands/mod/settings/restore.rbs +17 -0
- data/sig/factorix/cli/commands/mod/sync.rbs +19 -0
- data/sig/factorix/cli/commands/mod/uninstall.rbs +20 -0
- data/sig/factorix/cli/commands/mod/update.rbs +19 -0
- data/sig/factorix/cli/commands/mod/upload.rbs +24 -0
- data/sig/factorix/cli/commands/path.rbs +18 -0
- data/sig/factorix/cli/commands/requires_game_stopped.rbs +13 -0
- data/sig/factorix/cli/commands/version.rbs +13 -0
- data/sig/factorix/cli.rbs +11 -0
- data/sig/factorix/dependency/edge.rbs +32 -0
- data/sig/factorix/dependency/entry.rbs +30 -0
- data/sig/factorix/dependency/graph/builder.rbs +17 -0
- data/sig/factorix/dependency/graph.rbs +39 -0
- data/sig/factorix/dependency/list.rbs +69 -0
- data/sig/factorix/dependency/mod_version_requirement.rbs +18 -0
- data/sig/factorix/dependency/node.rbs +24 -0
- data/sig/factorix/dependency/parser.rbs +11 -0
- data/sig/factorix/dependency/validation_result.rbs +56 -0
- data/sig/factorix/dependency/validator.rbs +13 -0
- data/sig/factorix/errors.rbs +132 -0
- data/sig/factorix/formatting.rbs +8 -0
- data/sig/factorix/game_version.rbs +24 -0
- data/sig/factorix/http/cache_decorator.rbs +64 -0
- data/sig/factorix/http/client.rbs +55 -0
- data/sig/factorix/http/response.rbs +28 -0
- data/sig/factorix/http/retry_decorator.rbs +44 -0
- data/sig/factorix/http/retry_strategy.rbs +42 -0
- data/sig/factorix/info_json.rbs +19 -0
- data/sig/factorix/installed_mod.rbs +34 -0
- data/sig/factorix/mod.rbs +20 -0
- data/sig/factorix/mod_list.rbs +44 -0
- data/sig/factorix/mod_settings.rbs +47 -0
- data/sig/factorix/mod_state.rbs +18 -0
- data/sig/factorix/mod_version.rbs +23 -0
- data/sig/factorix/portal.rbs +37 -0
- data/sig/factorix/progress/download_handler.rbs +19 -0
- data/sig/factorix/progress/multi_presenter.rbs +15 -0
- data/sig/factorix/progress/presenter.rbs +17 -0
- data/sig/factorix/progress/presenter_adapter.rbs +17 -0
- data/sig/factorix/progress/scan_handler.rbs +16 -0
- data/sig/factorix/progress/upload_handler.rbs +17 -0
- data/sig/factorix/runtime/base.rbs +45 -0
- data/sig/factorix/runtime/linux.rbs +15 -0
- data/sig/factorix/runtime/mac_os.rbs +15 -0
- data/sig/factorix/runtime/user_configurable.rbs +13 -0
- data/sig/factorix/runtime/windows.rbs +23 -0
- data/sig/factorix/runtime/wsl.rbs +19 -0
- data/sig/factorix/runtime.rbs +9 -0
- data/sig/factorix/save_file.rbs +40 -0
- data/sig/factorix/ser_des/deserializer.rbs +49 -0
- data/sig/factorix/ser_des/serializer.rbs +45 -0
- data/sig/factorix/ser_des/signed_integer.rbs +37 -0
- data/sig/factorix/ser_des/unsigned_integer.rbs +37 -0
- data/sig/factorix/service_credential.rbs +19 -0
- data/sig/factorix/transfer/downloader.rbs +15 -0
- data/sig/factorix/transfer/uploader.rbs +21 -0
- data/sig/factorix.rbs +9 -0
- data/sig/tty/progressbar.rbs +18 -0
- metadata +431 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Factorix
|
|
4
|
+
class CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# Mixin for commands that backup files before writing
|
|
7
|
+
#
|
|
8
|
+
# This module provides:
|
|
9
|
+
# - --backup-extension option to specify backup file extension
|
|
10
|
+
# - backup_if_exists method to backup a file before overwriting
|
|
11
|
+
#
|
|
12
|
+
# Prepend this module to commands that modify mod-list.json or mod-settings.dat
|
|
13
|
+
module BackupSupport
|
|
14
|
+
# Hook called when this module is prepended to a class
|
|
15
|
+
# @param base [Class] the class prepending this module
|
|
16
|
+
def self.prepended(base)
|
|
17
|
+
base.class_eval do
|
|
18
|
+
option :backup_extension, type: :string, default: ".bak", desc: "Backup file extension"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Default backup extension
|
|
23
|
+
DEFAULT_BACKUP_EXTENSION = ".bak"
|
|
24
|
+
private_constant :DEFAULT_BACKUP_EXTENSION
|
|
25
|
+
|
|
26
|
+
# Store the --backup-extension option for use in backup_if_exists
|
|
27
|
+
# @param options [Hash] command options
|
|
28
|
+
def call(**options)
|
|
29
|
+
@backup_extension = options[:backup_extension] || DEFAULT_BACKUP_EXTENSION
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Backup existing file if it exists
|
|
34
|
+
#
|
|
35
|
+
# @param path [Pathname] File path to backup
|
|
36
|
+
# @return [void]
|
|
37
|
+
private def backup_if_exists(path)
|
|
38
|
+
return unless path.exist?
|
|
39
|
+
|
|
40
|
+
backup_path = Pathname("#{path}#{@backup_extension}")
|
|
41
|
+
path.rename(backup_path)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
require "tint_me"
|
|
5
|
+
|
|
6
|
+
module Factorix
|
|
7
|
+
class CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Base class for all CLI commands
|
|
10
|
+
#
|
|
11
|
+
# This class provides common functionality for all commands:
|
|
12
|
+
# - Common options (--config-path, --log-level, --quiet)
|
|
13
|
+
# - Common helper methods (say, quiet?)
|
|
14
|
+
# - Pre-call setup and error handling (via CommandWrapper prepended module)
|
|
15
|
+
#
|
|
16
|
+
# All command classes should inherit from this base class instead of
|
|
17
|
+
# directly from Dry::CLI::Command.
|
|
18
|
+
#
|
|
19
|
+
# @example Define a command
|
|
20
|
+
# class MyCommand < Base
|
|
21
|
+
# desc "My command description"
|
|
22
|
+
#
|
|
23
|
+
# def call(**)
|
|
24
|
+
# say "Hello, world!"
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
class Base < Dry::CLI::Command
|
|
28
|
+
# Emoji prefix mapping for common message types
|
|
29
|
+
EMOJI_PREFIXES = {
|
|
30
|
+
success: "\u{2713}", # CHECK MARK
|
|
31
|
+
info: "\u{2139}", # INFORMATION SOURCE
|
|
32
|
+
warn: "\u{26A0}\u{FE0E}", # WARNING SIGN (text presentation)
|
|
33
|
+
error: "\u{2717}", # BALLOT X
|
|
34
|
+
fatal: "\u{2620}\u{FE0E}" # SKULL AND CROSSBONES (text presentation)
|
|
35
|
+
}.freeze
|
|
36
|
+
private_constant :EMOJI_PREFIXES
|
|
37
|
+
|
|
38
|
+
# Plain style (no-op) for default output
|
|
39
|
+
PLAIN = TIntMe::Style[]
|
|
40
|
+
private_constant :PLAIN
|
|
41
|
+
|
|
42
|
+
# Color styles for message prefixes
|
|
43
|
+
STYLES = {
|
|
44
|
+
success: TIntMe[:green],
|
|
45
|
+
info: TIntMe[:cyan],
|
|
46
|
+
warn: TIntMe[:magenta],
|
|
47
|
+
error: TIntMe[:red],
|
|
48
|
+
fatal: TIntMe[:red, :bold]
|
|
49
|
+
}.freeze
|
|
50
|
+
private_constant :STYLES
|
|
51
|
+
|
|
52
|
+
# Prepend CommandWrapper to each command class that inherits from Base
|
|
53
|
+
def self.inherited(subclass)
|
|
54
|
+
super
|
|
55
|
+
subclass.prepend CommandWrapper
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Require that the game is not running when this command executes
|
|
59
|
+
# @return [void]
|
|
60
|
+
def self.require_game_stopped! = prepend RequiresGameStopped
|
|
61
|
+
|
|
62
|
+
# Enable confirmation prompts with --yes option
|
|
63
|
+
# @return [void]
|
|
64
|
+
def self.confirmable! = prepend Confirmable
|
|
65
|
+
|
|
66
|
+
# Enable backup support for file modifications
|
|
67
|
+
# @return [void]
|
|
68
|
+
def self.backup_support! = prepend BackupSupport
|
|
69
|
+
|
|
70
|
+
# Common options available to all commands
|
|
71
|
+
option :config_path, type: :string, aliases: ["-c"], desc: "Path to configuration file"
|
|
72
|
+
option :log_level, type: :string, values: %w[debug info warn error fatal], desc: "Set log level"
|
|
73
|
+
option :quiet, type: :flag, default: false, aliases: ["-q"], desc: "Suppress non-essential output"
|
|
74
|
+
|
|
75
|
+
private def say(message, prefix: "")
|
|
76
|
+
return if quiet?
|
|
77
|
+
|
|
78
|
+
resolved_prefix = EMOJI_PREFIXES.fetch(prefix) { prefix.to_s }
|
|
79
|
+
output = resolved_prefix.empty? ? message : "#{resolved_prefix} #{message}"
|
|
80
|
+
style = STYLES.fetch(prefix, PLAIN)
|
|
81
|
+
puts style[output]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private def quiet?
|
|
85
|
+
@quiet == true
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Factorix
|
|
4
|
+
class CLI
|
|
5
|
+
module Commands
|
|
6
|
+
module Cache
|
|
7
|
+
# Evict cache entries
|
|
8
|
+
#
|
|
9
|
+
# This command removes cache entries based on the specified criteria.
|
|
10
|
+
# At least one of --all, --expired, or --older-than must be specified.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# $ factorix cache evict --expired
|
|
14
|
+
# $ factorix cache evict api --all
|
|
15
|
+
# $ factorix cache evict download --older-than 7d
|
|
16
|
+
class Evict < Base
|
|
17
|
+
# @!parse
|
|
18
|
+
# # @return [Dry::Logger::Dispatcher]
|
|
19
|
+
# attr_reader :logger
|
|
20
|
+
include Import[:logger]
|
|
21
|
+
include Formatting
|
|
22
|
+
|
|
23
|
+
# Valid cache names for the caches argument
|
|
24
|
+
VALID_CACHES = %w[download api info_json].freeze
|
|
25
|
+
private_constant :VALID_CACHES
|
|
26
|
+
|
|
27
|
+
desc "Evict cache entries"
|
|
28
|
+
|
|
29
|
+
example [
|
|
30
|
+
"--expired # Remove expired entries from all caches",
|
|
31
|
+
"api --all # Remove all entries from api cache",
|
|
32
|
+
"download --older-than 7d # Remove entries older than 7 days"
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
argument :caches, type: :array, required: false, values: VALID_CACHES, desc: "Cache names"
|
|
36
|
+
|
|
37
|
+
option :all, type: :flag, default: false, desc: "Remove all entries"
|
|
38
|
+
option :expired, type: :flag, default: false, desc: "Remove expired entries only"
|
|
39
|
+
option :older_than, type: :string, default: nil, desc: "Remove entries older than AGE (e.g., 30s, 5m, 2h, 7d)"
|
|
40
|
+
|
|
41
|
+
# Execute the cache evict command
|
|
42
|
+
#
|
|
43
|
+
# @param caches [Array<String>, nil] cache names to evict
|
|
44
|
+
# @param all [Boolean] remove all entries
|
|
45
|
+
# @param expired [Boolean] remove expired entries only
|
|
46
|
+
# @param older_than [String, nil] remove entries older than this age
|
|
47
|
+
# @return [void]
|
|
48
|
+
def call(caches: nil, all: false, expired: false, older_than: nil, **)
|
|
49
|
+
validate_options!(all, expired, older_than)
|
|
50
|
+
|
|
51
|
+
@now = Time.now
|
|
52
|
+
@older_than_seconds = parse_age(older_than) if older_than
|
|
53
|
+
|
|
54
|
+
cache_names = resolve_cache_names(caches)
|
|
55
|
+
results = cache_names.to_h {|name| [name, evict_cache(name, all:, expired:)] }
|
|
56
|
+
|
|
57
|
+
output_results(results)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Validate that exactly one eviction option is specified
|
|
61
|
+
#
|
|
62
|
+
# @param all [Boolean] --all option
|
|
63
|
+
# @param expired [Boolean] --expired option
|
|
64
|
+
# @param older_than [String, nil] --older-than option
|
|
65
|
+
# @return [void]
|
|
66
|
+
# @raise [InvalidArgumentError] if options are invalid
|
|
67
|
+
private def validate_options!(all, expired, older_than)
|
|
68
|
+
options_count = [all, expired, older_than].count {|opt| opt }
|
|
69
|
+
|
|
70
|
+
raise InvalidArgumentError, "One of --all, --expired, or --older-than must be specified" if options_count == 0
|
|
71
|
+
raise InvalidArgumentError, "Only one of --all, --expired, or --older-than can be specified" if options_count > 1
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Parse age string into seconds
|
|
75
|
+
#
|
|
76
|
+
# @param age [String] age string (e.g., "30s", "5m", "2h", "7d")
|
|
77
|
+
# @return [Integer] age in seconds
|
|
78
|
+
# @raise [InvalidArgumentError] if age format is invalid
|
|
79
|
+
DURATION_MULTIPLIERS = {"s" => 1, "m" => 60, "h" => 3600, "d" => 86400}.freeze
|
|
80
|
+
private_constant :DURATION_MULTIPLIERS
|
|
81
|
+
|
|
82
|
+
private def parse_age(age)
|
|
83
|
+
match = age.match(/\A(\d+)([smhd])\z/)
|
|
84
|
+
raise InvalidArgumentError, "Invalid age format: #{age}. Use format like 30s, 5m, 2h, 7d" unless match
|
|
85
|
+
|
|
86
|
+
value = Integer(match[1])
|
|
87
|
+
unit = match[2]
|
|
88
|
+
|
|
89
|
+
value * DURATION_MULTIPLIERS.fetch(unit)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Resolve cache names from argument or return all
|
|
93
|
+
#
|
|
94
|
+
# @param caches [Array<String>, nil] cache names from argument
|
|
95
|
+
# @return [Array<Symbol>] resolved cache names
|
|
96
|
+
# @raise [InvalidArgumentError] if unknown cache name specified
|
|
97
|
+
private def resolve_cache_names(caches)
|
|
98
|
+
all_caches = Application.config.cache.values.keys
|
|
99
|
+
|
|
100
|
+
return all_caches if caches.nil? || caches.empty?
|
|
101
|
+
|
|
102
|
+
caches.map do |name|
|
|
103
|
+
sym = name.to_sym
|
|
104
|
+
raise InvalidArgumentError, "Unknown cache: #{name}. Valid caches: #{all_caches.join(", ")}" unless all_caches.include?(sym)
|
|
105
|
+
|
|
106
|
+
sym
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Evict entries from a single cache
|
|
111
|
+
#
|
|
112
|
+
# @param name [Symbol] cache name
|
|
113
|
+
# @param all [Boolean] remove all entries
|
|
114
|
+
# @param expired [Boolean] remove expired entries only
|
|
115
|
+
# @return [Hash] eviction result with :count and :size
|
|
116
|
+
private def evict_cache(name, all:, expired:)
|
|
117
|
+
config = Application.config.cache.public_send(name)
|
|
118
|
+
cache_dir = config.dir
|
|
119
|
+
ttl = config.ttl
|
|
120
|
+
|
|
121
|
+
return {count: 0, size: 0} unless cache_dir.exist?
|
|
122
|
+
|
|
123
|
+
count = 0
|
|
124
|
+
size = 0
|
|
125
|
+
|
|
126
|
+
cache_dir.glob("**/*").each do |path|
|
|
127
|
+
next unless path.file?
|
|
128
|
+
next if path.extname == ".lock"
|
|
129
|
+
|
|
130
|
+
next unless should_evict?(path, ttl, all:, expired:)
|
|
131
|
+
|
|
132
|
+
size += path.size
|
|
133
|
+
path.delete
|
|
134
|
+
count += 1
|
|
135
|
+
logger.debug("Evicted cache entry", path: path.to_s)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
logger.info("Evicted cache entries", cache: name, count:, size:)
|
|
139
|
+
{count:, size:}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Determine if a cache entry should be evicted
|
|
143
|
+
#
|
|
144
|
+
# @param path [Pathname] path to cache entry
|
|
145
|
+
# @param ttl [Integer, nil] cache TTL
|
|
146
|
+
# @param all [Boolean] remove all entries
|
|
147
|
+
# @param expired [Boolean] remove expired entries only
|
|
148
|
+
# @return [Boolean] true if entry should be evicted
|
|
149
|
+
private def should_evict?(path, ttl, all:, expired:)
|
|
150
|
+
return true if all
|
|
151
|
+
|
|
152
|
+
age_seconds = @now - path.mtime
|
|
153
|
+
|
|
154
|
+
if expired
|
|
155
|
+
return false if ttl.nil? # No TTL means never expires
|
|
156
|
+
|
|
157
|
+
age_seconds > ttl
|
|
158
|
+
else
|
|
159
|
+
# --older-than
|
|
160
|
+
age_seconds > @older_than_seconds
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Output eviction results
|
|
165
|
+
#
|
|
166
|
+
# @param results [Hash] results for each cache
|
|
167
|
+
# @return [void]
|
|
168
|
+
private def output_results(results)
|
|
169
|
+
# Calculate max width for alignment
|
|
170
|
+
max_name_width = results.keys.map {|k| k.to_s.length }.max
|
|
171
|
+
|
|
172
|
+
results.each do |name, data|
|
|
173
|
+
say "%-#{max_name_width}s: %3d entries removed (%s)" % [name, data[:count], format_size(data[:size])], prefix: :info
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Factorix
|
|
6
|
+
class CLI
|
|
7
|
+
module Commands
|
|
8
|
+
module Cache
|
|
9
|
+
# Display cache statistics
|
|
10
|
+
#
|
|
11
|
+
# This command outputs statistics for all cache stores
|
|
12
|
+
# in a human-readable or JSON format.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# $ factorix cache stat
|
|
16
|
+
# download:
|
|
17
|
+
# Directory: ~/.cache/factorix/download
|
|
18
|
+
# TTL: unlimited
|
|
19
|
+
# Entries: 42 / 42 (100.0% valid)
|
|
20
|
+
# ...
|
|
21
|
+
class Stat < Base
|
|
22
|
+
# @!parse
|
|
23
|
+
# # @return [Dry::Logger::Dispatcher]
|
|
24
|
+
# attr_reader :logger
|
|
25
|
+
include Import[:logger]
|
|
26
|
+
include Formatting
|
|
27
|
+
|
|
28
|
+
desc "Display cache statistics"
|
|
29
|
+
|
|
30
|
+
example [
|
|
31
|
+
" # Display statistics in text format",
|
|
32
|
+
"--json # Display statistics in JSON format"
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
option :json, type: :flag, default: false, desc: "Output in JSON format"
|
|
36
|
+
|
|
37
|
+
# Execute the cache stat command
|
|
38
|
+
#
|
|
39
|
+
# @param json [Boolean] output in JSON format
|
|
40
|
+
# @return [void]
|
|
41
|
+
def call(json:, **)
|
|
42
|
+
logger.debug("Collecting cache statistics")
|
|
43
|
+
|
|
44
|
+
@now = Time.now
|
|
45
|
+
cache_names = Application.config.cache.values.keys
|
|
46
|
+
stats = cache_names.to_h {|name| [name, collect_stats(name)] }
|
|
47
|
+
|
|
48
|
+
if json
|
|
49
|
+
puts JSON.pretty_generate(stats)
|
|
50
|
+
else
|
|
51
|
+
output_text(stats)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private def collect_stats(name)
|
|
56
|
+
config = Application.config.cache.public_send(name)
|
|
57
|
+
cache_dir = config.dir
|
|
58
|
+
|
|
59
|
+
entries = scan_entries(cache_dir, config.ttl)
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
directory: cache_dir.to_s,
|
|
63
|
+
ttl: config.ttl,
|
|
64
|
+
max_file_size: config.max_file_size,
|
|
65
|
+
compression_threshold: config.compression_threshold,
|
|
66
|
+
entries: build_entry_stats(entries),
|
|
67
|
+
size: build_size_stats(entries),
|
|
68
|
+
age: build_age_stats(entries),
|
|
69
|
+
stale_locks: count_stale_locks(cache_dir)
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Scan cache directory and collect entry information
|
|
74
|
+
#
|
|
75
|
+
# @param cache_dir [Pathname] cache directory path
|
|
76
|
+
# @param ttl [Integer, nil] time-to-live in seconds
|
|
77
|
+
# @return [Array<Hash>] array of entry info hashes
|
|
78
|
+
private def scan_entries(cache_dir, ttl)
|
|
79
|
+
return [] unless cache_dir.exist?
|
|
80
|
+
|
|
81
|
+
entries = []
|
|
82
|
+
cache_dir.glob("**/*").each do |path|
|
|
83
|
+
next unless path.file?
|
|
84
|
+
next if path.extname == ".lock"
|
|
85
|
+
|
|
86
|
+
age_seconds = @now - path.mtime
|
|
87
|
+
expired = ttl ? age_seconds > ttl : false
|
|
88
|
+
|
|
89
|
+
entries << {size: path.size, age: age_seconds, expired:}
|
|
90
|
+
end
|
|
91
|
+
entries
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Build entry count statistics
|
|
95
|
+
#
|
|
96
|
+
# @param entries [Array<Hash>] entry info array
|
|
97
|
+
# @return [Hash] entry statistics
|
|
98
|
+
private def build_entry_stats(entries)
|
|
99
|
+
total = entries.size
|
|
100
|
+
valid = entries.count {|e| !e[:expired] }
|
|
101
|
+
expired = total - valid
|
|
102
|
+
|
|
103
|
+
{total:, valid:, expired:}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Build size statistics
|
|
107
|
+
#
|
|
108
|
+
# @param entries [Array<Hash>] entry info array
|
|
109
|
+
# @return [Hash] size statistics
|
|
110
|
+
private def build_size_stats(entries)
|
|
111
|
+
return {total: 0, avg: 0, min: 0, max: 0} if entries.empty?
|
|
112
|
+
|
|
113
|
+
sizes = entries.map {|e| e[:size] }
|
|
114
|
+
{total: sizes.sum, avg: sizes.sum / sizes.size, min: sizes.min, max: sizes.max}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Build age statistics
|
|
118
|
+
#
|
|
119
|
+
# @param entries [Array<Hash>] entry info array
|
|
120
|
+
# @return [Hash] age statistics
|
|
121
|
+
private def build_age_stats(entries)
|
|
122
|
+
return {oldest: nil, newest: nil, avg: nil} if entries.empty?
|
|
123
|
+
|
|
124
|
+
ages = entries.map {|e| e[:age] }
|
|
125
|
+
{oldest: ages.max, newest: ages.min, avg: ages.sum / ages.size}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Count stale lock files
|
|
129
|
+
#
|
|
130
|
+
# @param cache_dir [Pathname] cache directory path
|
|
131
|
+
# @return [Integer] number of stale lock files
|
|
132
|
+
private def count_stale_locks(cache_dir)
|
|
133
|
+
return 0 unless cache_dir.exist?
|
|
134
|
+
|
|
135
|
+
lock_lifetime = Factorix::Cache::FileSystem::LOCK_FILE_LIFETIME
|
|
136
|
+
cache_dir.glob("**/*.lock").count {|path| @now - path.mtime > lock_lifetime }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Output statistics in text format (ccache-style)
|
|
140
|
+
#
|
|
141
|
+
# @param stats [Hash] statistics for all caches
|
|
142
|
+
# @return [void]
|
|
143
|
+
private def output_text(stats)
|
|
144
|
+
stats.each_with_index do |(name, data), index|
|
|
145
|
+
puts if index > 0
|
|
146
|
+
puts "#{name}:"
|
|
147
|
+
output_cache_stats(data)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Output statistics for a single cache
|
|
152
|
+
#
|
|
153
|
+
# @param data [Hash] cache statistics
|
|
154
|
+
# @return [void]
|
|
155
|
+
private def output_cache_stats(data)
|
|
156
|
+
puts " Directory: #{data[:directory]}"
|
|
157
|
+
puts " TTL: #{format_ttl(data[:ttl])}"
|
|
158
|
+
puts " Max file size: #{format_size(data[:max_file_size])}"
|
|
159
|
+
puts " Compression: #{format_compression(data[:compression_threshold])}"
|
|
160
|
+
|
|
161
|
+
entries = data[:entries]
|
|
162
|
+
valid_pct = entries[:total] > 0 ? (Float(entries[:valid]) / entries[:total] * 100) : 0.0
|
|
163
|
+
puts " Entries: #{entries[:valid]} / #{entries[:total]} (#{"%.1f" % valid_pct}% valid)"
|
|
164
|
+
|
|
165
|
+
size = data[:size]
|
|
166
|
+
puts " Size: #{format_size(size[:total])} (avg #{format_size(size[:avg])})"
|
|
167
|
+
|
|
168
|
+
age = data[:age]
|
|
169
|
+
if age[:oldest]
|
|
170
|
+
puts " Age: #{format_duration(age[:newest])} - #{format_duration(age[:oldest])} (avg #{format_duration(age[:avg])})"
|
|
171
|
+
else
|
|
172
|
+
puts " Age: -"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
puts " Stale locks: #{data[:stale_locks]}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Format TTL value for display
|
|
179
|
+
#
|
|
180
|
+
# @param ttl [Integer, nil] TTL in seconds
|
|
181
|
+
# @return [String] formatted TTL
|
|
182
|
+
private def format_ttl(ttl)
|
|
183
|
+
ttl.nil? ? "unlimited" : format_duration(ttl)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Format compression threshold for display
|
|
187
|
+
#
|
|
188
|
+
# @param threshold [Integer, nil] compression threshold in bytes
|
|
189
|
+
# @return [String] formatted compression setting
|
|
190
|
+
private def format_compression(threshold)
|
|
191
|
+
case threshold
|
|
192
|
+
when nil then "disabled"
|
|
193
|
+
when 0 then "enabled (always)"
|
|
194
|
+
else "enabled (>= #{format_size(threshold)})"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Factorix
|
|
6
|
+
class CLI
|
|
7
|
+
module Commands
|
|
8
|
+
# Module that wraps command execution to perform setup and error handling
|
|
9
|
+
#
|
|
10
|
+
# This module is prepended to Base to ensure configuration loading, log level
|
|
11
|
+
# setup, and consistent error handling happen for every command execution.
|
|
12
|
+
module CommandWrapper
|
|
13
|
+
# Performs setup before command execution, then calls the command's implementation
|
|
14
|
+
# Catches exceptions and displays user-friendly error messages
|
|
15
|
+
#
|
|
16
|
+
# @param options [Hash] command options including :config_path and :log_level
|
|
17
|
+
def call(**options)
|
|
18
|
+
@quiet = options[:quiet]
|
|
19
|
+
|
|
20
|
+
load_config!(options[:config_path])
|
|
21
|
+
log_level!(options[:log_level]) if options[:log_level]
|
|
22
|
+
|
|
23
|
+
super
|
|
24
|
+
rescue Error => e
|
|
25
|
+
# Expected errors (validation failures, missing dependencies, etc.)
|
|
26
|
+
log = Application[:logger]
|
|
27
|
+
log.warn(e.message)
|
|
28
|
+
log.debug(e)
|
|
29
|
+
say "Error: #{e.message}", prefix: :error unless @quiet
|
|
30
|
+
raise # Re-raise for exe/factorix to handle exit code
|
|
31
|
+
rescue => e
|
|
32
|
+
# Unexpected errors (bugs, system failures, etc.)
|
|
33
|
+
log = Application[:logger]
|
|
34
|
+
log.error(e)
|
|
35
|
+
say "Unexpected error: #{e.message}", prefix: :error unless @quiet
|
|
36
|
+
raise # Re-raise for exe/factorix to handle exit code
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private def load_config!(explicit_path)
|
|
40
|
+
path = resolve_config_path(explicit_path)
|
|
41
|
+
return unless path
|
|
42
|
+
|
|
43
|
+
Application.load_config(path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resolves which config path to use
|
|
47
|
+
# @param explicit_path [String, nil] path specified via --config-path
|
|
48
|
+
# @return [Pathname, nil] path to load, or nil if none should be loaded
|
|
49
|
+
private def resolve_config_path(explicit_path)
|
|
50
|
+
return Pathname(explicit_path) if explicit_path
|
|
51
|
+
return Pathname(ENV.fetch("FACTORIX_CONFIG")) if ENV["FACTORIX_CONFIG"]
|
|
52
|
+
|
|
53
|
+
default_path = Application[:runtime].factorix_config_path
|
|
54
|
+
default_path.exist? ? default_path : nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Sets the application logger's level
|
|
58
|
+
# @param level [String] log level (debug, info, warn, error, fatal)
|
|
59
|
+
private def log_level!(level)
|
|
60
|
+
logger = Application[:logger]
|
|
61
|
+
level_constant = Logger.const_get(level.upcase)
|
|
62
|
+
|
|
63
|
+
# Change only the File backend (first backend) level
|
|
64
|
+
# Dispatcher is always set to DEBUG to allow all messages through
|
|
65
|
+
file_backend = logger.backends.first
|
|
66
|
+
file_backend.level = level_constant if file_backend.respond_to?(:level=)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Factorix
|
|
4
|
+
class CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# Generate shell completion script for factorix
|
|
7
|
+
#
|
|
8
|
+
# This command outputs a shell completion script that can be evaluated
|
|
9
|
+
# to enable command-line completion for factorix.
|
|
10
|
+
#
|
|
11
|
+
# @example Enable completion (auto-detect shell)
|
|
12
|
+
# eval "$(factorix completion)"
|
|
13
|
+
# @example Enable completion for specific shell
|
|
14
|
+
# eval "$(factorix completion zsh)"
|
|
15
|
+
class Completion < Base
|
|
16
|
+
# Directory containing completion scripts
|
|
17
|
+
COMPLETION_DIR = Pathname(__dir__).join("../../../../completion").freeze
|
|
18
|
+
private_constant :COMPLETION_DIR
|
|
19
|
+
|
|
20
|
+
# Supported shells and their script filenames
|
|
21
|
+
SUPPORTED_SHELLS = {
|
|
22
|
+
"bash" => "_factorix.bash",
|
|
23
|
+
"fish" => "_factorix.fish",
|
|
24
|
+
"zsh" => "_factorix.zsh"
|
|
25
|
+
}.freeze
|
|
26
|
+
private_constant :SUPPORTED_SHELLS
|
|
27
|
+
|
|
28
|
+
desc "Generate shell completion script"
|
|
29
|
+
|
|
30
|
+
argument :shell,
|
|
31
|
+
type: :string,
|
|
32
|
+
required: false,
|
|
33
|
+
values: SUPPORTED_SHELLS.keys,
|
|
34
|
+
desc: "Shell type. Defaults to current shell from $SHELL"
|
|
35
|
+
|
|
36
|
+
example [
|
|
37
|
+
" # Output completion script for current shell",
|
|
38
|
+
"bash # Output bash completion script",
|
|
39
|
+
"fish # Output fish completion script",
|
|
40
|
+
"zsh # Output zsh completion script"
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Execute the completion command
|
|
44
|
+
#
|
|
45
|
+
# @param shell [String, nil] Shell type (zsh, bash, fish)
|
|
46
|
+
# @return [void]
|
|
47
|
+
# @raise [InvalidArgumentError] if shell type cannot be detected or is unsupported
|
|
48
|
+
# @raise [ConfigurationError] if completion script not found
|
|
49
|
+
def call(shell: nil, **)
|
|
50
|
+
shell_type = shell || detect_shell
|
|
51
|
+
validate_shell!(shell_type)
|
|
52
|
+
|
|
53
|
+
script_path = COMPLETION_DIR / SUPPORTED_SHELLS[shell_type]
|
|
54
|
+
raise ConfigurationError, "#{shell_type.capitalize} completion script not found" unless script_path.exist?
|
|
55
|
+
|
|
56
|
+
puts script_path.read
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Detect shell type from SHELL environment variable
|
|
60
|
+
#
|
|
61
|
+
# @return [String] Detected shell type
|
|
62
|
+
private def detect_shell
|
|
63
|
+
shell_path = ENV.fetch("SHELL", "")
|
|
64
|
+
shell_name = File.basename(shell_path)
|
|
65
|
+
|
|
66
|
+
return shell_name if SUPPORTED_SHELLS.key?(shell_name)
|
|
67
|
+
|
|
68
|
+
raise InvalidArgumentError, "Cannot detect shell type from SHELL=#{shell_path}. Please specify: #{SUPPORTED_SHELLS.keys.join(", ")}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Validate shell type
|
|
72
|
+
#
|
|
73
|
+
# @param shell [String] Shell type to validate
|
|
74
|
+
# @raise [InvalidArgumentError] If shell type is not supported
|
|
75
|
+
private def validate_shell!(shell)
|
|
76
|
+
return if SUPPORTED_SHELLS.key?(shell)
|
|
77
|
+
|
|
78
|
+
raise InvalidArgumentError, "Unsupported shell: #{shell}. Supported shells: #{SUPPORTED_SHELLS.keys.join(", ")}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|