mmi 0.2.1 → 0.2.3

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.
@@ -1,55 +1,40 @@
1
1
  require 'fileutils'
2
2
  require 'nokogiri'
3
+ require 'open-uri'
3
4
 
4
- require 'mmi/cached_download'
5
- require 'mmi/option_attributes'
5
+ require 'mmi/install_utils'
6
+ require 'mmi/constants'
7
+ require 'mmi/content_hash/sha512'
8
+ require 'mmi/property_attributes'
6
9
 
7
10
  module Mmi
8
11
  module Modloader
9
12
  class Fabric
10
- include Mmi::OptionAttributes
13
+ prepend Mmi::PropertyAttributes
11
14
 
12
- opt_accessor :version
13
- opt_accessor :install_type
14
- opt_accessor :mcversion, 'minecraft_version'
15
- opt_accessor(:install_dir ) { Mmi.minecraft_dir }
16
- opt_accessor(:download_mc, 'download_minecraft') { false }
15
+ property :version
16
+ property :install_type, validate: :validate_install_type
17
+ property :minecraft_version
18
+ property :install_dir, default: Mmi::Constants.minecraft_dir
19
+ property :download_minecraft, default: false, validate: :validate_download_minecraft
17
20
 
18
- def initialize(options)
19
- @options = options
20
-
21
- parse!
21
+ def self.allowed_install_types
22
+ %w[
23
+ client
24
+ server
25
+ ]
22
26
  end
23
27
 
24
- def parse!
25
- if self.version
26
- if self.install_type
27
- if allowed_install_types.include?(self.install_type)
28
- if self.mcversion
29
- if [true, false].include?(self.download_mc)
30
- # Pass.
31
- else
32
- raise Mmi::InvalidAttributeError, %Q(Invalid "modloader.download_minecraft". Expecting true or false, got #{self.download_mc.inspect}.)
33
- end
34
- else
35
- raise Mmi::MissingAttributeError, 'Missing "modloader.minecraft_version".'
36
- end
37
- else
38
- raise Mmi::InvalidAttributeError, %Q(Invalid "modloader.install_type". Expecting "client" or "server", got #{self.install_type.inspect}.)
39
- end
40
- else
41
- raise Mmi::MissingAttributeError, 'Missing "modloader.install_type".'
42
- end
43
- else
44
- raise Mmi::MissingAttributeError, 'Missing "modloader.version".'
28
+ def self.validate_install_type(value, errors)
29
+ if !allowed_install_types.include?(value)
30
+ errors << %Q{modloader "install_type" must be one of #{allowed_install_types.map(&:inspect).join(', ')}}
45
31
  end
46
32
  end
47
33
 
48
- def allowed_install_types
49
- %w[
50
- client
51
- server
52
- ]
34
+ def self.validate_download_minecraft(value, errors)
35
+ if ![true, false].include?(value)
36
+ errors << 'modloader "download_minecraft" must be true or false'
37
+ end
53
38
  end
54
39
 
55
40
  def base_uri
@@ -65,7 +50,7 @@ module Mmi
65
50
  end
66
51
 
67
52
  def metadata_path
68
- File.join(Mmi.cache_dir, 'fabric_maven_metadata.xml')
53
+ File.join(Mmi::Constants.cache_dir, 'fabric_maven_metadata.xml')
69
54
  end
70
55
 
71
56
  def installer_uri
@@ -77,7 +62,7 @@ module Mmi
77
62
  end
78
63
 
79
64
  def installer_path
80
- File.join(Mmi.cache_dir, "fabric-installer-#{self.version}.jar")
65
+ File.join(Mmi::Constants.cache_dir, "fabric-installer-#{self.version}.jar")
81
66
  end
82
67
 
83
68
  def absolute_install_dir
@@ -85,19 +70,25 @@ module Mmi
85
70
  end
86
71
 
87
72
  def download_installer
88
- Mmi.info "Downloading fabric-installer version #{self.version.inspect}."
73
+ installer_hash = Mmi::ContentHash::Sha512.new(URI.parse(installer_sha512sum_uri).read)
89
74
 
90
- begin
91
- Mmi::CachedDownload.download_cached(installer_uri, installer_path, sha512_uri: installer_sha512sum_uri)
92
- rescue OpenURI::HTTPError => e
93
- Mmi.fail! %Q(Error when requesting fabric installer. Maybe "modloader.version" == #{version.inspect} is invalid.\n#{e.inspect})
75
+ if !File.exist?(installer_path) || !installer_hash.match_file?(installer_path)
76
+ Mmi.info "Downloading fabric-installer version #{self.version.inspect}."
77
+
78
+ begin
79
+ Mmi::InstallUtils.download_to_file(installer_uri, installer_path, installer_hash)
80
+ rescue OpenURI::HTTPError => e
81
+ Mmi.fail! %Q{Error when requesting fabric installer. Maybe "modloader.version" == #{version.inspect} is invalid.\n#{e.inspect}}
82
+ end
83
+ else
84
+ Mmi.info "Using cached fabric-installer version #{self.version.inspect}."
94
85
  end
95
86
  end
96
87
 
97
88
  def run_installer
98
89
  FileUtils.mkdir_p(absolute_install_dir)
99
90
 
100
- if system('java', '-jar', installer_path, self.install_type, '-dir', absolute_install_dir, '-noprofile', '-mcversion', self.mcversion, self.download_mc ? '-downloadMinecraft' : '')
91
+ if system('java', '-jar', installer_path, self.install_type, '-dir', absolute_install_dir, '-noprofile', '-mcversion', self.minecraft_version, self.download_minecraft ? '-downloadMinecraft' : '')
101
92
  # Pass.
102
93
  else
103
94
  Mmi.fail! 'Failed to install Fabric modloader.'
@@ -111,7 +102,7 @@ module Mmi
111
102
 
112
103
  def available_versions
113
104
  begin
114
- Mmi::CachedDownload.download_cached(metadata_uri, metadata_path, sha512_uri: metadata_sha512sum_uri)
105
+ Mmi::InstallUtils.download_to_file(metadata_uri, metadata_path, Mmi::ContentHash::Sha512.new(URI.parse(metadata_sha512sum_uri).read))
115
106
  rescue OpenURI::HTTPError => e
116
107
  Mmi.fail! "Error when requesting available fabric installer versions.\n#{e.inspect}"
117
108
  end
@@ -1,10 +1,14 @@
1
1
  module Mmi
2
2
  module Modloader
3
3
  class None
4
- def initialize(options=nil)
4
+ def initialize
5
5
  # Not installing anything requires no configuration or setup.
6
6
  end
7
7
 
8
+ def self.parse(*)
9
+ new
10
+ end
11
+
8
12
  def install
9
13
  # Nothing to do.
10
14
  end
@@ -6,8 +6,8 @@ module Mmi
6
6
  BASE_URL = URI('https://api.modrinth.com/v2/')
7
7
 
8
8
  class << self
9
- def project_versions(mod_slug)
10
- JSON.parse((BASE_URL + "project/#{mod_slug}/version").open.read)
9
+ def project_versions(mod_slug, loader: nil, game_version: nil)
10
+ JSON.parse((BASE_URL + "project/#{mod_slug}/version?#{URI.encode_www_form(loaders: (%Q{["#{loader}"]} if loader), game_versions: (%Q{["#{game_version}"]} if game_version))}").open.read)
11
11
  end
12
12
  end
13
13
  end
@@ -0,0 +1,192 @@
1
+ module Mmi
2
+ module PropertyAttributes
3
+ def initialize(hash)
4
+ @hash = hash
5
+ end
6
+
7
+ def to_h
8
+ @hash
9
+ end
10
+
11
+ def [](key)
12
+ @hash[key]
13
+ end
14
+
15
+ module ClassMethods
16
+ PropertyConfiguration = Struct.new(:key, :type, :required, :default, :requires, :conflicts, :validate)
17
+
18
+ def registered_properties
19
+ @registered_properties ||= {}
20
+ end
21
+
22
+ def property(method_name, key=method_name.to_s, type: nil, required: nil, default: nil, requires: [], conflicts: [], validate: nil)
23
+ raise(ArgumentError, '"method_name" must be a Symbol' ) if !method_name.is_a?(Symbol)
24
+ raise(ArgumentError, %Q{#{type.inspect} is not a valid "type"}) if !valid_atomic_type?(type) && !valid_hash_type?(type)
25
+
26
+ requires = [requires ] if !requires .is_a?(Array)
27
+ conflicts = [conflicts] if !conflicts.is_a?(Array)
28
+
29
+ raise(ArgumentError, '"required" is mutually exclusive with "requires" and "conflicts"') if required && (requires.any? || conflicts.any?)
30
+ raise(ArgumentError, '"requires" and "conflicts" must not share entries' ) if requires.intersect?(conflicts)
31
+ raise(ArgumentError, '"required" is mutually exclusive with "default"' ) if required && !default.nil?
32
+
33
+ required = requires.none? && conflicts.none? && default.nil? if required.nil?
34
+
35
+ raise(ArgumentError, '"validate" must be nil, a symbol or a Proc') if !validate.nil? && !validate.is_a?(Symbol) && !validate.is_a?(Proc)
36
+
37
+ registered_properties[method_name] = PropertyConfiguration.new(key: key, type: type, required: required, default: default, requires: requires, conflicts: conflicts, validate: validate)
38
+
39
+ define_method(method_name) do
40
+ parsed_property_store[method_name]
41
+ end
42
+ end
43
+
44
+ def parse(hash)
45
+ new(hash).parse!
46
+ end
47
+
48
+ def valid_atomic_type?(type)
49
+ type.nil? || type.respond_to?(:parse)
50
+ end
51
+
52
+ def valid_hash_type?(type_hash)
53
+ type_hash.is_a?(Hash) && type_hash.key?(:field) && type_hash.key?(:types) && type_hash.fetch(:types).is_a?(Hash) && type_hash.fetch(:types).all? do |_, type|
54
+ valid_atomic_type?(type)
55
+ end
56
+ end
57
+ end
58
+
59
+ def self.prepended(klass)
60
+ klass.extend(ClassMethods)
61
+ end
62
+
63
+ def parsed_property_store
64
+ @parsed_property_store ||= {}
65
+ end
66
+
67
+ class ValidationError < StandardError; end
68
+
69
+ def validate_constraints!
70
+ errors =
71
+ self.class.registered_properties.map do |method_name, configuration|
72
+ [
73
+ method_name,
74
+ [].tap do |property_errors|
75
+ if configuration.required && !@hash.key?(configuration.key)
76
+ property_errors << "missing field #{configuration.key}"
77
+ elsif !configuration.required && @hash.key?(configuration.key)
78
+ if (missing_requires = configuration.requires.reject{ |e| @hash.key?(e) }).any?
79
+ property_errors << "missing required field(s) #{missing_requires.map(&:inspect).join(', ')}"
80
+ end
81
+
82
+ if (present_conflicts = configuration.conflicts.select{ |e| @hash.key?(e) }).any?
83
+ property_errors << "conflicting with field(s) #{present_conflicts.map(&:inspect).join(', ')}"
84
+ end
85
+ end
86
+
87
+ if @hash.key?(configuration.key) && self.class.valid_hash_type?(configuration.type)
88
+ if !@hash.fetch(configuration.key).is_a?(Hash)
89
+ property_errors << "field #{configuration.key.inspect} must be a Hash"
90
+ elsif !@hash.fetch(configuration.key).key?(configuration.type[:field]) || !configuration.type[:types].keys.include?(@hash.fetch(configuration.key).fetch(configuration.type[:field]))
91
+ property_errors << "field #{configuration.key.inspect} must have key #{configuration.type[:field].inspect} with one of values #{configuration.type[:types].keys.map(&:inspect).join(', ')}"
92
+ end
93
+ end
94
+
95
+ if @hash.key?(configuration.key) && configuration.validate
96
+ deduced_proc =
97
+ if configuration.validate.is_a?(Symbol)
98
+ self.class.method(configuration.validate)
99
+ else
100
+ configuration.validate
101
+ end
102
+
103
+ deduced_proc.call(@hash[configuration.key], property_errors)
104
+ end
105
+ end,
106
+ ]
107
+ end.select do |_, property_errors|
108
+ property_errors.any?
109
+ end.to_h
110
+
111
+ if errors.none?
112
+ true
113
+ else
114
+ raise(ValidationError, errors.map do |method_name, property_errors|
115
+ "#{method_name}: #{property_errors.join(', ')}"
116
+ end.join('; '))
117
+ end
118
+ end
119
+
120
+ def parse!
121
+ validate_constraints!
122
+
123
+ self.class.registered_properties.each do |method_name, configuration|
124
+ if !@hash.key?(configuration.key)
125
+ if configuration.required || !configuration.default.nil?
126
+ parsed_property_store[method_name] = configuration.default
127
+ else
128
+ # Do nothing.
129
+ end
130
+ elsif configuration.type.nil?
131
+ parsed_property_store[method_name] = self[configuration.key]
132
+ else
133
+ deduced_type =
134
+ if self.class.valid_hash_type?(configuration.type)
135
+ configuration.type[:types].fetch(self[configuration.key][configuration.type[:field]])
136
+ else
137
+ configuration.type
138
+ end
139
+
140
+ initializer_proc = proc do |item|
141
+ deduced_type.parse(item)
142
+ end
143
+
144
+ parsed_property_store[method_name] =
145
+ self[configuration.key].then do |value|
146
+ value.is_a?(Array) ? value.map(&initializer_proc) : initializer_proc[value]
147
+ end
148
+ end
149
+ end
150
+
151
+ self
152
+ end
153
+
154
+ def update_properties!(properties)
155
+ raise(ArgumentError, 'argument must be a Hash' ) if !properties.is_a?(Hash)
156
+ raise(ArgumentError, 'argument can only have keys that are defined properties') if (properties.keys - self.class.registered_properties.keys).any?
157
+
158
+ old_property_store = @parsed_property_store
159
+ @parsed_property_store = nil
160
+
161
+ old_properties = properties.map do |method_name, value|
162
+ key = self.class.registered_properties[method_name].key
163
+
164
+ old_value = @hash[key]
165
+
166
+ if !value.nil?
167
+ @hash[key] = value
168
+ elsif @hash.key?(key)
169
+ @hash.delete(key)
170
+ end
171
+
172
+ [method_name, old_value]
173
+ end
174
+
175
+ parse!
176
+ rescue ValidationError
177
+ @parsed_property_store = old_property_store
178
+
179
+ old_properties.each do |method_name, old_value|
180
+ key = self.class.registered_properties[method_name].key
181
+
182
+ if !old_value.nil?
183
+ @hash[key] = old_value
184
+ elsif @hash.key?(key)
185
+ @hash.delete(key)
186
+ end
187
+ end
188
+
189
+ raise
190
+ end
191
+ end
192
+ end
@@ -2,53 +2,23 @@ require 'fileutils'
2
2
  require 'open-uri'
3
3
 
4
4
  require 'mmi/github_api'
5
- require 'mmi/option_attributes'
5
+ require 'mmi/property_attributes'
6
6
 
7
7
  module Mmi
8
8
  module Source
9
9
  class Github
10
- include Mmi::OptionAttributes
10
+ prepend Mmi::PropertyAttributes
11
11
 
12
- opt_accessor :owner
13
- opt_accessor :repo
14
- opt_accessor :install_dir
15
- opt_accessor :filename
12
+ property :owner
13
+ property :repo
14
+ property :install_dir
15
+ property :filename, required: false
16
16
 
17
- opt_accessor :asset_id
18
- opt_accessor :release
19
- opt_accessor :file
17
+ property :asset_id, conflicts: %w[release file]
18
+ property :release, conflicts: 'asset_id', requires: 'file'
19
+ property :file, conflicts: 'asset_id', requires: 'release'
20
20
 
21
- def initialize(options)
22
- @options = options
23
-
24
- parse!
25
- end
26
-
27
- def parse!
28
- if self.owner
29
- if self.repo
30
- if self.install_dir
31
- if self.asset_id
32
- # Pass.
33
- elsif self.release
34
- if self.file
35
- # Pass.
36
- else
37
- raise Mmi::MissingAttributeError, 'Missing "source.file" from asset because "source.asset_id" is not provided.'
38
- end
39
- else
40
- raise Mmi::MissingAttributeError, 'Missing "source.release" from asset because "source.asset_id" is not provided.'
41
- end
42
- else
43
- raise Mmi::MissingAttributeError, 'Missing "source.install_dir" from asset.'
44
- end
45
- else
46
- raise Mmi::MissingAttributeError, 'Missing "source.repo" from asset.'
47
- end
48
- else
49
- raise Mmi::MissingAttributeError, 'Missing "source.owner" from asset.'
50
- end
51
- end
21
+ # TODO: Ensure that either :asset_id or [:release, :file] is given.
52
22
 
53
23
  def repository_url
54
24
  "https://github.com/#{self.owner}/#{self.repo}"
@@ -66,21 +36,10 @@ module Mmi
66
36
  end
67
37
  end
68
38
 
69
- def install(dir)
70
- install_dir = File.expand_path(self.install_dir, dir)
71
- filepath = File.join(install_dir, self.filename || (self.asset_id ? cached_asset_response.name : self.file))
72
-
73
- Mmi.info "Downloading #{download_url.inspect} into #{filepath.inspect}."
39
+ def install(install_record)
40
+ filepath = File.join(install_dir, self.filename || (self.asset_id ? cached_asset_response.name : self.file))
74
41
 
75
- FileUtils.mkdir_p(install_dir)
76
-
77
- begin
78
- stream = URI.parse(download_url).open
79
-
80
- IO.copy_stream(stream, filepath)
81
- rescue OpenURI::HTTPError => e
82
- Mmi.fail! "Error when requesting asset.\n#{e.inspect}"
83
- end
42
+ install_record.add(download_url, filepath, content_hash: nil) # As of 2024-07-18, the GitHub API does not return any hash over the asset.
84
43
  end
85
44
 
86
45
  def display_name
@@ -2,73 +2,42 @@ require 'fileutils'
2
2
  require 'open-uri'
3
3
 
4
4
  require 'mmi/modrinth_api'
5
- require 'mmi/option_attributes'
5
+ require 'mmi/property_attributes'
6
+ require 'mmi/content_hash/sha512'
6
7
 
7
8
  module Mmi
8
9
  module Source
9
10
  class Modrinth
10
- include Mmi::OptionAttributes
11
+ prepend Mmi::PropertyAttributes
11
12
 
12
- opt_accessor :name
13
- opt_accessor :version
14
- opt_accessor :version_file
13
+ property :name
14
+ property :version
15
+ property :version_file
16
+ property :install_dir
17
+ property :filename, required: false
15
18
 
16
- opt_accessor :install_dir
17
- opt_accessor :filename
18
-
19
- def initialize(options)
20
- @options = options
21
-
22
- parse!
23
- end
24
-
25
- def parse!
26
- if self.name
27
- if self.version
28
- if self.version_file
29
- if self.install_dir
30
- # Pass.
31
- else
32
- raise Mmi::MissingAttributeError, 'Missing "source.install_dir" from asset.'
33
- end
34
- else
35
- raise Mmi::MissingAttributeError, 'Missing "source.version_file" from asset.'
36
- end
37
- else
38
- raise Mmi::MissingAttributeError, 'Missing "source.version" from asset.'
39
- end
40
- else
41
- raise Mmi::MissingAttributeError, 'Missing "source.name" from asset.'
42
- end
43
- end
44
-
45
- def cached_mod_versions
46
- @cached_mod_versions ||= Mmi::ModrinthApi.project_versions(self.name)
19
+ def cached_mod_versions(loader: nil, game_version: nil)
20
+ (@cached_mod_versions ||= {})[{loader: loader, game_version: game_version}] ||= Mmi::ModrinthApi.project_versions(self.name, loader: loader, game_version: game_version)
47
21
  end
48
22
 
49
- def download_url
23
+ def api_version_file
50
24
  cached_mod_versions.select do |version|
51
25
  version['name'] == self.version
52
- end.first['files'].select do |files|
26
+ end.map do |version|
27
+ version['files']
28
+ end.flatten(1).select do |files|
53
29
  files['filename'] == self.version_file
54
- end.first['url'].gsub(/ /, '%20')
30
+ end.first
55
31
  end
56
32
 
57
- def install(dir)
58
- install_dir = File.expand_path(self.install_dir, dir)
59
- filepath = File.join(install_dir, self.filename || self.version_file)
60
-
61
- Mmi.info "Downloading #{download_url.inspect} into #{filepath.inspect}."
62
-
63
- FileUtils.mkdir_p(install_dir)
33
+ def download_url
34
+ api_version_file['url'].gsub(/ /, '%20')
35
+ end
36
+
37
+ def install(install_record)
38
+ filepath = File.join(install_dir, self.filename || self.version_file)
64
39
 
65
- begin
66
- stream = URI.parse(download_url).open
67
-
68
- IO.copy_stream(stream, filepath)
69
- rescue OpenURI::HTTPError => e
70
- Mmi.fail! "Error when requesting asset.\n#{e.inspect}"
71
- end
40
+ install_record.add(download_url, filepath, content_hash: Mmi::ContentHash::Sha512.new(api_version_file['hashes']['sha512']))
72
41
  end
73
42
 
74
43
  def display_name
@@ -1,54 +1,25 @@
1
1
  require 'fileutils'
2
2
  require 'open-uri'
3
3
 
4
- require 'mmi/option_attributes'
4
+ require 'mmi/property_attributes'
5
5
 
6
6
  module Mmi
7
7
  module Source
8
8
  class Url
9
- include Mmi::OptionAttributes
9
+ prepend Mmi::PropertyAttributes
10
10
 
11
- opt_accessor :url
12
- opt_accessor :install_dir
13
- opt_accessor :filename
14
-
15
- def initialize(options)
16
- @options = options
17
-
18
- parse!
19
- end
20
-
21
- def parse!
22
- if self.url
23
- if self.install_dir
24
- # Pass.
25
- else
26
- raise Mmi::MissingAttributeError, 'Missing "source.install_dir" from asset.'
27
- end
28
- else
29
- raise Mmi::MissingAttributeError, 'Missing "source.name" from asset.'
30
- end
31
- end
11
+ property :url
12
+ property :install_dir
13
+ property :filename, required: false
32
14
 
33
15
  def download_uri
34
16
  @download_uri ||= URI.parse(url)
35
17
  end
36
18
 
37
- def install(dir)
38
- install_dir = File.expand_path(self.install_dir, dir)
39
- filepath = File.join(install_dir, self.filename || File.basename(download_uri.path))
40
-
41
- Mmi.info "Downloading #{url.inspect} into #{filepath.inspect}."
42
-
43
- FileUtils.mkdir_p(install_dir)
19
+ def install(install_record)
20
+ filepath = File.join(install_dir, self.filename || File.basename(download_uri.path))
44
21
 
45
- begin
46
- stream = download_uri.open
47
-
48
- IO.copy_stream(stream, filepath)
49
- rescue OpenURI::HTTPError => e
50
- Mmi.fail! "Error when requesting asset.\n#{e.inspect}"
51
- end
22
+ install_record.add(url, filepath, content_hash: nil)
52
23
  end
53
24
 
54
25
  def display_name
data/lib/mmi/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Mmi
2
- VERSION = '0.2.1'.freeze
2
+ VERSION = '0.2.3'.freeze
3
3
  end
data/lib/mmi.rb CHANGED
@@ -2,17 +2,6 @@ require 'mmi/version'
2
2
  require 'mmi/mod_file_processor'
3
3
 
4
4
  module Mmi
5
- MMI_CACHE_DIR = File.join(Dir.home, '.cache', 'mmi')
6
- MINECRAFT_DIR = File.join(Dir.home, '.minecraft')
7
-
8
- def self.cache_dir
9
- MMI_CACHE_DIR
10
- end
11
-
12
- def self.minecraft_dir
13
- MINECRAFT_DIR
14
- end
15
-
16
5
  class ValidationError < StandardError; end
17
6
  class MissingAttributeError < ValidationError; end
18
7
  class InvalidAttributeError < ValidationError; end