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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,11 @@ dist/
# committed to the code branch (zero code-branch churn). A fresh clone re-creates
# the checkout with `spacedock state init`.
**/.spacedock-state/

# Agent and sandbox working dirs — never committed, and never treated as repo
# content by workflow discovery (the discovery walk reads these `.gitignore`
# directory patterns, so the agent-worktree `docs/dev` copies under .claude/ drop
# out rather than being counted as workflows). .safehouse is the launch sandbox
# profile; .claude holds Claude Code's agent worktrees (full repo checkouts).
.safehouse
.claude/
108 changes: 95 additions & 13 deletions internal/cli/frontdoor.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"github.com/spacedock-dev/spacedock/internal/contract"
"github.com/spacedock-dev/spacedock/internal/safehouse"
"github.com/spacedock-dev/spacedock/internal/status"
)

// bootstrapPrompt is the fixed launch-and-go message appended as the last inner
Expand Down Expand Up @@ -117,6 +118,81 @@ func executableFile(path string) bool {
return info.Mode().Perm()&0o111 != 0
}

// noPluginRemedy is the host-correct manual remedy printed when no plugin is
// installed and the operator opted out of auto-install with --no-install. It
// names the host's own install and bootstrap commands — a codex run never says
// `claude`. The caller owns this message (not gateHost) because the response to
// NoPluginFound is non-uniform: auto-install by default, refuse-and-instruct
// under --no-install.
func noPluginRemedy(host string) string {
return fmt.Sprintf(
"Spacedock: no installed %s plugin found. "+
"Run `spacedock install --host %s` (or `spacedock %s --skip-contract-check` to bootstrap).",
host, host, host)
}

// launchBanner writes a short pre-launch orientation banner to w before the host
// is handed control: the spacedock version, the workflow detected from dir, and a
// one-line orientation pointer. Callers suppress it on a resume (the operator is
// continuing a session, not starting one).
func launchBanner(host, dir string, w io.Writer) {
fmt.Fprintf(w, "spacedock %s · first officer launching %s\n", Version, host)
fmt.Fprintf(w, "Workflow: %s\n", detectedWorkflow(dir))
fmt.Fprintf(w, "%s is starting as your first officer; run `spacedock status` inside the session for the queue.\n", host)
}

// detectedWorkflow names the workflow the launch belongs to, found from ANY
// launch dir. When dir is inside a git repo, the whole repo is scanned downward
// (status.DiscoverWorkflows, which prunes the linked/agent-worktree + VCS noise),
// so launching from the repo root resolves the repo's real workflow rather than
// missing it. A single workflow is named by its path relative to the repo root
// (e.g. `docs/dev`); two or more report the count plus a pointer to `spacedock
// status`. With no enclosing git repo, a bounded walk-up names a workflow at or
// above dir. The result is never the cwd-relative `.`/`..`; "none detected
// (launching anyway)" is shown when no workflow is found.
func detectedWorkflow(dir string) string {
const noneDetected = "none detected (launching anyway)"
repoRoot := status.FindGitRoot(dir)
gitEntry := filepath.Join(repoRoot, ".git")
if dirExists(gitEntry) || fileExists(gitEntry) { // .git is a dir (repo) or a file (worktree gitlink)
workflows := status.DiscoverWorkflows(repoRoot)
switch len(workflows) {
case 0:
return noneDetected
case 1:
return workflowLabel(repoRoot, workflows[0])
default:
return fmt.Sprintf("%d workflows detected (run `spacedock status` to pick)", len(workflows))
}
}
// No enclosing git repo: a bounded walk-up names a workflow at or above dir.
if workflowDir, ok := status.DiscoverWorkflowDir(dir); ok {
return workflowLabel(repoRoot, workflowDir)
}
return noneDetected
}

// workflowLabel renders a workflow dir as a recognizable path relative to base
// (e.g. `docs/dev` relative to the repo root), falling back to the workflow dir's
// own base name. It never returns `.` or a `..`-escaping path. Both paths are
// resolved through symlinks first so a base that is a symlinked parent (e.g.
// macOS's /var → /private/var temp dirs) still yields the clean relative path
// rather than a spurious `..`-escape.
func workflowLabel(base, workflowDir string) string {
if rel, err := filepath.Rel(realpath(base), realpath(workflowDir)); err == nil && rel != "." && !strings.HasPrefix(rel, "..") {
return rel
}
return filepath.Base(workflowDir)
}

// realpath resolves path through symlinks, returning the original on error.
func realpath(path string) string {
if resolved, err := filepath.EvalSymlinks(path); err == nil {
return resolved
}
return path
}

// gateHost resolves the installed manifest for host and compares it against
// CONTRACT_VERSION, returning the verdict so the caller can distinguish a
// missing plugin (NoPluginFound — recoverable by installing) from an
Expand All @@ -127,8 +203,9 @@ func executableFile(path string) bool {
// inspects the VERDICT, not a doctor exit code: RunDoctor maps no-plugin-found to
// exit 0 (a non-fatal report), so a non-empty installPath to a missing manifest
// would otherwise slip through as "compatible". gateHost prints the actionable
// remedy for every non-Compatible verdict; the caller decides whether to act on
// it (auto-install) or fail fast.
// remedy for every non-Compatible verdict EXCEPT NoPluginFound, whose message the
// caller owns (it auto-installs by default and only refuses under --no-install,
// so the right wording depends on the caller's choice).
func gateHost(ops hostOps, host string, stderr io.Writer) contract.Verdict {
manifestPath, err := ops.ResolveManifest(host)
if err != nil {
Expand All @@ -138,17 +215,10 @@ func gateHost(ops hostOps, host string, stderr io.Writer) contract.Verdict {
return contract.MalformedRange
}
if manifestPath == "" {
fmt.Fprintf(stderr,
"Spacedock: no installed %s plugin found. "+
"Run `spacedock install --host %s` (or `spacedock claude --skip-contract-check` to bootstrap).\n", host, host)
return contract.NoPluginFound
}
res := contract.ManifestVerdict(manifestPath, host, devBranch, Version)
if res.Verdict == contract.NoPluginFound {
fmt.Fprintf(stderr,
"Spacedock: the installed %s plugin reported a manifest path that does not exist (%s). "+
"Run `spacedock install --host %s` (or `spacedock claude --skip-contract-check` to bootstrap).\n",
host, manifestPath, host)
return contract.NoPluginFound
}
if res.Verdict != contract.Compatible {
Expand Down Expand Up @@ -193,11 +263,14 @@ func runClaude(ctx context.Context, args []string, dir string, ops hostOps, look
case contract.NoPluginFound:
// No plugin on disk: the single command the user typed should yield a
// working session. Auto-install the plugin then launch, unless the
// operator opted out with --no-install (gateHost already printed the
// instruct remedy, so just fail fast).
// operator opted out with --no-install. The caller owns the NoPluginFound
// message (gateHost stays silent for it): announce the install on the
// default arm, print the host-correct manual remedy on the refuse arm.
if fd.noInstall {
fmt.Fprintln(stderr, noPluginRemedy("claude"))
return 1
}
fmt.Fprintf(stderr, "Installing the %s plugin…\n", "claude")
if _, err := ops.Install("claude", marketplaceSource, devBranch); err != nil {
fmt.Fprintf(stderr, "spacedock claude: auto-install failed: %v\n", err)
return 1
Expand All @@ -213,6 +286,9 @@ func runClaude(ctx context.Context, args []string, dir string, ops hostOps, look

wrap := safehouse.Present(dir) || fd.forceSafehouse || len(fd.safehouseFlags) > 0
resume := containsResume(fd.passthrough)
if !resume {
launchBanner("claude", dir, stderr)
}
inner := []string{"claude"}
if wrap {
inner = append(inner, "--dangerously-skip-permissions")
Expand Down Expand Up @@ -345,11 +421,14 @@ func runCodex(ctx context.Context, args []string, dir string, ops hostOps, lookP
// proceed to launch
case contract.NoPluginFound:
// No plugin on disk: auto-install then launch, unless the operator opted
// out with --no-install (gateHost already printed the instruct remedy, so
// just fail fast).
// out with --no-install. The caller owns the NoPluginFound message
// (gateHost stays silent for it): announce the install on the default arm,
// print the host-correct manual remedy on the refuse arm.
if fd.noInstall {
fmt.Fprintln(stderr, noPluginRemedy("codex"))
return 1
}
fmt.Fprintf(stderr, "Installing the %s plugin…\n", "codex")
if _, err := ops.Install("codex", marketplaceSource, devBranch); err != nil {
fmt.Fprintf(stderr, "spacedock codex: auto-install failed: %v\n", err)
return 1
Expand All @@ -365,6 +444,9 @@ func runCodex(ctx context.Context, args []string, dir string, ops hostOps, lookP

wrap := safehouse.Present(dir) || fd.forceSafehouse || len(fd.safehouseFlags) > 0
resume := codexResume(fd.passthrough)
if !resume {
launchBanner("codex", dir, stderr)
}
inner := []string{"codex"}
if wrap {
inner = append(inner, "--dangerously-bypass-approvals-and-sandbox")
Expand Down
176 changes: 153 additions & 23 deletions internal/cli/frontdoor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,35 +295,53 @@ func TestClaudeFrontDoorNonEmptyMissingManifestAutoInstalls(t *testing.T) {
})
}

// TestGateRemedyNamesLiveInstallCommand: every gateHost remedy must point at a
// command the binary actually recognizes. After the init->install rename a user
// who hits the gate and is told to "run spacedock init --host …" runs a command
// that now exits 2 (unknown command). Drive each remedy branch (resolve error,
// no plugin, missing manifest) and assert the printed remedy names `spacedock
// install` and never `spacedock init`; then prove the named command resolves by
// feeding it through cli.Run and asserting it is not the unknown-command exit 2.
// TestGateRemedyNamesLiveInstallCommand: every remedy must point at a command
// the binary actually recognizes. After the init->install rename a user who hits
// the gate and is told to "run spacedock init --host …" runs a command that now
// exits 2 (unknown command). gateHost owns only the always-fail-fast remedies
// (resolve error → MalformedRange); the NoPluginFound message is the caller's
// (it auto-installs by default, refuses under --no-install), so the no-plugin /
// missing-manifest remedies are asserted on the launcher's --no-install output.
// Each remedy must name `spacedock install` and never `spacedock init`.
func TestGateRemedyNamesLiveInstallCommand(t *testing.T) {
missing := filepath.Join(t.TempDir(), "no-such-dir", ".claude-plugin", "plugin.json")
cases := []struct {
name string
fake *fakeHost

// gateHost owns the resolve-error remedy (a host-CLI failure is a hard fail,
// not a missing plugin). It MUST print here.
t.Run("resolve error (gateHost-owned)", func(t *testing.T) {
var stderr bytes.Buffer
if v := gateHost(&fakeHost{resolveErr: errors.New("host CLI failed")}, "claude", &stderr); v == contract.Compatible {
t.Fatalf("gateHost = Compatible, want denied")
}
assertRemedyNamesInstall(t, stderr.String())
})

// The NoPluginFound remedies (no plugin, phantom manifest) are caller-owned:
// gateHost no longer prints for them, so assert the remedy on the launcher's
// --no-install refuse output.
noPluginCases := []struct {
name string
manifest string
}{
{"resolve error", &fakeHost{resolveErr: errors.New("host CLI failed")}},
{"no plugin", &fakeHost{manifest: ""}},
{"missing manifest", &fakeHost{manifest: missing}},
{"no plugin", ""},
{"missing manifest", missing},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var stderr bytes.Buffer
if v := gateHost(tc.fake, "claude", &stderr); v == contract.Compatible {
t.Fatalf("gateHost = Compatible, want denied for %s", tc.name)
for _, tc := range noPluginCases {
t.Run(tc.name+" (caller-owned via --no-install)", func(t *testing.T) {
fake := &fakeHost{manifest: tc.manifest}
var stdout, stderr bytes.Buffer
if code := runClaude(context.Background(), []string{"--no-install"}, t.TempDir(), fake, lookFound, &stdout, &stderr); code == 0 {
t.Fatalf("exit = 0, want non-zero with --no-install and no plugin")
}
remedy := stderr.String()
if !strings.Contains(remedy, "spacedock install") {
t.Fatalf("remedy does not name the live install command: %q", remedy)
assertRemedyNamesInstall(t, stderr.String())
})
t.Run(tc.name+" (gateHost stays silent)", func(t *testing.T) {
var stderr bytes.Buffer
if v := gateHost(&fakeHost{manifest: tc.manifest}, "claude", &stderr); v != contract.NoPluginFound {
t.Fatalf("gateHost verdict = %v, want NoPluginFound", v)
}
if strings.Contains(remedy, "spacedock init") {
t.Fatalf("remedy names the removed init command (exits 2): %q", remedy)
if stderr.Len() != 0 {
t.Fatalf("gateHost printed for NoPluginFound (caller owns the message): %q", stderr.String())
}
})
}
Expand All @@ -343,6 +361,118 @@ func TestGateRemedyNamesLiveInstallCommand(t *testing.T) {
}
}

// TestNoPluginAutoInstallAnnouncesHostCorrectly (AC-A, auto-install arm): with
// no installed plugin the launcher announces `Installing the {host} plugin…` on
// stderr before installing, and a codex run never names `claude` (the old
// gateHost remedy hardcoded a `spacedock claude` hint that was wrong in a codex
// run). Install + launch are observed via the recorded seams, not a string
// match.
func TestNoPluginAutoInstallAnnouncesHostCorrectly(t *testing.T) {
cases := []struct {
name string
host string
run func(args []string, dir string, fake *fakeHost, stderr *bytes.Buffer) int
}{
{"claude", "claude", func(args []string, dir string, fake *fakeHost, stderr *bytes.Buffer) int {
var stdout bytes.Buffer
return runClaude(context.Background(), args, dir, fake, lookFound, &stdout, stderr)
}},
{"codex", "codex", func(args []string, dir string, fake *fakeHost, stderr *bytes.Buffer) int {
var stdout bytes.Buffer
return runCodex(context.Background(), args, dir, fake, lookFound, &stdout, stderr)
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fake := &fakeHost{manifest: ""} // no plugin found
var stderr bytes.Buffer

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

if code != 0 {
t.Fatalf("exit = %d, want 0 (auto-install + launch) (stderr=%q)", code, stderr.String())
}
want := "Installing the " + tc.host + " plugin"
if !strings.Contains(stderr.String(), want) {
t.Fatalf("stderr missing %q announcement: %q", want, stderr.String())
}
if tc.host == "codex" && strings.Contains(stderr.String(), "spacedock claude") {
t.Fatalf("codex auto-install stderr names claude: %q", stderr.String())
}
if len(fake.installCmds) == 0 {
t.Fatalf("install seam not invoked: auto-install did not run")
}
if fake.launchedArg == nil {
t.Fatalf("launch seam not invoked after auto-install")
}
})
}
}

// TestNoPluginNoInstallRemedyIsHostCorrect (AC-A, refuse arm): with --no-install
// and no plugin the launcher prints the manual remedy naming the host-correct
// install/bootstrap commands (`spacedock install --host {host}`, `spacedock
// {host} --skip-contract-check`) and NEVER a `claude` hint in a codex run, then
// exits non-zero without installing or launching. This is the message the caller
// now owns (gateHost stopped printing for NoPluginFound).
func TestNoPluginNoInstallRemedyIsHostCorrect(t *testing.T) {
cases := []struct {
name string
host string
run func(args []string, dir string, fake *fakeHost, stderr *bytes.Buffer) int
}{
{"claude", "claude", func(args []string, dir string, fake *fakeHost, stderr *bytes.Buffer) int {
var stdout bytes.Buffer
return runClaude(context.Background(), args, dir, fake, lookFound, &stdout, stderr)
}},
{"codex", "codex", func(args []string, dir string, fake *fakeHost, stderr *bytes.Buffer) int {
var stdout bytes.Buffer
return runCodex(context.Background(), args, dir, fake, lookFound, &stdout, stderr)
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fake := &fakeHost{manifest: ""} // no plugin found
var stderr bytes.Buffer

code := tc.run([]string{"--no-install"}, t.TempDir(), fake, &stderr)

if code == 0 {
t.Fatalf("exit = 0, want non-zero with --no-install and no plugin")
}
out := stderr.String()
if !strings.Contains(out, "spacedock install --host "+tc.host) {
t.Fatalf("remedy missing host-correct install command for %s: %q", tc.host, out)
}
if !strings.Contains(out, "spacedock "+tc.host+" --skip-contract-check") {
t.Fatalf("remedy missing host-correct bootstrap command for %s: %q", tc.host, out)
}
if tc.host == "codex" && strings.Contains(out, "claude") {
t.Fatalf("codex --no-install remedy names claude: %q", out)
}
if len(fake.installCmds) != 0 {
t.Fatalf("install seam invoked despite --no-install: %v", fake.installCmds)
}
if fake.launchedArg != nil {
t.Fatalf("launch seam invoked despite --no-install: %v", fake.launchedArg)
}
})
}
}

// assertRemedyNamesInstall: every gate / refuse remedy must name the live
// `spacedock install` command and never the removed `spacedock init` (which now
// exits 2).
func assertRemedyNamesInstall(t *testing.T, remedy string) {
t.Helper()
if !strings.Contains(remedy, "spacedock install") {
t.Fatalf("remedy does not name the live install command: %q", remedy)
}
if strings.Contains(remedy, "spacedock init") {
t.Fatalf("remedy names the removed init command (exits 2): %q", remedy)
}
}

// TestClaudeFrontDoorSkipContractCheckBootstrap: the --skip-contract-check
// override launches without resolving the manifest (bootstrap case where the
// plugin is being installed for the first time).
Expand Down
Loading
Loading