Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8f82b95
refactor: extract API client to internal/apiclient for shared CLI/TUI…
sylvesterdamgaard Apr 17, 2026
fa4c0d8
feat: always-on Unix socket for local management without TCP
sylvesterdamgaard Apr 17, 2026
7a71c4b
feat: add list and status CLI commands
sylvesterdamgaard Apr 17, 2026
e6e595c
feat: add start, stop, restart, scale, reload-config CLI commands
sylvesterdamgaard Apr 17, 2026
cd401de
feat: add log broadcaster for real-time SSE subscription
sylvesterdamgaard Apr 17, 2026
96177e9
feat: wire log broadcaster through process manager to all supervisors
sylvesterdamgaard Apr 17, 2026
1ceca6d
feat: add SSE log stream endpoint at /api/v1/logs/stream
sylvesterdamgaard Apr 17, 2026
0880698
feat: add SSE log stream client for cbox-init logs -f
sylvesterdamgaard Apr 17, 2026
fbaf88e
feat: rewrite logs command to use API client with SSE streaming
sylvesterdamgaard Apr 17, 2026
38f5b79
fix: update logs follow flag default test to match new behavior
sylvesterdamgaard Apr 17, 2026
eb94ee7
feat: add log file tailing config structs (LogFileConfig, RotateConfig)
sylvesterdamgaard Apr 17, 2026
359c49e
feat: add validation and defaults for log file tailing config
sylvesterdamgaard Apr 17, 2026
d5ddefd
feat: add FileRotator for size-based log file rotation
sylvesterdamgaard Apr 17, 2026
14b3e62
feat: add FileTailer with pure Go tail -F semantics
sylvesterdamgaard Apr 17, 2026
04c843d
feat: integrate FileTailers into Supervisor lifecycle
sylvesterdamgaard Apr 17, 2026
2a0671d
docs: add design specs and implementation plans
sylvesterdamgaard Apr 17, 2026
20714de
fix: address errcheck lint warnings in logtail package
sylvesterdamgaard Apr 17, 2026
baaa0bc
fix: resolve remaining lint warnings (errcheck, staticcheck)
sylvesterdamgaard Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/cbox-init/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1405,7 +1405,7 @@ func TestLogsCommandFlagDefaults(t *testing.T) {
}{
{"level", "all"},
{"tail", "100"},
{"follow", "true"},
{"follow", "false"},
}

for _, tt := range tests {
Expand Down
98 changes: 98 additions & 0 deletions cmd/cbox-init/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package main

import (
"fmt"
"os"
"text/tabwriter"
"time"

"github.com/cboxdk/init/internal/apiclient"
"github.com/spf13/cobra"
)

var listCmd = &cobra.Command{
Use: "list",
Short: "List all processes and their status",
Long: `Display a table of all managed processes with their current state, scale, restart count, and uptime.`,
Args: cobra.NoArgs,
Run: runList,
}

var listURL string

func init() {
listCmd.Flags().StringVar(&listURL, "url", "", "API endpoint (auto-discovers Unix socket by default)")
}

func runList(cmd *cobra.Command, args []string) {
client := newClient(listURL)

processes, err := client.ListProcesses()
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Failed to list processes: %v\n", err)
os.Exit(1)
}

if len(processes) == 0 {
fmt.Println("No processes configured")
return
}

w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tSTATUS\tSCALE\tRESTARTS\tUPTIME")

hasUnhealthy := false
for _, p := range processes {
status := p.State
if status != "running" {
hasUnhealthy = true
}

scale := fmt.Sprintf("%d/%d", p.Scale, p.DesiredScale)

restarts := 0
var uptime string
for _, inst := range p.Instances {
restarts += inst.RestartCount
if inst.StartedAt > 0 && uptime == "" {
d := time.Since(time.Unix(inst.StartedAt, 0))
uptime = formatDuration(d)
}
}
if uptime == "" {
uptime = "-"
}

fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", p.Name, status, scale, restarts, uptime)
}
w.Flush()

if hasUnhealthy {
os.Exit(1)
}
}

// newClient creates an API client, using --url flag or auto-discovery
func newClient(urlFlag string) *apiclient.Client {
auth := os.Getenv("CBOX_INIT_API_AUTH")
if urlFlag != "" {
return apiclient.New(urlFlag, auth)
}
return apiclient.New("http://localhost:9180", auth)
}

// formatDuration formats a duration as human-readable
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60)
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60)
}
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
return fmt.Sprintf("%dd%dh", days, hours)
}
128 changes: 49 additions & 79 deletions cmd/cbox-init/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,120 +3,90 @@ package main
import (
"context"
"fmt"
"log/slog"
"os"
"time"
"os/signal"

"github.com/cboxdk/init/internal/audit"
"github.com/cboxdk/init/internal/config"
"github.com/cboxdk/init/internal/logger"
"github.com/cboxdk/init/internal/process"
"github.com/cboxdk/init/internal/setup"
"github.com/cboxdk/init/internal/signals"
"github.com/cboxdk/init/internal/tui"
"github.com/spf13/cobra"
)

var logsCmd = &cobra.Command{
Use: "logs [process...]",
Short: "Tail logs from processes",
Long: `Tail logs from one or more processes in real-time.

If no process names are specified, shows logs from all processes.
Use: "logs [process]",
Short: "Tail logs from a process or all processes",
Long: `Tail logs from one or all processes via the daemon API.

Examples:
cbox-init logs # All processes
cbox-init logs nginx # Single process
cbox-init logs nginx horizon # Multiple processes
cbox-init logs --level=error # Filter by level
cbox-init logs --tail=100 # Last 100 lines`,
Run: runLogs,
cbox-init logs # All processes, last 100 lines
cbox-init logs nginx # Specific process
cbox-init logs nginx --tail 50 # Last 50 lines
cbox-init logs -f # Stream all processes
cbox-init logs nginx -f # Stream specific process
cbox-init logs nginx --tail 20 -f # Last 20 lines then stream`,
Args: cobra.MaximumNArgs(1),
Run: runLogs,
}

var (
logsLevel string
logsTail int
logsFollow bool
logsLevel string
logsURL string
)

func init() {
logsCmd.Flags().StringVar(&logsLevel, "level", "all", "Filter by log level (debug|info|warn|error|all)")
logsCmd.Flags().IntVar(&logsTail, "tail", 100, "Number of lines to show")
logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", true, "Follow log output")
logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "Stream new log entries")
logsCmd.Flags().StringVar(&logsLevel, "level", "all", "Filter by log level (debug|info|warn|error|all)")
logsCmd.Flags().StringVar(&logsURL, "url", "", "API endpoint (auto-discovers Unix socket by default)")
}

func runLogs(cmd *cobra.Command, args []string) {
// Get config path
cfgPath := getConfigPath()

// Setup environment (minimal for log viewer)
workdir := os.Getenv("WORKDIR")
if workdir == "" {
workdir = "/var/www/html"
var processName string
if len(args) > 0 {
processName = args[0]
}

// Setup permissions (silent, detects framework internally)
permMgr := setup.NewPermissionManager(workdir, slog.Default())
_ = permMgr.Setup()

// Validate system (silent)
validator := setup.NewConfigValidator(slog.Default())
_ = validator.ValidateAll()
client := newClient(logsURL)

// Load configuration
cfg, err := config.LoadWithEnvExpansion(cfgPath)
// Fetch historical logs
var logs []logger.LogEntry
var err error
if processName != "" {
logs, err = client.GetLogs(processName, logsTail)
} else {
logs, err = client.GetStackLogs(logsTail)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
fmt.Fprintf(os.Stderr, "Failed to fetch logs: %v\n", err)
os.Exit(1)
}

// Initialize logger with specified level
logLevel := cfg.Global.LogLevel
if logsLevel != "all" {
logLevel = logsLevel
// Print historical logs
for _, entry := range logs {
printLogEntry(entry)
}
log := logger.New(logLevel, "text") // Text format for readability

slog.SetDefault(log)

// Create audit logger
auditLogger := audit.NewLogger(log, cfg.Global.AuditEnabled)

// Create process manager
pm := process.NewManager(cfg, log, auditLogger)

// Start zombie reaper
go signals.ReapZombies(cfg.Global.ZombieReapInterval)

// Start processes
ctx := context.Background()
if err := pm.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "❌ Failed to start processes: %v\n", err)
os.Exit(1)
// If not following, we're done
if !logsFollow {
return
}

// Monitor process health
pm.MonitorProcessHealth(ctx)

// Display header
fmt.Fprintf(os.Stderr, "📋 Tailing logs")
if len(args) > 0 {
fmt.Fprintf(os.Stderr, " for: %v", args)
} else {
fmt.Fprintf(os.Stderr, " for all processes")
}
fmt.Fprintf(os.Stderr, " (level: %s)\n", logLevel)
fmt.Fprintf(os.Stderr, "Press Ctrl+C to exit\n\n")
// Stream new entries via SSE
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()

// Launch simple log viewer
if err := tui.RunLogs(pm); err != nil {
fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err)
ch, err := client.StreamLogs(ctx, processName)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to connect to log stream: %v\n", err)
os.Exit(1)
}

// Shutdown when viewer exits
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.Global.ShutdownTimeout)*time.Second)
defer cancel()
for entry := range ch {
printLogEntry(entry)
}
}

_ = pm.Shutdown(shutdownCtx)
func printLogEntry(entry logger.LogEntry) {
ts := entry.Timestamp.Format("15:04:05.000")
fmt.Printf("%s [%s] %s: %s\n", ts, entry.Level, entry.ProcessName, entry.Message)
}
31 changes: 31 additions & 0 deletions cmd/cbox-init/reload_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package main

import (
"fmt"
"os"

"github.com/spf13/cobra"
)

var reloadConfigCmd = &cobra.Command{
Use: "reload-config",
Short: "Reload configuration from disk",
Long: `Reload the configuration file from disk without restarting the daemon.`,
Args: cobra.NoArgs,
Run: runReloadConfig,
}

var reloadConfigURL string

func init() {
reloadConfigCmd.Flags().StringVar(&reloadConfigURL, "url", "", "API endpoint (auto-discovers Unix socket by default)")
}

func runReloadConfig(cmd *cobra.Command, args []string) {
client := newClient(reloadConfigURL)
if err := client.ReloadConfig(); err != nil {
fmt.Fprintf(os.Stderr, "❌ Failed to reload config: %v\n", err)
os.Exit(1)
}
fmt.Println("✓ Configuration reloaded")
}
31 changes: 31 additions & 0 deletions cmd/cbox-init/restart.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package main

import (
"fmt"
"os"

"github.com/spf13/cobra"
)

var restartCmd = &cobra.Command{
Use: "restart <process>",
Short: "Restart a process",
Args: cobra.ExactArgs(1),
Run: runRestart,
}

var restartURL string

func init() {
restartCmd.Flags().StringVar(&restartURL, "url", "", "API endpoint (auto-discovers Unix socket by default)")
}

func runRestart(cmd *cobra.Command, args []string) {
name := args[0]
client := newClient(restartURL)
if err := client.RestartProcess(name); err != nil {
fmt.Fprintf(os.Stderr, "❌ Failed to restart %s: %v\n", name, err)
os.Exit(1)
}
fmt.Printf("✓ %s restarted\n", name)
}
20 changes: 12 additions & 8 deletions cmd/cbox-init/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ A modern process supervisor designed for Laravel and PHP applications with:
Examples:
cbox-init serve # Start daemon
cbox-init tui # Interactive dashboard
cbox-init logs nginx # Tail nginx logs
cbox-init list # List all processes
cbox-init status nginx # Show process details
cbox-init restart horizon # Restart horizon
cbox-init scale queue-default 10 # Scale to 10 workers`,
cbox-init scale queue-default 10 # Scale to 10 workers
cbox-init logs nginx -f # Stream nginx logs`,
Version: version,
// Default to serve command if no subcommand specified
Run: func(cmd *cobra.Command, args []string) {
Expand Down Expand Up @@ -61,10 +63,12 @@ func init() {
rootCmd.AddCommand(tuiCmd)
rootCmd.AddCommand(logsCmd)
rootCmd.AddCommand(scaffoldCmd)
// Process control commands (future):
// rootCmd.AddCommand(restartCmd)
// rootCmd.AddCommand(stopCmd)
// rootCmd.AddCommand(startCmd)
// rootCmd.AddCommand(scaleCmd)
// rootCmd.AddCommand(statusCmd)
// Process control commands
rootCmd.AddCommand(listCmd)
rootCmd.AddCommand(statusCmd)
rootCmd.AddCommand(startProcessCmd)
rootCmd.AddCommand(stopProcessCmd)
rootCmd.AddCommand(restartCmd)
rootCmd.AddCommand(scaleCmd)
rootCmd.AddCommand(reloadConfigCmd)
}
Loading
Loading