kompo 0.2.0 → 0.3.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,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Kompo
6
+ # Copy Gemfile, Gemfile.lock, and gemspec files to working directory if they exist
7
+ class CopyGemfile < Taski::Task
8
+ exports :gemfile_exists
9
+
10
+ def run
11
+ work_dir = WorkDir.path
12
+ project_dir = Taski.args.fetch(:project_dir, Taski.env.working_directory) || Taski.env.working_directory
13
+
14
+ gemfile_path = File.join(project_dir, 'Gemfile')
15
+ gemfile_lock_path = File.join(project_dir, 'Gemfile.lock')
16
+
17
+ @gemfile_exists = File.exist?(gemfile_path)
18
+ @copied_gemspecs = []
19
+
20
+ if @gemfile_exists
21
+ FileUtils.cp(gemfile_path, work_dir)
22
+ puts 'Copied: Gemfile'
23
+
24
+ if File.exist?(gemfile_lock_path)
25
+ FileUtils.cp(gemfile_lock_path, work_dir)
26
+ puts 'Copied: Gemfile.lock'
27
+ end
28
+
29
+ # Copy gemspec files if Gemfile references gemspec
30
+ copy_gemspec_if_needed(gemfile_path, project_dir, work_dir)
31
+ else
32
+ puts 'No Gemfile found, skipping'
33
+ end
34
+ end
35
+
36
+ def clean
37
+ return unless @gemfile_exists
38
+
39
+ work_dir = WorkDir.path
40
+ return unless work_dir && Dir.exist?(work_dir)
41
+
42
+ gemfile = File.join(work_dir, 'Gemfile')
43
+ gemfile_lock = File.join(work_dir, 'Gemfile.lock')
44
+
45
+ FileUtils.rm_f(gemfile)
46
+ FileUtils.rm_f(gemfile_lock)
47
+
48
+ # Clean up copied gemspec files
49
+ (@copied_gemspecs || []).each do |gemspec|
50
+ FileUtils.rm_f(gemspec)
51
+ end
52
+
53
+ puts 'Cleaned up Gemfile'
54
+ end
55
+
56
+ private
57
+
58
+ def copy_gemspec_if_needed(gemfile_path, project_dir, work_dir)
59
+ gemfile_content = File.read(gemfile_path)
60
+
61
+ # Check if Gemfile contains a gemspec directive
62
+ return unless gemfile_content.match?(/^\s*gemspec\b/)
63
+
64
+ # Copy all .gemspec files from project directory
65
+ gemspec_files = Dir.glob(File.join(project_dir, '*.gemspec'))
66
+ gemspec_files.each do |gemspec_path|
67
+ dest_path = File.join(work_dir, File.basename(gemspec_path))
68
+ FileUtils.cp(gemspec_path, dest_path)
69
+ @copied_gemspecs << dest_path
70
+ puts "Copied: #{File.basename(gemspec_path)}"
71
+ end
72
+
73
+ if gemspec_files.empty?
74
+ warn 'Warning: Gemfile contains gemspec directive but no .gemspec files found'
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Kompo
6
+ # Copy project files (entrypoint and additional files) to working directory
7
+ class CopyProjectFiles < Taski::Task
8
+ exports :entrypoint_path, :additional_paths
9
+
10
+ def run
11
+ work_dir = WorkDir.path
12
+ project_dir = Taski.args.fetch(:project_dir, Taski.env.working_directory) || Taski.env.working_directory
13
+ entrypoint = Taski.args.fetch(:entrypoint, 'main.rb')
14
+ files = Taski.args.fetch(:files, [])
15
+
16
+ # Copy entrypoint (preserve relative path structure)
17
+ src_entrypoint = File.expand_path(File.join(project_dir, entrypoint))
18
+ raise "Entrypoint not found: #{src_entrypoint}" unless File.exist?(src_entrypoint)
19
+
20
+ # Validate source is inside project_dir
21
+ real_project_dir = File.realpath(project_dir)
22
+ real_src = File.realpath(src_entrypoint)
23
+ unless real_src.start_with?(real_project_dir + File::SEPARATOR) || real_src == real_project_dir
24
+ raise "Entrypoint path escapes project directory: #{entrypoint}"
25
+ end
26
+
27
+ @entrypoint_path = File.join(work_dir, entrypoint)
28
+ FileUtils.mkdir_p(File.dirname(@entrypoint_path))
29
+ FileUtils.cp(src_entrypoint, @entrypoint_path)
30
+ puts "Copied entrypoint: #{entrypoint}"
31
+
32
+ # Copy additional files/directories
33
+ @additional_paths = []
34
+ files.each do |file|
35
+ src = File.expand_path(File.join(project_dir, file))
36
+ next unless File.exist?(src)
37
+
38
+ # Validate source is inside project_dir
39
+ real_src = File.realpath(src)
40
+ unless real_src.start_with?(real_project_dir + File::SEPARATOR) || real_src == real_project_dir
41
+ warn "Skipping path that escapes project directory: #{file}"
42
+ next
43
+ end
44
+
45
+ dest = File.join(work_dir, file)
46
+
47
+ # Validate destination is inside work_dir
48
+ real_work_dir = File.realpath(work_dir)
49
+ expanded_dest = File.expand_path(dest)
50
+ unless expanded_dest.start_with?(real_work_dir + File::SEPARATOR) || expanded_dest == real_work_dir
51
+ warn "Skipping path that would escape work directory: #{file}"
52
+ next
53
+ end
54
+
55
+ if File.directory?(src)
56
+ # Special handling for "." - copy directory contents directly to work_dir
57
+ if file == '.' || real_src == real_project_dir
58
+ copy_directory_contents(src, work_dir)
59
+ @additional_paths << work_dir
60
+ else
61
+ FileUtils.mkdir_p(dest)
62
+ FileUtils.cp_r(src, File.dirname(dest))
63
+ @additional_paths << dest
64
+ end
65
+ else
66
+ FileUtils.mkdir_p(File.dirname(dest))
67
+ FileUtils.cp(src, dest)
68
+ @additional_paths << dest
69
+ end
70
+ puts "Copied: #{file}"
71
+ end
72
+ end
73
+
74
+ def clean
75
+ work_dir = WorkDir.path
76
+ return unless work_dir && Dir.exist?(work_dir)
77
+
78
+ real_work_dir = File.realpath(work_dir)
79
+ files = Taski.args.fetch(:files, [])
80
+
81
+ # Clean up copied files (with path validation)
82
+ if @entrypoint_path
83
+ expanded_path = File.expand_path(@entrypoint_path)
84
+ FileUtils.rm_f(@entrypoint_path) if expanded_path.start_with?(real_work_dir + File::SEPARATOR)
85
+ end
86
+
87
+ files.each do |file|
88
+ path = File.join(work_dir, file)
89
+ expanded_path = File.expand_path(path)
90
+ # Only delete if path is inside work_dir
91
+ FileUtils.rm_rf(path) if expanded_path.start_with?(real_work_dir + File::SEPARATOR) && File.exist?(path)
92
+ end
93
+ puts 'Cleaned up project files'
94
+ end
95
+
96
+ private
97
+
98
+ def copy_directory_contents(src_dir, dest_dir)
99
+ # Copy all files and directories from src_dir directly into dest_dir
100
+ Dir.each_child(src_dir) do |child|
101
+ src_path = File.join(src_dir, child)
102
+ dest_path = File.join(dest_dir, child)
103
+
104
+ if File.directory?(src_path)
105
+ FileUtils.cp_r(src_path, dest_path)
106
+ else
107
+ FileUtils.cp(src_path, dest_path)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Kompo
6
+ # Find native gem extensions that need to be built
7
+ # Exports:
8
+ # - extensions: Array of hashes with extension info (dir_name, gem_ext_name, is_rust)
9
+ class FindNativeExtensions < Taski::Task
10
+ exports :extensions
11
+
12
+ def run
13
+ @extensions = []
14
+
15
+ # Skip if no Gemfile
16
+ unless CopyGemfile.gemfile_exists
17
+ puts 'No Gemfile, no native extensions to find'
18
+ return
19
+ end
20
+
21
+ ruby_version = InstallRuby.ruby_version
22
+ ruby_build_path = InstallRuby.ruby_build_path
23
+ ruby_install_dir = InstallRuby.ruby_install_dir
24
+ bundle_ruby_dir = BundleInstall.bundle_ruby_dir
25
+
26
+ # Get Init functions already defined in libruby-static
27
+ builtin_init_funcs = get_builtin_init_functions(ruby_install_dir)
28
+
29
+ # Find all native extensions in installed gems
30
+ extconf_files = Dir.glob(File.join(bundle_ruby_dir, 'gems/**/extconf.rb'))
31
+
32
+ extconf_files.each do |extconf_path|
33
+ dir_name = File.dirname(extconf_path)
34
+ gem_ext_name = File.basename(dir_name)
35
+
36
+ # Skip if Init function is already defined in libruby-static
37
+ # This catches gems like prism that are compiled into Ruby core
38
+ init_func = "Init_#{gem_ext_name}"
39
+ if builtin_init_funcs.include?(init_func)
40
+ puts "skip: #{gem_ext_name} is already built into Ruby (#{init_func} found in libruby-static)"
41
+ next
42
+ end
43
+
44
+ # Skip if this extension is part of Ruby standard library
45
+ ruby_std_lib = dir_name.split('/').drop_while { |p| p != 'ext' }.join('/')
46
+ ruby_ext_objects = Dir.glob(File.join(ruby_build_path, "ruby-#{ruby_version}", 'ext', '**', '*.o'))
47
+ if ruby_ext_objects.any? { |o| o.include?(ruby_std_lib) }
48
+ puts "skip: #{gem_ext_name} is included in Ruby standard library"
49
+ next
50
+ end
51
+
52
+ cargo_toml = File.join(dir_name, 'Cargo.toml')
53
+ is_rust = File.exist?(cargo_toml)
54
+
55
+ @extensions << {
56
+ dir_name: dir_name,
57
+ gem_ext_name: gem_ext_name,
58
+ is_rust: is_rust,
59
+ cargo_toml: is_rust ? cargo_toml : nil
60
+ }
61
+ end
62
+
63
+ puts "Found #{@extensions.size} native extensions to build"
64
+ end
65
+
66
+ private
67
+
68
+ # Extract Init_ function names from libruby-static using nm
69
+ def get_builtin_init_functions(ruby_install_dir)
70
+ lib_dir = File.join(ruby_install_dir, 'lib')
71
+ static_lib = Dir.glob(File.join(lib_dir, 'libruby*-static.a')).first
72
+ return Set.new unless static_lib
73
+
74
+ # Use nm to extract defined Init_ symbols (T = text/code section)
75
+ # Use Open3.capture2 with array form to avoid shell injection
76
+ output, status = Open3.capture2('nm', static_lib, err: File::NULL)
77
+ return Set.new unless status.success?
78
+
79
+ # Filter lines matching " T _?Init_" pattern and extract the symbol name
80
+ # macOS prefixes symbols with underscore, Linux does not
81
+ symbols = output.lines.select { |line| line.match?(/ T _?Init_/) }
82
+ symbols.map do |line|
83
+ # Third whitespace-separated field is the symbol name
84
+ symbol = line.split[2]
85
+ symbol&.delete_prefix('_')
86
+ end.compact.to_set
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Kompo
6
+ # Section to get the Homebrew path.
7
+ # Switches implementation based on whether Homebrew is already installed.
8
+ class HomebrewPath < Taski::Section
9
+ interfaces :path
10
+
11
+ # Common Homebrew installation paths
12
+ COMMON_BREW_PATHS = ['/opt/homebrew/bin/brew', '/usr/local/bin/brew'].freeze
13
+
14
+ def impl
15
+ homebrew_installed? ? Installed : Install
16
+ end
17
+
18
+ # Use existing Homebrew installation
19
+ class Installed < Taski::Task
20
+ def run
21
+ # First check PATH
22
+ brew_in_path, = Open3.capture2('which', 'brew', err: File::NULL)
23
+ brew_in_path = brew_in_path.chomp
24
+ @path = if brew_in_path.empty?
25
+ # Fallback to common installation paths
26
+ HomebrewPath::COMMON_BREW_PATHS.find { |p| File.executable?(p) }
27
+ else
28
+ brew_in_path
29
+ end
30
+ puts "Homebrew path: #{@path}"
31
+ end
32
+ end
33
+
34
+ # Install Homebrew
35
+ class Install < Taski::Task
36
+ MARKER_FILE = File.expand_path('~/.kompo_installed_homebrew')
37
+
38
+ def run
39
+ puts 'Homebrew not found. Installing...'
40
+ system('/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"')
41
+
42
+ brew_in_path, = Open3.capture2('which', 'brew', err: File::NULL)
43
+ @path = brew_in_path.chomp
44
+ if @path.empty?
45
+ # Check common installation paths
46
+ HomebrewPath::COMMON_BREW_PATHS.each do |p|
47
+ if File.executable?(p)
48
+ @path = p
49
+ break
50
+ end
51
+ end
52
+ end
53
+
54
+ raise 'Failed to install Homebrew' if @path.nil? || @path.empty?
55
+
56
+ # Mark that kompo installed Homebrew
57
+ File.write(MARKER_FILE, @path)
58
+ puts "Homebrew installed at: #{@path}"
59
+ end
60
+
61
+ def clean
62
+ # Only uninstall if kompo installed it
63
+ return unless File.exist?(MARKER_FILE)
64
+
65
+ puts 'Uninstalling Homebrew (installed by kompo)...'
66
+ system('NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/uninstall.sh)"')
67
+ File.delete(MARKER_FILE) if File.exist?(MARKER_FILE)
68
+ puts 'Homebrew uninstalled'
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def homebrew_installed?
75
+ # Check if brew is in PATH
76
+ brew_in_path, = Open3.capture2('which', 'brew', err: File::NULL)
77
+ return true unless brew_in_path.chomp.empty?
78
+
79
+ # Check common installation paths
80
+ COMMON_BREW_PATHS.any? { |p| File.executable?(p) }
81
+ end
82
+ end
83
+ end