boxwerk 0.2.0 → 0.3.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.
- checksums.yaml +4 -4
- data/AGENTS.md +24 -0
- data/ARCHITECTURE.md +264 -0
- data/CHANGELOG.md +59 -11
- data/README.md +56 -174
- data/Rakefile +46 -3
- data/TODO.md +317 -0
- data/USAGE.md +505 -0
- data/exe/boxwerk +55 -14
- data/lib/boxwerk/autoloader_mixin.rb +65 -0
- data/lib/boxwerk/box_manager.rb +405 -0
- data/lib/boxwerk/cli.rb +776 -37
- data/lib/boxwerk/constant_resolver.rb +236 -0
- data/lib/boxwerk/gem_resolver.rb +235 -0
- data/lib/boxwerk/gemfile_require_parser.rb +50 -0
- data/lib/boxwerk/global_context.rb +85 -0
- data/lib/boxwerk/package.rb +76 -24
- data/lib/boxwerk/package_context.rb +103 -0
- data/lib/boxwerk/package_resolver.rb +122 -0
- data/lib/boxwerk/privacy_checker.rb +159 -0
- data/lib/boxwerk/setup.rb +124 -16
- data/lib/boxwerk/version.rb +1 -1
- data/lib/boxwerk/zeitwerk_scanner.rb +172 -0
- data/lib/boxwerk.rb +30 -3
- metadata +54 -11
- data/lib/boxwerk/graph.rb +0 -53
- data/lib/boxwerk/loader.rb +0 -149
- data/lib/boxwerk/registry.rb +0 -26
data/lib/boxwerk/cli.rb
CHANGED
|
@@ -1,27 +1,40 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
|
|
3
6
|
module Boxwerk
|
|
4
|
-
# CLI parses commands and delegates to Setup for package management.
|
|
5
|
-
# Handles run, console, and help commands.
|
|
6
7
|
module CLI
|
|
7
8
|
class << self
|
|
8
|
-
|
|
9
|
+
attr_accessor :exe_path
|
|
10
|
+
|
|
11
|
+
def run(argv, exe_path: nil)
|
|
12
|
+
@exe_path = exe_path
|
|
9
13
|
if argv.empty?
|
|
10
14
|
print_usage
|
|
11
15
|
exit 1
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
case argv[0]
|
|
19
|
+
when 'exec'
|
|
20
|
+
exec_command(argv[1..])
|
|
15
21
|
when 'run'
|
|
16
|
-
run_command(argv[1
|
|
22
|
+
run_command(argv[1..])
|
|
17
23
|
when 'console'
|
|
18
|
-
console_command(argv[1
|
|
24
|
+
console_command(argv[1..])
|
|
25
|
+
when 'info'
|
|
26
|
+
info_command
|
|
27
|
+
when 'install'
|
|
28
|
+
install_command
|
|
19
29
|
when 'help', '--help', '-h'
|
|
20
30
|
print_usage
|
|
21
31
|
exit 0
|
|
32
|
+
when 'version', '--version', '-v'
|
|
33
|
+
puts "boxwerk #{Boxwerk::VERSION}"
|
|
34
|
+
exit 0
|
|
22
35
|
else
|
|
23
|
-
puts "Error: Unknown command '#{argv[0]}'"
|
|
24
|
-
puts ''
|
|
36
|
+
$stderr.puts "Error: Unknown command '#{argv[0]}'"
|
|
37
|
+
$stderr.puts ''
|
|
25
38
|
print_usage
|
|
26
39
|
exit 1
|
|
27
40
|
end
|
|
@@ -30,69 +43,795 @@ module Boxwerk
|
|
|
30
43
|
private
|
|
31
44
|
|
|
32
45
|
def print_usage
|
|
33
|
-
puts
|
|
46
|
+
puts "boxwerk #{Boxwerk::VERSION} — Runtime package isolation for Ruby"
|
|
34
47
|
puts ''
|
|
35
|
-
puts 'Usage: boxwerk <command> [args...]'
|
|
48
|
+
puts 'Usage: boxwerk <command> [options] [args...]'
|
|
36
49
|
puts ''
|
|
37
50
|
puts 'Commands:'
|
|
38
|
-
puts '
|
|
39
|
-
puts '
|
|
51
|
+
puts ' exec <command> [args...] Execute a command in the boxed environment'
|
|
52
|
+
puts ' run <script.rb> [args...] Run a Ruby script in a package box'
|
|
53
|
+
puts ' console [irb-args...] Start an IRB console in a package box'
|
|
54
|
+
puts ' RUBY_BOX=1 info Boot and show runtime autoload structure'
|
|
55
|
+
puts ' install Install gems for all packages'
|
|
40
56
|
puts ' help Show this help message'
|
|
57
|
+
puts ' version Show version'
|
|
58
|
+
puts ''
|
|
59
|
+
puts 'Options:'
|
|
60
|
+
puts ' -p, --package <name> Run in a specific package box (default: .)'
|
|
61
|
+
puts ' -a, --all Run exec for all packages sequentially'
|
|
62
|
+
puts ' -g, --global Run in the global context (no package)'
|
|
63
|
+
puts ' --package-paths <paths> Comma-separated package path globs'
|
|
64
|
+
puts ' --[no-]eager-load-global Toggle global eager loading'
|
|
65
|
+
puts ' --[no-]eager-load-packages Toggle package eager loading'
|
|
66
|
+
puts ''
|
|
67
|
+
puts 'Examples:'
|
|
68
|
+
puts ' boxwerk run main.rb'
|
|
69
|
+
puts ' boxwerk exec rake test'
|
|
70
|
+
puts ' boxwerk exec --package packs/util rake test'
|
|
71
|
+
puts ' boxwerk exec --all rake test'
|
|
72
|
+
puts ' boxwerk console'
|
|
73
|
+
puts ' boxwerk console --global'
|
|
74
|
+
puts ''
|
|
75
|
+
puts 'Setup:'
|
|
76
|
+
puts ' gem install boxwerk Install boxwerk'
|
|
77
|
+
puts ' RUBY_BOX=1 boxwerk run main.rb Run your app'
|
|
78
|
+
puts ''
|
|
79
|
+
puts ' # Or with Bundler:'
|
|
80
|
+
puts ' bundle install Install gems (including boxwerk)'
|
|
81
|
+
puts ' bundle binstubs boxwerk Create bin/boxwerk binstub'
|
|
82
|
+
puts ' RUBY_BOX=1 bin/boxwerk run main.rb Run your app'
|
|
83
|
+
puts ''
|
|
84
|
+
puts 'Requires: Ruby 4.0+ with RUBY_BOX=1 for exec/run/console commands'
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Parses --package/-p, --all/-a, and --global/-g flags from args, returning
|
|
88
|
+
# { package: name_or_nil, all: bool, global: bool, remaining: [...] }.
|
|
89
|
+
def parse_package_flag(args)
|
|
90
|
+
package_name = nil
|
|
91
|
+
all = false
|
|
92
|
+
global = false
|
|
93
|
+
config = {}
|
|
94
|
+
remaining = []
|
|
95
|
+
i = 0
|
|
96
|
+
|
|
97
|
+
while i < args.length
|
|
98
|
+
case args[i]
|
|
99
|
+
when '--package', '-p'
|
|
100
|
+
package_name = args[i + 1]
|
|
101
|
+
unless package_name
|
|
102
|
+
$stderr.puts 'Error: --package requires a package name'
|
|
103
|
+
exit 1
|
|
104
|
+
end
|
|
105
|
+
i += 2
|
|
106
|
+
when '--all', '-a'
|
|
107
|
+
all = true
|
|
108
|
+
i += 1
|
|
109
|
+
when '--global', '-g'
|
|
110
|
+
global = true
|
|
111
|
+
i += 1
|
|
112
|
+
when '--package-paths'
|
|
113
|
+
value = args[i + 1]
|
|
114
|
+
unless value
|
|
115
|
+
$stderr.puts 'Error: --package-paths requires a value'
|
|
116
|
+
exit 1
|
|
117
|
+
end
|
|
118
|
+
config['package_paths'] = value.split(',').map(&:strip)
|
|
119
|
+
i += 2
|
|
120
|
+
when '--eager-load-global'
|
|
121
|
+
config['eager_load_global'] = true
|
|
122
|
+
i += 1
|
|
123
|
+
when '--no-eager-load-global'
|
|
124
|
+
config['eager_load_global'] = false
|
|
125
|
+
i += 1
|
|
126
|
+
when '--eager-load-packages'
|
|
127
|
+
config['eager_load_packages'] = true
|
|
128
|
+
i += 1
|
|
129
|
+
when '--no-eager-load-packages'
|
|
130
|
+
config['eager_load_packages'] = false
|
|
131
|
+
i += 1
|
|
132
|
+
else
|
|
133
|
+
remaining = args[i..]
|
|
134
|
+
break
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
{
|
|
139
|
+
package: package_name,
|
|
140
|
+
all: all,
|
|
141
|
+
global: global,
|
|
142
|
+
config: config,
|
|
143
|
+
remaining: remaining,
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Resolves the target box for a command given parsed flags.
|
|
148
|
+
def resolve_target_box(result, package_name)
|
|
149
|
+
if package_name
|
|
150
|
+
normalized = Package.normalize(package_name)
|
|
151
|
+
box = result[:box_manager].boxes[normalized]
|
|
152
|
+
unless box
|
|
153
|
+
$stderr.puts "Error: Unknown package '#{package_name}'"
|
|
154
|
+
$stderr.puts "Available packages: #{result[:resolver].packages.keys.join(', ')}"
|
|
155
|
+
exit 1
|
|
156
|
+
end
|
|
157
|
+
box
|
|
158
|
+
else
|
|
159
|
+
result[:box_manager].boxes[result[:resolver].root.name]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Execute a Ruby command (gem binstub) in the boxed environment.
|
|
164
|
+
def exec_command(args)
|
|
165
|
+
parsed = parse_package_flag(args)
|
|
166
|
+
|
|
167
|
+
if parsed[:remaining].empty?
|
|
168
|
+
$stderr.puts 'Error: No command specified'
|
|
169
|
+
$stderr.puts ''
|
|
170
|
+
$stderr.puts 'Usage: boxwerk exec [-p <package>] <command> [args...]'
|
|
171
|
+
exit 1
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
command = parsed[:remaining][0]
|
|
175
|
+
command_args = parsed[:remaining][1..] || []
|
|
176
|
+
|
|
177
|
+
if parsed[:all]
|
|
178
|
+
# Boot all for --all (each subprocess boots its own target)
|
|
179
|
+
result = perform_setup
|
|
180
|
+
root_path = Setup.send(:find_root, Dir.pwd)
|
|
181
|
+
failed = []
|
|
182
|
+
|
|
183
|
+
result[:resolver].topological_order.each do |pkg|
|
|
184
|
+
label = pkg.root? ? '.' : pkg.name
|
|
185
|
+
pkg_name = pkg.root? ? '.' : pkg.name
|
|
186
|
+
puts "==> #{label}"
|
|
187
|
+
env = { 'RUBY_BOX' => '1', 'BUNDLE_GEMFILE' => nil }
|
|
188
|
+
success =
|
|
189
|
+
system(
|
|
190
|
+
env,
|
|
191
|
+
RbConfig.ruby,
|
|
192
|
+
@exe_path,
|
|
193
|
+
'exec',
|
|
194
|
+
'-p',
|
|
195
|
+
pkg_name,
|
|
196
|
+
command,
|
|
197
|
+
*command_args,
|
|
198
|
+
chdir: root_path,
|
|
199
|
+
)
|
|
200
|
+
failed << label unless success
|
|
201
|
+
puts ''
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
unless failed.empty?
|
|
205
|
+
$stderr.puts "Failed in: #{failed.join(', ')}"
|
|
206
|
+
exit 1
|
|
207
|
+
end
|
|
208
|
+
else
|
|
209
|
+
# Selective boot: only target + deps (global boots all)
|
|
210
|
+
target_packages = resolve_boot_targets(parsed)
|
|
211
|
+
result =
|
|
212
|
+
perform_setup(packages: target_packages, config: parsed[:config])
|
|
213
|
+
|
|
214
|
+
if parsed[:global]
|
|
215
|
+
box = Ruby::Box.root
|
|
216
|
+
install_global_resolver(result)
|
|
217
|
+
else
|
|
218
|
+
target_pkg =
|
|
219
|
+
(
|
|
220
|
+
if parsed[:package]
|
|
221
|
+
result[:resolver].packages[parsed[:package]]
|
|
222
|
+
else
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
)
|
|
226
|
+
box = resolve_target_box(result, parsed[:package])
|
|
227
|
+
install_resolver_on_ruby_root(result, target_package: target_pkg)
|
|
228
|
+
|
|
229
|
+
if parsed[:package] && parsed[:package] != '.'
|
|
230
|
+
root_path = Setup.send(:find_root, Dir.pwd)
|
|
231
|
+
pkg_dir = File.join(root_path, parsed[:package])
|
|
232
|
+
Dir.chdir(pkg_dir)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
run_command_in_box(
|
|
236
|
+
result,
|
|
237
|
+
box,
|
|
238
|
+
command,
|
|
239
|
+
command_args,
|
|
240
|
+
pkg_dir: Dir.pwd,
|
|
241
|
+
)
|
|
242
|
+
end
|
|
41
243
|
end
|
|
42
244
|
|
|
43
245
|
def run_command(args)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
puts '
|
|
246
|
+
parsed = parse_package_flag(args)
|
|
247
|
+
|
|
248
|
+
if parsed[:remaining].empty?
|
|
249
|
+
$stderr.puts 'Error: No script specified'
|
|
250
|
+
$stderr.puts ''
|
|
251
|
+
$stderr.puts 'Usage: boxwerk run [-p <package>] <script.rb> [args...]'
|
|
48
252
|
exit 1
|
|
49
253
|
end
|
|
50
254
|
|
|
51
|
-
script_path =
|
|
255
|
+
script_path = parsed[:remaining][0]
|
|
52
256
|
unless File.exist?(script_path)
|
|
53
|
-
puts "Error: Script not found: #{script_path}"
|
|
257
|
+
$stderr.puts "Error: Script not found: #{script_path}"
|
|
54
258
|
exit 1
|
|
55
259
|
end
|
|
56
260
|
|
|
57
|
-
|
|
58
|
-
|
|
261
|
+
target_packages = resolve_boot_targets(parsed)
|
|
262
|
+
result =
|
|
263
|
+
perform_setup(packages: target_packages, config: parsed[:config])
|
|
264
|
+
if parsed[:global]
|
|
265
|
+
box = Ruby::Box.root
|
|
266
|
+
install_global_resolver(result)
|
|
267
|
+
else
|
|
268
|
+
target_pkg =
|
|
269
|
+
(
|
|
270
|
+
if parsed[:package]
|
|
271
|
+
result[:resolver].packages[parsed[:package]]
|
|
272
|
+
else
|
|
273
|
+
nil
|
|
274
|
+
end
|
|
275
|
+
)
|
|
276
|
+
box = resolve_target_box(result, parsed[:package])
|
|
277
|
+
install_resolver_on_ruby_root(result, target_package: target_pkg)
|
|
278
|
+
end
|
|
279
|
+
execute_in_box(box, script_path, parsed[:remaining][1..] || [])
|
|
59
280
|
end
|
|
60
281
|
|
|
61
282
|
def console_command(args)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
283
|
+
parsed = parse_package_flag(args)
|
|
284
|
+
|
|
285
|
+
target_packages = resolve_boot_targets(parsed)
|
|
286
|
+
result =
|
|
287
|
+
perform_setup(packages: target_packages, config: parsed[:config])
|
|
288
|
+
if parsed[:global]
|
|
289
|
+
install_global_resolver(result)
|
|
290
|
+
pkg_label = 'global'
|
|
291
|
+
else
|
|
292
|
+
target_pkg =
|
|
293
|
+
(
|
|
294
|
+
if parsed[:package]
|
|
295
|
+
result[:resolver].packages[parsed[:package]]
|
|
296
|
+
else
|
|
297
|
+
nil
|
|
298
|
+
end
|
|
299
|
+
)
|
|
300
|
+
install_resolver_on_ruby_root(result, target_package: target_pkg)
|
|
301
|
+
pkg_label = parsed[:package] || '.'
|
|
302
|
+
end
|
|
303
|
+
# IRB runs in Ruby::Box.root with a composite resolver that provides
|
|
304
|
+
# the target package's constants. This works around a Ruby 4.0.1 GC
|
|
305
|
+
# crash when running IRB directly in child boxes.
|
|
306
|
+
start_console_in_box(Ruby::Box.root, parsed[:remaining], pkg_label)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
BOXWERK_CONFIG_DEFAULTS = {
|
|
310
|
+
'package_paths' => ['**/'],
|
|
311
|
+
'eager_load_global' => true,
|
|
312
|
+
'eager_load_packages' => false,
|
|
313
|
+
}.freeze
|
|
314
|
+
|
|
315
|
+
def info_command
|
|
316
|
+
# Boot the application, suppressing stdout from boot scripts
|
|
317
|
+
result = nil
|
|
318
|
+
orig_stdout = $stdout
|
|
319
|
+
$stdout = StringIO.new
|
|
320
|
+
begin
|
|
321
|
+
result = perform_setup
|
|
322
|
+
ensure
|
|
323
|
+
$stdout = orig_stdout
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
root_path = result[:root_path]
|
|
327
|
+
resolver = result[:resolver]
|
|
328
|
+
box_manager = result[:box_manager]
|
|
329
|
+
config = BOXWERK_CONFIG_DEFAULTS.merge(resolver.boxwerk_config)
|
|
330
|
+
eager_global = config.fetch('eager_load_global', true)
|
|
331
|
+
eager_packages = config.fetch('eager_load_packages', false)
|
|
332
|
+
gem_resolver = GemResolver.new(root_path)
|
|
333
|
+
|
|
334
|
+
puts "boxwerk #{Boxwerk::VERSION}"
|
|
335
|
+
puts ''
|
|
336
|
+
|
|
337
|
+
# Config — always shown with defaults filled in
|
|
338
|
+
puts 'Config'
|
|
339
|
+
puts ''
|
|
340
|
+
config.each { |k, v| puts " #{k}: #{v.inspect}" }
|
|
341
|
+
puts ''
|
|
342
|
+
|
|
343
|
+
# Dependency Graph
|
|
344
|
+
puts 'Dependency Graph'
|
|
345
|
+
puts ''
|
|
346
|
+
print_dependency_tree(resolver)
|
|
347
|
+
puts ''
|
|
348
|
+
|
|
349
|
+
# Global section
|
|
350
|
+
global = Boxwerk.global
|
|
351
|
+
global_boot = File.join(root_path, 'global', 'boot.rb')
|
|
352
|
+
global_dir_info = global&.__send__(:dir_info) || {}
|
|
353
|
+
global_autoload_dirs =
|
|
354
|
+
(global_dir_info[:autoload] || []).map { |d| normalize_dir_display(d, root_path) }
|
|
355
|
+
global_collapse_dirs =
|
|
356
|
+
(global_dir_info[:collapse] || []).map { |d| normalize_dir_display(d, root_path) }
|
|
357
|
+
global_ignore_dirs =
|
|
358
|
+
(global_dir_info[:ignore] || []).map { |d| normalize_dir_display(d, root_path) }
|
|
359
|
+
root_gems =
|
|
360
|
+
gem_resolver.gems_for(resolver.root)&.select { |g| !g.autorequire.nil? }
|
|
361
|
+
global_has_content =
|
|
362
|
+
File.exist?(global_boot) || global_autoload_dirs.any? || root_gems&.any?
|
|
363
|
+
|
|
364
|
+
if global_has_content
|
|
365
|
+
puts 'Global'
|
|
366
|
+
puts ''
|
|
367
|
+
puts " boot: global/boot.rb" if File.exist?(global_boot)
|
|
368
|
+
autoload_label = eager_global ? 'eager_load' : 'autoload'
|
|
369
|
+
print_dir_section(' ', autoload_label, global_autoload_dirs)
|
|
370
|
+
print_dir_section(' ', 'collapse', global_collapse_dirs)
|
|
371
|
+
print_dir_section(' ', 'ignore', global_ignore_dirs)
|
|
372
|
+
if root_gems&.any?
|
|
373
|
+
print_inline_or_multiline(' ', 'gems', root_gems.map { |g| "#{g.name} (#{g.version})" })
|
|
374
|
+
end
|
|
375
|
+
puts ''
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Packages — root (.) first, then others
|
|
379
|
+
puts 'Packages'
|
|
380
|
+
puts ''
|
|
381
|
+
root_first = [resolver.root] + resolver.topological_order.reject(&:root?)
|
|
382
|
+
root_first.each do |pkg|
|
|
383
|
+
print_package_info(pkg, box_manager, gem_resolver, root_path, eager_packages)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Gem conflicts
|
|
387
|
+
conflicts = gem_resolver.check_conflicts(resolver)
|
|
388
|
+
if conflicts.any?
|
|
389
|
+
puts 'Gem Conflicts'
|
|
390
|
+
puts ''
|
|
391
|
+
conflicts.each do |c|
|
|
392
|
+
puts " ⚠ #{c[:gem_name]}: #{c[:package_version]} in #{c[:package]} " \
|
|
393
|
+
"vs #{c[:global_version]} in root (both loaded into memory)"
|
|
394
|
+
end
|
|
395
|
+
puts ''
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def install_command
|
|
400
|
+
root_path = Setup.send(:find_root, Dir.pwd)
|
|
401
|
+
|
|
402
|
+
resolver = PackageResolver.new(root_path)
|
|
403
|
+
has_gemfile = false
|
|
404
|
+
|
|
405
|
+
# Install root (global) gems first, then packages in dependency order
|
|
406
|
+
ordered =
|
|
407
|
+
[resolver.root] + resolver.topological_order.reject(&:root?)
|
|
408
|
+
ordered.each do |pkg|
|
|
409
|
+
pkg_dir = pkg.root? ? root_path : File.join(root_path, pkg.name)
|
|
410
|
+
gemfile =
|
|
411
|
+
%w[gems.rb Gemfile].find { |f| File.exist?(File.join(pkg_dir, f)) }
|
|
412
|
+
next unless gemfile
|
|
413
|
+
|
|
414
|
+
has_gemfile = true
|
|
415
|
+
label = pkg.root? ? 'global gems' : "gems for #{pkg.name}"
|
|
416
|
+
print "Installing #{label}..."
|
|
417
|
+
$stdout.flush
|
|
418
|
+
Dir.chdir(pkg_dir) do
|
|
419
|
+
# Clear Bundler env vars so each package uses its own Gemfile,
|
|
420
|
+
# not the parent process's BUNDLE_GEMFILE or BUNDLE_PATH.
|
|
421
|
+
success =
|
|
422
|
+
Bundler.with_unbundled_env do
|
|
423
|
+
system('bundle', 'install', '--retry', '3', '--quiet')
|
|
424
|
+
end
|
|
425
|
+
unless success
|
|
426
|
+
$stderr.puts " Error: bundle install failed in #{pkg.root? ? '.' : pkg.name}"
|
|
427
|
+
exit 1
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
puts 'Done!'
|
|
431
|
+
puts ''
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
puts 'No packages with a Gemfile or gems.rb found.' unless has_gemfile
|
|
65
435
|
end
|
|
66
436
|
|
|
67
|
-
|
|
68
|
-
|
|
437
|
+
# Determines which packages to boot based on parsed flags.
|
|
438
|
+
# Returns nil for --all or --global (boot all), or an array
|
|
439
|
+
# of target Package objects for selective booting.
|
|
440
|
+
def resolve_boot_targets(parsed)
|
|
441
|
+
return nil if parsed[:all] || parsed[:global]
|
|
442
|
+
|
|
443
|
+
# For a specific package, resolve it from package.yml discovery
|
|
444
|
+
# to get the Package object. Setup.run will boot it + deps.
|
|
445
|
+
if parsed[:package]
|
|
446
|
+
root_path = Setup.send(:find_root, Dir.pwd)
|
|
447
|
+
resolver = PackageResolver.new(root_path)
|
|
448
|
+
normalized = Package.normalize(parsed[:package])
|
|
449
|
+
pkg = resolver.packages[normalized]
|
|
450
|
+
unless pkg
|
|
451
|
+
$stderr.puts "Error: Unknown package '#{parsed[:package]}'"
|
|
452
|
+
$stderr.puts "Available packages: #{resolver.packages.keys.join(', ')}"
|
|
453
|
+
exit 1
|
|
454
|
+
end
|
|
455
|
+
[pkg]
|
|
456
|
+
else
|
|
457
|
+
# Default: boot root package + deps
|
|
458
|
+
nil
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def perform_setup(packages: nil, config: {})
|
|
463
|
+
Boxwerk::Setup.run(
|
|
464
|
+
start_dir: Dir.pwd,
|
|
465
|
+
packages: packages,
|
|
466
|
+
config: config,
|
|
467
|
+
)
|
|
69
468
|
rescue => e
|
|
70
|
-
puts "Error: #{e.message}"
|
|
469
|
+
$stderr.puts "Error: #{e.message}"
|
|
71
470
|
exit 1
|
|
72
471
|
end
|
|
73
472
|
|
|
74
|
-
|
|
473
|
+
# Runs a command (binstub or script) in a box.
|
|
474
|
+
# Falls back to running as a shell command in pkg_dir if no
|
|
475
|
+
# binstub or gem binary is found.
|
|
476
|
+
def run_command_in_box(result, box, command, command_args, pkg_dir: nil)
|
|
477
|
+
if command.end_with?('.rb') || File.exist?(command)
|
|
478
|
+
execute_in_box(box, command, command_args)
|
|
479
|
+
else
|
|
480
|
+
# Check for project-level bin/<command> first, then gem binstubs
|
|
481
|
+
project_bin = File.join(Dir.pwd, 'bin', command)
|
|
482
|
+
if File.exist?(project_bin)
|
|
483
|
+
execute_in_box(box, project_bin, command_args)
|
|
484
|
+
else
|
|
485
|
+
bin_path = find_bin_path(command)
|
|
486
|
+
if bin_path
|
|
487
|
+
execute_in_box(box, bin_path, command_args, use_load: true)
|
|
488
|
+
else
|
|
489
|
+
# Fall back to shell command in the package directory
|
|
490
|
+
dir = pkg_dir || Dir.pwd
|
|
491
|
+
success = system(command, *command_args, chdir: dir)
|
|
492
|
+
exit(success ? 0 : 1)
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def execute_in_box(box, script_path, script_args, use_load: false)
|
|
499
|
+
expanded = File.expand_path(script_path)
|
|
75
500
|
box.eval("ARGV.replace(#{script_args.inspect})")
|
|
76
|
-
|
|
501
|
+
if use_load
|
|
502
|
+
# Eval file content directly rather than using load, because
|
|
503
|
+
# load creates a new file scope where inherited DSL methods
|
|
504
|
+
# (e.g. Rake's task) may not be visible in Ruby::Box.
|
|
505
|
+
content = File.read(expanded)
|
|
506
|
+
box.eval(content)
|
|
507
|
+
else
|
|
508
|
+
# Use eval with __dir__ set so relative paths resolve
|
|
509
|
+
# correctly (e.g. project binstubs using File.expand_path).
|
|
510
|
+
content = File.read(expanded)
|
|
511
|
+
dir = File.dirname(expanded)
|
|
512
|
+
wrapped = "__dir__ = #{dir.inspect}\n" + content
|
|
513
|
+
box.eval(wrapped)
|
|
514
|
+
end
|
|
77
515
|
end
|
|
78
516
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
517
|
+
# Installs a resolver on Ruby::Box.root that searches ALL packages.
|
|
518
|
+
# Used for --global mode so the global context can resolve any constant.
|
|
519
|
+
def install_global_resolver(result)
|
|
520
|
+
boxes = result[:box_manager].boxes
|
|
521
|
+
file_indexes = result[:box_manager].instance_variable_get(:@file_indexes)
|
|
522
|
+
|
|
523
|
+
all_boxes =
|
|
524
|
+
result[:resolver]
|
|
525
|
+
.packages
|
|
526
|
+
.values
|
|
527
|
+
.filter_map do |pkg|
|
|
528
|
+
box = boxes[pkg.name]
|
|
529
|
+
next unless box
|
|
530
|
+
{ box: box, file_index: file_indexes[pkg.name] || {} }
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
composite =
|
|
534
|
+
proc do |const_name|
|
|
535
|
+
name_str = const_name.to_s
|
|
536
|
+
found = false
|
|
537
|
+
value = nil
|
|
538
|
+
|
|
539
|
+
all_boxes.each do |entry|
|
|
540
|
+
box = entry[:box]
|
|
541
|
+
file_index = entry[:file_index]
|
|
542
|
+
|
|
543
|
+
has_constant =
|
|
544
|
+
file_index.key?(name_str) ||
|
|
545
|
+
file_index.any? { |k, _| k.start_with?("#{name_str}::") } ||
|
|
546
|
+
(
|
|
547
|
+
begin
|
|
548
|
+
box.const_get(const_name)
|
|
549
|
+
true
|
|
550
|
+
rescue NameError
|
|
551
|
+
false
|
|
552
|
+
end
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
next unless has_constant
|
|
556
|
+
|
|
557
|
+
value =
|
|
558
|
+
begin
|
|
559
|
+
box.const_get(const_name)
|
|
560
|
+
rescue NameError
|
|
561
|
+
file = file_index[name_str]
|
|
562
|
+
if file
|
|
563
|
+
box.require(file)
|
|
564
|
+
box.const_get(const_name)
|
|
565
|
+
else
|
|
566
|
+
child_key =
|
|
567
|
+
file_index.keys.find do |k|
|
|
568
|
+
k.start_with?("#{name_str}::")
|
|
569
|
+
end
|
|
570
|
+
if child_key
|
|
571
|
+
box.require(file_index[child_key])
|
|
572
|
+
box.const_get(const_name)
|
|
573
|
+
else
|
|
574
|
+
next
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
found = true
|
|
580
|
+
break
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
unless found
|
|
584
|
+
raise NameError.new(
|
|
585
|
+
"uninitialized constant #{name_str}",
|
|
586
|
+
const_name,
|
|
587
|
+
)
|
|
588
|
+
end
|
|
589
|
+
value
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
ruby_root = Ruby::Box.root
|
|
593
|
+
if ruby_root.const_defined?(:BOXWERK_DEPENDENCY_RESOLVER)
|
|
594
|
+
ruby_root.send(:remove_const, :BOXWERK_DEPENDENCY_RESOLVER)
|
|
595
|
+
end
|
|
596
|
+
ruby_root.const_set(:BOXWERK_DEPENDENCY_RESOLVER, composite)
|
|
597
|
+
ruby_root.eval(<<~RUBY)
|
|
598
|
+
class Object
|
|
599
|
+
def self.const_missing(const_name)
|
|
600
|
+
BOXWERK_DEPENDENCY_RESOLVER.call(const_name)
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
RUBY
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Installs a dependency resolver on Ruby::Box.root for the given
|
|
607
|
+
# package. Gems loaded via Bundler.require run in the root box (where
|
|
608
|
+
# their methods were defined). When those gems call load() (e.g. rake
|
|
609
|
+
# loading a Rakefile), the loaded files execute in the root box too.
|
|
610
|
+
# This method ensures const_missing is available there so that package
|
|
611
|
+
# constants can be resolved.
|
|
612
|
+
#
|
|
613
|
+
# When target_package is specified, the resolver also searches the
|
|
614
|
+
# target package's own box for its internal constants. This enables
|
|
615
|
+
# per-package testing where test files (loaded by rake in Ruby::Box.root)
|
|
616
|
+
# need access to the pack's own constants.
|
|
617
|
+
def install_resolver_on_ruby_root(result, target_package: nil)
|
|
618
|
+
target_pkg = target_package || result[:resolver].root
|
|
619
|
+
target_box = result[:box_manager].boxes[target_pkg.name]
|
|
620
|
+
|
|
621
|
+
# Delegate constant resolution from Ruby::Box.root to the target box.
|
|
622
|
+
# The target box already has its own const_missing (dependency resolver)
|
|
623
|
+
# for cross-package lookup. We check own constants first via
|
|
624
|
+
# const_get (which doesn't trigger const_missing recursion), then
|
|
625
|
+
# fall through to the dependency resolver.
|
|
626
|
+
own_box = target_box
|
|
627
|
+
dep_resolver =
|
|
628
|
+
begin
|
|
629
|
+
target_box.const_get(:BOXWERK_DEPENDENCY_RESOLVER)
|
|
630
|
+
rescue NameError
|
|
631
|
+
nil
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# File index for the target package (for autoload-style loading)
|
|
635
|
+
file_index = result[:box_manager].file_indexes[target_pkg.name] || {}
|
|
636
|
+
|
|
637
|
+
composite =
|
|
638
|
+
proc do |const_name|
|
|
639
|
+
name_str = const_name.to_s
|
|
640
|
+
# Try own box's already-defined constants first
|
|
641
|
+
resolved =
|
|
642
|
+
begin
|
|
643
|
+
own_box.const_get(const_name)
|
|
644
|
+
rescue NameError
|
|
645
|
+
nil
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
if resolved
|
|
649
|
+
resolved
|
|
650
|
+
else
|
|
651
|
+
# Try loading from file index (autoload entries)
|
|
652
|
+
file = file_index[name_str]
|
|
653
|
+
if file
|
|
654
|
+
own_box.require(file)
|
|
655
|
+
own_box.const_get(const_name)
|
|
656
|
+
elsif dep_resolver
|
|
657
|
+
# Delegate to the target box's dependency resolver
|
|
658
|
+
dep_resolver.call(const_name)
|
|
659
|
+
else
|
|
660
|
+
raise NameError.new(
|
|
661
|
+
"uninitialized constant #{name_str}",
|
|
662
|
+
const_name,
|
|
663
|
+
)
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
ruby_root = Ruby::Box.root
|
|
669
|
+
if ruby_root.const_defined?(:BOXWERK_DEPENDENCY_RESOLVER)
|
|
670
|
+
ruby_root.send(:remove_const, :BOXWERK_DEPENDENCY_RESOLVER)
|
|
671
|
+
end
|
|
672
|
+
ruby_root.const_set(:BOXWERK_DEPENDENCY_RESOLVER, composite)
|
|
673
|
+
ruby_root.eval(<<~RUBY)
|
|
674
|
+
class Object
|
|
675
|
+
def self.const_missing(const_name)
|
|
676
|
+
BOXWERK_DEPENDENCY_RESOLVER.call(const_name)
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
RUBY
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# Resolves a command name to its gem executable path. Iterates gem
|
|
683
|
+
# specs directly to avoid Bundler's Gem.bin_path hook which prints
|
|
684
|
+
# warnings when the gem name doesn't match the executable name
|
|
685
|
+
# (e.g. "rails" executable is in the "railties" gem).
|
|
686
|
+
def find_bin_path(command)
|
|
687
|
+
Gem::Specification.each do |spec|
|
|
688
|
+
spec.executables.each do |exe|
|
|
689
|
+
return spec.bin_file(exe) if exe == command
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
nil
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def start_console_in_box(box, irb_args = [], pkg_label = '.')
|
|
696
|
+
puts "boxwerk #{Boxwerk::VERSION} console (#{pkg_label})"
|
|
89
697
|
puts ''
|
|
90
698
|
|
|
91
699
|
box.eval(<<~RUBY)
|
|
700
|
+
require 'irb'
|
|
92
701
|
ARGV.replace(#{(['--noautocomplete'] + irb_args).inspect})
|
|
93
702
|
IRB.start
|
|
94
703
|
RUBY
|
|
95
704
|
end
|
|
705
|
+
|
|
706
|
+
# Renders a dependency tree like:
|
|
707
|
+
# .
|
|
708
|
+
# ├── packs/finance
|
|
709
|
+
# │ └── packs/util
|
|
710
|
+
# └── packs/greeting
|
|
711
|
+
def print_dependency_tree(resolver)
|
|
712
|
+
root = resolver.root
|
|
713
|
+
puts root.name
|
|
714
|
+
print_tree_children(root.dependencies, resolver, '')
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def print_tree_children(dep_names, resolver, prefix, ancestry = Set.new)
|
|
718
|
+
dep_names.each_with_index do |dep_name, i|
|
|
719
|
+
last = (i == dep_names.length - 1)
|
|
720
|
+
connector = last ? '└── ' : '├── '
|
|
721
|
+
|
|
722
|
+
if ancestry.include?(dep_name)
|
|
723
|
+
puts "#{prefix}#{connector}#{dep_name} (circular)"
|
|
724
|
+
else
|
|
725
|
+
puts "#{prefix}#{connector}#{dep_name}"
|
|
726
|
+
pkg = resolver.packages[dep_name]
|
|
727
|
+
if pkg && pkg.dependencies.any?
|
|
728
|
+
child_prefix = prefix + (last ? ' ' : '│ ')
|
|
729
|
+
print_tree_children(
|
|
730
|
+
pkg.dependencies,
|
|
731
|
+
resolver,
|
|
732
|
+
child_prefix,
|
|
733
|
+
ancestry | [dep_name],
|
|
734
|
+
)
|
|
735
|
+
end
|
|
736
|
+
end
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def print_package_info(pkg, box_manager, gem_resolver, root_path, eager_packages)
|
|
741
|
+
label = pkg.root? ? ' .' : " #{pkg.name}"
|
|
742
|
+
puts label
|
|
743
|
+
|
|
744
|
+
flags = []
|
|
745
|
+
flags << 'dependencies' if pkg.enforce_dependencies?
|
|
746
|
+
flags << 'privacy' if pkg.config['enforce_privacy']
|
|
747
|
+
if flags.any?
|
|
748
|
+
print_inline_or_multiline(' ', 'enforcements', flags)
|
|
749
|
+
else
|
|
750
|
+
puts ' enforcements: none'
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
deps = pkg.dependencies
|
|
754
|
+
if deps.any?
|
|
755
|
+
print_inline_or_multiline(' ', 'dependencies', deps)
|
|
756
|
+
else
|
|
757
|
+
puts ' dependencies: none'
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
pkg_dir = pkg.root? ? root_path : File.join(root_path, pkg.name)
|
|
761
|
+
puts " boot: boot.rb" if File.exist?(File.join(pkg_dir, 'boot.rb'))
|
|
762
|
+
|
|
763
|
+
# Autoload dirs from box_manager dir info
|
|
764
|
+
dirs = box_manager.package_dirs_info[pkg.name] || {}
|
|
765
|
+
autoload_dirs = dirs[:autoload] || []
|
|
766
|
+
collapse_dirs = dirs[:collapse] || []
|
|
767
|
+
ignore_dirs = dirs[:ignore] || []
|
|
768
|
+
|
|
769
|
+
autoload_label = eager_packages ? 'eager_load' : 'autoload'
|
|
770
|
+
print_dir_section(' ', autoload_label, autoload_dirs)
|
|
771
|
+
print_dir_section(' ', 'collapse', collapse_dirs)
|
|
772
|
+
print_dir_section(' ', 'ignore', ignore_dirs)
|
|
773
|
+
|
|
774
|
+
# pack_public sigil constants
|
|
775
|
+
pack_public = PrivacyChecker.pack_public_constants(pkg, root_path)
|
|
776
|
+
if pack_public&.any?
|
|
777
|
+
print_inline_or_multiline(' ', 'pack_public', pack_public.sort)
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
private_consts = PrivacyChecker.private_constants_list(pkg)
|
|
781
|
+
if private_consts.any?
|
|
782
|
+
print_inline_or_multiline(' ', 'private constants', private_consts.sort)
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# Direct gems — last; root gems shown in Global section
|
|
786
|
+
unless pkg.root?
|
|
787
|
+
gems = gem_resolver.gems_for(pkg)
|
|
788
|
+
direct_gems = gems&.select { |g| !g.autorequire.nil? }
|
|
789
|
+
if direct_gems&.any?
|
|
790
|
+
print_inline_or_multiline(' ', 'gems', direct_gems.map { |g| "#{g.name} (#{g.version})" })
|
|
791
|
+
end
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
puts ''
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# Normalizes a dir path for display: relative to base if possible,
|
|
798
|
+
# otherwise absolute. Always has a trailing slash.
|
|
799
|
+
def normalize_dir_display(dir, base)
|
|
800
|
+
expanded = File.expand_path(dir, base)
|
|
801
|
+
rel =
|
|
802
|
+
if expanded.start_with?("#{base}/")
|
|
803
|
+
expanded.delete_prefix("#{base}/")
|
|
804
|
+
else
|
|
805
|
+
expanded
|
|
806
|
+
end
|
|
807
|
+
rel.end_with?('/') ? rel : "#{rel}/"
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
# Prints a labeled section with items, inline if one item, multiline if many.
|
|
811
|
+
# indent: e.g. " "
|
|
812
|
+
# label: e.g. "enforcements"
|
|
813
|
+
# items: array of strings
|
|
814
|
+
def print_inline_or_multiline(indent, label, items)
|
|
815
|
+
if items.length == 1
|
|
816
|
+
puts "#{indent}#{label}: #{items.first}"
|
|
817
|
+
else
|
|
818
|
+
puts "#{indent}#{label}:"
|
|
819
|
+
items.each { |item| puts "#{indent} #{item}" }
|
|
820
|
+
end
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
# Prints a dir section (autoload, collapse, ignore).
|
|
824
|
+
# Dirs are already normalized relative strings.
|
|
825
|
+
def print_dir_section(indent, label, dirs)
|
|
826
|
+
return if dirs.empty?
|
|
827
|
+
|
|
828
|
+
if dirs.length == 1
|
|
829
|
+
puts "#{indent}#{label}: #{dirs.first}"
|
|
830
|
+
else
|
|
831
|
+
puts "#{indent}#{label}:"
|
|
832
|
+
dirs.each { |dir| puts "#{indent} #{dir}" }
|
|
833
|
+
end
|
|
834
|
+
end
|
|
96
835
|
end
|
|
97
836
|
end
|
|
98
837
|
end
|