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 switch hostURL.Scheme {
420 case "unix", "npipe":
421 needsStart := false
422 _, statErr := os.Stat(hostURL.Host)
423 switch {
424 case statErr == nil:
425 restarted, err := restartIfStale(cmd, hostURL)
426 if err != nil {
427 slog.Warn("Failed to check server version", "error", err)
428 }
429 needsStart = restarted || err != nil
430 case errors.Is(statErr, fs.ErrNotExist):
431 needsStart = true
432 default:
433 slog.Warn("Unexpected error stat'ing server socket, attempting cleanup",
434 "path", hostURL.Host, "error", statErr)
435 if err := os.Remove(hostURL.Host); err != nil && !errors.Is(err, fs.ErrNotExist) {
436 return fmt.Errorf("failed to remove stale server socket %q: %v", hostURL.Host, err)
437 }
438 needsStart = true
439 }
440
441 if needsStart {
442 if err := spawnAndWaitReady(cmd, hostURL); err != nil {
443 return fmt.Errorf("failed to initialize crush server: %v", err)
444 }
445 return nil
446 }
447
448 if err := waitForServerReady(cmd.Context(), hostURL); err != nil {
449 return fmt.Errorf("failed to initialize crush server: %v", err)
450 }
451 }
452
453 return nil
454}
455
456// spawnAndWaitReady serializes the spawn-and-wait-for-readiness sequence
457// across concurrent clients via an exclusive flock on
458// $XDG_CACHE_HOME/crush/server-<safeHost>/start.lock.
459//
460// After acquiring the lock it re-probes readiness so that a client that
461// blocked while another client was spawning can skip its own spawn and
462// just use the now-running server. The lock is held only for the
463// duration of "spawn + readiness probe" and released before the caller
464// resumes its normal lifetime.
465func spawnAndWaitReady(cmd *cobra.Command, hostURL *url.URL) error {
466 chDir, err := perHostServerDir(hostURL)
467 if err != nil {
468 return err
469 }
470 release, err := lock.File(cmd.Context(), filepath.Join(chDir, "start.lock"))
471 if err != nil {
472 // If the lock itself is unavailable, fall back to the
473 // unsynchronized path rather than blocking the user.
474 slog.Warn("Failed to acquire spawn lock, proceeding without single-flight", "error", err)
475 if err := startDetachedServer(cmd, hostURL); err != nil {
476 return err
477 }
478 return waitForServerReady(cmd.Context(), hostURL)
479 }
480 defer release()
481
482 // Another client may have just finished spawning while we were
483 // waiting on the lock; if the server is already responsive, skip
484 // the spawn entirely.
485 probeCtx, cancel := context.WithTimeout(cmd.Context(), 200*time.Millisecond)
486 probeErr := quickHealthProbe(probeCtx, hostURL)
487 cancel()
488 if probeErr == nil {
489 return nil
490 }
491
492 if err := startDetachedServer(cmd, hostURL); err != nil {
493 return err
494 }
495 return waitForServerReady(cmd.Context(), hostURL)
496}
497
498// quickHealthProbe issues a single readiness request with the caller's
499// context and returns nil iff the server is responsive right now.
500func quickHealthProbe(ctx context.Context, hostURL *url.URL) error {
501 httpClient, reqURL, err := readinessHTTPClient(hostURL)
502 if err != nil {
503 return err
504 }
505 return probeHealth(ctx, httpClient, reqURL, hostURL)
506}
507
508// perHostServerDir returns (and creates) the cache directory used for
509// per-host server state (logs, start.lock, etc.). The path is derived
510// from the parsed host URL rather than the global flag so the same key
511// is computed regardless of where the host came from.
512func perHostServerDir(hostURL *url.URL) (string, error) {
513 chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeHostName(hostURL))
514 if err := os.MkdirAll(chDir, 0o700); err != nil {
515 return "", fmt.Errorf("failed to create server working directory: %v", err)
516 }
517 return chDir, nil
518}
519
520// safeHostName returns a filesystem-safe identifier for hostURL,
521// suitable for use as a directory name. It mirrors the input shape of
522// the --host flag so client and server compute the same key.
523func safeHostName(hostURL *url.URL) string {
524 return safeNameRegexp.ReplaceAllString(
525 hostURL.Scheme+"://"+hostURL.Host+hostURL.Path, "_",
526 )
527}
528
529// serverReadyTimeout returns the total budget for the readiness probe.
530// Overridable via CRUSH_SERVER_READY_TIMEOUT (parsed as a Go duration).
531func serverReadyTimeout() time.Duration {
532 const def = 10 * time.Second
533 v := os.Getenv("CRUSH_SERVER_READY_TIMEOUT")
534 if v == "" {
535 return def
536 }
537 d, err := time.ParseDuration(v)
538 if err != nil || d <= 0 {
539 return def
540 }
541 return d
542}
543
544// waitForServerReady polls GET /v1/health until the server responds with
545// any 2xx status or the total timeout elapses. Each attempt uses a short
546// per-attempt timeout so a hung listener doesn't burn the whole budget.
547//
548// The HTTP transport is built to mirror how *client.Client dials so the
549// same unix socket / npipe / tcp setups all work uniformly here.
550func waitForServerReady(ctx context.Context, hostURL *url.URL) error {
551 httpClient, reqURL, err := readinessHTTPClient(hostURL)
552 if err != nil {
553 return err
554 }
555
556 const perAttempt = 100 * time.Millisecond
557 deadline := time.Now().Add(serverReadyTimeout())
558
559 var lastErr error
560 for {
561 if err := ctx.Err(); err != nil {
562 return err
563 }
564 if time.Now().After(deadline) {
565 if lastErr != nil {
566 return lastErr
567 }
568 return fmt.Errorf("timed out waiting for server readiness")
569 }
570
571 attemptCtx, cancel := context.WithTimeout(ctx, perAttempt)
572 err := probeHealth(attemptCtx, httpClient, reqURL, hostURL)
573 cancel()
574 if err == nil {
575 return nil
576 }
577 lastErr = err
578
579 select {
580 case <-ctx.Done():
581 return ctx.Err()
582 case <-time.After(perAttempt):
583 }
584 }
585}
586
587// readinessHTTPClient builds an *http.Client whose transport dials the
588// server using the same scheme-aware logic as *client.Client (unix
589// socket, named pipe, or tcp).
590func readinessHTTPClient(hostURL *url.URL) (*http.Client, string, error) {
591 c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
592 if err != nil {
593 return nil, "", err
594 }
595
596 tr := http.DefaultTransport.(*http.Transport).Clone()
597 tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
598 return c.Dial(ctx, network, addr)
599 }
600 if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
601 tr.DisableCompression = true
602 }
603
604 httpClient := &http.Client{Transport: tr}
605
606 // For unix sockets / named pipes we still need a syntactically valid
607 // HTTP URL; the actual address is resolved by the dialer.
608 host := hostURL.Host
609 if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
610 host = client.DummyHost
611 }
612 reqURL := (&url.URL{Scheme: "http", Host: host, Path: "/v1/health"}).String()
613 return httpClient, reqURL, nil
614}
615
616// probeHealth issues a single GET to the readiness endpoint and treats
617// any 2xx response as success.
618func probeHealth(ctx context.Context, h *http.Client, reqURL string, hostURL *url.URL) error {
619 req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
620 if err != nil {
621 return err
622 }
623 if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
624 req.Host = client.DummyHost
625 }
626 rsp, err := h.Do(req)
627 if err != nil {
628 return err
629 }
630 defer rsp.Body.Close()
631 _, _ = io.Copy(io.Discard, rsp.Body)
632 if rsp.StatusCode < 200 || rsp.StatusCode >= 300 {
633 return fmt.Errorf("server health check failed: %s", rsp.Status)
634 }
635 return nil
636}
637
638// restartIfStale checks whether the running server matches the current
639// client version. When they differ, it sends a shutdown command and
640// removes the stale socket so the caller can start a fresh server.
641//
642// It returns restarted=true when it has shut down a stale server and the
643// caller must spawn a new one. When the server matches the client version
644// (or the check itself fails), restarted is false.
645func restartIfStale(cmd *cobra.Command, hostURL *url.URL) (restarted bool, err error) {
646 c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
647 if err != nil {
648 return false, err
649 }
650 vi, err := c.VersionInfo(cmd.Context())
651 if err != nil {
652 return false, err
653 }
654 if vi.Version == version.Version && vi.BuildID == version.BuildID {
655 return false, nil
656 }
657 slog.Info(
658 "Server version mismatch, restarting",
659 "server_version", vi.Version,
660 "client_version", version.Version,
661 "server_build_id", vi.BuildID,
662 "client_build_id", version.BuildID,
663 )
664 _ = c.ShutdownServer(cmd.Context())
665 // Give the old process a moment to release the socket.
666 for range 20 {
667 if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
668 break
669 }
670 select {
671 case <-cmd.Context().Done():
672 return true, cmd.Context().Err()
673 case <-time.After(100 * time.Millisecond):
674 }
675 }
676 // Force-remove if the socket is still lingering.
677 _ = os.Remove(hostURL.Host)
678 return true, nil
679}
680
681var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
682
683func startDetachedServer(cmd *cobra.Command, hostURL *url.URL) error {
684 exe, err := os.Executable()
685 if err != nil {
686 return fmt.Errorf("failed to get executable path: %v", err)
687 }
688
689 chDir, err := perHostServerDir(hostURL)
690 if err != nil {
691 return err
692 }
693
694 cmdArgs := []string{"server"}
695 if clientHost != server.DefaultHost() {
696 cmdArgs = append(cmdArgs, "--host", clientHost)
697 }
698
699 // Use context.Background() so the parent's context cancellation does not
700 // kill the spawned server. detachProcess (Setsid on !windows,
701 // DETACHED_PROCESS on windows) is what truly detaches the child from
702 // this process's lifetime.
703 c := exec.CommandContext(context.Background(), exe, cmdArgs...)
704 stdoutPath := filepath.Join(chDir, "stdout.log")
705 stderrPath := filepath.Join(chDir, "stderr.log")
706 detachProcess(c)
707
708 stdout, err := os.Create(stdoutPath)
709 if err != nil {
710 return fmt.Errorf("failed to create stdout log file: %v", err)
711 }
712 defer stdout.Close()
713 c.Stdout = stdout
714
715 stderr, err := os.Create(stderrPath)
716 if err != nil {
717 return fmt.Errorf("failed to create stderr log file: %v", err)
718 }
719 defer stderr.Close()
720 c.Stderr = stderr
721
722 if err := c.Start(); err != nil {
723 return fmt.Errorf("failed to start crush server: %v", err)
724 }
725
726 if err := c.Process.Release(); err != nil {
727 return fmt.Errorf("failed to detach crush server process: %v", err)
728 }
729
730 return nil
731}
732
733func shouldEnableMetrics(cfg *config.Config) bool {
734 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
735 return false
736 }
737 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
738 return false
739 }
740 if cfg.Options.DisableMetrics {
741 return false
742 }
743 return true
744}
745
746func MaybePrependStdin(prompt string) (string, error) {
747 if term.IsTerminal(os.Stdin.Fd()) {
748 return prompt, nil
749 }
750 fi, err := os.Stdin.Stat()
751 if err != nil {
752 return prompt, err
753 }
754 // Check if stdin is a named pipe ( | ) or regular file ( < ).
755 if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
756 return prompt, nil
757 }
758 bts, err := io.ReadAll(os.Stdin)
759 if err != nil {
760 return prompt, err
761 }
762 return string(bts) + "\n\n" + prompt, nil
763}
764
765// resolveWorkspaceSessionID resolves a session ID that may be a full
766// UUID, full hash, or hash prefix. Works against the Workspace
767// interface so both local and client/server paths get hash prefix
768// support.
769func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
770 if sess, err := ws.GetSession(ctx, id); err == nil {
771 return sess, nil
772 }
773
774 sessions, err := ws.ListSessions(ctx)
775 if err != nil {
776 return session.Session{}, err
777 }
778
779 var matches []session.Session
780 for _, s := range sessions {
781 hash := session.HashID(s.ID)
782 if hash == id || strings.HasPrefix(hash, id) {
783 matches = append(matches, s)
784 }
785 }
786
787 switch len(matches) {
788 case 0:
789 return session.Session{}, fmt.Errorf("session not found: %s", id)
790 case 1:
791 return matches[0], nil
792 default:
793 return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
794 }
795}
796
797func ResolveCwd(cmd *cobra.Command) (string, error) {
798 cwd, _ := cmd.Flags().GetString("cwd")
799 if cwd != "" {
800 err := os.Chdir(cwd)
801 if err != nil {
802 return "", fmt.Errorf("failed to change directory: %v", err)
803 }
804 return cwd, nil
805 }
806 cwd, err := os.Getwd()
807 if err != nil {
808 return "", fmt.Errorf("failed to get current working directory: %v", err)
809 }
810 return cwd, nil
811}
812
813func createDotCrushDir(dir string) error {
814 if err := os.MkdirAll(dir, 0o700); err != nil {
815 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
816 }
817
818 gitIgnorePath := filepath.Join(dir, ".gitignore")
819 content, err := os.ReadFile(gitIgnorePath)
820
821 // create or update if old version
822 if os.IsNotExist(err) || string(content) == oldGitIgnore {
823 if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
824 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
825 }
826 }
827
828 return nil
829}
830
831//go:embed gitignore/old
832var oldGitIgnore string
833
834//go:embed gitignore/default
835var defaultGitIgnore string