diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json deleted file mode 100644 index eb09298e2..000000000 --- a/.claude-plugin/marketplace.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "spacedock", - "owner": { - "name": "CL Kao" - }, - "plugins": [ - { - "name": "spacedock", - "source": { - "source": "url", - "url": "https://github.com/spacedock-dev/spacedock.git", - "ref": "main" - }, - "description": "Turn directories of markdown files into structured workflows operated by AI agents", - "version": "0.0.2026060901", - "category": "workflow" - } - ] -} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a0f44793c..e30c373da 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -34,10 +34,12 @@ builds: # const, for the linker to write it. {{.Version}} is goreleaser's # git-describe-derived version (the tag on a release, a snapshot string on # --snapshot). - # devBranch pins the marketplace @ref the released binary installs from. The - # STABLE channel binds to `main`: a stable release published on `main` must - # install the `main` plugin. The source default stays `next` (frontdoor.go) so - # a `go install …@next` / `--plugin-dir` dev build is unaffected; only this + # devBranch selects which marketplace ENTRY the released binary installs (the + # channel is the entry name, not an @ref into the plugin repo). The STABLE + # channel stamps `main`, which the binary maps to the `spacedock` entry + # (tag-pinned in the marketplace repo). The source default stays `next` + # (frontdoor.go), which maps to the `spacedock-edge` entry, so a `go install + # …@next` / `--plugin-dir` dev build resolves the edge channel; only this # released stable artifact carries the `main` stamp (var, not const, for -X to # write it). ldflags: @@ -51,9 +53,9 @@ builds: - CGO_ENABLED=0 goos: *channel-goos goarch: *channel-goarch - # The EDGE channel binds to `next`: the `spacedock@next` cask installs the - # `next` plugin. Same Version stamp as stable; only the devBranch ldflag - # differs (the sole per-channel knob). + # The EDGE channel stamps `next`, which the binary maps to the `spacedock-edge` + # marketplace entry (tracking next HEAD). Same Version stamp as stable; only the + # devBranch ldflag differs (the sole per-channel knob). ldflags: - -s -w - -X github.com/spacedock-dev/spacedock/internal/cli.Version={{ .Version }} diff --git a/internal/cli/channel_selection_test.go b/internal/cli/channel_selection_test.go new file mode 100644 index 000000000..7a48e3945 --- /dev/null +++ b/internal/cli/channel_selection_test.go @@ -0,0 +1,236 @@ +// ABOUTME: AC-3 channel-resolution seam — devBranch selects the marketplace ENTRY +// ABOUTME: NAME (spacedock vs spacedock-edge) from the marketplace repo, no @ref shorthand. +package cli + +import ( + "bytes" + "context" + "reflect" + "strings" + "testing" +) + +// TestChannelEntryFromDevBranch locks the channel-selection rule under Model B: +// the binary's devBranch stamp selects the marketplace ENTRY NAME, not a git ref +// pinned into the plugin repo. A stable binary (devBranch=main) resolves the +// `spacedock` entry; an edge binary (devBranch=next) resolves `spacedock-edge`. +// The tag pin lives in the marketplace manifest, so the channel is the entry, not +// an @branch shorthand on the install command. +func TestChannelEntryFromDevBranch(t *testing.T) { + cases := []struct { + channel string + devBranch string + wantEntry string + }{ + {channel: "stable", devBranch: "main", wantEntry: "spacedock"}, + {channel: "edge", devBranch: "next", wantEntry: "spacedock-edge"}, + } + for _, tc := range cases { + t.Run(tc.channel, func(t *testing.T) { + if got := channelEntry(tc.devBranch); got != tc.wantEntry { + t.Errorf("channelEntry(%q) = %q, want %q (the %s channel)", tc.devBranch, got, tc.wantEntry, tc.channel) + } + }) + } +} + +// TestClaudeChannelInstallArgvSequence is AC-3's claude half: with marketplaceSource +// repointed to the marketplace repo and devBranch set per channel, the issued +// claude install argv installs the channel-correct ENTRY id (`spacedock@spacedock` +// stable / `spacedock-edge@spacedock` edge) and the marketplace add carries the +// BARE marketplace-repo source — no `@` shorthand. The plugin id (`@spacedock` +// suffix) is the marketplace NAME; the entry before the `@` is the channel. +func TestClaudeChannelInstallArgvSequence(t *testing.T) { + cases := []struct { + channel string + devBranch string + wantID string + }{ + {channel: "stable", devBranch: "main", wantID: "spacedock@spacedock"}, + {channel: "edge", devBranch: "next", wantID: "spacedock-edge@spacedock"}, + } + for _, tc := range cases { + t.Run(tc.channel, func(t *testing.T) { + want := []installStep{ + {argv: []string{"plugin", "uninstall", tc.wantID}, tolerateExit: true}, + {argv: []string{"plugin", "marketplace", "remove", "spacedock"}, tolerateExit: true}, + {argv: []string{"plugin", "marketplace", "add", "spacedock-dev/marketplace"}}, + {argv: []string{"plugin", "install", tc.wantID}}, + } + got := installArgvSequence("spacedock-dev/marketplace", tc.devBranch) + if !reflect.DeepEqual(got, want) { + t.Errorf("installArgvSequence(%q) =\n%v\nwant\n%v", tc.devBranch, got, want) + } + // No @ shorthand leaked onto the marketplace add. + for _, step := range got { + if len(step.argv) >= 3 && step.argv[1] == "marketplace" && step.argv[2] == "add" { + if strings.Contains(step.argv[3], "@") { + t.Errorf("%s channel marketplace add %q carries an @ shorthand; the tag pin lives in the manifest", tc.channel, step.argv[3]) + } + } + } + }) + } +} + +// TestCodexChannelInstallArgvSequence is AC-3's codex half: the codex install argv +// adds the BARE marketplace-repo source (no `--ref`, since the channel is the entry +// name, not a branch ref) and adds the channel-correct entry id. +func TestCodexChannelInstallArgvSequence(t *testing.T) { + cases := []struct { + channel string + devBranch string + wantID string + }{ + {channel: "stable", devBranch: "main", wantID: "spacedock@spacedock"}, + {channel: "edge", devBranch: "next", wantID: "spacedock-edge@spacedock"}, + } + for _, tc := range cases { + t.Run(tc.channel, func(t *testing.T) { + want := []installStep{ + {argv: []string{"plugin", "remove", tc.wantID}, tolerateExit: true}, + {argv: []string{"plugin", "marketplace", "remove", "spacedock"}, tolerateExit: true}, + {argv: []string{"plugin", "marketplace", "add", "spacedock-dev/marketplace"}}, + {argv: []string{"plugin", "add", tc.wantID}}, + } + got := codexInstallArgvSequence("spacedock-dev/marketplace", tc.devBranch) + if !reflect.DeepEqual(got, want) { + t.Errorf("codexInstallArgvSequence(%q) =\n%v\nwant\n%v", tc.devBranch, got, want) + } + for _, step := range got { + for _, a := range step.argv { + if a == "--ref" { + t.Errorf("%s channel codex sequence carries a --ref token; the channel is the entry name, not a branch ref", tc.channel) + } + } + } + }) + } +} + +// TestClaudeNoPluginAutoInstallSelectsChannelEntry is AC-3's end-to-end seam: the +// claude front door, with devBranch set per channel, drives the no-plugin +// auto-install through the real runClaude path and the recorded install seam +// carries the marketplace-repo source + the devBranch the channel entry derives +// from. The entry id installed is confirmed via installArgvSequence on the +// observed seam values — so the observed values ARE the production install argv, +// never a constant grep. +func TestClaudeNoPluginAutoInstallSelectsChannelEntry(t *testing.T) { + saved := devBranch + defer func() { devBranch = saved }() + + cases := []struct { + channel string + devBranch string + wantID string + }{ + {channel: "stable", devBranch: "main", wantID: "spacedock@spacedock"}, + {channel: "edge", devBranch: "next", wantID: "spacedock-edge@spacedock"}, + } + for _, tc := range cases { + t.Run(tc.channel, func(t *testing.T) { + devBranch = tc.devBranch + + fake := &fakeHost{manifest: ""} // fresh HOME: no claude plugin installed + var stdout, stderr bytes.Buffer + code := runClaude(context.Background(), nil, t.TempDir(), fake, lookFound, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit = %d, want 0 (no plugin → auto-install + launch) (stderr=%q)", code, stderr.String()) + } + + if len(fake.installCmds) < 3 { + t.Fatalf("install seam recorded %v, want {host, source, devBranch}", fake.installCmds) + } + if got := fake.installCmds[0]; got != "claude" { + t.Fatalf("install host = %q, want claude", got) + } + if got := fake.installCmds[1]; got != marketplaceSource { + t.Fatalf("install source = %q, want %q (the marketplace repo)", got, marketplaceSource) + } + if got := fake.installCmds[2]; got != tc.devBranch { + t.Fatalf("%s channel install devBranch = %q, want %q", tc.channel, got, tc.devBranch) + } + + // The observed seam values reconstruct the production install argv: the + // channel-correct entry id must be the install target. + seq := installArgvSequence(fake.installCmds[1], fake.installCmds[2]) + if !sequenceInstallsID(seq, tc.wantID) { + t.Fatalf("%s channel install sequence does not install %q; steps=%v", tc.channel, tc.wantID, seq) + } + }) + } +} + +// sequenceInstallsID reports whether the claude install sequence's final pinning +// step installs the given plugin id (`plugin install `). +func sequenceInstallsID(steps []installStep, id string) bool { + for _, step := range steps { + if len(step.argv) == 3 && step.argv[0] == "plugin" && step.argv[1] == "install" && step.argv[2] == id { + return true + } + } + return false +} + +// TestCodexNoPluginAutoInstallSelectsChannelEntry is AC-3's codex front-door half: +// codex is the SOLE-knob host (no marketplace calendar key acts as a secondary +// channel signal), so devBranch alone determines the channel entry. The test +// drives the real runCodex no-plugin auto-install with devBranch set per channel +// and OBSERVES the seam values, then reconstructs the production codex install +// argv from them — the channel-correct entry id (`spacedock@spacedock` stable / +// `spacedock-edge@spacedock` edge) must be the `plugin add` target. The values are +// read off the recorded seam, never grepped from a constant. +func TestCodexNoPluginAutoInstallSelectsChannelEntry(t *testing.T) { + saved := devBranch + defer func() { devBranch = saved }() + + cases := []struct { + channel string + devBranch string + wantID string + }{ + {channel: "stable", devBranch: "main", wantID: "spacedock@spacedock"}, + {channel: "edge", devBranch: "next", wantID: "spacedock-edge@spacedock"}, + } + for _, tc := range cases { + t.Run(tc.channel, func(t *testing.T) { + devBranch = tc.devBranch + + fake := &fakeHost{manifest: ""} // fresh HOME: no codex plugin installed + var stdout, stderr bytes.Buffer + code := runCodex(context.Background(), nil, t.TempDir(), fake, lookFound, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit = %d, want 0 (no plugin → auto-install + launch) (stderr=%q)", code, stderr.String()) + } + + if len(fake.installCmds) < 3 { + t.Fatalf("install seam recorded %v, want {host, source, devBranch}", fake.installCmds) + } + if got := fake.installCmds[0]; got != "codex" { + t.Fatalf("install host = %q, want codex", got) + } + if got := fake.installCmds[1]; got != marketplaceSource { + t.Fatalf("install source = %q, want %q (the marketplace repo)", got, marketplaceSource) + } + if got := fake.installCmds[2]; got != tc.devBranch { + t.Fatalf("%s channel install devBranch = %q, want %q (devBranch is the sole codex channel knob)", tc.channel, got, tc.devBranch) + } + + seq := codexInstallArgvSequence(fake.installCmds[1], fake.installCmds[2]) + if !codexSequenceAddsID(seq, tc.wantID) { + t.Fatalf("%s channel codex sequence does not `plugin add %q`; steps=%v", tc.channel, tc.wantID, seq) + } + }) + } +} + +// codexSequenceAddsID reports whether the codex install sequence's final pinning +// step adds the given plugin id (`plugin add `). +func codexSequenceAddsID(steps []installStep, id string) bool { + for _, step := range steps { + if len(step.argv) == 3 && step.argv[0] == "plugin" && step.argv[1] == "add" && step.argv[2] == id { + return true + } + } + return false +} diff --git a/internal/cli/codex_channel_smoke_test.go b/internal/cli/codex_channel_smoke_test.go deleted file mode 100644 index 78860d658..000000000 --- a/internal/cli/codex_channel_smoke_test.go +++ /dev/null @@ -1,133 +0,0 @@ -// ABOUTME: AC-a/AC-d — per-channel devBranch drives the claude/codex no-plugin -// ABOUTME: auto-install to @main/@next (claude) and --ref main/next (codex) in argv. -package cli - -import ( - "bytes" - "context" - "testing" -) - -// TestClaudeNoPluginAutoInstallChannelRef is AC-a's hermetic half: the claude -// front door, with devBranch set per channel, drives the no-plugin auto-install -// to the channel-correct marketplace `@ref`. It mirrors the codex test but in -// claude's `source@branch` shorthand: a stable binary (devBranch=main) resolves -// `spacedock-dev/spacedock@main`; an edge binary (devBranch=next) resolves -// `…@next`. The branch is observed off the recorded install seam, and -// marketplaceAddArg is confirmed to compose `source@branch` from it — so the -// observed value IS the production marketplace-add argv, never a constant grep. -// (The live built-binary smoke proves the BUILD stamp end-to-end; this test pins -// the channel→argv contract hermetically.) -func TestClaudeNoPluginAutoInstallChannelRef(t *testing.T) { - saved := devBranch - defer func() { devBranch = saved }() - - cases := []struct { - channel string - branch string - }{ - {channel: "stable", branch: "main"}, - {channel: "edge", branch: "next"}, - } - for _, tc := range cases { - t.Run(tc.channel, func(t *testing.T) { - devBranch = tc.branch - - fake := &fakeHost{manifest: ""} // fresh HOME: no claude plugin installed - var stdout, stderr bytes.Buffer - code := runClaude(context.Background(), nil, t.TempDir(), fake, lookFound, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0 (no plugin → auto-install + launch) (stderr=%q)", code, stderr.String()) - } - - if len(fake.installCmds) < 3 { - t.Fatalf("install seam recorded %v, want {host, source, branch}", fake.installCmds) - } - if got := fake.installCmds[0]; got != "claude" { - t.Fatalf("install host = %q, want claude", got) - } - if got := fake.installCmds[2]; got != tc.branch { - t.Fatalf("%s channel install branch = %q, want %q", tc.channel, got, tc.branch) - } - - wantRef := marketplaceSource + "@" + tc.branch - if got := marketplaceAddArg(marketplaceSource, fake.installCmds[2]); got != wantRef { - t.Fatalf("%s channel marketplace add arg = %q, want %q", tc.channel, got, wantRef) - } - }) - } -} - -// TestCodexNoPluginAutoInstallChannelRef is AC-d's hermetic proof: the codex -// front door is the SOLE-knob host (no .codex-plugin/marketplace.json calendar -// key acts as a secondary channel signal — claude has one, codex does not), so -// the `devBranch` ldflag is the only determinant of the codex `--ref`. The test -// drives the real runCodex no-plugin auto-install with devBranch set per channel -// and OBSERVES the branch the install seam records — exactly the value -// codexInstallArgvSequence threads into codex's `--ref ` flag (asserted -// below). A stable binary (devBranch=main) must resolve `--ref main`; an edge -// binary (devBranch=next) must resolve `--ref next`. The branch is read off the -// recorded seam interaction, never grepped from the constant. -func TestCodexNoPluginAutoInstallChannelRef(t *testing.T) { - saved := devBranch - defer func() { devBranch = saved }() - - cases := []struct { - channel string - branch string - }{ - {channel: "stable", branch: "main"}, - {channel: "edge", branch: "next"}, - } - for _, tc := range cases { - t.Run(tc.channel, func(t *testing.T) { - devBranch = tc.branch // the per-channel ldflag the released binary carries - - fake := &fakeHost{manifest: ""} // fresh HOME: no codex plugin installed - var stdout, stderr bytes.Buffer - code := runCodex(context.Background(), nil, t.TempDir(), fake, lookFound, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit = %d, want 0 (no plugin → auto-install + launch) (stderr=%q)", code, stderr.String()) - } - - // The seam records {host, source, branch}; branch is the channel ref. - if len(fake.installCmds) < 3 { - t.Fatalf("install seam recorded %v, want {host, source, branch}", fake.installCmds) - } - if got := fake.installCmds[0]; got != "codex" { - t.Fatalf("install host = %q, want codex", got) - } - if got := fake.installCmds[2]; got != tc.branch { - t.Fatalf("%s channel install branch = %q, want %q (devBranch is the sole codex channel knob)", tc.channel, got, tc.branch) - } - - // The recorded branch is exactly what codex's `--ref` carries: confirm the - // argv composition threads it through `--ref ` on the marketplace - // add step, so the observed seam value IS the production install argv. - seq := codexInstallArgvSequence(marketplaceSource, fake.installCmds[2]) - if !codexMarketplaceAddHasRef(seq, tc.branch) { - t.Fatalf("%s channel codexInstallArgvSequence has no `marketplace add … --ref %s`; steps=%v", tc.channel, tc.branch, seq) - } - }) - } -} - -// codexMarketplaceAddHasRef reports whether the install sequence's marketplace -// add step carries `--ref ` as adjacent argv tokens — the codex -// branch-pinning form (a separate flag, not the claude `source@branch` -// shorthand). It reads the argv structurally so a reordering or a dropped `--ref` -// reds AC-d rather than passing on a substring coincidence. -func codexMarketplaceAddHasRef(steps []installStep, branch string) bool { - for _, step := range steps { - argv := step.argv - if len(argv) < 4 || argv[0] != "plugin" || argv[1] != "marketplace" || argv[2] != "add" { - continue - } - for i := 3; i+1 < len(argv); i++ { - if argv[i] == "--ref" && argv[i+1] == branch { - return true - } - } - } - return false -} diff --git a/internal/cli/decoupling_behavior_test.go b/internal/cli/decoupling_behavior_test.go new file mode 100644 index 000000000..d7c1a1799 --- /dev/null +++ b/internal/cli/decoupling_behavior_test.go @@ -0,0 +1,230 @@ +// ABOUTME: AC-1 decoupling behavior — a real isolated-CLAUDE_CONFIG_DIR install of a +// ABOUTME: tag-pinned stable + branch-HEAD edge channel, advancing HEAD to prove the freeze. +package cli + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestStableChannelDecoupledFromBranchHead is the load-bearing AC-1 proof: a +// tag-pinned stable channel stays frozen on the tag's tree while an edge channel +// on a branch HEAD advances, both resolved from ONE marketplace as two entries of +// one source. It reproduces the recorded decoupling spike against the real claude +// host with LOCAL FIXTURE git repos (the spike used /tmp throwaway repos): +// +// 1. A plugin git repo with tag v0.0.1 (version 0.0.1) on an early commit and a +// `next` HEAD ahead at 0.0.2. +// 2. A marketplace dir holding ONE marketplace.json with two entries of that one +// {source:url,url:file://,ref:…} source — `spacedock` (stable) ref +// v0.0.1, `spacedock-edge` (edge) ref next. +// 3. Install both channels into an isolated CLAUDE_CONFIG_DIR + plugin cache. +// Observe two distinct cache dirs: stable 0.0.1, edge 0.0.2 — byte-verified +// from the installed skill body, not a command's self-claim. +// 4. Advance plugin `next` HEAD to 0.0.3, bump ONLY the edge entry's version, +// refresh the marketplace, and update both channels. +// 5. Assert stable is FROZEN (its cache still holds only 0.0.1, body v0.0.1) +// while edge ADVANCED (a new 0.0.3 cache dir, body v0.0.3). +// +// Skips when `claude` is not on PATH; a real install kept hermetic by env +// isolation + local-path fixtures (file:// git urls → offline), not a mock. +func TestStableChannelDecoupledFromBranchHead(t *testing.T) { + claudeBin, err := exec.LookPath("claude") + if err != nil { + t.Skip("claude not on PATH; decoupling behavior test requires the host CLI") + } + + tmp := t.TempDir() + plugin := buildPluginGitRepo(t, filepath.Join(tmp, "plugin")) + marketplace := buildChannelMarketplace(t, filepath.Join(tmp, "marketplace"), plugin, "v0.0.1", "next") + configDir := filepath.Join(tmp, "config") + cacheDir := filepath.Join(tmp, "cache") + mustMkdir(t, configDir) + mustMkdir(t, cacheDir) + + env := append(os.Environ(), + "CLAUDE_CONFIG_DIR="+configDir, + "CLAUDE_CODE_PLUGIN_CACHE_DIR="+cacheDir, + ) + + // Install both channels from the one marketplace. + runHost(t, claudeBin, env, "plugin", "marketplace", "add", marketplace) + runHost(t, claudeBin, env, "plugin", "install", "spacedock@spacedock") + runHost(t, claudeBin, env, "plugin", "install", "spacedock-edge@spacedock") + + // Two distinct cache dirs resolve from the one marketplace: stable on the tag + // commit (v0.0.1), edge on next HEAD (0.0.2). Byte-verified from the installed + // skill body — the tag pin checks out the tag's tree, not HEAD's. + if body := installedSkillBody(t, cacheDir, "spacedock", "0.0.1"); body != "body v0.0.1\n" { + t.Fatalf("stable cache skill body = %q, want %q (tag pin must serve the tag's tree)", body, "body v0.0.1\n") + } + if body := installedSkillBody(t, cacheDir, "spacedock-edge", "0.0.2"); body != "body v0.0.2\n" { + t.Fatalf("edge cache skill body = %q, want %q (edge serves next HEAD)", body, "body v0.0.2\n") + } + + // New work lands post-release: advance plugin `next` HEAD to 0.0.3 and bump + // ONLY the edge entry's version, then refresh the marketplace. + advancePluginHead(t, plugin, "0.0.3") + bumpEntryVersion(t, marketplace, "spacedock-edge", "0.0.3") + runHost(t, claudeBin, env, "plugin", "marketplace", "update", "spacedock") + // Updates are channel-scoped; stable is a no-op (already at the latest 0.0.1), + // edge advances. The stable update may report "already at the latest" (exit 0) + // — tolerate it and read the on-disk cache as the source of truth either way. + runHostTolerant(t, claudeBin, env, "plugin", "update", "spacedock@spacedock") + runHost(t, claudeBin, env, "plugin", "update", "spacedock-edge@spacedock") + + // The decoupling holds under advance: stable is FROZEN — its cache still holds + // only 0.0.1 (no 0.0.3 dir appeared) and the body is unchanged at v0.0.1, + // despite plugin HEAD moving to 0.0.3. + if dirs := cacheVersionDirs(t, cacheDir, "spacedock"); len(dirs) != 1 || dirs[0] != "0.0.1" { + t.Fatalf("stable cache version dirs = %v, want [0.0.1] only (tag-pinned stable must not advance with HEAD)", dirs) + } + if body := installedSkillBody(t, cacheDir, "spacedock", "0.0.1"); body != "body v0.0.1\n" { + t.Fatalf("after advance, stable cache body = %q, want %q (frozen)", body, "body v0.0.1\n") + } + + // Edge ADVANCED: a new 0.0.3 cache dir, body v0.0.3 (from next HEAD). + if body := installedSkillBody(t, cacheDir, "spacedock-edge", "0.0.3"); body != "body v0.0.3\n" { + t.Fatalf("after advance, edge 0.0.3 cache body = %q, want %q (edge must track HEAD)", body, "body v0.0.3\n") + } +} + +// buildPluginGitRepo writes a plugin git repo under root with a `next` branch +// carrying tag v0.0.1 (version 0.0.1, skill body "body v0.0.1") on an early commit +// and a `next` HEAD advanced to 0.0.2 ("body v0.0.2"). Returns the repo path. The +// plugin manifest carries a requires-contract bracketing CONTRACT_VERSION. +func buildPluginGitRepo(t *testing.T, root string) string { + t.Helper() + mustMkdir(t, filepath.Join(root, ".claude-plugin")) + mustMkdir(t, filepath.Join(root, "skills", "demo")) + + writePluginVersion(t, root, "0.0.1") + git(t, root, "init", "-q", "-b", "next") + git(t, root, "add", "-A") + git(t, root, "commit", "-q", "-m", "v0.0.1") + git(t, root, "tag", "v0.0.1") + + writePluginVersion(t, root, "0.0.2") + git(t, root, "add", "-A") + git(t, root, "commit", "-q", "-m", "v0.0.2 HEAD") + return root +} + +// writePluginVersion (re)writes the plugin's .claude-plugin/plugin.json and demo +// skill body to the given version (the skill body is "body v", the +// decoupling proof's per-version observable). +func writePluginVersion(t *testing.T, root, version string) { + t.Helper() + mustWrite(t, filepath.Join(root, ".claude-plugin", "plugin.json"), + fmt.Sprintf(`{ "name": "spacedock", "version": "%s", "requires-contract": ">=1,<2", "skills": "./skills/" }`+"\n", version)) + mustWrite(t, filepath.Join(root, "skills", "demo", "SKILL.md"), + fmt.Sprintf("---\nname: demo\ndescription: demo\n---\nbody v%s\n", version)) +} + +// advancePluginHead advances the plugin repo's `next` HEAD to a new version, +// committing the bump (new work landing post-release). +func advancePluginHead(t *testing.T, root, version string) { + t.Helper() + writePluginVersion(t, root, version) + git(t, root, "add", "-A") + git(t, root, "commit", "-q", "-m", "v"+version+" HEAD") +} + +// buildChannelMarketplace writes a marketplace dir under root holding ONE +// marketplace.json with two entries of one {source:url,url:file://,ref:…} +// source: `spacedock` (stable) pinned to stableRef, `spacedock-edge` (edge) on +// edgeRef. Returns the marketplace dir (added to claude by path; claude reads +// .claude-plugin/marketplace.json directly and clones the plugin source url@ref). +func buildChannelMarketplace(t *testing.T, root, pluginRepo, stableRef, edgeRef string) string { + t.Helper() + mustMkdir(t, filepath.Join(root, ".claude-plugin")) + url := "file://" + pluginRepo + manifest := fmt.Sprintf(`{ + "name": "spacedock", + "owner": { "name": "test" }, + "plugins": [ + { "name": "spacedock", "source": { "source": "url", "url": "%s", "ref": "%s" }, "description": "stable", "version": "0.0.1", "category": "workflow" }, + { "name": "spacedock-edge", "source": { "source": "url", "url": "%s", "ref": "%s" }, "description": "edge", "version": "0.0.2", "category": "workflow" } + ] +} +`, url, stableRef, url, edgeRef) + mustWrite(t, filepath.Join(root, ".claude-plugin", "marketplace.json"), manifest) + return root +} + +// bumpEntryVersion rewrites the named plugin entry's version in the marketplace +// manifest (leaving the other entry untouched) — the edge-only version bump the +// `claude plugin update` re-pull keys on. +func bumpEntryVersion(t *testing.T, marketplace, entry, version string) { + t.Helper() + path := filepath.Join(marketplace, ".claude-plugin", "marketplace.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read marketplace %s: %v", path, err) + } + var doc map[string]any + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("parse marketplace: %v", err) + } + plugins, _ := doc["plugins"].([]any) + for _, p := range plugins { + pm, _ := p.(map[string]any) + if pm["name"] == entry { + pm["version"] = version + } + } + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + t.Fatalf("marshal marketplace: %v", err) + } + mustWrite(t, path, string(out)+"\n") +} + +// installedSkillBody reads the installed demo skill body for the given entry at +// the given version from the plugin cache: +// /cache////skills/demo/SKILL.md, returning +// the body line after the YAML frontmatter (the per-version observable). +func installedSkillBody(t *testing.T, cacheDir, entry, version string) string { + t.Helper() + path := filepath.Join(cacheDir, "cache", "spacedock", entry, version, "skills", "demo", "SKILL.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read installed skill %s: %v", path, err) + } + return skillBody(string(data)) +} + +// skillBody returns the content after the closing `---` of the YAML frontmatter +// (the SKILL.md shape is `---\n---\n`), or the whole content +// when there is no frontmatter fence. +func skillBody(content string) string { + parts := strings.SplitN(content, "---\n", 3) + if len(parts) < 3 { + return content + } + return parts[2] +} + +// cacheVersionDirs lists the version subdirectories the host cached for the given +// entry under /cache/spacedock//. A tag-pinned channel must hold +// exactly one (frozen); an advancing channel grows a new dir. +func cacheVersionDirs(t *testing.T, cacheDir, entry string) []string { + t.Helper() + root := filepath.Join(cacheDir, "cache", "spacedock", entry) + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("read cache root %s: %v", root, err) + } + var dirs []string + for _, e := range entries { + if e.IsDir() { + dirs = append(dirs, e.Name()) + } + } + return dirs +} diff --git a/internal/cli/frontdoor.go b/internal/cli/frontdoor.go index 8af6e0123..bf9a88f58 100644 --- a/internal/cli/frontdoor.go +++ b/internal/cli/frontdoor.go @@ -37,16 +37,17 @@ type hostOps interface { // (production) or recording it (test). It returns only on failure to launch. Launch(argv []string, env []string) error // Install issues the host plugin commands to install/update the plugin from - // source (optionally pinned to branch), returning combined output. - Install(host, source, branch string) (string, error) + // source, returning combined output. devBranch selects the marketplace channel + // entry the install targets (see channelEntry). + Install(host, source, devBranch string) (string, error) } -// devBranch is the pre-release branch woven into the install/remedy commands as -// the marketplace `@ref` (and Codex `--ref`). The default is `next`: until `next` -// is the repository's default branch, the released binary installs the plugin -// from `spacedock-dev/spacedock@next`, where the root marketplace.json lives. It -// is a var (not a const) so the linker can stamp it, mirroring Version, and so -// `SPACEDOCK_DEV_BRANCH` can override it; tests save/restore it. +// devBranch is the binary's channel stamp: it selects which marketplace entry the +// install targets — `main` installs the stable `spacedock` entry, any other value +// (default `next`) installs the `spacedock-edge` entry tracking next HEAD (see +// channelEntry). It is a var (not a const) so the linker can stamp it per channel, +// mirroring Version, and so `SPACEDOCK_DEV_BRANCH` can override it; tests +// save/restore it. var devBranch = "next" var executablePath = os.Executable @@ -232,7 +233,7 @@ func gateHost(ops hostOps, host string, stderr io.Writer) contract.Verdict { if manifestPath == "" { return contract.NoPluginFound } - res := contract.ManifestVerdict(manifestPath, host, devBranch, Version) + res := contract.ManifestVerdict(manifestPath, host, Version) if res.Verdict == contract.NoPluginFound { return contract.NoPluginFound } diff --git a/internal/cli/host_exec.go b/internal/cli/host_exec.go index c3427dcd6..f262afbc7 100644 --- a/internal/cli/host_exec.go +++ b/internal/cli/host_exec.go @@ -209,14 +209,26 @@ func manifestSubpath(host string) string { return filepath.Join(".claude-plugin", "plugin.json") } -// marketplaceAddArg composes the `claude plugin marketplace add` target: the -// bare source, or `source@branch` when a branch is pinned (the @ref shorthand the -// host resolves against the repo-root marketplace.json on that ref). -func marketplaceAddArg(source, branch string) string { - if branch != "" { - return source + "@" + branch +// channelEntry maps the binary's devBranch stamp to the marketplace ENTRY name +// it installs: a stable binary (devBranch=main) installs the `spacedock` entry; an +// edge binary (any other devBranch, e.g. next) installs `spacedock-edge`. The two +// entries live in the one marketplace repo and resolve distinct pinned versions +// (stable pinned to a release tag, edge tracking next HEAD), so the channel is the +// entry name — the version pin lives in the manifest, not an @ref on the install +// command. +func channelEntry(devBranch string) string { + if devBranch == "main" { + return "spacedock" } - return source + return "spacedock-edge" +} + +// channelPluginID is the `@spacedock` plugin id for the channel devBranch +// selects: `spacedock@spacedock` (stable) or `spacedock-edge@spacedock` (edge). +// The `@spacedock` suffix is the marketplace NAME (the marketplace.json `name`); +// the prefix is the channel entry. +func channelPluginID(devBranch string) string { + return channelEntry(devBranch) + "@spacedock" } // Launch replaces the current process with argv via execve, so the host CLI @@ -242,67 +254,68 @@ type installStep struct { } // installArgvSequence is the 4-command upgrade shape `Install` issues: -// uninstall any existing spacedock@spacedock first (cause-and-effect — claude +// uninstall any existing channel plugin first (cause-and-effect — claude // tracks an installed plugin via its marketplace record, so the marketplace // remove later would orphan a live uninstall), drop the existing marketplace -// declaration for spacedock (so the next add re-pins the @ref instead of -// no-op'ing on "already on disk"), pin the marketplace source (@ref when a -// branch is set), then install fresh. The tolerance asymmetry: BOTH cleanup -// steps (plugin uninstall + marketplace remove) are tolerated as best-effort, -// because claude exits 1 on the fresh-box cases ("Plugin not found in -// installed plugins" for uninstall, "Marketplace 'spacedock' not found" for -// remove) with no way to distinguish those from real failures via stable -// stderr matching. BOTH pinning steps (marketplace add + plugin install) stay -// fail-fast — they are the real-failure backstops that surface a broken +// declaration for spacedock (so the next add re-pins the source instead of +// no-op'ing on "already on disk"), add the marketplace-repo source, then install +// the channel entry the devBranch selects (`spacedock` stable / `spacedock-edge` +// edge). The marketplace add carries the BARE marketplace-repo source — the +// channel and version pin live in the manifest, not an @ref shorthand. The +// tolerance asymmetry: BOTH cleanup steps (plugin uninstall + marketplace remove) +// are tolerated as best-effort, because claude exits 1 on the fresh-box cases +// ("Plugin not found in installed plugins" for uninstall, "Marketplace 'spacedock' +// not found" for remove) with no way to distinguish those from real failures via +// stable stderr matching. BOTH pinning steps (marketplace add + plugin install) +// stay fail-fast — they are the real-failure backstops that surface a broken // install (network, contract incompatibility, missing source). -func installArgvSequence(source, branch string) []installStep { +func installArgvSequence(source, devBranch string) []installStep { + id := channelPluginID(devBranch) return []installStep{ - {argv: []string{"plugin", "uninstall", "spacedock@spacedock"}, tolerateExit: true}, + {argv: []string{"plugin", "uninstall", id}, tolerateExit: true}, {argv: []string{"plugin", "marketplace", "remove", "spacedock"}, tolerateExit: true}, - {argv: []string{"plugin", "marketplace", "add", marketplaceAddArg(source, branch)}}, - {argv: []string{"plugin", "install", "spacedock@spacedock"}}, + {argv: []string{"plugin", "marketplace", "add", source}}, + {argv: []string{"plugin", "install", id}}, } } // codexInstallArgvSequence is the codex analog of installArgvSequence: the same // 4-command cleanup-then-pin shape, but in codex's verb vocabulary (`plugin -// remove` / `plugin add`, not claude's `uninstall` / `install`) and pinning the -// branch via codex's own `--ref ` flag (a separate argv token), NOT the -// claude `source@branch` shorthand. `--ref` and its value are OMITTED when branch -// is empty (the default-branch install). The tolerance asymmetry matches claude: -// BOTH cleanup steps (plugin remove + marketplace remove) are tolerated — on a -// fresh box `plugin remove` exits 0 (idempotent) but `marketplace remove` exits 1 -// ("marketplace `spacedock` is not configured or installed"), and neither is a -// real failure. BOTH pinning steps (marketplace add + plugin add) stay fail-fast -// — they are the real-failure backstops. -func codexInstallArgvSequence(source, branch string) []installStep { - addArgv := []string{"plugin", "marketplace", "add", source} - if branch != "" { - addArgv = append(addArgv, "--ref", branch) - } +// remove` / `plugin add`, not claude's `uninstall` / `install`). The marketplace +// add carries the BARE marketplace-repo source with NO `--ref` — the channel is +// the entry name (`spacedock` stable / `spacedock-edge` edge) the devBranch +// selects, and the version pin lives in the manifest, not a branch ref. The +// tolerance asymmetry matches claude: BOTH cleanup steps (plugin remove + +// marketplace remove) are tolerated — on a fresh box `plugin remove` exits 0 +// (idempotent) but `marketplace remove` exits 1 ("marketplace `spacedock` is not +// configured or installed"), and neither is a real failure. BOTH pinning steps +// (marketplace add + plugin add) stay fail-fast — they are the real-failure +// backstops. +func codexInstallArgvSequence(source, devBranch string) []installStep { + id := channelPluginID(devBranch) return []installStep{ - {argv: []string{"plugin", "remove", "spacedock@spacedock"}, tolerateExit: true}, + {argv: []string{"plugin", "remove", id}, tolerateExit: true}, {argv: []string{"plugin", "marketplace", "remove", "spacedock"}, tolerateExit: true}, - {argv: addArgv}, - {argv: []string{"plugin", "add", "spacedock@spacedock"}}, + {argv: []string{"plugin", "marketplace", "add", source}}, + {argv: []string{"plugin", "add", id}}, } } // Install shells the host plugin upgrade sequence (cleanup-then-pin) for claude -// or codex, returning combined output. Each host uses its own verb vocabulary and -// branch-pinning form (claude `source@branch` shorthand; codex `--ref `), -// supplied by installArgvSequence / codexInstallArgvSequence. The two cleanup -// steps are tolerated — their non-zero exits on a fresh-box ("not installed" / -// "not found") are appended to combined output and the loop continues. The two -// pinning steps (marketplace add, plugin install/add) are fail-fast and surface -// real install failures. -func (execHost) Install(host, source, branch string) (string, error) { +// or codex, returning combined output. Each host uses its own verb vocabulary +// (claude `uninstall`/`install`; codex `remove`/`add`), supplied by +// installArgvSequence / codexInstallArgvSequence; devBranch selects the channel +// entry both install. The two cleanup steps are tolerated — their non-zero exits +// on a fresh-box ("not installed" / "not found") are appended to combined output +// and the loop continues. The two pinning steps (marketplace add, plugin +// install/add) are fail-fast and surface real install failures. +func (execHost) Install(host, source, devBranch string) (string, error) { var steps []installStep switch host { case "claude": - steps = installArgvSequence(source, branch) + steps = installArgvSequence(source, devBranch) case "codex": - steps = codexInstallArgvSequence(source, branch) + steps = codexInstallArgvSequence(source, devBranch) default: return "", fmt.Errorf("programmatic install is supported for claude and codex, not %q", host) } diff --git a/internal/cli/init.go b/internal/cli/init.go index c1fb3888a..bd0420d8b 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -10,10 +10,13 @@ import ( "github.com/spacedock-dev/spacedock/internal/contract" ) -// marketplaceSource is the marketplace add source for the Spacedock plugin. The -// default release path resolves the published marketplace repo; a pre-release -// dev branch is pinned via devBranch in the emitted/issued commands. -const marketplaceSource = "spacedock-dev/spacedock" +// marketplaceSource is the marketplace add source: the standalone marketplace +// repo (NOT the plugin repo). It holds the one marketplace.json with two entries +// of one source — `spacedock` (stable, pinned to a release tag) and +// `spacedock-edge` (edge, tracking next HEAD). The binary's devBranch stamp +// selects which entry installs (see channelEntry); the version pin lives in the +// manifest, not an @ref on the install command. +const marketplaceSource = "spacedock-dev/marketplace" // runInit installs/updates the per-host plugin (claude) or emits the documented // add command pair (codex), then runs doctor. `--check` runs the report without @@ -45,7 +48,7 @@ func runInit(ctx context.Context, args []string, ops hostOps, stdout, stderr io. return 1 } if check { - return contract.RunDoctor(resolved, "codex", devBranch, Version, stdout, stderr) + return contract.RunDoctor(resolved, "codex", Version, stdout, stderr) } if resolved != "" { // An already-present plugin is refreshed on `install` like the claude @@ -63,8 +66,8 @@ func runInit(ctx context.Context, args []string, ops hostOps, stdout, stderr io. } // Codex install is documented prose when no installed plugin resolves: the - // host install verb is `add` (NOT `install`), and the marketplace add - // accepts the branch via --ref. + // host install verb is `add` (NOT `install`), and the channel entry the + // binary's devBranch selects is named in the documented `plugin add`. printCodexInstallProse(stdout) return 0 default: @@ -73,18 +76,18 @@ func runInit(ctx context.Context, args []string, ops hostOps, stdout, stderr io. } } -// printCodexInstallProse emits the documented Codex install command pair. The -// dev branch, when set, is pinned via --ref on the marketplace add. +// printCodexInstallProse emits the documented Codex install command pair: add the +// marketplace-repo source, then add the channel entry the binary's devBranch +// selects (`spacedock@spacedock` stable / `spacedock-edge@spacedock` edge). No +// `--ref` — the channel is the entry name and the version pin lives in the +// manifest, not a branch ref. func printCodexInstallProse(stdout io.Writer) { - addCmd := "codex plugin marketplace add " + marketplaceSource - if devBranch != "" { - addCmd += " --ref " + devBranch - } fmt.Fprintf(stdout, "Codex has no programmatic plugin install from spacedock. Run these in your shell:\n"+ - " %s\n"+ - " codex plugin add spacedock@spacedock\n"+ - "Then use the spacedock:first-officer skill in your Codex session.\n", addCmd) + " codex plugin marketplace add %s\n"+ + " codex plugin add %s\n"+ + "Then use the spacedock:first-officer skill in your Codex session.\n", + marketplaceSource, channelPluginID(devBranch)) } // runDoctor is the `spacedock doctor` command path. With `--plugin-manifest PATH` @@ -99,7 +102,7 @@ func runDoctor(ctx context.Context, args []string, ops hostOps, stdout, stderr i } if manifestPath != "" { - return contract.RunDoctor(manifestPath, host, devBranch, Version, stdout, stderr) + return contract.RunDoctor(manifestPath, host, Version, stdout, stderr) } resolved, err := ops.ResolveManifest(host) @@ -109,7 +112,7 @@ func runDoctor(ctx context.Context, args []string, ops hostOps, stdout, stderr i } // An empty resolved path is the no-plugin-found report; RunDoctor renders it // from a non-existent path as a non-fatal report. - return contract.RunDoctor(resolved, host, devBranch, Version, stdout, stderr) + return contract.RunDoctor(resolved, host, Version, stdout, stderr) } // parseInitArgs reads `--host claude|codex` (default claude) and `--check`. A diff --git a/internal/cli/init_devbranch_test.go b/internal/cli/init_devbranch_test.go index 76a655e53..8920b0b59 100644 --- a/internal/cli/init_devbranch_test.go +++ b/internal/cli/init_devbranch_test.go @@ -1,5 +1,5 @@ -// ABOUTME: AC-3a — `spacedock install --host claude` targets @next when devBranch is -// ABOUTME: pinned to next, and the composed marketplace-add argv carries @next. +// ABOUTME: AC-3a — `spacedock install --host claude` drives the install seam with the +// ABOUTME: binary's devBranch, which selects the marketplace channel entry to install. package cli import ( @@ -10,10 +10,11 @@ import ( ) // TestInitTargetsNextWhenDevBranchPinned locks AC-3a: with devBranch pinned to -// `next` (the released binary's default, until `next` is the default branch), -// `spacedock install --host claude` drives the install seam with branch=next, so the -// issued `marketplace add` resolves `spacedock-dev/spacedock@next`. The package -// var is saved/restored so the assertion does not leak into sibling tests. +// `next` (the released edge binary's default, until `next` is the default +// branch), `spacedock install --host claude` drives the install seam with +// devBranch=next, so the issued sequence installs the `spacedock-edge` channel +// entry. The package var is saved/restored so the assertion does not leak into +// sibling tests. func TestInitTargetsNextWhenDevBranchPinned(t *testing.T) { saved := devBranch devBranch = "next" @@ -26,67 +27,55 @@ func TestInitTargetsNextWhenDevBranchPinned(t *testing.T) { if code != 0 { t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String()) } - // Install records {host, source, branch}; branch carries the @ref pin. + // Install records {host, source, devBranch}; devBranch selects the channel entry. if len(fake.installCmds) < 3 { - t.Fatalf("install seam recorded %v, want {host, source, branch}", fake.installCmds) + t.Fatalf("install seam recorded %v, want {host, source, devBranch}", fake.installCmds) } if got := fake.installCmds[2]; got != "next" { - t.Fatalf("install branch = %q, want next (init must target @next)", got) - } -} - -// TestMarketplaceAddArgvCarriesRef locks the argv composition AC-3a asserts: the -// `claude plugin marketplace add` argv pins `source@branch` when a branch is set, -// and is the bare source when it is not. This is the exact 2-command argv shape -// owned today; task 38 changes Install to a 3-command shape (add/uninstall/ -// install) and this assertion is updated in lockstep then. -func TestMarketplaceAddArgvCarriesRef(t *testing.T) { - if got := marketplaceAddArg("spacedock-dev/spacedock", "next"); got != "spacedock-dev/spacedock@next" { - t.Errorf("marketplaceAddArg with branch = %q, want spacedock-dev/spacedock@next", got) - } - if got := marketplaceAddArg("spacedock-dev/spacedock", ""); got != "spacedock-dev/spacedock" { - t.Errorf("marketplaceAddArg without branch = %q, want bare source", got) + t.Fatalf("install devBranch = %q, want next (edge binary installs the edge channel)", got) } } // TestInstallArgvSequence locks AC-1 and AC-3's tolerance asymmetry: -// execHost.Install issues the 4-command upgrade shape — `plugin uninstall -// spacedock@spacedock` first (claude tracks an installed plugin via its -// marketplace record, so the marketplace remove later would orphan a live -// uninstall; tolerated, fresh-box exit 1 with "Plugin not found in installed -// plugins"), then `plugin marketplace remove spacedock` (tolerated, fresh-box -// exit 1 with "not found"), then `plugin marketplace add` (with the @ref pin), -// then `plugin install spacedock@spacedock`. The marketplace-remove step is -// what defeats the "already on disk" no-op in marketplace add when a stale pin -// is declared. The asymmetry: BOTH cleanup steps (uninstall + remove) are -// tolerated; BOTH pinning steps (add + install) are fail-fast. With an empty -// branch the marketplace add arg is the bare source. +// execHost.Install issues the 4-command upgrade shape — `plugin uninstall ` +// first (claude tracks an installed plugin via its marketplace record, so the +// marketplace remove later would orphan a live uninstall; tolerated, fresh-box +// exit 1 with "Plugin not found in installed plugins"), then `plugin marketplace +// remove spacedock` (tolerated, fresh-box exit 1 with "not found"), then `plugin +// marketplace add` with the BARE marketplace-repo source (no @ref — the channel +// is the entry name and the version pin lives in the manifest), then `plugin +// install `. The id is the channel entry the devBranch selects: +// `spacedock-edge@spacedock` for the edge binary, `spacedock@spacedock` for +// stable. The marketplace-remove step is what defeats the "already on disk" no-op +// in marketplace add when a stale source is declared. The asymmetry: BOTH cleanup +// steps (uninstall + remove) are tolerated; BOTH pinning steps (add + install) +// are fail-fast. func TestInstallArgvSequence(t *testing.T) { - wantWithBranch := []installStep{ - {argv: []string{"plugin", "uninstall", "spacedock@spacedock"}, tolerateExit: true}, + wantEdge := []installStep{ + {argv: []string{"plugin", "uninstall", "spacedock-edge@spacedock"}, tolerateExit: true}, {argv: []string{"plugin", "marketplace", "remove", "spacedock"}, tolerateExit: true}, - {argv: []string{"plugin", "marketplace", "add", "spacedock-dev/spacedock@next"}}, - {argv: []string{"plugin", "install", "spacedock@spacedock"}}, + {argv: []string{"plugin", "marketplace", "add", "spacedock-dev/marketplace"}}, + {argv: []string{"plugin", "install", "spacedock-edge@spacedock"}}, } - if got := installArgvSequence("spacedock-dev/spacedock", "next"); !reflect.DeepEqual(got, wantWithBranch) { - t.Errorf("installArgvSequence(branch=next) = %v, want %v", got, wantWithBranch) + if got := installArgvSequence("spacedock-dev/marketplace", "next"); !reflect.DeepEqual(got, wantEdge) { + t.Errorf("installArgvSequence(devBranch=next) = %v, want %v", got, wantEdge) } - wantBareSource := []installStep{ + wantStable := []installStep{ {argv: []string{"plugin", "uninstall", "spacedock@spacedock"}, tolerateExit: true}, {argv: []string{"plugin", "marketplace", "remove", "spacedock"}, tolerateExit: true}, - {argv: []string{"plugin", "marketplace", "add", "spacedock-dev/spacedock"}}, + {argv: []string{"plugin", "marketplace", "add", "spacedock-dev/marketplace"}}, {argv: []string{"plugin", "install", "spacedock@spacedock"}}, } - if got := installArgvSequence("spacedock-dev/spacedock", ""); !reflect.DeepEqual(got, wantBareSource) { - t.Errorf("installArgvSequence(no branch) = %v, want %v", got, wantBareSource) + if got := installArgvSequence("spacedock-dev/marketplace", "main"); !reflect.DeepEqual(got, wantStable) { + t.Errorf("installArgvSequence(devBranch=main) = %v, want %v", got, wantStable) } // Lock the tolerance asymmetry explicitly: the two cleanup steps (uninstall, // marketplace remove) are tolerated; the two pinning steps (marketplace add, // plugin install) are fail-fast. Any future drift toward tolerate-every-step // (or shifting tolerance onto a pinning step) fails here. - seq := installArgvSequence("spacedock-dev/spacedock", "next") + seq := installArgvSequence("spacedock-dev/marketplace", "next") for i, step := range seq { isCleanup := isUninstallStep(step.argv) || isMarketplaceRemoveStep(step.argv) if isCleanup && !step.tolerateExit { diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index 56e96be75..2c0b8c549 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -37,13 +37,15 @@ func TestInitClaudeIssuesHostPluginCommands(t *testing.T) { } } -// TestInitMarketplaceSourceIsMigratedRepo guards the migration cleanup: the -// marketplace-add target is `spacedock-dev/spacedock`, not the pre-migration -// `clkao/spacedock`. Without this, a silent revert of the marketplaceSource -// constant would not fail `go test` — both the claude install seam and the codex -// add-prose carry the source, so both paths are asserted. -func TestInitMarketplaceSourceIsMigratedRepo(t *testing.T) { - const wantSource = "spacedock-dev/spacedock" +// TestInitMarketplaceSourceIsMarketplaceRepo guards the Model B decouple: the +// marketplace-add target is the standalone marketplace repo +// `spacedock-dev/marketplace`, NOT the plugin repo `spacedock-dev/spacedock` (the +// manifest moved out of the plugin branch). Without this, a silent revert of the +// marketplaceSource constant back to the plugin repo would not fail `go test` — +// both the claude install seam and the codex add-prose carry the source, so both +// paths are asserted. +func TestInitMarketplaceSourceIsMarketplaceRepo(t *testing.T) { + const wantSource = "spacedock-dev/marketplace" t.Run("claude-install-seam", func(t *testing.T) { fake := &fakeHost{manifest: compatibleManifest(t)} @@ -76,8 +78,8 @@ func TestInitMarketplaceSourceIsMigratedRepo(t *testing.T) { if !strings.Contains(out, "codex plugin marketplace add "+wantSource) { t.Fatalf("codex add-prose marketplace source not %q:\n%s", wantSource, out) } - if strings.Contains(out, "clkao/spacedock") { - t.Fatalf("codex add-prose still names the pre-migration clkao/spacedock:\n%s", out) + if strings.Contains(out, "spacedock-dev/spacedock") { + t.Fatalf("codex add-prose still names the plugin repo spacedock-dev/spacedock; the manifest moved to the marketplace repo:\n%s", out) } }) } @@ -148,6 +150,12 @@ func TestInitCodexInstallReadiness(t *testing.T) { }) t.Run("not-installed", func(t *testing.T) { + // The codex add-prose adds the channel entry the binary's devBranch selects; + // pin a stable binary (devBranch=main) so the prose names spacedock@spacedock. + saved := devBranch + devBranch = "main" + defer func() { devBranch = saved }() + fake := &fakeHost{} var stdout, stderr bytes.Buffer @@ -170,6 +178,27 @@ func TestInitCodexInstallReadiness(t *testing.T) { } }) + // not-installed-edge: an edge binary (devBranch=next) names the edge channel + // entry spacedock-edge@spacedock in the add-prose — the channel selection + // reaches the documented codex install. + t.Run("not-installed-edge", func(t *testing.T) { + saved := devBranch + devBranch = "next" + defer func() { devBranch = saved }() + + fake := &fakeHost{} + var stdout, stderr bytes.Buffer + + code := runInit(context.Background(), []string{"--host", "codex"}, fake, &stdout, &stderr) + + if code != 0 { + t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String()) + } + if got := stdout.String(); !strings.Contains(got, "codex plugin add spacedock-edge@spacedock") { + t.Errorf("edge codex init prose missing 'codex plugin add spacedock-edge@spacedock':\n%s", got) + } + }) + t.Run("incompatible-installed", func(t *testing.T) { fake := &fakeHost{manifest: tooOldBinaryManifest(t)} var stdout, stderr bytes.Buffer diff --git a/internal/cli/install_behavior_codex_test.go b/internal/cli/install_behavior_codex_test.go index 0c0036907..bc31e7317 100644 --- a/internal/cli/install_behavior_codex_test.go +++ b/internal/cli/install_behavior_codex_test.go @@ -33,8 +33,9 @@ func TestCodexPluginInstallIsHostNative(t *testing.T) { // The real Install runs in-process through the production seam: the codex arm // shells the 4-step sequence against the isolated CODEX_HOME (CODEX_HOME is read - // from the env by the codex CLI). Empty branch → no --ref → fully offline. - out, err := execHost{}.Install("codex", marketplace, "") + // from the env by the codex CLI). devBranch=main selects the stable `spacedock` + // entry the fixture marketplace defines; the local-path source keeps it offline. + out, err := execHost{}.Install("codex", marketplace, "main") if err != nil { t.Fatalf("execHost.Install(codex) failed: %v\nout=%q", err, out) } @@ -98,8 +99,9 @@ func TestCodexInitRefreshAdvancesBehindPlugin(t *testing.T) { 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 { + // Seed the behind install (0.0.1) through the production seam. devBranch=main + // selects the stable `spacedock` entry the fixture defines. + if out, err := (execHost{}).Install("codex", behind, "main"); err != nil { t.Fatalf("seed Install(codex, 0.0.1) failed: %v\nout=%q", err, out) } if got := resolvedCodexManifestVersion(t); got != "0.0.1" { @@ -108,7 +110,7 @@ func TestCodexInitRefreshAdvancesBehindPlugin(t *testing.T) { // 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 { + if out, err := (execHost{}).Install("codex", newer, "main"); err != nil { t.Fatalf("refresh Install(codex, 0.0.2) failed: %v\nout=%q", err, out) } if got := resolvedCodexManifestVersion(t); got != "0.0.2" { diff --git a/internal/cli/install_tolerance_codex_test.go b/internal/cli/install_tolerance_codex_test.go index 33c59f6e6..82699781f 100644 --- a/internal/cli/install_tolerance_codex_test.go +++ b/internal/cli/install_tolerance_codex_test.go @@ -10,10 +10,11 @@ import ( ) // TestCodexInstallIssuesSequenceInOrder locks AC-1a: a stub codex that exits 0 on -// every step → Install("codex", "spacedock-dev/spacedock", "next") returns nil and -// the combined output carries all four step markers, including the codex-specific -// `plugin marketplace add spacedock-dev/spacedock --ref next` and `plugin add -// spacedock@spacedock`. The stub's echoed argv is the independent source of truth. +// every step → Install("codex", "spacedock-dev/marketplace", "next") returns nil +// and the combined output carries all four step markers, including the bare +// marketplace-repo `plugin marketplace add spacedock-dev/marketplace` (no --ref) +// and the edge channel's `plugin add spacedock-edge@spacedock`. The stub's echoed +// argv is the independent source of truth. func TestCodexInstallIssuesSequenceInOrder(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("stub script uses /bin/sh; not portable to Windows") @@ -21,15 +22,15 @@ func TestCodexInstallIssuesSequenceInOrder(t *testing.T) { dir := writeHostStub(t, "codex", "") t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - out, err := execHost{}.Install("codex", "spacedock-dev/spacedock", "next") + out, err := execHost{}.Install("codex", "spacedock-dev/marketplace", "next") if err != nil { t.Fatalf("Install returned error on all-zero codex stub: %v\nout=%q", err, out) } wantOrder := []string{ - "stub:plugin remove spacedock@spacedock:exit=0", + "stub:plugin remove spacedock-edge@spacedock:exit=0", "stub:plugin marketplace remove spacedock:exit=0", - "stub:plugin marketplace add spacedock-dev/spacedock --ref next:exit=0", - "stub:plugin add spacedock@spacedock:exit=0", + "stub:plugin marketplace add spacedock-dev/marketplace:exit=0", + "stub:plugin add spacedock-edge@spacedock:exit=0", } last := -1 for _, want := range wantOrder { @@ -57,15 +58,15 @@ func TestCodexInstallToleratesMarketplaceRemoveFailure(t *testing.T) { dir := writeHostStub(t, "codex", "plugin marketplace remove") t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - out, err := execHost{}.Install("codex", "spacedock-dev/spacedock", "next") + out, err := execHost{}.Install("codex", "spacedock-dev/marketplace", "next") if err != nil { t.Fatalf("Install returned error on tolerated marketplace-remove failure: %v\nout=%q", err, out) } for _, want := range []string{ - "stub:plugin remove spacedock@spacedock:exit=0", + "stub:plugin remove spacedock-edge@spacedock:exit=0", "stub:plugin marketplace remove spacedock:exit=1", - "stub:plugin marketplace add spacedock-dev/spacedock --ref next:exit=0", - "stub:plugin add spacedock@spacedock:exit=0", + "stub:plugin marketplace add spacedock-dev/marketplace:exit=0", + "stub:plugin add spacedock-edge@spacedock:exit=0", } { if !strings.Contains(out, want) { t.Errorf("combined output missing %q\nout=%q", want, out) @@ -85,14 +86,14 @@ func TestCodexInstallToleratesPluginRemoveFailure(t *testing.T) { dir := writeHostStub(t, "codex", "plugin remove") t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - out, err := execHost{}.Install("codex", "spacedock-dev/spacedock", "next") + out, err := execHost{}.Install("codex", "spacedock-dev/marketplace", "next") if err != nil { t.Fatalf("Install returned error on tolerated plugin-remove failure: %v\nout=%q", err, out) } for _, want := range []string{ - "stub:plugin remove spacedock@spacedock:exit=1", - "stub:plugin marketplace add spacedock-dev/spacedock --ref next:exit=0", - "stub:plugin add spacedock@spacedock:exit=0", + "stub:plugin remove spacedock-edge@spacedock:exit=1", + "stub:plugin marketplace add spacedock-dev/marketplace:exit=0", + "stub:plugin add spacedock-edge@spacedock:exit=0", } { if !strings.Contains(out, want) { t.Errorf("combined output missing %q\nout=%q", want, out) @@ -110,14 +111,14 @@ func TestCodexInstallFailsFastOnMarketplaceAdd(t *testing.T) { dir := writeHostStub(t, "codex", "plugin marketplace add") t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - out, err := execHost{}.Install("codex", "spacedock-dev/spacedock", "next") + out, err := execHost{}.Install("codex", "spacedock-dev/marketplace", "next") if err == nil { t.Fatalf("Install returned nil error; want marketplace-add fail-fast\nout=%q", out) } - if !strings.Contains(err.Error(), "plugin marketplace add spacedock-dev/spacedock --ref next") { + if !strings.Contains(err.Error(), "plugin marketplace add spacedock-dev/marketplace") { t.Errorf("error %q does not wrap the codex add subcommand argv", err) } - if !strings.Contains(out, "stub:plugin marketplace add spacedock-dev/spacedock --ref next:exit=1") { + if !strings.Contains(out, "stub:plugin marketplace add spacedock-dev/marketplace:exit=1") { t.Errorf("combined output missing add-step stub marker; out=%q", out) } } @@ -132,37 +133,42 @@ func TestCodexInstallFailsFastOnPluginAdd(t *testing.T) { dir := writeHostStub(t, "codex", "plugin add") t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - out, err := execHost{}.Install("codex", "spacedock-dev/spacedock", "next") + out, err := execHost{}.Install("codex", "spacedock-dev/marketplace", "next") if err == nil { t.Fatalf("Install returned nil error; want plugin-add fail-fast\nout=%q", out) } - if !strings.Contains(err.Error(), "plugin add spacedock@spacedock") { + if !strings.Contains(err.Error(), "plugin add spacedock-edge@spacedock") { t.Errorf("error %q does not wrap the codex plugin add subcommand argv", err) } - if !strings.Contains(out, "stub:plugin add spacedock@spacedock:exit=1") { + if !strings.Contains(out, "stub:plugin add spacedock-edge@spacedock:exit=1") { t.Errorf("combined output missing plugin-add stub marker; out=%q", out) } } -// TestCodexInstallOmitsRefWhenBranchEmpty locks the empty-branch arm of AC-1: with -// branch=="" the marketplace add carries the bare source and NO `--ref` token. A -// stub recording the exact argv is the source of truth — a leaked `--ref` would -// pin against a non-existent ref on the default-branch install path. -func TestCodexInstallOmitsRefWhenBranchEmpty(t *testing.T) { +// TestCodexInstallStableEntryOmitsRef locks the stable-channel arm of AC-1 (and +// the no-`--ref` invariant): the marketplace add carries the bare marketplace-repo +// source and NO `--ref` token (the channel is the entry name, not a branch ref), +// and the stable binary (devBranch=main) installs the `spacedock` entry. A stub +// recording the exact argv is the source of truth — a leaked `--ref` would wrongly +// pin a non-existent ref, and a wrong entry id would cross channels. +func TestCodexInstallStableEntryOmitsRef(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("stub script uses /bin/sh; not portable to Windows") } dir := writeHostStub(t, "codex", "") t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - out, err := execHost{}.Install("codex", "spacedock-dev/spacedock", "") + out, err := execHost{}.Install("codex", "spacedock-dev/marketplace", "main") if err != nil { - t.Fatalf("Install returned error on empty-branch codex stub: %v\nout=%q", err, out) + t.Fatalf("Install returned error on stable-channel codex stub: %v\nout=%q", err, out) } - if !strings.Contains(out, "stub:plugin marketplace add spacedock-dev/spacedock:exit=0") { + if !strings.Contains(out, "stub:plugin marketplace add spacedock-dev/marketplace:exit=0") { t.Errorf("combined output missing bare-source add marker; out=%q", out) } + if !strings.Contains(out, "stub:plugin add spacedock@spacedock:exit=0") { + t.Errorf("combined output missing stable-channel plugin add marker; out=%q", out) + } if strings.Contains(out, "--ref") { - t.Errorf("combined output carries a --ref token with empty branch; out=%q", out) + t.Errorf("combined output carries a --ref token; the channel is the entry name, not a branch ref; out=%q", out) } } diff --git a/internal/cli/install_tolerance_test.go b/internal/cli/install_tolerance_test.go index de7cd6d6b..bb0a04864 100644 --- a/internal/cli/install_tolerance_test.go +++ b/internal/cli/install_tolerance_test.go @@ -22,15 +22,15 @@ func TestInstallToleratesRemoveStepFailure(t *testing.T) { dir := writeClaudeStub(t, "plugin marketplace remove") t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - out, err := execHost{}.Install("claude", "spacedock-dev/spacedock", "next") + out, err := execHost{}.Install("claude", "spacedock-dev/marketplace", "next") if err != nil { t.Fatalf("Install returned error on tolerated remove failure: %v\nout=%q", err, out) } for _, want := range []string{ "stub:plugin marketplace remove spacedock:exit=1", - "stub:plugin marketplace add spacedock-dev/spacedock@next:exit=0", - "stub:plugin uninstall spacedock@spacedock:exit=0", - "stub:plugin install spacedock@spacedock:exit=0", + "stub:plugin marketplace add spacedock-dev/marketplace:exit=0", + "stub:plugin uninstall spacedock-edge@spacedock:exit=0", + "stub:plugin install spacedock-edge@spacedock:exit=0", } { if !strings.Contains(out, want) { t.Errorf("combined output missing %q\nout=%q", want, out) @@ -52,15 +52,15 @@ func TestInstallToleratesUninstallStepFailure(t *testing.T) { dir := writeClaudeStub(t, "plugin uninstall") t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - out, err := execHost{}.Install("claude", "spacedock-dev/spacedock", "next") + out, err := execHost{}.Install("claude", "spacedock-dev/marketplace", "next") if err != nil { t.Fatalf("Install returned error on tolerated uninstall failure: %v\nout=%q", err, out) } for _, want := range []string{ - "stub:plugin uninstall spacedock@spacedock:exit=1", + "stub:plugin uninstall spacedock-edge@spacedock:exit=1", "stub:plugin marketplace remove spacedock:exit=0", - "stub:plugin marketplace add spacedock-dev/spacedock@next:exit=0", - "stub:plugin install spacedock@spacedock:exit=0", + "stub:plugin marketplace add spacedock-dev/marketplace:exit=0", + "stub:plugin install spacedock-edge@spacedock:exit=0", } { if !strings.Contains(out, want) { t.Errorf("combined output missing %q\nout=%q", want, out) @@ -79,14 +79,14 @@ func TestInstallFailsFastOnAddStep(t *testing.T) { dir := writeClaudeStub(t, "plugin marketplace add") t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - out, err := execHost{}.Install("claude", "spacedock-dev/spacedock", "next") + out, err := execHost{}.Install("claude", "spacedock-dev/marketplace", "next") if err == nil { t.Fatalf("Install returned nil error; want add-step failure\nout=%q", out) } - if !strings.Contains(err.Error(), "plugin marketplace add spacedock-dev/spacedock@next") { + if !strings.Contains(err.Error(), "plugin marketplace add spacedock-dev/marketplace") { t.Errorf("error %q does not wrap the add subcommand argv", err) } - if !strings.Contains(out, "stub:plugin marketplace add spacedock-dev/spacedock@next:exit=1") { + if !strings.Contains(out, "stub:plugin marketplace add spacedock-dev/marketplace:exit=1") { t.Errorf("combined output missing add-step stub stderr; out=%q", out) } } diff --git a/internal/cli/upgrade_from_stale_test.go b/internal/cli/upgrade_from_stale_test.go index 5bf6589a1..f7a6a2bc0 100644 --- a/internal/cli/upgrade_from_stale_test.go +++ b/internal/cli/upgrade_from_stale_test.go @@ -50,17 +50,18 @@ func TestUpgradeFromStaleMovesToGreen(t *testing.T) { // The stale install resolves to the predates-contract verdict (exit 1) — the // dead-end this entity fixes. staleManifest := resolveClaudeManifestEnv(t, claudeBin, env) - staleVerdict := contract.ManifestVerdict(staleManifest, "claude", "next", Version) + staleVerdict := contract.ManifestVerdict(staleManifest, "claude", Version) if staleVerdict.Verdict != contract.PluginPredatesContract { t.Fatalf("stale install verdict = %v, want plugin-predates-contract (message=%q)", staleVerdict.Verdict, staleVerdict.Message) } // Upgrade via the committed argv shape. Plain `plugin install` would no-op // here (the plugin is already installed); the inserted uninstall is what - // moves it. Run the exact argv installArgvSequence emits. The remove step + // moves it. Run the exact argv installArgvSequence emits — devBranch=main + // targets the stable `spacedock` entry that was seeded above. The remove step // is tolerated — runHostTolerant accepts a non-zero exit on it without // failing the test, matching the production loop's behavior. - for _, step := range installArgvSequence(upgradedMarketplace, "") { + for _, step := range installArgvSequence(upgradedMarketplace, "main") { if step.tolerateExit { runHostTolerant(t, claudeBin, env, step.argv...) } else { @@ -70,7 +71,7 @@ func TestUpgradeFromStaleMovesToGreen(t *testing.T) { // Doctor now reports compatible (exit 0) — the install moved off the stale plugin. upgradedManifest := resolveClaudeManifestEnv(t, claudeBin, env) - upgradedVerdict := contract.ManifestVerdict(upgradedManifest, "claude", "next", Version) + upgradedVerdict := contract.ManifestVerdict(upgradedManifest, "claude", Version) if upgradedVerdict.Verdict != contract.Compatible { t.Fatalf("after upgrade, verdict = %v, want compatible (message=%q)", upgradedVerdict.Verdict, upgradedVerdict.Message) } @@ -102,7 +103,7 @@ func TestFreshBoxInstallSucceeds(t *testing.T) { t.Setenv("CLAUDE_CONFIG_DIR", configDir) t.Setenv("CLAUDE_CODE_PLUGIN_CACHE_DIR", cacheDir) - out, err := execHost{}.Install("claude", marketplace, "") + out, err := execHost{}.Install("claude", marketplace, "main") if err != nil { t.Fatalf("Install on fresh box returned error: %v\nout=%q", err, out) } diff --git a/internal/contract/contract.go b/internal/contract/contract.go index 1261720d6..b2775a54b 100644 --- a/internal/contract/contract.go +++ b/internal/contract/contract.go @@ -110,23 +110,23 @@ func ParseRange(raw string) (lo int, hi int, err error) { } // Compare classifies a binary at contract version c against a plugin's raw -// requires-contract range, for the named host and (pre-release) dev branch. It -// returns the verdict and the operator-facing message. pluginVersion and -// binaryVersion are the display semvers woven into the user-facing mismatch/OK -// lines. NoPluginFound is produced by the caller (when the manifest is absent), -// not here — Compare always has a raw range string to evaluate. -func Compare(c int, raw, host, branch, pluginVersion, binaryVersion string) Result { +// requires-contract range, for the named host. It returns the verdict and the +// operator-facing message. pluginVersion and binaryVersion are the display semvers +// woven into the user-facing mismatch/OK lines. NoPluginFound is produced by the +// caller (when the manifest is absent), not here — Compare always has a raw range +// string to evaluate. +func Compare(c int, raw, host, pluginVersion, binaryVersion string) Result { manifestNote := "" - return compareWithManifest(c, raw, host, branch, manifestNote, pluginVersion, binaryVersion) + return compareWithManifest(c, raw, host, manifestNote, pluginVersion, binaryVersion) } // compareWithManifest is Compare with an optional manifest path woven into the // malformed-range message so a packaging bug names the offending file. -func compareWithManifest(c int, raw, host, branch, manifestPath, pluginVersion, binaryVersion string) Result { +func compareWithManifest(c int, raw, host, manifestPath, pluginVersion, binaryVersion string) Result { if strings.TrimSpace(raw) == "" { return Result{ Verdict: PluginPredatesContract, - Message: pluginPredatesContractRemedy(host, branch), + Message: pluginPredatesContractRemedy(host), } } lo, hi, err := ParseRange(raw) @@ -256,18 +256,14 @@ func tooOldPluginRemedy(host string) string { // predates the contract mechanism (no requires-contract field). It names the // `spacedock install` one-liner — never raw ` plugin` commands — and omits the // `plugin update` fallback, which no-ops on a stale already-installed plugin. The -// host is parameterized; the optional pre-release branch suffixes the reinstall -// source so a dev install reflects the branch (the default release path omits it). -func pluginPredatesContractRemedy(host, branch string) string { - source := "spacedock-dev/spacedock" - if branch != "" { - source += "@" + branch - } +// host is parameterized. No reinstall-source parenthetical: `spacedock install` +// auto-selects the channel from the binary's own devBranch stamp (the marketplace +// entry name), so the remedy names neither a source repo nor an @branch suffix. +func pluginPredatesContractRemedy(host string) string { return fmt.Sprintf( "plugin-predates-contract: your installed Spacedock plugin is out of date "+ - "(predates this binary's contract). Upgrade it: spacedock install --host %s "+ - "(reinstalls from %s).", - host, source) + "(predates this binary's contract). Upgrade it: spacedock install --host %s.", + host) } // noPluginMessage is the pinned no-plugin-found report for a host. Not a diff --git a/internal/contract/contract_test.go b/internal/contract/contract_test.go index adfce674c..d017531e1 100644 --- a/internal/contract/contract_test.go +++ b/internal/contract/contract_test.go @@ -81,7 +81,7 @@ func TestCompare(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - res := Compare(c.contract, c.raw, "claude", "", "0.12.1", "0.19.4") + res := Compare(c.contract, c.raw, "claude", "0.12.1", "0.19.4") if res.Verdict != c.wantVerdict { t.Fatalf("Compare(%d,%q) verdict = %v, want %v", c.contract, c.raw, res.Verdict, c.wantVerdict) } @@ -106,7 +106,7 @@ func TestCompare(t *testing.T) { func TestCompatibleUpgradeHint(t *testing.T) { t.Run("behind-plugin-hints", func(t *testing.T) { for _, host := range []string{"claude", "codex"} { - res := Compare(CONTRACT_VERSION, ">=1,<2", host, "", "0.19.8", "0.20.0") + res := Compare(CONTRACT_VERSION, ">=1,<2", host, "0.19.8", "0.20.0") if res.Verdict != Compatible { t.Fatalf("host %s: verdict = %v, want Compatible (the hint must not change the verdict)", host, res.Verdict) } @@ -124,7 +124,7 @@ func TestCompatibleUpgradeHint(t *testing.T) { // Negative: equal versions carry no hint — there is nothing to upgrade to. t.Run("equal-version-no-hint", func(t *testing.T) { - res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "", "0.20.0", "0.20.0") + res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "0.20.0", "0.20.0") if res.Verdict != Compatible { t.Fatalf("verdict = %v, want Compatible", res.Verdict) } @@ -136,7 +136,7 @@ func TestCompatibleUpgradeHint(t *testing.T) { // Negative: an unstamped `dev` binary version is not valid semver — the hint // must not fire (no false "you must upgrade" against a dev build). t.Run("dev-binary-no-hint", func(t *testing.T) { - res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "", "0.19.8", "dev") + res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "0.19.8", "dev") if res.Verdict != Compatible { t.Fatalf("verdict = %v, want Compatible", res.Verdict) } @@ -148,7 +148,7 @@ func TestCompatibleUpgradeHint(t *testing.T) { // Negative: a binary OLDER than the plugin (but still contract-compatible) // carries no hint — the hint is for a behind plugin, not a behind binary. t.Run("older-binary-no-hint", func(t *testing.T) { - res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "", "0.21.0", "0.20.0") + res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "0.21.0", "0.20.0") if res.Verdict != Compatible { t.Fatalf("verdict = %v, want Compatible", res.Verdict) } @@ -162,7 +162,7 @@ func TestCompatibleUpgradeHint(t *testing.T) { // sorts BEFORE "0.9.0" ("1" < "9"), so a lexical-compare regression of // semverCompare would wrongly suppress the hint here. Pins the integer compare. t.Run("behind-plugin-double-digit-minor-hints", func(t *testing.T) { - res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "", "0.9.0", "0.10.0") + res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "0.9.0", "0.10.0") if res.Verdict != Compatible { t.Fatalf("verdict = %v, want Compatible", res.Verdict) } @@ -176,7 +176,7 @@ func TestCompatibleUpgradeHint(t *testing.T) { // so a lexical-compare regression would wrongly FIRE the hint on this older // binary. The two boundary cases together RED any lexical compare. t.Run("older-binary-double-digit-minor-no-hint", func(t *testing.T) { - res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "", "0.10.0", "0.9.0") + res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "0.10.0", "0.9.0") if res.Verdict != Compatible { t.Fatalf("verdict = %v, want Compatible", res.Verdict) } @@ -189,7 +189,7 @@ func TestCompatibleUpgradeHint(t *testing.T) { // dotted-int semver — the conservative gate emits no hint. Pins that // parseDottedInts rejects a `-rc1` suffix rather than stripping it. t.Run("prerelease-binary-no-hint", func(t *testing.T) { - res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "", "0.19.8", "0.20.0-rc1") + res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "0.19.8", "0.20.0-rc1") if res.Verdict != Compatible { t.Fatalf("verdict = %v, want Compatible", res.Verdict) } @@ -213,7 +213,7 @@ func TestCompareMessageShape(t *testing.T) { {1, ">=2,<3"}, // too-old-binary {2, ">=1,<2"}, // too-old-plugin } { - res := Compare(c.contract, c.raw, "claude", "", pluginVersion, binaryVersion) + res := Compare(c.contract, c.raw, "claude", pluginVersion, binaryVersion) header := "Spacedock version mismatch: binary " + binaryVersion + ", plugin " + pluginVersion if !strings.Contains(res.Message, header) { t.Errorf("Compare(%d,%q) message missing header %q: %q", c.contract, c.raw, header, res.Message) @@ -224,40 +224,36 @@ func TestCompareMessageShape(t *testing.T) { } } -// TestPluginPredatesContractRemedy locks the new verdict for an absent/empty -// requires-contract: it names the `spacedock install --host ` one-liner, -// reflects the dev branch (@next) when set, and OMITS the `plugin update` -// fallback that reusing too-old-plugin would drag in (that fallback no-ops on a -// stale install). A whitespace-only value routes here too; a non-empty -// unparseable value still reads as a packaging bug. +// TestPluginPredatesContractRemedy locks the verdict for an absent/empty +// requires-contract: it names the `spacedock install --host ` one-liner and +// OMITS the `plugin update` fallback that reusing too-old-plugin would drag in +// (that fallback no-ops on a stale install). Post-decouple the remedy carries NO +// reinstall-source parenthetical: `spacedock install` auto-selects the channel from +// the binary's own devBranch stamp (entry name), so there is no plugin-repo name +// and no `@branch` shorthand to name. A whitespace-only value routes here too; a +// non-empty unparseable value still reads as a packaging bug. func TestPluginPredatesContractRemedy(t *testing.T) { for _, raw := range []string{"", " "} { - res := Compare(1, raw, "claude", "next", "0.12.1", "0.19.4") + res := Compare(1, raw, "claude", "0.12.1", "0.19.4") if res.Verdict != PluginPredatesContract { t.Fatalf("Compare(1,%q) verdict = %v, want plugin-predates-contract", raw, res.Verdict) } if !strings.Contains(res.Message, "spacedock install --host claude") { t.Errorf("predates-contract remedy missing install one-liner: %q", res.Message) } - if !strings.Contains(res.Message, "@next") { - t.Errorf("predates-contract remedy missing @next branch: %q", res.Message) + if strings.Contains(res.Message, "spacedock-dev/spacedock") { + t.Errorf("predates-contract remedy names the plugin repo; post-decouple the reinstall source is not named in the remedy: %q", res.Message) + } + if strings.Contains(res.Message, "@next") { + t.Errorf("predates-contract remedy carries the removed @branch shorthand: %q", res.Message) } if strings.Contains(res.Message, "plugin update") { t.Errorf("predates-contract remedy must omit the no-op `plugin update` fallback: %q", res.Message) } } - // With no dev branch, the remedy is the clean release one-liner (no @suffix). - plain := Compare(1, "", "claude", "", "0.12.1", "0.19.4") - if strings.Contains(plain.Message, "@next") { - t.Errorf("predates-contract remedy with no branch should omit @next: %q", plain.Message) - } - if !strings.Contains(plain.Message, "spacedock install --host claude") { - t.Errorf("predates-contract remedy with no branch missing install one-liner: %q", plain.Message) - } - // A non-empty unparseable value is still a packaging bug, not predates-contract. - bug := Compare(1, ">=1", "claude", "next", "0.12.1", "0.19.4") + bug := Compare(1, ">=1", "claude", "0.12.1", "0.19.4") if bug.Verdict != MalformedRange { t.Fatalf("Compare(1,%q) verdict = %v, want malformed-range", ">=1", bug.Verdict) } @@ -272,7 +268,7 @@ func TestPluginPredatesContractRemedy(t *testing.T) { // (which now exits 2) — the remedy a user hits at the gate must run. func TestCompareHostSubstitution(t *testing.T) { for _, host := range []string{"claude", "codex"} { - res := Compare(2, ">=1,<2", host, "", "0.18.0", "0.19.4") + res := Compare(2, ">=1,<2", host, "0.18.0", "0.19.4") want := "spacedock install --host " + host if !strings.Contains(res.Message, want) { t.Errorf("too-old-plugin remedy for host %q missing %q: %q", host, want, res.Message) @@ -283,20 +279,3 @@ func TestCompareHostSubstitution(t *testing.T) { } } -// TestTooOldBinaryRemedyIsBranchAgnostic verifies the too-old-binary remedy -// carries the brew/source-build upgrade path with NO branch pin: `brew upgrade -// spacedock` carries no branch and the source build is branch-agnostic, so the -// remedy is identical whether or not a pre-release dev branch is set. -func TestTooOldBinaryRemedyIsBranchAgnostic(t *testing.T) { - withBranch := Compare(1, ">=2,<3", "claude", "next", "0.21.0", "0.19.4") - noBranch := Compare(1, ">=2,<3", "claude", "", "0.21.0", "0.19.4") - if !strings.Contains(withBranch.Message, "brew upgrade spacedock") { - t.Errorf("too-old-binary remedy must lead with brew upgrade: %q", withBranch.Message) - } - if strings.Contains(withBranch.Message, "@next") { - t.Errorf("too-old-binary remedy must not pin a branch: %q", withBranch.Message) - } - if withBranch.Message != noBranch.Message { - t.Errorf("too-old-binary remedy must be branch-agnostic:\n with branch=%q\n no branch=%q", withBranch.Message, noBranch.Message) - } -} diff --git a/internal/contract/doctor.go b/internal/contract/doctor.go index 15a55d092..1fddff556 100644 --- a/internal/contract/doctor.go +++ b/internal/contract/doctor.go @@ -47,15 +47,15 @@ func readRequiresContract(manifestPath string) (string, error) { } // ManifestVerdict resolves the compatibility verdict for the manifest at -// manifestPath against this binary's CONTRACT_VERSION, for the named host and -// (pre-release) dev branch. A missing manifest file yields NoPluginFound; an -// unparseable manifest JSON yields a MalformedRange-shaped Result naming the -// parse error. The plugin's display version (from the manifest) and the binary's -// display version (threaded in by the cli caller, which owns cli.Version) are -// woven into the user-facing message. The front door inspects the verdict -// directly (a non-empty path to a missing file is NoPluginFound, NOT compatible); -// RunDoctor maps the same verdict to an exit code and stream. -func ManifestVerdict(manifestPath, host, branch, binaryVersion string) Result { +// manifestPath against this binary's CONTRACT_VERSION, for the named host. A +// missing manifest file yields NoPluginFound; an unparseable manifest JSON yields +// a MalformedRange-shaped Result naming the parse error. The plugin's display +// version (from the manifest) and the binary's display version (threaded in by the +// cli caller, which owns cli.Version) are woven into the user-facing message. The +// front door inspects the verdict directly (a non-empty path to a missing file is +// NoPluginFound, NOT compatible); RunDoctor maps the same verdict to an exit code +// and stream. +func ManifestVerdict(manifestPath, host, binaryVersion string) Result { pluginVersion, raw, err := readManifest(manifestPath) if errors.Is(err, errNoManifest) { return Result{Verdict: NoPluginFound, Message: noPluginMessage(host)} @@ -63,17 +63,17 @@ func ManifestVerdict(manifestPath, host, branch, binaryVersion string) Result { if err != nil { return Result{Verdict: MalformedRange, Message: fmt.Sprintf("error: %s", err)} } - return compareWithManifest(CONTRACT_VERSION, raw, host, branch, manifestPath, pluginVersion, binaryVersion) + return compareWithManifest(CONTRACT_VERSION, raw, host, manifestPath, pluginVersion, binaryVersion) } // RunDoctor reports the compatibility verdict for the manifest at manifestPath -// against this binary's CONTRACT_VERSION, for the named host and (pre-release) -// dev branch. binaryVersion is the binary's display version threaded in for the -// user-facing message. A compatible verdict and a no-plugin-found report exit 0 -// (the report is non-fatal-by-default); every mismatch (too-old-binary, -// too-old-plugin, malformed-range) exits 1 with the pinned remedy on stderr. -func RunDoctor(manifestPath, host, branch, binaryVersion string, stdout, stderr io.Writer) int { - res := ManifestVerdict(manifestPath, host, branch, binaryVersion) +// against this binary's CONTRACT_VERSION, for the named host. binaryVersion is the +// binary's display version threaded in for the user-facing message. A compatible +// verdict and a no-plugin-found report exit 0 (the report is non-fatal-by-default); +// every mismatch (too-old-binary, too-old-plugin, malformed-range) exits 1 with the +// pinned remedy on stderr. +func RunDoctor(manifestPath, host, binaryVersion string, stdout, stderr io.Writer) int { + res := ManifestVerdict(manifestPath, host, binaryVersion) switch res.Verdict { case Compatible: fmt.Fprintln(stdout, res.Message) diff --git a/internal/contract/doctor_test.go b/internal/contract/doctor_test.go index 2b33fb04d..399d2711a 100644 --- a/internal/contract/doctor_test.go +++ b/internal/contract/doctor_test.go @@ -61,7 +61,7 @@ func TestDoctorVerdicts(t *testing.T) { t.Run(c.name, func(t *testing.T) { var stdout, stderr bytes.Buffer manifestPath := filepath.Join("testdata", c.manifest) - code := RunDoctor(manifestPath, c.host, "", binaryVersionForTest, &stdout, &stderr) + code := RunDoctor(manifestPath, c.host, binaryVersionForTest, &stdout, &stderr) if code != c.wantExit { t.Fatalf("exit = %d, want %d (stdout=%q stderr=%q)", code, c.wantExit, stdout.String(), stderr.String()) } @@ -80,7 +80,7 @@ func TestDoctorVerdicts(t *testing.T) { func TestDoctorMalformedNamesManifest(t *testing.T) { var stdout, stderr bytes.Buffer manifestPath := filepath.Join("testdata", "malformed-range.json") - code := RunDoctor(manifestPath, "claude", "", binaryVersionForTest, &stdout, &stderr) + code := RunDoctor(manifestPath, "claude", binaryVersionForTest, &stdout, &stderr) if code != 1 { t.Fatalf("exit = %d, want 1", code) } diff --git a/internal/contract/gate_test.go b/internal/contract/gate_test.go index ebf560ef2..566786606 100644 --- a/internal/contract/gate_test.go +++ b/internal/contract/gate_test.go @@ -54,7 +54,7 @@ func TestStartupGateAbortsBeforeDiscover(t *testing.T) { return cmd.Run() } - proceed, msg := gateAndMaybeDiscover(runVersion, c.embeddedRange, "claude", "", runDiscover) + proceed, msg := gateAndMaybeDiscover(runVersion, c.embeddedRange, "claude", runDiscover) if proceed != c.wantProceed { t.Fatalf("proceed = %v, want %v (msg=%q)", proceed, c.wantProceed, msg) @@ -91,7 +91,7 @@ func TestStartupGateAbortsBeforeDiscover(t *testing.T) { // against the embedded range, and only call discover when compatible. This is // the Go realization of the prose the FO follows — driven here by a real stub // process, not a mock. -func gateAndMaybeDiscover(runVersion func() (string, error), embeddedRange, host, branch string, runDiscover func() error) (proceed bool, message string) { +func gateAndMaybeDiscover(runVersion func() (string, error), embeddedRange, host string, runDiscover func() error) (proceed bool, message string) { out, err := runVersion() if err != nil { return false, "spacedock --version unavailable: " + err.Error() @@ -100,7 +100,7 @@ func gateAndMaybeDiscover(runVersion func() (string, error), embeddedRange, host if !ok { return false, "could not parse contract token from `spacedock --version`: " + strings.TrimSpace(out) } - res := Compare(c, embeddedRange, host, branch, "0.18.0", "0.19.4") + res := Compare(c, embeddedRange, host, "0.18.0", "0.19.4") if res.Verdict != Compatible { return false, res.Message } diff --git a/internal/contract/version_message_test.go b/internal/contract/version_message_test.go index 6fec157ea..b695a05fb 100644 --- a/internal/contract/version_message_test.go +++ b/internal/contract/version_message_test.go @@ -46,7 +46,7 @@ func TestMismatchShowsVersionsNotContract(t *testing.T) { t.Run(c.name, func(t *testing.T) { var stdout, stderr bytes.Buffer manifestPath := filepath.Join("testdata", c.manifest) - code := RunDoctor(manifestPath, "claude", "", binaryVersionForTest, &stdout, &stderr) + code := RunDoctor(manifestPath, "claude", binaryVersionForTest, &stdout, &stderr) if code != 1 { t.Fatalf("exit = %d, want 1 (stderr=%q)", code, stderr.String()) } @@ -73,7 +73,7 @@ func TestMismatchShowsVersionsNotContract(t *testing.T) { func TestCompatibleShowsVersions(t *testing.T) { var stdout, stderr bytes.Buffer manifestPath := filepath.Join("testdata", "compatible.json") - code := RunDoctor(manifestPath, "claude", "", binaryVersionForTest, &stdout, &stderr) + code := RunDoctor(manifestPath, "claude", binaryVersionForTest, &stdout, &stderr) if code != 0 { t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String()) } @@ -95,7 +95,7 @@ func TestCompatibleShowsVersions(t *testing.T) { func TestTooOldBinaryRemedyLeadsWithBrew(t *testing.T) { var stdout, stderr bytes.Buffer manifestPath := filepath.Join("testdata", "too-old-binary.json") - code := RunDoctor(manifestPath, "claude", "", binaryVersionForTest, &stdout, &stderr) + code := RunDoctor(manifestPath, "claude", binaryVersionForTest, &stdout, &stderr) if code != 1 { t.Fatalf("exit = %d, want 1", code) } @@ -106,4 +106,7 @@ func TestTooOldBinaryRemedyLeadsWithBrew(t *testing.T) { if !strings.Contains(out, "spacedock install") { t.Fatalf("too-old-binary remedy must name the plugin-refresh distinction `spacedock install`: %q", out) } + if strings.Contains(out, "@next") { + t.Fatalf("too-old-binary remedy must not pin a channel branch (the removed @branch shorthand): %q", out) + } } diff --git a/internal/release/channel_agreement_guard_test.go b/internal/release/channel_agreement_guard_test.go index fee0f46f3..8eba67b92 100644 --- a/internal/release/channel_agreement_guard_test.go +++ b/internal/release/channel_agreement_guard_test.go @@ -10,19 +10,18 @@ import ( ) // The post-flip agreement invariant: the released stable channel's plugin source -// must settle on `main` across three INDEPENDENTLY-authored surfaces — +// must settle on `main` across the two BINARY-side surfaces — // (1) release.yml's "Stamp plugin manifests" step git switch/push target, -// (2) .goreleaser.yaml's stable-build cli.devBranch ldflag, -// (3) .claude-plugin/marketplace.json source.ref. -// Surfaces (1) and (2) are the BINARY side; surface (3) is the marketplace side. -// Surface (3) is branch-local: marketplace.json points at the channel its branch -// serves — `next` on the edge branch, `main` on the stable branch. So the -// tri-surface ==main check is meaningful only on a tree whose marketplace ref is -// already `main` (the stable branch); on the edge tree (ref `next`) it skips, and -// the binary-side pair (1)==(2)==main is asserted unconditionally below. The -// surfaces are parsed out of three real artifacts authored by different changes, -// so a drift in any one fails the check — an independent source of truth, not a -// re-read of the value the implementer wrote. +// (2) .goreleaser.yaml's stable-build cli.devBranch ldflag. +// Under Model B the marketplace manifest moved OUT of the plugin branch into a +// separate marketplace repo, so the former third surface — an in-branch +// .claude-plugin/marketplace.json source.ref that had to be re-settled per release +// — no longer exists. That removal is the AC-2 invariant guarded by +// TestPluginBranchCarriesNoMarketplaceManifest below; the channel pin now lives in +// the marketplace repo's manifest, not the plugin branch. The two binary surfaces +// are parsed out of two real artifacts authored by different changes, so a drift in +// either fails the check — an independent source of truth, not a re-read of the +// value the implementer wrote. const stableChannelBranch = "main" // releaseStampTarget extracts the branch the release.yml "Stamp plugin manifests @@ -114,33 +113,6 @@ func devBranchLdflag(ldflags []string) string { return "" } -// marketplaceSourceRef extracts .claude-plugin/marketplace.json's first plugin -// entry's source.ref — surface (3), pj's. Parsed from the real file so a pj flip -// (next→main) is observed here, not asserted from a value k6d wrote. -func marketplaceSourceRef(manifest string) string { - var doc struct { - Plugins []struct { - Source struct { - Ref string `json:"ref"` - } `json:"source"` - } `json:"plugins"` - } - if err := yamlJSON([]byte(manifest), &doc); err != nil { - return "" - } - if len(doc.Plugins) == 0 { - return "" - } - return doc.Plugins[0].Source.Ref -} - -// yamlJSON decodes JSON via the yaml.v3 codec (a JSON document is valid YAML), so -// the agreement guard needs no extra import beyond the one the sibling goreleaser -// guard already carries. -func yamlJSON(blob []byte, out any) error { - return yaml.Unmarshal(blob, out) -} - func readReleaseWorkflow(t *testing.T) string { t.Helper() data, err := os.ReadFile(filepath.Join("..", "..", ".github", "workflows", "release.yml")) @@ -150,15 +122,6 @@ func readReleaseWorkflow(t *testing.T) string { return string(data) } -func readMarketplaceManifest(t *testing.T) string { - t.Helper() - data, err := os.ReadFile(filepath.Join("..", "..", ".claude-plugin", "marketplace.json")) - if err != nil { - t.Fatal(err) - } - return string(data) -} - // TestStableChannelBinaryPairAgreesOnMain locks AC-b's k6d-landable half: the two // BINARY-side surfaces — release.yml's stamp target (1) and .goreleaser.yaml's // stable-build devBranch (2) — must both be `main`. Each is parsed out of its own @@ -203,28 +166,58 @@ func TestEdgeChannelStampsNext(t *testing.T) { } } -// TestTriSurfaceChannelAgreement is the FULL agreement invariant: all three -// independently-authored surfaces — release.yml stamp target (1), -// .goreleaser.yaml stable devBranch (2), and .claude-plugin/marketplace.json -// source.ref (3) — must agree on `main`. Surface (3) is branch-local, so this -// asserts only on a `main`-ref tree (the stable branch); on the edge tree (ref -// `next`) it skips, since `next` correctly serves its own channel. The binary-side -// pair (1)==(2) is asserted unconditionally by TestStableChannelBinaryPairAgreesOnMain -// above. -func TestTriSurfaceChannelAgreement(t *testing.T) { - marketplaceRef := marketplaceSourceRef(readMarketplaceManifest(t)) - if marketplaceRef != stableChannelBranch { - t.Skipf("marketplace source.ref = %q (edge branch serves its own channel; the tri-surface ==%q agreement holds only on a main-ref tree); binary-side pair is covered by TestStableChannelBinaryPairAgreesOnMain", marketplaceRef, stableChannelBranch) +// TestPluginBranchCarriesNoMarketplaceManifest is the AC-2 git-state guard: the +// Model B decouple moves the marketplace manifest OUT of the plugin branch into a +// separate marketplace repo, so the plugin branch carries NO +// .claude-plugin/marketplace.json. With no in-branch manifest there is no +// per-release source.ref surface to re-settle — the field that used to be `main` +// on the stable branch and `next` on the edge branch, forcing a re-settle every +// release, is simply absent. The check is git state — the file's presence on disk, +// an independent fact, not a re-read of a value the implementer wrote. (The +// plugin's own .claude-plugin/plugin.json stays; only the marketplace.json moved.) +// This guards the main-bound branch; the next-branch manifest removal + full +// main/next alignment is the separate trunk reconcile. +func TestPluginBranchCarriesNoMarketplaceManifest(t *testing.T) { + manifest := filepath.Join("..", "..", ".claude-plugin", "marketplace.json") + if _, err := os.Stat(manifest); err == nil { + t.Fatalf("%s is present on the plugin branch; Model B moves the marketplace manifest to the separate marketplace repo, so the plugin branch must carry no marketplace.json (and thus no per-release source.ref to re-settle)", manifest) + } else if !os.IsNotExist(err) { + t.Fatalf("stat %s: %v", manifest, err) + } + + // The plugin manifest itself stays on the plugin branch — only the marketplace + // manifest moved out. + plugin := filepath.Join("..", "..", ".claude-plugin", "plugin.json") + if _, err := os.Stat(plugin); err != nil { + t.Fatalf("plugin manifest %s missing: %v (only marketplace.json should move out of the plugin branch)", plugin, err) } +} - surfaces := map[string]string{ - "release.yml stamp target": releaseStampTarget(readReleaseWorkflow(t)), - ".goreleaser.yaml stable devBranch": goreleaserStableDevBranch(readGoreleaserConfig(t)), - ".claude-plugin/marketplace.json ref": marketplaceRef, - } - for name, got := range surfaces { - if got != stableChannelBranch { - t.Errorf("channel surface %q = %q, want %q", name, got, stableChannelBranch) - } +// TestChannelSurfacesDoNotDivergeAfterDecouple re-expresses the old tri-surface +// agreement invariant for Model B. Before the decouple, the channel a release +// served had THREE surfaces that had to agree (release.yml stamp target, +// goreleaser stable devBranch, and an in-branch marketplace.json source.ref), and +// a drift on the manifest ref was a real per-release re-settle hazard. The decouple +// removes the in-branch ref surface entirely (guarded above), so the channel is now +// determined SOLELY by the binary's devBranch stamp selecting a marketplace ENTRY +// NAME (stable=main→spacedock, edge=next→spacedock-edge). The surviving invariant — +// independent values that CAN disagree, so not a tautology — is that the two +// channel devBranch stamps are both present and DISTINCT: if the stable and edge +// builds collapsed to one devBranch, both channels would select the same entry and +// the stable/edge split would vanish. The two values are parsed out of the real +// .goreleaser.yaml builds, so a config that drops the edge build, or sets both to +// the same branch, reds here. +func TestChannelSurfacesDoNotDivergeAfterDecouple(t *testing.T) { + config := readGoreleaserConfig(t) + stable := goreleaserStableDevBranch(config) + edge := goreleaserEdgeDevBranch(config) + if stable == "" { + t.Fatal(".goreleaser.yaml has no spacedock-stable build with a cli.devBranch ldflag") + } + if edge == "" { + t.Fatal(".goreleaser.yaml has no spacedock-edge build with a cli.devBranch ldflag") + } + if stable == edge { + t.Fatalf("stable and edge builds both stamp devBranch=%q; post-decouple the channel IS the devBranch-selected entry name, so identical stamps collapse the two channels onto one marketplace entry", stable) } } diff --git a/skills/integration/marketplace_manifest_test.go b/skills/integration/marketplace_manifest_test.go index a2a49c3e2..b77236493 100644 --- a/skills/integration/marketplace_manifest_test.go +++ b/skills/integration/marketplace_manifest_test.go @@ -1,75 +1,34 @@ -// ABOUTME: AC-2 manifest tests — root marketplace.json self-referential url+ref -// ABOUTME: entry, and .codex-plugin/plugin.json requires-contract brackets binary. +// ABOUTME: AC-2 manifest tests — the plugin branch carries no marketplace.json +// ABOUTME: (Model B), and .codex-plugin/plugin.json requires-contract brackets binary. package integration import ( "encoding/json" "os" "path/filepath" - "regexp" "testing" "github.com/spacedock-dev/spacedock/internal/contract" ) -// calendarVersionRe matches the marketplace entry's calendar key, `0.0.YYYYMMDDNN` -// — the `claude plugin update` re-pull key (AC-2d). It is DISTINCT from the -// plugin.json semver-ish `version` (the release-stamped display version, AC-4). -var calendarVersionRe = regexp.MustCompile(`^0\.0\.\d{10}$`) - -// TestRootMarketplaceSelfReferentialEntry locks AC-2a/2c: the root -// .claude-plugin/marketplace.json names the marketplace `spacedock` with one -// plugin entry also named `spacedock` (so the install id is `spacedock@spacedock`, -// the id the binary hardcodes), sourced url+ref (the no-restructure path; ref is -// the channel branch this manifest serves — next on the edge branch, main on the -// stable branch), carrying a calendar version key. -func TestRootMarketplaceSelfReferentialEntry(t *testing.T) { - path := filepath.Join(repoRoot(t), ".claude-plugin", "marketplace.json") - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read root marketplace %s: %v", path, err) - } - var mp struct { - Name string `json:"name"` - Plugins []struct { - Name string `json:"name"` - Source json.RawMessage `json:"source"` - Version string `json:"version"` - } `json:"plugins"` - } - if err := json.Unmarshal(data, &mp); err != nil { - t.Fatalf("parse marketplace: %v", err) - } - if mp.Name != "spacedock" { - t.Errorf("marketplace name = %q, want spacedock (install id is {entry}@{marketplace})", mp.Name) - } - if len(mp.Plugins) != 1 { - t.Fatalf("marketplace has %d plugin entries, want exactly 1", len(mp.Plugins)) - } - entry := mp.Plugins[0] - if entry.Name != "spacedock" { - t.Errorf("plugin entry name = %q, want spacedock (the binary hardcodes spacedock@spacedock)", entry.Name) - } - if !calendarVersionRe.MatchString(entry.Version) { - t.Errorf("entry version = %q, want a 0.0.YYYYMMDDNN calendar key", entry.Version) +// TestPluginBranchCarriesNoMarketplaceManifest locks AC-2: under Model B the +// marketplace manifest moved OUT of the plugin branch into a separate marketplace +// repo (the two channels — stable pinned to a release tag, edge tracking next HEAD +// — are entries of one source in THAT repo's manifest). So the plugin branch +// carries NO .claude-plugin/marketplace.json; with it gone, there is no in-branch +// source.ref for a release to re-settle. The plugin's own +// .claude-plugin/plugin.json stays — only the marketplace manifest moved. +func TestPluginBranchCarriesNoMarketplaceManifest(t *testing.T) { + marketplace := filepath.Join(repoRoot(t), ".claude-plugin", "marketplace.json") + if _, err := os.Stat(marketplace); err == nil { + t.Fatalf("%s is present; Model B moves the marketplace manifest to the separate marketplace repo, so the plugin branch must carry no marketplace.json", marketplace) + } else if !os.IsNotExist(err) { + t.Fatalf("stat %s: %v", marketplace, err) } - var src struct { - Source string `json:"source"` - URL string `json:"url"` - Ref string `json:"ref"` - } - if err := json.Unmarshal(entry.Source, &src); err != nil { - t.Fatalf("entry source is not the {source,url,ref} object url+ref form: %v", err) - } - if src.Source != "url" { - t.Errorf("entry source.source = %q, want url (source:\".\" is rejected by the host)", src.Source) - } - if src.Ref != "next" && src.Ref != "main" { - t.Errorf("entry source.ref = %q, want a channel branch — next (edge) or main (stable); each branch's marketplace.json points at the channel it serves", src.Ref) - } - if src.URL == "" { - t.Errorf("entry source.url is empty") + plugin := filepath.Join(repoRoot(t), ".claude-plugin", "plugin.json") + if _, err := os.Stat(plugin); err != nil { + t.Fatalf("plugin manifest %s missing: %v (only marketplace.json should move out)", plugin, err) } }