diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0b29cac3..c426ab18 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -498,12 +498,13 @@ func cwd() string { } // printVersion emits the version line with the contract token, then the sandbox -// posture and a per-runtime install/enablement block. The FIRST line is unchanged +// posture and a per-runtime plugin-version block. The FIRST line is unchanged // — `spacedock (contract )` — and load-bearing: the FO/ensign skills read // `(contract N)` from it, so everything new is appended BELOW line 1 (cobra's auto // version-flag, a bare version string, is deliberately NOT used). The Sandbox line // renders the shared three-way state for dir; the per-runtime block reports each -// host's binary install and spacedock-plugin enablement from the injected probe. +// host's installed spacedock plugin version (and a best-effort enabled marker) +// from the injected probe. func printVersion(w io.Writer, dir string, probe runtimeProbe, lookPath func(string) (string, error)) { fmt.Fprintf(w, "spacedock %s (contract %d)\n", Version, contract.CONTRACT_VERSION) available, _ := safehouse.Available(lookPath) diff --git a/internal/cli/host_runtime.go b/internal/cli/host_runtime.go index 12eaf038..eec9d24c 100644 --- a/internal/cli/host_runtime.go +++ b/internal/cli/host_runtime.go @@ -1,5 +1,5 @@ -// ABOUTME: Per-runtime install/enablement detection for --version — claude/codex -// ABOUTME: plugin-list enablement and pi readiness, behind an injectable probe seam. +// ABOUTME: Per-runtime plugin-version detection for --version — claude/codex +// ABOUTME: manifest version + best-effort enabled marker and pi readiness, behind a probe. package cli import ( @@ -7,83 +7,98 @@ import ( "fmt" "os/exec" "strings" + + "github.com/spacedock-dev/spacedock/internal/contract" ) -// enablement is a host's spacedock-plugin enablement posture. The zero value is -// notEnabled (the safe default: a host we could read but found no enabled -// spacedock entry is not enabled). enablementUnknown is reserved for the probe -// that could not determine enablement — a binary that resolves but whose -// enablement read errored (the sandboxed `codex plugin list` "Operation not -// permitted" mode), distinct from a confidently not-enabled host. -type enablement int +// enabledMarker is the best-effort enabled/disabled marker rendered after a host's +// plugin version. The zero value is markerUnknown: the probe could not read the +// host's enabled-state (a failed or unparseable `plugin list`), so the version +// renders bare with no marker rather than claiming a state we did not observe. +// markerEnabled (the normal case) also renders bare — only markerDisabled appends +// the ` (disabled)` marker, since "enabled" is the unremarkable default. +type enabledMarker int const ( - enablementNotEnabled enablement = iota - enablementEnabled - enablementUnknown + markerUnknown enabledMarker = iota + markerEnabled + markerDisabled ) // runtimeStatus is a host's --version posture: whether the host binary resolves, -// and (when it does) its spacedock-plugin enablement. enablement is meaningless -// when installed is false. +// the installed plugin version (from the resolved manifest; "" when no plugin), a +// best-effort enabled/disabled marker for that plugin, and pi's launch-readiness +// (pi has no marketplace version, so it renders `ready` instead). version, marker, +// and ready are meaningless when installed is false. type runtimeStatus struct { - installed bool - enablement enablement + installed bool + version string + marker enabledMarker + ready bool } -// runtimeProbe reports a host's install + enablement posture. Production backs it -// with the real host CLIs (execRuntimeProbe); tests back it with a fake that pins -// each host's outcome, so --version never shells a live host CLI in the test path. +// runtimeProbe reports a host's install + plugin-version posture. Production backs +// it with the real host CLIs (execRuntimeProbe); tests back it with a fake that +// pins each host's outcome, so --version never shells a live host CLI in the test +// path. type runtimeProbe interface { ProbeRuntime(host string) runtimeStatus } -// runtimeLine renders one host's --version line from its status. An absent binary -// is `not installed`; a probe that could not read enablement is `installed, -// enablement unknown` (never silently not installed); an enabled plugin is -// `installed, spacedock enabled`; otherwise the bare `installed`. +// runtimeLine renders one host's --version line from its status, version-forward. +// An absent host binary is `not installed`. A host with a resolved plugin version +// is `spacedock `, appending ` (disabled)` only when the best-effort +// marker confidently read disabled (markerEnabled and markerUnknown both render +// bare — enabled is the normal case, and an unread marker omits rather than +// invents). A host present with no plugin version is `spacedock not installed`; +// pi's launch-ready model has no version, so it renders `spacedock ready`. func runtimeLine(host string, s runtimeStatus) string { if !s.installed { return host + ": not installed" } - switch s.enablement { - case enablementEnabled: - return host + ": installed, spacedock enabled" - case enablementUnknown: - return host + ": installed, enablement unknown" - default: - return host + ": installed" + if s.version != "" { + line := host + ": spacedock " + s.version + if s.marker == markerDisabled { + line += " (disabled)" + } + return line } + if s.ready { + return host + ": spacedock ready" + } + return host + ": spacedock not installed" } -// claudeEnablement reads a `claude plugin list --json` body and resolves the -// spacedock@spacedock entry's enablement from its `enabled` boolean (AC-4): an -// entry with `enabled:true` is enabled, `enabled:false` (or no spacedock entry at -// all) is not enabled. A body that does not parse is an error so the caller renders -// `enablement unknown` rather than silently downgrading to not-enabled. -func claudeEnablement(body []byte) (enablement, error) { +// claudeMarker reads a `claude plugin list --json` body and resolves the +// spacedock@spacedock entry's best-effort enabled marker from its `enabled` +// boolean: an entry with `enabled:true` is markerEnabled, `enabled:false` is +// markerDisabled. An absent spacedock entry is markerUnknown (the version comes +// from the resolved manifest, so a missing list entry must not be reported as +// disabled). A body that does not parse is an error so the caller renders the bare +// version (markerUnknown) rather than claiming a state it could not read. +func claudeMarker(body []byte) (enabledMarker, error) { var entries []pluginListEntry if err := json.Unmarshal(body, &entries); err != nil { - return enablementUnknown, fmt.Errorf("parse claude plugin list --json: %w", err) + return markerUnknown, fmt.Errorf("parse claude plugin list --json: %w", err) } for _, e := range entries { if e.ID == "spacedock@spacedock" { if e.Enabled { - return enablementEnabled, nil + return markerEnabled, nil } - return enablementNotEnabled, nil + return markerDisabled, nil } } - return enablementNotEnabled, nil + return markerUnknown, nil } -// codexEntryEnabled reports whether the `codex plugin list` text output marks the -// given plugin id as enabled. It mirrors codexEntryInstalled's field-based parse: +// codexEntryDisabled reports whether the `codex plugin list` text output marks the +// given plugin id as disabled. It mirrors codexEntryInstalled's field-based parse: // the id must be a whitespace-delimited field, and a following field (within the -// row, stripped of surrounding `()` and a trailing `,`) must equal `enabled`. The -// codex status renders as `installed, enabled` (table form) or `(installed, -// enabled)` (legacy paren form), so `enabled` is the field after `installed`. -func codexEntryEnabled(listing, id string) bool { +// row, stripped of surrounding `()` and a trailing `,`) must equal `disabled`. An +// absent `disabled` field is not a disabled plugin (an installed row reads +// `installed, enabled`), so the caller treats the unmarked case as enabled. +func codexEntryDisabled(listing, id string) bool { for _, line := range strings.Split(listing, "\n") { fields := strings.Fields(line) idIdx := -1 @@ -97,7 +112,7 @@ func codexEntryEnabled(listing, id string) bool { continue } for _, f := range fields[idIdx+1:] { - if strings.Trim(f, "(),") == "enabled" { + if strings.Trim(f, "(),") == "disabled" { return true } } @@ -111,64 +126,82 @@ type execRuntimeProbe struct{} var _ runtimeProbe = execRuntimeProbe{} -// ProbeRuntime resolves the host binary on PATH, then (when present) reads its -// spacedock-plugin enablement. A binary that does not resolve is not installed; a -// binary that resolves but whose enablement read errors is `enablement unknown` -// (the sandbox-denied probe), never silently downgraded to not-installed. +// ProbeRuntime resolves the host binary on PATH, then (when present) reads the +// installed plugin version and a best-effort enabled marker. A binary that does +// not resolve is not installed. The version is read ROBUSTLY from the resolved +// plugin manifest (execHost.ResolveManifest → the manifest's version, the same +// source doctor and the contract gate read), so it renders even when the +// enabled-state probe (`plugin list`) errors — in that case the marker stays +// markerUnknown and the version shows bare. pi has no marketplace version, so it +// reports launch-readiness instead. func (execRuntimeProbe) ProbeRuntime(host string) runtimeStatus { if _, err := exec.LookPath(host); err != nil { return runtimeStatus{installed: false} } switch host { case "claude": - return runtimeStatus{installed: true, enablement: probeClaudeEnablement()} + return runtimeStatus{installed: true, version: probeVersion(host), marker: probeClaudeMarker()} case "codex": - return runtimeStatus{installed: true, enablement: probeCodexEnablement()} + return runtimeStatus{installed: true, version: probeVersion(host), marker: probeCodexMarker()} case "pi": - return runtimeStatus{installed: true, enablement: probePiEnablement()} + return runtimeStatus{installed: true, ready: probePiReady()} default: - return runtimeStatus{installed: true, enablement: enablementUnknown} + return runtimeStatus{installed: true} + } +} + +// probeVersion resolves the installed plugin manifest for host and reads its +// version, the robust version source `doctor` reads. A resolve error or a missing +// manifest yields "" (no plugin version to show), so the caller renders `spacedock +// not installed`. The marker is read separately and best-effort, so a `plugin +// list` failure never suppresses the version this returns. +func probeVersion(host string) string { + manifestPath, err := execHost{}.ResolveManifest(host) + if err != nil || manifestPath == "" { + return "" } + version, err := contract.ManifestVersion(manifestPath) + if err != nil { + return "" + } + return version } -// probeClaudeEnablement shells `claude plugin list --json` and reads the spacedock -// entry's `enabled` field. A failed command or unparseable body is `enablement -// unknown` (the host resolved, but enablement could not be determined). -func probeClaudeEnablement() enablement { +// probeClaudeMarker shells `claude plugin list --json` and reads the spacedock +// entry's `enabled` field into a best-effort marker. A failed command or +// unparseable body is markerUnknown (the version still renders from the manifest; +// only the marker is omitted). +func probeClaudeMarker() enabledMarker { out, err := exec.Command("claude", "plugin", "list", "--json").Output() if err != nil { - return enablementUnknown + return markerUnknown } - state, err := claudeEnablement(out) + marker, err := claudeMarker(out) if err != nil { - return enablementUnknown + return markerUnknown } - return state + return marker } -// probeCodexEnablement shells `codex plugin list` and reads the spacedock entry's -// `enabled` status from the text listing. A failed command (the sandbox-denied -// "Operation not permitted" mode) is `enablement unknown`; an installed-but-not- -// enabled entry is not enabled. -func probeCodexEnablement() enablement { +// probeCodexMarker shells `codex plugin list` and reads the spacedock entry's +// disabled status from the text listing. A failed command (the sandbox-denied +// "Operation not permitted" mode) is markerUnknown; an explicit disabled row is +// markerDisabled; otherwise the installed-and-enabled default is markerEnabled. +func probeCodexMarker() enabledMarker { out, err := exec.Command("codex", "plugin", "list").CombinedOutput() if err != nil { - return enablementUnknown + return markerUnknown } - if codexEntryEnabled(string(out), "spacedock@spacedock") { - return enablementEnabled + if codexEntryDisabled(string(out), "spacedock@spacedock") { + return markerDisabled } - return enablementNotEnabled + return markerEnabled } -// probePiEnablement runs the existing pi-runtime readiness check (pi has no -// plugin-list model — verified in the spike), so pi's "spacedock enabled" reuses -// piRuntimeLaunchReady (skills + extension present), NOT a plugin probe. Pi -// readiness is a boolean, so it never resolves to `enablement unknown`. -func probePiEnablement() enablement { +// probePiReady runs the existing pi-runtime readiness check (pi has no plugin-list +// or marketplace-version model — verified in the spike), so pi's `spacedock ready` +// reuses piRuntimeLaunchReady (skills + extension present), NOT a plugin probe. +func probePiReady() bool { cfg := piRuntimeConfigFromEnv(nil, cwd(), "") - if piRuntimeLaunchReady(checkPiRuntime(execPiRuntimeOps{}, cfg)) { - return enablementEnabled - } - return enablementNotEnabled + return piRuntimeLaunchReady(checkPiRuntime(execPiRuntimeOps{}, cfg)) } diff --git a/internal/cli/version_claude_enabled_test.go b/internal/cli/version_claude_enabled_test.go index df231f95..085bb802 100644 --- a/internal/cli/version_claude_enabled_test.go +++ b/internal/cli/version_claude_enabled_test.go @@ -1,15 +1,16 @@ -// ABOUTME: AC-4 oracle — claude spacedock-enablement reflects the plugin list -// ABOUTME: `enabled` boolean, not mere presence; a false entry is not "enabled". +// ABOUTME: AC oracle — claude's best-effort enabled/disabled marker reflects the +// ABOUTME: plugin list `enabled` boolean; an absent entry is unknown, not disabled. package cli import "testing" -// TestClaudeEnablementFromListJSON (AC-4) feeds the enablement reader a `claude -// plugin list --json` body with the spacedock entry's `enabled` set true vs false -// (and absent) and asserts the resolved enablement. The fixture JSON — shaped from -// the live spike capture (id + installPath + enabled) — is the independent source -// of truth: an `enabled:false` entry must NOT resolve to enabled. -func TestClaudeEnablementFromListJSON(t *testing.T) { +// TestClaudeMarkerFromListJSON feeds the marker reader a `claude plugin list +// --json` body with the spacedock entry's `enabled` set true vs false (and absent) +// and asserts the resolved best-effort marker. The fixture JSON — shaped from the +// live spike capture (id + installPath + enabled) — is the independent source of +// truth: an `enabled:false` entry resolves to disabled, an absent entry to unknown +// (the version still comes from the manifest, so we never claim it is disabled). +func TestClaudeMarkerFromListJSON(t *testing.T) { const enabledTrue = `[{"id":"spacedock@spacedock","installPath":"/p/0.19.9","enabled":true}, {"id":"other@market","installPath":"/q","enabled":true}]` const enabledFalse = `[{"id":"spacedock@spacedock","installPath":"/p/0.19.9","enabled":false}]` @@ -18,30 +19,30 @@ func TestClaudeEnablementFromListJSON(t *testing.T) { cases := []struct { name string body string - want enablement + want enabledMarker }{ - {"enabled-true", enabledTrue, enablementEnabled}, - {"enabled-false", enabledFalse, enablementNotEnabled}, - {"no-spacedock-entry", noSpacedock, enablementNotEnabled}, + {"enabled-true", enabledTrue, markerEnabled}, + {"enabled-false", enabledFalse, markerDisabled}, + {"no-spacedock-entry", noSpacedock, markerUnknown}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got, err := claudeEnablement([]byte(tc.body)) + got, err := claudeMarker([]byte(tc.body)) if err != nil { - t.Fatalf("claudeEnablement(%s) err = %v, want nil", tc.name, err) + t.Fatalf("claudeMarker(%s) err = %v, want nil", tc.name, err) } if got != tc.want { - t.Fatalf("claudeEnablement(%s) = %v, want %v", tc.name, got, tc.want) + t.Fatalf("claudeMarker(%s) = %v, want %v", tc.name, got, tc.want) } }) } } -// TestClaudeEnablementMalformedJSONErrors (AC-5 feeder) asserts a body that does -// not parse is an error, so the caller renders `enablement unknown` rather than -// silently downgrading to not-enabled. -func TestClaudeEnablementMalformedJSONErrors(t *testing.T) { - if _, err := claudeEnablement([]byte("not json")); err == nil { - t.Fatalf("claudeEnablement(malformed) err = nil, want a parse error so the caller renders enablement unknown") +// TestClaudeMarkerMalformedJSONErrors asserts a body that does not parse is an +// error, so the caller renders the bare version (markerUnknown) rather than +// silently claiming the plugin is disabled. +func TestClaudeMarkerMalformedJSONErrors(t *testing.T) { + if _, err := claudeMarker([]byte("not json")); err == nil { + t.Fatalf("claudeMarker(malformed) err = nil, want a parse error so the caller renders the bare version") } } diff --git a/internal/cli/version_runtime_test.go b/internal/cli/version_runtime_test.go index a0699277..90d45eb8 100644 --- a/internal/cli/version_runtime_test.go +++ b/internal/cli/version_runtime_test.go @@ -1,5 +1,5 @@ -// ABOUTME: AC-3/AC-5 oracles for --version — the load-bearing first line, the -// ABOUTME: Sandbox line, and the per-runtime install/enablement block from stubs. +// ABOUTME: AC oracles for --version — the load-bearing first line, the Sandbox +// ABOUTME: line, and the version-forward per-runtime block rendered from stubs. package cli import ( @@ -12,8 +12,8 @@ import ( "github.com/spacedock-dev/spacedock/internal/contract" ) -// fakeRuntimeProbe pins each host's install/enablement outcome so the per-runtime -// block renders from injected state, never a live host CLI. +// fakeRuntimeProbe pins each host's install/version/marker outcome so the +// per-runtime block renders from injected state, never a live host CLI. type fakeRuntimeProbe struct { status map[string]runtimeStatus } @@ -36,14 +36,13 @@ func renderVersion(probe runtimeProbe) string { // helper allocation-free; the path need not exist for safehouse.Present. const t1TempDir = "/nonexistent-version-dir" -// TestVersionFirstLineUnchanged (AC-3 part 1) pins the load-bearing invariant: the -// FIRST line still matches `^spacedock .* \(contract \d+\)$`, the token the FO and -// ensign skills parse. Appending the sandbox + per-runtime block must not disturb -// line 1. +// TestVersionFirstLineUnchanged pins the load-bearing invariant: the FIRST line +// still matches `^spacedock .* \(contract \d+\)$`, the token the FO and ensign +// skills parse. Appending the sandbox + per-runtime block must not disturb line 1. func TestVersionFirstLineUnchanged(t *testing.T) { probe := fakeRuntimeProbe{status: map[string]runtimeStatus{ - "claude": {installed: true, enablement: enablementEnabled}, - "codex": {installed: true, enablement: enablementNotEnabled}, + "claude": {installed: true, version: "0.20.0", marker: markerEnabled}, + "codex": {installed: true, version: "0.20.0", marker: markerDisabled}, "pi": {installed: false}, }} out := renderVersion(probe) @@ -58,22 +57,22 @@ func TestVersionFirstLineUnchanged(t *testing.T) { } } -// TestVersionPerRuntimeBlock (AC-3 part 2) drives each install/enablement outcome -// through the injected probe and asserts the exact per-runtime line, plus a Sandbox -// line. The expected strings are independent test-supplied values. +// TestVersionPerRuntimeBlock drives each install/version outcome through the +// injected probe and asserts the exact version-forward per-runtime line, plus a +// Sandbox line. The expected strings are independent test-supplied values. func TestVersionPerRuntimeBlock(t *testing.T) { probe := fakeRuntimeProbe{status: map[string]runtimeStatus{ - "claude": {installed: true, enablement: enablementEnabled}, - "codex": {installed: true, enablement: enablementNotEnabled}, - "pi": {installed: false}, + "claude": {installed: true, version: "0.20.0", marker: markerEnabled}, + "codex": {installed: true, version: "0.20.0", marker: markerDisabled}, + "pi": {installed: true, ready: true}, }} out := renderVersion(probe) for _, want := range []string{ "Sandbox: unavailable (safehouse not on PATH)", - "claude: installed, spacedock enabled", - "codex: installed", - "pi: not installed", + "claude: spacedock 0.20.0", + "codex: spacedock 0.20.0 (disabled)", + "pi: spacedock ready", } { if !lineEquals(out, want) { t.Fatalf("version output missing whole line %q:\n%s", want, out) @@ -81,30 +80,67 @@ func TestVersionPerRuntimeBlock(t *testing.T) { } } -// TestVersionEnablementUnknown (AC-5) injects a probe whose enablement read errored -// (binary resolves, but the probe could not determine enablement — the sandboxed -// `codex plugin list` "Operation not permitted" mode). The line must read -// `installed, enablement unknown`, never silently `not installed`. A separate -// binary-absent case asserts `not installed`. -func TestVersionEnablementUnknown(t *testing.T) { +// TestVersionHostAbsentAndNoPlugin asserts the two not-installed shapes: a host +// whose binary is absent reads `: not installed`, while a host that resolves +// but carries no plugin reads `: spacedock not installed`. +func TestVersionHostAbsentAndNoPlugin(t *testing.T) { probe := fakeRuntimeProbe{status: map[string]runtimeStatus{ - "claude": {installed: true, enablement: enablementUnknown}, - "codex": {installed: false}, // binary absent → not installed - "pi": {installed: true, enablement: enablementUnknown}, + "claude": {installed: false}, // binary absent + "codex": {installed: true, version: ""}, // host present, no plugin + "pi": {installed: true, ready: false}, // host present, not launch-ready }} out := renderVersion(probe) for _, want := range []string{ - "claude: installed, enablement unknown", - "codex: not installed", - "pi: installed, enablement unknown", + "claude: not installed", + "codex: spacedock not installed", + "pi: spacedock not installed", } { if !lineEquals(out, want) { t.Fatalf("version output missing whole line %q:\n%s", want, out) } } - // AC-5 guard: an enablement-unknown host must NOT be rendered as not installed. - if lineEquals(out, "claude: not installed") { - t.Fatalf("enablement-unknown claude silently rendered as not installed:\n%s", out) +} + +// TestVersionMarkerUnknownRendersBareVersion is the AC-2 oracle: when the +// enabled/disabled probe could not read state (markerUnknown) but the manifest +// resolved a version, the line shows the bare `spacedock ` with NO marker +// — the version still renders from the manifest even though the probe failed. It +// must never invent an "unknown" word or silently read as not installed. +func TestVersionMarkerUnknownRendersBareVersion(t *testing.T) { + probe := fakeRuntimeProbe{status: map[string]runtimeStatus{ + "claude": {installed: true, version: "0.20.0", marker: markerUnknown}, + "codex": {installed: false}, + "pi": {installed: false}, + }} + out := renderVersion(probe) + + if !lineEquals(out, "claude: spacedock 0.20.0") { + t.Fatalf("marker-unknown claude did not render the bare version line:\n%s", out) + } + // Guard: a probe-failed-but-version-resolved host must NOT read as not installed. + if lineEquals(out, "claude: not installed") || lineEquals(out, "claude: spacedock not installed") { + t.Fatalf("marker-unknown claude with a resolved version rendered as not installed:\n%s", out) + } +} + +// TestVersionVocabularyHasNoEnablementJargon is the AC-1 oracle on the rendered +// output: the noun "enablement" and the retired phrasings must appear nowhere in +// the per-runtime block across the install/version/marker outcomes. +func TestVersionVocabularyHasNoEnablementJargon(t *testing.T) { + probe := fakeRuntimeProbe{status: map[string]runtimeStatus{ + "claude": {installed: true, version: "0.20.0", marker: markerUnknown}, + "codex": {installed: true, version: "0.20.0", marker: markerDisabled}, + "pi": {installed: true, ready: true}, + }} + out := renderVersion(probe) + for _, banned := range []string{ + "enablement", + "spacedock enabled", + "enablement unknown", + } { + if strings.Contains(out, banned) { + t.Fatalf("version output contains retired jargon %q:\n%s", banned, out) + } } } diff --git a/internal/contract/doctor.go b/internal/contract/doctor.go index 1fddff55..c61f3189 100644 --- a/internal/contract/doctor.go +++ b/internal/contract/doctor.go @@ -46,6 +46,16 @@ func readRequiresContract(manifestPath string) (string, error) { return raw, err } +// ManifestVersion reads a plugin manifest JSON and returns its display version, +// the same source the doctor verdict reads. A missing manifest file yields +// errNoManifest; an unparseable manifest yields the parse error. Callers that only +// need the version to show it (e.g. --version's per-runtime block) use this rather +// than the full verdict. +func ManifestVersion(manifestPath string) (string, error) { + version, _, err := readManifest(manifestPath) + return version, err +} + // ManifestVerdict resolves the compatibility verdict for the manifest at // manifestPath against this binary's CONTRACT_VERSION, for the named host. A // missing manifest file yields NoPluginFound; an unparseable manifest JSON yields diff --git a/internal/contract/manifest_version_test.go b/internal/contract/manifest_version_test.go new file mode 100644 index 00000000..2997abe7 --- /dev/null +++ b/internal/contract/manifest_version_test.go @@ -0,0 +1,53 @@ +// ABOUTME: Direct unit test for ManifestVersion — the version source --version's +// ABOUTME: per-runtime block reads; asserts the real fixture version, not just non-empty. +package contract + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// TestManifestVersion drives ManifestVersion against a real fixture manifest and +// asserts the EXACT version string the file declares (0.12.1), so a hardcoded +// return would not pass — the test reads the manifest's own version. The +// missing-file case is errNoManifest (a distinct no-plugin state); an unparseable +// manifest is a parse error so the caller renders the bare version rather than a +// fabricated one. +func TestManifestVersion(t *testing.T) { + got, err := ManifestVersion(filepath.Join("testdata", "compatible.json")) + if err != nil { + t.Fatalf("ManifestVersion(compatible.json) err = %v, want nil", err) + } + if got != "0.12.1" { + t.Fatalf("ManifestVersion(compatible.json) = %q, want %q", got, "0.12.1") + } +} + +// TestManifestVersionMissingFile asserts a manifest path that does not exist yields +// errNoManifest, the no-plugin signal the --version probe collapses to an empty +// version (rendering `spacedock not installed`). +func TestManifestVersionMissingFile(t *testing.T) { + _, err := ManifestVersion(filepath.Join("testdata", "does-not-exist.json")) + if !errors.Is(err, errNoManifest) { + t.Fatalf("ManifestVersion(missing) err = %v, want errNoManifest", err) + } +} + +// TestManifestVersionUnparseable writes a manifest file with invalid JSON and +// asserts ManifestVersion returns a (non-errNoManifest) parse error, so the +// --version probe renders the bare/absent version rather than a fabricated one. +func TestManifestVersionUnparseable(t *testing.T) { + path := filepath.Join(t.TempDir(), "plugin.json") + if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + _, err := ManifestVersion(path) + if err == nil { + t.Fatalf("ManifestVersion(unparseable) err = nil, want a parse error") + } + if errors.Is(err, errNoManifest) { + t.Fatalf("ManifestVersion(unparseable) err = errNoManifest, want a parse error (the file exists, it just does not parse)") + } +}