Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion internal/cli/frontdoor.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,10 @@ func realpath(path string) string {
// would otherwise slip through as "compatible". gateHost prints the actionable
// remedy for every non-Compatible verdict EXCEPT NoPluginFound, whose message the
// caller owns (it auto-installs by default and only refuses under --no-install,
// so the right wording depends on the caller's choice).
// so the right wording depends on the caller's choice). On Compatible it stays
// silent on the bare OK line but surfaces the opt-in upgrade hint when the plugin
// is contract-compatible yet behind a strictly-newer binary; the hint never
// blocks — the caller still proceeds to launch.
func gateHost(ops hostOps, host string, stderr io.Writer) contract.Verdict {
manifestPath, err := ops.ResolveManifest(host)
if err != nil {
Expand All @@ -235,6 +238,13 @@ func gateHost(ops hostOps, host string, stderr io.Writer) contract.Verdict {
}
if res.Verdict != contract.Compatible {
fmt.Fprintln(stderr, res.Message)
return res.Verdict
}
// Compatible but behind: surface the opt-in upgrade hint (the front door
// stays silent on the bare OK line). The hint never blocks — the caller still
// proceeds to launch on Compatible.
if res.Hint != "" {
fmt.Fprintln(stderr, res.Hint)
}
return res.Verdict
}
Expand Down
76 changes: 76 additions & 0 deletions internal/cli/frontdoor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ func withExecutablePath(t *testing.T, path string, err error) {
t.Cleanup(func() { executablePath = orig })
}

// withVersion stamps the package Version (the binary display semver the gate
// feeds to the upgrade-hint compare), restoring it after the test. The package
// default is the `dev` sentinel, which suppresses the hint, so a test that
// exercises the behind-plugin hint must stamp a real semver.
func withVersion(t *testing.T, v string) {
t.Helper()
orig := Version
Version = v
t.Cleanup(func() { Version = orig })
}

func executableFixture(t *testing.T) string {
t.Helper()
p := filepath.Join(t.TempDir(), "spacedock")
Expand Down Expand Up @@ -111,6 +122,71 @@ func TestClaudeFrontDoorLaunchesOnCompatible(t *testing.T) {
}
}

// TestFrontDoorUpgradeHintOnBehindPlugin is AC-4: the front-door gate prints the
// opt-in upgrade hint to stderr when the resolved plugin is contract-compatible
// but behind the binary, then proceeds to launch (the hint never blocks). The
// compatible.json fixture is plugin 0.12.1; stamping the binary Version to a
// strictly-newer semver makes it behind-but-compatible. A paired equal-version
// case (binary == plugin) asserts the gate stays silent. Both arms observe the
// recorded launch + stderr, never a source grep.
func TestFrontDoorUpgradeHintOnBehindPlugin(t *testing.T) {
cases := []struct {
name string
host string
run func(args []string, dir string, fake *fakeHost, stderr *bytes.Buffer) int
}{
{"claude", "claude", func(args []string, dir string, fake *fakeHost, stderr *bytes.Buffer) int {
var stdout bytes.Buffer
return runClaude(context.Background(), args, dir, fake, lookFound, &stdout, stderr)
}},
{"codex", "codex", func(args []string, dir string, fake *fakeHost, stderr *bytes.Buffer) int {
var stdout bytes.Buffer
return runCodex(context.Background(), args, dir, fake, lookFound, &stdout, stderr)
}},
}
for _, tc := range cases {
t.Run(tc.name+" behind-plugin hints + launches", func(t *testing.T) {
withVersion(t, "0.20.0") // strictly newer than the fixture plugin 0.12.1
fake := &fakeHost{manifest: compatibleManifest(t)}
var stderr bytes.Buffer

code := tc.run(nil, t.TempDir(), fake, &stderr)

if code != 0 {
t.Fatalf("exit = %d, want 0 (the hint must not block launch) (stderr=%q)", code, stderr.String())
}
if fake.launchedArg == nil {
t.Fatalf("launch seam not invoked — the hint must not change the launch path")
}
out := stderr.String()
if !strings.Contains(out, "newer plugin") {
t.Fatalf("stderr missing the behind-plugin upgrade hint: %q", out)
}
if !strings.Contains(out, "spacedock install --host "+tc.host) {
t.Fatalf("stderr hint missing host install command for %s: %q", tc.host, out)
}
})

t.Run(tc.name+" equal-version stays silent", func(t *testing.T) {
withVersion(t, "0.12.1") // exactly the fixture plugin version
fake := &fakeHost{manifest: compatibleManifest(t)}
var stderr bytes.Buffer

code := tc.run(nil, t.TempDir(), fake, &stderr)

if code != 0 {
t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String())
}
if fake.launchedArg == nil {
t.Fatalf("launch seam not invoked on an equal-version compatible plugin")
}
if strings.Contains(stderr.String(), "newer plugin") || strings.Contains(stderr.String(), "spacedock install") {
t.Fatalf("equal-version gate must stay silent (no upgrade hint): %q", stderr.String())
}
})
}
}

// TestClaudeFrontDoorFailFastOnMismatch: on a mismatch verdict the launch seam is
// NOT invoked and the process exits non-zero with the pinned remedy on stderr.
func TestClaudeFrontDoorInjectsResolvedLauncherBin(t *testing.T) {
Expand Down
19 changes: 15 additions & 4 deletions internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,22 @@ func runInit(ctx context.Context, args []string, ops hostOps, stdout, stderr io.
fmt.Fprintf(stderr, "spacedock init: could not resolve the installed codex plugin: %v\n", err)
return 1
}
if resolved != "" || check {
code := contract.RunDoctor(resolved, "codex", devBranch, Version, stdout, stderr)
if code != 0 || resolved != "" || check {
return code
if check {
return contract.RunDoctor(resolved, "codex", devBranch, Version, stdout, stderr)
}
if resolved != "" {
// An already-present plugin is refreshed on `install` like the claude
// arm — drive the install seam, then run doctor. Without this the codex
// arm was a doctor-only no-op that left a behind plugin in place.
out, err := ops.Install("codex", marketplaceSource, devBranch)
if err != nil {
fmt.Fprintf(stderr, "spacedock install: host install failed: %v\n", err)
return 1
}
if out != "" {
fmt.Fprintln(stdout, out)
}
return runDoctor(ctx, []string{"--host", "codex"}, ops, stdout, stderr)
}

// Codex install is documented prose when no installed plugin resolves: the
Expand Down
35 changes: 30 additions & 5 deletions internal/cli/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ func TestInitCheckRunsDoctorWithoutInstalling(t *testing.T) {
}

func TestInitCodexInstallReadiness(t *testing.T) {
// compatible-installed: `spacedock install --host codex` re-installs an
// already-present compatible plugin instead of short-circuiting to a
// doctor-only no-op. The codex arm must drive the install seam exactly like
// the claude arm — the recorded {host, source, branch} call is the
// independent source of truth that the refresh actually fired, not the
// no-op the prior assertion codified.
t.Run("compatible-installed", func(t *testing.T) {
fake := &fakeHost{manifest: compatibleManifest(t)}
var stdout, stderr bytes.Buffer
Expand All @@ -111,14 +117,33 @@ func TestInitCodexInstallReadiness(t *testing.T) {
if code != 0 {
t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String())
}
wantInstall := []string{"codex", marketplaceSource, devBranch}
if !equalArgv(fake.installCmds, wantInstall) {
t.Fatalf("install seam = %v, want %v — codex init on a present plugin must refresh, not no-op", fake.installCmds, wantInstall)
}
// After install, init runs doctor — a compatible report on stdout.
out := stdout.String()
if !strings.Contains(out, "OK: spacedock binary "+Version+" and plugin 0.12.1") {
t.Fatalf("codex init should report compatible installed plugin first; stdout = %q", out)
t.Fatalf("codex init should run doctor after install and report compatible; stdout = %q", out)
}
for _, banned := range []string{"codex plugin marketplace add", "codex plugin add spacedock@spacedock"} {
if strings.Contains(out, banned) {
t.Errorf("compatible codex init must not print manual add command %q:\n%s", banned, out)
}
})

// compatible-installed-check: `--check` keeps the no-install report — it runs
// doctor without driving the install seam.
t.Run("compatible-installed-check", func(t *testing.T) {
fake := &fakeHost{manifest: compatibleManifest(t)}
var stdout, stderr bytes.Buffer

code := runInit(context.Background(), []string{"--host", "codex", "--check"}, fake, &stdout, &stderr)

if code != 0 {
t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String())
}
if len(fake.installCmds) != 0 {
t.Fatalf("--check must not install: %v", fake.installCmds)
}
if !strings.Contains(stdout.String(), "OK") {
t.Fatalf("--check should print the doctor report; stdout = %q", stdout.String())
}
})

Expand Down
75 changes: 74 additions & 1 deletion internal/cli/install_behavior_codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,84 @@ func TestCodexPluginInstallIsHostNative(t *testing.T) {
}
}

// TestCodexInitRefreshAdvancesBehindPlugin is the AC-2 live proof for the wired
// codex arm: seed an older plugin (0.0.1) via the production Install seam, then
// run that same seam against a newer (0.0.2) local marketplace — the path
// runInit's codex arm now drives on a present plugin. The resolved cache
// manifest must advance to 0.0.2, read from the on-disk manifest (not the
// install command's claim). This pins the spike's finding as a regression test
// on the production refresh path. Skips when `codex` is not on PATH; hermetic
// via CODEX_HOME isolation + local-path marketplaces (empty branch → no --ref →
// offline).
func TestCodexInitRefreshAdvancesBehindPlugin(t *testing.T) {
if _, err := exec.LookPath("codex"); err != nil {
t.Skip("codex not on PATH; refresh-advances smoke requires the host CLI")
}

tmp := t.TempDir()
behind := buildCodexMarketplaceAtVersion(t, filepath.Join(tmp, "behind"), "0.0.1")
newer := buildCodexMarketplaceAtVersion(t, filepath.Join(tmp, "newer"), "0.0.2")
codexHomeDir := filepath.Join(tmp, "codexhome")
mustMkdir(t, codexHomeDir)
t.Setenv("CODEX_HOME", codexHomeDir)

// Seed the behind install (0.0.1) through the production seam.
if out, err := (execHost{}).Install("codex", behind, ""); err != nil {
t.Fatalf("seed Install(codex, 0.0.1) failed: %v\nout=%q", err, out)
}
if got := resolvedCodexManifestVersion(t); got != "0.0.1" {
t.Fatalf("after seed, resolved manifest version = %q, want 0.0.1", got)
}

// Refresh-on-present (0.0.2) — the wired runInit codex arm calls exactly this
// Install seam when a plugin is already resolved.
if out, err := (execHost{}).Install("codex", newer, ""); err != nil {
t.Fatalf("refresh Install(codex, 0.0.2) failed: %v\nout=%q", err, out)
}
if got := resolvedCodexManifestVersion(t); got != "0.0.2" {
t.Fatalf("after refresh, resolved manifest version = %q, want 0.0.2 (refresh did not advance the behind plugin)", got)
}
}

// resolvedCodexManifestVersion resolves the cached spacedock@spacedock manifest
// via the production resolver and returns its on-disk version field — the
// independent source of truth that an install advanced the plugin, not the
// command's stdout.
func resolvedCodexManifestVersion(t *testing.T) string {
t.Helper()
manifest, err := execHost{}.resolveCodexManifest()
if err != nil {
t.Fatalf("resolveCodexManifest: %v", err)
}
if manifest == "" {
t.Fatalf("resolveCodexManifest returned empty after an install")
}
data, err := os.ReadFile(manifest)
if err != nil {
t.Fatalf("read resolved manifest %s: %v", manifest, err)
}
var m struct {
Version string `json:"version"`
}
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("parse resolved manifest %s: %v", manifest, err)
}
return m.Version
}

// buildLocalCodexMarketplace writes a minimal valid local-path marketplace under
// root and returns the marketplace directory. Codex reads the marketplace
// manifest from .claude-plugin/marketplace.json (it reuses the claude manifest
// layout) and the plugin manifest from the plugin's .codex-plugin/plugin.json.
// The plugin manifest carries a requires-contract bracketing CONTRACT_VERSION.
func buildLocalCodexMarketplace(t *testing.T, root string) string {
return buildCodexMarketplaceAtVersion(t, root, "0.0.0")
}

// buildCodexMarketplaceAtVersion is buildLocalCodexMarketplace parameterized by
// the plugin's display version, so a test can seed a behind plugin then refresh
// it from a newer marketplace and observe the resolved version advance.
func buildCodexMarketplaceAtVersion(t *testing.T, root, version string) string {
t.Helper()
marketplace := filepath.Join(root, "marketplace")
plugin := filepath.Join(marketplace, "spacedock")
Expand All @@ -98,7 +170,8 @@ func buildLocalCodexMarketplace(t *testing.T, root string) string {
]
}
`)
mustWrite(t, filepath.Join(plugin, ".codex-plugin", "plugin.json"), `{ "name": "spacedock", "version": "0.0.0", "requires-contract": ">=1,<2", "skills": "./skills/" }
mustWrite(t, filepath.Join(plugin, ".codex-plugin", "plugin.json"),
`{ "name": "spacedock", "version": "`+version+`", "requires-contract": ">=1,<2", "skills": "./skills/" }
`)
mustWrite(t, filepath.Join(plugin, "skills", "demo", "SKILL.md"), "---\nname: demo\ndescription: demo skill\n---\ndemo\n")
return marketplace
Expand Down
81 changes: 79 additions & 2 deletions internal/contract/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,14 @@ func (v Verdict) String() string {

// Result carries a comparison's verdict and the operator-facing message. For
// Compatible the message is a one-line "OK" report; for every mismatch it is the
// shared-shape actionable message with the per-class remedy.
// shared-shape actionable message with the per-class remedy. Hint is the opt-in
// upgrade hint for a compatible-but-behind plugin — empty unless the binary is a
// strictly-newer semver than the plugin. Doctor folds it into Message; the front
// door surfaces Hint alone (it stays silent on the bare OK line).
type Result struct {
Verdict Verdict
Message string
Hint string
}

// ParseRange parses a requires-contract value of the form ">=N,<M" into its
Expand Down Expand Up @@ -145,8 +149,81 @@ func compareWithManifest(c int, raw, host, branch, manifestPath, pluginVersion,
case c >= hi:
return Result{Verdict: TooOldPlugin, Message: mismatchMessage(binaryVersion, pluginVersion, "Update the plugin to continue.", tooOldPluginRemedy(host))}
default:
return Result{Verdict: Compatible, Message: fmt.Sprintf("OK: spacedock binary %s and plugin %s are compatible.", binaryVersion, pluginVersion)}
msg := fmt.Sprintf("OK: spacedock binary %s and plugin %s are compatible.", binaryVersion, pluginVersion)
hint := upgradeHint(host, pluginVersion, binaryVersion)
if hint != "" {
msg += "\n" + hint
}
return Result{Verdict: Compatible, Message: msg, Hint: hint}
}
}

// upgradeHint returns the opt-in upgrade hint appended to a Compatible message
// when the binary's display semver is strictly newer than the plugin's — a
// plugin that still works (the contract is compatible) but is behind. It returns
// "" (no hint) unless BOTH versions are valid dotted-int semver and the binary
// is strictly greater: an unstamped `dev` binary, a non-semver, or an
// equal/older binary emits nothing, so the gate never fires a false "you must
// upgrade". The hint names the host-specific refresh command.
func upgradeHint(host, pluginVersion, binaryVersion string) string {
if semverCompare(binaryVersion, pluginVersion) <= 0 {
return ""
}
return fmt.Sprintf(
"A newer plugin is available — run spacedock install --host %s to refresh.",
host)
}

// semverCompare orders two dotted-int versions (e.g. `0.20.0`), returning -1, 0,
// or 1. It returns 0 (treat as not-greater, so no hint) when EITHER side is not
// a valid dotted-int version — the defensive gate that keeps a non-semver (`dev`)
// or empty value from triggering the upgrade hint. Unlike the cli resolver's
// lexical fallback, a non-integer component here is a parse failure, not a
// lexical tiebreak: the hint must not fire on anything but a clean semver skew.
func semverCompare(a, b string) int {
an, aok := parseDottedInts(a)
bn, bok := parseDottedInts(b)
if !aok || !bok {
return 0
}
for i := 0; i < len(an) || i < len(bn); i++ {
var av, bv int
if i < len(an) {
av = an[i]
}
if i < len(bn) {
bv = bn[i]
}
if av != bv {
if av < bv {
return -1
}
return 1
}
}
return 0
}

// parseDottedInts splits a dotted version into its integer components, reporting
// ok=false when the string is empty or any component is not a non-negative
// integer. A pre-release/build suffix (e.g. `-rc1`) makes its component
// non-integer and so fails the parse — the conservative read for the upgrade
// hint, which only fires on a clean numeric skew.
func parseDottedInts(v string) ([]int, bool) {
v = strings.TrimSpace(v)
if v == "" {
return nil, false
}
parts := strings.Split(v, ".")
out := make([]int, len(parts))
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return nil, false
}
out[i] = n
}
return out, true
}

// mismatchMessage assembles the shared-shape mismatch message: a header naming
Expand Down
Loading
Loading