diff --git a/cmd/project.go b/cmd/project.go index 2e7e0b4..ca6d462 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -1,6 +1,7 @@ /* Copyright © 2026 Luca A. */ + package cmd import ( @@ -15,29 +16,27 @@ var projectCmd = &cobra.Command{ Use: "project", Short: "Manages projects", Run: func(cmd *cobra.Command, args []string) { - fmt.Println("project called") + cmd.Usage() }, } var overwriteProject bool var projectInitCmd = &cobra.Command{ - Use: "init", + Use: "init ", + Args: cobra.ExactArgs(1), + Short: "Initialize a new project", Run: func(cmd *cobra.Command, args []string) { - if len(args) != 1 { - utils.PrintFatal("Expected 1 argument (project's name), got %d args", len(args)) - } - name := args[0] - if !utils.FileExists(".foundry") { + if !utils.PathExists(".foundry") { if err := os.Mkdir(".foundry", 0755); err != nil { utils.PrintFatal("Failed to create .foundry directory: %v", err) } } rawConf := fmt.Sprintf(`name = "%s"`, name) - if utils.FileExists(".foundry/project.toml") && !overwriteProject { + if utils.PathExists(".foundry/project.toml") && !overwriteProject { utils.PrintFatal("Project already exists, consider removing .foundry/project.toml or using '--overwrite' (or '-o')") } else { // os.Create(".foundry/project.toml") diff --git a/cmd/root.go b/cmd/root.go index 83407c8..39e214a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ /* Copyright © 2026 Luca A. nykenik24@proton.me */ + package cmd import ( diff --git a/cmd/service.go b/cmd/service.go new file mode 100644 index 0000000..31843e8 --- /dev/null +++ b/cmd/service.go @@ -0,0 +1,265 @@ +/* +Copyright © 2026 Luca A. Nykenik24@proton.me +*/ + +package cmd + +import ( + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "syscall" + "time" + + "github.com/Nykenik24/foundry/core/service" + "github.com/Nykenik24/foundry/core/utils" + "github.com/pelletier/go-toml/v2" + "github.com/spf13/cobra" +) + +var socket string = "/tmp/foundry.sock" + +func daemonRunning() bool { + _, err := net.Dial("unix", socket) + return err == nil +} + +func ensureDaemon() error { + if daemonRunning() { + return nil + } + + cmd := exec.Command(os.Args[0], "service", "daemon") + + cmd.Stdout = nil + cmd.Stderr = nil + cmd.Stdin = nil + + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + + if err := cmd.Start(); err != nil { + return err + } + + for range 10 { + time.Sleep(200 * time.Millisecond) + + _, err := net.Dial("unix", socket) + if err == nil { + return nil + } + } + + return fmt.Errorf("daemon failed to start") +} + +func initManagerWithConfig() *service.ServiceManager { + if !utils.PathExists(".foundry") { + utils.PrintFatal("Not in a foundry project") + } + + if !utils.PathExists(".foundry/services.toml") { + os.Create(".foundry/services.toml") + return service.NewManager() + } + + rawSrc, err := os.ReadFile(".foundry/services.toml") + if err != nil { + utils.PrintFatal("%v", err) + } + + var serviceDoc service.ServiceDoc + toml.Unmarshal([]byte(rawSrc), &serviceDoc) + if serviceDoc.Services == nil { + serviceDoc.Services = make(map[string]*service.Service) + } + + manager := service.NewManager() + for _, service := range serviceDoc.Services { + manager.Register(service.Name, service.Cmd) + } + return manager +} + +var serviceCmd = &cobra.Command{ + Use: "service", + Short: "Manage services", + Run: func(cmd *cobra.Command, args []string) { + cmd.Usage() + }, +} + +var serviceDaemonCmd = &cobra.Command{ + Use: "daemon", + Short: "Start the service daemon", + Run: func(cmd *cobra.Command, args []string) { + utils.GlobalLogger.Log("Started daemon. It is suggested to fork this command.", utils.LogInfo) + + manager := initManagerWithConfig() + + daemon := service.NewDaemon(manager) + err := daemon.Run("/tmp/foundry.sock") + if err != nil { + utils.PrintFatal("%v", err) + } + }, +} + +var serviceStartCmd = &cobra.Command{ + Use: "start ", + Args: cobra.ExactArgs(1), + + Short: "Start a service", + Run: func(cmd *cobra.Command, args []string) { + if err := ensureDaemon(); err != nil { + utils.PrintFatal("%v", err) + } + + conn, err := net.Dial("unix", "/tmp/foundry.sock") + if err != nil { + utils.PrintFatal("%v", err) + } + defer conn.Close() + + req := service.Request{ + Action: "start", + Service: args[0], + } + + err = json.NewEncoder(conn).Encode(req) + if err != nil { + utils.PrintFatal("%v", err) + } + }, +} + +var serviceStopCmd = &cobra.Command{ + Use: "stop ", + Args: cobra.ExactArgs(1), + + Short: "Stop a service", + Run: func(cmd *cobra.Command, args []string) { + if err := ensureDaemon(); err != nil { + utils.PrintFatal("%v", err) + } + + conn, err := net.Dial("unix", "/tmp/foundry.sock") + if err != nil { + utils.PrintFatal("%v", err) + } + defer conn.Close() + + req := service.Request{ + Action: "stop", + Service: args[0], + } + + err = json.NewEncoder(conn).Encode(req) + if err != nil { + utils.PrintFatal("%v", err) + } + }, +} + +var serviceListCmd = &cobra.Command{ + Use: "list", + Short: "List all services", + Run: func(cmd *cobra.Command, args []string) { + if err := ensureDaemon(); err != nil { + utils.PrintFatal("%v", err) + } + + conn, err := net.Dial("unix", "/tmp/foundry.sock") + if err != nil { + utils.PrintFatal("%v", err) + } + defer conn.Close() + + req := service.Request{ + Action: "list", + } + + err = json.NewEncoder(conn).Encode(req) + if err != nil { + utils.PrintFatal("%v", err) + } + + var resp service.Response + err = json.NewDecoder(conn).Decode(&resp) + if err != nil { + utils.PrintFatal("%v", err) + } + + if resp.Error != "" { + utils.PrintFatal("%s", resp.Error) + } + + fmt.Printf("Found %d service(s)\n", len(resp.Services)) + for _, svc := range resp.Services { + var status string + switch svc.Status { + case true: + status = "\033[32mRunning" + case false: + status = "\033[31mStopped" + } + fmt.Printf("└── Service \033[34m'%s'\033[0m: %s\033[0m\n", svc.Name, status) + } + }, +} + +var serviceKillCmd = &cobra.Command{ + Use: "dkill", + Short: "Stop the service daemon", + Run: func(cmd *cobra.Command, args []string) { + conn, err := net.Dial("unix", "/tmp/foundry.sock") + if err != nil { + utils.PrintFatal("Could not connect to daemon: %v", err) + } + defer conn.Close() + + req := service.Request{ + Action: "shutdown", + } + + if err := json.NewEncoder(conn).Encode(req); err != nil { + utils.PrintFatal("Failed to send shutdown: %v", err) + } + + var resp service.Response + if err := json.NewDecoder(conn).Decode(&resp); err != nil { + utils.PrintFatal("Failed to read response: %v", err) + } + + os.Remove("/tmp/foundry.sock") + utils.GlobalLogger.Log("Shutting daemon down...", utils.LogInfo) + }, +} + +var serviceIsActiveCmd = &cobra.Command{ + Use: "isactive", + Short: "Check if daemon is running", + Run: func(cmd *cobra.Command, args []string) { + if daemonRunning() { + fmt.Println("Daemon is \033[32mactive\033[0m.") + } else { + fmt.Println("Daemon is \033[31minactive\033[0m.") + fmt.Println("Use \033[34m`foundry service daemon`\033[0m") + } + }, +} + +func init() { + serviceCmd.AddCommand(serviceDaemonCmd) + serviceCmd.AddCommand(serviceStartCmd) + serviceCmd.AddCommand(serviceStopCmd) + serviceCmd.AddCommand(serviceListCmd) + serviceCmd.AddCommand(serviceKillCmd) + serviceCmd.AddCommand(serviceIsActiveCmd) + + rootCmd.AddCommand(serviceCmd) +} diff --git a/cmd/task.go b/cmd/task.go index bddfd96..f53577c 100644 --- a/cmd/task.go +++ b/cmd/task.go @@ -1,6 +1,7 @@ /* Copyright © 2026 Luca A. */ + package cmd import ( @@ -19,25 +20,23 @@ var taskCmd = &cobra.Command{ Use: "task", Short: "Manages tasks", Run: func(cmd *cobra.Command, args []string) { - fmt.Println("task called") + cmd.Usage() }, } var newTaskCommand string var taskNewCmd = &cobra.Command{ - Use: "new", + Use: "new ", + Args: cobra.ExactArgs(1), + Short: "Creates a new task", Run: func(cmd *cobra.Command, args []string) { - if len(args) < 1 { - utils.PrintFatal("Expected 1 arg (name) in 'task new', got %d", len(args)) - } - - if !utils.FileExists(".foundry") { + if !utils.PathExists(".foundry") { utils.PrintFatal("Not a foundry project") } - if !utils.FileExists(".foundry/tasks.toml") { + if !utils.PathExists(".foundry/tasks.toml") { if err := os.WriteFile(".foundry/tasks.toml", []byte(""), 0644); err != nil { utils.PrintFatal("Error when writing tasks document: %v", err) } @@ -55,14 +54,14 @@ var taskNewCmd = &cobra.Command{ } if tasks.Tasks == nil { - tasks.Tasks = make(map[string]task.Task) + tasks.Tasks = make(map[string]*task.Task) } if _, exists := tasks.Tasks[name]; exists { utils.PrintFatal("Task '%s' already exists", name) } - tasks.Tasks[name] = *task.NewTask(name, newTaskCommand) + tasks.Tasks[name] = task.NewTask(name, newTaskCommand) newSrc, err := toml.Marshal(tasks) if err != nil { @@ -73,18 +72,16 @@ var taskNewCmd = &cobra.Command{ } var taskRemoveCmd = &cobra.Command{ - Use: "remove", + Use: "remove ", + Args: cobra.ExactArgs(1), + Short: "Remove a task", Run: func(cmd *cobra.Command, args []string) { - if len(args) < 1 { - utils.PrintFatal("Expected 1 arg (name) in 'task remove', got %d", len(args)) - } - - if !utils.FileExists(".foundry") { + if !utils.PathExists(".foundry") { utils.PrintFatal("Not a foundry project") } - if !utils.FileExists(".foundry/tasks.toml") { + if !utils.PathExists(".foundry/tasks.toml") { if err := os.WriteFile(".foundry/tasks.toml", []byte(""), 0644); err != nil { utils.PrintFatal("Error when writing tasks document: %v", err) } @@ -102,7 +99,7 @@ var taskRemoveCmd = &cobra.Command{ } if tasks.Tasks == nil { - tasks.Tasks = make(map[string]task.Task) + tasks.Tasks = make(map[string]*task.Task) } if _, exists := tasks.Tasks[name]; !exists { @@ -120,18 +117,16 @@ var taskRemoveCmd = &cobra.Command{ } var taskRunCmd = &cobra.Command{ - Use: "run", + Use: "run ", + Args: cobra.ExactArgs(1), + Short: "Run a task", Run: func(cmd *cobra.Command, args []string) { - if len(args) < 1 { - log.Fatalf("Expected 1 arg (task's name), got %d", len(args)) - } - - if !utils.FileExists(".foundry") { + if !utils.PathExists(".foundry") { log.Fatalf("Not a foundry project") } - if !utils.FileExists(".foundry/tasks.toml") { + if !utils.PathExists(".foundry/tasks.toml") { if err := os.WriteFile(".foundry/tasks.toml", []byte(""), 0644); err != nil { log.Fatalf("Error when writing tasks document: %v", err) } @@ -149,7 +144,7 @@ var taskRunCmd = &cobra.Command{ } if tasks.Tasks == nil { - tasks.Tasks = make(map[string]task.Task) + tasks.Tasks = make(map[string]*task.Task) } if _, exists := tasks.Tasks[name]; !exists { @@ -174,11 +169,11 @@ var taskListCmd = &cobra.Command{ Use: "list", Short: "List all tasks", Run: func(cmd *cobra.Command, args []string) { - if !utils.FileExists(".foundry") { + if !utils.PathExists(".foundry") { log.Fatalf("Not a foundry project") } - if !utils.FileExists(".foundry/tasks.toml") { + if !utils.PathExists(".foundry/tasks.toml") { if err := os.WriteFile(".foundry/tasks.toml", []byte(""), 0644); err != nil { log.Fatalf("Error when writing tasks document: %v", err) } @@ -195,7 +190,7 @@ var taskListCmd = &cobra.Command{ } if tasks.Tasks == nil { - tasks.Tasks = make(map[string]task.Task) + tasks.Tasks = make(map[string]*task.Task) } fmt.Printf("Found %d task(s)\n", len(tasks.Tasks)) diff --git a/core/project/project.go b/core/project/project.go index 2d92dc2..8fe01d1 100644 --- a/core/project/project.go +++ b/core/project/project.go @@ -18,11 +18,11 @@ type Project struct { } func NewProject(rootPath string) (*Project, error) { - if !utils.FileExists(fmt.Sprintf("%s/.foundry", rootPath)) { + if !utils.PathExists(fmt.Sprintf("%s/.foundry", rootPath)) { return nil, fmt.Errorf(".foundry not found in root directory '%s'\n", rootPath) } - if !utils.FileExists(fmt.Sprintf("%s/.foundry/project.toml", rootPath)) { + if !utils.PathExists(fmt.Sprintf("%s/.foundry/project.toml", rootPath)) { return nil, fmt.Errorf("project.toml not found in foundry dir '%s/.foundry'\n", rootPath) } diff --git a/core/service/daemon.go b/core/service/daemon.go new file mode 100644 index 0000000..f6d30a0 --- /dev/null +++ b/core/service/daemon.go @@ -0,0 +1,124 @@ +package service + +import ( + "encoding/json" + "net" + "os" + + "github.com/Nykenik24/foundry/core/utils" +) + +type Request struct { + Action string `json:"action"` + Service string `json:"service"` +} + +type ResponseService struct { + Name string `json:"name"` + Status bool `json:"status"` +} + +type Response struct { + Status string `json:"status,omitempty"` + Services []ResponseService `json:"services,omitempty"` + Error string `json:"error,omitempty"` +} + +type Daemon struct { + manager *ServiceManager +} + +func NewDaemon(manager *ServiceManager) *Daemon { + return &Daemon{manager: manager} +} + +func (d *Daemon) Run(socket string) error { + os.Remove(socket) + + l, err := net.Listen("unix", socket) + if err != nil { + return err + } + + for { + conn, err := l.Accept() + if err != nil { + continue + } + + go d.handle(conn) + } +} + +func (d *Daemon) handle(conn net.Conn) { + defer conn.Close() + + var req Request + if err := json.NewDecoder(conn).Decode(&req); err != nil { + json.NewEncoder(conn).Encode(Response{ + Error: err.Error(), + }) + return + } + + switch req.Action { + + case "start": + err := d.manager.Start(req.Service) + if err != nil { + json.NewEncoder(conn).Encode(Response{ + Error: err.Error(), + }) + utils.PrintError("When starting service: %v", err) + return + } + + json.NewEncoder(conn).Encode(Response{ + Status: "ok", + }) + + case "stop": + err := d.manager.Stop(req.Service) + if err != nil { + json.NewEncoder(conn).Encode(Response{ + Error: err.Error(), + }) + utils.PrintError("When stopping service: %v", err) + return + } + + json.NewEncoder(conn).Encode(Response{ + Status: "stopped", + }) + + case "list": + services := d.manager.List() + + var names []ResponseService + for _, svc := range services { + names = append(names, ResponseService{ + Name: svc.Name, + Status: svc.Running, + }) + } + + json.NewEncoder(conn).Encode(Response{ + Services: names, + }) + + case "shutdown": + for _, svc := range d.manager.List() { + svc.logger.Log("Daemon shutdown", utils.LogProc) + } + + json.NewEncoder(conn).Encode(Response{Status: "shutting down"}) + go func() { + os.Exit(0) + }() + + default: + json.NewEncoder(conn).Encode(Response{ + Error: "unknown action", + }) + } +} diff --git a/core/service/manager.go b/core/service/manager.go new file mode 100644 index 0000000..774e43b --- /dev/null +++ b/core/service/manager.go @@ -0,0 +1,148 @@ +package service + +import ( + "context" + "fmt" + "os" + "os/exec" + "sync" + "syscall" + + "github.com/Nykenik24/foundry/core/utils" +) + +type ServiceManager struct { + services map[string]*Service + mu sync.Mutex +} + +func NewManager() *ServiceManager { + return &ServiceManager{ + services: make(map[string]*Service), + } +} + +func (m *ServiceManager) Start(name string) error { + m.mu.Lock() + svc, ok := m.services[name] + if !ok { + m.mu.Unlock() + return fmt.Errorf("service not found") + } + + if svc.Running { + m.mu.Unlock() + return fmt.Errorf("service already running") + } + m.mu.Unlock() + + if !ok { + return fmt.Errorf("service not found") + } + + if svc.Running { + return fmt.Errorf("service already running") + } + + ctx := context.Background() + cmd := exec.CommandContext(ctx, "sh", "-c", svc.Cmd) + + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + + if utils.PathExists(".foundry") { + if !utils.PathExists(".foundry/proc-logs") { + os.Mkdir(".foundry/proc-logs", 0755) + } + logfile, _ := os.Create(fmt.Sprintf(".foundry/proc-logs/%s.log", svc.Name)) + cmd.Stdout = logfile + cmd.Stderr = logfile + + svc.logger.SetNewOutput(logfile) + svc.logger.MinimumLevel(utils.LogProc) + svc.logger.Log("(Foundry) service started", utils.LogProc) + } else { + return fmt.Errorf("Not in a foundry project") + } + + err := cmd.Start() + if err != nil { + return err + } + + svc.Process = cmd + + svc.Running = true + + go func() { + err := cmd.Wait() + + m.mu.Lock() + defer m.mu.Unlock() + + svc.Running = false + + if err != nil { + utils.GlobalLogger.Log(fmt.Sprintf("service %s exited: %v\n", svc.Name, err), utils.LogInfo) + } + }() + + return nil +} + +func (m *ServiceManager) Stop(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + svc, ok := m.services[name] + if !ok { + return fmt.Errorf("service not found") + } + + if !svc.Running { + return fmt.Errorf("service not running") + } + + pgid, err := syscall.Getpgid(svc.Process.Process.Pid) + if err != nil { + return err + } + + err = syscall.Kill(-pgid, syscall.SIGTERM) + if err != nil { + return err + } + + svc.Running = false + return nil +} + +func (m *ServiceManager) List() []Service { + m.mu.Lock() + defer m.mu.Unlock() + + result := []Service{} + for _, svc := range m.services { + result = append(result, *svc) + } + return result +} + +func (m *ServiceManager) Register(name, cmd string) { + m.mu.Lock() + defer m.mu.Unlock() + + m.services[name] = &Service{ + Name: name, + Cmd: cmd, + logger: utils.NewLogger(os.Stderr), + } +} + +func (m *ServiceManager) Restart(name string) error { + if err := m.Stop(name); err != nil { + return err + } + return m.Start(name) +} diff --git a/core/service/service.go b/core/service/service.go new file mode 100644 index 0000000..3da1ad9 --- /dev/null +++ b/core/service/service.go @@ -0,0 +1,31 @@ +package service + +import ( + "fmt" + "os/exec" + + "github.com/Nykenik24/foundry/core/utils" +) + +type Service struct { + Name string `toml:"name"` + Cmd string `toml:"cmd"` + Process *exec.Cmd + Running bool + logger *utils.Logger +} + +func (s *Service) String() string { + var status string + switch s.Running { + case true: + status = "Running" + case false: + status = "Stopped" + } + return fmt.Sprintf("%s %v", s.Name, status) +} + +type ServiceDoc struct { + Services map[string]*Service `toml:"services"` +} diff --git a/core/task/task.go b/core/task/task.go index d6876eb..3574000 100644 --- a/core/task/task.go +++ b/core/task/task.go @@ -12,7 +12,7 @@ type Task struct { } type TasksDocument struct { - Tasks map[string]Task `toml:"tasks"` + Tasks map[string]*Task `toml:"tasks"` } func NewTask(name, cmd string) *Task { diff --git a/core/utils/fs.go b/core/utils/fs.go index 542f38d..6ec4a6a 100644 --- a/core/utils/fs.go +++ b/core/utils/fs.go @@ -5,7 +5,7 @@ import ( "os" ) -func FileExists(path string) bool { +func PathExists(path string) bool { _, err := os.Stat(path) return !errors.Is(err, os.ErrNotExist) } diff --git a/core/utils/logger.go b/core/utils/logger.go new file mode 100644 index 0000000..2186d3a --- /dev/null +++ b/core/utils/logger.go @@ -0,0 +1,52 @@ +package utils + +import ( + "fmt" + "os" + "time" +) + +type LogLevel int + +const ( + LogTrace LogLevel = iota + LogDebug + LogProc + LogInfo + LogWarn + LogError +) + +var LogLevelStr = map[LogLevel]string{ + LogTrace: "\033[35mTRACE\033[0m", + LogDebug: "\033[36mDEBUG\033[0m", + LogInfo: "\033[32mINFO\033[0m", + LogProc: "\033[34mPROCESS\033[0m", + LogWarn: "\033[33mWARN\033[0m", + LogError: "\033[31mERROR\033[0m", +} + +type Logger struct { + out *os.File + minimumLevel LogLevel +} + +func NewLogger(out *os.File) *Logger { + return &Logger{out: out} +} + +var GlobalLogger *Logger = NewLogger(os.Stderr) + +func (l *Logger) Log(msg string, level LogLevel) { + if level > l.minimumLevel { + fmt.Fprintf(l.out, "\033[90m%s\033[0m %s: %s\n", time.Now().Format(time.DateTime), LogLevelStr[level], msg) + } +} + +func (l *Logger) MinimumLevel(min LogLevel) { + l.minimumLevel = min +} + +func (l *Logger) SetNewOutput(newOut *os.File) { + l.out = newOut +}