// Package cli provides the CLI runtime and utilities. package cli import ( "context" "fmt" "net" "net/http" "os" "path/filepath" "strconv" "sync" "syscall" "time" "forge.lthn.ai/core/go/pkg/io" "golang.org/x/term" ) // Mode represents the CLI execution mode. type Mode int const ( // ModeInteractive indicates TTY attached with coloured output. ModeInteractive Mode = iota // ModePipe indicates stdout is piped, colours disabled. ModePipe // ModeDaemon indicates headless execution, log-only output. ModeDaemon ) // String returns the string representation of the Mode. func (m Mode) String() string { switch m { case ModeInteractive: return "interactive" case ModePipe: return "pipe" case ModeDaemon: return "daemon" default: return "unknown" } } // DetectMode determines the execution mode based on environment. // Checks CORE_DAEMON env var first, then TTY status. func DetectMode() Mode { if os.Getenv("CORE_DAEMON") == "1" { return ModeDaemon } if !IsTTY() { return ModePipe } return ModeInteractive } // IsTTY returns true if stdout is a terminal. func IsTTY() bool { return term.IsTerminal(int(os.Stdout.Fd())) } // IsStdinTTY returns true if stdin is a terminal. func IsStdinTTY() bool { return term.IsTerminal(int(os.Stdin.Fd())) } // IsStderrTTY returns true if stderr is a terminal. func IsStderrTTY() bool { return term.IsTerminal(int(os.Stderr.Fd())) } // --- PID File Management --- // PIDFile manages a process ID file for single-instance enforcement. type PIDFile struct { path string mu sync.Mutex } // NewPIDFile creates a PID file manager. func NewPIDFile(path string) *PIDFile { return &PIDFile{path: path} } // Acquire writes the current PID to the file. // Returns error if another instance is running. func (p *PIDFile) Acquire() error { p.mu.Lock() defer p.mu.Unlock() // Check if PID file exists if data, err := io.Local.Read(p.path); err == nil { pid, err := strconv.Atoi(data) if err == nil && pid > 0 { // Check if process is still running if process, err := os.FindProcess(pid); err == nil { if err := process.Signal(syscall.Signal(0)); err == nil { return fmt.Errorf("another instance is running (PID %d)", pid) } } } // Stale PID file, remove it _ = io.Local.Delete(p.path) } // Ensure directory exists if dir := filepath.Dir(p.path); dir != "." { if err := io.Local.EnsureDir(dir); err != nil { return fmt.Errorf("failed to create PID directory: %w", err) } } // Write current PID pid := os.Getpid() if err := io.Local.Write(p.path, strconv.Itoa(pid)); err != nil { return fmt.Errorf("failed to write PID file: %w", err) } return nil } // Release removes the PID file. func (p *PIDFile) Release() error { p.mu.Lock() defer p.mu.Unlock() return io.Local.Delete(p.path) } // Path returns the PID file path. func (p *PIDFile) Path() string { return p.path } // --- Health Check Server --- // HealthServer provides a minimal HTTP health check endpoint. type HealthServer struct { addr string server *http.Server listener net.Listener mu sync.Mutex ready bool checks []HealthCheck } // HealthCheck is a function that returns nil if healthy. type HealthCheck func() error // NewHealthServer creates a health check server. func NewHealthServer(addr string) *HealthServer { return &HealthServer{ addr: addr, ready: true, } } // AddCheck registers a health check function. func (h *HealthServer) AddCheck(check HealthCheck) { h.mu.Lock() h.checks = append(h.checks, check) h.mu.Unlock() } // SetReady sets the readiness status. func (h *HealthServer) SetReady(ready bool) { h.mu.Lock() h.ready = ready h.mu.Unlock() } // Start begins serving health check endpoints. // Endpoints: // - /health - liveness probe (always 200 if server is up) // - /ready - readiness probe (200 if ready, 503 if not) func (h *HealthServer) Start() error { mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { h.mu.Lock() checks := h.checks h.mu.Unlock() for _, check := range checks { if err := check(); err != nil { w.WriteHeader(http.StatusServiceUnavailable) _, _ = fmt.Fprintf(w, "unhealthy: %v\n", err) return } } w.WriteHeader(http.StatusOK) _, _ = fmt.Fprintln(w, "ok") }) mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) { h.mu.Lock() ready := h.ready h.mu.Unlock() if !ready { w.WriteHeader(http.StatusServiceUnavailable) _, _ = fmt.Fprintln(w, "not ready") return } w.WriteHeader(http.StatusOK) _, _ = fmt.Fprintln(w, "ready") }) listener, err := net.Listen("tcp", h.addr) if err != nil { return fmt.Errorf("failed to listen on %s: %w", h.addr, err) } h.listener = listener h.server = &http.Server{Handler: mux} go func() { if err := h.server.Serve(listener); err != http.ErrServerClosed { LogError(fmt.Sprintf("health server error: %v", err)) } }() return nil } // Stop gracefully shuts down the health server. func (h *HealthServer) Stop(ctx context.Context) error { if h.server == nil { return nil } return h.server.Shutdown(ctx) } // Addr returns the actual address the server is listening on. // Useful when using port 0 for dynamic port assignment. func (h *HealthServer) Addr() string { if h.listener != nil { return h.listener.Addr().String() } return h.addr } // --- Daemon Runner --- // DaemonOptions configures daemon mode execution. type DaemonOptions struct { // PIDFile path for single-instance enforcement. // Leave empty to skip PID file management. PIDFile string // ShutdownTimeout is the maximum time to wait for graceful shutdown. // Default: 30 seconds. ShutdownTimeout time.Duration // HealthAddr is the address for health check endpoints. // Example: ":8080", "127.0.0.1:9000" // Leave empty to disable health checks. HealthAddr string // HealthChecks are additional health check functions. HealthChecks []HealthCheck // OnReload is called when SIGHUP is received. // Use for config reloading. Leave nil to ignore SIGHUP. OnReload func() error } // Daemon manages daemon lifecycle. type Daemon struct { opts DaemonOptions pid *PIDFile health *HealthServer reload chan struct{} running bool mu sync.Mutex } // NewDaemon creates a daemon runner with the given options. func NewDaemon(opts DaemonOptions) *Daemon { if opts.ShutdownTimeout == 0 { opts.ShutdownTimeout = 30 * time.Second } d := &Daemon{ opts: opts, reload: make(chan struct{}, 1), } if opts.PIDFile != "" { d.pid = NewPIDFile(opts.PIDFile) } if opts.HealthAddr != "" { d.health = NewHealthServer(opts.HealthAddr) for _, check := range opts.HealthChecks { d.health.AddCheck(check) } } return d } // Start initialises the daemon (PID file, health server). // Call this after cli.Init(). func (d *Daemon) Start() error { d.mu.Lock() defer d.mu.Unlock() if d.running { return fmt.Errorf("daemon already running") } // Acquire PID file if d.pid != nil { if err := d.pid.Acquire(); err != nil { return err } } // Start health server if d.health != nil { if err := d.health.Start(); err != nil { if d.pid != nil { _ = d.pid.Release() } return err } } d.running = true return nil } // Run blocks until the context is cancelled or a signal is received. // Handles graceful shutdown with the configured timeout. func (d *Daemon) Run(ctx context.Context) error { d.mu.Lock() if !d.running { d.mu.Unlock() return fmt.Errorf("daemon not started - call Start() first") } d.mu.Unlock() // Wait for context cancellation (from signal handler) <-ctx.Done() return d.Stop() } // Stop performs graceful shutdown. func (d *Daemon) Stop() error { d.mu.Lock() defer d.mu.Unlock() if !d.running { return nil } var errs []error // Create shutdown context with timeout shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout) defer cancel() // Stop health server if d.health != nil { d.health.SetReady(false) if err := d.health.Stop(shutdownCtx); err != nil { errs = append(errs, fmt.Errorf("health server: %w", err)) } } // Release PID file if d.pid != nil { if err := d.pid.Release(); err != nil && !os.IsNotExist(err) { errs = append(errs, fmt.Errorf("pid file: %w", err)) } } d.running = false if len(errs) > 0 { return fmt.Errorf("shutdown errors: %v", errs) } return nil } // SetReady sets the daemon readiness status for health checks. func (d *Daemon) SetReady(ready bool) { if d.health != nil { d.health.SetReady(ready) } } // HealthAddr returns the health server address, or empty if disabled. func (d *Daemon) HealthAddr() string { if d.health != nil { return d.health.Addr() } return "" } // --- Convenience Functions --- // Run blocks until context is cancelled or signal received. // Simple helper for daemon mode without advanced features. // // cli.Init(cli.Options{AppName: "myapp"}) // defer cli.Shutdown() // cli.Run(cli.Context()) func Run(ctx context.Context) error { mustInit() <-ctx.Done() return ctx.Err() } // RunWithTimeout wraps Run with a graceful shutdown timeout. // The returned function should be deferred to replace cli.Shutdown(). // // cli.Init(cli.Options{AppName: "myapp"}) // shutdown := cli.RunWithTimeout(30 * time.Second) // defer shutdown() // cli.Run(cli.Context()) func RunWithTimeout(timeout time.Duration) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() // Create done channel for shutdown completion done := make(chan struct{}) go func() { Shutdown() close(done) }() select { case <-done: // Clean shutdown case <-ctx.Done(): // Timeout - force exit LogWarn("shutdown timeout exceeded, forcing exit") } } }