From 0aaa9acd9bb38d134ed2be0adfa6dc31750be3e7 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 23 Aug 2026 09:38:44 -0400 Subject: [PATCH 01/16] Add Dev::Deps::Baseline: a lockless host manifest brew converges toward Co-authored-by: Cursor --- bin/dev | 13 ++ lib/dev/deps/baseline.rb | 158 ++++++++++++++++++++ share/baseline/dependencies.rb | 39 +++++ src/dev/builtins/up_command.rb | 9 +- src/dev/dependency_service.rb | 18 ++- test/dev/bin_dev_test.rb | 5 +- test/dev/builtins/up_command_test.rb | 33 ++++- test/dev/dependency_service_test.rb | 47 +++++- test/dev/deps/baseline_test.rb | 214 +++++++++++++++++++++++++++ 9 files changed, 524 insertions(+), 12 deletions(-) create mode 100644 lib/dev/deps/baseline.rb create mode 100644 share/baseline/dependencies.rb create mode 100644 test/dev/deps/baseline_test.rb diff --git a/bin/dev b/bin/dev index 9112b00..49b82f8 100755 --- a/bin/dev +++ b/bin/dev @@ -59,6 +59,19 @@ end begin Dev::Runner.new(ui: ui).run(ARGV) rescue Dev::DevYamlNotFoundError + # `dev up` stays valid outside any project: it converges the host baseline + # (layer 1) only, which IS the fresh-box bootstrap — install dev, `dev up`, + # ready. Everything else still needs a project. + if ARGV.first == "up" + require "dev/deps/baseline" + if Dev::Deps::Baseline.new.converge_if_stale + puts "dev: host baseline converged." + else + puts "dev: host baseline already converged." + end + puts "dev: no dev.yml here — run dev up inside a project to provision it too." + exit 0 + end warn "dev: no dev.yml found in this directory or any parent." warn "Run dev from inside a project that defines a dev.yml." exit 1 diff --git a/lib/dev/deps/baseline.rb b/lib/dev/deps/baseline.rb new file mode 100644 index 0000000..13f32e1 --- /dev/null +++ b/lib/dev/deps/baseline.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require "digest" +require "fileutils" +require "pathname" +require_relative "../deps" +require_relative "cache" +require_relative "dependency" +require_relative "registry" + +module Dev + module Deps + # The host baseline layer (plans#26): the org-invariant tools every + # d3mlabs host needs (git, gh, rbenv, shadowenv, the Cursor agent CLI via + # the upstream cursor-cli cask), declared in a manifest that ships INSIDE + # dev's distribution (share/baseline/) rather than in any project repo — + # upgrading dev is what changes the baseline. + # + # Deliberately lockless: every baseline entry is a Homebrew name, and + # brew installs by name — the baseline is *convergence toward a tool + # set*, not version pinning, so a resolved lockfile would record versions + # nothing enforces. The O(1) staleness check is therefore a digest of + # the manifest itself against a per-host stamp under XDG: + # + # - `dev up` converges a stale baseline as its first step (and is the + # only remediation), + # - every other command surfaces staleness as a warn-only nag via + # DependencyService#guard! — the baseline never blocks, even in CI. + class Baseline + # The shipped manifest, relative to this file (lib/dev/deps/ → repo or + # libexec root) — the installed location under brew, same resolution + # as Plan::Templates::BUNDLE_FILE. + SHIPPED_MANIFEST = Pathname(File.expand_path(File.join(__dir__, "..", "..", "..", "share", "baseline", "dependencies.rb"))) + + STALE_MESSAGE = "host baseline stale — run `dev up`" + + STAMP_FILE = "converged-digest" + + # @param manifest_path [Pathname, String] the baseline manifest (the + # shipped one by default) + # @param state_dir [Pathname, String] host state root — XDG data home + # like the learnings cache, NOT ~/.dev/state: project stamps are + # per-checkout working state; this is host-layer state + # @param integrations_factory [#call] () → {Symbol => Integration}; + # the install seam, injectable so tests never touch the host + def initialize(manifest_path: SHIPPED_MANIFEST, state_dir: default_state_dir, integrations_factory: nil) + @manifest_path = Pathname(manifest_path) + @state_dir = Pathname(state_dir) + @integrations_factory = integrations_factory || + -> { Registry.host_integrations(project_root: @manifest_path.dirname, cache: Cache.new) } + end + + # The warn-only nag for a stale host, nil when converged. A + # distribution without a shipped manifest has nothing to converge and + # is never stale. + # + # @return [String, nil] + def message + stale? ? STALE_MESSAGE : nil + end + + # Install the manifest's declarations (filtered to this host OS) and + # stamp the host converged. Stamping only happens after a fully- + # successful install, so a crashed run keeps nagging. + # + # @return [void] + def converge + integrations = @integrations_factory.call + host_dependencies.group_by(&:integration).each do |type, dependencies| + integrations.fetch(type).install_all(dependencies) + end + stamp! + end + + # The O(1)-guarded converge `dev up` runs first: one digest comparison + # on a warm host, a full converge on a stale one. + # + # @return [Boolean] whether a converge ran + def converge_if_stale + return false unless stale? + + converge + true + end + + private + + # @return [Boolean] whether the manifest digest drifted from the stamp + def stale? + return false unless @manifest_path.file? + + manifest_digest != stamped_digest + end + + # The manifest's declarations as installable Dependencies, minus other + # hosts' entries (e.g. the darwin-gated agent CLI on a Linux target + # host). No resolve step: the constraint hash IS the install metadata + # — brew converges by name, so there is no resolved version to carry. + # + # @return [Array] + def host_dependencies + host = Deps.detect_host + declarations.filter_map do |declaration| + next if declaration.host && declaration.host.to_s != host + + Dependency.new( + name: declaration.name, + integration: declaration.integration, + group: declaration.group, + version: nil, + hash: nil, + metadata: declaration.constraint, + ) + end + end + + # @return [Array] the manifest's declarations + def declarations + Deps.reset! + Kernel.load(@manifest_path.to_s) + Deps.last_config&.declarations || [] + end + + # @return [String] SHA-256 hex of the manifest content + def manifest_digest + Digest::SHA256.file(@manifest_path.to_s).hexdigest + end + + # @return [String, nil] the last converged manifest digest on this host + def stamped_digest + stamp_path.file? ? stamp_path.read.strip : nil + end + + # Record the just-converged manifest digest. + # + # @return [void] + def stamp! + FileUtils.mkdir_p(stamp_path.dirname) + stamp_path.write(manifest_digest) + end + + # Fixed host-singular stamp path: the baseline is per-host, so its + # stamp must not vary with the manifest's path (which moves on every + # brew upgrade of dev). + # + # @return [Pathname] + def stamp_path + @state_dir / "host-baseline" / STAMP_FILE + end + + # @return [String] $XDG_DATA_HOME/dev (the learnings-cache precedent) + def default_state_dir + data_home = ENV.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local", "share")) + File.join(data_home, "dev") + end + end + end +end diff --git a/share/baseline/dependencies.rb b/share/baseline/dependencies.rb new file mode 100644 index 0000000..114c7ba --- /dev/null +++ b/share/baseline/dependencies.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# The host baseline manifest (plans#26, layer 1 of a machine): the +# org-invariant tools every d3mlabs host converges toward, regardless of +# which projects it serves. Shipped inside dev's distribution — upgrading +# dev is what changes the baseline. `dev up` converges it as its first +# step; every other command only warns when it drifts. +# +# Everything here is a Homebrew name and brew converges by name, so there +# is no lockfile — the baseline declares a tool set, not versions. +# +# Deliberately small: project-specific tools belong in each repo's +# dependencies.rb (layer 2), and OS-provided tools (curl, tar) are not +# re-declared — shadowing the system copies buys nothing. +require "dev/deps" + +Dev::Deps.define do + group :baseline do + # Declared even though dev's Homebrew formula pulls git/gh via + # depends_on — the manifest is the verification record, and hosts that + # got dev some other way still converge. + brew "git" + brew "gh" + # dev's Ruby toolchain managers: project rubies via rbenv, per-project + # activation via shadowenv. + brew "rbenv" + brew "shadowenv" + end + + # The headless Cursor agent CLI ai-flow spawns, via the upstream + # homebrew-cask package (world-readable under /opt/homebrew — the agent + # user reads it with no shared-root machinery); AI_FLOW_AGENT_BIN points + # at the Caskroom's stable bin/cursor-agent symlink. darwin-gated: agent + # jobs route to Mac runners; target hosts (the gamebox) never get agent + # pieces. + group :agent, host: :darwin do + brew "cursor-cli", cask: true + end +end diff --git a/src/dev/builtins/up_command.rb b/src/dev/builtins/up_command.rb index dd6a1ab..02b4818 100644 --- a/src/dev/builtins/up_command.rb +++ b/src/dev/builtins/up_command.rb @@ -4,6 +4,7 @@ require "dev/cd" require "dev/command" require "dev/credentials" +require "dev/deps/baseline" module Dev module Builtins @@ -20,12 +21,15 @@ class UpCommand < BuiltinCommand params( install_deps_command: InstallDepsCommand, hook_installer: Dev::Cd::HookInstaller, + baseline: Dev::Deps::Baseline, ).void end - def initialize(install_deps_command:, hook_installer: Dev::Cd::HookInstaller.new) + def initialize(install_deps_command:, hook_installer: Dev::Cd::HookInstaller.new, + baseline: Dev::Deps::Baseline.new) super() @install_deps_command = T.let(install_deps_command, InstallDepsCommand) @hook_installer = T.let(hook_installer, Dev::Cd::HookInstaller) + @baseline = T.let(baseline, Dev::Deps::Baseline) end sig { override.returns(String) } @@ -45,6 +49,9 @@ def stamps? = true sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) + # The host baseline converges before project provisioning: project + # installs may lean on baseline tools (gh, rbenv). O(1) when warm. + @baseline.converge_if_stale provision_build_credentials(context) @hook_installer.ensure_installed @install_deps_command.call(args:, context:) diff --git a/src/dev/dependency_service.rb b/src/dev/dependency_service.rb index d015065..049825c 100644 --- a/src/dev/dependency_service.rb +++ b/src/dev/dependency_service.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "dev/deps" +require "dev/deps/baseline" require "dev/deps/staleness" module Dev @@ -9,7 +10,7 @@ module Dev # guard policy before a command runs, the current staleness messages, and # the installed-stamp write (lock!) after a stamping command succeeds. # Deliberately narrow — Lockfile and Deps::Cache consumers stay direct for - # now; this fronts only Staleness. + # now; this fronts only Staleness (plus the host baseline's warn-only nag). class DependencyService extend T::Sig @@ -17,9 +18,10 @@ class DependencyService # pipeline bug, not a reminder. class StaleDependencyStateError < RuntimeError; end - sig { params(staleness: Dev::Deps::Staleness).void } - def initialize(staleness:) + sig { params(staleness: Dev::Deps::Staleness, baseline: Dev::Deps::Baseline).void } + def initialize(staleness:, baseline: Dev::Deps::Baseline.new) @staleness = T.let(staleness, Dev::Deps::Staleness) + @baseline = T.let(baseline, Dev::Deps::Baseline) end # All current staleness messages (see Dev::Deps::Staleness#messages). @@ -31,12 +33,18 @@ def messages end # Two O(1) digest checks at every command start: manifest vs lockfile, - # lockfile vs installed stamp. Warn on workstations; error in CI. + # lockfile vs installed stamp. Warn on workstations; error in CI. The + # host baseline gets its own check with softer semantics: always a + # warning, even in CI — a drifted host tool set is never a reason to + # block a project command (plans#26). # # @return [void] - # @raise [StaleDependencyStateError] in CI, when any layer is stale + # @raise [StaleDependencyStateError] in CI, when any project layer is stale sig { void } def guard! + baseline_message = @baseline.message + $stderr.puts "dev: warning: #{baseline_message}" if baseline_message + stale_messages = messages return if stale_messages.empty? diff --git a/test/dev/bin_dev_test.rb b/test/dev/bin_dev_test.rb index 8a2c9a8..6d6f475 100644 --- a/test/dev/bin_dev_test.rb +++ b/test/dev/bin_dev_test.rb @@ -41,8 +41,11 @@ class Dev::BinDevTest < Minitest::Test "BUNDLE_GEMFILE" => "/nonexistent/harness/Gemfile", } + # A project command with no global/no-project fallback: `up` outside a + # project now converges the host baseline (a real host mutation), so it + # can never be spawned from tests. When "running a project command (bare dev renders the global usage instead) there" - _out, err, status = Open3.capture3(hostile, "sh", BIN_DEV, "up", chdir: dir) + _out, err, status = Open3.capture3(hostile, "sh", BIN_DEV, "test", chdir: dir) Then "dev reached its own no-dev.yml refusal — not a crash inside the caller's bundler" !status.success? diff --git a/test/dev/builtins/up_command_test.rb b/test/dev/builtins/up_command_test.rb index 4170e3e..5262349 100644 --- a/test/dev/builtins/up_command_test.rb +++ b/test/dev/builtins/up_command_test.rb @@ -27,7 +27,9 @@ class Dev::Builtins::UpCommandTest < Minitest::Test install_deps = typed_mock(Dev::Builtins::InstallDepsCommand) hook_installer = typed_mock(Dev::Cd::HookInstaller) hook_installer.expects(:ensure_installed).once.returns(:already_present) - command = Dev::Builtins::UpCommand.new(install_deps_command: install_deps, hook_installer: hook_installer) + command = Dev::Builtins::UpCommand.new( + install_deps_command: install_deps, hook_installer: hook_installer, baseline: fresh_baseline, + ) context = build_context When "running up" @@ -37,6 +39,25 @@ class Dev::Builtins::UpCommandTest < Minitest::Test 1 * install_deps.call(args: ["-v"], context: context) end + test "call converges a stale host baseline as its first step" do + Given "an up command whose baseline expects the staleness-gated converge" + baseline = typed_mock(Dev::Deps::Baseline) + baseline.expects(:converge_if_stale).once.returns(true) + install_deps = typed_mock(Dev::Builtins::InstallDepsCommand) + install_deps.stubs(:call) + hook_installer = typed_mock(Dev::Cd::HookInstaller) + hook_installer.stubs(:ensure_installed).returns(:already_present) + command = Dev::Builtins::UpCommand.new( + install_deps_command: install_deps, hook_installer: hook_installer, baseline: baseline, + ) + + When "running up" + command.call(args: [], context: build_context) + + Then "the expectation on the baseline holds" + true + end + test "call resolves docker build arg credentials before anything else" do Given "a context whose build container declares build_args" command = build_command @@ -80,7 +101,15 @@ def build_command install_deps.stubs(:call) hook_installer = typed_mock(Dev::Cd::HookInstaller) hook_installer.stubs(:ensure_installed).returns(:already_present) - Dev::Builtins::UpCommand.new(install_deps_command: install_deps, hook_installer: hook_installer) + Dev::Builtins::UpCommand.new( + install_deps_command: install_deps, hook_installer: hook_installer, baseline: fresh_baseline, + ) + end + + def fresh_baseline + baseline = typed_mock(Dev::Deps::Baseline) + baseline.stubs(:converge_if_stale).returns(false) + baseline end def container_config(build_args:) diff --git a/test/dev/dependency_service_test.rb b/test/dev/dependency_service_test.rb index 4f43039..17d5c5b 100644 --- a/test/dev/dependency_service_test.rb +++ b/test/dev/dependency_service_test.rb @@ -63,11 +63,44 @@ class Dev::DependencyServiceTest < Minitest::Test raises Dev::DependencyService::StaleDependencyStateError end + test "guard! surfaces a stale host baseline as a warning, never a block — even in CI" do + Given "a stale baseline with an otherwise in-sync project, in CI" + service = build_service(messages: [], baseline_message: "host baseline stale — run `dev up`") + Dev::Deps.stubs(:detect_env).returns("ci") + old_stderr = $stderr + $stderr = StringIO.new + + When "guarding" + service.guard! + + Then "the nag is advisory and the command proceeds" + $stderr.string.include?("warning: host baseline stale — run `dev up`") + + Cleanup + $stderr = old_stderr + end + + test "guard! stays quiet about a fresh baseline" do + Given "an in-sync project and baseline" + service = build_service(messages: [], baseline_message: nil) + old_stderr = $stderr + $stderr = StringIO.new + + When "guarding" + service.guard! + + Then + $stderr.string.empty? + + Cleanup + $stderr = old_stderr + end + test "lock! records the installed stamp" do Given "a staleness expecting its stamp write" staleness = typed_mock(Dev::Deps::Staleness) staleness.expects(:stamp_installed!).once - service = Dev::DependencyService.new(staleness: staleness) + service = Dev::DependencyService.new(staleness: staleness, baseline: quiet_baseline) When "locking" service.lock! @@ -78,9 +111,17 @@ class Dev::DependencyServiceTest < Minitest::Test private - def build_service(messages:) + def build_service(messages:, baseline_message: nil) staleness = typed_mock(Dev::Deps::Staleness) staleness.stubs(:messages).returns(messages) - Dev::DependencyService.new(staleness: staleness) + baseline = typed_mock(Dev::Deps::Baseline) + baseline.stubs(:message).returns(baseline_message) + Dev::DependencyService.new(staleness: staleness, baseline: baseline) + end + + def quiet_baseline + baseline = typed_mock(Dev::Deps::Baseline) + baseline.stubs(:message).returns(nil) + baseline end end diff --git a/test/dev/deps/baseline_test.rb b/test/dev/deps/baseline_test.rb new file mode 100644 index 0000000..d86d2de --- /dev/null +++ b/test/dev/deps/baseline_test.rb @@ -0,0 +1,214 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/deps/baseline" +require "digest" +require "fileutils" +require "tmpdir" + +transform!(RSpock::AST::Transformation) +class Dev::Deps::BaselineTest < Minitest::Test + MANIFEST = <<~MANIFEST + Dev::Deps.define do + group :baseline do + brew "git" + end + end + MANIFEST + + # Stands in for BrewIntegration at the factory seam: records what would + # install, touches nothing on the host. + class RecordingIntegration + attr_reader :installed + + def initialize + @installed = [] + end + + def install_all(dependencies) + @installed.concat(dependencies) + end + end + + def write_manifest(dir, content = MANIFEST) + path = File.join(dir, "baseline", "dependencies.rb") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + path + end + + def build_baseline(dir, manifest_path, integration: RecordingIntegration.new) + Dev::Deps::Baseline.new( + manifest_path: manifest_path, + state_dir: File.join(dir, "state"), + integrations_factory: -> { { brew: integration } }, + ) + end + + test "a never-converged host reports the baseline message" do + Given "a shipped manifest and no stamp on this host" + dir = Dir.mktmpdir("dev-baseline-test-") + baseline = build_baseline(dir, write_manifest(dir)) + + Expect "the warn-only nag with its remediation" + baseline.message == "host baseline stale — run `dev up`" + + Cleanup + FileUtils.rm_rf(dir) + end + + test "converge installs the manifest's declarations and stamps the digest" do + Given "a stale host" + dir = Dir.mktmpdir("dev-baseline-test-") + manifest_path = write_manifest(dir) + integration = RecordingIntegration.new + baseline = build_baseline(dir, manifest_path, integration: integration) + + When "converging" + baseline.converge + + Then "the brew dep landed (lockless: no version, constraint as metadata) and the host went quiet" + integration.installed.map(&:name) == ["git"] + integration.installed.fetch(0).integration == :brew + integration.installed.fetch(0).version.nil? + baseline.message.nil? + File.read(File.join(dir, "state", "host-baseline", "converged-digest")) == + Digest::SHA256.file(manifest_path).hexdigest + + Cleanup + FileUtils.rm_rf(dir) + end + + test "converge_if_stale converges a stale host and reports it did" do + Given "a stale host" + dir = Dir.mktmpdir("dev-baseline-test-") + integration = RecordingIntegration.new + baseline = build_baseline(dir, write_manifest(dir), integration: integration) + + When "converging if stale" + converged = baseline.converge_if_stale + + Then + converged == true + integration.installed.map(&:name) == ["git"] + + Cleanup + FileUtils.rm_rf(dir) + end + + test "converge_if_stale is an O(1) no-op on a warm host" do + Given "a host already converged on the shipped manifest" + dir = Dir.mktmpdir("dev-baseline-test-") + manifest_path = write_manifest(dir) + build_baseline(dir, manifest_path).converge + integration = RecordingIntegration.new + baseline = build_baseline(dir, manifest_path, integration: integration) + + When "converging if stale" + converged = baseline.converge_if_stale + + Then "nothing installed" + converged == false + integration.installed.empty? + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a manifest change (a dev upgrade) makes a converged host stale again" do + Given "a converged host whose shipped manifest then changes" + dir = Dir.mktmpdir("dev-baseline-test-") + manifest_path = write_manifest(dir) + build_baseline(dir, manifest_path).converge + File.write(manifest_path, <<~MANIFEST) + Dev::Deps.define do + group :baseline do + brew "git" + brew "gh" + end + end + MANIFEST + baseline = build_baseline(dir, manifest_path) + + Expect + baseline.message == "host baseline stale — run `dev up`" + + Cleanup + FileUtils.rm_rf(dir) + end + + test "another host's declarations are filtered out of the converge" do + Given "a manifest gating one group to the OTHER host OS (the darwin-gated agent CLI case)" + dir = Dir.mktmpdir("dev-baseline-test-") + other_host = Dev::Deps.detect_host == "darwin" ? :linux : :darwin + manifest_path = write_manifest(dir, <<~MANIFEST) + Dev::Deps.define do + group :baseline do + brew "git" + end + + group :agent, host: :#{other_host} do + brew "cursor-cli", cask: true + end + end + MANIFEST + integration = RecordingIntegration.new + baseline = build_baseline(dir, manifest_path, integration: integration) + + When "converging" + baseline.converge + + Then "only this host's entries install" + integration.installed.map(&:name) == ["git"] + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a distribution without a shipped manifest is never stale" do + Given "no manifest at all" + dir = Dir.mktmpdir("dev-baseline-test-") + integration = RecordingIntegration.new + baseline = build_baseline(dir, File.join(dir, "baseline", "dependencies.rb"), integration: integration) + + When "checking and converging if stale" + converged = baseline.converge_if_stale + + Then "quiet, and nothing to install" + baseline.message.nil? + converged == false + integration.installed.empty? + + Cleanup + FileUtils.rm_rf(dir) + end + + test "converge with the default integrations wiring is host-safe on an empty manifest" do + Given "a manifest declaring nothing, and no factory injected (real Registry wiring)" + dir = Dir.mktmpdir("dev-baseline-test-") + manifest_path = write_manifest(dir, "# nothing declared\n") + baseline = Dev::Deps::Baseline.new(manifest_path: manifest_path, state_dir: File.join(dir, "state")) + + When "converging through the real integrations table" + baseline.converge + + Then "no declarations dispatch, and the converge still stamps" + File.exist?(File.join(dir, "state", "host-baseline", "converged-digest")) + + Cleanup + FileUtils.rm_rf(dir) + end + + test "the shipped baseline manifest exists and default construction resolves it" do + Given "a Baseline built entirely from defaults" + baseline = Dev::Deps::Baseline.new + + Expect "the shipped manifest is part of the distribution" + Dev::Deps::Baseline::SHIPPED_MANIFEST.file? + !baseline.nil? + + Cleanup + nil + end +end From 1f4f6d369b689c61dfd154ef907d15247e0747d3 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 23 Aug 2026 09:38:44 -0400 Subject: [PATCH 02/16] Converge the host baseline under dev up and nag (warn-only) elsewhere Co-authored-by: Cursor --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e44415e..ef415b9 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,12 @@ Two YAML lockfiles, same format, two purposes: Both files are generated by `dev update-deps` and committed to git. Never edit them by hand. +### Host baseline + +Alongside per-project dependencies, dev ships a **host baseline** — the org-invariant tools every d3mlabs host converges toward (git, gh, rbenv, shadowenv, and on Macs the Cursor agent CLI via the upstream `cursor-cli` cask). The manifest ships inside dev's distribution (`share/baseline/dependencies.rb`), not in any project repo: upgrading dev is what changes the baseline. + +The baseline is deliberately **lockless**: every entry is a Homebrew name and brew converges by name, so a lockfile would record versions nothing enforces. Staleness is an O(1) digest of the manifest itself against a host-side stamp (`$XDG_DATA_HOME/dev/host-baseline/converged-digest`). `dev up` converges a stale baseline as its first step (a silent no-op on warm hosts), and also works **outside any project**: with no `dev.yml` found it converges the host layer only, which is the fresh-box bootstrap (`brew install dev` → `dev up` → ready). Every other command surfaces a stale baseline as a warn-only nag ("host baseline stale — run `dev up`") — advisory even in CI, never a block. `Dev::Deps::Baseline` owns the layer. + ### dependencies.rb Declare dependencies using a Ruby DSL: @@ -368,7 +374,7 @@ Custom integrations implement `Dev::Deps::Integration` (with `install_all(pins, - **`dev update-deps`** — resolve constraints from `dependencies.rb`, write lockfiles (recording the manifest digest for the staleness check). Always available (no need to define in `dev.yml`). - **`dev install-deps`** — install locked deps handled on the host (gh releases, steam apps) into their version-keyed install dirs, filtered to the detected env and host OS. Finishes by refreshing agent skill links (see [Agent skills & org learnings](#agent-skills--org-learnings)). -- **`dev up`** — auto-installs all deps from lockfiles (build group first), then runs the project's `up:` command from `dev.yml` if defined. On success, stamps the installed lockfile digest (see `dev check`). Finishes by refreshing agent skill links, like `install-deps`. +- **`dev up`** — first converges the host baseline when stale (see [Host baseline](#host-baseline)), then auto-installs all deps from lockfiles (build group first), then runs the project's `up:` command from `dev.yml` if defined. On success, stamps the installed lockfile digest (see `dev check`). Finishes by refreshing agent skill links, like `install-deps`. Also valid outside any project: converges the host baseline only — the fresh-box bootstrap. - **`dev check`** — report dependency-state staleness explicitly: `dependencies.rb` vs lockfiles (digest recorded by `update-deps`), and lockfiles vs the per-machine installed stamp (`~/.dev/state//installed-digest`, written after a fully-successful `up`/`install-deps`). The same two O(1) checks run at every command start — warning on workstations, erroring in CI. - **`dev deps path `** — print the absolute path of a locked artifact (e.g. `dev deps path ficsit SML LinuxServer`, or `dev deps path xcode` for the pinned DEVELOPER_DIR) so scripts don't reconstruct cache keys or layout conventions. - **`dev cred get `** — resolve a credential through the provider chain (ENV → keychain → file → prompt) and print it. A non-interactive miss errors with `gh secret set` guidance. Mirrors `dev deps path` for shell consumers (e.g. a staging sync). Global: works without a `dev.yml`. From 4d4aebecd28857224323143563073a76271f9978 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 23 Aug 2026 09:52:51 -0400 Subject: [PATCH 03/16] Move the agent CLI out of the baseline: it is ai-flow's own dependency Co-authored-by: Cursor --- README.md | 2 +- share/baseline/dependencies.rb | 14 +++++--------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ef415b9..c7c5d4a 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ Both files are generated by `dev update-deps` and committed to git. Never edit t ### Host baseline -Alongside per-project dependencies, dev ships a **host baseline** — the org-invariant tools every d3mlabs host converges toward (git, gh, rbenv, shadowenv, and on Macs the Cursor agent CLI via the upstream `cursor-cli` cask). The manifest ships inside dev's distribution (`share/baseline/dependencies.rb`), not in any project repo: upgrading dev is what changes the baseline. +Alongside per-project dependencies, dev ships a **host baseline** — the org-invariant tools every d3mlabs host converges toward (git, gh, rbenv, shadowenv). The manifest ships inside dev's distribution (`share/baseline/dependencies.rb`), not in any project repo: upgrading dev is what changes the baseline. Tools that belong to one piece of software stay in that repo's `dependencies.rb` — e.g. the Cursor agent CLI is ai-flow's dependency (the `cursor-cli` cask), installed by converging the ai-flow checkout, not by the baseline. The baseline is deliberately **lockless**: every entry is a Homebrew name and brew converges by name, so a lockfile would record versions nothing enforces. Staleness is an O(1) digest of the manifest itself against a host-side stamp (`$XDG_DATA_HOME/dev/host-baseline/converged-digest`). `dev up` converges a stale baseline as its first step (a silent no-op on warm hosts), and also works **outside any project**: with no `dev.yml` found it converges the host layer only, which is the fresh-box bootstrap (`brew install dev` → `dev up` → ready). Every other command surfaces a stale baseline as a warn-only nag ("host baseline stale — run `dev up`") — advisory even in CI, never a block. `Dev::Deps::Baseline` owns the layer. diff --git a/share/baseline/dependencies.rb b/share/baseline/dependencies.rb index 114c7ba..9314205 100644 --- a/share/baseline/dependencies.rb +++ b/share/baseline/dependencies.rb @@ -27,13 +27,9 @@ brew "shadowenv" end - # The headless Cursor agent CLI ai-flow spawns, via the upstream - # homebrew-cask package (world-readable under /opt/homebrew — the agent - # user reads it with no shared-root machinery); AI_FLOW_AGENT_BIN points - # at the Caskroom's stable bin/cursor-agent symlink. darwin-gated: agent - # jobs route to Mac runners; target hosts (the gamebox) never get agent - # pieces. - group :agent, host: :darwin do - brew "cursor-cli", cask: true - end + # Deliberately NOT here: the Cursor agent CLI. It is ai-flow's own + # dependency (declared in ai-flow's dependencies.rb as the cursor-cli + # cask) — converging the ai-flow checkout is what makes a box + # agent-capable, so target hosts and plain dev machines never carry + # agent pieces they don't serve. end From 0bb21a6b939a11a90a1c4de7b0d9dd7601266f05 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 23 Aug 2026 12:01:21 -0400 Subject: [PATCH 04/16] Source the baseline manifest from the org repo named in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev is public and hardcodes no org content, so the baseline manifest moves out of dev's distribution: the org names its repo in the new baseline_repo setting and dev reads baseline/dependencies.rb from it, caching a machine-local copy next to the stamp. dev up refreshes the cache (falling back to it offline) and converges on digest drift; the per-command nag stays O(1) against the cache, never the network. Settings resolution is now layered gitconfig-style — ENV over the user file over the brew-prefix system file an org deployment formula ships. Co-authored-by: Cursor --- README.md | 26 ++++- lib/dev/deps/baseline.rb | 166 +++++++++++++++++++--------- lib/dev/settings.rb | 102 ++++++++++++----- share/baseline/dependencies.rb | 35 ------ test/dev/deps/baseline_test.rb | 196 ++++++++++++++++++++------------- test/dev/settings_test.rb | 126 ++++++++++++++++++--- 6 files changed, 442 insertions(+), 209 deletions(-) delete mode 100644 share/baseline/dependencies.rb diff --git a/README.md b/README.md index c7c5d4a..9b43c61 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,28 @@ dev's feature set is three independent opt-ins; a repo takes whichever rungs it A gem repo typically stops at rungs 1–2 (commands + a pinned Ruby, hand-written gemspec/Gemfile); an app repo usually takes all three. +## Org configuration & deployment + +dev's source hardcodes no org content — every org-specific fact enters through **settings**, resolved per key with gitconfig-style layering (`Dev::Settings`): + +1. **ENV var** — `DEV_PLANS_REPO`, `DEV_KNOWLEDGE_REPO`, `DEV_BASELINE_REPO`. Highest precedence. +2. **User file** — `~/.config/dev/config.yml` (or `$XDG_CONFIG_HOME/dev/config.yml`). +3. **System file** — `$(brew --prefix)/etc/dev/config.yml`, shipped by an org's deployment formula. + +Missing files are empty layers; a key set in the user file wins over the system file. The keys: + +```yaml +plans_repo: d3mlabs/plans # org-wide plans repo (dev plan --org) +knowledge_repo: d3mlabs/knowledge # org learnings sync source +baseline_repo: d3mlabs/knowledge # repo whose baseline/dependencies.rb is the host baseline +``` + +Leaving a nilable key unset turns its feature off (`plans_repo` is only required by `dev plan --org`). Three consumption stories: + +- **Org deployment (recommended):** the org's Homebrew formula is the deployment artifact — it packages the generic tool *and* the org's identity. It installs the org `config.yml` into the prefix's `etc/dev/` (pkgetc — brew preserves locally-modified etc files across upgrades) and declares `depends_on` for the tools dev itself shells out to. One command installs both: `brew install d3mlabs/d3mlabs/dev` is the reference deployment. An org that doesn't want to own a full build formula can publish a thin config-only formula instead (e.g. `acme/tools/acme-dev`: payload is `etc/dev/config.yml`, plus `depends_on "d3mlabs/d3mlabs/dev"`), so `brew install acme/tools/acme-dev` deploys the upstream tool with acme's identity. +- **Individual / handrolled:** install dev from any org's tap (or source) and write the user file yourself with the keys above — no formula involvement, useful for personal machines or orgs without a tap. +- **CI / fleet:** set the ENV vars in the pipeline or MDM profile — no files needed, and they override both file layers. + ## Usage From anywhere under a git repo that has a `dev.yml` at its root: @@ -269,9 +291,9 @@ Both files are generated by `dev update-deps` and committed to git. Never edit t ### Host baseline -Alongside per-project dependencies, dev ships a **host baseline** — the org-invariant tools every d3mlabs host converges toward (git, gh, rbenv, shadowenv). The manifest ships inside dev's distribution (`share/baseline/dependencies.rb`), not in any project repo: upgrading dev is what changes the baseline. Tools that belong to one piece of software stay in that repo's `dependencies.rb` — e.g. the Cursor agent CLI is ai-flow's dependency (the `cursor-cli` cask), installed by converging the ai-flow checkout, not by the baseline. +Alongside per-project dependencies, dev converges a **host baseline** — the org-invariant tools every host needs regardless of which projects it serves (e.g. git, gh, rbenv, shadowenv). dev ships the machinery only; the manifest lives in the org's repo named by the `baseline_repo` setting (see [Org configuration & deployment](#org-configuration--deployment)), at the conventional path `baseline/dependencies.rb`. Unset means no baseline — dev stays generic. Tools that belong to one piece of software stay in that repo's own `dependencies.rb`; org-wide dev tooling (an editor-class agent CLI, say) belongs in the baseline, not in every project manifest. -The baseline is deliberately **lockless**: every entry is a Homebrew name and brew converges by name, so a lockfile would record versions nothing enforces. Staleness is an O(1) digest of the manifest itself against a host-side stamp (`$XDG_DATA_HOME/dev/host-baseline/converged-digest`). `dev up` converges a stale baseline as its first step (a silent no-op on warm hosts), and also works **outside any project**: with no `dev.yml` found it converges the host layer only, which is the fresh-box bootstrap (`brew install dev` → `dev up` → ready). Every other command surfaces a stale baseline as a warn-only nag ("host baseline stale — run `dev up`") — advisory even in CI, never a block. `Dev::Deps::Baseline` owns the layer. +The baseline is deliberately **lockless**: every entry is a Homebrew name and brew converges by name, so a lockfile would record versions nothing enforces. dev keeps a machine-local cache of the manifest (`$XDG_DATA_HOME/dev/host-baseline/dependencies.rb`, next to the `converged-digest` stamp). `dev up` refreshes the cache from the org repo, then converges when the cached digest drifted from the stamp (a silent single-digest no-op on warm hosts); a failed fetch falls back to the cached copy, so offline `dev up` still works. `dev up` also works **outside any project**: with no `dev.yml` found it converges the host layer only, which is the fresh-box bootstrap (`brew install /dev` → `dev up` → ready). Every other command surfaces a stale baseline as a warn-only nag ("host baseline stale — run `dev up`") computed from the cache alone — O(1), never network, advisory even in CI. `Dev::Deps::Baseline` owns the layer. ### dependencies.rb diff --git a/lib/dev/deps/baseline.rb b/lib/dev/deps/baseline.rb index 13f32e1..3f77816 100644 --- a/lib/dev/deps/baseline.rb +++ b/lib/dev/deps/baseline.rb @@ -2,66 +2,132 @@ require "digest" require "fileutils" +require "open3" require "pathname" require_relative "../deps" +require_relative "../settings" require_relative "cache" require_relative "dependency" require_relative "registry" module Dev module Deps - # The host baseline layer (plans#26): the org-invariant tools every - # d3mlabs host needs (git, gh, rbenv, shadowenv, the Cursor agent CLI via - # the upstream cursor-cli cask), declared in a manifest that ships INSIDE - # dev's distribution (share/baseline/) rather than in any project repo — - # upgrading dev is what changes the baseline. + # The host baseline layer (plans#26): the org-invariant tools every host + # needs (e.g. git, gh, rbenv, shadowenv, the agent CLI), declared in a + # manifest that lives in the org's repo named by the `baseline_repo` + # setting — dev is public and ships no org content, so the org names its + # manifest source and dev supplies the machinery. Unset means no + # baseline: the whole layer is off. # # Deliberately lockless: every baseline entry is a Homebrew name, and # brew installs by name — the baseline is *convergence toward a tool # set*, not version pinning, so a resolved lockfile would record versions - # nothing enforces. The O(1) staleness check is therefore a digest of - # the manifest itself against a per-host stamp under XDG: + # nothing enforces. # - # - `dev up` converges a stale baseline as its first step (and is the - # only remediation), - # - every other command surfaces staleness as a warn-only nag via - # DependencyService#guard! — the baseline never blocks, even in CI. + # The manifest is cached machine-locally (next to the stamp, under XDG + # data): `dev up` refreshes the cache from the org repo and converges + # when the cached digest drifts from the per-host stamp; a failed fetch + # falls back to the cached copy, so offline `dev up` still works. Every + # other command surfaces staleness as a warn-only O(1) nag (cached + # digest vs stamp, never network) via DependencyService#guard! — the + # baseline never blocks, even in CI. class Baseline - # The shipped manifest, relative to this file (lib/dev/deps/ → repo or - # libexec root) — the installed location under brew, same resolution - # as Plan::Templates::BUNDLE_FILE. - SHIPPED_MANIFEST = Pathname(File.expand_path(File.join(__dir__, "..", "..", "..", "share", "baseline", "dependencies.rb"))) - STALE_MESSAGE = "host baseline stale — run `dev up`" STAMP_FILE = "converged-digest" - # @param manifest_path [Pathname, String] the baseline manifest (the - # shipped one by default) + CACHE_FILE = "dependencies.rb" + + # Conventional manifest location inside the org's baseline repo. + MANIFEST_REPO_PATH = "baseline/dependencies.rb" + + # Fetches a file's raw content from a repo's default branch through + # the gh CLI — the same boundary shape as Plan::GithubIssues#repo_file. + # nil for any failure: the caller decides the fallback. + class GhFetcher + # @param owner_repo [String] "owner/repo" + # @param path [String] file path inside the repo + # @return [String, nil] the file content, or nil when unavailable + def repo_file(owner_repo, path) + out, _err, status = Open3.capture3( + "gh", "api", "-H", "Accept: application/vnd.github.raw", "repos/#{owner_repo}/contents/#{path}" + ) + status.success? ? out : nil + rescue SystemCallError + nil + end + end + + # @param settings [Dev::Settings] source of the baseline_repo key # @param state_dir [Pathname, String] host state root — XDG data home # like the learnings cache, NOT ~/.dev/state: project stamps are # per-checkout working state; this is host-layer state + # @param fetcher [#repo_file] raw-content boundary, injectable so + # tests never touch the network # @param integrations_factory [#call] () → {Symbol => Integration}; # the install seam, injectable so tests never touch the host - def initialize(manifest_path: SHIPPED_MANIFEST, state_dir: default_state_dir, integrations_factory: nil) - @manifest_path = Pathname(manifest_path) + def initialize(settings: Dev::Settings.new, state_dir: default_state_dir, + fetcher: GhFetcher.new, integrations_factory: nil) + @settings = settings @state_dir = Pathname(state_dir) + @fetcher = fetcher @integrations_factory = integrations_factory || - -> { Registry.host_integrations(project_root: @manifest_path.dirname, cache: Cache.new) } + -> { Registry.host_integrations(project_root: cache_path.dirname, cache: Cache.new) } end - # The warn-only nag for a stale host, nil when converged. A - # distribution without a shipped manifest has nothing to converge and - # is never stale. + # The warn-only nag for a stale host, nil when converged. Computed + # from the machine-local cache only — O(1), never network. A machine + # without a configured baseline repo has nothing to converge and is + # never stale. # # @return [String, nil] def message stale? ? STALE_MESSAGE : nil end - # Install the manifest's declarations (filtered to this host OS) and - # stamp the host converged. Stamping only happens after a fully- - # successful install, so a crashed run keeps nagging. + # The converge `dev up` runs first: refresh the cached manifest from + # the org repo (falling back to the cached copy when the fetch fails, + # so offline runs still work), then install and stamp when the cache + # digest drifted from the per-host stamp. One digest comparison on a + # warm host after the refresh. + # + # @return [Boolean] whether a converge ran + def converge_if_stale + return false unless baseline_repo + + refresh_cache + return false unless stale? && cache_path.file? + + converge + true + end + + private + + # @return [String, nil] the org repo named in settings, or nil (layer off) + def baseline_repo + @settings.baseline_repo + end + + # Fetch the manifest fresh and rewrite the cache; a failed fetch keeps + # the cached copy (with a warning), so a fresh box with no cache stays + # stale and keeps nagging. + # + # @return [void] + def refresh_cache + content = @fetcher.repo_file(baseline_repo, MANIFEST_REPO_PATH) + if content + FileUtils.mkdir_p(cache_path.dirname) + cache_path.write(content) + else + detail = cache_path.file? ? " — converging from the cached copy" : "" + $stderr.puts "dev: warning: could not fetch baseline manifest from #{baseline_repo}#{detail}" + end + end + + # Install the cached manifest's declarations (filtered to this host + # OS) and stamp the host converged. Stamping only happens after a + # fully-successful install, so a crashed run keeps nagging. # # @return [void] def converge @@ -72,24 +138,15 @@ def converge stamp! end - # The O(1)-guarded converge `dev up` runs first: one digest comparison - # on a warm host, a full converge on a stale one. + # A host is stale when a baseline repo is configured and this host's + # stamp doesn't match the cached manifest — including the fresh-box + # case where neither cache nor stamp exists yet. # - # @return [Boolean] whether a converge ran - def converge_if_stale - return false unless stale? - - converge - true - end - - private - - # @return [Boolean] whether the manifest digest drifted from the stamp + # @return [Boolean] def stale? - return false unless @manifest_path.file? + return false unless baseline_repo - manifest_digest != stamped_digest + cache_digest != stamped_digest || stamped_digest.nil? end # The manifest's declarations as installable Dependencies, minus other @@ -114,16 +171,16 @@ def host_dependencies end end - # @return [Array] the manifest's declarations + # @return [Array] the cached manifest's declarations def declarations Deps.reset! - Kernel.load(@manifest_path.to_s) + Kernel.load(cache_path.to_s) Deps.last_config&.declarations || [] end - # @return [String] SHA-256 hex of the manifest content - def manifest_digest - Digest::SHA256.file(@manifest_path.to_s).hexdigest + # @return [String, nil] SHA-256 hex of the cached manifest, nil without one + def cache_digest + cache_path.file? ? Digest::SHA256.file(cache_path.to_s).hexdigest : nil end # @return [String, nil] the last converged manifest digest on this host @@ -136,12 +193,19 @@ def stamped_digest # @return [void] def stamp! FileUtils.mkdir_p(stamp_path.dirname) - stamp_path.write(manifest_digest) + stamp_path.write(cache_digest) + end + + # The machine-local copy of the org manifest, next to the stamp. The + # cache is what the nag and the converge read; the org repo is only + # touched by refresh_cache. + # + # @return [Pathname] + def cache_path + @state_dir / "host-baseline" / CACHE_FILE end - # Fixed host-singular stamp path: the baseline is per-host, so its - # stamp must not vary with the manifest's path (which moves on every - # brew upgrade of dev). + # Fixed host-singular stamp path: the baseline is per-host state. # # @return [Pathname] def stamp_path diff --git a/lib/dev/settings.rb b/lib/dev/settings.rb index 343d7e8..833aaed 100644 --- a/lib/dev/settings.rb +++ b/lib/dev/settings.rb @@ -3,43 +3,50 @@ require "yaml" module Dev - # Global (per-machine) settings, read from ~/.config/dev/config.yml - # (or $XDG_CONFIG_HOME/dev/config.yml) — the same directory as dev's - # credentials file. Keys: + # Global (per-machine) settings — the seam where an org's identity enters + # a generic dev install (dev is public and hardcodes no org content). + # Per-key resolution is layered, gitconfig-style: + # + # 1. ENV var (DEV_PLANS_REPO, DEV_KNOWLEDGE_REPO, DEV_BASELINE_REPO) — + # CI/fleet management, no files needed + # 2. user file: ~/.config/dev/config.yml (or $XDG_CONFIG_HOME/dev/…) — + # individuals and per-user overrides + # 3. system file: $(brew --prefix)/etc/dev/config.yml — shipped by an + # org's deployment formula (see README "Deploying dev to an org") + # + # Keys: # # plans_repo: d3mlabs/plans # knowledge_repo: d3mlabs/knowledge + # baseline_repo: d3mlabs/knowledge # # `plans_repo` is the org-wide plans repo that `dev plan new --org` / # `dev plan link --org` target. `knowledge_repo` is the org knowledge repo - # dev keeps a machine-local cache of; leaving it unset simply means no org - # learnings sync (dev is public and hardcodes no org content). ENV - # overrides: DEV_PLANS_REPO and DEV_KNOWLEDGE_REPO (matching the - # credentials ENV-first convention). + # dev keeps a machine-local cache of. `baseline_repo` is the repo whose + # `baseline/dependencies.rb` declares the host baseline `dev up` + # converges. Leaving a nilable key unset turns its feature off. class Settings class MissingSettingError < RuntimeError; end - # @return [String] path of the config file settings are read from + # @return [String] path of the user config file (layer 2) attr_reader :config_path - # @param config_path [String, nil] override for tests; defaults to the - # XDG config location - def initialize(config_path: nil) + # @param config_path [String, nil] user file override for tests; + # defaults to the XDG config location + # @param system_config_path [String, nil] system file override for + # tests; defaults to the Homebrew prefix's etc/dev/config.yml + def initialize(config_path: nil, system_config_path: nil) @config_path = config_path || default_config_path + @system_config_path = system_config_path || default_system_config_path end # @return [String] "owner/repo" of the org-wide plans repo - # @raise [MissingSettingError] when unset + # @raise [MissingSettingError] when unset in every layer def plans_repo - from_env = ENV["DEV_PLANS_REPO"] - return from_env if from_env && !from_env.empty? - - value = load_config["plans_repo"] - return value if value && !value.empty? - - raise MissingSettingError, - "no org plans repo configured — add `plans_repo: /` " \ - "to #{@config_path} (or set DEV_PLANS_REPO)." + setting("plans_repo", "DEV_PLANS_REPO") || + raise(MissingSettingError, + "no org plans repo configured — add `plans_repo: /` " \ + "to #{@config_path} (or set DEV_PLANS_REPO).") end # The org knowledge repo the machine cache syncs from. Unset is a @@ -48,26 +55,65 @@ def plans_repo # # @return [String, nil] "owner/repo" (or any git-clonable URL), or nil def knowledge_repo - from_env = ENV["DEV_KNOWLEDGE_REPO"] - return from_env if from_env && !from_env.empty? + setting("knowledge_repo", "DEV_KNOWLEDGE_REPO") + end - value = load_config["knowledge_repo"] - (value && !value.empty?) ? value : nil + # The repo whose baseline/dependencies.rb declares the host baseline. + # Unset is a supported state: no baseline to converge, no drift nag — + # dev stays generic. + # + # @return [String, nil] "owner/repo", or nil + def baseline_repo + setting("baseline_repo", "DEV_BASELINE_REPO") end private + # Resolve one key through the layers: ENV → user file → system file. + # Empty strings count as unset at every layer. + # + # @param key [String] config file key + # @param env_var [String] ENV override name + # @return [String, nil] + def setting(key, env_var) + from_env = ENV[env_var] + return from_env if from_env && !from_env.empty? + + value = layered_config[key] + (value && !value.empty?) ? value : nil + end + # @return [String] def default_config_path config_home = ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config")) File.join(config_home, "dev", "config.yml") end + # The system layer an org deployment formula installs into the Homebrew + # prefix (pkgetc). Prefix from HOMEBREW_PREFIX when set, else the first + # standard install location present on this machine; nil (an empty + # layer) on brewless machines. + # + # @return [String, nil] + def default_system_config_path + prefix = ENV["HOMEBREW_PREFIX"] + prefix = nil if prefix && prefix.empty? + prefix ||= ["/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"].find { |p| Dir.exist?(p) } + prefix && File.join(prefix, "etc", "dev", "config.yml") + end + + # @return [Hash] user keys merged over system keys; missing files are + # empty layers + def layered_config + load_yaml(@system_config_path).merge(load_yaml(@config_path)) + end + + # @param path [String, nil] # @return [Hash] - def load_config - return {} unless File.exist?(@config_path) + def load_yaml(path) + return {} unless path && File.exist?(path) - YAML.safe_load(File.read(@config_path)) || {} + YAML.safe_load(File.read(path)) || {} end end end diff --git a/share/baseline/dependencies.rb b/share/baseline/dependencies.rb deleted file mode 100644 index 9314205..0000000 --- a/share/baseline/dependencies.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -# The host baseline manifest (plans#26, layer 1 of a machine): the -# org-invariant tools every d3mlabs host converges toward, regardless of -# which projects it serves. Shipped inside dev's distribution — upgrading -# dev is what changes the baseline. `dev up` converges it as its first -# step; every other command only warns when it drifts. -# -# Everything here is a Homebrew name and brew converges by name, so there -# is no lockfile — the baseline declares a tool set, not versions. -# -# Deliberately small: project-specific tools belong in each repo's -# dependencies.rb (layer 2), and OS-provided tools (curl, tar) are not -# re-declared — shadowing the system copies buys nothing. -require "dev/deps" - -Dev::Deps.define do - group :baseline do - # Declared even though dev's Homebrew formula pulls git/gh via - # depends_on — the manifest is the verification record, and hosts that - # got dev some other way still converge. - brew "git" - brew "gh" - # dev's Ruby toolchain managers: project rubies via rbenv, per-project - # activation via shadowenv. - brew "rbenv" - brew "shadowenv" - end - - # Deliberately NOT here: the Cursor agent CLI. It is ai-flow's own - # dependency (declared in ai-flow's dependencies.rb as the cursor-cli - # cask) — converging the ai-flow checkout is what makes a box - # agent-capable, so target hosts and plain dev machines never carry - # agent pieces they don't serve. -end diff --git a/test/dev/deps/baseline_test.rb b/test/dev/deps/baseline_test.rb index d86d2de..7f36cb7 100644 --- a/test/dev/deps/baseline_test.rb +++ b/test/dev/deps/baseline_test.rb @@ -31,107 +31,159 @@ def install_all(dependencies) end end - def write_manifest(dir, content = MANIFEST) - path = File.join(dir, "baseline", "dependencies.rb") - FileUtils.mkdir_p(File.dirname(path)) - File.write(path, content) - path + # Stands in for the gh raw-content boundary: serves canned manifest + # content (nil = fetch failure) and records every fetch. + class FakeFetcher + attr_reader :fetches + + def initialize(content) + @content = content + @fetches = [] + end + + def repo_file(owner_repo, path) + @fetches << [owner_repo, path] + @content + end end - def build_baseline(dir, manifest_path, integration: RecordingIntegration.new) - Dev::Deps::Baseline.new( - manifest_path: manifest_path, + def settings_with_baseline_repo(dir, repo) + config_path = File.join(dir, "config.yml") + File.write(config_path, repo ? "baseline_repo: #{repo}\n" : "") + Dev::Settings.new(config_path: config_path, system_config_path: File.join(dir, "no-system.yml")) + end + + def build_baseline(dir, repo: "acme/knowledge", fetcher: FakeFetcher.new(MANIFEST), + integration: RecordingIntegration.new) + saved_env = ENV.delete("DEV_BASELINE_REPO") + baseline = Dev::Deps::Baseline.new( + settings: settings_with_baseline_repo(dir, repo), state_dir: File.join(dir, "state"), + fetcher: fetcher, integrations_factory: -> { { brew: integration } }, ) + ENV["DEV_BASELINE_REPO"] = saved_env if saved_env + baseline + end + + def cache_path(dir) + File.join(dir, "state", "host-baseline", "dependencies.rb") end - test "a never-converged host reports the baseline message" do - Given "a shipped manifest and no stamp on this host" + def stamp_path(dir) + File.join(dir, "state", "host-baseline", "converged-digest") + end + + test "a never-converged host reports the baseline message without fetching" do + Given "a configured baseline repo and no stamp on this host" dir = Dir.mktmpdir("dev-baseline-test-") - baseline = build_baseline(dir, write_manifest(dir)) + fetcher = FakeFetcher.new(MANIFEST) + baseline = build_baseline(dir, fetcher: fetcher) - Expect "the warn-only nag with its remediation" + Expect "the warn-only nag with its remediation, computed offline" baseline.message == "host baseline stale — run `dev up`" + fetcher.fetches.empty? Cleanup FileUtils.rm_rf(dir) end - test "converge installs the manifest's declarations and stamps the digest" do + test "converge_if_stale fetches the manifest, caches it, installs, and stamps" do Given "a stale host" dir = Dir.mktmpdir("dev-baseline-test-") - manifest_path = write_manifest(dir) + fetcher = FakeFetcher.new(MANIFEST) integration = RecordingIntegration.new - baseline = build_baseline(dir, manifest_path, integration: integration) + baseline = build_baseline(dir, fetcher: fetcher, integration: integration) - When "converging" - baseline.converge + When "converging if stale" + converged = baseline.converge_if_stale - Then "the brew dep landed (lockless: no version, constraint as metadata) and the host went quiet" + Then "one fetch of the conventional path; the brew dep landed (lockless: no version); the host went quiet" + converged == true + fetcher.fetches == [["acme/knowledge", "baseline/dependencies.rb"]] integration.installed.map(&:name) == ["git"] integration.installed.fetch(0).integration == :brew integration.installed.fetch(0).version.nil? + File.read(cache_path(dir)) == MANIFEST + File.read(stamp_path(dir)) == Digest::SHA256.hexdigest(MANIFEST) baseline.message.nil? - File.read(File.join(dir, "state", "host-baseline", "converged-digest")) == - Digest::SHA256.file(manifest_path).hexdigest Cleanup FileUtils.rm_rf(dir) end - test "converge_if_stale converges a stale host and reports it did" do - Given "a stale host" + test "converge_if_stale is a no-op install on a warm host" do + Given "a host already converged on the upstream manifest" dir = Dir.mktmpdir("dev-baseline-test-") + build_baseline(dir).converge_if_stale integration = RecordingIntegration.new - baseline = build_baseline(dir, write_manifest(dir), integration: integration) + baseline = build_baseline(dir, integration: integration) When "converging if stale" converged = baseline.converge_if_stale - Then + Then "nothing installed" + converged == false + integration.installed.empty? + + Cleanup + FileUtils.rm_rf(dir) + end + + test "an upstream manifest change (an org baseline bump) makes a converged host stale again" do + Given "a converged host whose org manifest then grows a tool" + dir = Dir.mktmpdir("dev-baseline-test-") + build_baseline(dir).converge_if_stale + changed = MANIFEST.sub("brew \"git\"", "brew \"git\"\n brew \"gh\"") + integration = RecordingIntegration.new + baseline = build_baseline(dir, fetcher: FakeFetcher.new(changed), integration: integration) + + When "the next dev up converges" + converged = baseline.converge_if_stale + + Then "the refreshed cache drives a new converge" converged == true - integration.installed.map(&:name) == ["git"] + integration.installed.map(&:name) == %w[git gh] Cleanup FileUtils.rm_rf(dir) end - test "converge_if_stale is an O(1) no-op on a warm host" do - Given "a host already converged on the shipped manifest" + test "a fetch failure falls back to the cached manifest — offline dev up still converges" do + Given "a cached manifest, a fresh stamp-less host state, and an unreachable repo" dir = Dir.mktmpdir("dev-baseline-test-") - manifest_path = write_manifest(dir) - build_baseline(dir, manifest_path).converge + FileUtils.mkdir_p(File.dirname(cache_path(dir))) + File.write(cache_path(dir), MANIFEST) integration = RecordingIntegration.new - baseline = build_baseline(dir, manifest_path, integration: integration) + baseline = build_baseline(dir, fetcher: FakeFetcher.new(nil), integration: integration) When "converging if stale" - converged = baseline.converge_if_stale + converged = nil + _out, err = capture_io { converged = baseline.converge_if_stale } - Then "nothing installed" - converged == false - integration.installed.empty? + Then "the cached copy converges, with a warning about the failed refresh" + converged == true + integration.installed.map(&:name) == ["git"] + err.include?("could not fetch baseline manifest") Cleanup FileUtils.rm_rf(dir) end - test "a manifest change (a dev upgrade) makes a converged host stale again" do - Given "a converged host whose shipped manifest then changes" + test "a fetch failure with no cache leaves the host nagging, never crashing" do + Given "no cached manifest and an unreachable repo" dir = Dir.mktmpdir("dev-baseline-test-") - manifest_path = write_manifest(dir) - build_baseline(dir, manifest_path).converge - File.write(manifest_path, <<~MANIFEST) - Dev::Deps.define do - group :baseline do - brew "git" - brew "gh" - end - end - MANIFEST - baseline = build_baseline(dir, manifest_path) + integration = RecordingIntegration.new + baseline = build_baseline(dir, fetcher: FakeFetcher.new(nil), integration: integration) - Expect + When "converging if stale" + converged = nil + _out, err = capture_io { converged = baseline.converge_if_stale } + + Then "nothing to converge from, so the nag stays" + converged == false + integration.installed.empty? + err.include?("could not fetch baseline manifest") baseline.message == "host baseline stale — run `dev up`" Cleanup @@ -139,25 +191,22 @@ def build_baseline(dir, manifest_path, integration: RecordingIntegration.new) end test "another host's declarations are filtered out of the converge" do - Given "a manifest gating one group to the OTHER host OS (the darwin-gated agent CLI case)" + Given "a manifest gating one entry to the OTHER host OS (the darwin-gated agent CLI case)" dir = Dir.mktmpdir("dev-baseline-test-") other_host = Dev::Deps.detect_host == "darwin" ? :linux : :darwin - manifest_path = write_manifest(dir, <<~MANIFEST) + manifest = <<~MANIFEST Dev::Deps.define do group :baseline do brew "git" - end - - group :agent, host: :#{other_host} do - brew "cursor-cli", cask: true + brew "cursor-cli", cask: true, host: :#{other_host} end end MANIFEST integration = RecordingIntegration.new - baseline = build_baseline(dir, manifest_path, integration: integration) + baseline = build_baseline(dir, fetcher: FakeFetcher.new(manifest), integration: integration) When "converging" - baseline.converge + baseline.converge_if_stale Then "only this host's entries install" integration.installed.map(&:name) == ["git"] @@ -166,18 +215,20 @@ def build_baseline(dir, manifest_path, integration: RecordingIntegration.new) FileUtils.rm_rf(dir) end - test "a distribution without a shipped manifest is never stale" do - Given "no manifest at all" + test "an unset baseline_repo turns the whole layer off" do + Given "no baseline_repo in settings" dir = Dir.mktmpdir("dev-baseline-test-") + fetcher = FakeFetcher.new(MANIFEST) integration = RecordingIntegration.new - baseline = build_baseline(dir, File.join(dir, "baseline", "dependencies.rb"), integration: integration) + baseline = build_baseline(dir, repo: nil, fetcher: fetcher, integration: integration) When "checking and converging if stale" converged = baseline.converge_if_stale - Then "quiet, and nothing to install" + Then "quiet, no fetch, nothing to install" baseline.message.nil? converged == false + fetcher.fetches.empty? integration.installed.empty? Cleanup @@ -187,28 +238,21 @@ def build_baseline(dir, manifest_path, integration: RecordingIntegration.new) test "converge with the default integrations wiring is host-safe on an empty manifest" do Given "a manifest declaring nothing, and no factory injected (real Registry wiring)" dir = Dir.mktmpdir("dev-baseline-test-") - manifest_path = write_manifest(dir, "# nothing declared\n") - baseline = Dev::Deps::Baseline.new(manifest_path: manifest_path, state_dir: File.join(dir, "state")) + saved_env = ENV.delete("DEV_BASELINE_REPO") + baseline = Dev::Deps::Baseline.new( + settings: settings_with_baseline_repo(dir, "acme/knowledge"), + state_dir: File.join(dir, "state"), + fetcher: FakeFetcher.new("# nothing declared\n"), + ) When "converging through the real integrations table" - baseline.converge + baseline.converge_if_stale Then "no declarations dispatch, and the converge still stamps" - File.exist?(File.join(dir, "state", "host-baseline", "converged-digest")) + File.exist?(stamp_path(dir)) Cleanup + ENV["DEV_BASELINE_REPO"] = saved_env if saved_env FileUtils.rm_rf(dir) end - - test "the shipped baseline manifest exists and default construction resolves it" do - Given "a Baseline built entirely from defaults" - baseline = Dev::Deps::Baseline.new - - Expect "the shipped manifest is part of the distribution" - Dev::Deps::Baseline::SHIPPED_MANIFEST.file? - !baseline.nil? - - Cleanup - nil - end end diff --git a/test/dev/settings_test.rb b/test/dev/settings_test.rb index 99dbbff..7cb4c28 100644 --- a/test/dev/settings_test.rb +++ b/test/dev/settings_test.rb @@ -8,12 +8,32 @@ transform!(RSpock::AST::Transformation) class Dev::SettingsTest < Minitest::Test - test "plans_repo reads from the config file" do - Given "a config file declaring the org plans repo" + # Build Settings with hermetic layer paths: both files live in the temp + # dir, so the machine's real user/system config never leaks into a test. + def build_settings(dir) + Dev::Settings.new( + config_path: File.join(dir, "user", "config.yml"), + system_config_path: File.join(dir, "system", "config.yml"), + ) + end + + def write_user(dir, content) + path = File.join(dir, "user", "config.yml") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + + def write_system(dir, content) + path = File.join(dir, "system", "config.yml") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + + test "plans_repo reads from the user config file" do + Given "a user config file declaring the org plans repo" dir = Dir.mktmpdir("dev-settings-test-") - path = File.join(dir, "config.yml") - File.write(path, "plans_repo: d3mlabs/plans\n") - settings = Dev::Settings.new(config_path: path) + write_user(dir, "plans_repo: d3mlabs/plans\n") + settings = build_settings(dir) Expect settings.plans_repo == "d3mlabs/plans" @@ -25,10 +45,9 @@ class Dev::SettingsTest < Minitest::Test test "DEV_PLANS_REPO overrides the config file" do Given "a config file and an ENV override" dir = Dir.mktmpdir("dev-settings-test-") - path = File.join(dir, "config.yml") - File.write(path, "plans_repo: d3mlabs/plans\n") + write_user(dir, "plans_repo: d3mlabs/plans\n") ENV["DEV_PLANS_REPO"] = "acme/plans" - settings = Dev::Settings.new(config_path: path) + settings = build_settings(dir) Expect settings.plans_repo == "acme/plans" @@ -39,9 +58,9 @@ class Dev::SettingsTest < Minitest::Test end test "an unset plans_repo raises with instructions" do - Given "no config file" + Given "no config file in either layer" dir = Dir.mktmpdir("dev-settings-test-") - settings = Dev::Settings.new(config_path: File.join(dir, "config.yml")) + settings = build_settings(dir) When "reading the plans repo" settings.plans_repo @@ -53,13 +72,42 @@ class Dev::SettingsTest < Minitest::Test FileUtils.rm_rf(dir) end + test "a key falls through to the system config file" do + Given "only the system layer (an org deployment's file) declares the key" + dir = Dir.mktmpdir("dev-settings-test-") + write_system(dir, "plans_repo: d3mlabs/plans\n") + settings = build_settings(dir) + + Expect + settings.plans_repo == "d3mlabs/plans" + + Cleanup + FileUtils.rm_rf(dir) + end + + test "the user file wins over the system file per key, gitconfig-style" do + Given "both layers set plans_repo, and only the system layer sets knowledge_repo" + dir = Dir.mktmpdir("dev-settings-test-") + write_system(dir, "plans_repo: d3mlabs/plans\nknowledge_repo: d3mlabs/knowledge\n") + write_user(dir, "plans_repo: personal/plans\n") + saved_env = ENV.delete("DEV_KNOWLEDGE_REPO") + settings = build_settings(dir) + + Expect "the user's plans_repo wins while the system knowledge_repo still applies" + settings.plans_repo == "personal/plans" + settings.knowledge_repo == "d3mlabs/knowledge" + + Cleanup + ENV["DEV_KNOWLEDGE_REPO"] = saved_env if saved_env + FileUtils.rm_rf(dir) + end + test "knowledge_repo reads from the config file" do Given "a config file declaring the org knowledge repo" dir = Dir.mktmpdir("dev-settings-test-") - path = File.join(dir, "config.yml") - File.write(path, "knowledge_repo: d3mlabs/knowledge\n") + write_user(dir, "knowledge_repo: d3mlabs/knowledge\n") saved_env = ENV.delete("DEV_KNOWLEDGE_REPO") - settings = Dev::Settings.new(config_path: path) + settings = build_settings(dir) Expect settings.knowledge_repo == "d3mlabs/knowledge" @@ -72,11 +120,10 @@ class Dev::SettingsTest < Minitest::Test test "DEV_KNOWLEDGE_REPO overrides the config file" do Given "a config file and an ENV override" dir = Dir.mktmpdir("dev-settings-test-") - path = File.join(dir, "config.yml") - File.write(path, "knowledge_repo: d3mlabs/knowledge\n") + write_user(dir, "knowledge_repo: d3mlabs/knowledge\n") saved_env = ENV["DEV_KNOWLEDGE_REPO"] ENV["DEV_KNOWLEDGE_REPO"] = "acme/knowledge" - settings = Dev::Settings.new(config_path: path) + settings = build_settings(dir) Expect settings.knowledge_repo == "acme/knowledge" @@ -90,7 +137,7 @@ class Dev::SettingsTest < Minitest::Test Given "no config file" dir = Dir.mktmpdir("dev-settings-test-") saved_env = ENV.delete("DEV_KNOWLEDGE_REPO") - settings = Dev::Settings.new(config_path: File.join(dir, "config.yml")) + settings = build_settings(dir) Expect settings.knowledge_repo.nil? @@ -99,4 +146,49 @@ class Dev::SettingsTest < Minitest::Test ENV["DEV_KNOWLEDGE_REPO"] = saved_env if saved_env FileUtils.rm_rf(dir) end + + test "baseline_repo reads from the config file" do + Given "a config file declaring the org baseline repo" + dir = Dir.mktmpdir("dev-settings-test-") + write_user(dir, "baseline_repo: d3mlabs/knowledge\n") + saved_env = ENV.delete("DEV_BASELINE_REPO") + settings = build_settings(dir) + + Expect + settings.baseline_repo == "d3mlabs/knowledge" + + Cleanup + ENV["DEV_BASELINE_REPO"] = saved_env if saved_env + FileUtils.rm_rf(dir) + end + + test "DEV_BASELINE_REPO overrides the config file" do + Given "a config file and an ENV override" + dir = Dir.mktmpdir("dev-settings-test-") + write_user(dir, "baseline_repo: d3mlabs/knowledge\n") + saved_env = ENV["DEV_BASELINE_REPO"] + ENV["DEV_BASELINE_REPO"] = "acme/baseline" + settings = build_settings(dir) + + Expect + settings.baseline_repo == "acme/baseline" + + Cleanup + saved_env ? ENV["DEV_BASELINE_REPO"] = saved_env : ENV.delete("DEV_BASELINE_REPO") + FileUtils.rm_rf(dir) + end + + test "an unset baseline_repo is nil — no host baseline is a supported state" do + Given "no config file" + dir = Dir.mktmpdir("dev-settings-test-") + saved_env = ENV.delete("DEV_BASELINE_REPO") + settings = build_settings(dir) + + Expect + settings.baseline_repo.nil? + + Cleanup + ENV["DEV_BASELINE_REPO"] = saved_env if saved_env + FileUtils.rm_rf(dir) + end end From c05959d7da1677b93173b7723ce6518a3a9f85e2 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 23 Aug 2026 16:48:15 -0400 Subject: [PATCH 05/16] Deployment scheme: split into dev-core + per-org dev formulas The tap now ships dev-core (the org-blank tool) and a slim dev deployment formula (org config + dependency edge). Every org's install becomes `brew install //dev`; individuals install dev-core and hand-write the user config. release.rb rewrites both formulas' url+sha in lockstep. Co-authored-by: Cursor --- README.md | 19 ++++++++++++------ bin/release.rb | 52 ++++++++++++++++++++++++++++++-------------------- 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 9b43c61..a37316e 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ Global CLI tool for d3mlabs projects. Discovers `dev.yml` in your git repos and ## Installation -Install via Homebrew (from the d3mlabs tap). This installs `dev` and shadowenv (for per-project Ruby env in repos that use `dev up`): +Install via Homebrew. Orgs install their deployment formula (tool + org configuration in one command); individuals without an org install the generic `dev-core` and write their own config — see [Org configuration & deployment](#org-configuration--deployment): ```bash -brew tap d3mlabs -brew install d3mlabs/dev +brew install d3mlabs/d3mlabs/dev # d3mlabs (or your org's //dev) +brew install d3mlabs/d3mlabs/dev-core # org-blank tool only ``` ### System dependencies @@ -104,12 +104,19 @@ knowledge_repo: d3mlabs/knowledge # org learnings sync source baseline_repo: d3mlabs/knowledge # repo whose baseline/dependencies.rb is the host baseline ``` -Leaving a nilable key unset turns its feature off (`plans_repo` is only required by `dev plan --org`). Three consumption stories: +Leaving a nilable key unset turns its feature off (`plans_repo` is only required by `dev plan --org`). The tool ships as two kinds of formula (the Debian core-package/config-package split, applied to a tap): -- **Org deployment (recommended):** the org's Homebrew formula is the deployment artifact — it packages the generic tool *and* the org's identity. It installs the org `config.yml` into the prefix's `etc/dev/` (pkgetc — brew preserves locally-modified etc files across upgrades) and declares `depends_on` for the tools dev itself shells out to. One command installs both: `brew install d3mlabs/d3mlabs/dev` is the reference deployment. An org that doesn't want to own a full build formula can publish a thin config-only formula instead (e.g. `acme/tools/acme-dev`: payload is `etc/dev/config.yml`, plus `depends_on "d3mlabs/d3mlabs/dev"`), so `brew install acme/tools/acme-dev` deploys the upstream tool with acme's identity. -- **Individual / handrolled:** install dev from any org's tap (or source) and write the user file yourself with the keys above — no formula involvement, useful for personal machines or orgs without a tap. +- **`d3mlabs/d3mlabs/dev-core`** — the generic tool, org-blank: the build payload plus the tools dev itself shells out to (git, gh, ruby, rbenv, ruby-build, shadowenv). It ships no org content. +- **A deployment formula named `dev` in each org's tap** — `depends_on "d3mlabs/d3mlabs/dev-core"` plus the org's `config.yml` installed into the prefix's `etc/dev/` (pkgetc — brew preserves locally-modified etc files across upgrades). Formula names only need to be unique within a tap, so every org's install is the same shape: `brew install d3mlabs/d3mlabs/dev` is the reference deployment, and an adopting org publishes `acme/tap/dev` with identical structure and its own keys. + +Three consumption stories: + +- **Org deployment (recommended):** `brew install //dev` — one command installs tool + identity, and the org evolves its config by shipping a new deployment formula revision. +- **Individual / handrolled:** `brew install d3mlabs/d3mlabs/dev-core`, then write the user file yourself with the keys above — no org involvement, useful for personal machines or orgs without a tap. - **CI / fleet:** set the ENV vars in the pipeline or MDM profile — no files needed, and they override both file layers. +Installs predating the split (when `dev` was a monolithic tool+config formula) migrate with a hard cut: `brew uninstall dev && brew install d3mlabs/d3mlabs/dev`. + ## Usage From anywhere under a git repo that has a `dev.yml` at its root: diff --git a/bin/release.rb b/bin/release.rb index ea8263a..3bf9160 100755 --- a/bin/release.rb +++ b/bin/release.rb @@ -36,7 +36,13 @@ DEV_ROOT = Pathname.new(File.expand_path("..", __dir__)) FORMULA_REPO = DEV_ROOT.join("..", "homebrew-d3mlabs") -FORMULA_PATH = FORMULA_REPO.join("Formula", "dev.rb") +# Two formulas version in lockstep off the same release tarball: dev-core +# (the generic tool) and dev (the d3mlabs deployment: org config + a +# dependency on dev-core). +FORMULA_PATHS = [ + FORMULA_REPO.join("Formula", "dev-core.rb"), + FORMULA_REPO.join("Formula", "dev.rb"), +].freeze VERSION_FILE = DEV_ROOT.join("VERSION") GEMFILE_LOCK = DEV_ROOT.join("Gemfile.lock") TARBALL_URL = "https://github.com/d3mlabs/dev/archive/refs/tags/v%s.tar.gz" @@ -214,28 +220,32 @@ def compute_sha256(version) end def update_formula(version, sha) - abort "Homebrew tap not found at #{FORMULA_REPO}" unless FORMULA_PATH.exist? - - # Read as UTF-8 explicitly: the formula has non-ASCII bytes (e.g. an em-dash - # in a comment), and when release.rb runs under a non-UTF-8 locale (such as a - # piped, login-less subshell) Ruby's default external encoding is US-ASCII, - # which makes the gsub! below raise "invalid byte sequence in US-ASCII". - formula = FORMULA_PATH.read(encoding: "UTF-8") - - # Update the package url + its sha256 together, anchored to the github archive - # url. The formula also carries one `sha256` line per vendored-gem `resource`; - # those are immutable per gem version and must NOT change on a dev release. - # (A prior gsub over every `sha256 "..."` replaced the resource checksums too, - # with the tarball sha, silently corrupting them — clean installs then failed - # resource verification.) Matching the url+sha as a pair keeps it surgical. - pattern = %r{(url "https://github\.com/d3mlabs/dev/archive/refs/tags/v)[\d.]+(\.tar\.gz"\n\s+sha256 ")[0-9a-f]+(")} - updated = formula.sub(pattern) { "#{$1}#{version}#{$2}#{sha}#{$3}" } - abort "Could not find the package url+sha256 to update in #{FORMULA_PATH}" if updated == formula - - FORMULA_PATH.write(updated) + FORMULA_PATHS.each do |formula_path| + abort "Homebrew formula not found at #{formula_path}" unless formula_path.exist? + + # Read as UTF-8 explicitly: the formulas have non-ASCII bytes (e.g. an + # em-dash in a comment), and when release.rb runs under a non-UTF-8 locale + # (such as a piped, login-less subshell) Ruby's default external encoding + # is US-ASCII, which makes the sub below raise "invalid byte sequence in + # US-ASCII". + formula = formula_path.read(encoding: "UTF-8") + + # Update the package url + its sha256 together, anchored to the github + # archive url. dev-core also carries one `sha256` line per vendored-gem + # `resource`; those are immutable per gem version and must NOT change on a + # dev release. (A prior gsub over every `sha256 "..."` replaced the + # resource checksums too, with the tarball sha, silently corrupting them — + # clean installs then failed resource verification.) Matching the url+sha + # as a pair keeps it surgical. + pattern = %r{(url "https://github\.com/d3mlabs/dev/archive/refs/tags/v)[\d.]+(\.tar\.gz"\n\s+sha256 ")[0-9a-f]+(")} + updated = formula.sub(pattern) { "#{$1}#{version}#{$2}#{sha}#{$3}" } + abort "Could not find the package url+sha256 to update in #{formula_path}" if updated == formula + + formula_path.write(updated) + end Dir.chdir(FORMULA_REPO) do - run!("git", "add", "Formula/dev.rb") + run!("git", "add", *FORMULA_PATHS.map { |path| path.relative_path_from(FORMULA_REPO).to_s }) run!("git", "commit", "-m", "dev: #{version}") run!("git", "push", "origin", "main") end From 96ff3d66ad9de092615b95b834c27f1bd4cf7026 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 23 Aug 2026 20:37:33 -0400 Subject: [PATCH 06/16] Replace the host baseline layer with the Brewfile contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brew converges brew: the org tooling list lives in the deployment formula's Brewfile (etc/dev/Brewfile) and dev up only triggers brew — throttled brew update, scoped upgrade of the self-named deployment_formula, then brew bundle install. Deletes the baseline fetch/cache/stamp/nag machinery; settings drop baseline_repo for deployment_formula. Co-authored-by: Cursor --- README.md | 29 +-- bin/dev | 16 +- lib/dev/deps/baseline.rb | 222 -------------------- lib/dev/host/converge.rb | 194 +++++++++++++++++ lib/dev/settings.rb | 33 +-- src/dev/builtins/up_command.rb | 15 +- src/dev/dependency_service.rb | 16 +- test/dev/bin_dev_test.rb | 4 +- test/dev/builtins/up_command_test.rb | 24 +-- test/dev/dependency_service_test.rb | 47 +---- test/dev/deps/baseline_test.rb | 258 ----------------------- test/dev/host/converge_test.rb | 266 ++++++++++++++++++++++++ test/dev/learnings/accessor_test.rb | 20 +- test/dev/learnings/synchronizer_test.rb | 18 +- test/dev/settings_test.rb | 46 ++-- 15 files changed, 590 insertions(+), 618 deletions(-) delete mode 100644 lib/dev/deps/baseline.rb create mode 100644 lib/dev/host/converge.rb delete mode 100644 test/dev/deps/baseline_test.rb create mode 100644 test/dev/host/converge_test.rb diff --git a/README.md b/README.md index a37316e..3bc3e5f 100644 --- a/README.md +++ b/README.md @@ -92,27 +92,27 @@ A gem repo typically stops at rungs 1–2 (commands + a pinned Ruby, hand-writte dev's source hardcodes no org content — every org-specific fact enters through **settings**, resolved per key with gitconfig-style layering (`Dev::Settings`): -1. **ENV var** — `DEV_PLANS_REPO`, `DEV_KNOWLEDGE_REPO`, `DEV_BASELINE_REPO`. Highest precedence. +1. **ENV var** — `DEV_PLANS_REPO`, `DEV_KNOWLEDGE_REPO`, `DEV_DEPLOYMENT_FORMULA`. Highest precedence. 2. **User file** — `~/.config/dev/config.yml` (or `$XDG_CONFIG_HOME/dev/config.yml`). 3. **System file** — `$(brew --prefix)/etc/dev/config.yml`, shipped by an org's deployment formula. Missing files are empty layers; a key set in the user file wins over the system file. The keys: ```yaml -plans_repo: d3mlabs/plans # org-wide plans repo (dev plan --org) -knowledge_repo: d3mlabs/knowledge # org learnings sync source -baseline_repo: d3mlabs/knowledge # repo whose baseline/dependencies.rb is the host baseline +plans_repo: d3mlabs/plans # org-wide plans repo (dev plan --org) +knowledge_repo: d3mlabs/knowledge # org learnings sync source +deployment_formula: d3mlabs/d3mlabs/dev # the formula `dev up` self-updates (the deployment names itself) ``` -Leaving a nilable key unset turns its feature off (`plans_repo` is only required by `dev plan --org`). The tool ships as two kinds of formula (the Debian core-package/config-package split, applied to a tap): +Leaving a nilable key unset turns its feature off (`plans_repo` is only required by `dev plan --org`). Manage the user file with `dev config` (`list` / `get ` / `set `) instead of hand-editing YAML. The tool ships as two kinds of formula (the Debian core-package/config-package split, applied to a tap): - **`d3mlabs/d3mlabs/dev-core`** — the generic tool, org-blank: the build payload plus the tools dev itself shells out to (git, gh, ruby, rbenv, ruby-build, shadowenv). It ships no org content. -- **A deployment formula named `dev` in each org's tap** — `depends_on "d3mlabs/d3mlabs/dev-core"` plus the org's `config.yml` installed into the prefix's `etc/dev/` (pkgetc — brew preserves locally-modified etc files across upgrades). Formula names only need to be unique within a tap, so every org's install is the same shape: `brew install d3mlabs/d3mlabs/dev` is the reference deployment, and an adopting org publishes `acme/tap/dev` with identical structure and its own keys. +- **A deployment formula named `dev` in each org's tap** — `depends_on "d3mlabs/d3mlabs/dev-core"` plus the org's payload installed into the prefix's `etc/dev/` (pkgetc — brew preserves locally-modified etc files across upgrades): a `config.yml` with the org's keys (including `deployment_formula`, its own name — that's how `dev up` knows what to upgrade) and an optional `Brewfile` with the org's host tooling (see [Host tooling: the Brewfile contract](#host-tooling-the-brewfile-contract)). Formula names only need to be unique within a tap, so every org's install is the same shape: `brew install d3mlabs/d3mlabs/dev` is the reference deployment, and an adopting org publishes `acme/tap/dev` with identical structure and its own payload. Three consumption stories: -- **Org deployment (recommended):** `brew install //dev` — one command installs tool + identity, and the org evolves its config by shipping a new deployment formula revision. -- **Individual / handrolled:** `brew install d3mlabs/d3mlabs/dev-core`, then write the user file yourself with the keys above — no org involvement, useful for personal machines or orgs without a tap. +- **Org deployment (recommended):** `brew install //dev` — one command installs tool + identity, and the org evolves its config and tooling list by shipping a new deployment formula revision; every machine picks it up on its next `dev up`. +- **Individual / handrolled:** `brew install d3mlabs/d3mlabs/dev-core`, then `dev config set ` for the keys you need — no org involvement, useful for personal machines or orgs without a tap. No Brewfile means the host tooling step self-skips. - **CI / fleet:** set the ENV vars in the pipeline or MDM profile — no files needed, and they override both file layers. Installs predating the split (when `dev` was a monolithic tool+config formula) migrate with a hard cut: `brew uninstall dev && brew install d3mlabs/d3mlabs/dev`. @@ -296,11 +296,16 @@ Two YAML lockfiles, same format, two purposes: Both files are generated by `dev update-deps` and committed to git. Never edit them by hand. -### Host baseline +### Host tooling: the Brewfile contract -Alongside per-project dependencies, dev converges a **host baseline** — the org-invariant tools every host needs regardless of which projects it serves (e.g. git, gh, rbenv, shadowenv). dev ships the machinery only; the manifest lives in the org's repo named by the `baseline_repo` setting (see [Org configuration & deployment](#org-configuration--deployment)), at the conventional path `baseline/dependencies.rb`. Unset means no baseline — dev stays generic. Tools that belong to one piece of software stay in that repo's own `dependencies.rb`; org-wide dev tooling (an editor-class agent CLI, say) belongs in the baseline, not in every project manifest. +Alongside per-project dependencies, an org converges **host tooling** — the org-invariant tools every developer machine needs regardless of which projects it serves (an editor-class agent CLI, say). The principle is **brew converges brew**: dev never re-implements host tooling convergence, it only *triggers* brew's — the same way it triggers bundler for gems. -The baseline is deliberately **lockless**: every entry is a Homebrew name and brew converges by name, so a lockfile would record versions nothing enforces. dev keeps a machine-local cache of the manifest (`$XDG_DATA_HOME/dev/host-baseline/dependencies.rb`, next to the `converged-digest` stamp). `dev up` refreshes the cache from the org repo, then converges when the cached digest drifted from the stamp (a silent single-digest no-op on warm hosts); a failed fetch falls back to the cached copy, so offline `dev up` still works. `dev up` also works **outside any project**: with no `dev.yml` found it converges the host layer only, which is the fresh-box bootstrap (`brew install /dev` → `dev up` → ready). Every other command surfaces a stale baseline as a warn-only nag ("host baseline stale — run `dev up`") computed from the cache alone — O(1), never network, advisory even in CI. `Dev::Deps::Baseline` owns the layer. +- **The list lives in the deployment formula's `Brewfile`**, installed into `$(brew --prefix)/etc/dev/` beside `config.yml`. Convention, not configuration: file present means `dev up` runs `brew bundle install` against it; absent (tapless individual, CI) means the step self-skips. No settings key, no fetch, no cache — the file is local, delivered by packaging. +- **Disjoint sets:** `dev-core`'s `depends_on` answers "what does the tool need" (git, gh, ruby, rbenv, ruby-build, shadowenv); the Brewfile answers "what does the org want beyond that". No entry ever appears in both; if dev drops a dep the org still wants, that fact migrates to the Brewfile. Tools that belong to one piece of software stay in that repo's own `dependencies.rb`. +- **Private taps:** Brewfiles natively support `tap` entries, including private taps over authenticated git — sensitive tooling goes in a private tap the Brewfile references. `gh auth login` must precede `dev up` in that case (the failure mode is brew's own clear git-auth error). +- **Trust model:** a Brewfile is brew-evaluated Ruby DSL, so converging it executes org-authored code — the same trust already granted by installing the org's deployment formula. dev adds no new trust surface: the file lives in the brew prefix at a fixed path, never a user-supplied one, and brew's tap-trust gate covers formulas from untrusted taps. + +On every `dev up`, before project provisioning, `Dev::Host::Converge` runs the host layer: a **throttled `brew update`** (daily stamp under `$XDG_DATA_HOME/dev/host/`), a **scoped `brew upgrade` of the `deployment_formula`** the deployment named in its own `config.yml` (falling back to `dev-core` for tapless individuals; skipped entirely for source checkouts — never a blanket `brew upgrade` of unrelated packages), then **`brew bundle install`** against the Brewfile when one exists (not throttled — an upgrade may land a new Brewfile the same run must converge). The whole layer is warn-only: offline machines and failed upgrades never block project provisioning. Upgrading is symmetric: the org edits one line in its tap's Brewfile (or ships a config change via formula revision) and every machine converges on its next `dev up` — no brew vocabulary required, though a direct `brew upgrade` keeps working for users who prefer it. ### dependencies.rb @@ -403,7 +408,7 @@ Custom integrations implement `Dev::Deps::Integration` (with `install_all(pins, - **`dev update-deps`** — resolve constraints from `dependencies.rb`, write lockfiles (recording the manifest digest for the staleness check). Always available (no need to define in `dev.yml`). - **`dev install-deps`** — install locked deps handled on the host (gh releases, steam apps) into their version-keyed install dirs, filtered to the detected env and host OS. Finishes by refreshing agent skill links (see [Agent skills & org learnings](#agent-skills--org-learnings)). -- **`dev up`** — first converges the host baseline when stale (see [Host baseline](#host-baseline)), then auto-installs all deps from lockfiles (build group first), then runs the project's `up:` command from `dev.yml` if defined. On success, stamps the installed lockfile digest (see `dev check`). Finishes by refreshing agent skill links, like `install-deps`. Also valid outside any project: converges the host baseline only — the fresh-box bootstrap. +- **`dev up`** — first converges the host layer (throttled self-update + org Brewfile, see [Host tooling: the Brewfile contract](#host-tooling-the-brewfile-contract)), then auto-installs all deps from lockfiles (build group first), then runs the project's `up:` command from `dev.yml` if defined. On success, stamps the installed lockfile digest (see `dev check`). Finishes by refreshing agent skill links, like `install-deps`. Also valid outside any project: converges the host layer only — the fresh-box bootstrap (`brew install //dev` → `dev up` → ready). - **`dev check`** — report dependency-state staleness explicitly: `dependencies.rb` vs lockfiles (digest recorded by `update-deps`), and lockfiles vs the per-machine installed stamp (`~/.dev/state//installed-digest`, written after a fully-successful `up`/`install-deps`). The same two O(1) checks run at every command start — warning on workstations, erroring in CI. - **`dev deps path `** — print the absolute path of a locked artifact (e.g. `dev deps path ficsit SML LinuxServer`, or `dev deps path xcode` for the pinned DEVELOPER_DIR) so scripts don't reconstruct cache keys or layout conventions. - **`dev cred get `** — resolve a credential through the provider chain (ENV → keychain → file → prompt) and print it. A non-interactive miss errors with `gh secret set` guidance. Mirrors `dev deps path` for shell consumers (e.g. a staging sync). Global: works without a `dev.yml`. diff --git a/bin/dev b/bin/dev index 49b82f8..1c1a4a8 100755 --- a/bin/dev +++ b/bin/dev @@ -59,16 +59,14 @@ end begin Dev::Runner.new(ui: ui).run(ARGV) rescue Dev::DevYamlNotFoundError - # `dev up` stays valid outside any project: it converges the host baseline - # (layer 1) only, which IS the fresh-box bootstrap — install dev, `dev up`, - # ready. Everything else still needs a project. + # `dev up` stays valid outside any project: it converges the host layer + # only (throttled self-update + org Brewfile), which IS the fresh-box + # bootstrap — install dev, `dev up`, ready. Everything else still needs a + # project. if ARGV.first == "up" - require "dev/deps/baseline" - if Dev::Deps::Baseline.new.converge_if_stale - puts "dev: host baseline converged." - else - puts "dev: host baseline already converged." - end + require "dev/host/converge" + Dev::Host::Converge.new.run + puts "dev: host layer converged." puts "dev: no dev.yml here — run dev up inside a project to provision it too." exit 0 end diff --git a/lib/dev/deps/baseline.rb b/lib/dev/deps/baseline.rb deleted file mode 100644 index 3f77816..0000000 --- a/lib/dev/deps/baseline.rb +++ /dev/null @@ -1,222 +0,0 @@ -# frozen_string_literal: true - -require "digest" -require "fileutils" -require "open3" -require "pathname" -require_relative "../deps" -require_relative "../settings" -require_relative "cache" -require_relative "dependency" -require_relative "registry" - -module Dev - module Deps - # The host baseline layer (plans#26): the org-invariant tools every host - # needs (e.g. git, gh, rbenv, shadowenv, the agent CLI), declared in a - # manifest that lives in the org's repo named by the `baseline_repo` - # setting — dev is public and ships no org content, so the org names its - # manifest source and dev supplies the machinery. Unset means no - # baseline: the whole layer is off. - # - # Deliberately lockless: every baseline entry is a Homebrew name, and - # brew installs by name — the baseline is *convergence toward a tool - # set*, not version pinning, so a resolved lockfile would record versions - # nothing enforces. - # - # The manifest is cached machine-locally (next to the stamp, under XDG - # data): `dev up` refreshes the cache from the org repo and converges - # when the cached digest drifts from the per-host stamp; a failed fetch - # falls back to the cached copy, so offline `dev up` still works. Every - # other command surfaces staleness as a warn-only O(1) nag (cached - # digest vs stamp, never network) via DependencyService#guard! — the - # baseline never blocks, even in CI. - class Baseline - STALE_MESSAGE = "host baseline stale — run `dev up`" - - STAMP_FILE = "converged-digest" - - CACHE_FILE = "dependencies.rb" - - # Conventional manifest location inside the org's baseline repo. - MANIFEST_REPO_PATH = "baseline/dependencies.rb" - - # Fetches a file's raw content from a repo's default branch through - # the gh CLI — the same boundary shape as Plan::GithubIssues#repo_file. - # nil for any failure: the caller decides the fallback. - class GhFetcher - # @param owner_repo [String] "owner/repo" - # @param path [String] file path inside the repo - # @return [String, nil] the file content, or nil when unavailable - def repo_file(owner_repo, path) - out, _err, status = Open3.capture3( - "gh", "api", "-H", "Accept: application/vnd.github.raw", "repos/#{owner_repo}/contents/#{path}" - ) - status.success? ? out : nil - rescue SystemCallError - nil - end - end - - # @param settings [Dev::Settings] source of the baseline_repo key - # @param state_dir [Pathname, String] host state root — XDG data home - # like the learnings cache, NOT ~/.dev/state: project stamps are - # per-checkout working state; this is host-layer state - # @param fetcher [#repo_file] raw-content boundary, injectable so - # tests never touch the network - # @param integrations_factory [#call] () → {Symbol => Integration}; - # the install seam, injectable so tests never touch the host - def initialize(settings: Dev::Settings.new, state_dir: default_state_dir, - fetcher: GhFetcher.new, integrations_factory: nil) - @settings = settings - @state_dir = Pathname(state_dir) - @fetcher = fetcher - @integrations_factory = integrations_factory || - -> { Registry.host_integrations(project_root: cache_path.dirname, cache: Cache.new) } - end - - # The warn-only nag for a stale host, nil when converged. Computed - # from the machine-local cache only — O(1), never network. A machine - # without a configured baseline repo has nothing to converge and is - # never stale. - # - # @return [String, nil] - def message - stale? ? STALE_MESSAGE : nil - end - - # The converge `dev up` runs first: refresh the cached manifest from - # the org repo (falling back to the cached copy when the fetch fails, - # so offline runs still work), then install and stamp when the cache - # digest drifted from the per-host stamp. One digest comparison on a - # warm host after the refresh. - # - # @return [Boolean] whether a converge ran - def converge_if_stale - return false unless baseline_repo - - refresh_cache - return false unless stale? && cache_path.file? - - converge - true - end - - private - - # @return [String, nil] the org repo named in settings, or nil (layer off) - def baseline_repo - @settings.baseline_repo - end - - # Fetch the manifest fresh and rewrite the cache; a failed fetch keeps - # the cached copy (with a warning), so a fresh box with no cache stays - # stale and keeps nagging. - # - # @return [void] - def refresh_cache - content = @fetcher.repo_file(baseline_repo, MANIFEST_REPO_PATH) - if content - FileUtils.mkdir_p(cache_path.dirname) - cache_path.write(content) - else - detail = cache_path.file? ? " — converging from the cached copy" : "" - $stderr.puts "dev: warning: could not fetch baseline manifest from #{baseline_repo}#{detail}" - end - end - - # Install the cached manifest's declarations (filtered to this host - # OS) and stamp the host converged. Stamping only happens after a - # fully-successful install, so a crashed run keeps nagging. - # - # @return [void] - def converge - integrations = @integrations_factory.call - host_dependencies.group_by(&:integration).each do |type, dependencies| - integrations.fetch(type).install_all(dependencies) - end - stamp! - end - - # A host is stale when a baseline repo is configured and this host's - # stamp doesn't match the cached manifest — including the fresh-box - # case where neither cache nor stamp exists yet. - # - # @return [Boolean] - def stale? - return false unless baseline_repo - - cache_digest != stamped_digest || stamped_digest.nil? - end - - # The manifest's declarations as installable Dependencies, minus other - # hosts' entries (e.g. the darwin-gated agent CLI on a Linux target - # host). No resolve step: the constraint hash IS the install metadata - # — brew converges by name, so there is no resolved version to carry. - # - # @return [Array] - def host_dependencies - host = Deps.detect_host - declarations.filter_map do |declaration| - next if declaration.host && declaration.host.to_s != host - - Dependency.new( - name: declaration.name, - integration: declaration.integration, - group: declaration.group, - version: nil, - hash: nil, - metadata: declaration.constraint, - ) - end - end - - # @return [Array] the cached manifest's declarations - def declarations - Deps.reset! - Kernel.load(cache_path.to_s) - Deps.last_config&.declarations || [] - end - - # @return [String, nil] SHA-256 hex of the cached manifest, nil without one - def cache_digest - cache_path.file? ? Digest::SHA256.file(cache_path.to_s).hexdigest : nil - end - - # @return [String, nil] the last converged manifest digest on this host - def stamped_digest - stamp_path.file? ? stamp_path.read.strip : nil - end - - # Record the just-converged manifest digest. - # - # @return [void] - def stamp! - FileUtils.mkdir_p(stamp_path.dirname) - stamp_path.write(cache_digest) - end - - # The machine-local copy of the org manifest, next to the stamp. The - # cache is what the nag and the converge read; the org repo is only - # touched by refresh_cache. - # - # @return [Pathname] - def cache_path - @state_dir / "host-baseline" / CACHE_FILE - end - - # Fixed host-singular stamp path: the baseline is per-host state. - # - # @return [Pathname] - def stamp_path - @state_dir / "host-baseline" / STAMP_FILE - end - - # @return [String] $XDG_DATA_HOME/dev (the learnings-cache precedent) - def default_state_dir - data_home = ENV.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local", "share")) - File.join(data_home, "dev") - end - end - end -end diff --git a/lib/dev/host/converge.rb b/lib/dev/host/converge.rb new file mode 100644 index 0000000..1c87ba1 --- /dev/null +++ b/lib/dev/host/converge.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require "fileutils" +require "open3" +require "pathname" +require "time" +require_relative "../settings" + +module Dev + module Host + # The host layer of `dev up` (plans#26): brew converges brew — dev never + # re-implements host tooling convergence, it only *triggers* brew's, the + # same way it triggers bundler for gems. Three steps, all brew-executed: + # + # 1. throttled `brew update` (daily stamp), so warm runs stay snappy + # 2. scoped `brew upgrade` of the org's deployment formula — the + # deployment names itself via the `deployment_formula` setting; the + # formula revision delivers dev itself plus the org's config.yml and + # Brewfile into the prefix's etc/dev/ + # 3. `brew bundle install` against the etc/dev/Brewfile when one exists + # — the org's tooling list beyond the tool's own dependencies + # + # The Brewfile presence is convention, not configuration: no file (tapless + # individual, CI) means the step self-skips. The whole layer is warn-only: + # a failed self-update or tooling converge never blocks project + # provisioning (offline `dev up` still works). + class Converge + # One self-update check per day keeps warm `dev up` fast; the Brewfile + # converge is NOT throttled — an upgrade in step 2 may land a new + # Brewfile that step 3 must converge in the same run. + UPDATE_INTERVAL_SECONDS = 24 * 60 * 60 + + UPDATE_STAMP_FILE = "brew-update-stamp" + + # A brew formula token: bare name or tap-qualified org/repo/name. The + # deployment_formula value crosses a settings boundary into a brew + # invocation, so validate its shape — a leading `-` must never reach + # brew as a flag. + FORMULA_PATTERN = %r{\A[A-Za-z0-9][\w.+@-]*(?:/[A-Za-z0-9][\w.-]*){0,2}\z} + + # The generic tool's own formula — the self-update target for tapless + # individuals who installed dev-core directly (no deployment). + CORE_FORMULA = "dev-core" + + # Runs brew commands. Split by what the caller needs: `run` streams + # output to the terminal (installs the user should see), `quiet?` only + # answers success (existence checks). + class Executor + # @param cmd [Array] argv, never a shell string + # @return [Boolean] + def run(*cmd) + !!system(*cmd) + end + + # @param cmd [Array] argv, never a shell string + # @return [Boolean] + def quiet?(*cmd) + _out, _err, status = Open3.capture3(*cmd) + status.success? + rescue SystemCallError + false + end + end + + # @param settings [Dev::Settings] source of deployment_formula and the + # system config location (whose directory also holds the Brewfile) + # @param state_dir [Pathname, String] host state root (XDG data home, + # the learnings-cache precedent) for the update-throttle stamp + # @param executor [#run, #quiet?] brew invocation seam, injectable so + # tests never call brew + # @param clock [#call] () → Time, injectable for throttle tests + def initialize(settings: Dev::Settings.new, state_dir: default_state_dir, + executor: Executor.new, clock: -> { Time.now }) + @settings = settings + @state_dir = Pathname(state_dir) + @executor = executor + @clock = clock + end + + # The whole host layer, in order: deployment sanity warning, throttled + # self-update, Brewfile converge. A no-op on brewless machines (no + # prefix means no system config location, no Brewfile, nothing to + # upgrade). + # + # @return [void] + def run + return unless system_config_path + + warn_unnamed_deployment + self_update if update_due? + converge_brewfile if brewfile_path.file? + end + + private + + # An etc config.yml is evidence of a deployment, and a deployment must + # name itself or org updates silently stop flowing (self-update has no + # target). Resolved-value check: a user-file or ENV override counts as + # named. Hand-rollers with only a Brewfile in etc never see this. + # + # @return [void] + def warn_unnamed_deployment + return unless File.exist?(system_config_path.to_s) + return if @settings.deployment_formula + + $stderr.puts "dev: warning: a deployment config exists at #{system_config_path} but no " \ + "deployment_formula is set — dev cannot self-update. Fix with: " \ + "`dev config set deployment_formula //`." + end + + # `brew update` then a scoped upgrade of exactly one formula — never a + # blanket `brew upgrade`; the user's unrelated packages are not dev's + # business. Stamps only after a successful update so an offline run + # retries next time. + # + # @return [void] + def self_update + unless @executor.run("brew", "update", "--quiet") + $stderr.puts "dev: warning: brew update failed — skipping the dev self-update check." + return + end + + target = upgrade_target + if target && !@executor.run("brew", "upgrade", "--quiet", target) + $stderr.puts "dev: warning: brew upgrade #{target} failed." + end + stamp_update! + end + + # The one formula the self-update may touch: the org's self-named + # deployment, or dev-core for tapless individuals, or nothing (source + # checkouts). + # + # @return [String, nil] + def upgrade_target + formula = @settings.deployment_formula + if formula + return formula if FORMULA_PATTERN.match?(formula) + + $stderr.puts "dev: warning: ignoring malformed deployment_formula #{formula.inspect}." + return nil + end + + CORE_FORMULA if @executor.quiet?("brew", "list", "--formula", "--versions", CORE_FORMULA) + end + + # The org tooling list, converged by brew's own mechanism. brew bundle + # upgrades outdated entries by default, so org tools stay current. + # + # @return [void] + def converge_brewfile + return if @executor.run("brew", "bundle", "install", "--file=#{brewfile_path}") + + $stderr.puts "dev: warning: brew bundle failed for #{brewfile_path} — host tooling may be incomplete." + end + + # @return [Boolean] whether the daily self-update check is due + def update_due? + !update_stamp_path.file? || + @clock.call - update_stamp_path.mtime >= UPDATE_INTERVAL_SECONDS + end + + # @return [void] + def stamp_update! + FileUtils.mkdir_p(update_stamp_path.dirname) + FileUtils.touch(update_stamp_path) + end + + # @return [Pathname] + def update_stamp_path + @state_dir / "host" / UPDATE_STAMP_FILE + end + + # The org Brewfile lives beside the system config.yml — both are the + # deployment formula's payload into the prefix's etc/dev/. + # + # @return [Pathname] + def brewfile_path + Pathname(system_config_path.to_s).dirname / "Brewfile" + end + + # @return [String, nil] nil on brewless machines (empty layer) + def system_config_path + @settings.system_config_path + end + + # @return [String] $XDG_DATA_HOME/dev (the learnings-cache precedent) + def default_state_dir + data_home = ENV.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local", "share")) + File.join(data_home, "dev") + end + end + end +end diff --git a/lib/dev/settings.rb b/lib/dev/settings.rb index 833aaed..ff01a1b 100644 --- a/lib/dev/settings.rb +++ b/lib/dev/settings.rb @@ -7,8 +7,8 @@ module Dev # a generic dev install (dev is public and hardcodes no org content). # Per-key resolution is layered, gitconfig-style: # - # 1. ENV var (DEV_PLANS_REPO, DEV_KNOWLEDGE_REPO, DEV_BASELINE_REPO) — - # CI/fleet management, no files needed + # 1. ENV var (DEV_PLANS_REPO, DEV_KNOWLEDGE_REPO, + # DEV_DEPLOYMENT_FORMULA) — CI/fleet management, no files needed # 2. user file: ~/.config/dev/config.yml (or $XDG_CONFIG_HOME/dev/…) — # individuals and per-user overrides # 3. system file: $(brew --prefix)/etc/dev/config.yml — shipped by an @@ -18,19 +18,27 @@ module Dev # # plans_repo: d3mlabs/plans # knowledge_repo: d3mlabs/knowledge - # baseline_repo: d3mlabs/knowledge + # deployment_formula: d3mlabs/d3mlabs/dev # # `plans_repo` is the org-wide plans repo that `dev plan new --org` / # `dev plan link --org` target. `knowledge_repo` is the org knowledge repo - # dev keeps a machine-local cache of. `baseline_repo` is the repo whose - # `baseline/dependencies.rb` declares the host baseline `dev up` - # converges. Leaving a nilable key unset turns its feature off. + # dev keeps a machine-local cache of. `deployment_formula` is the brew + # formula `dev up`'s self-update upgrades — the deployment names itself + # (see Dev::Host::Converge). Leaving a nilable key unset turns its + # feature off. class Settings class MissingSettingError < RuntimeError; end # @return [String] path of the user config file (layer 2) attr_reader :config_path + # The system layer's location (layer 3); nil on brewless machines. Its + # directory is also where a deployment ships the org Brewfile, so the + # host converge reads this to find both. + # + # @return [String, nil] + attr_reader :system_config_path + # @param config_path [String, nil] user file override for tests; # defaults to the XDG config location # @param system_config_path [String, nil] system file override for @@ -58,13 +66,14 @@ def knowledge_repo setting("knowledge_repo", "DEV_KNOWLEDGE_REPO") end - # The repo whose baseline/dependencies.rb declares the host baseline. - # Unset is a supported state: no baseline to converge, no drift nag — - # dev stays generic. + # The brew formula the `dev up` self-update upgrades — the org + # deployment's own name, shipped in the config.yml it installs. Unset is + # a supported state: no deployment to self-update (tapless individuals + # fall back to dev-core, source checkouts skip entirely). # - # @return [String, nil] "owner/repo", or nil - def baseline_repo - setting("baseline_repo", "DEV_BASELINE_REPO") + # @return [String, nil] e.g. "d3mlabs/d3mlabs/dev", or nil + def deployment_formula + setting("deployment_formula", "DEV_DEPLOYMENT_FORMULA") end private diff --git a/src/dev/builtins/up_command.rb b/src/dev/builtins/up_command.rb index 02b4818..55818fc 100644 --- a/src/dev/builtins/up_command.rb +++ b/src/dev/builtins/up_command.rb @@ -4,7 +4,7 @@ require "dev/cd" require "dev/command" require "dev/credentials" -require "dev/deps/baseline" +require "dev/host/converge" module Dev module Builtins @@ -21,15 +21,15 @@ class UpCommand < BuiltinCommand params( install_deps_command: InstallDepsCommand, hook_installer: Dev::Cd::HookInstaller, - baseline: Dev::Deps::Baseline, + host_converge: Dev::Host::Converge, ).void end def initialize(install_deps_command:, hook_installer: Dev::Cd::HookInstaller.new, - baseline: Dev::Deps::Baseline.new) + host_converge: Dev::Host::Converge.new) super() @install_deps_command = T.let(install_deps_command, InstallDepsCommand) @hook_installer = T.let(hook_installer, Dev::Cd::HookInstaller) - @baseline = T.let(baseline, Dev::Deps::Baseline) + @host_converge = T.let(host_converge, Dev::Host::Converge) end sig { override.returns(String) } @@ -49,9 +49,10 @@ def stamps? = true sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) - # The host baseline converges before project provisioning: project - # installs may lean on baseline tools (gh, rbenv). O(1) when warm. - @baseline.converge_if_stale + # The host layer converges before project provisioning (throttled + # self-update + org Brewfile): project installs may lean on host + # tools (gh, rbenv). Warn-only — never blocks the project. + @host_converge.run provision_build_credentials(context) @hook_installer.ensure_installed @install_deps_command.call(args:, context:) diff --git a/src/dev/dependency_service.rb b/src/dev/dependency_service.rb index 049825c..b90c8f4 100644 --- a/src/dev/dependency_service.rb +++ b/src/dev/dependency_service.rb @@ -2,7 +2,6 @@ # frozen_string_literal: true require "dev/deps" -require "dev/deps/baseline" require "dev/deps/staleness" module Dev @@ -10,7 +9,7 @@ module Dev # guard policy before a command runs, the current staleness messages, and # the installed-stamp write (lock!) after a stamping command succeeds. # Deliberately narrow — Lockfile and Deps::Cache consumers stay direct for - # now; this fronts only Staleness (plus the host baseline's warn-only nag). + # now; this fronts only Staleness. class DependencyService extend T::Sig @@ -18,10 +17,9 @@ class DependencyService # pipeline bug, not a reminder. class StaleDependencyStateError < RuntimeError; end - sig { params(staleness: Dev::Deps::Staleness, baseline: Dev::Deps::Baseline).void } - def initialize(staleness:, baseline: Dev::Deps::Baseline.new) + sig { params(staleness: Dev::Deps::Staleness).void } + def initialize(staleness:) @staleness = T.let(staleness, Dev::Deps::Staleness) - @baseline = T.let(baseline, Dev::Deps::Baseline) end # All current staleness messages (see Dev::Deps::Staleness#messages). @@ -33,18 +31,12 @@ def messages end # Two O(1) digest checks at every command start: manifest vs lockfile, - # lockfile vs installed stamp. Warn on workstations; error in CI. The - # host baseline gets its own check with softer semantics: always a - # warning, even in CI — a drifted host tool set is never a reason to - # block a project command (plans#26). + # lockfile vs installed stamp. Warn on workstations; error in CI. # # @return [void] # @raise [StaleDependencyStateError] in CI, when any project layer is stale sig { void } def guard! - baseline_message = @baseline.message - $stderr.puts "dev: warning: #{baseline_message}" if baseline_message - stale_messages = messages return if stale_messages.empty? diff --git a/test/dev/bin_dev_test.rb b/test/dev/bin_dev_test.rb index 6d6f475..dff0cce 100644 --- a/test/dev/bin_dev_test.rb +++ b/test/dev/bin_dev_test.rb @@ -42,8 +42,8 @@ class Dev::BinDevTest < Minitest::Test } # A project command with no global/no-project fallback: `up` outside a - # project now converges the host baseline (a real host mutation), so it - # can never be spawned from tests. + # project converges the host layer (a real host mutation via brew), so + # it can never be spawned from tests. When "running a project command (bare dev renders the global usage instead) there" _out, err, status = Open3.capture3(hostile, "sh", BIN_DEV, "test", chdir: dir) diff --git a/test/dev/builtins/up_command_test.rb b/test/dev/builtins/up_command_test.rb index 5262349..21a3e6a 100644 --- a/test/dev/builtins/up_command_test.rb +++ b/test/dev/builtins/up_command_test.rb @@ -28,7 +28,7 @@ class Dev::Builtins::UpCommandTest < Minitest::Test hook_installer = typed_mock(Dev::Cd::HookInstaller) hook_installer.expects(:ensure_installed).once.returns(:already_present) command = Dev::Builtins::UpCommand.new( - install_deps_command: install_deps, hook_installer: hook_installer, baseline: fresh_baseline, + install_deps_command: install_deps, hook_installer: hook_installer, host_converge: quiet_host_converge, ) context = build_context @@ -39,22 +39,22 @@ class Dev::Builtins::UpCommandTest < Minitest::Test 1 * install_deps.call(args: ["-v"], context: context) end - test "call converges a stale host baseline as its first step" do - Given "an up command whose baseline expects the staleness-gated converge" - baseline = typed_mock(Dev::Deps::Baseline) - baseline.expects(:converge_if_stale).once.returns(true) + test "call converges the host layer as its first step" do + Given "an up command whose host converge expects its run" + host_converge = typed_mock(Dev::Host::Converge) + host_converge.expects(:run).once install_deps = typed_mock(Dev::Builtins::InstallDepsCommand) install_deps.stubs(:call) hook_installer = typed_mock(Dev::Cd::HookInstaller) hook_installer.stubs(:ensure_installed).returns(:already_present) command = Dev::Builtins::UpCommand.new( - install_deps_command: install_deps, hook_installer: hook_installer, baseline: baseline, + install_deps_command: install_deps, hook_installer: hook_installer, host_converge: host_converge, ) When "running up" command.call(args: [], context: build_context) - Then "the expectation on the baseline holds" + Then "the expectation on the host converge holds" true end @@ -102,14 +102,14 @@ def build_command hook_installer = typed_mock(Dev::Cd::HookInstaller) hook_installer.stubs(:ensure_installed).returns(:already_present) Dev::Builtins::UpCommand.new( - install_deps_command: install_deps, hook_installer: hook_installer, baseline: fresh_baseline, + install_deps_command: install_deps, hook_installer: hook_installer, host_converge: quiet_host_converge, ) end - def fresh_baseline - baseline = typed_mock(Dev::Deps::Baseline) - baseline.stubs(:converge_if_stale).returns(false) - baseline + def quiet_host_converge + host_converge = typed_mock(Dev::Host::Converge) + host_converge.stubs(:run) + host_converge end def container_config(build_args:) diff --git a/test/dev/dependency_service_test.rb b/test/dev/dependency_service_test.rb index 17d5c5b..4f43039 100644 --- a/test/dev/dependency_service_test.rb +++ b/test/dev/dependency_service_test.rb @@ -63,44 +63,11 @@ class Dev::DependencyServiceTest < Minitest::Test raises Dev::DependencyService::StaleDependencyStateError end - test "guard! surfaces a stale host baseline as a warning, never a block — even in CI" do - Given "a stale baseline with an otherwise in-sync project, in CI" - service = build_service(messages: [], baseline_message: "host baseline stale — run `dev up`") - Dev::Deps.stubs(:detect_env).returns("ci") - old_stderr = $stderr - $stderr = StringIO.new - - When "guarding" - service.guard! - - Then "the nag is advisory and the command proceeds" - $stderr.string.include?("warning: host baseline stale — run `dev up`") - - Cleanup - $stderr = old_stderr - end - - test "guard! stays quiet about a fresh baseline" do - Given "an in-sync project and baseline" - service = build_service(messages: [], baseline_message: nil) - old_stderr = $stderr - $stderr = StringIO.new - - When "guarding" - service.guard! - - Then - $stderr.string.empty? - - Cleanup - $stderr = old_stderr - end - test "lock! records the installed stamp" do Given "a staleness expecting its stamp write" staleness = typed_mock(Dev::Deps::Staleness) staleness.expects(:stamp_installed!).once - service = Dev::DependencyService.new(staleness: staleness, baseline: quiet_baseline) + service = Dev::DependencyService.new(staleness: staleness) When "locking" service.lock! @@ -111,17 +78,9 @@ class Dev::DependencyServiceTest < Minitest::Test private - def build_service(messages:, baseline_message: nil) + def build_service(messages:) staleness = typed_mock(Dev::Deps::Staleness) staleness.stubs(:messages).returns(messages) - baseline = typed_mock(Dev::Deps::Baseline) - baseline.stubs(:message).returns(baseline_message) - Dev::DependencyService.new(staleness: staleness, baseline: baseline) - end - - def quiet_baseline - baseline = typed_mock(Dev::Deps::Baseline) - baseline.stubs(:message).returns(nil) - baseline + Dev::DependencyService.new(staleness: staleness) end end diff --git a/test/dev/deps/baseline_test.rb b/test/dev/deps/baseline_test.rb deleted file mode 100644 index 7f36cb7..0000000 --- a/test/dev/deps/baseline_test.rb +++ /dev/null @@ -1,258 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require "test_helper" -require "dev/deps/baseline" -require "digest" -require "fileutils" -require "tmpdir" - -transform!(RSpock::AST::Transformation) -class Dev::Deps::BaselineTest < Minitest::Test - MANIFEST = <<~MANIFEST - Dev::Deps.define do - group :baseline do - brew "git" - end - end - MANIFEST - - # Stands in for BrewIntegration at the factory seam: records what would - # install, touches nothing on the host. - class RecordingIntegration - attr_reader :installed - - def initialize - @installed = [] - end - - def install_all(dependencies) - @installed.concat(dependencies) - end - end - - # Stands in for the gh raw-content boundary: serves canned manifest - # content (nil = fetch failure) and records every fetch. - class FakeFetcher - attr_reader :fetches - - def initialize(content) - @content = content - @fetches = [] - end - - def repo_file(owner_repo, path) - @fetches << [owner_repo, path] - @content - end - end - - def settings_with_baseline_repo(dir, repo) - config_path = File.join(dir, "config.yml") - File.write(config_path, repo ? "baseline_repo: #{repo}\n" : "") - Dev::Settings.new(config_path: config_path, system_config_path: File.join(dir, "no-system.yml")) - end - - def build_baseline(dir, repo: "acme/knowledge", fetcher: FakeFetcher.new(MANIFEST), - integration: RecordingIntegration.new) - saved_env = ENV.delete("DEV_BASELINE_REPO") - baseline = Dev::Deps::Baseline.new( - settings: settings_with_baseline_repo(dir, repo), - state_dir: File.join(dir, "state"), - fetcher: fetcher, - integrations_factory: -> { { brew: integration } }, - ) - ENV["DEV_BASELINE_REPO"] = saved_env if saved_env - baseline - end - - def cache_path(dir) - File.join(dir, "state", "host-baseline", "dependencies.rb") - end - - def stamp_path(dir) - File.join(dir, "state", "host-baseline", "converged-digest") - end - - test "a never-converged host reports the baseline message without fetching" do - Given "a configured baseline repo and no stamp on this host" - dir = Dir.mktmpdir("dev-baseline-test-") - fetcher = FakeFetcher.new(MANIFEST) - baseline = build_baseline(dir, fetcher: fetcher) - - Expect "the warn-only nag with its remediation, computed offline" - baseline.message == "host baseline stale — run `dev up`" - fetcher.fetches.empty? - - Cleanup - FileUtils.rm_rf(dir) - end - - test "converge_if_stale fetches the manifest, caches it, installs, and stamps" do - Given "a stale host" - dir = Dir.mktmpdir("dev-baseline-test-") - fetcher = FakeFetcher.new(MANIFEST) - integration = RecordingIntegration.new - baseline = build_baseline(dir, fetcher: fetcher, integration: integration) - - When "converging if stale" - converged = baseline.converge_if_stale - - Then "one fetch of the conventional path; the brew dep landed (lockless: no version); the host went quiet" - converged == true - fetcher.fetches == [["acme/knowledge", "baseline/dependencies.rb"]] - integration.installed.map(&:name) == ["git"] - integration.installed.fetch(0).integration == :brew - integration.installed.fetch(0).version.nil? - File.read(cache_path(dir)) == MANIFEST - File.read(stamp_path(dir)) == Digest::SHA256.hexdigest(MANIFEST) - baseline.message.nil? - - Cleanup - FileUtils.rm_rf(dir) - end - - test "converge_if_stale is a no-op install on a warm host" do - Given "a host already converged on the upstream manifest" - dir = Dir.mktmpdir("dev-baseline-test-") - build_baseline(dir).converge_if_stale - integration = RecordingIntegration.new - baseline = build_baseline(dir, integration: integration) - - When "converging if stale" - converged = baseline.converge_if_stale - - Then "nothing installed" - converged == false - integration.installed.empty? - - Cleanup - FileUtils.rm_rf(dir) - end - - test "an upstream manifest change (an org baseline bump) makes a converged host stale again" do - Given "a converged host whose org manifest then grows a tool" - dir = Dir.mktmpdir("dev-baseline-test-") - build_baseline(dir).converge_if_stale - changed = MANIFEST.sub("brew \"git\"", "brew \"git\"\n brew \"gh\"") - integration = RecordingIntegration.new - baseline = build_baseline(dir, fetcher: FakeFetcher.new(changed), integration: integration) - - When "the next dev up converges" - converged = baseline.converge_if_stale - - Then "the refreshed cache drives a new converge" - converged == true - integration.installed.map(&:name) == %w[git gh] - - Cleanup - FileUtils.rm_rf(dir) - end - - test "a fetch failure falls back to the cached manifest — offline dev up still converges" do - Given "a cached manifest, a fresh stamp-less host state, and an unreachable repo" - dir = Dir.mktmpdir("dev-baseline-test-") - FileUtils.mkdir_p(File.dirname(cache_path(dir))) - File.write(cache_path(dir), MANIFEST) - integration = RecordingIntegration.new - baseline = build_baseline(dir, fetcher: FakeFetcher.new(nil), integration: integration) - - When "converging if stale" - converged = nil - _out, err = capture_io { converged = baseline.converge_if_stale } - - Then "the cached copy converges, with a warning about the failed refresh" - converged == true - integration.installed.map(&:name) == ["git"] - err.include?("could not fetch baseline manifest") - - Cleanup - FileUtils.rm_rf(dir) - end - - test "a fetch failure with no cache leaves the host nagging, never crashing" do - Given "no cached manifest and an unreachable repo" - dir = Dir.mktmpdir("dev-baseline-test-") - integration = RecordingIntegration.new - baseline = build_baseline(dir, fetcher: FakeFetcher.new(nil), integration: integration) - - When "converging if stale" - converged = nil - _out, err = capture_io { converged = baseline.converge_if_stale } - - Then "nothing to converge from, so the nag stays" - converged == false - integration.installed.empty? - err.include?("could not fetch baseline manifest") - baseline.message == "host baseline stale — run `dev up`" - - Cleanup - FileUtils.rm_rf(dir) - end - - test "another host's declarations are filtered out of the converge" do - Given "a manifest gating one entry to the OTHER host OS (the darwin-gated agent CLI case)" - dir = Dir.mktmpdir("dev-baseline-test-") - other_host = Dev::Deps.detect_host == "darwin" ? :linux : :darwin - manifest = <<~MANIFEST - Dev::Deps.define do - group :baseline do - brew "git" - brew "cursor-cli", cask: true, host: :#{other_host} - end - end - MANIFEST - integration = RecordingIntegration.new - baseline = build_baseline(dir, fetcher: FakeFetcher.new(manifest), integration: integration) - - When "converging" - baseline.converge_if_stale - - Then "only this host's entries install" - integration.installed.map(&:name) == ["git"] - - Cleanup - FileUtils.rm_rf(dir) - end - - test "an unset baseline_repo turns the whole layer off" do - Given "no baseline_repo in settings" - dir = Dir.mktmpdir("dev-baseline-test-") - fetcher = FakeFetcher.new(MANIFEST) - integration = RecordingIntegration.new - baseline = build_baseline(dir, repo: nil, fetcher: fetcher, integration: integration) - - When "checking and converging if stale" - converged = baseline.converge_if_stale - - Then "quiet, no fetch, nothing to install" - baseline.message.nil? - converged == false - fetcher.fetches.empty? - integration.installed.empty? - - Cleanup - FileUtils.rm_rf(dir) - end - - test "converge with the default integrations wiring is host-safe on an empty manifest" do - Given "a manifest declaring nothing, and no factory injected (real Registry wiring)" - dir = Dir.mktmpdir("dev-baseline-test-") - saved_env = ENV.delete("DEV_BASELINE_REPO") - baseline = Dev::Deps::Baseline.new( - settings: settings_with_baseline_repo(dir, "acme/knowledge"), - state_dir: File.join(dir, "state"), - fetcher: FakeFetcher.new("# nothing declared\n"), - ) - - When "converging through the real integrations table" - baseline.converge_if_stale - - Then "no declarations dispatch, and the converge still stamps" - File.exist?(stamp_path(dir)) - - Cleanup - ENV["DEV_BASELINE_REPO"] = saved_env if saved_env - FileUtils.rm_rf(dir) - end -end diff --git a/test/dev/host/converge_test.rb b/test/dev/host/converge_test.rb new file mode 100644 index 0000000..6673de8 --- /dev/null +++ b/test/dev/host/converge_test.rb @@ -0,0 +1,266 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/host/converge" +require "dev/settings" +require "fileutils" +require "stringio" +require "tmpdir" + +transform!(RSpock::AST::Transformation) +class Dev::Host::ConvergeTest < Minitest::Test + # Records every brew invocation instead of running it — the executor is + # the true boundary; everything else (settings layers, Brewfile, stamp) + # uses real files in temp dirs. + class RecordingExecutor + attr_reader :commands + + def initialize(run_result: true, quiet_result: false) + @commands = [] + @run_result = run_result + @quiet_result = quiet_result + end + + def run(*cmd) + @commands << cmd + @run_result + end + + def quiet?(*cmd) + @commands << cmd + @quiet_result + end + end + + test "run is a no-op on a brewless machine (no system config location)" do + Given "settings that resolve no brew prefix" + dir = Dir.mktmpdir("dev-host-converge-test-") + executor = RecordingExecutor.new + converge = build_converge(dir, executor: executor) + converge.instance_variable_get(:@settings).stubs(:system_config_path).returns(nil) + + When "converging" + converge.run + + Then "brew is never invoked" + executor.commands.empty? + + Cleanup + FileUtils.rm_rf(dir) + end + + test "first run performs the throttled self-update and stamps, but has nothing to upgrade" do + Given "no deployment config, no Brewfile, dev-core not brew-installed" + dir = Dir.mktmpdir("dev-host-converge-test-") + executor = RecordingExecutor.new(quiet_result: false) + converge = build_converge(dir, executor: executor) + + When "converging" + converge.run + + Then "brew update + the dev-core check ran, nothing upgraded or bundled, and the throttle stamp exists" + executor.commands == [ + ["brew", "update", "--quiet"], + ["brew", "list", "--formula", "--versions", "dev-core"], + ] + File.exist?(File.join(dir, "state", "host", "brew-update-stamp")) + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a named deployment upgrades exactly that formula" do + Given "a system config naming the deployment" + dir = Dir.mktmpdir("dev-host-converge-test-") + write_system_config(dir, "deployment_formula: d3mlabs/d3mlabs/dev\n") + executor = RecordingExecutor.new + converge = build_converge(dir, executor: executor) + + When "converging" + stderr = capture_stderr { converge.run } + + Then "the scoped upgrade targets the self-named formula, with no warning" + executor.commands.include?(["brew", "upgrade", "--quiet", "d3mlabs/d3mlabs/dev"]) + stderr.empty? + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a malformed deployment_formula never reaches brew" do + Given "a hostile value that would parse as a brew flag" + dir = Dir.mktmpdir("dev-host-converge-test-") + write_system_config(dir, 'deployment_formula: "--force evil"' + "\n") + executor = RecordingExecutor.new + converge = build_converge(dir, executor: executor) + + When "converging" + stderr = capture_stderr { converge.run } + + Then "no upgrade is attempted and the rejection is warned" + executor.commands.none? { |cmd| cmd[0..1] == ["brew", "upgrade"] } + stderr.include?("malformed deployment_formula") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "an unset key falls back to dev-core when it is brew-installed" do + Given "no deployment config, dev-core installed" + dir = Dir.mktmpdir("dev-host-converge-test-") + executor = RecordingExecutor.new(quiet_result: true) + converge = build_converge(dir, executor: executor) + + When "converging" + converge.run + + Then "the tapless individual's tool self-updates" + executor.commands.include?(["brew", "upgrade", "--quiet", "dev-core"]) + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a warm rerun inside the throttle window skips the self-update but still converges the Brewfile" do + Given "a fresh throttle stamp and an org Brewfile" + dir = Dir.mktmpdir("dev-host-converge-test-") + stamp = File.join(dir, "state", "host", "brew-update-stamp") + FileUtils.mkdir_p(File.dirname(stamp)) + FileUtils.touch(stamp) + brewfile = write_brewfile(dir, %(cask "cursor-cli"\n)) + executor = RecordingExecutor.new + converge = build_converge(dir, executor: executor) + + When "converging" + converge.run + + Then "no update or upgrade, but the Brewfile converge is never throttled" + executor.commands == [["brew", "bundle", "install", "--file=#{brewfile}"]] + + Cleanup + FileUtils.rm_rf(dir) + end + + test "an expired throttle stamp re-runs the self-update" do + Given "a stamp older than the update interval" + dir = Dir.mktmpdir("dev-host-converge-test-") + stamp = File.join(dir, "state", "host", "brew-update-stamp") + FileUtils.mkdir_p(File.dirname(stamp)) + FileUtils.touch(stamp) + late_clock = -> { Time.now + Dev::Host::Converge::UPDATE_INTERVAL_SECONDS + 1 } + executor = RecordingExecutor.new + converge = build_converge(dir, executor: executor, clock: late_clock) + + When "converging a day later" + converge.run + + Then "brew update ran again" + executor.commands.first == ["brew", "update", "--quiet"] + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a failed brew update warns and skips the upgrade without stamping, so the next run retries" do + Given "an offline machine (every streamed brew command fails)" + dir = Dir.mktmpdir("dev-host-converge-test-") + write_system_config(dir, "deployment_formula: d3mlabs/d3mlabs/dev\n") + executor = RecordingExecutor.new(run_result: false) + converge = build_converge(dir, executor: executor) + + When "converging" + stderr = capture_stderr { converge.run } + + Then "no upgrade was attempted, the failure is a warning, and no stamp was written" + executor.commands.none? { |cmd| cmd[0..1] == ["brew", "upgrade"] } + stderr.include?("brew update failed") + !File.exist?(File.join(dir, "state", "host", "brew-update-stamp")) + + Cleanup + FileUtils.rm_rf(dir) + end + + test "an etc config.yml without a resolvable deployment_formula warns with the remedy" do + Given "a deployment config that forgot to name itself" + dir = Dir.mktmpdir("dev-host-converge-test-") + write_system_config(dir, "plans_repo: acme/plans\n") + converge = build_converge(dir, executor: RecordingExecutor.new) + + When "converging" + stderr = capture_stderr { converge.run } + + Then "the warning names the failure and the one-command fix" + stderr.include?("no deployment_formula is set") + stderr.include?("dev config set deployment_formula") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "the unnamed-deployment warning is silenced by a higher layer naming it" do + Given "a keyless system config but a user file naming the deployment" + dir = Dir.mktmpdir("dev-host-converge-test-") + write_system_config(dir, "plans_repo: acme/plans\n") + write_user_config(dir, "deployment_formula: acme/tap/dev\n") + executor = RecordingExecutor.new + converge = build_converge(dir, executor: executor) + + When "converging" + stderr = capture_stderr { converge.run } + + Then "no warning, and the user-layer target is upgraded" + stderr.empty? + executor.commands.include?(["brew", "upgrade", "--quiet", "acme/tap/dev"]) + + Cleanup + FileUtils.rm_rf(dir) + end + + private + + # Hermetic converge: settings layers, throttle stamp, and Brewfile all + # live under the test's temp dir; only the executor is faked. + def build_converge(dir, executor:, clock: -> { Time.now }) + settings = Dev::Settings.new( + config_path: File.join(dir, "user", "config.yml"), + system_config_path: File.join(dir, "etc", "config.yml"), + ) + Dev::Host::Converge.new( + settings: settings, + state_dir: File.join(dir, "state"), + executor: executor, + clock: clock, + ) + end + + def write_system_config(dir, content) + path = File.join(dir, "etc", "config.yml") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + + def write_user_config(dir, content) + path = File.join(dir, "user", "config.yml") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + + # @return [String] the Brewfile path (beside the system config, as a + # deployment ships it) + def write_brewfile(dir, content) + path = File.join(dir, "etc", "Brewfile") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + path + end + + def capture_stderr + old_stderr = $stderr + $stderr = StringIO.new + yield + $stderr.string + ensure + $stderr = old_stderr + end +end diff --git a/test/dev/learnings/accessor_test.rb b/test/dev/learnings/accessor_test.rb index d39a5b3..9afee1f 100644 --- a/test/dev/learnings/accessor_test.rb +++ b/test/dev/learnings/accessor_test.rb @@ -34,7 +34,7 @@ def build_env(dir, project_root: :default) source = build_source_repo(dir) config = File.join(dir, "config.yml") File.write(config, "knowledge_repo: #{source}\n") - settings = Dev::Settings.new(config_path: config) + settings = hermetic_settings(dir) cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache")) # The fixture cache lives under the real temp dir; the tmpdir override # keeps the installer's ephemeral-source guard out of these tests' way. @@ -50,6 +50,16 @@ def build_env(dir, project_root: :default) [accessor, cache, project, synchronizer, gem_skill_linker] end + # Settings with both file layers pinned inside the temp dir — the + # machine's real system config (installed by a deployment formula) must + # never leak a knowledge_repo into these tests. + def hermetic_settings(dir) + Dev::Settings.new( + config_path: File.join(dir, "config.yml"), + system_config_path: File.join(dir, "system-config.yml"), + ) + end + def build_source_repo(dir) source = File.join(dir, "knowledge") FileUtils.mkdir_p(File.join(source, "skills", "srp")) @@ -112,9 +122,7 @@ def backdate_cache(cache, seconds) Given "an accessor over empty settings" dir = Dir.mktmpdir("dev-learnings-acc-test-") saved_env = ENV.delete("DEV_KNOWLEDGE_REPO") - accessor = Dev::Learnings::Accessor.new( - project_root: dir, settings: Dev::Settings.new(config_path: File.join(dir, "config.yml")), - ) + accessor = Dev::Learnings::Accessor.new(project_root: dir, settings: hermetic_settings(dir)) out = StringIO.new When "running dev learnings status" @@ -291,9 +299,7 @@ def backdate_cache(cache, seconds) Given "an accessor over empty settings" dir = Dir.mktmpdir("dev-learnings-acc-test-") saved_env = ENV.delete("DEV_KNOWLEDGE_REPO") - accessor = Dev::Learnings::Accessor.new( - project_root: dir, settings: Dev::Settings.new(config_path: File.join(dir, "config.yml")), - ) + accessor = Dev::Learnings::Accessor.new(project_root: dir, settings: hermetic_settings(dir)) When "running dev learnings invariants" accessor.run(["invariants"], out: StringIO.new) diff --git a/test/dev/learnings/synchronizer_test.rb b/test/dev/learnings/synchronizer_test.rb index 4cea113..f37d2b0 100644 --- a/test/dev/learnings/synchronizer_test.rb +++ b/test/dev/learnings/synchronizer_test.rb @@ -18,7 +18,7 @@ def build_env(dir, refresh_floor: 0) source = build_source_repo(dir) config = File.join(dir, "config.yml") File.write(config, "knowledge_repo: #{source}\n") - settings = Dev::Settings.new(config_path: config) + settings = hermetic_settings(dir) cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"), refresh_floor: refresh_floor) # The fixture cache lives under the real temp dir; the tmpdir override # keeps the installer's ephemeral-source guard out of these tests' way. @@ -45,6 +45,16 @@ def commit_all(source, message) "commit", "-qm", message, exception: true) end + # Settings with both file layers pinned inside the temp dir — the + # machine's real system config (installed by a deployment formula) must + # never leak a knowledge_repo into these tests. + def hermetic_settings(dir) + Dev::Settings.new( + config_path: File.join(dir, "config.yml"), + system_config_path: File.join(dir, "system-config.yml"), + ) + end + test "sync! refreshes the cache, links org skills, renders machine-side, and links the project" do Given "a configured synchronizer with an empty cache" dir = Dir.mktmpdir("dev-learnings-sync-test-") @@ -127,7 +137,7 @@ def commit_all(source, message) dir = Dir.mktmpdir("dev-learnings-sync-test-") config = File.join(dir, "config.yml") File.write(config, "knowledge_repo: d3mlabs/knowledge\n") - settings = Dev::Settings.new(config_path: config) + settings = hermetic_settings(dir) When "constructing through the factory" synchronizer = Dev::Learnings::Synchronizer.for(settings: settings) @@ -143,7 +153,7 @@ def commit_all(source, message) Given "settings without a knowledge repo" dir = Dir.mktmpdir("dev-learnings-sync-test-") saved_env = ENV.delete("DEV_KNOWLEDGE_REPO") - settings = Dev::Settings.new(config_path: File.join(dir, "config.yml")) + settings = hermetic_settings(dir) installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp")) synchronizer = Dev::Learnings::Synchronizer.for(settings: settings, skill_installer: installer) project = Pathname(dir) / "repo" @@ -166,7 +176,7 @@ def commit_all(source, message) Given "the null synchronizer of an unconfigured machine" dir = Dir.mktmpdir("dev-learnings-sync-test-") saved_env = ENV.delete("DEV_KNOWLEDGE_REPO") - settings = Dev::Settings.new(config_path: File.join(dir, "config.yml")) + settings = hermetic_settings(dir) synchronizer = Dev::Learnings::Synchronizer.for(settings: settings) When "forcing a sync" diff --git a/test/dev/settings_test.rb b/test/dev/settings_test.rb index 7cb4c28..449a389 100644 --- a/test/dev/settings_test.rb +++ b/test/dev/settings_test.rb @@ -147,48 +147,60 @@ def write_system(dir, content) FileUtils.rm_rf(dir) end - test "baseline_repo reads from the config file" do - Given "a config file declaring the org baseline repo" + test "deployment_formula reads from the system config file (the deployment names itself)" do + Given "a system config declaring the deployment's own formula" dir = Dir.mktmpdir("dev-settings-test-") - write_user(dir, "baseline_repo: d3mlabs/knowledge\n") - saved_env = ENV.delete("DEV_BASELINE_REPO") + write_system(dir, "deployment_formula: d3mlabs/d3mlabs/dev\n") + saved_env = ENV.delete("DEV_DEPLOYMENT_FORMULA") settings = build_settings(dir) Expect - settings.baseline_repo == "d3mlabs/knowledge" + settings.deployment_formula == "d3mlabs/d3mlabs/dev" Cleanup - ENV["DEV_BASELINE_REPO"] = saved_env if saved_env + ENV["DEV_DEPLOYMENT_FORMULA"] = saved_env if saved_env FileUtils.rm_rf(dir) end - test "DEV_BASELINE_REPO overrides the config file" do - Given "a config file and an ENV override" + test "DEV_DEPLOYMENT_FORMULA overrides the config files" do + Given "a system config and an ENV override" dir = Dir.mktmpdir("dev-settings-test-") - write_user(dir, "baseline_repo: d3mlabs/knowledge\n") - saved_env = ENV["DEV_BASELINE_REPO"] - ENV["DEV_BASELINE_REPO"] = "acme/baseline" + write_system(dir, "deployment_formula: d3mlabs/d3mlabs/dev\n") + saved_env = ENV["DEV_DEPLOYMENT_FORMULA"] + ENV["DEV_DEPLOYMENT_FORMULA"] = "acme/tap/dev" settings = build_settings(dir) Expect - settings.baseline_repo == "acme/baseline" + settings.deployment_formula == "acme/tap/dev" Cleanup - saved_env ? ENV["DEV_BASELINE_REPO"] = saved_env : ENV.delete("DEV_BASELINE_REPO") + saved_env ? ENV["DEV_DEPLOYMENT_FORMULA"] = saved_env : ENV.delete("DEV_DEPLOYMENT_FORMULA") FileUtils.rm_rf(dir) end - test "an unset baseline_repo is nil — no host baseline is a supported state" do + test "an unset deployment_formula is nil — no deployment to self-update is a supported state" do Given "no config file" dir = Dir.mktmpdir("dev-settings-test-") - saved_env = ENV.delete("DEV_BASELINE_REPO") + saved_env = ENV.delete("DEV_DEPLOYMENT_FORMULA") + settings = build_settings(dir) + + Expect + settings.deployment_formula.nil? + + Cleanup + ENV["DEV_DEPLOYMENT_FORMULA"] = saved_env if saved_env + FileUtils.rm_rf(dir) + end + + test "system_config_path is exposed for the host converge to find the deployment payload" do + Given "hermetic settings" + dir = Dir.mktmpdir("dev-settings-test-") settings = build_settings(dir) Expect - settings.baseline_repo.nil? + settings.system_config_path == File.join(dir, "system", "config.yml") Cleanup - ENV["DEV_BASELINE_REPO"] = saved_env if saved_env FileUtils.rm_rf(dir) end end From 50b08d4a968f42f4cb47c083890865c690816be4 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 23 Aug 2026 20:43:54 -0400 Subject: [PATCH 07/16] Add dev config: tool-guided settings management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config list / get / set over the layered settings, with the source layer shown gitconfig --show-origin style. Known-keys only, from the Settings::KNOWN_KEYS registry — the one list both the command and the resolver read. set merge-writes the user file as plain string-keyed YAML. Global command, dispatched before any dev.yml lookup. Co-authored-by: Cursor --- README.md | 3 +- lib/dev/config_accessor.rb | 99 ++++++++++++++++ lib/dev/settings.rb | 48 ++++++++ src/dev/builtins.rb | 1 + src/dev/builtins/config_command.rb | 37 ++++++ src/dev/global_dispatch.rb | 10 +- src/dev/runner.rb | 1 + test/dev/config_accessor_test.rb | 182 +++++++++++++++++++++++++++++ test/dev/global_dispatch_test.rb | 28 +++++ 9 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 lib/dev/config_accessor.rb create mode 100644 src/dev/builtins/config_command.rb create mode 100644 test/dev/config_accessor_test.rb diff --git a/README.md b/README.md index 3bc3e5f..4c24074 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ dev # List all available commands The tool walks up from your current directory until it finds a git repo root (directory containing `.git`), then looks for `dev.yml` there. If found, it parses the commands and executes the `run` string for your chosen subcommand. -A few builtins are global and work from **any** directory, no `dev.yml` needed: `dev cd` (host-global navigation), `dev clone` (host-global checkout creation), `dev cred` (host-global credentials), and `dev plan` (workspace-global plan sync). Project commands (`dev up` and anything declared in `dev.yml`) still require a nearby `dev.yml`. +A few builtins are global and work from **any** directory, no `dev.yml` needed: `dev cd` (host-global navigation), `dev clone` (host-global checkout creation), `dev config` (host-global settings), `dev cred` (host-global credentials), and `dev plan` (workspace-global plan sync). Project commands (`dev up` and anything declared in `dev.yml`) still require a nearby `dev.yml`. ## dev cd — jump between checkouts @@ -411,6 +411,7 @@ Custom integrations implement `Dev::Deps::Integration` (with `install_all(pins, - **`dev up`** — first converges the host layer (throttled self-update + org Brewfile, see [Host tooling: the Brewfile contract](#host-tooling-the-brewfile-contract)), then auto-installs all deps from lockfiles (build group first), then runs the project's `up:` command from `dev.yml` if defined. On success, stamps the installed lockfile digest (see `dev check`). Finishes by refreshing agent skill links, like `install-deps`. Also valid outside any project: converges the host layer only — the fresh-box bootstrap (`brew install //dev` → `dev up` → ready). - **`dev check`** — report dependency-state staleness explicitly: `dependencies.rb` vs lockfiles (digest recorded by `update-deps`), and lockfiles vs the per-machine installed stamp (`~/.dev/state//installed-digest`, written after a fully-successful `up`/`install-deps`). The same two O(1) checks run at every command start — warning on workstations, erroring in CI. - **`dev deps path `** — print the absolute path of a locked artifact (e.g. `dev deps path ficsit SML LinuxServer`, or `dev deps path xcode` for the pinned DEVELOPER_DIR) so scripts don't reconstruct cache keys or layout conventions. +- **`dev config list | get | set `** — manage dev's settings (see [Org configuration & deployment](#org-configuration--deployment)). `list` shows every known key with its resolved value and source layer (`env` / `user` / `system` / unset) — the settings debugging tool; `get` prints the resolved value (exit 1 when unset); `set` writes the user file (`~/.config/dev/config.yml`), creating it if missing. Known keys only. Global: works without a `dev.yml`. - **`dev cred get `** — resolve a credential through the provider chain (ENV → keychain → file → prompt) and print it. A non-interactive miss errors with `gh secret set` guidance. Mirrors `dev deps path` for shell consumers (e.g. a staging sync). Global: works without a `dev.yml`. - **`dev cd `** — jump to a checkout under `$DEV_CD_ROOT` (default `~/src`) by fuzzy name, with Tab completion (see [dev cd](#dev-cd--jump-between-checkouts)). Global: works without a `dev.yml`. - **`dev clone [/]`** — clone a GitHub repo via your `gh` auth into the canonical `$DEV_CD_ROOT/github.com//` path (org defaults to `d3mlabs`) and land there (see [dev clone](#dev-clone--clone-into-the-canonical-layout)). Clone-only — run `dev up` yourself. Global: works without a `dev.yml`. diff --git a/lib/dev/config_accessor.rb b/lib/dev/config_accessor.rb new file mode 100644 index 0000000..3c293de --- /dev/null +++ b/lib/dev/config_accessor.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require_relative "settings" + +module Dev + # CLI accessor over Dev::Settings, surfaced as `dev config` — the + # tool-guided way to manage the user settings file (no hand-written + # YAML). Mirrors Dev::CredentialAccessor's shape: a global command whose + # clean failures raise and are mapped to exit 1 at the dispatch boundary. + # + # Known-keys only: the registry is Settings::KNOWN_KEYS, so the command + # and the resolver can never disagree about what exists. `list` doubles + # as the settings debugging tool — every key with its resolved value and + # the layer it came from, gitconfig `--show-origin` style. + class ConfigAccessor + class UsageError < RuntimeError; end + + # Raised for a key outside Settings::KNOWN_KEYS; the message lists the + # valid ones. + class UnknownKeyError < RuntimeError; end + + # Raised by `get` when the key resolves unset across all layers — the + # CLI boundary maps it to a non-zero exit. + class UnsetKeyError < RuntimeError; end + + USAGE = "usage: dev config list | get | set " + + # @param settings [Dev::Settings] + def initialize(settings: Dev::Settings.new) + @settings = settings + end + + # Dispatch a `dev config …` invocation. + # + # @param args [Array] argv after the "config" command + # @param out [IO] output stream + # @raise [UsageError] on an unrecognized invocation + def run(args, out: $stdout) + subcommand, *rest = args + case subcommand + when "list" then list(out) + when "get" then get(out, *rest) + when "set" then set(out, *rest) + else raise UsageError, USAGE + end + end + + private + + # Every known key with its resolved value and source layer. + # + # @param out [IO] + # @return [void] + def list(out) + width = Dev::Settings::KNOWN_KEYS.keys.map(&:length).max + Dev::Settings::KNOWN_KEYS.each_key do |key| + value, source = @settings.lookup(key) + rendered = (source == :unset) ? "(unset)" : "#{value} (#{source})" + out.puts "#{key.ljust(width)} #{rendered}" + end + end + + # @param out [IO] + # @param key [String, nil] + # @return [void] + # @raise [UsageError] without a key + # @raise [UnsetKeyError] when the key resolves unset + def get(out, key = nil, *extra) + raise UsageError, USAGE unless key && extra.empty? + + value, _source = @settings.lookup(validated(key)) + raise UnsetKeyError, "#{key} is unset" unless value + + out.puts value + end + + # @param out [IO] + # @param key [String, nil] + # @param value [String, nil] + # @return [void] + # @raise [UsageError] without a key and value + def set(out, key = nil, value = nil, *extra) + raise UsageError, USAGE unless key && value && extra.empty? + + @settings.set(validated(key), value) + out.puts "#{key} set in #{@settings.config_path}" + end + + # @param key [String] + # @return [String] the key, when known + # @raise [UnknownKeyError] otherwise + def validated(key) + return key if Dev::Settings::KNOWN_KEYS.key?(key) + + raise UnknownKeyError, + "unknown key #{key.inspect} — known keys: #{Dev::Settings::KNOWN_KEYS.keys.join(", ")}" + end + end +end diff --git a/lib/dev/settings.rb b/lib/dev/settings.rb index ff01a1b..f5674fd 100644 --- a/lib/dev/settings.rb +++ b/lib/dev/settings.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "fileutils" require "yaml" module Dev @@ -29,6 +30,15 @@ module Dev class Settings class MissingSettingError < RuntimeError; end + # The settings registry: every known key and its ENV override. The one + # list `dev config` reads (never a duplicated copy that can drift) — + # a new setting joins here and the command picks it up for free. + KNOWN_KEYS = { + "plans_repo" => "DEV_PLANS_REPO", + "knowledge_repo" => "DEV_KNOWLEDGE_REPO", + "deployment_formula" => "DEV_DEPLOYMENT_FORMULA", + }.freeze + # @return [String] path of the user config file (layer 2) attr_reader :config_path @@ -76,6 +86,38 @@ def deployment_formula setting("deployment_formula", "DEV_DEPLOYMENT_FORMULA") end + # Resolve a known key together with the layer it came from — the + # `dev config list` view (gitconfig --show-origin style). + # + # @param key [String] a KNOWN_KEYS key + # @return [Array(String, Symbol), Array(nil, Symbol)] value and source + # layer: :env, :user, :system, or :unset (value nil) + def lookup(key) + env_value = present(ENV[KNOWN_KEYS.fetch(key)]) + return [env_value, :env] if env_value + + user_value = present(load_yaml(@config_path)[key]) + return [user_value, :user] if user_value + + system_value = present(load_yaml(@system_config_path)[key]) + return [system_value, :system] if system_value + + [nil, :unset] + end + + # Write a known key into the user file (layer 2), creating it if + # missing and preserving its other keys. String-keyed, string-valued + # dump only — the file stays hand-readable plain YAML. + # + # @param key [String] a KNOWN_KEYS key + # @param value [String] + # @return [void] + def set(key, value) + KNOWN_KEYS.fetch(key) + FileUtils.mkdir_p(File.dirname(@config_path)) + File.write(@config_path, YAML.dump(load_yaml(@config_path).merge(key => value.to_s))) + end + private # Resolve one key through the layers: ENV → user file → system file. @@ -92,6 +134,12 @@ def setting(key, env_var) (value && !value.empty?) ? value : nil end + # @param value [String, nil] + # @return [String, nil] the value, with empty strings counting as unset + def present(value) + (value && !value.to_s.empty?) ? value.to_s : nil + end + # @return [String] def default_config_path config_home = ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config")) diff --git a/src/dev/builtins.rb b/src/dev/builtins.rb index 72ce454..3b3c966 100644 --- a/src/dev/builtins.rb +++ b/src/dev/builtins.rb @@ -15,6 +15,7 @@ module Builtins; end require_relative "builtins/cd_command" require_relative "builtins/check_command" require_relative "builtins/clone_command" +require_relative "builtins/config_command" require_relative "builtins/cred_command" require_relative "builtins/deps_command" require_relative "builtins/help_command" diff --git a/src/dev/builtins/config_command.rb b/src/dev/builtins/config_command.rb new file mode 100644 index 0000000..d27fec2 --- /dev/null +++ b/src/dev/builtins/config_command.rb @@ -0,0 +1,37 @@ +# typed: strict +# frozen_string_literal: true + +require "dev/command" +require "dev/config_accessor" + +module Dev + module Builtins + # `dev config` is dispatched globally (before dev.yml lookup) in bin/dev; + # this builtin only surfaces it in `dev --help` and keeps it callable + # inside a project. + class ConfigCommand < BuiltinCommand + extend T::Sig + + # Shared with the global usage listing (GlobalDispatch), which reads + # descriptions without instantiating the builtin. + DESC = "Manage dev settings (config list | get | set )" + + sig { params(accessor: Dev::ConfigAccessor).void } + def initialize(accessor: Dev::ConfigAccessor.new) + super() + @accessor = T.let(accessor, Dev::ConfigAccessor) + end + + sig { override.returns(String) } + def desc = DESC + + sig { override.returns(Command::Category) } + def category = Command::Category::Workflow + + sig { override.params(args: T::Array[String], context: ExecutionContext).void } + def call(args:, context:) + @accessor.run(args) + end + end + end +end diff --git a/src/dev/global_dispatch.rb b/src/dev/global_dispatch.rb index 4841b37..e232f04 100644 --- a/src/dev/global_dispatch.rb +++ b/src/dev/global_dispatch.rb @@ -4,6 +4,7 @@ require "pathname" require "dev/builtins/cd_command" require "dev/builtins/clone_command" +require "dev/builtins/config_command" require "dev/builtins/cred_command" require "dev/builtins/learnings_command" require "dev/builtins/plan_command" @@ -12,6 +13,7 @@ require "dev/clone" require "dev/plan" require "dev/learnings" +require "dev/config_accessor" require "dev/credentials" require "dev/credential_accessor" @@ -23,6 +25,7 @@ module Dev # - `dev clone` — host-global (clones into the canonical checkout layout # under $DEV_CD_ROOT; on a fresh machine it runs before # any project exists) + # - `dev config` — host-global (settings live under XDG / ~/.config/dev) # - `dev cred` — host-global (credentials live under XDG / ~/.config/dev) # - `dev plan` — workspace-global (plans live in the enclosing # workspace, no project config is read) @@ -47,6 +50,7 @@ class GlobalDispatch { "cd" => Builtins::CdCommand::DESC, "clone" => Builtins::CloneCommand::DESC, + "config" => Builtins::ConfigCommand::DESC, "cred" => Builtins::CredCommand::DESC, "learnings" => Builtins::LearningsCommand::DESC, "plan" => Builtins::PlanCommand::DESC, @@ -59,21 +63,24 @@ class GlobalDispatch # @param cd_accessor [Dev::Cd::Accessor] # @param clone_accessor [Dev::Clone::Accessor] + # @param config_accessor [Dev::ConfigAccessor] # @param cred_accessor [Dev::CredentialAccessor] # @param usage_printer [Dev::Cli::GlobalUsagePrinter] sig do params( cd_accessor: Dev::Cd::Accessor, clone_accessor: Dev::Clone::Accessor, + config_accessor: Dev::ConfigAccessor, cred_accessor: Dev::CredentialAccessor, usage_printer: Dev::Cli::GlobalUsagePrinter, ).void end def initialize(cd_accessor: Dev::Cd::Accessor.new, clone_accessor: Dev::Clone::Accessor.new, - cred_accessor: Dev::CredentialAccessor.new, + config_accessor: Dev::ConfigAccessor.new, cred_accessor: Dev::CredentialAccessor.new, usage_printer: Dev::Cli::GlobalUsagePrinter.new) @cd_accessor = T.let(cd_accessor, Dev::Cd::Accessor) @clone_accessor = T.let(clone_accessor, Dev::Clone::Accessor) + @config_accessor = T.let(config_accessor, Dev::ConfigAccessor) @cred_accessor = T.let(cred_accessor, Dev::CredentialAccessor) @usage_printer = T.let(usage_printer, Dev::Cli::GlobalUsagePrinter) end @@ -109,6 +116,7 @@ def run(argv) case cmd_name when "cd" then @cd_accessor.run(args) when "clone" then @clone_accessor.run(args) + when "config" then @config_accessor.run(args) # Plan and Learnings accessors are built per run: their workspace root # depends on the cwd. when "plan" then Dev::Plan::Accessor.new(project_root: workspace_root).run(args) diff --git a/src/dev/runner.rb b/src/dev/runner.rb index a641e94..6ed7aef 100644 --- a/src/dev/runner.rb +++ b/src/dev/runner.rb @@ -224,6 +224,7 @@ def build_builtins(manifest, dependency_service, help:) "check" => Builtins::CheckCommand.new(dependency_service:), "deps" => Builtins::DepsCommand.new, "cache" => Builtins::CacheCommand.new, + "config" => Builtins::ConfigCommand.new, "cred" => Builtins::CredCommand.new, "plan" => Builtins::PlanCommand.new, }, T::Hash[String, BuiltinCommand]) diff --git a/test/dev/config_accessor_test.rb b/test/dev/config_accessor_test.rb new file mode 100644 index 0000000..5c3ebf5 --- /dev/null +++ b/test/dev/config_accessor_test.rb @@ -0,0 +1,182 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/config_accessor" +require "fileutils" +require "stringio" +require "tmpdir" + +transform!(RSpock::AST::Transformation) +class Dev::ConfigAccessorTest < Minitest::Test + # An accessor over hermetic settings: both file layers live in the temp + # dir, so the machine's real config never leaks into a test. + def build_accessor(dir) + settings = Dev::Settings.new( + config_path: File.join(dir, "user", "config.yml"), + system_config_path: File.join(dir, "system", "config.yml"), + ) + Dev::ConfigAccessor.new(settings: settings) + end + + def write_layer(dir, layer, content) + path = File.join(dir, layer, "config.yml") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + + test "list shows every known key with its resolved value and source layer" do + Given "a key in each file layer, one ENV override, and one unset key" + dir = Dir.mktmpdir("dev-config-acc-test-") + write_layer(dir, "user", "knowledge_repo: acme/knowledge\n") + write_layer(dir, "system", "plans_repo: acme/plans\n") + saved_env = ENV["DEV_DEPLOYMENT_FORMULA"] + ENV["DEV_DEPLOYMENT_FORMULA"] = "acme/tap/dev" + accessor = build_accessor(dir) + out = StringIO.new + + When "listing" + accessor.run(["list"], out: out) + + Then "each key names its value and origin, gitconfig --show-origin style" + out.string.include?("plans_repo") && out.string.include?("acme/plans (system)") + out.string.include?("acme/knowledge (user)") + out.string.include?("acme/tap/dev (env)") + + Cleanup + saved_env ? ENV["DEV_DEPLOYMENT_FORMULA"] = saved_env : ENV.delete("DEV_DEPLOYMENT_FORMULA") + FileUtils.rm_rf(dir) + end + + test "list marks a key unset when no layer defines it" do + Given "empty layers" + dir = Dir.mktmpdir("dev-config-acc-test-") + saved_env = ENV.delete("DEV_DEPLOYMENT_FORMULA") + accessor = build_accessor(dir) + out = StringIO.new + + When "listing" + accessor.run(["list"], out: out) + + Then "the deployment key reads unset" + out.string.match?(/deployment_formula\s+\(unset\)/) + + Cleanup + ENV["DEV_DEPLOYMENT_FORMULA"] = saved_env if saved_env + FileUtils.rm_rf(dir) + end + + test "get prints the resolved value" do + Given "a system-layer value" + dir = Dir.mktmpdir("dev-config-acc-test-") + write_layer(dir, "system", "plans_repo: acme/plans\n") + accessor = build_accessor(dir) + out = StringIO.new + + When "getting the key" + accessor.run(["get", "plans_repo"], out: out) + + Then "the bare value prints (script-consumable)" + out.string == "acme/plans\n" + + Cleanup + FileUtils.rm_rf(dir) + end + + test "get on an unset key raises, mapping to a non-zero exit at the CLI boundary" do + Given "empty layers" + dir = Dir.mktmpdir("dev-config-acc-test-") + saved_env = ENV.delete("DEV_KNOWLEDGE_REPO") + accessor = build_accessor(dir) + + When "getting an unset key" + accessor.run(["get", "knowledge_repo"], out: StringIO.new) + + Then + raises Dev::ConfigAccessor::UnsetKeyError + + Cleanup + ENV["DEV_KNOWLEDGE_REPO"] = saved_env if saved_env + FileUtils.rm_rf(dir) + end + + test "set writes the user file (creating it) and get round-trips the value" do + Given "no config files at all" + dir = Dir.mktmpdir("dev-config-acc-test-") + accessor = build_accessor(dir) + out = StringIO.new + + When "setting then getting the key" + accessor.run(["set", "deployment_formula", "acme/tap/dev"], out: out) + accessor.run(["get", "deployment_formula"], out: out) + + Then "the set confirmed its destination and the value round-tripped" + out.string.include?("deployment_formula set in #{File.join(dir, "user", "config.yml")}") + out.string.end_with?("acme/tap/dev\n") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "set preserves the user file's other keys" do + Given "a user file with an existing key" + dir = Dir.mktmpdir("dev-config-acc-test-") + write_layer(dir, "user", "plans_repo: acme/plans\n") + accessor = build_accessor(dir) + + When "setting a different key" + accessor.run(["set", "knowledge_repo", "acme/knowledge"], out: StringIO.new) + + Then "both keys live in the file as plain string-keyed YAML" + reloaded = YAML.safe_load(File.read(File.join(dir, "user", "config.yml"))) + reloaded == { "plans_repo" => "acme/plans", "knowledge_repo" => "acme/knowledge" } + + Cleanup + FileUtils.rm_rf(dir) + end + + test "an unknown key errors with the known-keys list" do + Given "an accessor" + dir = Dir.mktmpdir("dev-config-acc-test-") + accessor = build_accessor(dir) + + When "setting a key outside the registry" + error = nil + begin + accessor.run(["set", "favorite_color", "teal"], out: StringIO.new) + rescue Dev::ConfigAccessor::UnknownKeyError => e + error = e + end + + Then "the error names the key and lists the valid ones" + error.message.include?("favorite_color") + error.message.include?("plans_repo") + error.message.include?("deployment_formula") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "unrecognized invocations raise the usage error" do + Given "an accessor" + dir = Dir.mktmpdir("dev-config-acc-test-") + accessor = build_accessor(dir) + + When "running #{args.inspect}" + accessor.run(args, out: StringIO.new) + + Then + raises Dev::ConfigAccessor::UsageError + + Cleanup + FileUtils.rm_rf(dir) + + Where + args | _ + [] | 0 + ["frobnicate"] | 0 + ["get"] | 0 + ["set", "plans_repo"] | 0 + ["get", "plans_repo", "junk"] | 0 + end +end diff --git a/test/dev/global_dispatch_test.rb b/test/dev/global_dispatch_test.rb index 9063091..7a27555 100644 --- a/test/dev/global_dispatch_test.rb +++ b/test/dev/global_dispatch_test.rb @@ -19,6 +19,17 @@ def run(args) end end unless defined?(RecordingCredAccessor) +# A config accessor stand-in recording its argv, so dispatch is tested +# without touching the real config files. Subclasses the real accessor to +# satisfy the dispatcher's typed constructor. +class RecordingConfigAccessor < Dev::ConfigAccessor + attr_reader :last_args + + def run(args, out: $stdout) + @last_args = args + end +end unless defined?(RecordingConfigAccessor) + # A clone accessor stand-in recording its argv, so dispatch is tested without # gh or shell RC writes. Subclasses the real accessor to satisfy the # dispatcher's typed constructor. @@ -47,6 +58,7 @@ class Dev::GlobalDispatchTest < Minitest::Test name | expected "cd" | true "clone" | true + "config" | true "plan" | true "cred" | true "learnings" | true @@ -214,6 +226,22 @@ class Dev::GlobalDispatchTest < Minitest::Test FileUtils.rm_rf(cwd) end + test "dev config dispatches globally without a dev.yml lookup" do + Given "a recording config accessor and a cwd with no dev.yml" + config = RecordingConfigAccessor.new + dispatch = Dev::GlobalDispatch.new(config_accessor: config, cred_accessor: RecordingCredAccessor.new) + cwd = Dir.mktmpdir("dispatch-cwd-") + + When "we dispatch dev config" + Dir.chdir(cwd) { dispatch.run(["config", "get", "plans_repo"]) } + + Then "the accessor received the subcommand argv" + config.last_args == ["get", "plans_repo"] + + Cleanup + FileUtils.rm_rf(cwd) + end + test "dev plan usage errors surface cleanly from a directory with no dev.yml" do Given "a cwd with no dev.yml anywhere above it" dispatch = Dev::GlobalDispatch.new(cred_accessor: RecordingCredAccessor.new) From b835291602cc2cdde2dab0e6f39e81108895db8c Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 23 Aug 2026 21:03:43 -0400 Subject: [PATCH 08/16] Drop the self-update throttle: dev owns no rate limiter Measured no-op costs on a converged host (brew update ~0.5s, scoped upgrade ~0.4s, brew bundle ~0.9s) don't justify a daily stamp that delays deployment fixes by up to 24h. Every dev up now runs the full chain; brew's own HOMEBREW_AUTO_UPDATE_SECS stays the only network rate limiter, tunable through brew rather than dev. Co-authored-by: Cursor --- README.md | 4 +-- bin/dev | 5 ++- lib/dev/host/converge.rb | 56 +++++++------------------------ src/dev/builtins/up_command.rb | 6 ++-- test/dev/host/converge_test.rb | 60 +++++++++------------------------- 5 files changed, 33 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 4c24074..29b47b2 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ Alongside per-project dependencies, an org converges **host tooling** — the or - **Private taps:** Brewfiles natively support `tap` entries, including private taps over authenticated git — sensitive tooling goes in a private tap the Brewfile references. `gh auth login` must precede `dev up` in that case (the failure mode is brew's own clear git-auth error). - **Trust model:** a Brewfile is brew-evaluated Ruby DSL, so converging it executes org-authored code — the same trust already granted by installing the org's deployment formula. dev adds no new trust surface: the file lives in the brew prefix at a fixed path, never a user-supplied one, and brew's tap-trust gate covers formulas from untrusted taps. -On every `dev up`, before project provisioning, `Dev::Host::Converge` runs the host layer: a **throttled `brew update`** (daily stamp under `$XDG_DATA_HOME/dev/host/`), a **scoped `brew upgrade` of the `deployment_formula`** the deployment named in its own `config.yml` (falling back to `dev-core` for tapless individuals; skipped entirely for source checkouts — never a blanket `brew upgrade` of unrelated packages), then **`brew bundle install`** against the Brewfile when one exists (not throttled — an upgrade may land a new Brewfile the same run must converge). The whole layer is warn-only: offline machines and failed upgrades never block project provisioning. Upgrading is symmetric: the org edits one line in its tap's Brewfile (or ships a config change via formula revision) and every machine converges on its next `dev up` — no brew vocabulary required, though a direct `brew upgrade` keeps working for users who prefer it. +On every `dev up`, before project provisioning, `Dev::Host::Converge` runs the host layer: **`brew update`**, a **scoped `brew upgrade` of the `deployment_formula`** the deployment named in its own `config.yml` (falling back to `dev-core` for tapless individuals; skipped entirely for source checkouts — never a blanket `brew upgrade` of unrelated packages), then **`brew bundle install`** against the Brewfile when one exists. dev adds no throttle of its own — the no-op steps are sub-second, and brew's `HOMEBREW_AUTO_UPDATE_SECS` remains the only network rate limiter (tune it through brew) — so a deployment fix propagates on the very next `dev up`. The whole layer is warn-only: offline machines and failed upgrades never block project provisioning. Upgrading is symmetric: the org edits one line in its tap's Brewfile (or ships a config change via formula revision) and every machine converges on its next `dev up` — no brew vocabulary required, though a direct `brew upgrade` keeps working for users who prefer it. ### dependencies.rb @@ -408,7 +408,7 @@ Custom integrations implement `Dev::Deps::Integration` (with `install_all(pins, - **`dev update-deps`** — resolve constraints from `dependencies.rb`, write lockfiles (recording the manifest digest for the staleness check). Always available (no need to define in `dev.yml`). - **`dev install-deps`** — install locked deps handled on the host (gh releases, steam apps) into their version-keyed install dirs, filtered to the detected env and host OS. Finishes by refreshing agent skill links (see [Agent skills & org learnings](#agent-skills--org-learnings)). -- **`dev up`** — first converges the host layer (throttled self-update + org Brewfile, see [Host tooling: the Brewfile contract](#host-tooling-the-brewfile-contract)), then auto-installs all deps from lockfiles (build group first), then runs the project's `up:` command from `dev.yml` if defined. On success, stamps the installed lockfile digest (see `dev check`). Finishes by refreshing agent skill links, like `install-deps`. Also valid outside any project: converges the host layer only — the fresh-box bootstrap (`brew install //dev` → `dev up` → ready). +- **`dev up`** — first converges the host layer (self-update + org Brewfile, see [Host tooling: the Brewfile contract](#host-tooling-the-brewfile-contract)), then auto-installs all deps from lockfiles (build group first), then runs the project's `up:` command from `dev.yml` if defined. On success, stamps the installed lockfile digest (see `dev check`). Finishes by refreshing agent skill links, like `install-deps`. Also valid outside any project: converges the host layer only — the fresh-box bootstrap (`brew install //dev` → `dev up` → ready). - **`dev check`** — report dependency-state staleness explicitly: `dependencies.rb` vs lockfiles (digest recorded by `update-deps`), and lockfiles vs the per-machine installed stamp (`~/.dev/state//installed-digest`, written after a fully-successful `up`/`install-deps`). The same two O(1) checks run at every command start — warning on workstations, erroring in CI. - **`dev deps path `** — print the absolute path of a locked artifact (e.g. `dev deps path ficsit SML LinuxServer`, or `dev deps path xcode` for the pinned DEVELOPER_DIR) so scripts don't reconstruct cache keys or layout conventions. - **`dev config list | get | set `** — manage dev's settings (see [Org configuration & deployment](#org-configuration--deployment)). `list` shows every known key with its resolved value and source layer (`env` / `user` / `system` / unset) — the settings debugging tool; `get` prints the resolved value (exit 1 when unset); `set` writes the user file (`~/.config/dev/config.yml`), creating it if missing. Known keys only. Global: works without a `dev.yml`. diff --git a/bin/dev b/bin/dev index 1c1a4a8..5efc0d4 100755 --- a/bin/dev +++ b/bin/dev @@ -60,9 +60,8 @@ begin Dev::Runner.new(ui: ui).run(ARGV) rescue Dev::DevYamlNotFoundError # `dev up` stays valid outside any project: it converges the host layer - # only (throttled self-update + org Brewfile), which IS the fresh-box - # bootstrap — install dev, `dev up`, ready. Everything else still needs a - # project. + # only (self-update + org Brewfile), which IS the fresh-box bootstrap — + # install dev, `dev up`, ready. Everything else still needs a project. if ARGV.first == "up" require "dev/host/converge" Dev::Host::Converge.new.run diff --git a/lib/dev/host/converge.rb b/lib/dev/host/converge.rb index 1c87ba1..03264f1 100644 --- a/lib/dev/host/converge.rb +++ b/lib/dev/host/converge.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true -require "fileutils" require "open3" require "pathname" -require "time" require_relative "../settings" module Dev @@ -12,7 +10,8 @@ module Host # re-implements host tooling convergence, it only *triggers* brew's, the # same way it triggers bundler for gems. Three steps, all brew-executed: # - # 1. throttled `brew update` (daily stamp), so warm runs stay snappy + # 1. `brew update`, so a deployment fix propagates on the very next + # `dev up` # 2. scoped `brew upgrade` of the org's deployment formula — the # deployment names itself via the `deployment_formula` setting; the # formula revision delivers dev itself plus the org's config.yml and @@ -20,18 +19,16 @@ module Host # 3. `brew bundle install` against the etc/dev/Brewfile when one exists # — the org's tooling list beyond the tool's own dependencies # + # dev owns no throttle: measured no-op costs are sub-second per step + # (update ~0.5s, scoped upgrade ~0.4s, bundle ~0.9s), and brew's own + # HOMEBREW_AUTO_UPDATE_SECS remains the only network rate limiter — + # tunable through brew, not dev. + # # The Brewfile presence is convention, not configuration: no file (tapless # individual, CI) means the step self-skips. The whole layer is warn-only: # a failed self-update or tooling converge never blocks project # provisioning (offline `dev up` still works). class Converge - # One self-update check per day keeps warm `dev up` fast; the Brewfile - # converge is NOT throttled — an upgrade in step 2 may land a new - # Brewfile that step 3 must converge in the same run. - UPDATE_INTERVAL_SECONDS = 24 * 60 * 60 - - UPDATE_STAMP_FILE = "brew-update-stamp" - # A brew formula token: bare name or tap-qualified org/repo/name. The # deployment_formula value crosses a settings boundary into a brew # invocation, so validate its shape — a leading `-` must never reach @@ -64,20 +61,14 @@ def quiet?(*cmd) # @param settings [Dev::Settings] source of deployment_formula and the # system config location (whose directory also holds the Brewfile) - # @param state_dir [Pathname, String] host state root (XDG data home, - # the learnings-cache precedent) for the update-throttle stamp # @param executor [#run, #quiet?] brew invocation seam, injectable so # tests never call brew - # @param clock [#call] () → Time, injectable for throttle tests - def initialize(settings: Dev::Settings.new, state_dir: default_state_dir, - executor: Executor.new, clock: -> { Time.now }) + def initialize(settings: Dev::Settings.new, executor: Executor.new) @settings = settings - @state_dir = Pathname(state_dir) @executor = executor - @clock = clock end - # The whole host layer, in order: deployment sanity warning, throttled + # The whole host layer, in order: deployment sanity warning, # self-update, Brewfile converge. A no-op on brewless machines (no # prefix means no system config location, no Brewfile, nothing to # upgrade). @@ -87,7 +78,7 @@ def run return unless system_config_path warn_unnamed_deployment - self_update if update_due? + self_update converge_brewfile if brewfile_path.file? end @@ -110,8 +101,7 @@ def warn_unnamed_deployment # `brew update` then a scoped upgrade of exactly one formula — never a # blanket `brew upgrade`; the user's unrelated packages are not dev's - # business. Stamps only after a successful update so an offline run - # retries next time. + # business. # # @return [void] def self_update @@ -124,7 +114,6 @@ def self_update if target && !@executor.run("brew", "upgrade", "--quiet", target) $stderr.puts "dev: warning: brew upgrade #{target} failed." end - stamp_update! end # The one formula the self-update may touch: the org's self-named @@ -154,23 +143,6 @@ def converge_brewfile $stderr.puts "dev: warning: brew bundle failed for #{brewfile_path} — host tooling may be incomplete." end - # @return [Boolean] whether the daily self-update check is due - def update_due? - !update_stamp_path.file? || - @clock.call - update_stamp_path.mtime >= UPDATE_INTERVAL_SECONDS - end - - # @return [void] - def stamp_update! - FileUtils.mkdir_p(update_stamp_path.dirname) - FileUtils.touch(update_stamp_path) - end - - # @return [Pathname] - def update_stamp_path - @state_dir / "host" / UPDATE_STAMP_FILE - end - # The org Brewfile lives beside the system config.yml — both are the # deployment formula's payload into the prefix's etc/dev/. # @@ -183,12 +155,6 @@ def brewfile_path def system_config_path @settings.system_config_path end - - # @return [String] $XDG_DATA_HOME/dev (the learnings-cache precedent) - def default_state_dir - data_home = ENV.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local", "share")) - File.join(data_home, "dev") - end end end end diff --git a/src/dev/builtins/up_command.rb b/src/dev/builtins/up_command.rb index 55818fc..cd63922 100644 --- a/src/dev/builtins/up_command.rb +++ b/src/dev/builtins/up_command.rb @@ -49,9 +49,9 @@ def stamps? = true sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) - # The host layer converges before project provisioning (throttled - # self-update + org Brewfile): project installs may lean on host - # tools (gh, rbenv). Warn-only — never blocks the project. + # The host layer converges before project provisioning (self-update + # + org Brewfile): project installs may lean on host tools (gh, + # rbenv). Warn-only — never blocks the project. @host_converge.run provision_build_credentials(context) @hook_installer.ensure_installed diff --git a/test/dev/host/converge_test.rb b/test/dev/host/converge_test.rb index 6673de8..06300d7 100644 --- a/test/dev/host/converge_test.rb +++ b/test/dev/host/converge_test.rb @@ -11,8 +11,8 @@ transform!(RSpock::AST::Transformation) class Dev::Host::ConvergeTest < Minitest::Test # Records every brew invocation instead of running it — the executor is - # the true boundary; everything else (settings layers, Brewfile, stamp) - # uses real files in temp dirs. + # the true boundary; everything else (settings layers, Brewfile) uses + # real files in temp dirs. class RecordingExecutor attr_reader :commands @@ -50,7 +50,7 @@ def quiet?(*cmd) FileUtils.rm_rf(dir) end - test "first run performs the throttled self-update and stamps, but has nothing to upgrade" do + test "a bare host runs the self-update but has nothing to upgrade or bundle" do Given "no deployment config, no Brewfile, dev-core not brew-installed" dir = Dir.mktmpdir("dev-host-converge-test-") executor = RecordingExecutor.new(quiet_result: false) @@ -59,12 +59,11 @@ def quiet?(*cmd) When "converging" converge.run - Then "brew update + the dev-core check ran, nothing upgraded or bundled, and the throttle stamp exists" + Then "brew update + the dev-core check ran, nothing upgraded or bundled" executor.commands == [ ["brew", "update", "--quiet"], ["brew", "list", "--formula", "--versions", "dev-core"], ] - File.exist?(File.join(dir, "state", "host", "brew-update-stamp")) Cleanup FileUtils.rm_rf(dir) @@ -122,47 +121,24 @@ def quiet?(*cmd) FileUtils.rm_rf(dir) end - test "a warm rerun inside the throttle window skips the self-update but still converges the Brewfile" do - Given "a fresh throttle stamp and an org Brewfile" + test "a Brewfile beside the system config converges via brew bundle" do + Given "an org Brewfile in etc" dir = Dir.mktmpdir("dev-host-converge-test-") - stamp = File.join(dir, "state", "host", "brew-update-stamp") - FileUtils.mkdir_p(File.dirname(stamp)) - FileUtils.touch(stamp) brewfile = write_brewfile(dir, %(cask "cursor-cli"\n)) - executor = RecordingExecutor.new + executor = RecordingExecutor.new(quiet_result: false) converge = build_converge(dir, executor: executor) When "converging" converge.run - Then "no update or upgrade, but the Brewfile converge is never throttled" - executor.commands == [["brew", "bundle", "install", "--file=#{brewfile}"]] + Then "brew bundle runs against the etc Brewfile as the last step" + executor.commands.last == ["brew", "bundle", "install", "--file=#{brewfile}"] Cleanup FileUtils.rm_rf(dir) end - test "an expired throttle stamp re-runs the self-update" do - Given "a stamp older than the update interval" - dir = Dir.mktmpdir("dev-host-converge-test-") - stamp = File.join(dir, "state", "host", "brew-update-stamp") - FileUtils.mkdir_p(File.dirname(stamp)) - FileUtils.touch(stamp) - late_clock = -> { Time.now + Dev::Host::Converge::UPDATE_INTERVAL_SECONDS + 1 } - executor = RecordingExecutor.new - converge = build_converge(dir, executor: executor, clock: late_clock) - - When "converging a day later" - converge.run - - Then "brew update ran again" - executor.commands.first == ["brew", "update", "--quiet"] - - Cleanup - FileUtils.rm_rf(dir) - end - - test "a failed brew update warns and skips the upgrade without stamping, so the next run retries" do + test "a failed brew update warns and skips the upgrade, but the Brewfile still converges" do Given "an offline machine (every streamed brew command fails)" dir = Dir.mktmpdir("dev-host-converge-test-") write_system_config(dir, "deployment_formula: d3mlabs/d3mlabs/dev\n") @@ -172,10 +148,9 @@ def quiet?(*cmd) When "converging" stderr = capture_stderr { converge.run } - Then "no upgrade was attempted, the failure is a warning, and no stamp was written" + Then "no upgrade was attempted and the failure is a warning" executor.commands.none? { |cmd| cmd[0..1] == ["brew", "upgrade"] } stderr.include?("brew update failed") - !File.exist?(File.join(dir, "state", "host", "brew-update-stamp")) Cleanup FileUtils.rm_rf(dir) @@ -219,19 +194,14 @@ def quiet?(*cmd) private - # Hermetic converge: settings layers, throttle stamp, and Brewfile all - # live under the test's temp dir; only the executor is faked. - def build_converge(dir, executor:, clock: -> { Time.now }) + # Hermetic converge: settings layers and Brewfile live under the test's + # temp dir; only the executor is faked. + def build_converge(dir, executor:) settings = Dev::Settings.new( config_path: File.join(dir, "user", "config.yml"), system_config_path: File.join(dir, "etc", "config.yml"), ) - Dev::Host::Converge.new( - settings: settings, - state_dir: File.join(dir, "state"), - executor: executor, - clock: clock, - ) + Dev::Host::Converge.new(settings: settings, executor: executor) end def write_system_config(dir, content) From f1d27e5b5a085075d32ec9676fb5be263102895a Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Sun, 23 Aug 2026 21:10:34 -0400 Subject: [PATCH 09/16] Cover the converge failure branches, real executor, and config builtin Codecov's patch check flagged the warn-only branches (failed upgrade, failed bundle), the production Executor bodies, and ConfigCommand#call as untested. Co-authored-by: Cursor --- test/dev/builtins/config_command_test.rb | 32 ++++++++++++ test/dev/host/converge_test.rb | 65 +++++++++++++++++++++++- 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 test/dev/builtins/config_command_test.rb diff --git a/test/dev/builtins/config_command_test.rb b/test/dev/builtins/config_command_test.rb new file mode 100644 index 0000000..1bfdda1 --- /dev/null +++ b/test/dev/builtins/config_command_test.rb @@ -0,0 +1,32 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/builtins/config_command" +require "pathname" + +transform!(RSpock::AST::Transformation) +class Dev::Builtins::ConfigCommandTest < Minitest::Test + include SorbetHelper + + test "call dispatches argv to the config accessor" do + Given "a config command over an expecting accessor" + accessor = typed_mock(Dev::ConfigAccessor) + accessor.expects(:run).with(["get", "plans_repo"]).once + command = Dev::Builtins::ConfigCommand.new(accessor: accessor) + + When "running config" + command.call(args: ["get", "plans_repo"], context: build_context) + + Then "the expectation on the accessor holds" + true + end + + private + + def build_context + Dev::ExecutionContext.new( + ui: typed_mock(Dev::Cli::Ui), ruby_version: "4.0.1", project_root: Pathname.new("/tmp/config-test"), + ) + end +end diff --git a/test/dev/host/converge_test.rb b/test/dev/host/converge_test.rb index 06300d7..b7d35d2 100644 --- a/test/dev/host/converge_test.rb +++ b/test/dev/host/converge_test.rb @@ -5,6 +5,7 @@ require "dev/host/converge" require "dev/settings" require "fileutils" +require "rbconfig" require "stringio" require "tmpdir" @@ -16,14 +17,19 @@ class Dev::Host::ConvergeTest < Minitest::Test class RecordingExecutor attr_reader :commands - def initialize(run_result: true, quiet_result: false) + # @param fail_subcommands [Array] brew subcommands whose run + # reports failure (e.g. ["upgrade"]), for the warn-only branches + def initialize(run_result: true, quiet_result: false, fail_subcommands: []) @commands = [] @run_result = run_result @quiet_result = quiet_result + @fail_subcommands = fail_subcommands end def run(*cmd) @commands << cmd + return false if @fail_subcommands.include?(cmd[1]) + @run_result end @@ -138,6 +144,43 @@ def quiet?(*cmd) FileUtils.rm_rf(dir) end + test "a failed scoped upgrade warns and still converges the Brewfile" do + Given "a named deployment whose upgrade fails" + dir = Dir.mktmpdir("dev-host-converge-test-") + write_system_config(dir, "deployment_formula: d3mlabs/d3mlabs/dev\n") + brewfile = write_brewfile(dir, %(cask "cursor-cli"\n)) + executor = RecordingExecutor.new(fail_subcommands: ["upgrade"]) + converge = build_converge(dir, executor: executor) + + When "converging" + stderr = capture_stderr { converge.run } + + Then "the failure is a warning and the Brewfile step still ran" + stderr.include?("brew upgrade d3mlabs/d3mlabs/dev failed") + executor.commands.last == ["brew", "bundle", "install", "--file=#{brewfile}"] + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a failed brew bundle warns instead of blocking" do + Given "an org Brewfile whose converge fails" + dir = Dir.mktmpdir("dev-host-converge-test-") + write_brewfile(dir, %(cask "cursor-cli"\n)) + executor = RecordingExecutor.new(quiet_result: false, fail_subcommands: ["bundle"]) + converge = build_converge(dir, executor: executor) + + When "converging" + stderr = capture_stderr { converge.run } + + Then "the failure surfaces as a warning naming the Brewfile" + stderr.include?("brew bundle failed") + stderr.include?("Brewfile") + + Cleanup + FileUtils.rm_rf(dir) + end + test "a failed brew update warns and skips the upgrade, but the Brewfile still converges" do Given "an offline machine (every streamed brew command fails)" dir = Dir.mktmpdir("dev-host-converge-test-") @@ -156,6 +199,26 @@ def quiet?(*cmd) FileUtils.rm_rf(dir) end + test "the real executor's run maps exit status to a boolean" do + Given "the production executor" + executor = Dev::Host::Converge::Executor.new + + Expect "success and failure map to booleans, and a missing binary is false" + executor.run(RbConfig.ruby, "-e", "exit 0") == true + executor.run(RbConfig.ruby, "-e", "exit 1") == false + executor.run("definitely-not-a-command-#{Process.pid}") == false + end + + test "the real executor's quiet? answers success without streaming output" do + Given "the production executor" + executor = Dev::Host::Converge::Executor.new + + Expect "exit status maps to a boolean and a missing binary is false, not an exception" + executor.quiet?(RbConfig.ruby, "-e", "puts :ok") == true + executor.quiet?(RbConfig.ruby, "-e", "exit 1") == false + executor.quiet?("definitely-not-a-command-#{Process.pid}") == false + end + test "an etc config.yml without a resolvable deployment_formula warns with the remedy" do Given "a deployment config that forgot to name itself" dir = Dir.mktmpdir("dev-host-converge-test-") From e5585ce0b337cf6597a543fc018bd2a7b131fcfa Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Tue, 25 Aug 2026 09:30:41 -0400 Subject: [PATCH 10/16] Tighten FORMULA_PATTERN to canonical brew tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tap-qualified segments were missing @ and + — a versioned deployment formula (org/tap/dev@2) was falsely rejected as malformed, silently stopping self-updates in exactly the tap-qualified case the guard serves. Also reject the invalid two-segment form and require brew's canonical lowercase spelling (brew stores taps downcased). Co-authored-by: Cursor --- lib/dev/host/converge.rb | 8 ++++-- test/dev/host/converge_test.rb | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/lib/dev/host/converge.rb b/lib/dev/host/converge.rb index 03264f1..e976728 100644 --- a/lib/dev/host/converge.rb +++ b/lib/dev/host/converge.rb @@ -29,11 +29,15 @@ module Host # a failed self-update or tooling converge never blocks project # provisioning (offline `dev up` still works). class Converge - # A brew formula token: bare name or tap-qualified org/repo/name. The + # A canonical brew formula token: bare name or fully tap-qualified + # user/repo/name (exactly one or three segments — a two-segment form is + # not a formula reference), lowercase throughout (brew stores taps + # downcased, so the canonical spelling is the lowercase one). The # deployment_formula value crosses a settings boundary into a brew # invocation, so validate its shape — a leading `-` must never reach # brew as a flag. - FORMULA_PATTERN = %r{\A[A-Za-z0-9][\w.+@-]*(?:/[A-Za-z0-9][\w.-]*){0,2}\z} + FORMULA_PATTERN = + %r{\A[a-z0-9][a-z0-9_.+@-]*(?:/[a-z0-9][a-z0-9_.+@-]*/[a-z0-9][a-z0-9_.+@-]*)?\z} # The generic tool's own formula — the self-update target for tapless # individuals who installed dev-core directly (no deployment). diff --git a/test/dev/host/converge_test.rb b/test/dev/host/converge_test.rb index b7d35d2..0b9da8e 100644 --- a/test/dev/host/converge_test.rb +++ b/test/dev/host/converge_test.rb @@ -111,6 +111,52 @@ def quiet?(*cmd) FileUtils.rm_rf(dir) end + test "'#{formula}' (#{shape}) keeps its spelling through to brew upgrade" do + Given "a deployment named with that token shape" + dir = Dir.mktmpdir("dev-host-converge-test-") + write_system_config(dir, "deployment_formula: #{formula}\n") + executor = RecordingExecutor.new + converge = build_converge(dir, executor: executor) + + When "converging" + stderr = capture_stderr { converge.run } + + Then "the scoped upgrade targets the formula as spelled, with no warning" + executor.commands.include?(["brew", "upgrade", "--quiet", formula]) + stderr.empty? + + Cleanup + FileUtils.rm_rf(dir) + + Where + formula | shape + "d3mlabs/tap/dev@2" | "tap-qualified versioned" + "org/tap/libc++" | "tap-qualified plused" + end + + test "'#{formula}' (#{reason}) is rejected as malformed" do + Given "a deployment_formula that is not a canonical brew token" + dir = Dir.mktmpdir("dev-host-converge-test-") + write_system_config(dir, "deployment_formula: #{formula}\n") + executor = RecordingExecutor.new + converge = build_converge(dir, executor: executor) + + When "converging" + stderr = capture_stderr { converge.run } + + Then "no upgrade is attempted and the rejection is warned" + executor.commands.none? { |cmd| cmd[0..1] == ["brew", "upgrade"] } + stderr.include?("malformed deployment_formula") + + Cleanup + FileUtils.rm_rf(dir) + + Where + formula | reason + "foo/bar" | "two segments is not a formula reference" + "Dev-Core" | "brew's canonical tap form is lowercase" + end + test "an unset key falls back to dev-core when it is brew-installed" do Given "no deployment config, dev-core installed" dir = Dir.mktmpdir("dev-host-converge-test-") From ad2a358643b3c25da3b2def02617551c5bdbacdb Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Tue, 25 Aug 2026 09:37:10 -0400 Subject: [PATCH 11/16] Split ExecutionContext into host and project halves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The domain model dev actually has: every run carries a host half (ui), and a project half only when a dev.yml encloses the cwd. ProjectContext groups root/ruby/python/build_container/runner; project! makes the registration invariant explicit (project commands only exist when the project does). Groundwork for a project-optional Runner — no behavior change yet. Co-authored-by: Cursor --- src/dev/builtins/cache_command.rb | 7 ++-- src/dev/builtins/deps_command.rb | 2 +- src/dev/builtins/install_deps_command.rb | 13 ++++--- src/dev/builtins/learnings_command.rb | 2 +- src/dev/builtins/plan_command.rb | 2 +- src/dev/builtins/provide_image_command.rb | 5 ++- src/dev/builtins/reset_container_command.rb | 7 ++-- src/dev/builtins/runner_setup_command.rb | 2 +- src/dev/builtins/up_command.rb | 2 +- src/dev/builtins/update_deps_command.rb | 7 ++-- src/dev/execution_context.rb | 38 +++++++++++++++---- src/dev/runner.rb | 21 +++++----- test/dev/builtin_executor_test.rb | 5 ++- test/dev/builtins/cache_command_test.rb | 10 +++-- test/dev/builtins/cd_command_test.rb | 3 +- test/dev/builtins/check_command_test.rb | 3 +- test/dev/builtins/clone_command_test.rb | 3 +- test/dev/builtins/config_command_test.rb | 3 +- test/dev/builtins/cred_command_test.rb | 3 +- test/dev/builtins/deps_command_test.rb | 5 ++- test/dev/builtins/help_command_test.rb | 3 +- .../dev/builtins/install_deps_command_test.rb | 5 ++- test/dev/builtins/learnings_command_test.rb | 5 ++- test/dev/builtins/plan_command_test.rb | 5 ++- .../builtins/provide_image_command_test.rb | 8 ++-- .../builtins/reset_container_command_test.rb | 8 ++-- .../dev/builtins/runner_setup_command_test.rb | 8 ++-- test/dev/builtins/up_command_test.rb | 8 ++-- test/dev/builtins/update_deps_command_test.rb | 5 ++- test/dev/command_executor_test.rb | 5 ++- test/dev/command_service_test.rb | 3 +- test/dev/execution_context_test.rb | 30 +++++++++++++++ test/dev/overridden_executor_test.rb | 5 ++- test/dev/runner_test.rb | 6 +-- 34 files changed, 174 insertions(+), 73 deletions(-) create mode 100644 test/dev/execution_context_test.rb diff --git a/src/dev/builtins/cache_command.rb b/src/dev/builtins/cache_command.rb index 553b0fa..33b68c4 100644 --- a/src/dev/builtins/cache_command.rb +++ b/src/dev/builtins/cache_command.rb @@ -40,14 +40,15 @@ def call(args:, context:) subcommand, *rest = args raise ArgumentError, "usage: dev cache gc [--keep N]" unless subcommand == "gc" - gc = @cache_gc_factory.call(Dev::Deps::Lockfile.new(dir: context.project_root)) + project = context.project! + gc = @cache_gc_factory.call(Dev::Deps::Lockfile.new(dir: project.root)) # The build container config (when present) lets GC also prune stale # content-tagged images while protecting the live tag. image_ref = T.let(nil, T.nilable(String)) live_tag = T.let(nil, T.nilable(String)) - if (cfg = context.build_container) + if (cfg = project.build_container) image_ref = cfg.image_ref - live_tag = BuildContainer.image_with_tag(cfg, project_root: context.project_root) + live_tag = BuildContainer.image_with_tag(cfg, project_root: project.root) end gc.gc(keep: parse_keep(rest), image_ref: image_ref, live_tag: live_tag) end diff --git a/src/dev/builtins/deps_command.rb b/src/dev/builtins/deps_command.rb index 84a1ca4..1ff85a8 100644 --- a/src/dev/builtins/deps_command.rb +++ b/src/dev/builtins/deps_command.rb @@ -41,7 +41,7 @@ def category = Command::Category::Lifecycle sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) - @accessor_factory.call(context.project_root).run(args) + @accessor_factory.call(context.project!.root).run(args) end end end diff --git a/src/dev/builtins/install_deps_command.rb b/src/dev/builtins/install_deps_command.rb index 34143bc..efa79e2 100644 --- a/src/dev/builtins/install_deps_command.rb +++ b/src/dev/builtins/install_deps_command.rb @@ -78,18 +78,19 @@ def stamps? = true # engine. sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) + project = context.project! # Headless boxes (CI, runner services) reach install-deps before any # dev.yml command has run CommandRunner's provisioning, so the builtin # must provision the pinned Ruby itself — bundler installs against it. - ShadowenvRuby.ensure!(ruby_version: context.ruby_version, project_root: context.project_root) + ShadowenvRuby.ensure!(ruby_version: project.ruby_version, project_root: project.root) - lockfile = Dev::Deps::Lockfile.new(dir: context.project_root) + lockfile = Dev::Deps::Lockfile.new(dir: project.root) installer = @installer_factory.call( lockfile, Dev::Deps::Registry.host_integrations( - project_root: context.project_root, + project_root: project.root, cache: Dev::Deps::Cache.new, - python_version: context.python_version, + python_version: project.python_version, ), ) installer.install(env: Dev::Deps.detect_env, host: Dev::Deps.detect_host) @@ -99,8 +100,8 @@ def call(args:, context:) # This is hygiene, not a bootstrap contract: workflows that must start # on fresh invariants (e.g. ai-flow's runner) run an explicit blocking # `dev learnings sync` step instead of relying on this side effect. - @gem_skill_linker_factory.call(context.project_root).link_all - @synchronizer.sync(project_root: context.project_root) + @gem_skill_linker_factory.call(project.root).link_all + @synchronizer.sync(project_root: project.root) end end end diff --git a/src/dev/builtins/learnings_command.rb b/src/dev/builtins/learnings_command.rb index cb9733f..ce74c0c 100644 --- a/src/dev/builtins/learnings_command.rb +++ b/src/dev/builtins/learnings_command.rb @@ -37,7 +37,7 @@ def category = Command::Category::Workflow sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) - @accessor_factory.call(context.project_root).run(args) + @accessor_factory.call(context.project!.root).run(args) end end end diff --git a/src/dev/builtins/plan_command.rb b/src/dev/builtins/plan_command.rb index b62101e..b748d31 100644 --- a/src/dev/builtins/plan_command.rb +++ b/src/dev/builtins/plan_command.rb @@ -41,7 +41,7 @@ def staleness_exempt? = true sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) - @accessor_factory.call(context.project_root).run(args) + @accessor_factory.call(context.project!.root).run(args) end end end diff --git a/src/dev/builtins/provide_image_command.rb b/src/dev/builtins/provide_image_command.rb index 2f8f667..3885c13 100644 --- a/src/dev/builtins/provide_image_command.rb +++ b/src/dev/builtins/provide_image_command.rb @@ -34,10 +34,11 @@ def staleness_exempt? = true sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) - cfg = T.must(context.build_container) + project = context.project! + cfg = T.must(project.build_container) image_tag = BuildContainer.ensure_image!( cfg, - project_root: context.project_root, + project_root: project.root, push: false, publish: ENV["DEV_PUBLISH_IMAGE"] == "1", build_args_provider: -> { Dev::Credentials.resolve_build_args(cfg.build_args) }, diff --git a/src/dev/builtins/reset_container_command.rb b/src/dev/builtins/reset_container_command.rb index 26edd89..d263edf 100644 --- a/src/dev/builtins/reset_container_command.rb +++ b/src/dev/builtins/reset_container_command.rb @@ -19,9 +19,10 @@ def category = Command::Category::Lifecycle sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) - cfg = T.must(context.build_container) - image_tag = BuildContainer.image_with_tag(cfg, project_root: context.project_root) - removed = BuildContainer.reset_service!(image_tag, context.project_root) + project = context.project! + cfg = T.must(project.build_container) + image_tag = BuildContainer.image_with_tag(cfg, project_root: project.root) + removed = BuildContainer.reset_service!(image_tag, project.root) puts(removed.empty? ? "dev: no persistent build container to remove." : "dev: removed #{removed.join(", ")}.") end end diff --git a/src/dev/builtins/runner_setup_command.rb b/src/dev/builtins/runner_setup_command.rb index 6db1fbe..e17c8ff 100644 --- a/src/dev/builtins/runner_setup_command.rb +++ b/src/dev/builtins/runner_setup_command.rb @@ -51,7 +51,7 @@ def category = Command::Category::Lifecycle sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) - cfg = context.runner + cfg = context.project!.runner raise ArgumentError, "no `runner:` block in dev.yml" if cfg.nil? @runner_setup_factory.call( diff --git a/src/dev/builtins/up_command.rb b/src/dev/builtins/up_command.rb index cd63922..0eac987 100644 --- a/src/dev/builtins/up_command.rb +++ b/src/dev/builtins/up_command.rb @@ -66,7 +66,7 @@ def call(args:, context:) # triggered image build in containerized commands non-interactive. sig { params(context: ExecutionContext).void } def provision_build_credentials(context) - config = context.build_container + config = context.project!.build_container return if config.nil? || config.build_args.empty? Dev::Credentials.resolve_build_args(config.build_args) diff --git a/src/dev/builtins/update_deps_command.rb b/src/dev/builtins/update_deps_command.rb index 4edc18c..d998b36 100644 --- a/src/dev/builtins/update_deps_command.rb +++ b/src/dev/builtins/update_deps_command.rb @@ -30,18 +30,19 @@ def staleness_exempt? = true sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) - deps_rb = context.project_root / "dependencies.rb" + project_root = context.project!.root + deps_rb = project_root / "dependencies.rb" Dev::Deps.reset! Kernel.load(deps_rb.to_s) if deps_rb.exist? deps_config = Dev::Deps.last_config || Dev::Deps.define {} resolver = Dev::Deps::Resolver.new( repositories: Dev::Deps::Registry.repositories( - project_root: context.project_root, + project_root: project_root, ruby_version_requirement: deps_config.ruby_version_requirement, ), ) - lockfile = Dev::Deps::Lockfile.new(dir: context.project_root) + lockfile = Dev::Deps::Lockfile.new(dir: project_root) resolved = resolver.resolve(deps_config.declarations) # Record the manifest digest so the staleness check can tell whether # dependencies.rb changed after this resolution (Dev::Deps::Staleness). diff --git a/src/dev/execution_context.rb b/src/dev/execution_context.rb index bed350b..97c35f4 100644 --- a/src/dev/execution_context.rb +++ b/src/dev/execution_context.rb @@ -7,16 +7,40 @@ require_relative "runner_setup_config" module Dev - # Context passed to a command execution. Generic runtime context — - # individual command types use what they need. - class ExecutionContext < T::Struct - extend T::Sig - - const :ui, Dev::Cli::Ui + # The project half of an execution context: everything resolved from the + # enclosing dev.yml project. Absent entirely when no project encloses the + # cwd — commands that need it either only exist when it does (project + # builtins, yaml commands) or handle its absence as their own business + # logic (hybrids like `up`). + class ProjectContext < T::Struct + const :root, Pathname const :ruby_version, String const :python_version, T.nilable(String), default: nil - const :project_root, Pathname const :build_container, T.nilable(Dev::BuildContainerConfig), default: nil const :runner, T.nilable(Dev::RunnerSetupConfig), default: nil end + + # Context passed to a command execution: the host half (always present) + # plus the project half (nil outside any dev.yml project). Individual + # command types use what they need. + class ExecutionContext < T::Struct + extend T::Sig + + # `project!` was called with no project half. Project commands are + # registered only when a project exists, so reaching this is a dev bug — + # the raise makes the registration invariant explicit. + class ProjectRequiredError < StandardError; end + + const :ui, Dev::Cli::Ui + const :project, T.nilable(Dev::ProjectContext), default: nil + + # The project half, for commands that require a project. + # + # @return [Dev::ProjectContext] + # @raise [ProjectRequiredError] when no project encloses the run + sig { returns(Dev::ProjectContext) } + def project! + project || raise(ProjectRequiredError, "no project context — this command requires a dev.yml project") + end + end end diff --git a/src/dev/runner.rb b/src/dev/runner.rb index 6ed7aef..9931abc 100644 --- a/src/dev/runner.rb +++ b/src/dev/runner.rb @@ -95,11 +95,13 @@ def build_context manifest = @manifest_loader.with_toolchain(@manifest, project_root: Dev.target_project_root) ExecutionContext.new( ui: @ui, - ruby_version: ShadowenvRuby.resolve_ruby_version(manifest.declared_ruby_version), - python_version: manifest.declared_python_version, - project_root: Dev.target_project_root, - build_container: manifest.build_container, - runner: manifest.runner, + project: ProjectContext.new( + root: Dev.target_project_root, + ruby_version: ShadowenvRuby.resolve_ruby_version(manifest.declared_ruby_version), + python_version: manifest.declared_python_version, + build_container: manifest.build_container, + runner: manifest.runner, + ), ) end @@ -185,12 +187,13 @@ def build_command_service(manifest, context) # @return [CommandExecutor] sig { params(context: ExecutionContext).returns(CommandExecutor) } def build_executor(context) + project = context.project! command_runner = CommandRunner.new( ui: context.ui, - ruby_version: context.ruby_version, - python_version: context.python_version, - build_container: context.build_container, - project_root: context.project_root, + ruby_version: project.ruby_version, + python_version: project.python_version, + build_container: project.build_container, + project_root: project.root, ) builtin_executor = BuiltinExecutor.new project_executor = ProjectExecutor.new(command_runner:) diff --git a/test/dev/builtin_executor_test.rb b/test/dev/builtin_executor_test.rb index 1af163e..41f3474 100644 --- a/test/dev/builtin_executor_test.rb +++ b/test/dev/builtin_executor_test.rb @@ -31,7 +31,10 @@ def call(args:, context:) def build_context ui = typed_mock(Dev::Cli::Ui) - Dev::ExecutionContext.new(ui: ui, ruby_version: "4.0.1", project_root: Pathname.new("/tmp/builtin-executor")) + Dev::ExecutionContext.new( + ui: ui, + project: Dev::ProjectContext.new(root: Pathname.new("/tmp/builtin-executor"), ruby_version: "4.0.1"), + ) end test "execute runs the builtin's Ruby body in-process with args and context" do diff --git a/test/dev/builtins/cache_command_test.rb b/test/dev/builtins/cache_command_test.rb index 5ca76af..402f66c 100644 --- a/test/dev/builtins/cache_command_test.rb +++ b/test/dev/builtins/cache_command_test.rb @@ -61,7 +61,7 @@ class Dev::Builtins::CacheCommandTest < Minitest::Test command = build_command(gc) context = build_context(build_container: config) BuildContainer.stubs(:image_with_tag) - .with(config, project_root: context.project_root) + .with(config, project_root: context.project!.root) .returns("myregistry/myapp-linux:content-abc123") When "running cache gc" @@ -109,9 +109,11 @@ def build_command(gc) def build_context(build_container: nil) Dev::ExecutionContext.new( ui: typed_mock(Dev::Cli::Ui), - ruby_version: "4.0.1", - project_root: Pathname.new("/tmp/cache-test"), - build_container: build_container, + project: Dev::ProjectContext.new( + root: Pathname.new("/tmp/cache-test"), + ruby_version: "4.0.1", + build_container: build_container, + ), ) end end diff --git a/test/dev/builtins/cd_command_test.rb b/test/dev/builtins/cd_command_test.rb index 01e1b42..f2c445f 100644 --- a/test/dev/builtins/cd_command_test.rb +++ b/test/dev/builtins/cd_command_test.rb @@ -26,7 +26,8 @@ class Dev::Builtins::CdCommandTest < Minitest::Test def build_context Dev::ExecutionContext.new( - ui: typed_mock(Dev::Cli::Ui), ruby_version: "4.0.1", project_root: Pathname.new("/tmp/cd-test"), + ui: typed_mock(Dev::Cli::Ui), + project: Dev::ProjectContext.new(root: Pathname.new("/tmp/cd-test"), ruby_version: "4.0.1"), ) end end diff --git a/test/dev/builtins/check_command_test.rb b/test/dev/builtins/check_command_test.rb index db6d706..1b71c44 100644 --- a/test/dev/builtins/check_command_test.rb +++ b/test/dev/builtins/check_command_test.rb @@ -61,7 +61,8 @@ class Dev::Builtins::CheckCommandTest < Minitest::Test def build_context Dev::ExecutionContext.new( - ui: typed_mock(Dev::Cli::Ui), ruby_version: "4.0.1", project_root: Pathname.new("/tmp/check-test"), + ui: typed_mock(Dev::Cli::Ui), + project: Dev::ProjectContext.new(root: Pathname.new("/tmp/check-test"), ruby_version: "4.0.1"), ) end end diff --git a/test/dev/builtins/clone_command_test.rb b/test/dev/builtins/clone_command_test.rb index eb04e70..8b6aff0 100644 --- a/test/dev/builtins/clone_command_test.rb +++ b/test/dev/builtins/clone_command_test.rb @@ -26,7 +26,8 @@ class Dev::Builtins::CloneCommandTest < Minitest::Test def build_context Dev::ExecutionContext.new( - ui: typed_mock(Dev::Cli::Ui), ruby_version: "4.0.1", project_root: Pathname.new("/tmp/clone-test"), + ui: typed_mock(Dev::Cli::Ui), + project: Dev::ProjectContext.new(root: Pathname.new("/tmp/clone-test"), ruby_version: "4.0.1"), ) end end diff --git a/test/dev/builtins/config_command_test.rb b/test/dev/builtins/config_command_test.rb index 1bfdda1..480483a 100644 --- a/test/dev/builtins/config_command_test.rb +++ b/test/dev/builtins/config_command_test.rb @@ -26,7 +26,8 @@ class Dev::Builtins::ConfigCommandTest < Minitest::Test def build_context Dev::ExecutionContext.new( - ui: typed_mock(Dev::Cli::Ui), ruby_version: "4.0.1", project_root: Pathname.new("/tmp/config-test"), + ui: typed_mock(Dev::Cli::Ui), + project: Dev::ProjectContext.new(root: Pathname.new("/tmp/config-test"), ruby_version: "4.0.1"), ) end end diff --git a/test/dev/builtins/cred_command_test.rb b/test/dev/builtins/cred_command_test.rb index 0377dcc..cc7f86e 100644 --- a/test/dev/builtins/cred_command_test.rb +++ b/test/dev/builtins/cred_command_test.rb @@ -26,7 +26,8 @@ class Dev::Builtins::CredCommandTest < Minitest::Test def build_context Dev::ExecutionContext.new( - ui: typed_mock(Dev::Cli::Ui), ruby_version: "4.0.1", project_root: Pathname.new("/tmp/cred-test"), + ui: typed_mock(Dev::Cli::Ui), + project: Dev::ProjectContext.new(root: Pathname.new("/tmp/cred-test"), ruby_version: "4.0.1"), ) end end diff --git a/test/dev/builtins/deps_command_test.rb b/test/dev/builtins/deps_command_test.rb index 4096b15..db27cc2 100644 --- a/test/dev/builtins/deps_command_test.rb +++ b/test/dev/builtins/deps_command_test.rb @@ -58,6 +58,9 @@ class Dev::Builtins::DepsCommandTest < Minitest::Test private def build_context(project_root) - Dev::ExecutionContext.new(ui: typed_mock(Dev::Cli::Ui), ruby_version: "4.0.1", project_root: project_root) + Dev::ExecutionContext.new( + ui: typed_mock(Dev::Cli::Ui), + project: Dev::ProjectContext.new(root: project_root, ruby_version: "4.0.1"), + ) end end diff --git a/test/dev/builtins/help_command_test.rb b/test/dev/builtins/help_command_test.rb index 8ffbeff..3ab328e 100644 --- a/test/dev/builtins/help_command_test.rb +++ b/test/dev/builtins/help_command_test.rb @@ -65,7 +65,8 @@ def build_help(project_name: "testproject", usage_printer: typed_mock(Dev::Cli:: def build_context Dev::ExecutionContext.new( - ui: typed_mock(Dev::Cli::Ui), ruby_version: "4.0.1", project_root: Pathname.new("/tmp/help-test"), + ui: typed_mock(Dev::Cli::Ui), + project: Dev::ProjectContext.new(root: Pathname.new("/tmp/help-test"), ruby_version: "4.0.1"), ) end end diff --git a/test/dev/builtins/install_deps_command_test.rb b/test/dev/builtins/install_deps_command_test.rb index be30205..731f99c 100644 --- a/test/dev/builtins/install_deps_command_test.rb +++ b/test/dev/builtins/install_deps_command_test.rb @@ -119,6 +119,9 @@ def build_command end def build_context(project_root) - Dev::ExecutionContext.new(ui: typed_mock(Dev::Cli::Ui), ruby_version: "4.0.1", project_root: project_root) + Dev::ExecutionContext.new( + ui: typed_mock(Dev::Cli::Ui), + project: Dev::ProjectContext.new(root: project_root, ruby_version: "4.0.1"), + ) end end diff --git a/test/dev/builtins/learnings_command_test.rb b/test/dev/builtins/learnings_command_test.rb index 000bafb..eb530bc 100644 --- a/test/dev/builtins/learnings_command_test.rb +++ b/test/dev/builtins/learnings_command_test.rb @@ -41,6 +41,9 @@ class Dev::Builtins::LearningsCommandTest < Minitest::Test private def build_context(project_root) - Dev::ExecutionContext.new(ui: typed_mock(Dev::Cli::Ui), ruby_version: "4.0.1", project_root: project_root) + Dev::ExecutionContext.new( + ui: typed_mock(Dev::Cli::Ui), + project: Dev::ProjectContext.new(root: project_root, ruby_version: "4.0.1"), + ) end end diff --git a/test/dev/builtins/plan_command_test.rb b/test/dev/builtins/plan_command_test.rb index 3cd9676..b2d7db5 100644 --- a/test/dev/builtins/plan_command_test.rb +++ b/test/dev/builtins/plan_command_test.rb @@ -41,6 +41,9 @@ class Dev::Builtins::PlanCommandTest < Minitest::Test private def build_context(project_root) - Dev::ExecutionContext.new(ui: typed_mock(Dev::Cli::Ui), ruby_version: "4.0.1", project_root: project_root) + Dev::ExecutionContext.new( + ui: typed_mock(Dev::Cli::Ui), + project: Dev::ProjectContext.new(root: project_root, ruby_version: "4.0.1"), + ) end end diff --git a/test/dev/builtins/provide_image_command_test.rb b/test/dev/builtins/provide_image_command_test.rb index be16d1c..519366e 100644 --- a/test/dev/builtins/provide_image_command_test.rb +++ b/test/dev/builtins/provide_image_command_test.rb @@ -78,9 +78,11 @@ class Dev::Builtins::ProvideImageCommandTest < Minitest::Test def build_context(build_container) Dev::ExecutionContext.new( ui: typed_mock(Dev::Cli::Ui), - ruby_version: "4.0.1", - project_root: Pathname.new("/tmp/provide-image-test"), - build_container: build_container, + project: Dev::ProjectContext.new( + root: Pathname.new("/tmp/provide-image-test"), + ruby_version: "4.0.1", + build_container: build_container, + ), ) end end diff --git a/test/dev/builtins/reset_container_command_test.rb b/test/dev/builtins/reset_container_command_test.rb index b35a2e7..4bd5812 100644 --- a/test/dev/builtins/reset_container_command_test.rb +++ b/test/dev/builtins/reset_container_command_test.rb @@ -56,9 +56,11 @@ class Dev::Builtins::ResetContainerCommandTest < Minitest::Test def build_context(build_container) Dev::ExecutionContext.new( ui: typed_mock(Dev::Cli::Ui), - ruby_version: "4.0.1", - project_root: Pathname.new("/tmp/reset-container-test"), - build_container: build_container, + project: Dev::ProjectContext.new( + root: Pathname.new("/tmp/reset-container-test"), + ruby_version: "4.0.1", + build_container: build_container, + ), ) end end diff --git a/test/dev/builtins/runner_setup_command_test.rb b/test/dev/builtins/runner_setup_command_test.rb index 5d69ed3..1f4e90f 100644 --- a/test/dev/builtins/runner_setup_command_test.rb +++ b/test/dev/builtins/runner_setup_command_test.rb @@ -109,9 +109,11 @@ def build_recording_command def build_context(runner) Dev::ExecutionContext.new( ui: typed_mock(Dev::Cli::Ui), - ruby_version: "4.0.1", - project_root: Pathname.new("/tmp/runner-setup-test"), - runner: runner, + project: Dev::ProjectContext.new( + root: Pathname.new("/tmp/runner-setup-test"), + ruby_version: "4.0.1", + runner: runner, + ), ) end end diff --git a/test/dev/builtins/up_command_test.rb b/test/dev/builtins/up_command_test.rb index 21a3e6a..97bb3d7 100644 --- a/test/dev/builtins/up_command_test.rb +++ b/test/dev/builtins/up_command_test.rb @@ -119,9 +119,11 @@ def container_config(build_args:) def build_context(build_container: nil) Dev::ExecutionContext.new( ui: typed_mock(Dev::Cli::Ui), - ruby_version: "4.0.1", - project_root: Pathname.new("/tmp/up-test"), - build_container: build_container, + project: Dev::ProjectContext.new( + root: Pathname.new("/tmp/up-test"), + ruby_version: "4.0.1", + build_container: build_container, + ), ) end end diff --git a/test/dev/builtins/update_deps_command_test.rb b/test/dev/builtins/update_deps_command_test.rb index d68cbc1..47be8a7 100644 --- a/test/dev/builtins/update_deps_command_test.rb +++ b/test/dev/builtins/update_deps_command_test.rb @@ -64,6 +64,9 @@ class Dev::Builtins::UpdateDepsCommandTest < Minitest::Test private def build_context(project_root) - Dev::ExecutionContext.new(ui: typed_mock(Dev::Cli::Ui), ruby_version: "4.0.1", project_root: project_root) + Dev::ExecutionContext.new( + ui: typed_mock(Dev::Cli::Ui), + project: Dev::ProjectContext.new(root: project_root, ruby_version: "4.0.1"), + ) end end diff --git a/test/dev/command_executor_test.rb b/test/dev/command_executor_test.rb index 3c59565..9f834a8 100644 --- a/test/dev/command_executor_test.rb +++ b/test/dev/command_executor_test.rb @@ -23,7 +23,10 @@ def call(args:, context:); end def build_context ui = typed_mock(Dev::Cli::Ui) - Dev::ExecutionContext.new(ui: ui, ruby_version: "4.0.1", project_root: Pathname.new("/tmp/executor-test")) + Dev::ExecutionContext.new( + ui: ui, + project: Dev::ProjectContext.new(root: Pathname.new("/tmp/executor-test"), ruby_version: "4.0.1"), + ) end # Strategy mocks are strict: any message a test doesn't expect is an diff --git a/test/dev/command_service_test.rb b/test/dev/command_service_test.rb index 9f18b09..37ddb8c 100644 --- a/test/dev/command_service_test.rb +++ b/test/dev/command_service_test.rb @@ -60,8 +60,7 @@ def build_executor def fake_context Dev::ExecutionContext.new( ui: typed_mock(Dev::Cli::Ui), - ruby_version: "4.0.1", - project_root: Pathname.new("/tmp/service-test"), + project: Dev::ProjectContext.new(root: Pathname.new("/tmp/service-test"), ruby_version: "4.0.1"), ) end diff --git a/test/dev/execution_context_test.rb b/test/dev/execution_context_test.rb new file mode 100644 index 0000000..d1882d7 --- /dev/null +++ b/test/dev/execution_context_test.rb @@ -0,0 +1,30 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/execution_context" + +transform!(RSpock::AST::Transformation) +class Dev::ExecutionContextTest < Minitest::Test + include SorbetHelper + + test "project! unwraps the project half when one exists" do + Given "a context with a project half" + project = Dev::ProjectContext.new(root: Pathname.new("/tmp/ctx-test"), ruby_version: "4.0.1") + context = Dev::ExecutionContext.new(ui: typed_mock(Dev::Cli::Ui), project: project) + + Expect "the non-nil project" + context.project!.equal?(project) + end + + test "project! raises ProjectRequiredError outside a project" do + Given "a context with no project half" + context = Dev::ExecutionContext.new(ui: typed_mock(Dev::Cli::Ui)) + + When "unwrapping the project" + context.project! + + Then + raises Dev::ExecutionContext::ProjectRequiredError + end +end diff --git a/test/dev/overridden_executor_test.rb b/test/dev/overridden_executor_test.rb index 41025cc..a4010fe 100644 --- a/test/dev/overridden_executor_test.rb +++ b/test/dev/overridden_executor_test.rb @@ -29,7 +29,10 @@ def call(args:, context:); end def build_context ui = typed_mock(Dev::Cli::Ui) - Dev::ExecutionContext.new(ui: ui, ruby_version: "4.0.1", project_root: Pathname.new("/tmp/overridden-executor")) + Dev::ExecutionContext.new( + ui: ui, + project: Dev::ProjectContext.new(root: Pathname.new("/tmp/overridden-executor"), ruby_version: "4.0.1"), + ) end def build_command(stamps:) diff --git a/test/dev/runner_test.rb b/test/dev/runner_test.rb index 68fd429..24e6c83 100644 --- a/test/dev/runner_test.rb +++ b/test/dev/runner_test.rb @@ -308,9 +308,9 @@ class RunnerTest < Minitest::Test cmd_name == "test" args == ["--fast"] context.ui == ui - context.ruby_version == "9.9.9" - context.python_version == "3.12" - context.project_root == root + context.project!.ruby_version == "9.9.9" + context.project!.python_version == "3.12" + context.project!.root == root Cleanup FileUtils.rm_rf(root) From 9ade632c736527ac673443872e96e175b3dd0ab3 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Tue, 25 Aug 2026 09:38:47 -0400 Subject: [PATCH 12/16] UpCommand owns its no-project behavior up is a hybrid command: the host half (converge + cd RC hook) always runs; the project half provisions only when a project context exists, otherwise up prints the fresh-box bootstrap message and succeeds. This is the business logic bin/dev's rescue was homing at the wrong layer. Co-authored-by: Cursor --- src/dev/builtins/up_command.rb | 20 +++++++++++++---- test/dev/builtins/up_command_test.rb | 32 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/dev/builtins/up_command.rb b/src/dev/builtins/up_command.rb index 0eac987..c26b25b 100644 --- a/src/dev/builtins/up_command.rb +++ b/src/dev/builtins/up_command.rb @@ -14,6 +14,11 @@ module Builtins # provisioning. Projects with only a dependencies.rb get `dev up` for # free. `up` also ensures the `dev cd` shell hook (idempotent) — # provisioning is where dev's RC hooks land, next to the shadowenv one. + # + # `up` is a hybrid command: its host half (converge + RC hook) always + # runs, and its project half requires the project context. Outside any + # project the host half IS the fresh-box bootstrap — install dev, + # `dev up`, ready — so a nil project is a supported state, not an error. class UpCommand < BuiltinCommand extend T::Sig @@ -53,8 +58,15 @@ def call(args:, context:) # + org Brewfile): project installs may lean on host tools (gh, # rbenv). Warn-only — never blocks the project. @host_converge.run - provision_build_credentials(context) @hook_installer.ensure_installed + project = context.project + if project.nil? + puts "dev: host layer converged." + puts "dev: no dev.yml here — run dev up inside a project to provision it too." + return + end + + provision_build_credentials(project) @install_deps_command.call(args:, context:) end @@ -64,9 +76,9 @@ def call(args:, context:) # command should work unattended. Resolving docker build args here # (prompting and storing credentials on first run) keeps the lazily # triggered image build in containerized commands non-interactive. - sig { params(context: ExecutionContext).void } - def provision_build_credentials(context) - config = context.project!.build_container + sig { params(project: ProjectContext).void } + def provision_build_credentials(project) + config = project.build_container return if config.nil? || config.build_args.empty? Dev::Credentials.resolve_build_args(config.build_args) diff --git a/test/dev/builtins/up_command_test.rb b/test/dev/builtins/up_command_test.rb index 97bb3d7..71d7472 100644 --- a/test/dev/builtins/up_command_test.rb +++ b/test/dev/builtins/up_command_test.rb @@ -7,6 +7,7 @@ require "dev/build_container_config" require "dev/credentials" require "pathname" +require "stringio" transform!(RSpock::AST::Transformation) class Dev::Builtins::UpCommandTest < Minitest::Test @@ -58,6 +59,28 @@ class Dev::Builtins::UpCommandTest < Minitest::Test true end + test "call without a project converges the host half and skips provisioning" do + Given "a projectless context and collaborators expecting only host work" + host_converge = typed_mock(Dev::Host::Converge) + host_converge.expects(:run).once + hook_installer = typed_mock(Dev::Cd::HookInstaller) + hook_installer.expects(:ensure_installed).once.returns(:appended) + install_deps = typed_mock(Dev::Builtins::InstallDepsCommand) + command = Dev::Builtins::UpCommand.new( + install_deps_command: install_deps, hook_installer: hook_installer, host_converge: host_converge, + ) + context = Dev::ExecutionContext.new(ui: typed_mock(Dev::Cli::Ui)) + + When "running up outside any project" + stdout = capture_stdout { command.call(args: [], context: context) } + + Then "install-deps and credentials never run, and the bootstrap message points at projects" + 0 * install_deps.call(args: anything, context: anything) + 0 * Dev::Credentials.resolve_build_args(anything) + stdout.include?("dev: host layer converged.") + stdout.include?("no dev.yml here — run dev up inside a project to provision it too") + end + test "call resolves docker build arg credentials before anything else" do Given "a context whose build container declares build_args" command = build_command @@ -116,6 +139,15 @@ def container_config(build_args:) Dev::BuildContainerConfig.new(image: "myapp-linux", registry: "myregistry", build_args: build_args) end + def capture_stdout + old_stdout = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = old_stdout + end + def build_context(build_container: nil) Dev::ExecutionContext.new( ui: typed_mock(Dev::Cli::Ui), From 763b413ef11a9b294d359d08939aaf3ffc61c33b Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Tue, 25 Aug 2026 09:52:24 -0400 Subject: [PATCH 13/16] Make the Runner project-optional; bin/dev handles nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Runner now constructs without a dev.yml: manifest resolution moves into run (inside the exit_for mapping, where project-input errors like the removed ruby: key already map to clean exits), the projectless catalog registers only up (backed by a builtin-only executor and a null dependency service), and a lookup miss maps to the no-dev.yml refusal. bin/dev's rescue block — host converge for up, the refusal for everything else — was this exact logic homed at the wrong layer; it is deleted, not moved. Co-authored-by: Cursor --- bin/dev | 25 +-------- src/dev.rb | 17 ++++-- src/dev/command_executor.rb | 48 +++++++++++++--- src/dev/dependency_service.rb | 26 +++++++++ src/dev/runner.rb | 85 +++++++++++++++++++++-------- test/dev/command_executor_test.rb | 27 +++++++++ test/dev/dependency_service_test.rb | 18 ++++++ test/dev/runner_test.rb | 74 +++++++++++++++++++++++++ 8 files changed, 262 insertions(+), 58 deletions(-) diff --git a/bin/dev b/bin/dev index 5efc0d4..6531b93 100755 --- a/bin/dev +++ b/bin/dev @@ -56,25 +56,6 @@ else Dev::Cli::NoUi.new end -begin - Dev::Runner.new(ui: ui).run(ARGV) -rescue Dev::DevYamlNotFoundError - # `dev up` stays valid outside any project: it converges the host layer - # only (self-update + org Brewfile), which IS the fresh-box bootstrap — - # install dev, `dev up`, ready. Everything else still needs a project. - if ARGV.first == "up" - require "dev/host/converge" - Dev::Host::Converge.new.run - puts "dev: host layer converged." - puts "dev: no dev.yml here — run dev up inside a project to provision it too." - exit 0 - end - warn "dev: no dev.yml found in this directory or any parent." - warn "Run dev from inside a project that defines a dev.yml." - exit 1 -rescue Dev::ProjectManifestLoader::UnsupportedDevYamlRubyError => e - # Rejected at parse time (Runner construction), before the run loop's own - # rescue-to-exit mapping can see it. - warn "dev: #{e.message}" - exit 1 -end +# The Runner is project-optional and owns the rescue-to-exit mapping of the +# CLI boundary; nothing to handle here. +Dev::Runner.new(ui: ui).run(ARGV) diff --git a/src/dev.rb b/src/dev.rb index 2de83ee..298998f 100644 --- a/src/dev.rb +++ b/src/dev.rb @@ -20,9 +20,12 @@ class DevYamlNotFoundError < StandardError; end class << self extend T::Sig - # Pathname of dev.yml current working directory. Walks back parents until it finds a dev.yml file. Memoized on first call. - sig { returns(Pathname) } - def dev_yaml_file + # Pathname of the dev.yml enclosing the current working directory, or + # nil when no parent carries one — dev's project-optional entry point + # (running outside any project is a supported state, not an error). + # Memoized on first hit. + sig { returns(T.nilable(Pathname)) } + def find_dev_yaml_file @dev_yaml_file = T.let(@dev_yaml_file, T.nilable(Pathname)) return @dev_yaml_file if @dev_yaml_file @@ -34,11 +37,15 @@ def dev_yaml_file break end end - raise DevYamlNotFoundError unless result - @dev_yaml_file = result end + # Pathname of the enclosing dev.yml, for callers that require a project. + sig { returns(Pathname) } + def dev_yaml_file + find_dev_yaml_file || raise(DevYamlNotFoundError) + end + # Target project root (directory containing dev.yml). # # Resolved lazily rather than as a load-time constant: requiring "dev" from diff --git a/src/dev/command_executor.rb b/src/dev/command_executor.rb index 84e6efc..c5eed91 100644 --- a/src/dev/command_executor.rb +++ b/src/dev/command_executor.rb @@ -16,20 +16,30 @@ module Dev class CommandExecutor extend T::Sig + # A project or overridden command reached a composite wired without its + # project arms. Those variants are only registered when a project + # exists, and the projectless wiring is builtin-only — so reaching this + # is a dev wiring bug; the raise keeps the invariant explicit. + class ProjectExecutionUnavailableError < StandardError; end + + # The project arms are optional: outside any project (no dev.yml) the + # repository holds only builtins, so the projectless wiring passes just + # the builtin arm. + # # @param builtin_executor [BuiltinExecutor] - # @param project_executor [ProjectExecutor] - # @param overridden_executor [OverriddenExecutor] + # @param project_executor [ProjectExecutor, nil] + # @param overridden_executor [OverriddenExecutor, nil] sig do params( builtin_executor: BuiltinExecutor, - project_executor: ProjectExecutor, - overridden_executor: OverriddenExecutor, + project_executor: T.nilable(ProjectExecutor), + overridden_executor: T.nilable(OverriddenExecutor), ).void end - def initialize(builtin_executor:, project_executor:, overridden_executor:) + def initialize(builtin_executor:, project_executor: nil, overridden_executor: nil) @builtin_executor = T.let(builtin_executor, BuiltinExecutor) - @project_executor = T.let(project_executor, ProjectExecutor) - @overridden_executor = T.let(overridden_executor, OverriddenExecutor) + @project_executor = T.let(project_executor, T.nilable(ProjectExecutor)) + @overridden_executor = T.let(overridden_executor, T.nilable(OverriddenExecutor)) end # Dispatch one command to its strategy. @@ -39,15 +49,17 @@ def initialize(builtin_executor:, project_executor:, overridden_executor:) # @param context [ExecutionContext] # @return [void] # @raise [CommandRunner::CommandFailedError] when a waited child fails + # @raise [ProjectExecutionUnavailableError] when a project-scoped variant + # reaches a builtin-only composite (a wiring bug) sig { params(command: Command, args: T::Array[String], context: ExecutionContext).void } def execute(command, args:, context:) case command when BuiltinCommand @builtin_executor.execute(command, args:, context:) when ProjectCommand - @project_executor.exec_into(command, args:) + project_executor.exec_into(command, args:) when OverriddenCommand - @overridden_executor.execute(command, args:, context:) + overridden_executor.execute(command, args:, context:) else # :nocov: — the sealed hierarchy leaves no fourth variant to # construct, so this arm is unreachable at runtime; T.absurd keeps @@ -56,5 +68,23 @@ def execute(command, args:, context:) # :nocov: end end + + private + + # @return [ProjectExecutor] + # @raise [ProjectExecutionUnavailableError] + sig { returns(ProjectExecutor) } + def project_executor + @project_executor || + raise(ProjectExecutionUnavailableError, "project command dispatched to a builtin-only executor") + end + + # @return [OverriddenExecutor] + # @raise [ProjectExecutionUnavailableError] + sig { returns(OverriddenExecutor) } + def overridden_executor + @overridden_executor || + raise(ProjectExecutionUnavailableError, "overridden command dispatched to a builtin-only executor") + end end end diff --git a/src/dev/dependency_service.rb b/src/dev/dependency_service.rb index b90c8f4..daed497 100644 --- a/src/dev/dependency_service.rb +++ b/src/dev/dependency_service.rb @@ -58,4 +58,30 @@ def lock! @staleness.stamp_installed! end end + + # The null service for runs outside any project: with no dev.yml there is + # no dependency state to check or stamp, so the guard and the stamp are + # honest no-ops — the command pipeline stays uniform instead of branching + # on project presence at every staleness touchpoint. + class NoProjectDependencyService < DependencyService + extend T::Sig + + # No staleness collaborator: there is no project to be stale about. + sig { void } + def initialize; end + + # @return [Array] always empty + sig { returns(T::Array[String]) } + def messages + [] + end + + # @return [void] + sig { void } + def guard!; end + + # @return [void] + sig { void } + def lock!; end + end end diff --git a/src/dev/runner.rb b/src/dev/runner.rb index 9931abc..fb6b298 100644 --- a/src/dev/runner.rb +++ b/src/dev/runner.rb @@ -26,6 +26,12 @@ module Dev # command onion: route argv to a command name (bare/--help/-h mean help), # assemble the ExecutionContext, wire the service graph, make one call # into CommandService, and map rescues to exits at the CLI boundary. + # + # The Runner is project-optional: with no enclosing dev.yml it still runs, + # over the projectless catalog (just `up`, the fresh-box bootstrap) and a + # context with no project half. Which commands exist is a registration + # concern owned here; whether a command handles a missing project is the + # command's own business logic. class Runner extend T::Sig @@ -33,7 +39,7 @@ class Runner params( ui: Dev::Cli::Ui, out: T.any(IO, StringIO), - dev_yaml_path: Pathname, + dev_yaml_path: T.nilable(Pathname), manifest_loader: ProjectManifestLoader, command_service: T.nilable(CommandService), ).void @@ -41,30 +47,32 @@ class Runner def initialize( ui:, out: $stdout, - dev_yaml_path: Dev.dev_yaml_file, + dev_yaml_path: Dev.find_dev_yaml_file, manifest_loader: ProjectManifestLoader.new, command_service: nil ) @ui = T.let(ui, Dev::Cli::Ui) @out = T.let(out, T.any(IO, StringIO)) + @dev_yaml_path = T.let(dev_yaml_path, T.nilable(Pathname)) @manifest_loader = T.let(manifest_loader, ProjectManifestLoader) - @manifest = T.let(manifest_loader.load(dev_yaml_path), ProjectManifest) @command_service = T.let(command_service, T.nilable(CommandService)) end # Runs the dev command specified by the given argv. # # Composition happens here rather than in the constructor so that - # everything — including the toolchain pass over dependencies.rb, which - # is arbitrary project Ruby — stays inside the exit_for error mapping. + # everything — the dev.yml parse and the toolchain pass over + # dependencies.rb, both arbitrary project input — stays inside the + # exit_for error mapping. # # @param argv [Array[String]] The argv to run the command with. # @return [void] sig { params(argv: T::Array[String]).void } def run(argv) cmd_name, args = route(argv) - context = build_context - service = @command_service || build_command_service(@manifest, context) + manifest = @dev_yaml_path && @manifest_loader.load(@dev_yaml_path) + context = build_context(manifest) + service = @command_service || build_command_service(manifest, context) service.execute(cmd_name, args:, context:) rescue StandardError => e exit_for(e) @@ -86,13 +94,17 @@ def route(argv) [T.must(args.shift), args] end - # Assemble the per-run ExecutionContext. The toolchain pass over - # dependencies.rb runs unconditionally here, once per invocation. + # Assemble the per-run ExecutionContext: always the host half; the + # project half only when a manifest exists (the toolchain pass over + # dependencies.rb runs there, once per invocation). # + # @param manifest [ProjectManifest, nil] # @return [ExecutionContext] - sig { returns(ExecutionContext) } - def build_context - manifest = @manifest_loader.with_toolchain(@manifest, project_root: Dev.target_project_root) + sig { params(manifest: T.nilable(ProjectManifest)).returns(ExecutionContext) } + def build_context(manifest) + return ExecutionContext.new(ui: @ui) if manifest.nil? + + manifest = @manifest_loader.with_toolchain(manifest, project_root: Dev.target_project_root) ExecutionContext.new( ui: @ui, project: ProjectContext.new( @@ -105,9 +117,8 @@ def build_context ) end - # The rescue-to-exit mapping of the CLI boundary, in one place — the - # counterpart of bin/dev's DevYamlNotFoundError handling. Errors keep - # their native namespaces all the way up here (no service-layer + # The rescue-to-exit mapping of the CLI boundary, in one place. Errors + # keep their native namespaces all the way up here (no service-layer # wrapping); anything unmapped is a dev bug and re-raises with its # backtrace. # @@ -131,8 +142,16 @@ def exit_for(error) $stderr.puts "dev: #{error}" Kernel.exit(127) when CommandRepository::CommandNotFoundError - $stderr.puts "dev: #{error}" - $stderr.puts "Run 'dev' or 'dev --help' to see available commands." + # Outside a project the real gap is the missing dev.yml, not the + # particular name that failed to resolve against the tiny + # projectless catalog. + if @dev_yaml_path.nil? + $stderr.puts "dev: no dev.yml found in this directory or any parent." + $stderr.puts "Run dev from inside a project that defines a dev.yml." + else + $stderr.puts "dev: #{error}" + $stderr.puts "Run 'dev' or 'dev --help' to see available commands." + end Kernel.exit(1) when ArgumentError, RuntimeError $stderr.puts "dev: #{error}" @@ -144,15 +163,17 @@ def exit_for(error) # The composition root: the one place the repository (consumed only by # CommandService, the onion rule) and the builtin set are constructed. - # Which builtins exist is config-gated here — runner-setup only with a - # `runner:` block, provide-image/reset-container only with a build - # container. + # Which builtins exist is config-gated here — project builtins only with + # a manifest, runner-setup only with a `runner:` block, + # provide-image/reset-container only with a build container. # - # @param manifest [ProjectManifest] + # @param manifest [ProjectManifest, nil] # @param context [ExecutionContext] # @return [CommandService] - sig { params(manifest: ProjectManifest, context: ExecutionContext).returns(CommandService) } + sig { params(manifest: T.nilable(ProjectManifest), context: ExecutionContext).returns(CommandService) } def build_command_service(manifest, context) + return build_projectless_command_service if manifest.nil? + dependency_service = DependencyService.new( staleness: Dev::Deps::Staleness.new(project_root: Dev.target_project_root), ) @@ -178,6 +199,26 @@ def build_command_service(manifest, context) service end + # The projectless catalog: `up` is the one command that exists without a + # project (its host half is the fresh-box bootstrap — install dev, `dev + # up`, ready). The truly global commands (cd, clone, cred, ...) are + # dispatched before the Runner; everything else requires the project, so + # it simply isn't registered — a lookup miss maps to the no-dev.yml + # refusal in exit_for. + # + # @return [CommandService] + sig { returns(CommandService) } + def build_projectless_command_service + CommandService.new( + repository: CommandRepository.new( + builtins: { "up" => Builtins::UpCommand.new(install_deps_command: Builtins::InstallDepsCommand.new) }, + project_commands: {}, + ), + executor: CommandExecutor.new(builtin_executor: BuiltinExecutor.new), + dependency_service: NoProjectDependencyService.new, + ) + end + # Wire the executor composite: one CommandRunner (built from the run's # context, the process boundary's collaborators), one BuiltinExecutor, # and one ProjectExecutor, shared with the OverriddenExecutor that diff --git a/test/dev/command_executor_test.rb b/test/dev/command_executor_test.rb index 9f834a8..561399a 100644 --- a/test/dev/command_executor_test.rb +++ b/test/dev/command_executor_test.rb @@ -71,6 +71,33 @@ def build_strategies true end + test "a project command against a builtin-only composite is a wiring bug" do + Given "a composite wired without project arms (the projectless wiring)" + command = Dev::ProjectCommand.new(run: "./bin/test.sh", desc: "Run tests", container: false) + executor = Dev::CommandExecutor.new(builtin_executor: typed_mock(Dev::BuiltinExecutor)) + + When "executing" + executor.execute(command, args: [], context: build_context) + + Then + raises Dev::CommandExecutor::ProjectExecutionUnavailableError + end + + test "an overridden command against a builtin-only composite is a wiring bug" do + Given "a composite wired without project arms (the projectless wiring)" + command = Dev::OverriddenCommand.new( + builtin: FakeBuiltin.new, + project: Dev::ProjectCommand.new(run: "./bin/up.rb", desc: "Setup", container: false), + ) + executor = Dev::CommandExecutor.new(builtin_executor: typed_mock(Dev::BuiltinExecutor)) + + When "executing" + executor.execute(command, args: [], context: build_context) + + Then + raises Dev::CommandExecutor::ProjectExecutionUnavailableError + end + test "an overridden command dispatches to the overridden strategy" do Given "a composite whose overridden strategy expects the dispatch" command = Dev::OverriddenCommand.new( diff --git a/test/dev/dependency_service_test.rb b/test/dev/dependency_service_test.rb index 4f43039..e490f66 100644 --- a/test/dev/dependency_service_test.rb +++ b/test/dev/dependency_service_test.rb @@ -17,6 +17,24 @@ class Dev::DependencyServiceTest < Minitest::Test service.messages == ["lockfiles are stale"] end + test "the no-project service has nothing to guard, report, or stamp" do + Given "the null service for runs outside any project" + service = Dev::NoProjectDependencyService.new + old_stderr = $stderr + $stderr = StringIO.new + + When "running both lifecycle calls" + service.guard! + service.lock! + + Then "no messages, no warnings, no raise" + service.messages == [] + $stderr.string.empty? + + Cleanup + $stderr = old_stderr + end + test "guard! is silent when everything is in sync" do Given "an in-sync staleness" service = build_service(messages: []) diff --git a/test/dev/runner_test.rb b/test/dev/runner_test.rb index 24e6c83..cdaac68 100644 --- a/test/dev/runner_test.rb +++ b/test/dev/runner_test.rb @@ -316,6 +316,80 @@ class RunnerTest < Minitest::Test FileUtils.rm_rf(root) end + test "run without a dev.yml assembles a projectless context" do + Given "a Runner constructed with no dev.yml anywhere" + contexts = [] + command_service = typed_mock(Dev::CommandService) + command_service.stubs(:execute).with { |cmd_name, args:, context:| + contexts << [cmd_name, context] + true } + runner = Dev::Runner.new(dev_yaml_path: nil, ui: fake_ui, command_service: command_service) + + When "running up" + runner.run(["up"]) + + Then "the service got a context with a ui and no project half" + cmd_name, context = contexts.fetch(0) + cmd_name == "up" + context.project.nil? + end + + test "a project command without a dev.yml maps to the no-dev.yml refusal" do + Given "a Runner with no dev.yml, over its real service graph" + runner = Dev::Runner.new(dev_yaml_path: nil, ui: fake_ui, out: StringIO.new) + old_stderr = $stderr + $stderr = StringIO.new + Kernel.expects(:exit).with(1).once + + When "running a project command" + runner.run(["test"]) + + Then "the refusal names the missing dev.yml" + $stderr.string.include?("no dev.yml found in this directory or any parent") + $stderr.string.include?("Run dev from inside a project that defines a dev.yml.") + + Cleanup + $stderr = old_stderr + end + + test "project builtins are not registered without a dev.yml" do + Given "a Runner with no dev.yml, over its real service graph" + runner = Dev::Runner.new(dev_yaml_path: nil, ui: fake_ui, out: StringIO.new) + old_stderr = $stderr + $stderr = StringIO.new + Kernel.expects(:exit).with(1).once + + When "running a project-scoped builtin" + runner.run(["install-deps"]) + + Then "the lookup fails like any other command outside a project" + $stderr.string.include?("no dev.yml found in this directory or any parent") + + Cleanup + $stderr = old_stderr + end + + test "a dev.yml with the removed ruby: key maps to a clean error inside run" do + Given "a Runner over a dev.yml that still carries ruby:" + tmp = Tempfile.new(["dev", ".yml"]) + tmp.write(YAML.dump({ "name" => "testproject", "ruby" => "3.3.0", "commands" => {} })) + tmp.flush + runner = Dev::Runner.new(dev_yaml_path: Pathname.new(tmp.path), ui: fake_ui, out: StringIO.new) + old_stderr = $stderr + $stderr = StringIO.new + Kernel.expects(:exit).with(1).once + + When "running any command" + runner.run(["test"]) + + Then "the migration message reaches stderr as a dev: error" + $stderr.string.include?("dev.yml `ruby:` is no longer supported") + + Cleanup + $stderr = old_stderr + tmp.close! + end + test "a failed waited child exits with the child's status" do Given "a Runner whose service raises the child's failure" command_service = typed_mock(Dev::CommandService) From 7a63da32569389fb05cb2bafe08c61b2d54e10cd Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Tue, 25 Aug 2026 09:54:04 -0400 Subject: [PATCH 14/16] Update the command-dispatch skill for the project-optional Runner Co-authored-by: Cursor --- .../skills/architecture/command-dispatch/SKILL.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.cursor/skills/architecture/command-dispatch/SKILL.md b/.cursor/skills/architecture/command-dispatch/SKILL.md index 5f6dfb2..675d9e4 100644 --- a/.cursor/skills/architecture/command-dispatch/SKILL.md +++ b/.cursor/skills/architecture/command-dispatch/SKILL.md @@ -14,11 +14,14 @@ routes argv through two layers: (`src/dev/global_dispatch.rb`) runs first, before any dev.yml lookup, so `cd`, `plan`, `cred`, and `learnings` work from any directory. Each owns host- or workspace-global state, never project config. -2. **Project commands** — everything else builds `Dev::Runner` - (`src/dev/runner.rb`), which requires a dev.yml in the cwd's ancestry - (`DevYamlNotFoundError` at the CLI boundary) and runs the - yaml-declared command, plus project builtins like `up` / - `install-deps`. +2. **Everything else** — builds `Dev::Runner` (`src/dev/runner.rb`), the + project-optional composition root. With an enclosing dev.yml it runs + the yaml-declared command plus the project builtins (`install-deps`, + `deps`, `cache`, ...). Without one, the catalog is just `up` — a + hybrid whose host half (converge + cd RC hook) always runs and whose + project half needs the project (`ExecutionContext#project`, nil + outside a project) — and any other lookup maps to the no-dev.yml + refusal in `Runner#exit_for`. `bin/dev` itself rescues nothing. The seams: From 90b373a97c92376cc1c14f04ec20f75f4fa41ddd Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 28 Aug 2026 17:56:45 -0400 Subject: [PATCH 15/16] Gather host convergence into Dev::HostService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host::Converge was a single-#run method object, and "what keeping a host converged consists of" lived in no class — up hand-assembled converge + RC hook while plan/learnings hand-assembled skills + learnings sync. HostService names that responsibility with four domain verbs (converge_tooling, install_rc_hook, install_skills, sync_learnings); the brew layer becomes converge_tooling's private implementation. Co-authored-by: Cursor --- README.md | 2 +- lib/dev/host/converge.rb | 164 -------------- lib/dev/host_service.rb | 211 ++++++++++++++++++ lib/dev/settings.rb | 2 +- src/dev/builtins/up_command.rb | 16 +- test/dev/builtins/up_command_test.rb | 54 ++--- .../converge_test.rb => host_service_test.rb} | 203 +++++++++++------ 7 files changed, 380 insertions(+), 272 deletions(-) delete mode 100644 lib/dev/host/converge.rb create mode 100644 lib/dev/host_service.rb rename test/dev/{host/converge_test.rb => host_service_test.rb} (59%) diff --git a/README.md b/README.md index 29b47b2..79ea54e 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ Alongside per-project dependencies, an org converges **host tooling** — the or - **Private taps:** Brewfiles natively support `tap` entries, including private taps over authenticated git — sensitive tooling goes in a private tap the Brewfile references. `gh auth login` must precede `dev up` in that case (the failure mode is brew's own clear git-auth error). - **Trust model:** a Brewfile is brew-evaluated Ruby DSL, so converging it executes org-authored code — the same trust already granted by installing the org's deployment formula. dev adds no new trust surface: the file lives in the brew prefix at a fixed path, never a user-supplied one, and brew's tap-trust gate covers formulas from untrusted taps. -On every `dev up`, before project provisioning, `Dev::Host::Converge` runs the host layer: **`brew update`**, a **scoped `brew upgrade` of the `deployment_formula`** the deployment named in its own `config.yml` (falling back to `dev-core` for tapless individuals; skipped entirely for source checkouts — never a blanket `brew upgrade` of unrelated packages), then **`brew bundle install`** against the Brewfile when one exists. dev adds no throttle of its own — the no-op steps are sub-second, and brew's `HOMEBREW_AUTO_UPDATE_SECS` remains the only network rate limiter (tune it through brew) — so a deployment fix propagates on the very next `dev up`. The whole layer is warn-only: offline machines and failed upgrades never block project provisioning. Upgrading is symmetric: the org edits one line in its tap's Brewfile (or ships a config change via formula revision) and every machine converges on its next `dev up` — no brew vocabulary required, though a direct `brew upgrade` keeps working for users who prefer it. +On every `dev up`, before project provisioning, `Dev::HostService` converges the host tooling: **`brew update`**, a **scoped `brew upgrade` of the `deployment_formula`** the deployment named in its own `config.yml` (falling back to `dev-core` for tapless individuals; skipped entirely for source checkouts — never a blanket `brew upgrade` of unrelated packages), then **`brew bundle install`** against the Brewfile when one exists. dev adds no throttle of its own — the no-op steps are sub-second, and brew's `HOMEBREW_AUTO_UPDATE_SECS` remains the only network rate limiter (tune it through brew) — so a deployment fix propagates on the very next `dev up`. The whole layer is warn-only: offline machines and failed upgrades never block project provisioning. Upgrading is symmetric: the org edits one line in its tap's Brewfile (or ships a config change via formula revision) and every machine converges on its next `dev up` — no brew vocabulary required, though a direct `brew upgrade` keeps working for users who prefer it. ### dependencies.rb diff --git a/lib/dev/host/converge.rb b/lib/dev/host/converge.rb deleted file mode 100644 index e976728..0000000 --- a/lib/dev/host/converge.rb +++ /dev/null @@ -1,164 +0,0 @@ -# frozen_string_literal: true - -require "open3" -require "pathname" -require_relative "../settings" - -module Dev - module Host - # The host layer of `dev up` (plans#26): brew converges brew — dev never - # re-implements host tooling convergence, it only *triggers* brew's, the - # same way it triggers bundler for gems. Three steps, all brew-executed: - # - # 1. `brew update`, so a deployment fix propagates on the very next - # `dev up` - # 2. scoped `brew upgrade` of the org's deployment formula — the - # deployment names itself via the `deployment_formula` setting; the - # formula revision delivers dev itself plus the org's config.yml and - # Brewfile into the prefix's etc/dev/ - # 3. `brew bundle install` against the etc/dev/Brewfile when one exists - # — the org's tooling list beyond the tool's own dependencies - # - # dev owns no throttle: measured no-op costs are sub-second per step - # (update ~0.5s, scoped upgrade ~0.4s, bundle ~0.9s), and brew's own - # HOMEBREW_AUTO_UPDATE_SECS remains the only network rate limiter — - # tunable through brew, not dev. - # - # The Brewfile presence is convention, not configuration: no file (tapless - # individual, CI) means the step self-skips. The whole layer is warn-only: - # a failed self-update or tooling converge never blocks project - # provisioning (offline `dev up` still works). - class Converge - # A canonical brew formula token: bare name or fully tap-qualified - # user/repo/name (exactly one or three segments — a two-segment form is - # not a formula reference), lowercase throughout (brew stores taps - # downcased, so the canonical spelling is the lowercase one). The - # deployment_formula value crosses a settings boundary into a brew - # invocation, so validate its shape — a leading `-` must never reach - # brew as a flag. - FORMULA_PATTERN = - %r{\A[a-z0-9][a-z0-9_.+@-]*(?:/[a-z0-9][a-z0-9_.+@-]*/[a-z0-9][a-z0-9_.+@-]*)?\z} - - # The generic tool's own formula — the self-update target for tapless - # individuals who installed dev-core directly (no deployment). - CORE_FORMULA = "dev-core" - - # Runs brew commands. Split by what the caller needs: `run` streams - # output to the terminal (installs the user should see), `quiet?` only - # answers success (existence checks). - class Executor - # @param cmd [Array] argv, never a shell string - # @return [Boolean] - def run(*cmd) - !!system(*cmd) - end - - # @param cmd [Array] argv, never a shell string - # @return [Boolean] - def quiet?(*cmd) - _out, _err, status = Open3.capture3(*cmd) - status.success? - rescue SystemCallError - false - end - end - - # @param settings [Dev::Settings] source of deployment_formula and the - # system config location (whose directory also holds the Brewfile) - # @param executor [#run, #quiet?] brew invocation seam, injectable so - # tests never call brew - def initialize(settings: Dev::Settings.new, executor: Executor.new) - @settings = settings - @executor = executor - end - - # The whole host layer, in order: deployment sanity warning, - # self-update, Brewfile converge. A no-op on brewless machines (no - # prefix means no system config location, no Brewfile, nothing to - # upgrade). - # - # @return [void] - def run - return unless system_config_path - - warn_unnamed_deployment - self_update - converge_brewfile if brewfile_path.file? - end - - private - - # An etc config.yml is evidence of a deployment, and a deployment must - # name itself or org updates silently stop flowing (self-update has no - # target). Resolved-value check: a user-file or ENV override counts as - # named. Hand-rollers with only a Brewfile in etc never see this. - # - # @return [void] - def warn_unnamed_deployment - return unless File.exist?(system_config_path.to_s) - return if @settings.deployment_formula - - $stderr.puts "dev: warning: a deployment config exists at #{system_config_path} but no " \ - "deployment_formula is set — dev cannot self-update. Fix with: " \ - "`dev config set deployment_formula //`." - end - - # `brew update` then a scoped upgrade of exactly one formula — never a - # blanket `brew upgrade`; the user's unrelated packages are not dev's - # business. - # - # @return [void] - def self_update - unless @executor.run("brew", "update", "--quiet") - $stderr.puts "dev: warning: brew update failed — skipping the dev self-update check." - return - end - - target = upgrade_target - if target && !@executor.run("brew", "upgrade", "--quiet", target) - $stderr.puts "dev: warning: brew upgrade #{target} failed." - end - end - - # The one formula the self-update may touch: the org's self-named - # deployment, or dev-core for tapless individuals, or nothing (source - # checkouts). - # - # @return [String, nil] - def upgrade_target - formula = @settings.deployment_formula - if formula - return formula if FORMULA_PATTERN.match?(formula) - - $stderr.puts "dev: warning: ignoring malformed deployment_formula #{formula.inspect}." - return nil - end - - CORE_FORMULA if @executor.quiet?("brew", "list", "--formula", "--versions", CORE_FORMULA) - end - - # The org tooling list, converged by brew's own mechanism. brew bundle - # upgrades outdated entries by default, so org tools stay current. - # - # @return [void] - def converge_brewfile - return if @executor.run("brew", "bundle", "install", "--file=#{brewfile_path}") - - $stderr.puts "dev: warning: brew bundle failed for #{brewfile_path} — host tooling may be incomplete." - end - - # The org Brewfile lives beside the system config.yml — both are the - # deployment formula's payload into the prefix's etc/dev/. - # - # @return [Pathname] - def brewfile_path - Pathname(system_config_path.to_s).dirname / "Brewfile" - end - - # @return [String, nil] nil on brewless machines (empty layer) - def system_config_path - @settings.system_config_path - end - end - end -end diff --git a/lib/dev/host_service.rb b/lib/dev/host_service.rb new file mode 100644 index 0000000..f17e5e5 --- /dev/null +++ b/lib/dev/host_service.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +require "open3" +require "pathname" +require_relative "cd/hook_installer" +require_relative "learnings/synchronizer" +require_relative "settings" +require_relative "skill_installer" + +module Dev + # What keeping a host converged consists of — one method per piece of + # machine state dev owns: the brew tooling layer, the shell RC hook, the + # user-global skill links, and the org learnings artifacts. Every + # operation shares the same contract: no user arguments, idempotent, and + # warn-only (host hygiene rides other commands and must never block + # them). Commands compose these verbs — `dev up`'s host half is + # converge_tooling + install_rc_hook; `dev plan` and `install-deps` + # refresh the cheap artifact pair on every invocation. + # + # Anything host-scoped but not convergence-shaped (a user-facing verb + # with arguments, a reporting surface) is not this class's business — + # it belongs to its own command accessor. + class HostService + # A canonical brew formula token: bare name or fully tap-qualified + # user/repo/name (exactly one or three segments — a two-segment form is + # not a formula reference), lowercase throughout (brew stores taps + # downcased, so the canonical spelling is the lowercase one). The + # deployment_formula value crosses a settings boundary into a brew + # invocation, so validate its shape — a leading `-` must never reach + # brew as a flag. + FORMULA_PATTERN = + %r{\A[a-z0-9][a-z0-9_.+@-]*(?:/[a-z0-9][a-z0-9_.+@-]*/[a-z0-9][a-z0-9_.+@-]*)?\z} + + # The generic tool's own formula — the self-update target for tapless + # individuals who installed dev-core directly (no deployment). + CORE_FORMULA = "dev-core" + + # Runs brew commands. Split by what the caller needs: `run` streams + # output to the terminal (installs the user should see), `quiet?` only + # answers success (existence checks). + class BrewExecutor + # @param cmd [Array] argv, never a shell string + # @return [Boolean] + def run(*cmd) + !!system(*cmd) + end + + # @param cmd [Array] argv, never a shell string + # @return [Boolean] + def quiet?(*cmd) + _out, _err, status = Open3.capture3(*cmd) + status.success? + rescue SystemCallError + false + end + end + + # @param settings [Dev::Settings] source of deployment_formula and the + # system config location (whose directory also holds the Brewfile) + # @param brew_executor [#run, #quiet?] brew invocation seam, injectable + # so tests never call brew + # @param hook_installer [Dev::Cd::HookInstaller] the shell RC hook seam + # @param skill_installer [Dev::SkillInstaller] target for dev's shipped + # skill links (defaults to the user-global ~/.cursor/skills) + # @param synchronizer [Dev::Learnings::Synchronizer, Dev::Learnings::UnconfiguredSynchronizer] + # the org learnings read path (the unconfigured null object when no + # knowledge repo is set) + def initialize(settings: Dev::Settings.new, brew_executor: BrewExecutor.new, + hook_installer: Dev::Cd::HookInstaller.new, + skill_installer: Dev::SkillInstaller.new, + synchronizer: Dev::Learnings::Synchronizer.for(settings: settings)) + @settings = settings + @brew_executor = brew_executor + @hook_installer = hook_installer + @skill_installer = skill_installer + @synchronizer = synchronizer + end + + # The brew tooling layer (plans#26): brew converges brew — dev never + # re-implements host tooling convergence, it only *triggers* brew's, + # the same way it triggers bundler for gems. In order: deployment + # sanity warning, `brew update` (so a deployment fix propagates on the + # very next `dev up`), a scoped `brew upgrade` of the org's + # self-named deployment formula (whose revision delivers dev itself + # plus the org's config.yml and Brewfile into the prefix's etc/dev/), + # then `brew bundle install` against the etc/dev/Brewfile when one + # exists — the org's tooling list beyond the tool's own dependencies. + # + # dev owns no throttle: measured no-op costs are sub-second per step + # (update ~0.5s, scoped upgrade ~0.4s, bundle ~0.9s), and brew's own + # HOMEBREW_AUTO_UPDATE_SECS remains the only network rate limiter — + # tunable through brew, not dev. The Brewfile presence is convention, + # not configuration: no file (tapless individual, CI) means the step + # self-skips. A no-op on brewless machines (no prefix means no system + # config location, no Brewfile, nothing to upgrade). + # + # @return [void] + def converge_tooling + return unless system_config_path + + warn_unnamed_deployment + self_update + converge_brewfile if brewfile_path.file? + end + + # Ensure the `dev cd` wrapper function + completer are in the user's + # shell RC (idempotent) — provisioning is where dev's RC hooks land, + # next to the shadowenv one. + # + # @return [Symbol, false] :added, :already_present, or false + # (unsupported shell) + def install_rc_hook + @hook_installer.ensure_installed + end + + # Install or refresh the user-global links to dev's own shipped skills. + # Cheap and idempotent, so every hook point can afford it — and `brew + # upgrade` refreshes shipped skills automatically (the symlinks resolve + # through the installed tree, wherever brew put it). + # + # @return [void] + def install_skills + @skill_installer.install_all(Dev::SkillInstaller::SHIPPED_SKILLS_DIR) + end + + # Refresh the machine's org learnings artifacts, best-effort (the + # network pull bounded by a short timeout, never raising). The blocking + # error-bubbling variant stays `dev learnings sync`'s own business. + # + # @param project_root [Pathname, String, nil] project to link the + # invariants render into; nil skips the link (no project context) + # @return [void] + def sync_learnings(project_root: nil) + @synchronizer.sync(project_root: project_root) + end + + private + + # An etc config.yml is evidence of a deployment, and a deployment must + # name itself or org updates silently stop flowing (self-update has no + # target). Resolved-value check: a user-file or ENV override counts as + # named. Hand-rollers with only a Brewfile in etc never see this. + # + # @return [void] + def warn_unnamed_deployment + return unless File.exist?(system_config_path.to_s) + return if @settings.deployment_formula + + $stderr.puts "dev: warning: a deployment config exists at #{system_config_path} but no " \ + "deployment_formula is set — dev cannot self-update. Fix with: " \ + "`dev config set deployment_formula //`." + end + + # `brew update` then a scoped upgrade of exactly one formula — never a + # blanket `brew upgrade`; the user's unrelated packages are not dev's + # business. + # + # @return [void] + def self_update + unless @brew_executor.run("brew", "update", "--quiet") + $stderr.puts "dev: warning: brew update failed — skipping the dev self-update check." + return + end + + target = upgrade_target + if target && !@brew_executor.run("brew", "upgrade", "--quiet", target) + $stderr.puts "dev: warning: brew upgrade #{target} failed." + end + end + + # The one formula the self-update may touch: the org's self-named + # deployment, or dev-core for tapless individuals, or nothing (source + # checkouts). + # + # @return [String, nil] + def upgrade_target + formula = @settings.deployment_formula + if formula + return formula if FORMULA_PATTERN.match?(formula) + + $stderr.puts "dev: warning: ignoring malformed deployment_formula #{formula.inspect}." + return nil + end + + CORE_FORMULA if @brew_executor.quiet?("brew", "list", "--formula", "--versions", CORE_FORMULA) + end + + # The org tooling list, converged by brew's own mechanism. brew bundle + # upgrades outdated entries by default, so org tools stay current. + # + # @return [void] + def converge_brewfile + return if @brew_executor.run("brew", "bundle", "install", "--file=#{brewfile_path}") + + $stderr.puts "dev: warning: brew bundle failed for #{brewfile_path} — host tooling may be incomplete." + end + + # The org Brewfile lives beside the system config.yml — both are the + # deployment formula's payload into the prefix's etc/dev/. + # + # @return [Pathname] + def brewfile_path + Pathname(system_config_path.to_s).dirname / "Brewfile" + end + + # @return [String, nil] nil on brewless machines (empty layer) + def system_config_path + @settings.system_config_path + end + end +end diff --git a/lib/dev/settings.rb b/lib/dev/settings.rb index f5674fd..fcf72d8 100644 --- a/lib/dev/settings.rb +++ b/lib/dev/settings.rb @@ -25,7 +25,7 @@ module Dev # `dev plan link --org` target. `knowledge_repo` is the org knowledge repo # dev keeps a machine-local cache of. `deployment_formula` is the brew # formula `dev up`'s self-update upgrades — the deployment names itself - # (see Dev::Host::Converge). Leaving a nilable key unset turns its + # (see Dev::HostService). Leaving a nilable key unset turns its # feature off. class Settings class MissingSettingError < RuntimeError; end diff --git a/src/dev/builtins/up_command.rb b/src/dev/builtins/up_command.rb index c26b25b..c61d538 100644 --- a/src/dev/builtins/up_command.rb +++ b/src/dev/builtins/up_command.rb @@ -1,10 +1,9 @@ # typed: strict # frozen_string_literal: true -require "dev/cd" require "dev/command" require "dev/credentials" -require "dev/host/converge" +require "dev/host_service" module Dev module Builtins @@ -25,16 +24,13 @@ class UpCommand < BuiltinCommand sig do params( install_deps_command: InstallDepsCommand, - hook_installer: Dev::Cd::HookInstaller, - host_converge: Dev::Host::Converge, + host_service: Dev::HostService, ).void end - def initialize(install_deps_command:, hook_installer: Dev::Cd::HookInstaller.new, - host_converge: Dev::Host::Converge.new) + def initialize(install_deps_command:, host_service: Dev::HostService.new) super() @install_deps_command = T.let(install_deps_command, InstallDepsCommand) - @hook_installer = T.let(hook_installer, Dev::Cd::HookInstaller) - @host_converge = T.let(host_converge, Dev::Host::Converge) + @host_service = T.let(host_service, Dev::HostService) end sig { override.returns(String) } @@ -57,8 +53,8 @@ def call(args:, context:) # The host layer converges before project provisioning (self-update # + org Brewfile): project installs may lean on host tools (gh, # rbenv). Warn-only — never blocks the project. - @host_converge.run - @hook_installer.ensure_installed + @host_service.converge_tooling + @host_service.install_rc_hook project = context.project if project.nil? puts "dev: host layer converged." diff --git a/test/dev/builtins/up_command_test.rb b/test/dev/builtins/up_command_test.rb index 71d7472..d5fc59d 100644 --- a/test/dev/builtins/up_command_test.rb +++ b/test/dev/builtins/up_command_test.rb @@ -26,11 +26,10 @@ class Dev::Builtins::UpCommandTest < Minitest::Test test "call ensures the dev cd shell hook and composes the install-deps body" do Given "an up command with expectations on both collaborators" install_deps = typed_mock(Dev::Builtins::InstallDepsCommand) - hook_installer = typed_mock(Dev::Cd::HookInstaller) - hook_installer.expects(:ensure_installed).once.returns(:already_present) - command = Dev::Builtins::UpCommand.new( - install_deps_command: install_deps, hook_installer: hook_installer, host_converge: quiet_host_converge, - ) + host_service = typed_mock(Dev::HostService) + host_service.stubs(:converge_tooling) + host_service.expects(:install_rc_hook).once.returns(:already_present) + command = Dev::Builtins::UpCommand.new(install_deps_command: install_deps, host_service: host_service) context = build_context When "running up" @@ -40,35 +39,29 @@ class Dev::Builtins::UpCommandTest < Minitest::Test 1 * install_deps.call(args: ["-v"], context: context) end - test "call converges the host layer as its first step" do - Given "an up command whose host converge expects its run" - host_converge = typed_mock(Dev::Host::Converge) - host_converge.expects(:run).once + test "call converges the host tooling as its first step" do + Given "an up command whose host service expects the tooling converge" + host_service = typed_mock(Dev::HostService) + host_service.expects(:converge_tooling).once + host_service.stubs(:install_rc_hook).returns(:already_present) install_deps = typed_mock(Dev::Builtins::InstallDepsCommand) install_deps.stubs(:call) - hook_installer = typed_mock(Dev::Cd::HookInstaller) - hook_installer.stubs(:ensure_installed).returns(:already_present) - command = Dev::Builtins::UpCommand.new( - install_deps_command: install_deps, hook_installer: hook_installer, host_converge: host_converge, - ) + command = Dev::Builtins::UpCommand.new(install_deps_command: install_deps, host_service: host_service) When "running up" command.call(args: [], context: build_context) - Then "the expectation on the host converge holds" + Then "the expectation on the host service holds" true end test "call without a project converges the host half and skips provisioning" do - Given "a projectless context and collaborators expecting only host work" - host_converge = typed_mock(Dev::Host::Converge) - host_converge.expects(:run).once - hook_installer = typed_mock(Dev::Cd::HookInstaller) - hook_installer.expects(:ensure_installed).once.returns(:appended) + Given "a projectless context and a host service expecting only host work" + host_service = typed_mock(Dev::HostService) + host_service.expects(:converge_tooling).once + host_service.expects(:install_rc_hook).once.returns(:appended) install_deps = typed_mock(Dev::Builtins::InstallDepsCommand) - command = Dev::Builtins::UpCommand.new( - install_deps_command: install_deps, hook_installer: hook_installer, host_converge: host_converge, - ) + command = Dev::Builtins::UpCommand.new(install_deps_command: install_deps, host_service: host_service) context = Dev::ExecutionContext.new(ui: typed_mock(Dev::Cli::Ui)) When "running up outside any project" @@ -122,17 +115,14 @@ class Dev::Builtins::UpCommandTest < Minitest::Test def build_command install_deps = typed_mock(Dev::Builtins::InstallDepsCommand) install_deps.stubs(:call) - hook_installer = typed_mock(Dev::Cd::HookInstaller) - hook_installer.stubs(:ensure_installed).returns(:already_present) - Dev::Builtins::UpCommand.new( - install_deps_command: install_deps, hook_installer: hook_installer, host_converge: quiet_host_converge, - ) + Dev::Builtins::UpCommand.new(install_deps_command: install_deps, host_service: quiet_host_service) end - def quiet_host_converge - host_converge = typed_mock(Dev::Host::Converge) - host_converge.stubs(:run) - host_converge + def quiet_host_service + host_service = typed_mock(Dev::HostService) + host_service.stubs(:converge_tooling) + host_service.stubs(:install_rc_hook).returns(:already_present) + host_service end def container_config(build_args:) diff --git a/test/dev/host/converge_test.rb b/test/dev/host_service_test.rb similarity index 59% rename from test/dev/host/converge_test.rb rename to test/dev/host_service_test.rb index 0b9da8e..4f93163 100644 --- a/test/dev/host/converge_test.rb +++ b/test/dev/host_service_test.rb @@ -2,15 +2,18 @@ # frozen_string_literal: true require "test_helper" -require "dev/host/converge" +require "dev/host_service" require "dev/settings" require "fileutils" +require "pathname" require "rbconfig" require "stringio" require "tmpdir" transform!(RSpock::AST::Transformation) -class Dev::Host::ConvergeTest < Minitest::Test +class Dev::HostServiceTest < Minitest::Test + include SorbetHelper + # Records every brew invocation instead of running it — the executor is # the true boundary; everything else (settings layers, Brewfile) uses # real files in temp dirs. @@ -39,15 +42,15 @@ def quiet?(*cmd) end end - test "run is a no-op on a brewless machine (no system config location)" do + test "converge_tooling is a no-op on a brewless machine (no system config location)" do Given "settings that resolve no brew prefix" - dir = Dir.mktmpdir("dev-host-converge-test-") + dir = Dir.mktmpdir("dev-host-service-test-") executor = RecordingExecutor.new - converge = build_converge(dir, executor: executor) - converge.instance_variable_get(:@settings).stubs(:system_config_path).returns(nil) + service = build_service(dir, brew_executor: executor) + service.instance_variable_get(:@settings).stubs(:system_config_path).returns(nil) - When "converging" - converge.run + When "converging the tooling" + service.converge_tooling Then "brew is never invoked" executor.commands.empty? @@ -58,12 +61,12 @@ def quiet?(*cmd) test "a bare host runs the self-update but has nothing to upgrade or bundle" do Given "no deployment config, no Brewfile, dev-core not brew-installed" - dir = Dir.mktmpdir("dev-host-converge-test-") + dir = Dir.mktmpdir("dev-host-service-test-") executor = RecordingExecutor.new(quiet_result: false) - converge = build_converge(dir, executor: executor) + service = build_service(dir, brew_executor: executor) - When "converging" - converge.run + When "converging the tooling" + service.converge_tooling Then "brew update + the dev-core check ran, nothing upgraded or bundled" executor.commands == [ @@ -77,13 +80,13 @@ def quiet?(*cmd) test "a named deployment upgrades exactly that formula" do Given "a system config naming the deployment" - dir = Dir.mktmpdir("dev-host-converge-test-") + dir = Dir.mktmpdir("dev-host-service-test-") write_system_config(dir, "deployment_formula: d3mlabs/d3mlabs/dev\n") executor = RecordingExecutor.new - converge = build_converge(dir, executor: executor) + service = build_service(dir, brew_executor: executor) - When "converging" - stderr = capture_stderr { converge.run } + When "converging the tooling" + stderr = capture_stderr { service.converge_tooling } Then "the scoped upgrade targets the self-named formula, with no warning" executor.commands.include?(["brew", "upgrade", "--quiet", "d3mlabs/d3mlabs/dev"]) @@ -95,13 +98,13 @@ def quiet?(*cmd) test "a malformed deployment_formula never reaches brew" do Given "a hostile value that would parse as a brew flag" - dir = Dir.mktmpdir("dev-host-converge-test-") + dir = Dir.mktmpdir("dev-host-service-test-") write_system_config(dir, 'deployment_formula: "--force evil"' + "\n") executor = RecordingExecutor.new - converge = build_converge(dir, executor: executor) + service = build_service(dir, brew_executor: executor) - When "converging" - stderr = capture_stderr { converge.run } + When "converging the tooling" + stderr = capture_stderr { service.converge_tooling } Then "no upgrade is attempted and the rejection is warned" executor.commands.none? { |cmd| cmd[0..1] == ["brew", "upgrade"] } @@ -113,13 +116,13 @@ def quiet?(*cmd) test "'#{formula}' (#{shape}) keeps its spelling through to brew upgrade" do Given "a deployment named with that token shape" - dir = Dir.mktmpdir("dev-host-converge-test-") + dir = Dir.mktmpdir("dev-host-service-test-") write_system_config(dir, "deployment_formula: #{formula}\n") executor = RecordingExecutor.new - converge = build_converge(dir, executor: executor) + service = build_service(dir, brew_executor: executor) - When "converging" - stderr = capture_stderr { converge.run } + When "converging the tooling" + stderr = capture_stderr { service.converge_tooling } Then "the scoped upgrade targets the formula as spelled, with no warning" executor.commands.include?(["brew", "upgrade", "--quiet", formula]) @@ -136,13 +139,13 @@ def quiet?(*cmd) test "'#{formula}' (#{reason}) is rejected as malformed" do Given "a deployment_formula that is not a canonical brew token" - dir = Dir.mktmpdir("dev-host-converge-test-") + dir = Dir.mktmpdir("dev-host-service-test-") write_system_config(dir, "deployment_formula: #{formula}\n") executor = RecordingExecutor.new - converge = build_converge(dir, executor: executor) + service = build_service(dir, brew_executor: executor) - When "converging" - stderr = capture_stderr { converge.run } + When "converging the tooling" + stderr = capture_stderr { service.converge_tooling } Then "no upgrade is attempted and the rejection is warned" executor.commands.none? { |cmd| cmd[0..1] == ["brew", "upgrade"] } @@ -159,12 +162,12 @@ def quiet?(*cmd) test "an unset key falls back to dev-core when it is brew-installed" do Given "no deployment config, dev-core installed" - dir = Dir.mktmpdir("dev-host-converge-test-") + dir = Dir.mktmpdir("dev-host-service-test-") executor = RecordingExecutor.new(quiet_result: true) - converge = build_converge(dir, executor: executor) + service = build_service(dir, brew_executor: executor) - When "converging" - converge.run + When "converging the tooling" + service.converge_tooling Then "the tapless individual's tool self-updates" executor.commands.include?(["brew", "upgrade", "--quiet", "dev-core"]) @@ -175,13 +178,13 @@ def quiet?(*cmd) test "a Brewfile beside the system config converges via brew bundle" do Given "an org Brewfile in etc" - dir = Dir.mktmpdir("dev-host-converge-test-") + dir = Dir.mktmpdir("dev-host-service-test-") brewfile = write_brewfile(dir, %(cask "cursor-cli"\n)) executor = RecordingExecutor.new(quiet_result: false) - converge = build_converge(dir, executor: executor) + service = build_service(dir, brew_executor: executor) - When "converging" - converge.run + When "converging the tooling" + service.converge_tooling Then "brew bundle runs against the etc Brewfile as the last step" executor.commands.last == ["brew", "bundle", "install", "--file=#{brewfile}"] @@ -192,14 +195,14 @@ def quiet?(*cmd) test "a failed scoped upgrade warns and still converges the Brewfile" do Given "a named deployment whose upgrade fails" - dir = Dir.mktmpdir("dev-host-converge-test-") + dir = Dir.mktmpdir("dev-host-service-test-") write_system_config(dir, "deployment_formula: d3mlabs/d3mlabs/dev\n") brewfile = write_brewfile(dir, %(cask "cursor-cli"\n)) executor = RecordingExecutor.new(fail_subcommands: ["upgrade"]) - converge = build_converge(dir, executor: executor) + service = build_service(dir, brew_executor: executor) - When "converging" - stderr = capture_stderr { converge.run } + When "converging the tooling" + stderr = capture_stderr { service.converge_tooling } Then "the failure is a warning and the Brewfile step still ran" stderr.include?("brew upgrade d3mlabs/d3mlabs/dev failed") @@ -211,13 +214,13 @@ def quiet?(*cmd) test "a failed brew bundle warns instead of blocking" do Given "an org Brewfile whose converge fails" - dir = Dir.mktmpdir("dev-host-converge-test-") + dir = Dir.mktmpdir("dev-host-service-test-") write_brewfile(dir, %(cask "cursor-cli"\n)) executor = RecordingExecutor.new(quiet_result: false, fail_subcommands: ["bundle"]) - converge = build_converge(dir, executor: executor) + service = build_service(dir, brew_executor: executor) - When "converging" - stderr = capture_stderr { converge.run } + When "converging the tooling" + stderr = capture_stderr { service.converge_tooling } Then "the failure surfaces as a warning naming the Brewfile" stderr.include?("brew bundle failed") @@ -229,13 +232,13 @@ def quiet?(*cmd) test "a failed brew update warns and skips the upgrade, but the Brewfile still converges" do Given "an offline machine (every streamed brew command fails)" - dir = Dir.mktmpdir("dev-host-converge-test-") + dir = Dir.mktmpdir("dev-host-service-test-") write_system_config(dir, "deployment_formula: d3mlabs/d3mlabs/dev\n") executor = RecordingExecutor.new(run_result: false) - converge = build_converge(dir, executor: executor) + service = build_service(dir, brew_executor: executor) - When "converging" - stderr = capture_stderr { converge.run } + When "converging the tooling" + stderr = capture_stderr { service.converge_tooling } Then "no upgrade was attempted and the failure is a warning" executor.commands.none? { |cmd| cmd[0..1] == ["brew", "upgrade"] } @@ -245,9 +248,9 @@ def quiet?(*cmd) FileUtils.rm_rf(dir) end - test "the real executor's run maps exit status to a boolean" do + test "the real brew executor's run maps exit status to a boolean" do Given "the production executor" - executor = Dev::Host::Converge::Executor.new + executor = Dev::HostService::BrewExecutor.new Expect "success and failure map to booleans, and a missing binary is false" executor.run(RbConfig.ruby, "-e", "exit 0") == true @@ -255,9 +258,9 @@ def quiet?(*cmd) executor.run("definitely-not-a-command-#{Process.pid}") == false end - test "the real executor's quiet? answers success without streaming output" do + test "the real brew executor's quiet? answers success without streaming output" do Given "the production executor" - executor = Dev::Host::Converge::Executor.new + executor = Dev::HostService::BrewExecutor.new Expect "exit status maps to a boolean and a missing binary is false, not an exception" executor.quiet?(RbConfig.ruby, "-e", "puts :ok") == true @@ -267,12 +270,12 @@ def quiet?(*cmd) test "an etc config.yml without a resolvable deployment_formula warns with the remedy" do Given "a deployment config that forgot to name itself" - dir = Dir.mktmpdir("dev-host-converge-test-") + dir = Dir.mktmpdir("dev-host-service-test-") write_system_config(dir, "plans_repo: acme/plans\n") - converge = build_converge(dir, executor: RecordingExecutor.new) + service = build_service(dir, brew_executor: RecordingExecutor.new) - When "converging" - stderr = capture_stderr { converge.run } + When "converging the tooling" + stderr = capture_stderr { service.converge_tooling } Then "the warning names the failure and the one-command fix" stderr.include?("no deployment_formula is set") @@ -284,14 +287,14 @@ def quiet?(*cmd) test "the unnamed-deployment warning is silenced by a higher layer naming it" do Given "a keyless system config but a user file naming the deployment" - dir = Dir.mktmpdir("dev-host-converge-test-") + dir = Dir.mktmpdir("dev-host-service-test-") write_system_config(dir, "plans_repo: acme/plans\n") write_user_config(dir, "deployment_formula: acme/tap/dev\n") executor = RecordingExecutor.new - converge = build_converge(dir, executor: executor) + service = build_service(dir, brew_executor: executor) - When "converging" - stderr = capture_stderr { converge.run } + When "converging the tooling" + stderr = capture_stderr { service.converge_tooling } Then "no warning, and the user-layer target is upgraded" stderr.empty? @@ -301,16 +304,88 @@ def quiet?(*cmd) FileUtils.rm_rf(dir) end + test "install_rc_hook delegates to the shell RC hook installer" do + Given "a service over a mocked hook installer" + dir = Dir.mktmpdir("dev-host-service-test-") + hook_installer = typed_mock(Dev::Cd::HookInstaller) + service = build_service(dir, hook_installer: hook_installer) + + When "ensuring the RC hook" + service.install_rc_hook + + Then "the installer received ensure_installed" + 1 * hook_installer.ensure_installed + + Cleanup + FileUtils.rm_rf(dir) + end + + test "install_skills links dev's shipped skills user-globally" do + Given "a service over a mocked skill installer" + dir = Dir.mktmpdir("dev-host-service-test-") + skill_installer = typed_mock(Dev::SkillInstaller) + service = build_service(dir, skill_installer: skill_installer) + + When "installing the shipped skills" + service.install_skills + + Then "the installer received the shipped skills dir" + 1 * skill_installer.install_all(Dev::SkillInstaller::SHIPPED_SKILLS_DIR) + + Cleanup + FileUtils.rm_rf(dir) + end + + test "sync_learnings hands the project root to the best-effort synchronizer" do + Given "a service over a mocked synchronizer" + dir = Dir.mktmpdir("dev-host-service-test-") + synchronizer = typed_mock(Dev::Learnings::Synchronizer) + service = build_service(dir, synchronizer: synchronizer) + + When "syncing learnings inside a project" + service.sync_learnings(project_root: Pathname.new("/tmp/some-project")) + + Then "the synchronizer received the best-effort sync with the root" + 1 * synchronizer.sync(project_root: Pathname.new("/tmp/some-project")) + + Cleanup + FileUtils.rm_rf(dir) + end + + test "sync_learnings outside any project syncs the machine-global parts" do + Given "a service over a mocked synchronizer" + dir = Dir.mktmpdir("dev-host-service-test-") + synchronizer = typed_mock(Dev::Learnings::Synchronizer) + service = build_service(dir, synchronizer: synchronizer) + + When "syncing learnings with no project context" + service.sync_learnings + + Then "the synchronizer received a nil project root" + 1 * synchronizer.sync(project_root: nil) + + Cleanup + FileUtils.rm_rf(dir) + end + private - # Hermetic converge: settings layers and Brewfile live under the test's - # temp dir; only the executor is faked. - def build_converge(dir, executor:) + # Hermetic service: settings layers and Brewfile live under the test's + # temp dir; the brew executor is faked, and the delegation collaborators + # are injectable per test. + def build_service(dir, brew_executor: RecordingExecutor.new, hook_installer: Dev::Cd::HookInstaller.new, + skill_installer: Dev::SkillInstaller.new, synchronizer: nil) settings = Dev::Settings.new( config_path: File.join(dir, "user", "config.yml"), system_config_path: File.join(dir, "etc", "config.yml"), ) - Dev::Host::Converge.new(settings: settings, executor: executor) + Dev::HostService.new( + settings: settings, + brew_executor: brew_executor, + hook_installer: hook_installer, + skill_installer: skill_installer, + synchronizer: synchronizer || Dev::Learnings::Synchronizer.for(settings: settings), + ) end def write_system_config(dir, content) From 91b4e726425a7b9d016e4173ca101595793f41fd Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 28 Aug 2026 17:59:54 -0400 Subject: [PATCH 16/16] Route the plan and install-deps hook points through HostService The skills + learnings refresh pair was hand-assembled from individual collaborators at each hook point. Plan::Accessor and InstallDepsCommand now inject the one host service and call its verbs, which also replaces install-deps' untyped synchronizer param with a typed collaborator. Learnings::Accessor keeps its direct collaborators: its sync subcommand is the explicit blocking refresh, a different contract from the warn-only hooks. Co-authored-by: Cursor --- lib/dev/plan.rb | 3 +-- lib/dev/plan/accessor.rb | 14 ++++++------- src/dev/builtins/install_deps_command.rb | 10 ++++----- .../dev/builtins/install_deps_command_test.rb | 21 ++++++++++++------- test/dev/plan/accessor_test.rb | 14 ++++++------- 5 files changed, 32 insertions(+), 30 deletions(-) diff --git a/lib/dev/plan.rb b/lib/dev/plan.rb index 56791e0..73e14b0 100644 --- a/lib/dev/plan.rb +++ b/lib/dev/plan.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true require "dev/settings" -require "dev/skill_installer" -require "dev/learnings" +require "dev/host_service" require "dev/plan/executor" require "dev/plan/header" require "dev/plan/frontmatter" diff --git a/lib/dev/plan/accessor.rb b/lib/dev/plan/accessor.rb index d155625..39ca61e 100644 --- a/lib/dev/plan/accessor.rb +++ b/lib/dev/plan/accessor.rb @@ -32,20 +32,18 @@ class UsageError < RuntimeError; end # @param issues [Dev::Plan::GithubIssues, nil] # @param settings [Dev::Settings, nil] # @param merge_base [Dev::Plan::MergeBase, nil] - # @param skill_installer [Dev::SkillInstaller, nil] target for dev's - # shipped skill links (defaults to the user-global ~/.cursor/skills) - # @param learnings [Dev::Learnings::Synchronizer, Dev::Learnings::UnconfiguredSynchronizer, nil] + # @param host_service [Dev::HostService, nil] the machine-convergence + # hook point (shipped skill links + org learnings artifacts) # @param executor [Dev::Plan::Executor] CLI boundary (injectable for tests) def initialize(project_root:, executor: Executor.new, workspace: nil, issues: nil, - settings: nil, merge_base: nil, skill_installer: nil, learnings: nil) + settings: nil, merge_base: nil, host_service: nil) @project_root = project_root @executor = executor @workspace = workspace || Workspace.new(project_root: project_root, executor: executor) @issues = issues || GithubIssues.new(executor: executor) @settings = settings || Dev::Settings.new @merge_base = merge_base || MergeBase.new - @skill_installer = skill_installer || Dev::SkillInstaller.new - @learnings = learnings || Learnings::Synchronizer.for(settings: @settings) + @host_service = host_service || Dev::HostService.new(settings: @settings) end # Dispatch a `dev plan …` invocation. @@ -58,8 +56,8 @@ def run(args, out: $stdout, input: $stdin) # Hook point: refresh dev's shipped skill links and the org learnings # artifacts. Cheap and idempotent (content-compared, the network pull # bounded by a short timeout), so every invocation can afford it. - @skill_installer.install_all(Dev::SkillInstaller::SHIPPED_SKILLS_DIR) - @learnings.sync(project_root: @project_root) + @host_service.install_skills + @host_service.sync_learnings(project_root: @project_root) subcommand, *rest = args case subcommand when "new" then new_plan(rest, out:) diff --git a/src/dev/builtins/install_deps_command.rb b/src/dev/builtins/install_deps_command.rb index efa79e2..d2fa2da 100644 --- a/src/dev/builtins/install_deps_command.rb +++ b/src/dev/builtins/install_deps_command.rb @@ -9,7 +9,7 @@ require "dev/deps/integration" require "dev/deps/lockfile" require "dev/deps/registry" -require "dev/learnings" +require "dev/host_service" require "shadowenv_ruby" module Dev @@ -40,7 +40,7 @@ class InstallDepsCommand < BuiltinCommand params( installer_factory: InstallerFactory, gem_skill_linker_factory: GemSkillLinkerFactory, - synchronizer: T.untyped, + host_service: Dev::HostService, ).void end def initialize( @@ -48,12 +48,12 @@ def initialize( Dev::Deps::DependencyInstaller.new(lockfile:, integrations:) }, gem_skill_linker_factory: ->(project_root) { Dev::Deps::GemSkillLinker.new(project_root:) }, - synchronizer: Dev::Learnings::Synchronizer.for + host_service: Dev::HostService.new ) super() @installer_factory = T.let(installer_factory, InstallerFactory) @gem_skill_linker_factory = T.let(gem_skill_linker_factory, GemSkillLinkerFactory) - @synchronizer = T.let(synchronizer, T.untyped) + @host_service = T.let(host_service, Dev::HostService) end sig { override.returns(String) } @@ -101,7 +101,7 @@ def call(args:, context:) # on fresh invariants (e.g. ai-flow's runner) run an explicit blocking # `dev learnings sync` step instead of relying on this side effect. @gem_skill_linker_factory.call(project.root).link_all - @synchronizer.sync(project_root: project.root) + @host_service.sync_learnings(project_root: project.root) end end end diff --git a/test/dev/builtins/install_deps_command_test.rb b/test/dev/builtins/install_deps_command_test.rb index 731f99c..479e32a 100644 --- a/test/dev/builtins/install_deps_command_test.rb +++ b/test/dev/builtins/install_deps_command_test.rb @@ -29,8 +29,8 @@ class Dev::Builtins::InstallDepsCommandTest < Minitest::Test installer.expects(:install).with(env: Dev::Deps.detect_env, host: Dev::Deps.detect_host).once linker = typed_mock(Dev::Deps::GemSkillLinker) linker.expects(:link_all).once - synchronizer = mock - synchronizer.expects(:sync).with(project_root: root).once + host_service = typed_mock(Dev::HostService) + host_service.expects(:sync_learnings).with(project_root: root).once linker_roots = [] command = Dev::Builtins::InstallDepsCommand.new( installer_factory: ->(_lockfile, _integrations) { installer }, @@ -38,7 +38,7 @@ class Dev::Builtins::InstallDepsCommandTest < Minitest::Test linker_roots << project_root linker }, - synchronizer: synchronizer, + host_service: host_service, ) # Headless boxes reach install-deps before any CommandRunner provisioning, # so the builtin provisions the toolchain itself — the true boundary. @@ -70,7 +70,7 @@ class Dev::Builtins::InstallDepsCommandTest < Minitest::Test linker.stubs(:link_all) linker }, - synchronizer: stub(sync: nil), + host_service: quiet_host_service, ) ShadowenvRuby.stubs(:ensure!) @@ -92,10 +92,9 @@ class Dev::Builtins::InstallDepsCommandTest < Minitest::Test # The empty project keeps the real collaborators inert: the lockfile # pins nothing (install dispatches nothing) and no Gemfile exists (the # linker returns before shelling out). Only the machine-global - # boundaries — the Ruby provisioner and the learnings synchronizer — - # are faked. + # boundaries — the Ruby provisioner and the host service — are faked. root = Pathname.new(Dir.mktmpdir("install-deps-default-")) - command = Dev::Builtins::InstallDepsCommand.new(synchronizer: stub(sync: nil)) + command = Dev::Builtins::InstallDepsCommand.new(host_service: quiet_host_service) ShadowenvRuby.stubs(:ensure!) When "running install-deps" @@ -114,10 +113,16 @@ def build_command Dev::Builtins::InstallDepsCommand.new( installer_factory: ->(_lockfile, _integrations) { typed_mock(Dev::Deps::DependencyInstaller) }, gem_skill_linker_factory: ->(_project_root) { typed_mock(Dev::Deps::GemSkillLinker) }, - synchronizer: stub(sync: nil), + host_service: quiet_host_service, ) end + def quiet_host_service + host_service = typed_mock(Dev::HostService) + host_service.stubs(:sync_learnings) + host_service + end + def build_context(project_root) Dev::ExecutionContext.new( ui: typed_mock(Dev::Cli::Ui), diff --git a/test/dev/plan/accessor_test.rb b/test/dev/plan/accessor_test.rb index 338e66f..4651cdd 100644 --- a/test/dev/plan/accessor_test.rb +++ b/test/dev/plan/accessor_test.rb @@ -68,11 +68,12 @@ class FakePlanSettings def plans_repo = "d3mlabs/plans" end unless defined?(FakePlanSettings) -# A learnings synchronizer stand-in: plan flows are under test here, and the -# real synchronizer would read the machine's config and touch user-global dirs. -class NoopLearningsSynchronizer - def sync(project_root: nil); end -end unless defined?(NoopLearningsSynchronizer) +# A host service stand-in: plan flows are under test here, and the real +# service would read the machine's config and touch user-global dirs. +class NoopHostService + def install_skills; end + def sync_learnings(project_root: nil); end +end unless defined?(NoopHostService) transform!(RSpock::AST::Transformation) class Dev::Plan::AccessorTest < Minitest::Test @@ -92,8 +93,7 @@ def build_env(dir) issues: issues, settings: FakePlanSettings.new, merge_base: Dev::Plan::MergeBase.new(state_dir: File.join(dir, "state")), - skill_installer: Dev::SkillInstaller.new(skills_dir: File.join(dir, "skills")), - learnings: NoopLearningsSynchronizer.new, + host_service: NoopHostService.new, ) [accessor, root, issues] end