Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,11 @@ end
Four orthogonal axes scope a declaration; each answers a different question:

- **`group`** — *purpose* (`:app`, `:test`, `:build`, `:game`, `:editor`, …). User-defined; `:build` installs first.
The vocabulary is dev-owned and integrations translate it natively (e.g. group names become bundler groups in the generated Gemfile):
- `:app` — runtime dependencies the project needs to run. The default: declarations outside any group block land here, mirroring a hand-written Gemfile's top section.
- `:development` / `:test` — the human's workbench and the test suite, per the Gemfile convention.
- `:build` — the build-time toolchain (compilers, caches, codegen). Installed before every other group, and the group `bin/install-build-deps.rb` materializes into container build images.
- Domain groups (`:game`, `:editor`, `:integration`, …) are welcome — any symbol works; the name is documentation plus a filter handle.
- **`env`** — *execution context* the dep is for (`"ci"` / `"dev"`), declared via `env :ci do ... end` inside a group. Filtered at install against the detected env (`CI` variable only — a Linux workstation is `dev`, a Mac CI runner is `ci`).
- **`host`** — *OS of the machine the dep installs on* (`:darwin` / `:linux`). Declared per-group (`group :editor, host: :darwin do ... end`) or per-declaration (`gh ..., host: :linux`). Filtered at install against the detected host OS — deps for other hosts are still resolved and locked, so the lockfile stays the single source of truth for every machine.
- **`platform`** — *what artifact variant the dep targets* (e.g. `"LinuxServer"`), for multi-arch integrations like ficsit. A resolve-time concern, not an install filter.
Expand All @@ -338,7 +343,7 @@ All built-in integrations are declared in one place — `lib/dev/deps/registry.r

`xcode "26.1.1"` pins the Xcode toolchain (macOS only; a no-op on other hosts). dev installs the pin to `/Applications/Xcode-<ver>.app` via the [xcodes](https://github.com/XcodesOrg/xcodes) CLI — declare `brew "xcodes", host: :darwin` in `:build` so it exists first — and publishes `DEVELOPER_DIR` into the project shadowenv. Interactive runs pass any Apple ID/2FA/sudo prompt through to you; headless runs fail fast with remediation instead of hanging (normal practice: pre-install the pin interactively once during machine bring-up, e.g. a CI runner's).

`gem()` declares Ruby gems: dev generates a `Gemfile`/`Gemfile.lock` from your declarations (a top-level `gem` lands in the default group; `group(:test) { gem ... }` scopes it to a bundler group), and `dev install-deps` runs `bundle install`. `brew()` dual-writes — the container build path keeps reading the group structure while `dev install-deps` also installs the formulae on the host (idempotently).
`gem()` declares Ruby gems: dev generates a `Gemfile`/`Gemfile.lock` from your declarations (a top-level `gem` lands in the default group; `group(:test) { gem ... }` scopes it to a bundler group), and `dev install-deps` runs `bundle install`. Every verb works at the top level too and lands in `:app`. Declarations are the single data model: `brew()` entries ride the resolver → lockfile → install pipeline on the host (idempotently), and the container build path (`bin/install-build-deps.rb`) derives its install list from the same `:build`-group declarations.

`python "3.12"` pins the Python toolchain: dev provisions the interpreter (Homebrew `python@3.12`) and a project-local `.venv`, and publishes it into the project shadowenv (`VIRTUAL_ENV` + `.venv/bin` on `PATH`). `pip()` declares packages installed into that venv — like `luarocks()`, you declare only the top-level packages and pip resolves the transitive tree at install time. Gate heavy, platform-specific stacks (e.g. a PyTorch-backed ML tool) with `host:` so only the machines that use them pay the download.

Expand Down
91 changes: 39 additions & 52 deletions bin/install-build-deps.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,68 +22,55 @@
config = Dev::Deps.last_config
abort "No config found — dependencies.rb must call Dev::Deps.define" unless config

env = "ci"
build_group = config.group("build")

config.taps.each do |tap|
puts ">>> Registering tap: #{tap.name}"
system("brew", "tap", tap.name) || abort("brew tap #{tap.name} failed")
end

# Collect brew entries: global + matching env scope.
brew_entries = Array(build_group["brew"])
env_section = build_group.dig("env", env)
brew_entries += Array(env_section["brew"]) if env_section
ENV_NAME = "ci"

# Host OS of the container being built, for host-gated brew entries. Docker
# builds are Linux; the constant spares a RUBY_PLATFORM sniff that could never
# say anything else here.
HOST = "linux"
HOST = :linux

def install_brew_entry(entry)
case entry
when String
puts ">>> Installing: #{entry}"
system("brew", "install", entry) || abort("brew install #{entry} failed")
when Hash
entry.each do |name, opts|
# Host-gated entries (e.g. brew "xcodes", host: :darwin) skip
# non-matching hosts — mirrors DependencyInstaller#filter_by_host.
host = opts["host"]
if host && host.to_s != HOST
puts ">>> Skipping #{name} (host: #{host})"
next
end

post_install = opts.delete("post_install")
tap = opts["tap"]
version = opts["version"]
cask = opts["cask"]
config.taps.each do |tap|
puts ">>> Registering tap: #{tap.name}"
system("brew", "tap", tap.name) || abort("brew tap #{tap.name} failed")
end

if cask
puts ">>> Installing cask: #{name}"
system("brew", "install", "--cask", name) || abort("brew install --cask #{name} failed")
else
spec = if tap
version_suffix = version ? "@#{version}" : ""
"#{tap}/#{name}#{version_suffix}"
elsif version
"#{name}@#{version}"
else
name
end
puts ">>> Installing: #{spec}"
system("brew", "install", spec) || abort("brew install #{spec} failed")
end
def install_brew_declaration(decl)
tap = decl.constraint["tap"]
version = decl.constraint["version"]

if post_install
puts ">>> Running post_install for #{name}"
post_install.call(name, opts)
end
if decl.constraint["cask"]
puts ">>> Installing cask: #{decl.name}"
system("brew", "install", "--cask", decl.name) || abort("brew install --cask #{decl.name} failed")
else
spec = if tap
version_suffix = version ? "@#{version}" : ""
"#{tap}/#{decl.name}#{version_suffix}"
elsif version
"#{decl.name}@#{version}"
else
decl.name
end
puts ">>> Installing: #{spec}"
system("brew", "install", spec) || abort("brew install #{spec} failed")
end

if decl.post_install
puts ">>> Running post_install for #{decl.name}"
decl.post_install.call(decl.name, decl.constraint)
end
end

# The build image materializes the :build group's brew declarations for this
# env — the same declarations the resolver/lockfile pipeline reads; env/host
# filtering mirrors DependencyInstaller (nil means "everywhere").
build_brews = config.declarations.select do |decl|
decl.integration == :brew &&
decl.group == :build &&
(decl.env.nil? || decl.env == ENV_NAME)
end

brew_entries.each { |entry| install_brew_entry(entry) }
hosted, skipped = build_brews.partition { |decl| decl.host.nil? || decl.host == HOST }
skipped.each { |decl| puts ">>> Skipping #{decl.name} (host: #{decl.host})" }
hosted.each { |decl| install_brew_declaration(decl) }

puts ">>> All build dependencies installed"
15 changes: 2 additions & 13 deletions lib/dev/deps/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,26 @@ module Dev
module Deps
# Parsed dependency configuration. Returned by Dev::Deps.define.
class Config
attr_reader :taps, :groups, :declarations, :ruby_version_requirement,
attr_reader :taps, :declarations, :ruby_version_requirement,
:lua_version, :python_version, :registered_integrations

# @param taps [Array<Tap>] declared Homebrew taps
# @param groups [Hash] group name → { "brew" => [...], "env" => {...} }
# @param declarations [Array<DependencyDeclaration>] all declared dependencies
# (gems are :bundler declarations, brew formulae are :brew declarations, etc.)
# @param ruby_version_requirement [String, nil] required Ruby version
# @param lua_version [String, nil] Lua version for LuaRocks
# @param python_version [String, nil] Python minor version for the pip venv
# @param registered_integrations [Hash{Symbol => Class}] custom integration registrations
def initialize(taps:, groups:, declarations:, ruby_version_requirement:,
def initialize(taps:, declarations:, ruby_version_requirement:,
lua_version:, python_version:, registered_integrations:)
@taps = taps
@groups = groups
@declarations = declarations
@ruby_version_requirement = ruby_version_requirement
@lua_version = lua_version
@python_version = python_version
@registered_integrations = registered_integrations
end

# Return the config for a named group, with safe defaults for missing groups.
#
# @param name [String, Symbol] group name
# @return [Hash]
def group(name)
@groups[name.to_s] || { "brew" => [], "env" => {} }
end

class << self
# Evaluate a DSL block and return a Config instance.
#
Expand All @@ -52,7 +42,6 @@ def define(&block)

new(
taps:,
groups: dsl.groups,
declarations: dsl.declarations,
ruby_version_requirement: dsl.ruby_version_requirement,
lua_version: dsl.lua_version_value,
Expand Down
Loading
Loading