From d7882c1ba73a68445c8094a7e61947987afe3610 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 21 Aug 2026 01:23:18 -0400 Subject: [PATCH 1/5] Support a fixed state key in Staleness for host-singular stamps Co-authored-by: Cursor --- lib/dev/deps/staleness.rb | 10 +++++++++- test/dev/deps/staleness_test.rb | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/dev/deps/staleness.rb b/lib/dev/deps/staleness.rb index ff6b90d..a6af94b 100644 --- a/lib/dev/deps/staleness.rb +++ b/lib/dev/deps/staleness.rb @@ -44,9 +44,14 @@ class Staleness # @param project_root [Pathname] repo root (holds dependencies.rb + lockfiles) # @param state_dir [Pathname] per-machine state root (default ~/.dev/state) - def initialize(project_root:, state_dir: Pathname(File.expand_path("~/.dev/state"))) + # @param state_key [String, nil] fixed stamp subdir, overriding the + # path-derived per-checkout key — for host-singular state like the + # baseline stamp, which must survive the manifest dir moving with + # each installed dev version + def initialize(project_root:, state_dir: Pathname(File.expand_path("~/.dev/state")), state_key: nil) @project_root = Pathname(project_root) @state_dir = Pathname(state_dir) + @state_key = state_key end # All current staleness messages, oldest layer first (a stale manifest @@ -131,9 +136,12 @@ def stamp_path # Per-checkout state key: readable basename + a short path digest so two # checkouts of the same project on one machine get independent stamps. + # A fixed state_key takes precedence (host-singular consumers). # # @return [String] def project_key + return @state_key if @state_key + expanded = File.expand_path(@project_root.to_s) "#{File.basename(expanded)}-#{Digest::SHA256.hexdigest(expanded)[0, 8]}" end diff --git a/test/dev/deps/staleness_test.rb b/test/dev/deps/staleness_test.rb index a4882bb..109055d 100644 --- a/test/dev/deps/staleness_test.rb +++ b/test/dev/deps/staleness_test.rb @@ -45,6 +45,25 @@ def build_staleness(dir, project) FileUtils.rm_rf(dir) end + test "a fixed state key overrides the per-checkout stamp path" do + Given "a synced project with a fixed state key (the host-baseline case)" + dir = Dir.mktmpdir("dev-staleness-test-") + project = build_synced_project(dir) + staleness = Dev::Deps::Staleness.new( + project_root: project, state_dir: File.join(dir, "state"), state_key: "host-baseline", + ) + + When "stamping" + staleness.stamp_installed! + + Then "the stamp lives at the fixed key, not a path-derived one, and reads back" + File.exist?(File.join(dir, "state", "host-baseline", "installed-digest")) + staleness.messages == [] + + Cleanup + FileUtils.rm_rf(dir) + end + test "editing dependencies.rb after update-deps reports the manifest message" do Given "a synced project whose manifest then changes" dir = Dir.mktmpdir("dev-staleness-test-") From 84cec3808faec142e3528f1f8afd56baf0153a56 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 21 Aug 2026 01:23:18 -0400 Subject: [PATCH 2/5] Lift publish_current into the Integration base Co-authored-by: Cursor --- lib/dev/deps/gh_integration.rb | 18 ------------------ lib/dev/deps/integration.rb | 19 +++++++++++++++++++ test/dev/deps/integration_test.rb | 28 ++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/lib/dev/deps/gh_integration.rb b/lib/dev/deps/gh_integration.rb index 22e045a..5f10109 100644 --- a/lib/dev/deps/gh_integration.rb +++ b/lib/dev/deps/gh_integration.rb @@ -145,24 +145,6 @@ def install_from_source(dep) FileUtils.rm_rf(staging_dir) if staging_dir end - # Point /current at the just-installed version via a relative - # symlink, swapped in atomically. Host consumers (e.g. cellbound's - # build-game.sh via UE_ROOT) reference this stable path without knowing the - # locked tag; the versioned dirs themselves stay immutable — only this - # pointer moves, to the most recently installed version. - # - # @param base_dir [Pathname] declared install_dir - # @param target_dir [Pathname] the published version dir - def publish_current(base_dir, target_dir) - link = base_dir / "current" - tmp = base_dir / ".current-#{Process.pid}-#{SecureRandom.hex(4)}" - File.symlink(target_dir.basename.to_s, tmp.to_s) - File.rename(tmp.to_s, link.to_s) - rescue StandardError - FileUtils.rm_f(tmp.to_s) if tmp - raise - end - # Fetch the tag's source tarball into archive_path. Uses `gh api .../tarball` # rather than a bare codeload URL so the request carries gh's auth token and # follows the redirect — required for private/Epic-gated repos. Isolated so diff --git a/lib/dev/deps/integration.rb b/lib/dev/deps/integration.rb index 5317aee..9533258 100644 --- a/lib/dev/deps/integration.rb +++ b/lib/dev/deps/integration.rb @@ -99,6 +99,25 @@ def publish_version(staging, versioned) rescue Errno::ENOTEMPTY, Errno::EEXIST, Errno::ENOTDIR, Errno::EISDIR false end + + # Point /current at the just-installed version via a relative + # symlink, swapped in atomically. Host consumers (e.g. UE_ROOT for the + # engine, AI_FLOW_AGENT_BIN for the agent CLI) reference this stable path + # without knowing the locked version; the versioned dirs themselves stay + # immutable — only this pointer moves, to the most recently installed + # version. + # + # @param base_dir [Pathname] declared install_dir + # @param target_dir [Pathname] the published version dir + def publish_current(base_dir, target_dir) + link = base_dir / "current" + tmp = base_dir / ".current-#{Process.pid}-#{SecureRandom.hex(4)}" + File.symlink(target_dir.basename.to_s, tmp.to_s) + File.rename(tmp.to_s, link.to_s) + rescue StandardError + FileUtils.rm_f(tmp.to_s) if tmp + raise + end end end end diff --git a/test/dev/deps/integration_test.rb b/test/dev/deps/integration_test.rb index 57a6a06..5eb36bb 100644 --- a/test/dev/deps/integration_test.rb +++ b/test/dev/deps/integration_test.rb @@ -4,9 +4,18 @@ require "test_helper" require "dev/deps/integration" require "dev/deps/repository" +require "fileutils" +require "pathname" +require "tmpdir" transform!(RSpock::AST::Transformation) class Dev::Deps::IntegrationTest < Minitest::Test + # Publishes the base class's private helpers under test — subclasses call + # them from install paths; here they are the unit. + class ExposedIntegration < Dev::Deps::Integration + def publish_current!(base_dir, target_dir) = publish_current(base_dir, target_dir) + end + test "base class install_all raises NotImplementedError" do Given "an Integration with injected dependencies" repo = Dev::Deps::Repository.new @@ -18,4 +27,23 @@ class Dev::Deps::IntegrationTest < Minitest::Test Then raises NotImplementedError end + + test "publish_current re-raises when the swap fails, leaving no temp link behind" do + Given "a base dir the symlink cannot be created in" + dir = Dir.mktmpdir("dev-integration-test-") + base_dir = Pathname(dir) / "base" + FileUtils.mkdir_p(base_dir) + FileUtils.chmod(0o555, base_dir) + integration = ExposedIntegration.new(repository: Dev::Deps::Repository.new, cache: nil) + + When "publishing the current pointer" + integration.publish_current!(base_dir, base_dir / "1.0.0") + + Then + raises Errno::EACCES + + Cleanup + FileUtils.chmod(0o755, base_dir) + FileUtils.rm_rf(dir) + end end From afe6f36c6af942f254ea9f770b982ed4b40fa495 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 21 Aug 2026 01:23:18 -0400 Subject: [PATCH 3/5] Add the cursor_agent integration: pin and install the Cursor agent CLI Co-authored-by: Cursor --- lib/dev/deps/cursor_agent_integration.rb | 123 ++++++++++ lib/dev/deps/cursor_agent_repository.rb | 78 +++++++ lib/dev/deps/dsl.rb | 14 ++ lib/dev/deps/registry.rb | 8 + .../dev/deps/cursor_agent_integration_test.rb | 221 ++++++++++++++++++ test/dev/deps/cursor_agent_repository_test.rb | 130 +++++++++++ test/dev/deps/dsl_test.rb | 17 ++ test/dev/deps/registry_consistency_test.rb | 2 +- 8 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 lib/dev/deps/cursor_agent_integration.rb create mode 100644 lib/dev/deps/cursor_agent_repository.rb create mode 100644 test/dev/deps/cursor_agent_integration_test.rb create mode 100644 test/dev/deps/cursor_agent_repository_test.rb diff --git a/lib/dev/deps/cursor_agent_integration.rb b/lib/dev/deps/cursor_agent_integration.rb new file mode 100644 index 0000000..fcac43a --- /dev/null +++ b/lib/dev/deps/cursor_agent_integration.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "fileutils" +require "pathname" +require "rbconfig" +require_relative "integration" +require_relative "../deps" + +module Dev + module Deps + # Lifecycle handler for the Cursor agent CLI (cursor_agent integration). + # + # Materializes the locked version into an immutable version-keyed subdir + # of its declared install_dir (install_dir//, see Integration's + # version-keyed layout) by downloading the same package the official + # install script would — https://downloads.cursor.com/lab//… — + # but pinned to the lock instead of whatever is latest. A `current` + # symlink gives consumers (AI_FLOW_AGENT_BIN, PATH entries) a stable path + # that survives version bumps. + class CursorAgentIntegration < Integration + class DownloadError < StandardError; end + class ExtractionError < StandardError; end + class UnsupportedArchitectureError < StandardError; end + + MARKER_FILE = ".dev-cursor-agent" + + # The served package URL scheme, as baked into the official install + # script (CursorAgentRepository resolves the version from the same + # script, so the pair drifts together or not at all). + DOWNLOAD_URL_TEMPLATE = "https://downloads.cursor.com/lab/%{version}/%{os}/%{arch}/agent-cli-package.tar.gz" + + # uname-style CPU names → the scheme's arch segment. + ARCHES = { "arm64" => "arm64", "aarch64" => "arm64", "x86_64" => "x64", "amd64" => "x64" }.freeze + + # Install all cursor_agent dependencies. + # + # @param dependencies [Array] cursor_agent deps to install + def install_all(dependencies) + dependencies.each { |dep| install(dep) } + end + + private + + # @param dep [Dependency] + def install(dep) + base_dir = Pathname(File.expand_path(dep.metadata["install_dir"])) + target_dir = versioned_dir(base_dir, dep.version) + if version_published?(target_dir, MARKER_FILE, dep.version) + puts ">>> #{dep.name}@#{dep.version} already installed at #{target_dir}" + publish_current(base_dir, target_dir) + return + end + + # Staging lives next to the version dirs so the publish is a cheap + # same-filesystem rename; a crashed run leaves published versions + # intact. + staging_dir = new_staging_dir(base_dir) + package_dir = staging_dir / "package" + archive_path = staging_dir / "agent-cli-package.tar.gz" + FileUtils.mkdir_p(package_dir) + + puts ">>> Downloading #{dep.name}@#{dep.version}" + download_package(package_url(dep.version), archive_path) + extract_package(archive_path, package_dir) + + # Stamp the marker inside staging so the published dir is atomically + # complete: a reader never sees content without a valid marker. + (package_dir / MARKER_FILE).write(dep.version) + if publish_version(package_dir, target_dir) + puts ">>> Installed #{dep.name}@#{dep.version} to #{target_dir}" + else + puts ">>> #{dep.name}@#{dep.version} published concurrently at #{target_dir}" + end + publish_current(base_dir, target_dir) + ensure + FileUtils.rm_rf(staging_dir) if staging_dir + end + + # The pinned package URL for this host. + # + # @param version [String] the locked version + # @return [String] + # @raise [UnsupportedArchitectureError] on a CPU the scheme has no + # package for + def package_url(version) + cpu = RbConfig::CONFIG["host_cpu"] + arch = ARCHES[cpu] + raise UnsupportedArchitectureError, "no cursor-agent package for #{cpu}" unless arch + + format(DOWNLOAD_URL_TEMPLATE, version: version, os: Deps.detect_host, arch: arch) + end + + # Download the package archive. Isolated so tests can stub the network + # boundary. + # + # @param url [String] + # @param archive_path [Pathname] destination .tar.gz + # @raise [DownloadError] if the download fails + def download_package(url, archive_path) + success = system("curl", "-fsSL", url, "-o", archive_path.to_s) + return if success + + raise DownloadError, "cursor-agent download failed: #{url}" + end + + # Extract the package, stripping its single top-level directory (the + # official script's --strip-components=1) so cursor-agent lands at the + # version dir root. + # + # @param archive_path [Pathname] + # @param package_dir [Pathname] + # @raise [ExtractionError] if tar fails + def extract_package(archive_path, package_dir) + success = system( + "tar", "--strip-components=1", "-xzf", archive_path.to_s, "-C", package_dir.to_s + ) + return if success + + raise ExtractionError, "cursor-agent extraction failed for #{archive_path}" + end + end + end +end diff --git a/lib/dev/deps/cursor_agent_repository.rb b/lib/dev/deps/cursor_agent_repository.rb new file mode 100644 index 0000000..38fac1b --- /dev/null +++ b/lib/dev/deps/cursor_agent_repository.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "open3" +require_relative "repository" +require_relative "dependency" + +module Dev + module Deps + # Resolves the Cursor agent CLI to a pinned version. + # + # Cursor publishes no release feed; the served install script + # (https://cursor.com/install) bakes the current version into its download + # URL at serve time. Resolution fetches the script and extracts that baked + # version — a single metadata-sized request, no artifact download — so the + # lock pins exactly what the script would have installed at resolve time. + # + # Declared in dependencies.rb as: + # cursor_agent "cursor-agent", install_dir: "~/.dev/tools/cursor-agent" + class CursorAgentRepository < Repository + # curl could not fetch the install script (network down, endpoint gone). + class InstallScriptFetchError < StandardError; end + + # The script no longer carries a recognizable baked download URL — the + # served shape drifted and this repository needs updating. + class VersionParseError < StandardError; end + + INSTALL_SCRIPT_URL = "https://cursor.com/install" + + # The baked version sits in the script's download URL: + # https://downloads.cursor.com/lab//${OS}/${ARCH}/... + BAKED_URL_PATTERN = %r{downloads\.cursor\.com/lab/([^/"]+)/} + + # Resolve the agent CLI to the version the served script currently bakes. + # + # @param id [Hash] must include "name", "integration", "group", + # "install_dir" + # @return [Dependency] + # @raise [InstallScriptFetchError] if the script cannot be fetched + # @raise [VersionParseError] if no baked version is found in it + def fetch(id) + script, ok = fetch_install_script + unless ok + raise InstallScriptFetchError, + "could not fetch #{INSTALL_SCRIPT_URL} — is the network up?" + end + + version = script[BAKED_URL_PATTERN, 1] + unless version + raise VersionParseError, + "no baked download URL found in #{INSTALL_SCRIPT_URL} — " \ + "the served script's shape changed; update CursorAgentRepository" + end + + Dependency.new( + name: id["name"], + integration: id["integration"].to_sym, + group: id["group"].to_sym, + version: version, + hash: nil, + metadata: { "install_dir" => id["install_dir"] }, + ) + end + + private + + # Fetch the served install script. Isolated so tests can stub the + # network boundary. + # + # @return [Array(String, Boolean)] script body, success? + def fetch_install_script + out, _err, status = Open3.capture3("curl", "-fsSL", INSTALL_SCRIPT_URL) + [out, status.success?] + rescue Errno::ENOENT + ["", false] + end + end + end +end diff --git a/lib/dev/deps/dsl.rb b/lib/dev/deps/dsl.rb index d30589d..fc3a2ae 100644 --- a/lib/dev/deps/dsl.rb +++ b/lib/dev/deps/dsl.rb @@ -298,6 +298,20 @@ def custom(name, integration:, **spec) add_declaration(name, integration.to_sym, spec) end + # Declare the Cursor agent CLI (the headless agent ai-flow spawns), + # installed into a version-keyed subdir of install_dir with a stable + # `current` symlink (like gh/steam). Resolution pins the version the + # served install script currently bakes into its download URL — Cursor + # publishes no release feed — so the lock captures an exact version and + # hosts install that pin, not whatever is latest at install time. + # + # @param name [String, Symbol] dependency name (e.g. "cursor-agent") + # @param install_dir [String] host directory the CLI is installed into + # @param spec [Hash] additional options (e.g. host:) + def cursor_agent(name, install_dir:, **spec) + add_declaration(name, :cursor_agent, spec.merge(install_dir: install_dir)) + end + # Pin the Xcode toolchain — a first-class dep like ruby, but riding the # normal resolver -> lockfile -> install pipeline. The integration is # inherently darwin-scoped (Xcode only exists on macOS; a no-op on other diff --git a/lib/dev/deps/registry.rb b/lib/dev/deps/registry.rb index f0c89d6..661762c 100644 --- a/lib/dev/deps/registry.rb +++ b/lib/dev/deps/registry.rb @@ -18,6 +18,8 @@ require_relative "xcode_integration" require_relative "pip_repository" require_relative "pip_integration" +require_relative "cursor_agent_repository" +require_relative "cursor_agent_integration" module Dev module Deps @@ -130,6 +132,12 @@ def host? integration_needs: %i[project_root python_version], scope: HOST, ), + Entry.new( + symbol: :cursor_agent, + repository: CursorAgentRepository, + integration: CursorAgentIntegration, + scope: HOST, + ), ].freeze class << self diff --git a/test/dev/deps/cursor_agent_integration_test.rb b/test/dev/deps/cursor_agent_integration_test.rb new file mode 100644 index 0000000..6b4db1c --- /dev/null +++ b/test/dev/deps/cursor_agent_integration_test.rb @@ -0,0 +1,221 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/deps/cursor_agent_integration" +require "dev/deps/cursor_agent_repository" +require "dev/deps/dependency" +require "dev/deps/cache" +require "tmpdir" +require "fileutils" + +transform!(RSpock::AST::Transformation) +class Dev::Deps::CursorAgentIntegrationTest < Minitest::Test + # Stubs the download boundary: records the URL and plants a real package + # tarball (one top-level dir, like the served artifact) at the destination. + class FixtureCursorAgentIntegration < Dev::Deps::CursorAgentIntegration + attr_reader :downloaded_urls + + def initialize(tarball:, **kwargs) + super(**kwargs) + @tarball = tarball + @downloaded_urls = [] + end + + def download_package(url, archive_path) + @downloaded_urls << url + FileUtils.cp(@tarball, archive_path) + end + end + + # A real tar.gz shaped like the served package: a single top-level dir + # wrapping the cursor-agent binary (the install strips it, like the + # official script's --strip-components=1). + def build_package_tarball(dir) + payload = File.join(dir, "package", "dist-package") + FileUtils.mkdir_p(payload) + File.write(File.join(payload, "cursor-agent"), "#!/bin/sh\necho agent\n") + tarball = File.join(dir, "agent-cli-package.tar.gz") + system("tar", "-czf", tarball, "-C", File.join(dir, "package"), "dist-package") || raise("tar failed") + tarball + end + + def build_dependency(install_dir) + Dev::Deps::Dependency.new( + name: "cursor-agent", integration: :cursor_agent, group: :baseline, + version: "2026.08.11-e8db854", hash: nil, + metadata: { "install_dir" => install_dir }, + ) + end + + def build_integration(dir, tarball) + FixtureCursorAgentIntegration.new( + tarball: tarball, + repository: Dev::Deps::CursorAgentRepository.new, + cache: Dev::Deps::Cache.new(cache_dir: File.join(dir, "cache")), + ) + end + + test "install materializes the version-keyed dir with marker and current pointer" do + Given "a locked dep and a fixture package" + dir = Dir.mktmpdir("dev-cursor-agent-test-") + install_dir = File.join(dir, "tools", "cursor-agent") + integration = build_integration(dir, build_package_tarball(dir)) + + When "installing" + integration.install_all([build_dependency(install_dir)]) + + Then "the binary landed versioned, marker stamped, current pointing at it" + File.exist?(File.join(install_dir, "2026.08.11-e8db854", "cursor-agent")) + File.read(File.join(install_dir, "2026.08.11-e8db854", ".dev-cursor-agent")).strip == "2026.08.11-e8db854" + File.readlink(File.join(install_dir, "current")) == "2026.08.11-e8db854" + + Cleanup + FileUtils.rm_rf(dir) + end + + test "the download URL carries the locked version and this host's os/arch" do + Given "a locked dep" + dir = Dir.mktmpdir("dev-cursor-agent-test-") + integration = build_integration(dir, build_package_tarball(dir)) + + When "installing" + integration.install_all([build_dependency(File.join(dir, "tools"))]) + url = integration.downloaded_urls.fetch(0) + + Then "the URL is the served scheme, pinned to the lock" + url.start_with?("https://downloads.cursor.com/lab/2026.08.11-e8db854/") + url.end_with?("/agent-cli-package.tar.gz") + %w[darwin linux].any? { |os| url.include?("/#{os}/") } + %w[arm64 x64].any? { |arch| url.include?("/#{arch}/") } + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a published version is skipped without re-downloading" do + Given "an already-installed version" + dir = Dir.mktmpdir("dev-cursor-agent-test-") + install_dir = File.join(dir, "tools", "cursor-agent") + tarball = build_package_tarball(dir) + build_integration(dir, tarball).install_all([build_dependency(install_dir)]) + integration = build_integration(dir, tarball) + + When "installing again" + integration.install_all([build_dependency(install_dir)]) + + Then "idempotent: no second download" + integration.downloaded_urls.empty? + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a concurrently published version is left intact and still pointed at" do + Given "a version dir another job already published mid-download (content, no readable marker yet)" + dir = Dir.mktmpdir("dev-cursor-agent-test-") + install_dir = File.join(dir, "tools", "cursor-agent") + occupied = File.join(install_dir, "2026.08.11-e8db854") + FileUtils.mkdir_p(occupied) + File.write(File.join(occupied, "cursor-agent"), "the concurrent winner's binary\n") + integration = build_integration(dir, build_package_tarball(dir)) + + When "installing into the occupied slot" + integration.install_all([build_dependency(install_dir)]) + + Then "first writer wins — the existing dir survives and current points at it" + File.read(File.join(occupied, "cursor-agent")) == "the concurrent winner's binary\n" + File.readlink(File.join(install_dir, "current")) == "2026.08.11-e8db854" + + Cleanup + FileUtils.rm_rf(dir) + end + + # The real curl boundary, exercised through a fake curl on PATH — the same + # argv the integration passes, no network. + def with_fake_curl(dir, script_body) + fake_bin = File.join(dir, "bin") + FileUtils.mkdir_p(fake_bin) + File.write(File.join(fake_bin, "curl"), script_body) + FileUtils.chmod(0o755, File.join(fake_bin, "curl")) + original_path = ENV.fetch("PATH") + ENV["PATH"] = "#{fake_bin}:#{original_path}" + original_path + end + + test "the real download boundary installs through whatever curl PATH serves" do + Given "a fake curl that writes the fixture package to curl's -o destination" + dir = Dir.mktmpdir("dev-cursor-agent-test-") + install_dir = File.join(dir, "tools", "cursor-agent") + tarball = build_package_tarball(dir) + original_path = with_fake_curl(dir, <<~SH) + #!/bin/sh + while [ $# -gt 1 ]; do + if [ "$1" = "-o" ]; then cp "#{tarball}" "$2"; exit 0; fi + shift + done + exit 1 + SH + integration = Dev::Deps::CursorAgentIntegration.new( + repository: Dev::Deps::CursorAgentRepository.new, + cache: Dev::Deps::Cache.new(cache_dir: File.join(dir, "cache")), + ) + + When "installing through the real download path" + integration.install_all([build_dependency(install_dir)]) + + Then "the binary landed exactly as with the stubbed boundary" + File.exist?(File.join(install_dir, "2026.08.11-e8db854", "cursor-agent")) + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(dir) + end + + test "a failed download raises DownloadError" do + Given "a curl that always fails" + dir = Dir.mktmpdir("dev-cursor-agent-test-") + original_path = with_fake_curl(dir, "#!/bin/sh\nexit 22\n") + integration = Dev::Deps::CursorAgentIntegration.new( + repository: Dev::Deps::CursorAgentRepository.new, + cache: Dev::Deps::Cache.new(cache_dir: File.join(dir, "cache")), + ) + + When "installing" + integration.install_all([build_dependency(File.join(dir, "tools", "cursor-agent"))]) + + Then + raises Dev::Deps::CursorAgentIntegration::DownloadError + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(dir) + end + + test "a corrupt package raises ExtractionError" do + Given "a curl that serves bytes tar cannot read" + dir = Dir.mktmpdir("dev-cursor-agent-test-") + original_path = with_fake_curl(dir, <<~SH) + #!/bin/sh + while [ $# -gt 1 ]; do + if [ "$1" = "-o" ]; then echo "not a tarball" > "$2"; exit 0; fi + shift + done + exit 1 + SH + integration = Dev::Deps::CursorAgentIntegration.new( + repository: Dev::Deps::CursorAgentRepository.new, + cache: Dev::Deps::Cache.new(cache_dir: File.join(dir, "cache")), + ) + + When "installing" + integration.install_all([build_dependency(File.join(dir, "tools", "cursor-agent"))]) + + Then + raises Dev::Deps::CursorAgentIntegration::ExtractionError + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(dir) + end +end diff --git a/test/dev/deps/cursor_agent_repository_test.rb b/test/dev/deps/cursor_agent_repository_test.rb new file mode 100644 index 0000000..4e452ef --- /dev/null +++ b/test/dev/deps/cursor_agent_repository_test.rb @@ -0,0 +1,130 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/deps/cursor_agent_repository" +require "tmpdir" +require "fileutils" + +transform!(RSpock::AST::Transformation) +class Dev::Deps::CursorAgentRepositoryTest < Minitest::Test + # The served install script's load-bearing lines: the version is baked into + # the download URL at serve time, which is what makes it pinnable. + SCRIPT = <<~SCRIPT + #!/bin/bash + DOWNLOAD_URL="https://downloads.cursor.com/lab/2026.08.11-e8db854/${OS}/${ARCH}/agent-cli-package.tar.gz" + FINAL_DIR="$HOME/.local/share/cursor-agent/versions/2026.08.11-e8db854" + SCRIPT + + # Replays a canned curl result at the network boundary. + class FixtureCursorAgentRepository < Dev::Deps::CursorAgentRepository + def initialize(body:, success: true) + super() + @body = body + @success = success + end + + def fetch_install_script + [@body, @success] + end + end + + def fetch_id + { + "name" => "cursor-agent", + "integration" => "cursor_agent", + "group" => "baseline", + "install_dir" => "~/.dev/tools/cursor-agent", + } + end + + test "fetch pins the version the served install script bakes into its URL" do + Given "a repository replaying the served script" + repository = FixtureCursorAgentRepository.new(body: SCRIPT) + + When "resolving" + dependency = repository.fetch(fetch_id) + + Then "the baked version is the pin and the install_dir rides the metadata" + dependency.name == "cursor-agent" + dependency.integration == :cursor_agent + dependency.group == :baseline + dependency.version == "2026.08.11-e8db854" + dependency.metadata["install_dir"] == "~/.dev/tools/cursor-agent" + + Cleanup + nil + end + + test "a failed script fetch raises the fetch error" do + Given "a repository whose curl fails" + repository = FixtureCursorAgentRepository.new(body: "", success: false) + + When "resolving" + repository.fetch(fetch_id) + + Then + raises Dev::Deps::CursorAgentRepository::InstallScriptFetchError + + Cleanup + nil + end + + test "a script without a baked download URL raises the parse error" do + Given "a repository replaying a script that drifted away from the known shape" + repository = FixtureCursorAgentRepository.new(body: "#!/bin/bash\necho reshaped\n") + + When "resolving" + repository.fetch(fetch_id) + + Then + raises Dev::Deps::CursorAgentRepository::VersionParseError + + Cleanup + nil + end + + test "the real curl boundary resolves through whatever curl PATH serves" do + Given "a fake curl at the front of PATH replaying the served script" + dir = Dir.mktmpdir("dev-cursor-agent-repo-test-") + script_file = File.join(dir, "install.sh") + File.write(script_file, SCRIPT) + fake_bin = File.join(dir, "bin") + FileUtils.mkdir_p(fake_bin) + File.write(File.join(fake_bin, "curl"), "#!/bin/sh\ncat \"#{script_file}\"\n") + FileUtils.chmod(0o755, File.join(fake_bin, "curl")) + original_path = ENV.fetch("PATH") + ENV["PATH"] = "#{fake_bin}:#{original_path}" + repository = Dev::Deps::CursorAgentRepository.new + + When "resolving through the real boundary" + dependency = repository.fetch(fetch_id) + + Then "the fake-served script's baked version is the pin" + dependency.version == "2026.08.11-e8db854" + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(dir) + end + + test "a host without curl maps to the fetch error, not a crash" do + Given "a PATH serving no curl at all" + dir = Dir.mktmpdir("dev-cursor-agent-repo-test-") + empty_bin = File.join(dir, "bin") + FileUtils.mkdir_p(empty_bin) + original_path = ENV.fetch("PATH") + ENV["PATH"] = empty_bin + repository = Dev::Deps::CursorAgentRepository.new + + When "resolving through the real boundary" + repository.fetch(fetch_id) + + Then + raises Dev::Deps::CursorAgentRepository::InstallScriptFetchError + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(dir) + end +end diff --git a/test/dev/deps/dsl_test.rb b/test/dev/deps/dsl_test.rb index 5eb149a..ab3fb7e 100644 --- a/test/dev/deps/dsl_test.rb +++ b/test/dev/deps/dsl_test.rb @@ -375,6 +375,23 @@ class Dev::Deps::DSLTest < Minitest::Test decl.constraint["branch"] == "public" end + test "cursor_agent() produces a DependencyDeclaration with cursor_agent integration" do + When "defining the agent CLI in a darwin-gated baseline group" + config = Dev::Deps.define do + group :baseline, host: :darwin do + cursor_agent "cursor-agent", install_dir: "~/.dev/tools/cursor-agent" + end + end + + Then + decl = config.declarations[0] + decl.name == "cursor-agent" + decl.integration == :cursor_agent + decl.group == :baseline + decl.host == :darwin + decl.constraint["install_dir"] == "~/.dev/tools/cursor-agent" + end + test "steam() accepts an explicit buildid pin" do When "defining a steam dep with a pinned buildid" config = Dev::Deps.define do diff --git a/test/dev/deps/registry_consistency_test.rb b/test/dev/deps/registry_consistency_test.rb index bb19c5d..0b2d1c1 100644 --- a/test/dev/deps/registry_consistency_test.rb +++ b/test/dev/deps/registry_consistency_test.rb @@ -26,7 +26,7 @@ class Dev::Deps::RegistryConsistencyTest < Minitest::Test # Every GroupDSL verb that creates a declaration, mapped to its integration # symbol. Adding a new declaration verb must add a Registry entry too. - DECLARATION_INTEGRATIONS = %i[bundler brew cmake luarocks ficsit gh steam pip].freeze + DECLARATION_INTEGRATIONS = %i[bundler brew cmake luarocks ficsit gh steam pip cursor_agent].freeze def source_file(klass) File.realpath(Object.const_source_location(klass.name).first) From df37f737e1408fedaf7b714ec2feac4e5221fd43 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 21 Aug 2026 01:23:26 -0400 Subject: [PATCH 4/5] Add Dev::Deps::Baseline: the shipped host manifest, lock, and stamp Co-authored-by: Cursor --- bin/update-baseline.rb | 16 +++ lib/dev/deps/baseline.rb | 126 ++++++++++++++++++ share/baseline/dependencies.rb | 33 +++++ share/baseline/deps.lock | 30 +++++ test/dev/deps/baseline_test.rb | 227 +++++++++++++++++++++++++++++++++ 5 files changed, 432 insertions(+) create mode 100755 bin/update-baseline.rb create mode 100644 lib/dev/deps/baseline.rb create mode 100644 share/baseline/dependencies.rb create mode 100644 share/baseline/deps.lock create mode 100644 test/dev/deps/baseline_test.rb diff --git a/bin/update-baseline.rb b/bin/update-baseline.rb new file mode 100755 index 0000000..34d7b73 --- /dev/null +++ b/bin/update-baseline.rb @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Regenerate share/baseline/deps.lock from share/baseline/dependencies.rb. +# +# dev-repo maintenance only: run here when the baseline manifest changes, +# commit the lock, release. Consuming hosts never resolve — they install +# the shipped pin (Dev::Deps::Baseline#converge via `dev up`). + +lib = File.expand_path("../lib", __dir__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) + +require "dev/deps/baseline" + +Dev::Deps::Baseline.new.update_lock! +puts "dev: baseline lock regenerated at #{Dev::Deps::Baseline::SHIPPED_DIR / "deps.lock"}" diff --git a/lib/dev/deps/baseline.rb b/lib/dev/deps/baseline.rb new file mode 100644 index 0000000..cfbe6d2 --- /dev/null +++ b/lib/dev/deps/baseline.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "digest" +require "pathname" +require_relative "../deps" +require_relative "cache" +require_relative "dependency_installer" +require_relative "lockfile" +require_relative "registry" +require_relative "resolver" +require_relative "staleness" + +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), + # 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 lock is resolved in dev's own repo (`bin/update-baseline.rb`) and + # committed, so hosts only ever install from the shipped pin — they never + # resolve. Convergence state is one stamp per host under a fixed key + # (Staleness with state_key "host-baseline"), giving the same O(1) + # digest staleness check projects get: + # + # - `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+lock, relative to this file (lib/dev/deps/ → + # repo or libexec root) — the installed location under brew, same + # resolution as Plan::Templates::BUNDLE_FILE. + SHIPPED_DIR = Pathname(File.expand_path(File.join(__dir__, "..", "..", "..", "share", "baseline"))) + + # Fixed stamp key: the baseline is host-singular, so its stamp must not + # vary with the manifest dir's path (which moves on every brew upgrade). + STATE_KEY = "host-baseline" + + STALE_MESSAGE = "host baseline stale — run `dev up`" + + # @param manifest_dir [Pathname, String] dir holding dependencies.rb + + # deps.lock (the shipped bundle 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 installer_factory [#call] (lockfile, integrations) → installer; + # the DependencyInstaller seam, injectable so tests never touch the host + def initialize( + manifest_dir: SHIPPED_DIR, + state_dir: default_state_dir, + installer_factory: ->(lockfile, integrations) { DependencyInstaller.new(lockfile:, integrations:) } + ) + @manifest_dir = Pathname(manifest_dir) + @state_dir = Pathname(state_dir) + @installer_factory = installer_factory + end + + # The warn-only nag for a stale host, nil when converged. A distribution + # without a shipped lock has nothing to converge and is never stale. + # + # @return [String, nil] + def message + staleness.install_message && STALE_MESSAGE + end + + # Install the shipped lock (filtered to this env and host OS, like any + # project install) and stamp the host converged. Stamping only happens + # after a fully-successful install, so a crashed run keeps nagging. + # + # @return [void] + def converge + lockfile = Lockfile.new(dir: @manifest_dir) + integrations = Registry.host_integrations(project_root: @manifest_dir, cache: Cache.new) + @installer_factory.call(lockfile, integrations).install(env: Deps.detect_env, host: Deps.detect_host) + staleness.stamp_installed! + 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 if message.nil? + + converge + true + end + + # Re-resolve the manifest and rewrite the committed lock — dev-repo + # maintenance (bin/update-baseline.rb), never run on consuming hosts. + # + # @param resolver [Resolver] injectable for tests; defaults to the + # registry-wired resolver + # @return [void] + def update_lock!(resolver: Resolver.new(repositories: Registry.repositories(project_root: @manifest_dir))) + manifest = @manifest_dir / "dependencies.rb" + Deps.reset! + Kernel.load(manifest.to_s) + declarations = Deps.last_config&.declarations || [] + Lockfile.new(dir: @manifest_dir).lock( + resolver.resolve(declarations), + manifest_digest: Digest::SHA256.file(manifest.to_s).hexdigest, + ) + end + + private + + # The baseline's staleness view: the shipped dir plays the project-root + # role (it holds the lock), while the stamp lives under the fixed + # host-singular key. + # + # @return [Staleness] + def staleness + Staleness.new(project_root: @manifest_dir, state_dir: @state_dir, state_key: STATE_KEY) + 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..664e347 --- /dev/null +++ b/share/baseline/dependencies.rb @@ -0,0 +1,33 @@ +# 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 — with the lock committed next to this +# file (regenerate via bin/update-baseline.rb). `dev up` converges it as +# its first step; every other command only warns when it drifts. +# +# 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. darwin-gated: agent jobs + # route to Mac runners; target hosts (the gamebox) never get agent pieces. + group :agent, host: :darwin do + cursor_agent "cursor-agent", install_dir: "~/.dev/tools/cursor-agent" + end +end diff --git a/share/baseline/deps.lock b/share/baseline/deps.lock new file mode 100644 index 0000000..9550087 --- /dev/null +++ b/share/baseline/deps.lock @@ -0,0 +1,30 @@ +# Generated by dev. Do not edit. +# Edit dependencies.rb and run dev update-deps to change. +# dependencies-digest: 977b7a12f74c82f37daf8637a3957a88eaa9725173128f82a227c04c2a161ef3 + +git: + integration: brew + group: baseline + version: 2.55.0 + hash: SHA256=8cae58826d317457128023d07c07f00ac00aaf3019356ff72a837e804f5d0306 +gh: + integration: brew + group: baseline + version: 2.98.0 + hash: SHA256=a726b1b74d9ec5d18cdd68df30b1eb1f4d0c2ceff1a93d42f227d9501cc92c07 +rbenv: + integration: brew + group: baseline + version: 1.3.2 + hash: SHA256=8158fb1f059c1316523b2cc9074c5c041b3944828dc3b76cb032f893e754f013 +shadowenv: + integration: brew + group: baseline + version: 3.5.1 + hash: SHA256=3249a38740fd5a0d86466569ae6bb112e7cf33be9ed553ba728937337e2d742d +cursor-agent: + integration: cursor_agent + group: agent + version: 2026.08.11-e8db854 + install_dir: "~/.dev/tools/cursor-agent" + host: darwin diff --git a/test/dev/deps/baseline_test.rb b/test/dev/deps/baseline_test.rb new file mode 100644 index 0000000..ed61df0 --- /dev/null +++ b/test/dev/deps/baseline_test.rb @@ -0,0 +1,227 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/deps/baseline" +require "dev/deps/lockfile" +require "dev/deps/dependency" +require "digest" +require "tmpdir" +require "fileutils" + +transform!(RSpock::AST::Transformation) +class Dev::Deps::BaselineTest < Minitest::Test + # A shipped-baseline-shaped dir: a manifest plus the lock generated from it + # (manifest digest recorded, like update_lock! does). + def build_shipped_dir(dir) + manifest_dir = File.join(dir, "baseline") + FileUtils.mkdir_p(manifest_dir) + manifest = File.join(manifest_dir, "dependencies.rb") + File.write(manifest, <<~MANIFEST) + Dev::Deps.define do + group :baseline do + brew "git" + end + end + MANIFEST + Dev::Deps::Lockfile.new(dir: manifest_dir).lock( + [Dev::Deps::Dependency.new( + name: "git", integration: :brew, group: :baseline, version: "2.51.0", hash: nil, metadata: {}, + )], + manifest_digest: Digest::SHA256.file(manifest).hexdigest, + ) + manifest_dir + end + + # Stands in for DependencyInstaller at the factory seam: records the + # env/host each install ran for, touches nothing on the host. + class RecordingInstaller + attr_reader :installs + + def initialize + @installs = [] + end + + def install(env:, host:) + @installs << { env: env, host: host } + end + end + + # Stands in for Resolver at update_lock!'s injection seam. + class CannedResolver + def resolve(_declarations) + [Dev::Deps::Dependency.new( + name: "git", integration: :brew, group: :baseline, version: "2.51.0", hash: nil, metadata: {}, + )] + end + end + + def build_baseline(dir, manifest_dir, installer: RecordingInstaller.new) + Dev::Deps::Baseline.new( + manifest_dir: manifest_dir, + state_dir: File.join(dir, "state"), + installer_factory: ->(_lockfile, _integrations) { installer }, + ) + end + + test "a never-converged host reports the baseline message" do + Given "a shipped lock and no stamp on this host" + dir = Dir.mktmpdir("dev-baseline-test-") + baseline = build_baseline(dir, build_shipped_dir(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 for the detected env and host, then stamps" do + Given "a stale host" + dir = Dir.mktmpdir("dev-baseline-test-") + installer = RecordingInstaller.new + baseline = build_baseline(dir, build_shipped_dir(dir), installer: installer) + + When "converging" + baseline.converge + + Then "one filtered install ran and the host went quiet" + installer.installs == [{ env: Dev::Deps.detect_env, host: Dev::Deps.detect_host }] + baseline.message.nil? + File.exist?(File.join(dir, "state", "host-baseline", "installed-digest")) + + 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-") + installer = RecordingInstaller.new + baseline = build_baseline(dir, build_shipped_dir(dir), installer: installer) + + When "converging if stale" + converged = baseline.converge_if_stale + + Then + converged == true + installer.installs.size == 1 + + 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 lock" + dir = Dir.mktmpdir("dev-baseline-test-") + manifest_dir = build_shipped_dir(dir) + build_baseline(dir, manifest_dir).converge + installer = RecordingInstaller.new + baseline = build_baseline(dir, manifest_dir, installer: installer) + + When "converging if stale" + converged = baseline.converge_if_stale + + Then "nothing installed" + converged == false + installer.installs.empty? + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a shipped lock change (a dev upgrade) makes a converged host stale again" do + Given "a converged host whose shipped lock then changes" + dir = Dir.mktmpdir("dev-baseline-test-") + manifest_dir = build_shipped_dir(dir) + build_baseline(dir, manifest_dir).converge + Dev::Deps::Lockfile.new(dir: manifest_dir).lock( + [Dev::Deps::Dependency.new( + name: "gh", integration: :brew, group: :baseline, version: "2.83.0", hash: nil, metadata: {}, + )], + ) + baseline = build_baseline(dir, manifest_dir) + + Expect + baseline.message == "host baseline stale — run `dev up`" + + Cleanup + FileUtils.rm_rf(dir) + end + + test "converge with the default installer wiring is host-safe on an empty distribution" do + Given "a manifest dir with no lock, and no installer injected (real wiring)" + dir = Dir.mktmpdir("dev-baseline-test-") + manifest_dir = File.join(dir, "baseline") + FileUtils.mkdir_p(manifest_dir) + baseline = Dev::Deps::Baseline.new(manifest_dir: manifest_dir, state_dir: File.join(dir, "state")) + + When "converging through the real DependencyInstaller" + baseline.converge + + Then "an empty lock read dispatches nothing and no stamp is written (no digest to record)" + !File.exist?(File.join(dir, "state", "host-baseline", "installed-digest")) + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a distribution without a shipped lock is never stale" do + Given "a manifest dir with no lock at all" + dir = Dir.mktmpdir("dev-baseline-test-") + manifest_dir = File.join(dir, "baseline") + FileUtils.mkdir_p(manifest_dir) + installer = RecordingInstaller.new + baseline = build_baseline(dir, manifest_dir, installer: installer) + + When "checking and converging if stale" + converged = baseline.converge_if_stale + + Then "quiet, and nothing to install" + baseline.message.nil? + converged == false + installer.installs.empty? + + Cleanup + FileUtils.rm_rf(dir) + end + + test "update_lock! resolves the manifest and writes the digest-stamped lock" do + Given "a manifest dir without a lock" + dir = Dir.mktmpdir("dev-baseline-test-") + manifest_dir = File.join(dir, "baseline") + FileUtils.mkdir_p(manifest_dir) + manifest = File.join(manifest_dir, "dependencies.rb") + File.write(manifest, <<~MANIFEST) + Dev::Deps.define do + group :baseline do + brew "git" + end + end + MANIFEST + baseline = build_baseline(dir, manifest_dir) + + When "updating the lock through an injected resolver" + baseline.update_lock!(resolver: CannedResolver.new) + + Then "the lock exists, carries the manifest digest, and round-trips the dep" + lockfile = Dev::Deps::Lockfile.new(dir: manifest_dir) + lockfile.manifest_digest == Digest::SHA256.file(manifest).hexdigest + lockfile.read.map(&:name) == ["git"] + + Cleanup + FileUtils.rm_rf(dir) + end + + test "the shipped baseline carries its manifest and committed lock" do + Given "the distribution's own baseline dir" + shipped = Dev::Deps::Baseline::SHIPPED_DIR + + Expect "manifest and lock both ship (the lock is committed, not generated on hosts)" + shipped.join("dependencies.rb").file? + shipped.join("deps.lock").file? + + Cleanup + nil + end +end From 3343c67f060bcf3ae1f6fc38f9e19b4508beab7b Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 21 Aug 2026 01:23:26 -0400 Subject: [PATCH 5/5] Converge the host baseline under dev up and nag (warn-only) elsewhere Co-authored-by: Cursor --- README.md | 11 ++++++- bin/dev | 13 ++++++++ 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 ++++++++++++++++++++++++++-- 7 files changed, 123 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e44415e..130da0b 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). The manifest and its lock live inside dev's distribution (`share/baseline/`), not in any project repo: upgrading dev is what changes the baseline, and hosts never resolve — they install the shipped pin (the lock is regenerated in this repo via `bin/update-baseline.rb` and committed). + +`dev up` converges the baseline as its first step, gated by an O(1) digest check against a host-side stamp (`$XDG_DATA_HOME/dev/host-baseline/installed-digest`) — a no-op on warm hosts. `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`") — advisory even in CI, never a block. `Dev::Deps::Baseline` owns the layer; it reuses the same resolver → lockfile → installer pipeline and `Staleness` machinery as project deps, pointed at the shipped manifest with a fixed stamp key. + ### dependencies.rb Declare dependencies using a Ruby DSL: @@ -335,11 +341,14 @@ All built-in integrations are declared in one place — `lib/dev/deps/registry.r | `steam()` | SteamIntegration | SteamRepository | deps.lock | | `xcode()` | XcodeIntegration | XcodeRepository | deps.lock | | `pip()` | PipIntegration | PipRepository | deps.lock | +| `cursor_agent()` | CursorAgentIntegration | CursorAgentRepository | deps.lock | `xcode "26.1.1"` pins the Xcode toolchain (macOS only; a no-op on other hosts). dev installs the pin to `/Applications/Xcode-.app` via the [xcodes](https://github.com/XcodesOrg/xcodes) CLI — declare `brew "xcodes", host: :darwin` in `:build` so it exists first — and publishes `DEVELOPER_DIR` into the project shadowenv. Interactive runs pass any Apple ID/2FA/sudo prompt through to you; headless runs fail fast with remediation instead of hanging (normal practice: pre-install the pin interactively once during machine bring-up, e.g. a CI runner's). `gem()` declares Ruby gems: dev generates a `Gemfile`/`Gemfile.lock` from your declarations (a top-level `gem` lands in the default group; `group(:test) { gem ... }` scopes it to a bundler group), and `dev install-deps` runs `bundle install`. `brew()` dual-writes — the container build path keeps reading the group structure while `dev install-deps` also installs the formulae on the host (idempotently). +`cursor_agent()` declares the headless Cursor agent CLI (what ai-flow spawns). Cursor publishes no release feed, so resolution fetches the served install script (`https://cursor.com/install`) and pins the version it bakes into its download URL; install downloads that exact pinned package into a version-keyed subdir of `install_dir` with a stable `current` symlink — point `AI_FLOW_AGENT_BIN` at `/current/cursor-agent`. Declared in the host baseline (darwin-gated), not in project manifests. + `python "3.12"` pins the Python toolchain: dev provisions the interpreter (Homebrew `python@3.12`) and a project-local `.venv`, and publishes it into the project shadowenv (`VIRTUAL_ENV` + `.venv/bin` on `PATH`). `pip()` declares packages installed into that venv — like `luarocks()`, you declare only the top-level packages and pip resolves the transitive tree at install time. Gate heavy, platform-specific stacks (e.g. a PyTorch-backed ML tool) with `host:` so only the machines that use them pay the download. ### Custom integrations @@ -368,7 +377,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`. 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/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