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/.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 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..07a384a 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,100 @@ 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 { + // 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 + // 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 +765,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 +843,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..e5fd48a --- /dev/null +++ b/internal/core/ahoy/dev_install_test.go @@ -0,0 +1,286 @@ +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) + } +} + +// 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 { + 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") }