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.
data/USAGE.md ADDED
@@ -0,0 +1,505 @@
1
+ # Usage
2
+
3
+ Complete usage guide for Boxwerk — runtime package isolation for Ruby.
4
+
5
+ ## Requirements
6
+
7
+ - Ruby 4.0+ with the `RUBY_BOX=1` environment variable set before process boot
8
+ - `package.yml` files ([Packwerk](https://github.com/Shopify/packwerk) format)
9
+
10
+ ## Installation
11
+
12
+ ### With Bundler
13
+
14
+ Add `boxwerk` to your project's `gems.rb` or `Gemfile`:
15
+
16
+ ```ruby
17
+ # gems.rb
18
+ source 'https://rubygems.org'
19
+
20
+ gem 'boxwerk'
21
+ ```
22
+
23
+ Install and set up:
24
+
25
+ ```bash
26
+ bundle install # Install gems (including boxwerk)
27
+ bundle binstubs boxwerk # Create bin/boxwerk binstub
28
+ bin/boxwerk install # Install per-package gems (works without pre-installed project gems)
29
+ ```
30
+
31
+ Run your application:
32
+
33
+ ```bash
34
+ RUBY_BOX=1 bin/boxwerk run main.rb
35
+ ```
36
+
37
+ ### Without Bundler
38
+
39
+ Boxwerk works without any Gemfile. Install it as a system gem:
40
+
41
+ ```bash
42
+ gem install boxwerk
43
+ ```
44
+
45
+ Run directly:
46
+
47
+ ```bash
48
+ RUBY_BOX=1 boxwerk run main.rb
49
+ ```
50
+
51
+ In this mode, no gems are loaded into the global context (except Boxwerk itself). Per-package gems are still supported if Bundler is available on the system.
52
+
53
+ ## Package Configuration (package.yml)
54
+
55
+ Standard [Packwerk](https://github.com/Shopify/packwerk) format:
56
+
57
+ ```yaml
58
+ # packs/finance/package.yml
59
+ enforce_dependencies: true
60
+ dependencies:
61
+ - packs/util
62
+ - packs/billing
63
+
64
+ enforce_privacy: true
65
+ public_path: public/ # default; directory of public constants
66
+ private_constants:
67
+ - "::InternalHelper" # explicitly blocked even if in public_path
68
+ ```
69
+
70
+ | Field | Type | Default | Description |
71
+ |------------------------|----------|-----------|-------------------------------------------------|
72
+ | `enforce_dependencies` | bool | `false` | Block access to undeclared dependencies |
73
+ | `dependencies` | list | `[]` | Direct dependency package paths |
74
+ | `enforce_privacy` | bool | `false` | Restrict external access to public constants |
75
+ | `public_path` | string | `public/` | Directory containing the package's public API |
76
+ | `private_constants` | list | `[]` | Constants blocked even if in the public path |
77
+
78
+ ## `pack_public: true` Sigil
79
+
80
+ Files outside the `public_path` can be individually marked public by adding a comment sigil in the first 5 lines:
81
+
82
+ ```ruby
83
+ # pack_public: true
84
+ class SpecialService
85
+ # accessible to dependents even though it lives in lib/, not public/
86
+ end
87
+ ```
88
+
89
+ The sigil is matched with `#.*pack_public:\s*true`.
90
+
91
+ ## CLI Reference
92
+
93
+ ```
94
+ boxwerk <command> [options] [args...]
95
+ ```
96
+
97
+ ### Commands
98
+
99
+ #### `boxwerk run <script.rb> [args...]`
100
+
101
+ Run a Ruby script in a package box.
102
+
103
+ ```bash
104
+ boxwerk run main.rb # Run in root package box
105
+ boxwerk run --package packs/finance main.rb # Run in a specific package box
106
+ boxwerk run --global main.rb # Run in global context (no package)
107
+ ```
108
+
109
+ #### `boxwerk exec <command> [args...]`
110
+
111
+ Execute a command in the boxed environment. Boxwerk looks for the command in this order:
112
+
113
+ 1. **Project binstub** — `./bin/<command>` in the project root
114
+ 2. **Gem binstub** — resolved via `Gem.bin_path`
115
+ 3. **Shell command** — falls back to running the command as a shell process in the package directory
116
+
117
+ Project binstubs take precedence, allowing custom command entry points (e.g. a `bin/rails` that sets `APP_PATH` and requires `rails/commands`).
118
+
119
+ ```bash
120
+ boxwerk exec rake test # Root package
121
+ boxwerk exec --package packs/util rake test # Specific package
122
+ boxwerk exec --all rake test # All packages sequentially
123
+ ```
124
+
125
+ With `--all`, each package runs in a separate subprocess for clean isolation (avoids `at_exit` conflicts from test frameworks like Minitest).
126
+
127
+ #### `boxwerk console [irb-args...]`
128
+
129
+ Start an IRB console with package constants accessible. IRB runs in `Ruby::Box.root` with a composite resolver that provides the target package's constants.
130
+
131
+ ```bash
132
+ boxwerk console # Root package context
133
+ boxwerk console --package packs/finance # Specific package context
134
+ boxwerk console --global # Global context
135
+ ```
136
+
137
+ IRB autocomplete is disabled (box-scoped constants are not visible to the completer).
138
+
139
+ #### `boxwerk info`
140
+
141
+ Boot the application and show runtime autoload structure: config, dependency tree, global section, per-package enforcements/dependencies/autoload dirs/gems/constants. Also reports gem version conflicts.
142
+
143
+ ```bash
144
+ RUBY_BOX=1 boxwerk info
145
+ ```
146
+
147
+ Output sections:
148
+ - **Config** — `boxwerk.yml` settings with defaults filled in
149
+ - **Dependency Graph** — tree view; circular dependencies are marked `(circular)`
150
+ - **Global** — boot script, autoload or `eager_load` dirs (label is `eager_load` when eager loading enabled), gems
151
+ - **Packages** — each package with enforcements, dependencies, `autoload`/`eager_load` dirs, `collapse` dirs, `ignore` dirs, `pack_public` constants, explicit private constants, and direct gems
152
+
153
+ Requires `RUBY_BOX=1` (boots the application to gather runtime autoload information).
154
+
155
+ When eager loading is enabled (`eager_load_global` / `eager_load_packages`), the autoload section label becomes `eager_load`.
156
+
157
+ #### `boxwerk install`
158
+
159
+ Run `bundle install` for every package that has a `Gemfile` or `gems.rb`. Installs global (root) gems first, then packages.
160
+
161
+ ```bash
162
+ bin/boxwerk install
163
+ ```
164
+
165
+ Does not require `RUBY_BOX=1`. Works without pre-installing global project gems first — the binstub skips `bundler/setup` for this command so you can run it as the first step after cloning a project.
166
+
167
+ #### `boxwerk help` / `boxwerk version`
168
+
169
+ Show usage or version.
170
+
171
+ ### Options
172
+
173
+ | Flag | Short | Applies to | Description |
174
+ |---------------------------|-------|-------------------------|----------------------------------------------------|
175
+ | `--package <name>` | `-p` | `exec`, `run`, `console`| Run in a specific package box (default: `.`) |
176
+ | `--all` | `-a` | `exec` | Run for all packages sequentially (subprocesses) |
177
+ | `--global` | `-g` | `exec`, `run`, `console`| Run in the global context (no package) |
178
+ | `--package-paths <paths>` | | `exec`, `run`, `console`| Comma-separated package path globs (override `boxwerk.yml`) |
179
+ | `--eager-load-global` | | `exec`, `run`, `console`| Enable global eager loading |
180
+ | `--no-eager-load-global` | | `exec`, `run`, `console`| Disable global eager loading |
181
+ | `--eager-load-packages` | | `exec`, `run`, `console`| Enable package eager loading |
182
+ | `--no-eager-load-packages`| | `exec`, `run`, `console`| Disable package eager loading |
183
+
184
+ CLI config options override `boxwerk.yml` values. This enables quick configuration without creating a config file.
185
+
186
+ Package names passed to `--package` are normalized: leading `./` and trailing `/` are stripped, so `./packs/loyalty`, `packs/loyalty/`, and `packs/loyalty` all refer to the same package.
187
+
188
+ ## Per-Package Gems
189
+
190
+ Each package can have its own `Gemfile`/`gems.rb` and corresponding lockfile. Different packages can use different versions of the same gem.
191
+
192
+ ```
193
+ packs/billing/
194
+ ├── package.yml
195
+ ├── Gemfile # gem 'stripe', '~> 5.0'
196
+ ├── Gemfile.lock
197
+ └── lib/
198
+ └── payment.rb # Stripe auto-required, ready to use
199
+ ```
200
+
201
+ ### Gem Isolation Model
202
+
203
+ - **Global gems** (root `Gemfile`) are loaded in the global context and inherited by all child boxes via `$LOADED_FEATURES` snapshot at box creation time
204
+ - **Per-package gems** are resolved from lockfiles and added to each box's `$LOAD_PATH` independently
205
+ - **Auto-required:** Gems declared in a package's `Gemfile`/`gems.rb` are automatically required in the package box (like Bundler's default behaviour). No manual `require` needed
206
+ - **`require: false`** — Gems declared with `require: false` are added to `$LOAD_PATH` but not auto-required
207
+ - **Custom require:** `gem 'foo', require: 'foo/bar'` auto-requires `foo/bar` instead of `foo`
208
+ - **Gems do NOT leak** across package boundaries — package A cannot see package B's gems, even if A depends on B
209
+ - **Cross-package version differences** are safe — each box has its own isolated `$LOAD_PATH`
210
+ - **Global override warning:** If a package defines a gem that's also in the root `Gemfile` at a different version, both versions load into memory (functionally correct but wastes memory). Boxwerk warns at boot time
211
+ - **Shared global gems:** Add a gem to the root `Gemfile` without `require: false` to share a single copy across all packages
212
+
213
+ Run `boxwerk install` to install gems for all packages.
214
+
215
+ ## Constant Resolution
216
+
217
+ ### Intra-Package: autoload
218
+
219
+ Each package's constants are registered as `autoload` entries in its box. When code references `Calculator`, Ruby's built-in autoload loads the file — standard Ruby behaviour.
220
+
221
+ ### Cross-Package: const_missing
222
+
223
+ When a constant is not found in the current box, `Object.const_missing` fires. Boxwerk's per-box handler:
224
+
225
+ 1. Iterates through declared direct dependencies
226
+ 2. Checks each dependency for the constant (via file index or `const_get`)
227
+ 3. Enforces privacy rules
228
+ 4. Returns the constant value from the dependency's box
229
+ 5. Raises `NameError` if no dependency has it
230
+
231
+ ```ruby
232
+ # Simplified flow:
233
+ class Object
234
+ def self.const_missing(const_name)
235
+ deps.each do |dep|
236
+ next unless dep.has_constant?(const_name)
237
+ check_privacy!(const_name, dep)
238
+ return dep.box.const_get(const_name)
239
+ end
240
+ raise NameError, "uninitialized constant #{const_name}"
241
+ end
242
+ end
243
+ ```
244
+
245
+ Constants are **not** wrapped in namespaces. `Invoice` is accessed as `Invoice`, not `Finance::Invoice`.
246
+
247
+ ### Namespace Module Resolution
248
+
249
+ Parent modules are resolved by loading child files. If a file index has `Menu::Item` but no direct `Menu` entry, referencing `Menu` triggers autoload of a child file, which defines the `Menu` module as a side effect.
250
+
251
+ ## Privacy Enforcement
252
+
253
+ Privacy is checked at `const_missing` time when resolving cross-package constants:
254
+
255
+ - **`public_path`** — Only files in this directory (default: `public/`) define the package's public API. Constants outside it are blocked.
256
+ - **`private_constants`** — Explicitly private constants, blocked even if in the public path.
257
+ - **`pack_public: true` sigil** — Files outside the public path can opt in to public visibility.
258
+
259
+ Violations raise `NameError` with a descriptive message:
260
+
261
+ ```
262
+ Privacy violation: 'InternalHelper' is private to 'packs/finance'.
263
+ Only constants in the public path are accessible.
264
+ ```
265
+
266
+ ## Root Package vs Global Context
267
+
268
+ These are different concepts:
269
+
270
+ - **Root package** (`.`) — Your top-level `package.yml`. Gets its own `Ruby::Box` like any other package. Has dependencies and constants. This is where your application code runs by default.
271
+ - **Global context** (`Ruby::Box.root`) — Where global gems are loaded via `Bundler.require`. Contains global gems and constants. All child boxes are copied from it.
272
+
273
+ The global context is an implementation detail of how `Ruby::Box` works. Constants and files loaded in the global context before package boxes are created are inherited by **all** package boxes. This is because each `Ruby::Box.new` creates a snapshot of `Ruby::Box.root` at that moment.
274
+
275
+ This has important implications:
276
+
277
+ - Global gems loaded via `Bundler.require` are available everywhere (loaded before boxes)
278
+ - Constants defined in `global/` files are available everywhere (required before boxes)
279
+ - Code in `global/boot.rb` runs before any package boxes exist
280
+ - Anything loaded **after** box creation is only visible in the box that loaded it
281
+
282
+ Use `--global` / `-g` to run commands in the global context directly. A composite resolver is installed on `Ruby::Box.root` so that **all** package constants are accessible — useful for scripts, debugging, or tools that need cross-package access without picking a single package.
283
+
284
+ ## Global Gems
285
+
286
+ Gems in the root `Gemfile`/`gems.rb` are loaded in `Ruby::Box.root` during boot:
287
+
288
+ 1. `Bundler.setup` + `Bundler.require` run in the global context
289
+ 2. All loaded gems become available in child boxes via `$LOADED_FEATURES` snapshot
290
+ 3. Use `require: false` to keep a gem on `$LOAD_PATH` without loading it at boot
291
+
292
+ ```ruby
293
+ # gems.rb
294
+ gem 'activesupport' # loaded globally, available everywhere
295
+ gem 'pry', require: false # on $LOAD_PATH but not loaded
296
+ ```
297
+
298
+ > **Note:** The root `gems.rb`/`Gemfile` is always for global gems shared across all packages. If your top-level package needs "package private" gems, use an implicit root (no `package.yml` at root) and create a `packs/main` package as your entry point with its own `gems.rb`.
299
+
300
+ ### Gems with Internal Autoloading
301
+
302
+ Some gems (like Rails) use Zeitwerk internally to autoload their own constants. When loaded via `Bundler.require` in the global context, these autoloads are registered as pending entries in `Ruby::Box.root`. Because child boxes inherit a snapshot of the root box at creation time, pending autoloads may not resolve correctly in child boxes.
303
+
304
+ Boxwerk runs `Zeitwerk::Loader.eager_load_all` after global boot to resolve all pending autoloads. If your gem needs additional setup before eager loading (e.g. requiring sub-components), do this in `global/boot.rb`:
305
+
306
+ ```ruby
307
+ # global/boot.rb
308
+ require "active_record/railtie" # register Zeitwerk autoloads
309
+ require "action_controller/railtie"
310
+ # Boxwerk runs Zeitwerk::Loader.eager_load_all after this script
311
+ ```
312
+
313
+ ## Testing
314
+
315
+ Run tests through Boxwerk to enforce package isolation:
316
+
317
+ ```bash
318
+ boxwerk exec rake test # Root package tests
319
+ boxwerk exec --package packs/finance rake test # Specific package tests
320
+ boxwerk exec --all rake test # All packages sequentially
321
+ ```
322
+
323
+ Each `--all` run spawns a separate subprocess per package for clean isolation — test frameworks like Minitest register tests globally via `at_exit`, which would conflict across packages in a single process.
324
+
325
+ ## Configuration (`boxwerk.yml`)
326
+
327
+ An optional `boxwerk.yml` file at the project root configures Boxwerk behaviour.
328
+
329
+ ```yaml
330
+ # boxwerk.yml
331
+ package_paths:
332
+ - "packs/*" # default: ["**/"]
333
+ eager_load_global: true # default: true
334
+ eager_load_packages: false # default: false
335
+ ```
336
+
337
+ | Field | Type | Default | Description |
338
+ |-----------------------|------|-----------|------------------------------------------------------------|
339
+ | `package_paths` | list | `["**/"]` | Glob patterns for where to search for `package.yml` files |
340
+ | `eager_load_global` | bool | `true` | Eager-load `global/` files and Zeitwerk constants at boot |
341
+ | `eager_load_packages` | bool | `false` | Eager-load all constants in each package box after boot |
342
+
343
+ By default, Boxwerk searches everywhere (`**/`) for `package.yml` files. Set `package_paths` to restrict the search to specific directories.
344
+
345
+ ### Eager Loading
346
+
347
+ - **`eager_load_global`** — When `true` (default), requires all files in `global/` and any dirs registered via `Boxwerk.global.autoloader.push_dir`, then runs `Zeitwerk::Loader.eager_load_all`. This ensures constants are defined before child boxes are created. When `false`, global constants are registered as lazy autoloads (accessible on demand, e.g. in `global/boot.rb`) but not eagerly required.
348
+ - **`eager_load_packages`** — When `true`, eager-loads all constants in each package box immediately after it boots. When `false` (default), constants are lazy-loaded via autoload on first access.
349
+
350
+ ## Implicit Root Package
351
+
352
+ If no `package.yml` exists at the project root, Boxwerk creates an implicit root package with:
353
+
354
+ - `enforce_dependencies: false`
355
+ - `enforce_privacy: false`
356
+ - Automatic dependencies on all discovered packages
357
+
358
+ This is useful for gradually adopting Boxwerk — you can start with just sub-packages and no root `package.yml`. The implicit root can access constants from all packages without declaring explicit dependencies.
359
+
360
+ ## Global Boot
361
+
362
+ ### `global/` Directory
363
+
364
+ An optional `global/` directory at the project root provides global constants and initialization. Files in `global/` are required in the root box before package boxes are created, so all definitions are inherited by every package box.
365
+
366
+ Files follow Zeitwerk conventions:
367
+
368
+ ```
369
+ global/
370
+ ├── boot.rb # Global boot script (optional)
371
+ ├── config.rb # Config
372
+ └── middleware.rb # Middleware
373
+ ```
374
+
375
+ ```ruby
376
+ # global/config.rb
377
+ module Config
378
+ SHOP_NAME = ENV.fetch('SHOP_NAME', 'My App')
379
+ CURRENCY = '$'
380
+ end
381
+ ```
382
+
383
+ ### `global/boot.rb`
384
+
385
+ An optional `global/boot.rb` script runs in the root box after global files are loaded but before package boxes are created. Use it for initialization that all packages should inherit.
386
+
387
+ ```ruby
388
+ # global/boot.rb
389
+ require 'dotenv/load'
390
+ puts "Booting #{Config::SHOP_NAME}..."
391
+ ```
392
+
393
+ #### `Boxwerk.global.autoloader`
394
+
395
+ Use `Boxwerk.global.autoloader` in `global/boot.rb`, a package `boot.rb`, or anywhere in the application to register additional root-level autoload directories. Constants loaded this way are available in all package boxes.
396
+
397
+ ```ruby
398
+ # global/boot.rb
399
+ # Load shared utilities from a custom lib/ dir
400
+ Boxwerk.global.autoloader.push_dir(File.expand_path('../lib', __dir__))
401
+ ```
402
+
403
+ Methods:
404
+
405
+ ```ruby
406
+ Boxwerk.global.autoloader.push_dir("lib") # Register lazy autoloads
407
+ Boxwerk.global.autoloader.collapse("lib/utils") # Collapse namespace
408
+ Boxwerk.global.autoloader.ignore("lib/utils") # Ignore from autoloading
409
+ Boxwerk.global.autoloader.setup # Register any pending dirs
410
+ ```
411
+
412
+ `push_dir` and `collapse` auto-call `setup` (lazy autoload registration), so constants are accessible immediately in `boot.rb` via the autoload mechanism. Files are NOT eagerly required by `push_dir`. When `eager_load_global: true`, Boxwerk eagerly requires all registered dirs after `global/boot.rb` so child boxes inherit the constants eagerly.
413
+
414
+ ### Root-Level `boot.rb`
415
+
416
+ A `boot.rb` at the project root is a **root package** boot script — it runs in the root package's box (like any other package's `boot.rb`). This is different from `global/boot.rb` which runs in the root box.
417
+
418
+ ### Use Cases
419
+
420
+ - **`global/boot.rb`** — Load environment variables, define global config, manually eager load constants (e.g. Rails internals)
421
+ - **`boot.rb`** — Root package initialization, e.g. booting Rails (see [Rails example](examples/rails/)). Runs after all packages are booted
422
+
423
+ ## Per-Package Boot Scripts
424
+
425
+ Each package can have an optional `boot.rb` that runs after the package's own constants are scanned and per-package gems are loaded, but before cross-package constants are wired. It can be used to configure additional autoload dirs and collapse:
426
+
427
+ ```ruby
428
+ # packs/models/boot.rb
429
+ pkg = Boxwerk.package
430
+
431
+ pkg.name # => "packs/models"
432
+ pkg.root? # => false
433
+ pkg.config # => frozen hash of package.yml values
434
+ pkg.root_path # => absolute path to the package directory
435
+ pkg.autoloader # => autoload configuration object
436
+
437
+ pkg.autoloader.push_dir("models")
438
+ pkg.autoloader.collapse("lib/concerns") # Promotes lib/concerns/* to parent namespace
439
+ pkg.autoloader.ignore("lib/legacy") # Excludes lib/legacy/* from autoloading
440
+ ```
441
+
442
+ `collapse` removes the intermediate namespace directory from the constant hierarchy. For example, collapsing `lib/analytics/formatters` means files in that directory are accessible as `Analytics::CsvFormatter` rather than `Analytics::Formatters::CsvFormatter`. The `Formatters` intermediate constant is removed from the box.
443
+
444
+ `ignore` prevents files in the directory from being autoloaded. Accessing any constant from that directory raises `NameError`.
445
+
446
+ ### `autoloader.setup`
447
+
448
+ `push_dir` and `collapse` automatically call `setup`, registering constants immediately so they are available during `boot.rb` execution. You can also call `autoloader.setup` explicitly to ensure dirs are registered at a specific point:
449
+
450
+ ```ruby
451
+ # packs/svc/boot.rb
452
+ pkg = Boxwerk.package
453
+ pkg.autoloader.push_dir("extras")
454
+
455
+ # Helper is available immediately (push_dir auto-called setup)
456
+ Helper.configure(ENV["API_KEY"])
457
+ ```
458
+
459
+ `setup` can be called multiple times — each call registers only the dirs added since the last call.
460
+
461
+ `Boxwerk.package` returns a `PackageContext` accessible from anywhere inside a package box. The `BOXWERK_PACKAGE` constant is also set in each package box for direct access.
462
+
463
+ ### Autoloader Public API
464
+
465
+ Both `Boxwerk.global.autoloader` and `Boxwerk.package.autoloader` expose the same four methods:
466
+
467
+ | Method | Description |
468
+ |---|---|
469
+ | `push_dir(dir)` | Add a dir to autoload roots; registers lazy autoloads immediately |
470
+ | `collapse(dir)` | Collapse a subdir into its parent namespace; registers immediately |
471
+ | `ignore(dir)` | Exclude a dir from autoloading entirely |
472
+ | `setup` | Register any pending dirs (auto-called by `push_dir` and `collapse`) |
473
+
474
+ All methods return `self`, allowing chaining.
475
+
476
+ ### Monkey Patch Isolation
477
+
478
+ Because each package runs in its own `Ruby::Box`, monkey patches defined in a
479
+ package are isolated to that box:
480
+
481
+ ```ruby
482
+ # packs/kitchen/boot.rb
483
+ class String
484
+ def to_order_ticket
485
+ "🎫 #{upcase}"
486
+ end
487
+ end
488
+ ```
489
+
490
+ Code inside the kitchen package can call `"Latte".to_order_ticket`, but other
491
+ packages and the root context will not see the method.
492
+
493
+ ## Circular Dependencies
494
+
495
+ Boxwerk allows circular dependencies. Both packages in a cycle are booted; the first visited in DFS order goes first. Dependencies in cycles are still wired normally.
496
+
497
+ ## Relaxed Dependency Enforcement
498
+
499
+ When `enforce_dependencies: false`, `const_missing` searches ALL packages — not just declared dependencies. Explicit dependencies are searched first (in declared order), then remaining packages. Privacy rules still apply per-package.
500
+
501
+ ## Examples
502
+
503
+ - [`examples/minimal/`](examples/minimal/) — Simplest setup: three packages, dependency enforcement, no gems
504
+ - [`examples/complex/`](examples/complex/) — Full-featured: namespaced constants, privacy enforcement, per-package gems, global gems, and tests
505
+ - [`examples/rails/`](examples/rails/) — Rails with ActiveRecord, foundation package, privacy
data/exe/boxwerk CHANGED
@@ -1,20 +1,61 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- unless ENV['RUBY_BOX'] == '1'
5
- puts "Error: Boxwerk requires Ruby::Box to be enabled"
6
- puts "Please set RUBY_BOX=1 environment variable"
7
- exit 1
4
+ # If Bundler has already been set up (via `bundle exec` or a Bundler binstub),
5
+ # re-exec in a clean Ruby process so Boxwerk controls gem loading from scratch.
6
+ # This prevents gems being loaded twice (once by Bundler in the main box, once
7
+ # by Boxwerk in the root box).
8
+ if defined?(Bundler)
9
+ Bundler.with_unbundled_env { exec(RbConfig.ruby, __FILE__, *ARGV) }
8
10
  end
9
11
 
10
- unless defined?(Ruby::Box)
11
- puts "Error: Ruby::Box is not available in this Ruby version"
12
- puts "Boxwerk requires Ruby 4.0 or later with Box support"
13
- exit 1
14
- end
12
+ boxwerk_lib = File.expand_path('../lib', __dir__)
13
+ boxwerk_exe = File.expand_path(__FILE__)
14
+
15
+ # Commands that don't need Ruby::Box (no package boot required).
16
+ NO_BOX_COMMANDS = %w[install help --help -h version --version -v].freeze
17
+ # Subset that don't need Bundler at all — safe to run without any gems installed.
18
+ STANDALONE_COMMANDS = %w[install help --help -h version --version -v].freeze
19
+
20
+ if NO_BOX_COMMANDS.include?(ARGV[0])
21
+ $LOAD_PATH.unshift(boxwerk_lib)
22
+ require 'boxwerk'
23
+
24
+ unless STANDALONE_COMMANDS.include?(ARGV[0])
25
+ gemfile = %w[gems.rb Gemfile].find { |f| File.exist?(f) }
26
+ if gemfile
27
+ require 'bundler/setup'
28
+ Bundler.require
29
+ end
30
+ end
31
+
32
+ Boxwerk::CLI.run(ARGV, exe_path: boxwerk_exe)
33
+ else
34
+ unless defined?(Ruby::Box)
35
+ $stderr.puts 'Error: Ruby::Box is not available.'
36
+ $stderr.puts 'Boxwerk requires Ruby 4.0 or later with Ruby::Box support.'
37
+ exit 1
38
+ end
15
39
 
16
- Ruby::Box.root.eval(<<~RUBY)
17
- require 'bundler/setup'
18
- Bundler.require
19
- Boxwerk::CLI.run(ARGV)
20
- RUBY
40
+ unless Ruby::Box.enabled?
41
+ $stderr.puts 'Error: Ruby::Box is not enabled.'
42
+ $stderr.puts 'Set the RUBY_BOX=1 environment variable before starting Ruby.'
43
+ exit 1
44
+ end
45
+
46
+ # Load and run in the root box. Boxwerk is loaded first (before Bundler
47
+ # restricts $LOAD_PATH), then project gems are loaded via Bundler. All
48
+ # definitions end up in the root box, inherited by child boxes.
49
+ Ruby::Box.root.eval(<<~RUBY)
50
+ $LOAD_PATH.unshift(#{boxwerk_lib.inspect})
51
+ require 'boxwerk'
52
+
53
+ gemfile = %w[gems.rb Gemfile].find { |f| File.exist?(f) }
54
+ if gemfile
55
+ require 'bundler/setup'
56
+ Bundler.require
57
+ end
58
+
59
+ Boxwerk::CLI.run(ARGV, exe_path: #{boxwerk_exe.inspect})
60
+ RUBY
61
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxwerk
4
+ # Shared autoload configuration API included by both
5
+ # {GlobalContext::Autoloader} and {PackageContext::Autoloader}.
6
+ #
7
+ # Provides the four public methods available in boot scripts:
8
+ # {#push_dir}, {#collapse}, {#ignore}, and {#setup}.
9
+ module AutoloaderMixin
10
+ # Adds +dir+ to autoload paths and immediately registers lazy autoloads.
11
+ # Constants in the directory are accessible via autoload from this point on.
12
+ # @param dir [String] Absolute or relative path to an autoload root.
13
+ # @return [self]
14
+ def push_dir(dir)
15
+ @push_dirs << dir
16
+ setup
17
+ self
18
+ end
19
+
20
+ # Collapses +dir+, mapping its files to the parent namespace rather than
21
+ # introducing an intermediate namespace for the directory itself.
22
+ # @param dir [String] Absolute or relative path to a directory to collapse.
23
+ # @return [self]
24
+ def collapse(dir)
25
+ @collapse_dirs << dir
26
+ setup
27
+ self
28
+ end
29
+
30
+ # Ignores +dir+ from autoloading entirely.
31
+ # @param dir [String] Absolute or relative path to a directory to ignore.
32
+ # @return [self]
33
+ def ignore(dir)
34
+ @ignore_dirs << dir
35
+ self
36
+ end
37
+
38
+ # Registers lazy autoloads for all dirs added since the last +setup+ call.
39
+ # Called automatically by {#push_dir} and {#collapse}; only call explicitly
40
+ # if you need to trigger registration outside of those methods.
41
+ # @return [self]
42
+ def setup
43
+ new_push = @push_dirs[@setup_index[:push]..]
44
+ new_collapse = @collapse_dirs[@setup_index[:collapse]..]
45
+ @setup_index[:push] = @push_dirs.length
46
+ @setup_index[:collapse] = @collapse_dirs.length
47
+ do_setup(new_push, new_collapse) unless new_push.empty? && new_collapse.empty?
48
+ self
49
+ end
50
+
51
+ private
52
+
53
+ # Initialises the shared dir state. Call from subclass +initialize+.
54
+ def init_dirs
55
+ @push_dirs = []
56
+ @collapse_dirs = []
57
+ @ignore_dirs = []
58
+ @setup_index = { push: 0, collapse: 0 }
59
+ end
60
+
61
+ # Template method: subclasses implement this to perform actual registration.
62
+ def do_setup(_new_push, _new_collapse)
63
+ end
64
+ end
65
+ end