feat: send server client version info

Ayman Bagabas created

Change summary

internal/app/app.go      |  9 ++++++++
internal/cmd/root.go     | 47 +++++++++++++++++++++++++++++++++++------
internal/proto/proto.go  |  1 
internal/server/proto.go | 23 ++++++++++++++++++++
4 files changed, 73 insertions(+), 7 deletions(-)

Detailed changes

internal/app/app.go 🔗

@@ -148,6 +148,15 @@ func (app *App) Events() <-chan tea.Msg {
 	return app.events
 }
 
+// SendEvent pushes a message into the application's events channel.
+// It is non-blocking; the message is dropped if the channel is full.
+func (app *App) SendEvent(msg tea.Msg) {
+	select {
+	case app.events <- msg:
+	default:
+	}
+}
+
 // RunNonInteractive runs the application in non-interactive mode with the
 // given prompt, printing to stdout.
 func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {

internal/cmd/root.go 🔗

@@ -87,18 +87,51 @@ crush --yolo
 crush --data-dir /path/to/custom/.crush
   `,
 	RunE: func(cmd *cobra.Command, args []string) error {
-		app, err := setupAppWithProgressBar(cmd)
+		hostURL, err := server.ParseHostURL(clientHost)
 		if err != nil {
+			return fmt.Errorf("invalid host URL: %v", err)
+		}
+
+		if err := ensureServer(cmd, hostURL); err != nil {
 			return err
 		}
-		defer app.Shutdown()
+
+		appInstance, err := setupAppWithProgressBar(cmd)
+		if err != nil {
+			return err
+		}
+		defer appInstance.Shutdown()
+
+		// Register the workspace with the server so it tracks active
+		// clients and auto-shuts down when the last one exits.
+		cwd, _ := ResolveCwd(cmd)
+		dataDir, _ := cmd.Flags().GetString("data-dir")
+		debug, _ := cmd.Flags().GetBool("debug")
+		yolo, _ := cmd.Flags().GetBool("yolo")
+
+		c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
+		if err != nil {
+			return fmt.Errorf("failed to create client: %v", err)
+		}
+
+		ws, err := c.CreateWorkspace(cmd.Context(), proto.Workspace{
+			Path:    cwd,
+			DataDir: dataDir,
+			Debug:   debug,
+			YOLO:    yolo,
+			Version: version.Version,
+		})
+		if err != nil {
+			return fmt.Errorf("failed to register workspace: %v", err)
+		}
+		defer func() { _ = c.DeleteWorkspace(cmd.Context(), ws.ID) }()
 
 		event.AppInitialized()
 
 		// Set up the TUI.
 		var env uv.Environ = os.Environ()
 
-		com := common.DefaultCommon(app)
+		com := common.DefaultCommon(appInstance)
 		model := ui.New(com)
 
 		program := tea.NewProgram(
@@ -107,7 +140,7 @@ crush --data-dir /path/to/custom/.crush
 			tea.WithContext(cmd.Context()),
 			tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state
 		)
-		go app.Subscribe(program)
+		go appInstance.Subscribe(program)
 
 		if _, err := program.Run(); err != nil {
 			event.Error(err)
@@ -283,7 +316,8 @@ func setupClientApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, *prot
 }
 
 // ensureServer auto-starts a detached server if the socket file does not
-// exist.
+// exist. When connecting to an existing server, it waits for the health
+// endpoint to respond.
 func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
 	switch hostURL.Scheme {
 	case "unix", "npipe":
@@ -308,9 +342,8 @@ func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
 		if err != nil {
 			return fmt.Errorf("failed to initialize crush server: %v", err)
 		}
-	default:
-		// TCP: assume server is already running.
 	}
+
 	return nil
 }
 

internal/proto/proto.go 🔗

@@ -16,6 +16,7 @@ type Workspace struct {
 	YOLO    bool           `json:"yolo,omitempty"`
 	Debug   bool           `json:"debug,omitempty"`
 	DataDir string         `json:"data_dir,omitempty"`
+	Version string         `json:"version,omitempty"`
 	Config  *config.Config `json:"config,omitempty"`
 	Env     []string       `json:"env,omitempty"`
 }

internal/server/proto.go 🔗

@@ -16,6 +16,7 @@ import (
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/proto"
 	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/util"
 	"github.com/charmbracelet/crush/internal/version"
 	"github.com/google/uuid"
 )
@@ -541,6 +542,16 @@ func (c *controllerV1) handleDeleteWorkspaces(w http.ResponseWriter, r *http.Req
 		ws.App.Shutdown()
 	}
 	c.workspaces.Del(id)
+
+	// When the last workspace is removed, shut down the server.
+	if c.workspaces.Len() == 0 {
+		slog.Info("Last workspace removed, shutting down server...")
+		go func() {
+			if err := c.Shutdown(context.Background()); err != nil {
+				slog.Error("Failed to shutdown server", "error", err)
+			}
+		}()
+	}
 }
 
 func (c *controllerV1) handleGetWorkspace(w http.ResponseWriter, r *http.Request) {
@@ -618,6 +629,18 @@ func (c *controllerV1) handlePostWorkspaces(w http.ResponseWriter, r *http.Reque
 	}
 
 	c.workspaces.Set(id, ws)
+
+	if args.Version != "" && args.Version != version.Version {
+		slog.Warn("Client/server version mismatch",
+			"client", args.Version,
+			"server", version.Version,
+		)
+		appWorkspace.SendEvent(util.NewWarnMsg(fmt.Sprintf(
+			"Server version %q differs from client version %q. Consider restarting the server.",
+			version.Version, args.Version,
+		)))
+	}
+
 	jsonEncode(w, proto.Workspace{
 		ID:      id,
 		Path:    args.Path,