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: diff --git a/README.md b/README.md index e44415e..79ea54e 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 @@ -88,6 +88,35 @@ 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_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 +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`). 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 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 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`. + ## Usage From anywhere under a git repo that has a `dev.yml` at its root: @@ -101,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 @@ -267,6 +296,17 @@ 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 tooling: the Brewfile contract + +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 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::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 Declare dependencies using a Ruby DSL: @@ -368,9 +408,10 @@ 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 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`. - **`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/bin/dev b/bin/dev index 9112b00..6531b93 100755 --- a/bin/dev +++ b/bin/dev @@ -56,15 +56,6 @@ else Dev::Cli::NoUi.new end -begin - Dev::Runner.new(ui: ui).run(ARGV) -rescue Dev::DevYamlNotFoundError - 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/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 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/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/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/lib/dev/settings.rb b/lib/dev/settings.rb index 343d7e8..fcf72d8 100644 --- a/lib/dev/settings.rb +++ b/lib/dev/settings.rb @@ -1,45 +1,70 @@ # frozen_string_literal: true +require "fileutils" 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_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 + # org's deployment formula (see README "Deploying dev to an org") + # + # Keys: # # plans_repo: d3mlabs/plans # knowledge_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; 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. `deployment_formula` is the brew + # formula `dev up`'s self-update upgrades — the deployment names itself + # (see Dev::HostService). 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 + # 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 - # @param config_path [String, nil] override for tests; defaults to the - # XDG config location - def initialize(config_path: nil) + # 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 + # 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,14 +73,72 @@ def plans_repo # # @return [String, nil] "owner/repo" (or any git-clonable URL), or nil def knowledge_repo - from_env = ENV["DEV_KNOWLEDGE_REPO"] + setting("knowledge_repo", "DEV_KNOWLEDGE_REPO") + end + + # 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] e.g. "d3mlabs/d3mlabs/dev", or nil + 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. + # 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 = load_config["knowledge_repo"] + value = layered_config[key] (value && !value.empty?) ? value : nil end - private + # @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 @@ -63,11 +146,31 @@ def default_config_path 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/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/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/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/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/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..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) } @@ -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 + @host_service.sync_learnings(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 dd6a1ab..c61d538 100644 --- a/src/dev/builtins/up_command.rb +++ b/src/dev/builtins/up_command.rb @@ -1,9 +1,9 @@ # typed: strict # frozen_string_literal: true -require "dev/cd" require "dev/command" require "dev/credentials" +require "dev/host_service" module Dev module Builtins @@ -13,19 +13,24 @@ 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 sig do params( install_deps_command: InstallDepsCommand, - hook_installer: Dev::Cd::HookInstaller, + host_service: Dev::HostService, ).void end - def initialize(install_deps_command:, hook_installer: Dev::Cd::HookInstaller.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_service = T.let(host_service, Dev::HostService) end sig { override.returns(String) } @@ -45,8 +50,19 @@ def stamps? = true sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) - provision_build_credentials(context) - @hook_installer.ensure_installed + # 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_service.converge_tooling + @host_service.install_rc_hook + 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 @@ -56,9 +72,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.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/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/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 d015065..daed497 100644 --- a/src/dev/dependency_service.rb +++ b/src/dev/dependency_service.rb @@ -34,7 +34,7 @@ def messages # lockfile vs installed stamp. Warn on workstations; error in CI. # # @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! stale_messages = messages @@ -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/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/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..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,26 +94,31 @@ 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, - 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 - # 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. # @@ -129,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}" @@ -142,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), ) @@ -176,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 @@ -185,12 +228,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:) @@ -224,6 +268,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/bin_dev_test.rb b/test/dev/bin_dev_test.rb index 8a2c9a8..dff0cce 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 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, "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/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 new file mode 100644 index 0000000..480483a --- /dev/null +++ b/test/dev/builtins/config_command_test.rb @@ -0,0 +1,33 @@ +# 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), + 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..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,11 +113,20 @@ 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), 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 4170e3e..d5fc59d 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 @@ -25,9 +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_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" @@ -37,6 +39,41 @@ class Dev::Builtins::UpCommandTest < Minitest::Test 1 * install_deps.call(args: ["-v"], context: context) end + 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) + 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 service holds" + true + end + + test "call without a project converges the host half and skips provisioning" do + 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, host_service: host_service) + 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 @@ -78,21 +115,37 @@ 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) + Dev::Builtins::UpCommand.new(install_deps_command: install_deps, host_service: quiet_host_service) + end + + 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:) 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), - 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..561399a 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 @@ -68,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/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/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/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/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/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) diff --git a/test/dev/host_service_test.rb b/test/dev/host_service_test.rb new file mode 100644 index 0000000..4f93163 --- /dev/null +++ b/test/dev/host_service_test.rb @@ -0,0 +1,420 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/host_service" +require "dev/settings" +require "fileutils" +require "pathname" +require "rbconfig" +require "stringio" +require "tmpdir" + +transform!(RSpock::AST::Transformation) +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. + class RecordingExecutor + attr_reader :commands + + # @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 + + def quiet?(*cmd) + @commands << cmd + @quiet_result + end + end + + 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-service-test-") + executor = RecordingExecutor.new + service = build_service(dir, brew_executor: executor) + service.instance_variable_get(:@settings).stubs(:system_config_path).returns(nil) + + When "converging the tooling" + service.converge_tooling + + Then "brew is never invoked" + executor.commands.empty? + + Cleanup + FileUtils.rm_rf(dir) + end + + 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-service-test-") + executor = RecordingExecutor.new(quiet_result: false) + service = build_service(dir, brew_executor: executor) + + When "converging the tooling" + service.converge_tooling + + Then "brew update + the dev-core check ran, nothing upgraded or bundled" + executor.commands == [ + ["brew", "update", "--quiet"], + ["brew", "list", "--formula", "--versions", "dev-core"], + ] + + 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-service-test-") + write_system_config(dir, "deployment_formula: d3mlabs/d3mlabs/dev\n") + executor = RecordingExecutor.new + service = build_service(dir, brew_executor: executor) + + 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"]) + 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-service-test-") + write_system_config(dir, 'deployment_formula: "--force evil"' + "\n") + executor = RecordingExecutor.new + service = build_service(dir, brew_executor: executor) + + 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"] } + stderr.include?("malformed deployment_formula") + + Cleanup + 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-service-test-") + write_system_config(dir, "deployment_formula: #{formula}\n") + executor = RecordingExecutor.new + service = build_service(dir, brew_executor: executor) + + 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]) + 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-service-test-") + write_system_config(dir, "deployment_formula: #{formula}\n") + executor = RecordingExecutor.new + service = build_service(dir, brew_executor: executor) + + 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"] } + 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-service-test-") + executor = RecordingExecutor.new(quiet_result: true) + service = build_service(dir, brew_executor: executor) + + When "converging the tooling" + service.converge_tooling + + Then "the tapless individual's tool self-updates" + executor.commands.include?(["brew", "upgrade", "--quiet", "dev-core"]) + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a Brewfile beside the system config converges via brew bundle" do + Given "an org Brewfile in etc" + dir = Dir.mktmpdir("dev-host-service-test-") + brewfile = write_brewfile(dir, %(cask "cursor-cli"\n)) + executor = RecordingExecutor.new(quiet_result: false) + service = build_service(dir, brew_executor: executor) + + 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}"] + + Cleanup + 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-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"]) + service = build_service(dir, brew_executor: executor) + + 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") + 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-service-test-") + write_brewfile(dir, %(cask "cursor-cli"\n)) + executor = RecordingExecutor.new(quiet_result: false, fail_subcommands: ["bundle"]) + service = build_service(dir, brew_executor: executor) + + 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") + 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-service-test-") + write_system_config(dir, "deployment_formula: d3mlabs/d3mlabs/dev\n") + executor = RecordingExecutor.new(run_result: false) + service = build_service(dir, brew_executor: executor) + + 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"] } + stderr.include?("brew update failed") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "the real brew executor's run maps exit status to a boolean" do + Given "the production executor" + 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 + executor.run(RbConfig.ruby, "-e", "exit 1") == false + executor.run("definitely-not-a-command-#{Process.pid}") == false + end + + test "the real brew executor's quiet? answers success without streaming output" do + Given "the production executor" + 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 + 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-service-test-") + write_system_config(dir, "plans_repo: acme/plans\n") + service = build_service(dir, brew_executor: RecordingExecutor.new) + + 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") + 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-service-test-") + write_system_config(dir, "plans_repo: acme/plans\n") + write_user_config(dir, "deployment_formula: acme/tap/dev\n") + executor = RecordingExecutor.new + service = build_service(dir, brew_executor: executor) + + When "converging the tooling" + stderr = capture_stderr { service.converge_tooling } + + 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 + + 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 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::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) + 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/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/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 diff --git a/test/dev/runner_test.rb b/test/dev/runner_test.rb index 68fd429..cdaac68 100644 --- a/test/dev/runner_test.rb +++ b/test/dev/runner_test.rb @@ -308,14 +308,88 @@ 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) 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) diff --git a/test/dev/settings_test.rb b/test/dev/settings_test.rb index 99dbbff..449a389 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,61 @@ class Dev::SettingsTest < Minitest::Test ENV["DEV_KNOWLEDGE_REPO"] = saved_env if saved_env FileUtils.rm_rf(dir) end + + 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_system(dir, "deployment_formula: d3mlabs/d3mlabs/dev\n") + saved_env = ENV.delete("DEV_DEPLOYMENT_FORMULA") + settings = build_settings(dir) + + Expect + settings.deployment_formula == "d3mlabs/d3mlabs/dev" + + Cleanup + ENV["DEV_DEPLOYMENT_FORMULA"] = saved_env if saved_env + FileUtils.rm_rf(dir) + end + + test "DEV_DEPLOYMENT_FORMULA overrides the config files" do + Given "a system config and an ENV override" + dir = Dir.mktmpdir("dev-settings-test-") + 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.deployment_formula == "acme/tap/dev" + + Cleanup + saved_env ? ENV["DEV_DEPLOYMENT_FORMULA"] = saved_env : ENV.delete("DEV_DEPLOYMENT_FORMULA") + FileUtils.rm_rf(dir) + end + + 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_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.system_config_path == File.join(dir, "system", "config.yml") + + Cleanup + FileUtils.rm_rf(dir) + end end