1package cmd
2
3import (
4 "bytes"
5 "context"
6 _ "embed"
7 "errors"
8 "fmt"
9 "io"
10 "io/fs"
11 "log/slog"
12 "net"
13 "net/http"
14 "net/url"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "regexp"
19 "strconv"
20 "strings"
21 "time"
22
23 tea "charm.land/bubbletea/v2"
24 fang "charm.land/fang/v2"
25 "charm.land/lipgloss/v2"
26 "github.com/charmbracelet/colorprofile"
27 "github.com/charmbracelet/crush/internal/app"
28 "github.com/charmbracelet/crush/internal/client"
29 "github.com/charmbracelet/crush/internal/config"
30 "github.com/charmbracelet/crush/internal/db"
31 "github.com/charmbracelet/crush/internal/event"
32 "github.com/charmbracelet/crush/internal/lock"
33 crushlog "github.com/charmbracelet/crush/internal/log"
34 "github.com/charmbracelet/crush/internal/projects"
35 "github.com/charmbracelet/crush/internal/proto"
36 "github.com/charmbracelet/crush/internal/server"
37 "github.com/charmbracelet/crush/internal/session"
38 "github.com/charmbracelet/crush/internal/skills"
39 "github.com/charmbracelet/crush/internal/ui/common"
40 ui "github.com/charmbracelet/crush/internal/ui/model"
41 "github.com/charmbracelet/crush/internal/version"
42 "github.com/charmbracelet/crush/internal/workspace"
43 uv "github.com/charmbracelet/ultraviolet"
44 "github.com/charmbracelet/x/ansi"
45 "github.com/charmbracelet/x/exp/charmtone"
46 xstrings "github.com/charmbracelet/x/exp/strings"
47 "github.com/charmbracelet/x/term"
48 "github.com/spf13/cobra"
49)
50
51var clientHost string
52
53func init() {
54 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
55 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
56 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
57 rootCmd.PersistentFlags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
58 rootCmd.Flags().BoolP("help", "h", false, "Help")
59 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
60 rootCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
61 rootCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
62 rootCmd.MarkFlagsMutuallyExclusive("session", "continue")
63
64 rootCmd.AddCommand(
65 runCmd,
66 dirsCmd,
67 projectsCmd,
68 updateProvidersCmd,
69 logsCmd,
70 logoutCmd,
71 schemaCmd,
72 loginCmd,
73 statsCmd,
74 sessionCmd,
75 )
76}
77
78var rootCmd = &cobra.Command{
79 Use: "crush",
80 Short: "A terminal-first AI assistant for software development",
81 Long: "A glamorous, terminal-first AI assistant for software development and adjacent tasks",
82 Example: `
83# Run in interactive mode
84crush
85
86# Run non-interactively
87crush run "Guess my 5 favorite PokΓ©mon"
88
89# Run a non-interactively with pipes and redirection
90cat README.md | crush run "make this more glamorous" > GLAMOROUS_README.md
91
92# Run with debug logging in a specific directory
93crush --debug --cwd /path/to/project
94
95# Run in yolo mode (auto-accept all permissions; use with care)
96crush --yolo
97
98# Run with custom data directory
99crush --data-dir /path/to/custom/.crush
100
101# Continue a previous session
102crush --session {session-id}
103
104# Continue the most recent session
105crush --continue
106 `,
107 RunE: func(cmd *cobra.Command, args []string) error {
108 sessionID, _ := cmd.Flags().GetString("session")
109 continueLast, _ := cmd.Flags().GetBool("continue")
110
111 ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
112 if err != nil {
113 return err
114 }
115 defer cleanup()
116
117 if sessionID != "" {
118 sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
119 if err != nil {
120 return err
121 }
122 sessionID = sess.ID
123 }
124
125 event.AppInitialized()
126
127 com := common.DefaultCommon(ws)
128 model := ui.New(com, sessionID, continueLast)
129
130 var env uv.Environ = os.Environ()
131 program := tea.NewProgram(
132 model,
133 tea.WithEnvironment(env),
134 tea.WithContext(cmd.Context()),
135 tea.WithFilter(ui.MouseEventFilter),
136 )
137 go ws.Subscribe(program)
138
139 if _, err := program.Run(); err != nil {
140 event.Error(err)
141 slog.Error("TUI run error", "error", err)
142 return errors.New("Crush crashed. If metrics are enabled, we were notified about it. If you'd like to report it, please copy the stacktrace above and open an issue at https://github.com/charmbracelet/crush/issues/new?template=bug.yml") //nolint:staticcheck
143 }
144 return nil
145 },
146}
147
148var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
149 ββββββββ ββββββββ
150 βββββββββββ βββββββββββ
151ββββββββββββββββββββββββββββ
152ββββββββββββββββββββββββββββ
153ββββββββββββββββββββββββββββ
154ββββββββββ ββββββ ββββββββββ
155ββββββββββββββββββββββββββββ
156 ββββββββββββββββββββββββ
157 ββββββββββββββββββββ
158 ββββββββββββββ
159 ββββββ
160`)
161
162// copied from cobra:
163const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
164`
165
166func Execute() {
167 // FIXME: config.Load uses slog internally during provider resolution,
168 // but the file-based logger isn't set up until after config is loaded
169 // (because the log path depends on the data directory from config).
170 // This creates a window where slog calls in config.Load leak to
171 // stderr. We discard early logs here as a workaround. The proper
172 // fix is to remove slog calls from config.Load and have it return
173 // warnings/diagnostics instead of logging them as a side effect.
174 slog.SetDefault(slog.New(slog.DiscardHandler))
175
176 // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
177 // it forward to a bytes.Buffer, write the colored heartbit to it, and then
178 // finally prepend it in the version template.
179 // Unfortunately cobra doesn't give us a way to set a function to handle
180 // printing the version, and PreRunE runs after the version is already
181 // handled, so that doesn't work either.
182 // This is the only way I could find that works relatively well.
183 if term.IsTerminal(os.Stdout.Fd()) {
184 var b bytes.Buffer
185 w := colorprofile.NewWriter(os.Stdout, os.Environ())
186 w.Forward = &b
187 _, _ = w.WriteString(heartbit.String())
188 rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
189 }
190 if err := fang.Execute(
191 context.Background(),
192 rootCmd,
193 fang.WithVersion(version.Version),
194 fang.WithNotifySignal(os.Interrupt),
195 ); err != nil {
196 os.Exit(1)
197 }
198}
199
200// supportsProgressBar tries to determine whether the current terminal supports
201// progress bars by looking into environment variables.
202func supportsProgressBar() bool {
203 if !term.IsTerminal(os.Stderr.Fd()) {
204 return false
205 }
206 termProg := os.Getenv("TERM_PROGRAM")
207 _, isWindowsTerminal := os.LookupEnv("WT_SESSION")
208
209 return isWindowsTerminal || xstrings.ContainsAnyOf(strings.ToLower(termProg), "ghostty", "iterm2", "rio")
210}
211
212// useClientServer returns true when the client/server architecture is
213// enabled via the CRUSH_CLIENT_SERVER environment variable.
214func useClientServer() bool {
215 v, _ := strconv.ParseBool(os.Getenv("CRUSH_CLIENT_SERVER"))
216 return v
217}
218
219// setupWorkspaceWithProgressBar wraps setupWorkspace with an optional
220// terminal progress bar shown during initialization.
221func setupWorkspaceWithProgressBar(cmd *cobra.Command) (workspace.Workspace, func(), error) {
222 showProgress := supportsProgressBar()
223 if showProgress {
224 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
225 }
226
227 ws, cleanup, err := setupWorkspace(cmd)
228
229 if showProgress {
230 _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
231 }
232
233 return ws, cleanup, err
234}
235
236// setupWorkspace returns a Workspace and cleanup function. When
237// CRUSH_CLIENT_SERVER=1, it connects to a server process and returns a
238// ClientWorkspace. Otherwise it creates an in-process app.App and
239// returns an AppWorkspace.
240func setupWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
241 if useClientServer() {
242 return setupClientServerWorkspace(cmd)
243 }
244 return setupLocalWorkspace(cmd)
245}
246
247// setupLocalWorkspace creates an in-process app.App and wraps it in an
248// AppWorkspace.
249func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
250 debug, _ := cmd.Flags().GetBool("debug")
251 yolo, _ := cmd.Flags().GetBool("yolo")
252 dataDir, _ := cmd.Flags().GetString("data-dir")
253 ctx := cmd.Context()
254
255 cwd, err := ResolveCwd(cmd)
256 if err != nil {
257 return nil, nil, err
258 }
259
260 store, err := config.Init(cwd, dataDir, debug)
261 if err != nil {
262 return nil, nil, err
263 }
264
265 cfg := store.Config()
266 store.Overrides().SkipPermissionRequests = yolo
267
268 if err := os.MkdirAll(cfg.Options.DataDirectory, 0o700); err != nil {
269 return nil, nil, fmt.Errorf("failed to create data directory: %q %w", cfg.Options.DataDirectory, err)
270 }
271
272 gitIgnorePath := filepath.Join(cfg.Options.DataDirectory, ".gitignore")
273 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
274 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
275 return nil, nil, fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
276 }
277 }
278
279 if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
280 slog.Warn("Failed to register project", "error", err)
281 }
282
283 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
284 if err != nil {
285 return nil, nil, err
286 }
287
288 logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
289 crushlog.Setup(logFile, debug)
290
291 // Discover skills once before app.New. Local mode hosts a single
292 // workspace per process, so WithGlobalMirror keeps the package
293 // globals (which the TUI reads via skills.GetLatestStates) in sync
294 // with the manager.
295 discoveryCfg := localSkillsDiscoveryConfig(store)
296 allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
297 skillsMgr := skills.NewManager(
298 allSkills, activeSkills, skillStates,
299 skills.WithGlobalMirror(),
300 skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
301 skills.WithWorkingDir(discoveryCfg.WorkingDir),
302 )
303
304 appInstance, err := app.New(ctx, conn, store, skillsMgr)
305 if err != nil {
306 _ = conn.Close()
307 slog.Error("Failed to create app instance", "error", err)
308 return nil, nil, err
309 }
310
311 if shouldEnableMetrics(cfg) {
312 event.Init()
313 }
314
315 ws := workspace.NewAppWorkspace(appInstance, store)
316 cleanup := func() { appInstance.Shutdown() }
317 return ws, cleanup, nil
318}
319
320// localSkillsDiscoveryConfig adapts a *config.ConfigStore to the inputs
321// skills.DiscoverFromConfig expects.
322func localSkillsDiscoveryConfig(store *config.ConfigStore) skills.DiscoveryConfig {
323 opts := store.Config().Options
324 var paths, disabled []string
325 if opts != nil {
326 paths = opts.SkillsPaths
327 disabled = opts.DisabledSkills
328 }
329 var resolver func(string) (string, error)
330 if r := store.Resolver(); r != nil {
331 resolver = r.ResolveValue
332 }
333 return skills.DiscoveryConfig{
334 SkillsPaths: paths,
335 DisabledSkills: disabled,
336 WorkingDir: store.WorkingDir(),
337 Resolver: resolver,
338 }
339}
340
341// setupClientServerWorkspace connects to a server process and wraps the
342// result in a ClientWorkspace.
343func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
344 c, protoWs, cleanupServer, err := connectToServer(cmd)
345 if err != nil {
346 return nil, nil, err
347 }
348
349 clientWs := workspace.NewClientWorkspace(c, *protoWs)
350
351 if protoWs.Config.IsConfigured() {
352 if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
353 slog.Error("Failed to initialize coder agent", "error", err)
354 }
355 }
356
357 return clientWs, cleanupServer, nil
358}
359
360// connectToServer ensures the server is running, creates a client and
361// workspace, and returns a cleanup function that deletes the workspace.
362func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
363 hostURL, err := server.ParseHostURL(clientHost)
364 if err != nil {
365 return nil, nil, nil, fmt.Errorf("invalid host URL: %v", err)
366 }
367
368 if err := ensureServer(cmd, hostURL); err != nil {
369 return nil, nil, nil, err
370 }
371
372 debug, _ := cmd.Flags().GetBool("debug")
373 yolo, _ := cmd.Flags().GetBool("yolo")
374 dataDir, _ := cmd.Flags().GetString("data-dir")
375 ctx := cmd.Context()
376
377 cwd, err := ResolveCwd(cmd)
378 if err != nil {
379 return nil, nil, nil, err
380 }
381
382 c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
383 if err != nil {
384 return nil, nil, nil, err
385 }
386
387 wsReq := proto.Workspace{
388 Path: cwd,
389 DataDir: dataDir,
390 Debug: debug,
391 YOLO: yolo,
392 Version: version.Version,
393 Env: os.Environ(),
394 }
395
396 ws, err := c.CreateWorkspace(ctx, wsReq)
397 if err != nil {
398 return nil, nil, nil, fmt.Errorf("failed to create workspace: %v", err)
399 }
400
401 if shouldEnableMetrics(ws.Config) {
402 event.Init()
403 }
404
405 if ws.Config != nil {
406 logFile := filepath.Join(ws.Config.Options.DataDirectory, "logs", "crush.log")
407 crushlog.Setup(logFile, debug)
408 }
409
410 cleanup := func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }
411 return c, ws, cleanup, nil
412}
413
414// ensureServer auto-starts a detached server if the socket file does not
415// exist. When the socket exists, it verifies that the running server
416// version matches the client; on mismatch it shuts down the old server
417// and starts a fresh one.
418func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
419 // Initialize the persistent log here so stale-socket diagnostics
420 // emitted before connectToServer runs are captured in the per-host
421 // server log file. crushlog.Setup uses sync.Once internally, so the
422 // later call from connectToServer becomes a no-op.
423 debug, _ := cmd.Flags().GetBool("debug")
424 logFile := filepath.Join(config.GlobalCacheDir(), "server-"+safeHostName(hostURL), "crush.log")
425 crushlog.Setup(logFile, debug)
426
427 switch hostURL.Scheme {
428 case "unix", "npipe":
429 needsStart := false
430 _, statErr := os.Stat(hostURL.Host)
431 switch {
432 case statErr == nil:
433 // Probe the socket explicitly before the version-check
434 // path. A stale unix socket file (the previous server
435 // exited without cleaning up) would otherwise make
436 // restartIfStale spin on a non-responsive endpoint; here
437 // we detect it with a short DialTimeout and remove the
438 // orphaned file so the normal spawn path can run.
439 if hostURL.Scheme == "unix" {
440 conn, dialErr := net.DialTimeout( //nolint:noctx
441 hostURL.Scheme, hostURL.Host, 200*time.Millisecond,
442 )
443 if dialErr == nil {
444 conn.Close()
445 } else if server.IsStaleSocketErr(dialErr) {
446 slog.Warn("Stale socket detected, removing",
447 "path", hostURL.Host, "error", dialErr)
448 if err := os.Remove(hostURL.Host); err != nil && !errors.Is(err, fs.ErrNotExist) {
449 return fmt.Errorf("failed to remove stale server socket %q: %v", hostURL.Host, err)
450 }
451 needsStart = true
452 break
453 }
454 }
455 restarted, err := restartIfStale(cmd, hostURL)
456 if err != nil {
457 slog.Warn("Failed to check server version", "error", err)
458 }
459 needsStart = restarted || err != nil
460 case errors.Is(statErr, fs.ErrNotExist):
461 needsStart = true
462 default:
463 slog.Warn("Unexpected error stat'ing server socket, attempting cleanup",
464 "path", hostURL.Host, "error", statErr)
465 if err := os.Remove(hostURL.Host); err != nil && !errors.Is(err, fs.ErrNotExist) {
466 return fmt.Errorf("failed to remove stale server socket %q: %v", hostURL.Host, err)
467 }
468 needsStart = true
469 }
470
471 if needsStart {
472 if err := spawnAndWaitReady(cmd, hostURL); err != nil {
473 return fmt.Errorf("failed to initialize crush server: %v", err)
474 }
475 return nil
476 }
477
478 if err := waitForServerReady(cmd.Context(), hostURL); err != nil {
479 return fmt.Errorf("failed to initialize crush server: %v", err)
480 }
481 }
482
483 return nil
484}
485
486// spawnAndWaitReady serializes the spawn-and-wait-for-readiness sequence
487// across concurrent clients via an exclusive flock on
488// $XDG_CACHE_HOME/crush/server-<safeHost>/start.lock.
489//
490// After acquiring the lock it re-probes readiness so that a client that
491// blocked while another client was spawning can skip its own spawn and
492// just use the now-running server. The lock is held only for the
493// duration of "spawn + readiness probe" and released before the caller
494// resumes its normal lifetime.
495func spawnAndWaitReady(cmd *cobra.Command, hostURL *url.URL) error {
496 chDir, err := perHostServerDir(hostURL)
497 if err != nil {
498 return err
499 }
500 release, err := lock.File(cmd.Context(), filepath.Join(chDir, "start.lock"))
501 if err != nil {
502 // If the lock itself is unavailable, fall back to the
503 // unsynchronized path rather than blocking the user.
504 slog.Warn("Failed to acquire spawn lock, proceeding without single-flight", "error", err)
505 if err := startDetachedServer(cmd, hostURL); err != nil {
506 return err
507 }
508 return waitForServerReady(cmd.Context(), hostURL)
509 }
510 defer release()
511
512 // Another client may have just finished spawning while we were
513 // waiting on the lock; if the server is already responsive, skip
514 // the spawn entirely.
515 probeCtx, cancel := context.WithTimeout(cmd.Context(), 200*time.Millisecond)
516 probeErr := quickHealthProbe(probeCtx, hostURL)
517 cancel()
518 if probeErr == nil {
519 return nil
520 }
521
522 if err := startDetachedServer(cmd, hostURL); err != nil {
523 return err
524 }
525 return waitForServerReady(cmd.Context(), hostURL)
526}
527
528// quickHealthProbe issues a single readiness request with the caller's
529// context and returns nil iff the server is responsive right now.
530func quickHealthProbe(ctx context.Context, hostURL *url.URL) error {
531 httpClient, reqURL, err := readinessHTTPClient(hostURL)
532 if err != nil {
533 return err
534 }
535 return probeHealth(ctx, httpClient, reqURL, hostURL)
536}
537
538// perHostServerDir returns (and creates) the cache directory used for
539// per-host server state (logs, start.lock, etc.). The path is derived
540// from the parsed host URL rather than the global flag so the same key
541// is computed regardless of where the host came from.
542func perHostServerDir(hostURL *url.URL) (string, error) {
543 chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeHostName(hostURL))
544 if err := os.MkdirAll(chDir, 0o700); err != nil {
545 return "", fmt.Errorf("failed to create server working directory: %v", err)
546 }
547 return chDir, nil
548}
549
550// safeHostName returns a filesystem-safe identifier for hostURL,
551// suitable for use as a directory name. It mirrors the input shape of
552// the --host flag so client and server compute the same key.
553func safeHostName(hostURL *url.URL) string {
554 return safeNameRegexp.ReplaceAllString(
555 hostURL.Scheme+"://"+hostURL.Host+hostURL.Path, "_",
556 )
557}
558
559// serverReadyTimeout returns the total budget for the readiness probe.
560// Overridable via CRUSH_SERVER_READY_TIMEOUT (parsed as a Go duration).
561func serverReadyTimeout() time.Duration {
562 const def = 10 * time.Second
563 v := os.Getenv("CRUSH_SERVER_READY_TIMEOUT")
564 if v == "" {
565 return def
566 }
567 d, err := time.ParseDuration(v)
568 if err != nil || d <= 0 {
569 return def
570 }
571 return d
572}
573
574// waitForServerReady polls GET /v1/health until the server responds with
575// any 2xx status or the total timeout elapses. Each attempt uses a short
576// per-attempt timeout so a hung listener doesn't burn the whole budget.
577//
578// The HTTP transport is built to mirror how *client.Client dials so the
579// same unix socket / npipe / tcp setups all work uniformly here.
580func waitForServerReady(ctx context.Context, hostURL *url.URL) error {
581 httpClient, reqURL, err := readinessHTTPClient(hostURL)
582 if err != nil {
583 return err
584 }
585
586 const perAttempt = 100 * time.Millisecond
587 deadline := time.Now().Add(serverReadyTimeout())
588
589 var lastErr error
590 for {
591 if err := ctx.Err(); err != nil {
592 return err
593 }
594 if time.Now().After(deadline) {
595 if lastErr != nil {
596 return lastErr
597 }
598 return fmt.Errorf("timed out waiting for server readiness")
599 }
600
601 attemptCtx, cancel := context.WithTimeout(ctx, perAttempt)
602 err := probeHealth(attemptCtx, httpClient, reqURL, hostURL)
603 cancel()
604 if err == nil {
605 return nil
606 }
607 lastErr = err
608
609 select {
610 case <-ctx.Done():
611 return ctx.Err()
612 case <-time.After(perAttempt):
613 }
614 }
615}
616
617// readinessHTTPClient builds an *http.Client whose transport dials the
618// server using the same scheme-aware logic as *client.Client (unix
619// socket, named pipe, or tcp).
620func readinessHTTPClient(hostURL *url.URL) (*http.Client, string, error) {
621 c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
622 if err != nil {
623 return nil, "", err
624 }
625
626 tr := http.DefaultTransport.(*http.Transport).Clone()
627 tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
628 return c.Dial(ctx, network, addr)
629 }
630 if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
631 tr.DisableCompression = true
632 }
633
634 httpClient := &http.Client{Transport: tr}
635
636 // For unix sockets / named pipes we still need a syntactically valid
637 // HTTP URL; the actual address is resolved by the dialer.
638 host := hostURL.Host
639 if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
640 host = client.DummyHost
641 }
642 reqURL := (&url.URL{Scheme: "http", Host: host, Path: "/v1/health"}).String()
643 return httpClient, reqURL, nil
644}
645
646// probeHealth issues a single GET to the readiness endpoint and treats
647// any 2xx response as success.
648func probeHealth(ctx context.Context, h *http.Client, reqURL string, hostURL *url.URL) error {
649 req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
650 if err != nil {
651 return err
652 }
653 if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
654 req.Host = client.DummyHost
655 }
656 rsp, err := h.Do(req)
657 if err != nil {
658 return err
659 }
660 defer rsp.Body.Close()
661 _, _ = io.Copy(io.Discard, rsp.Body)
662 if rsp.StatusCode < 200 || rsp.StatusCode >= 300 {
663 return fmt.Errorf("server health check failed: %s", rsp.Status)
664 }
665 return nil
666}
667
668// restartIfStale checks whether the running server matches the current
669// client version. When they differ, it sends a shutdown command and
670// removes the stale socket so the caller can start a fresh server.
671//
672// It returns restarted=true when it has shut down a stale server and the
673// caller must spawn a new one. When the server matches the client version
674// (or the check itself fails), restarted is false.
675func restartIfStale(cmd *cobra.Command, hostURL *url.URL) (restarted bool, err error) {
676 c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
677 if err != nil {
678 return false, err
679 }
680 vi, err := c.VersionInfo(cmd.Context())
681 if err != nil {
682 return false, err
683 }
684 if vi.Version == version.Version && vi.BuildID == version.BuildID {
685 return false, nil
686 }
687 slog.Info(
688 "Server version mismatch, restarting",
689 "server_version", vi.Version,
690 "client_version", version.Version,
691 "server_build_id", vi.BuildID,
692 "client_build_id", version.BuildID,
693 )
694 _ = c.ShutdownServer(cmd.Context())
695 // Give the old process a moment to release the socket.
696 for range 20 {
697 if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
698 break
699 }
700 select {
701 case <-cmd.Context().Done():
702 return true, cmd.Context().Err()
703 case <-time.After(100 * time.Millisecond):
704 }
705 }
706 // Force-remove if the socket is still lingering.
707 _ = os.Remove(hostURL.Host)
708 return true, nil
709}
710
711var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
712
713func startDetachedServer(cmd *cobra.Command, hostURL *url.URL) error {
714 exe, err := os.Executable()
715 if err != nil {
716 return fmt.Errorf("failed to get executable path: %v", err)
717 }
718
719 chDir, err := perHostServerDir(hostURL)
720 if err != nil {
721 return err
722 }
723
724 cmdArgs := []string{"server"}
725 if clientHost != server.DefaultHost() {
726 cmdArgs = append(cmdArgs, "--host", clientHost)
727 }
728
729 // Use context.Background() so the parent's context cancellation does not
730 // kill the spawned server. detachProcess (Setsid on !windows,
731 // DETACHED_PROCESS on windows) is what truly detaches the child from
732 // this process's lifetime.
733 c := exec.CommandContext(context.Background(), exe, cmdArgs...)
734 stdoutPath := filepath.Join(chDir, "stdout.log")
735 stderrPath := filepath.Join(chDir, "stderr.log")
736 detachProcess(c)
737
738 stdout, err := os.Create(stdoutPath)
739 if err != nil {
740 return fmt.Errorf("failed to create stdout log file: %v", err)
741 }
742 defer stdout.Close()
743 c.Stdout = stdout
744
745 stderr, err := os.Create(stderrPath)
746 if err != nil {
747 return fmt.Errorf("failed to create stderr log file: %v", err)
748 }
749 defer stderr.Close()
750 c.Stderr = stderr
751
752 if err := c.Start(); err != nil {
753 return fmt.Errorf("failed to start crush server: %v", err)
754 }
755
756 if err := c.Process.Release(); err != nil {
757 return fmt.Errorf("failed to detach crush server process: %v", err)
758 }
759
760 return nil
761}
762
763func shouldEnableMetrics(cfg *config.Config) bool {
764 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
765 return false
766 }
767 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
768 return false
769 }
770 if cfg.Options.DisableMetrics {
771 return false
772 }
773 return true
774}
775
776func MaybePrependStdin(prompt string) (string, error) {
777 if term.IsTerminal(os.Stdin.Fd()) {
778 return prompt, nil
779 }
780 fi, err := os.Stdin.Stat()
781 if err != nil {
782 return prompt, err
783 }
784 // Check if stdin is a named pipe ( | ) or regular file ( < ).
785 if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
786 return prompt, nil
787 }
788 bts, err := io.ReadAll(os.Stdin)
789 if err != nil {
790 return prompt, err
791 }
792 return string(bts) + "\n\n" + prompt, nil
793}
794
795// resolveWorkspaceSessionID resolves a session ID that may be a full
796// UUID, full hash, or hash prefix. Works against the Workspace
797// interface so both local and client/server paths get hash prefix
798// support.
799func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
800 if sess, err := ws.GetSession(ctx, id); err == nil {
801 return sess, nil
802 }
803
804 sessions, err := ws.ListSessions(ctx)
805 if err != nil {
806 return session.Session{}, err
807 }
808
809 var matches []session.Session
810 for _, s := range sessions {
811 hash := session.HashID(s.ID)
812 if hash == id || strings.HasPrefix(hash, id) {
813 matches = append(matches, s)
814 }
815 }
816
817 switch len(matches) {
818 case 0:
819 return session.Session{}, fmt.Errorf("session not found: %s", id)
820 case 1:
821 return matches[0], nil
822 default:
823 return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
824 }
825}
826
827func ResolveCwd(cmd *cobra.Command) (string, error) {
828 cwd, _ := cmd.Flags().GetString("cwd")
829 if cwd != "" {
830 err := os.Chdir(cwd)
831 if err != nil {
832 return "", fmt.Errorf("failed to change directory: %v", err)
833 }
834 return cwd, nil
835 }
836 cwd, err := os.Getwd()
837 if err != nil {
838 return "", fmt.Errorf("failed to get current working directory: %v", err)
839 }
840 return cwd, nil
841}
842
843func createDotCrushDir(dir string) error {
844 if err := os.MkdirAll(dir, 0o700); err != nil {
845 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
846 }
847
848 gitIgnorePath := filepath.Join(dir, ".gitignore")
849 content, err := os.ReadFile(gitIgnorePath)
850
851 // create or update if old version
852 if os.IsNotExist(err) || string(content) == oldGitIgnore {
853 if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
854 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
855 }
856 }
857
858 return nil
859}
860
861//go:embed gitignore/old
862var oldGitIgnore string
863
864//go:embed gitignore/default
865var defaultGitIgnore string