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.
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Herve
6
+ module CLI
7
+ module RubyCommands
8
+ class Uninstall < BaseCommand
9
+ desc "uninstall given ruby version"
10
+ argument :version, desc: "version to uninstall", required: true
11
+ option :install_dir, desc: "installation directory", default: DEFAULT_RUBIES
12
+
13
+ def call(version:, **options)
14
+ install_dir = Herve.expand_path(options[:install_dir])
15
+ raise ConfigError, "install_dir does not exist" unless File.directory?(install_dir)
16
+
17
+ config.ruby_dirs = [install_dir] unless install_dir.nil?
18
+ ruby = RubyCommands.find_ruby(config, version)
19
+ raise RubyError, "no matching ruby" if ruby.nil?
20
+
21
+ uninstall_ruby(ruby)
22
+ end
23
+
24
+ private
25
+
26
+ def uninstall_ruby(ruby)
27
+ FileUtils.remove_dir(ruby.path)
28
+ logger.info { "Ruby #{Rainbow(ruby.version).cyan} uninstalled" }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herve
4
+ module CLI
5
+ module RubyCommands
6
+ def self.find_ruby(config, request)
7
+ req = request.nil? ? config.ruby_request : Herve::Ruby::Request.parse(request)
8
+ config.matching_ruby(req)
9
+ end
10
+
11
+ def self.current_platform_string
12
+ platform = ENV["HERVE_TEST_PLATFORM"] || RUBY_PLATFORM
13
+ case platform
14
+ when "x86_64-linux"
15
+ "x86_64_linux"
16
+ when "aarch64-darwin"
17
+ "arm64_sonoma"
18
+ when "aarch64-linux"
19
+ "arm64_linux"
20
+ else
21
+ raise Error, "herve does not support #{platform}"
22
+ end
23
+ end
24
+ end
25
+
26
+ require_relative "ruby_commands/find"
27
+ require_relative "ruby_commands/install"
28
+ require_relative "ruby_commands/list"
29
+ require_relative "ruby_commands/pin"
30
+ require_relative "ruby_commands/run"
31
+ require_relative "ruby_commands/uninstall"
32
+
33
+ CLI.register "ruby" do |cli|
34
+ cli.register "find", RubyCommands::Find
35
+ cli.register "install", RubyCommands::Install
36
+ cli.register "list", RubyCommands::List
37
+ cli.register "pin", RubyCommands::Pin
38
+ cli.register "run", RubyCommands::Run
39
+ cli.register "uninstall", RubyCommands::Uninstall
40
+ end
41
+
42
+ %w[find install list pin run uninstall].each do |cmd|
43
+ before("ruby #{cmd}") do |args|
44
+ setup_config(args)
45
+ setup_logger(args)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "completely"
4
+
5
+ module Herve
6
+ module CLI
7
+ module ShellCommands
8
+ class Completion < BaseCommand
9
+ desc "generate completion for your shell"
10
+ argument :shell, desc: "your shell", required: true, values: SUPPORTED_SHELLS
11
+
12
+ def call(shell:, **_args)
13
+ raise Error, "unsupported shell '#{shell}'" unless SUPPORTED_SHELLS.include?(shell)
14
+
15
+ if shell == "zsh"
16
+ puts "autoload -Uz +X compinit && compinit"
17
+ puts "autoload -Uz +X bashcompinit && bashcompinit"
18
+ end
19
+
20
+ puts Completely::Completions.new(generate_completion_data).script
21
+ end
22
+
23
+ private
24
+
25
+ def generate_completion_data
26
+ data = {}
27
+ update_data_with_commands(data, cli_root_node)
28
+ program_name = Dry::CLI::ProgramName.call
29
+ data[program_name] = data.keys.reject { |k| k.include?(" ") } << "help"
30
+ data
31
+ end
32
+
33
+ def update_data_with_commands(data, node, prefix: nil)
34
+ node.children.each do |name, subnode|
35
+ key = prefix.nil? ? name : "#{prefix} #{name}"
36
+ if subnode.command
37
+ arguments, options = command(subnode.command)
38
+ update_data_with_arguments(data, key, arguments)
39
+ update_data_with_options(data, key, options)
40
+ elsif subnode.children
41
+ data[key] = subnode.children.keys
42
+ update_data_with_commands(data, subnode, prefix: key)
43
+ end
44
+ end
45
+ end
46
+
47
+ def cli_root_node
48
+ CLI.get({}).instance_variable_get(:@node)
49
+ end
50
+
51
+ def command(command)
52
+ args = command.arguments.map { |arg| argument_values(arg) }
53
+ options = command.options.to_h { |opt| ["--#{opt.name.to_s.gsub("_", "-")}", option_values(opt)] }
54
+ options["--help"] = []
55
+ [args, options]
56
+ end
57
+
58
+ def argument_values(argument)
59
+ return ["<directory>"] if argument.name.to_s.end_with?("dir")
60
+
61
+ argument.values
62
+ end
63
+
64
+ def option_values(option)
65
+ return ["<directory>"] if option.name.to_s.end_with?("dir")
66
+ return option.values if option.values
67
+
68
+ []
69
+ end
70
+
71
+ def update_data_with_arguments(data, name, arguments)
72
+ values = arguments.shift || []
73
+ data[name] = values.dup
74
+ values.each { |v| update_data_with_arguments(data, "#{name}*#{v}", arguments) }
75
+ end
76
+
77
+ def update_data_with_options(data, name, options)
78
+ data[name] ||= []
79
+ data[name].concat(options.keys)
80
+ options.each do |opt, values|
81
+ next if values.empty?
82
+
83
+ data["#{name}*#{opt}"] = values
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herve
4
+ module CLI
5
+ module ShellCommands
6
+ class Env < BaseCommand
7
+ desc "generate environment to configure your shell"
8
+ argument :shell, desc: "your shell", required: true, values: SUPPORTED_SHELLS
9
+
10
+ def call(shell:, **_args)
11
+ ruby = config.project_ruby
12
+ env = Config.env_for(ruby)
13
+ unset = env.select { |_k, v| v.nil? }.keys
14
+ set = env.compact
15
+ case shell
16
+ when "bash", "zsh"
17
+ env_bash(unset, set)
18
+ else
19
+ raise Error, "unsupported shell '#{shell}'"
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def env_bash(unset, set)
26
+ puts "unset #{unset.join(" ")}" unless unset.empty?
27
+ set.each do |var, val|
28
+ puts "export #{var}=#{val}"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herve
4
+ module CLI
5
+ module ShellCommands
6
+ class Init < BaseCommand
7
+ desc "generate environment to configure your shell"
8
+ argument :shell, desc: "your shell", required: true, values: SUPPORTED_SHELLS
9
+
10
+ def call(shell:, **)
11
+ case shell
12
+ when "bash"
13
+ init_bash
14
+ when "zsh"
15
+ init_zsh
16
+ else
17
+ raise Error, "unsupported shell '#{shell}'"
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def init_bash
24
+ puts <<~ENDOFTEXT
25
+ _herve_autoload_hook() {
26
+ eval "$(#{config.current_exe} shell env bash)"
27
+ }
28
+ _herve_autoload_hook
29
+ chpwd_hook() {
30
+ if [[ "$PWD" != "$_OLDPWD" ]]; then
31
+ _herve_autoload_hook
32
+ _OLDPWD="$PWD"
33
+ fi
34
+ }
35
+ _OLDPWD="$PWD"
36
+ PROMPT_COMMAND="_chpwd_hook${PROMPT_COMMAND:*; $PROMPT_COMMAND}"
37
+ ENDOFTEXT
38
+ end
39
+
40
+ def init_zsh
41
+ puts <<~ENDOFTEXT
42
+ autoload -U add-zsh-hook
43
+ _herve_autoload_hook() {
44
+ eval "$(#{config.current_exe} shell env zsh)"
45
+ }
46
+ add-zsh-hook chpwd _herve_autoload_hook
47
+ _herve_autoload_hook
48
+ ENDOFTEXT
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herve
4
+ module CLI
5
+ module ShellCommands
6
+ # Supported shells
7
+ # @return [Array<String>]
8
+ SUPPORTED_SHELLS = %w[bash zsh].freeze
9
+ end
10
+
11
+ require_relative "shell_commands/completion"
12
+ require_relative "shell_commands/env"
13
+ require_relative "shell_commands/init"
14
+
15
+ CLI.register "shell" do |cli|
16
+ cli.register "completion", ShellCommands::Completion
17
+ cli.register "env", ShellCommands::Env
18
+ cli.register "init", ShellCommands::Init
19
+ end
20
+
21
+ %w[env init].each do |cmd|
22
+ before("shell #{cmd}") do |args|
23
+ setup_config(args)
24
+ end
25
+ end
26
+ end
27
+ end
data/lib/herve/cli.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "rainbow"
5
+ require "dry/cli"
6
+
7
+ module Herve
8
+ module CLI
9
+ extend Dry::CLI::Registry
10
+
11
+ class BaseCommand < Dry::CLI::Command
12
+ # @return [Config]
13
+ attr_reader :config
14
+ # @return [Logger]
15
+ attr_reader :logger
16
+
17
+ class << self
18
+ def inherited(klass)
19
+ super
20
+
21
+ klass.option :ruby_dir, desc: "ruby directories to use"
22
+ klass.option :project_dir, desc: "project directory to use"
23
+ klass.option :verbose, desc: "Add verbosity", type: :flag, aliases: %w[-v]
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def setup_config(options)
30
+ # Unfortunately, Rainbow checks STDOUT and STDERR
31
+ Rainbow.enabled = $stdout.tty? && $stderr.tty?
32
+
33
+ config = Config.from_env_and_options(ENV, options)
34
+
35
+ ruby_dir = options[:ruby_dir]
36
+ ruby_dirs = if ruby_dir.nil?
37
+ config.default_ruby_dirs
38
+ else
39
+ ruby_dir.split(":").map { |rd| config.root_dir.join(rd) }
40
+ end
41
+ config.ruby_dirs = ruby_dirs
42
+ @config = config
43
+ end
44
+
45
+ def setup_logger(options)
46
+ logger = Logger.new($stdout)
47
+ logger.level = options[:verbose] ? Logger::DEBUG : Logger::INFO
48
+ logger.formatter = proc do |severity, _datetime, _progname, msg|
49
+ colored_msg = case severity
50
+ when "FATAL", "ERROR"
51
+ Rainbow(msg).red
52
+ when "DEBUG"
53
+ Rainbow(msg).darkslategray
54
+ else
55
+ msg
56
+ end
57
+ "#{colored_msg}\n"
58
+ end
59
+ @logger = logger
60
+ end
61
+ end
62
+
63
+ require_relative "cli/gem_command"
64
+ require_relative "cli/ruby_commands"
65
+ require_relative "cli/shell_commands"
66
+ end
67
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "xdg"
5
+ require "json"
6
+
7
+ module Herve
8
+ # @private environment variables handle by herve
9
+ ENV_VARS = (%w[RUBY_ROOT RUBY_ENGINE RUBY_VERSION RUBYLIB RUBYOPT] +
10
+ ENV.keys.select { |v| v.start_with?("GEM_") || v.start_with?("BUNDLE") }).freeze
11
+ # Base URL to fetch releases
12
+ RELEASES_URL = "https://api.github.com"
13
+
14
+ class ConfigError < Error; end
15
+
16
+ class Config
17
+ DEFAULT_ROOT_DIR = "/"
18
+ DEFAULT_SEARCH_RUBIES = %w[$HOME/.rubies /opt/rubies /usr/local/rubies].freeze
19
+
20
+ attr_reader :root_dir, :project_dir, :current_exe, :gemfile, :cache, :release_base_url
21
+ attr_accessor :ruby_dirs
22
+
23
+ class << self
24
+ def from_env_and_options(env, options)
25
+ root_dir = Pathname.new(env["RV_ROOT_DIR"] || DEFAULT_ROOT_DIR)
26
+ project_dir = if options.key?(:project_dir) && !options[:project_dir].nil?
27
+ Pathname.new(options[:project_dir])
28
+ else
29
+ find_project_dir(Pathname.pwd, root_dir)
30
+ end
31
+ bundle_gemfile = env.fetch("BUNDLE_GEMFILE", nil)
32
+ gemfile = bundle_gemfile ? Pathname.new(bundle_gemfile) : nil
33
+ env.fetch("HERVE_RELEASES_URL", RELEASES_URL)
34
+ new(root_dir, project_dir, gemfile)
35
+ end
36
+
37
+ # @param [Ruby,nil] ruby
38
+ # @return [Array(Array<String>, Hash(String => String)>)]
39
+ def env_for(ruby)
40
+ env = ENV_VARS.to_h { |var| [var, nil] }
41
+ paths = split_path(ENV["PATH"])
42
+
43
+ old_ruby_paths = %w[RUBY_ROOT GEM_ROOT GEM_HOME].map { |v| ENV[v] }
44
+ .compact
45
+ .map { |p| File.join(p, "bin") }
46
+ old_gem_paths = split_path(ENV["GEM_PATH"])
47
+
48
+ # Remove old Ruby and Gem paths from PATH
49
+ paths.delete_if { |p| old_ruby_paths.include?(p) || old_gem_paths.include?(p) }
50
+ set_env_for(ruby, env, paths) unless ruby.nil?
51
+ env["PATH"] = paths.join(":")
52
+ env
53
+ end
54
+
55
+ private
56
+
57
+ def find_project_dir(cwd, root_dir)
58
+ project_dir = cwd
59
+ loop do
60
+ ruby_version = project_dir.join(".ruby-version")
61
+ return project_dir if ruby_version.exist?
62
+ return nil if project_dir == root_dir
63
+
64
+ parent = project_dir.parent
65
+ return nil if parent == project_dir
66
+
67
+ project_dir = parent
68
+ end
69
+ end
70
+
71
+ def split_path(path)
72
+ (path || "").split(":")
73
+ end
74
+
75
+ def set_env_for(ruby, env, paths)
76
+ gem_paths = []
77
+ paths.insert(0, ruby.bin_path.to_s)
78
+ env["RUBY_ROOT"] = ruby.path.to_s
79
+ ruby_request = ruby.version
80
+ env["RUBY_ENGINE"] = ruby_request.engine.name
81
+ env["RUBY_VERSION"] = ruby_request.version_number
82
+ if ruby.gem_home
83
+ gem_home = ruby.gem_home
84
+ gem_home_bin = gem_home.join("bin").to_s
85
+ paths.insert(0, gem_home_bin)
86
+ gem_paths.insert(0, gem_home_bin)
87
+ env["GEM_HOME"] = gem_home.to_s
88
+ end
89
+ if ruby.gem_root
90
+ gem_root = ruby.gem_root
91
+ gem_root_bin = gem_root.join("bin").to_s
92
+ paths.insert(0, gem_root_bin)
93
+ gem_paths.insert(0, gem_root_bin)
94
+ env["GEM_ROOT"] = gem_root.to_s
95
+ end
96
+ env["GEM_PATH"] = gem_paths.join(":")
97
+ end
98
+ end
99
+
100
+ def initialize(root_dir, project_dir, gemfile, release_base_url = RELEASES_URL)
101
+ @root_dir = root_dir
102
+ @project_dir = project_dir
103
+ @current_exe = Pathname.new($PROGRAM_NAME).realpath
104
+ @gemfile = gemfile
105
+ @release_base_url = release_base_url
106
+ @cache = Cache.new(Pathname.new(XDG::Cache.new.home).join("herve"))
107
+ end
108
+
109
+ def default_ruby_dirs
110
+ DEFAULT_SEARCH_RUBIES.map do |dir|
111
+ path = Herve.expand_path(dir)
112
+ joinable_path = path.relative_path_from("/")
113
+ joined_path = root_dir.join(joinable_path)
114
+ if joined_path.split.last.to_s == ".rubies"
115
+ # Ensure ~/.rubies is in list, even if it does not exist yet
116
+ joined_path
117
+ else
118
+ begin
119
+ joined_path.realpath
120
+ rescue SystemCallError
121
+ nil
122
+ end
123
+ end
124
+ end.compact
125
+ end
126
+
127
+ def rubies
128
+ discover_rubies
129
+ end
130
+
131
+ def project_ruby
132
+ matching_ruby(ruby_request)
133
+ end
134
+
135
+ # @return [Ruby::Request,nil]
136
+ def ruby_request
137
+ return nil if project_dir.nil?
138
+
139
+ begin
140
+ file = project_dir.join(".ruby-version")
141
+ Ruby::Request.parse(File.read(file))
142
+ rescue Error
143
+ nil
144
+ rescue SystemCallError => e
145
+ raise Error, e.message
146
+ end
147
+ end
148
+
149
+ def matching_ruby(request)
150
+ return nil if request.nil?
151
+
152
+ rubies.reverse.find { |ruby| request.satisfied_by(ruby) }
153
+ end
154
+
155
+ def ruby_version
156
+ file = project_dir.join(".ruby-version")
157
+ raise ConfigError, "no .ruby-version file in #{project_dir}" unless file.exist?
158
+
159
+ file.read
160
+ end
161
+
162
+ def ruby_version=(version)
163
+ dir = project_dir || Dir.cwd
164
+ dir.join(".ruby-version").write("#{version}\n")
165
+ end
166
+
167
+ private
168
+
169
+ def discover_rubies
170
+ paths = []
171
+ ruby_dirs.select(&:exist?).map do |dir|
172
+ dir.children.select do |child|
173
+ paths += child.children.select(&:directory?) if child.directory? && child.readable?
174
+ end
175
+ end
176
+
177
+ paths.map do |path|
178
+ cached_ruby(path) || Ruby.from_directory(path)
179
+ end
180
+ end
181
+
182
+ def cached_ruby(path)
183
+ key = cache_key_from_path(path)
184
+ return nil if key.nil?
185
+
186
+ entry = cache.entry(:ruby, "interpreters", key)
187
+ begin
188
+ content = File.read(entry.path)
189
+ cached_ruby = JSON.parse(content)
190
+ rescue SystemCallError, JSON::ParserError
191
+ return nil
192
+ end
193
+ return cached_ruby if cached_ruby.valid?
194
+
195
+ entry.path.delete
196
+ nil
197
+ end
198
+
199
+ def cache_key_from_path(path)
200
+ bin = path.join("bin").join("ruby")
201
+ return nil unless bin.exist?
202
+
203
+ timestamp = Cache::Timestamp.path(bin)
204
+ Cache.digest("#{timestamp}#{bin}")
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herve
4
+ VERSION = "0.1.1"
5
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herve
4
+ class Ruby
5
+ class Engine
6
+ # Known engines, in priority order
7
+ KINDS = %i[ruby jruby truffleruby mruby].freeze
8
+
9
+ attr_reader :kind
10
+
11
+ def initialize(name)
12
+ @name = name
13
+ @kind = KINDS.find { |k| k.to_s == name } || :unknown
14
+ end
15
+
16
+ def name
17
+ @name.dup
18
+ end
19
+ alias to_s name
20
+
21
+ def <=>(other)
22
+ priority(kind) <=> priority(other.kind)
23
+ end
24
+
25
+ def ==(other)
26
+ kind == other.kind
27
+ end
28
+
29
+ private
30
+
31
+ def priority(kind)
32
+ return 100 if kind == :unknown
33
+
34
+ KINDS.index(kind)
35
+ end
36
+ end
37
+ end
38
+ end