From 4f5a4faa70caa5fa3112bcd994b321bb85441a3f Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 23 Sep 2026 13:08:10 -0400 Subject: [PATCH] deps path gh : resolve a gh release's install dir from the lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumers of gh-provisioned engines hardcode `~/.dev/engines//current`, which broke the moment the agent-host bootstrap re-rooted the data root onto /Users/Shared/dev (cellbound-3d's `dev editor`). The accessor now answers for gh deps the way it already does for xcode: from the locked version, through DataRoot, to the immutable version-keyed dir — verified by the publish marker, so a half-published or missing install surfaces as NotInstalledError ("run dev up") instead of a wrong engine. Resolving from the lock rather than the `current` pointer means a checkout always gets the tag its lockfile names, not whatever this machine installed last; `current` becomes retirable once consumers migrate. Co-authored-by: Cursor --- README.md | 2 +- lib/dev/deps/accessor.rb | 52 +++++++++++-- src/dev/builtins/deps_command.rb | 2 +- test/dev/deps/accessor_test.rb | 123 +++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 59a90ed..f87f44b 100644 --- a/README.md +++ b/README.md @@ -437,7 +437,7 @@ Custom integrations implement `Dev::Deps::Integration` (with `install_all(pins, - **`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 (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 deps path `** — print the absolute path of a locked artifact (e.g. `dev deps path ficsit SML LinuxServer`, `dev deps path xcode` for the pinned DEVELOPER_DIR, or `dev deps path gh UnrealEngineMac` for a gh release's version-keyed install dir under the data root) 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`. diff --git a/lib/dev/deps/accessor.rb b/lib/dev/deps/accessor.rb index 908e00c..b5a3b05 100644 --- a/lib/dev/deps/accessor.rb +++ b/lib/dev/deps/accessor.rb @@ -7,14 +7,17 @@ require_relative "cache" require_relative "ficsit_integration" require_relative "xcode_integration" +require_relative "gh_integration" +require_relative "../data_root" module Dev module Deps # Read-only accessor over the lockfile + content cache, surfaced as # `dev deps `. It answers "where is a locked dep's artifact?" # — the cached zip for a ficsit mod platform, the DEVELOPER_DIR for the - # pinned Xcode — so consumers (deploy, build scripts, CI) resolve paths - # from the lockfile instead of reconstructing dev's layout conventions. + # pinned Xcode, the version-keyed install dir of a gh release — so + # consumers (deploy, build scripts, CI) resolve paths from the lockfile + # instead of reconstructing dev's layout conventions. class Accessor extend T::Sig @@ -24,16 +27,25 @@ class PlatformNotLockedError < StandardError; end class NotCachedError < StandardError; end class NotInstalledError < StandardError; end - USAGE = "usage: dev deps path ficsit | dev deps path xcode" + USAGE = "usage: dev deps path ficsit | dev deps path xcode | dev deps path gh " # @param lockfile [Lockfile] # @param cache [Cache] # @param xcode_install_root [String] where Xcode bundles live (tests use a tmpdir) - sig { params(lockfile: Lockfile, cache: Cache, xcode_install_root: String).void } - def initialize(lockfile:, cache:, xcode_install_root: XcodeIntegration::INSTALL_ROOT) + # @param data_root [String] where `~/.dev` install_dirs re-root (tests use a tmpdir) + sig do + params(lockfile: Lockfile, cache: Cache, xcode_install_root: String, data_root: String).void + end + def initialize( + lockfile:, + cache:, + xcode_install_root: XcodeIntegration::INSTALL_ROOT, + data_root: Dev::DataRoot.path + ) @lockfile = lockfile @cache = cache @xcode_install_root = xcode_install_root + @data_root = data_root end # Dispatch a `dev deps …` invocation and print the result. @@ -56,15 +68,15 @@ def run(args, out: $stdout) # Resolve the artifact path for a locked dependency. # - # @param integration [String] integration name ("ficsit" or "xcode") - # @param name [String, nil] dependency name (e.g. "SML"; unused for xcode) + # @param integration [String] integration name ("ficsit", "xcode" or "gh") + # @param name [String, nil] dependency name (e.g. "SML", "UnrealEngineMac"; unused for xcode) # @param platform [String, nil] ficsit target name (e.g. "LinuxServer") # @return [Pathname] absolute path to the artifact # @raise [UsageError] for a missing/unsupported integration # @raise [NotLockedError] if the dep isn't in the lockfile # @raise [PlatformNotLockedError] if the platform isn't locked for the dep # @raise [NotCachedError] if the zip isn't in the cache (run dev up) - # @raise [NotInstalledError] if the pinned Xcode isn't installed (run dev up) + # @raise [NotInstalledError] if the pinned Xcode or gh release isn't installed (run dev up) sig do params( integration: T.nilable(String), @@ -76,12 +88,36 @@ def path(integration = nil, name = nil, platform = nil) case integration when "ficsit" then ficsit_path(name, platform) when "xcode" then xcode_developer_dir + when "gh" then gh_install_dir(name) else raise UsageError, USAGE end end private + # The version-keyed install dir of a locked gh release — the immutable + # directory GhIntegration publishes under the dep's install_dir, re-rooted + # onto the data root. Resolved from the lock rather than the `current` + # pointer so a checkout always gets the tag its lockfile names, not + # whatever this machine installed last. + # + # @param name [String, nil] + # @return [Pathname] + sig { params(name: T.nilable(String)).returns(Pathname) } + def gh_install_dir(name) + raise UsageError, USAGE unless name + + dep = find_dep(:gh, name) + base_dir = Dev::DataRoot.expand(dep.metadata.fetch("install_dir"), root: @data_root) + version_dir = Pathname(base_dir) / dep.version + marker = version_dir / GhIntegration::MARKER_FILE + unless marker.file? && marker.read.strip == dep.version + raise NotInstalledError, "#{name}@#{dep.version} is not installed at #{version_dir} — run dev up" + end + + version_dir + end + # @param name [String, nil] # @param platform [String, nil] # @return [Pathname] diff --git a/src/dev/builtins/deps_command.rb b/src/dev/builtins/deps_command.rb index 4b92d0b..5f76d9f 100644 --- a/src/dev/builtins/deps_command.rb +++ b/src/dev/builtins/deps_command.rb @@ -34,7 +34,7 @@ def initialize( end sig { override.returns(String) } - def desc = "Inspect locked dependencies (e.g. deps path ficsit )" + def desc = "Inspect locked dependencies (e.g. deps path ficsit , deps path gh )" sig { override.returns(Command::Category) } def category = Command::Category::Lifecycle diff --git a/test/dev/deps/accessor_test.rb b/test/dev/deps/accessor_test.rb index 16d27f8..2655209 100644 --- a/test/dev/deps/accessor_test.rb +++ b/test/dev/deps/accessor_test.rb @@ -8,6 +8,7 @@ require "dev/deps/dependency" require "dev/deps/ficsit_integration" require "dev/deps/xcode_integration" +require "dev/deps/gh_integration" require "pathname" require "tmpdir" require "stringio" @@ -64,6 +65,128 @@ def setup_locked_xcode(dir, version: "26.1.1", installed: true) [accessor, developer_dir] end + GH_ENGINE_TAG = "5.8.0-mac-editor-1" + + # Lock a gh engine dep whose `~/.dev` install_dir re-roots onto dir (the + # injected data root), optionally publishing the version dir + marker. + def setup_locked_gh(dir, installed: true, marker_version: GH_ENGINE_TAG) + lockfile = Dev::Deps::Lockfile.new(dir: dir) + lockfile.lock([ + Dev::Deps::Dependency.new( + name: "UnrealEngineMac", integration: :gh, group: :editor, + version: GH_ENGINE_TAG, hash: nil, + metadata: { "repo" => "d3mlabs/unreal-engine", "install_dir" => "~/.dev/engines/ue5-mac" }, + ), + ]) + version_dir = File.join(dir, "engines", "ue5-mac", GH_ENGINE_TAG) + if installed + FileUtils.mkdir_p(version_dir) + File.write(File.join(version_dir, Dev::Deps::GhIntegration::MARKER_FILE), marker_version) + end + + accessor = Dev::Deps::Accessor.new( + lockfile: lockfile, + cache: Dev::Deps::Cache.new(cache_dir: File.join(dir, "cache")), + data_root: dir, + ) + [accessor, version_dir] + end + + test "path gh returns the locked version dir under the data root" do + Given "a locked and installed gh engine" + dir = Dir.mktmpdir("dev-accessor-test-") + accessor, version_dir = setup_locked_gh(dir) + + When "asking for the engine path" + result = accessor.path("gh", "UnrealEngineMac") + + Then "it is the version-keyed dir, not a current pointer" + result == Pathname(version_dir) + + Cleanup + FileUtils.rm_rf(dir) + end + + test "path gh raises NotInstalledError when the version dir is absent" do + Given "a locked but uninstalled gh engine" + dir = Dir.mktmpdir("dev-accessor-test-") + accessor, = setup_locked_gh(dir, installed: false) + + When "asking for the engine path" + error = assert_raises(Dev::Deps::Accessor::NotInstalledError) do + accessor.path("gh", "UnrealEngineMac") + end + + Then "the fix is dev up" + error.message.include?("run dev up") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "path gh raises NotInstalledError when the marker records another version" do + Given "a version dir whose marker was stamped by a different tag (half-published)" + dir = Dir.mktmpdir("dev-accessor-test-") + accessor, = setup_locked_gh(dir, marker_version: "5.8.0-mac-editor-0") + + When "asking for the engine path" + accessor.path("gh", "UnrealEngineMac") + + Then + raises Dev::Deps::Accessor::NotInstalledError + + Cleanup + FileUtils.rm_rf(dir) + end + + test "path gh raises NotLockedError without the dep in the lockfile" do + Given "a lockfile without any gh dep" + dir = Dir.mktmpdir("dev-accessor-test-") + accessor, = setup_locked_sml(dir, platforms: linux_platforms) + + When "asking for an engine path" + error = assert_raises(Dev::Deps::Accessor::NotLockedError) do + accessor.path("gh", "UnrealEngineMac") + end + + Then "the fix is update-deps" + error.message.include?("run dev update-deps") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "path gh raises UsageError without a dep name" do + Given "an accessor" + dir = Dir.mktmpdir("dev-accessor-test-") + accessor, = setup_locked_gh(dir) + + When "asking for a gh path with no name" + accessor.path("gh") + + Then + raises Dev::Deps::Accessor::UsageError + + Cleanup + FileUtils.rm_rf(dir) + end + + test "run path gh prints the version dir to the output stream" do + Given "a locked and installed gh engine" + dir = Dir.mktmpdir("dev-accessor-test-") + accessor, version_dir = setup_locked_gh(dir) + out = StringIO.new + + When "running deps path gh UnrealEngineMac" + accessor.run(["path", "gh", "UnrealEngineMac"], out: out) + + Then + out.string.strip == version_dir + + Cleanup + FileUtils.rm_rf(dir) + end + test "path xcode returns the pinned DEVELOPER_DIR" do Given "a locked and installed xcode pin" dir = Dir.mktmpdir("dev-accessor-test-")