herve 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ec25d37bb470a28f6173a95457d5c8c375e9a63c2904579d64a0a2324f05f4fa
4
+ data.tar.gz: fa49ed9155893e3d74682842834cd5cd93656baee1e8ec26d57c06484ead7a75
5
+ SHA512:
6
+ metadata.gz: 4e876114eaa14969974614e29f0f7408a0889379f663b8146b3407c57c4b29de92b5efdc4f480a8d41f86e799d0548cd7d8508a3e4af8289e28893bbdf2d4aa5
7
+ data.tar.gz: 7820104f12d2c484251f6d58d36b86102ed52e8531cc8da5ef2a4c5fadfe84b82e0d483f35a37d861ad01bfd049ac72c925a500586c2eabbaa31befd21314ab4
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,30 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ TargetRubyVersion: 3.2
4
+
5
+ Layout/LineLength:
6
+ Max: 120
7
+
8
+ Metrics/AbcSize:
9
+ Max: 20
10
+
11
+ Metrics/ClassLength:
12
+ Enabled: false
13
+
14
+ Metrics/MethodLength:
15
+ Max: 20
16
+
17
+ Style/Documentation:
18
+ Enabled: false
19
+
20
+ Style/FetchEnvVar:
21
+ Enabled: false
22
+
23
+ Style/StderrPuts:
24
+ Enabled: false
25
+
26
+ Style/StringLiterals:
27
+ EnforcedStyle: double_quotes
28
+
29
+ Style/StringLiteralsInInterpolation:
30
+ EnforcedStyle: double_quotes
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-08-26
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 sd77
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # Herve
2
+
3
+ Welcome to Herve, a Ruby version manager.
4
+
5
+ Do you knwon [rv](https://github.com/spinel-coop/rv-ruby/), a Ruby version manager with high ambitions? It seems so cool, but unfortunately, it is written in Rust;-)
6
+
7
+ Herve is a reimplementation in Ruby. My first goal is to be compatible with `rv`, and second one is to have fun.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ gem install herve
13
+
14
+ #bash
15
+ echo 'eval "$(herve shell init bash)"' >> ~/.bashrc
16
+ eval "$(herve shell init bash)"
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ echo "3.4.1" > .ruby-version
23
+ herve ruby install 3.4.1
24
+ ```
25
+
26
+ ## Why Herve
27
+
28
+ Herve is a French first name. It sounds like "rv" in French.
29
+
30
+ ## Contributing
31
+
32
+ Bug reports and pull requests are welcome on Codeberg at <https://codeberg.org/sd77/herve>.
33
+
34
+ ## License
35
+
36
+ Herve is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/exe/herve ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "dry/cli"
5
+ require "herve"
6
+
7
+ begin
8
+ Dry::CLI.new(Herve::CLI).call
9
+ rescue Herve::Error, Gem::Exception => e
10
+ $stderr.puts "#{Rainbow("Error:").red} #{e}"
11
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ module Herve
5
+ class Cache
6
+ class Entry
7
+ attr_reader :path
8
+
9
+ def initialize(dir, file)
10
+ @path = dir.join(file)
11
+ end
12
+
13
+ def dir
14
+ @path.parent
15
+ end
16
+
17
+ def shard
18
+ Shard.new(dir)
19
+ end
20
+
21
+ def read
22
+ exist? ? File.read(path) : nil
23
+ end
24
+
25
+ def read_json
26
+ data = read
27
+ return nil if data.nil?
28
+
29
+ begin
30
+ JSON.parse(data)
31
+ rescue JSON::ParserError => e
32
+ raise CacheError, "entry #{path} should be JSON but is not: #{e.message}"
33
+ end
34
+ end
35
+
36
+ def write(data)
37
+ @path.parent.mkpath unless @path.parent.exist?
38
+ File.write(path, data)
39
+ end
40
+
41
+ def write_json(data)
42
+ write(data.to_json)
43
+ end
44
+
45
+ def exist?
46
+ path.exist?
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herve
4
+ class Cache
5
+ class Shard
6
+ attr_reader :path
7
+
8
+ def initialize(path)
9
+ @path = path
10
+ end
11
+
12
+ def entry(file)
13
+ Entry.new(path, file)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ module Herve
2
+ class Cache
3
+ class Timestamp
4
+ # @return [Time]
5
+ attr_reader :time
6
+
7
+ class << self
8
+ # @param [Pathname] path
9
+ # @return [Timestamp]
10
+ def path(path)
11
+ new(path.ctime)
12
+ end
13
+
14
+ # @return [Timestamp]
15
+ def now
16
+ new(Time.now)
17
+ end
18
+ end
19
+
20
+ # @param [Time] time
21
+ def initialize(time)
22
+ @time = time
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/xxhash"
4
+
5
+ module Herve
6
+ class CacheError < Error; end
7
+
8
+ class Cache
9
+ attr_reader :root
10
+
11
+ class << self
12
+ def digest(data)
13
+ Digest::XXH3_64bits.hexdigest(data)
14
+ end
15
+ end
16
+
17
+ def initialize(root)
18
+ @root = root
19
+ end
20
+
21
+ def bucket(bucket_type)
22
+ root.join(bucket_path(bucket_type))
23
+ end
24
+
25
+ def shard(bucket_type, dir)
26
+ Shard.new(bucket(bucket_type).join(dir))
27
+ end
28
+
29
+ def entry(bucket_type, dir, file)
30
+ Entry.new(bucket(bucket_type).join(dir), file)
31
+ end
32
+
33
+ private
34
+
35
+ def bucket_path(type)
36
+ case type
37
+ when :ruby
38
+ "ruby-v0"
39
+ else
40
+ raise CacheError, "unknown bucket type"
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ require_relative "cache/entry"
47
+ require_relative "cache/shard"
48
+ require_relative "cache/timestamp"
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herve
4
+ module CLI
5
+ class GemCommand < BaseCommand
6
+ desc "build a gem from project's gemspec"
7
+ argument :gemspec, desc: "gemspec to use"
8
+ option :force, desc: "skip validation of the spec", type: :flag
9
+ option :strict, desc: "consider warnings as errors when validating the spec", type: :flag
10
+ option :output, desc: "output gem with the given filename", aliases: %w[-o]
11
+
12
+ def call(gemspec: nil, **options)
13
+ @options = options
14
+ build_gem(config.project_dir, gemspec)
15
+ end
16
+
17
+ private
18
+
19
+ def build_gem(project_dir, gemspec)
20
+ require "rubygems/package"
21
+ require "rubygems/specification"
22
+
23
+ Dir.chdir(project_dir) do
24
+ gemspec ||= find_gemspec
25
+ spec = Gem::Specification.load(gemspec)
26
+ raise Error, "Error loading gemspec" unless spec
27
+
28
+ Gem::Package.build(spec, @options[:force], @options[:strict], @options[:output])
29
+ end
30
+ end
31
+
32
+ def find_gemspec
33
+ gemspecs = Dir.glob("*.gemspec")
34
+
35
+ raise Error, "Multiple gemspecs found: #{gemspecs}, please specify one" if gemspecs.size > 1
36
+
37
+ gemspecs.first
38
+ end
39
+ end
40
+
41
+ register "gem", GemCommand
42
+ before("gem") do |args|
43
+ setup_config(args)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herve
4
+ module CLI
5
+ module RubyCommands
6
+ class Find < BaseCommand
7
+ desc "search for a ruby installation"
8
+ argument :version, desc: "specific version to search for"
9
+
10
+ def call(version: nil, **)
11
+ ruby = RubyCommands.find_ruby(config, version)
12
+ raise RubyError, "no matching ruby" if ruby.nil?
13
+
14
+ puts ruby.executable_path
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/follow_redirects"
5
+ # require "minitar"
6
+ require "zlib"
7
+
8
+ module Herve
9
+ module CLI
10
+ module RubyCommands
11
+ class Install < BaseCommand
12
+ desc "install given ruby version"
13
+ argument :version, desc: "version to install", required: true
14
+ option :install_dir, desc: "installation directory", default: DEFAULT_RUBIES
15
+
16
+ def call(version:, **options)
17
+ install_dir = Herve.expand_path(options[:install_dir])
18
+ logger.info { "Installing ruby #{Rainbow(version).cyan} into #{Rainbow(install_dir).cyan}" }
19
+ requested = Herve::Ruby::Request.parse(version)
20
+ if requested.patch.nil?
21
+ raise VersionError,
22
+ "major, minor and patch are required in version, but get #{version}"
23
+ end
24
+
25
+ url = ruby_url(requested.to_s)
26
+ tarball_path = local_tarball_path(config, url)
27
+ download_ruby_tarball_if_needed(url, tarball_path)
28
+
29
+ extract_ruby_tarball(tarball_path, install_dir)
30
+ logger.info { "Ruby #{Rainbow(version).cyan} installed" }
31
+ end
32
+
33
+ private
34
+
35
+ def ruby_url(version)
36
+ number = version.delete_prefix("ruby-")
37
+ platform = RubyCommands.current_platform_string
38
+
39
+ "https://github.com/spinel-coop/rv-ruby/releases/download/#{number}/portable-#{version}.#{platform}.bottle.tar.gz"
40
+ end
41
+
42
+ def local_tarball_path(config, url)
43
+ cache_key = Cache.digest(url)
44
+ config.cache.shard(:ruby, "tarballs").path.join("#{cache_key}.tar.gz")
45
+ end
46
+
47
+ def download_ruby_tarball_if_needed(url, tarball_path)
48
+ tarball_path.parent.mkpath unless tarball_path.parent.exist?
49
+
50
+ if tarball_path.exist?
51
+ logger.warn { "tarball #{tarball_path} already exists, skipping download" }
52
+ else
53
+ download_ruby_tarball(url, tarball_path)
54
+ end
55
+ end
56
+
57
+ def download_ruby_tarball(url, path)
58
+ logger.debug { "download #{url} into #{path}" }
59
+ client = Faraday.new(url) do |faraday|
60
+ faraday.response :follow_redirects
61
+ end
62
+ resp = client.get
63
+ File.write(path, resp.body)
64
+ end
65
+
66
+ def extract_ruby_tarball(tarball_path, dir)
67
+ dir.mkpath
68
+ # Do not use minitar: speed 7x
69
+ # Minitar.unpack(Zlib::GzipReader.new(File.open(tarball_path, "rb")), dir.to_s)
70
+ logger.debug { "extract ruby tarball into #{dir}" }
71
+ system("tar", "-C", dir.to_s, "-xf", tarball_path.to_s)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/follow_redirects"
5
+ require "time"
6
+
7
+ module Herve
8
+ module CLI
9
+ module RubyCommands
10
+ class List < BaseCommand
11
+ desc "list available ruby installations"
12
+ option :format, desc: "list output format", default: "text", values: %w[json text]
13
+ option :installed_only, desc: "only list installed rubies", type: :boolean, default: false
14
+
15
+ # @private
16
+ # Max age to use when there is no info in HTTP response
17
+ DEFAULT_MAX_AGE = 60
18
+ # @private
19
+ # Minimum cache duration to not check less than every minute
20
+ MINIMUM_CACHE_TTL = 60
21
+ # @private
22
+ ARCH_REGEX = /portable-ruby-[\d.]+\.(?<arch>[a-zA-Z0-9_]+)\.bottle\.tar\.gz/
23
+
24
+ # @private
25
+ # Associate a ruby name with its assets
26
+ Release = Data.define(:name, :assets)
27
+ # @private
28
+ # Ruby entry: Ruby + metadata
29
+ Entry = Data.define(:installed, :active, :details) do
30
+ def to_json(state = nil, *)
31
+ hsh = {
32
+ installed: installed,
33
+ active: active,
34
+ details: details
35
+ }
36
+ JSON::State.from_state(state).generate(hsh)
37
+ end
38
+ end
39
+
40
+ def call(**options)
41
+ installed_rubies = config.rubies
42
+ active_ruby = config.project_ruby
43
+
44
+ if options[:installed_only]
45
+ if installed_rubies.empty?
46
+ logger.warn { "No Ruby installation found." }
47
+ logger.info do
48
+ "Try installin Ruby with '#{Rainbow("herve ruby install").cyan}' or check your configuration"
49
+ end
50
+ return
51
+ end
52
+
53
+ print_rubies(installed_rubies, active_ruby, options[:format])
54
+ return
55
+ end
56
+
57
+ all_releases = fetch_available_releases(config.cache)
58
+
59
+ rubies_map = {}
60
+ installed_rubies.each do |ruby|
61
+ rubies_map[ruby.display_name] ||= []
62
+ rubies_map[ruby.display_name] << ruby
63
+ end
64
+
65
+ desired_od, desired_arch = parse_arch_string(RubyCommands.current_platform_string)
66
+ available_rubies = all_releases.map do |release|
67
+ release["assets"].map do |asset|
68
+ ruby_from_release(release, asset)
69
+ end
70
+ end.flatten
71
+ available_rubies.filter! { |ruby| (ruby.os == desired_od) && (ruby.arch == desired_arch) }
72
+
73
+ available_rubies.each do |ruby|
74
+ display_name = ruby.display_name
75
+ next if rubies_map.key?(display_name)
76
+
77
+ rubies_map[display_name] ||= []
78
+ rubies_map[display_name] << ruby
79
+ end
80
+
81
+ if rubies_map.empty? && options[:format] == "text"
82
+ logger.warn { "No rubies found for your platform" }
83
+ return
84
+ end
85
+
86
+ print_rubies(rubies_map.values.flatten, active_ruby, options[:format])
87
+ end
88
+
89
+ private
90
+
91
+ def print_rubies(rubies, active_ruby, format)
92
+ entries = rubies.sort.map do |ruby|
93
+ Entry.new(!ruby.path.to_s.start_with?("http"), active_ruby == ruby, ruby)
94
+ end
95
+
96
+ case format
97
+ when "text"
98
+ width = rubies.max { |a, b| a.display_name.length <=> b.display_name.length }.display_name.length
99
+ entries.each do |entry|
100
+ puts format_ruby_entry(entry, width)
101
+ end
102
+ when "json"
103
+ puts entries.to_json
104
+ end
105
+ end
106
+
107
+ def format_ruby_entry(entry, width)
108
+ ruby = entry.details
109
+ marker = entry.active ? "*" : " "
110
+ if entry.installed
111
+ format("#{marker} %#{width}s %s (%s)", Rainbow(ruby.version.to_s).cyan, Rainbow("[installed]").green,
112
+ ruby.path)
113
+ else
114
+ format(" %#{width}s %s", Rainbow(ruby.version.to_s).cyan, Rainbow("[available]").dark)
115
+ end
116
+ end
117
+
118
+ def fetch_available_releases(cache)
119
+ cache_entry = cache.entry(:ruby, "releases", "available_rubies.json")
120
+
121
+ cached_data = cache_entry.read_json
122
+ if cached_data
123
+ if Time.now < Time.strptime(cached_data["expire_at"], "%Y-%m-%d %H:%M:%S %z")
124
+ logger.debug { "Using cached list of releases" }
125
+ return cached_data["releases"]
126
+ end
127
+ logger.debug { "Cached ruby list is stale, re-validating with server" }
128
+ else
129
+ logger.debug { "No cached data" }
130
+ end
131
+
132
+ # No data in cache, or cache is stale
133
+ etag = cached_data&.fetch("etag", nil)
134
+ api_base = config.release_base_url
135
+ headers = { "User-Agent" => "herve-cli", "Accept" => "application/vnd.github+json" }
136
+ if etag
137
+ logger.debug { "Using Etag to make a conditional request: #{etag}" }
138
+ headers["If-None-Match"] = etag
139
+ end
140
+
141
+ client = Faraday.new("#{api_base}/repos/spinel-coop/rv-ruby", headers: headers) do |faraday|
142
+ faraday.response :raise_error
143
+ faraday.response :json
144
+ end
145
+ response = client.get("releases")
146
+
147
+ case response.status
148
+ when 304 # NotModified
149
+ logger.debug { "releases list is unchanged" }
150
+ max_age = parse_max_age(response.headers["Cache-Control"])
151
+ cached_data["expire_at"] = Time.now + [max_age, MINIMUM_CACHE_TTL].max
152
+ File.write(cache_entry.path, cached_data.to_json)
153
+ cached_data["releases"]
154
+ when 200 # OK
155
+ logger.debug { "Received new releases list" }
156
+ etag = response.headers["Etag"]
157
+ max_age = parse_max_age(response.headers["Cache-Control"])
158
+ releases = response.body
159
+ logger.debug { "Fetched #{releases.size} releases" }
160
+
161
+ cached_data = {
162
+ "expire_at" => Time.now + [max_age, MINIMUM_CACHE_TTL].max,
163
+ "etag" => etag,
164
+ "releases" => releases
165
+ }
166
+ cache_entry.write_json(cached_data)
167
+ releases
168
+ else
169
+ raise Faraday::Error, "Failed to fetch release (status: #{response.status})"
170
+ end
171
+ end
172
+
173
+ def parse_max_age(cache_control)
174
+ m = cache_control.match(/max-age=(\d+)/)
175
+ return DEFAULT_MAX_AGE unless m
176
+
177
+ m[1].to_i
178
+ end
179
+
180
+ def parse_arch_string(platform)
181
+ case platform
182
+ when "x86_64_linux"
183
+ %w[linux x86_64]
184
+ when "arm64_sonoma"
185
+ %w[macos aarch64]
186
+ when "arm64_linux"
187
+ %w[linux aarch64]
188
+ else
189
+ %w[unknown unknown]
190
+ end
191
+ end
192
+
193
+ def ruby_from_release(release, asset)
194
+ version = Herve::Ruby::Request.parse("ruby-#{release["name"]}")
195
+ display_name = version.to_s
196
+
197
+ m = asset["name"].match(ARCH_REGEX)
198
+ platform = m["arch"] || "unknown"
199
+ os, arch = parse_arch_string(platform)
200
+
201
+ Herve::Ruby.new("#{display_name}-#{os}-#{arch}", version, Pathname.new(asset["browser_download_url"]), nil,
202
+ arch, os, nil)
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herve
4
+ module CLI
5
+ module RubyCommands
6
+ class Pin < BaseCommand
7
+ desc "show or set ruby version for the current project"
8
+ argument :version, desc: "version to pin"
9
+
10
+ def call(version: nil, **)
11
+ if version.nil?
12
+ show_pinned_ruby
13
+ else
14
+ pin_ruby(version)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def show_pinned_ruby
21
+ project_dir = config.project_dir
22
+ raise ConfigError, "no project dir" if project_dir.nil?
23
+
24
+ version = config.ruby_version
25
+ logger.info { "#{Rainbow(project_dir).cyan} is pinned to Ruby #{Rainbow(version).cyan}" }
26
+ end
27
+
28
+ def pin_ruby(version)
29
+ pinned = Herve::Ruby::Request.parse(version).version_number
30
+ config.ruby_version = pinned
31
+ logger.info { "#{Rainbow(config.project_dir).cyan} pinned to Ruby #{Rainbow(version).cyan}" }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herve
4
+ module CLI
5
+ module RubyCommands
6
+ class Run < BaseCommand
7
+ desc "run a specific ruby version"
8
+ argument :version, desc: "ruby version to run", required: true
9
+
10
+ def call(version:, **args)
11
+ ruby = RubyCommands.find_ruby(config, version)
12
+ env = Config.env_for(ruby)
13
+ raise Herve::Ruby::RequestError, "no version #{request} installed" if ruby.nil?
14
+
15
+ cmd = [ruby.executable_path.to_s] + args[:args]
16
+ Kernel.exec(env, *cmd)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end