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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/site/reference/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ The `Sandbox:` line answers one question — is this process sandboxed? Sandboxe

`spacedock status --boot` reports the same three. The pre-launch banner answers the neighbouring but different question — whether the launch it is about to perform will be wrapped — so its `Sandbox:` line reads in terms of that launch.

The trailing `contract 3` is a frozen compatibility sentinel read only by skill versions predating the current version gate. It prints inside a session only. For what is installed for each host — plugin versions and enablement — use `spacedock doctor`.
The trailing `contract 3` is a frozen compatibility sentinel read only by skill versions predating the current version gate. It prints inside a session only. Use `spacedock doctor --host <host>` to compare this binary with the selected channel plugin. If another channel is enabled, doctor names both plugins and reports the load conflict without changing it. A normal launch repairs that conflict before starting the host; under `--no-install`, run `spacedock install --host <host>` manually.

## Launch

Expand Down
70 changes: 52 additions & 18 deletions internal/cli/frontdoor.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type hostOps interface {
// when no plugin is installed (a distinct, non-error state). A non-nil error
// means the host CLI itself failed.
ResolveManifest(host string) (string, error)
// PluginInventory supplies the launch gate and doctor's sibling-channel view.
PluginInventory(host string) ([]pluginInventoryEntry, error)
// Launch spawns argv with env as a resident child and waits (production) or
// records it (test), returning the host's propagated exit code. The error is
// reserved for a launch failure (host binary not found, fork failure), not a
Expand All @@ -51,6 +53,14 @@ type hostOps interface {
InstallCodexLocalPluginDir(source string) (string, error)
}

// pluginInventoryEntry normalizes the Claude and Codex plugin-list schemas.
type pluginInventoryEntry struct {
ID string
Version string
Installed bool
Enabled bool
}

// 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
Expand Down Expand Up @@ -322,8 +332,8 @@ func gateHost(ops hostOps, host string, stderr io.Writer) contract.Result {
}
}

// resolveHealableGate runs the version gate for host and, for its two healable
// verdicts (NoPluginFound, TooOldPlugin), either auto-installs the plugin and
// resolveHealableGate runs the version gate for host and, for its healable
// states (NoPluginFound, TooOldPlugin, or an enabled sibling), auto-installs and
// re-gates ONCE (the default) or refuses with the host-correct remedy
// (--no-install) — D6's shared "a single command yields a working session"
// contract for both front doors. A too-old-plugin heal announces "Refreshing"
Expand All @@ -334,34 +344,58 @@ func gateHost(ops hostOps, host string, stderr io.Writer) contract.Result {
// already on stderr).
func resolveHealableGate(ops hostOps, host string, noInstall bool, stderr io.Writer) bool {
res := gateHost(ops, host, stderr)
announce := ""
switch res.Verdict {
case contract.Compatible:
return true
case contract.NoPluginFound, contract.TooOldPlugin:
if noInstall {
printHealableRemedy(host, res, stderr)
inventory, err := ops.PluginInventory(host)
if err != nil {
fmt.Fprintf(stderr, "Spacedock: could not verify the %s plugin enablement state: %v.\n", host, err)
fmt.Fprintf(stderr, "Run `spacedock install --host %s` before launching.\n", host)
return false
}
announce := "Installing the " + host + " plugin…"
if _, conflict := enabledSiblingPlugin(inventory); !conflict {
return true
}
announce = "Refreshing the " + host + " plugin to remove its enabled sibling…"
case contract.NoPluginFound, contract.TooOldPlugin:
announce = "Installing the " + host + " plugin…"
if res.Verdict == contract.TooOldPlugin {
announce = "Refreshing the " + host + " plugin…"
}
fmt.Fprintln(stderr, announce)
if _, err := ops.Install(host, channelMarketplaceSource(devBranch), devBranch); err != nil {
fmt.Fprintf(stderr, "spacedock %s: auto-install failed: %v\n", host, err)
return false
}
regate := gateHost(ops, host, stderr)
if regate.Verdict != contract.Compatible {
printHealableRemedy(host, regate, stderr)
return false
}
return true
default:
// too-old-binary / malformed-version: gateHost already printed the
// remedy. Fail fast — auto-installing would not fix an incompatibility.
return false
}
if noInstall {
if res.Verdict == contract.Compatible {
printSiblingRemedy(host, stderr)
} else {
printHealableRemedy(host, res, stderr)
}
return false
}
fmt.Fprintln(stderr, announce)
if _, err := ops.Install(host, channelMarketplaceSource(devBranch), devBranch); err != nil {
fmt.Fprintf(stderr, "spacedock %s: auto-install failed: %v\n", host, err)
return false
}
regate := gateHost(ops, host, stderr)
if regate.Verdict != contract.Compatible {
printHealableRemedy(host, regate, stderr)
return false
}
inventory, err := ops.PluginInventory(host)
if _, conflict := enabledSiblingPlugin(inventory); err != nil || conflict {
fmt.Fprintf(stderr, "Spacedock: the %s plugin repair did not leave one enabled channel.\n", host)
printSiblingRemedy(host, stderr)
return false
}
return true
}

func printSiblingRemedy(host string, stderr io.Writer) {
fmt.Fprintf(stderr, "Run `spacedock install --host %s` to keep only the %s channel.\n", host, selectedChannelWord())
}

// printHealableRemedy prints the caller-owned remedy for a NoPluginFound or
Expand Down
168 changes: 159 additions & 9 deletions internal/cli/frontdoor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
Expand All @@ -27,15 +28,19 @@ type fakeHost struct {
// empty (the default), ResolveManifest keeps returning the pre-install
// manifest even after Install — simulating an install that does NOT change
// resolution, the "second miss" case.
manifestAfterInstall string
installed bool
resolveErr error
launchedArg []string // argv captured by Launch
launchedEnv []string // env captured by Launch
launchCode int // host exit code Launch returns (default 0)
launchErr error
installCmds []string // host commands captured by Install
installOut string
manifestAfterInstall string
installed bool
resolveErr error
launchedArg []string // argv captured by Launch
launchedEnv []string // env captured by Launch
launchCode int // host exit code Launch returns (default 0)
launchErr error
installCmds []string // host commands captured by Install
installOut string
inventory []pluginInventoryEntry
inventoryAfterInstall []pluginInventoryEntry
inventoryErr error
inventoryCalls int
}

func (f *fakeHost) ResolveManifest(host string) (string, error) {
Expand All @@ -45,6 +50,14 @@ func (f *fakeHost) ResolveManifest(host string) (string, error) {
return f.manifest, f.resolveErr
}

func (f *fakeHost) PluginInventory(host string) ([]pluginInventoryEntry, error) {
f.inventoryCalls++
if f.installed && f.inventoryAfterInstall != nil {
return f.inventoryAfterInstall, f.inventoryErr
}
return f.inventory, f.inventoryErr
}

func (f *fakeHost) Launch(argv []string, env []string) (int, error) {
f.launchedArg = argv
f.launchedEnv = env
Expand All @@ -63,6 +76,143 @@ func (f *fakeHost) InstallCodexLocalPluginDir(source string) (string, error) {
return f.installOut, nil
}

func stableInventory(selected, sibling bool) []pluginInventoryEntry {
return []pluginInventoryEntry{
{ID: "spacedock@spacedock", Version: "0.27.1", Installed: true, Enabled: selected},
{ID: "spacedock@spacedock-edge", Version: "0.28.0-pre0", Installed: true, Enabled: sibling},
}
}

func repeatedStableInventory() []pluginInventoryEntry {
return append(stableInventory(true, false), pluginInventoryEntry{
ID: "spacedock@spacedock-edge", Version: "0.28.0-pre0", Installed: true, Enabled: true,
})
}

func TestStable0271FrontDoorsHealEnabledSiblingBeforeLaunch(t *testing.T) {
withVersion(t, "0.27.1")
savedBranch := devBranch
devBranch = "main"
t.Cleanup(func() { devBranch = savedBranch })

frontDoors := []struct {
name string
run func([]string, *fakeHost, *bytes.Buffer) int
}{
{name: "claude", run: func(args []string, host *fakeHost, stderr *bytes.Buffer) int {
var stdout bytes.Buffer
return runClaude(context.Background(), args, t.TempDir(), host, lookFound, &stdout, stderr)
}},
{name: "codex", run: func(args []string, host *fakeHost, stderr *bytes.Buffer) int {
var stdout bytes.Buffer
return runCodex(context.Background(), args, t.TempDir(), host, lookFound, &stdout, stderr)
}},
}

for _, door := range frontDoors {
t.Run(door.name+"/repeated-scope-auto-heal", func(t *testing.T) {
host := &fakeHost{manifest: writeVersionedManifest(t, "0.27.1"), inventory: repeatedStableInventory(), inventoryAfterInstall: stableInventory(true, false)[:1]}
var stderr bytes.Buffer
if code := door.run(nil, host, &stderr); code != 0 || host.launchedArg == nil {
t.Fatalf("exit=%d launched=%v stderr=%q", code, host.launchedArg != nil, stderr.String())
}
if len(host.installCmds) != 3 || host.inventoryCalls != 2 {
t.Fatalf("install=%v inventoryCalls=%d, want one heal and verification", host.installCmds, host.inventoryCalls)
}
})
t.Run(door.name+"/auto-heal", func(t *testing.T) {
host := &fakeHost{manifest: writeVersionedManifest(t, "0.27.1"), inventory: stableInventory(true, true), inventoryAfterInstall: stableInventory(true, false)[:1]}
var stderr bytes.Buffer
if code := door.run(nil, host, &stderr); code != 0 || host.launchedArg == nil {
t.Fatalf("exit=%d launched=%v stderr=%q", code, host.launchedArg != nil, stderr.String())
}
if len(host.installCmds) != 3 || host.inventoryCalls != 2 {
t.Fatalf("install=%v inventoryCalls=%d, want one heal and verification", host.installCmds, host.inventoryCalls)
}
})
t.Run(door.name+"/no-install", func(t *testing.T) {
host := &fakeHost{manifest: writeVersionedManifest(t, "0.27.1"), inventory: stableInventory(true, true)}
var stderr bytes.Buffer
if code := door.run([]string{"--no-install"}, host, &stderr); code == 0 || host.launchedArg != nil || len(host.installCmds) != 0 {
t.Fatalf("exit=%d launched=%v install=%v", code, host.launchedArg != nil, host.installCmds)
}
want := "Run `spacedock install --host " + door.name + "` to keep only the stable channel.\n"
if stderr.String() != want {
t.Fatalf("stderr=%q, want %q", stderr.String(), want)
}
})
t.Run(door.name+"/inventory-failure", func(t *testing.T) {
host := &fakeHost{manifest: writeVersionedManifest(t, "0.27.1"), inventoryErr: errors.New("host unavailable")}
var stderr bytes.Buffer
if code := door.run(nil, host, &stderr); code == 0 || host.launchedArg != nil || len(host.installCmds) != 0 {
t.Fatalf("exit=%d launched=%v install=%v", code, host.launchedArg != nil, host.installCmds)
}
want := "Spacedock: could not verify the " + door.name + " plugin enablement state: host unavailable.\n" +
"Run `spacedock install --host " + door.name + "` before launching.\n"
if stderr.String() != want {
t.Fatalf("stderr=%q, want %q", stderr.String(), want)
}
})
}
}

func TestDoctorSiblingInventory(t *testing.T) {
withVersion(t, "0.27.1")
savedBranch := devBranch
devBranch = "main"
t.Cleanup(func() { devBranch = savedBranch })
conflicts := []struct {
name, host string
inventory []pluginInventoryEntry
}{{"claude conflict", "claude", stableInventory(true, true)}, {"codex conflict", "codex", stableInventory(true, true)}, {"repeated sibling scopes", "claude", repeatedStableInventory()}}
for _, conflict := range conflicts {
t.Run(conflict.name, func(t *testing.T) {
host := &fakeHost{manifest: writeVersionedManifest(t, "0.27.1"), inventory: conflict.inventory}
var stdout, stderr bytes.Buffer
if code := runDoctor(context.Background(), []string{"--host", conflict.host}, host, &stdout, &stderr); code != 0 {
t.Fatalf("exit=%d stderr=%q", code, stderr.String())
}
want := "OK: spacedock binary 0.27.1 and plugin 0.27.1 are compatible.\n" +
"CONFLICT: " + conflict.host + " can load a different Spacedock plugin than doctor checked.\n" +
" checked: spacedock@spacedock 0.27.1 (installed, enabled)\n" +
" sibling: spacedock@spacedock-edge 0.28.0-pre0 (installed, enabled)\n" +
"Run `spacedock install --host " + conflict.host + "` to keep only the stable channel.\n"
if stdout.String() != want || len(host.installCmds) != 0 {
t.Fatalf("stdout=%q, want %q; doctor installs=%v", stdout.String(), want, host.installCmds)
}
})
}
t.Run("disabled sibling", func(t *testing.T) {
host := &fakeHost{manifest: writeVersionedManifest(t, "0.27.1"), inventory: stableInventory(true, false)}
var stdout, stderr bytes.Buffer
want := "OK: spacedock binary 0.27.1 and plugin 0.27.1 are compatible.\n"
if code := runDoctor(context.Background(), nil, host, &stdout, &stderr); code != 0 || stdout.String() != want {
t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
t.Run("inventory failure", func(t *testing.T) {
host := &fakeHost{manifest: writeVersionedManifest(t, "0.27.1"), inventoryErr: errors.New("host unavailable")}
var stdout, stderr bytes.Buffer
want := "OK: spacedock binary 0.27.1 and plugin 0.27.1 are compatible.\n" +
"INCOMPLETE: doctor checked compatibility but did not read the claude plugin enablement state: host unavailable\n"
if code := runDoctor(context.Background(), nil, host, &stdout, &stderr); code != 0 || stdout.String() != want {
t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
}

func TestParsePluginInventoryLiveSchemas(t *testing.T) {
want := stableInventory(false, true)
claude := `[{"id":"spacedock@spacedock","version":"0.27.1","enabled":false,"installPath":"/stable"},{"id":"spacedock@spacedock-edge","version":"0.28.0-pre0","enabled":true,"installPath":"/edge"}]`
codex := `{"installed":[{"pluginId":"spacedock@spacedock","version":"0.27.1","installed":true,"enabled":false},{"pluginId":"spacedock@spacedock-edge","version":"0.28.0-pre0","installed":true,"enabled":true}]}`
for _, fixture := range []struct{ host, data string }{{"claude", claude}, {"codex", codex}} {
got, err := parsePluginInventory(fixture.host, []byte(fixture.data))
if err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("%s inventory=%#v err=%v, want %#v", fixture.host, got, err, want)
}
}
}

// testBinaryVersion is the deterministic binary version a handful of gating
// tests pin via withVersion when they need an exact, self-chosen relationship to
// a fixture (e.g. the upgrade-hint patch-skew cases). It shares its minor (19)
Expand Down
45 changes: 45 additions & 0 deletions internal/cli/host_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,55 @@ var _ hostOps = execHost{}
// an installed plugin can be present-but-disabled.)
type pluginListEntry struct {
ID string `json:"id"`
PluginID string `json:"pluginId"`
Version string `json:"version"`
InstallPath string `json:"installPath"`
Installed bool `json:"installed"`
Enabled bool `json:"enabled"`
}

// PluginInventory normalizes the host's JSON listing for doctor.
func (execHost) PluginInventory(host string) ([]pluginInventoryEntry, error) {
out, err := exec.Command(host, "plugin", "list", "--json").CombinedOutput()
if err != nil {
return nil, fmt.Errorf("%s plugin list --json: %w (%s)", host, err, strings.TrimSpace(string(out)))
}
return parsePluginInventory(host, out)
}

func parsePluginInventory(host string, data []byte) ([]pluginInventoryEntry, error) {
var raw []pluginListEntry
switch host {
case "claude":
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse claude plugin list --json: %w", err)
}
case "codex":
var envelope struct {
Installed []pluginListEntry `json:"installed"`
}
if err := json.Unmarshal(data, &envelope); err != nil {
return nil, fmt.Errorf("parse codex plugin list --json: %w", err)
}
if envelope.Installed == nil {
return nil, fmt.Errorf("parse codex plugin list --json: missing installed inventory")
}
raw = envelope.Installed
default:
return nil, fmt.Errorf("plugin inventory is unsupported for host %q", host)
}
inventory := make([]pluginInventoryEntry, 0, len(raw))
for _, entry := range raw {
if host == "claude" {
entry.Installed = entry.InstallPath != ""
} else {
entry.ID = entry.PluginID
}
inventory = append(inventory, pluginInventoryEntry{entry.ID, entry.Version, entry.Installed, entry.Enabled})
}
return inventory, nil
}

// ResolveManifest returns the installed spacedock@spacedock plugin manifest path
// for host, or "" (no error) when no plugin is installed. The two hosts resolve
// differently: Claude reports an installPath in `claude plugin list --json`;
Expand Down
Loading