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