diff --git a/bin/native/darwin-arm64/loaf b/bin/native/darwin-arm64/loaf index 6d43b9b6..fa7cf5f0 100755 Binary files a/bin/native/darwin-arm64/loaf and b/bin/native/darwin-arm64/loaf differ diff --git a/cmd/loaf/installed_distribution_test.go b/cmd/loaf/installed_distribution_test.go new file mode 100644 index 00000000..b2e98ae5 --- /dev/null +++ b/cmd/loaf/installed_distribution_test.go @@ -0,0 +1,276 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// These end-to-end tests pin installed-distribution authority: a current +// installed Loaf executable invoked from inside an older source checkout must +// report and install its own shipped distribution, never the checkout's. They +// run the real compiled binary with fully isolated HOME/XDG/LOAF_DB/git +// configuration so no global state is read or mutated. + +const ( + installedTestVersion = "9.9.9-current" + staleTestVersion = "1.1.1-stale" +) + +func TestInstalledDistributionVersionAuthorityFromStaleCheckout(t *testing.T) { + repo := repoRoot(t) + installedRoot := writeInstalledDistributionFixture(t, repo, installedTestVersion) + staleCheckout := writeStaleCheckoutFixture(t, staleTestVersion) + env := isolatedInstallEnv(t) + + output, err := runInstalledLoaf(installedRoot, staleCheckout, env, "version") + if err != nil { + t.Fatalf("installed loaf version from stale checkout error = %v\n%s", err, output) + } + if !strings.Contains(output, installedTestVersion) { + t.Fatalf("version output = %q, want installed distribution version %q", output, installedTestVersion) + } + if strings.Contains(output, staleTestVersion) { + t.Fatalf("version output = %q, must not report the stale checkout version %q", output, staleTestVersion) + } +} + +func TestInstalledDistributionUpgradeAuthorityFromStaleCheckout(t *testing.T) { + repo := repoRoot(t) + installedRoot := writeInstalledDistributionFixture(t, repo, installedTestVersion) + staleCheckout := writeStaleCheckoutFixture(t, staleTestVersion) + env := isolatedInstallEnv(t) + home := envValue(t, env, "HOME") + + // A pre-existing Cursor install so --upgrade selects it. + writeFixtureFile(t, filepath.Join(home, ".cursor", ".loaf-version"), staleTestVersion+"\n") + writeFixtureFile(t, filepath.Join(home, ".cursor", "skills", "foundations", "SKILL.md"), "# Previously Installed Foundations\n") + + output, err := runInstalledLoaf(installedRoot, staleCheckout, env, "install", "--upgrade", "--yes") + if err != nil { + t.Fatalf("installed loaf install --upgrade from stale checkout error = %v\n%s", err, output) + } + + marker := readFixtureFile(t, filepath.Join(home, ".cursor", ".loaf-version")) + if strings.TrimSpace(marker) != installedTestVersion { + t.Fatalf(".cursor/.loaf-version = %q, want installed distribution version %q", marker, installedTestVersion) + } + skill := readFixtureFile(t, filepath.Join(home, ".agents", "skills", "foundations", "SKILL.md")) + if !strings.Contains(skill, "# Current Foundations") { + t.Fatalf("installed skill = %q, want packaged content from the installed distribution", skill) + } + if strings.Contains(skill, "Stale") { + t.Fatalf("installed skill = %q, must not come from the stale checkout dist/", skill) + } + fenced := readFixtureFile(t, filepath.Join(staleCheckout, "AGENTS.md")) + if !strings.Contains(fenced, "v"+installedTestVersion) { + t.Fatalf("project AGENTS.md fenced section = %q, want stamp v%s", fenced, installedTestVersion) + } + if strings.Contains(fenced, "v"+staleTestVersion) { + t.Fatalf("project AGENTS.md fenced section = %q, must not be stamped with the stale checkout version", fenced) + } +} + +// A source checkout's own binary keeps the checkout as its distribution root — +// the explicit local-development opt-in is running that binary, regardless of +// the invoking working directory. +func TestInstalledDistributionCheckoutOwnBinaryReportsCheckoutVersion(t *testing.T) { + repo := repoRoot(t) + checkout := writeStaleCheckoutFixture(t, "3.3.3-dev") + nativeDir := filepath.Join(checkout, "bin", "native", "test-target") + copyFixtureBinary(t, sharedTestLoafBinary(t, repo), filepath.Join(nativeDir, "loaf")) + writeFixtureFile(t, filepath.Join(checkout, "bin", "package.json"), "{\n \"type\": \"commonjs\"\n}\n") + elsewhere := realpath(t, t.TempDir()) + env := isolatedInstallEnv(t) + + cmd := exec.Command(filepath.Join(nativeDir, "loaf"), "version") + cmd.Dir = elsewhere + cmd.Env = env + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("checkout-owned loaf version error = %v\n%s", err, output) + } + if !strings.Contains(string(output), "3.3.3-dev") { + t.Fatalf("version output = %q, want the owning checkout's version", output) + } +} + +// A bare binary with no adjacent distribution invoked from inside a stale +// checkout must degrade to identity-only output: version reports 0.0.0 with +// reinstall guidance, and no Targets list or Content counts may be read from +// the checkout the caller is standing in. +func TestInstalledDistributionBareBinaryVersionOmitsCheckoutTargetsAndContent(t *testing.T) { + repo := repoRoot(t) + bareBinary := filepath.Join(realpath(t, t.TempDir()), "loaf") + copyFixtureBinary(t, sharedTestLoafBinary(t, repo), bareBinary) + staleCheckout := writeStaleCheckoutFixture(t, staleTestVersion) + env := isolatedInstallEnv(t) + + cmd := exec.Command(bareBinary, "version") + cmd.Dir = staleCheckout + cmd.Env = env + outputBytes, err := cmd.CombinedOutput() + output := string(outputBytes) + if err != nil { + t.Fatalf("bare loaf version error = %v\n%s", err, output) + } + if !strings.Contains(output, "0.0.0") { + t.Fatalf("version output = %q, want honest 0.0.0 fallback", output) + } + if !strings.Contains(output, "reinstall Loaf") { + t.Fatalf("version output = %q, want the resolver's reinstall guidance", output) + } + if strings.Contains(output, staleTestVersion) { + t.Fatalf("version output = %q, must not report the stale checkout version %q", output, staleTestVersion) + } + for _, forbidden := range []string{"Targets:", "Content:", "Skills:", "Agents:", "Hooks:", "dist/cursor/"} { + if strings.Contains(output, forbidden) { + t.Fatalf("version output = %q, must not surface the stale checkout's %q", output, forbidden) + } + } +} + +// writeInstalledDistributionFixture lays out a release-archive style install: +// the native binary at bin/loaf with package.json, content, config, and built +// dist/ targets adjacent — the shape shipped by package-release.mjs. +func writeInstalledDistributionFixture(t *testing.T, repo string, version string) string { + t.Helper() + root := realpath(t, t.TempDir()) + copyFixtureBinary(t, sharedTestLoafBinary(t, repo), filepath.Join(root, "bin", "loaf")) + writeFixtureFile(t, filepath.Join(root, "package.json"), `{"name":"loaf","version":"`+version+`"}`) + writeFixtureFile(t, filepath.Join(root, "content", "skills", "foundations", "SKILL.md"), "# Current Foundations\n") + writeFixtureFile(t, filepath.Join(root, "config", "hooks.yaml"), "hooks:\n pre-tool:\n - id: check-secrets\n") + writeFixtureFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Current Foundations\n") + writeFixtureFile(t, filepath.Join(root, "dist", "cursor", "hooks.json"), `{"version":1,"hooks":{"PostToolUse":[{"command":"loaf journal log --from-hook","matcher":"Bash","loaf-managed":true}]}}`) + return root +} + +// writeStaleCheckoutFixture lays out an older Loaf source checkout: package +// metadata, a .git marker, content, and stale built dist/ output. +func writeStaleCheckoutFixture(t *testing.T, version string) string { + t.Helper() + root := realpath(t, t.TempDir()) + writeFixtureFile(t, filepath.Join(root, "package.json"), `{"name":"loaf","version":"`+version+`"}`) + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatalf("MkdirAll .git error = %v", err) + } + writeFixtureFile(t, filepath.Join(root, "content", "skills", "foundations", "SKILL.md"), "# Stale Foundations\n") + writeFixtureFile(t, filepath.Join(root, "config", "hooks.yaml"), "hooks:\n pre-tool:\n - id: check-secrets\n") + writeFixtureFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Stale Foundations\n") + writeFixtureFile(t, filepath.Join(root, "dist", "cursor", "hooks.json"), `{"version":1,"hooks":{"PostToolUse":[{"command":"loaf journal log --from-hook","matcher":"Bash","loaf-managed":true}]}}`) + return root +} + +func runInstalledLoaf(installedRoot string, workingDir string, env []string, args ...string) (string, error) { + cmd := exec.Command(filepath.Join(installedRoot, "bin", "loaf"), args...) + cmd.Dir = workingDir + cmd.Env = env + output, err := cmd.CombinedOutput() + return string(output), err +} + +// isolatedInstallEnv builds a process environment whose HOME, XDG paths, Loaf +// database, git configuration, and PATH all point at throwaway locations so +// the tests can never read or mutate real installations. +func isolatedInstallEnv(t *testing.T) []string { + t.Helper() + home := realpath(t, t.TempDir()) + return envWith( + "HOME="+home, + "USERPROFILE="+home, + "XDG_CONFIG_HOME="+filepath.Join(home, ".config"), + "XDG_DATA_HOME="+filepath.Join(home, ".local", "share"), + "XDG_STATE_HOME="+filepath.Join(home, ".local", "state"), + "XDG_CACHE_HOME="+filepath.Join(home, ".cache"), + "LOAF_DB="+filepath.Join(home, "loaf-test.sqlite"), + "CODEX_HOME="+filepath.Join(home, ".codex"), + "GIT_CONFIG_GLOBAL="+filepath.Join(home, ".gitconfig-isolated"), + "GIT_CONFIG_SYSTEM="+os.DevNull, + "PATH=/usr/bin:/bin", + ) +} + +func envValue(t *testing.T, env []string, key string) string { + t.Helper() + for _, entry := range env { + if value, ok := strings.CutPrefix(entry, key+"="); ok { + return value + } + } + t.Fatalf("environment fixture missing %s", key) + return "" +} + +var ( + sharedTestBinaryDir string + sharedTestBinaryPath string +) + +// TestMain removes the shared compiled binary's temp directory after every +// test in the package has finished. Cleanup cannot hang off the first +// requesting test (later tests still copy from the artifact), so it runs +// once, after m.Run returns, when no test can race the deletion. +func TestMain(m *testing.M) { + code := m.Run() + if sharedTestBinaryDir != "" { + os.RemoveAll(sharedTestBinaryDir) + } + os.Exit(code) +} + +// sharedTestLoafBinary compiles cmd/loaf once per test process; fixtures copy +// the artifact into their own layouts. TestMain deletes the directory after +// the package's tests complete. +func sharedTestLoafBinary(t *testing.T, repo string) string { + t.Helper() + if sharedTestBinaryPath != "" { + return sharedTestBinaryPath + } + dir, err := os.MkdirTemp("", "loaf-installed-dist-") + if err != nil { + t.Fatalf("MkdirTemp error = %v", err) + } + binary := filepath.Join(dir, "loaf") + if output, err := runCommand(repo, "go", "build", "-o", binary, "./cmd/loaf"); err != nil { + os.RemoveAll(dir) + t.Fatalf("go build ./cmd/loaf error = %v\n%s", err, output) + } + sharedTestBinaryDir = dir + sharedTestBinaryPath = binary + return binary +} + +func copyFixtureBinary(t *testing.T, source string, dest string) { + t.Helper() + body, err := os.ReadFile(source) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", source, err) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(dest), err) + } + if err := os.WriteFile(dest, body, 0o755); err != nil { + t.Fatalf("WriteFile(%s) error = %v", dest, err) + } +} + +func writeFixtureFile(t *testing.T, path string, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("WriteFile(%s) error = %v", path, err) + } +} + +func readFixtureFile(t *testing.T, path string) string { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", path, err) + } + return string(body) +} diff --git a/cmd/loaf/main_test.go b/cmd/loaf/main_test.go index 2e55c7f1..b6fff1f7 100644 --- a/cmd/loaf/main_test.go +++ b/cmd/loaf/main_test.go @@ -72,10 +72,10 @@ func TestPublicBinaryVersionShowsInjectedBuildInfoNatively(t *testing.T) { func TestPublicBinaryDispatchesStateVersionAndReleasePreflightNatively(t *testing.T) { repoRoot := repoRoot(t) - binary := filepath.Join(t.TempDir(), "loaf") - if output, err := runCommand(repoRoot, "go", "build", "-o", binary, "./cmd/loaf"); err != nil { - t.Fatalf("go build ./cmd/loaf error = %v\n%s", err, output) - } + // version reads Targets/Content from the binary's own installed + // distribution, so the compiled binary runs from a distribution-shaped + // fixture instead of a bare temp directory. + binary := filepath.Join(writeInstalledDistributionFixture(t, repoRoot, "9.9.9-native-dispatch"), "bin", "loaf") workingDir := realpath(t, t.TempDir()) dataHome := t.TempDir() @@ -124,10 +124,9 @@ func TestPublicBinaryDispatchesStateVersionAndReleasePreflightNatively(t *testin func TestPublicBinaryDispatchesVersionFlagNatively(t *testing.T) { repoRoot := repoRoot(t) - binary := filepath.Join(t.TempDir(), "loaf") - if output, err := runCommand(repoRoot, "go", "build", "-o", binary, "./cmd/loaf"); err != nil { - t.Fatalf("go build ./cmd/loaf error = %v\n%s", err, output) - } + // Same distribution-shaped fixture as above: --version's Content sections + // come from the binary's own distribution, never the working directory. + binary := filepath.Join(writeInstalledDistributionFixture(t, repoRoot, "9.9.9-native-dispatch"), "bin", "loaf") output, err := runBinary(binary, repoRoot, envWith(), "--version") if err != nil { diff --git a/docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.207-candidate-smoke.json b/docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.207-candidate-smoke.json index d5be472f..6f4ae1bb 100644 --- a/docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.207-candidate-smoke.json +++ b/docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.207-candidate-smoke.json @@ -1,6 +1,6 @@ { "evidence_version": 2, - "timestamp": "2026-07-18T11:54:49.222Z", + "timestamp": "2026-07-18T20:20:02.709Z", "target": "claude-code", "surface": "cli", "version": "2.1.207", @@ -41,7 +41,7 @@ "stderr_empty": true, "model_visible_marker_observed": true, "assistant_marker_match": true, - "marker": "LOAF_U8_SMOKE_F1231C2C2C90", + "marker": "LOAF_U8_SMOKE_A0E48BBB016D", "hook_observation": { "event_name": "SessionStart:startup", "native_json": true, @@ -52,6 +52,6 @@ "hooks_path": "plugins/loaf/hooks/hooks.json", "hooks_sha256": "5c7343a34af37d6c6e930981f71e405b8023975de4146822058f98c15251b7c2", "native_binary_path": "plugins/loaf/bin/native/darwin-arm64/loaf", - "native_binary_sha256": "c4e684cb2b9f717444001b0dc5a333bb94e124ccdf9c3201141f9958a208142e" + "native_binary_sha256": "5b76b90e835834bea3f4208cb8fa4e344951e43ab90f986837f09ac3d6eb2519" } } diff --git a/docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.1-isolated-smoke.json b/docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.1-isolated-smoke.json index 0115595f..b6c83b28 100644 --- a/docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.1-isolated-smoke.json +++ b/docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.1-isolated-smoke.json @@ -1,6 +1,6 @@ { "evidence_version": 2, - "timestamp": "2026-07-18T11:55:07.064Z", + "timestamp": "2026-07-18T20:20:13.650Z", "target": "codex", "surface": "cli", "version": "0.144.1", @@ -39,7 +39,7 @@ "stderr": "Reading additional input from stdin...", "model_visible_marker_observed": true, "assistant_marker_match": true, - "marker": "LOAF_CODEX_U8_FF34F6887226", + "marker": "LOAF_CODEX_U8_F97EDD94E710", "hook_observation": { "event_name": "SessionStart:startup", "native_json": true, @@ -50,6 +50,6 @@ "hooks_path": "dist/codex/.codex/hooks.json", "hooks_sha256": "0f9e3feb7204c3309a7db6a02224ad881f6801a8cdd126ec6222e6d9a804c33d", "native_binary_path": "bin/native/darwin-arm64/loaf", - "native_binary_sha256": "c4e684cb2b9f717444001b0dc5a333bb94e124ccdf9c3201141f9958a208142e" + "native_binary_sha256": "5b76b90e835834bea3f4208cb8fa4e344951e43ab90f986837f09ac3d6eb2519" } } diff --git a/docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.17.18-isolated-smoke.json b/docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.17.18-isolated-smoke.json index 017bb62a..139bf412 100644 --- a/docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.17.18-isolated-smoke.json +++ b/docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.17.18-isolated-smoke.json @@ -1,6 +1,6 @@ { "evidence_version": 2, - "timestamp": "2026-07-18T16:07:02.475Z", + "timestamp": "2026-07-18T20:20:33.088Z", "target": "opencode", "surface": "cli", "version": "1.17.18", @@ -39,11 +39,11 @@ "root_session_lookup_proven": true, "no_auth_supplied": true, "cleanup_succeeded": true, - "marker": "LOAF_OPENCODE_U8_B7F4AFA24D7B", + "marker": "LOAF_OPENCODE_U8_9E0B4A1D1C95", "candidate_artifacts": { "hooks_path": "dist/opencode/plugins/hooks.ts", "hooks_sha256": "4d71c088c9c0ec609725f3d9e216411c975920d24704e35c4e70edf1d008a767", "native_binary_path": "bin/native/darwin-arm64/loaf", - "native_binary_sha256": "c4e684cb2b9f717444001b0dc5a333bb94e124ccdf9c3201141f9958a208142e" + "native_binary_sha256": "5b76b90e835834bea3f4208cb8fa4e344951e43ab90f986837f09ac3d6eb2519" } } diff --git a/docs/changes/20260718-installed-distribution-authority/change.md b/docs/changes/20260718-installed-distribution-authority/change.md new file mode 100644 index 00000000..7108c753 --- /dev/null +++ b/docs/changes/20260718-installed-distribution-authority/change.md @@ -0,0 +1,106 @@ +--- +change: installed-distribution-authority +created: 2026-07-18 +branch: hotfix/installed-distribution-authority +--- + +# Installed-Distribution Authority for Identity-Bearing Commands + +## Problem + +`resolveLoafPackageRoot(paths ...string)` in `internal/cli/version.go` searches the working directory and project runtime root before `os.Executable()`. Loaf's semver is not baked into the binary — it is read from the nearest `package.json` whose `name` is `loaf` — so the identity of the running binary is decided by where the user is standing. An installed v2.0.0-alpha.8 executable invoked inside an older checkout reports that checkout's version, and `loaf install --upgrade` silently reinstalls the stale checkout's `dist/` content, stamping stale versions into managed markers and fenced sections. `install.go`, `config check --fix`, and Runner dispatch propagate the accidental source root; `doctor` is worse — it reads `packageVersion(runtimeRoot)` straight off the project root, so in any non-Loaf project the "installed" version it diagnoses against is that project's own `package.json` version. + +## Hypothesis + +Partitioning root authority — project root for project state, source-checkout root for `build`, installed-distribution root (executable provenance) for identity-bearing commands — makes an installed binary report and install only its own shipped content, regardless of the invoking directory, while local development keeps working because running a checkout's own binary makes that checkout the distribution root by construction. + +## Scope + +**In** + +- A Runner-level installed-distribution root resolver: `os.Executable()` → `filepath.EvalSymlinks` → upward walk to the adjacent `package.json` named `loaf`. No working-directory or project-root fallback. +- `version`, `install` (including `--upgrade` and deprecation cleanup), `config check` target maintenance, and `doctor`'s reported CLI version consume the installed-distribution root. +- `build` keeps the existing working-directory/project-first source-checkout resolution, renamed so the authority split is legible in code. +- End-to-end regression coverage: a current installed distribution invoked from an older checkout reports the installed version and upgrades from the installed `dist/`. +- Tracked native binaries and generated outputs regenerated via the sanctioned build scripts only. + +**Out** (deferred, not rejected) + +- `setup` and `release` root resolution — both are source-checkout workflows today; revisit if a setup-from-installed-distribution ceremony appears. +- Embedding the semver into the binary via ldflags — changes the committed-binary reproducibility contract; a release/publish decision, not a hotfix. +- Recovery UX for `~/.local/bin` copies that carry no adjacent distribution tree (launcher plus `native/` runtime only). The **current** `loaf install`/`setup` binary prompt (`installLoafBinary`) still writes exactly this layout, as older Loaf versions did; under this change those binaries intentionally fail closed for `install`/`config check` with reinstall guidance and degrade `version` to identity-only output. Making that layout self-describing (or improving its recovery UX) is deferred, not rejected. + +**Cut** (explicitly rejected) + +- Any cwd-inferred "development mode" for installation. Local-development installs are explicit: you run the checkout's own binary (`./bin/loaf`, `npm link`, `/plugin marketplace add `). No new opt-in flag or environment variable is introduced. +- Hardcoded install locations (Homebrew Cellar paths, `~/.local/bin`) in resolution logic — provenance comes from the executable path only. + +## Observable Workflow + +```text +$ cd ~/old-loaf-checkout # checkout at 2.0.0-alpha.6 +$ loaf version # installed 2.0.0-alpha.8 binary on PATH +loaf 2.0.0-alpha.8 # ← the binary's own version, not alpha.6 + +$ loaf install --upgrade # upgrades from the installed distribution's dist/ + ✓ Cursor installed … # markers and fenced sections stamped 2.0.0-alpha.8 + +$ cd ~/loaf && ./bin/loaf version # a checkout's own binary: that checkout is the +loaf 2.0.0-dev # distribution root — dev flow unchanged, explicit + +$ loaf build # still builds the checkout you are standing in +``` + +A binary with no adjacent distribution (a bare `go build` artifact in `/tmp`, or the launcher+native copy the binary-install prompt places in `~/.local/bin`) degrades `version` to identity-only output — `0.0.0`, the Go runtime line, and the resolver's reinstall guidance, with no Targets or Content sections, which would otherwise be read from the caller's working directory — and fails `install`/`config check` closed with the same guidance instead of adopting whatever checkout surrounds the caller. This fail-closed behavior is intentional: with no provenance there is no distribution to describe or install from. + +## Rabbit Holes and No-Gos + +- Do not re-add any working-directory fallback to installed-distribution resolution "for convenience" — that is the bug, reintroduced. +- Do not stamp versions into binaries or change `go-build-flags.mjs` ldflags here; committed-binary reproducibility is a separate contract. +- Do not touch target adapter/manifest semantics from PR #107/#117 — only where the source `dist/` and version string come from. +- Do not grow a config surface (env var, flag) for choosing the distribution root; executable provenance is the whole model. + +## Decisions + +Provenance: accepted during hotfix shaping against the reported stale-authority incident, 2026-07-18. + +1. **Executable provenance is the only authority for installed-distribution resolution.** `os.Executable()` + `EvalSymlinks` + upward walk. Rationale: symlink evaluation makes Homebrew shims (`/opt/homebrew/bin/loaf` → Cellar) resolve to the real distribution; the walk makes release archives, npm installs, and source checkouts all work with one rule. Forecloses cwd inference entirely. +2. **The existing explicit dev opt-in is reused, not reinvented.** Running a checkout's own binary is the opt-in; executable provenance honors it naturally. No `LOAF_*` root-override variable exists today and none is added. +3. **Unresolvable provenance fails closed for mutating commands, degrades honestly for read-only ones.** `install` and `config check` error with guidance; `version` reports `0.0.0` plus the resolver's guidance and omits Targets/Content entirely (an empty root would resolve those lookups against the working directory — the bug again); `doctor` skips the fenced-version comparison rather than diagnosing against a fabricated version. +4. **`build` (and `setup`) keep source-checkout resolution byte-for-byte.** The function is renamed `resolveSourceCheckoutRoot` so the two authorities cannot be conflated again silently. +5. **Tests inject provenance through a Runner seam** (`Executable func() (string, error)`), never by mutating package state — the CLI package's tests construct Runners directly and must be able to simulate installed layouts. + +## Planning Contract + +### Approach + +Add `internal/cli/distribution.go` with `Runner.resolveInstalledDistributionRoot()` and the `Runner.Executable` seam. Rewire `runVersion`, `runInstall`, `runConfig` (check), and `runDoctor` to it; `runDoctor` passes an empty CLI version on unresolvable provenance and `checkFencedVersion` skips on empty input. Rename `resolveLoafPackageRoot` → `resolveSourceCheckoutRoot` with unchanged behavior for `build`/`setup`. Update the ~60 affected unit-test Runner literals to inject fixture provenance. + +### Risks + +- Unit tests across `internal/cli` assume cwd-first resolution; each affected Runner literal needs the seam or the test silently exercises the fallback path. Mitigated by running the full package suite and fixing every failure explicitly. +- `cmd/loaf` end-to-end tests run the real binary from temp directories; any that relied on cwd resolution will surface in the suite run. +- The `~/.local/bin` copy layout (launcher + native runtime only, no package tree) — written by the **current** `install`/`setup` binary prompt as well as older Loaf versions — loses version reporting and fails mutating commands closed. Intentional fail-closed behavior, documented in Out. + +### Sequencing + +Regression first (fails on base), then the fix, then falsification (revert fix → regression fails → restore → passes), then suite + sanctioned rebuild of tracked artifacts. Evidence preserved in `plan.md`. + +## Implementation Units + +- **U1 — Regression evidence.** `cmd/loaf/installed_distribution_test.go`: a compiled current binary in a release-archive layout, invoked from a stale checkout with isolated HOME/XDG/`LOAF_DB`/git config, must report the installed version (`version`) and upgrade from the installed `dist/` (`install --upgrade`), with managed markers and fenced sections stamped current; a checkout's own binary must keep reporting the checkout version (explicit dev flow). +- **U2 — Authority split.** `distribution.go` resolver + `Runner.Executable` seam; rewire `version`/`install`/`config check`/`doctor`; rename source-checkout resolver; adjust unit tests and add focused unit coverage for the resolver and fallback behaviors. +- **U3 — Artifacts.** Regenerate tracked native binaries and build outputs via `npm run build` (sanctioned scripts) only. + +## Verification Contract + +- **V1.** `go test ./cmd/loaf -run TestInstalledDistribution` fails at base commit c8d5f51a (stale version reported, stale content installed) and passes with the fix. +- **V2.** Falsification: with the fix temporarily reverted, V1's regression fails again; restored, it passes. No temporary edits remain in the final diff. +- **V3.** `go test ./...` passes; `npm run build` (build:go, cli-ref, content, verify:go-artifacts) succeeds with reproducible artifacts. +- **V4.** Smoke under isolated HOME/XDG/`LOAF_DB`: installed-current-from-stale-checkout reports the current version; `install --upgrade` selects packaged content; `.loaf-version` markers and fenced sections read current; `loaf build` inside a checkout still builds that checkout. + +## Definition of Done + +- All Verification Contract items pass and their evidence (failing command + output, falsification transcript) is recorded in `plan.md`. +- No journal commands were run and no global installation, database, or user configuration was touched. +- The final diff contains only: the resolver and rewired consumers, the renamed source resolver, test changes, this change folder, and artifacts regenerated by sanctioned build scripts. diff --git a/docs/changes/20260718-installed-distribution-authority/plan.md b/docs/changes/20260718-installed-distribution-authority/plan.md new file mode 100644 index 00000000..827974e8 --- /dev/null +++ b/docs/changes/20260718-installed-distribution-authority/plan.md @@ -0,0 +1,89 @@ +# Plan — installed-distribution-authority + +Working plan and evidence log for the hotfix on `hotfix/installed-distribution-authority` (base c8d5f51a = origin/main = v2.0.0-alpha.8). See `change.md` for the contract. + +## Steps + +1. Inspect `internal/cli/version.go`, `install.go`, `config.go`, `doctor.go`, `build.go`, Runner dispatch in `cli.go`, the Change contract, and existing tests. *(done)* +2. Add the end-to-end regression (`cmd/loaf/installed_distribution_test.go`) and run it against base — record the failing command and output below before touching product code. +3. Implement the authority split: `internal/cli/distribution.go` (`Runner.Executable` seam + `resolveInstalledDistributionRoot`), rewire `version`/`install`/`config check`/`doctor`, rename `resolveLoafPackageRoot` → `resolveSourceCheckoutRoot` for `build`/`setup`. +4. Adapt affected unit tests (inject fixture provenance through the seam) and add focused unit coverage. +5. Falsify: temporarily revert the resolver rewiring, prove the regression fails, restore, prove it passes. Record both transcripts below. +6. Full `go test ./...`; sanctioned artifact regeneration via `npm run build`; isolated-environment smokes (temp HOME, XDG, `LOAF_DB`, git config, install targets). +7. Inspect the final diff; confirm no temporary edits, no journal commands, no global mutation. + +## Evidence + +### Failing before fix (step 2) + +Command, run at base c8d5f51a with only the new test file added: `go test ./cmd/loaf -run 'TestInstalledDistribution' -v` + +```text +=== RUN TestInstalledDistributionVersionAuthorityFromStaleCheckout + installed_distribution_test.go:33: version output = "\n\x1b[1mloaf\x1b[0m 1.1.1-stale\n\x1b[90mgo\x1b[0m 1.26.5\n\n\x1b[1mTargets:\x1b[0m\n cursor \x1b[90mdist/cursor/\x1b[0m\n\n\x1b[1mContent:\x1b[0m\n Skills: 1\n Agents: 0\n Hooks: 1\n\n", want installed distribution version "9.9.9-current" +--- FAIL: TestInstalledDistributionVersionAuthorityFromStaleCheckout (1.03s) +=== RUN TestInstalledDistributionUpgradeAuthorityFromStaleCheckout + installed_distribution_test.go:58: .cursor/.loaf-version = "1.1.1-stale\n", want installed distribution version "9.9.9-current" +--- FAIL: TestInstalledDistributionUpgradeAuthorityFromStaleCheckout (0.66s) +=== RUN TestInstalledDistributionCheckoutOwnBinaryReportsCheckoutVersion +--- PASS: TestInstalledDistributionCheckoutOwnBinaryReportsCheckoutVersion (0.36s) +FAIL +FAIL github.com/levifig/loaf/cmd/loaf 2.405s +``` + +The installed 9.9.9-current executable reports the stale checkout's version and `install --upgrade` re-stamps `.cursor/.loaf-version` with `1.1.1-stale`. The checkout-own-binary guard passes before and after by design — it pins the explicit dev flow, not the bug. + +### Falsification (step 5) + +The fix was temporarily disabled by inserting a cwd-first branch at the top of `resolveInstalledDistributionRoot` (marked `FALSIFICATION-ONLY`), restoring the original working-directory authority. `go test ./cmd/loaf -run 'TestInstalledDistribution'` then failed exactly as at base: + +```text +--- FAIL: TestInstalledDistributionVersionAuthorityFromStaleCheckout (1.04s) + installed_distribution_test.go:33: version output = "…loaf 1.1.1-stale…", want installed distribution version "9.9.9-current" +--- FAIL: TestInstalledDistributionUpgradeAuthorityFromStaleCheckout (0.42s) + installed_distribution_test.go:58: .cursor/.loaf-version = "1.1.1-stale\n", want installed distribution version "9.9.9-current" +FAIL github.com/levifig/loaf/cmd/loaf 2.163s +``` + +The temporary branch was removed (verified: `grep -c FALSIFICATION internal/cli/distribution.go` → 0) and a fresh uncached run passes: `go test ./cmd/loaf -run 'TestInstalledDistribution' -count=1` → `ok github.com/levifig/loaf/cmd/loaf 1.765s`. + +### Final verification (step 6) + +- `go test ./... -count=1` — entire suite passes; `npm run typecheck` passes; `gofmt -l internal/cli cmd/loaf` clean; zero remaining `resolveLoafPackageRoot` references. +- `npm run build` (sanctioned scripts) succeeded end to end — build:go, build:cli-ref, build:content (`bin/loaf build` resolving this source checkout), verify:go-artifacts ("present and synchronized"). Tracked artifact deltas match repo convention (#107/#117 precedent): only `bin/native/darwin-arm64/loaf` and `plugins/loaf/bin/native/darwin-arm64/loaf`. +- Live smoke with the freshly built binary under `env -i` isolation (temp HOME, XDG_CONFIG_HOME/XDG_DATA_HOME, `LOAF_DB`, GIT_CONFIG_GLOBAL/SYSTEM, minimal PATH): installed alpha.8 layout invoked with cwd inside an alpha.6 checkout reported `loaf 2.0.0-alpha.8`; `install --upgrade --yes` wrote `.cursor/.loaf-version` = `2.0.0-alpha.8`, installed the packaged skill content (`# Current Foundations`, not the stale checkout's), and stamped the project fenced section `v2.0.0-alpha.8` only. +- Explicit dev flow: the checkout's own `bin/native/darwin-arm64/loaf version` from a foreign working directory reports the checkout's version (executable provenance, no cwd inference); pinned permanently by `TestInstalledDistributionCheckoutOwnBinaryReportsCheckoutVersion`. +- `loaf change check docs/changes/20260718-installed-distribution-authority` (fresh binary, isolated `LOAF_DB`): no violations, executable: yes. +- No journal commands were run; no global installation, database, or user configuration was read or mutated; no commits, pushes, or dependency changes were made. + +### Capability-evidence regeneration (step 6, continued) + +The full suite exposed the expected capability-evidence gate after the sanctioned rebuild: the three retained U8 installed-smoke receipts pinned `native_binary_sha256` `c4e684cb2b9f717444001b0dc5a333bb94e124ccdf9c3201141f9958a208142e`, while the rebuilt candidate is `f41c6a4c5268f02bd594471ffe324f10196258bf31c0faa31431e7bd3fcba16d`; `LoadTargetCapabilityEvidence` re-hashes current candidate artifacts against receipts, so `go test ./internal/cli` failed on hash drift by design, not from a product defect. Per coordinator authorization the receipts were regenerated through the sanctioned writers only — no hash was hand-edited. + +- Pinned harness CLIs provisioned in disposable npm prefixes with an isolated npm cache (`npm install -g --prefix /tmp/loaf-u8-pins-*/{claude,codex,opencode} --cache /tmp/loaf-u8-pins-*/npm-cache`): `@anthropic-ai/claude-code@2.1.207`, `@openai/codex@0.144.1`, `opencode-ai@1.17.18`; verified `claude --version` → `2.1.207 (Claude Code)`, `codex --version` → `codex-cli 0.144.1`, `opencode --version` → `1.17.18`. Globally installed newer versions (claude 2.1.214, codex 0.144.5, opencode 1.18.3) were left untouched; pinned identities in receipts and `config/target-capabilities.json` required no change, so no decision gate was tripped. +- Sanctioned runners rerun with the pinned CLI first on `PATH`: `node cli/scripts/u8-claude-smoke.mjs` → `exit_code 0, assistant_marker_match true`; `node cli/scripts/u8-codex-smoke.mjs` → `exit_code 0, assistant_marker_match true`; `node cli/scripts/u8-opencode-smoke.mjs` → `exit_code 0, assistant_marker_match true, plugin_loaded true, root_session_lookup_proven true, cleanup_succeeded true`. Each runner rebuilt the candidate itself (`npm run build:go` + `bin/loaf build --target `) and ran the harness in its own disposable repo with isolated `LOAF_DB` (plus isolated `CODEX_HOME` for Codex and isolated HOME/XDG for OpenCode). +- Reproducibility held: after all three runner-internal rebuilds, `bin/native/darwin-arm64/loaf` and `plugins/loaf/bin/native/darwin-arm64/loaf` still hash `f41c6a4c5268f02bd594471ffe324f10196258bf31c0faa31431e7bd3fcba16d`. +- Receipt diffs are exactly minimal: in each of the three receipt JSONs only `native_binary_sha256` (`c4e684cb…` → `f41c6a4c…`), `timestamp`, and the random `marker` changed; invocation shapes, pinned versions, and hooks hashes are byte-identical. +- Gate cleared: `go test ./internal/cli -run 'TargetCapability|InstalledSmoke' -count=1` → ok; `go test ./cmd/loaf -run 'TestInstalledDistribution' -count=1` → ok; full `go test ./... -count=1` → ok for cmd/loaf, internal/cli, internal/project, internal/state. +- No journal commands were run; no global installation, database, or user configuration was mutated; disposable prefixes were removed after verification. + +### Independent-review follow-up (2026-07-18, post-approval findings) + +Three review findings addressed mechanically within the authority contract; all prior hotfix work, falsification evidence, and receipt integrity preserved. + +1. **`version` degraded output no longer reads the working directory (MEDIUM).** `runVersion` previously zeroed the root on unresolvable provenance and let `builtTargets`/`countSkillDirs`/`countAgentFiles`/`countHookEntries` join paths onto the empty root — relative lookups against the caller's cwd, so a stale checkout's Targets/Content rendered in the output. Targets and Content now render only from the resolved installed-distribution root; with no provenance the output ends at identity (`0.0.0` + go line) plus the resolver's reinstall guidance. Development behavior is unchanged and stays provenance-derived: a checkout's own binary resolves that checkout as its distribution (`TestInstalledDistributionCheckoutOwnBinaryReportsCheckoutVersion` still passes untouched). + - Regression tests: `TestRunnerVersionOmitsTargetsAndContentWithoutDistribution` (internal/cli, `t.Chdir` into a fully populated stale checkout) and `TestInstalledDistributionBareBinaryVersionOmitsCheckoutTargetsAndContent` (cmd/loaf, real bare binary with no adjacent distribution invoked with cwd inside a stale checkout). + - Falsification: with the guard disabled (`if false && rootErr != nil`, `FALSIFICATION-ONLY` marker), both tests fail showing the stale checkout's `Targets: cursor dist/cursor/`, `Skills: 1`, `Hooks: 1`; marker removed (`grep -c FALSIFICATION internal/cli/version.go` → 0) and both pass at `-count=1`. + - `TestPublicBinaryDispatchesStateVersionAndReleasePreflightNatively` and `TestPublicBinaryDispatchesVersionFlagNatively` had exercised the cwd fallback (bare binary, repo-root cwd); they now run the shared binary from a distribution-shaped fixture, keeping their native-dispatch assertions intact. +2. **Docs corrected (MEDIUM).** `change.md` Out, Observable Workflow, Decision 3, and Risks now state that the **current** `loaf install`/`setup` binary prompt (`installLoafBinary`) writes the launcher+native-only `~/.local/bin` layout — not only older Loaf versions — and that fail-closed `install`/`config check` plus identity-only degraded `version` output for that layout is intentional. +3. **Shared test binary cleanup (LOW).** `cmd/loaf` gained a `TestMain` that removes the shared compiled binary's temp directory after `m.Run` returns — deterministic, race-free (the package has no `t.Parallel`), and never during tests. Verified: a fresh package run leaves zero new `loaf-installed-dist-*` directories in TMPDIR; 14 leaked directories from earlier runs were removed. + +Verification after the fixes: + +- `gofmt -l internal/cli cmd/loaf` clean; `go vet ./internal/cli ./cmd/loaf` clean. +- `npm run build` succeeded end to end (build:go, cli-ref, content, verify:go-artifacts "present and synchronized"); tracked artifact deltas remain only the two native binaries, both now sha256 `5b76b90e835834bea3f4208cb8fa4e344951e43ab90f986837f09ac3d6eb2519` (previous review pass `f41c6a4c…`, base `c4e684cb…`). +- Pinned CLIs re-provisioned in fresh disposable prefixes (`npm install -g --prefix /tmp/loaf-u8-pins-*/{claude,codex,opencode} --cache /tmp/loaf-u8-pins-*/npm-cache`): `claude --version` → `2.1.207 (Claude Code)`, `codex --version` → `codex-cli 0.144.1`, `opencode --version` → `1.17.18`. Globally installed newer versions were left untouched. +- U8 smokes rerun via the sanctioned runners with the pinned CLI first on `PATH`: `node cli/scripts/u8-claude-smoke.mjs` → `exit_code 0, assistant_marker_match true`; `node cli/scripts/u8-codex-smoke.mjs` → `exit_code 0, assistant_marker_match true`; `node cli/scripts/u8-opencode-smoke.mjs` → `exit_code 0, assistant_marker_match true, plugin_loaded true, root_session_lookup_proven true, cleanup_succeeded true`. Reproducibility held: both binaries still hash `5b76b90e…` after the runner-internal rebuilds. +- Receipt diffs vs HEAD exactly minimal: in each of the three receipt JSONs only `native_binary_sha256` (`c4e684cb…` → `5b76b90e…`), `timestamp`, and the random `marker` changed; pinned identities, receipt names, invocation shapes, and capability classifications are byte-identical. No hash was hand-edited. +- Gates: `go test ./internal/cli -run 'TargetCapability|InstalledSmoke' -count=1` → ok; `go test ./cmd/loaf -run 'TestInstalledDistribution' -count=1` → ok; full `go test ./... -count=1` → ok (cmd/loaf 30.3s, internal/cli 51.2s, internal/project 0.5s, internal/state 14.6s). +- No journal commands were run; no global installation, database, or user configuration was read or mutated; disposable pin prefixes were removed after verification. diff --git a/internal/cli/build.go b/internal/cli/build.go index b41f39d8..aa2fe15c 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -30,7 +30,7 @@ func (r Runner) runBuild(args []string, out io.Writer, runtimeRoot string) error writeBuildHelp(out) return nil } - loafRoot, err := resolveLoafPackageRoot(r.WorkingDir, runtimeRoot) + loafRoot, err := resolveSourceCheckoutRoot(r.WorkingDir, runtimeRoot) if err != nil { return err } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 9ab5af75..cdac6e73 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -38,6 +38,10 @@ type Runner struct { // and `go test`, and surface in `loaf --version` / `loaf version` when set. BuildCommit string BuildDate string + // Executable optionally overrides os.Executable as the source of + // installed-distribution provenance (see distribution.go). Tests inject + // fixture layouts through it; nil means the real executable. + Executable func() (string, error) } type housekeepingOptions struct { @@ -258,7 +262,7 @@ func (r Runner) Run(args []string) error { case "--agent-help": dispatchErr = writeAgentHelpJSON(out) case "--version", "-v": - dispatchErr = r.runVersion(out, runtime.RootPath()) + dispatchErr = r.runVersion(out) case "build": dispatchErr = r.runBuild(args[1:], out, runtime.RootPath()) case "init": @@ -326,7 +330,7 @@ func (r Runner) Run(args []string) error { case "kb": dispatchErr = r.runKb(args[1:], out, runtime.RootPath()) case "version": - dispatchErr = r.runVersion(out, runtime.RootPath()) + dispatchErr = r.runVersion(out) default: fmt.Fprintf(errOut, "error: unknown command '%s'\n\n", args[0]) writeRootHelp(errOut) diff --git a/internal/cli/config.go b/internal/cli/config.go index 1d6f0227..d33fb500 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -75,7 +75,7 @@ func (r Runner) runConfig(args []string, out io.Writer, runtimeRoot string) erro if err != nil { return err } - loafRoot, err := resolveLoafPackageRoot(r.WorkingDir, runtimeRoot) + loafRoot, err := r.resolveInstalledDistributionRoot() if err != nil { return err } diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 567e55f2..a0b81b99 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -17,7 +17,7 @@ func TestRunnerConfigCheckFixCreatesProjectConfig(t *testing.T) { root, _ := setupInstallCommandFixture(t) var checkOut bytes.Buffer - err := Runner{Stdout: &checkOut, WorkingDir: root}.Run([]string{"config", "check", "--json"}) + err := Runner{Stdout: &checkOut, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"config", "check", "--json"}) var exitErr ExitError if !errors.As(err, &exitErr) || exitErr.Code != 2 { t.Fatalf("config check error = %v, want exit code 2", err) @@ -31,7 +31,7 @@ func TestRunnerConfigCheckFixCreatesProjectConfig(t *testing.T) { } var fixOut bytes.Buffer - err = Runner{Stdout: &fixOut, WorkingDir: root}.Run([]string{"config", "check", "--fix", "--json"}) + err = Runner{Stdout: &fixOut, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"config", "check", "--fix", "--json"}) if err != nil { t.Fatalf("config check --fix error = %v\n%s", err, fixOut.String()) } @@ -80,7 +80,7 @@ func TestRunnerConfigCheckFixAcceptsCurrentCodexHooksSchema(t *testing.T) { writeInstallFile(t, filepath.Join(home, ".codex", "hooks.json"), `{"hooks":{"SessionStart":[{"matcher":"startup","hooks":[{"type":"command","command":"user codex hook"}]}]}}`+"\n") var checkOut bytes.Buffer - err := Runner{Stdout: &checkOut, WorkingDir: root}.Run([]string{"config", "check", "--json"}) + err := Runner{Stdout: &checkOut, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"config", "check", "--json"}) if err != nil { t.Fatalf("config check error = %v, want current schema to pass", err) } @@ -94,7 +94,7 @@ func TestRunnerConfigCheckFixAcceptsCurrentCodexHooksSchema(t *testing.T) { } var fixOut bytes.Buffer - err = Runner{Stdout: &fixOut, WorkingDir: root}.Run([]string{"config", "check", "--fix", "--json"}) + err = Runner{Stdout: &fixOut, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"config", "check", "--fix", "--json"}) if err != nil { t.Fatalf("config check --fix error = %v\n%s", err, fixOut.String()) } @@ -176,7 +176,7 @@ func TestRunnerConfigCheckFixFromNestedDirectoryRefusesProjectRootExecutable(t * } var output bytes.Buffer - runErr := (Runner{Stdout: &output, WorkingDir: nestedWorkingDir}).Run([]string{"config", "check", "--fix", "--json"}) + runErr := (Runner{Stdout: &output, WorkingDir: nestedWorkingDir, Executable: distributionFixtureExecutable(projectRoot)}).Run([]string{"config", "check", "--fix", "--json"}) var exitErr ExitError if !errors.As(runErr, &exitErr) || exitErr.Code != 2 { t.Fatalf("nested config check --fix error = %v, want trust refusal exit 2", runErr) diff --git a/internal/cli/distribution.go b/internal/cli/distribution.go new file mode 100644 index 00000000..ccef9ef6 --- /dev/null +++ b/internal/cli/distribution.go @@ -0,0 +1,77 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" +) + +// Loaf resolves three distinct roots that must never be conflated: +// +// - The project root (project.ResolveRoot / state.Runtime) is where the +// user's project state lives. It stays working-directory derived. +// - The installed-distribution root is the Loaf package tree that ships +// with the running executable. Identity-bearing commands — version, +// install (including --upgrade), config check maintenance, and doctor's +// reported CLI version — resolve it from executable provenance only. +// - The source-checkout root is the checkout a development command operates +// on. build and setup resolve it from the working directory and project +// root, exactly as before. + +// resolveInstalledDistributionRoot locates the Loaf package tree that ships +// with the running executable. Release archives place the binary at +// /bin/loaf; npm installs and source checkouts place it at +// /bin/native//loaf; Homebrew exposes it through a bin symlink +// into the Cellar tree. Evaluating symlinks and walking upward from the +// executable reaches the adjacent package.json named "loaf" in all of those +// layouts. The working directory and project root are deliberately never +// consulted: distribution authority comes from the binary being run, not from +// where it is invoked, so an installed executable inside an older checkout can +// never adopt that checkout as its distribution. Local development needs no +// special mode — running a checkout's own binary makes that checkout the +// distribution root by construction. +func (r Runner) resolveInstalledDistributionRoot() (string, error) { + path, err := r.executablePath() + if err != nil { + return "", fmt.Errorf("resolve loaf executable: %w", err) + } + if resolved, err := filepath.EvalSymlinks(path); err == nil { + path = resolved + } + if root, ok := findLoafPackageRoot(filepath.Dir(path), map[string]bool{}); ok { + return root, nil + } + return "", fmt.Errorf("no Loaf distribution found alongside %s; reinstall Loaf, or run a source checkout's own bin/loaf for local development", path) +} + +func (r Runner) executablePath() (string, error) { + if r.Executable != nil { + return r.Executable() + } + return os.Executable() +} + +// resolveSourceCheckoutRoot locates the Loaf source checkout a development +// command operates on, searching the supplied working/project paths first and +// falling back to the executable's own tree. Only build and setup may use +// this: they act on the checkout the caller is standing in. Identity-bearing +// commands must use resolveInstalledDistributionRoot instead. +func resolveSourceCheckoutRoot(paths ...string) (string, error) { + seen := map[string]bool{} + for _, path := range paths { + if root, ok := findLoafPackageRoot(path, seen); ok { + return root, nil + } + } + if executable, err := os.Executable(); err == nil { + if root, ok := findLoafPackageRoot(filepath.Dir(executable), seen); ok { + return root, nil + } + } + if cwd, err := os.Getwd(); err == nil { + if root, ok := findLoafPackageRoot(cwd, seen); ok { + return root, nil + } + } + return "", fmt.Errorf("could not find loaf package root") +} diff --git a/internal/cli/distribution_test.go b/internal/cli/distribution_test.go new file mode 100644 index 00000000..ccb6fbbc --- /dev/null +++ b/internal/cli/distribution_test.go @@ -0,0 +1,151 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// distributionFixtureExecutable returns an Executable override pointing inside +// the fixture tree, mirroring the bin/native//loaf layout, so +// installed-distribution resolution selects the fixture as the running +// binary's own distribution. +func distributionFixtureExecutable(root string) func() (string, error) { + return func() (string, error) { + return filepath.Join(root, "bin", "native", "test-target", "loaf"), nil + } +} + +func TestResolveInstalledDistributionRootUsesExecutableProvenanceNotWorkingDirectory(t *testing.T) { + installed := writeVersionFixture(t) + stale := realpath(t, t.TempDir()) + writeFile(t, filepath.Join(stale, "package.json"), `{"name":"loaf","version":"1.1.1-stale"}`) + + runner := Runner{WorkingDir: stale, Executable: distributionFixtureExecutable(installed)} + root, err := runner.resolveInstalledDistributionRoot() + if err != nil { + t.Fatalf("resolveInstalledDistributionRoot error = %v", err) + } + if root != installed { + t.Fatalf("root = %q, want executable-adjacent distribution %q (never the working directory %q)", root, installed, stale) + } +} + +func TestResolveInstalledDistributionRootFailsClosedWithoutAdjacentDistribution(t *testing.T) { + bare := realpath(t, t.TempDir()) + runner := Runner{ + WorkingDir: writeVersionFixture(t), + Executable: func() (string, error) { return filepath.Join(bare, "loaf"), nil }, + } + if root, err := runner.resolveInstalledDistributionRoot(); err == nil { + t.Fatalf("resolveInstalledDistributionRoot = %q, want failure for a bare executable with no adjacent distribution", root) + } else if !strings.Contains(err.Error(), "reinstall Loaf") { + t.Fatalf("error = %v, want reinstall guidance", err) + } +} + +func TestResolveInstalledDistributionRootFollowsExecutableSymlink(t *testing.T) { + installed := writeVersionFixture(t) + nativeBinary := filepath.Join(installed, "bin", "native", "test-target", "loaf") + mkdirAll(t, filepath.Dir(nativeBinary)) + writeFile(t, nativeBinary, "#!/bin/sh\n") + shimDir := realpath(t, t.TempDir()) + shim := filepath.Join(shimDir, "loaf") + if err := os.Symlink(nativeBinary, shim); err != nil { + t.Fatalf("Symlink error = %v", err) + } + + runner := Runner{Executable: func() (string, error) { return shim, nil }} + root, err := runner.resolveInstalledDistributionRoot() + if err != nil { + t.Fatalf("resolveInstalledDistributionRoot error = %v", err) + } + if root != installed { + t.Fatalf("root = %q, want symlink-evaluated distribution %q", root, installed) + } +} + +func TestRunnerVersionReportsInstalledDistributionFromStaleCheckout(t *testing.T) { + installed := writeVersionFixture(t) + stale := realpath(t, t.TempDir()) + writeFile(t, filepath.Join(stale, "package.json"), `{"name":"loaf","version":"1.1.1-stale"}`) + + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: stale, + Executable: distributionFixtureExecutable(installed), + }.Run([]string{"version"}) + if err != nil { + t.Fatalf("version error = %v", err) + } + if !strings.Contains(stdout.String(), "9.8.7-test.1") { + t.Fatalf("version output = %q, want installed distribution version", stdout.String()) + } + if strings.Contains(stdout.String(), "1.1.1-stale") { + t.Fatalf("version output = %q, must not report the stale checkout version", stdout.String()) + } +} + +func TestRunnerVersionFallsBackToZeroWithoutDistribution(t *testing.T) { + bare := realpath(t, t.TempDir()) + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: writeVersionFixture(t), + Executable: func() (string, error) { return filepath.Join(bare, "loaf"), nil }, + }.Run([]string{"version"}) + if err != nil { + t.Fatalf("version error = %v", err) + } + if !strings.Contains(stdout.String(), "0.0.0") { + t.Fatalf("version output = %q, want honest 0.0.0 fallback instead of adopting the working directory", stdout.String()) + } +} + +// The Content/Targets helpers join paths onto the resolved root; with no +// installed distribution an empty root would degrade those joins to +// working-directory-relative lookups. Standing inside a fully populated stale +// checkout, a bare binary must not surface that checkout's targets or counts. +func TestRunnerVersionOmitsTargetsAndContentWithoutDistribution(t *testing.T) { + stale := writeVersionFixture(t) + t.Chdir(stale) + bare := realpath(t, t.TempDir()) + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: stale, + Executable: func() (string, error) { return filepath.Join(bare, "loaf"), nil }, + }.Run([]string{"version"}) + if err != nil { + t.Fatalf("version error = %v", err) + } + output := stdout.String() + if !strings.Contains(output, "0.0.0") { + t.Fatalf("version output = %q, want honest 0.0.0 fallback", output) + } + if !strings.Contains(output, "reinstall Loaf") { + t.Fatalf("version output = %q, want the resolver's reinstall guidance", output) + } + for _, forbidden := range []string{"Targets:", "Content:", "Skills:", "Agents:", "Hooks:", "plugins/loaf/", "dist/cursor/"} { + if strings.Contains(output, forbidden) { + t.Fatalf("version output = %q, must not read %q from the working directory without an installed distribution", output, forbidden) + } + } +} + +func TestRunnerInstallFailsClosedWithoutDistribution(t *testing.T) { + bare := realpath(t, t.TempDir()) + root, _ := setupInstallCommandFixture(t) + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: root, + Executable: func() (string, error) { return filepath.Join(bare, "loaf"), nil }, + }.Run([]string{"install", "--to", "cursor", "--yes"}) + if err == nil || !strings.Contains(err.Error(), "no Loaf distribution found alongside") { + t.Fatalf("install error = %v, want fail-closed distribution resolution instead of installing from the working directory", err) + } +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index b42801ab..774c09ec 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -70,7 +70,14 @@ func (r Runner) runDoctor(args []string, out io.Writer, runtimeRoot string) erro writeDoctorHelp(out) return nil } - report := runDoctorChecks(out, doctorContext{projectRoot: runtimeRoot}, options, packageVersion(runtimeRoot), r.Stdin) + // The version doctor diagnoses against is the installed distribution's, + // never the project's own package.json: outside a Loaf checkout the two + // are unrelated, and inside a stale checkout they must not be conflated. + cliVersion := "" + if distributionRoot, err := r.resolveInstalledDistributionRoot(); err == nil { + cliVersion = packageVersion(distributionRoot) + } + report := runDoctorChecks(out, doctorContext{projectRoot: runtimeRoot}, options, cliVersion, r.Stdin) if report.Failures > 0 { return ExitError{Code: 1} } @@ -422,6 +429,9 @@ func checkFencedVersion(cliVersion string) doctorCheck { Name: "fenced-version", Description: "Fenced section version matches installed loaf version", Run: func(ctx doctorContext) doctorResult { + if cliVersion == "" { + return doctorResult{Status: doctorSkip, Message: "No installed Loaf distribution found for this executable; cannot compare fenced versions"} + } canonical := filepath.Join(ctx.projectRoot, "AGENTS.md") if !doctorRegularFileExists(canonical) { return doctorResult{Status: doctorSkip, Message: "No root AGENTS.md to inspect"} diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index 21ffcf6d..3a0aaf0b 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -59,7 +59,7 @@ func TestRunnerDoctorFixPromptsBeforeEachRepairAndAcceptsYes(t *testing.T) { writeDoctorFile(t, filepath.Join(root, ".cursor", "rules", "loaf.mdc"), "legacy\n") var stdout bytes.Buffer - err := (Runner{Stdout: &stdout, Stdin: strings.NewReader("y\ny\n"), WorkingDir: root}).Run([]string{"doctor", "--fix"}) + err := (Runner{Stdout: &stdout, Stdin: strings.NewReader("y\ny\n"), WorkingDir: root, Executable: distributionFixtureExecutable(root)}).Run([]string{"doctor", "--fix"}) if err != nil { t.Fatalf("doctor --fix error = %v\n%s", err, stdout.String()) } @@ -232,6 +232,7 @@ func TestRunnerDoctorWarningsDoNotFail(t *testing.T) { err := Runner{ Stdout: &stdout, WorkingDir: root, + Executable: distributionFixtureExecutable(root), }.Run([]string{"doctor"}) if err != nil { t.Fatalf("doctor warning-only error = %v, want nil", err) diff --git a/internal/cli/install.go b/internal/cli/install.go index 82c08b94..7583266c 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -50,7 +50,7 @@ func (r Runner) runInstall(args []string, out io.Writer, runtimeRoot string) err return nil } - loafRoot, err := resolveLoafPackageRoot(r.WorkingDir, runtimeRoot) + loafRoot, err := r.resolveInstalledDistributionRoot() if err != nil { return err } diff --git a/internal/cli/install_command_test.go b/internal/cli/install_command_test.go index db548575..b403e248 100644 --- a/internal/cli/install_command_test.go +++ b/internal/cli/install_command_test.go @@ -19,6 +19,7 @@ func TestRunnerInstallExplicitCursorTargetRunsNatively(t *testing.T) { err := Runner{ Stdout: &stdout, WorkingDir: root, + Executable: distributionFixtureExecutable(root), }.Run([]string{"install", "--to", "cursor", "--yes"}) if err != nil { t.Fatalf("install --to cursor error = %v\n%s", err, stdout.String()) @@ -56,7 +57,7 @@ func TestRunnerInstallUsesAgentsHomeSkillDestinations(t *testing.T) { } var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--to", target, "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--to", target, "--yes"}) if err != nil { t.Fatalf("install --to %s error = %v\n%s", target, err, stdout.String()) } @@ -83,7 +84,7 @@ func TestRunnerInstallSharedSkillsPreservesForeignEntries(t *testing.T) { writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--to", "cursor", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--to", "cursor", "--yes"}) if err != nil { t.Fatalf("install --to cursor error = %v\n%s", err, stdout.String()) } @@ -95,7 +96,7 @@ func TestRunnerInstallSharedSkillsPreservesForeignEntries(t *testing.T) { } writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "go-development", "SKILL.md"), "# Go\n") stdout.Reset() - err = Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--to", "cursor", "--yes"}) + err = Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--to", "cursor", "--yes"}) if err != nil { t.Fatalf("second install --to cursor error = %v\n%s", err, stdout.String()) } @@ -109,7 +110,7 @@ func TestRunnerInstallRecordKeepsRelocatedTargetDetectable(t *testing.T) { writeInstallFile(t, filepath.Join(root, "dist", "cursor", "skills", "foundations", "SKILL.md"), "# Foundations\n") var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--to", "cursor", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--to", "cursor", "--yes"}) if err != nil { t.Fatalf("install --to cursor error = %v\n%s", err, stdout.String()) } @@ -128,7 +129,7 @@ func TestRunnerInstallUpgradeOnlyInstallsDetectedLoafTargets(t *testing.T) { writeInstallFile(t, filepath.Join(home, ".cursor", loafInstallMarkerFile), "old\n") var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade", "--yes"}) if err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } @@ -161,7 +162,7 @@ func TestRunnerInstallUpgradeMigratesLegacyProjectInstructionLayout(t *testing.T } var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: root}).Run([]string{"install", "--upgrade", "--yes"}); err != nil { + if err := (Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}).Run([]string{"install", "--upgrade", "--yes"}); err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } canonical := filepath.Join(root, "AGENTS.md") @@ -187,7 +188,7 @@ func TestRunnerInstallUpgradeDetectsLegacyAmpWithoutMutatingLegacyPath(t *testin writeInstallFile(t, filepath.Join(root, "dist", "amp", ".amp", "plugins", "loaf.ts"), "current plugin\n") var stdout bytes.Buffer - if err := (Runner{Stdout: &stdout, WorkingDir: root}).Run([]string{"install", "--upgrade", "--yes"}); err != nil { + if err := (Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}).Run([]string{"install", "--upgrade", "--yes"}); err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } if !strings.Contains(stdout.String(), "Upgrading:") || !strings.Contains(stdout.String(), "amp") || !strings.Contains(stdout.String(), "Amp installed") { @@ -250,7 +251,7 @@ func TestRunnerInstallUpgradeRelocatesOpenCodeAndAmpSkillHomes(t *testing.T) { }`, tc.target, oldSkills, ownerMarker, tc.target)) var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade", "--yes"}) if err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } @@ -286,7 +287,7 @@ func TestRunnerInstallUpgradeCleansRetiredTargetFromManifest(t *testing.T) { }`) var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade", "--yes"}) if err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } @@ -322,7 +323,7 @@ func TestRunnerInstallUpgradeCleansRetiredGeminiTargetWithoutReintroducingIt(t * }`) var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade", "--yes"}) if err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } @@ -356,7 +357,7 @@ func TestRunnerInstallUpgradeCleansRetiredSkillFromManifest(t *testing.T) { }`) var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade", "--yes"}) if err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } @@ -390,7 +391,7 @@ func TestRunnerInstallUpgradeSkipsDestructiveDeprecationWithoutExplicitYes(t *te }`) var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade"}) if err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } @@ -426,7 +427,7 @@ func TestRunnerInstallUpgradeCleansRetiredAgentFromManifest(t *testing.T) { }`) var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade", "--yes"}) if err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } @@ -462,7 +463,7 @@ func TestRunnerInstallUpgradeSkipsUnmarkedRetiredAgent(t *testing.T) { }`) var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade", "--yes"}) if err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } @@ -492,7 +493,7 @@ func TestRunnerInstallUpgradeReportsDefaultDeprecationWindow(t *testing.T) { }`) var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade", "--yes"}) if err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } @@ -523,7 +524,7 @@ func TestRunnerInstallUpgradeReportsDeprecationSignoff(t *testing.T) { }`) var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade", "--yes"}) if err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } @@ -554,7 +555,7 @@ func TestRunnerInstallUpgradeReportsAliasTombstoneFromManifest(t *testing.T) { }`) var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade", "--yes"}) if err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } @@ -596,7 +597,7 @@ func TestRunnerInstallUpgradeReportsExternalizedSkillWithoutRemoving(t *testing. }`) var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade", "--yes"}) if err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } @@ -636,7 +637,7 @@ func TestRunnerInstallUpgradeSkipsUnmarkedRetiredTarget(t *testing.T) { }`) var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade", "--yes"}) if err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } @@ -670,7 +671,7 @@ func TestRunnerInstallUpgradeRelocatesManifestPathExactlyOnce(t *testing.T) { }`) var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade", "--yes"}) if err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } @@ -681,7 +682,7 @@ func TestRunnerInstallUpgradeRelocatesManifestPathExactlyOnce(t *testing.T) { } stdout.Reset() - err = Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade", "--yes"}) + err = Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade", "--yes"}) if err != nil { t.Fatalf("second install --upgrade error = %v\n%s", err, stdout.String()) } @@ -714,7 +715,7 @@ func TestRunnerInstallUpgradeRemovesStaleRelocatedPathWhenDestinationExists(t *t }`) var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--upgrade", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--upgrade", "--yes"}) if err != nil { t.Fatalf("install --upgrade error = %v\n%s", err, stdout.String()) } @@ -733,7 +734,7 @@ func TestRunnerInstallCodexUsesCodeXHomeNatively(t *testing.T) { writeInstallFile(t, filepath.Join(root, "dist", "codex", ".codex", "hooks.json"), `{"hooks":{}}`) var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--to", "codex", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--to", "codex", "--yes"}) if err != nil { t.Fatalf("install --to codex error = %v\n%s", err, stdout.String()) } @@ -754,7 +755,7 @@ func TestRunnerInstallCodexBasicCommandsFailsWhenCapabilityCannotBeInstalled(t * writeInstallFile(t, filepath.Join(root, "dist", "codex", ".codex", "rules", "loaf.rules.tmpl"), "# Loaf Codex policy\n{{LOAF_BASIC_RULES}}\n") var stdout bytes.Buffer - err := Runner{Stdout: &stdout, WorkingDir: root}.Run([]string{"install", "--to", "codex", "--codex-basic-commands", "--yes"}) + err := Runner{Stdout: &stdout, WorkingDir: root, Executable: distributionFixtureExecutable(root)}.Run([]string{"install", "--to", "codex", "--codex-basic-commands", "--yes"}) if err == nil || !strings.Contains(err.Error(), "not on PATH") { t.Fatalf("Codex basic command policy install error = %v, want visible executable trust failure\n%s", err, stdout.String()) } @@ -780,6 +781,7 @@ func TestRunnerInstallOffersBinarySelfInstall(t *testing.T) { Stdout: &stdout, Stdin: strings.NewReader("y\n\n\n"), WorkingDir: root, + Executable: distributionFixtureExecutable(root), }.Run([]string{"install", "--to", "cursor", "--yes"}) if err != nil { t.Fatalf("install --to cursor with binary prompt error = %v\n%s", err, stdout.String()) @@ -802,6 +804,7 @@ func TestRunnerInstallInteractiveSelectionRunsNatively(t *testing.T) { Stdout: &stdout, Stdin: strings.NewReader("y\n"), WorkingDir: root, + Executable: distributionFixtureExecutable(root), }.Run([]string{"install"}) if err != nil { t.Fatalf("interactive install error = %v\n%s", err, stdout.String()) @@ -829,6 +832,7 @@ func TestRunnerInstallInteractiveNoTargetsStillUpdatesClaudeProjectFile(t *testi Stdout: &stdout, Stdin: strings.NewReader("n\n"), WorkingDir: root, + Executable: distributionFixtureExecutable(root), }.Run([]string{"install"}) if err != nil { t.Fatalf("interactive no-target install error = %v\n%s", err, stdout.String()) @@ -859,6 +863,7 @@ func TestRunnerInstallMcpRecommendationWritesCursorProjectConfig(t *testing.T) { Stdout: &stdout, Stdin: strings.NewReader("p\nn\n"), WorkingDir: root, + Executable: distributionFixtureExecutable(root), }.Run([]string{"install", "--to", "cursor", "--yes"}) if err != nil { t.Fatalf("install --to cursor with MCP prompt error = %v\n%s", err, stdout.String()) @@ -917,6 +922,7 @@ EOS Stdout: &stdout, Stdin: strings.NewReader("n\np\ny\n"), WorkingDir: root, + Executable: distributionFixtureExecutable(root), }.Run([]string{"install", "--to", "cursor", "--yes"}) if err != nil { t.Fatalf("install --to cursor with Serena prompt error = %v\n%s", err, stdout.String()) @@ -1000,6 +1006,7 @@ func TestRunnerInstallFromLinkedWorktreeWritesMainLoafConfig(t *testing.T) { err := Runner{ Stdout: &stdout, WorkingDir: linked, + Executable: distributionFixtureExecutable(main), }.Run([]string{"install", "--to", "cursor", "--yes"}) if err != nil { t.Fatalf("install from linked worktree error = %v\n%s", err, stdout.String()) @@ -1024,7 +1031,8 @@ func TestRunnerInstallHelpAndInvalidTargetAreNative(t *testing.T) { t.Fatalf("help output = %q, want native install help", helpOut.String()) } - err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: t.TempDir()}).Run([]string{"install", "--to", "wat"}) + root, _ := setupInstallCommandFixture(t) + err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: root, Executable: distributionFixtureExecutable(root)}).Run([]string{"install", "--to", "wat"}) if err == nil || !strings.Contains(err.Error(), "unknown install target") { t.Fatalf("invalid target error = %v, want native unknown target error", err) } diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 917d56e1..c6f6dda8 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -32,7 +32,7 @@ func (r Runner) runSetup(args []string, out io.Writer, runtimeRoot string) error } } - loafRoot, err := resolveLoafPackageRoot(r.WorkingDir, runtimeRoot) + loafRoot, err := resolveSourceCheckoutRoot(r.WorkingDir, runtimeRoot) if err != nil { return err } diff --git a/internal/cli/setup_test.go b/internal/cli/setup_test.go index 94f7436e..d433e157 100644 --- a/internal/cli/setup_test.go +++ b/internal/cli/setup_test.go @@ -36,6 +36,7 @@ func TestRunnerSetupRunsInitBuildAndInstallNatively(t *testing.T) { Stdout: &stdout, WorkingDir: root, StateHome: stateHome, + Executable: distributionFixtureExecutable(root), }.Run([]string{"setup", target}) if err != nil { t.Fatalf("setup error = %v", err) diff --git a/internal/cli/version.go b/internal/cli/version.go index c71afc4e..15c85bff 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -22,9 +22,9 @@ var versionTargetOutputs = []builtTarget{ {name: "codex", path: "dist/codex/"}, } -func (r Runner) runVersion(out io.Writer, runtimeRoot string) error { - root, err := resolveLoafPackageRoot(r.WorkingDir, runtimeRoot) - if err != nil { +func (r Runner) runVersion(out io.Writer) error { + root, rootErr := r.resolveInstalledDistributionRoot() + if rootErr != nil { root = "" } version := packageVersion(root) @@ -32,6 +32,16 @@ func (r Runner) runVersion(out io.Writer, runtimeRoot string) error { fmt.Fprintf(out, "\n%s %s%s\n", ansiBold("loaf"), version, buildInfoSuffix(r.BuildCommit, r.BuildDate)) fmt.Fprintf(out, "%s %s\n", ansiGray("go"), strings.TrimPrefix(runtimeVersion(), "go")) + // Targets and Content describe the installed distribution. Without + // resolvable executable provenance there is no distribution to describe — + // an empty root would make the lookups below relative to the working + // directory, counting whatever checkout the caller happens to stand in — + // so the degraded output ends at identity plus the resolver's guidance. + if rootErr != nil { + fmt.Fprintf(out, "\n%s\n\n", ansiGray(rootErr.Error())) + return nil + } + targets := builtTargets(root) if len(targets) > 0 { fmt.Fprintf(out, "\n%s\n", ansiBold("Targets:")) @@ -73,26 +83,6 @@ func buildInfoSuffix(commit, date string) string { return " (" + strings.Join(parts, " · ") + ")" } -func resolveLoafPackageRoot(paths ...string) (string, error) { - seen := map[string]bool{} - for _, path := range paths { - if root, ok := findLoafPackageRoot(path, seen); ok { - return root, nil - } - } - if executable, err := os.Executable(); err == nil { - if root, ok := findLoafPackageRoot(filepath.Dir(executable), seen); ok { - return root, nil - } - } - if cwd, err := os.Getwd(); err == nil { - if root, ok := findLoafPackageRoot(cwd, seen); ok { - return root, nil - } - } - return "", fmt.Errorf("could not find loaf package root") -} - func findLoafPackageRoot(path string, seen map[string]bool) (string, bool) { if path == "" { return "", false diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go index 75434c37..afd8cde5 100644 --- a/internal/cli/version_test.go +++ b/internal/cli/version_test.go @@ -15,6 +15,7 @@ func TestRunnerVersionUsesNativePackageMetadata(t *testing.T) { err := Runner{ Stdout: &stdout, WorkingDir: root, + Executable: distributionFixtureExecutable(root), }.Run([]string{"version"}) if err != nil { t.Fatalf("version error = %v", err) @@ -48,6 +49,7 @@ func TestRunnerVersionDoesNotRequireLegacyBridge(t *testing.T) { err := Runner{ Stdout: &stdout, WorkingDir: root, + Executable: distributionFixtureExecutable(root), }.Run([]string{"version"}) if err != nil { t.Fatalf("version error = %v", err) @@ -66,6 +68,7 @@ func TestRunnerVersionFlagsDoNotRequireLegacyBridge(t *testing.T) { err := Runner{ Stdout: &stdout, WorkingDir: root, + Executable: distributionFixtureExecutable(root), }.Run([]string{flag}) if err != nil { t.Fatalf("%s error = %v", flag, err) @@ -115,6 +118,7 @@ func TestRunnerVersionIncludesBuildInfoWhenSet(t *testing.T) { err := Runner{ Stdout: &stdout, WorkingDir: root, + Executable: distributionFixtureExecutable(root), BuildCommit: "abc1234", BuildDate: "2026-06-27T12:00:00Z", }.Run([]string{"version"}) @@ -137,6 +141,7 @@ func TestRunnerVersionCleanWithoutBuildInfo(t *testing.T) { err := Runner{ Stdout: &stdout, WorkingDir: root, + Executable: distributionFixtureExecutable(root), }.Run([]string{"version"}) if err != nil { t.Fatalf("version error = %v", err) diff --git a/plugins/loaf/bin/native/darwin-arm64/loaf b/plugins/loaf/bin/native/darwin-arm64/loaf index 6d43b9b6..fa7cf5f0 100755 Binary files a/plugins/loaf/bin/native/darwin-arm64/loaf and b/plugins/loaf/bin/native/darwin-arm64/loaf differ