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,544 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Package a Ruby gem or Gemfile-based app into a single self-extracting binary.
4
+ #
5
+ # Modes:
6
+ # Gem mode: --gem NAME --entry BIN
7
+ # Gemfile mode: --gemfile PATH --entry BIN
8
+ #
9
+ # Usage:
10
+ # # Single gem
11
+ # ./scripts/package.sh --ruby-dir build/ruby-4.0.0-aarch64-darwin \
12
+ # --gem herb --output build/herb
13
+ #
14
+ # # Gemfile-based app
15
+ # ./scripts/package.sh --ruby-dir build/ruby-4.0.0-aarch64-darwin \
16
+ # --gemfile /path/to/Gemfile --entry my_app --output build/my_app
17
+ #
18
+ set -euo pipefail
19
+
20
+ RUBY_DIR=""
21
+ GEM_NAME=""
22
+ GEMFILE=""
23
+ ENTRY_BIN=""
24
+ OUTPUT=""
25
+ STUB=""
26
+ PRUNE_LEVEL="default" # none, default, aggressive
27
+ KEEP_GEMS=""
28
+ REMOVE_GEMS=""
29
+ FIX_DYLIBS="yes"
30
+
31
+ while [[ $# -gt 0 ]]; do
32
+ case "$1" in
33
+ --ruby-dir) RUBY_DIR="$2"; shift 2 ;;
34
+ --gem) GEM_NAME="$2"; shift 2 ;;
35
+ --gemfile) GEMFILE="$2"; shift 2 ;;
36
+ --entry) ENTRY_BIN="$2"; shift 2 ;;
37
+ --output) OUTPUT="$2"; shift 2 ;;
38
+ --stub) STUB="$2"; shift 2 ;;
39
+ --prune) PRUNE_LEVEL="$2"; shift 2 ;;
40
+ --keep-gems) KEEP_GEMS="$2"; shift 2 ;;
41
+ --remove-gems) REMOVE_GEMS="$2"; shift 2 ;;
42
+ --no-fix-dylibs) FIX_DYLIBS="no"; shift ;;
43
+ *) echo "Unknown option: $1"; exit 1 ;;
44
+ esac
45
+ done
46
+
47
+ if [[ -z "$RUBY_DIR" || -z "$OUTPUT" ]]; then
48
+ echo "Usage: $0 --ruby-dir DIR --output FILE (--gem NAME | --gemfile PATH) [--entry BIN]"
49
+ exit 1
50
+ fi
51
+ if [[ -z "$GEM_NAME" && -z "$GEMFILE" ]]; then
52
+ echo "ERROR: Must specify --gem or --gemfile"
53
+ exit 1
54
+ fi
55
+
56
+ source "$(dirname "$0")/_common.sh"
57
+ RUBY_DIR="$(cd "$RUBY_DIR" && pwd)"
58
+
59
+ # Defaults
60
+ ENTRY_BIN="${ENTRY_BIN:-$GEM_NAME}"
61
+ STUB="${STUB:-${PROJECT_DIR}/build/stub}"
62
+ RUBY="${RUBY_DIR}/bin/ruby"
63
+ GEM_CMD="${RUBY_DIR}/bin/gem"
64
+
65
+ STAGING_DIR=$(mktemp -d)
66
+ PAYLOAD_FILE=$(mktemp)
67
+ trap 'rm -rf "$STAGING_DIR" "$PAYLOAD_FILE"' EXIT
68
+
69
+ MODE="gem"
70
+ [[ -n "$GEMFILE" ]] && MODE="gemfile"
71
+
72
+ echo "==> Packaging (mode: ${MODE}, entry: ${ENTRY_BIN})"
73
+ echo " Ruby: ${RUBY_DIR}"
74
+
75
+ # ===================================================================
76
+ # Stage 1: Copy Ruby
77
+ # ===================================================================
78
+ echo "==> Copying Ruby..."
79
+ mkdir -p "${STAGING_DIR}/bin"
80
+ cp "${RUBY_DIR}/bin/ruby" "${STAGING_DIR}/bin/ruby"
81
+ chmod +x "${STAGING_DIR}/bin/ruby"
82
+
83
+ mkdir -p "${STAGING_DIR}/lib"
84
+ cp -a "${RUBY_DIR}/lib/ruby" "${STAGING_DIR}/lib/ruby"
85
+
86
+ # Copy top-level shared libs (bundled musl loader, libgcc_s, etc.)
87
+ for f in "${RUBY_DIR}"/lib/*.so* "${RUBY_DIR}"/lib/ld-*; do
88
+ [[ -f "$f" ]] && cp -a "$f" "${STAGING_DIR}/lib/"
89
+ done
90
+
91
+ # ===================================================================
92
+ # Stage 2: Install gems (gem mode) or bundle (gemfile mode)
93
+ # ===================================================================
94
+ if [[ "$MODE" == "gemfile" ]]; then
95
+ echo "==> Installing gems via Bundler (standalone mode)..."
96
+ GEMFILE_ABS="$(cd "$(dirname "$GEMFILE")" && pwd)/$(basename "$GEMFILE")"
97
+ GEMFILE_DIR="$(dirname "$GEMFILE_ABS")"
98
+
99
+ # Ensure bundler is available
100
+ if [[ ! -f "${RUBY_DIR}/bin/bundle" ]]; then
101
+ "${GEM_CMD}" install bundler --no-document 2>&1 | tail -1
102
+ fi
103
+
104
+ BUNDLE="${RUBY_DIR}/bin/bundle"
105
+ BUNDLE_DIR="${STAGING_DIR}/bundle"
106
+ mkdir -p "$BUNDLE_DIR"
107
+
108
+ # Run bundle install --standalone
109
+ # This creates a self-contained bundle with a bundler/setup.rb
110
+ cd "$GEMFILE_DIR"
111
+ BUNDLE_PATH="$BUNDLE_DIR" \
112
+ BUNDLE_GEMFILE="$GEMFILE_ABS" \
113
+ BUNDLE_WITHOUT="development:test" \
114
+ BUNDLE_STANDALONE=true \
115
+ "$BUNDLE" install --standalone --jobs 4 2>&1 | sed 's/^/ /'
116
+ cd "$PROJECT_DIR"
117
+
118
+ echo " Checking standalone bundle..."
119
+ SETUP_RB=$(find "$BUNDLE_DIR" -name "setup.rb" -path "*/bundler/*" | head -1)
120
+ if [[ -n "$SETUP_RB" ]]; then
121
+ echo " OK: standalone setup.rb found"
122
+ # Bundler's standalone setup.rb has correct per-gem require_paths but
123
+ # uses absolute build-time paths with Ruby interpolations like:
124
+ # File.expand_path("#{__dir__}/../../../<tmp>/bundle/#{RUBY_ENGINE}/...")
125
+ # Rewrite to use RUBOX_ROOT, preserving the Ruby interpolations
126
+ # for RUBY_ENGINE and Gem.ruby_api_version.
127
+ echo " Rewriting load paths to use runtime root..."
128
+
129
+ # Find the bundle directory name inside staging (could be "bundle" or "vendor")
130
+ BUNDLE_SUBDIR=$(basename "$BUNDLE_DIR")
131
+
132
+ # Use Ruby to rewrite the setup.rb -- handles the path parsing cleanly
133
+ "${RUBY}" -e '
134
+ root_marker = ARGV[0] # e.g. "/tmp/.../bundle"
135
+ setup = File.read(ARGV[1])
136
+ lines = setup.lines
137
+
138
+ out = []
139
+ out << %(_root = ENV["RUBOX_ROOT"])
140
+ out << %(_bundle = File.join(_root, "#{ARGV[2]}"))
141
+ out << ""
142
+
143
+ lines.each do |line|
144
+ if line.start_with?("$:.unshift")
145
+ # Replace the File.expand_path("#{__dir__}/../../..<abs_path>/bundle/...")
146
+ # with File.join(_bundle, "...")
147
+ # The path after the bundle dir looks like: #{RUBY_ENGINE}/#{Gem...}/gems/foo/lib
148
+ if line.include?(root_marker)
149
+ suffix = line.split(root_marker, 2).last
150
+ # Clean up: remove leading /, trailing quote/paren/newline
151
+ suffix = suffix.sub(/^\//, "").sub(/[")\s]+$/, "")
152
+ out << %($:.unshift File.join(_bundle, "#{suffix}"))
153
+ end
154
+ elsif !line.include?("$:.unshift")
155
+ out << line.rstrip
156
+ end
157
+ end
158
+
159
+ File.write(ARGV[1], out.join("\n") + "\n")
160
+ ' "$BUNDLE_DIR" "$SETUP_RB" "$BUNDLE_SUBDIR" 2>&1 | sed 's/^/ /'
161
+ else
162
+ echo " WARNING: standalone setup.rb not found, gems may not load correctly"
163
+ fi
164
+
165
+ # Copy the app's bin/ directory and any source files
166
+ echo " Copying application files..."
167
+ mkdir -p "${STAGING_DIR}/app"
168
+ if [[ -d "${GEMFILE_DIR}/bin" ]]; then
169
+ cp -a "${GEMFILE_DIR}/bin" "${STAGING_DIR}/app/bin"
170
+ fi
171
+ if [[ -d "${GEMFILE_DIR}/lib" ]]; then
172
+ cp -a "${GEMFILE_DIR}/lib" "${STAGING_DIR}/app/lib"
173
+ fi
174
+ # Copy the entry script itself if it lives outside bin/
175
+ if [[ -f "${GEMFILE_DIR}/${ENTRY_BIN}" ]]; then
176
+ cp "${GEMFILE_DIR}/${ENTRY_BIN}" "${STAGING_DIR}/app/"
177
+ fi
178
+ else
179
+ echo "==> Gem '${GEM_NAME}' should already be installed in Ruby dir"
180
+ # Check if gem exists by looking for its directory (works for cross-platform builds)
181
+ GEM_FOUND=$(find "${RUBY_DIR}/lib/ruby/gems" -maxdepth 3 -type d -name "${GEM_NAME}-*" | head -1)
182
+ if [[ -z "$GEM_FOUND" ]]; then
183
+ # Try running gem install (only works for native platform)
184
+ if "${RUBY}" --version >/dev/null 2>&1; then
185
+ echo " Not found, installing..."
186
+ "${GEM_CMD}" install "${GEM_NAME}" --no-document 2>&1 | sed 's/^/ /'
187
+ else
188
+ echo "ERROR: gem '${GEM_NAME}' not found in ${RUBY_DIR} and cannot run gem install (cross-platform build)."
189
+ echo " Install the gem first inside Docker using the target Ruby."
190
+ exit 1
191
+ fi
192
+ fi
193
+ echo " OK: ${GEM_NAME} found"
194
+ fi
195
+
196
+ # ===================================================================
197
+ # Stage 3: Prune
198
+ # ===================================================================
199
+ echo "==> Pruning (level: ${PRUNE_LEVEL})..."
200
+
201
+ # Always: remove gem cache, build artifacts, docs
202
+ rm -rf "${STAGING_DIR}/lib/ruby/gems"/*/cache
203
+ find "${STAGING_DIR}" -name "*.o" -delete 2>/dev/null || true
204
+ find "${STAGING_DIR}" \( -name "doc" -o -name "ri" -o -name ".rdoc" \) -type d | xargs rm -rf 2>/dev/null || true
205
+ # Remove include/ dir (C headers, never needed at runtime)
206
+ rm -rf "${STAGING_DIR}"/lib/ruby/gems/*/extensions/*/include 2>/dev/null || true
207
+
208
+ if [[ "$PRUNE_LEVEL" != "none" ]]; then
209
+ # Remove test/spec dirs from gems
210
+ find "${STAGING_DIR}/lib/ruby/gems" \
211
+ \( -name "test" -o -name "spec" -o -name "tests" -o -name "specs" \) \
212
+ -type d | xargs rm -rf 2>/dev/null || true
213
+
214
+ # Remove sig/ directories (RBS type signatures)
215
+ find "${STAGING_DIR}/lib/ruby/gems" -name "sig" -type d | xargs rm -rf 2>/dev/null || true
216
+
217
+ # Remove C source from installed gems
218
+ find "${STAGING_DIR}/lib/ruby/gems" \( -name "*.c" -o -name "*.h" \) -type f -delete 2>/dev/null || true
219
+ find "${STAGING_DIR}/lib/ruby/gems" -name "Makefile" -type f -delete 2>/dev/null || true
220
+
221
+ # Remove .bundle files for Ruby versions we don't need
222
+ # Get Ruby version - try running ruby, fall back to directory inspection
223
+ if "${RUBY}" --version >/dev/null 2>&1; then
224
+ RUBY_MAJOR_MINOR=$("${RUBY}" -e 'puts RUBY_VERSION.split(".")[0..1].join(".")')
225
+ else
226
+ # Cross-platform: infer from the gems directory name
227
+ RUBY_MAJOR_MINOR=$(ls -1 "${RUBY_DIR}/lib/ruby/gems/" 2>/dev/null | head -1 | sed 's/\.[0-9]*$//')
228
+ fi
229
+ echo " Keeping native extensions for Ruby ${RUBY_MAJOR_MINOR} only"
230
+ # Find versioned .bundle dirs like herb/3.3/, herb/3.4/, etc.
231
+ find "${STAGING_DIR}/lib/ruby/gems" -type d -regex '.*/[0-9]\.[0-9]$' | while read -r ver_dir; do
232
+ ver=$(basename "$ver_dir")
233
+ if [[ "$ver" != "$RUBY_MAJOR_MINOR" ]]; then
234
+ rm -rf "$ver_dir"
235
+ fi
236
+ done
237
+
238
+ # Apply prune list
239
+ PRUNE_LIST="${DATA_DIR}/prune-list.conf"
240
+ if [[ -f "$PRUNE_LIST" ]]; then
241
+ while IFS= read -r gem_pattern; do
242
+ # Skip comments and empty lines
243
+ [[ "$gem_pattern" =~ ^#.*$ || -z "$gem_pattern" ]] && continue
244
+ gem_pattern=$(echo "$gem_pattern" | tr -d '[:space:]')
245
+
246
+ # Check if this gem should be kept
247
+ if [[ -n "$KEEP_GEMS" ]] && echo "$KEEP_GEMS" | grep -qw "$gem_pattern"; then
248
+ continue
249
+ fi
250
+
251
+ # Remove from gems directory
252
+ find "${STAGING_DIR}/lib/ruby/gems" -maxdepth 3 -type d -name "${gem_pattern}-*" | while read -r d; do
253
+ echo " Pruning gem: $(basename "$d")"
254
+ rm -rf "$d"
255
+ done
256
+
257
+ # Remove from specifications
258
+ find "${STAGING_DIR}/lib/ruby/gems" -name "${gem_pattern}-*.gemspec" -delete 2>/dev/null || true
259
+
260
+ # Remove from extensions
261
+ find "${STAGING_DIR}/lib/ruby/gems" -path "*/extensions/*/${gem_pattern}-*" -type d | xargs rm -rf 2>/dev/null || true
262
+
263
+ # Remove from stdlib (lib/ruby/X.Y.Z/)
264
+ for stdlib_dir in "${STAGING_DIR}"/lib/ruby/[0-9]*/; do
265
+ rm -rf "${stdlib_dir}/${gem_pattern}" 2>/dev/null || true
266
+ rm -f "${stdlib_dir}/${gem_pattern}.rb" 2>/dev/null || true
267
+ done
268
+ done < "$PRUNE_LIST"
269
+ fi
270
+
271
+ # Remove extra gems specified by user
272
+ if [[ -n "$REMOVE_GEMS" ]]; then
273
+ IFS=',' read -ra EXTRA_REMOVE <<< "$REMOVE_GEMS"
274
+ for gem_name in "${EXTRA_REMOVE[@]}"; do
275
+ gem_name=$(echo "$gem_name" | tr -d '[:space:]')
276
+ find "${STAGING_DIR}/lib/ruby/gems" -maxdepth 3 -type d -name "${gem_name}-*" | while read -r d; do
277
+ echo " Pruning extra: $(basename "$d")"
278
+ rm -rf "$d"
279
+ done
280
+ done
281
+ fi
282
+
283
+ # Remove fiddle (links to Homebrew libffi, rarely needed)
284
+ if [[ -z "$KEEP_GEMS" ]] || ! echo "$KEEP_GEMS" | grep -qw "fiddle"; then
285
+ find "${STAGING_DIR}" -path "*fiddle*" -delete 2>/dev/null || true
286
+ fi
287
+ fi
288
+
289
+ STAGED_SIZE=$(du -sm "${STAGING_DIR}" | cut -f1)
290
+ echo " Staged size after pruning: ${STAGED_SIZE}MB"
291
+
292
+ # ===================================================================
293
+ # Stage 4: Fix dylib references (macOS)
294
+ # ===================================================================
295
+ # Determine binary format for platform-specific steps
296
+ BINARY_FORMAT_CHECK=$(file "${STAGING_DIR}/bin/ruby" | grep -o 'ELF' || true)
297
+
298
+ if [[ "$FIX_DYLIBS" == "yes" && "$BINARY_FORMAT_CHECK" != "ELF" && "$(uname -s)" == "Darwin" ]]; then
299
+ echo "==> Fixing dylib references (macOS)..."
300
+ "${DATA_DIR}/scripts/fix-dylibs.sh" "${STAGING_DIR}"
301
+ fi
302
+
303
+ # For Linux: verify the bundled loader and runtime libs are present.
304
+ # These are baked into the Ruby installation by the Dockerfile at build time.
305
+ if [[ "$BINARY_FORMAT_CHECK" == "ELF" ]]; then
306
+ LOADER=$(find "${STAGING_DIR}/lib" -name "ld-musl-*" -type f 2>/dev/null | head -1)
307
+ if [[ -n "$LOADER" ]]; then
308
+ chmod +x "$LOADER"
309
+ echo " Bundled musl loader: $(basename "$LOADER")"
310
+ # List all bundled .so files
311
+ find "${STAGING_DIR}/lib" -maxdepth 1 -name "*.so*" -type f | while read -r f; do
312
+ echo " Bundled lib: $(basename "$f")"
313
+ done
314
+ else
315
+ echo " WARNING: No bundled musl loader found in lib/"
316
+ echo " The binary may not work on non-musl Linux systems."
317
+ echo " Rebuild Ruby with the Dockerfile to bundle the loader."
318
+ fi
319
+ fi
320
+
321
+ # ===================================================================
322
+ # Stage 5: Verify portability
323
+ # ===================================================================
324
+ echo "==> Verifying portability..."
325
+
326
+ # Determine binary format
327
+ BINARY_FORMAT=$(file "${STAGING_DIR}/bin/ruby" | grep -o 'ELF\|Mach-O')
328
+
329
+ if [[ "$BINARY_FORMAT" == "ELF" ]]; then
330
+ # Linux: check with ldd (if available) or file
331
+ if command -v ldd &>/dev/null && ldd "${STAGING_DIR}/bin/ruby" 2>&1 | grep -q "statically linked"; then
332
+ echo " OK: ruby is statically linked"
333
+ elif file "${STAGING_DIR}/bin/ruby" | grep -q "statically linked"; then
334
+ echo " OK: ruby is statically linked"
335
+ else
336
+ echo " INFO: cannot verify static linking (cross-platform build)"
337
+ fi
338
+ # Check .so files for non-system deps
339
+ find "${STAGING_DIR}" -name "*.so" -type f | while read -r f; do
340
+ if command -v readelf &>/dev/null; then
341
+ NEEDED=$(readelf -d "$f" 2>/dev/null | grep NEEDED | grep -v 'libc\.\|libm\.\|libdl\.\|libpthread\.\|librt\.\|ld-linux' || true)
342
+ if [[ -n "$NEEDED" ]]; then
343
+ echo " WARN: ${f#${STAGING_DIR}/} has deps: $NEEDED"
344
+ fi
345
+ fi
346
+ done
347
+ elif [[ "$BINARY_FORMAT" == "Mach-O" ]]; then
348
+ # macOS: check with otool
349
+ PORTABLE=true
350
+ NON_SYS=$(otool -L "${STAGING_DIR}/bin/ruby" 2>/dev/null | tail -n +2 | grep -v '/usr/lib/' | grep -v '/System/' || true)
351
+ if [[ -n "$NON_SYS" ]]; then
352
+ echo " FAIL: bin/ruby has non-portable deps:"
353
+ echo "$NON_SYS" | sed 's/^/ /'
354
+ PORTABLE=false
355
+ fi
356
+
357
+ find "${STAGING_DIR}" -type f \( -name "*.bundle" -o -name "*.dylib" \) ! -path "*/DWARF/*" | while read -r f; do
358
+ NON_SYS=$(otool -L "$f" 2>/dev/null | tail -n +2 | grep -v '/usr/lib/' | grep -v '/System/' | grep -v '@loader_path' || true)
359
+ if [[ -n "$NON_SYS" ]]; then
360
+ echo " FAIL: ${f#${STAGING_DIR}/}"
361
+ echo "$NON_SYS" | sed 's/^/ /'
362
+ PORTABLE=false
363
+ fi
364
+ done
365
+
366
+ if [[ "$PORTABLE" == "false" ]]; then
367
+ echo ""
368
+ echo "WARNING: Non-portable dependencies detected."
369
+ echo ""
370
+ fi
371
+ fi
372
+
373
+ # ===================================================================
374
+ # Stage 6: Create entry script
375
+ # ===================================================================
376
+ echo "==> Creating entry script..."
377
+
378
+ GEM_VERSION_DIR=$(ls -1 "${STAGING_DIR}/lib/ruby/gems/" 2>/dev/null | head -1)
379
+
380
+ if [[ "$MODE" == "gemfile" ]]; then
381
+ # Gemfile mode: use standalone bundle + app's own scripts
382
+ SETUP_RB_REL=$(find "${STAGING_DIR}/bundle" -name "setup.rb" -path "*/bundler/*" 2>/dev/null | head -1)
383
+ SETUP_RB_REL="${SETUP_RB_REL#${STAGING_DIR}/}"
384
+
385
+ # Find the entry script in the app
386
+ ENTRY_PATH=""
387
+ for candidate in "app/bin/${ENTRY_BIN}" "app/${ENTRY_BIN}"; do
388
+ if [[ -f "${STAGING_DIR}/${candidate}" ]]; then
389
+ ENTRY_PATH="${candidate}"
390
+ break
391
+ fi
392
+ done
393
+ if [[ -z "$ENTRY_PATH" ]]; then
394
+ echo "ERROR: entry '${ENTRY_BIN}' not found in app/bin/ or app/"
395
+ exit 1
396
+ fi
397
+ echo " Entry point: ${ENTRY_PATH}"
398
+
399
+ cat > "${STAGING_DIR}/entry.rb" << EOF
400
+ # rubox entry (gemfile mode)
401
+ root = ENV["RUBOX_ROOT"]
402
+
403
+ # Cleanup handler for no-cache mode
404
+ if cleanup_dir = ENV["RUBOX_CLEANUP"]
405
+ at_exit { require "fileutils"; FileUtils.rm_rf(cleanup_dir) rescue nil }
406
+ end
407
+
408
+ # Load standalone bundle (sets up \$LOAD_PATH for all gems)
409
+ require File.join(root, "${SETUP_RB_REL}")
410
+
411
+ # Add app's lib/ to load path
412
+ app_lib = File.join(root, "app", "lib")
413
+ \$LOAD_PATH.unshift(app_lib) if File.directory?(app_lib)
414
+
415
+ # Run the app's entry script
416
+ load File.join(root, "${ENTRY_PATH}")
417
+ EOF
418
+ else
419
+ # Gem mode: set up load paths manually to avoid rubygems dependency.
420
+ # Static Ruby builds may not have rubygems loaded by default.
421
+ cat > "${STAGING_DIR}/entry.rb" << 'ENTRY_EOF'
422
+ # rubox entry (gem mode)
423
+ root = ENV["RUBOX_ROOT"]
424
+
425
+ # Cleanup handler for no-cache mode
426
+ if cleanup_dir = ENV["RUBOX_CLEANUP"]
427
+ at_exit do
428
+ require "fileutils"
429
+ FileUtils.rm_rf(cleanup_dir) rescue nil
430
+ end
431
+ end
432
+
433
+ ENTRY_EOF
434
+
435
+ # Build $LOAD_PATH entries from the actual gem directories
436
+ cat >> "${STAGING_DIR}/entry.rb" << EOF
437
+ # Add Ruby stdlib to load path
438
+ ruby_lib = File.join(root, "lib", "ruby", "${GEM_VERSION_DIR}")
439
+ \$LOAD_PATH.unshift(ruby_lib)
440
+
441
+ # Add arch-specific stdlib
442
+ Dir.glob(File.join(ruby_lib, "*-*")).each do |arch_dir|
443
+ \$LOAD_PATH.unshift(arch_dir) if File.directory?(arch_dir)
444
+ end
445
+
446
+ # Add gem lib directories to load path
447
+ gem_base = File.join(root, "lib", "ruby", "gems", "${GEM_VERSION_DIR}")
448
+ Dir.glob(File.join(gem_base, "gems", "*", "lib")).each do |lib_dir|
449
+ \$LOAD_PATH.unshift(lib_dir)
450
+ end
451
+
452
+ # Add native extension directories
453
+ Dir.glob(File.join(gem_base, "extensions", "**", "*.{bundle,so}")).each do |ext|
454
+ ext_dir = File.dirname(ext)
455
+ \$LOAD_PATH.unshift(ext_dir) unless \$LOAD_PATH.include?(ext_dir)
456
+ end
457
+ EOF
458
+
459
+ # Invoke the gem's CLI. Two strategies:
460
+ #
461
+ # 1. If rubygems is available (macOS builds), use Gem.bin_path which
462
+ # handles all edge cases (complex exe scripts, require_relative, etc.)
463
+ #
464
+ # 2. If rubygems is not loaded (static Linux builds), fall back to
465
+ # require + grep approach for the CLI invocation.
466
+ #
467
+ cat >> "${STAGING_DIR}/entry.rb" << EOF
468
+
469
+ # Try rubygems first (handles complex exe scripts correctly)
470
+ begin
471
+ require "rubygems"
472
+ ENV["GEM_HOME"] = gem_base
473
+ ENV["GEM_PATH"] = gem_base
474
+ Gem.clear_paths
475
+ gem "${GEM_NAME}"
476
+ load Gem.bin_path("${GEM_NAME}", "${ENTRY_BIN}")
477
+ rescue LoadError, NoMethodError
478
+ # Rubygems not available (static build) -- manual require + CLI
479
+ require "${GEM_NAME}"
480
+ EOF
481
+
482
+ # Find the gem's exe script to extract the CLI invocation for the fallback path
483
+ GEM_EXE=$(find "${RUBY_DIR}/lib/ruby/gems" -path "*/gems/${GEM_NAME}-*/exe/${ENTRY_BIN}" -type f | head -1)
484
+ if [[ -z "$GEM_EXE" ]]; then
485
+ GEM_EXE=$(find "${RUBY_DIR}/lib/ruby/gems" -path "*/gems/${GEM_NAME}-*/bin/${ENTRY_BIN}" -type f | head -1)
486
+ fi
487
+
488
+ if [[ -n "$GEM_EXE" ]]; then
489
+ # Extract just the CLI invocation (last non-empty, non-comment, non-boilerplate lines)
490
+ echo " # Fallback CLI invocation from $(basename "$GEM_EXE")" >> "${STAGING_DIR}/entry.rb"
491
+ grep -v '^#' "$GEM_EXE" | \
492
+ grep -v 'frozen_string_literal' | \
493
+ grep -v 'require_relative' | \
494
+ grep -v 'require\b' | \
495
+ grep -v '^\s*$' | \
496
+ sed 's/^/ /' >> "${STAGING_DIR}/entry.rb"
497
+ fi
498
+
499
+ echo "end" >> "${STAGING_DIR}/entry.rb"
500
+ fi
501
+
502
+ echo " Entry script created (mode: ${MODE})"
503
+
504
+ # ===================================================================
505
+ # Stage 7: Compress and assemble
506
+ # ===================================================================
507
+ echo "==> Compressing payload..."
508
+ cd "${STAGING_DIR}"
509
+
510
+ # Use gzip -- universally available on all target systems (no zstd dependency).
511
+ # --no-mac-metadata avoids ._* resource fork files that confuse GNU tar on Linux.
512
+ tar czf "${PAYLOAD_FILE}" --no-mac-metadata . 2>/dev/null || \
513
+ tar czf "${PAYLOAD_FILE}" .
514
+ cd "${PROJECT_DIR}"
515
+
516
+ PAYLOAD_SIZE=$(stat -f%z "${PAYLOAD_FILE}" 2>/dev/null || stat -c%s "${PAYLOAD_FILE}" 2>/dev/null)
517
+ echo " Payload: $(echo "scale=1; ${PAYLOAD_SIZE}/1048576" | bc)MB"
518
+
519
+ echo "==> Assembling binary..."
520
+
521
+ if [[ ! -f "$STUB" ]]; then
522
+ echo "ERROR: Stub not found at ${STUB}. Run: make stub"
523
+ exit 1
524
+ fi
525
+
526
+ cp "${STUB}" "${OUTPUT}"
527
+ STUB_SIZE=$(stat -f%z "${OUTPUT}" 2>/dev/null || stat -c%s "${OUTPUT}" 2>/dev/null)
528
+
529
+ cat "${PAYLOAD_FILE}" >> "${OUTPUT}"
530
+
531
+ WRITE_FOOTER="${PROJECT_DIR}/build/write-footer"
532
+ if [[ ! -f "$WRITE_FOOTER" ]]; then
533
+ echo "ERROR: write-footer not found. Run: make stub"
534
+ exit 1
535
+ fi
536
+ "$WRITE_FOOTER" "${OUTPUT}" "${STUB_SIZE}" "${PAYLOAD_SIZE}"
537
+
538
+ chmod +x "${OUTPUT}"
539
+
540
+ FINAL_SIZE=$(stat -f%z "${OUTPUT}" 2>/dev/null || stat -c%s "${OUTPUT}" 2>/dev/null)
541
+ echo ""
542
+ echo "==> Done!"
543
+ echo " Binary: ${OUTPUT} ($(echo "scale=1; ${FINAL_SIZE}/1048576" | bc)MB)"
544
+ echo " Run: ./${OUTPUT}"
Binary file
Binary file
Binary file
data/exe/rubox ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+ require "rubox/cli"
3
+ Rubox::CLI.run(ARGV)