rubox 0.1.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.
@@ -0,0 +1,130 @@
1
+ require "open3"
2
+ require "net/http"
3
+ require "uri"
4
+ require "fileutils"
5
+
6
+ module Rubox
7
+ class Builder
8
+ # Pre-built Ruby binaries are hosted at this base URL.
9
+ # Falls back to building from source if download fails.
10
+ DOWNLOAD_BASE = "https://github.com/khasinski/rubox/releases/download"
11
+
12
+ attr_reader :ruby_version, :target, :output_dir
13
+
14
+ def initialize(ruby_version:, target:, output_dir: nil)
15
+ @ruby_version = ruby_version
16
+ @target = target
17
+ @output_dir = output_dir || "build/ruby-#{ruby_version}-#{target}"
18
+ end
19
+
20
+ def built?
21
+ File.exist?(File.join(output_dir, "bin", "ruby"))
22
+ end
23
+
24
+ def build!
25
+ return if built?
26
+
27
+ if try_download
28
+ puts "==> Using pre-built Ruby #{ruby_version} for #{target}"
29
+ return
30
+ end
31
+
32
+ puts "==> No pre-built binary available, building from source..."
33
+ build_from_source!
34
+ end
35
+
36
+ private
37
+
38
+ def try_download
39
+ url = "#{DOWNLOAD_BASE}/ruby-#{ruby_version}/ruby-#{ruby_version}-#{target}.tar.gz"
40
+ tarball = "#{output_dir}.tar.gz"
41
+
42
+ puts "==> Checking for pre-built Ruby #{ruby_version} for #{target}..."
43
+
44
+ begin
45
+ uri = URI(url)
46
+ # Follow redirects (GitHub releases redirect to S3)
47
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
48
+ open_timeout: 5, read_timeout: 30) do |http|
49
+ request = Net::HTTP::Head.new(uri)
50
+ http.request(request)
51
+ end
52
+
53
+ unless response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
54
+ puts " Not found (#{response.code})"
55
+ return false
56
+ end
57
+
58
+ puts " Downloading..."
59
+ FileUtils.mkdir_p(File.dirname(tarball))
60
+
61
+ # Stream download
62
+ download_file(url, tarball)
63
+
64
+ puts " Extracting..."
65
+ FileUtils.mkdir_p(output_dir)
66
+ system("tar", "xzf", tarball, "-C", output_dir, exception: true)
67
+ FileUtils.rm_f(tarball)
68
+
69
+ if built?
70
+ puts " Ruby #{ruby_version} ready at #{output_dir}"
71
+ return true
72
+ else
73
+ puts " Download extracted but Ruby binary not found, falling back to source build"
74
+ FileUtils.rm_rf(output_dir)
75
+ return false
76
+ end
77
+ rescue StandardError => e
78
+ puts " Download failed (#{e.message}), will build from source"
79
+ FileUtils.rm_f(tarball)
80
+ return false
81
+ end
82
+ end
83
+
84
+ def download_file(url, dest)
85
+ uri = URI(url)
86
+ max_redirects = 5
87
+
88
+ max_redirects.times do
89
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
90
+ read_timeout: 120) do |http|
91
+ request = Net::HTTP::Get.new(uri)
92
+ http.request(request) do |response|
93
+ case response
94
+ when Net::HTTPRedirection
95
+ uri = URI(response["location"])
96
+ next
97
+ when Net::HTTPSuccess
98
+ File.open(dest, "wb") do |f|
99
+ response.read_body { |chunk| f.write(chunk) }
100
+ end
101
+ return
102
+ else
103
+ raise "HTTP #{response.code}"
104
+ end
105
+ end
106
+ end
107
+ end
108
+
109
+ raise "Too many redirects"
110
+ end
111
+
112
+ def build_from_source!
113
+ Rubox.validate_data_dir!
114
+ script = File.join(Rubox.data_dir, "scripts", "build-ruby.sh")
115
+
116
+ env = {
117
+ "RUBOX_DATA_DIR" => Rubox.data_dir,
118
+ }
119
+
120
+ cmd = [
121
+ script,
122
+ "--ruby-version", ruby_version,
123
+ "--target", target,
124
+ "--output", output_dir,
125
+ ]
126
+
127
+ system(env, *cmd, exception: true)
128
+ end
129
+ end
130
+ end
data/lib/rubox/cli.rb ADDED
@@ -0,0 +1,214 @@
1
+ require "optparse"
2
+ require "rubox"
3
+
4
+ module Rubox
5
+ class CLI
6
+ def self.run(argv)
7
+ new(argv).run
8
+ end
9
+
10
+ def initialize(argv)
11
+ @argv = argv.dup
12
+ @options = {
13
+ prune: "default",
14
+ yes: false,
15
+ }
16
+ end
17
+
18
+ def run
19
+ command = @argv.shift
20
+
21
+ case command
22
+ when "pack"
23
+ parse_pack_options!
24
+ cmd_pack
25
+ when "build-ruby"
26
+ parse_build_options!
27
+ cmd_build_ruby
28
+ when "clean"
29
+ cmd_clean
30
+ when "-h", "--help", "help", nil
31
+ print_usage
32
+ when "-v", "--version"
33
+ puts "rubox #{VERSION}"
34
+ else
35
+ $stderr.puts "Unknown command: #{command}"
36
+ $stderr.puts "Run: rubox --help"
37
+ exit 1
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def parse_pack_options!
44
+ OptionParser.new do |opts|
45
+ opts.banner = "Usage: rubox pack [options]"
46
+ opts.on("--gem NAME", "Package a rubygems gem") { |v| @options[:gem] = v }
47
+ opts.on("--gemfile PATH", "Package a Gemfile-based app") { |v| @options[:gemfile] = v }
48
+ opts.on("--entry BIN", "Entry point binary name") { |v| @options[:entry] = v }
49
+ opts.on("--target TARGET", "Target platform") { |v| @options[:target] = v }
50
+ opts.on("--output PATH", "Output binary path") { |v| @options[:output] = v }
51
+ opts.on("--prune LEVEL", "Prune level: none, default") { |v| @options[:prune] = v }
52
+ opts.on("--keep-gems LIST", "Comma-separated gems to keep") { |v| @options[:keep_gems] = v }
53
+ opts.on("-y", "--yes", "Skip confirmation prompts") { @options[:yes] = true }
54
+ end.parse!(@argv)
55
+ end
56
+
57
+ def parse_build_options!
58
+ OptionParser.new do |opts|
59
+ opts.banner = "Usage: rubox build-ruby [options]"
60
+ opts.on("--target TARGET", "Target platform") { |v| @options[:target] = v }
61
+ opts.on("--ruby-version VER", "Ruby version") { |v| @options[:ruby_version] = v }
62
+ opts.on("-y", "--yes", "Skip confirmation prompts") { @options[:yes] = true }
63
+ end.parse!(@argv)
64
+ end
65
+
66
+ def cmd_pack
67
+ detector = Detector.new
68
+
69
+ if detector.has_config?
70
+ puts "Using config: #{Detector::CONFIG_FILE}"
71
+ end
72
+
73
+ # Merge: CLI flags > .rubox.yml > auto-detection
74
+ target = @options[:target] || detector.target || Platform.host_target
75
+ gem_name = @options[:gem] || detector.gem_name
76
+ gemfile = @options[:gemfile]
77
+
78
+ if gem_name.nil? && gemfile.nil?
79
+ gemfile = detector.gemfile_path
80
+ if gemfile
81
+ puts "Detected Gemfile: #{gemfile}"
82
+ else
83
+ $stderr.puts "ERROR: No --gem or --gemfile specified and no Gemfile found in #{Dir.pwd}"
84
+ exit 1
85
+ end
86
+ end
87
+
88
+ ruby_version = @options[:ruby_version] || detector.ruby_version
89
+ entry = @options[:entry] || (gem_name if gem_name) || detector.entry_name
90
+
91
+ # Output path
92
+ output = @options[:output] || begin
93
+ name = entry || gem_name || File.basename(Dir.pwd)
94
+ suffix = (target != Platform.host_target) ? "-#{target}" : ""
95
+ "build/#{name}#{suffix}"
96
+ end
97
+
98
+ ruby_dir = "build/ruby-#{ruby_version}-#{target}"
99
+
100
+ # Build Ruby if needed
101
+ builder = Builder.new(ruby_version: ruby_version, target: target, output_dir: ruby_dir)
102
+
103
+ unless builder.built?
104
+ puts ""
105
+ puts "rubox needs to fetch and compile a static Ruby #{ruby_version} for #{target}."
106
+ puts "This is a one-time operation (cached for future builds)."
107
+ puts ""
108
+
109
+ unless @options[:yes]
110
+ print "Continue? [y/N] "
111
+ answer = $stdin.gets&.strip&.downcase
112
+ unless answer == "y" || answer == "yes"
113
+ puts "Aborted."
114
+ exit 0
115
+ end
116
+ end
117
+
118
+ builder.build!
119
+ end
120
+
121
+ # Install gem if needed
122
+ packager = Packager.new(
123
+ ruby_dir: ruby_dir,
124
+ output: output,
125
+ target: target,
126
+ gem_name: gem_name,
127
+ gemfile: gemfile,
128
+ entry: entry,
129
+ prune: @options[:prune] || detector.prune || "default",
130
+ keep_gems: @options[:keep_gems] || detector.keep_gems,
131
+ )
132
+
133
+ if gem_name
134
+ gem_found = Dir.glob(File.join(ruby_dir, "lib/ruby/gems/*/gems/#{gem_name}-*")).any?
135
+ unless gem_found
136
+ packager.install_gem!(gem_name)
137
+ end
138
+ end
139
+
140
+ packager.package!
141
+
142
+ puts ""
143
+ puts "Binary ready: #{output}"
144
+ end
145
+
146
+ def cmd_build_ruby
147
+ detector = Detector.new
148
+ ruby_version = @options[:ruby_version] || detector.ruby_version
149
+ target = @options[:target]
150
+
151
+ builder = Builder.new(ruby_version: ruby_version, target: target)
152
+
153
+ if builder.built?
154
+ puts "Ruby #{ruby_version} for #{target} already built at #{builder.output_dir}"
155
+ return
156
+ end
157
+
158
+ puts ""
159
+ puts "rubox needs to fetch and compile a static Ruby #{ruby_version} for #{target}."
160
+ puts "This is a one-time operation."
161
+ puts ""
162
+
163
+ unless @options[:yes]
164
+ print "Continue? [y/N] "
165
+ answer = $stdin.gets&.strip&.downcase
166
+ unless answer == "y" || answer == "yes"
167
+ puts "Aborted."
168
+ exit 0
169
+ end
170
+ end
171
+
172
+ builder.build!
173
+ end
174
+
175
+ def cmd_clean
176
+ puts "Cleaning build artifacts..."
177
+ FileUtils.rm_rf("build")
178
+ puts "Done."
179
+ end
180
+
181
+ def print_usage
182
+ puts <<~USAGE
183
+ rubox v#{VERSION} - Package Ruby apps into single portable binaries.
184
+
185
+ Usage:
186
+ rubox pack [options] Package a gem or app into a single binary
187
+ rubox build-ruby [options] Build the static Ruby interpreter
188
+ rubox clean Remove build artifacts
189
+ rubox --version Show version
190
+
191
+ Pack options:
192
+ --gem NAME Package a rubygems gem
193
+ --gemfile PATH Package a Gemfile-based app (auto-detected if present)
194
+ --entry BIN Entry point binary name (defaults to gem name)
195
+ --target TARGET Target platform (default: #{Platform.host_target})
196
+ --output PATH Output binary path
197
+ --prune LEVEL Prune level: none, default (default: default)
198
+ --keep-gems LIST Comma-separated gems to keep despite prune list
199
+ -y, --yes Skip confirmation prompts
200
+
201
+ Targets:
202
+ aarch64-darwin macOS Apple Silicon
203
+ x86_64-darwin macOS Intel
204
+ aarch64-linux Linux ARM64
205
+ x86_64-linux Linux x86_64
206
+
207
+ Examples:
208
+ rubox pack --gem herb
209
+ rubox pack # auto-detects Gemfile
210
+ rubox pack -y --target aarch64-linux
211
+ USAGE
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,105 @@
1
+ require "yaml"
2
+
3
+ module Rubox
4
+ class Detector
5
+ CONFIG_FILE = ".rubox.yml"
6
+
7
+ attr_reader :dir
8
+
9
+ def initialize(dir = Dir.pwd)
10
+ @dir = dir
11
+ @config = load_config
12
+ end
13
+
14
+ def config
15
+ @config
16
+ end
17
+
18
+ def ruby_version
19
+ @config["ruby_version"] || from_ruby_version_file || from_gemfile || current_ruby_version
20
+ end
21
+
22
+ def gemfile_path
23
+ if @config["gemfile"]
24
+ path = File.expand_path(@config["gemfile"], dir)
25
+ return path if File.exist?(path)
26
+ end
27
+
28
+ %w[Gemfile gems.rb].each do |name|
29
+ path = File.join(dir, name)
30
+ return path if File.exist?(path)
31
+ end
32
+ nil
33
+ end
34
+
35
+ def entry_name
36
+ return @config["entry"] if @config["entry"]
37
+
38
+ gemspec = Dir.glob(File.join(dir, "*.gemspec")).first
39
+ if gemspec
40
+ content = File.read(gemspec)
41
+ if content =~ /spec\.executables\s*=.*?["']([^"']+)["']/
42
+ return $1
43
+ end
44
+ end
45
+
46
+ File.basename(dir)
47
+ end
48
+
49
+ def target
50
+ @config["target"]
51
+ end
52
+
53
+ def prune
54
+ @config["prune"]
55
+ end
56
+
57
+ def keep_gems
58
+ gems = @config["keep_gems"]
59
+ gems.is_a?(Array) ? gems.join(",") : gems
60
+ end
61
+
62
+ def gem_name
63
+ @config["gem"]
64
+ end
65
+
66
+ def has_config?
67
+ File.exist?(File.join(dir, CONFIG_FILE))
68
+ end
69
+
70
+ private
71
+
72
+ def load_config
73
+ path = File.join(dir, CONFIG_FILE)
74
+ return {} unless File.exist?(path)
75
+
76
+ config = YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
77
+ config.transform_keys(&:to_s)
78
+ rescue => e
79
+ $stderr.puts "rubox: warning: failed to parse #{CONFIG_FILE}: #{e.message}"
80
+ {}
81
+ end
82
+
83
+ def from_ruby_version_file
84
+ path = File.join(dir, ".ruby-version")
85
+ return nil unless File.exist?(path)
86
+
87
+ version = File.read(path).strip.sub(/^ruby-/, "")
88
+ version.match?(/^\d+\.\d+\.\d+$/) ? version : nil
89
+ end
90
+
91
+ def from_gemfile
92
+ path = gemfile_path
93
+ return nil unless path
94
+
95
+ content = File.read(path)
96
+ if content =~ /^\s*ruby\s+["'](\d+\.\d+\.\d+)["']/
97
+ $1
98
+ end
99
+ end
100
+
101
+ def current_ruby_version
102
+ RUBY_VERSION
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,118 @@
1
+ module Rubox
2
+ class Packager
3
+ def initialize(ruby_dir:, output:, target:, gem_name: nil, gemfile: nil,
4
+ entry: nil, prune: "default", keep_gems: nil)
5
+ @ruby_dir = ruby_dir
6
+ @output = output
7
+ @target = target
8
+ @gem_name = gem_name
9
+ @gemfile = gemfile
10
+ @entry = entry || gem_name
11
+ @prune = prune
12
+ @keep_gems = keep_gems
13
+ end
14
+
15
+ def package!
16
+ Rubox.validate_data_dir!
17
+ script = File.join(Rubox.data_dir, "scripts", "package.sh")
18
+ stub = stub_path
19
+ ensure_write_footer!
20
+
21
+ args = [
22
+ script,
23
+ "--ruby-dir", @ruby_dir,
24
+ "--stub", stub,
25
+ "--output", @output,
26
+ "--prune", @prune,
27
+ ]
28
+
29
+ if @gem_name
30
+ args += ["--gem", @gem_name]
31
+ args += ["--entry", @entry] if @entry
32
+ elsif @gemfile
33
+ args += ["--gemfile", @gemfile]
34
+ args += ["--entry", @entry] if @entry
35
+ end
36
+
37
+ args += ["--keep-gems", @keep_gems] if @keep_gems
38
+
39
+ env = {
40
+ "RUBOX_DATA_DIR" => Rubox.data_dir,
41
+ }
42
+
43
+ system(env, *args, exception: true)
44
+ end
45
+
46
+ def install_gem!(gem_name)
47
+ ruby_bin = File.join(@ruby_dir, "bin", "ruby")
48
+
49
+ if File.exist?(ruby_bin) && system(ruby_bin, "--version", out: File::NULL, err: File::NULL)
50
+ # Native platform -- run gem install directly
51
+ gem_cmd = File.join(@ruby_dir, "bin", "gem")
52
+ system(gem_cmd, "install", gem_name, "--no-document", exception: true)
53
+ else
54
+ # Cross-platform -- use Docker
55
+ puts "Installing #{gem_name} via Docker (cross-platform)..."
56
+ docker_platform = @target.start_with?("x86_64") ? "linux/amd64" : "linux/arm64"
57
+ system(
58
+ "docker", "run", "--rm", "--platform", docker_platform,
59
+ "-v", "#{File.expand_path(@ruby_dir)}:/opt/ruby",
60
+ "alpine:3.21",
61
+ "sh", "-c",
62
+ "apk add --no-cache build-base libgcc >/dev/null 2>&1 && /opt/ruby/bin/gem install #{gem_name} --no-document",
63
+ exception: true
64
+ )
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ # Find a pre-built stub for the target, or compile one from source.
71
+ def stub_path
72
+ prebuilt = File.join(Rubox.data_dir, "stubs", "stub-#{@target}")
73
+ return prebuilt if File.exist?(prebuilt)
74
+
75
+ # No pre-built stub -- compile from source
76
+ stub = File.join("build", "stub-#{@target}")
77
+ unless File.exist?(stub)
78
+ Dir.mkdir("build") unless Dir.exist?("build")
79
+ src = File.join(Rubox.data_dir, "ext", "stub.c")
80
+
81
+ if Platform.cross_build?(@target)
82
+ puts "Cross-compiling stub for #{@target} via Docker..."
83
+ docker_platform = @target.start_with?("x86_64") ? "linux/amd64" : "linux/arm64"
84
+ system(
85
+ "docker", "run", "--rm", "--platform", docker_platform,
86
+ "-v", "#{Dir.pwd}:/src", "-w", "/src",
87
+ "alpine:3.21",
88
+ "sh", "-c",
89
+ "apk add --no-cache gcc musl-dev >/dev/null 2>&1 && " \
90
+ "cc -O2 -Wall -Wextra -static -o #{stub} #{src}",
91
+ exception: true
92
+ )
93
+ else
94
+ system("cc", "-O2", "-Wall", "-Wextra", "-o", stub, src, exception: true)
95
+ end
96
+ end
97
+ stub
98
+ end
99
+
100
+ def ensure_write_footer!
101
+ prebuilt = File.join(Rubox.data_dir, "stubs", "write-footer-#{@target}")
102
+ if File.exist?(prebuilt)
103
+ # Symlink pre-built into build/ so package.sh can find it
104
+ Dir.mkdir("build") unless Dir.exist?("build")
105
+ wf = File.join("build", "write-footer")
106
+ FileUtils.cp(prebuilt, wf) unless File.exist?(wf)
107
+ return
108
+ end
109
+
110
+ wf = File.join("build", "write-footer")
111
+ return if File.exist?(wf)
112
+
113
+ Dir.mkdir("build") unless Dir.exist?("build")
114
+ src = File.join(Rubox.data_dir, "ext", "write-footer.c")
115
+ system("cc", "-O2", "-Wall", "-Wextra", "-o", wf, src, exception: true)
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,40 @@
1
+ module Rubox
2
+ module Platform
3
+ def self.host_arch
4
+ arch = RbConfig::CONFIG["host_cpu"] || `uname -m`.strip
5
+ case arch
6
+ when /arm64|aarch64/ then "aarch64"
7
+ when /x86_64|amd64/ then "x86_64"
8
+ else arch
9
+ end
10
+ end
11
+
12
+ def self.host_os
13
+ case RbConfig::CONFIG["host_os"]
14
+ when /darwin/ then "darwin"
15
+ when /linux/ then "linux"
16
+ else `uname -s`.strip.downcase
17
+ end
18
+ end
19
+
20
+ def self.host_target
21
+ "#{host_arch}-#{host_os}"
22
+ end
23
+
24
+ def self.valid_targets
25
+ %w[aarch64-darwin x86_64-darwin aarch64-linux x86_64-linux]
26
+ end
27
+
28
+ def self.valid_target?(target)
29
+ valid_targets.include?(target)
30
+ end
31
+
32
+ def self.cross_build?(target)
33
+ target != host_target
34
+ end
35
+
36
+ def self.linux_target?(target)
37
+ target.end_with?("-linux")
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,3 @@
1
+ module Rubox
2
+ VERSION = "0.1.0"
3
+ end
data/lib/rubox.rb ADDED
@@ -0,0 +1,31 @@
1
+ require "fileutils"
2
+ require "rubox/version"
3
+ require "rubox/platform"
4
+ require "rubox/detector"
5
+ require "rubox/builder"
6
+ require "rubox/packager"
7
+
8
+ module Rubox
9
+ def self.data_dir
10
+ @data_dir ||= File.expand_path("../data", __dir__)
11
+ end
12
+
13
+ REQUIRED_DATA_FILES = %w[
14
+ scripts/build-ruby.sh
15
+ scripts/package.sh
16
+ scripts/_common.sh
17
+ ext/stub.c
18
+ ext/write-footer.c
19
+ Dockerfile.ruby-build
20
+ prune-list.conf
21
+ ].freeze
22
+
23
+ def self.validate_data_dir!
24
+ missing = REQUIRED_DATA_FILES.select { |f| !File.exist?(File.join(data_dir, f)) }
25
+ return if missing.empty?
26
+
27
+ abort "rubox: data directory incomplete (#{data_dir}).\n" \
28
+ "Missing: #{missing.join(', ')}\n" \
29
+ "Reinstall the gem: gem install rubox"
30
+ end
31
+ end