From 92602d4e0b674d1f736840520c1f26d40ee425ce Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:04:13 +0100 Subject: [PATCH 1/3] feat: add track-latest dev install mode to ahoy install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `abcd ahoy install` symlinks the pinned built binary, so dogfooding live development meant hand-rolling a `~/.local/bin/abcd` wrapper that ran `go build -C && exec` on every call. This retires that workaround. `--dev` installs a POSIX-sh shim at the same PATH target that rebuilds abcd from the source tip on every invocation and execs the fresh binary. A broken build fails loudly and never execs a stale binary (loud-staging). The mode is detected from the installed shim itself and surfaced by `abcd ahoy` status as `install: dev (tip build)` — never recorded in the tracked repo config, so it can never go stale or dirty a committed file. Installing over an existing install applies-as-update in either direction; a foreign occupant is never clobbered, and uninstall removes the shim (it is ours). Wired through the CLI flag, the ahoy plugin surface, and the regenerated CLI reference and compatibility surface. The shim is written with the store's no-follow atomic writer and recognised by an exact prefix, per the security review; the config-recording mechanism the original design proposed was dropped as dead (nothing reads it) per the code review. Assisted-by: Claude:claude-opus-4-8 --- .abcd/development/release/surface.json | 7 + CHANGELOG.md | 13 ++ commands/abcd/ahoy.md | 9 + docs/reference/cli/commands.md | 1 + internal/core/ahoy/ahoy.go | 5 + internal/core/ahoy/apply.go | 109 ++++++++++- internal/core/ahoy/detect.go | 27 +++ internal/core/ahoy/dev_install_test.go | 257 +++++++++++++++++++++++++ internal/core/ahoy/dev_shim_test.go | 93 +++++++++ internal/core/ahoy/store.go | 89 +++++++++ internal/surface/cli/cli.go | 11 +- 11 files changed, 609 insertions(+), 12 deletions(-) create mode 100644 internal/core/ahoy/dev_install_test.go create mode 100644 internal/core/ahoy/dev_shim_test.go diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index 082c825..e99a05b 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -45,6 +45,13 @@ "required": false, "hidden": false }, + { + "name": "dev", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + }, { "name": "docs-target", "shorthand": "", diff --git a/CHANGELOG.md b/CHANGELOG.md index cc06e01..bf8348b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ called out in a **Breaking** section. ### Added +- **`abcd ahoy install --dev` — a track-latest dogfood install mode** (iss-75). + Normal `ahoy install` symlinks the pinned built binary, so tracking live + development meant hand-rolling a `~/.local/bin/abcd` wrapper that ran + `go build -C && exec` on every call. That manual workaround now dies: + `--dev` installs a shim at the same `PATH` target that rebuilds abcd from the + source tip on every invocation and execs the fresh binary. A broken build fails + loudly and never execs a stale binary (loud-staging). `abcd ahoy` status reports + the mode as `install: dev (tip build)`, detected from the installed shim itself + (never recorded in the tracked repo config, so it can never go stale), so a dev + install is never invisible. Installing over an existing + install applies-as-update in either direction — `--dev` replaces the pinned + symlink with the shim, a plain re-install restores the symlink — and a foreign + occupant is still never clobbered. - **Record-id minting now sees every branch, and a spec-id uniqueness lint closes the class** (iss-115, iss-120). Sequential ids (`iss-N`, `itd-N`, `spc-N`) were minted from the local working tree only, so two branches cut from the same base diff --git a/commands/abcd/ahoy.md b/commands/abcd/ahoy.md index a4d37c2..17340e5 100644 --- a/commands/abcd/ahoy.md +++ b/commands/abcd/ahoy.md @@ -19,6 +19,10 @@ Then summarise the JSON for the user: - `folder_kind` — `managed-repo`, `unmanaged-repo`, or `unmanaged-folder`. - `plugin_root_status` and `root_sha` — where abcd is anchored. +- `signals.install_mode` — the PATH-entry install mode: `dev (tip build)` when + the track-latest dogfood shim is installed, `pinned` for the built-binary + symlink, empty when there is nothing on `PATH` yet. Report it so a dev install + is never invisible. - `gaps` — how many are outstanding, and for each actionable one its `title`, `category`, and `fix_hint`; call out which are `required`. @@ -26,6 +30,11 @@ If there are actionable gaps, tell the user to run `abcd ahoy install` to apply them. If `folder_kind` is `unmanaged-folder`, note there is nothing to act on (not a git repo, no abcd markers). +For dogfooding abcd itself, `abcd ahoy install --dev` installs a track-latest +shim instead of the pinned-binary symlink: the `PATH` entry rebuilds abcd from +the source tip on every call and fails loudly on a broken build. Re-running +`abcd ahoy install` without `--dev` switches back to the pinned symlink. + If the `abcd` binary is not on `PATH`, fall back to `go run ./cmd/abcd ahoy --json` from the repo root, or tell the user to build it with `make build`. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 9473f9c..0966c2e 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -55,6 +55,7 @@ Install or update abcd in this repo (idempotent) ``` --adopt adopt an unmanaged repo without prompting + --dev track-latest dogfood mode: the PATH entry rebuilds from the source tip on every call instead of pinning the built binary --docs-target string marker target: claude_md | agents_md | both | skip --oracle-backend string oracle backend: host-delegated | native | cli | api | mcp --refuse-adopt decline to adopt an unmanaged repo diff --git a/internal/core/ahoy/ahoy.go b/internal/core/ahoy/ahoy.go index 8dfc3b0..0018047 100644 --- a/internal/core/ahoy/ahoy.go +++ b/internal/core/ahoy/ahoy.go @@ -90,6 +90,11 @@ type InstallOptions struct { Yes bool // approve every resolvable category ApprovedCategories map[GapCategory]bool // nil => interactive; explicit => partial subset ValueOverrides map[string]string // visibility/docs_target/oracle_backend/scan_deep + // Dev requests the track-latest dogfood mode: the PATH entry becomes a shim + // that rebuilds abcd from the source tip on every call and execs the fresh + // binary (failing loudly on a broken build), instead of a symlink to the + // pinned built binary. --dev. + Dev bool } // InstallResult is the outcome of Install. diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index 11029dc..fc6653c 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -57,9 +57,16 @@ func Install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) // interactive confirmation (never under --yes), as the gap's fix hint // advertises; and an explicit value override that differs from the persisted // config, which forces an apply-as-update on an otherwise-clean repo (iss-107). + // An explicit --dev (or a plain install over an existing dev shim) forces an + // apply-as-update on an otherwise-clean repo, the same way an explicit value + // override does (iss-107): the requested install mode differs from what is on + // disk, so there is work to do even with zero gaps. + modeForced := modeWouldChange(opts, det) + if len(actionable(det.Gaps)) == 0 && !(!opts.Yes && pinAdoptable(det.Gaps)) && - !overridesWouldChange(abs, opts.ValueOverrides) { + !overridesWouldChange(abs, opts.ValueOverrides) && + !modeForced { return InstallResult{Status: "already_up_to_date"}, nil } @@ -73,6 +80,8 @@ func Install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) prompter: p, gapPresent: gapIDSet(det.Gaps), autoYes: opts.Yes, + devMode: opts.Dev, + modeForced: modeForced, } // Ordered apply steps. @@ -119,6 +128,8 @@ type applyCtx struct { writes []string changes []string // human-readable value changes an explicit override forced autoYes bool // --yes: every category auto-approved without interaction + devMode bool // --dev: install the track-latest shim instead of the symlink + modeForced bool // the requested install mode differs from the on-disk state visibilityForced bool // an explicit --visibility override overwrote a valid value docsTargetForced bool // an explicit --docs-target override overwrote a valid value @@ -593,29 +604,96 @@ func markerFilesDropped(from, to string) []string { return dropped } -// stepSymlink installs the owned PATH symlink. It refuses to clobber a foreign -// binary. Default: yes for private, no for public. +// stepSymlink installs the PATH entry: an owned symlink to the pinned binary +// (default), or the track-latest dev shim under --dev. It runs on a fresh install +// (the symlink.missing gap) or when a mode switch was forced on an already-present +// owned entry (apply-as-update, iss-107). It refuses to clobber a foreign binary. func (a *applyCtx) stepSymlink() { - if !a.approved[ConfigChange] || !a.has("symlink.missing") { + if a.det.pluginRoot == "" { return } - if a.det.pluginRoot == "" { + gapDriven := a.approved[ConfigChange] && a.has("symlink.missing") + if !gapDriven && !a.modeForced { return } target := binTarget() - source := pluginBinaryPath(a.det.pluginRoot) - // Refuse to clobber anything already present. - if _, err := os.Lstat(target); err == nil { + kind := classifyBinTarget(target, a.det.pluginRoot) + if kind == binTargetForeign { + return // never clobber something we do not own + } + if a.devMode { + a.installDevShim(target, kind) + return + } + a.installPinnedSymlink(target, kind) +} + +// installDevShim writes the track-latest shim, replacing an owned pinned symlink +// if one is there. An existing dev shim is left as-is (idempotent). +func (a *applyCtx) installDevShim(target string, kind binTargetKind) { + if kind == binTargetDevShim { + return + } + if kind == binTargetOwnedSymlink { + if err := os.Remove(target); err != nil { + return + } + a.echoChange("install_mode", "pinned", "dev") + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return + } + content := renderDevShim(a.det.pluginRoot, pluginBinaryPath(a.det.pluginRoot)) + // Atomic no-follow write (rename over the target), matching the rest of the + // store: a symlink pre-planted at the leaf is replaced, never written through, + // and the executable bit is set via fchmod on the temp descriptor. + if err := fsutil.WriteFileAtomic(target, []byte(content), 0o755); err != nil { + return + } + a.note(target) +} + +// installPinnedSymlink writes the owned symlink to the pinned binary, replacing a +// dev shim if one is there. An existing owned symlink is left as-is (idempotent). +func (a *applyCtx) installPinnedSymlink(target string, kind binTargetKind) { + if kind == binTargetOwnedSymlink { return } + if kind == binTargetDevShim { + if err := os.Remove(target); err != nil { + return + } + a.echoChange("install_mode", "dev", "pinned") + } if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return } + source := pluginBinaryPath(a.det.pluginRoot) if err := os.Symlink(source, target); err == nil { a.note(target) } } +// modeWouldChange reports whether the requested install mode differs from the +// on-disk PATH target, so an otherwise up-to-date repo still has work to do. A +// foreign occupant is never touched, so it never counts as a change. +func modeWouldChange(opts InstallOptions, det DetectionResult) bool { + if det.pluginRoot == "" { + return false + } + kind := classifyBinTarget(binTarget(), det.pluginRoot) + if kind == binTargetForeign { + return false + } + if opts.Dev { + return kind != binTargetDevShim + } + // Plain install: only a switch away from an existing dev shim is a forced + // change; a missing entry is handled by the symlink.missing gap, and an owned + // symlink is already correct. + return kind == binTargetDevShim +} + // stepRules writes the per-repo .abcd/rules.json override skeleton when absent. // It is deliberately the empty-domains skeleton, NOT a copy of the bundled // defaults: the default domains live once in the abcd binary (itd-3), and this @@ -683,7 +761,17 @@ func Uninstall(cwd string) (UninstallReceipt, error) { case lerr != nil: receipt.Symlink.Note = "absent" case fi.Mode()&os.ModeSymlink == 0: - receipt.Symlink.Note = "not a symlink; left untouched" + // A regular file: our own dev shim (remove it — it is ours), else foreign. + if isDevShimFile(target) { + if err := os.Remove(target); err == nil { + receipt.Symlink.Removed = true + receipt.Symlink.Note = "removed dev shim" + } else { + receipt.Symlink.Note = "remove failed" + } + } else { + receipt.Symlink.Note = "not a symlink; left untouched" + } case !ok: receipt.Symlink.Note = "plugin root unresolved; left untouched" default: @@ -751,6 +839,9 @@ func Status(cwd string) (string, error) { if det.RootSHA != "" { fmt.Fprintf(&b, "root sha: %s\n", shortSHA(det.RootSHA)) } + if mode, _ := det.Signals["install_mode"].(string); mode != "" { + fmt.Fprintf(&b, "install: %s\n", mode) + } act := actionable(det.Gaps) switch det.FolderKind { case UnmanagedFolder: diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index 18f22e5..e0fdaed 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -46,6 +46,10 @@ func Detect(cwd string) (DetectionResult, error) { pluginStatus = "resolved" } + // The PATH-target install mode is a machine-scope fact independent of folder + // kind, surfaced so status reports "dev (tip build)" honestly (never invisible). + signals["install_mode"] = detectInstallMode(pluginRoot, pluginOK) + res := DetectionResult{ FolderKind: kind, RootSHA: identity.RootSHA, @@ -377,6 +381,11 @@ func detectPathSymlink(pluginRoot string, pluginOK bool) []Gap { return nil } if fi.Mode()&modeSymlink == 0 { + if isDevShimFile(target) { + // Our own track-latest dev shim (abcd ahoy install --dev) — a valid + // install, not a foreign occupant. Surfaced via the install_mode signal. + return nil + } return []Gap{{ ID: "symlink.foreign", Category: ConfigChange, Scope: "machine", Title: "non-symlink at " + target, Detail: "A regular file occupies the PATH symlink target.", @@ -397,6 +406,24 @@ func detectPathSymlink(pluginRoot string, pluginOK bool) []Gap { }} } +// detectInstallMode reports the current PATH-target install mode: "dev (tip +// build)" when the track-latest shim occupies it, "pinned" when our owned symlink +// does, and "" when the target is absent, foreign, or the plugin root is +// unresolved (nothing to attribute a mode to). +func detectInstallMode(pluginRoot string, pluginOK bool) string { + if !pluginOK { + return "" + } + switch classifyBinTarget(binTarget(), pluginRoot) { + case binTargetDevShim: + return "dev (tip build)" + case binTargetOwnedSymlink: + return "pinned" + default: + return "" + } +} + func detectHookManifest(pluginRoot string, pluginOK bool) []Gap { if !pluginOK { return nil diff --git a/internal/core/ahoy/dev_install_test.go b/internal/core/ahoy/dev_install_test.go new file mode 100644 index 0000000..66933a6 --- /dev/null +++ b/internal/core/ahoy/dev_install_test.go @@ -0,0 +1,257 @@ +package ahoy + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// devInstallOpts is a fully-specified, non-interactive dev-mode install: adopt +// the repo, approve every category, pin the config, and request track-latest. +func devInstallOpts() InstallOptions { + opts := installOpts() + opts.Dev = true + return opts +} + +// detectInstallModeSignal returns the install_mode detection signal for repo — +// the honest, filesystem-derived mode that status reports. +func detectInstallModeSignal(t *testing.T, repo string) string { + t.Helper() + det, err := Detect(repo) + if err != nil { + t.Fatal(err) + } + m, _ := det.Signals["install_mode"].(string) + return m +} + +// configHasInstallSection reports whether config.json carries an `install` +// section at all. The install mode is derived from the filesystem, never +// recorded in the tracked repo config, so this must always be false — the +// regression pin that a dev install never dirties config.json. +func configHasInstallSection(t *testing.T, repo string) bool { + t.Helper() + cfg, err := readConfig(repo) + if err != nil { + t.Fatalf("readConfig: %v", err) + } + _, ok := cfg["install"] + return ok +} + +// TestDevInstallWritesShimAndSurfacesMode is coverage (a): a --dev install writes +// the rebuild-then-exec shim (not a symlink) at the PATH target, the shim carries +// the loud-fail path, and detection surfaces the mode from the installed shim +// (never recorded in the tracked repo config). +func TestDevInstallWritesShimAndSurfacesMode(t *testing.T) { + _, pluginRoot := setupHermetic(t) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + + res, err := Install(repo, devInstallOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if res.Status != "clean" { + t.Fatalf("dev install status = %q (remaining=%v), want clean", res.Status, res.Remaining) + } + + target := binTarget() + fi, err := os.Lstat(target) + if err != nil { + t.Fatalf("PATH target not created: %v", err) + } + if fi.Mode()&os.ModeSymlink != 0 { + t.Fatalf("dev mode installed a symlink, want a regular shim file") + } + data, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + shim := string(data) + for _, want := range []string{ + devShimMarker, // recognisable as our shim + "go build", // (a) rebuilds from source tip + "exec ", // (b) execs the fresh binary + "refusing to run", // (c) loud-fail message + "exit 1", // (c) non-zero exit on build failure + pluginRoot, // (d) baked absolute source-repo path + } { + if !strings.Contains(shim, want) { + t.Errorf("shim missing %q:\n%s", want, shim) + } + } + // The loud-fail branch must precede the exec so a broken build never falls + // through to a stale binary. + if strings.Index(shim, "exit 1") > strings.LastIndex(shim, "exec ") { + t.Errorf("exec precedes the loud-fail exit; a broken build could exec a stale binary:\n%s", shim) + } + + // Detection surfaces the mode honestly from the installed shim — and the + // tracked repo config is never touched with an install section. + det, err := Detect(repo) + if err != nil { + t.Fatal(err) + } + if m, _ := det.Signals["install_mode"].(string); m != "dev (tip build)" { + t.Errorf("install_mode signal = %q, want %q", m, "dev (tip build)") + } + if configHasInstallSection(t, repo) { + t.Errorf("dev install wrote an install section into tracked config.json; want none") + } + // The dev shim is a valid install, never flagged as a foreign occupant. + if hasGap(det.Gaps, "symlink.foreign") { + t.Errorf("dev shim wrongly flagged symlink.foreign: %+v", det.Gaps) + } +} + +// TestNormalInstallRegressionPin is coverage (b): a plain normal install still +// creates the pinned-binary symlink and records NO install section — byte-for-byte +// the pre-dev-mode behaviour. +func TestNormalInstallRegressionPin(t *testing.T) { + _, pluginRoot := setupHermetic(t) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := Install(repo, installOpts(), RefusingPrompter{}); err != nil { + t.Fatal(err) + } + target := binTarget() + fi, err := os.Lstat(target) + if err != nil { + t.Fatalf("PATH target not created: %v", err) + } + if fi.Mode()&os.ModeSymlink == 0 { + t.Fatalf("normal install did not create a symlink") + } + dest, _ := os.Readlink(target) + if resolveSymlinkDest(target, dest) != resolvePath(pluginBinaryPath(pluginRoot)) { + t.Errorf("symlink dest = %q, want %q", dest, pluginBinaryPath(pluginRoot)) + } + if configHasInstallSection(t, repo) { + t.Errorf("normal install wrote an install section; want none (regression)") + } + det, err := Detect(repo) + if err != nil { + t.Fatal(err) + } + if m, _ := det.Signals["install_mode"].(string); m != "pinned" { + t.Errorf("install_mode signal = %q, want pinned", m) + } +} + +// TestInstallPinnedToDevTransition is coverage (c), one direction: a normal +// install followed by --dev applies-as-update, replacing the symlink with the +// shim and echoing the mode change. +func TestInstallPinnedToDevTransition(t *testing.T) { + setupHermetic(t) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := Install(repo, installOpts(), RefusingPrompter{}); err != nil { + t.Fatal(err) + } + res, err := Install(repo, devInstallOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if res.Status != "clean" { + t.Fatalf("transition status = %q, want clean", res.Status) + } + if !containsChange(res.Changes, "install_mode: pinned -> dev") { + t.Errorf("changes = %v, want an install_mode pinned -> dev echo", res.Changes) + } + fi, err := os.Lstat(binTarget()) + if err != nil { + t.Fatal(err) + } + if fi.Mode()&os.ModeSymlink != 0 { + t.Errorf("transition left a symlink, want the dev shim") + } + if got := detectInstallModeSignal(t, repo); got != "dev (tip build)" { + t.Errorf("install_mode signal = %q, want %q", got, "dev (tip build)") + } + // Idempotent: a second --dev install is a no-op. + res2, err := Install(repo, devInstallOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if res2.Status != "already_up_to_date" { + t.Errorf("re-dev-install status = %q, want already_up_to_date", res2.Status) + } +} + +// TestInstallDevToPinnedTransition is coverage (c), the other direction: a --dev +// install followed by a plain install applies-as-update, restoring the symlink. +func TestInstallDevToPinnedTransition(t *testing.T) { + _, pluginRoot := setupHermetic(t) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := Install(repo, devInstallOpts(), RefusingPrompter{}); err != nil { + t.Fatal(err) + } + res, err := Install(repo, installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if res.Status != "clean" { + t.Fatalf("transition status = %q, want clean", res.Status) + } + if !containsChange(res.Changes, "install_mode: dev -> pinned") { + t.Errorf("changes = %v, want an install_mode dev -> pinned echo", res.Changes) + } + fi, err := os.Lstat(binTarget()) + if err != nil { + t.Fatal(err) + } + if fi.Mode()&os.ModeSymlink == 0 { + t.Errorf("transition did not restore the symlink") + } + dest, _ := os.Readlink(binTarget()) + if resolveSymlinkDest(binTarget(), dest) != resolvePath(pluginBinaryPath(pluginRoot)) { + t.Errorf("restored symlink dest = %q, want %q", dest, pluginBinaryPath(pluginRoot)) + } + if got := detectInstallModeSignal(t, repo); got != "pinned" { + t.Errorf("install_mode signal = %q, want pinned", got) + } +} + +// TestUninstallRemovesDevShim pins that uninstall recognises and removes the +// dev shim (it is ours), not just the pinned symlink. +func TestUninstallRemovesDevShim(t *testing.T) { + setupHermetic(t) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := Install(repo, devInstallOpts(), RefusingPrompter{}); err != nil { + t.Fatal(err) + } + receipt, err := Uninstall(repo) + if err != nil { + t.Fatal(err) + } + if !receipt.Symlink.Removed { + t.Errorf("uninstall did not remove the dev shim: %+v", receipt.Symlink) + } + if _, err := os.Lstat(binTarget()); !os.IsNotExist(err) { + t.Errorf("dev shim still present after uninstall: %v", err) + } +} + +func containsChange(changes []string, want string) bool { + for _, c := range changes { + if c == want { + return true + } + } + return false +} diff --git a/internal/core/ahoy/dev_shim_test.go b/internal/core/ahoy/dev_shim_test.go new file mode 100644 index 0000000..dc9a084 --- /dev/null +++ b/internal/core/ahoy/dev_shim_test.go @@ -0,0 +1,93 @@ +package ahoy + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// writeExecutable writes body to path with the executable bit set. +func writeExecutable(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + t.Fatal(err) + } +} + +// TestDevShimFailsLoudlyOnBuildFailure is coverage (d): it exercises the +// production shim content against a fake `go` that fails the build, and asserts +// the shim exits non-zero with a loud message and NEVER execs the stale binary. +func TestDevShimFailsLoudlyOnBuildFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shim; no /bin/sh on windows") + } + repo := t.TempDir() + binDir := t.TempDir() + freshBin := filepath.Join(binDir, "abcd-fresh") + // A stale binary that shouts if it is ever exec'd. + writeExecutable(t, freshBin, "#!/bin/sh\necho STALE-EXEC\n") + + // A fake `go` on PATH that fails every build (exit 3). + fakeGoDir := t.TempDir() + writeExecutable(t, filepath.Join(fakeGoDir, "go"), "#!/bin/sh\nexit 3\n") + + shimPath := filepath.Join(binDir, "abcd-shim") + writeExecutable(t, shimPath, renderDevShim(repo, freshBin)) + + cmd := exec.Command(shimPath, "version") + cmd.Env = append(os.Environ(), "PATH="+fakeGoDir+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + + if err == nil { + t.Fatalf("shim exited 0 on a failed build; want non-zero") + } + if ec, ok := err.(*exec.ExitError); ok && ec.ExitCode() == 0 { + t.Fatalf("shim exit code 0; want non-zero") + } + if !strings.Contains(stderr.String(), "build failed") { + t.Errorf("stderr missing the loud build-failure message: %q", stderr.String()) + } + if strings.Contains(stdout.String(), "STALE-EXEC") { + t.Errorf("shim exec'd the stale binary after a failed build (loud-staging violated)") + } +} + +// TestDevShimExecsFreshBinaryOnBuildSuccess proves the success path: when the +// build succeeds the shim execs the (freshly built) binary and passes args +// through. +func TestDevShimExecsFreshBinaryOnBuildSuccess(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shim; no /bin/sh on windows") + } + repo := t.TempDir() + binDir := t.TempDir() + freshBin := filepath.Join(binDir, "abcd-fresh") + // The "built" binary echoes a sentinel plus its args. + writeExecutable(t, freshBin, "#!/bin/sh\necho FRESH-EXEC:\"$@\"\n") + + // A fake `go` that "succeeds" without touching the pre-built binary. + fakeGoDir := t.TempDir() + writeExecutable(t, filepath.Join(fakeGoDir, "go"), "#!/bin/sh\nexit 0\n") + + shimPath := filepath.Join(binDir, "abcd-shim") + writeExecutable(t, shimPath, renderDevShim(repo, freshBin)) + + cmd := exec.Command(shimPath, "version", "--json") + cmd.Env = append(os.Environ(), "PATH="+fakeGoDir+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("shim failed on a successful build: %v (stderr=%q)", err, stderr.String()) + } + if !strings.Contains(stdout.String(), "FRESH-EXEC:version --json") { + t.Errorf("shim did not exec the fresh binary with args: %q", stdout.String()) + } +} diff --git a/internal/core/ahoy/store.go b/internal/core/ahoy/store.go index a215b21..449290c 100644 --- a/internal/core/ahoy/store.go +++ b/internal/core/ahoy/store.go @@ -1,6 +1,7 @@ package ahoy import ( + "bytes" "encoding/json" "errors" "os" @@ -123,6 +124,94 @@ func binTarget() string { return "/usr/local/bin/abcd" } +// --------------------------------------------------------------------------- +// dev-mode shim (abcd ahoy install --dev) +// --------------------------------------------------------------------------- + +// devShimMarker is the identifying first-comment line of the dev shim. Detection +// recognises the shim by this marker (never mistaking it for a foreign occupant), +// and uninstall removes only a file that carries it. +const devShimMarker = "# abcd-dev-shim" + +// shSingleQuote wraps s for safe interpolation inside single quotes in a POSIX +// shell, so a source-repo path containing a quote cannot break out of the string. +func shSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// renderDevShim returns the POSIX-sh track-latest shim body. On every call it +// rebuilds abcd from sourceRepo's tip into freshBin and execs it; a failed build +// fails loudly and NEVER execs a stale binary (loud-staging). The absolute paths +// live only inside this installed file (outside any repo), never in a committed +// artefact. +func renderDevShim(sourceRepo, freshBin string) string { + return "#!/bin/sh\n" + + devShimMarker + " (managed by `abcd ahoy install --dev`)\n" + + "# Rebuilds abcd from the source tip on every call, then execs the fresh\n" + + "# binary. A broken build fails loudly and never execs a stale binary.\n" + + "# Do not edit; run `abcd ahoy install` to pin a built binary instead.\n" + + "ABCD_DEV_REPO=" + shSingleQuote(sourceRepo) + "\n" + + "ABCD_DEV_BIN=" + shSingleQuote(freshBin) + "\n" + + "if ! go build -C \"$ABCD_DEV_REPO\" -o \"$ABCD_DEV_BIN\" ./cmd/abcd; then\n" + + "\tprintf 'abcd dev shim: build failed in %s — refusing to run a stale binary\\n' \"$ABCD_DEV_REPO\" >&2\n" + + "\texit 1\n" + + "fi\n" + + "exec \"$ABCD_DEV_BIN\" \"$@\"\n" +} + +// devShimPrefix is the exact opening of a shim renderDevShim produced: the +// shebang followed immediately by the marker line. Recognition requires this +// prefix — not a substring match anywhere in the file — so a foreign file that +// merely mentions the marker in its body is never mistaken for ours. +var devShimPrefix = []byte("#!/bin/sh\n" + devShimMarker) + +// isDevShimFile reports whether path is a regular file we wrote as the dev shim. +// Uses the same guarded read as the rest of ahoy, so a symlink leaf or an +// oversized/irregular file reads as "not our shim" rather than being followed. +func isDevShimFile(path string) bool { + data, err := fsutil.ReadGuarded(path, maxAhoyFileBytes) + if err != nil { + return false + } + return bytes.HasPrefix(data, devShimPrefix) +} + +// binTargetKind classifies what currently occupies the PATH target. +type binTargetKind int + +const ( + binTargetAbsent binTargetKind = iota // nothing there + binTargetOwnedSymlink // our symlink -> the pinned binary + binTargetDevShim // our dev-mode rebuild-then-exec shim + binTargetForeign // something else; never clobber it +) + +// classifyBinTarget inspects the PATH target and reports whether it is absent, +// our owned pinned symlink, our dev shim, or a foreign occupant. +func classifyBinTarget(target, pluginRoot string) binTargetKind { + fi, err := os.Lstat(target) + if err != nil { + if os.IsNotExist(err) { + return binTargetAbsent + } + return binTargetForeign // present but unstattable: treat as foreign, never clobber + } + if fi.Mode()&os.ModeSymlink != 0 { + dest, err := os.Readlink(target) + if err != nil { + return binTargetForeign + } + if resolveSymlinkDest(target, dest) == resolvePath(pluginBinaryPath(pluginRoot)) { + return binTargetOwnedSymlink + } + return binTargetForeign + } + if isDevShimFile(target) { + return binTargetDevShim + } + return binTargetForeign +} + func isDir(p string) bool { fi, err := os.Stat(p) return err == nil && fi.IsDir() diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 9f69f03..1a92f79 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -1427,6 +1427,9 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { fmt.Fprintf(w, "abcd ahoy — %s\n", res.FolderKind) fmt.Fprintf(w, " plugin root: %s\n", res.PluginRootStatus) fmt.Fprintf(w, " root sha: %s\n", res.RootSHA) + if mode, _ := res.Signals["install_mode"].(string); mode != "" { + fmt.Fprintf(w, " install: %s\n", mode) + } fmt.Fprintf(w, " gaps: %d\n", len(res.Gaps)) // Classification is read-only; the human report names the // next step per folder kind (itd-40 AC2/AC3). @@ -1445,6 +1448,7 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { yes bool adopt bool refuseAdopt bool + dev bool visibility string docsTarget string oracleBackend string @@ -1459,7 +1463,7 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { if err != nil { return err } - opts, err := installOptionsFromFlags(cmd, yes, adopt, refuseAdopt, visibility, docsTarget, oracleBackend, scanDeep) + opts, err := installOptionsFromFlags(cmd, yes, adopt, refuseAdopt, dev, visibility, docsTarget, oracleBackend, scanDeep) if err != nil { return err } @@ -1487,6 +1491,7 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { installCmd.Flags().BoolVar(&yes, "yes", false, "approve every resolvable change category without prompting") installCmd.Flags().BoolVar(&adopt, "adopt", false, "adopt an unmanaged repo without prompting") installCmd.Flags().BoolVar(&refuseAdopt, "refuse-adopt", false, "decline to adopt an unmanaged repo") + installCmd.Flags().BoolVar(&dev, "dev", false, "track-latest dogfood mode: the PATH entry rebuilds from the source tip on every call instead of pinning the built binary") installCmd.Flags().StringVar(&visibility, "visibility", "", "repo visibility: private | public") installCmd.Flags().StringVar(&docsTarget, "docs-target", "", "marker target: claude_md | agents_md | both | skip") installCmd.Flags().StringVar(&oracleBackend, "oracle-backend", "", "oracle backend: host-delegated | native | cli | api | mcp") @@ -1590,8 +1595,8 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { // installOptionsFromFlags validates the install flags and builds InstallOptions. // Only explicitly-set value flags become overrides; unset values fall through to // the prompter (interactive) or its default (non-interactive). -func installOptionsFromFlags(cmd *cobra.Command, yes, adopt, refuseAdopt bool, visibility, docsTarget, oracleBackend, scanDeep string) (ahoy.InstallOptions, error) { - opts := ahoy.InstallOptions{Yes: yes} +func installOptionsFromFlags(cmd *cobra.Command, yes, adopt, refuseAdopt, dev bool, visibility, docsTarget, oracleBackend, scanDeep string) (ahoy.InstallOptions, error) { + opts := ahoy.InstallOptions{Yes: yes, Dev: dev} if adopt && refuseAdopt { return opts, fmt.Errorf("abcd ahoy install: --adopt and --refuse-adopt are mutually exclusive") } From a744cf39f66ba9acadee8ee55849bc687ca7827f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:04:33 +0100 Subject: [PATCH 2/3] chore: resolve iss-75 in the ledger The track-latest dev install mode (abcd ahoy install --dev) settles the open fork: build the mode, not document the wrapper. Assisted-by: Claude:claude-opus-4-8 --- .../iss-75-dogfood-always-latest-install-mode.md | 2 ++ 1 file changed, 2 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-75-dogfood-always-latest-install-mode.md (73%) diff --git a/.abcd/work/issues/open/iss-75-dogfood-always-latest-install-mode.md b/.abcd/work/issues/resolved/iss-75-dogfood-always-latest-install-mode.md similarity index 73% rename from .abcd/work/issues/open/iss-75-dogfood-always-latest-install-mode.md rename to .abcd/work/issues/resolved/iss-75-dogfood-always-latest-install-mode.md index 5e681e3..552ab59 100644 --- a/.abcd/work/issues/open/iss-75-dogfood-always-latest-install-mode.md +++ b/.abcd/work/issues/resolved/iss-75-dogfood-always-latest-install-mode.md @@ -6,6 +6,8 @@ severity: "minor" category: "observation" source: "user-observation" found_during: "abcd-plugin-dogfooding" +resolution: "Built abcd ahoy install --dev: a track-latest shim rebuilds from source tip and execs the fresh binary, failing loudly on a broken build, replacing the hand-rolled wrapper. Mode surfaced by status from the installed shim; transitions apply-as-update both directions." +impact: additive --- abcd lacks an always-latest / dev install mode for dogfooding: 'abcd ahoy install' symlinks a pinned built binary and there is no plain bin/abcd, so tracking live development required a hand-rolled ~/.local/bin/abcd wrapper that runs 'go build -C && exec' on each call (rebuild-from-source, fail loudly on a broken build). This manual workaround means abcd's own install path does not cover the dogfooding case. Consider 'abcd ahoy install --dev' / a track-latest mode, or bless+document the wrapper as the sanctioned dogfood path. Recorded per the golden rule: a manual workaround must be captured, never silently bypassed, so abcd can be fixed. \ No newline at end of file From f613fc594b2f070f736d6b02f48f169dc3bb0349 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:15:25 +0100 Subject: [PATCH 3/3] fix: honour declined ConfigChange approval on a fresh --dev install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit modeWouldChange's --dev branch returned true for an absent PATH target (kind != binTargetDevShim), setting modeForced on a fresh install. stepSymlink then wrote the machine-scope shim even when the user declined the ConfigChange (PATH) approval — a consent bypass a plain install does not have. Narrow the --dev branch to the genuine switch (kind == binTargetOwnedSymlink): a fresh --dev install now flows through the symlink.missing gap, whose gapDriven path respects a declined ConfigChange. Transitions (owned symlink -> shim) stay forced and idempotence (dev shim -> already_up_to_date) is unchanged. The mirror non-dev branch already returns false for an absent target, so it has no equivalent hole and is left as-is. Assisted-by: Claude:claude-opus-4-8 --- internal/core/ahoy/apply.go | 6 +++++- internal/core/ahoy/dev_install_test.go | 29 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index fc6653c..07a384a 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -686,7 +686,11 @@ func modeWouldChange(opts InstallOptions, det DetectionResult) bool { return false } if opts.Dev { - return kind != binTargetDevShim + // Only a switch away from the pinned symlink is a forced change. A missing + // entry is NOT forced: it flows through the symlink.missing gap so a declined + // ConfigChange approval is honoured (a fresh --dev must not bypass consent the + // way a plain install cannot). An existing dev shim is already correct. + return kind == binTargetOwnedSymlink } // Plain install: only a switch away from an existing dev shim is a forced // change; a missing entry is handled by the symlink.missing gap, and an owned diff --git a/internal/core/ahoy/dev_install_test.go b/internal/core/ahoy/dev_install_test.go index 66933a6..e5fd48a 100644 --- a/internal/core/ahoy/dev_install_test.go +++ b/internal/core/ahoy/dev_install_test.go @@ -247,6 +247,35 @@ func TestUninstallRemovesDevShim(t *testing.T) { } } +// TestDevInstallRespectsDeclinedConfigChange pins the consent boundary: the PATH +// entry is a ConfigChange, so a fresh --dev install must NOT write the shim when +// that category is declined — exactly as a plain install honours the decline. +func TestDevInstallRespectsDeclinedConfigChange(t *testing.T) { + setupHermetic(t) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + adopt := true + opts := InstallOptions{ + Adopt: &adopt, + Dev: true, + // Approve every resolvable category EXCEPT ConfigChange (the PATH entry). + ApprovedCategories: map[GapCategory]bool{ + SafeAutocreate: true, + PluginOwned: true, + UserState: true, + Dependency: true, + }, + } + if _, err := Install(repo, opts, RefusingPrompter{}); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(binTarget()); !os.IsNotExist(err) { + t.Errorf("--dev wrote the PATH shim despite a declined ConfigChange approval (consent bypass): %v", err) + } +} + func containsChange(changes []string, want string) bool { for _, c := range changes { if c == want {